@@ -25,6 +25,9 @@ import java.util.Set;
import java.util.UUID ;
import java.util.concurrent.atomic.AtomicBoolean ;
import org.flywaydb.core.Flyway ;
import org.flywaydb.core.api.FlywayException ;
import org.slf4j.Logger ;
import org.slf4j.LoggerFactory ;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty ;
import org.springframework.jdbc.core.JdbcTemplate ;
import org.springframework.jdbc.datasource.DataSourceTransactionManager ;
@@ -37,8 +40,12 @@ import org.springframework.transaction.support.TransactionTemplate;
@ConditionalOnProperty ( name = " finance.setup.enabled " , havingValue = " true " )
public class SetupService {
private static final Logger LOGGER = LoggerFactory . getLogger ( SetupService . class ) ;
private static final String MYSQL_PREFIX = " jdbc:mysql:// " ;
private static final List < String > SUPPORTED_TYPES = List . of ( DatabaseType . MYSQL . name ( ) ) ;
private static final Set < Integer > MYSQL_PERMISSION_ERROR_CODES = Set . of ( 1044 , 1045 , 1142 , 1227 , 1370 ) ;
private static final long RESTART_TRIGGER_DELAY_MILLIS = 2_000L ;
private static final int LOGIN_REDIRECT_DELAY_SECONDS = 10 ;
private final SetupProperties properties ;
private final AtomicBoolean locked = new AtomicBoolean ( false ) ;
@@ -56,7 +63,7 @@ public class SetupService {
public SetupViews . Status status ( ) {
boolean isLocked = locked . get ( ) | | Files . exists ( Path . of ( properties . markerFile ( ) ) ) ;
return new SetupViews . Status ( ! isLocked , isLocked , SUPPORTED_TYPES ,
return new SetupViews . Status ( ! isLocked , isLocked , false , SUPPORTED_TYPES ,
isLocked ? " 安装向导已锁定 " : " 请完成数据库和管理员初始化 " ) ;
}
@@ -74,8 +81,9 @@ public class SetupService {
false , " PostgreSQL 连接可用,但当前 Preview 的业务迁移仅支持 MySQL 8.4 " ) ;
}
ConnectionResult connection = testConnection ( settings ) ;
validateSchemaForInstallation ( settings ) ;
return new SetupViews . Connection ( true , settings . type ( ) . name ( ) , connection . version ( ) , true ,
" MySQL 8.4 连接和 DDL/DML 权限验证通过 " ) ;
" MySQL 8.4 连接和完整迁移 权限验证通过 " ) ;
}
public SetupViews . Completed complete ( SetupContracts . CompleteRequest request ) {
@@ -106,7 +114,7 @@ public class SetupService {
if ( properties . restartAfterComplete ( ) ) {
Thread restart = new Thread ( ( ) - > {
try {
Thread . sleep ( 750L ) ;
Thread . sleep ( RESTART_TRIGGER_DELAY_MILLIS ) ;
} catch ( InterruptedException exception ) {
Thread . currentThread ( ) . interrupt ( ) ;
}
@@ -116,18 +124,13 @@ public class SetupService {
restart . start ( ) ;
}
return new SetupViews . Completed ( true , request . adminUsername ( ) ,
" 安装完成,系统正在切换到正式模式 " , 10 ) ;
" 安装完成,系统正在切换到正式模式 " , LOGIN_REDIRECT_DELAY_SECONDS ) ;
}
private void migrateAndCreateAdministrator ( DatabaseSettings settings , SetupContracts . CompleteRequest request ) {
try {
ensureCompatibleSchema ( settings ) ;
Flyway . configure ( )
. dataSource ( settings . jdbcUrl ( ) , settings . username ( ) , settings . password ( ) )
. locations ( " classpath:db/migration " )
. cleanDisabled ( true )
. load ( )
. migrate ( ) ;
validateSchemaForInstallation ( settings ) ;
flyway ( settings ) . migrate ( ) ;
DriverManagerDataSource dataSource = new DriverManagerDataSource ( settings . jdbcUrl ( ) , settings . username ( ) ,
settings . password ( ) ) ;
JdbcTemplate jdbc = new JdbcTemplate ( dataSource ) ;
@@ -137,12 +140,13 @@ public class SetupService {
if ( exception instanceof SetupException setupException ) {
throw setupException ;
}
LOGGER . error ( " Database migration or administrator initialization failed " , exception ) ;
throw new SetupException ( org . springframework . http . HttpStatus . UNPROCESSABLE_ENTITY ,
" DATABASE_INITIALIZATION_FAILED " , " 数据库迁移或管理员初始化失败,请检查账号的 DDL/DML 权限 " ) ;
" DATABASE_INITIALIZATION_FAILED " , databaseInitializationFailureMessage ( exception ) ) ;
}
}
private void ensureCompatibleSchema ( DatabaseSettings settings ) {
private boolean ensureCompatibleSchema ( DatabaseSettings settings ) {
JdbcTemplate jdbc = new JdbcTemplate ( new DriverManagerDataSource ( settings . jdbcUrl ( ) , settings . username ( ) ,
settings . password ( ) ) ) ;
Integer tableCount = jdbc . queryForObject ( """
@@ -160,7 +164,42 @@ public class SetupService {
throw new SetupException ( org . springframework . http . HttpStatus . UNPROCESSABLE_ENTITY ,
" DATABASE_NOT_EMPTY " , " 请选择空数据库,或提供已有 Kaidi Flyway 数据库 " ) ;
}
return true ;
}
return false ;
}
private void validateSchemaForInstallation ( DatabaseSettings settings ) {
try {
if ( ! ensureCompatibleSchema ( settings ) ) {
return ;
}
flyway ( settings ) . validate ( ) ;
} catch ( SetupException exception ) {
throw exception ;
} catch ( FlywayException exception ) {
LOGGER . error ( " Database migration history validation failed " , exception ) ;
throw new SetupException ( org . springframework . http . HttpStatus . UNPROCESSABLE_ENTITY ,
" DATABASE_MIGRATION_STATE_INVALID " ,
" 数据库迁移历史校验失败,请使用空数据库,或先修复已有迁移状态: " + safeErrorMessage ( exception ) ) ;
} catch ( RuntimeException exception ) {
LOGGER . error ( " Database schema inspection failed " , exception ) ;
SQLException sqlException = findSqlException ( exception ) ;
String detail = sqlException = = null
? " 数据库结构检查失败: " + safeErrorMessage ( exception )
: databaseConnectionFailureMessage ( sqlException ) ;
throw new SetupException ( org . springframework . http . HttpStatus . UNPROCESSABLE_ENTITY ,
" DATABASE_SCHEMA_INSPECTION_FAILED " , detail ) ;
}
}
private Flyway flyway ( DatabaseSettings settings ) {
return Flyway . configure ( )
. dataSource ( settings . jdbcUrl ( ) , settings . username ( ) , settings . password ( ) )
. locations ( " classpath:db/migration " )
. ignoreMigrationPatterns ( " *:pending " )
. cleanDisabled ( true )
. load ( ) ;
}
private void initializeInstallation ( JdbcTemplate jdbc , SetupContracts . CompleteRequest request ) {
@@ -280,18 +319,148 @@ public class SetupService {
throw new SetupException ( org . springframework . http . HttpStatus . UNPROCESSABLE_ENTITY ,
" MYSQL_VERSION_UNSUPPORTED " , " 当前版本要求 MySQL 8.4.x,检测到 " + version ) ;
}
String probe = " kaidi_setup_probe_ " + UUID . randomUUID ( ) . toString ( ) . replace ( " - " , " " ) ;
try {
statement . execute ( " CREATE TABLE " + probe + " (id INT NOT NULL PRIMARY KEY) " ) ;
statement . execute ( " INSERT INTO " + probe + " (id) VALUES (1) " ) ;
statement . execute ( " UPDATE " + probe + " SET id = 2 WHERE id = 1 " ) ;
} finally {
statement . execute ( " DROP TABLE IF EXISTS " + probe ) ;
}
verifyMigrationPrivileges ( connection , statement , settings . type ( ) ) ;
return new ConnectionResult ( true , version ) ;
} catch ( SQLException exception ) {
LOGGER . warn ( " Database connection or migration privilege verification failed: SQL state={}, errorCode={} " ,
exception . getSQLState ( ) , exception . getErrorCode ( ) , exception ) ;
throw new SetupException ( org . springframework . http . HttpStatus . UNPROCESSABLE_ENTITY ,
" DATABASE_CONNECTION_FAILED " , " 数据库连接或 DDL/DML 权限验证失败 " ) ;
" DATABASE_CONNECTION_FAILED " , databaseConnectionFailureMessage ( exception ) ) ;
}
}
private String databaseConnectionFailureMessage ( SQLException exception ) {
if ( isPermissionError ( exception ) ) {
return " 数据库账号缺少完整迁移权限 " + sqlErrorSummary ( exception ) ;
}
if ( exception . getSQLState ( ) ! = null & & exception . getSQLState ( ) . startsWith ( " 08 " ) ) {
return " 数据库连接超时或中断 " + sqlErrorSummary ( exception ) ;
}
return " 数据库连接或完整迁移权限验证失败 " + sqlErrorSummary ( exception ) ;
}
private String databaseInitializationFailureMessage ( Throwable exception ) {
SQLException sqlException = findSqlException ( exception ) ;
if ( sqlException ! = null ) {
if ( isPermissionError ( sqlException ) ) {
return " 数据库账号缺少完成迁移所需权限 " + sqlErrorSummary ( sqlException ) ;
}
if ( sqlException . getSQLState ( ) ! = null & & sqlException . getSQLState ( ) . startsWith ( " 08 " ) ) {
return " 数据库迁移期间连接超时或中断 " + sqlErrorSummary ( sqlException ) ;
}
return " 数据库迁移或管理员初始化失败 " + sqlErrorSummary ( sqlException ) ;
}
return " 数据库迁移或管理员初始化失败: " + safeErrorMessage ( exception ) ;
}
private SQLException findSqlException ( Throwable exception ) {
Throwable current = exception ;
while ( current ! = null ) {
if ( current instanceof SQLException sqlException ) {
return sqlException ;
}
current = current . getCause ( ) ;
}
return null ;
}
private boolean isPermissionError ( SQLException exception ) {
return MYSQL_PERMISSION_ERROR_CODES . contains ( exception . getErrorCode ( ) ) ;
}
private String sqlErrorSummary ( SQLException exception ) {
String state = exception . getSQLState ( ) = = null ? " UNKNOWN " : exception . getSQLState ( ) ;
return " ( SQLState " + state + " ,错误码 " + exception . getErrorCode ( ) + " ): "
+ safeErrorMessage ( exception ) ;
}
private String safeErrorMessage ( Throwable exception ) {
Throwable current = exception ;
while ( current . getCause ( ) ! = null ) {
current = current . getCause ( ) ;
}
String message = current . getMessage ( ) = = null ? current . getClass ( ) . getSimpleName ( ) : current . getMessage ( ) ;
String sanitized = message . replaceAll ( " (?i)(password|pwd)=([^ \\ s&;]+) " , " $1=*** " )
. replaceAll ( " \\ s+ " , " " ) . trim ( ) ;
return sanitized . length ( ) < = 240 ? sanitized : sanitized . substring ( 0 , 240 ) + " ... " ;
}
private void verifyMigrationPrivileges ( Connection connection , Statement statement , DatabaseType databaseType )
throws SQLException {
String suffix = UUID . randomUUID ( ) . toString ( ) . replace ( " - " , " " ) ;
String parentTable = " kaidi_setup_probe_parent_ " + suffix ;
String childTable = " kaidi_setup_probe_child_ " + suffix ;
String foreignKey = " fk_setup_probe_ " + suffix ;
String index = " idx_setup_probe_ " + suffix ;
String routine = " kaidi_setup_probe_routine_ " + suffix ;
String temporaryTable = " kaidi_setup_probe_temp_ " + suffix ;
try {
statement . execute ( " CREATE TABLE " + parentTable + " (id INT NOT NULL PRIMARY KEY) " ) ;
if ( databaseType = = DatabaseType . MYSQL ) {
statement . execute ( " CREATE TABLE " + childTable
+ " (id INT NOT NULL PRIMARY KEY, parent_id INT NOT NULL, CONSTRAINT " + foreignKey
+ " FOREIGN KEY (parent_id) REFERENCES " + parentTable + " (id)) " ) ;
statement . execute ( " ALTER TABLE " + childTable + " ADD COLUMN note VARCHAR(32) NULL " ) ;
statement . execute ( " CREATE INDEX " + index + " ON " + childTable + " (parent_id) " ) ;
statement . execute ( " CREATE PROCEDURE " + routine + " () SELECT 1 AS probe_value " ) ;
try ( Statement call = connection . createStatement ( ) ;
ResultSet result = call . executeQuery ( " CALL " + routine + " () " ) ) {
if ( ! result . next ( ) | | result . getInt ( 1 ) ! = 1 ) {
throw new SQLException ( " Migration privilege routine probe returned an invalid result " ) ;
}
}
statement . execute ( " INSERT INTO " + parentTable + " (id) VALUES (1) " ) ;
statement . execute ( " INSERT INTO " + childTable + " (id, parent_id, note) VALUES (1, 1, 'probe') " ) ;
try ( ResultSet result = statement . executeQuery ( " SELECT note FROM " + childTable + " WHERE id = 1 " ) ) {
if ( ! result . next ( ) | | ! " probe " . equals ( result . getString ( 1 ) ) ) {
throw new SQLException ( " Migration privilege SELECT probe returned an invalid result " ) ;
}
}
statement . execute ( " CREATE TEMPORARY TABLE " + temporaryTable
+ " (id INT NOT NULL PRIMARY KEY) " ) ;
statement . execute ( " INSERT INTO " + temporaryTable + " (id) VALUES (1) " ) ;
statement . execute ( " DROP TEMPORARY TABLE " + temporaryTable ) ;
statement . execute ( " UPDATE " + childTable + " SET note = 'verified' WHERE id = 1 " ) ;
statement . execute ( " DELETE FROM " + childTable + " WHERE id = 1 " ) ;
statement . execute ( " DELETE FROM " + parentTable + " WHERE id = 1 " ) ;
} else {
statement . execute ( " INSERT INTO " + parentTable + " (id) VALUES (1) " ) ;
statement . execute ( " UPDATE " + parentTable + " SET id = 2 WHERE id = 1 " ) ;
statement . execute ( " DELETE FROM " + parentTable + " WHERE id = 2 " ) ;
}
} catch ( SQLException exception ) {
cleanupProbeObjects ( statement , databaseType , routine , temporaryTable , childTable , parentTable , exception ) ;
throw exception ;
}
cleanupProbeObjects ( statement , databaseType , routine , temporaryTable , childTable , parentTable , null ) ;
}
private void cleanupProbeObjects ( Statement statement , DatabaseType databaseType , String routine ,
String temporaryTable , String childTable , String parentTable ,
SQLException original )
throws SQLException {
SQLException cleanupFailure = null ;
List < String > cleanupStatements = databaseType = = DatabaseType . MYSQL
? List . of ( " DROP PROCEDURE IF EXISTS " + routine , " DROP TEMPORARY TABLE IF EXISTS " + temporaryTable ,
" DROP TABLE IF EXISTS " + childTable , " DROP TABLE IF EXISTS " + parentTable )
: List . of ( " DROP TABLE IF EXISTS " + childTable , " DROP TABLE IF EXISTS " + parentTable ) ;
for ( String sql : cleanupStatements ) {
try {
statement . execute ( sql ) ;
} catch ( SQLException exception ) {
if ( cleanupFailure = = null ) {
cleanupFailure = exception ;
} else {
cleanupFailure . addSuppressed ( exception ) ;
}
}
}
if ( cleanupFailure ! = null ) {
if ( original ! = null ) {
original . addSuppressed ( cleanupFailure ) ;
} else {
throw cleanupFailure ;
}
}
}