mirror of
https://github.com/SOR2171/Springboot4-Vue3_Demo.git
synced 2026-10-23 11:31:10 +08:00
git init
This commit is contained in:
Generated
+10
@@ -0,0 +1,10 @@
|
||||
# 默认忽略的文件
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# 基于编辑器的 HTTP 客户端请求
|
||||
/httpRequests/
|
||||
# 已忽略包含查询文件的默认文件夹
|
||||
/queries/
|
||||
# Datasource local storage ignored files
|
||||
/dataSources/
|
||||
/dataSources.local.xml
|
||||
Generated
+151
@@ -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>
|
||||
Generated
+9
@@ -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>
|
||||
Generated
+20
@@ -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
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="Ask2AgentMigrationStateService">
|
||||
<option name="migrationStatus" value="COMPLETED" />
|
||||
</component>
|
||||
</project>
|
||||
Generated
+16
@@ -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>
|
||||
Generated
+7
@@ -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>
|
||||
Generated
+7
@@ -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>
|
||||
Generated
+8
@@ -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>
|
||||
Generated
+6
@@ -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
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
|
||||
```
|
||||
@@ -0,0 +1,3 @@
|
||||
/gradlew text eol=lf
|
||||
*.bat text eol=crlf
|
||||
*.jar binary
|
||||
@@ -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
@@ -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!
|
||||
```
|
||||
@@ -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")
|
||||
}
|
||||
BIN
Binary file not shown.
@@ -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
|
||||
@@ -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
|
||||
)
|
||||
+19
@@ -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 = "",
|
||||
)
|
||||
+15
@@ -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>?
|
||||
}
|
||||
+209
@@ -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"))
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
@@ -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).
|
||||
Vendored
+10
@@ -0,0 +1,10 @@
|
||||
/* eslint-disable */
|
||||
/* prettier-ignore */
|
||||
// @ts-nocheck
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
// Generated by unplugin-auto-import
|
||||
// biome-ignore lint: disable
|
||||
export {}
|
||||
declare global {
|
||||
|
||||
}
|
||||
Vendored
+51
@@ -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']
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Generated
+3437
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 |
@@ -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>
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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')
|
||||
@@ -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
|
||||
Vendored
+15
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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()],
|
||||
}),
|
||||
],
|
||||
})
|
||||
Reference in New Issue
Block a user