This commit is contained in:
SOR
2026-04-04 08:32:57 +08:00
commit b3e1fd9e18
76 changed files with 6650 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
# 默认忽略的文件
/shelf/
/workspace.xml
# 基于编辑器的 HTTP 客户端请求
/httpRequests/
# 已忽略包含查询文件的默认文件夹
/queries/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
+151
View File
@@ -0,0 +1,151 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="LanguageInjectionConfiguration">
<injection language="SQL" injector-id="java">
<display-name>AsyncQueryRunner (org.apache.commons.dbutils)</display-name>
<single-file value="true" />
<place><![CDATA[psiParameter().ofMethod(0, psiMethod().withName("batch").withParameterCount(2).definedInClass("org.apache.commons.dbutils.AsyncQueryRunner"))]]></place>
<place><![CDATA[psiParameter().ofMethod(0, psiMethod().withName("insertBatch").withParameterCount(3).definedInClass("org.apache.commons.dbutils.AsyncQueryRunner"))]]></place>
<place><![CDATA[psiParameter().ofMethod(0, psiMethod().withName("query", "insert").withParameters("java.lang.String", "org.apache.commons.dbutils.ResultSetHandler").definedInClass("org.apache.commons.dbutils.AsyncQueryRunner"))]]></place>
<place><![CDATA[psiParameter().ofMethod(0, psiMethod().withName("query", "insert").withParameters("java.lang.String", "org.apache.commons.dbutils.ResultSetHandler", "java.lang.Object...").definedInClass("org.apache.commons.dbutils.AsyncQueryRunner"))]]></place>
<place><![CDATA[psiParameter().ofMethod(0, psiMethod().withName("update").withParameters("java.lang.String").definedInClass("org.apache.commons.dbutils.AsyncQueryRunner"))]]></place>
<place><![CDATA[psiParameter().ofMethod(0, psiMethod().withName("update").withParameters("java.lang.String", "java.lang.Object").definedInClass("org.apache.commons.dbutils.AsyncQueryRunner"))]]></place>
<place><![CDATA[psiParameter().ofMethod(0, psiMethod().withName("update").withParameters("java.lang.String", "java.lang.Object...").definedInClass("org.apache.commons.dbutils.AsyncQueryRunner"))]]></place>
<place><![CDATA[psiParameter().ofMethod(1, psiMethod().withName("batch").withParameterCount(3).definedInClass("org.apache.commons.dbutils.AsyncQueryRunner"))]]></place>
<place><![CDATA[psiParameter().ofMethod(1, psiMethod().withName("insertBatch").withParameterCount(4).definedInClass("org.apache.commons.dbutils.AsyncQueryRunner"))]]></place>
<place><![CDATA[psiParameter().ofMethod(1, psiMethod().withName("query", "insert").withParameters("java.sql.Connection", "java.lang.String", "org.apache.commons.dbutils.ResultSetHandler").definedInClass("org.apache.commons.dbutils.AsyncQueryRunner"))]]></place>
<place><![CDATA[psiParameter().ofMethod(1, psiMethod().withName("query", "insert").withParameters("java.sql.Connection", "java.lang.String", "org.apache.commons.dbutils.ResultSetHandler", "java.lang.Object...").definedInClass("org.apache.commons.dbutils.AsyncQueryRunner"))]]></place>
<place><![CDATA[psiParameter().ofMethod(1, psiMethod().withName("update").withParameters("java.sql.Connection", "java.lang.String").definedInClass("org.apache.commons.dbutils.AsyncQueryRunner"))]]></place>
<place><![CDATA[psiParameter().ofMethod(1, psiMethod().withName("update").withParameters("java.sql.Connection", "java.lang.String", "java.lang.Object").definedInClass("org.apache.commons.dbutils.AsyncQueryRunner"))]]></place>
<place><![CDATA[psiParameter().ofMethod(1, psiMethod().withName("update").withParameters("java.sql.Connection", "java.lang.String", "java.lang.Object...").definedInClass("org.apache.commons.dbutils.AsyncQueryRunner"))]]></place>
</injection>
<injection language="SQL" injector-id="java">
<display-name>Jodd (jodd.db)</display-name>
<single-file value="true" />
<place><![CDATA[psiParameter().ofMethod(0, psiMethod().withName("query").withParameterCount(1).definedInClass("jodd.db.DbQuery"))]]></place>
<place><![CDATA[psiParameter().ofMethod(1, psiMethod().withName("DbQuery").withParameterCount(2).definedInClass("jodd.db.DbQuery"))]]></place>
<place><![CDATA[psiParameter().ofMethod(1, psiMethod().withName("query").withParameterCount(2).definedInClass("jodd.db.DbQuery"))]]></place>
<place><![CDATA[psiParameter().ofMethod(2, psiMethod().withName("DbQuery").withParameterCount(3).definedInClass("jodd.db.DbQuery"))]]></place>
</injection>
<injection language="SQL" injector-id="java">
<display-name>MyBatis @Select/@Delete/@Insert/@Update</display-name>
<single-file value="true" />
<place><![CDATA[psiMethod().withName("value").withParameters().definedInClass("org.apache.ibatis.annotations.Delete")]]></place>
<place><![CDATA[psiMethod().withName("value").withParameters().definedInClass("org.apache.ibatis.annotations.Insert")]]></place>
<place><![CDATA[psiMethod().withName("value").withParameters().definedInClass("org.apache.ibatis.annotations.Select")]]></place>
<place><![CDATA[psiMethod().withName("value").withParameters().definedInClass("org.apache.ibatis.annotations.Update")]]></place>
</injection>
<injection language="SQL" injector-id="java">
<display-name>QueryRunner (org.apache.commons.dbutils)</display-name>
<single-file value="true" />
<place><![CDATA[psiParameter().ofMethod(0, psiMethod().withName("batch").withParameterCount(2).definedInClass("org.apache.commons.dbutils.QueryRunner"))]]></place>
<place><![CDATA[psiParameter().ofMethod(0, psiMethod().withName("insertBatch").withParameterCount(3).definedInClass("org.apache.commons.dbutils.QueryRunner"))]]></place>
<place><![CDATA[psiParameter().ofMethod(0, psiMethod().withName("query", "insert").withParameters("java.lang.String", "org.apache.commons.dbutils.ResultSetHandler").definedInClass("org.apache.commons.dbutils.QueryRunner"))]]></place>
<place><![CDATA[psiParameter().ofMethod(0, psiMethod().withName("query", "insert", "execute").withParameters("java.lang.String", "org.apache.commons.dbutils.ResultSetHandler", "java.lang.Object...").definedInClass("org.apache.commons.dbutils.QueryRunner"))]]></place>
<place><![CDATA[psiParameter().ofMethod(0, psiMethod().withName("update").withParameters("java.lang.String").definedInClass("org.apache.commons.dbutils.QueryRunner"))]]></place>
<place><![CDATA[psiParameter().ofMethod(0, psiMethod().withName("update").withParameters("java.lang.String", "java.lang.Object").definedInClass("org.apache.commons.dbutils.QueryRunner"))]]></place>
<place><![CDATA[psiParameter().ofMethod(0, psiMethod().withName("update", "execute").withParameters("java.lang.String", "java.lang.Object...").definedInClass("org.apache.commons.dbutils.QueryRunner"))]]></place>
<place><![CDATA[psiParameter().ofMethod(1, psiMethod().withName("batch").withParameterCount(3).definedInClass("org.apache.commons.dbutils.QueryRunner"))]]></place>
<place><![CDATA[psiParameter().ofMethod(1, psiMethod().withName("insertBatch").withParameterCount(4).definedInClass("org.apache.commons.dbutils.QueryRunner"))]]></place>
<place><![CDATA[psiParameter().ofMethod(1, psiMethod().withName("query", "insert").withParameters("java.sql.Connection", "java.lang.String", "org.apache.commons.dbutils.ResultSetHandler", "java.lang.Object...").definedInClass("org.apache.commons.dbutils.QueryRunner"))]]></place>
<place><![CDATA[psiParameter().ofMethod(1, psiMethod().withName("query", "insert", "execute").withParameters("java.sql.Connection", "java.lang.String", "org.apache.commons.dbutils.ResultSetHandler").definedInClass("org.apache.commons.dbutils.QueryRunner"))]]></place>
<place><![CDATA[psiParameter().ofMethod(1, psiMethod().withName("update").withParameters("java.sql.Connection", "java.lang.String").definedInClass("org.apache.commons.dbutils.QueryRunner"))]]></place>
<place><![CDATA[psiParameter().ofMethod(1, psiMethod().withName("update").withParameters("java.sql.Connection", "java.lang.String", "java.lang.Object").definedInClass("org.apache.commons.dbutils.QueryRunner"))]]></place>
<place><![CDATA[psiParameter().ofMethod(1, psiMethod().withName("update", "execute").withParameters("java.sql.Connection", "java.lang.String", "java.lang.Object...").definedInClass("org.apache.commons.dbutils.QueryRunner"))]]></place>
</injection>
<injection language="SQL" injector-id="java">
<display-name>R2DBC (io.r2dbc)</display-name>
<single-file value="true" />
<place><![CDATA[psiParameter().ofMethod(0, psiMethod().withName("add").definedInClass("io.r2dbc.spi.Batch"))]]></place>
<place><![CDATA[psiParameter().ofMethod(0, psiMethod().withName("createStatement").definedInClass("io.r2dbc.spi.Connection"))]]></place>
</injection>
<injection language="PostgreSQL" injector-id="java">
<display-name>Reactiverse Postgres Client (io.reactiverse)</display-name>
<single-file value="true" />
<place><![CDATA[psiParameter().ofMethod(0, psiMethod().withName("query", "prepare", "preparedQuery", "preparedBatch").definedInClass("io.reactiverse.pgclient.PgConnection"))]]></place>
<place><![CDATA[psiParameter().ofMethod(0, psiMethod().withName("query", "prepare", "preparedQuery", "preparedBatch").definedInClass("io.reactiverse.pgclient.PgTransaction"))]]></place>
<place><![CDATA[psiParameter().ofMethod(0, psiMethod().withName("query", "prepare", "preparedQuery", "preparedBatch", "rxQuery", "rxPrepare", "rxPreparedQuery", "rxPreparedBatch").definedInClass("io.reactiverse.reactivex.pgclient.PgClient"))]]></place>
<place><![CDATA[psiParameter().ofMethod(0, psiMethod().withName("query", "prepare", "preparedQuery", "preparedBatch", "rxQuery", "rxPrepare", "rxPreparedQuery", "rxPreparedBatch").definedInClass("io.reactiverse.reactivex.pgclient.PgConnection"))]]></place>
<place><![CDATA[psiParameter().ofMethod(0, psiMethod().withName("query", "prepare", "preparedQuery", "preparedBatch", "rxQuery", "rxPrepare", "rxPreparedQuery", "rxPreparedBatch").definedInClass("io.reactiverse.reactivex.pgclient.PgPool"))]]></place>
<place><![CDATA[psiParameter().ofMethod(0, psiMethod().withName("query", "prepare", "preparedQuery", "preparedBatch", "rxQuery", "rxPrepare", "rxPreparedQuery", "rxPreparedBatch").definedInClass("io.reactiverse.reactivex.pgclient.PgTransaction"))]]></place>
<place><![CDATA[psiParameter().ofMethod(0, psiMethod().withName("query", "preparedQuery", "preparedBatch").definedInClass("io.reactiverse.axle.pgclient.PgClient"))]]></place>
<place><![CDATA[psiParameter().ofMethod(0, psiMethod().withName("query", "preparedQuery", "preparedBatch").definedInClass("io.reactiverse.pgclient.PgClient"))]]></place>
<place><![CDATA[psiParameter().ofMethod(0, psiMethod().withName("query", "preparedQuery", "preparedBatch").definedInClass("io.reactiverse.pgclient.PgPool"))]]></place>
</injection>
<injection language="SQL" injector-id="java">
<display-name>SmallRye Axle SqlClient (io.vertx.axle.sqlclient)</display-name>
<single-file value="true" />
<place><![CDATA[psiParameter().ofMethod(0, psiMethod().withName("query", "preparedQuery", "preparedBatch").definedInClass("io.vertx.axle.sqlclient.Pool"))]]></place>
<place><![CDATA[psiParameter().ofMethod(0, psiMethod().withName("query", "preparedQuery", "preparedBatch").definedInClass("io.vertx.axle.sqlclient.SqlClient"))]]></place>
</injection>
<injection language="SQL" injector-id="java">
<display-name>SmallRye Mutiny SqlClient (io.vertx.mutiny.sqlclient)</display-name>
<single-file value="true" />
<place><![CDATA[psiParameter().ofMethod(0, psiMethod().withName("query", "preparedQuery", "preparedBatch").definedInClass("io.vertx.mutiny.sqlclient.Pool"))]]></place>
<place><![CDATA[psiParameter().ofMethod(0, psiMethod().withName("query", "preparedQuery", "preparedBatch").definedInClass("io.vertx.mutiny.sqlclient.SqlClient"))]]></place>
</injection>
<injection language="SQL" injector-id="java">
<display-name>SmallRye Mutiny SqlConnection (io.vertx.mutiny.sqlclient)</display-name>
<single-file value="true" />
<place><![CDATA[psiParameter().ofMethod(0, psiMethod().withName("prepare", "prepareAndAwait").definedInClass("io.vertx.mutiny.db2client.DB2Connection"))]]></place>
<place><![CDATA[psiParameter().ofMethod(0, psiMethod().withName("prepare", "prepareAndAwait").definedInClass("io.vertx.mutiny.mssqlclient.MSSQLConnection"))]]></place>
<place><![CDATA[psiParameter().ofMethod(0, psiMethod().withName("prepare", "prepareAndAwait").definedInClass("io.vertx.mutiny.mysqlclient.MySQLConnection"))]]></place>
<place><![CDATA[psiParameter().ofMethod(0, psiMethod().withName("prepare", "prepareAndAwait").definedInClass("io.vertx.mutiny.pgclient.PgConnection"))]]></place>
</injection>
<injection language="SQL" injector-id="java">
<display-name>Vert.x SQL Extensions (io.vertx.ext.sql)</display-name>
<single-file value="true" />
<place><![CDATA[psiParameter().ofMethod(0, psiMethod().withName("query", "queryWithParams", "queryStream", "queryStreamWithParams", "querySingle", "querySingleWithParams", "update", "updateWithParams", "call", "callWithParams").definedInClass("io.vertx.ext.sql.SQLClient"))]]></place>
<place><![CDATA[psiParameter().ofMethod(0, psiMethod().withName("query", "queryWithParams", "queryStream", "queryStreamWithParams", "querySingle", "querySingleWithParams", "update", "updateWithParams", "call", "callWithParams").definedInClass("io.vertx.ext.sql.SQLOperations"))]]></place>
<place><![CDATA[psiParameter().ofMethod(0, psiMethod().withName("query", "queryWithParams", "queryStream", "queryStreamWithParams", "querySingle", "querySingleWithParams", "update", "updateWithParams", "call", "callWithParams", "execute", "batchWithParams", "batchCallableWithParams").definedInClass("io.vertx.ext.sql.SQLConnection"))]]></place>
</injection>
<injection language="SQL" injector-id="java">
<display-name>Vert.x SQL Reactive Extensions (io.vertx.reactivex.ext.sql)</display-name>
<single-file value="true" />
<place><![CDATA[psiParameter().ofMethod(0, psiMethod().withName("query", "queryWithParams", "queryStream", "queryStreamWithParams", "querySingle", "querySingleWithParams", "update", "updateWithParams", "call", "callWithParams").definedInClass("io.vertx.reactivex.ext.sql.SQLOperations"))]]></place>
<place><![CDATA[psiParameter().ofMethod(0, psiMethod().withName("query", "queryWithParams", "queryStream", "queryStreamWithParams", "querySingle", "querySingleWithParams", "update", "updateWithParams", "call", "callWithParams", "execute", "batchWithParams", "batchCallableWithParams", "rxQuerySingle", "rxQuerySingleWithParams", "rxQuery", "rxQueryWithParams", "rxQueryStream", "rxQueryStreamWithParams", "rxUpdate", "rxUpdateWithParams", "rxCall", "rxCallWithParams", "rxExecute", "rxBatchWithParams", "rxBatchCallableWithParams").definedInClass("io.vertx.reactivex.ext.sql.SQLClient"))]]></place>
<place><![CDATA[psiParameter().ofMethod(0, psiMethod().withName("query", "queryWithParams", "queryStream", "queryStreamWithParams", "querySingle", "querySingleWithParams", "update", "updateWithParams", "call", "callWithParams", "execute", "batchWithParams", "batchCallableWithParams", "rxQuerySingle", "rxQuerySingleWithParams", "rxQuery", "rxQueryWithParams", "rxQueryStream", "rxQueryStreamWithParams", "rxUpdate", "rxUpdateWithParams", "rxCall", "rxCallWithParams", "rxExecute", "rxBatchWithParams", "rxBatchCallableWithParams").definedInClass("io.vertx.reactivex.ext.sql.SQLConnection"))]]></place>
<place><![CDATA[psiParameter().ofMethod(0, psiMethod().withName("querySingle", "rxQuerySingle", "querySingleWithParams", "rxQuerySingleWithParams").definedInClass("io.vertx.reactivex.ext.asyncsql.AsyncSQLClient"))]]></place>
<place><![CDATA[psiParameter().ofMethod(0, psiMethod().withName("querySingle", "rxQuerySingle", "querySingleWithParams", "rxQuerySingleWithParams").definedInClass("io.vertx.reactivex.ext.asyncsql.MySQLClient"))]]></place>
<place><![CDATA[psiParameter().ofMethod(0, psiMethod().withName("querySingle", "rxQuerySingle", "querySingleWithParams", "rxQuerySingleWithParams").definedInClass("io.vertx.reactivex.ext.asyncsql.PostgreSQLClient"))]]></place>
</injection>
<injection language="SQL" injector-id="java">
<display-name>Vert.x SqlClient (io.vertx.sqlclient)</display-name>
<single-file value="true" />
<place><![CDATA[psiParameter().ofMethod(0, psiMethod().withName("query", "prepare", "preparedQuery", "preparedBatch").definedInClass("io.vertx.mssqlclient.MSSQLConnection"))]]></place>
<place><![CDATA[psiParameter().ofMethod(0, psiMethod().withName("query", "prepare", "preparedQuery", "preparedBatch").definedInClass("io.vertx.mysqlclient.MySQLConnection"))]]></place>
<place><![CDATA[psiParameter().ofMethod(0, psiMethod().withName("query", "prepare", "preparedQuery", "preparedBatch").definedInClass("io.vertx.pgclient.PgConnection"))]]></place>
<place><![CDATA[psiParameter().ofMethod(0, psiMethod().withName("query", "prepare", "preparedQuery", "preparedBatch").definedInClass("io.vertx.sqlclient.Pool"))]]></place>
<place><![CDATA[psiParameter().ofMethod(0, psiMethod().withName("query", "prepare", "preparedQuery", "preparedBatch").definedInClass("io.vertx.sqlclient.SqlClient"))]]></place>
<place><![CDATA[psiParameter().ofMethod(0, psiMethod().withName("query", "prepare", "preparedQuery", "preparedBatch").definedInClass("io.vertx.sqlclient.SqlConnection"))]]></place>
<place><![CDATA[psiParameter().ofMethod(0, psiMethod().withName("query", "prepare", "preparedQuery", "preparedBatch").definedInClass("io.vertx.sqlclient.Transaction"))]]></place>
</injection>
<injection language="SQL" injector-id="java">
<display-name>Vert.x SqlClient RxJava2 (io.vertx.reactivex.sqlclient)</display-name>
<single-file value="true" />
<place><![CDATA[psiParameter().ofMethod(0, psiMethod().withName("query", "prepare", "preparedQuery", "preparedBatch", "rxQuery", "rxPrepare", "rxPreparedQuery", "rxPreparedBatch").definedInClass("io.vertx.reactivex.mysqlclient.MySQLConnection"))]]></place>
<place><![CDATA[psiParameter().ofMethod(0, psiMethod().withName("query", "prepare", "preparedQuery", "preparedBatch", "rxQuery", "rxPrepare", "rxPreparedQuery", "rxPreparedBatch").definedInClass("io.vertx.reactivex.pgclient.PgConnection"))]]></place>
<place><![CDATA[psiParameter().ofMethod(0, psiMethod().withName("query", "prepare", "preparedQuery", "preparedBatch", "rxQuery", "rxPrepare", "rxPreparedQuery", "rxPreparedBatch").definedInClass("io.vertx.reactivex.sqlclient.SqlConnection"))]]></place>
<place><![CDATA[psiParameter().ofMethod(0, psiMethod().withName("query", "prepare", "preparedQuery", "preparedBatch", "rxQuery", "rxPrepare", "rxPreparedQuery", "rxPreparedBatch").definedInClass("io.vertx.reactivex.sqlclient.Transaction"))]]></place>
<place><![CDATA[psiParameter().ofMethod(0, psiMethod().withName("query", "prepare", "preparedQuery", "preparedBatch", "rxQuery", "rxPreparedQuery", "rxPreparedBatch").definedInClass("io.vertx.reactivex.mysqlclient.MySQLPool"))]]></place>
<place><![CDATA[psiParameter().ofMethod(0, psiMethod().withName("query", "prepare", "preparedQuery", "preparedBatch", "rxQuery", "rxPreparedQuery", "rxPreparedBatch").definedInClass("io.vertx.reactivex.pgclient.PgPool"))]]></place>
<place><![CDATA[psiParameter().ofMethod(0, psiMethod().withName("query", "prepare", "preparedQuery", "preparedBatch", "rxQuery", "rxPreparedQuery", "rxPreparedBatch").definedInClass("io.vertx.reactivex.sqlclient.Pool"))]]></place>
<place><![CDATA[psiParameter().ofMethod(0, psiMethod().withName("query", "prepare", "preparedQuery", "preparedBatch", "rxQuery", "rxPreparedQuery", "rxPreparedBatch").definedInClass("io.vertx.reactivex.sqlclient.SqlClient"))]]></place>
</injection>
<injection language="SQL" injector-id="java">
<display-name>jOOQ (org.jooq.DSLContext)</display-name>
<single-file value="true" />
<place><![CDATA[psiParameter().ofMethod(0, psiMethod().withName("batch").withParameters("java.lang.String", "java.lang.Object[]...").definedInClass("org.jooq.DSLContext"))]]></place>
<place><![CDATA[psiParameter().ofMethod(0, psiMethod().withName("query", "fetch", "fetchLazy", "fetchAsync", "fetchStream", "fetchMany", "fetchOne", "fetchSingle", "fetchOptional", "fetchValue", "fetchOptionalValue", "fetchValues", "execute", "resultQuery").withParameters("java.lang.String", "java.lang.Object...").definedInClass("org.jooq.DSLContext"))]]></place>
<place><![CDATA[psiParameter().ofMethod(0, psiMethod().withName("query", "fetch", "fetchLazy", "fetchAsync", "fetchStream", "fetchMany", "fetchOne", "fetchSingle", "fetchOptional", "fetchValue", "fetchOptionalValue", "fetchValues", "execute", "resultQuery", "batch").withParameters("java.lang.String").definedInClass("org.jooq.DSLContext"))]]></place>
<place><![CDATA[psiParameter().ofMethod(psiMethod().withName("batch").withParameters("java.lang.String...").definedInClass("org.jooq.DSLContext"))]]></place>
</injection>
<injection language="SQL" injector-id="java">
<display-name>rxjava2-jdbc (org.davidmoten.rx.jdbc)</display-name>
<single-file value="true" />
<place><![CDATA[psiMethod().withName("value").definedInClass("org.davidmoten.rx.jdbc.annotations.Query")]]></place>
<place><![CDATA[psiParameter().ofMethod(0, psiMethod().withName("call", "select", "update").definedInClass("org.davidmoten.rx.jdbc.Database"))]]></place>
<place><![CDATA[psiParameter().ofMethod(0, psiMethod().withName("call", "select", "update").definedInClass("org.davidmoten.rx.jdbc.TransactedBuilder"))]]></place>
</injection>
</component>
</project>
+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="JAVA_MODULE" version="4">
<component name="NewModuleRootManager" inherit-compiler-output="true">
<exclude-output />
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>
+20
View File
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<bytecodeTargetLevel>
<module name="careerpp" target="25" />
<module name="careerpp.main" target="25" />
<module name="careerpp.test" target="25" />
</bytecodeTargetLevel>
</component>
<component name="JavacSettings">
<option name="ADDITIONAL_OPTIONS_OVERRIDE">
<module name="backend" options="-parameters" />
<module name="backend.main" options="-parameters" />
<module name="backend.test" options="-parameters" />
<module name="careerpp" options="-parameters" />
<module name="careerpp.main" options="-parameters" />
<module name="careerpp.test" options="-parameters" />
</option>
</component>
</project>
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Ask2AgentMigrationStateService">
<option name="migrationStatus" value="COMPLETED" />
</component>
</project>
+16
View File
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GradleSettings">
<option name="linkedExternalProjectsSettings">
<GradleProjectSettings>
<option name="externalProjectPath" value="$PROJECT_DIR$/backend" />
<option name="gradleJvm" value="24" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$/backend" />
</set>
</option>
</GradleProjectSettings>
</option>
</component>
</project>
+7
View File
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="KotlinJpsPluginSettings">
<option name="externalSystemId" value="Gradle" />
<option name="version" value="2.3.20" />
</component>
</project>
+7
View File
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2">
<output url="file://$PROJECT_DIR$/out" />
</component>
</project>
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/Springboot4-Vue3.iml" filepath="$PROJECT_DIR$/.idea/Springboot4-Vue3.iml" />
</modules>
</component>
</project>
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="SqlDialectMappings">
<file url="file://$PROJECT_DIR$/backend/src/main/resources/springboot_vue3.sql" dialect="GenericSQL" />
</component>
</project>
Generated
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 SOR2171
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+122
View File
@@ -0,0 +1,122 @@
## Springboot4-Vue3
###### ~~*Long may the Student Pack*~~
English / [简体中文](./README_CN.md)
---
### Tech Stack
#### Backend
- Kotlin with Gradle.kts
- Spring Security
- Spring Mail
- Mybatis Plus
- JWT
- MySQL
- Redis
- RabbitMQ
#### Frontend
- Vue3 with Vite and TypeScript
- vue-router
- axios
- element-plus
---
#### Project Setup
##### General Requirements
- JDK 25+
- Kotlin 2.3.20+
- Node.js 16+
- MySQL 8+
- Redis 6+
- RabbitMQ 3.8+
##### Git clone
```bash
git clone https://github.com/SOR2171/Springboot4-Vue3_Demo.git
cd Springboot4-Vue3_Demo
```
##### Backend Setup
1. Go to backend directory
```bash
cd backend
```
2. Configure application properties
Crate `src/main/resources/application.yml`
or edit `src/main/resources/application-example.yml`
to set your own configurations.
3. Load Database
Import `src/main/resources/springboot_vue3.sql`
> default database user:
>
> username: `test`
> password: `123456`
>
> (the password saved in SQL needs to be obtained through the test class)
4. Grade build
```bash
gradle build
```
jar directory: `build/libs/backend-(ver).jar`
5. Run the backend server
```bash
java -jar build/libs/backend-(ver).jar
```
##### Frontend Setup
1. Go to frontend directory
```bash
cd frontend
```
2. prepare some picture
you need a `frontend/src/assets/welcome-image.png`, which is the background of welcome page.
3. Build
```bash
npm install
npm run build
```
The build files will be generated in `dist` directory.
##### Nginx Setup (Optional)
If you want to serve the frontend with Nginx, you can follow these steps:
1. Install Nginx if you don't have it.
[download Nginx](https://nginx.org/en/download.html)
2. Copy the contents of the `dist` directory to your Nginx `html` directory.
3. Configure Nginx to serve the frontend and proxy API requests to the backend server.
4. Restart Nginx.
```bash
sudo systemctl restart nginx
```
+129
View File
@@ -0,0 +1,129 @@
## Springboot4-Vue3
###### ~~*赞美学生包*~~
[English](./README.md) / 简体中文
---
### 技术栈
#### 后端
- Kotlin + Gradle.kts
- Spring Security
- Spring Mail
- Mybatis Plus
- JWT
- MySQL
- Redis
- RabbitMQ
#### 前端
- Vue3 + Vite + TypeScript
- vue-router
- axios
- element-plus
---
### 项目搭建
#### 基础要求
- JDK 25+
- Kotlin 2.3.20+
- Node.js 16+
- MySQL 8+
- Redis 6+
- RabbitMQ 3.8+
#### Git 克隆项目
```bash
git clone https://github.com/SOR2171/Springboot4-Vue3_Demo.git
cd Springboot4-Vue3_Demo
```
#### 后端配置
1. 进入后端目录
```bash
cd backend
```
2. 配置应用属性
创建 `src/main/resources/application.yml`
或编辑 `src/main/resources/application-example.yml`
以设置你自己的配置。
3. 导入数据库
导入 `src/main/resources/springboot_vue3.sql`
> 默认数据库用户:
>
> 用户名:`test`
> 密码:`123456`
>
> (SQL中保存的密码需要通过测试类获取)
4. Gradle 构建
```bash
gradle build
```
jar 文件目录:`build/libs/backend-(ver).jar`
5. 启动后端服务器
```bash
java -jar build/libs/backend-(ver).jar
```
---
#### 前端配置
1. 进入前端目录
```bash
cd frontend
```
2. 准备一些图片
你需要一张 `frontend/src/assets/welcome-image.png` 用作登陆界面的背景
3. 构建前端
```bash
npm install
npm run build
```
构建结果会生成在 `dist` 目录中。
---
#### Nginx 部署(可选)
如果你想使用 Nginx 来部署前端,可以按以下步骤操作:
1. 若未安装 Nginx,请先安装。
[下载 Nginx](https://nginx.org/en/download.html)
2. 将 `dist` 目录中的内容复制到 Nginx 的 `html` 目录。
3. 配置 Nginx,使其能够提供前端页面,并将 API 请求代理到后端服务器。
4. 重启 Nginx。
```bash
sudo systemctl restart nginx
```
+3
View File
@@ -0,0 +1,3 @@
/gradlew text eol=lf
*.bat text eol=crlf
*.jar binary
+41
View File
@@ -0,0 +1,41 @@
HELP.md
.gradle
build/
!gradle/wrapper/gradle-wrapper.jar
!**/src/main/**/build/
!**/src/test/**/build/
src/main/resources/application.yaml
### STS ###
.apt_generated
.classpath
.factorypath
.project
.settings
.springBeans
.sts4-cache
bin/
!**/src/main/**/bin/
!**/src/test/**/bin/
### IntelliJ IDEA ###
.idea
*.iws
*.iml
*.ipr
out/
!**/src/main/**/out/
!**/src/test/**/out/
### NetBeans ###
/nbproject/private/
/nbbuild/
/dist/
/nbdist/
/.nb-gradle/
### VS Code ###
.vscode/
### Kotlin ###
.kotlin
+125
View File
@@ -0,0 +1,125 @@
## localhost:8080/api
所有返回的json对象总有如下格式
```
{
"code": int,
"message": string,
"data": T
}
```
接下来将省略`/api`并只给出`T`的类声明
当`"code"`不是`200`时,`"data"`必定为`null`
部分`/api/test`的返回值不遵循上述格式
## /auth
### POST /login
Authorization: 无
key: username, password
data:
```
{
"username": string,
"role": string,
"token": sting,
"expire": time
}
```
### GET /logout
Authorization: Bearer $Token
key: 无
data:
```
null
```
### GET /ask-code?email=******&type=register
Authorization: 无
key: 无
data:
```
null
```
### GET /ask-code?email=******&type=reset
Authorization: 无
key: 无
data:
```
null
```
### POST /register
Authorization: 无
key: 无
data:email, code, username, password
```
null
```
### POST /reset
Authorization: 无
key: email, code, password
data:
```
null
```
### GET /relogin
Authorization: Bearer $Token
key: 无
data:
```
{
"token": sting,
"expire": time
}
```
## /test
### GET /hello
Authorization: Bearer $Token
key: 无
回复:
```
Hello Kotlin WebFlux!
```
+61
View File
@@ -0,0 +1,61 @@
plugins {
kotlin("jvm") version "2.3.20"
kotlin("plugin.spring") version "2.3.20"
kotlin("plugin.serialization") version "2.3.20"
id("org.springframework.boot") version "4.0.4"
id("io.spring.dependency-management") version "1.1.7"
}
group = "com.github.sor2171"
version = "1.0.0"
java {
toolchain {
languageVersion = JavaLanguageVersion.of(25)
}
}
repositories {
mavenCentral()
}
dependencies {
implementation("com.auth0:java-jwt:4.4.0")
implementation("com.baomidou:mybatis-plus-spring-boot4-starter:3.5.15")
implementation("org.springframework.boot:spring-boot-starter-amqp")
implementation("org.springframework.boot:spring-boot-starter-data-redis-reactive")
implementation("org.springframework.boot:spring-boot-starter-json")
implementation("org.springframework.boot:spring-boot-starter-mail")
implementation("org.springframework.boot:spring-boot-starter-security")
implementation("org.springframework.boot:spring-boot-starter-validation")
implementation("org.springframework.boot:spring-boot-starter-webflux")
implementation("org.springframework:spring-messaging")
implementation("io.projectreactor.kotlin:reactor-kotlin-extensions")
implementation("org.jetbrains.kotlin:kotlin-reflect")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor")
implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.6.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.1")
implementation("io.projectreactor.kotlin:reactor-kotlin-extensions")
implementation("tools.jackson.module:jackson-module-kotlin")
runtimeOnly("com.mysql:mysql-connector-j")
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.jetbrains.kotlin:kotlin-test-junit5")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}
kotlin {
compilerOptions {
freeCompilerArgs.addAll("-Xjsr305=strict", "-Xannotation-default-target=param-property")
jvmToolchain(25)
}
}
tasks.withType<org.springframework.boot.gradle.tasks.run.BootRun> {
jvmArgs("--enable-native-access=ALL-UNNAMED")
}
tasks.withType<Test> {
useJUnitPlatform()
jvmArgs("--enable-native-access=ALL-UNNAMED")
}
Binary file not shown.
+7
View File
@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.0-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
+4
View File
@@ -0,0 +1,4 @@
plugins {
id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0"
}
rootProject.name = "backend"
@@ -0,0 +1,11 @@
package com.github.sor2171.backend
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
@SpringBootApplication
class CareerppApplication
fun main(args: Array<String>) {
runApplication<CareerppApplication>(*args)
}
@@ -0,0 +1,15 @@
package com.github.sor2171.backend.config
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder
import org.springframework.security.crypto.password.PasswordEncoder
@Configuration
class BeanConfig {
@Bean
fun passwordEncoder(): PasswordEncoder {
return BCryptPasswordEncoder()
}
}
@@ -0,0 +1,60 @@
package com.github.sor2171.backend.config
import kotlinx.serialization.json.Json
import kotlinx.serialization.serializer
import org.springframework.amqp.core.Message
import org.springframework.amqp.core.MessageProperties
import org.springframework.amqp.rabbit.config.SimpleRabbitListenerContainerFactory
import org.springframework.amqp.rabbit.connection.ConnectionFactory
import org.springframework.amqp.rabbit.core.RabbitTemplate
import org.springframework.amqp.support.converter.AbstractMessageConverter
import org.springframework.amqp.support.converter.MessageConverter
import org.springframework.beans.factory.annotation.Qualifier
import org.springframework.boot.amqp.autoconfigure.SimpleRabbitListenerContainerFactoryConfigurer
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
@Configuration
class RabbitConfig {
@Bean
fun messageConverter(@Qualifier("kotlinxSerializationJson") json: Json): MessageConverter =
object : AbstractMessageConverter() {
override fun createMessage(obj: Any, messageProperties: MessageProperties): Message {
val serializer = json.serializersModule.serializer(obj::class.java)
val jsonString = json.encodeToString(serializer, obj)
val bytes = jsonString.toByteArray(Charsets.UTF_8)
messageProperties.contentType = MessageProperties.CONTENT_TYPE_JSON
messageProperties.contentLength = bytes.size.toLong()
return Message(bytes, messageProperties)
}
override fun fromMessage(message: Message): Any {
val content = String(message.body, Charsets.UTF_8)
return content
}
}
@Bean
fun rabbitTemplate(
connectionFactory: ConnectionFactory,
messageConverter: MessageConverter
): RabbitTemplate {
val rabbitTemplate = RabbitTemplate(connectionFactory)
rabbitTemplate.messageConverter = messageConverter
return rabbitTemplate
}
@Bean
fun rabbitListenerContainerFactory(
configurer: SimpleRabbitListenerContainerFactoryConfigurer,
connectionFactory: ConnectionFactory,
messageConverter: MessageConverter
): SimpleRabbitListenerContainerFactory {
val factory = SimpleRabbitListenerContainerFactory()
configurer.configure(factory, connectionFactory)
factory.setMessageConverter(messageConverter)
return factory
}
}
@@ -0,0 +1,151 @@
package com.github.sor2171.backend.config
import com.github.sor2171.backend.entity.RestBean
import com.github.sor2171.backend.entity.vo.response.AuthorizeVO
import com.github.sor2171.backend.filter.JwtAuthorizeFilter
import com.github.sor2171.backend.service.AccountService
import com.github.sor2171.backend.utils.DateUtils
import com.github.sor2171.backend.utils.JwtUtils
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.encodeToJsonElement
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
import org.springframework.security.authentication.ReactiveAuthenticationManager
import org.springframework.security.authentication.UserDetailsRepositoryReactiveAuthenticationManager
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity
import org.springframework.security.config.web.server.SecurityWebFiltersOrder
import org.springframework.security.config.web.server.ServerHttpSecurity
import org.springframework.security.core.userdetails.User
import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.security.web.server.SecurityWebFilterChain
import org.springframework.security.web.server.authentication.AuthenticationWebFilter
import org.springframework.security.web.server.authentication.ServerAuthenticationConverter
import org.springframework.security.web.server.context.NoOpServerSecurityContextRepository
import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatchers
import org.springframework.web.server.ServerWebExchange
import reactor.core.publisher.Mono
@Configuration
@EnableWebFluxSecurity
class SecurityConfig(
private val jwtUtils: JwtUtils,
private val service: AccountService,
private val jwtAuthorizeFilter: JwtAuthorizeFilter,
private val passwordEncoder: PasswordEncoder
) {
@Bean
fun reactiveAuthenticationManager(): ReactiveAuthenticationManager {
val manager = UserDetailsRepositoryReactiveAuthenticationManager(service)
manager.setPasswordEncoder(passwordEncoder)
return manager
}
@Bean
fun securityWebFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain {
return http
.authorizeExchange {
it
.pathMatchers(
"/api/auth/relogin"
).authenticated()
.pathMatchers(
"/api/auth/**",
"/error"
).permitAll()
.anyExchange().authenticated()
}
.addFilterAt(loginAuthenticationFilter(), SecurityWebFiltersOrder.AUTHENTICATION)
.addFilterAt(jwtAuthorizeFilter, SecurityWebFiltersOrder.AUTHENTICATION)
.exceptionHandling {
it.authenticationEntryPoint { exchange, ex ->
writeJsonResponse(
exchange,
HttpStatus.UNAUTHORIZED,
RestBean.unauthenticated(ex.message)
)
}
.accessDeniedHandler { exchange, ex ->
writeJsonResponse(
exchange,
HttpStatus.FORBIDDEN,
RestBean.forbidden(ex.message)
)
}
}
.csrf { it.disable() }
.securityContextRepository(NoOpServerSecurityContextRepository.getInstance())
.build()
}
@Bean
fun loginAuthenticationFilter(): AuthenticationWebFilter {
val filter = AuthenticationWebFilter(reactiveAuthenticationManager())
filter.setRequiresAuthenticationMatcher(
ServerWebExchangeMatchers.pathMatchers("/api/auth/login")
)
filter.setServerAuthenticationConverter(loginAuthenticationConverter())
filter.setAuthenticationSuccessHandler { webFilterExchange, authentication ->
val user = authentication.principal as User
service.findAccountByNameOrEmail(user.username)
.flatMap { account ->
val vo = account.toAnotherObject(
AuthorizeVO::class,
mapOf(
"token" to jwtUtils.createJwt(user, account.id!!, account.username),
"expire" to DateUtils.dateToLocalDateTime(jwtUtils.expiresTime())
)
)
writeJsonResponse(
webFilterExchange.exchange,
HttpStatus.OK,
RestBean.success(vo)
)
}
}
filter.setAuthenticationFailureHandler { webFilterExchange, exception ->
writeJsonResponse(
webFilterExchange.exchange,
HttpStatus.UNAUTHORIZED,
RestBean.unauthenticated(exception.message)
)
}
return filter
}
private fun loginAuthenticationConverter(): ServerAuthenticationConverter {
return ServerAuthenticationConverter { exchange ->
exchange.formData.flatMap { formData ->
val username = formData.getFirst("username")
val password = formData.getFirst("password")
if (username != null && password != null) {
Mono.just(
UsernamePasswordAuthenticationToken(
username,
password
)
)
} else {
Mono.empty()
}
}
}
}
}
internal inline fun <reified T> writeJsonResponse(
exchange: ServerWebExchange,
status: HttpStatus,
body: RestBean<T>
): Mono<Void> {
val response = exchange.response
response.statusCode = status
response.headers.contentType = MediaType.APPLICATION_JSON
val jsonString = Json.encodeToJsonElement(body).toString()
val buffer = response.bufferFactory().wrap(jsonString.toByteArray())
return response.writeWith(Mono.just(buffer))
}
@@ -0,0 +1,17 @@
package com.github.sor2171.backend.config
import kotlinx.serialization.json.Json
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
@Configuration
class SerializationConfig {
@Bean
fun kotlinxSerializationJson(): Json = Json {
ignoreUnknownKeys = true
isLenient = true
encodeDefaults = false
prettyPrint = true
}
}
@@ -0,0 +1,79 @@
package com.github.sor2171.backend.controller
import com.github.sor2171.backend.entity.RestBean
import com.github.sor2171.backend.entity.vo.request.EmailRegisterVO
import com.github.sor2171.backend.entity.vo.request.PasswordResetVO
import com.github.sor2171.backend.service.AccountService
import jakarta.validation.Valid
import jakarta.validation.constraints.Email
import jakarta.validation.constraints.NotBlank
import jakarta.validation.constraints.Pattern
import org.slf4j.LoggerFactory
import org.springframework.validation.annotation.Validated
import org.springframework.web.bind.annotation.*
import org.springframework.web.server.ServerWebExchange
import reactor.core.publisher.Mono
@Validated
@RestController
@RequestMapping("/api/auth")
class AuthorizeController(
private val service: AccountService
) {
private val logger = LoggerFactory.getLogger(this::class.java)
@GetMapping("/ask-code")
fun askVerifyCode(
@RequestParam @NotBlank @Email email: String,
@RequestParam @Pattern(regexp = "(register|reset)") type: String,
exchange: ServerWebExchange
): Mono<RestBean<Any?>> {
logger.info("Trying to ask code")
val ip = exchange.request.remoteAddress?.address?.hostAddress ?: "unknown"
return service.askEmailVerifyCode(type, email, ip)
.map { messageHandler(it) }
}
@PostMapping("/register")
fun emailRegister(@RequestBody @Valid vo: EmailRegisterVO): Mono<RestBean<Any?>> {
logger.info("Trying to register")
return service.registerEmailAccount(vo)
.map { messageHandler(it) }
}
@PostMapping("/reset")
fun emailResetPassword(@RequestBody @Valid vo: PasswordResetVO): Mono<RestBean<Any?>> {
logger.info("Trying to reset password")
return service.resetEmailAccountPassword(vo)
.map { messageHandler(it) }
}
@GetMapping("/logout")
fun logout(exchange: ServerWebExchange): Mono<RestBean<Any?>> {
logger.info("Trying to logout")
val authorization = exchange.request.headers.getFirst("Authorization")
return Mono.fromCallable { service.invalidateJwt(authorization) }
.map { success ->
if (success) RestBean.success()
else RestBean.logoutFailed()
}
}
@GetMapping("/relogin")
fun relogin(exchange: ServerWebExchange): Mono<RestBean<Any?>> {
logger.info("Trying to relogin")
val authorization = exchange.request.headers.getFirst("Authorization")
return service
.jwtTokenRelogin(authorization)
?.map { RestBean.success(it) }
?: Mono.just(messageHandler("Please login with password."))
}
private fun messageHandler(wrongMessage: String): RestBean<Any?> {
return if (wrongMessage.isBlank()) {
RestBean.success()
} else {
RestBean.failure(400, null, wrongMessage)
}
}
}
@@ -0,0 +1,14 @@
package com.github.sor2171.backend.controller
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
import reactor.core.publisher.Mono
@RestController
@RequestMapping("/api/test")
class TestController {
@GetMapping("/hello")
fun test(): Mono<String> = Mono.just("Hello Kotlin WebFlux!")
}
@@ -0,0 +1,56 @@
package com.github.sor2171.backend.entity
import kotlin.reflect.KClass
import kotlin.reflect.KProperty
import kotlin.reflect.full.createInstance
import kotlin.reflect.full.declaredMemberProperties
import kotlin.reflect.full.memberProperties
import kotlin.reflect.jvm.javaField
interface DataCopy {
fun <T : Any> toAnotherObject(
toClass: KClass<T>,
otherProperties: Map<String, Any?>
): T {
try {
val voProperties = toClass.declaredMemberProperties
val vo = toClass.createInstance()
for (voProperty in voProperties)
copyFieldData(voProperty, otherProperties, vo)
return vo
} catch (e: ReflectiveOperationException) {
throw RuntimeException(
"${toClass.simpleName} has no fields.",
e
)
}
}
private fun copyFieldData(
toProperty: KProperty<*>,
otherProperties: Map<String, Any?>,
another: Any
) {
try {
val thisProperty = this::class.memberProperties
.firstOrNull { it.name == toProperty.name }
val value =
if (thisProperty != null) {
thisProperty.getter.call(this)
} else if (otherProperties.containsKey(toProperty.name))
otherProperties[toProperty.name]
else
throw RuntimeException(
"Property ${toProperty.name} not found."
)
val field = toProperty.javaField!!
field.isAccessible = true
field.set(another, value)
} catch (e: Exception) {
e.printStackTrace()
}
}
}
@@ -0,0 +1,41 @@
package com.github.sor2171.backend.entity
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
@Serializable
data class RestBean<T>(
val code: Int,
val data: T,
val message: String
) {
companion object {
fun <T> success(data: T, message: String = "Success"): RestBean<T> {
return RestBean(200, data, message)
}
// 针对无 data 返回的情况,统一使用 Any?
fun success(message: String = "Success"): RestBean<Any?> {
return RestBean(200, null, message)
}
fun <T> failure(code: Int = 401, data: T, message: String?): RestBean<T> {
return RestBean(code, data, message ?: "Failure")
}
fun unauthenticated(message: String?) =
failure(401, null, message)
fun forbidden(message: String?) =
failure(403, null, message)
fun logoutFailed(message: String = ""): RestBean<Any?> {
val formatMessage = if (message.isBlank()) "" else ": $message"
return failure(400, null, "Logout Failed$formatMessage")
}
}
}
inline fun <reified T> RestBean<T>.toJsonString(): String {
return Json.encodeToString(this)
}
@@ -0,0 +1,20 @@
package com.github.sor2171.backend.entity.dto
import com.baomidou.mybatisplus.annotation.IdType
import com.baomidou.mybatisplus.annotation.TableId
import com.baomidou.mybatisplus.annotation.TableName
import com.github.sor2171.backend.entity.DataCopy
import kotlinx.datetime.LocalDateTime
import kotlinx.serialization.Serializable
@Serializable
@TableName("account")
data class Account(
@TableId(type = IdType.AUTO)
val id: Int? = null,
val username: String = "",
val password: String = "",
val email: String = "",
val role: String = "",
val registerTime: LocalDateTime? = null
) : DataCopy
@@ -0,0 +1,10 @@
package com.github.sor2171.backend.entity.dto
import kotlinx.serialization.Serializable
@Serializable
data class EmailVerificationMsg(
val email: String,
val type: String,
val code: String
)
@@ -0,0 +1,19 @@
package com.github.sor2171.backend.entity.vo.request
import jakarta.validation.constraints.Email
import jakarta.validation.constraints.Pattern
import kotlinx.serialization.Serializable
import org.hibernate.validator.constraints.Length
@Serializable
data class EmailRegisterVO(
@param:Email
val email: String = "",
@param:Length(min = 6, max = 6)
val code: String? = null,
@param:Pattern(regexp = "^[a-zA-Z0-9\\u4e00-\\u9fa5]+$")
@param:Length(min = 4, max = 16)
val username: String = "",
@param:Length(min = 6, max = 20)
val password: String = "",
)
@@ -0,0 +1,15 @@
package com.github.sor2171.backend.entity.vo.request
import jakarta.validation.constraints.Email
import kotlinx.serialization.Serializable
import org.hibernate.validator.constraints.Length
@Serializable
data class PasswordResetVO(
@param:Email
val email: String = "",
@param:Length(min = 6, max = 6)
val code: String? = null,
@param:Length(min = 6, max = 20)
val password: String = "",
)
@@ -0,0 +1,13 @@
package com.github.sor2171.backend.entity.vo.response
import com.github.sor2171.backend.utils.DateUtils.getCurrentDateTime
import kotlinx.datetime.LocalDateTime
import kotlinx.serialization.Serializable
@Serializable
data class AuthorizeVO(
val username: String = "",
val role: String = "",
val token: String = "",
val expire: LocalDateTime = getCurrentDateTime(),
)
@@ -0,0 +1,9 @@
package com.github.sor2171.backend.entity.vo.response
import kotlinx.serialization.Serializable
@Serializable
data class ReloginVO(
val token: String = "",
val expire: String = "",
)
@@ -0,0 +1,23 @@
package com.github.sor2171.backend.exception
import com.github.sor2171.backend.entity.RestBean
import jakarta.validation.ValidationException
import org.slf4j.LoggerFactory
import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.bind.annotation.RestControllerAdvice
@RestControllerAdvice
class ValidationController {
private val log = LoggerFactory.getLogger(ValidationController::class.java)
@ExceptionHandler(ValidationException::class)
fun validationException(e: Exception): RestBean<out String?> {
log.warn("Resolve [${e.javaClass.name}: ${e.message}]")
return RestBean.failure(
400,
null,
"parameter validation error"
)
}
}
@@ -0,0 +1,38 @@
package com.github.sor2171.backend.filter
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.http.HttpHeaders
import org.springframework.http.HttpMethod
import org.springframework.web.cors.CorsConfiguration
import org.springframework.web.cors.reactive.CorsWebFilter
import org.springframework.web.cors.reactive.UrlBasedCorsConfigurationSource
@Configuration
class CorsFilter {
@Bean
fun corsWebFilter(): CorsWebFilter {
val config = CorsConfiguration().apply {
allowedOriginPatterns = listOf("*")
allowedMethods = listOf(
HttpMethod.GET.name(),
HttpMethod.POST.name(),
HttpMethod.PUT.name(),
HttpMethod.DELETE.name(),
HttpMethod.OPTIONS.name()
)
allowedHeaders = listOf(
HttpHeaders.AUTHORIZATION,
HttpHeaders.CONTENT_TYPE
)
allowCredentials = true
}
val source = UrlBasedCorsConfigurationSource().apply {
registerCorsConfiguration("/**", config)
}
return CorsWebFilter(source)
}
}
@@ -0,0 +1,72 @@
package com.github.sor2171.backend.filter
import com.github.sor2171.backend.entity.RestBean
import com.github.sor2171.backend.entity.toJsonString
import com.github.sor2171.backend.utils.Const
import org.springframework.core.annotation.Order
import org.springframework.data.redis.core.ReactiveStringRedisTemplate
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
import org.springframework.stereotype.Component
import org.springframework.web.server.ServerWebExchange
import org.springframework.web.server.WebFilter
import org.springframework.web.server.WebFilterChain
import reactor.core.publisher.Mono
import java.time.Duration
@Component
@Order(Const.FLOW_LIMIT_ORDER)
class FlowLimitFilter(
private val template: ReactiveStringRedisTemplate
) : WebFilter {
override fun filter(exchange: ServerWebExchange, chain: WebFilterChain): Mono<Void> {
val address = exchange.request.remoteAddress?.address?.hostAddress ?: "unknown"
return tryCount(address).flatMap { allowed ->
if (allowed) {
chain.filter(exchange)
} else {
writeBlockMessage(exchange)
}
}
}
private fun writeBlockMessage(exchange: ServerWebExchange): Mono<Void> {
val response = exchange.response
response.statusCode = HttpStatus.FORBIDDEN
response.headers.contentType = MediaType.APPLICATION_JSON
val buffer = response.bufferFactory()
.wrap(RestBean.forbidden("Too many requests").toJsonString().toByteArray())
return response.writeWith(Mono.just(buffer))
}
private fun tryCount(ip: String): Mono<Boolean> {
val blockKey = Const.FLOW_LIMIT_BLOCK + ip
val counterKey = Const.FLOW_LIMIT_COUNTER + ip
return template.hasKey(blockKey).flatMap { isBlocked ->
if (isBlocked) {
Mono.just(false)
} else {
template.hasKey(counterKey).flatMap { hasCounter ->
if (hasCounter) {
template.opsForValue().increment(counterKey).flatMap { count ->
if (count > 10) {
template.opsForValue()
.set(blockKey, "1", Duration.ofMinutes(1))
.thenReturn(false)
} else {
Mono.just(true)
}
}
} else {
template.opsForValue()
.set(counterKey, "1", Duration.ofSeconds(3))
.thenReturn(true)
}
}
}
}
}
}
@@ -0,0 +1,37 @@
package com.github.sor2171.backend.filter
import com.github.sor2171.backend.utils.JwtUtils
import org.springframework.http.HttpHeaders
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
import org.springframework.security.core.context.ReactiveSecurityContextHolder
import org.springframework.stereotype.Component
import org.springframework.web.server.ServerWebExchange
import org.springframework.web.server.WebFilter
import org.springframework.web.server.WebFilterChain
import reactor.core.publisher.Mono
@Component
class JwtAuthorizeFilter(
private val utils: JwtUtils
) : WebFilter {
override fun filter(exchange: ServerWebExchange, chain: WebFilterChain): Mono<Void> {
val authorization = exchange.request.headers.getFirst(HttpHeaders.AUTHORIZATION)
val jwt = utils.resolveJwt(authorization)
return if (jwt != null) {
val user = utils.toUser(jwt)
val authentication = UsernamePasswordAuthenticationToken(
user,
null,
user.authorities
)
exchange.attributes["id"] = utils.toId(jwt)
chain.filter(exchange)
.contextWrite(ReactiveSecurityContextHolder.withAuthentication(authentication))
} else {
chain.filter(exchange)
}
}
}
@@ -0,0 +1,59 @@
package com.github.sor2171.backend.listener
import com.github.sor2171.backend.entity.dto.EmailVerificationMsg
import kotlinx.serialization.json.Json
import org.slf4j.LoggerFactory
import org.springframework.amqp.rabbit.annotation.RabbitHandler
import org.springframework.amqp.rabbit.annotation.RabbitListener
import org.springframework.beans.factory.annotation.Value
import org.springframework.mail.MailSender
import org.springframework.mail.SimpleMailMessage
import org.springframework.stereotype.Component
@Component
@RabbitListener(queues = ["mail"])
class MailQueueListener(
@param:Value($$"${spring.mail.username}")
private val username: String,
private val json: Json,
private val sender: MailSender
) {
private val logger = LoggerFactory.getLogger(this::class.java)
@RabbitHandler
fun senderMailMessage(content: String) {
val data = json.decodeFromString<EmailVerificationMsg>(content)
val (email, type, code) = data
val message = when (type) {
"register" -> createMailMessage(
"Welcome to Our Service",
"Thank you for registering! Your verification code is: $code"
+ "\nThis code is valid for 3 minutes.",
email
)
"reset" -> createMailMessage(
"Password Reset Request",
"You requested a password reset. Your verification code is: $code"
+ "\nThis code is valid for 3 minutes.",
email
)
else -> return
}
sender.send(message)
logger.info("Email sent to $email.")
}
fun createMailMessage(title: String, content: String, to: String): SimpleMailMessage {
val message = SimpleMailMessage()
message.subject = title
message.text = content
message.from = username
message.setTo(to)
return message
}
}
@@ -0,0 +1,8 @@
package com.github.sor2171.backend.mapper
import com.baomidou.mybatisplus.core.mapper.BaseMapper
import com.github.sor2171.backend.entity.dto.Account
import org.apache.ibatis.annotations.Mapper
@Mapper
interface AccountMapper : BaseMapper<Account>
@@ -0,0 +1,18 @@
package com.github.sor2171.backend.service
import com.baomidou.mybatisplus.extension.service.IService
import com.github.sor2171.backend.entity.dto.Account
import com.github.sor2171.backend.entity.vo.request.EmailRegisterVO
import com.github.sor2171.backend.entity.vo.request.PasswordResetVO
import com.github.sor2171.backend.entity.vo.response.ReloginVO
import org.springframework.security.core.userdetails.ReactiveUserDetailsService
import reactor.core.publisher.Mono
interface AccountService : IService<Account>, ReactiveUserDetailsService {
fun findAccountByNameOrEmail(text: String): Mono<Account>
fun askEmailVerifyCode(type: String, email: String, ip: String): Mono<String>
fun registerEmailAccount(vo: EmailRegisterVO): Mono<String>
fun resetEmailAccountPassword(vo: PasswordResetVO): Mono<String>
fun invalidateJwt(headerToken: String?): Boolean
fun jwtTokenRelogin(headerToken: String?): Mono<ReloginVO>?
}
@@ -0,0 +1,209 @@
package com.github.sor2171.backend.service.impl
import com.baomidou.mybatisplus.core.toolkit.Wrappers
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl
import com.github.sor2171.backend.entity.dto.Account
import com.github.sor2171.backend.entity.dto.EmailVerificationMsg
import com.github.sor2171.backend.entity.vo.request.EmailRegisterVO
import com.github.sor2171.backend.entity.vo.request.PasswordResetVO
import com.github.sor2171.backend.entity.vo.response.ReloginVO
import com.github.sor2171.backend.mapper.AccountMapper
import com.github.sor2171.backend.service.AccountService
import com.github.sor2171.backend.utils.Const
import com.github.sor2171.backend.utils.DateUtils.getCurrentDateTime
import com.github.sor2171.backend.utils.FlowUtils
import com.github.sor2171.backend.utils.JwtUtils
import org.springframework.amqp.core.AmqpTemplate
import org.springframework.data.redis.core.ReactiveStringRedisTemplate
import org.springframework.security.core.userdetails.User
import org.springframework.security.core.userdetails.UserDetails
import org.springframework.security.core.userdetails.UsernameNotFoundException
import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.stereotype.Service
import reactor.core.publisher.Mono
import reactor.core.scheduler.Schedulers
import java.time.Duration
@Service
class AccountServiceImpl(
private val utils: FlowUtils,
private val jwtUtils: JwtUtils,
private val amqpTemplate: AmqpTemplate,
private val stringRedisTemplate: ReactiveStringRedisTemplate,
private val encoder: PasswordEncoder
) : ServiceImpl<AccountMapper, Account>(), AccountService {
override fun findByUsername(username: String): Mono<UserDetails> {
return findAccountByNameOrEmail(username)
.switchIfEmpty(Mono.error(UsernameNotFoundException("Account with name $username not found")))
.map { account ->
User.withUsername(username)
.password(account.password)
.roles(account.role)
.build()
}
}
override fun findAccountByNameOrEmail(text: String): Mono<Account> {
return Mono.fromCallable {
this.query()
.eq("username", text).or()
.eq("email", text)
.one()
}.flatMap { account ->
Mono.just(account)
}
}
override fun askEmailVerifyCode(type: String, email: String, ip: String): Mono<String> {
return verifyLimit(ip).flatMap { allowed ->
if (allowed) {
val code = (100000..999999).random().toString()
val data = EmailVerificationMsg(email, type, code)
Mono.fromRunnable<Void> {
amqpTemplate.convertAndSend("mail", data)
}.then(
stringRedisTemplate.opsForValue()
.set(
Const.VERIFY_EMAIL_DATA + email,
code,
Duration.ofMinutes(3)
)
).thenReturn("").subscribeOn(Schedulers.boundedElastic())
} else {
Mono.just("Request limit exceeded. Please try again later.")
}
}
}
override fun registerEmailAccount(vo: EmailRegisterVO): Mono<String> {
val (email, code, username, password) = vo
return verifyCode(email, code).flatMap { error ->
if (!error.isBlank()) {
return@flatMap Mono.just(error)
}
existAccountByEmail(email).flatMap { emailExists ->
if (emailExists) {
return@flatMap Mono.just("account with the same email already exists.")
}
existAccountByUsername(username).flatMap { usernameExists ->
if (usernameExists) {
return@flatMap Mono.just("username already exists.")
}
val encodedPassword = encoder.encode(password)
val account = Account(
null,
username,
encodedPassword!!,
email,
"user",
getCurrentDateTime()
)
Mono.fromCallable { this.save(account) }
.flatMap { success ->
if (success) {
stringRedisTemplate.delete(Const.VERIFY_EMAIL_DATA + email)
.thenReturn("")
} else {
Mono.just("something went wrong. Please contact the administrator.")
}
}
}
}
}
}
override fun resetEmailAccountPassword(vo: PasswordResetVO): Mono<String> {
val (email, code, password) = vo
return verifyCode(email, code).flatMap { error ->
if (!error.isBlank()) {
return@flatMap Mono.just(error)
}
existAccountByEmail(email).flatMap { exists ->
if (!exists) {
return@flatMap Mono.just("account with the email does not exist.")
}
val encodedPassword = encoder.encode(password)
Mono.fromCallable {
this.update()
.eq("email", email)
.set("password", encodedPassword)
.update()
}.flatMap { success ->
if (success) {
stringRedisTemplate.delete(Const.VERIFY_EMAIL_DATA + email)
.thenReturn("")
} else {
Mono.just("something went wrong. Please contact the administrator.")
}
}
}
}
}
override fun jwtTokenRelogin(headerToken: String?): Mono<ReloginVO>? {
val user = jwtUtils.resolveJwt(headerToken?.substring("Bearer ".length))
?.let { jwtUtils.toUser(it) }
?: return null
val accountMono = findAccountByNameOrEmail(user.username)
val voMono = accountMono.map {
ReloginVO(
jwtUtils.createJwt(user, it.id!!, it.username),
jwtUtils.expiresTime().toInstant().toString()
)
}
return voMono
}
override fun invalidateJwt(headerToken: String?): Boolean {
return jwtUtils.invalidateJwt(headerToken)
}
private fun existAccountByEmail(email: String): Mono<Boolean> {
return Mono.fromCallable {
this.baseMapper.exists(
Wrappers.query<Account>()
.eq("email", email)
)
}
}
private fun existAccountByUsername(username: String): Mono<Boolean> {
return Mono.fromCallable {
this.baseMapper.exists(
Wrappers.query<Account>()
.eq("username", username)
)
}
}
private fun verifyLimit(ip: String): Mono<Boolean> {
val key = Const.VERIFY_EMAIL_LIMIT + ip
return utils.limitOnceCheck(key, 60)
}
private fun verifyCode(email: String, receivedCode: String?): Mono<String> {
return stringRedisTemplate.opsForValue()
.get(Const.VERIFY_EMAIL_DATA + email)
.flatMap { code ->
when {
receivedCode == null || code != receivedCode ->
Mono.just("verify code is wrong.")
else -> Mono.just("")
}
}
.switchIfEmpty(Mono.just("verify code has not been sent"))
}
}
@@ -0,0 +1,12 @@
package com.github.sor2171.backend.utils
object Const {
const val FLOW_LIMIT_ORDER = -101
const val JWT_BLACK_LIST = "jwt:blacklist:"
const val VERIFY_EMAIL_LIMIT = "verify:email:limit:"
const val VERIFY_EMAIL_DATA = "verify:email:data:"
const val FLOW_LIMIT_COUNTER = "flow:counter:"
const val FLOW_LIMIT_BLOCK = "flow:block:"
}
@@ -0,0 +1,17 @@
package com.github.sor2171.backend.utils
import kotlinx.datetime.*
import kotlinx.datetime.TimeZone
import java.util.*
object DateUtils {
fun getCurrentDateTime() =
Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault())
fun dateToLocalDateTime(date: Date): LocalDateTime {
return date
.toInstant()
.toKotlinInstant()
.toLocalDateTime(TimeZone.currentSystemDefault())
}
}
@@ -0,0 +1,26 @@
package com.github.sor2171.backend.utils
import jakarta.annotation.Resource
import org.springframework.data.redis.core.ReactiveStringRedisTemplate
import org.springframework.stereotype.Component
import reactor.core.publisher.Mono
import java.time.Duration
@Component
class FlowUtils(
@Resource
val template: ReactiveStringRedisTemplate
) {
fun limitOnceCheck(key: String, blockTime: Int): Mono<Boolean> {
return template.hasKey(key).flatMap { exists ->
if (exists) {
Mono.just(false)
} else {
template.opsForValue()
.set(key, "", Duration.ofSeconds(blockTime.toLong()))
.thenReturn(true)
}
}
}
}
@@ -0,0 +1,107 @@
package com.github.sor2171.backend.utils
import com.auth0.jwt.JWT
import com.auth0.jwt.JWTVerifier
import com.auth0.jwt.algorithms.Algorithm
import com.auth0.jwt.interfaces.DecodedJWT
import org.springframework.beans.factory.annotation.Value
import org.springframework.data.redis.core.StringRedisTemplate
import org.springframework.security.core.userdetails.User
import org.springframework.security.core.userdetails.UserDetails
import org.springframework.stereotype.Component
import java.util.*
import java.util.concurrent.TimeUnit
@Component
class JwtUtils(
@param:Value($$"${spring.security.jwt.key}")
private val key: String,
@param:Value($$"${spring.security.jwt.expire-hours}")
private val expireHours: Int,
private val template: StringRedisTemplate,
private val algorithm: Algorithm = Algorithm.HMAC256(key),
private val jwtVerifier: JWTVerifier = JWT.require(algorithm).build()
) {
fun invalidateJwt(headerToken: String?): Boolean {
val token = this.convertToToken(headerToken) ?: return false
try {
val jwt = jwtVerifier.verify(token)
val id = jwt.id
return deleteToken(id, jwt.expiresAt)
} catch (e: Exception) {
println(e.message)
return false
}
}
private fun deleteToken(uuid: String, time: Date): Boolean {
val now = Date()
val expire = (time.time - now.time).coerceAtLeast(0)
template.opsForValue().set(
Const.JWT_BLACK_LIST + uuid,
"deleted",
expire,
TimeUnit.MILLISECONDS
)
return true
}
private fun isInvalidToken(uuid: String): Boolean =
template.hasKey(Const.JWT_BLACK_LIST + uuid) ?: false
fun resolveJwt(headerToken: String?): DecodedJWT? {
val token = convertToToken(headerToken) ?: return null
try {
val decodedJWT = jwtVerifier.verify(token)
if (this.isInvalidToken(decodedJWT.id)) return null
val expiresAt = decodedJWT.expiresAt
return if (Date().after(expiresAt)) null
else decodedJWT
} catch (e: Exception) {
println(e.message)
return null
}
}
fun createJwt(
details: UserDetails,
id: Int,
username: String
): String {
return JWT.create()
.withJWTId(UUID.randomUUID().toString())
.withClaim("id", id)
.withClaim("username", username)
.withClaim(
"authorities",
details.authorities.map { it.authority }.toList()
)
.withExpiresAt(expiresTime())
.withIssuedAt(Date())
.sign(algorithm)
}
fun toUser(jwt: DecodedJWT): UserDetails {
val claims = jwt.claims
return User
.withUsername(claims["username"]!!.asString())
.password("********")
.authorities(*(claims["authorities"]!!.asArray(String::class.java)))
.build()
}
fun toId(jwt: DecodedJWT) = jwt.claims["id"]?.asInt() ?: -1
fun expiresTime(): Date {
val cal = Calendar.getInstance()
cal.add(Calendar.HOUR, expireHours)
return cal.time
}
fun convertToToken(headerToken: String?): String? =
headerToken?.replace("Bearer ", "")
}
@@ -0,0 +1,38 @@
spring:
webflux:
base-path: /
mail:
#host: smtp.example.com
#username: 123456@example.com
#password: password
#port: 123456
protocol: smtp
properties:
mail.smtp.auth: true
mail.smtp.ssl.enable: true
mail.smtp.timeout: 5000
rabbitmq:
host: localhost
port: 5672
virtual-host: /
#username: admin
#password: 123456
application:
name: backend
security:
jwt:
#key: 123456
expire-hours: 168
ip-ban-limit: 10
ip-ban-time: 3
ip-ban-time-min: 1
datasource:
url: jdbc:mysql://localhost:3306/kotlin_springboot
#username: root
#password: 123456
driver-class-name: com.mysql.cj.jdbc.Driver
@@ -0,0 +1,57 @@
-- MySQL dump 10.13 Distrib 8.0.43, for Linux (x86_64)
--
-- Host: 127.0.0.1 Database: kotlin_springboot
-- ------------------------------------------------------
-- Server version 8.0.43-0ubuntu0.24.04.2
/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */;
/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */;
/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */;
/*!50503 SET NAMES utf8mb4 */;
/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */;
/*!40103 SET TIME_ZONE='+00:00' */;
/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */;
/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */;
/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */;
/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */;
--
-- Table structure for table `account`
--
DROP TABLE IF EXISTS `account`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `account` (
`id` int NOT NULL AUTO_INCREMENT,
`username` varchar(255) DEFAULT NULL,
`password` varchar(255) DEFAULT NULL,
`email` varchar(255) DEFAULT NULL,
`role` varchar(255) DEFAULT NULL,
`register_time` datetime DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `unique_email` (`email`),
UNIQUE KEY `unique_name` (`username`)
) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Dumping data for table `account`
--
LOCK TABLES `account` WRITE;
/*!40000 ALTER TABLE `account` DISABLE KEYS */;
INSERT INTO `account` VALUES (1,'test','$2a$10$Xqe2W.5zs/I8FAs3cK331e6tyuo4XWTHNhW1oV6bOHUzwMZ2kAPTa','1234567890@gmail.com','user','2025-11-06 15:56:37');
/*!40000 ALTER TABLE `account` ENABLE KEYS */;
UNLOCK TABLES;
/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */;
/*!40101 SET SQL_MODE=@OLD_SQL_MODE */;
/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */;
/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */;
/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */;
/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */;
/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */;
/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */;
-- Dump completed on 2025-11-24 17:53:21
@@ -0,0 +1,15 @@
package com.github.sor2171.backend
import org.junit.jupiter.api.Test
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder
@SpringBootTest
class BackendApplicationTests {
@Test
fun contextLoads() {
println(BCryptPasswordEncoder().encode("123456"))
}
}
+26
View File
@@ -0,0 +1,26 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
src/assets
+5
View File
@@ -0,0 +1,5 @@
# Vue 3 + TypeScript + Vite
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).
+10
View File
@@ -0,0 +1,10 @@
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// Generated by unplugin-auto-import
// biome-ignore lint: disable
export {}
declare global {
}
+51
View File
@@ -0,0 +1,51 @@
/* eslint-disable */
// @ts-nocheck
// biome-ignore lint: disable
// oxlint-disable
// ------
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
ChatPage: typeof import('./src/components/home/ChatPage.vue')['default']
ElAside: typeof import('element-plus/es')['ElAside']
ElAvatar: typeof import('element-plus/es')['ElAvatar']
ElButton: typeof import('element-plus/es')['ElButton']
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
ElCol: typeof import('element-plus/es')['ElCol']
ElContainer: typeof import('element-plus/es')['ElContainer']
ElDialog: typeof import('element-plus/es')['ElDialog']
ElDivider: typeof import('element-plus/es')['ElDivider']
ElDropdown: typeof import('element-plus/es')['ElDropdown']
ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu']
ElFooter: typeof import('element-plus/es')['ElFooter']
ElForm: typeof import('element-plus/es')['ElForm']
ElFormItem: typeof import('element-plus/es')['ElFormItem']
ElHeader: typeof import('element-plus/es')['ElHeader']
ElIcon: typeof import('element-plus/es')['ElIcon']
ElImage: typeof import('element-plus/es')['ElImage']
ElInput: typeof import('element-plus/es')['ElInput']
ElLink: typeof import('element-plus/es')['ElLink']
ElMain: typeof import('element-plus/es')['ElMain']
ElMenu: typeof import('element-plus/es')['ElMenu']
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
ElMenuItemGroup: typeof import('element-plus/es')['ElMenuItemGroup']
ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
ElRow: typeof import('element-plus/es')['ElRow']
ElScrollbar: typeof import('element-plus/es')['ElScrollbar']
ElSpace: typeof import('element-plus/es')['ElSpace']
ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
HeaderBar: typeof import('./src/components/home/HeaderBar.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
}
export interface GlobalDirectives {
vLoading: typeof import('element-plus/es')['ElLoadingDirective']
}
}
+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="NewModuleRootManager" inherit-compiler-output="true">
<exclude-output />
<content url="file://$MODULE_DIR$" />
<orderEntry type="sourceFolder" forTests="false" />
<orderEntry type="library" name="index" level="application" />
</component>
</module>
+19
View File
@@ -0,0 +1,19 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vue.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<link rel="stylesheet" href="//unpkg.com/element-plus/dist/index.css"/>
<title>frontend</title>
<style>
body {
margin: 0;
}
</style>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+3437
View File
File diff suppressed because it is too large Load Diff
+35
View File
@@ -0,0 +1,35 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.2",
"@vueuse/core": "^14.0.0",
"axios": "^1.13.2",
"dompurify": "^3.3.0",
"element-plus": "^2.11.7",
"markdown-it": "^14.1.0",
"marked": "^17.0.1",
"unplugin-auto-import": "^20.3.0",
"uuid": "^13.0.0",
"vue": "^3.5.24",
"vue-router": "^4.6.3"
},
"devDependencies": {
"@types/markdown-it": "^14.1.2",
"@types/node": "^24.10.1",
"@vitejs/plugin-vue": "^6.0.1",
"@vue/tsconfig": "^0.8.1",
"sass-embedded": "^1.93.3",
"typescript": "~5.9.3",
"unplugin-vue-components": "^30.0.0",
"vite": "^7.2.4",
"vue-tsc": "^3.1.4"
}
}
+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07"
height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198">
<path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"/>
<path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"/>
<path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"/>
</svg>

