diff --git a/bin/prod/deploy_from_package.sh b/bin/prod/deploy_from_package.sh new file mode 100755 index 0000000..39fe37a --- /dev/null +++ b/bin/prod/deploy_from_package.sh @@ -0,0 +1,257 @@ +#!/bin/sh + +set -eu + +JAVA_BIN="/home/webapp/env/java/bin/java" +BACKEND_PORT=63310 +SPRING_PROFILE="uat" +JAVA_OPTS="-Duser.timezone=Asia/Shanghai -Xms512m -Xmx1024m -XX:MetaspaceSize=128m -XX:MaxMetaspaceSize=512m -XX:+HeapDumpOnOutOfMemoryError" + +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname "$0")" && pwd) +BACKEND_DIR="$SCRIPT_DIR/backend" +FRONTEND_DIR="$SCRIPT_DIR/frontend" +BACKEND_JAR_TARGET="$BACKEND_DIR/ruoyi-admin.jar" +BACKEND_PID_FILE="$BACKEND_DIR/backend.pid" +BACKEND_LOG_FILE="$BACKEND_DIR/backend-console.log" +FRONTEND_DIST_ARCHIVE="$FRONTEND_DIR/dist.zip" +FRONTEND_DIST_DIR="$FRONTEND_DIR/dist" +BACKEND_MARKER="-Dloan.pricing.home=$SCRIPT_DIR" + +usage() { + cat <<'EOF' +用法: + ./deploy_from_package.sh +EOF +} + +timestamp() { + date "+%Y%m%d%H%M%S" +} + +log_info() { + printf '[%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$1" +} + +log_error() { + printf '[%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$1" >&2 +} + +cleanup() { + if [ -n "${WORK_DIR:-}" ] && [ -d "$WORK_DIR" ]; then + rm -rf "$WORK_DIR" + fi +} + +require_dir() { + if [ ! -d "$1" ]; then + log_error "缺少目录: $1" + exit 1 + fi +} + +require_command() { + if ! command -v "$1" >/dev/null 2>&1; then + log_error "缺少命令: $1" + exit 1 + fi +} + +find_release_archive() { + archives=$(find "$SCRIPT_DIR" -maxdepth 1 -type f -name '*.zip' ! -name 'dist.zip') + count=$(printf '%s\n' "$archives" | sed '/^$/d' | wc -l | tr -d ' ') + + if [ "$count" -ne 1 ]; then + log_error "脚本同目录发布 zip 数量不正确,期望 1 个,实际 $count 个" + exit 1 + fi + + printf '%s\n' "$archives" +} + +extract_release_package() { + release_archive="$1" + release_extract_dir="$2" + + mkdir -p "$release_extract_dir" + unzip -oq "$release_archive" -d "$release_extract_dir" +} + +assert_single_jar() { + search_dir="$1" + count=$(find "$search_dir" -type f -name '*.jar' ! -path '*/__MACOSX/*' ! -name '._*' | wc -l | tr -d ' ') + + if [ "$count" -ne 1 ]; then + log_error "后端 jar 数量不正确,期望 1 个,实际 $count 个" + exit 1 + fi + + find "$search_dir" -type f -name '*.jar' ! -path '*/__MACOSX/*' ! -name '._*' | head -n 1 +} + +assert_single_dist_zip() { + search_dir="$1" + count=$(find "$search_dir" -type f -name 'dist.zip' ! -path '*/__MACOSX/*' ! -name '._*' | wc -l | tr -d ' ') + + if [ "$count" -ne 1 ]; then + log_error "前端 dist.zip 数量不正确,期望 1 个,实际 $count 个" + exit 1 + fi + + find "$search_dir" -type f -name 'dist.zip' ! -path '*/__MACOSX/*' ! -name '._*' | head -n 1 +} + +backup_backend_jar() { + if [ -f "$BACKEND_JAR_TARGET" ]; then + mv "$BACKEND_JAR_TARGET" "$BACKEND_JAR_TARGET.$(timestamp).bak" + fi +} + +backup_frontend_dist() { + if [ -d "$FRONTEND_DIST_DIR" ]; then + mv "$FRONTEND_DIST_DIR" "$FRONTEND_DIR/dist-$(timestamp)" + fi +} + +deploy_backend_jar() { + source_jar="$1" + mv "$source_jar" "$BACKEND_JAR_TARGET" +} + +deploy_frontend_dist() { + source_dist_zip="$1" + rm -f "$FRONTEND_DIST_ARCHIVE" + rm -rf "$FRONTEND_DIST_DIR" + mv "$source_dist_zip" "$FRONTEND_DIST_ARCHIVE" + unzip -oq "$FRONTEND_DIST_ARCHIVE" -d "$FRONTEND_DIR" + + if [ ! -d "$FRONTEND_DIST_DIR" ]; then + log_error "dist.zip 解压后未找到 $FRONTEND_DIST_DIR" + exit 1 + fi +} + +collect_backend_pids() { + ps -ef | awk -v marker="$BACKEND_MARKER" -v jar="$BACKEND_JAR_TARGET" ' + index($0, "") == 0 && index($0, marker) > 0 { + for (i = 1; i < NF; i++) { + if ($i == "-jar" && $(i + 1) == jar) { + print $2 + break + } + } + } + ' | xargs 2>/dev/null || true +} + +stop_backend() { + pids=$(collect_backend_pids) + + if [ -z "${pids:-}" ]; then + rm -f "$BACKEND_PID_FILE" + log_info "未发现运行中的后端进程" + return 0 + fi + + log_info "停止后端进程: $pids" + for pid in $pids; do + kill -TERM "$pid" 2>/dev/null || true + done + + elapsed=0 + remaining="$pids" + while [ "$elapsed" -lt 30 ]; do + remaining="" + for pid in $pids; do + if kill -0 "$pid" 2>/dev/null; then + remaining="$remaining $pid" + fi + done + + remaining=$(echo "$remaining" | xargs 2>/dev/null || true) + if [ -z "${remaining:-}" ]; then + break + fi + + sleep 1 + elapsed=$((elapsed + 1)) + done + + if [ -n "${remaining:-}" ]; then + log_info "执行强制停止: $remaining" + for pid in $remaining; do + kill -KILL "$pid" 2>/dev/null || true + done + fi + + rm -f "$BACKEND_PID_FILE" +} + +start_backend() { + if [ ! -x "$JAVA_BIN" ]; then + log_error "未检测到可执行 Java: $JAVA_BIN" + exit 1 + fi + + if [ ! -f "$BACKEND_JAR_TARGET" ]; then + log_error "未找到后端 jar: $BACKEND_JAR_TARGET" + exit 1 + fi + + if [ -n "$(collect_backend_pids)" ]; then + log_error "检测到后端已在运行,请先停止旧进程" + exit 1 + fi + + printf '\n===== %s deploy =====\n' "$(date '+%Y-%m-%d %H:%M:%S')" >> "$BACKEND_LOG_FILE" + + nohup "$JAVA_BIN" $JAVA_OPTS "$BACKEND_MARKER" -jar "$BACKEND_JAR_TARGET" \ + --spring.profiles.active="$SPRING_PROFILE" \ + --server.port="$BACKEND_PORT" >> "$BACKEND_LOG_FILE" 2>&1 & + backend_pid=$! + printf '%s\n' "$backend_pid" > "$BACKEND_PID_FILE" + + sleep 1 + + if ! kill -0 "$backend_pid" 2>/dev/null; then + log_error "后端启动失败,请检查日志: $BACKEND_LOG_FILE" + exit 1 + fi + + log_info "后端已启动,PID: $backend_pid" +} + +main() { + if [ "$#" -ne 0 ]; then + usage + exit 1 + fi + + require_dir "$BACKEND_DIR" + require_dir "$FRONTEND_DIR" + require_command unzip + require_command find + require_command ps + require_command nohup + + release_archive=$(find_release_archive) + WORK_DIR=$(mktemp -d "${TMPDIR:-/tmp}/deploy_from_package.XXXXXX") + trap cleanup EXIT INT TERM + + extract_release_package "$release_archive" "$WORK_DIR/package" + + backend_jar_source=$(assert_single_jar "$WORK_DIR/package") + frontend_dist_source=$(assert_single_dist_zip "$WORK_DIR/package") + + backup_backend_jar + backup_frontend_dist + stop_backend + deploy_backend_jar "$backend_jar_source" + deploy_frontend_dist "$frontend_dist_source" + start_backend + + log_info "部署完成" + log_info "后端 jar: $BACKEND_JAR_TARGET" + log_info "前端目录: $FRONTEND_DIST_DIR" +} + +main "$@" diff --git a/bin/prod/deploy_from_package_test.sh b/bin/prod/deploy_from_package_test.sh new file mode 100755 index 0000000..e86ad09 --- /dev/null +++ b/bin/prod/deploy_from_package_test.sh @@ -0,0 +1,262 @@ +#!/bin/sh + +set -eu + +ROOT_DIR=$(CDPATH= cd -- "$(dirname "$0")/../.." && pwd) +SCRIPT_UNDER_TEST="$ROOT_DIR/bin/prod/deploy_from_package.sh" + +fail() { + printf 'FAIL: %s\n' "$1" >&2 + exit 1 +} + +assert_file_exists() { + file_path="$1" + [ -e "$file_path" ] || fail "expected file to exist: $file_path" +} + +assert_grep() { + pattern="$1" + target="$2" + if ! grep -Eq "$pattern" "$target"; then + fail "expected pattern [$pattern] in $target" + fi +} + +create_fake_java() { + fake_java="$1" + + cat > "$fake_java" <<'EOF' +#!/bin/sh +set -eu + +port="" +for arg in "$@"; do + case "$arg" in + --server.port=*) + port=${arg#--server.port=} + ;; + esac +done + +if [ -z "$port" ]; then + echo "missing port" >&2 + exit 1 +fi + +while :; do + sleep 1 +done +EOF + + chmod +x "$fake_java" +} + +create_release_zip() { + release_dir="$1" + release_zip_name="$2" + + mkdir -p "$release_dir/package/deploy" "$release_dir/package/__MACOSX/deploy" + mkdir -p "$release_dir/package/frontend_payload/dist" "$release_dir/package/frontend_payload/__MACOSX/dist" + printf 'new-jar\n' > "$release_dir/package/deploy/ruoyi-admin.jar" + printf 'macos-meta\n' > "$release_dir/package/__MACOSX/deploy/._ruoyi-admin.jar" + printf 'new\n' > "$release_dir/package/frontend_payload/dist/index.html" + printf 'macos-meta\n' > "$release_dir/package/frontend_payload/__MACOSX/dist/._index.html" + ( + cd "$release_dir/package/frontend_payload" + zip -qr "$release_dir/package/dist.zip" dist __MACOSX + ) + mv "$release_dir/package/dist.zip" "$release_dir/package/deploy/dist.zip" + ( + cd "$release_dir/package" + zip -qr "$release_dir/$release_zip_name" deploy __MACOSX + ) +} + +find_free_port() { + python3 - <<'PY' +import socket + +sock = socket.socket() +sock.bind(("127.0.0.1", 0)) +print(sock.getsockname()[1]) +sock.close() +PY +} + +prepare_release_dir() { + release_dir="$1" + backend_port="$2" + + mkdir -p "$release_dir/backend" "$release_dir/frontend" "$release_dir/fake-java-bin" + printf 'old-jar\n' > "$release_dir/backend/ruoyi-admin.jar" + mkdir -p "$release_dir/frontend/dist" + printf 'old\n' > "$release_dir/frontend/dist/index.html" + + create_fake_java "$release_dir/fake-java-bin/java" + create_release_zip "$release_dir" "deploy.zip" + cp "$SCRIPT_UNDER_TEST" "$release_dir/deploy_from_package.sh" + perl -0pi -e "s#JAVA_BIN=\"/home/webapp/env/java/bin/java\"#JAVA_BIN=\"$release_dir/fake-java-bin/java\"#" \ + "$release_dir/deploy_from_package.sh" + perl -0pi -e "s/BACKEND_PORT=63310/BACKEND_PORT=$backend_port/" \ + "$release_dir/deploy_from_package.sh" + chmod +x "$release_dir/deploy_from_package.sh" +} + +cleanup_release_dir() { + release_dir="$1" + + if [ -f "$release_dir/backend/backend.pid" ]; then + backend_pid=$(cat "$release_dir/backend/backend.pid" 2>/dev/null || true) + if [ -n "${backend_pid:-}" ]; then + kill "$backend_pid" 2>/dev/null || true + wait "$backend_pid" 2>/dev/null || true + fi + fi + + rm -rf "$release_dir" +} + +test_deploy_success() { + release_dir=$(mktemp -d) + backend_port=$(find_free_port) + trap 'cleanup_release_dir "$release_dir"' EXIT INT TERM + + prepare_release_dir "$release_dir" "$backend_port" + ( + cd "$release_dir" + ./deploy_from_package.sh + ) + + assert_file_exists "$release_dir/backend/ruoyi-admin.jar" + assert_file_exists "$release_dir/frontend/dist.zip" + assert_file_exists "$release_dir/frontend/dist/index.html" + assert_file_exists "$release_dir/backend/backend.pid" + assert_file_exists "$release_dir/backend/backend-console.log" + assert_grep 'new' "$release_dir/frontend/dist/index.html" + + backup_jar_count=$(find "$release_dir/backend" -maxdepth 1 -type f -name 'ruoyi-admin.jar.*.bak' | wc -l | tr -d ' ') + [ "$backup_jar_count" -eq 1 ] || fail "expected one backup jar, got $backup_jar_count" + + backup_dist_count=$(find "$release_dir/frontend" -maxdepth 1 -type d -name 'dist-*' | wc -l | tr -d ' ') + [ "$backup_dist_count" -eq 1 ] || fail "expected one backup dist dir, got $backup_dist_count" + + backend_pid=$(cat "$release_dir/backend/backend.pid") + kill -0 "$backend_pid" 2>/dev/null || fail "expected backend pid to be running" + + trap - EXIT INT TERM + cleanup_release_dir "$release_dir" +} + +test_multiple_release_zip_should_fail() { + release_dir=$(mktemp -d) + backend_port=$(find_free_port) + trap 'cleanup_release_dir "$release_dir"' EXIT INT TERM + + prepare_release_dir "$release_dir" "$backend_port" + cp "$release_dir/deploy.zip" "$release_dir/deploy-copy.zip" + + if ( + cd "$release_dir" + ./deploy_from_package.sh >/tmp/deploy_from_package_test.stderr 2>&1 + ); then + fail "expected deploy_from_package.sh to fail when multiple release zips exist" + fi + + assert_file_exists /tmp/deploy_from_package_test.stderr + assert_grep '发布 zip 数量不正确' /tmp/deploy_from_package_test.stderr + + rm -f /tmp/deploy_from_package_test.stderr + trap - EXIT INT TERM + cleanup_release_dir "$release_dir" +} + +test_defunct_process_should_be_ignored() { + release_dir=$(mktemp -d) + backend_port=$(find_free_port) + trap 'cleanup_release_dir "$release_dir"' EXIT INT TERM + + prepare_release_dir "$release_dir" "$backend_port" + mkdir -p "$release_dir/fake-ps-bin" + cat > "$release_dir/fake-ps-bin/ps" < -Dloan.pricing.home=$release_dir -jar $release_dir/backend/ruoyi-admin.jar +PSOUT + exit 0 +fi +/bin/ps "\$@" +EOF + chmod +x "$release_dir/fake-ps-bin/ps" + + ( + cd "$release_dir" + PATH="$release_dir/fake-ps-bin:/usr/bin:/bin" ./deploy_from_package.sh + ) + + backend_pid=$(cat "$release_dir/backend/backend.pid") + kill -0 "$backend_pid" 2>/dev/null || fail "expected backend pid to be running when defunct process is ignored" + + trap - EXIT INT TERM + cleanup_release_dir "$release_dir" +} + +test_only_current_project_jar_should_match() { + release_dir=$(mktemp -d) + backend_port=$(find_free_port) + trap 'cleanup_release_dir "$release_dir"' EXIT INT TERM + + prepare_release_dir "$release_dir" "$backend_port" + mkdir -p "$release_dir/fake-ps-bin" + cat > "$release_dir/fake-ps-bin/ps" </dev/null || fail "expected backend pid to be running when non-target jar process is ignored" + + trap - EXIT INT TERM + cleanup_release_dir "$release_dir" +} + +test_should_use_ps_ef_for_process_detection() { + if rg -n 'pgrep' "$SCRIPT_UNDER_TEST" >/dev/null 2>&1; then + fail "expected deploy_from_package.sh not to depend on pgrep" + fi + + if ! rg -n 'ps -ef' "$SCRIPT_UNDER_TEST" >/dev/null 2>&1; then + fail "expected deploy_from_package.sh to use ps -ef for process detection" + fi + + if rg -n '\b(ss|lsof|netstat|resolve_frontend_source_dir|is_port_listening)\b' "$SCRIPT_UNDER_TEST" >/dev/null 2>&1; then + fail "expected deploy_from_package.sh to remove port detection and unzip compatibility helpers" + fi +} + +main() { + [ -f "$SCRIPT_UNDER_TEST" ] || fail "script under test not found: $SCRIPT_UNDER_TEST" + test_should_use_ps_ef_for_process_detection + test_deploy_success + test_multiple_release_zip_should_fail + test_defunct_process_should_be_ignored + test_only_current_project_jar_should_match + printf 'PASS: deploy_from_package tests\n' +} + +main "$@" diff --git a/bin/prod/deploy_release.sh b/bin/prod/deploy_release.sh new file mode 100755 index 0000000..c66b8e1 --- /dev/null +++ b/bin/prod/deploy_release.sh @@ -0,0 +1,245 @@ +#!/bin/sh + +set -eu + +WEBAPP_ROOT="/home/webapp" +ENV_ROOT="$WEBAPP_ROOT/env" +APP_ROOT="$WEBAPP_ROOT/loan-pricing" +JAVA_HOME="$ENV_ROOT/java" +NGINX_HOME="$ENV_ROOT/nginx" +NGINX_CONF="$NGINX_HOME/conf/nginx.conf" +BACKEND_DIR="$APP_ROOT/backend" +FRONTEND_DIR="$APP_ROOT/frontend" +FRONTEND_DIST_DIR="$FRONTEND_DIR/dist" +BACKUP_DIR="$APP_ROOT/backup" +LOG_DIR="$APP_ROOT/logs" +RUN_DIR="$APP_ROOT/run" +TMP_DIR="$APP_ROOT/tmp" +BACKEND_JAR="$BACKEND_DIR/ruoyi-admin.jar" +FRONTEND_PORT=63311 +JAVA_RESTART_SCRIPT="$WEBAPP_ROOT/restart_java.sh" + +timestamp() { + date "+%Y-%m-%d %H:%M:%S" +} + +log_info() { + printf '[%s] %s\n' "$(timestamp)" "$1" +} + +log_error() { + printf '[%s] %s\n' "$(timestamp)" "$1" >&2 +} + +usage() { + cat <<'EOF' +用法: + ./bin/prod/deploy_release.sh <发布压缩包路径> +EOF +} + +require_root() { + if [ "$(id -u)" -ne 0 ]; then + log_error "请使用 root 用户执行部署脚本" + exit 1 + fi +} + +require_command() { + if ! command -v "$1" >/dev/null 2>&1; then + log_error "缺少命令: $1" + exit 1 + fi +} + +ensure_runtime_dirs() { + mkdir -p "$BACKEND_DIR" "$FRONTEND_DIR" "$BACKUP_DIR" "$LOG_DIR" "$RUN_DIR" "$TMP_DIR" +} + +cleanup() { + if [ -n "${WORK_DIR:-}" ] && [ -d "$WORK_DIR" ]; then + rm -rf "$WORK_DIR" + fi +} + +extract_release_package() { + release_archive="$1" + release_extract_dir="$2" + + mkdir -p "$release_extract_dir" + + case "$release_archive" in + *.zip) + unzip -oq "$release_archive" -d "$release_extract_dir" + ;; + *.tar.gz|*.tgz) + tar -xzf "$release_archive" -C "$release_extract_dir" + ;; + *.tar) + tar -xf "$release_archive" -C "$release_extract_dir" + ;; + *) + log_error "不支持的发布包格式: $release_archive" + exit 1 + ;; + esac +} + +assert_single_file() { + search_dir="$1" + file_name="$2" + description="$3" + count=$(find "$search_dir" -type f -name "$file_name" | wc -l | tr -d ' ') + + if [ "$count" -ne 1 ]; then + log_error "$description 数量不正确,期望 1 个,实际 $count 个" + exit 1 + fi + + find "$search_dir" -type f -name "$file_name" | head -n 1 +} + +assert_single_jar() { + search_dir="$1" + count=$(find "$search_dir" -type f -name '*.jar' | wc -l | tr -d ' ') + + if [ "$count" -ne 1 ]; then + log_error "后端 jar 数量不正确,期望 1 个,实际 $count 个" + exit 1 + fi + + find "$search_dir" -type f -name '*.jar' | head -n 1 +} + +backup_current_release() { + backup_stamp=$(date "+%Y%m%d%H%M%S") + CURRENT_BACKUP_DIR="$BACKUP_DIR/$backup_stamp" + + mkdir -p "$CURRENT_BACKUP_DIR/backend" "$CURRENT_BACKUP_DIR/frontend" + + if [ -f "$BACKEND_JAR" ]; then + cp -a "$BACKEND_JAR" "$CURRENT_BACKUP_DIR/backend/" + fi + + if [ -d "$FRONTEND_DIST_DIR" ]; then + cp -a "$FRONTEND_DIST_DIR" "$CURRENT_BACKUP_DIR/frontend/" + fi + + log_info "旧版本已备份到: $CURRENT_BACKUP_DIR" +} + +deploy_backend() { + source_jar="$1" + + rm -f "$BACKEND_DIR"/*.jar + cp "$source_jar" "$BACKEND_JAR" +} + +resolve_frontend_source_dir() { + unzip_dir="$1" + + if [ -f "$unzip_dir/index.html" ]; then + printf '%s\n' "$unzip_dir" + return 0 + fi + + if [ -f "$unzip_dir/dist/index.html" ]; then + printf '%s\n' "$unzip_dir/dist" + return 0 + fi + + candidate=$(find "$unzip_dir" -type f -name 'index.html' | head -n 1) + if [ -z "${candidate:-}" ]; then + log_error "dist.zip 解压后未找到 index.html" + exit 1 + fi + + dirname "$candidate" +} + +deploy_frontend() { + dist_zip="$1" + dist_unpack_dir="$WORK_DIR/frontend" + + mkdir -p "$dist_unpack_dir" + unzip -oq "$dist_zip" -d "$dist_unpack_dir" + + frontend_source_dir=$(resolve_frontend_source_dir "$dist_unpack_dir") + rm -rf "$FRONTEND_DIST_DIR" + mkdir -p "$FRONTEND_DIST_DIR" + cp -a "$frontend_source_dir"/. "$FRONTEND_DIST_DIR"/ +} + +reload_nginx() { + nginx_pid_file="$RUN_DIR/nginx.pid" + + "$NGINX_HOME/sbin/nginx" -t -c "$NGINX_CONF" + + if [ -f "$nginx_pid_file" ]; then + nginx_pid=$(cat "$nginx_pid_file" 2>/dev/null || true) + if [ -n "${nginx_pid:-}" ] && kill -0 "$nginx_pid" 2>/dev/null; then + "$NGINX_HOME/sbin/nginx" -c "$NGINX_CONF" -s reload + log_info "Nginx 已重载,前端端口: $FRONTEND_PORT" + return 0 + fi + fi + + "$NGINX_HOME/sbin/nginx" -c "$NGINX_CONF" + log_info "Nginx 已启动,前端端口: $FRONTEND_PORT" +} + +main() { + if [ "$#" -ne 1 ]; then + usage + exit 1 + fi + + require_root + require_command tar + require_command unzip + require_command find + + release_archive="$1" + if [ ! -f "$release_archive" ]; then + log_error "发布压缩包不存在: $release_archive" + exit 1 + fi + + if [ ! -x "$JAVA_HOME/bin/java" ]; then + log_error "未检测到 Java,请先执行 ./bin/prod/install_env.sh" + exit 1 + fi + + if [ ! -x "$NGINX_HOME/sbin/nginx" ]; then + log_error "未检测到 Nginx,请先执行 ./bin/prod/install_env.sh" + exit 1 + fi + + if [ ! -x "$JAVA_RESTART_SCRIPT" ]; then + log_error "未检测到 Java 重启脚本: $JAVA_RESTART_SCRIPT" + exit 1 + fi + + ensure_runtime_dirs + WORK_DIR=$(mktemp -d "$TMP_DIR/release.XXXXXX") + trap cleanup EXIT INT TERM + + extract_release_package "$release_archive" "$WORK_DIR/package" + + backend_jar_source=$(assert_single_jar "$WORK_DIR/package") + frontend_dist_source=$(assert_single_file "$WORK_DIR/package" 'dist.zip' '前端 dist.zip') + + backup_current_release + "$JAVA_RESTART_SCRIPT" stop + deploy_backend "$backend_jar_source" + deploy_frontend "$frontend_dist_source" + "$JAVA_RESTART_SCRIPT" start + reload_nginx + + log_info "部署完成" + log_info "后端 jar: $BACKEND_JAR" + log_info "前端目录: $FRONTEND_DIST_DIR" + log_info "备份目录: $CURRENT_BACKUP_DIR" +} + +main "$@" diff --git a/bin/prod/install_env.sh b/bin/prod/install_env.sh new file mode 100755 index 0000000..ae0dfc7 --- /dev/null +++ b/bin/prod/install_env.sh @@ -0,0 +1,244 @@ +#!/bin/sh + +set -eu + +WEBAPP_ROOT="/home/webapp" +ENV_ROOT="$WEBAPP_ROOT/env" +APP_ROOT="$WEBAPP_ROOT/loan-pricing" +JAVA_HOME="$ENV_ROOT/java" +NGINX_HOME="$ENV_ROOT/nginx" +NGINX_CONF="$NGINX_HOME/conf/nginx.conf" +BACKEND_DIR="$APP_ROOT/backend" +FRONTEND_DIR="$APP_ROOT/frontend" +BACKUP_DIR="$APP_ROOT/backup" +LOG_DIR="$APP_ROOT/logs" +RUN_DIR="$APP_ROOT/run" +TMP_DIR="$APP_ROOT/tmp" +BACKEND_PORT=63310 +FRONTEND_PORT=63311 + +timestamp() { + date "+%Y-%m-%d %H:%M:%S" +} + +log_info() { + printf '[%s] %s\n' "$(timestamp)" "$1" +} + +log_error() { + printf '[%s] %s\n' "$(timestamp)" "$1" >&2 +} + +require_root() { + if [ "$(id -u)" -ne 0 ]; then + log_error "请使用 root 用户执行安装脚本" + exit 1 + fi +} + +require_command() { + if ! command -v "$1" >/dev/null 2>&1; then + log_error "缺少命令: $1" + exit 1 + fi +} + +ensure_base_dirs() { + mkdir -p "$ENV_ROOT" "$BACKEND_DIR" "$FRONTEND_DIR" "$BACKUP_DIR" "$LOG_DIR" "$RUN_DIR" "$TMP_DIR" +} + +find_archive() { + search_kind="$1" + found="" + + case "$search_kind" in + java) + set -- "$WEBAPP_ROOT/openjdk" "$WEBAPP_ROOT" + patterns='openjdk*.tar.gz openjdk*.tgz jdk*.tar.gz jdk*.tgz' + ;; + nginx) + set -- "$WEBAPP_ROOT/nginx" "$ENV_ROOT" "$WEBAPP_ROOT" + patterns='nginx-*.tar.gz nginx-*.tgz' + ;; + *) + log_error "未知的安装包类型: $search_kind" + exit 1 + ;; + esac + + for dir in "$@"; do + if [ ! -d "$dir" ]; then + continue + fi + + for pattern in $patterns; do + candidate=$(find "$dir" -maxdepth 1 -type f -name "$pattern" | sort | head -n 1) + if [ -n "${candidate:-}" ]; then + found="$candidate" + break + fi + done + + if [ -n "${found:-}" ]; then + break + fi + done + + if [ -z "${found:-}" ]; then + log_error "未找到 $search_kind 安装包" + exit 1 + fi + + printf '%s\n' "$found" +} + +install_yum_dependencies() { + require_command yum + + log_info "安装系统依赖" + yum install -y \ + gcc \ + make \ + pcre \ + pcre-devel \ + zlib \ + zlib-devel \ + openssl \ + openssl-devel \ + tar \ + gzip \ + unzip \ + which \ + findutils \ + procps-ng \ + iproute +} + +install_java() { + java_archive="$1" + + log_info "安装 Java: $java_archive" + rm -rf "$JAVA_HOME" + mkdir -p "$JAVA_HOME" + tar -xzf "$java_archive" -C "$JAVA_HOME" --strip-components=1 + + if [ ! -x "$JAVA_HOME/bin/java" ]; then + log_error "Java 安装失败,未找到 $JAVA_HOME/bin/java" + exit 1 + fi + + "$JAVA_HOME/bin/java" -version >/dev/null 2>&1 +} + +install_nginx() { + nginx_archive="$1" + build_dir=$(mktemp -d "$ENV_ROOT/nginx-build.XXXXXX") + + log_info "编译安装 Nginx: $nginx_archive" + rm -rf "$NGINX_HOME" + mkdir -p "$NGINX_HOME" + + tar -xzf "$nginx_archive" -C "$build_dir" + source_dir=$(find "$build_dir" -mindepth 1 -maxdepth 1 -type d | head -n 1) + if [ -z "${source_dir:-}" ]; then + rm -rf "$build_dir" + log_error "Nginx 源码目录解析失败" + exit 1 + fi + + jobs=$(getconf _NPROCESSORS_ONLN 2>/dev/null || echo 1) + ( + cd "$source_dir" + ./configure --prefix="$NGINX_HOME" --with-http_ssl_module + make -j"$jobs" + make install + ) + + rm -rf "$build_dir" + + if [ ! -x "$NGINX_HOME/sbin/nginx" ]; then + log_error "Nginx 安装失败,未找到 $NGINX_HOME/sbin/nginx" + exit 1 + fi +} + +write_nginx_conf() { + log_info "写入 Nginx 配置: $NGINX_CONF" + + mkdir -p "$NGINX_HOME/conf" "$NGINX_HOME/logs" "$FRONTEND_DIR/dist" + + cat > "$NGINX_CONF" <&2 +} + +usage() { + cat <<'EOF' +用法: ./restart_java.sh [start|stop|restart|status] + +默认动作: + restart 重启后端 Java 进程 +EOF +} + +ensure_runtime_dirs() { + mkdir -p "$BACKEND_DIR" "$LOG_DIR" "$RUN_DIR" +} + +is_managed_backend_pid() { + pid="$1" + if [ -z "${pid:-}" ] || ! kill -0 "$pid" 2>/dev/null; then + return 1 + fi + + args=$(ps -o args= -p "$pid" 2>/dev/null || true) + if [ -z "${args:-}" ]; then + return 1 + fi + + case "$args" in + *"$BACKEND_MARKER"*"$BACKEND_JAR"*|*"$BACKEND_JAR"*"$BACKEND_MARKER"*) + return 0 + ;; + esac + + return 1 +} + +collect_backend_pids() { + pids="" + + if [ -f "$BACKEND_PID_FILE" ]; then + file_pid=$(cat "$BACKEND_PID_FILE" 2>/dev/null || true) + if [ -n "${file_pid:-}" ] && is_managed_backend_pid "$file_pid"; then + pids="$pids $file_pid" + fi + fi + + marker_pids=$(ps -ef | awk -v marker="$BACKEND_MARKER" -v jar="$BACKEND_JAR" ' + index($0, "") == 0 && index($0, marker) > 0 { + for (i = 1; i < NF; i++) { + if ($i == "-jar" && $(i + 1) == jar) { + print $2 + break + } + } + } + ' | xargs 2>/dev/null || true) + if [ -n "${marker_pids:-}" ]; then + for pid in $marker_pids; do + if is_managed_backend_pid "$pid"; then + pids="$pids $pid" + fi + done + fi + + printf '%s\n' "$(echo "$pids" | xargs 2>/dev/null || true)" +} + +stop_backend() { + pids=$(collect_backend_pids) + + if [ -z "${pids:-}" ]; then + rm -f "$BACKEND_PID_FILE" + log_info "未发现运行中的后端进程" + return 0 + fi + + log_info "停止后端进程: $pids" + for pid in $pids; do + kill -TERM "$pid" 2>/dev/null || true + done + + elapsed=0 + while [ "$elapsed" -lt 30 ]; do + remaining="" + for pid in $pids; do + if kill -0 "$pid" 2>/dev/null; then + remaining="$remaining $pid" + fi + done + + remaining=$(echo "$remaining" | xargs 2>/dev/null || true) + if [ -z "${remaining:-}" ]; then + break + fi + + sleep 1 + elapsed=$((elapsed + 1)) + done + + if [ -n "${remaining:-}" ]; then + log_info "执行强制停止: $remaining" + for pid in $remaining; do + kill -KILL "$pid" 2>/dev/null || true + done + fi + + rm -f "$BACKEND_PID_FILE" +} + +start_backend() { + ensure_runtime_dirs + + if [ ! -x "$JAVA_HOME/bin/java" ]; then + log_error "未检测到可执行 Java: $JAVA_HOME/bin/java" + exit 1 + fi + + if [ ! -f "$BACKEND_JAR" ]; then + log_error "未找到后端 jar: $BACKEND_JAR" + exit 1 + fi + + if [ -n "$(collect_backend_pids)" ]; then + log_error "检测到后端已在运行,请先执行 stop 或 restart" + exit 1 + fi + + printf '\n===== %s restart =====\n' "$(timestamp)" >> "$BACKEND_CONSOLE_LOG" + + ( + cd "$BACKEND_DIR" + nohup "$JAVA_HOME/bin/java" $JAVA_OPTS -jar "$BACKEND_JAR" --spring.profiles.active=pro --server.port="$BACKEND_PORT" >> "$BACKEND_CONSOLE_LOG" 2>&1 & + echo $! > "$BACKEND_PID_FILE" + ) + + sleep 3 + + backend_pid=$(cat "$BACKEND_PID_FILE" 2>/dev/null || true) + if [ -z "${backend_pid:-}" ] || ! kill -0 "$backend_pid" 2>/dev/null; then + log_error "后端启动失败,请检查日志: $BACKEND_CONSOLE_LOG" + exit 1 + fi + + log_info "后端已启动,PID: $backend_pid" +} + +status_backend() { + pids=$(collect_backend_pids) + if [ -n "${pids:-}" ]; then + log_info "后端正在运行,进程: $pids" + return 0 + fi + + log_info "后端未运行" +} + +main() { + action="${1:-restart}" + + case "$action" in + start) + start_backend + ;; + stop) + stop_backend + ;; + restart) + stop_backend + start_backend + ;; + status) + status_backend + ;; + *) + usage + exit 1 + ;; + esac +} + +main "$@" diff --git a/bin/prod/restart_java_test.sh b/bin/prod/restart_java_test.sh new file mode 100644 index 0000000..7ac4fd5 --- /dev/null +++ b/bin/prod/restart_java_test.sh @@ -0,0 +1,116 @@ +#!/bin/sh + +set -eu + +ROOT_DIR=$(CDPATH= cd -- "$(dirname "$0")/../.." && pwd) +SCRIPT_UNDER_TEST="$ROOT_DIR/bin/prod/restart_java.sh" + +fail() { + printf 'FAIL: %s\n' "$1" >&2 + exit 1 +} + +assert_grep() { + pattern="$1" + target="$2" + if ! grep -Eq -- "$pattern" "$target"; then + fail "expected pattern [$pattern] in $target" + fi +} + +assert_not_grep() { + pattern="$1" + target="$2" + if grep -Eq -- "$pattern" "$target"; then + fail "did not expect pattern [$pattern] in $target" + fi +} + +create_fake_java() { + fake_java="$1" + + cat > "$fake_java" <<'EOF' +#!/bin/sh +set -eu + +while :; do + sleep 1 +done +EOF + + chmod +x "$fake_java" +} + +prepare_script_env() { + work_dir="$1" + + mkdir -p "$work_dir/env/jdk/bin" "$work_dir/loan-pricing/backend" "$work_dir/loan-pricing/logs" "$work_dir/loan-pricing/run" + create_fake_java "$work_dir/env/jdk/bin/java" + printf 'fake-jar\n' > "$work_dir/loan-pricing/backend/ruoyi-admin.jar" + cp "$SCRIPT_UNDER_TEST" "$work_dir/restart_java.sh" + perl -0pi -e "s#WEBAPP_ROOT=\"/home/webapp\"#WEBAPP_ROOT=\"$work_dir\"#" "$work_dir/restart_java.sh" + chmod +x "$work_dir/restart_java.sh" +} + +cleanup_work_dir() { + work_dir="$1" + + if [ -f "$work_dir/loan-pricing/run/backend.pid" ]; then + backend_pid=$(cat "$work_dir/loan-pricing/run/backend.pid" 2>/dev/null || true) + if [ -n "${backend_pid:-}" ]; then + kill "$backend_pid" 2>/dev/null || true + wait "$backend_pid" 2>/dev/null || true + fi + fi + + rm -rf "$work_dir" +} + +test_script_contract() { + assert_grep 'JAVA_HOME="\$ENV_ROOT/jdk"' "$SCRIPT_UNDER_TEST" + assert_grep '--spring\.profiles\.active=pro' "$SCRIPT_UNDER_TEST" + assert_grep 'ps -ef' "$SCRIPT_UNDER_TEST" + assert_not_grep 'pgrep' "$SCRIPT_UNDER_TEST" + assert_not_grep 'mvn' "$SCRIPT_UNDER_TEST" + assert_not_grep 'require_root' "$SCRIPT_UNDER_TEST" + assert_not_grep '\b(ss|lsof|netstat)\b' "$SCRIPT_UNDER_TEST" +} + +test_restart_flow() { + work_dir=$(mktemp -d) + trap 'cleanup_work_dir "$work_dir"' EXIT INT TERM + + prepare_script_env "$work_dir" + + "$work_dir/restart_java.sh" start + if [ ! -f "$work_dir/loan-pricing/run/backend.pid" ]; then + fail "expected backend pid file after start" + fi + + backend_pid=$(cat "$work_dir/loan-pricing/run/backend.pid") + kill -0 "$backend_pid" 2>/dev/null || fail "expected backend process to be running after start" + + status_output=$("$work_dir/restart_java.sh" status 2>&1 || true) + printf '%s\n' "$status_output" | grep -q '后端正在运行' || fail "expected status output to show running" + + "$work_dir/restart_java.sh" restart + restarted_pid=$(cat "$work_dir/loan-pricing/run/backend.pid") + kill -0 "$restarted_pid" 2>/dev/null || fail "expected backend process to be running after restart" + + "$work_dir/restart_java.sh" stop + if [ -f "$work_dir/loan-pricing/run/backend.pid" ]; then + fail "expected backend pid file to be removed after stop" + fi + + trap - EXIT INT TERM + cleanup_work_dir "$work_dir" +} + +main() { + [ -f "$SCRIPT_UNDER_TEST" ] || fail "script under test not found: $SCRIPT_UNDER_TEST" + test_script_contract + test_restart_flow + printf 'PASS: restart_java tests\n' +} + +main "$@" diff --git a/deploy/2026-03-31-local-nginx-java-install-manual.md b/deploy/2026-03-31-local-nginx-java-install-manual.md new file mode 100644 index 0000000..0d8dbb0 --- /dev/null +++ b/deploy/2026-03-31-local-nginx-java-install-manual.md @@ -0,0 +1,190 @@ +# 本地安装 Nginx 和 Java 手册 + +## 适用范围 + +本手册适用于需要在本地 Linux 环境手动安装贷款定价系统运行环境的场景,安装结果与当前生产脚本约定保持一致: + +- Java 安装到 `/home/webapp/env/java` +- Nginx 安装到 `/home/webapp/env/nginx` +- 项目部署目录使用 `/home/webapp/loan-pricing` +- 后端服务端口固定为 `63310` +- 前端 Nginx 端口固定为 `63311` + +## 前置条件 + +安装前请先确认: + +- 当前用户具备 `root` 权限 +- 本机已配置可用的 `yum` 源 +- `/home/webapp` 目录已存在 +- `/home/webapp` 下已准备安装包: + - `openjdk-21.0.2_linux-aarch64_bin.tar.gz` + - `nginx-1.20.2.tar.gz` + +如果安装包文件名不同,只要仍是 Java 的 `tar.gz` 包和 Nginx 的源码 `tar.gz` 包,也可以使用同样步骤。 + +## 目录规划 + +安装完成后目录结构如下: + +```text +/home/webapp +├── env +│ ├── java +│ └── nginx +└── loan-pricing + ├── backend + ├── frontend + ├── backup + ├── logs + ├── run + └── tmp +``` + +## 第一步:安装系统依赖 + +执行以下命令安装编译 Nginx 和运行部署脚本所需依赖: + +```sh +yum install -y \ + gcc \ + make \ + pcre \ + pcre-devel \ + zlib \ + zlib-devel \ + openssl \ + openssl-devel \ + tar \ + gzip \ + unzip \ + which \ + findutils \ + procps-ng \ + iproute +``` + +## 第二步:创建目录 + +执行以下命令初始化目录: + +```sh +mkdir -p \ + /home/webapp/env \ + /home/webapp/loan-pricing/backend \ + /home/webapp/loan-pricing/frontend \ + /home/webapp/loan-pricing/backup \ + /home/webapp/loan-pricing/logs \ + /home/webapp/loan-pricing/run \ + /home/webapp/loan-pricing/tmp +``` + +## 第三步:安装 Java + +解压 Java 安装包到目标目录: + +```sh +rm -rf /home/webapp/env/java +mkdir -p /home/webapp/env/java +tar -xzf /home/webapp/openjdk-21.0.2_linux-aarch64_bin.tar.gz -C /home/webapp/env/java --strip-components=1 +``` + +验证安装结果: + +```sh +/home/webapp/env/java/bin/java -version +``` + +如果能正常输出 Java 版本,说明安装成功。 + +## 第四步:安装 Nginx + +Nginx 安装包为源码包,需要先解压、编译、安装: + +```sh +rm -rf /home/webapp/env/nginx +mkdir -p /home/webapp/env/nginx +mkdir -p /home/webapp/env/nginx-build +tar -xzf /home/webapp/nginx-1.20.2.tar.gz -C /home/webapp/env/nginx-build +cd /home/webapp/env/nginx-build/nginx-1.20.2 +./configure --prefix=/home/webapp/env/nginx --with-http_ssl_module +make -j"$(getconf _NPROCESSORS_ONLN 2>/dev/null || echo 1)" +make install +``` + +安装完成后可执行文件位置为: + +```text +/home/webapp/env/nginx/sbin/nginx +``` + +## 第五步:写入 Nginx 配置 + +仓库已提供可直接参考的配置文件: + +```text +deploy/nginx.conf +``` + +将该文件内容写入 `/home/webapp/env/nginx/conf/nginx.conf` 即可。 + +## 第六步:校验 Nginx 配置 + +执行: + +```sh +/home/webapp/env/nginx/sbin/nginx -t -c /home/webapp/env/nginx/conf/nginx.conf +``` + +如果输出 `syntax is ok` 和 `test is successful`,说明配置可用。 + +## 第七步:启动 Nginx + +执行: + +```sh +/home/webapp/env/nginx/sbin/nginx -c /home/webapp/env/nginx/conf/nginx.conf +``` + +如果后续修改了配置,可执行: + +```sh +/home/webapp/env/nginx/sbin/nginx -c /home/webapp/env/nginx/conf/nginx.conf -s reload +``` + +## 第八步:验证端口 + +执行: + +```sh +ss -lnt | grep 63311 +``` + +如果能看到 `63311` 监听记录,说明前端 Nginx 已启动成功。 + +## 建议执行方式 + +如果本机已经放置了以下脚本,也可以直接使用脚本完成安装: + +```sh +cd /home/webapp +./install_env.sh +``` + +如果只需要管理后端 Java 进程,可执行: + +```sh +cd /home/webapp +./restart_java.sh start +./restart_java.sh stop +./restart_java.sh restart +./restart_java.sh status +``` + +## 常见检查项 + +- `yum` 不可用:先确认系统已配置可用的 `yum` 源 +- Java 未安装成功:检查 `/home/webapp/openjdk-*.tar.gz` 是否存在且未损坏 +- Nginx 编译失败:检查 `gcc`、`make`、`pcre-devel`、`zlib-devel`、`openssl-devel` 是否已安装 +- Nginx 启动失败:先执行 `nginx -t` 查看配置是否正确 +- 前端无法访问后端:检查本机 `63310` 端口是否已有 Java 服务监听 diff --git a/deploy/deploy.zip b/deploy/deploy.zip new file mode 100644 index 0000000..7f473dc Binary files /dev/null and b/deploy/deploy.zip differ diff --git a/deploy/nginx.conf b/deploy/nginx.conf new file mode 100644 index 0000000..457c3c6 --- /dev/null +++ b/deploy/nginx.conf @@ -0,0 +1,45 @@ +user nobody; +worker_processes 1; + +error_log /home/webapp/loan-pricing/logs/nginx-error.log warn; +pid /home/webapp/loan-pricing/run/nginx.pid; + +events { + worker_connections 1024; +} + +http { + include /home/webapp/env/nginx/conf/mime.types; + default_type application/octet-stream; + + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + + access_log /home/webapp/loan-pricing/logs/nginx-access.log main; + + sendfile on; + keepalive_timeout 65; + client_max_body_size 100m; + + server { + listen 63311; + server_name _; + + root /home/webapp/loan-pricing/frontend/dist; + index index.html; + + location /prod-api/ { + proxy_pass http://127.0.0.1:63310/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location / { + try_files $uri $uri/ /index.html; + } + } +} diff --git a/doc/2026-04-15-全量迁移892-without-redis前端实施记录.md b/doc/2026-04-15-全量迁移892-without-redis前端实施记录.md new file mode 100644 index 0000000..2f46356 --- /dev/null +++ b/doc/2026-04-15-全量迁移892-without-redis前端实施记录.md @@ -0,0 +1,63 @@ +# 全量迁移 `892-without-redis` 前端实施记录 + +## 修改时间 + +- 2026-04-15 + +## 本次完成内容 + +- 迁入贷款定价前端页面与组件: + - [workflow/index.vue](/Users/wkc/Desktop/loan-pricing/loan-pricing-jdk-1.8/ruoyi-ui/src/views/loanPricing/workflow/index.vue) + - [workflow/detail.vue](/Users/wkc/Desktop/loan-pricing/loan-pricing-jdk-1.8/ruoyi-ui/src/views/loanPricing/workflow/detail.vue) + - [workflow/components/PersonalCreateDialog.vue](/Users/wkc/Desktop/loan-pricing/loan-pricing-jdk-1.8/ruoyi-ui/src/views/loanPricing/workflow/components/PersonalCreateDialog.vue) + - [workflow/components/CorporateCreateDialog.vue](/Users/wkc/Desktop/loan-pricing/loan-pricing-jdk-1.8/ruoyi-ui/src/views/loanPricing/workflow/components/CorporateCreateDialog.vue) + - [workflow/components/PersonalWorkflowDetail.vue](/Users/wkc/Desktop/loan-pricing/loan-pricing-jdk-1.8/ruoyi-ui/src/views/loanPricing/workflow/components/PersonalWorkflowDetail.vue) + - [workflow/components/CorporateWorkflowDetail.vue](/Users/wkc/Desktop/loan-pricing/loan-pricing-jdk-1.8/ruoyi-ui/src/views/loanPricing/workflow/components/CorporateWorkflowDetail.vue) + - [workflow/components/ModelOutputDisplay.vue](/Users/wkc/Desktop/loan-pricing/loan-pricing-jdk-1.8/ruoyi-ui/src/views/loanPricing/workflow/components/ModelOutputDisplay.vue) +- 迁入贷款定价 API: + - [workflow.js](/Users/wkc/Desktop/loan-pricing/loan-pricing-jdk-1.8/ruoyi-ui/src/api/loanPricing/workflow.js) +- 迁入密码传输工具: + - [passwordTransfer.js](/Users/wkc/Desktop/loan-pricing/loan-pricing-jdk-1.8/ruoyi-ui/src/utils/passwordTransfer.js) +- 接入前端密码传输调用: + - [src/api/login.js](/Users/wkc/Desktop/loan-pricing/loan-pricing-jdk-1.8/ruoyi-ui/src/api/login.js) + - [src/api/system/user.js](/Users/wkc/Desktop/loan-pricing/loan-pricing-jdk-1.8/ruoyi-ui/src/api/system/user.js) +- 调整登录页默认值为空,移除默认账号密码回填: + - [src/views/login.vue](/Users/wkc/Desktop/loan-pricing/loan-pricing-jdk-1.8/ruoyi-ui/src/views/login.vue) +- 补充前端密码传输环境变量: + - [ruoyi-ui/.env.development](/Users/wkc/Desktop/loan-pricing/loan-pricing-jdk-1.8/ruoyi-ui/.env.development) + - [ruoyi-ui/.env.production](/Users/wkc/Desktop/loan-pricing/loan-pricing-jdk-1.8/ruoyi-ui/.env.production) +- 补充前端依赖: + - `crypto-js` + - `html-webpack-plugin` +- 迁入目标分支中的前端静态测试: + - `ruoyi-ui/tests/*` + +## 关键整合说明 + +- 前端密码传输使用目标分支的 AES ECB 方案,但仍按当前仓库结构挂载到现有 `src/api` 层 +- 登录页默认用户名和默认密码已清空,同时保留 cookie 回填逻辑 +- 依赖安装时使用 `nvm use 14.21.3`,满足仓库对前端 Node 版本由 `nvm` 控制的要求 +- `npm install` 后补了 `html-webpack-plugin`,用于修复现有构建链缺失 peer 依赖导致的生产构建失败 + +## 验证结果 + +### 前端静态测试 + +- `source ~/.nvm/nvm.sh && cd ruoyi-ui && nvm use 14.21.3 >/dev/null && node tests/password-transfer-api.test.js && node tests/login-default-credentials.test.js && node tests/personal-create-input-params.test.js && node tests/retail-display-fields.test.js && node tests/personal-final-calculate-rate-display.test.js && node tests/workflow-detail-card-order.test.js && node tests/workflow-index-refresh.test.js` + - 结果:通过 + +### 依赖安装 + +- `source ~/.nvm/nvm.sh && cd ruoyi-ui && nvm use 14.21.3 && npm install` + - 结果:通过 + +### 生产构建 + +- `source ~/.nvm/nvm.sh && cd ruoyi-ui && nvm use 14.21.3 >/dev/null && npm run build:prod` + - 结果:通过 + - 备注:有 asset size warning,但构建成功,`dist/` 已生成 + +## 未在本记录中执行的内容 + +- 未启动前端 dev server 做交互式页面冒烟 +- 因此没有需要额外清理的前端测试进程 diff --git a/doc/2026-04-15-全量迁移892-without-redis后端实施记录.md b/doc/2026-04-15-全量迁移892-without-redis后端实施记录.md new file mode 100644 index 0000000..6685219 --- /dev/null +++ b/doc/2026-04-15-全量迁移892-without-redis后端实施记录.md @@ -0,0 +1,71 @@ +# 全量迁移 `892-without-redis` 后端实施记录 + +## 修改时间 + +- 2026-04-15 + +## 本次完成内容 + +- 新增并接入 `ruoyi-loan-pricing` 模块,纳入根 `pom.xml` 与 `ruoyi-admin/pom.xml` +- 保留 `MyBatis-Plus + Lombok`,将贷款定价模块中的 `jakarta.*` 兼容替换为当前基线可运行的 `javax.*` +- 在 `ruoyi-framework` 中接入 `MyBatis-Plus`: + - `MybatisSqlSessionFactoryBean` + - `MybatisPlusInterceptor` + - MySQL 分页拦截器 +- 迁入贷款定价后端主链: + - Controller / DTO / Entity / VO / Mapper / Service / XML + - 敏感字段加解密与脱敏服务 + - 个人测算入参对齐 + - 列表联表查询测算利率 + - 详情页个人最终测算利率取值 +- 补充 `HttpUtils#doPostFormUrlEncoded`,满足模型表单调用 +- 新增 `PasswordTransferCryptoService` +- 接入登录密码传输后端链路: + - [SysLoginController](/Users/wkc/Desktop/loan-pricing/loan-pricing-jdk-1.8/ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/SysLoginController.java) + - [SysRegisterController](/Users/wkc/Desktop/loan-pricing/loan-pricing-jdk-1.8/ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/SysRegisterController.java) + - [SysProfileController](/Users/wkc/Desktop/loan-pricing/loan-pricing-jdk-1.8/ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/SysProfileController.java) + - [SysUserController](/Users/wkc/Desktop/loan-pricing/loan-pricing-jdk-1.8/ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/SysUserController.java) +- 补齐密码传输配置: + - `security.password-transfer.key` +- 迁入目标分支中的部署脚本、环境配置和 SQL 资产: + - `bin/prod/*` + - `deploy/*` + - `sql/loan_pricing_*.sql` + - `sql/model_*.sql` + - `test_api/*` + +## 关键整合说明 + +- 没有回退当前分支的 `JDK8` 与“去 Redis 改为内存缓存”基线 +- 贷款定价模块没有改写为普通 MyBatis,而是保留 `MyBatis-Plus` 风格实现 +- 由于当前主工程是 `Spring Boot 2.5 / JDK8`,没有原样保留 `jakarta.*`,而是按你的确认改成 `javax.*` +- 贷款定价模块中的 Swagger v3 注解未继续保留,避免为非业务注解引入额外运行时依赖 + +## 验证结果 + +### 构建验证 + +- `mvn -pl ruoyi-loan-pricing -am -DskipTests package` + - 结果:通过 +- `mvn -pl ruoyi-admin -am -DskipTests package` + - 结果:通过 + +### 定向测试 + +- `mvn -pl ruoyi-loan-pricing -am -Dtest=LoanPricingWorkflowMapperXmlTest,LoanPricingWorkflowServiceImplTest,LoanPricingModelServicePersonalParamsTest,SensitiveFieldCryptoServiceTest,LoanPricingSensitiveDisplayServiceTest,ModelRetailOutputFieldsTest -Dsurefire.failIfNoSpecifiedTests=false test` + - 结果:通过 + - 统计:22 tests run, 0 failures, 0 errors + +- `mvn -pl ruoyi-admin -am -Dtest=SysLoginControllerPasswordTransferTest,SysRegisterControllerPasswordTransferTest,SysProfileControllerPasswordTransferTest,SysUserControllerPasswordTransferTest,CacheControllerTest -Dsurefire.failIfNoSpecifiedTests=false test` + - 结果:通过 + - 统计:7 tests run, 0 failures, 0 errors + +### 全量后端测试 + +- `mvn test` + - 结果:通过 + +## 未在本记录中执行的内容 + +- 未执行真实数据库初始化和真实模型接口联调 +- 未在本记录中启动长期运行的后端进程,因此无需额外清理测试进程 diff --git a/pom.xml b/pom.xml index b03dad4..25b841c 100644 --- a/pom.xml +++ b/pom.xml @@ -34,8 +34,9 @@ 9.0.112 1.2.13 5.7.14 - 5.3.39 - + 5.3.39 + 3.5.7 + @@ -114,12 +115,18 @@ ${yauaa.version} - - - com.github.pagehelper - pagehelper-spring-boot-starter - ${pagehelper.boot.version} - + + + com.github.pagehelper + pagehelper-spring-boot-starter + ${pagehelper.boot.version} + + + + com.baomidou + mybatis-plus-boot-starter + ${mybatis-plus.version} + @@ -204,17 +211,24 @@ ${ruoyi.version} - - - com.ruoyi - ruoyi-system - ${ruoyi.version} - - - - - com.ruoyi - ruoyi-common + + + com.ruoyi + ruoyi-system + ${ruoyi.version} + + + + + com.ruoyi + ruoyi-loan-pricing + ${ruoyi.version} + + + + + com.ruoyi + ruoyi-common ${ruoyi.version} @@ -225,10 +239,11 @@ ruoyi-admin ruoyi-framework ruoyi-system - ruoyi-quartz - ruoyi-generator - ruoyi-common - + ruoyi-quartz + ruoyi-generator + ruoyi-common + ruoyi-loan-pricing + pom @@ -271,4 +286,4 @@ - \ No newline at end of file + diff --git a/ruoyi-admin/pom.xml b/ruoyi-admin/pom.xml index f99ed3f..7de0bb9 100644 --- a/ruoyi-admin/pom.xml +++ b/ruoyi-admin/pom.xml @@ -61,6 +61,12 @@ ruoyi-generator + + + com.ruoyi + ruoyi-loan-pricing + + org.springframework.boot spring-boot-starter-test diff --git a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/SysLoginController.java b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/SysLoginController.java index caebb39..5eeb5d8 100644 --- a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/SysLoginController.java +++ b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/SysLoginController.java @@ -13,14 +13,15 @@ import com.ruoyi.common.core.domain.AjaxResult; import com.ruoyi.common.core.domain.entity.SysMenu; import com.ruoyi.common.core.domain.entity.SysUser; import com.ruoyi.common.core.domain.model.LoginBody; -import com.ruoyi.common.core.domain.model.LoginUser; -import com.ruoyi.common.core.text.Convert; -import com.ruoyi.common.utils.DateUtils; -import com.ruoyi.common.utils.SecurityUtils; -import com.ruoyi.common.utils.StringUtils; -import com.ruoyi.framework.web.service.SysLoginService; -import com.ruoyi.framework.web.service.SysPermissionService; -import com.ruoyi.framework.web.service.TokenService; +import com.ruoyi.common.core.domain.model.LoginUser; +import com.ruoyi.common.core.text.Convert; +import com.ruoyi.common.utils.DateUtils; +import com.ruoyi.common.utils.SecurityUtils; +import com.ruoyi.common.utils.StringUtils; +import com.ruoyi.framework.web.service.PasswordTransferCryptoService; +import com.ruoyi.framework.web.service.SysLoginService; +import com.ruoyi.framework.web.service.SysPermissionService; +import com.ruoyi.framework.web.service.TokenService; import com.ruoyi.system.service.ISysConfigService; import com.ruoyi.system.service.ISysMenuService; @@ -44,8 +45,11 @@ public class SysLoginController @Autowired private TokenService tokenService; - @Autowired - private ISysConfigService configService; + @Autowired + private ISysConfigService configService; + + @Autowired + private PasswordTransferCryptoService passwordTransferCryptoService; /** * 登录方法 @@ -54,12 +58,13 @@ public class SysLoginController * @return 结果 */ @PostMapping("/login") - public AjaxResult login(@RequestBody LoginBody loginBody) - { - AjaxResult ajax = AjaxResult.success(); - // 生成令牌 - String token = loginService.login(loginBody.getUsername(), loginBody.getPassword(), loginBody.getCode(), - loginBody.getUuid()); + public AjaxResult login(@RequestBody LoginBody loginBody) + { + AjaxResult ajax = AjaxResult.success(); + loginBody.setPassword(passwordTransferCryptoService.decrypt(loginBody.getPassword())); + // 生成令牌 + String token = loginService.login(loginBody.getUsername(), loginBody.getPassword(), loginBody.getCode(), + loginBody.getUuid()); ajax.put(Constants.TOKEN, token); return ajax; } diff --git a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/SysProfileController.java b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/SysProfileController.java index b325045..09395df 100644 --- a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/SysProfileController.java +++ b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/SysProfileController.java @@ -18,13 +18,14 @@ import com.ruoyi.common.core.domain.entity.SysUser; import com.ruoyi.common.core.domain.model.LoginUser; import com.ruoyi.common.enums.BusinessType; import com.ruoyi.common.utils.DateUtils; -import com.ruoyi.common.utils.SecurityUtils; -import com.ruoyi.common.utils.StringUtils; -import com.ruoyi.common.utils.file.FileUploadUtils; -import com.ruoyi.common.utils.file.FileUtils; -import com.ruoyi.common.utils.file.MimeTypeUtils; -import com.ruoyi.framework.web.service.TokenService; -import com.ruoyi.system.service.ISysUserService; +import com.ruoyi.common.utils.SecurityUtils; +import com.ruoyi.common.utils.StringUtils; +import com.ruoyi.common.utils.file.FileUploadUtils; +import com.ruoyi.common.utils.file.FileUtils; +import com.ruoyi.common.utils.file.MimeTypeUtils; +import com.ruoyi.framework.web.service.PasswordTransferCryptoService; +import com.ruoyi.framework.web.service.TokenService; +import com.ruoyi.system.service.ISysUserService; /** * 个人信息 业务处理 @@ -38,8 +39,11 @@ public class SysProfileController extends BaseController @Autowired private ISysUserService userService; - @Autowired - private TokenService tokenService; + @Autowired + private TokenService tokenService; + + @Autowired + private PasswordTransferCryptoService passwordTransferCryptoService; /** * 个人信息 @@ -90,13 +94,13 @@ public class SysProfileController extends BaseController */ @Log(title = "个人信息", businessType = BusinessType.UPDATE) @PutMapping("/updatePwd") - public AjaxResult updatePwd(@RequestBody Map params) - { - String oldPassword = params.get("oldPassword"); - String newPassword = params.get("newPassword"); - LoginUser loginUser = getLoginUser(); - Long userId = loginUser.getUserId(); - SysUser user = userService.selectUserById(userId); + public AjaxResult updatePwd(@RequestBody Map params) + { + String oldPassword = passwordTransferCryptoService.decrypt(params.get("oldPassword")); + String newPassword = passwordTransferCryptoService.decrypt(params.get("newPassword")); + LoginUser loginUser = getLoginUser(); + Long userId = loginUser.getUserId(); + SysUser user = userService.selectUserById(userId); String password = user.getPassword(); if (!SecurityUtils.matchesPassword(oldPassword, password)) { diff --git a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/SysRegisterController.java b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/SysRegisterController.java index f1552e0..7c0bd16 100644 --- a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/SysRegisterController.java +++ b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/SysRegisterController.java @@ -4,12 +4,13 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RestController; -import com.ruoyi.common.core.controller.BaseController; -import com.ruoyi.common.core.domain.AjaxResult; -import com.ruoyi.common.core.domain.model.RegisterBody; -import com.ruoyi.common.utils.StringUtils; -import com.ruoyi.framework.web.service.SysRegisterService; -import com.ruoyi.system.service.ISysConfigService; +import com.ruoyi.common.core.controller.BaseController; +import com.ruoyi.common.core.domain.AjaxResult; +import com.ruoyi.common.core.domain.model.RegisterBody; +import com.ruoyi.common.utils.StringUtils; +import com.ruoyi.framework.web.service.PasswordTransferCryptoService; +import com.ruoyi.framework.web.service.SysRegisterService; +import com.ruoyi.system.service.ISysConfigService; /** * 注册验证 @@ -22,17 +23,21 @@ public class SysRegisterController extends BaseController @Autowired private SysRegisterService registerService; - @Autowired - private ISysConfigService configService; + @Autowired + private ISysConfigService configService; + + @Autowired + private PasswordTransferCryptoService passwordTransferCryptoService; @PostMapping("/register") public AjaxResult register(@RequestBody RegisterBody user) { - if (!("true".equals(configService.selectConfigByKey("sys.account.registerUser")))) - { - return error("当前系统没有开启注册功能!"); - } - String msg = registerService.register(user); - return StringUtils.isEmpty(msg) ? success() : error(msg); - } -} + if (!("true".equals(configService.selectConfigByKey("sys.account.registerUser")))) + { + return error("当前系统没有开启注册功能!"); + } + user.setPassword(passwordTransferCryptoService.decrypt(user.getPassword())); + String msg = registerService.register(user); + return StringUtils.isEmpty(msg) ? success() : error(msg); + } +} diff --git a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/SysUserController.java b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/SysUserController.java index 209161c..c4bd7fb 100644 --- a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/SysUserController.java +++ b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/SysUserController.java @@ -24,12 +24,13 @@ import com.ruoyi.common.core.domain.entity.SysRole; import com.ruoyi.common.core.domain.entity.SysUser; import com.ruoyi.common.core.page.TableDataInfo; import com.ruoyi.common.enums.BusinessType; -import com.ruoyi.common.utils.SecurityUtils; -import com.ruoyi.common.utils.StringUtils; -import com.ruoyi.common.utils.poi.ExcelUtil; -import com.ruoyi.system.service.ISysDeptService; -import com.ruoyi.system.service.ISysPostService; -import com.ruoyi.system.service.ISysRoleService; +import com.ruoyi.common.utils.SecurityUtils; +import com.ruoyi.common.utils.StringUtils; +import com.ruoyi.common.utils.poi.ExcelUtil; +import com.ruoyi.framework.web.service.PasswordTransferCryptoService; +import com.ruoyi.system.service.ISysDeptService; +import com.ruoyi.system.service.ISysPostService; +import com.ruoyi.system.service.ISysRoleService; import com.ruoyi.system.service.ISysUserService; /** @@ -50,8 +51,11 @@ public class SysUserController extends BaseController @Autowired private ISysDeptService deptService; - @Autowired - private ISysPostService postService; + @Autowired + private ISysPostService postService; + + @Autowired + private PasswordTransferCryptoService passwordTransferCryptoService; /** * 获取用户列表 @@ -134,13 +138,14 @@ public class SysUserController extends BaseController { return error("新增用户'" + user.getUserName() + "'失败,手机号码已存在"); } - else if (StringUtils.isNotEmpty(user.getEmail()) && !userService.checkEmailUnique(user)) - { - return error("新增用户'" + user.getUserName() + "'失败,邮箱账号已存在"); - } - user.setCreateBy(getUsername()); - user.setPassword(SecurityUtils.encryptPassword(user.getPassword())); - return toAjax(userService.insertUser(user)); + else if (StringUtils.isNotEmpty(user.getEmail()) && !userService.checkEmailUnique(user)) + { + return error("新增用户'" + user.getUserName() + "'失败,邮箱账号已存在"); + } + user.setCreateBy(getUsername()); + user.setPassword(passwordTransferCryptoService.decrypt(user.getPassword())); + user.setPassword(SecurityUtils.encryptPassword(user.getPassword())); + return toAjax(userService.insertUser(user)); } /** @@ -192,13 +197,14 @@ public class SysUserController extends BaseController @PreAuthorize("@ss.hasPermi('system:user:resetPwd')") @Log(title = "用户管理", businessType = BusinessType.UPDATE) @PutMapping("/resetPwd") - public AjaxResult resetPwd(@RequestBody SysUser user) - { - userService.checkUserAllowed(user); - userService.checkUserDataScope(user.getUserId()); - user.setPassword(SecurityUtils.encryptPassword(user.getPassword())); - user.setUpdateBy(getUsername()); - return toAjax(userService.resetPwd(user)); + public AjaxResult resetPwd(@RequestBody SysUser user) + { + userService.checkUserAllowed(user); + userService.checkUserDataScope(user.getUserId()); + user.setPassword(passwordTransferCryptoService.decrypt(user.getPassword())); + user.setPassword(SecurityUtils.encryptPassword(user.getPassword())); + user.setUpdateBy(getUsername()); + return toAjax(userService.resetPwd(user)); } /** diff --git a/ruoyi-admin/src/main/resources/application-pro.yml b/ruoyi-admin/src/main/resources/application-pro.yml new file mode 100644 index 0000000..3e27510 --- /dev/null +++ b/ruoyi-admin/src/main/resources/application-pro.yml @@ -0,0 +1,86 @@ +# 开发环境配置 +server: + # 服务器的HTTP端口,默认为63310 + port: 63310 + servlet: + # 应用的访问路径 + context-path: / + tomcat: + # tomcat的URI编码 + uri-encoding: UTF-8 + # 连接数满后的排队数,默认为100 + accept-count: 1000 + threads: + # tomcat最大线程数,默认为200 + max: 800 + # Tomcat启动初始化的线程数,默认值10 + min-spare: 100 + + +# 数据源配置 +spring: + datasource: + type: com.alibaba.druid.pool.DruidDataSource + driverClassName: com.mysql.cj.jdbc.Driver + druid: + # 主库数据源 + master: + url: jdbc:mysql://64.127.23.7:3306/loan-pricing?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8 + username: lrdb + password: Synx2024 + # 从库数据源 + slave: + # 从数据源开关/默认关闭 + enabled: false + url: + username: + password: + # 初始连接数 + initialSize: 5 + # 最小连接池数量 + minIdle: 10 + # 最大连接池数量 + maxActive: 20 + # 配置获取连接等待超时的时间 + maxWait: 60000 + # 配置连接超时时间 + connectTimeout: 30000 + # 配置网络超时时间 + socketTimeout: 60000 + # 配置间隔多久才进行一次检测,检测需要关闭的空闲连接,单位是毫秒 + timeBetweenEvictionRunsMillis: 60000 + # 配置一个连接在池中最小生存的时间,单位是毫秒 + minEvictableIdleTimeMillis: 300000 + # 配置一个连接在池中最大生存的时间,单位是毫秒 + maxEvictableIdleTimeMillis: 900000 + # 配置检测连接是否有效 + validationQuery: SELECT 1 FROM DUAL + testWhileIdle: true + testOnBorrow: false + testOnReturn: false + webStatFilter: + enabled: true + statViewServlet: + enabled: true + # 设置白名单,不填则允许所有访问 + allow: + url-pattern: /druid/* + # 控制台管理用户名和密码 + login-username: ruoyi + login-password: 123456 + filter: + stat: + enabled: true + # 慢SQL记录 + log-slow-sql: true + slow-sql-millis: 1000 + merge-sql: true + wall: + config: + multi-statement-allow: true +model: + url: http://64.202.32.40:8083/api/service/interface/invokeService/syllcs + +security: + password-transfer: + key: "1234567890abcdef" diff --git a/ruoyi-admin/src/main/resources/application-uat.yml b/ruoyi-admin/src/main/resources/application-uat.yml new file mode 100644 index 0000000..452ef82 --- /dev/null +++ b/ruoyi-admin/src/main/resources/application-uat.yml @@ -0,0 +1,86 @@ +# 开发环境配置 +server: + # 服务器的HTTP端口,默认为63310 + port: 63310 + servlet: + # 应用的访问路径 + context-path: / + tomcat: + # tomcat的URI编码 + uri-encoding: UTF-8 + # 连接数满后的排队数,默认为100 + accept-count: 1000 + threads: + # tomcat最大线程数,默认为200 + max: 800 + # Tomcat启动初始化的线程数,默认值10 + min-spare: 100 + + +# 数据源配置 +spring: + datasource: + type: com.alibaba.druid.pool.DruidDataSource + driverClassName: com.mysql.cj.jdbc.Driver + druid: + # 主库数据源 + master: + url: jdbc:mysql://192.168.0.111:40628/loan-pricing?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8 + username: root + password: Kfcx@1234 + # 从库数据源 + slave: + # 从数据源开关/默认关闭 + enabled: false + url: + username: + password: + # 初始连接数 + initialSize: 5 + # 最小连接池数量 + minIdle: 10 + # 最大连接池数量 + maxActive: 20 + # 配置获取连接等待超时的时间 + maxWait: 60000 + # 配置连接超时时间 + connectTimeout: 30000 + # 配置网络超时时间 + socketTimeout: 60000 + # 配置间隔多久才进行一次检测,检测需要关闭的空闲连接,单位是毫秒 + timeBetweenEvictionRunsMillis: 60000 + # 配置一个连接在池中最小生存的时间,单位是毫秒 + minEvictableIdleTimeMillis: 300000 + # 配置一个连接在池中最大生存的时间,单位是毫秒 + maxEvictableIdleTimeMillis: 900000 + # 配置检测连接是否有效 + validationQuery: SELECT 1 FROM DUAL + testWhileIdle: true + testOnBorrow: false + testOnReturn: false + webStatFilter: + enabled: true + statViewServlet: + enabled: true + # 设置白名单,不填则允许所有访问 + allow: + url-pattern: /druid/* + # 控制台管理用户名和密码 + login-username: ruoyi + login-password: 123456 + filter: + stat: + enabled: true + # 慢SQL记录 + log-slow-sql: true + slow-sql-millis: 1000 + merge-sql: true + wall: + config: + multi-statement-allow: true +model: + url: http://localhost:63310/rate/pricing/mock/invokeModel + +security: + password-transfer: + key: "1234567890abcdef" diff --git a/ruoyi-admin/src/main/resources/application.yml b/ruoyi-admin/src/main/resources/application.yml index 3d20776..a47346d 100644 --- a/ruoyi-admin/src/main/resources/application.yml +++ b/ruoyi-admin/src/main/resources/application.yml @@ -98,14 +98,18 @@ swagger: pathMapping: /dev-api # 防盗链配置 -referer: - # 防盗链开关 - enabled: false - # 允许的域名列表 - allowed-domains: localhost,127.0.0.1,ruoyi.vip,www.ruoyi.vip - -# 防止XSS攻击 -xss: +referer: + # 防盗链开关 + enabled: false + # 允许的域名列表 + allowed-domains: localhost,127.0.0.1,ruoyi.vip,www.ruoyi.vip + +security: + password-transfer: + key: "1234567890abcdef" + +# 防止XSS攻击 +xss: # 过滤开关 enabled: true # 排除链接(多个用逗号分隔) diff --git a/ruoyi-admin/src/test/java/com/ruoyi/web/controller/system/SysLoginControllerPasswordTransferTest.java b/ruoyi-admin/src/test/java/com/ruoyi/web/controller/system/SysLoginControllerPasswordTransferTest.java new file mode 100644 index 0000000..e4e1cdd --- /dev/null +++ b/ruoyi-admin/src/test/java/com/ruoyi/web/controller/system/SysLoginControllerPasswordTransferTest.java @@ -0,0 +1,40 @@ +package com.ruoyi.web.controller.system; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import com.ruoyi.framework.web.service.PasswordTransferCryptoService; +import com.ruoyi.framework.web.service.SysLoginService; + +class SysLoginControllerPasswordTransferTest +{ + @Test + void shouldDecryptPasswordBeforeCallingLoginService() throws Exception + { + SysLoginService loginService = mock(SysLoginService.class); + PasswordTransferCryptoService passwordTransferCryptoService = mock(PasswordTransferCryptoService.class); + when(passwordTransferCryptoService.decrypt("cipher")).thenReturn("admin123"); + when(loginService.login("admin", "admin123", "1", "u")).thenReturn("token"); + + SysLoginController controller = new SysLoginController(); + ReflectionTestUtils.setField(controller, "loginService", loginService); + ReflectionTestUtils.setField(controller, "passwordTransferCryptoService", passwordTransferCryptoService); + + MockMvc mockMvc = MockMvcBuilders.standaloneSetup(controller).build(); + + mockMvc.perform(post("/login") + .contentType("application/json") + .content("{\"username\":\"admin\",\"password\":\"cipher\",\"code\":\"1\",\"uuid\":\"u\"}")) + .andExpect(status().isOk()); + + verify(passwordTransferCryptoService).decrypt("cipher"); + verify(loginService).login("admin", "admin123", "1", "u"); + } +} diff --git a/ruoyi-admin/src/test/java/com/ruoyi/web/controller/system/SysProfileControllerPasswordTransferTest.java b/ruoyi-admin/src/test/java/com/ruoyi/web/controller/system/SysProfileControllerPasswordTransferTest.java new file mode 100644 index 0000000..aea25a4 --- /dev/null +++ b/ruoyi-admin/src/test/java/com/ruoyi/web/controller/system/SysProfileControllerPasswordTransferTest.java @@ -0,0 +1,72 @@ +package com.ruoyi.web.controller.system; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.util.Collections; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import com.ruoyi.common.core.domain.entity.SysUser; +import com.ruoyi.common.core.domain.model.LoginUser; +import com.ruoyi.common.utils.SecurityUtils; +import com.ruoyi.framework.web.service.PasswordTransferCryptoService; +import com.ruoyi.framework.web.service.TokenService; +import com.ruoyi.system.service.ISysUserService; + +class SysProfileControllerPasswordTransferTest +{ + @AfterEach + void tearDown() + { + SecurityContextHolder.clearContext(); + } + + @Test + void shouldDecryptPasswordsBeforeCheckingOldPassword() throws Exception + { + ISysUserService userService = mock(ISysUserService.class); + TokenService tokenService = mock(TokenService.class); + PasswordTransferCryptoService passwordTransferCryptoService = mock(PasswordTransferCryptoService.class); + when(passwordTransferCryptoService.decrypt("oldCipher")).thenReturn("oldPlain"); + when(passwordTransferCryptoService.decrypt("newCipher")).thenReturn("newPlain"); + when(userService.resetUserPwd(org.mockito.ArgumentMatchers.anyLong(), org.mockito.ArgumentMatchers.anyString())) + .thenReturn(1); + + SysUser storedUser = new SysUser(); + storedUser.setUserId(2L); + storedUser.setPassword(SecurityUtils.encryptPassword("oldPlain")); + when(userService.selectUserById(2L)).thenReturn(storedUser); + + SysUser currentUser = new SysUser(); + currentUser.setUserId(2L); + currentUser.setUserName("admin"); + LoginUser loginUser = new LoginUser(2L, 1L, currentUser, Collections.emptySet()); + SecurityContextHolder.getContext() + .setAuthentication(new UsernamePasswordAuthenticationToken(loginUser, null, Collections.emptyList())); + + SysProfileController controller = new SysProfileController(); + ReflectionTestUtils.setField(controller, "userService", userService); + ReflectionTestUtils.setField(controller, "tokenService", tokenService); + ReflectionTestUtils.setField(controller, "passwordTransferCryptoService", passwordTransferCryptoService); + + MockMvc mockMvc = MockMvcBuilders.standaloneSetup(controller).build(); + + mockMvc.perform(put("/system/user/profile/updatePwd") + .contentType("application/json") + .content("{\"oldPassword\":\"oldCipher\",\"newPassword\":\"newCipher\"}")) + .andExpect(status().isOk()); + + verify(passwordTransferCryptoService).decrypt("oldCipher"); + verify(passwordTransferCryptoService).decrypt("newCipher"); + verify(userService).resetUserPwd(org.mockito.ArgumentMatchers.eq(2L), org.mockito.ArgumentMatchers.anyString()); + verify(tokenService).setLoginUser(loginUser); + } +} diff --git a/ruoyi-admin/src/test/java/com/ruoyi/web/controller/system/SysRegisterControllerPasswordTransferTest.java b/ruoyi-admin/src/test/java/com/ruoyi/web/controller/system/SysRegisterControllerPasswordTransferTest.java new file mode 100644 index 0000000..38608ef --- /dev/null +++ b/ruoyi-admin/src/test/java/com/ruoyi/web/controller/system/SysRegisterControllerPasswordTransferTest.java @@ -0,0 +1,50 @@ +package com.ruoyi.web.controller.system; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import com.ruoyi.common.core.domain.model.RegisterBody; +import com.ruoyi.framework.web.service.PasswordTransferCryptoService; +import com.ruoyi.framework.web.service.SysRegisterService; +import com.ruoyi.system.service.ISysConfigService; + +class SysRegisterControllerPasswordTransferTest +{ + @Test + void shouldDecryptPasswordBeforeCallingRegisterService() throws Exception + { + SysRegisterService registerService = mock(SysRegisterService.class); + ISysConfigService configService = mock(ISysConfigService.class); + PasswordTransferCryptoService passwordTransferCryptoService = mock(PasswordTransferCryptoService.class); + when(configService.selectConfigByKey("sys.account.registerUser")).thenReturn("true"); + when(passwordTransferCryptoService.decrypt("cipher")).thenReturn("admin123"); + when(registerService.register(any(RegisterBody.class))).thenReturn(""); + + SysRegisterController controller = new SysRegisterController(); + ReflectionTestUtils.setField(controller, "registerService", registerService); + ReflectionTestUtils.setField(controller, "configService", configService); + ReflectionTestUtils.setField(controller, "passwordTransferCryptoService", passwordTransferCryptoService); + + MockMvc mockMvc = MockMvcBuilders.standaloneSetup(controller).build(); + + mockMvc.perform(post("/register") + .contentType("application/json") + .content("{\"username\":\"u1\",\"password\":\"cipher\",\"code\":\"1\",\"uuid\":\"u\"}")) + .andExpect(status().isOk()); + + verify(passwordTransferCryptoService).decrypt("cipher"); + ArgumentCaptor captor = ArgumentCaptor.forClass(RegisterBody.class); + verify(registerService).register(captor.capture()); + assertEquals("admin123", captor.getValue().getPassword()); + } +} diff --git a/ruoyi-admin/src/test/java/com/ruoyi/web/controller/system/SysUserControllerPasswordTransferTest.java b/ruoyi-admin/src/test/java/com/ruoyi/web/controller/system/SysUserControllerPasswordTransferTest.java new file mode 100644 index 0000000..09c160c --- /dev/null +++ b/ruoyi-admin/src/test/java/com/ruoyi/web/controller/system/SysUserControllerPasswordTransferTest.java @@ -0,0 +1,113 @@ +package com.ruoyi.web.controller.system; + +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.util.Collections; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import com.ruoyi.common.core.domain.entity.SysUser; +import com.ruoyi.common.core.domain.model.LoginUser; +import com.ruoyi.common.utils.SecurityUtils; +import com.ruoyi.framework.web.service.PasswordTransferCryptoService; +import com.ruoyi.system.service.ISysDeptService; +import com.ruoyi.system.service.ISysPostService; +import com.ruoyi.system.service.ISysRoleService; +import com.ruoyi.system.service.ISysUserService; + +class SysUserControllerPasswordTransferTest +{ + @AfterEach + void tearDown() + { + SecurityContextHolder.clearContext(); + } + + @Test + void shouldDecryptPasswordBeforeAddingUser() throws Exception + { + ISysUserService userService = mock(ISysUserService.class); + ISysRoleService roleService = mock(ISysRoleService.class); + ISysDeptService deptService = mock(ISysDeptService.class); + ISysPostService postService = mock(ISysPostService.class); + PasswordTransferCryptoService passwordTransferCryptoService = mock(PasswordTransferCryptoService.class); + when(passwordTransferCryptoService.decrypt("cipher")).thenReturn("initPwd"); + when(userService.checkUserNameUnique(org.mockito.ArgumentMatchers.any(SysUser.class))).thenReturn(true); + when(userService.insertUser(org.mockito.ArgumentMatchers.any(SysUser.class))).thenReturn(1); + + setAuthentication(); + + SysUserController controller = new SysUserController(); + ReflectionTestUtils.setField(controller, "userService", userService); + ReflectionTestUtils.setField(controller, "roleService", roleService); + ReflectionTestUtils.setField(controller, "deptService", deptService); + ReflectionTestUtils.setField(controller, "postService", postService); + ReflectionTestUtils.setField(controller, "passwordTransferCryptoService", passwordTransferCryptoService); + + MockMvc mockMvc = MockMvcBuilders.standaloneSetup(controller).build(); + + mockMvc.perform(post("/system/user") + .contentType("application/json") + .content("{\"userName\":\"u1\",\"nickName\":\"n1\",\"deptId\":1,\"password\":\"cipher\"}")) + .andExpect(status().isOk()); + + verify(passwordTransferCryptoService).decrypt("cipher"); + ArgumentCaptor captor = ArgumentCaptor.forClass(SysUser.class); + verify(userService).insertUser(captor.capture()); + assertTrue(SecurityUtils.matchesPassword("initPwd", captor.getValue().getPassword())); + } + + @Test + void shouldDecryptPasswordBeforeResettingUserPassword() throws Exception + { + ISysUserService userService = mock(ISysUserService.class); + ISysRoleService roleService = mock(ISysRoleService.class); + ISysDeptService deptService = mock(ISysDeptService.class); + ISysPostService postService = mock(ISysPostService.class); + PasswordTransferCryptoService passwordTransferCryptoService = mock(PasswordTransferCryptoService.class); + when(passwordTransferCryptoService.decrypt("cipher")).thenReturn("resetPwd"); + when(userService.resetPwd(org.mockito.ArgumentMatchers.any(SysUser.class))).thenReturn(1); + + setAuthentication(); + + SysUserController controller = new SysUserController(); + ReflectionTestUtils.setField(controller, "userService", userService); + ReflectionTestUtils.setField(controller, "roleService", roleService); + ReflectionTestUtils.setField(controller, "deptService", deptService); + ReflectionTestUtils.setField(controller, "postService", postService); + ReflectionTestUtils.setField(controller, "passwordTransferCryptoService", passwordTransferCryptoService); + + MockMvc mockMvc = MockMvcBuilders.standaloneSetup(controller).build(); + + mockMvc.perform(put("/system/user/resetPwd") + .contentType("application/json") + .content("{\"userId\":2,\"password\":\"cipher\"}")) + .andExpect(status().isOk()); + + verify(passwordTransferCryptoService).decrypt("cipher"); + ArgumentCaptor captor = ArgumentCaptor.forClass(SysUser.class); + verify(userService).resetPwd(captor.capture()); + assertTrue(SecurityUtils.matchesPassword("resetPwd", captor.getValue().getPassword())); + } + + private void setAuthentication() + { + SysUser currentUser = new SysUser(); + currentUser.setUserId(1L); + currentUser.setUserName("admin"); + LoginUser loginUser = new LoginUser(1L, 1L, currentUser, Collections.emptySet()); + SecurityContextHolder.getContext() + .setAuthentication(new UsernamePasswordAuthenticationToken(loginUser, null, Collections.emptyList())); + } +} diff --git a/ruoyi-common/src/main/java/com/ruoyi/common/utils/http/HttpUtils.java b/ruoyi-common/src/main/java/com/ruoyi/common/utils/http/HttpUtils.java index d505789..ece401f 100644 --- a/ruoyi-common/src/main/java/com/ruoyi/common/utils/http/HttpUtils.java +++ b/ruoyi-common/src/main/java/com/ruoyi/common/utils/http/HttpUtils.java @@ -10,18 +10,27 @@ import java.net.SocketTimeoutException; import java.net.URL; import java.net.URLConnection; import java.nio.charset.StandardCharsets; -import java.security.cert.X509Certificate; -import javax.net.ssl.HostnameVerifier; -import javax.net.ssl.HttpsURLConnection; -import javax.net.ssl.SSLContext; -import javax.net.ssl.SSLSession; -import javax.net.ssl.TrustManager; -import javax.net.ssl.X509TrustManager; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import com.ruoyi.common.constant.Constants; -import com.ruoyi.common.utils.StringUtils; -import org.springframework.http.MediaType; +import java.security.cert.X509Certificate; +import java.util.Collections; +import java.util.Map; +import javax.net.ssl.HostnameVerifier; +import javax.net.ssl.HttpsURLConnection; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLSession; +import javax.net.ssl.TrustManager; +import javax.net.ssl.X509TrustManager; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import com.ruoyi.common.constant.Constants; +import com.ruoyi.common.utils.StringUtils; +import org.springframework.http.MediaType; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestTemplate; /** * 通用http发送方法 @@ -282,12 +291,38 @@ public class HttpUtils } } - private static class TrustAnyHostnameVerifier implements HostnameVerifier - { - @Override - public boolean verify(String hostname, SSLSession session) - { - return true; - } - } -} \ No newline at end of file + private static class TrustAnyHostnameVerifier implements HostnameVerifier + { + @Override + public boolean verify(String hostname, SSLSession session) + { + return true; + } + } + + public static T doPostFormUrlEncoded(String url, Map params, HttpHeaders headers, Class responseType) + { + MultiValueMap formParams = new LinkedMultiValueMap(); + if (params != null && !params.isEmpty()) + { + formParams.setAll(params); + } + + HttpHeaders requestHeaders = headers == null ? new HttpHeaders() : headers; + requestHeaders.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + requestHeaders.setAcceptCharset(Collections.singletonList(StandardCharsets.UTF_8)); + + HttpEntity> requestEntity = new HttpEntity>(formParams, requestHeaders); + RestTemplate restTemplate = new RestTemplate(); + try + { + ResponseEntity response = restTemplate.exchange(url, HttpMethod.POST, requestEntity, responseType); + log.info("POST(form-urlencoded) 请求成功,URL:{},响应结果:{}", url, response.getBody()); + return response.getBody(); + } + catch (Exception e) + { + throw new RuntimeException("POST(form-urlencoded) 请求失败,URL:" + url + ",异常信息:" + e.getMessage(), e); + } + } +} diff --git a/ruoyi-framework/pom.xml b/ruoyi-framework/pom.xml index 5ffcc3d..c9fb692 100644 --- a/ruoyi-framework/pom.xml +++ b/ruoyi-framework/pom.xml @@ -29,11 +29,16 @@ spring-boot-starter-aop - - - com.alibaba - druid-spring-boot-starter - + + + com.alibaba + druid-spring-boot-starter + + + + com.baomidou + mybatis-plus-boot-starter + diff --git a/ruoyi-framework/src/main/java/com/ruoyi/framework/config/ApplicationConfig.java b/ruoyi-framework/src/main/java/com/ruoyi/framework/config/ApplicationConfig.java index 66a00e7..b0b9e3d 100644 --- a/ruoyi-framework/src/main/java/com/ruoyi/framework/config/ApplicationConfig.java +++ b/ruoyi-framework/src/main/java/com/ruoyi/framework/config/ApplicationConfig.java @@ -1,9 +1,12 @@ -package com.ruoyi.framework.config; - -import java.util.TimeZone; -import org.mybatis.spring.annotation.MapperScan; -import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer; -import org.springframework.context.annotation.Bean; +package com.ruoyi.framework.config; + +import java.util.TimeZone; +import com.baomidou.mybatisplus.annotation.DbType; +import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor; +import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor; +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer; +import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.EnableAspectJAutoProxy; @@ -23,8 +26,16 @@ public class ApplicationConfig * 时区配置 */ @Bean - public Jackson2ObjectMapperBuilderCustomizer jacksonObjectMapperCustomization() - { - return jacksonObjectMapperBuilder -> jacksonObjectMapperBuilder.timeZone(TimeZone.getDefault()); - } -} + public Jackson2ObjectMapperBuilderCustomizer jacksonObjectMapperCustomization() + { + return jacksonObjectMapperBuilder -> jacksonObjectMapperBuilder.timeZone(TimeZone.getDefault()); + } + + @Bean + public MybatisPlusInterceptor mybatisPlusInterceptor() + { + MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); + interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); + return interceptor; + } +} diff --git a/ruoyi-framework/src/main/java/com/ruoyi/framework/config/MyBatisConfig.java b/ruoyi-framework/src/main/java/com/ruoyi/framework/config/MyBatisConfig.java index e30fe74..c2707dd 100644 --- a/ruoyi-framework/src/main/java/com/ruoyi/framework/config/MyBatisConfig.java +++ b/ruoyi-framework/src/main/java/com/ruoyi/framework/config/MyBatisConfig.java @@ -1,28 +1,29 @@ -package com.ruoyi.framework.config; - -import java.io.IOException; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.HashSet; -import java.util.List; -import javax.sql.DataSource; -import org.apache.ibatis.io.VFS; -import org.apache.ibatis.session.SqlSessionFactory; -import org.mybatis.spring.SqlSessionFactoryBean; -import org.mybatis.spring.boot.autoconfigure.SpringBootVFS; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.core.env.Environment; -import org.springframework.core.io.DefaultResourceLoader; -import org.springframework.core.io.Resource; -import org.springframework.core.io.support.PathMatchingResourcePatternResolver; -import org.springframework.core.io.support.ResourcePatternResolver; -import org.springframework.core.type.classreading.CachingMetadataReaderFactory; -import org.springframework.core.type.classreading.MetadataReader; -import org.springframework.core.type.classreading.MetadataReaderFactory; -import org.springframework.util.ClassUtils; -import com.ruoyi.common.utils.StringUtils; +package com.ruoyi.framework.config; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import javax.sql.DataSource; +import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; +import org.apache.ibatis.io.VFS; +import org.apache.ibatis.plugin.Interceptor; +import org.apache.ibatis.session.SqlSessionFactory; +import org.mybatis.spring.boot.autoconfigure.SpringBootVFS; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.env.Environment; +import org.springframework.core.io.DefaultResourceLoader; +import org.springframework.core.io.Resource; +import org.springframework.core.io.support.PathMatchingResourcePatternResolver; +import org.springframework.core.io.support.ResourcePatternResolver; +import org.springframework.core.type.classreading.CachingMetadataReaderFactory; +import org.springframework.core.type.classreading.MetadataReader; +import org.springframework.core.type.classreading.MetadataReaderFactory; +import org.springframework.util.ClassUtils; +import com.ruoyi.common.utils.StringUtils; /** * Mybatis支持*匹配扫描包 @@ -32,8 +33,11 @@ import com.ruoyi.common.utils.StringUtils; @Configuration public class MyBatisConfig { - @Autowired - private Environment env; + @Autowired + private Environment env; + + @Autowired(required = false) + private Interceptor[] interceptors; static final String DEFAULT_RESOURCE_PATTERN = "**/*.class"; @@ -122,11 +126,15 @@ public class MyBatisConfig typeAliasesPackage = setTypeAliasesPackage(typeAliasesPackage); VFS.addImplClass(SpringBootVFS.class); - final SqlSessionFactoryBean sessionFactory = new SqlSessionFactoryBean(); - sessionFactory.setDataSource(dataSource); - sessionFactory.setTypeAliasesPackage(typeAliasesPackage); - sessionFactory.setMapperLocations(resolveMapperLocations(StringUtils.split(mapperLocations, ","))); - sessionFactory.setConfigLocation(new DefaultResourceLoader().getResource(configLocation)); - return sessionFactory.getObject(); - } -} \ No newline at end of file + final MybatisSqlSessionFactoryBean sessionFactory = new MybatisSqlSessionFactoryBean(); + sessionFactory.setDataSource(dataSource); + sessionFactory.setTypeAliasesPackage(typeAliasesPackage); + sessionFactory.setMapperLocations(resolveMapperLocations(StringUtils.split(mapperLocations, ","))); + sessionFactory.setConfigLocation(new DefaultResourceLoader().getResource(configLocation)); + if (interceptors != null) + { + sessionFactory.setPlugins(interceptors); + } + return sessionFactory.getObject(); + } +} diff --git a/ruoyi-framework/src/main/java/com/ruoyi/framework/web/service/PasswordTransferCryptoService.java b/ruoyi-framework/src/main/java/com/ruoyi/framework/web/service/PasswordTransferCryptoService.java new file mode 100644 index 0000000..a6cd10f --- /dev/null +++ b/ruoyi-framework/src/main/java/com/ruoyi/framework/web/service/PasswordTransferCryptoService.java @@ -0,0 +1,30 @@ +package com.ruoyi.framework.web.service; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import javax.crypto.Cipher; +import javax.crypto.spec.SecretKeySpec; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import com.ruoyi.common.exception.ServiceException; + +@Service +public class PasswordTransferCryptoService +{ + @Value("${security.password-transfer.key}") + private String key; + + public String decrypt(String cipherText) + { + try + { + Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding"); + cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), "AES")); + return new String(cipher.doFinal(Base64.getDecoder().decode(cipherText)), StandardCharsets.UTF_8); + } + catch (Exception ex) + { + throw new ServiceException("密码解密失败"); + } + } +} diff --git a/ruoyi-loan-pricing/pom.xml b/ruoyi-loan-pricing/pom.xml new file mode 100644 index 0000000..f37c953 --- /dev/null +++ b/ruoyi-loan-pricing/pom.xml @@ -0,0 +1,52 @@ + + + + ruoyi + com.ruoyi + 3.9.2 + + 4.0.0 + jar + + ruoyi-loan-pricing + + + 利率定价模块 + + + + + + + com.ruoyi + ruoyi-common + + + + com.ruoyi + ruoyi-framework + + + + com.ruoyi + ruoyi-system + + + + org.projectlombok + lombok + 1.18.36 + true + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + diff --git a/ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/controller/LoanPricingWorkflowController.java b/ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/controller/LoanPricingWorkflowController.java new file mode 100644 index 0000000..def42cc --- /dev/null +++ b/ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/controller/LoanPricingWorkflowController.java @@ -0,0 +1,100 @@ +package com.ruoyi.loanpricing.controller; + +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.ruoyi.common.annotation.Log; +import com.ruoyi.common.core.controller.BaseController; +import com.ruoyi.common.core.domain.AjaxResult; +import com.ruoyi.common.core.page.PageDomain; +import com.ruoyi.common.core.page.TableDataInfo; +import com.ruoyi.common.core.page.TableSupport; +import com.ruoyi.common.enums.BusinessType; +import com.ruoyi.loanpricing.domain.dto.CorporateLoanPricingCreateDTO; +import com.ruoyi.loanpricing.domain.dto.PersonalLoanPricingCreateDTO; +import com.ruoyi.loanpricing.domain.entity.LoanPricingWorkflow; +import com.ruoyi.loanpricing.domain.vo.LoanPricingWorkflowListVO; +import com.ruoyi.loanpricing.domain.vo.LoanPricingWorkflowVO; +import com.ruoyi.loanpricing.service.ILoanPricingWorkflowService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; + +/** + * 利率定价流程Controller + * + * @author ruoyi + * @date 2025-01-19 + */ +@RestController +@RequestMapping("/loanPricing/workflow") +public class LoanPricingWorkflowController extends BaseController +{ + @Autowired + private ILoanPricingWorkflowService loanPricingWorkflowService; + + /** + * 发起个人客户利率定价流程 + */ + @Log(title = "个人客户利率定价流程", businessType = BusinessType.INSERT) + @PostMapping("/create/personal") + public AjaxResult createPersonal(@Validated @RequestBody PersonalLoanPricingCreateDTO dto) { + LoanPricingWorkflow result = loanPricingWorkflowService.createPersonalLoanPricing(dto); + return success(result); + } + + /** + * 发起企业客户利率定价流程 + */ + @Log(title = "企业客户利率定价流程", businessType = BusinessType.INSERT) + @PostMapping("/create/corporate") + public AjaxResult createCorporate(@Validated @RequestBody CorporateLoanPricingCreateDTO dto) + { + LoanPricingWorkflow result = loanPricingWorkflowService.createCorporateLoanPricing(dto); + return success(result); + } + + /** + * 查询利率定价流程列表 + */ + @GetMapping("/list") + public TableDataInfo list(LoanPricingWorkflow loanPricingWorkflow) + { + PageDomain pageDomain = TableSupport.buildPageRequest(); + Page page = new Page<>(pageDomain.getPageNum(), pageDomain.getPageSize()); + IPage result = loanPricingWorkflowService.selectLoanPricingPage(page, loanPricingWorkflow); + TableDataInfo rspData = new TableDataInfo(); + rspData.setCode(200); + rspData.setMsg("查询成功"); + rspData.setRows(result.getRecords()); + rspData.setTotal(result.getTotal()); + return rspData; + } + + /** + * 查询利率定价流程详情 + */ + @GetMapping("/{serialNum}") + public AjaxResult getInfo(@PathVariable("serialNum") String serialNum) + { + LoanPricingWorkflowVO workflow = loanPricingWorkflowService.selectLoanPricingBySerialNum(serialNum); + if (workflow == null) + { + return error("记录不存在"); + } + return success(workflow); + } + + /** + * 设定执行利率 + */ + @Log(title = "利率定价流程", businessType = BusinessType.UPDATE) + @PutMapping("/{serialNum}/executeRate") + public AjaxResult setExecuteRate(@PathVariable("serialNum") String serialNum, + @RequestBody Map request) { + String executeRate = request.get("executeRate"); + boolean success = loanPricingWorkflowService.setExecuteRate(serialNum, executeRate); + return success ? success() : error("设定失败"); + } +} diff --git a/ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/controller/LoanRatePricingMockController.java b/ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/controller/LoanRatePricingMockController.java new file mode 100644 index 0000000..c049458 --- /dev/null +++ b/ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/controller/LoanRatePricingMockController.java @@ -0,0 +1,50 @@ +package com.ruoyi.loanpricing.controller; + + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.ruoyi.common.annotation.Anonymous; +import com.ruoyi.common.core.controller.BaseController; +import com.ruoyi.common.core.domain.AjaxResult; +import com.ruoyi.loanpricing.domain.dto.ModelInvokeDTO; +import org.springframework.core.io.ClassPathResource; +import org.springframework.web.bind.annotation.*; + + +import java.io.InputStream; + + +/** + * @Author 吴凯程 + * @Date 2025/11/10 + **/ +@RestController +@RequestMapping("/rate/pricing/mock") +public class LoanRatePricingMockController extends BaseController { + + @Anonymous + @PostMapping("/invokeModel") + public AjaxResult invokeModel( ModelInvokeDTO modelInvokeDTO) { + ObjectNode jsonNodes; + if (modelInvokeDTO.getCustType().equals("个人")) { + jsonNodes = loadJsonFromResource("data/retail_output.json"); + } else { + jsonNodes = loadJsonFromResource("data/corp_output.json"); + } + + return new AjaxResult(10000, "success", jsonNodes); + } + + private ObjectNode loadJsonFromResource(String resourcePath){ + ClassPathResource classPathResource = new ClassPathResource(resourcePath); + try (InputStream inputStream = classPathResource.getInputStream();){ + + ObjectMapper objectMapper = new ObjectMapper(); + return objectMapper.readValue(inputStream, ObjectNode.class); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + +} diff --git a/ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/domain/dto/CorporateLoanPricingCreateDTO.java b/ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/domain/dto/CorporateLoanPricingCreateDTO.java new file mode 100644 index 0000000..2675651 --- /dev/null +++ b/ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/domain/dto/CorporateLoanPricingCreateDTO.java @@ -0,0 +1,48 @@ +package com.ruoyi.loanpricing.domain.dto; + +import lombok.Data; +import java.io.Serializable; +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.Pattern; + +/** + * 企业客户利率定价发起DTO + * + * @author ruoyi + * @date 2025-01-19 + */ +@Data +public class CorporateLoanPricingCreateDTO implements Serializable { + + private static final long serialVersionUID = 1L; + + @NotBlank(message = "客户内码不能为空") + private String custIsn; + + private String custName; + + private String idType; + + private String idNum; + + @NotBlank(message = "担保方式不能为空") + @Pattern(regexp = "^(信用|保证|抵押|质押)$", message = "担保方式必须是:信用、保证、抵押、质押之一") + private String guarType; + + @NotBlank(message = "申请金额不能为空") + private String applyAmt; + + private String loanTerm; + + private String isAgriGuar; + + private String isGreenLoan; + + private String isTechEnt; + + private String isTradeConstruction; + + private String collType; + + private String collThirdParty; +} diff --git a/ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/domain/dto/ModelInvokeDTO.java b/ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/domain/dto/ModelInvokeDTO.java new file mode 100644 index 0000000..d65bbfb --- /dev/null +++ b/ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/domain/dto/ModelInvokeDTO.java @@ -0,0 +1,170 @@ +package com.ruoyi.loanpricing.domain.dto; + +import lombok.Data; + +/** + * @Author 吴凯程 + * @Date 2025/12/23 + **/ +@Data +public class ModelInvokeDTO { + /** + * 业务方流水号(必填) + * 可使用时间戳,或自定义随机生成 + */ + private String serialNum; + + /** + * 机构编码(必填) + * 固定值:892000 + */ + private String orgCode; + + /** + * 运行模式(必填) + * 固定值:1:同步 + */ + private String runType = "1"; + + /** + * 客户内码(必填) + */ + private String custIsn; + + /** + * 客户类型(必填) + * 可选值:个人/企业 + */ + private String custType; + + /** + * 担保方式(必填) + * 可选值:信用,保证,抵押,质押 + */ + private String guarType; + + /** + * 中间业务_个人_快捷支付(非必填) + * 可选值:true/false + */ + private String midPerQuickPay; + + /** + * 中间业务_个人_电费代扣(非必填) + * 可选值:true/false + */ + private String midPerEleDdc; + + /** + * 中间业务_企业_电费代扣(非必填) + * 可选值:true/false + */ + private String midEntEleDdc; + + /** + * 中间业务_企业_水费代扣(非必填) + * 可选值:true/false + */ + private String midEntWaterDdc; + + /** + * 申请金额(必填) + * 单位:元 + */ + private String applyAmt; + + /** + * 贷款期限(必填) + * 单位:年 + */ + private String loanTerm; + + /** + * 净身企业(非必填) + * 可选值:true/false + */ + private String isCleanEnt; + + /** + * 开立基本结算账户(非必填) + * 可选值:true/false + */ + private String hasSettleAcct; + + /** + * 制造业企业(非必填) + * 可选值:true/false + */ + private String isManufacturing; + + /** + * 省农担担保贷款(非必填) + * 可选值:true/false + */ + private String isAgriGuar; + + /** + * 是否纳税信用等级A级(非必填) + * 可选值:true/false + */ + private String isTaxA; + + /** + * 是否县级及以上农业龙头企业(非必填) + * 可选值:true/false + */ + private String isAgriLeading; + + private String isInclusiveFinance; + + /** + * 贷款用途(非必填) + * 可选值:consumer/business + */ + private String loanPurpose; + + /** + * 是否有经营佐证(非必填) + * 可选值:0/1 + */ + private String bizProof; + + /** + * 循环功能(非必填) + * 可选值:0/1 + */ + private String loanLoop; + + /** + * 抵质押类型(非必填) + * 可选值:一类/二类/三类 + */ + private String collType; + + /** + * 抵质押物是否三方所有(非必填) + * 可选值:0/1 + */ + private String collThirdParty; + +// /** +// * 贷款利率(必填) +// */ +// private String loanRate; + + /** + * 客户名称(非必填) + */ + private String custName; + + /** + * 证件类型(非必填) + */ + private String idType; + + /** + * 证件号码(非必填) + */ + private String idNum; + +} diff --git a/ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/domain/dto/PersonalLoanPricingCreateDTO.java b/ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/domain/dto/PersonalLoanPricingCreateDTO.java new file mode 100644 index 0000000..a8e97af --- /dev/null +++ b/ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/domain/dto/PersonalLoanPricingCreateDTO.java @@ -0,0 +1,49 @@ +package com.ruoyi.loanpricing.domain.dto; + +import lombok.Data; +import java.io.Serializable; +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.Pattern; + +/** + * 个人客户利率定价发起DTO + * + * @author ruoyi + * @date 2025-01-19 + */ +@Data +public class PersonalLoanPricingCreateDTO implements Serializable { + + private static final long serialVersionUID = 1L; + + @NotBlank(message = "客户内码不能为空") + private String custIsn; + + private String custName; + + private String idType; + + private String idNum; + + @NotBlank(message = "担保方式不能为空") + @Pattern(regexp = "^(信用|保证|抵押|质押)$", message = "担保方式必须是:信用、保证、抵押、质押之一") + private String guarType; + + @NotBlank(message = "申请金额不能为空") + private String applyAmt; + + @NotBlank(message = "贷款用途不能为空") + @Pattern(regexp = "^(consumer|business)$", message = "贷款用途必须是:consumer、business之一") + private String loanPurpose; + + @NotBlank(message = "借款期限不能为空") + private String loanTerm; + + private String bizProof; + + private String loanLoop; + + private String collType; + + private String collThirdParty; +} diff --git a/ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/domain/entity/LoanPricingWorkflow.java b/ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/domain/entity/LoanPricingWorkflow.java new file mode 100644 index 0000000..f64a162 --- /dev/null +++ b/ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/domain/entity/LoanPricingWorkflow.java @@ -0,0 +1,159 @@ +package com.ruoyi.loanpricing.domain.entity; + +import com.baomidou.mybatisplus.annotation.*; +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Data; +import javax.validation.constraints.NotBlank; + +import java.io.Serializable; +import java.util.Date; + +/** + * 利率定价流程对象 loan_pricing_workflow + * + * @author ruoyi + * @date 2025-01-19 + */ +@Data +@TableName("loan_pricing_workflow") +public class LoanPricingWorkflow implements Serializable +{ + private static final long serialVersionUID = 1L; + + /** 主键ID */ + @TableId(type = IdType.AUTO) + private Long id; + + /** 模型输出ID */ + private Long modelOutputId; + + /** 业务方流水号 */ + private String serialNum; + + /** 机构编码 */ + private String orgCode; + + /** 运行模式: 1-同步 */ + private String runType; + + /** 客户内码 */ + @NotBlank(message = "客户内码不能为空") + private String custIsn; + + /** 客户类型: 个人/企业 */ + @NotBlank(message = "客户类型不能为空") + private String custType; + + /** 担保方式: 信用/保证/抵押/质押 */ + @NotBlank(message = "担保方式不能为空") + private String guarType; + + /** 中间业务_个人_快捷支付: true/false */ + private String midPerQuickPay; + + /** 中间业务_个人_电费代扣: true/false */ + private String midPerEleDdc; + + /** 中间业务_企业_电费代扣: true/false */ + private String midEntEleDdc; + + /** 中间业务_企业_水费代扣: true/false */ + private String midEntWaterDdc; + + /** 申请金额(元) */ + @NotBlank(message = "申请金额不能为空") + private String applyAmt; + + /** + * 贷款期限 + */ + private String loanTerm; + + /** 净身企业: true/false */ + private String isCleanEnt; + + /** 开立基本结算账户: true/false */ + private String hasSettleAcct; + + /** 制造业企业: true/false */ + private String isManufacturing; + + /** 省农担担保贷款: true/false */ + private String isAgriGuar; + + /** + * 贸易和建筑业企业标识: true/false + */ + private String isTradeConstruction; + + /** + * 绿色贷款: true/false + */ + private String isGreenLoan; + + /** + * 科技型企业: true/false + */ + private String isTechEnt; + + /** 是否纳税信用等级A级: true/false */ + private String isTaxA; + + /** 是否县级及以上农业龙头企业: true/false */ + private String isAgriLeading; + + /** 贷款用途: consumer-消费/business-经营 */ + private String loanPurpose; + + /** 是否有经营佐证: true/false */ + private String bizProof; + + /** 循环功能: true/false */ + private String loanLoop; + + /** 抵质押类型: 一线/一类/二类 */ + private String collType; + + /** 抵质押物是否三方所有: true/false */ + private String collThirdParty; + + /** 贷款利率 */ + private String loanRate; + + /** + * 执行利率(%) + */ + private String executeRate; + + /** 客户名称 */ + private String custName; + + /** + * 证件类型 + */ + private String idType; + + /** 证件号码 */ + private String idNum; + + /** 是否普惠小微借款人: true/false */ + private String isInclusiveFinance; + + /** 创建者 */ + @TableField(fill = FieldFill.INSERT) + private String createBy; + + /** 创建时间 */ + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + @TableField(fill = FieldFill.INSERT) + private Date createTime; + + /** 更新者 */ + @TableField(fill = FieldFill.INSERT_UPDATE) + private String updateBy; + + /** 更新时间 */ + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + @TableField(fill = FieldFill.INSERT_UPDATE) + private Date updateTime; +} diff --git a/ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/domain/entity/ModelCorpOutputFields.java b/ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/domain/entity/ModelCorpOutputFields.java new file mode 100644 index 0000000..c7ec5ad --- /dev/null +++ b/ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/domain/entity/ModelCorpOutputFields.java @@ -0,0 +1,125 @@ +package com.ruoyi.loanpricing.domain.entity; + +import com.baomidou.mybatisplus.annotation.FieldFill; +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import lombok.Data; + +import java.util.Date; + +/** + * 贷款定价模型输入参数对象 + * + * @author ruoyi + * @date 2025-01-21 + */ +@Data +public class ModelCorpOutputFields { + + @TableId(type = IdType.AUTO) + private Long id; + // 客户内码 + private String custIsn; + // 客户类型 + private String custType; + // 担保方式 + private String guarType; + // 客户名称 + private String custName; + // 证件类型 + private String idType; + // 证件号码 + private String idNum; + // 基准利率 + private String baseLoanRate; + // 我行首贷客户 + private String isFirstLoan; + // 用信天数 + private String faithDay; + // BP_首贷 + private String bpFirstLoan; + // BP_贷龄 + private String bpAgeLoan; + // TOTAL_BP_忠诚度 + private String totalBpLoyalty; + // 存款年日均 + private String balanceAvg; + // 贷款年日均 + private String loanAvg; + // 派生率 + private String derivationRate; + // TOTAL_BP_贡献度 + private String totalBpContribution; + // 中间业务_企业_企业互联 + private String midEntConnect; + // 中间业务_企业_有效价值客户 + private String midEntEffect; + // 中间业务_企业_国际业务 + private String midEntInter; + // 中间业务_企业_承兑 + private String midEntAccept; + // 中间业务_企业_贴现 + private String midEntDiscount; + // 中间业务_企业_电费代扣 + private String midEntEleDdc; + // 中间业务_企业_水费代扣 + private String midEntWaterDdc; + // 中间业务_企业_税务代扣 + private String midEntTax; + // BP_中间业务 + private String bpMid; + // 代发工资户数 + private String payroll; + // 存量贷款余额 + private String invLoanAmount; + // BP_代发工资 + private String bpPayroll; + // 净身企业 + private String isCleanEnt; + // 开立基本结算账户 + private String hasSettleAcct; + // 省农担担保贷款 + private String isAgriGuar; + // 绿色贷款 + private String isGreenLoan; + // 科技型企业 + private String isTechEnt; + // BP_企业客户类别 + private String bpEntType; + // TOTAL_BP_关联度 + private String totoalBpRelevance; + // 贷款期限 + private String loanTerm; + // BP_贷款期限 + private String bpLoanTerm; + // 申请金额 + private String applyAmt; + // BP_贷款额度 + private String bpLoanAmount; + // 抵质押类型 + private String collType; + // 抵质押物是否三方所有 + private String collThirdParty; + // BP_抵押物 + private String bpCollateral; + // 灰名单客户 + private String greyCust; + // 本金逾期 + private String prinOverdue; + // 利息逾期 + private String interestOverdue; + // 信用卡逾期 + private String cardOverdue; + // BP_灰名单与逾期 + private String bpGreyOverdue; + // TOTAL_BP_风险度 + private String totoalBpRisk; + // 浮动BP + private String totalBp; + // 测算利率 + private String calculateRate; + + @TableField(fill = FieldFill.INSERT) + private Date createTime; +} diff --git a/ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/domain/entity/ModelRetailOutputFields.java b/ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/domain/entity/ModelRetailOutputFields.java new file mode 100644 index 0000000..ee72e3a --- /dev/null +++ b/ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/domain/entity/ModelRetailOutputFields.java @@ -0,0 +1,190 @@ +package com.ruoyi.loanpricing.domain.entity; + +import com.baomidou.mybatisplus.annotation.FieldFill; +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import lombok.Data; + +import java.util.Date; + +/** + * 贷款定价模型输入参数对象 + * + * @author ruoyi + * @date 2025-01-21 + */ +@Data +public class ModelRetailOutputFields { + + @TableId(type = IdType.AUTO) + private Long id; + + // 客户内码 + private String custIsn; + + // 客户类型 + private String custType; + + // 担保方式 + private String guarType; + + // 客户名称 + private String custName; + + // 证件类型 + private String idType; + + // 证件号码 + private String idNum; + + // 基准利率 + private String baseLoanRate; + + // 我行首贷客户 + private String isFirstLoan; + + // 用信天数 + private String faithDay; + + // 客户年龄 + private String custAge; + + // BP_首贷 + private String bpFirstLoan; + + // BP_贷龄 + private String bpAgeLoan; + + // BP_年龄 + private String bpAge; + + // TOTAL_BP_忠诚度 + private String totalBpLoyalty; + + // 存款年日均 + private String balanceAvg; + + // 贷款年日均 + private String loanAvg; + + // 派生率 + private String derivationRate; + + // TOTAL_BP_贡献度 + private String totalBpContribution; + + // 中间业务_个人_信用卡 + private String midPerCard; + + // 中间业务_个人_一码通 + private String midPerPass; + + // 中间业务_个人_丰收互联 + private String midPerHarvest; + + // 中间业务_个人_有效客户 + private String midPerEffect; + + // 中间业务_个人_快捷支付 + private String midPerQuickPay; + + // 中间业务_个人_电费代扣 + private String midPerEleDdc; + + // 中间业务_个人_水费代扣 + private String midPerWaterDdc; + + // 中间业务_个人_华数费代扣 + private String midPerHuashuDdc; + + // 中间业务_个人_煤气费代扣 + private String MidPerGasDdc; + + // 中间业务_个人_市民卡 + private String midPerCitizencard; + + // 中间业务_个人_理财业务 + private String midPerFinMan; + + // 中间业务_个人_etc + private String midPerEtc; + + // BP_中间业务 + private String bpMid; + + // TOTAL_BP_关联度(注意原字段名拼写错误:totoalBpRelevance,已保留原拼写) + private String totoalBpRelevance; + + // 申请金额 + private String applyAmt; + + // BP_贷款额度 + private String bpLoanAmount; + + // 贷款用途 + private String loanPurpose; + + // 是否有经营佐证 + private String bizProof; + + // BP_贷款用途 + private String bpLoanUse; + + // 循环功能 + private String loanLoop; + + // BP_循环功能 + private String bpLoanLoop; + + // 抵质押类型 + private String collType; + + // 抵质押物是否三方所有 + private String collThirdParty; + + // BP_抵押物 + private String bpCollateral; + + // 灰名单客户 + private String greyCust; + + // 本金逾期 + private String prinOverdue; + + // 利息逾期 + private String interestOverdue; + + // 信用卡逾期 + private String cardOverdue; + + // BP_灰名单与逾期 + private String bpGreyOverdue; + + // TOTAL_BP_风险度(注意原字段名拼写错误:totoalBpRisk,已保留原拼写) + private String totoalBpRisk; + + // 浮动BP + private String totalBp; + + // 测算利率 + private String calculateRate; + + // 历史利率 + private String loanRateHistory; + + // 产品最低利率下限 + private String minRateProduct; + + // 平滑幅度 + private String smoothRange; + + // 最终测算利率 + private String finalCalculateRate; + + // 参考利率 + private String referenceRate; + + @TableField(fill = FieldFill.INSERT) + private Date createTime; +} diff --git a/ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/domain/vo/LoanPricingWorkflowListVO.java b/ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/domain/vo/LoanPricingWorkflowListVO.java new file mode 100644 index 0000000..80ef681 --- /dev/null +++ b/ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/domain/vo/LoanPricingWorkflowListVO.java @@ -0,0 +1,27 @@ +package com.ruoyi.loanpricing.domain.vo; + +import lombok.Data; + +import java.util.Date; + +@Data +public class LoanPricingWorkflowListVO +{ + private String serialNum; + + private String custName; + + private String custType; + + private String guarType; + + private String applyAmt; + + private String calculateRate; + + private String executeRate; + + private Date createTime; + + private String createBy; +} diff --git a/ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/domain/vo/LoanPricingWorkflowVO.java b/ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/domain/vo/LoanPricingWorkflowVO.java new file mode 100644 index 0000000..bac1861 --- /dev/null +++ b/ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/domain/vo/LoanPricingWorkflowVO.java @@ -0,0 +1,21 @@ +package com.ruoyi.loanpricing.domain.vo; + +import com.ruoyi.loanpricing.domain.entity.LoanPricingWorkflow; +import com.ruoyi.loanpricing.domain.entity.ModelCorpOutputFields; +import com.ruoyi.loanpricing.domain.entity.ModelRetailOutputFields; +import lombok.Data; + +/** + * @Author: wkc + * @CreateTime: 2026-01-21 + */ +@Data +public class LoanPricingWorkflowVO { + + private LoanPricingWorkflow loanPricingWorkflow; + + private ModelRetailOutputFields modelRetailOutputFields; + + private ModelCorpOutputFields modelCorpOutputFields; + +} diff --git a/ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/mapper/LoanPricingWorkflowMapper.java b/ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/mapper/LoanPricingWorkflowMapper.java new file mode 100644 index 0000000..63746ef --- /dev/null +++ b/ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/mapper/LoanPricingWorkflowMapper.java @@ -0,0 +1,20 @@ +package com.ruoyi.loanpricing.mapper; + +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.ruoyi.loanpricing.domain.entity.LoanPricingWorkflow; +import com.ruoyi.loanpricing.domain.vo.LoanPricingWorkflowListVO; +import org.apache.ibatis.annotations.Param; + +/** + * 利率定价流程Mapper接口 + * + * @author ruoyi + * @date 2025-01-19 + */ +public interface LoanPricingWorkflowMapper extends BaseMapper +{ + IPage selectWorkflowPageWithRates(Page page, + @Param("query") LoanPricingWorkflow query); +} diff --git a/ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/mapper/ModelCorpOutputFieldsMapper.java b/ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/mapper/ModelCorpOutputFieldsMapper.java new file mode 100644 index 0000000..e02b519 --- /dev/null +++ b/ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/mapper/ModelCorpOutputFieldsMapper.java @@ -0,0 +1,15 @@ +package com.ruoyi.loanpricing.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.ruoyi.loanpricing.domain.entity.ModelCorpOutputFields; + +/** + * 对公贷款定价模型输出字段Mapper接口 + * + * @author ruoyi + * @date 2025-01-21 + */ +public interface ModelCorpOutputFieldsMapper extends BaseMapper +{ + +} diff --git a/ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/mapper/ModelRetailOutputFieldsMapper.java b/ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/mapper/ModelRetailOutputFieldsMapper.java new file mode 100644 index 0000000..f3aa994 --- /dev/null +++ b/ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/mapper/ModelRetailOutputFieldsMapper.java @@ -0,0 +1,15 @@ +package com.ruoyi.loanpricing.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.ruoyi.loanpricing.domain.entity.ModelRetailOutputFields; + +/** + * 零售贷款定价模型输出字段Mapper接口 + * + * @author ruoyi + * @date 2025-01-21 + */ +public interface ModelRetailOutputFieldsMapper extends BaseMapper +{ + +} diff --git a/ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/service/ILoanPricingWorkflowService.java b/ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/service/ILoanPricingWorkflowService.java new file mode 100644 index 0000000..0f8cda2 --- /dev/null +++ b/ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/service/ILoanPricingWorkflowService.java @@ -0,0 +1,70 @@ +package com.ruoyi.loanpricing.service; + +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.ruoyi.loanpricing.domain.dto.CorporateLoanPricingCreateDTO; +import com.ruoyi.loanpricing.domain.dto.PersonalLoanPricingCreateDTO; +import com.ruoyi.loanpricing.domain.entity.LoanPricingWorkflow; +import com.ruoyi.loanpricing.domain.vo.LoanPricingWorkflowListVO; +import com.ruoyi.loanpricing.domain.vo.LoanPricingWorkflowVO; + +import java.util.List; + +/** + * 利率定价流程Service接口 + * + * @author ruoyi + * @date 2025-01-19 + */ +public interface ILoanPricingWorkflowService +{ + /** + * 发起个人客户利率定价流程 + * + * @param dto 个人客户发起DTO + * @return 结果 + */ + public LoanPricingWorkflow createPersonalLoanPricing(PersonalLoanPricingCreateDTO dto); + + /** + * 发起企业客户利率定价流程 + * + * @param dto 企业客户发起DTO + * @return 结果 + */ + public LoanPricingWorkflow createCorporateLoanPricing(CorporateLoanPricingCreateDTO dto); + + /** + * 查询利率定价流程列表 + * + * @param loanPricingWorkflow 利率定价流程信息 + * @return 利率定价流程集合 + */ + public List selectLoanPricingList(LoanPricingWorkflow loanPricingWorkflow); + + /** + * 分页查询利率定价流程列表 + * + * @param page 分页参数 + * @param loanPricingWorkflow 利率定价流程信息 + * @return 分页结果 + */ + public IPage selectLoanPricingPage(Page page, LoanPricingWorkflow loanPricingWorkflow); + + /** + * 查询利率定价流程详情 + * + * @param serialNum 业务方流水号 + * @return 利率定价流程 + */ + public LoanPricingWorkflowVO selectLoanPricingBySerialNum(String serialNum); + + /** + * 设定执行利率 + * + * @param serialNum 业务方流水号 + * @param executeRate 执行利率 + * @return 是否成功 + */ + public boolean setExecuteRate(String serialNum, String executeRate); +} diff --git a/ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/service/LoanPricingModelService.java b/ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/service/LoanPricingModelService.java new file mode 100644 index 0000000..c4b457e --- /dev/null +++ b/ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/service/LoanPricingModelService.java @@ -0,0 +1,109 @@ +package com.ruoyi.loanpricing.service; + +import com.alibaba.fastjson2.JSON; +import com.alibaba.fastjson2.JSONObject; +import com.ruoyi.common.utils.bean.BeanUtils; +import com.ruoyi.loanpricing.domain.dto.ModelInvokeDTO; +import com.ruoyi.loanpricing.domain.entity.LoanPricingWorkflow; +import com.ruoyi.loanpricing.domain.entity.ModelCorpOutputFields; +import com.ruoyi.loanpricing.domain.entity.ModelRetailOutputFields; +import com.ruoyi.loanpricing.mapper.LoanPricingWorkflowMapper; +import com.ruoyi.loanpricing.mapper.ModelCorpOutputFieldsMapper; +import com.ruoyi.loanpricing.mapper.ModelRetailOutputFieldsMapper; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.stereotype.Service; +import java.util.Objects; +import javax.annotation.Resource; + +/** + * @Author: wkc + * @CreateTime: 2026-01-21 + */ +@Service +@Slf4j +@EnableAsync +public class LoanPricingModelService { + + @Resource + private ModelService modelService; + + @Resource + private LoanPricingWorkflowMapper loanPricingWorkflowMapper; + + @Resource + private ModelRetailOutputFieldsMapper modelRetailOutputFieldsMapper; + + @Resource + private ModelCorpOutputFieldsMapper modelCorpOutputFieldsMapper; + + @Resource + private SensitiveFieldCryptoService sensitiveFieldCryptoService; + + public void invokeModelAsync(Long workflowId) { + LoanPricingWorkflow loanPricingWorkflow = loanPricingWorkflowMapper.selectById(workflowId); + if (Objects.isNull(loanPricingWorkflow)){ + log.error("未找到对应的流程信息,未调用模型服务"); + return; + } + try + { + loanPricingWorkflow.setCustName(sensitiveFieldCryptoService.decrypt(loanPricingWorkflow.getCustName())); + loanPricingWorkflow.setIdNum(sensitiveFieldCryptoService.decrypt(loanPricingWorkflow.getIdNum())); + } + catch (RuntimeException ex) + { + log.error("贷款定价模型调用前敏感字段解密失败", ex); + throw ex; + } + ModelInvokeDTO modelInvokeDTO = new ModelInvokeDTO(); + BeanUtils.copyProperties(loanPricingWorkflow, modelInvokeDTO); + if ("个人".equals(loanPricingWorkflow.getCustType())) + { + normalizePersonalModelInvokeDTO(modelInvokeDTO); + } + JSONObject response = modelService.invokeModel(modelInvokeDTO); + if (loanPricingWorkflow.getCustType().equals("个人")){ + // 个人模型 + ModelRetailOutputFields modelRetailOutputFields = JSON.parseObject(response.toJSONString(), ModelRetailOutputFields.class); + modelRetailOutputFieldsMapper.insert(modelRetailOutputFields); + log.info("个人模型调用成功"); + LoanPricingWorkflow workflowToUpdate = new LoanPricingWorkflow(); + workflowToUpdate.setId(loanPricingWorkflow.getId()); + workflowToUpdate.setModelOutputId(modelRetailOutputFields.getId()); + loanPricingWorkflowMapper.updateById(workflowToUpdate); + log.info("更新流程信息成功"); + }else if (loanPricingWorkflow.getCustType().equals("企业")){ + // 企业模型 + ModelCorpOutputFields modelCorpOutputFields = JSON.parseObject(response.toJSONString(), ModelCorpOutputFields.class); + modelCorpOutputFieldsMapper.insert(modelCorpOutputFields); + log.info("企业模型调用成功"); + LoanPricingWorkflow workflowToUpdate = new LoanPricingWorkflow(); + workflowToUpdate.setId(loanPricingWorkflow.getId()); + workflowToUpdate.setModelOutputId(modelCorpOutputFields.getId()); + loanPricingWorkflowMapper.updateById(workflowToUpdate); + log.info("更新流程信息成功"); + } + } + + private void normalizePersonalModelInvokeDTO(ModelInvokeDTO modelInvokeDTO) + { + modelInvokeDTO.setBizProof(toZeroOne(modelInvokeDTO.getBizProof())); + modelInvokeDTO.setLoanLoop(toZeroOne(modelInvokeDTO.getLoanLoop())); + modelInvokeDTO.setCollThirdParty(toZeroOne(modelInvokeDTO.getCollThirdParty())); + } + + private String toZeroOne(String value) + { + if ("true".equals(value) || "1".equals(value)) + { + return "1"; + } + if ("false".equals(value) || "0".equals(value)) + { + return "0"; + } + return value; + } +} diff --git a/ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/service/LoanPricingSensitiveDisplayService.java b/ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/service/LoanPricingSensitiveDisplayService.java new file mode 100644 index 0000000..d2c315f --- /dev/null +++ b/ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/service/LoanPricingSensitiveDisplayService.java @@ -0,0 +1,46 @@ +package com.ruoyi.loanpricing.service; + +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +@Service +public class LoanPricingSensitiveDisplayService +{ + public String maskCustName(String custName) + { + if (!StringUtils.hasText(custName)) + { + return custName; + } + if (custName.contains("公司") && custName.length() > 4) + { + return custName.substring(0, 2) + "*".repeat(custName.length() - 4) + custName.substring(custName.length() - 2); + } + if (custName.length() == 1) + { + return custName; + } + return custName.substring(0, 1) + "*".repeat(custName.length() - 1); + } + + public String maskIdNum(String idNum) + { + if (!StringUtils.hasText(idNum)) + { + return idNum; + } + if (idNum.startsWith("91") && idNum.length() == 18) + { + return idNum.substring(0, 2) + "*".repeat(13) + idNum.substring(idNum.length() - 3); + } + if (idNum.matches("\\d{17}[\\dXx]")) + { + return idNum.substring(0, 4) + "*".repeat(8) + idNum.substring(idNum.length() - 4); + } + if (idNum.length() > 5) + { + return idNum.substring(0, 2) + "*".repeat(idNum.length() - 5) + idNum.substring(idNum.length() - 3); + } + return "*".repeat(idNum.length()); + } +} diff --git a/ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/service/ModelService.java b/ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/service/ModelService.java new file mode 100644 index 0000000..73fd9cf --- /dev/null +++ b/ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/service/ModelService.java @@ -0,0 +1,70 @@ +package com.ruoyi.loanpricing.service; + +import com.alibaba.fastjson2.JSON; +import com.alibaba.fastjson2.JSONObject; +import com.alibaba.fastjson2.TypeReference; +import com.ruoyi.common.core.domain.entity.SysDictData; +import com.ruoyi.common.core.redis.RedisCache; +import com.ruoyi.common.exception.ServiceException; +import com.ruoyi.common.utils.http.HttpUtils; +import com.ruoyi.loanpricing.domain.dto.ModelInvokeDTO; +import com.ruoyi.loanpricing.domain.entity.LoanPricingWorkflow; +import org.springframework.beans.factory.annotation.Value; + + +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.stereotype.Service; + +import java.util.Map; +import java.util.Objects; + +/** + * @Author 吴凯程 + * @Date 2025/12/11 + **/ +@Service +@Slf4j +@EnableAsync +public class ModelService { + + @Value("${model.url}") + private String modelUrl; + + + + public JSONObject invokeModel(ModelInvokeDTO modelInvokeDTO) { + Map requestBody = entityToMap(modelInvokeDTO); + JSONObject response = HttpUtils.doPostFormUrlEncoded(modelUrl, requestBody, null, JSONObject.class); + log.info("------------------->调用模型返回结果:" + JSON.toJSONString(response)); + if(Objects.nonNull(response) && response.containsKey("code") && response.getInteger("code") == 10000){ + JSONObject mappingOutputFields = response.getJSONObject("data").getJSONObject("mappingOutputFields"); +// return JSON.parseObject(mappingOutputFields.toJSONString(), ModelOutputFields.class); + return mappingOutputFields; + }else{ + log.error("------------------->调用模型失败,失败原因为:" + response.getString("message")); + throw new ServiceException("调用模型失败"); + } + } + + /** + * 使用FastJSON将实体类转换为Map + * @param obj 待转换的实体类对象 + * @return 转换后的Map + */ + public static Map entityToMap(Object obj) { + if (obj == null) { + return null; + } + // 先转为JSON字符串,再转换为指定类型的Map + String jsonStr = JSON.toJSONString(obj); + return JSON.parseObject(jsonStr, new TypeReference>() {}); + } + + + + + + + +} diff --git a/ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/service/SensitiveFieldCryptoService.java b/ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/service/SensitiveFieldCryptoService.java new file mode 100644 index 0000000..6e051e3 --- /dev/null +++ b/ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/service/SensitiveFieldCryptoService.java @@ -0,0 +1,68 @@ +package com.ruoyi.loanpricing.service; + +import com.ruoyi.common.exception.ServiceException; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import javax.crypto.Cipher; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +@Service +public class SensitiveFieldCryptoService +{ + private final String key; + + public SensitiveFieldCryptoService(@Value("${loan-pricing.sensitive.key:}") String key) + { + this.key = key; + } + + public String encrypt(String plainText) + { + validateKey(); + if (!StringUtils.hasText(plainText)) + { + return plainText; + } + try + { + Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding"); + cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), "AES")); + return Base64.getEncoder().encodeToString(cipher.doFinal(plainText.getBytes(StandardCharsets.UTF_8))); + } + catch (Exception ex) + { + throw new ServiceException("贷款定价敏感字段加密失败"); + } + } + + public String decrypt(String cipherText) + { + validateKey(); + if (!StringUtils.hasText(cipherText)) + { + return cipherText; + } + try + { + Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding"); + cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), "AES")); + return new String(cipher.doFinal(Base64.getDecoder().decode(cipherText)), StandardCharsets.UTF_8); + } + catch (Exception ex) + { + throw new ServiceException("贷款定价敏感字段解密失败"); + } + } + + private void validateKey() + { + if (!StringUtils.hasText(key)) + { + throw new IllegalStateException("loan-pricing.sensitive.key 未配置"); + } + } +} diff --git a/ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/service/impl/LoanPricingWorkflowServiceImpl.java b/ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/service/impl/LoanPricingWorkflowServiceImpl.java new file mode 100644 index 0000000..6d80edb --- /dev/null +++ b/ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/service/impl/LoanPricingWorkflowServiceImpl.java @@ -0,0 +1,262 @@ +package com.ruoyi.loanpricing.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.ruoyi.loanpricing.domain.dto.CorporateLoanPricingCreateDTO; +import com.ruoyi.loanpricing.domain.dto.PersonalLoanPricingCreateDTO; +import com.ruoyi.loanpricing.domain.entity.LoanPricingWorkflow; +import com.ruoyi.loanpricing.domain.entity.ModelCorpOutputFields; +import com.ruoyi.loanpricing.domain.entity.ModelRetailOutputFields; +import com.ruoyi.loanpricing.domain.vo.LoanPricingWorkflowListVO; +import com.ruoyi.loanpricing.domain.vo.LoanPricingWorkflowVO; +import com.ruoyi.loanpricing.mapper.LoanPricingWorkflowMapper; +import com.ruoyi.loanpricing.mapper.ModelCorpOutputFieldsMapper; +import com.ruoyi.loanpricing.mapper.ModelRetailOutputFieldsMapper; +import com.ruoyi.loanpricing.service.ILoanPricingWorkflowService; +import com.ruoyi.loanpricing.service.LoanPricingSensitiveDisplayService; +import com.ruoyi.loanpricing.service.LoanPricingModelService; +import com.ruoyi.loanpricing.service.SensitiveFieldCryptoService; +import com.ruoyi.loanpricing.util.LoanPricingConverter; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.List; +import java.util.Objects; +import javax.annotation.Resource; + +/** + * 利率定价流程Service业务层处理 + * + * @author ruoyi + * @date 2025-01-19 + */ +@Service +public class LoanPricingWorkflowServiceImpl implements ILoanPricingWorkflowService +{ + @Resource + private LoanPricingWorkflowMapper loanPricingWorkflowMapper; + + @Resource + private LoanPricingModelService loanPricingModelService; + + @Resource + private ModelRetailOutputFieldsMapper modelRetailOutputFieldsMapper; + + @Resource + private ModelCorpOutputFieldsMapper modelCorpOutputFieldsMapper; + + @Resource + private SensitiveFieldCryptoService sensitiveFieldCryptoService; + + @Resource + private LoanPricingSensitiveDisplayService loanPricingSensitiveDisplayService; + + + /** + * 发起利率定价流程 + * + * @param loanPricingWorkflow 利率定价流程信息 + * @return 结果 + */ + @Transactional(rollbackFor = Exception.class) + public LoanPricingWorkflow createLoanPricing(LoanPricingWorkflow loanPricingWorkflow) + { + // 自动生成业务方流水号(时间戳) + SimpleDateFormat sdf = new SimpleDateFormat("yyyyMMddHHmmssSSS"); + String serialNum = sdf.format(new Date()); + loanPricingWorkflow.setSerialNum(serialNum); + + // 设置默认值 + if (!StringUtils.hasText(loanPricingWorkflow.getOrgCode())) + { + loanPricingWorkflow.setOrgCode("892000"); + } + if (!StringUtils.hasText(loanPricingWorkflow.getRunType())) + { + loanPricingWorkflow.setRunType("1"); + } + + loanPricingWorkflow.setCustName(sensitiveFieldCryptoService.encrypt(loanPricingWorkflow.getCustName())); + loanPricingWorkflow.setIdNum(sensitiveFieldCryptoService.encrypt(loanPricingWorkflow.getIdNum())); + loanPricingWorkflowMapper.insert(loanPricingWorkflow); + loanPricingModelService.invokeModelAsync(loanPricingWorkflow.getId()); + + return loanPricingWorkflow; + } + + /** + * 发起个人客户利率定价流程 + * + * @param dto 个人客户发起DTO + * @return 结果 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public LoanPricingWorkflow createPersonalLoanPricing(PersonalLoanPricingCreateDTO dto) { + LoanPricingWorkflow entity = LoanPricingConverter.toEntity(dto); + return createLoanPricing(entity); + } + + /** + * 发起企业客户利率定价流程 + * + * @param dto 企业客户发起DTO + * @return 结果 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public LoanPricingWorkflow createCorporateLoanPricing(CorporateLoanPricingCreateDTO dto) { + LoanPricingWorkflow entity = LoanPricingConverter.toEntity(dto); + return createLoanPricing(entity); + } + + /** + * 查询利率定价流程列表 + * + * @param loanPricingWorkflow 利率定价流程信息 + * @return 利率定价流程 + */ + @Override + public List selectLoanPricingList(LoanPricingWorkflow loanPricingWorkflow) + { + LambdaQueryWrapper wrapper = buildQueryWrapper(loanPricingWorkflow); + // 按更新时间倒序 + wrapper.orderByDesc(LoanPricingWorkflow::getUpdateTime); + return loanPricingWorkflowMapper.selectList(wrapper); + } + + /** + * 分页查询利率定价流程列表 + * + * @param page 分页参数 + * @param loanPricingWorkflow 利率定价流程信息 + * @return 利率定价流程 + */ + @Override + public IPage selectLoanPricingPage(Page page, LoanPricingWorkflow loanPricingWorkflow) + { + IPage pageResult = loanPricingWorkflowMapper.selectWorkflowPageWithRates(page, loanPricingWorkflow); + pageResult.getRecords().forEach(row -> row.setCustName( + loanPricingSensitiveDisplayService.maskCustName( + sensitiveFieldCryptoService.decrypt(row.getCustName())))); + return pageResult; + } + + /** + * 查询利率定价流程详情 + * + * @param serialNum 业务方流水号 + * @return 利率定价流程 + */ + @Override + public LoanPricingWorkflowVO selectLoanPricingBySerialNum(String serialNum) + { + LoanPricingWorkflowVO loanPricingWorkflowVO = new LoanPricingWorkflowVO(); + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(LoanPricingWorkflow::getSerialNum, serialNum); + LoanPricingWorkflow loanPricingWorkflow = loanPricingWorkflowMapper.selectOne(wrapper); + String plainCustName = sensitiveFieldCryptoService.decrypt(loanPricingWorkflow.getCustName()); + String plainIdNum = sensitiveFieldCryptoService.decrypt(loanPricingWorkflow.getIdNum()); + loanPricingWorkflow.setCustName(loanPricingSensitiveDisplayService.maskCustName(plainCustName)); + loanPricingWorkflow.setIdNum(loanPricingSensitiveDisplayService.maskIdNum(plainIdNum)); + loanPricingWorkflowVO.setLoanPricingWorkflow(loanPricingWorkflow); + + if (Objects.nonNull(loanPricingWorkflow.getModelOutputId())){ + if (loanPricingWorkflow.getCustType().equals("个人")){ + ModelRetailOutputFields modelRetailOutputFields = modelRetailOutputFieldsMapper.selectById(loanPricingWorkflow.getModelOutputId()); + if (Objects.nonNull(modelRetailOutputFields)) + { + maskModelRetailOutputBasicInfo(modelRetailOutputFields); + loanPricingWorkflow.setLoanRate(modelRetailOutputFields.getFinalCalculateRate()); + } + loanPricingWorkflowVO.setModelRetailOutputFields(modelRetailOutputFields); + } + if (loanPricingWorkflow.getCustType().equals("企业")){ + ModelCorpOutputFields modelCorpOutputFields = modelCorpOutputFieldsMapper.selectById(loanPricingWorkflow.getModelOutputId()); + if (Objects.nonNull(modelCorpOutputFields)) + { + maskModelCorpOutputBasicInfo(modelCorpOutputFields); + loanPricingWorkflow.setLoanRate(modelCorpOutputFields.getCalculateRate()); + } + loanPricingWorkflowVO.setModelCorpOutputFields(modelCorpOutputFields); + } + } + + + return loanPricingWorkflowVO; + } + + /** + * 构建查询条件 + * + * @param loanPricingWorkflow 利率定价流程信息 + * @return LambdaQueryWrapper + */ + private LambdaQueryWrapper buildQueryWrapper(LoanPricingWorkflow loanPricingWorkflow) + { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + + // 按创建者筛选 + if (StringUtils.hasText(loanPricingWorkflow.getCreateBy())) + { + wrapper.like(LoanPricingWorkflow::getCreateBy, loanPricingWorkflow.getCreateBy()); + } + + // 按客户内码模糊查询 + if (StringUtils.hasText(loanPricingWorkflow.getCustIsn())) + { + wrapper.like(LoanPricingWorkflow::getCustIsn, loanPricingWorkflow.getCustIsn()); + } + + // 按机构号筛选 + if (StringUtils.hasText(loanPricingWorkflow.getOrgCode())) + { + wrapper.like(LoanPricingWorkflow::getOrgCode, loanPricingWorkflow.getOrgCode()); + } + + return wrapper; + } + + private void maskModelRetailOutputBasicInfo(ModelRetailOutputFields modelRetailOutputFields) + { + modelRetailOutputFields.setCustName( + loanPricingSensitiveDisplayService.maskCustName(modelRetailOutputFields.getCustName())); + modelRetailOutputFields.setIdNum( + loanPricingSensitiveDisplayService.maskIdNum(modelRetailOutputFields.getIdNum())); + } + + private void maskModelCorpOutputBasicInfo(ModelCorpOutputFields modelCorpOutputFields) + { + modelCorpOutputFields.setCustName( + loanPricingSensitiveDisplayService.maskCustName(modelCorpOutputFields.getCustName())); + modelCorpOutputFields.setIdNum( + loanPricingSensitiveDisplayService.maskIdNum(modelCorpOutputFields.getIdNum())); + } + + /** + * 设定执行利率 + * + * @param serialNum 业务方流水号 + * @param executeRate 执行利率 + * @return 是否成功 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public boolean setExecuteRate(String serialNum, String executeRate) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(LoanPricingWorkflow::getSerialNum, serialNum); + LoanPricingWorkflow workflow = loanPricingWorkflowMapper.selectOne(wrapper); + + if (workflow == null) { + return false; + } + + workflow.setExecuteRate(executeRate); + int result = loanPricingWorkflowMapper.updateById(workflow); + return result > 0; + } +} diff --git a/ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/util/LoanPricingConverter.java b/ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/util/LoanPricingConverter.java new file mode 100644 index 0000000..ca0715e --- /dev/null +++ b/ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/util/LoanPricingConverter.java @@ -0,0 +1,67 @@ +package com.ruoyi.loanpricing.util; + +import com.ruoyi.loanpricing.domain.dto.CorporateLoanPricingCreateDTO; +import com.ruoyi.loanpricing.domain.dto.PersonalLoanPricingCreateDTO; +import com.ruoyi.loanpricing.domain.entity.LoanPricingWorkflow; + +/** + * 利率定价转换器 + * + * @author ruoyi + * @date 2025-01-19 + */ +public class LoanPricingConverter { + + /** + * 个人客户DTO转Entity + * + * @param dto 个人客户发起DTO + * @return 利率定价流程实体 + */ + public static LoanPricingWorkflow toEntity(PersonalLoanPricingCreateDTO dto) { + LoanPricingWorkflow entity = new LoanPricingWorkflow(); + // 映射共同字段 + entity.setCustIsn(dto.getCustIsn()); + entity.setCustType("个人"); + entity.setCustName(dto.getCustName()); + entity.setIdType(dto.getIdType()); + entity.setIdNum(dto.getIdNum()); + entity.setGuarType(dto.getGuarType()); + entity.setApplyAmt(dto.getApplyAmt()); + entity.setLoanPurpose(dto.getLoanPurpose()); + entity.setLoanTerm(dto.getLoanTerm()); + entity.setCollType(dto.getCollType()); + entity.setCollThirdParty(dto.getCollThirdParty()); + // 映射个人特有字段 + entity.setBizProof(dto.getBizProof()); + entity.setLoanLoop(dto.getLoanLoop()); + return entity; + } + + /** + * 企业客户DTO转Entity + * + * @param dto 企业客户发起DTO + * @return 利率定价流程实体 + */ + public static LoanPricingWorkflow toEntity(CorporateLoanPricingCreateDTO dto) { + LoanPricingWorkflow entity = new LoanPricingWorkflow(); + // 映射共同字段 + entity.setCustIsn(dto.getCustIsn()); + entity.setCustType("企业"); + entity.setCustName(dto.getCustName()); + entity.setIdType(dto.getIdType()); + entity.setIdNum(dto.getIdNum()); + entity.setGuarType(dto.getGuarType()); + entity.setApplyAmt(dto.getApplyAmt()); + entity.setCollType(dto.getCollType()); + entity.setCollThirdParty(dto.getCollThirdParty()); + // 映射企业特有字段 + entity.setIsAgriGuar(dto.getIsAgriGuar()); + entity.setIsGreenLoan(dto.getIsGreenLoan()); + entity.setIsTechEnt(dto.getIsTechEnt()); + entity.setIsTradeConstruction(dto.getIsTradeConstruction()); + entity.setLoanTerm(dto.getLoanTerm()); + return entity; + } +} diff --git a/ruoyi-loan-pricing/src/main/resources/data/corp_output.json b/ruoyi-loan-pricing/src/main/resources/data/corp_output.json new file mode 100644 index 0000000..5752edc --- /dev/null +++ b/ruoyi-loan-pricing/src/main/resources/data/corp_output.json @@ -0,0 +1,68 @@ +{ + "traceId": "350626558347246735E7F4722CUZRWOMNRR53O0", + "cost": 2267, + "tokenId": "17364055486305E7F4722M8IPFWNL8TOBEB", + "mappingOutputFields": { + "custIsn": "CUST20260121001", + "custType": "企业客户", + "guarType": "抵押担保", + "custName": "北京智联科技有限公司", + "idType": "营业执照", + "idNum": "91110108MA00XXXXXX", + "baseLoanRate": "3.45", + "isFirstLoan": "N", + "faithDay": "730", + "bpFirstLoan": "0", + "bpAgeLoan": "5.2", + "totalBpLoyalty": "8.5", + "balanceAvg": "5000000.00", + "loanAvg": "3000000.00", + "derivationRate": "1.8", + "totalBpContribution": "12.3", + "midEntConnect": "100000.00", + "midEntEffect": "50000.00", + "midEntInter": "80000.00", + "midEntAccept": "200000.00", + "midEntDiscount": "150000.00", + "midEntEleDdc": "30000.00", + "midEntWaterDdc": "10000.00", + "midEntTax": "40000.00", + "bpMid": "6.8", + "payroll": "200", + "invLoanAmount": "2500000.00", + "bpPayroll": "4.1", + "isCleanEnt": "Y", + "hasSettleAcct": "Y", + "isAgriGuar": "N", + "isGreenLoan": "Y", + "isTechEnt": "Y", + "bpEntType": "7.5", + "totoalBpRelevance": "9.2", + "loanTerm": "36", + "bpLoanTerm": "3.3", + "applyAmt": "5000000.00", + "bpLoanAmount": "5.8", + "collType": "房产抵押", + "collThirdParty": "N", + "bpCollateral": "4.5", + "greyCust": "N", + "prinOverdue": "N", + "interestOverdue": "N", + "cardOverdue": "N", + "bpGreyOverdue": "0", + "totoalBpRisk": "1.2", + "totalBp": "48.2", + "calculateRate": "3.932" + }, + "extensionMap": {}, + "reasonMessage": "Running successfully", + "bizTime": 1736405548630, + "outputFields": {}, + "workflowCode": "TBKH", + "orgCode": "802000", + "bizId": "2025010914345", + "reasonCode": 200, + "workflowVersion": 14, + "callTime": 1736405548630, + "status": 1 +} \ No newline at end of file diff --git a/ruoyi-loan-pricing/src/main/resources/data/retail_output.json b/ruoyi-loan-pricing/src/main/resources/data/retail_output.json new file mode 100644 index 0000000..38777c1 --- /dev/null +++ b/ruoyi-loan-pricing/src/main/resources/data/retail_output.json @@ -0,0 +1,73 @@ +{ + "traceId": "350626558347246735E7F4722CUZRWOMNRR53O0", + "cost": 2267, + "tokenId": "17364055486305E7F4722M8IPFWNL8TOBEB", + "mappingOutputFields": { + "custIsn": "CUST20260121001", + "custType": "个人", + "guarType": "信用担保", + "custName": "张三", + "idType": "身份证", + "idNum": "330106199001011234", + "baseLoanRate": "4.35", + "isFirstLoan": "是", + "faithDay": "365", + "custAge": "36", + "bpFirstLoan": "50", + "bpAgeLoan": "30", + "bpAge": "20", + "totalBpLoyalty": "95", + "balanceAvg": "50000.00", + "loanAvg": "100000.00", + "derivationRate": "1.2", + "totalBpContribution": "88", + "midPerCard": "1000.50", + "midPerPass": "500.00", + "midPerHarvest": "800.20", + "midPerEffect": "是", + "midPerQuickPay": "300.00", + "midPerEleDdc": "150.00", + "midPerWaterDdc": "80.00", + "midPerHuashuDdc": "120.00", + "MidPerGasDdc": "90.00", + "midPerCitizencard": "200.00", + "midPerFinMan": "5000.00", + "midPerEtc": "180.00", + "bpMid": "45", + "totoalBpRelevance": "90", + "applyAmt": "200000.00", + "bpLoanAmount": "60", + "loanPurpose": "个人消费", + "bizProof": "有", + "bpLoanUse": "55", + "loanLoop": "支持", + "bpLoanLoop": "40", + "collType": "无抵质押", + "collThirdParty": "否", + "bpCollateral": "0", + "greyCust": "否", + "prinOverdue": "否", + "interestOverdue": "否", + "cardOverdue": "否", + "bpGreyOverdue": "98", + "totoalBpRisk": "95", + "totalBp": "350", + "calculateRate": "6.15", + "loanRateHistory": "6.40", + "minRateProduct": "5.50", + "smoothRange": "-0.10", + "finalCalculateRate": "6.05", + "referenceRate": "5.95" + }, + "extensionMap": {}, + "reasonMessage": "Running successfully", + "bizTime": 1736405548630, + "outputFields": {}, + "workflowCode": "TBKH", + "orgCode": "802000", + "bizId": "2025010914345", + "reasonCode": 200, + "workflowVersion": 14, + "callTime": 1736405548630, + "status": 1 +} diff --git a/ruoyi-loan-pricing/src/main/resources/mapper/loanpricing/LoanPricingWorkflowMapper.xml b/ruoyi-loan-pricing/src/main/resources/mapper/loanpricing/LoanPricingWorkflowMapper.xml new file mode 100644 index 0000000..fac11dd --- /dev/null +++ b/ruoyi-loan-pricing/src/main/resources/mapper/loanpricing/LoanPricingWorkflowMapper.xml @@ -0,0 +1,39 @@ + + + + + + + diff --git a/ruoyi-loan-pricing/src/test/java/com/ruoyi/loanpricing/domain/entity/ModelRetailOutputFieldsTest.java b/ruoyi-loan-pricing/src/test/java/com/ruoyi/loanpricing/domain/entity/ModelRetailOutputFieldsTest.java new file mode 100644 index 0000000..bdd49ee --- /dev/null +++ b/ruoyi-loan-pricing/src/test/java/com/ruoyi/loanpricing/domain/entity/ModelRetailOutputFieldsTest.java @@ -0,0 +1,26 @@ +package com.ruoyi.loanpricing.domain.entity; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.lang.reflect.Field; +import java.util.Arrays; +import java.util.Set; +import java.util.stream.Collectors; +import org.junit.jupiter.api.Test; + +class ModelRetailOutputFieldsTest +{ + @Test + void shouldContainLatestRetailDisplayRateFields() + { + Set fieldNames = Arrays.stream(ModelRetailOutputFields.class.getDeclaredFields()) + .map(Field::getName) + .collect(Collectors.toSet()); + + assertTrue(fieldNames.contains("loanRateHistory"), "缺少字段 loanRateHistory"); + assertTrue(fieldNames.contains("minRateProduct"), "缺少字段 minRateProduct"); + assertTrue(fieldNames.contains("smoothRange"), "缺少字段 smoothRange"); + assertTrue(fieldNames.contains("finalCalculateRate"), "缺少字段 finalCalculateRate"); + assertTrue(fieldNames.contains("referenceRate"), "缺少字段 referenceRate"); + } +} diff --git a/ruoyi-loan-pricing/src/test/java/com/ruoyi/loanpricing/domain/vo/LoanPricingWorkflowListVOTest.java b/ruoyi-loan-pricing/src/test/java/com/ruoyi/loanpricing/domain/vo/LoanPricingWorkflowListVOTest.java new file mode 100644 index 0000000..cff31db --- /dev/null +++ b/ruoyi-loan-pricing/src/test/java/com/ruoyi/loanpricing/domain/vo/LoanPricingWorkflowListVOTest.java @@ -0,0 +1,19 @@ +package com.ruoyi.loanpricing.domain.vo; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; + +class LoanPricingWorkflowListVOTest +{ + @Test + void shouldExposeCalculateRateAndExecuteRateFields() + { + LoanPricingWorkflowListVO vo = new LoanPricingWorkflowListVO(); + vo.setCalculateRate("6.15"); + vo.setExecuteRate("5.80"); + + assertEquals("6.15", vo.getCalculateRate()); + assertEquals("5.80", vo.getExecuteRate()); + } +} diff --git a/ruoyi-loan-pricing/src/test/java/com/ruoyi/loanpricing/mapper/LoanPricingWorkflowMapperXmlTest.java b/ruoyi-loan-pricing/src/test/java/com/ruoyi/loanpricing/mapper/LoanPricingWorkflowMapperXmlTest.java new file mode 100644 index 0000000..d63a746 --- /dev/null +++ b/ruoyi-loan-pricing/src/test/java/com/ruoyi/loanpricing/mapper/LoanPricingWorkflowMapperXmlTest.java @@ -0,0 +1,22 @@ +package com.ruoyi.loanpricing.mapper; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; +import org.springframework.util.StreamUtils; + +class LoanPricingWorkflowMapperXmlTest +{ + @Test + void shouldUseRetailFinalCalculateRateInWorkflowListQuery() throws IOException + { + ClassPathResource resource = new ClassPathResource("mapper/loanpricing/LoanPricingWorkflowMapper.xml"); + String xml = StreamUtils.copyToString(resource.getInputStream(), StandardCharsets.UTF_8); + + assertTrue(xml.contains("WHEN lpw.cust_type = '个人' THEN mr.final_calculate_rate")); + assertTrue(xml.contains("WHEN lpw.cust_type = '企业' THEN mc.calculate_rate")); + } +} diff --git a/ruoyi-loan-pricing/src/test/java/com/ruoyi/loanpricing/service/LoanPricingModelServicePersonalParamsTest.java b/ruoyi-loan-pricing/src/test/java/com/ruoyi/loanpricing/service/LoanPricingModelServicePersonalParamsTest.java new file mode 100644 index 0000000..f777773 --- /dev/null +++ b/ruoyi-loan-pricing/src/test/java/com/ruoyi/loanpricing/service/LoanPricingModelServicePersonalParamsTest.java @@ -0,0 +1,125 @@ +package com.ruoyi.loanpricing.service; + +import com.alibaba.fastjson2.JSONObject; +import com.ruoyi.loanpricing.domain.dto.ModelInvokeDTO; +import com.ruoyi.loanpricing.domain.dto.PersonalLoanPricingCreateDTO; +import com.ruoyi.loanpricing.domain.entity.LoanPricingWorkflow; +import com.ruoyi.loanpricing.mapper.LoanPricingWorkflowMapper; +import com.ruoyi.loanpricing.mapper.ModelCorpOutputFieldsMapper; +import com.ruoyi.loanpricing.mapper.ModelRetailOutputFieldsMapper; +import com.ruoyi.loanpricing.util.LoanPricingConverter; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Objects; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class LoanPricingModelServicePersonalParamsTest { + + @Mock + private ModelService modelService; + + @Mock + private LoanPricingWorkflowMapper loanPricingWorkflowMapper; + + @Mock + private ModelRetailOutputFieldsMapper modelRetailOutputFieldsMapper; + + @Mock + private ModelCorpOutputFieldsMapper modelCorpOutputFieldsMapper; + + @Mock + private SensitiveFieldCryptoService sensitiveFieldCryptoService; + + @InjectMocks + private LoanPricingModelService loanPricingModelService; + + @Test + void shouldContainLoanPurposeAndLoanTermInPersonalCreateDto() throws NoSuchFieldException { + assertNotNull(PersonalLoanPricingCreateDTO.class.getDeclaredField("loanPurpose")); + assertNotNull(PersonalLoanPricingCreateDTO.class.getDeclaredField("loanTerm")); + } + + @Test + void shouldMapLoanPurposeAndLoanTermFromPersonalDto() { + PersonalLoanPricingCreateDTO dto = new PersonalLoanPricingCreateDTO(); + dto.setCustIsn("CUST001"); + dto.setCustName("张三"); + dto.setGuarType("信用"); + dto.setApplyAmt("100000"); + dto.setLoanPurpose("business"); + dto.setLoanTerm("3"); + + LoanPricingWorkflow workflow = LoanPricingConverter.toEntity(dto); + + assertEquals("business", workflow.getLoanPurpose()); + assertEquals("3", workflow.getLoanTerm()); + } + + @Test + void shouldContainLoanTermAndLoanLoopInModelInvokeDto() throws NoSuchFieldException { + assertNotNull(ModelInvokeDTO.class.getDeclaredField("loanTerm")); + assertNotNull(ModelInvokeDTO.class.getDeclaredField("loanLoop")); + } + + @Test + void shouldInvokePersonalModelWithExpectedParams() { + LoanPricingWorkflow workflow = new LoanPricingWorkflow(); + workflow.setId(1L); + workflow.setSerialNum("202604090001"); + workflow.setOrgCode("892000"); + workflow.setRunType("1"); + workflow.setCustIsn("CUST001"); + workflow.setCustType("个人"); + workflow.setCustName("cipher-name"); + workflow.setIdType("身份证"); + workflow.setIdNum("cipher-id"); + workflow.setGuarType("信用"); + workflow.setApplyAmt("100000"); + workflow.setLoanPurpose("business"); + workflow.setLoanTerm("3"); + workflow.setBizProof("true"); + workflow.setLoanLoop("false"); + workflow.setCollThirdParty("true"); + workflow.setCollType("一类"); + + JSONObject response = new JSONObject(); + response.put("calculateRate", "6.15"); + + when(loanPricingWorkflowMapper.selectById(1L)).thenReturn(workflow); + when(sensitiveFieldCryptoService.decrypt("cipher-name")).thenReturn("张三"); + when(sensitiveFieldCryptoService.decrypt("cipher-id")).thenReturn("110101199001011234"); + when(modelService.invokeModel(any())).thenReturn(response); + + loanPricingModelService.invokeModelAsync(1L); + + verify(modelService).invokeModel(argThat((ModelInvokeDTO dto) -> + Objects.equals("202604090001", dto.getSerialNum()) + && Objects.equals("892000", dto.getOrgCode()) + && Objects.equals("1", dto.getRunType()) + && Objects.equals("CUST001", dto.getCustIsn()) + && Objects.equals("个人", dto.getCustType()) + && Objects.equals("张三", dto.getCustName()) + && Objects.equals("身份证", dto.getIdType()) + && Objects.equals("110101199001011234", dto.getIdNum()) + && Objects.equals("信用", dto.getGuarType()) + && Objects.equals("100000", dto.getApplyAmt()) + && Objects.equals("business", dto.getLoanPurpose()) + && Objects.equals("3", dto.getLoanTerm()) + && Objects.equals("1", dto.getBizProof()) + && Objects.equals("0", dto.getLoanLoop()) + && Objects.equals("1", dto.getCollThirdParty()) + && Objects.equals("一类", dto.getCollType()))); + } +} diff --git a/ruoyi-loan-pricing/src/test/java/com/ruoyi/loanpricing/service/LoanPricingModelServiceTest.java b/ruoyi-loan-pricing/src/test/java/com/ruoyi/loanpricing/service/LoanPricingModelServiceTest.java new file mode 100644 index 0000000..e983273 --- /dev/null +++ b/ruoyi-loan-pricing/src/test/java/com/ruoyi/loanpricing/service/LoanPricingModelServiceTest.java @@ -0,0 +1,90 @@ +package com.ruoyi.loanpricing.service; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.alibaba.fastjson2.JSONObject; +import com.ruoyi.loanpricing.domain.dto.ModelInvokeDTO; +import com.ruoyi.loanpricing.domain.entity.LoanPricingWorkflow; +import com.ruoyi.loanpricing.mapper.LoanPricingWorkflowMapper; +import com.ruoyi.loanpricing.mapper.ModelCorpOutputFieldsMapper; +import com.ruoyi.loanpricing.mapper.ModelRetailOutputFieldsMapper; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Objects; + +@ExtendWith(MockitoExtension.class) +class LoanPricingModelServiceTest +{ + @Mock + private ModelService modelService; + + @Mock + private LoanPricingWorkflowMapper loanPricingWorkflowMapper; + + @Mock + private ModelRetailOutputFieldsMapper modelRetailOutputFieldsMapper; + + @Mock + private ModelCorpOutputFieldsMapper modelCorpOutputFieldsMapper; + + @Mock + private SensitiveFieldCryptoService sensitiveFieldCryptoService; + + @InjectMocks + private LoanPricingModelService loanPricingModelService; + + @Test + void shouldDecryptCustNameAndIdNumBeforeInvokeModel() + { + LoanPricingWorkflow workflow = new LoanPricingWorkflow(); + workflow.setId(1L); + workflow.setCustType("个人"); + workflow.setCustName("cipher-name"); + workflow.setIdNum("cipher-id"); + + JSONObject response = new JSONObject(); + response.put("calculateRate", "6.15"); + + when(loanPricingWorkflowMapper.selectById(1L)).thenReturn(workflow); + when(sensitiveFieldCryptoService.decrypt("cipher-name")).thenReturn("张三"); + when(sensitiveFieldCryptoService.decrypt("cipher-id")).thenReturn("110101199001011234"); + when(modelService.invokeModel(any())).thenReturn(response); + + loanPricingModelService.invokeModelAsync(1L); + + verify(modelService).invokeModel(argThat((ModelInvokeDTO dto) -> + Objects.equals("张三", dto.getCustName()) + && Objects.equals("110101199001011234", dto.getIdNum()))); + } + + @Test + void shouldNotWritePlainCustNameAndIdNumBackWhenUpdatingWorkflow() + { + LoanPricingWorkflow workflow = new LoanPricingWorkflow(); + workflow.setId(2L); + workflow.setCustType("个人"); + workflow.setCustName("cipher-name"); + workflow.setIdNum("cipher-id"); + + JSONObject response = new JSONObject(); + response.put("calculateRate", "6.15"); + + when(loanPricingWorkflowMapper.selectById(2L)).thenReturn(workflow); + when(sensitiveFieldCryptoService.decrypt("cipher-name")).thenReturn("张三"); + when(sensitiveFieldCryptoService.decrypt("cipher-id")).thenReturn("110101199001011234"); + when(modelService.invokeModel(any())).thenReturn(response); + + loanPricingModelService.invokeModelAsync(2L); + + verify(loanPricingWorkflowMapper).updateById(argThat((LoanPricingWorkflow entity) -> + !Objects.equals("张三", entity.getCustName()) + && !Objects.equals("110101199001011234", entity.getIdNum()))); + } +} diff --git a/ruoyi-loan-pricing/src/test/java/com/ruoyi/loanpricing/service/LoanPricingSensitiveDisplayServiceTest.java b/ruoyi-loan-pricing/src/test/java/com/ruoyi/loanpricing/service/LoanPricingSensitiveDisplayServiceTest.java new file mode 100644 index 0000000..2071487 --- /dev/null +++ b/ruoyi-loan-pricing/src/test/java/com/ruoyi/loanpricing/service/LoanPricingSensitiveDisplayServiceTest.java @@ -0,0 +1,24 @@ +package com.ruoyi.loanpricing.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; + +class LoanPricingSensitiveDisplayServiceTest +{ + private final LoanPricingSensitiveDisplayService displayService = new LoanPricingSensitiveDisplayService(); + + @Test + void shouldMaskPersonalNameAndIdNum() + { + assertEquals("张*", displayService.maskCustName("张三")); + assertEquals("1101********1234", displayService.maskIdNum("110101199001011234")); + } + + @Test + void shouldMaskCorporateNameAndCreditCode() + { + assertEquals("测试****公司", displayService.maskCustName("测试科技有限公司")); + assertEquals("91*************00X", displayService.maskIdNum("91110000100000000X")); + } +} diff --git a/ruoyi-loan-pricing/src/test/java/com/ruoyi/loanpricing/service/SensitiveFieldCryptoServiceTest.java b/ruoyi-loan-pricing/src/test/java/com/ruoyi/loanpricing/service/SensitiveFieldCryptoServiceTest.java new file mode 100644 index 0000000..6b7f551 --- /dev/null +++ b/ruoyi-loan-pricing/src/test/java/com/ruoyi/loanpricing/service/SensitiveFieldCryptoServiceTest.java @@ -0,0 +1,32 @@ +package com.ruoyi.loanpricing.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.junit.jupiter.api.Test; + +class SensitiveFieldCryptoServiceTest +{ + @Test + void shouldEncryptAndDecryptCustNameAndIdNum() + { + SensitiveFieldCryptoService service = new SensitiveFieldCryptoService("1234567890abcdef"); + + String nameCipher = service.encrypt("张三"); + String idNumCipher = service.encrypt("110101199001011234"); + + assertNotEquals("张三", nameCipher); + assertNotEquals("110101199001011234", idNumCipher); + assertEquals("张三", service.decrypt(nameCipher)); + assertEquals("110101199001011234", service.decrypt(idNumCipher)); + } + + @Test + void shouldRejectBlankKeyConfiguration() + { + SensitiveFieldCryptoService service = new SensitiveFieldCryptoService(""); + + assertThrows(IllegalStateException.class, () -> service.encrypt("张三")); + } +} diff --git a/ruoyi-loan-pricing/src/test/java/com/ruoyi/loanpricing/service/impl/LoanPricingWorkflowServiceImplTest.java b/ruoyi-loan-pricing/src/test/java/com/ruoyi/loanpricing/service/impl/LoanPricingWorkflowServiceImplTest.java new file mode 100644 index 0000000..542fade --- /dev/null +++ b/ruoyi-loan-pricing/src/test/java/com/ruoyi/loanpricing/service/impl/LoanPricingWorkflowServiceImplTest.java @@ -0,0 +1,256 @@ +package com.ruoyi.loanpricing.service.impl; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.ruoyi.loanpricing.domain.entity.LoanPricingWorkflow; +import com.ruoyi.loanpricing.domain.entity.ModelCorpOutputFields; +import com.ruoyi.loanpricing.domain.entity.ModelRetailOutputFields; +import com.ruoyi.loanpricing.domain.vo.LoanPricingWorkflowListVO; +import com.ruoyi.loanpricing.domain.vo.LoanPricingWorkflowVO; +import com.ruoyi.loanpricing.mapper.LoanPricingWorkflowMapper; +import com.ruoyi.loanpricing.mapper.ModelCorpOutputFieldsMapper; +import com.ruoyi.loanpricing.mapper.ModelRetailOutputFieldsMapper; +import com.ruoyi.loanpricing.service.LoanPricingSensitiveDisplayService; +import com.ruoyi.loanpricing.service.LoanPricingModelService; +import com.ruoyi.loanpricing.service.SensitiveFieldCryptoService; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Collections; +import java.util.Objects; + +@ExtendWith(MockitoExtension.class) +class LoanPricingWorkflowServiceImplTest +{ + @Mock + private LoanPricingWorkflowMapper loanPricingWorkflowMapper; + + @Mock + private LoanPricingModelService loanPricingModelService; + + @Mock + private ModelRetailOutputFieldsMapper modelRetailOutputFieldsMapper; + + @Mock + private ModelCorpOutputFieldsMapper modelCorpOutputFieldsMapper; + + @Mock + private SensitiveFieldCryptoService sensitiveFieldCryptoService; + + @Mock + private LoanPricingSensitiveDisplayService loanPricingSensitiveDisplayService; + + @InjectMocks + private LoanPricingWorkflowServiceImpl loanPricingWorkflowService; + + @Test + void shouldEncryptCustNameAndIdNumBeforeInsert() + { + LoanPricingWorkflow workflow = new LoanPricingWorkflow(); + workflow.setCustName("张三"); + workflow.setIdNum("110101199001011234"); + workflow.setCustIsn("CUST001"); + + when(sensitiveFieldCryptoService.encrypt("张三")).thenReturn("cipher-name"); + when(sensitiveFieldCryptoService.encrypt("110101199001011234")).thenReturn("cipher-id"); + + loanPricingWorkflowService.createLoanPricing(workflow); + + verify(loanPricingWorkflowMapper).insert(argThat((LoanPricingWorkflow entity) -> + Objects.equals("cipher-name", entity.getCustName()) + && Objects.equals("cipher-id", entity.getIdNum()) + && Objects.equals("CUST001", entity.getCustIsn()))); + } + + @Test + void shouldReturnPagedWorkflowListWithCalculateRate() + { + LoanPricingWorkflowListVO row = new LoanPricingWorkflowListVO(); + row.setCalculateRate("6.15"); + + Page pageResult = new Page<>(1, 10); + pageResult.setRecords(java.util.List.of(row)); + + when(loanPricingWorkflowMapper.selectWorkflowPageWithRates(any(), any())).thenReturn(pageResult); + + IPage result = loanPricingWorkflowService.selectLoanPricingPage(new Page<>(1, 10), new LoanPricingWorkflow()); + + assertEquals("6.15", result.getRecords().get(0).getCalculateRate()); + } + + @Test + void shouldMaskCustNameWhenReturningPagedWorkflowList() + { + LoanPricingWorkflowListVO row = new LoanPricingWorkflowListVO(); + row.setCustName("cipher-name"); + + Page pageResult = new Page<>(1, 10); + pageResult.setRecords(Collections.singletonList(row)); + + when(loanPricingWorkflowMapper.selectWorkflowPageWithRates(any(), any())).thenReturn(pageResult); + when(sensitiveFieldCryptoService.decrypt("cipher-name")).thenReturn("张三"); + when(loanPricingSensitiveDisplayService.maskCustName("张三")).thenReturn("张*"); + + IPage result = loanPricingWorkflowService.selectLoanPricingPage(new Page<>(1, 10), new LoanPricingWorkflow()); + + assertEquals("张*", result.getRecords().get(0).getCustName()); + } + + @Test + void shouldUseCustIsnInsteadOfCustNameAsQueryCondition() + { + LoanPricingWorkflow query = new LoanPricingWorkflow(); + query.setCustIsn("CUST001"); + query.setCustName("张三"); + + when(loanPricingWorkflowMapper.selectList(any())).thenReturn(Collections.emptyList()); + + loanPricingWorkflowService.selectLoanPricingList(query); + + ArgumentCaptor> wrapperCaptor = ArgumentCaptor.forClass(LambdaQueryWrapper.class); + verify(loanPricingWorkflowMapper).selectList(wrapperCaptor.capture()); + + LambdaQueryWrapper wrapper = wrapperCaptor.getValue(); + TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), LoanPricingWorkflow.class); + String sqlSegment = wrapper.getSqlSegment(); + + assertTrue(sqlSegment.contains("cust_isn"), sqlSegment); + assertTrue(!sqlSegment.contains("cust_name"), sqlSegment); + } + + @Test + void shouldUseRetailModelOutputFinalCalculateRateForWorkflowDetail() + { + LoanPricingWorkflow workflow = new LoanPricingWorkflow(); + workflow.setSerialNum("P20260328001"); + workflow.setCustType("个人"); + workflow.setModelOutputId(11L); + workflow.setLoanRate("4.35"); + + ModelRetailOutputFields retailOutputFields = new ModelRetailOutputFields(); + retailOutputFields.setCalculateRate("6.15"); + retailOutputFields.setFinalCalculateRate("6.05"); + + when(loanPricingWorkflowMapper.selectOne(any())).thenReturn(workflow); + when(modelRetailOutputFieldsMapper.selectById(11L)).thenReturn(retailOutputFields); + + LoanPricingWorkflowVO result = loanPricingWorkflowService.selectLoanPricingBySerialNum("P20260328001"); + + assertEquals("6.05", result.getLoanPricingWorkflow().getLoanRate()); + assertEquals("6.15", result.getModelRetailOutputFields().getCalculateRate()); + assertEquals("6.05", result.getModelRetailOutputFields().getFinalCalculateRate()); + } + + @Test + void shouldMaskCustNameAndIdNumWhenReturningWorkflowDetail() + { + LoanPricingWorkflow workflow = new LoanPricingWorkflow(); + workflow.setSerialNum("P20260328001"); + workflow.setCustType("个人"); + workflow.setCustName("cipher-name"); + workflow.setIdNum("cipher-id"); + + when(loanPricingWorkflowMapper.selectOne(any())).thenReturn(workflow); + when(sensitiveFieldCryptoService.decrypt("cipher-name")).thenReturn("张三"); + when(sensitiveFieldCryptoService.decrypt("cipher-id")).thenReturn("110101199001011234"); + when(loanPricingSensitiveDisplayService.maskCustName("张三")).thenReturn("张*"); + when(loanPricingSensitiveDisplayService.maskIdNum("110101199001011234")).thenReturn("1101********1234"); + + LoanPricingWorkflowVO result = loanPricingWorkflowService.selectLoanPricingBySerialNum("P20260328001"); + + assertEquals("张*", result.getLoanPricingWorkflow().getCustName()); + assertEquals("1101********1234", result.getLoanPricingWorkflow().getIdNum()); + } + + @Test + void shouldMaskCustNameAndIdNumInRetailModelOutputBasicInfo() + { + LoanPricingWorkflow workflow = new LoanPricingWorkflow(); + workflow.setSerialNum("P20260328001"); + workflow.setCustType("个人"); + workflow.setCustName("cipher-name"); + workflow.setIdNum("cipher-id"); + workflow.setModelOutputId(11L); + + ModelRetailOutputFields retailOutputFields = new ModelRetailOutputFields(); + retailOutputFields.setCustName("张三"); + retailOutputFields.setIdNum("110101199001011234"); + retailOutputFields.setCalculateRate("6.15"); + + when(loanPricingWorkflowMapper.selectOne(any())).thenReturn(workflow); + when(modelRetailOutputFieldsMapper.selectById(11L)).thenReturn(retailOutputFields); + when(sensitiveFieldCryptoService.decrypt("cipher-name")).thenReturn("张三"); + when(sensitiveFieldCryptoService.decrypt("cipher-id")).thenReturn("110101199001011234"); + when(loanPricingSensitiveDisplayService.maskCustName("张三")).thenReturn("张*"); + when(loanPricingSensitiveDisplayService.maskIdNum("110101199001011234")).thenReturn("1101********1234"); + + LoanPricingWorkflowVO result = loanPricingWorkflowService.selectLoanPricingBySerialNum("P20260328001"); + + assertEquals("张*", result.getModelRetailOutputFields().getCustName()); + assertEquals("1101********1234", result.getModelRetailOutputFields().getIdNum()); + } + + @Test + void shouldUseCorporateModelOutputCalculateRateForWorkflowDetail() + { + LoanPricingWorkflow workflow = new LoanPricingWorkflow(); + workflow.setSerialNum("C20260328001"); + workflow.setCustType("企业"); + workflow.setModelOutputId(22L); + workflow.setLoanRate("3.80"); + + ModelCorpOutputFields corpOutputFields = new ModelCorpOutputFields(); + corpOutputFields.setCalculateRate("3.932"); + + when(loanPricingWorkflowMapper.selectOne(any())).thenReturn(workflow); + when(modelCorpOutputFieldsMapper.selectById(22L)).thenReturn(corpOutputFields); + + LoanPricingWorkflowVO result = loanPricingWorkflowService.selectLoanPricingBySerialNum("C20260328001"); + + assertEquals("3.932", result.getLoanPricingWorkflow().getLoanRate()); + assertEquals("3.932", result.getModelCorpOutputFields().getCalculateRate()); + } + + @Test + void shouldMaskCustNameAndIdNumInCorporateModelOutputBasicInfo() + { + LoanPricingWorkflow workflow = new LoanPricingWorkflow(); + workflow.setSerialNum("C20260328001"); + workflow.setCustType("企业"); + workflow.setCustName("cipher-name"); + workflow.setIdNum("cipher-id"); + workflow.setModelOutputId(22L); + + ModelCorpOutputFields corpOutputFields = new ModelCorpOutputFields(); + corpOutputFields.setCustName("测试科技有限公司"); + corpOutputFields.setIdNum("91110000100000000X"); + corpOutputFields.setCalculateRate("3.932"); + + when(loanPricingWorkflowMapper.selectOne(any())).thenReturn(workflow); + when(modelCorpOutputFieldsMapper.selectById(22L)).thenReturn(corpOutputFields); + when(sensitiveFieldCryptoService.decrypt("cipher-name")).thenReturn("测试科技有限公司"); + when(sensitiveFieldCryptoService.decrypt("cipher-id")).thenReturn("91110000100000000X"); + when(loanPricingSensitiveDisplayService.maskCustName("测试科技有限公司")).thenReturn("测试****公司"); + when(loanPricingSensitiveDisplayService.maskIdNum("91110000100000000X")).thenReturn("91*************00X"); + + LoanPricingWorkflowVO result = loanPricingWorkflowService.selectLoanPricingBySerialNum("C20260328001"); + + assertEquals("测试****公司", result.getModelCorpOutputFields().getCustName()); + assertEquals("91*************00X", result.getModelCorpOutputFields().getIdNum()); + } +} diff --git a/ruoyi-ui/.env.development b/ruoyi-ui/.env.development index 18b2a3e..eded11b 100644 --- a/ruoyi-ui/.env.development +++ b/ruoyi-ui/.env.development @@ -4,8 +4,9 @@ VUE_APP_TITLE = 若依管理系统 # 开发环境配置 ENV = 'development' -# 若依管理系统/开发环境 -VUE_APP_BASE_API = '/dev-api' - -# 路由懒加载 -VUE_CLI_BABEL_TRANSPILE_MODULES = true +# 若依管理系统/开发环境 +VUE_APP_BASE_API = '/dev-api' +VUE_APP_PASSWORD_TRANSFER_KEY = '1234567890abcdef' + +# 路由懒加载 +VUE_CLI_BABEL_TRANSPILE_MODULES = true diff --git a/ruoyi-ui/.env.production b/ruoyi-ui/.env.production index cb064ec..6b23067 100644 --- a/ruoyi-ui/.env.production +++ b/ruoyi-ui/.env.production @@ -4,5 +4,6 @@ VUE_APP_TITLE = 若依管理系统 # 生产环境配置 ENV = 'production' -# 若依管理系统/生产环境 -VUE_APP_BASE_API = '/prod-api' +# 若依管理系统/生产环境 +VUE_APP_BASE_API = '/prod-api' +VUE_APP_PASSWORD_TRANSFER_KEY = '1234567890abcdef' diff --git a/ruoyi-ui/package.json b/ruoyi-ui/package.json index 6c80837..9daeb4f 100644 --- a/ruoyi-ui/package.json +++ b/ruoyi-ui/package.json @@ -28,6 +28,7 @@ "axios": "0.30.3", "clipboard": "2.0.8", "core-js": "3.37.1", + "crypto-js": "4.2.0", "echarts": "5.4.0", "element-ui": "2.15.14", "file-saver": "2.0.5", @@ -54,6 +55,7 @@ "chalk": "4.1.0", "compression-webpack-plugin": "6.1.2", "connect": "3.6.6", + "html-webpack-plugin": "3.2.0", "sass": "1.32.13", "sass-loader": "10.1.1", "script-ext-html-webpack-plugin": "2.1.5", diff --git a/ruoyi-ui/src/api/loanPricing/workflow.js b/ruoyi-ui/src/api/loanPricing/workflow.js new file mode 100644 index 0000000..b314a8d --- /dev/null +++ b/ruoyi-ui/src/api/loanPricing/workflow.js @@ -0,0 +1,45 @@ +import request from '@/utils/request' + +// 查询利率定价流程列表 +export function listWorkflow(query) { + return request({ + url: '/loanPricing/workflow/list', + method: 'get', + params: query + }) +} + +// 查询利率定价流程详情 +export function getWorkflow(serialNum) { + return request({ + url: '/loanPricing/workflow/' + serialNum, + method: 'get' + }) +} + +// 创建个人客户利率定价流程 +export function createPersonalWorkflow(data) { + return request({ + url: '/loanPricing/workflow/create/personal', + method: 'post', + data: data + }) +} + +// 创建企业客户利率定价流程 +export function createCorporateWorkflow(data) { + return request({ + url: '/loanPricing/workflow/create/corporate', + method: 'post', + data: data + }) +} + +// 设定执行利率 +export function setExecuteRate(serialNum, executeRate) { + return request({ + url: '/loanPricing/workflow/' + serialNum + '/executeRate', + method: 'put', + data: { executeRate: executeRate } + }) +} diff --git a/ruoyi-ui/src/api/login.js b/ruoyi-ui/src/api/login.js index 5be6f62..cf6e7e2 100644 --- a/ruoyi-ui/src/api/login.js +++ b/ruoyi-ui/src/api/login.js @@ -1,14 +1,15 @@ -import request from '@/utils/request' - -// 登录方法 -export function login(username, password, code, uuid) { - const data = { - username, - password, - code, - uuid - } - return request({ +import request from '@/utils/request' +import { encryptPasswordFields } from '@/utils/passwordTransfer' + +// 登录方法 +export function login(username, password, code, uuid) { + const data = encryptPasswordFields({ + username, + password, + code, + uuid + }, ['password'], process.env.VUE_APP_PASSWORD_TRANSFER_KEY) + return request({ url: '/login', headers: { isToken: false, @@ -17,19 +18,20 @@ export function login(username, password, code, uuid) { method: 'post', data: data }) -} - -// 注册方法 -export function register(data) { - return request({ - url: '/register', +} + +// 注册方法 +export function register(data) { + const payload = encryptPasswordFields(data, ['password'], process.env.VUE_APP_PASSWORD_TRANSFER_KEY) + return request({ + url: '/register', headers: { isToken: false - }, - method: 'post', - data: data - }) -} + }, + method: 'post', + data: payload + }) +} // 获取用户详细信息 export function getInfo() { @@ -66,4 +68,4 @@ export function getCodeImg() { method: 'get', timeout: 20000 }) -} \ No newline at end of file +} diff --git a/ruoyi-ui/src/api/system/user.js b/ruoyi-ui/src/api/system/user.js index 7de6384..9812d43 100644 --- a/ruoyi-ui/src/api/system/user.js +++ b/ruoyi-ui/src/api/system/user.js @@ -1,5 +1,6 @@ -import request from '@/utils/request' -import { parseStrEmpty } from "@/utils/ruoyi" +import request from '@/utils/request' +import { parseStrEmpty } from "@/utils/ruoyi" +import { encryptPasswordFields } from '@/utils/passwordTransfer' // 查询用户列表 export function listUser(query) { @@ -19,13 +20,14 @@ export function getUser(userId) { } // 新增用户 -export function addUser(data) { - return request({ - url: '/system/user', - method: 'post', - data: data - }) -} +export function addUser(data) { + const payload = encryptPasswordFields(data, ['password'], process.env.VUE_APP_PASSWORD_TRANSFER_KEY) + return request({ + url: '/system/user', + method: 'post', + data: payload + }) +} // 修改用户 export function updateUser(data) { @@ -45,12 +47,12 @@ export function delUser(userId) { } // 用户密码重置 -export function resetUserPwd(userId, password) { - const data = { - userId, - password - } - return request({ +export function resetUserPwd(userId, password) { + const data = encryptPasswordFields({ + userId, + password + }, ['password'], process.env.VUE_APP_PASSWORD_TRANSFER_KEY) + return request({ url: '/system/user/resetPwd', method: 'put', data: data @@ -88,12 +90,12 @@ export function updateUserProfile(data) { } // 用户密码重置 -export function updateUserPwd(oldPassword, newPassword) { - const data = { - oldPassword, - newPassword - } - return request({ +export function updateUserPwd(oldPassword, newPassword) { + const data = encryptPasswordFields({ + oldPassword, + newPassword + }, ['oldPassword', 'newPassword'], process.env.VUE_APP_PASSWORD_TRANSFER_KEY) + return request({ url: '/system/user/profile/updatePwd', method: 'put', data: data diff --git a/ruoyi-ui/src/utils/passwordTransfer.js b/ruoyi-ui/src/utils/passwordTransfer.js new file mode 100644 index 0000000..6427b23 --- /dev/null +++ b/ruoyi-ui/src/utils/passwordTransfer.js @@ -0,0 +1,14 @@ +import CryptoJS from 'crypto-js' + +export function encryptPasswordFields(payload, fields, key) { + const next = { ...payload } + fields.forEach((field) => { + if (next[field]) { + next[field] = CryptoJS.AES.encrypt(next[field], CryptoJS.enc.Utf8.parse(key), { + mode: CryptoJS.mode.ECB, + padding: CryptoJS.pad.Pkcs7 + }).toString() + } + }) + return next +} diff --git a/ruoyi-ui/src/views/loanPricing/workflow/components/BargainingPoolDisplay.vue b/ruoyi-ui/src/views/loanPricing/workflow/components/BargainingPoolDisplay.vue new file mode 100644 index 0000000..b0aab5a --- /dev/null +++ b/ruoyi-ui/src/views/loanPricing/workflow/components/BargainingPoolDisplay.vue @@ -0,0 +1,100 @@ + + + + + diff --git a/ruoyi-ui/src/views/loanPricing/workflow/components/CorporateCreateDialog.vue b/ruoyi-ui/src/views/loanPricing/workflow/components/CorporateCreateDialog.vue new file mode 100644 index 0000000..44f906c --- /dev/null +++ b/ruoyi-ui/src/views/loanPricing/workflow/components/CorporateCreateDialog.vue @@ -0,0 +1,303 @@ + + + + + diff --git a/ruoyi-ui/src/views/loanPricing/workflow/components/CorporateWorkflowDetail.vue b/ruoyi-ui/src/views/loanPricing/workflow/components/CorporateWorkflowDetail.vue new file mode 100644 index 0000000..fe17473 --- /dev/null +++ b/ruoyi-ui/src/views/loanPricing/workflow/components/CorporateWorkflowDetail.vue @@ -0,0 +1,324 @@ + + + + + diff --git a/ruoyi-ui/src/views/loanPricing/workflow/components/CustomerTypeSelector.vue b/ruoyi-ui/src/views/loanPricing/workflow/components/CustomerTypeSelector.vue new file mode 100644 index 0000000..98167ee --- /dev/null +++ b/ruoyi-ui/src/views/loanPricing/workflow/components/CustomerTypeSelector.vue @@ -0,0 +1,105 @@ + + + + + diff --git a/ruoyi-ui/src/views/loanPricing/workflow/components/ModelOutputDisplay.vue b/ruoyi-ui/src/views/loanPricing/workflow/components/ModelOutputDisplay.vue new file mode 100644 index 0000000..4aa649d --- /dev/null +++ b/ruoyi-ui/src/views/loanPricing/workflow/components/ModelOutputDisplay.vue @@ -0,0 +1,311 @@ + + + + + diff --git a/ruoyi-ui/src/views/loanPricing/workflow/components/PersonalCreateDialog.vue b/ruoyi-ui/src/views/loanPricing/workflow/components/PersonalCreateDialog.vue new file mode 100644 index 0000000..910cb95 --- /dev/null +++ b/ruoyi-ui/src/views/loanPricing/workflow/components/PersonalCreateDialog.vue @@ -0,0 +1,281 @@ + + + + + diff --git a/ruoyi-ui/src/views/loanPricing/workflow/components/PersonalWorkflowDetail.vue b/ruoyi-ui/src/views/loanPricing/workflow/components/PersonalWorkflowDetail.vue new file mode 100644 index 0000000..d679f4c --- /dev/null +++ b/ruoyi-ui/src/views/loanPricing/workflow/components/PersonalWorkflowDetail.vue @@ -0,0 +1,330 @@ + + + + + diff --git a/ruoyi-ui/src/views/loanPricing/workflow/detail.vue b/ruoyi-ui/src/views/loanPricing/workflow/detail.vue new file mode 100644 index 0000000..5d7bf02 --- /dev/null +++ b/ruoyi-ui/src/views/loanPricing/workflow/detail.vue @@ -0,0 +1,97 @@ + + + + + diff --git a/ruoyi-ui/src/views/loanPricing/workflow/index.vue b/ruoyi-ui/src/views/loanPricing/workflow/index.vue new file mode 100644 index 0000000..360231b --- /dev/null +++ b/ruoyi-ui/src/views/loanPricing/workflow/index.vue @@ -0,0 +1,182 @@ + + + diff --git a/ruoyi-ui/src/views/login.vue b/ruoyi-ui/src/views/login.vue index 30ff921..12a2447 100644 --- a/ruoyi-ui/src/views/login.vue +++ b/ruoyi-ui/src/views/login.vue @@ -73,13 +73,13 @@ export default { return { title: process.env.VUE_APP_TITLE, footerContent: defaultSettings.footerContent, - codeUrl: "", - loginForm: { - username: "admin", - password: "admin123", - rememberMe: false, - code: "", - uuid: "" + codeUrl: "", + loginForm: { + username: "", + password: "", + rememberMe: false, + code: "", + uuid: "" }, loginRules: { username: [ diff --git a/ruoyi-ui/tests/id-number-validation-removal.test.js b/ruoyi-ui/tests/id-number-validation-removal.test.js new file mode 100644 index 0000000..ba9e505 --- /dev/null +++ b/ruoyi-ui/tests/id-number-validation-removal.test.js @@ -0,0 +1,34 @@ +const fs = require('fs') +const path = require('path') +const assert = require('assert') + +function read(relativePath) { + return fs.readFileSync(path.join(__dirname, '..', relativePath), 'utf8') +} + +const personalCreateDialog = read('src/views/loanPricing/workflow/components/PersonalCreateDialog.vue') +const corporateCreateDialog = read('src/views/loanPricing/workflow/components/CorporateCreateDialog.vue') + +assert( + !personalCreateDialog.includes('const validateIdNum ='), + '个人新增弹窗仍包含证件号码格式校验函数' +) + +assert( + !corporateCreateDialog.includes('const validateIdNum ='), + '企业新增弹窗仍包含证件号码格式校验函数' +) + +assert( + personalCreateDialog.includes("idNum: [") && + personalCreateDialog.includes('{required: true, message: "证件号码不能为空", trigger: "blur"}'), + '个人新增弹窗证件号码规则应仅保留必填' +) + +assert( + corporateCreateDialog.includes("idNum: [") && + corporateCreateDialog.includes('{required: true, message: "证件号码不能为空", trigger: "blur"}'), + '企业新增弹窗证件号码规则应仅保留必填' +) + +console.log('id number validation removal assertions passed') diff --git a/ruoyi-ui/tests/login-default-credentials.test.js b/ruoyi-ui/tests/login-default-credentials.test.js new file mode 100644 index 0000000..39bf690 --- /dev/null +++ b/ruoyi-ui/tests/login-default-credentials.test.js @@ -0,0 +1,30 @@ +const fs = require('fs') +const path = require('path') +const assert = require('assert') + +const loginViewSource = fs.readFileSync( + path.join(__dirname, '../src/views/login.vue'), + 'utf8' +) + +assert( + /loginForm:\s*\{[\s\S]*username:\s*""/.test(loginViewSource), + '登录页默认用户名应为空字符串' +) + +assert( + /loginForm:\s*\{[\s\S]*password:\s*""/.test(loginViewSource), + '登录页默认密码应为空字符串' +) + +assert( + /username:\s*username === undefined \? this\.loginForm\.username : username/.test(loginViewSource), + '登录页应继续支持从 cookie 回填用户名' +) + +assert( + /password:\s*password === undefined \? this\.loginForm\.password : decrypt\(password\)/.test(loginViewSource), + '登录页应继续支持从 cookie 回填密码' +) + +console.log('login default credentials assertions passed') diff --git a/ruoyi-ui/tests/password-transfer-api.test.js b/ruoyi-ui/tests/password-transfer-api.test.js new file mode 100644 index 0000000..fd7a37f --- /dev/null +++ b/ruoyi-ui/tests/password-transfer-api.test.js @@ -0,0 +1,88 @@ +const assert = require('assert') +const fs = require('fs') +const path = require('path') +const vm = require('vm') + +function loadModule(filePath, stubs = {}) { + const source = fs.readFileSync(filePath, 'utf8') + const exportedNames = [] + const transformed = source + .replace(/^import .*$/gm, '') + .replace(/export function\s+([A-Za-z0-9_]+)\s*\(/g, (_, name) => { + exportedNames.push(name) + return `function ${name}(` + }) + .replace(/export default\s+/g, 'module.exports = ') + + const sandbox = { + module: { exports: {} }, + exports: {}, + require, + console, + process: { + env: { + VUE_APP_PASSWORD_TRANSFER_KEY: '1234567890abcdef' + } + }, + ...stubs + } + + vm.runInNewContext( + `${transformed}\nmodule.exports = { ${exportedNames.join(', ')} };`, + sandbox, + { filename: filePath } + ) + + return sandbox.module.exports +} + +const passwordTransferModule = loadModule( + path.resolve(__dirname, '../src/utils/passwordTransfer.js'), + { CryptoJS: require('crypto-js') } +) + +const { encryptPasswordFields } = passwordTransferModule + +const encrypted = encryptPasswordFields( + { password: 'admin123', code: '8888' }, + ['password'], + '1234567890abcdef' +) + +assert.notStrictEqual(encrypted.password, 'admin123') +assert.strictEqual(encrypted.code, '8888') + +const request = config => config +const loginModule = loadModule( + path.resolve(__dirname, '../src/api/login.js'), + { request, encryptPasswordFields } +) + +const loginConfig = loginModule.login('admin', 'admin123', '8888', 'uuid-1') +assert.notStrictEqual(loginConfig.data.password, 'admin123') +assert.strictEqual(loginConfig.data.username, 'admin') + +const registerConfig = loginModule.register({ username: 'u1', password: 'p1', confirmPassword: 'p1', code: '8888' }) +assert.notStrictEqual(registerConfig.data.password, 'p1') +assert.strictEqual(registerConfig.data.confirmPassword, 'p1') + +const userModule = loadModule( + path.resolve(__dirname, '../src/api/system/user.js'), + { + request, + encryptPasswordFields, + parseStrEmpty: value => value + } +) + +const updatePwdConfig = userModule.updateUserPwd('oldPwd', 'newPwd') +assert.notStrictEqual(updatePwdConfig.data.oldPassword, 'oldPwd') +assert.notStrictEqual(updatePwdConfig.data.newPassword, 'newPwd') + +const addUserConfig = userModule.addUser({ userName: 'u1', password: 'initPwd', nickName: 'n1' }) +assert.notStrictEqual(addUserConfig.data.password, 'initPwd') + +const resetUserPwdConfig = userModule.resetUserPwd(2, 'resetPwd') +assert.notStrictEqual(resetUserPwdConfig.data.password, 'resetPwd') + +console.log('password-transfer-api test passed') diff --git a/ruoyi-ui/tests/personal-create-input-params.test.js b/ruoyi-ui/tests/personal-create-input-params.test.js new file mode 100644 index 0000000..88ac252 --- /dev/null +++ b/ruoyi-ui/tests/personal-create-input-params.test.js @@ -0,0 +1,65 @@ +const fs = require('fs') +const path = require('path') +const assert = require('assert') + +function read(relativePath) { + return fs.readFileSync(path.join(__dirname, '..', relativePath), 'utf8') +} + +const personalCreateDialog = read('src/views/loanPricing/workflow/components/PersonalCreateDialog.vue') +const personalDetail = read('src/views/loanPricing/workflow/components/PersonalWorkflowDetail.vue') + +assert( + personalCreateDialog.includes('label="贷款用途"') && personalCreateDialog.includes('prop="loanPurpose"'), + '个人新增弹窗缺少贷款用途字段' +) + +assert( + personalCreateDialog.includes('label="借款期限(年)"') && personalCreateDialog.includes('prop="loanTerm"'), + '个人新增弹窗缺少借款期限字段' +) + +assert( + personalCreateDialog.includes("value=\"consumer\"") && personalCreateDialog.includes("value=\"business\""), + '个人新增弹窗缺少贷款用途选项' +) + +assert( + personalCreateDialog.includes('loanTermOptions') && + personalCreateDialog.includes("'1'") && + personalCreateDialog.includes("'6'") && + !personalCreateDialog.includes("'7'"), + '个人新增弹窗借款期限选项应限制为 1-6 年' +) + +assert( + personalCreateDialog.includes('label="一类"') && + personalCreateDialog.includes('label="二类"') && + personalCreateDialog.includes('label="三类"') && + !personalCreateDialog.includes('label="一线"'), + '个人新增弹窗抵质押类型选项未按 Excel 对齐' +) + +assert( + !personalCreateDialog.includes('{required: true, message: "请选择抵质押类型", trigger: "change"}'), + '个人新增弹窗仍将抵质押类型设为必填' +) + +assert( + personalCreateDialog.includes("bizProof: this.form.bizProof ? '1' : '0'") && + personalCreateDialog.includes("loanLoop: this.form.loanLoop ? '1' : '0'") && + personalCreateDialog.includes("collThirdParty: this.form.collThirdParty ? '1' : '0'"), + '个人新增弹窗开关字段未按 1/0 提交' +) + +assert( + personalDetail.includes('label="贷款用途"') && personalDetail.includes('detailData.loanPurpose'), + '个人详情页缺少贷款用途展示' +) + +assert( + personalDetail.includes("value === '1'") && personalDetail.includes("value === '0'"), + '个人详情页布尔格式化未兼容 1/0' +) + +console.log('personal create input params assertions passed') diff --git a/ruoyi-ui/tests/personal-final-calculate-rate-display.test.js b/ruoyi-ui/tests/personal-final-calculate-rate-display.test.js new file mode 100644 index 0000000..e1791e5 --- /dev/null +++ b/ruoyi-ui/tests/personal-final-calculate-rate-display.test.js @@ -0,0 +1,21 @@ +const fs = require('fs') +const path = require('path') +const assert = require('assert') + +function read(relativePath) { + return fs.readFileSync(path.join(__dirname, '..', relativePath), 'utf8') +} + +const personalDetail = read('src/views/loanPricing/workflow/components/PersonalWorkflowDetail.vue') + +assert( + /label="最终测算利率"/.test(personalDetail), + '个人流程详情左侧缺少“最终测算利率”标签' +) + +assert( + /return this\.retailOutput\?\.finalCalculateRate \|\| '-'/.test(personalDetail), + '个人流程详情没有使用 finalCalculateRate 展示最终测算利率' +) + +console.log('personal final calculate rate display assertions passed') diff --git a/ruoyi-ui/tests/retail-display-fields.test.js b/ruoyi-ui/tests/retail-display-fields.test.js new file mode 100644 index 0000000..530cb85 --- /dev/null +++ b/ruoyi-ui/tests/retail-display-fields.test.js @@ -0,0 +1,29 @@ +const fs = require('fs') +const path = require('path') +const assert = require('assert') + +function read(relativePath) { + return fs.readFileSync(path.join(__dirname, '..', relativePath), 'utf8') +} + +const personalDetail = read('src/views/loanPricing/workflow/components/PersonalWorkflowDetail.vue') +const modelOutput = read('src/views/loanPricing/workflow/components/ModelOutputDisplay.vue') + +assert( + personalDetail.includes('label="借款期限"') && personalDetail.includes('detailData.loanTerm'), + '个人详情页缺少借款期限展示' +) + +const requiredRetailFields = [ + 'retailOutput.loanRateHistory', + 'retailOutput.minRateProduct', + 'retailOutput.smoothRange', + 'retailOutput.finalCalculateRate', + 'retailOutput.referenceRate' +] + +requiredRetailFields.forEach((field) => { + assert(modelOutput.includes(field), `模型输出缺少字段展示: ${field}`) +}) + +console.log('retail display fields assertions passed') diff --git a/ruoyi-ui/tests/workflow-detail-card-order.test.js b/ruoyi-ui/tests/workflow-detail-card-order.test.js new file mode 100644 index 0000000..442db7a --- /dev/null +++ b/ruoyi-ui/tests/workflow-detail-card-order.test.js @@ -0,0 +1,27 @@ +const fs = require('fs') +const path = require('path') +const assert = require('assert') + +function read(relativePath) { + return fs.readFileSync(path.join(__dirname, '..', relativePath), 'utf8') +} + +function assertModelOutputBeforeDetailCard(source, label) { + const modelOutputIndex = source.indexOf('') + + assert(modelOutputIndex !== -1, `${label} 缺少模型输出卡片`) + assert(detailCardIndex !== -1, `${label} 缺少流程详情卡片`) + assert( + modelOutputIndex < detailCardIndex, + `${label} 的模型输出卡片应位于流程详情卡片上方` + ) +} + +const personalDetail = read('src/views/loanPricing/workflow/components/PersonalWorkflowDetail.vue') +const corporateDetail = read('src/views/loanPricing/workflow/components/CorporateWorkflowDetail.vue') + +assertModelOutputBeforeDetailCard(personalDetail, '个人流程详情') +assertModelOutputBeforeDetailCard(corporateDetail, '企业流程详情') + +console.log('workflow detail card order assertions passed') diff --git a/ruoyi-ui/tests/workflow-index-refresh.test.js b/ruoyi-ui/tests/workflow-index-refresh.test.js new file mode 100644 index 0000000..b049447 --- /dev/null +++ b/ruoyi-ui/tests/workflow-index-refresh.test.js @@ -0,0 +1,52 @@ +const assert = require('assert') +const fs = require('fs') +const path = require('path') +const vm = require('vm') + +function loadComponentOptions(filePath) { + const source = fs.readFileSync(filePath, 'utf8') + const scriptMatch = source.match(/