After

Width:  |  Height:  |  Size: 456 B

+27
View File
@@ -0,0 +1,27 @@
<script setup lang="ts">
import {useDark, useToggle} from "@vueuse/core";
useDark({
selector: 'html',
attribute: 'class',
valueDark: 'dark',
valueLight: 'light',
})
useDark({
onChanged(dark) {
useToggle(dark)
}
}
)
</script>
<template>
<div>
<router-view/>
</div>
</template>
<style scoped>
</style>
+136
View File
@@ -0,0 +1,136 @@
import axios from 'axios'
import { ElMessage } from "element-plus";
import type {
ApiResponse,
LoginResponse,
FailureCallback,
SuccessCallback
} from "../interfaces/ApiTypes.ts";
import { deleteAccessToken, storeAccessToken, takeAccessToken } from "../utils/JwtUtils.ts";
import router from "../router";
// --- Constants & Defaults ---
export const chatStreamURL = `/api/chat/stream`;
const defaultFailure: FailureCallback = (message, code, url) => {
console.warn(`Request to ${url} failed ${code}: ${message}`);
ElMessage.warning('Something went wrong: ' + message);
}
const defaultError: ErrorCallback = (err) => {
console.error(err);
ElMessage.error('Something went wrong, please contact the administrator.');
}
const accessHeader = (): Record<string, string> => {
const token = takeAccessToken();
return token ? {
'Authorization': 'Bearer ' + token
} : {};
};
// --- Internal Request Helpers ---
function internalPost<T>(
url: string,
data: any,
header: Record<string, string>,
success: SuccessCallback<T>,
failure: FailureCallback = defaultFailure,
error: ErrorCallback = defaultError
): void {
axios.post<ApiResponse<T>>(url, data, { headers: header })
.then(({ data }) => {
if (data.code === 200) {
success(data.data);
} else {
failure(data.message, data.code, url);
}
})
.catch(err => error(err));
}
function internalGet<T>(
url: string,
header: Record<string, string>,
success: SuccessCallback<T>,
failure: FailureCallback = defaultFailure,
error: ErrorCallback = defaultError
): void {
axios.get<ApiResponse<T>>(url, { headers: header })
.then(({ data }) => {
if (data.code === 200) {
success(data.data);
} else {
failure(data.message, data.code, url);
}
})
.catch(err => error(err));
}
// --- Exported Functions ---
/**
* Generic GET request
* @param url API Endpoint
* @param success Callback on success (code 200)
* @param failure Callback on business failure (code != 200)
* @param error Callback on network/system error
*/
export function get<T = any>(
url: string,
success: SuccessCallback<T>,
failure: FailureCallback = defaultFailure,
error: ErrorCallback = defaultError
): void {
internalGet<T>(url, accessHeader(), success, failure, error);
}
/**
* Generic POST request
* @param url API Endpoint
* @param data Request body
* @param success Callback on success (code 200)
* @param failure Callback on business failure (code != 200)
* @param error Callback on network/system error
*/
export function post<T = any>(
url: string,
data: any,
success: SuccessCallback<T>,
failure: FailureCallback = defaultFailure,
error: ErrorCallback = defaultError
): void {
internalPost<T>(url, data, accessHeader(), success, failure, error);
}
export function login(
username: string,
password: string,
remember: boolean,
success: SuccessCallback<LoginResponse>
): void {
internalPost<LoginResponse>(
'/api/auth/login',
{
username: username,
password: password
},
{
'Content-Type': 'application/x-www-form-urlencoded'
},
(data) => {
storeAccessToken(data.token, remember, data.expire);
ElMessage.success(`Login successfully! Welcome back, ${data.username}`);
success(data);
}
);
}
export function logout(success: () => void): void {
deleteAccessToken();
ElMessage.success('Logout successfully!');
router.push('/');
success();
}
+19
View File
@@ -0,0 +1,19 @@
export interface JwtAuth {
token: string;
expire: number;
}
export interface ApiResponse<T = any> {
code: number;
message: string;
data: T;
}
export interface LoginResponse {
token: string;
expire: number;
username: string;
}
export type SuccessCallback<T = any> = (data: T) => void;
export type FailureCallback = (message: string, code: number, url: string) => void;
+14
View File
@@ -0,0 +1,14 @@
import {createApp} from 'vue'
import App from './App.vue'
import router from "./router/index.ts";
// import axios from "axios";
import 'element-plus/dist/index.css';
import 'element-plus/theme-chalk/dark/css-vars.css';
// axios.defaults.baseURL = 'http://localhost:8080'
const app = createApp(App)
app.use(router)
app.mount('#app')
+60
View File
@@ -0,0 +1,60 @@
import { createRouter, createWebHistory } from 'vue-router'
import type { RouteRecordRaw } from 'vue-router'
declare module 'vue-router' {
interface RouteMeta {
title?: string;
}
}
const routes: Array<RouteRecordRaw> = [
{
path: '/',
name: 'welcome',
component: () => import('../views/WelcomeView.vue'),
children: [
{
path: '',
name: 'welcome-login',
component: () => import('../views/welcome/LoginPage.vue'),
meta: { title: 'Welcome - login' },
},
{
path: 'register',
name: 'welcome-register',
component: () => import('../views/welcome/RegisterPage.vue'),
meta: { title: 'Welcome - register' },
},
{
path: 'reset',
name: 'welcome-reset',
component: () => import('../views/welcome/ResetPage.vue'),
meta: { title: 'Welcome - reset' },
}
]
},
{
path: '/home',
name: 'home',
component: () => import('../views/HomeView.vue'),
meta: { title: 'home' },
}
]
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes
})
router.beforeEach((to, _from, next) => {
const nearestTitle = to.meta.title;
if (nearestTitle) {
document.title = nearestTitle;
} else {
document.title = 'frontend - page';
}
next();
});
export default router
+15
View File
@@ -0,0 +1,15 @@
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}
declare module '*.css' {
const content: any;
export default content;
}
declare module '*.scss' {
const content: any;
export default content;
}
+66
View File
@@ -0,0 +1,66 @@
import type { JwtAuth } from "../interfaces/ApiTypes.ts";
import { ElMessage } from "element-plus";
import router from "../router";
const authItemName = 'access_token';
function takeAccessToken(): string | null {
const str = localStorage.getItem(authItemName) || sessionStorage.getItem(authItemName);
if (!str) return null;
try {
const authObject: JwtAuth = JSON.parse(str);
if (authObject.expire < Date.now()) {
deleteAccessToken();
ElMessage.warning('Your session has expired, please log in again.');
return null;
}
return authObject.token;
} catch (e) {
deleteAccessToken();
return null;
}
}
function storeAccessToken(token: string, remember: boolean, expire: number): void {
const authObject: JwtAuth = {
token: token,
expire: expire
};
const authString = JSON.stringify(authObject);
if (remember) {
localStorage.setItem(authItemName, authString);
} else {
sessionStorage.setItem(authItemName, authString);
localStorage.removeItem(authItemName);
}
}
function deleteAccessToken(): void {
localStorage.removeItem(authItemName);
sessionStorage.removeItem(authItemName);
}
function loginPageTokenInit(): void {
const storedToken = takeAccessToken();
if (storedToken) {
router.push('/home')
}
}
function homePageTokenInit(): void {
const storedToken = takeAccessToken();
if (!storedToken) {
router.push('/').then(() => {
ElMessage.warning('Have not logged in yet, please log in.');
});
}
}
export {
takeAccessToken,
storeAccessToken,
deleteAccessToken,
loginPageTokenInit,
homePageTokenInit
}
+31
View File
@@ -0,0 +1,31 @@
<script setup lang="ts">
import { logout } from "../api";
import { homePageTokenInit } from "../utils/JwtUtils";
import { onMounted } from "vue";
onMounted(() => {
homePageTokenInit();
});
const handleLogout = () => {
logout(() => {
});
};
</script>
<template>
<div class="center-container">
<el-button @click="handleLogout" type="primary">logout</el-button>
</div>
</template>
<style scoped>
.center-container {
width: 100vw;
height: 100vh;
display: flex;
justify-content: center;
align-items: center;
}
</style>
+64
View File
@@ -0,0 +1,64 @@
<script setup lang="ts">
import { onMounted } from "vue";
import { loginPageTokenInit } from "../utils/JwtUtils.ts";
onMounted(() => {
loginPageTokenInit()
})
</script>
<template>
<div style="width: 100vw;height: 100vh;overflow: hidden;display: flex">
<div id="left-card">
<el-image style="width: 100%;height: 100%" fit="cover"
src="src/assets/welcome-image.png"/>
</div>
<div id="welcome-title">
<div>welcome here.</div>
</div>
<div id="right-card">
<router-view v-slot="{Component}">
<transition name="el-fade-in-linear" mode="out-in">
<component :is="Component"/>
</transition>
</router-view>
</div>
</div>
</template>
<style scoped>
#left-card {
flex: 1;
background-color: black
}
@media (max-width: 512px) {
#left-card {
display: none;
}
}
#right-card {
max-width: 100%;
height: 100vh;
z-index: 1;
background-color: var(--el-bg-color);
}
@media (min-width: 513px) {
#right-card {
max-width: 400px;
}
}
#welcome-title {
position: absolute;
bottom: 30px;
left: 120px;
transform: translate(-50%, -50%);
font-weight: bold;
color: white;
text-shadow: 0 0 10px black;
}
</style>
+97
View File
@@ -0,0 +1,97 @@
<script setup lang="ts">
import {reactive, ref} from "vue";
import {Lock, User} from "@element-plus/icons-vue";
import {login} from "../../api";
import router from "../../router/index.ts";
const formRef = ref()
const form = reactive({
username: '',
password: '',
remember: false
})
const formRule = {
username: [
{ required: true, message: 'please input username or email', trigger: 'blur' }
],
password: [
{ required: true, message: 'please input password', trigger: 'blur' }
]
}
function userLogin() {
formRef.value.validate((valid:boolean) => {
if (valid) {
login(form.username, form.password, form.remember, () => router.push('/home'))
} else {
console.log('error submit!')
return false
}
})
}
</script>
<template>
<div style="text-align: center;margin: 32px">
<div style="margin-top: 169px">
<div style="font-size: 28px;font-weight: bold">Login</div>
<div style="font-size: 14px;color: slategray">Please enter your username (or your email) and your password</div>
</div>
<div style="margin: 32px">
<el-form model="form" :rules="formRule" ref="formRef">
<el-form-item>
<el-input v-model="form.username" maxlength="63" type="text" placeholder="username or email">
<template #prefix>
<el-icon>
<User/>
</el-icon>
</template>
</el-input>
</el-form-item>
<el-form-item>
<el-input v-model="form.password" maxlength="20" type="password" placeholder="password">
<template #prefix>
<el-icon>
<Lock/>
</el-icon>
</template>
</el-input>
</el-form-item>
<el-row>
<el-col :span="12" style="text-align: left">
<el-form-item>
<el-checkbox v-model="form.remember" label="remember me"/>
</el-form-item>
</el-col>
<el-col :span="12" style="text-align: right">
<el-link @click="router.push('/reset')">
forget password?
</el-link>
</el-col>
</el-row>
</el-form>
</div>
<div style="margin-top: 24px">
<el-button @click="userLogin" style="width: 280px" type="primary">
Sign in
</el-button>
</div>
<el-divider>
<span style="font-size: 10px;color: slategray">have no account?</span>
</el-divider>
<div style="margin-top: 16px">
<el-button @click="router.push('/register')" style="width: 280px" type="default">
Sign up
</el-button>
</div>
</div>
</template>
<style scoped>
</style>
+206
View File
@@ -0,0 +1,206 @@
<script setup lang="ts">
import {reactive, ref} from "vue";
import {Bell, Lock, Message, User} from "@element-plus/icons-vue";
import router from "../../router/index.js";
import {get, post} from "../../api";
import {ElMessage} from "element-plus";
const coldTime = ref(0);
const formRef = ref()
const isCounting = ref(false)
let timer: number | null = null;
const form = reactive({
username: '',
password: '',
passwordRepeat: '',
email: '',
code: ''
})
const startColdTime = (): void => {
isCounting.value = true;
coldTime.value = 60;
timer = setInterval(() => {
coldTime.value--;
if (coldTime.value <= 0) {
if (timer) {
clearInterval(timer);
}
timer = null;
isCounting.value = false;
coldTime.value = 0;
}
}, 1000);
};
const validateUsername = (_rule: any, value: string, callback: (err?: Error) => void): void => {
if (value === "") {
callback(new Error("Please input username"));
} else if (!/^[a-zA-Z0-9\u4e00-\u9fa5]+$/.test(value)) {
callback(new Error("Only letters, numbers, or Chinese characters"));
} else {
callback();
}
};
const validatePasswordRepeat = (_rule: any, value: string, callback: (err?: Error) => void): void => {
if (value === "") {
callback(new Error("Please repeat password"));
} else if (value !== form.password) {
callback(new Error("Passwords do not match"));
} else {
callback();
}
};
const rule = {
username: [
{validator: validateUsername, trigger: ['blur', 'change']},
{min: 4, max: 16, message: 'Length should be 4 to 16', trigger: 'blur'}
],
password: [
{required: true, message: 'Please input password', trigger: 'blur'},
{min: 6, max: 20, message: 'Length should be 6 to 20', trigger: ['blur', 'change']}
],
passwordRepeat: [
{validator: validatePasswordRepeat, trigger: ['blur', 'change']},
],
email: [
{required: true, message: 'Please input email', trigger: 'blur'},
{type: 'email', message: 'Please input a valid email', trigger: ['blur', 'change']}
],
code: [
{required: true, message: 'Please input verification code', trigger: 'blur'}
]
}
function askCode(): void {
get(
`/api/auth/ask-code?email=${form.email}&type=register`,
(response: unknown) => {
coldTime.value = 60;
startColdTime();
ElMessage.success(`Verification code sent to ${form.email}`);
console.log(response);
},
(err: unknown) => {
coldTime.value = 0;
ElMessage.error("Email address is invalid or already registered");
console.log(err);
}
);
}
function register(): void {
formRef.value?.validate((valid: boolean) => {
if (valid) {
post(
"/api/auth/register",
{...form},
() => {
ElMessage.success("Registration successfully!");
router.push("/");
},
(err: unknown) => {
ElMessage.error("Please check your information and try again.");
console.log(err);
}
);
}
});
}
</script>
<template>
<div>
<div style="text-align: center;margin: 32px">
<div style="margin-top: 169px; margin-bottom: 50px">
<div style="font-size: 28px;font-weight: bold">Register</div>
<div style="font-size: 14px;color: slategray">welcome to join us!</div>
</div>
<div style="margin: 32px;">
<el-form :model="form" :rules="rule" ref="formRef">
<el-form-item prop="username">
<el-input v-model="form.username" maxlength="16" type="text" placeholder="Username">
<template #prefix>
<el-icon>
<User/>
</el-icon>
</template>
</el-input>
</el-form-item>
<el-form-item prop="password">
<el-input v-model="form.password" maxlength="20" type="password" placeholder="Password">
<template #prefix>
<el-icon>
<Lock/>
</el-icon>
</template>
</el-input>
</el-form-item>
<el-form-item prop="passwordRepeat">
<el-input v-model="form.passwordRepeat" maxlength="20" type="password" placeholder="Repeat password">
<template #prefix>
<el-icon>
<Lock/>
</el-icon>
</template>
</el-input>
</el-form-item>
<el-form-item prop="email">
<el-input v-model="form.email" type="text" placeholder="Email">
<template #prefix>
<el-icon>
<Message/>
</el-icon>
</template>
</el-input>
</el-form-item>
<el-form-item prop="code">
<el-row justify="space-between">
<el-col :span="11">
<el-input v-model="form.code" maxlength="6" type="text" placeholder="Verify code">
<template #prefix>
<el-icon>
<Bell/>
</el-icon>
</template>
</el-input>
</el-col>
<el-col :span="10">
<el-button @click="askCode" :disabled="coldTime" type="success" plain style="width: 100%">
{{ coldTime > 0 ? `Wait for ${coldTime}s` : 'Get Code' }}
</el-button>
</el-col>
</el-row>
</el-form-item>
</el-form>
</div>
<div style="margin-top: 24px">
<el-button @click="register" style="width: 280px" type="primary">Register</el-button>
</div>
<el-divider>
<span style="font-size: 10px;color: slategray">already have a account?</span>
</el-divider>
<div style="margin-top: 16px">
<el-button @click="router.push('/')" style="width: 280px" type="default">
Sign in
</el-button>
</div>
</div>
</div>
</template>
<style scoped>
</style>
+220
View File
@@ -0,0 +1,220 @@
<script setup lang="ts">
import {reactive, ref} from "vue";
import {Bell, Lock, Message} from "@element-plus/icons-vue";
import {get, post} from "../../api";
import {ElMessage} from "element-plus";
import router from "../../router/index.js";
const activeStep = ref(0);
const coldTime = ref(0);
const formRef = ref()
const isCounting = ref(false)
let timer: number | null = null;
const form = reactive({
password: '',
passwordRepeat: '',
email: '',
code: ''
})
const startColdTime = () => {
isCounting.value = true
coldTime.value = 60
timer = setInterval(() => {
coldTime.value--
if (coldTime.value <= 0) {
if (timer) {
clearInterval(timer);
}
timer = null
isCounting.value = false
coldTime.value = 0
}
}, 1000)
}
const validatePasswordRepeat = (_rule: any, value: string, callback: (err?: Error) => void): void => {
if (value === "") {
callback(new Error("Please repeat password"));
} else if (value !== form.password) {
callback(new Error("Passwords do not match"));
} else {
callback();
}
};
const rule = {
password: [
{required: true, message: 'Please input password', trigger: 'blur'},
{min: 6, max: 20, message: 'Length should be 6 to 20', trigger: ['blur', 'change']}
],
passwordRepeat: [
{validator: validatePasswordRepeat, trigger: ['blur', 'change']},
],
email: [
{required: true, message: 'Please input email', trigger: 'blur'},
{type: 'email', message: 'Please input a valid email', trigger: ['blur', 'change']}
],
code: [
{required: true, message: 'Please input verification code', trigger: 'blur'}
]
}
function askCode() {
get(
`/api/auth/ask-code?email=${form.email}&type=reset`,
(response) => {
coldTime.value = 60;
startColdTime();
ElMessage.success(`Verification code sent to ${form.email}`);
console.log(response);
},
(err) => {
coldTime.value = 0;
ElMessage.error('Email address is invalid or already registered');
console.log(err);
});
}
function resetPassword() {
formRef.value.validate((valid: boolean) => {
if (valid) {
post(
'/api/auth/reset',
{...form},
() => {
ElMessage.success('Reset successfully!')
router.push('/')
},
(err) => {
ElMessage.error('Please check your information and try again.')
console.log(err)
}
)
}
}
)
}
</script>
<template>
<div style="text-align: center;margin: 32px">
<div class="title">
Reset Password
</div>
<div style="margin: 32px">
<el-steps :active="activeStep" finish-status="success" align-center>
<el-step title="Verify Email"/>
<el-step title="Reset Password"/>
</el-steps>
</div>
<div style="margin: 32px" v-if="activeStep === 0">
<div class="description">
Please enter your email address.
</div>
<el-form :model="form" :rules="rule" ref="formRef">
<el-form-item prop="email">
<el-input v-model="form.email" type="text" placeholder="Email">
<template #prefix>
<el-icon>
<Message/>
</el-icon>
</template>
</el-input>
</el-form-item>
<el-form-item prop="code">
<el-row justify="space-between">
<el-col :span="11">
<el-input v-model="form.code" maxlength="6" type="text" placeholder="Verify code">
<template #prefix>
<el-icon>
<Bell/>
</el-icon>
</template>
</el-input>
</el-col>
<el-col :span="10">
<el-button @click="askCode" :disabled="coldTime" type="success" plain style="width: 100%">
{{ coldTime > 0 ? `Wait for ${coldTime}s` : 'Get Code' }}
</el-button>
</el-col>
</el-row>
</el-form-item>
</el-form>
</div>
<div style="margin: 32px" v-if="activeStep === 1">
<div class="description">
Please enter new password.
</div>
<el-form :model="form" :rules="rule" ref="formRef">
<el-form-item prop="password">
<el-input v-model="form.password" maxlength="20" type="password" placeholder="Password">
<template #prefix>
<el-icon>
<Lock/>
</el-icon>
</template>
</el-input>
</el-form-item>
<el-form-item prop="passwordRepeat">
<el-input v-model="form.passwordRepeat" maxlength="20" type="password" placeholder="Repeat password">
<template #prefix>
<el-icon>
<Lock/>
</el-icon>
</template>
</el-input>
</el-form-item>
</el-form>
</div>
<div style="margin-top: 24px" v-if="activeStep === 0">
<el-button @click="activeStep++" :disabled="!(form.code.toString().length === 6)" style="width: 280px"
type="primary">
Go Next Step
</el-button>
</div>
<div style="margin-top: 24px" v-if="activeStep === 1">
<el-button @click="resetPassword" style="width: 280px" type="primary">
Reset
</el-button>
</div>
<el-divider>
<span style="font-size: 10px;color: slategray">Wanna go back?</span>
</el-divider>
<div style="margin-top: 24px" v-if="activeStep === 0">
<el-button @click="router.push('/')" style="width: 280px" type="default">
Go Back
</el-button>
</div>
<div style="margin-top: 24px" v-if="activeStep === 1">
<el-button @click="activeStep--" style="width: 280px" type="default">
Go Last Step
</el-button>
</div>
</div>
</template>
<
<style scoped>
.title {
font-size: 28px;
font-weight: bold;
margin-top: 169px
}
.description {
font-size: 14px;
color: slategray;
margin-bottom: 32px
}
</style>
+16
View File
@@ -0,0 +1,16 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"types": ["vite/client"],
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+26
View File
@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}
+23
View File
@@ -0,0 +1,23 @@
import { defineConfig } from 'vite'
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
base: "./",
server: {
host: '0.0.0.0',
port: 5173,
allowedHosts: true
},
plugins: [
vue(),
AutoImport({
resolvers: [ElementPlusResolver()],
}),
Components({
resolvers: [ElementPlusResolver()],
}),
],
})