You need to sign in before continuing.

Commit f3eace43b81d305ca3d1e9a373ab3367d614a5eb

Authored by qianbao
1 parent 3af94dd2

1111

Showing 99 changed files with 10891 additions and 0 deletions
.gitignore 0 → 100644
  1 +HELP.md
  2 +target/
  3 +.mvn/wrapper/maven-wrapper.jar
  4 +!**/src/main/**/target/
  5 +!**/src/test/**/target/
  6 +
  7 +### STS ###
  8 +.apt_generated
  9 +.classpath
  10 +.factorypath
  11 +.project
  12 +.settings
  13 +.springBeans
  14 +.sts4-cache
  15 +
  16 +### IntelliJ IDEA ###
  17 +.idea
  18 +*.iws
  19 +*.iml
  20 +*.ipr
  21 +
  22 +### NetBeans ###
  23 +/nbproject/private/
  24 +/nbbuild/
  25 +/dist/
  26 +/nbdist/
  27 +/.nb-gradle/
  28 +build/
  29 +!**/src/main/**/build/
  30 +!**/src/test/**/build/
  31 +
  32 +### VS Code ###
  33 +.vscode/
... ...
.mvn/wrapper/maven-wrapper.properties 0 → 100644
  1 +wrapperVersion=3.3.4
  2 +distributionType=only-script
  3 +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.12/apache-maven-3.9.12-bin.zip
... ...
logPath_IS_UNDEFINED/2026-02-09/2026-02-09.debug-0.log 0 → 100644
logPath_IS_UNDEFINED/2026-02-09/2026-02-09.error-0.log 0 → 100644
logPath_IS_UNDEFINED/2026-02-09/2026-02-09.info-0.log 0 → 100644
mvnw 0 → 100644
  1 +#!/bin/sh
  2 +# ----------------------------------------------------------------------------
  3 +# Licensed to the Apache Software Foundation (ASF) under one
  4 +# or more contributor license agreements. See the NOTICE file
  5 +# distributed with this work for additional information
  6 +# regarding copyright ownership. The ASF licenses this file
  7 +# to you under the Apache License, Version 2.0 (the
  8 +# "License"); you may not use this file except in compliance
  9 +# with the License. You may obtain a copy of the License at
  10 +#
  11 +# http://www.apache.org/licenses/LICENSE-2.0
  12 +#
  13 +# Unless required by applicable law or agreed to in writing,
  14 +# software distributed under the License is distributed on an
  15 +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
  16 +# KIND, either express or implied. See the License for the
  17 +# specific language governing permissions and limitations
  18 +# under the License.
  19 +# ----------------------------------------------------------------------------
  20 +
  21 +# ----------------------------------------------------------------------------
  22 +# Apache Maven Wrapper startup batch script, version 3.3.4
  23 +#
  24 +# Optional ENV vars
  25 +# -----------------
  26 +# JAVA_HOME - location of a JDK home dir, required when download maven via java source
  27 +# MVNW_REPOURL - repo url base for downloading maven distribution
  28 +# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
  29 +# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output
  30 +# ----------------------------------------------------------------------------
  31 +
  32 +set -euf
  33 +[ "${MVNW_VERBOSE-}" != debug ] || set -x
  34 +
  35 +# OS specific support.
  36 +native_path() { printf %s\\n "$1"; }
  37 +case "$(uname)" in
  38 +CYGWIN* | MINGW*)
  39 + [ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")"
  40 + native_path() { cygpath --path --windows "$1"; }
  41 + ;;
  42 +esac
  43 +
  44 +# set JAVACMD and JAVACCMD
  45 +set_java_home() {
  46 + # For Cygwin and MinGW, ensure paths are in Unix format before anything is touched
  47 + if [ -n "${JAVA_HOME-}" ]; then
  48 + if [ -x "$JAVA_HOME/jre/sh/java" ]; then
  49 + # IBM's JDK on AIX uses strange locations for the executables
  50 + JAVACMD="$JAVA_HOME/jre/sh/java"
  51 + JAVACCMD="$JAVA_HOME/jre/sh/javac"
  52 + else
  53 + JAVACMD="$JAVA_HOME/bin/java"
  54 + JAVACCMD="$JAVA_HOME/bin/javac"
  55 +
  56 + if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then
  57 + echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2
  58 + echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2
  59 + return 1
  60 + fi
  61 + fi
  62 + else
  63 + JAVACMD="$(
  64 + 'set' +e
  65 + 'unset' -f command 2>/dev/null
  66 + 'command' -v java
  67 + )" || :
  68 + JAVACCMD="$(
  69 + 'set' +e
  70 + 'unset' -f command 2>/dev/null
  71 + 'command' -v javac
  72 + )" || :
  73 +
  74 + if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then
  75 + echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2
  76 + return 1
  77 + fi
  78 + fi
  79 +}
  80 +
  81 +# hash string like Java String::hashCode
  82 +hash_string() {
  83 + str="${1:-}" h=0
  84 + while [ -n "$str" ]; do
  85 + char="${str%"${str#?}"}"
  86 + h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296))
  87 + str="${str#?}"
  88 + done
  89 + printf %x\\n $h
  90 +}
  91 +
  92 +verbose() { :; }
  93 +[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; }
  94 +
  95 +die() {
  96 + printf %s\\n "$1" >&2
  97 + exit 1
  98 +}
  99 +
  100 +trim() {
  101 + # MWRAPPER-139:
  102 + # Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds.
  103 + # Needed for removing poorly interpreted newline sequences when running in more
  104 + # exotic environments such as mingw bash on Windows.
  105 + printf "%s" "${1}" | tr -d '[:space:]'
  106 +}
  107 +
  108 +scriptDir="$(dirname "$0")"
  109 +scriptName="$(basename "$0")"
  110 +
  111 +# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties
  112 +while IFS="=" read -r key value; do
  113 + case "${key-}" in
  114 + distributionUrl) distributionUrl=$(trim "${value-}") ;;
  115 + distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;;
  116 + esac
  117 +done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties"
  118 +[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
  119 +
  120 +case "${distributionUrl##*/}" in
  121 +maven-mvnd-*bin.*)
  122 + MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/
  123 + case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in
  124 + *AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;;
  125 + :Darwin*x86_64) distributionPlatform=darwin-amd64 ;;
  126 + :Darwin*arm64) distributionPlatform=darwin-aarch64 ;;
  127 + :Linux*x86_64*) distributionPlatform=linux-amd64 ;;
  128 + *)
  129 + echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2
  130 + distributionPlatform=linux-amd64
  131 + ;;
  132 + esac
  133 + distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip"
  134 + ;;
  135 +maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;;
  136 +*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;;
  137 +esac
  138 +
  139 +# apply MVNW_REPOURL and calculate MAVEN_HOME
  140 +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash>
  141 +[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}"
  142 +distributionUrlName="${distributionUrl##*/}"
  143 +distributionUrlNameMain="${distributionUrlName%.*}"
  144 +distributionUrlNameMain="${distributionUrlNameMain%-bin}"
  145 +MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}"
  146 +MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")"
  147 +
  148 +exec_maven() {
  149 + unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || :
  150 + exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD"
  151 +}
  152 +
  153 +if [ -d "$MAVEN_HOME" ]; then
  154 + verbose "found existing MAVEN_HOME at $MAVEN_HOME"
  155 + exec_maven "$@"
  156 +fi
  157 +
  158 +case "${distributionUrl-}" in
  159 +*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;;
  160 +*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;;
  161 +esac
  162 +
  163 +# prepare tmp dir
  164 +if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then
  165 + clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; }
  166 + trap clean HUP INT TERM EXIT
  167 +else
  168 + die "cannot create temp dir"
  169 +fi
  170 +
  171 +mkdir -p -- "${MAVEN_HOME%/*}"
  172 +
  173 +# Download and Install Apache Maven
  174 +verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
  175 +verbose "Downloading from: $distributionUrl"
  176 +verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
  177 +
  178 +# select .zip or .tar.gz
  179 +if ! command -v unzip >/dev/null; then
  180 + distributionUrl="${distributionUrl%.zip}.tar.gz"
  181 + distributionUrlName="${distributionUrl##*/}"
  182 +fi
  183 +
  184 +# verbose opt
  185 +__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR=''
  186 +[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v
  187 +
  188 +# normalize http auth
  189 +case "${MVNW_PASSWORD:+has-password}" in
  190 +'') MVNW_USERNAME='' MVNW_PASSWORD='' ;;
  191 +has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;;
  192 +esac
  193 +
  194 +if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then
  195 + verbose "Found wget ... using wget"
  196 + wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl"
  197 +elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then
  198 + verbose "Found curl ... using curl"
  199 + curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl"
  200 +elif set_java_home; then
  201 + verbose "Falling back to use Java to download"
  202 + javaSource="$TMP_DOWNLOAD_DIR/Downloader.java"
  203 + targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName"
  204 + cat >"$javaSource" <<-END
  205 + public class Downloader extends java.net.Authenticator
  206 + {
  207 + protected java.net.PasswordAuthentication getPasswordAuthentication()
  208 + {
  209 + return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() );
  210 + }
  211 + public static void main( String[] args ) throws Exception
  212 + {
  213 + setDefault( new Downloader() );
  214 + java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() );
  215 + }
  216 + }
  217 + END
  218 + # For Cygwin/MinGW, switch paths to Windows format before running javac and java
  219 + verbose " - Compiling Downloader.java ..."
  220 + "$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java"
  221 + verbose " - Running Downloader.java ..."
  222 + "$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")"
  223 +fi
  224 +
  225 +# If specified, validate the SHA-256 sum of the Maven distribution zip file
  226 +if [ -n "${distributionSha256Sum-}" ]; then
  227 + distributionSha256Result=false
  228 + if [ "$MVN_CMD" = mvnd.sh ]; then
  229 + echo "Checksum validation is not supported for maven-mvnd." >&2
  230 + echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
  231 + exit 1
  232 + elif command -v sha256sum >/dev/null; then
  233 + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then
  234 + distributionSha256Result=true
  235 + fi
  236 + elif command -v shasum >/dev/null; then
  237 + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then
  238 + distributionSha256Result=true
  239 + fi
  240 + else
  241 + echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2
  242 + echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
  243 + exit 1
  244 + fi
  245 + if [ $distributionSha256Result = false ]; then
  246 + echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2
  247 + echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2
  248 + exit 1
  249 + fi
  250 +fi
  251 +
  252 +# unzip and move
  253 +if command -v unzip >/dev/null; then
  254 + unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip"
  255 +else
  256 + tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar"
  257 +fi
  258 +
  259 +# Find the actual extracted directory name (handles snapshots where filename != directory name)
  260 +actualDistributionDir=""
  261 +
  262 +# First try the expected directory name (for regular distributions)
  263 +if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then
  264 + if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then
  265 + actualDistributionDir="$distributionUrlNameMain"
  266 + fi
  267 +fi
  268 +
  269 +# If not found, search for any directory with the Maven executable (for snapshots)
  270 +if [ -z "$actualDistributionDir" ]; then
  271 + # enable globbing to iterate over items
  272 + set +f
  273 + for dir in "$TMP_DOWNLOAD_DIR"/*; do
  274 + if [ -d "$dir" ]; then
  275 + if [ -f "$dir/bin/$MVN_CMD" ]; then
  276 + actualDistributionDir="$(basename "$dir")"
  277 + break
  278 + fi
  279 + fi
  280 + done
  281 + set -f
  282 +fi
  283 +
  284 +if [ -z "$actualDistributionDir" ]; then
  285 + verbose "Contents of $TMP_DOWNLOAD_DIR:"
  286 + verbose "$(ls -la "$TMP_DOWNLOAD_DIR")"
  287 + die "Could not find Maven distribution directory in extracted archive"
  288 +fi
  289 +
  290 +verbose "Found extracted Maven distribution directory: $actualDistributionDir"
  291 +printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url"
  292 +mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME"
  293 +
  294 +clean || :
  295 +exec_maven "$@"
... ...
mvnw.cmd 0 → 100644
  1 +<# : batch portion
  2 +@REM ----------------------------------------------------------------------------
  3 +@REM Licensed to the Apache Software Foundation (ASF) under one
  4 +@REM or more contributor license agreements. See the NOTICE file
  5 +@REM distributed with this work for additional information
  6 +@REM regarding copyright ownership. The ASF licenses this file
  7 +@REM to you under the Apache License, Version 2.0 (the
  8 +@REM "License"); you may not use this file except in compliance
  9 +@REM with the License. You may obtain a copy of the License at
  10 +@REM
  11 +@REM http://www.apache.org/licenses/LICENSE-2.0
  12 +@REM
  13 +@REM Unless required by applicable law or agreed to in writing,
  14 +@REM software distributed under the License is distributed on an
  15 +@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
  16 +@REM KIND, either express or implied. See the License for the
  17 +@REM specific language governing permissions and limitations
  18 +@REM under the License.
  19 +@REM ----------------------------------------------------------------------------
  20 +
  21 +@REM ----------------------------------------------------------------------------
  22 +@REM Apache Maven Wrapper startup batch script, version 3.3.4
  23 +@REM
  24 +@REM Optional ENV vars
  25 +@REM MVNW_REPOURL - repo url base for downloading maven distribution
  26 +@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
  27 +@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output
  28 +@REM ----------------------------------------------------------------------------
  29 +
  30 +@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0)
  31 +@SET __MVNW_CMD__=
  32 +@SET __MVNW_ERROR__=
  33 +@SET __MVNW_PSMODULEP_SAVE=%PSModulePath%
  34 +@SET PSModulePath=
  35 +@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @(
  36 + IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B)
  37 +)
  38 +@SET PSModulePath=%__MVNW_PSMODULEP_SAVE%
  39 +@SET __MVNW_PSMODULEP_SAVE=
  40 +@SET __MVNW_ARG0_NAME__=
  41 +@SET MVNW_USERNAME=
  42 +@SET MVNW_PASSWORD=
  43 +@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*)
  44 +@echo Cannot start maven from wrapper >&2 && exit /b 1
  45 +@GOTO :EOF
  46 +: end batch / begin powershell #>
  47 +
  48 +$ErrorActionPreference = "Stop"
  49 +if ($env:MVNW_VERBOSE -eq "true") {
  50 + $VerbosePreference = "Continue"
  51 +}
  52 +
  53 +# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties
  54 +$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl
  55 +if (!$distributionUrl) {
  56 + Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
  57 +}
  58 +
  59 +switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) {
  60 + "maven-mvnd-*" {
  61 + $USE_MVND = $true
  62 + $distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip"
  63 + $MVN_CMD = "mvnd.cmd"
  64 + break
  65 + }
  66 + default {
  67 + $USE_MVND = $false
  68 + $MVN_CMD = $script -replace '^mvnw','mvn'
  69 + break
  70 + }
  71 +}
  72 +
  73 +# apply MVNW_REPOURL and calculate MAVEN_HOME
  74 +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash>
  75 +if ($env:MVNW_REPOURL) {
  76 + $MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" }
  77 + $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')"
  78 +}
  79 +$distributionUrlName = $distributionUrl -replace '^.*/',''
  80 +$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$',''
  81 +
  82 +$MAVEN_M2_PATH = "$HOME/.m2"
  83 +if ($env:MAVEN_USER_HOME) {
  84 + $MAVEN_M2_PATH = "$env:MAVEN_USER_HOME"
  85 +}
  86 +
  87 +if (-not (Test-Path -Path $MAVEN_M2_PATH)) {
  88 + New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null
  89 +}
  90 +
  91 +$MAVEN_WRAPPER_DISTS = $null
  92 +if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) {
  93 + $MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists"
  94 +} else {
  95 + $MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists"
  96 +}
  97 +
  98 +$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain"
  99 +$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join ''
  100 +$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME"
  101 +
  102 +if (Test-Path -Path "$MAVEN_HOME" -PathType Container) {
  103 + Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME"
  104 + Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
  105 + exit $?
  106 +}
  107 +
  108 +if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) {
  109 + Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl"
  110 +}
  111 +
  112 +# prepare tmp dir
  113 +$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile
  114 +$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir"
  115 +$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null
  116 +trap {
  117 + if ($TMP_DOWNLOAD_DIR.Exists) {
  118 + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
  119 + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
  120 + }
  121 +}
  122 +
  123 +New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null
  124 +
  125 +# Download and Install Apache Maven
  126 +Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
  127 +Write-Verbose "Downloading from: $distributionUrl"
  128 +Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
  129 +
  130 +$webclient = New-Object System.Net.WebClient
  131 +if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) {
  132 + $webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD)
  133 +}
  134 +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
  135 +$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null
  136 +
  137 +# If specified, validate the SHA-256 sum of the Maven distribution zip file
  138 +$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum
  139 +if ($distributionSha256Sum) {
  140 + if ($USE_MVND) {
  141 + Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties."
  142 + }
  143 + Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash
  144 + if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) {
  145 + Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property."
  146 + }
  147 +}
  148 +
  149 +# unzip and move
  150 +Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null
  151 +
  152 +# Find the actual extracted directory name (handles snapshots where filename != directory name)
  153 +$actualDistributionDir = ""
  154 +
  155 +# First try the expected directory name (for regular distributions)
  156 +$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain"
  157 +$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD"
  158 +if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) {
  159 + $actualDistributionDir = $distributionUrlNameMain
  160 +}
  161 +
  162 +# If not found, search for any directory with the Maven executable (for snapshots)
  163 +if (!$actualDistributionDir) {
  164 + Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object {
  165 + $testPath = Join-Path $_.FullName "bin/$MVN_CMD"
  166 + if (Test-Path -Path $testPath -PathType Leaf) {
  167 + $actualDistributionDir = $_.Name
  168 + }
  169 + }
  170 +}
  171 +
  172 +if (!$actualDistributionDir) {
  173 + Write-Error "Could not find Maven distribution directory in extracted archive"
  174 +}
  175 +
  176 +Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir"
  177 +Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null
  178 +try {
  179 + Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null
  180 +} catch {
  181 + if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) {
  182 + Write-Error "fail to move MAVEN_HOME"
  183 + }
  184 +} finally {
  185 + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
  186 + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
  187 +}
  188 +
  189 +Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
... ...
src/main/java/com/xly/XlyAiApplication.java 0 → 100644
  1 +package com.xly;
  2 +
  3 +import cn.hutool.core.util.ObjectUtil;
  4 +import cn.hutool.core.util.StrUtil;
  5 +import org.mybatis.spring.annotation.MapperScan;
  6 +import org.slf4j.Logger;
  7 +import org.slf4j.LoggerFactory;
  8 +import org.springframework.boot.SpringApplication;
  9 +import org.springframework.boot.autoconfigure.SpringBootApplication;
  10 +import org.springframework.boot.builder.SpringApplicationBuilder;
  11 +import org.springframework.boot.web.servlet.support.SpringBootServletInitializer;
  12 +import org.springframework.cache.annotation.EnableCaching;
  13 +import org.springframework.scheduling.annotation.EnableAsync;
  14 +import org.springframework.scheduling.annotation.EnableScheduling;
  15 +import org.springframework.web.bind.annotation.PostMapping;
  16 +import org.springframework.web.bind.annotation.RequestBody;
  17 +
  18 +import java.util.HashMap;
  19 +import java.util.Map;
  20 +
  21 +@EnableAsync
  22 +@EnableScheduling
  23 +@EnableCaching
  24 +@SpringBootApplication
  25 +@MapperScan("com.xly.mapper")
  26 +public class XlyAiApplication extends SpringBootServletInitializer { // 关键:继承 SpringBootServletInitializer
  27 +
  28 + private static final Logger logger = LoggerFactory.getLogger(XlyAiApplication.class);
  29 +
  30 + @Override // 关键:重写 configure 方法
  31 + protected SpringApplicationBuilder configure(SpringApplicationBuilder application) {
  32 + return application.sources(XlyAiApplication.class);
  33 + }
  34 +
  35 + /**
  36 + * 提取报修结构化信息
  37 + */
  38 + @PostMapping("/")
  39 + public String home(@RequestBody Map<String,Object> sendMap) {
  40 + return "欢迎来到小羚羊AI系统";
  41 + }
  42 +
  43 + public static void main(String[] args) {
  44 + SpringApplication.run(XlyAiApplication.class, args);
  45 + logger.info(" \n" +
  46 + " /$$ \n" +
  47 + " | $$ \n" +
  48 + " /$$ /$$ /$$ /$$ /$$ /$$$$$$$| $$ /$$$$$$$\n" +
  49 + "| $$ | $$ | $$| $$ | $$ /$$_____/| $$ /$$_____/\n" +
  50 + "| $$ | $$ | $$| $$ | $$| $$ | $$| $$$$$$ \n" +
  51 + "| $$ | $$ | $$| $$ | $$| $$ | $$ \\____ $$\n" +
  52 + "| $$$$$/$$$$/| $$$$$$$| $$$$$$$| $$ /$$$$$$$/\n" +
  53 + " \\_____/\\___/ \\____ $$ \\_______/|__/|_______/ \n" +
  54 + " /$$ | $$ \n" +
  55 + " | $$$$$$/ \n" +
  56 + " \\______/ ");
  57 + logger.info("I wish you a pleasant use of the system and never have any bugs");
  58 + }
  59 +}
0 60 \ No newline at end of file
... ...
src/main/java/com/xly/agent/ChatiAgent.java 0 → 100644
  1 +package com.xly.agent;
  2 +
  3 +import dev.langchain4j.service.MemoryId;
  4 +import dev.langchain4j.service.SystemMessage;
  5 +import dev.langchain4j.service.UserMessage;
  6 +import dev.langchain4j.service.V;
  7 +
  8 +public interface ChatiAgent {
  9 + @SystemMessage("""
  10 + 你是一个轻松自然的聊天伙伴,语气亲切口语化,像朋友一样闲聊。
  11 + 要求:1. 不生硬、不说教,避免书面化表达;
  12 + 2. 主动接梗,适当延伸话题,不一问一答;
  13 + 3. 偶尔带点小幽默,保持轻松无压力的氛围;
  14 + 4. 回答简洁,符合日常聊天的语气,不啰嗦。
  15 + 5. 首次沟通时发现称呼不是“小羚羊”时,请回复“我不是..,我是小羚羊”,语气俏皮。
  16 + """)
  17 + @UserMessage("用户说:{{userInput}}")
  18 + String chat(@MemoryId String userId, @V("userInput") String userInput);
  19 +}
... ...
src/main/java/com/xly/agent/DynamicTableNl2SqlAiAgent.java 0 → 100644
  1 +package com.xly.agent;
  2 +
  3 +import dev.langchain4j.service.MemoryId;
  4 +import dev.langchain4j.service.SystemMessage;
  5 +import dev.langchain4j.service.UserMessage;
  6 +import dev.langchain4j.service.V;
  7 +
  8 +/**
  9 + * 适配动态表结构的NL2SQL AI服务
  10 + * 核心:表结构作为动态参数传入,@SystemMessage仅保留通用规则
  11 + */
  12 +
  13 +
  14 +public interface DynamicTableNl2SqlAiAgent {
  15 +
  16 + /**
  17 + * 动态表结构:自然语言转MySQL SELECT语句
  18 + * 入参:数据库名、表名(多表用,分隔)、表结构、用户查询
  19 + */
  20 + @SystemMessage("""
  21 + 你是资深MySQL数据分析师,严格遵循以下**通用规则**生成SQL,适用于所有业务场景:
  22 + 1. 语法规范:仅生成符合MySQL8.0/5.7的标准SELECT语句,兼容低版本,多表关联用JOIN而非逗号;
  23 + 2. 输出格式:仅返回SQL语句本身,无任何解释、换行、```sql/```包裹、备注、多余空格,直接输出可执行SQL;
  24 + 3. 编写规范:
  25 + 3.1 多表关联必须使用 表名+字段名(如表名.字段名),严格按下面[涉及表名]中的表次序关联,聚合函数(SUM/COUNT/AVG/MIN/MAX)必须加业务化别名,日期过滤使用标准DATE格式(yyyy-MM-dd);
  26 + 3.2 SQL所有字段均采用 表名.字段名 方式生成,务必确保 字段名 在相应的 表名 描述的字段中存在,如果不存在重试其它方式,直到满足条件
  27 + 3.3 SQL所有字段涉及的所有表名,都要**严格**按下面[涉及表名]中的表次序关联,没有关联不允许使用
  28 + 3.4 SQL所有的查询条件,如果是字符类型的字段,均需要加不为空判断(如ifnull(customername,'')<>'')
  29 + 4. 安全约束:禁止生成任何DDL/DML语句(DROP/ALTER/INSERT/UPDATE/DELETE等),禁止使用子查询、存储过程、自定义函数、临时表;
  30 + 5. 精准性:严格按用户需求+传入的表结构生成,仅使用指定字段/表,无多余字段、无无效表关联、无冗余过滤条件;
  31 + 6. 关联规则:多表关联时,必须使用外键/业务唯一键关联,禁止无意义关联。
  32 + """)
  33 + @UserMessage("""
  34 + 【业务场景表结构信息】
  35 + 涉及表名:{{tableNames}}(多表用,分隔,需关联时请按规范使用JOIN)
  36 + 表结构详情:{{tableStruct}}(多表请标注表名+字段,格式:表名(字段1:类型,字段2:类型,主键/外键))
  37 + 【用户需求】
  38 + {{userInput}}
  39 + 请根据上述表结构+通用规则,生成符合要求的MySQL SELECT语句:
  40 + """)
  41 + String generateMysqlSql(@MemoryId String userId,
  42 + @V("tableNames") String tableNames,
  43 + @V("tableStruct") String tableStruct,
  44 + @V("userInput") String userInput);
  45 +
  46 + /**
  47 + * 动态表结构:自然语言解释SQL执行结果
  48 + * 入参:用户问题、执行的SQL、表结构、JSON格式结果
  49 + */
  50 + @SystemMessage("""
  51 + 你是专业的业务数据分析师,严格遵循以下**通用规则**解释查询结果,适用于所有业务场景:
  52 + 1. 解释风格:贴合业务场景,无任何SQL专业术语,用口语化、简洁的商业语言说明,避免技术词汇;
  53 + 2. 数据准确:严格按照JSON执行结果解释,不夸大、不遗漏、不编造数据,数值与结果完全一致;
  54 + 3. 输出格式:仅返回解释内容,不要列出ID,无多余标题、换行、符号,结果为空时直接返回“未查询到相关数据”;
  55 + 4. 长度控制:单条解释不超过150字,条理清晰,重点突出核心数据/趋势;
  56 + 5. 禁止重复:不重复用户问题、不重复执行的SQL语句,仅针对结果做业务解读。
  57 + """)
  58 + @UserMessage("""
  59 + 【业务场景表结构信息】
  60 + 表结构详情:{{tableStruct}}
  61 + 【查询相关信息】
  62 + 用户原始查询:{{userInput}}
  63 + 执行的MySQL SQL:{{sql}}
  64 + SQL执行结果(JSON格式):{{result}}
  65 + 请根据上述信息+通用规则,对查询结果做业务解释:
  66 + """)
  67 + String explainSqlResult(@MemoryId String userId,
  68 + @V("userInput") String userInput,
  69 + @V("sql") String sql,
  70 + @V("tableStruct") String tableStruct,
  71 + @V("result") String result);
  72 +}
0 73 \ No newline at end of file
... ...
src/main/java/com/xly/agent/ErpAiAgent.java 0 → 100644
  1 +package com.xly.agent;
  2 +
  3 +
  4 +import dev.langchain4j.service.MemoryId;
  5 +import dev.langchain4j.service.SystemMessage;
  6 +import dev.langchain4j.service.UserMessage;
  7 +import dev.langchain4j.service.V;
  8 +
  9 +/**
  10 + * 优化后:新增场景专属交互规则,大模型仅处理当前场景业务指令
  11 + */
  12 +public interface ErpAiAgent {
  13 + @SystemMessage("""
  14 + 你是一个专业的 工具方法匹配与参数提取 助手,核心职责是根据用户输入(含历史对话)精准匹配工具方法、提取参数、判断缺失并生成交互式补全提示;
  15 + 按严格按以下步骤处理,无任何额外输出!规则如下:
  16 + 1. 方法匹配:先精准拆解用户查询的核心业务意图,再自动匹配唯一符合用户问题的工具方法(MethodNo),禁止自创;
  17 + 2. 参数提取:提取该工具的全部参数,与描述完全一致,严格按标注类型赋值,数字无引号,为空时禁止赋值0;
  18 + """)
  19 + @UserMessage("用户输入:{{userInput}}")
  20 + String chat(@MemoryId String userId, @V("userInput") String userInput);
  21 +}
0 22 \ No newline at end of file
... ...
src/main/java/com/xly/agent/SceneSelectorAiAgent.java 0 → 100644
  1 +package com.xly.agent;
  2 +
  3 +import com.xly.entity.SceneIntentParseResp;
  4 +import dev.langchain4j.service.SystemMessage;
  5 +import dev.langchain4j.service.UserMessage;
  6 +import dev.langchain4j.service.V;
  7 +import org.springframework.stereotype.Component;
  8 +
  9 +/**
  10 + * 场景意图解析AI服务:专门让大模型解析用户输入的意图,匹配对应的业务场景
  11 + * 基于LangChain4j AiServices构建,由大模型返回标准化的场景编码
  12 + */
  13 +public interface SceneSelectorAiAgent {
  14 +
  15 + /**
  16 + * 核心方法:解析用户意图,匹配业务场景
  17 + * @param userInput 用户输入
  18 + * @param authScenesDesc 可访问场景描述
  19 + * @return 标准化的意图解析响应(仅返回sceneCode)
  20 + */
  21 + @SystemMessage("""
  22 + 你是专业的ERP系统**意图解析助理**,你的唯一职责是根据用户输入,匹配其意图对应的业务场景,严格遵循以下规则:
  23 + 1. 仅从用户提供的「可访问场景列表」中选择匹配的场景,绝对不允许虚构场景;
  24 + 2. 匹配规则:用户输入的意图与场景的「支持功能」高度相关,即匹配该场景;
  25 + 3. 输出格式:必须严格返回JSON格式,仅包含sceneCode字段,无任何多余文字、解释、换行;
  26 + - 匹配到一个场景:sceneCode为场景码(如salemange/purchasemange/productionmange),scene为场景名称(如销售管理/采购管理/生产管理);
  27 + - 匹配到多个场景,请列出并让客户选择场景;
  28 + - 无匹配场景/用户仅问候:sceneCode为NO_MATCH;
  29 + 4. 不允许添加任何额外字段,不允许返回JSON以外的内容,确保后端能直接解析;
  30 + 5. 忽略用户输入中的无关语气词(如“你好”“帮我”“麻烦”),提取核心业务意图。
  31 + """)
  32 + @UserMessage("""
  33 + 用户输入:{{userInput}}
  34 + 可访问场景列表:{{authScenesDesc}}
  35 + 请严格按指定格式返回匹配的场景编码!
  36 + """)
  37 + SceneIntentParseResp parseSceneIntent(@V("userInput") String userInput,
  38 + @V("authScenesDesc") String authScenesDesc);
  39 +}
0 40 \ No newline at end of file
... ...
src/main/java/com/xly/config/BizExecuteUtil.java 0 → 100644
  1 +package com.xly.config;
  2 +
  3 +import groovy.lang.GroovyClassLoader;
  4 +import groovy.lang.GroovyObject;
  5 +import lombok.RequiredArgsConstructor;
  6 +import lombok.extern.slf4j.Slf4j;
  7 +import org.springframework.expression.Expression;
  8 +import org.springframework.expression.ExpressionParser;
  9 +import org.springframework.expression.spel.standard.SpelExpressionParser;
  10 +import org.springframework.expression.spel.support.StandardEvaluationContext;
  11 +import org.springframework.stereotype.Component;
  12 +
  13 +import java.util.Map;
  14 +import java.util.Objects;
  15 +
  16 +/**
  17 + * 业务逻辑执行工具:EL表达式 + Groovy脚本
  18 + */
  19 +@Slf4j
  20 +@Component
  21 +@RequiredArgsConstructor
  22 +public class BizExecuteUtil {
  23 + /**
  24 + * EL表达式解析器
  25 + */
  26 + private final ExpressionParser elParser = new SpelExpressionParser();
  27 +
  28 + /**
  29 + * Groovy类加载器
  30 + */
  31 + private final GroovyClassLoader groovyClassLoader = new GroovyClassLoader();
  32 +
  33 + /**
  34 + * 执行业务逻辑
  35 + * @param bizType 1=EL,2=Groovy
  36 + * @param bizContent 逻辑内容
  37 + * @param params JSON参数
  38 + * @return 执行结果
  39 + */
  40 + public String execute(Integer bizType, String bizContent, Map<String, Object> params) {
  41 + if (Objects.isNull(bizType) || Objects.isNull(bizContent) || bizContent.isBlank()) {
  42 + throw new IllegalArgumentException("业务逻辑配置异常");
  43 + }
  44 + // EL表达式执行
  45 + if (1 == bizType) {
  46 + return executeEL(bizContent, params);
  47 + }
  48 + // Groovy脚本执行
  49 + else if (2 == bizType) {
  50 + return executeGroovy(bizContent, params);
  51 + }
  52 + else {
  53 + throw new IllegalArgumentException("不支持的业务逻辑类型:" + bizType);
  54 + }
  55 + }
  56 +
  57 + /**
  58 + * 执行EL表达式
  59 + */
  60 + private String executeEL(String elContent, Map<String, Object> params) {
  61 + try {
  62 + StandardEvaluationContext context = new StandardEvaluationContext();
  63 + context.setVariable("params", params);
  64 + Expression expression = elParser.parseExpression(elContent);
  65 + return expression.getValue(context, String.class);
  66 + } catch (Exception e) {
  67 + log.error("EL表达式执行失败:{}", elContent, e);
  68 + throw new IllegalArgumentException("EL表达式执行失败:" + e.getMessage());
  69 + }
  70 + }
  71 +
  72 + /**
  73 + * 执行Groovy脚本
  74 + */
  75 + private String executeGroovy(String groovyContent, Map<String, Object> params) {
  76 + try {
  77 + // 构建Groovy脚本类
  78 + String scriptCode = "class DynamicScript { def execute(Map params) { " + groovyContent + " } }";
  79 + Class<?> groovyClass = groovyClassLoader.parseClass(scriptCode);
  80 + GroovyObject groovyObject = (GroovyObject) groovyClass.getDeclaredConstructor().newInstance();
  81 + // 执行脚本并返回结果
  82 + Object result = groovyObject.invokeMethod("execute", new Object[]{params});
  83 + return Objects.isNull(result) ? "执行成功" : result.toString();
  84 + } catch (Exception e) {
  85 + log.error("Groovy脚本执行失败:{}", groovyContent, e);
  86 + throw new IllegalArgumentException("Groovy脚本执行失败:" + e.getMessage());
  87 + }
  88 + }
  89 +}
0 90 \ No newline at end of file
... ...
src/main/java/com/xly/config/CorsConfig.java 0 → 100644
  1 +package com.xly.config;
  2 +
  3 +import org.springframework.context.annotation.Bean;
  4 +import org.springframework.context.annotation.Configuration;
  5 +import org.springframework.web.cors.CorsConfiguration;
  6 +import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
  7 +import org.springframework.web.filter.CorsFilter;
  8 +import org.springframework.web.servlet.config.annotation.CorsRegistry;
  9 +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
  10 +
  11 +/***
  12 + * @Author 钱豹
  13 + * @Date 22:40 2026/2/3
  14 + * @Param
  15 + * @return
  16 + * @Description 跨域配置
  17 + **/
  18 +@Configuration
  19 +public class CorsConfig {
  20 +
  21 + /**
  22 + * 允许所有跨域请求 - CorsFilter方式
  23 + */
  24 + @Bean
  25 + public CorsFilter corsFilter() {
  26 + CorsConfiguration config = new CorsConfiguration();
  27 +
  28 + // 允许所有域名
  29 + config.addAllowedOriginPattern("*");
  30 +
  31 + // 允许所有请求方法
  32 + config.addAllowedMethod("*");
  33 +
  34 + // 允许所有请求头
  35 + config.addAllowedHeader("*");
  36 +
  37 + // 允许携带凭证(如cookies)
  38 + config.setAllowCredentials(true);
  39 +
  40 + // 暴露所有响应头
  41 + config.addExposedHeader("*");
  42 +
  43 + // 预检请求缓存时间
  44 + config.setMaxAge(3600L);
  45 +
  46 + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
  47 + source.registerCorsConfiguration("/**", config);
  48 +
  49 + return new CorsFilter(source);
  50 + }
  51 +
  52 + /**
  53 + * 允许所有跨域请求 - WebMvcConfigurer方式
  54 + */
  55 + @Bean
  56 + public WebMvcConfigurer corsConfigurer() {
  57 + return new WebMvcConfigurer() {
  58 + @Override
  59 + public void addCorsMappings(CorsRegistry registry) {
  60 + registry.addMapping("/**")
  61 + .allowedOriginPatterns("*") // 使用 allowedOriginPatterns 代替 allowedOrigins
  62 + .allowedMethods("*")
  63 + .allowedHeaders("*")
  64 + .exposedHeaders("*")
  65 + .allowCredentials(true)
  66 + .maxAge(3600);
  67 + }
  68 + };
  69 + }
  70 +}
0 71 \ No newline at end of file
... ...
src/main/java/com/xly/config/JacksonConfig.java 0 → 100644
  1 +package com.xly.config;
  2 +
  3 +import com.fasterxml.jackson.databind.ObjectMapper;
  4 +import com.fasterxml.jackson.databind.SerializationFeature;
  5 +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
  6 +import org.springframework.context.annotation.Bean;
  7 +import org.springframework.context.annotation.Configuration;
  8 +import org.springframework.context.annotation.Primary;
  9 +
  10 +/**
  11 + * 全局 ObjectMapper 配置:支持 LocalDate 序列化/反序列化
  12 + */
  13 +@Configuration
  14 +public class JacksonConfig {
  15 +
  16 + /**
  17 + * 配置好的 ObjectMapper:注册 JavaTimeModule,支持 Java 8 时间类型
  18 + */
  19 + @Bean
  20 + @Primary // 标记为默认实例,避免多 ObjectMapper 冲突
  21 + public ObjectMapper objectMapper() {
  22 + ObjectMapper mapper = new ObjectMapper();
  23 + // 1. 核心:注册 JSR310 模块,处理 LocalDate/LocalDateTime
  24 + mapper.registerModule(new JavaTimeModule());
  25 + // 2. 关闭时间戳序列化,LocalDate 以 "yyyy-MM-dd" 字符串形式存储
  26 + mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
  27 + // 3. 忽略未知字段(AI 返回多余字段时不报错)
  28 + mapper.configure(com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
  29 + return mapper;
  30 + }
  31 +}
0 32 \ No newline at end of file
... ...
src/main/java/com/xly/config/ModelConfig.java 0 → 100644
  1 +package com.xly.config;
  2 +
  3 +import com.fasterxml.jackson.databind.ObjectMapper;
  4 +import com.fasterxml.jackson.databind.SerializationFeature;
  5 +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
  6 +import com.xly.agent.DynamicTableNl2SqlAiAgent;
  7 +import com.xly.agent.SceneSelectorAiAgent;
  8 +import dev.langchain4j.memory.chat.MessageWindowChatMemory;
  9 +import dev.langchain4j.model.chat.ChatLanguageModel;
  10 +import dev.langchain4j.model.chat.StreamingChatLanguageModel;
  11 +import dev.langchain4j.model.ollama.OllamaChatModel;
  12 +import dev.langchain4j.model.ollama.OllamaStreamingChatModel;
  13 +import dev.langchain4j.service.AiServices;
  14 +import org.springframework.beans.factory.annotation.Qualifier;
  15 +import org.springframework.beans.factory.annotation.Value;
  16 +import org.springframework.context.annotation.Bean;
  17 +import org.springframework.context.annotation.Configuration;
  18 +import org.springframework.context.annotation.Primary;
  19 +
  20 +import java.time.Duration;
  21 +
  22 +/**
  23 + * 大模型初始化配置(单例复用,避免重复创建)
  24 + */
  25 +@Configuration
  26 +public class ModelConfig {
  27 +
  28 +
  29 + @Value("${langchain4j.ollama.base-url}")
  30 + private String chatModelUrl;
  31 +
  32 + @Value("${langchain4j.ollama.base-url}")
  33 + private String sqlModelUrl;
  34 +
  35 + @Value("${langchain4j.ollama.chat-model-name}")
  36 + private String chatModelName;
  37 +
  38 + @Value("${langchain4j.ollama.sql-model-name}")
  39 + private String sqlModelName;
  40 +
  41 + // 中文对话模型 qwen2.5:7b-instruct
  42 + @Bean
  43 + @Primary
  44 + public OllamaChatModel chatLanguageModel() {
  45 + return OllamaChatModel.builder()
  46 + .baseUrl(chatModelUrl)
  47 + .modelName(chatModelName) // 使用聊天模型名称
  48 + .temperature(0.0) // 建议调整为0.0太确定
  49 + .topP(0.9)
  50 +// .numPredict(2048) // 添加生成长度限制
  51 + .timeout(Duration.ofSeconds(60)) // 缩短超时时间
  52 + .maxRetries(2)
  53 + .build();
  54 + }
  55 +
  56 + /***
  57 + * @Author 钱豹
  58 + * @Date 13:25 2026/2/6
  59 + * @Param []
  60 + * @return dev.langchain4j.model.ollama.OllamaChatModel
  61 + * @Description 聊天
  62 + **/
  63 + @Bean("chatiModel")
  64 + public ChatLanguageModel chatiModel() {
  65 + return OllamaChatModel.builder()
  66 + .baseUrl(chatModelUrl)
  67 + .modelName(chatModelName) // 使用聊天模型名称
  68 + .temperature(0.8) // 建议调整为0.8太确定
  69 + .topP(0.9)
  70 +// .numPredict(2048) // 添加生成长度限制
  71 + .timeout(Duration.ofSeconds(60)) // 缩短超时时间
  72 + .maxRetries(2)
  73 + .build();
  74 + }
  75 +
  76 + // SQL/代码专用模型 qwen2.5-coder:14b
  77 + @Bean("sqlChatModel") // 明确指定bean名称
  78 + public ChatLanguageModel sqlChatModel() {
  79 + return OllamaChatModel.builder()
  80 + .baseUrl(sqlModelUrl)
  81 + .modelName(sqlModelName) // 使用SQL模型名称
  82 + .temperature(0.0)
  83 + .topP(0.95)
  84 + .numPredict(4096) // 代码生成需要更长
  85 + .timeout(Duration.ofSeconds(120))
  86 + .maxRetries(3)
  87 +// .repeatPenalty(1.1) // 减少重复
  88 + .build();
  89 + }
  90 +
  91 + /***
  92 + * @Author 钱豹
  93 + * @Date 22:53 2026/2/3
  94 + * @Param []
  95 + * @return dev.langchain4j.model.chat.StreamingChatLanguageModel
  96 + * @Description 流式聊天模型 - 使用 @Primary
  97 + **/
  98 + @Bean("streamingChatModel")
  99 + @Primary
  100 + public StreamingChatLanguageModel streamingChatModel() {
  101 + return OllamaStreamingChatModel.builder()
  102 + .baseUrl(chatModelUrl)
  103 + .modelName(chatModelName)
  104 + .temperature(0.7)
  105 + .topP(0.9)
  106 + .numPredict(1024)
  107 + .timeout(Duration.ofSeconds(60))
  108 + .build();
  109 + }
  110 +
  111 + // 流式SQL/代码模型 - 指定名称
  112 + @Bean("streamingSqlModel")
  113 + public StreamingChatLanguageModel streamingSqlModel() {
  114 + return OllamaStreamingChatModel.builder()
  115 + .baseUrl(sqlModelUrl)
  116 + .modelName(sqlModelName)
  117 + .temperature(0.3)
  118 + .topP(0.95)
  119 + .numPredict(2048)
  120 + .timeout(Duration.ofSeconds(120))
  121 + .build();
  122 + }
  123 +
  124 + /**
  125 + * 全局ObjectMapper:支持LocalDate序列化
  126 + */
  127 + @Bean
  128 + @Primary
  129 + public ObjectMapper objectMapper() {
  130 + ObjectMapper mapper = new ObjectMapper();
  131 + mapper.registerModule(new JavaTimeModule());
  132 + mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
  133 + mapper.configure(com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
  134 + return mapper;
  135 + }
  136 +
  137 + //动态SQL生成模型
  138 + @Bean
  139 + public DynamicTableNl2SqlAiAgent dynamicTableNl2SqlAiAgent(@Qualifier("sqlChatModel") ChatLanguageModel sqlModel) {
  140 + return AiServices.builder(DynamicTableNl2SqlAiAgent.class)
  141 + .chatLanguageModel(sqlModel)
  142 + // 会话记忆:每个用户最多保留10轮对话,避免记忆溢出
  143 + .chatMemoryProvider(memoryId -> MessageWindowChatMemory.withMaxMessages(10))
  144 + .build();
  145 + }
  146 +
  147 + //场景意图解析AI服务
  148 + @Bean
  149 + public SceneSelectorAiAgent sceneSelectorAiAgent(OllamaChatModel sqlModel) {
  150 + return AiServices.builder(SceneSelectorAiAgent.class)
  151 + .chatLanguageModel(sqlModel)
  152 + // 会话记忆:每个用户最多保留10轮对话,避免记忆溢出
  153 + .chatMemoryProvider(memoryId -> MessageWindowChatMemory.withMaxMessages(10))
  154 + .build();
  155 + }
  156 +}
0 157 \ No newline at end of file
... ...
src/main/java/com/xly/config/MvcConfig.java 0 → 100644
  1 +package com.xly.config;
  2 +
  3 +import org.springframework.context.annotation.Configuration;
  4 +import org.springframework.web.servlet.config.annotation.ViewControllerRegistry;
  5 +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
  6 +
  7 +@Configuration
  8 +public class MvcConfig implements WebMvcConfigurer {
  9 +
  10 + @Override
  11 + public void addViewControllers(ViewControllerRegistry registry) {
  12 + // 直接映射URL到视图
  13 + registry.addViewController("/chat").setViewName("chat");
  14 + registry.addViewController("/").setViewName("index");
  15 + }
  16 +}
0 17 \ No newline at end of file
... ...
src/main/java/com/xly/config/OperableChatMemoryProvider.java 0 → 100644
  1 +package com.xly.config;
  2 +
  3 +
  4 +import dev.langchain4j.memory.ChatMemory;
  5 +import dev.langchain4j.memory.chat.ChatMemoryProvider;
  6 +import dev.langchain4j.memory.chat.MessageWindowChatMemory;
  7 +import dev.langchain4j.data.message.ChatMessage;
  8 +import org.springframework.stereotype.Component;
  9 +
  10 +import java.util.List;
  11 +import java.util.Map;
  12 +import java.util.Objects;
  13 +import java.util.concurrent.ConcurrentHashMap;
  14 +
  15 +/**
  16 + * 可操作的ChatMemoryProvider:获取消息对象+清除记忆(指定/全量)+删除单条消息
  17 + * 实现框架原生接口,无缝对接AiServices,线程安全适配生产环境
  18 + */
  19 +@Component
  20 +public class OperableChatMemoryProvider implements ChatMemoryProvider {
  21 + // 核心缓存:memoryId -> ChatMemory,保证一个会话/用户对应唯一记忆实例
  22 + private final Map<Object, ChatMemory> memoryCache = new ConcurrentHashMap<>();
  23 + // 记忆最大消息数,根据业务需求调整(原配置为10)
  24 + private static final int MAX_MESSAGE_SIZE = 100;
  25 +
  26 + /**
  27 + * 框架原生方法:获取【当前memoryId对应的ChatMemory实例(含消息对象)】
  28 + * AiServices自动调用,也是手动操作记忆/消息的唯一入口
  29 + */
  30 + @Override
  31 + public ChatMemory get(Object memoryId) {
  32 + // 空memoryId兜底,避免空指针
  33 + Object finalMemId = Objects.isNull(memoryId) ? "default_erp_chat_memory" : memoryId;
  34 + // 不存在则创建MessageWindowChatMemory,存在则复用
  35 + return memoryCache.computeIfAbsent(finalMemId, k -> MessageWindowChatMemory.withMaxMessages(MAX_MESSAGE_SIZE));
  36 + }
  37 +
  38 + // ===================== 1. 获取消息对象(当前memoryId) =====================
  39 + /**
  40 + * 获取当前会话/用户的全部消息列表(含用户消息、AI消息,按对话顺序)
  41 + */
  42 + public List<ChatMessage> getCurrentChatMessages(Object memoryId) {
  43 + if (Objects.isNull(memoryId)) {
  44 + return List.of();
  45 + }
  46 + // 从ChatMemory中获取原生消息列表
  47 + return this.get(memoryId).messages();
  48 + }
  49 +
  50 + // ===================== 2. 清除记忆(核心需求) =====================
  51 + /**
  52 + * 清空【指定memoryId】的全部记忆(最常用,如前端「清空对话」)
  53 + */
  54 + public void clearSpecifiedMemory(Object memoryId) {
  55 + if (Objects.nonNull(memoryId)) {
  56 + // 调用ChatMemory原生clear(),清空该实例所有消息
  57 + this.get(memoryId).clear();
  58 + }
  59 + }
  60 +
  61 + /**
  62 + * 全量清除【所有memoryId】的记忆(系统级清理,如定时任务/后台操作)
  63 + */
  64 + public void clearAllMemory() {
  65 + // 遍历所有记忆实例,逐个调用原生clear()清空
  66 + memoryCache.values().forEach(ChatMemory::clear);
  67 + // 可选:彻底清空缓存,销毁所有实例(释放内存,后续会重新创建)
  68 + // memoryCache.clear();
  69 + }
  70 +
  71 + // ===================== 3. 精准操作:删除单条消息 =====================
  72 + /**
  73 + * 删除当前memoryId的指定单条消息(如删除错误消息)
  74 + * @param message 要删除的消息对象(从getCurrentChatMessages中获取)
  75 + */
  76 + public void deleteSingleMessage(Object memoryId, ChatMessage message) {
  77 + if (Objects.nonNull(memoryId) && Objects.nonNull(message)) {
  78 + ChatMemory currentMemory = this.get(memoryId);
  79 + // 删除指定消息
  80 + currentMemory.messages().remove(message);
  81 + // 刷新记忆,保证MessageWindowChatMemory的最大消息数限制生效
  82 +// currentMemory.update(currentMemory.messages());
  83 + }
  84 + }
  85 +
  86 + /**
  87 + * 移除并清除指定记忆(清空消息+从缓存删除实例,彻底释放资源,适用于过期会话)
  88 + */
  89 + public void removeAndClearMemory(Object memoryId) {
  90 + if (Objects.nonNull(memoryId)) {
  91 + ChatMemory chatMemory = memoryCache.remove(memoryId);
  92 + if (Objects.nonNull(chatMemory)) {
  93 + chatMemory.clear();
  94 + }
  95 + }
  96 + }
  97 +}
... ...
src/main/java/com/xly/constant/BusinessCode.java 0 → 100644
  1 +package com.xly.constant;
  2 +
  3 +import lombok.Getter;
  4 +
  5 +/***
  6 + * @Author 钱豹
  7 + * @Date 9:53 2026/2/9
  8 + * @Param
  9 + * @return
  10 + * @Description //TODO
  11 + **/
  12 +@Getter
  13 +public enum BusinessCode {
  14 +
  15 + //报价确认
  16 + QUOCONFIRM("quoconfirm", "报价确认");
  17 +
  18 + private final String code;
  19 + private final String message;
  20 +
  21 + BusinessCode(String code, String message) {
  22 + this.code = code;
  23 + this.message = message;
  24 + }
  25 +
  26 + /**
  27 + * 根据code获取ErrorCode
  28 + */
  29 + public static BusinessCode getByCode(Integer code) {
  30 + for (BusinessCode errorCode : values()) {
  31 + if (errorCode.getCode().equals(code)) {
  32 + return errorCode;
  33 + }
  34 + }
  35 + return QUOCONFIRM;
  36 + }
  37 +}
0 38 \ No newline at end of file
... ...
src/main/java/com/xly/constant/CommonConstant.java 0 → 100644
  1 +package com.xly.constant;
  2 +
  3 +/**
  4 + * 全局通用常量
  5 + * 包含大模型解析提示语、请求头、资源前缀等
  6 + */
  7 +public class CommonConstant {
  8 + //重置方法
  9 + public static final String RESET = "<button \n" +
  10 + " data-action=\"reset\" \n" +
  11 + " style=\"\n" +
  12 + " background: linear-gradient(135deg, #1677ff, #4096ff);\n" +
  13 + " color: white;\n" +
  14 + " border: none;\n" +
  15 + " padding: 6px 14px;\n" +
  16 + " border-radius: 6px;\n" +
  17 + " cursor: pointer;\n" +
  18 + " font-size: 12px;\n" +
  19 + " font-weight: 500;\n" +
  20 + " transition: all 0.3s ease;\n" +
  21 + " \"\n" +
  22 + " data-text=\"重置\"\n" +
  23 + " onclick=\"reset('重置')\"\n" +
  24 + " onmouseover=\"this.style.opacity='0.8'\"\n" +
  25 + " onmouseout=\"this.style.opacity='1'\">\n" +
  26 + " 重置\n" +
  27 + "</button> 复位 \n\n";
  28 +
  29 +}
... ...
src/main/java/com/xly/constant/ErrorCode.java 0 → 100644
  1 +package com.xly.constant;
  2 +
  3 +import lombok.Getter;
  4 +
  5 +/***
  6 + * @Author 钱豹
  7 + * @Date 23:04 2026/1/30
  8 + * @Param
  9 + * @return
  10 + * @Description 异常码枚举
  11 + **/
  12 +@Getter
  13 +public enum ErrorCode {
  14 +
  15 + // 成功
  16 + SUCCESS(200, "操作成功"),
  17 + SUCCESSMSG(201, "成功"),
  18 + ERRORMSG(202, "失败"),
  19 + WFHYY(203, "未返回原因"),
  20 +
  21 + // 客户端错误
  22 + BAD_REQUEST(400, "请求参数错误"),
  23 + UNAUTHORIZED(401, "未授权"),
  24 + FORBIDDEN(403, "禁止访问"),
  25 + NOT_FOUND(404, "资源不存在"),
  26 +
  27 + // 参数错误
  28 + PARAM_ERROR(40001, "参数错误"),
  29 + PARAM_REQUIRED(40002, "参数缺失"),
  30 + PARAM_TYPE_ERROR(40003, "参数类型错误"),
  31 + PARAM_FORMAT_ERROR(40004, "参数格式错误"),
  32 +
  33 + // 业务错误
  34 + BUSINESS_ERROR(50001, "业务异常"),
  35 + DATA_ERROR(50002, "数据异常"),
  36 + DATA_NOT_FOUND(50003, "数据不存在"),
  37 + DATA_EXISTS(50004, "数据已存在"),
  38 + DATA_STATE_ERROR(50005, "数据状态异常"),
  39 +
  40 + // 用户相关
  41 + USER_NOT_FOUND(60001, "用户不存在"),
  42 + USER_DISABLED(60002, "用户已禁用"),
  43 + USER_PASSWORD_ERROR(60003, "密码错误"),
  44 + USER_NOT_LOGIN(60004, "用户未登录"),
  45 +
  46 + // 权限相关
  47 + PERMISSION_DENIED(70001, "权限不足"),
  48 + ROLE_NOT_FOUND(70002, "角色不存在"),
  49 +
  50 + // 系统错误
  51 + SYSTEM_ERROR(10000, "系统异常"),
  52 + SERVICE_UNAVAILABLE(10001, "服务不可用"),
  53 + DB_ERROR(10002, "数据库异常"),
  54 + NETWORK_ERROR(10003, "网络异常"),
  55 + THIRD_PARTY_ERROR(10004, "第三方服务异常"),
  56 + CONFIG_ERROR(10005, "配置错误"),
  57 +
  58 + // 文件相关
  59 + FILE_UPLOAD_ERROR(80001, "文件上传失败"),
  60 + FILE_NOT_FOUND(80002, "文件不存在"),
  61 + FILE_TYPE_ERROR(80003, "文件类型错误"),
  62 + FILE_SIZE_ERROR(80004, "文件大小超限"),
  63 +
  64 + PYTHON_ERROR(9001, "Python脚本执行失败");
  65 +
  66 + private final Integer code;
  67 + private final String message;
  68 +
  69 + ErrorCode(Integer code, String message) {
  70 + this.code = code;
  71 + this.message = message;
  72 + }
  73 +
  74 + /**
  75 + * 根据code获取ErrorCode
  76 + */
  77 + public static ErrorCode getByCode(Integer code) {
  78 + for (ErrorCode errorCode : values()) {
  79 + if (errorCode.getCode().equals(code)) {
  80 + return errorCode;
  81 + }
  82 + }
  83 + return SYSTEM_ERROR;
  84 + }
  85 +}
0 86 \ No newline at end of file
... ...
src/main/java/com/xly/constant/ProcedureConstant.java 0 → 100644
  1 +package com.xly.constant;
  2 +
  3 +import java.util.ArrayList;
  4 +import java.util.HashMap;
  5 +import java.util.List;
  6 +import java.util.Map;
  7 +
  8 +/***
  9 + * @Author 钱豹
  10 + * @Date 22:41 2026/2/3
  11 + * @Param
  12 + * @return
  13 + * @Description 调用过程的常量
  14 + **/
  15 +public class ProcedureConstant {
  16 +
  17 + public static final String PROTYPESTRING = "proc";
  18 + public static final String CONFTYPESTRING = "sType";
  19 + public static final String SSQLSTRSTRING = "sSqlStr";
  20 + public static final String SRETURN = "sReturn";
  21 + public static final String SCODE = "sCode";
  22 + public static final String OUTSETSTRING = "outSet";
  23 + public static final String SDEFAULT = "sDefault";
  24 + public static final String IN = "IN";
  25 + public static final String OUT = "OUT";
  26 + public static final String HEADER = "HEADER";
  27 + public static final String OUTLIST = "outList";
  28 + public static final String OUTMAP = "outMap";
  29 +
  30 + public static Map<String, Object> getRetMap(List<Map<String, Object>> proList, Map<String, Object> outMap) {
  31 + Map<String, Object> retMap = new HashMap<>(8);
  32 + Map<String, Object> proMap = new HashMap<>(4);
  33 + List<Map<String, Object>> outList = new ArrayList<>(1);
  34 + outList.add(outMap);
  35 + proMap.put("proData", proList);
  36 + proMap.put("outData", outList);
  37 + retMap.put("dataSet", proMap);
  38 + retMap.put(SCODE, outMap.get(SCODE));
  39 + retMap.put(SRETURN, outMap.get(SRETURN));
  40 + return retMap;
  41 + }
  42 +
  43 +}
... ...
src/main/java/com/xly/constant/ReturnTypeCode.java 0 → 100644
  1 +package com.xly.constant;
  2 +
  3 +import lombok.Getter;
  4 +
  5 +/***
  6 + * @Author 钱豹
  7 + * @Date 23:04 2026/1/30
  8 + * @Param
  9 + * @return
  10 + * @Description 异常码枚举
  11 + **/
  12 +@Getter
  13 +public enum ReturnTypeCode {
  14 +
  15 + // 成功
  16 + HTML("html", "html"),
  17 + MAKEDOWN("makedown", "makedown");
  18 +
  19 +
  20 + private final String code;
  21 + private final String message;
  22 +
  23 + ReturnTypeCode(String code, String message) {
  24 + this.code = code;
  25 + this.message = message;
  26 + }
  27 +
  28 + /**
  29 + * 根据code获取ErrorCode
  30 + */
  31 + public static ReturnTypeCode getByCode(String code) {
  32 + for (ReturnTypeCode errorCode : values()) {
  33 + if (errorCode.getCode().equals(code)) {
  34 + return errorCode;
  35 + }
  36 + }
  37 + return MAKEDOWN;
  38 + }
  39 +}
0 40 \ No newline at end of file
... ...
src/main/java/com/xly/constant/RuleCode.java 0 → 100644
  1 +package com.xly.constant;
  2 +
  3 +import lombok.Getter;
  4 +
  5 +/***
  6 + * @Author 钱豹
  7 + * @Date 23:04 2026/1/30
  8 + * @Param
  9 + * @return
  10 + * @Description 异常码枚举
  11 + **/
  12 +@Getter
  13 +public enum RuleCode {
  14 +// {"sql":"SQL","pro":"过程","const":"常量"}
  15 + // 成功
  16 + SQL("sql", "SQL"),
  17 + // 客户端错误
  18 + PRO("pro", "过程"),
  19 + CONST("const", "常量");
  20 +
  21 + private final String code;
  22 + private final String message;
  23 +
  24 + RuleCode(String code, String message) {
  25 + this.code = code;
  26 + this.message = message;
  27 + }
  28 +
  29 + /**
  30 + * 根据code获取ErrorCode
  31 + */
  32 + public static RuleCode getByCode(Integer code) {
  33 + for (RuleCode errorCode : values()) {
  34 + if (errorCode.getCode().equals(code)) {
  35 + return errorCode;
  36 + }
  37 + }
  38 + return CONST;
  39 + }
  40 +}
0 41 \ No newline at end of file
... ...
src/main/java/com/xly/constant/UrlErpConstant.java 0 → 100644
  1 +package com.xly.constant;
  2 +
  3 +/***
  4 + * @Author 钱豹
  5 + * @Date 0:37 2026/2/6
  6 + * @Param
  7 + * @return
  8 + * @Description ERP URL后缀
  9 + **/
  10 +public class UrlErpConstant {
  11 +
  12 + public static final String getBusinessDataByFormcustomId = "/business/getBusinessDataByFormcustomId/{}?sModelsId={}&sName=";
  13 +
  14 +}
... ...
src/main/java/com/xly/entity/AiResponseDTO.java 0 → 100644
  1 +package com.xly.entity;
  2 +
  3 +import cn.hutool.core.util.StrUtil;
  4 +import com.xly.constant.ErrorCode;
  5 +import com.xly.constant.ReturnTypeCode;
  6 +import lombok.AllArgsConstructor;
  7 +import lombok.Builder;
  8 +import lombok.Data;
  9 +import lombok.NoArgsConstructor;
  10 +
  11 +import java.io.Serializable;
  12 +
  13 +/**
  14 + * TTS响应数据传输对象
  15 + */
  16 +@Data
  17 +@Builder
  18 +@NoArgsConstructor
  19 +@AllArgsConstructor
  20 +public class AiResponseDTO implements Serializable {
  21 +
  22 + private static final long serialVersionUID = 1L;
  23 + // AI文字部分
  24 + private String aiText;
  25 + //系统拼接返回的文字部分
  26 + private String systemText;
  27 + //业务场景名称
  28 + private String sSceneName = StrUtil.EMPTY;
  29 + //业务方法名称
  30 + private String sMethodName = StrUtil.EMPTY;
  31 + private String sReturnType = ReturnTypeCode.MAKEDOWN.getCode();
  32 +
  33 +}
0 34 \ No newline at end of file
... ...
src/main/java/com/xly/entity/ConfirmationData.java 0 → 100644
  1 +package com.xly.entity;
  2 +
  3 +import lombok.Data;
  4 +
  5 +import java.util.HashMap;
  6 +import java.util.Map;
  7 +
  8 +@Data
  9 +public class ConfirmationData {
  10 + private final String requestId;
  11 + private final String toolName;
  12 + private final Map<String, Object> parameters;
  13 + private final String initialResult;
  14 + private final long expiryTimestamp; // 超时时间戳
  15 +
  16 + public ConfirmationData(String requestId, String toolName,
  17 + Map<String, Object> parameters,
  18 + String initialResult,
  19 + long timeoutMillis) {
  20 + this.requestId = requestId;
  21 + this.toolName = toolName;
  22 + this.parameters = parameters != null ? new HashMap<>(parameters) : new HashMap<>();
  23 + this.initialResult = initialResult;
  24 + this.expiryTimestamp = System.currentTimeMillis() + timeoutMillis;
  25 + }
  26 +
  27 + // Getters 和 检查方法
  28 + public boolean isExpired() {
  29 + return System.currentTimeMillis() > expiryTimestamp;
  30 + }
  31 +
  32 + public String getRequestId() { return requestId; }
  33 + public String getToolName() { return toolName; }
  34 + public Map<String, Object> getParameters() { return new HashMap<>(parameters); }
  35 + public String getInitialResult() { return initialResult; }
  36 + public long getExpiryTimestamp() { return expiryTimestamp; }
  37 +}
... ...
src/main/java/com/xly/entity/DynamicNl2SqlRequest.java 0 → 100644
  1 +package com.xly.entity;
  2 +
  3 +
  4 +import lombok.AllArgsConstructor;
  5 +import lombok.Data;
  6 +import lombok.NoArgsConstructor;
  7 +
  8 +/**
  9 + * 动态表结构的NL2SQL请求实体
  10 + * 适配多场景、任意表结构的NL2SQL查询
  11 + */
  12 +@Data
  13 +@NoArgsConstructor
  14 +@AllArgsConstructor
  15 +public class DynamicNl2SqlRequest {
  16 + /** 涉及表名(多表用,分隔,如product,sales、order,user) */
  17 + private String tableNames;
  18 + /** 表结构详情(严格按格式:表名(字段1:类型,字段2:类型,主键/外键),多表换行/逗号分隔) */
  19 + private String tableStruct;
  20 + /** 用户自然语言查询问题 */
  21 + private String question;
  22 +}
0 23 \ No newline at end of file
... ...
src/main/java/com/xly/entity/ErpDataset.java 0 → 100644
  1 +package com.xly.entity;
  2 +
  3 +import lombok.Data;
  4 +
  5 +import java.util.List;
  6 +import java.util.Map;
  7 +
  8 +/***
  9 + * @Author 钱豹
  10 + * @Date 1:22 2026/2/6
  11 + * @Param
  12 + * @return
  13 + * @Description ERP 返回的结果集
  14 + **/
  15 +@Data
  16 +public class ErpDataset {
  17 + private Integer start;
  18 + private Integer pageSize;
  19 + private Integer totalCount;
  20 + private Integer billNum;
  21 + private Integer end;
  22 + private Integer totalPageCount;
  23 + private Integer previousPageNo;
  24 + private Integer currentPageNo;
  25 + private Integer nextPageNo;
  26 + private List<ErpRow> rows;
  27 +}
... ...
src/main/java/com/xly/entity/ErpResult.java 0 → 100644
  1 +package com.xly.entity;
  2 +
  3 +import lombok.Data;
  4 +
  5 +/***
  6 + * @Author 钱豹
  7 + * @Date 22:42 2026/1/30
  8 + * @Param
  9 + * @return
  10 + * @Description 参数实体类
  11 + **/
  12 +@Data
  13 +public class ErpResult {
  14 +
  15 + private Integer code;
  16 + private String msg;
  17 + private ErpDataset dataset;
  18 +
  19 +}
... ...
src/main/java/com/xly/entity/ErpRow.java 0 → 100644
  1 +package com.xly.entity;
  2 +
  3 +import lombok.Data;
  4 +
  5 +import java.util.List;
  6 +import java.util.Map;
  7 +
  8 +@Data
  9 +public class ErpRow {
  10 + private List<Map<String,Object>> dataSet;
  11 + private List<Map<String,Object>> sumSet;
  12 +}
... ...
src/main/java/com/xly/entity/Nl2SqlRequest.java 0 → 100644
  1 +package com.xly.entity;
  2 +
  3 +import lombok.AllArgsConstructor;
  4 +import lombok.Data;
  5 +import lombok.NoArgsConstructor;
  6 +
  7 +import java.util.List;
  8 +import java.util.Map;
  9 +
  10 +/**
  11 + * NL2SQL统一请求参数
  12 + */
  13 +@Data
  14 +@NoArgsConstructor
  15 +@AllArgsConstructor
  16 +public class Nl2SqlRequest {
  17 + /** 用户自然语言查询语句 */
  18 + private String question;
  19 +}
  20 +
  21 +
  22 +
... ...
src/main/java/com/xly/entity/Nl2SqlResult.java 0 → 100644
  1 +package com.xly.entity;
  2 +
  3 +import lombok.AllArgsConstructor;
  4 +import lombok.Data;
  5 +import lombok.NoArgsConstructor;
  6 +
  7 +import java.util.List;
  8 +import java.util.Map;
  9 +
  10 +
  11 +
  12 +/**
  13 + * NL2SQL统一返回结果
  14 + */
  15 +@Data
  16 +@NoArgsConstructor
  17 +@AllArgsConstructor
  18 +public class Nl2SqlResult {
  19 + /** 生成并校验后的可执行MySQL SQL */
  20 + private String generateSql;
  21 + /** SQL执行结构化结果 */
  22 + private List<Map<String, Object>> sqlResult;
  23 + /** 结果自然语言解释 */
  24 + private String resultExplain;
  25 +}
  26 +
... ...
src/main/java/com/xly/entity/ParamRule.java 0 → 100644
  1 +package com.xly.entity;
  2 +
  3 +import lombok.Data;
  4 +
  5 +import java.time.LocalDateTime;
  6 +
  7 +/***
  8 + * @Author 钱豹
  9 + * @Date 22:42 2026/1/30
  10 + * @Param
  11 + * @return
  12 + * @Description 参数实体类
  13 + **/
  14 +@Data
  15 +public class ParamRule {
  16 + private Integer iOrder;
  17 + private String sParamValue;
  18 + private String sParentId;
  19 + private String sParam;
  20 + private String sRule;
  21 + private String sType;
  22 + private String sExampleValue;
  23 + private String sDefaultValue;
  24 + private String sParamMissMemo;
  25 + private String sParamConfig;
  26 + private String sCopyTo;
  27 + private String sRuleTs;
  28 + private Boolean bTipModel;
  29 + private Boolean bEmpty;
  30 + private Boolean bConfirmAfter;
  31 +
  32 +}
... ...
src/main/java/com/xly/entity/SceneDto.java 0 → 100644
  1 +package com.xly.entity;
  2 +
  3 +import jakarta.persistence.Column;
  4 +import jakarta.persistence.Id;
  5 +import lombok.Data;
  6 +
  7 +import java.time.LocalDateTime;
  8 +
  9 +/***
  10 + * @Author 钱豹
  11 + * @Date 22:41 2026/1/30
  12 + * @Param
  13 + * @return
  14 + * @Description 模型实体
  15 + **/
  16 +@Data
  17 +public class SceneDto {
  18 + @Id
  19 +// @GeneratedValue(strategy = GenerationType.IDENTITY)
  20 + private String sId;
  21 +
  22 + @Column(name = "iOrder")
  23 + private Integer iOrder;
  24 +
  25 + @Column(name = "tCreateDate")
  26 + private LocalDateTime tCreateDate;
  27 +
  28 + @Column(name = "sMakePerson")
  29 + private String sMakePerson;
  30 +
  31 + private String sBrandsId;
  32 +
  33 + private String sSubsidiaryId;
  34 +
  35 + private String sBillNo;
  36 +
  37 + private String sSceneNo;
  38 +
  39 + private String sSceneName;
  40 +
  41 + private String sNickName;
  42 +
  43 + private String sSceneContext;
  44 +
  45 + private String sStatus;
  46 +
  47 +
  48 + private LocalDateTime tUpdateDate;
  49 +
  50 +}
... ...
src/main/java/com/xly/entity/SceneIntentParseReq.java 0 → 100644
  1 +package com.xly.entity;
  2 +import lombok.Data;
  3 +
  4 +/**
  5 + * 意图解析请求参数:传给大模型的入参,包含用户输入+可访问场景列表
  6 + */
  7 +@Data
  8 +public class SceneIntentParseReq{
  9 + /**
  10 + * 用户自然语言输入
  11 + */
  12 + private String userInput;
  13 + /**
  14 + * 该用户权限内的可访问场景(格式:场景编码-场景名称-支持功能,如ORDER_OPERATE-订单操作-下单、查订单、取消订单)
  15 + */
  16 + private String authScenesDesc;
  17 +}
... ...
src/main/java/com/xly/entity/SceneIntentParseResp.java 0 → 100644
  1 +package com.xly.entity;
  2 +
  3 +import lombok.AllArgsConstructor;
  4 +import lombok.Data;
  5 +import lombok.NoArgsConstructor;
  6 +
  7 +import java.util.Map;
  8 +
  9 +/**
  10 + * 大模型意图解析响应DTO:强制大模型按此格式返回,方便后端解析
  11 + * 核心:仅返回场景编码(如ORDER_OPERATE),无需多余描述
  12 + */
  13 +@Data
  14 +@NoArgsConstructor
  15 +@AllArgsConstructor
  16 +public class SceneIntentParseResp {
  17 + /**
  18 + * 匹配的业务场景编码,必须是BusinessScene的枚举名称(如ORDER_OPERATE/CUSTOMER_MANAGE/STOCK_QUERY)
  19 + * 无匹配场景时,返回:NO_MATCH
  20 + */
  21 + private String sceneCode;
  22 +
  23 +
  24 + /**
  25 + * 业务场景名(如销量订单、送货单)
  26 + */
  27 + private String scene;
  28 +
  29 + /**
  30 + * 操作方法名(如增加、修改、审核、物流跟踪)
  31 + */
  32 + private String method;
  33 +
  34 + /**
  35 + * JSON结构化参数(键=参数名,值=参数值,类型与工具描述一致)
  36 + */
  37 + private Map<String, Object> params;
  38 +
  39 +
  40 +}
  41 +
... ...
src/main/java/com/xly/entity/SceneTemplate.java 0 → 100644
  1 +package com.xly.entity;
  2 +
  3 +import jakarta.persistence.*;
  4 +import lombok.Data;
  5 +
  6 +/**
  7 + * 数据库存储的动态模板
  8 + */
  9 +@Entity
  10 +@Table(name = "ai_scene_template")
  11 +@Data
  12 +public class SceneTemplate {
  13 +
  14 + @Id
  15 + @GeneratedValue(strategy = GenerationType.IDENTITY)
  16 + private Long id;
  17 +
  18 + @Column(name = "scene_code")
  19 + private String sceneCode; // 场景编码
  20 +
  21 + @Column(name = "scene_name")
  22 + private String sceneName; // 场景名称
  23 +
  24 + @Column(name = "template_content", columnDefinition = "TEXT")
  25 + private String templateContent; // 模板内容
  26 +
  27 + @Column(name = "variables_config", columnDefinition = "JSON")
  28 + private String variablesConfig; // 变量配置
  29 +
  30 + @Column(name = "rules_config", columnDefinition = "JSON")
  31 + private String rulesConfig; // 规则配置
  32 +
  33 + @Column(name = "is_active")
  34 + private Boolean active = true;
  35 +
  36 + public void setId(Long id) {
  37 + this.id = id;
  38 + }
  39 +
  40 + public Long getId() {
  41 + return id;
  42 + }
  43 +}
0 44 \ No newline at end of file
... ...
src/main/java/com/xly/entity/ToolMeta.java 0 → 100644
  1 +package com.xly.entity;
  2 +
  3 +
  4 +import lombok.Data;
  5 +
  6 +import java.time.LocalDateTime;
  7 +import java.util.List;
  8 +import java.util.Map;
  9 +
  10 +/***
  11 + * @Author 钱豹
  12 + * @Date 22:41 2026/1/30
  13 + * @Param
  14 + * @return
  15 + * @Description 工具实体
  16 + **/
  17 +@Data
  18 +public class ToolMeta {
  19 + private String sId;
  20 + private Integer iOrder;
  21 + private LocalDateTime tCreateDate;
  22 + private String sBrandsId;
  23 + private String sSubsidiaryId;
  24 + private String sMakePerson;
  25 + private String sBillNo;
  26 + private String sFormId;
  27 + private String sStatus;
  28 + private String sceneName;
  29 + private String sMethodName;
  30 + private String stoolDesc;
  31 + private String sParamRules;
  32 + private Integer iBizType;
  33 + private LocalDateTime tUpdateDate;
  34 + private String sMethodNo;
  35 + private String sControlName;
  36 + private String sApiUrl;
  37 + private String sSrcFormId;
  38 + private String sBizContent;
  39 + private String sSceneId;
  40 + private String sInputTabelName;
  41 + private String sStructureMemo;
  42 + private String sAIshowfield;
  43 + private List<Map<String,Object>> sAIshowfieldShow;
  44 + private Integer iActionType;
  45 + private String sendUrl;
  46 + private List<ParamRule> paramRuleList;//模型
  47 + private List<ParamRule> paramRuleListCheck;//需要校验
  48 + private List<ParamRule> paramRuleListAll;//所有的
  49 + private LocalDateTime tMakeDate;
  50 +}
... ...
src/main/java/com/xly/entity/UserSceneSession.java 0 → 100644
  1 +package com.xly.entity;
  2 +
  3 +
  4 +import cn.hutool.core.util.ObjectUtil;
  5 +import lombok.Data;
  6 +
  7 +import java.util.List;
  8 +import java.util.Map;
  9 +import java.util.concurrent.atomic.AtomicInteger;
  10 +import java.util.stream.Collectors;
  11 +
  12 +/**
  13 + * 用户会话状态:记录每个用户的场景选择状态,实现场景会话持久化
  14 + * 存储:用户ID、权限内场景、是否已选场景、当前选定场景
  15 + */
  16 +@Data
  17 +public class UserSceneSession {
  18 + /**
  19 + * 唯一标识:用户ID
  20 + */
  21 + private String userId;
  22 + private String authorization;
  23 + /**
  24 + * 该用户权限内可访问的所有场景(从权限映射中获取)
  25 + */
  26 + private List<SceneDto> authScenes;
  27 +
  28 + private List<ToolMeta> authTool;
  29 + /**
  30 + * 是否已选择场景(true=已选,进入专属场景交互;false=未选,先展示选择界面)
  31 + */
  32 + private boolean sceneSelected = false;
  33 + /**
  34 + * 当前选定的业务场景(sceneSelected=true时才有值)
  35 + */
  36 + private SceneDto currentScene;
  37 +
  38 + private ToolMeta currentTool;
  39 + /***
  40 + * @Author 钱豹
  41 + * @Date 10:07 2026/1/31
  42 + * @Param
  43 + * @return
  44 + * @Description 当前已有参数
  45 + **/
  46 + private Map<String,Object> currentArgs;
  47 +
  48 + private String sFunPrompts; //方法返回的参数补全提示
  49 +
  50 + private Boolean bCleanMemory = false;
  51 + /**
  52 + * 构建场景选择提示语:展示权限内场景,引导用户选择
  53 + * @return 自然语言提示语
  54 + */
  55 + public String buildSceneSelectHint() {
  56 + if (authScenes == null || authScenes.isEmpty()) {
  57 + return "<p style='color:red;'>抱歉,你暂无任何业务场景的访问权限,请联系管理员开通!</p>";
  58 + }
  59 + // 按名称分组
  60 + StringBuilder sb = new StringBuilder("<div style='line-height:1.8;'>");
  61 + sb.append("<span>请输入序号或点链接进入</span>");
  62 + sb.append("<ul style='padding-left:10px;margin:0;list-style: none;'>");
  63 + for(int i=0;i<authScenes.size();i++){
  64 + SceneDto sceneDto = authScenes.get(i);
  65 + sb.append("<li>").append(i+1).append("、").append("<span style=\"text-decoration: underline;\" data-action=\"reset\" data-text=\"") .append(i+1).append("\" onclick=\"reset('").append(i+1).append("')\">") .append(sceneDto.getSNickName()).append("</span><font style='color:#666; font-size:14px;'>(").append(sceneDto.getSSceneContext()).append(")</font>").append("</li>");
  66 + }
  67 + sb.append("</ul>");
  68 + sb.append("<span style='color:red; font-size:14px;'>提示:可直接输入问题,系统会自动匹配智能体! </span>");
  69 + sb.append("</div>");
  70 + return sb.toString();
  71 +
  72 + }
  73 +
  74 +
  75 + // 新增:将权限内场景转换为「大模型可识别的描述字符串」
  76 + public String buildAuthScenesForLlm(List<ToolMeta> metasAll) {
  77 + if (authScenes == null || authScenes.isEmpty()) {
  78 + return "无可用场景";
  79 + }
  80 + // 格式:场景编码-场景名称-支持功能,如ORDER_OPERATE-订单操作-下单、查订单、取消订单
  81 + return authScenes.stream()
  82 + .map(scene -> "sceneCode:" + scene.getSSceneNo() + ",场景名称:" + scene.getSSceneName() + ",场景内容:\n" + getSceneContext(scene,metasAll))
  83 + .collect(Collectors.joining("\n"));
  84 + }
  85 + /***
  86 + * @Author 钱豹
  87 + * @Date 14:12 2026/2/6
  88 + * @Param [sceneDto, metasAll]
  89 + * @return java.lang.String
  90 + * @Description 方法名称拼接
  91 + **/
  92 + private String getSceneContext(SceneDto sceneDto,List<ToolMeta> metasAll){
  93 + List<ToolMeta> metas = metasAll.stream().filter(m->m.getSSceneId().equals(sceneDto.getSId())).collect(Collectors.toUnmodifiableList());
  94 + StringBuilder sb = new StringBuilder();
  95 + AtomicInteger index = new AtomicInteger(1);
  96 + metas.forEach(m -> {
  97 + sb.append(index.getAndIncrement())
  98 + .append(". 【")
  99 +// .append(m.getSMethodNo())
  100 +// .append("-")
  101 + .append(m.getSMethodName())
  102 + .append("】")
  103 + .append(m.getStoolDesc())
  104 + .append("\n");
  105 + });
  106 + if(ObjectUtil.isEmpty(sb)){
  107 + sb.append(sceneDto.getSSceneContext());
  108 + }
  109 + return sb.toString();
  110 + }
  111 +
  112 + /**
  113 + * 根据用户输入的序号,匹配选定场景
  114 + * @param input 用户输入的序号(如1/2/3)
  115 + * @return true=匹配成功,false=匹配失败
  116 + */
  117 + public boolean selectSceneByInput(String input) {
  118 + try {
  119 + int index = Integer.parseInt(input) - 1;
  120 + if (index >= 0 && index < authScenes.size()) {
  121 + this.currentScene = authScenes.get(index);
  122 + this.sceneSelected = true;
  123 + return true;
  124 + }
  125 + } catch (NumberFormatException e) {
  126 + // 非数字输入,直接返回false
  127 + }
  128 + return false;
  129 + }
  130 +
  131 +
  132 +}
0 133 \ No newline at end of file
... ...
src/main/java/com/xly/exception/GlobalExceptionHandler.java 0 → 100644
  1 +package com.xly.exception;
  2 +
  3 +import com.xly.constant.ErrorCode;
  4 +import com.xly.exception.dto.BusinessException;
  5 +import com.xly.tts.bean.TTSResponseDTO;
  6 +import jakarta.servlet.http.HttpServletRequest;
  7 +import jakarta.validation.ConstraintViolationException;
  8 +import lombok.extern.slf4j.Slf4j;
  9 +import org.junit.jupiter.api.Order;
  10 +import org.springframework.core.Ordered;
  11 +import org.springframework.dao.DataAccessException;
  12 +import org.springframework.dao.DataIntegrityViolationException;
  13 +import org.springframework.dao.DuplicateKeyException;
  14 +import org.springframework.http.converter.HttpMessageConversionException;
  15 +import org.springframework.jdbc.BadSqlGrammarException;
  16 +import org.springframework.web.HttpMediaTypeNotSupportedException;
  17 +import org.springframework.web.HttpRequestMethodNotSupportedException;
  18 +import org.springframework.web.bind.MethodArgumentNotValidException;
  19 +import org.springframework.web.bind.annotation.ExceptionHandler;
  20 +import org.springframework.web.bind.annotation.RestControllerAdvice;
  21 +import org.springframework.web.servlet.NoHandlerFoundException;
  22 +
  23 +import java.util.Date;
  24 +import java.util.HashMap;
  25 +import java.util.List;
  26 +import java.util.Map;
  27 +import java.util.stream.Collectors;
  28 +
  29 +@Slf4j
  30 +@RestControllerAdvice
  31 +@Order(Ordered.HIGHEST_PRECEDENCE)
  32 +public class GlobalExceptionHandler {
  33 +
  34 +
  35 + /**
  36 + * 处理所有异常
  37 + */
  38 + @ExceptionHandler(Exception.class)
  39 + public TTSResponseDTO handleException(HttpServletRequest request, Exception e) {
  40 + log.error("请求地址: {}, 全局异常: ", request.getRequestURI(), e);
  41 +
  42 + // 获取请求信息
  43 + String method = request.getMethod();
  44 + String uri = request.getRequestURI();
  45 + String queryString = request.getQueryString();
  46 +
  47 + // 记录异常日志(可存入数据库)
  48 +// ErrorLog errorLog = ErrorLog.builder()
  49 +// .method(method)
  50 +// .uri(uri)
  51 +// .queryString(queryString)
  52 +// .exceptionName(e.getClass().getName())
  53 +// .exceptionMessage(e.getMessage())
  54 +// .stackTrace(ExceptionUtils.getStackTrace(e))
  55 +// .ip(IpUtil.getIpAddr(request))
  56 +// .userAgent(request.getHeader("User-Agent"))
  57 +// .timestamp(new Date())
  58 +// .build();
  59 +//
  60 +// // 异步保存错误日志
  61 +// saveErrorLogAsync(errorLog);
  62 +
  63 + // 生产环境隐藏详细错误信息
  64 + if (isProduction()) {
  65 + return TTSResponseDTO.error(ErrorCode.SYSTEM_ERROR);
  66 + } else {
  67 + // 开发环境返回详细错误信息
  68 + Map<String, Object> errorDetail = new HashMap<>();
  69 + errorDetail.put("exception", e.getClass().getName());
  70 + errorDetail.put("message", e.getMessage());
  71 + errorDetail.put("path", uri);
  72 + errorDetail.put("method", method);
  73 + errorDetail.put("timestamp", new Date());
  74 + return TTSResponseDTO.error(ErrorCode.SYSTEM_ERROR.getCode(), "系统异常: " + e.getMessage());
  75 + }
  76 + }
  77 +
  78 + /**
  79 + * 处理业务异常
  80 + */
  81 + @ExceptionHandler(BusinessException.class)
  82 + public TTSResponseDTO handleBusinessException(BusinessException e) {
  83 + log.warn("业务异常: {}", e.getMessage());
  84 + return TTSResponseDTO.error(e.getCode(), e.getMessage());
  85 + }
  86 +
  87 + /**
  88 + * 处理数据校验异常
  89 + */
  90 + @ExceptionHandler(MethodArgumentNotValidException.class)
  91 + public TTSResponseDTO handleMethodArgumentNotValidException(
  92 + MethodArgumentNotValidException e) {
  93 + List<String> errors = e.getBindingResult().getFieldErrors()
  94 + .stream()
  95 + .map(error -> error.getField() + ": " + error.getDefaultMessage())
  96 + .collect(Collectors.toList());
  97 +
  98 + String message = String.join("; ", errors);
  99 + log.warn("参数校验失败: {}", message);
  100 + return TTSResponseDTO.error(ErrorCode.PARAM_ERROR.getCode(), message);
  101 + }
  102 +
  103 + /**
  104 + * 处理约束违反异常
  105 + */
  106 + @ExceptionHandler(ConstraintViolationException.class)
  107 + public TTSResponseDTO handleConstraintViolationException(
  108 + ConstraintViolationException e) {
  109 + List<String> errors = e.getConstraintViolations()
  110 + .stream()
  111 + .map(violation ->
  112 + violation.getPropertyPath() + ": " + violation.getMessage())
  113 + .collect(Collectors.toList());
  114 +
  115 + String message = String.join("; ", errors);
  116 + log.warn("参数约束违反: {}", message);
  117 + return TTSResponseDTO.error(ErrorCode.PARAM_ERROR.getCode(), message);
  118 + }
  119 +
  120 + /**
  121 + * 处理Http请求方法不支持异常
  122 + */
  123 + @ExceptionHandler(HttpRequestMethodNotSupportedException.class)
  124 + public TTSResponseDTO handleHttpRequestMethodNotSupportedException(
  125 + HttpRequestMethodNotSupportedException e) {
  126 + log.warn("HTTP方法不支持: {} {}", e.getMethod(), e.getSupportedHttpMethods());
  127 + return TTSResponseDTO.error(ErrorCode.BAD_REQUEST.getCode(),
  128 + "请求方法 '" + e.getMethod() + "' 不支持");
  129 + }
  130 +
  131 + /**
  132 + * 处理媒体类型不支持异常
  133 + */
  134 + @ExceptionHandler(HttpMediaTypeNotSupportedException.class)
  135 + public TTSResponseDTO handleHttpMediaTypeNotSupportedException(
  136 + HttpMediaTypeNotSupportedException e) {
  137 + log.warn("媒体类型不支持: {}", e.getContentType());
  138 + return TTSResponseDTO.error(ErrorCode.BAD_REQUEST.getCode(),
  139 + "媒体类型不支持: " + e.getContentType());
  140 + }
  141 +
  142 + /**
  143 + * 处理404异常
  144 + */
  145 + @ExceptionHandler(NoHandlerFoundException.class)
  146 + public TTSResponseDTO handleNoHandlerFoundException(
  147 + NoHandlerFoundException e) {
  148 + log.warn("接口不存在: {} {}", e.getHttpMethod(), e.getRequestURL());
  149 + return TTSResponseDTO.error(ErrorCode.NOT_FOUND.getCode(),
  150 + "接口不存在: " + e.getRequestURL());
  151 + }
  152 +
  153 + /**
  154 + * 处理类型转换异常
  155 + */
  156 + @ExceptionHandler(HttpMessageConversionException.class)
  157 + public TTSResponseDTO handleHttpMessageConversionException(
  158 + HttpMessageConversionException e) {
  159 + log.warn("HTTP消息转换异常: {}", e.getMessage());
  160 + return TTSResponseDTO.error(ErrorCode.PARAM_ERROR.getCode(),
  161 + "参数格式错误");
  162 + }
  163 +
  164 + /**
  165 + * 处理数据库异常
  166 + */
  167 + @ExceptionHandler(DataAccessException.class)
  168 + public TTSResponseDTO handleDataAccessException(DataAccessException e) {
  169 + log.error("数据库异常: {}", e.getMessage(), e);
  170 +
  171 + // 根据具体异常类型细化处理
  172 + if (e instanceof DuplicateKeyException) {
  173 + return TTSResponseDTO.error(ErrorCode.DATA_EXISTS.getCode(),
  174 + "数据已存在");
  175 + } else if (e instanceof DataIntegrityViolationException) {
  176 + return TTSResponseDTO.error(ErrorCode.DATA_ERROR.getCode(),
  177 + "数据完整性违反");
  178 + } else if (e instanceof BadSqlGrammarException) {
  179 + return TTSResponseDTO.error(ErrorCode.DB_ERROR.getCode(),
  180 + "SQL语法错误");
  181 + }
  182 +
  183 + return TTSResponseDTO.error(ErrorCode.DB_ERROR);
  184 + }
  185 +
  186 + /**
  187 + * 处理JWT异常
  188 + */
  189 +// @ExceptionHandler(JwtException.class)
  190 +// public TTSResponseDTO handleJwtException(JwtException e) {
  191 +// log.warn("JWT异常: {}", e.getMessage());
  192 +// return ApiResult.error(ErrorCode.UNAUTHORIZED.getCode(),
  193 +// "Token无效或已过期");
  194 +// }
  195 +
  196 +// /**
  197 +// * 异步保存错误日志
  198 +// */
  199 +// @Async
  200 +// public void saveErrorLogAsync(ErrorLog errorLog) {
  201 +// try {
  202 +// // 保存到数据库或日志文件
  203 +// errorLogService.save(errorLog);
  204 +// } catch (Exception ex) {
  205 +// log.error("保存错误日志失败", ex);
  206 +// }
  207 +// }
  208 +
  209 + /***
  210 + * @Author 钱豹
  211 + * @Date 23:21 2026/1/30
  212 + * @Param []
  213 + * @return boolean
  214 + * @Description 判断是否生产环境还是开发环境 应该根据yml 配置来 后期拓展
  215 + **/
  216 + private boolean isProduction() {
  217 + //TODO
  218 + String profile = "dev";
  219 + return "prod".equals(profile);
  220 + }
  221 +}
0 222 \ No newline at end of file
... ...
src/main/java/com/xly/exception/TtsExceptionFilter.java 0 → 100644
  1 +package com.xly.exception;
  2 +
  3 +import com.fasterxml.jackson.databind.ObjectMapper;
  4 +import com.xly.tts.bean.TTSResponseDTO;
  5 +import jakarta.servlet.*;
  6 +import jakarta.servlet.http.HttpServletRequest;
  7 +import jakarta.servlet.http.HttpServletResponse;
  8 +import lombok.RequiredArgsConstructor;
  9 +import lombok.extern.slf4j.Slf4j;
  10 +import org.springframework.core.annotation.Order;
  11 +import org.springframework.http.HttpStatus;
  12 +import org.springframework.http.MediaType;
  13 +import org.springframework.stereotype.Component;
  14 +
  15 +import java.io.IOException;
  16 +
  17 +@Slf4j
  18 +@Component
  19 +@Order(1)
  20 +@RequiredArgsConstructor
  21 +public class TtsExceptionFilter implements Filter {
  22 +
  23 + private final ObjectMapper objectMapper;
  24 +
  25 + @Override
  26 + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
  27 + throws IOException, ServletException {
  28 +
  29 + HttpServletRequest httpRequest = (HttpServletRequest) request;
  30 + HttpServletResponse httpResponse = (HttpServletResponse) response;
  31 +
  32 + // 只处理 TTS 相关接口
  33 + if (!httpRequest.getRequestURI().contains("/api/tts/")) {
  34 + chain.doFilter(request, response);
  35 + return;
  36 + }
  37 +
  38 + try {
  39 + chain.doFilter(request, response);
  40 + } catch (Exception ex) {
  41 + log.error("TTS接口异常: {}", httpRequest.getRequestURI(), ex);
  42 +
  43 + // 清除之前的响应
  44 + if (httpResponse.isCommitted()) {
  45 + return;
  46 + }
  47 +
  48 + httpResponse.reset();
  49 + httpResponse.setStatus(HttpStatus.INTERNAL_SERVER_ERROR.value());
  50 + httpResponse.setContentType(MediaType.APPLICATION_JSON_VALUE);
  51 + httpResponse.setCharacterEncoding("UTF-8");
  52 +
  53 + TTSResponseDTO errorResult = TTSResponseDTO.error(
  54 + HttpStatus.INTERNAL_SERVER_ERROR.value(),
  55 + "语音合成服务异常: " + ex.getMessage()
  56 + );
  57 +
  58 + String jsonResponse = objectMapper.writeValueAsString(errorResult);
  59 + httpResponse.getWriter().write(jsonResponse);
  60 + httpResponse.getWriter().flush();
  61 + }
  62 + }
  63 +}
0 64 \ No newline at end of file
... ...
src/main/java/com/xly/exception/dto/AuthenticationException.java 0 → 100644
  1 +package com.xly.exception.dto;
  2 +
  3 +import com.xly.constant.ErrorCode;
  4 +
  5 +/***
  6 + * @Author 钱豹
  7 + * @Date 23:13 2026/1/30
  8 + * @Param
  9 + * @return
  10 + * @Description 认证异常
  11 + **/
  12 +public class AuthenticationException extends BaseException {
  13 + public AuthenticationException(ErrorCode errorCode) {
  14 + super(errorCode);
  15 + }
  16 +
  17 + public AuthenticationException(String message) {
  18 + super(ErrorCode.UNAUTHORIZED.getCode(), message);
  19 + }
  20 +}
0 21 \ No newline at end of file
... ...
src/main/java/com/xly/exception/dto/AuthorizationException.java 0 → 100644
  1 +package com.xly.exception.dto;
  2 +
  3 +
  4 +import com.xly.constant.ErrorCode;
  5 +
  6 +/***
  7 + * @Author 钱豹
  8 + * @Date 23:14 2026/1/30
  9 + * @Param
  10 + * @return
  11 + * @Description 授权异常
  12 + **/
  13 +public class AuthorizationException extends BaseException {
  14 + public AuthorizationException(ErrorCode errorCode) {
  15 + super(errorCode);
  16 + }
  17 +
  18 + public AuthorizationException(String message) {
  19 + super(ErrorCode.FORBIDDEN.getCode(), message);
  20 + }
  21 +}
0 22 \ No newline at end of file
... ...
src/main/java/com/xly/exception/dto/BaseException.java 0 → 100644
  1 +package com.xly.exception.dto;
  2 +
  3 +import com.xly.constant.ErrorCode;
  4 +import lombok.Data;
  5 +import lombok.EqualsAndHashCode;
  6 +
  7 +/***
  8 + * @Author 钱豹
  9 + * @Date 23:13 2026/1/30
  10 + * @Param
  11 + * @return
  12 + * @Description 基础异常定义
  13 + **/
  14 +@Data
  15 +@EqualsAndHashCode(callSuper = true)
  16 +public class BaseException extends RuntimeException {
  17 + private final Integer code;
  18 + private final String message;
  19 +
  20 + public BaseException(ErrorCode errorCode) {
  21 + super(errorCode.getMessage());
  22 + this.code = errorCode.getCode();
  23 + this.message = errorCode.getMessage();
  24 + }
  25 +
  26 + public BaseException(ErrorCode errorCode, String message) {
  27 + super(message);
  28 + this.code = errorCode.getCode();
  29 + this.message = message;
  30 + }
  31 +
  32 + public BaseException(Integer code, String message) {
  33 + super(message);
  34 + this.code = code;
  35 + this.message = message;
  36 + }
  37 +}
0 38 \ No newline at end of file
... ...
src/main/java/com/xly/exception/dto/BusinessException.java 0 → 100644
  1 +package com.xly.exception.dto;
  2 +
  3 +import com.xly.constant.ErrorCode;
  4 +
  5 +/***
  6 + * @Author 钱豹
  7 + * @Date 23:13 2026/1/30
  8 + * @Param
  9 + * @return
  10 + * @Description 业务异常
  11 + **/
  12 +public class BusinessException extends BaseException {
  13 + public BusinessException(ErrorCode errorCode) {
  14 + super(errorCode);
  15 + }
  16 +
  17 + public BusinessException(ErrorCode errorCode, String message) {
  18 + super(errorCode, message);
  19 + }
  20 +
  21 + public BusinessException(Integer code, String message) {
  22 + super(code, message);
  23 + }
  24 +}
  25 +
  26 +
  27 +
... ...
src/main/java/com/xly/exception/dto/DataException.java 0 → 100644
  1 +package com.xly.exception.dto;
  2 +
  3 +import com.xly.constant.ErrorCode;
  4 +
  5 +/***
  6 + * @Author 钱豹
  7 + * @Date 23:13 2026/1/30
  8 + * @Param
  9 + * @return
  10 + * @Description 数据异常
  11 + **/
  12 +public class DataException extends BaseException {
  13 + public DataException(ErrorCode errorCode) {
  14 + super(errorCode);
  15 + }
  16 +
  17 + public DataException(String message) {
  18 + super(ErrorCode.DATA_ERROR.getCode(), message);
  19 + }
  20 +}
0 21 \ No newline at end of file
... ...
src/main/java/com/xly/exception/sqlexception/Nl2SqlBaseException.java 0 → 100644
  1 +package com.xly.exception.sqlexception;
  2 +
  3 +/**
  4 + * NL2SQL根异常
  5 + */
  6 +public class Nl2SqlBaseException extends RuntimeException {
  7 + public Nl2SqlBaseException(String message) {
  8 + super(message);
  9 + }
  10 +
  11 + public Nl2SqlBaseException(String message, Throwable cause) {
  12 + super(message, cause);
  13 + }
  14 +}
... ...
src/main/java/com/xly/exception/sqlexception/SqlExecuteException.java 0 → 100644
  1 +package com.xly.exception.sqlexception;
  2 +
  3 +/**
  4 + * SQL执行异常(数据库执行/结果解析失败)
  5 + */
  6 +public class SqlExecuteException extends Nl2SqlBaseException {
  7 + public SqlExecuteException(String message) {
  8 + super(message);
  9 + }
  10 +
  11 + public SqlExecuteException(String message, Throwable cause) {
  12 + super(message, cause);
  13 + }
  14 +}
0 15 \ No newline at end of file
... ...
src/main/java/com/xly/exception/sqlexception/SqlGenerateException.java 0 → 100644
  1 +package com.xly.exception.sqlexception;
  2 +
  3 +/**
  4 + * SQL生成异常(@AiService调用失败/生成空SQL)
  5 + */
  6 +public class SqlGenerateException extends Nl2SqlBaseException {
  7 + public SqlGenerateException(String message) {
  8 + super(message);
  9 + }
  10 +
  11 + public SqlGenerateException(String message, Throwable cause) {
  12 + super(message, cause);
  13 + }
  14 +}
0 15 \ No newline at end of file
... ...
src/main/java/com/xly/exception/sqlexception/SqlValidateException.java 0 → 100644
  1 +package com.xly.exception.sqlexception;
  2 +
  3 +/**
  4 + * SQL强校验异常(语法错误/危险关键词/非SELECT语句)
  5 + */
  6 +public class SqlValidateException extends Nl2SqlBaseException {
  7 + public SqlValidateException(String message) {
  8 + super(message);
  9 + }
  10 +
  11 + public SqlValidateException(String message, Throwable cause) {
  12 + super(message, cause);
  13 + }
  14 +}
0 15 \ No newline at end of file
... ...
src/main/java/com/xly/mapper/AiToolDetailParamsMapper.java 0 → 100644
  1 +package com.xly.mapper;
  2 +
  3 +import com.xly.entity.ParamRule;
  4 +import org.apache.ibatis.annotations.Mapper;
  5 +import org.apache.ibatis.annotations.Select;
  6 +import org.springframework.stereotype.Repository;
  7 +
  8 +import java.util.List;
  9 +
  10 +@Repository
  11 +@Mapper
  12 +public interface AiToolDetailParamsMapper {
  13 +
  14 + // XML配置方式
  15 + @Select("SELECT * FROM ai_tool_detail_params")
  16 + List<ParamRule> findAll();
  17 +}
0 18 \ No newline at end of file
... ...
src/main/java/com/xly/mapper/DynamicExeDbMapper.java 0 → 100644
  1 +package com.xly.mapper;
  2 +
  3 +
  4 +import org.apache.ibatis.annotations.Mapper;
  5 +import org.springframework.stereotype.Repository;
  6 +
  7 +import java.util.List;
  8 +import java.util.Map;
  9 +
  10 +
  11 +@Repository
  12 +@Mapper
  13 +public interface DynamicExeDbMapper {
  14 +
  15 + /***
  16 + * @Author 钱豹
  17 + * @Date 22:43 2026/1/30
  18 + * @Param []
  19 + * @return java.util.List<java.util.Map<java.lang.String,java.lang.Object>>
  20 + * @Description 查询SQL执行
  21 + **/
  22 + List<Map<String,Object>> findSql(Map<String,Object> searMap);
  23 +
  24 + /***
  25 + * @Author 钱豹
  26 + * @Date 22:44 2026/1/30
  27 + * @Param [updMap]
  28 + * @return void
  29 + * @Description 更新SQL执行
  30 + **/
  31 + void updateSql(Map<String,Object> updMap);
  32 +
  33 + /***
  34 + * @Author 钱豹
  35 + * @Date 22:45 2026/1/30
  36 + * @Param [updMap]
  37 + * @return void
  38 + * @Description 新增SQL执行
  39 + **/
  40 + void addSql(Map<String,Object> addMap);
  41 +
  42 + /****
  43 + * @Author 钱豹
  44 + * @Date 22:56 2026/1/30
  45 + * @Param [delMap]
  46 + * @return void
  47 + * @Description 删除QL执行
  48 + **/
  49 + void delSql(Map<String,Object> delMap);
  50 +
  51 +
  52 + /***
  53 + * @Author 钱豹
  54 + * @Date 22:45 2026/1/30
  55 + * @Param
  56 + * @return
  57 + * @Description 动态执行过程 并且有返回 执行过程 返回多个数据集 ,默认10个
  58 + **/
  59 + List<List<Map<String,Object>>> getCallProMoreResult(Map<String,Object> map);
  60 +
  61 + /***
  62 + * @Author 钱豹
  63 + * @Date 1:41 2026/2/4
  64 + * @Param [map]
  65 + * @return java.util.List<java.util.Map<java.lang.String,java.lang.Object>>
  66 + * @Description 调用过程
  67 + **/
  68 + List<Map<String,Object>> getCallPro(Map<String,Object> map);
  69 +
  70 +}
... ...
src/main/java/com/xly/mapper/ParamRuleMapper.java 0 → 100644
  1 +package com.xly.mapper;
  2 +
  3 +import com.xly.entity.ParamRule;
  4 +import org.apache.ibatis.annotations.Mapper;
  5 +import org.apache.ibatis.annotations.Select;
  6 +import org.springframework.stereotype.Repository;
  7 +
  8 +import java.util.List;
  9 +
  10 +@Repository
  11 +@Mapper
  12 +public interface ParamRuleMapper {
  13 +
  14 + // XML配置方式
  15 + @Select("SELECT * FROM ai_tool_detail_params")
  16 + List<ParamRule> findAll();
  17 +}
0 18 \ No newline at end of file
... ...
src/main/java/com/xly/mapper/SceneMapper.java 0 → 100644
  1 +package com.xly.mapper;
  2 +
  3 +import com.xly.entity.SceneDto;
  4 +import org.apache.ibatis.annotations.*;
  5 +import org.springframework.stereotype.Repository;
  6 +
  7 +import java.util.List;
  8 +
  9 +@Repository
  10 +@Mapper
  11 +public interface SceneMapper {
  12 +
  13 + // XML配置方式
  14 + @Select("SELECT * FROM ai_agent order by iOrder")
  15 + List<SceneDto> findAll();
  16 +}
0 17 \ No newline at end of file
... ...
src/main/java/com/xly/mapper/ToolMetaMapper.java 0 → 100644
  1 +package com.xly.mapper;
  2 +
  3 +
  4 +import com.xly.entity.ToolMeta;
  5 +import org.apache.ibatis.annotations.Mapper;
  6 +import org.apache.ibatis.annotations.Select;
  7 +import org.springframework.stereotype.Repository;
  8 +
  9 +import java.util.List;
  10 +
  11 +@Repository
  12 +@Mapper
  13 +public interface ToolMetaMapper {
  14 +
  15 + // XML配置方式
  16 + @Select("SELECT A.* FROM ai_tool AS A order by iOrder")
  17 + List<ToolMeta> findAll();
  18 +
  19 +}
0 20 \ No newline at end of file
... ...
src/main/java/com/xly/mapper/UserSceneSessionMapper.java 0 → 100644
  1 +package com.xly.mapper;
  2 +
  3 +import org.apache.ibatis.annotations.Mapper;
  4 +import org.apache.ibatis.annotations.Param;
  5 +import org.apache.ibatis.annotations.Select;
  6 +import java.util.List;
  7 +import java.util.Map;
  8 +
  9 +@Mapper
  10 +public interface UserSceneSessionMapper {
  11 +
  12 + /**
  13 + * 查询用户权限Key列表
  14 + * @param sUserId 用户ID
  15 + * @return 权限Key列表
  16 + */
  17 + @Select("SELECT J.sKey AS sKey FROM sysjurisdiction AS J JOIN sisjurisdictionclassify AS B ON J.sJurisdictionClassifyId = B.sId WHERE B.sId IN(SELECT U.sJurisdictionClassifyId FROM sftlogininfojurisdictiongroup AS U WHERE U.sParentId = #{sUserId}) ")
  18 + List<Map<String, Object>> findUserPermissions(@Param("sUserId") String sUserId);
  19 +
  20 +}
0 21 \ No newline at end of file
... ...
src/main/java/com/xly/runner/AppStartupRunner.java 0 → 100644
  1 +package com.xly.runner;
  2 +
  3 +import com.xly.entity.SceneDto;
  4 +import com.xly.entity.ToolMeta;
  5 +import com.xly.entity.ParamRule;
  6 +import com.xly.mapper.SceneMapper;
  7 +import com.xly.mapper.ParamRuleMapper;
  8 +import com.xly.mapper.ToolMetaMapper;
  9 +import lombok.extern.slf4j.Slf4j;
  10 +import org.springframework.beans.factory.annotation.Autowired;
  11 +import org.springframework.boot.CommandLineRunner;
  12 +import org.springframework.core.annotation.Order;
  13 +import org.springframework.stereotype.Component;
  14 +
  15 +import java.util.ArrayList;
  16 +import java.util.Comparator;
  17 +import java.util.List;
  18 +import java.util.stream.Collectors;
  19 +
  20 +@Slf4j
  21 +@Component
  22 +@Order(100)
  23 +public class AppStartupRunner implements CommandLineRunner {
  24 +
  25 + // 静态缓存列表
  26 + private static final List<SceneDto> AI_AGENT_CACHE = new ArrayList<>();
  27 + private static final List<ToolMeta> AI_TOOL_CACHE = new ArrayList<>();
  28 + private static final List<ParamRule> AI_TOOL_PARAMS_CACHE = new ArrayList<>();
  29 +
  30 + // 使用实例变量进行依赖注入
  31 + @Autowired
  32 + private SceneMapper sceneMapper;
  33 +
  34 + @Autowired
  35 + private ParamRuleMapper paramRuleMapper;
  36 +
  37 + @Autowired
  38 + private ToolMetaMapper toolMetaMapper;
  39 +
  40 + public void cleanAllInit(){
  41 + AI_AGENT_CACHE.clear();
  42 + AI_TOOL_CACHE.clear();
  43 + AI_TOOL_PARAMS_CACHE.clear();
  44 + //初始化
  45 + initCache();
  46 + }
  47 +
  48 +
  49 +
  50 + @Override
  51 + public void run(String... args) throws Exception {
  52 + log.info("应用启动完成,开始初始化...");
  53 + // 初始化缓存
  54 + initCache();
  55 + // 打印缓存信息
  56 + printCacheInfo();
  57 + log.info("应用初始化完成");
  58 + }
  59 +
  60 + /**
  61 + * 配置热点数据加载
  62 + */
  63 + private void initCache() {
  64 + log.info("开始初始化缓存...");
  65 + // 1. 加载AI代理数据
  66 + List<SceneDto> sceneDtos = sceneMapper.findAll();
  67 + if (sceneDtos != null) {
  68 + AI_AGENT_CACHE.clear();
  69 + AI_AGENT_CACHE.addAll(sceneDtos);
  70 + log.info("加载AI代理数据: {} 条", sceneDtos.size());
  71 + } else {
  72 + log.warn("AI代理数据为空");
  73 + }
  74 +
  75 + // 2. 加载AI工具数据
  76 + List<ToolMeta> toolMetas = toolMetaMapper.findAll();
  77 + if (toolMetas != null) {
  78 + AI_TOOL_CACHE.clear();
  79 + AI_TOOL_CACHE.addAll(toolMetas);
  80 + log.info("加载AI工具数据: {} 条", toolMetas.size());
  81 + } else {
  82 + log.warn("AI工具数据为空");
  83 + }
  84 +
  85 + // 3. 加载AI工具参数数据
  86 + List<ParamRule> toolParams = paramRuleMapper.findAll();
  87 + if (toolParams != null) {
  88 + AI_TOOL_PARAMS_CACHE.clear();
  89 + AI_TOOL_PARAMS_CACHE.addAll(toolParams);
  90 + log.info("加载AI工具参数数据: {} 条", toolParams.size());
  91 + } else {
  92 + log.warn("AI工具参数数据为空");
  93 + }
  94 +
  95 + log.info("缓存初始化完成");
  96 + }
  97 +
  98 + /**
  99 + * 打印缓存信息
  100 + */
  101 + private void printCacheInfo() {
  102 + log.info("=== 缓存统计 ===");
  103 + log.info("AI代理缓存: {} 条", AI_AGENT_CACHE.size());
  104 + log.info("AI工具缓存: {} 条", AI_TOOL_CACHE.size());
  105 + log.info("AI工具参数缓存: {} 条", AI_TOOL_PARAMS_CACHE.size());
  106 + // 打印前几条数据示例
  107 + if (!AI_AGENT_CACHE.isEmpty()) {
  108 + log.info("AI代理示例: {}", AI_AGENT_CACHE.get(0));
  109 + }
  110 + if (!AI_TOOL_CACHE.isEmpty()) {
  111 + log.info("AI工具示例: {}", AI_TOOL_CACHE.get(0));
  112 + }
  113 + }
  114 +
  115 + /**
  116 + * 静态方法获取缓存(线程安全)
  117 + */
  118 + public static List<SceneDto> getAiAgentCache() {
  119 + return new ArrayList<>(AI_AGENT_CACHE); // 返回副本,保证线程安全
  120 + }
  121 +
  122 + public static List<ToolMeta> getAiToolCache() {
  123 + return new ArrayList<>(AI_TOOL_CACHE);
  124 + }
  125 +
  126 + public static List<ParamRule> getAiToolParamsCache() {
  127 + return new ArrayList<>(AI_TOOL_PARAMS_CACHE);
  128 + }
  129 +
  130 + public static List<ToolMeta> getTools(List<String> sModleData) {
  131 + List<ToolMeta> rData = AI_TOOL_CACHE.stream()
  132 + .filter(agent -> sModleData.contains(agent.getSSrcFormId()))
  133 + .collect(Collectors.toList());
  134 + rData.sort(Comparator.comparing(h -> h.getIOrder()));
  135 + return rData;
  136 + }
  137 +
  138 + public static List<ToolMeta> getAllTools() {
  139 + return AI_TOOL_CACHE;
  140 + }
  141 +
  142 + /**
  143 + * 根据ID获取AI代理
  144 + */
  145 + public static SceneDto getAiAgentById(String sId) {
  146 + return AI_AGENT_CACHE.stream()
  147 + .filter(agent -> agent.getSId().equals(sId))
  148 + .findFirst()
  149 + .orElse(null);
  150 + }
  151 + /**
  152 + * 根据编码获取AI代理
  153 + */
  154 + public static SceneDto getAiAgentByCode(String sCode) {
  155 + return AI_AGENT_CACHE.stream()
  156 + .filter(agent -> agent.getSSceneNo().equals(sCode))
  157 + .findFirst()
  158 + .orElse(null);
  159 + }
  160 +
  161 + /**
  162 + * 刷新缓存
  163 + */
  164 + public void refreshCache() {
  165 + initCache();
  166 + }
  167 +}
0 168 \ No newline at end of file
... ...
src/main/java/com/xly/service/DynamicExeDbService.java 0 → 100644
  1 +package com.xly.service;
  2 +
  3 +import cn.hutool.core.text.CharSequenceUtil;
  4 +import cn.hutool.core.util.ObjectUtil;
  5 +import cn.hutool.core.util.StrUtil;
  6 +import cn.hutool.json.JSONUtil;
  7 +import com.alibaba.fastjson.JSONObject;
  8 +import com.xly.constant.ErrorCode;
  9 +import com.xly.constant.ProcedureConstant;
  10 +import com.xly.exception.dto.BusinessException;
  11 +import com.xly.mapper.DynamicExeDbMapper;
  12 +import lombok.RequiredArgsConstructor;
  13 +import lombok.extern.slf4j.Slf4j;
  14 +import org.springframework.stereotype.Service;
  15 +
  16 +import java.util.*;
  17 +import java.util.stream.Stream;
  18 +
  19 +/***
  20 + * @Author 钱豹
  21 + * @Date 22:39 2026/1/30
  22 + * @Param
  23 + * @return
  24 + * @Description 动态执行SQL业务类
  25 + **/
  26 +@Slf4j
  27 +@Service("dynamicExeDbService")
  28 +@RequiredArgsConstructor
  29 +public class DynamicExeDbService {
  30 +
  31 + private final DynamicExeDbMapper dynamicExeDbMapper;
  32 + private static final String PARAMETER_NAME = "PARAMETER_NAME";
  33 + private static final String PARAMETER_MODE = "PARAMETER_MODE";
  34 + private static final String DATA_TYPE = "DATA_TYPE";
  35 + private static final String INT = "int";
  36 +
  37 +
  38 +
  39 + /***
  40 + * @Author 钱豹
  41 + * @Date 22:43 2026/1/30
  42 + * @Param []
  43 + * @return java.util.List<java.util.Map<java.lang.String,java.lang.Object>>
  44 + * @Description 查询SQL执行
  45 + **/
  46 + public List<Map<String,Object>> findSql(Map<String,Object> searMap,String sSql){
  47 + Map<String,Object> findMap = new HashMap<>(searMap);
  48 + findMap.put("sSql",sSql);
  49 + return dynamicExeDbMapper.findSql(findMap);
  50 + }
  51 +
  52 + /***
  53 + * @Author 钱豹
  54 + * @Date 22:44 2026/1/30
  55 + * @Param [updMap]
  56 + * @return void
  57 + * @Description 更新SQL执行
  58 + **/
  59 + public void updateSql(Map<String,Object> updMap,String sSql){
  60 + Map<String,Object> updateMap = new HashMap<>(updMap);
  61 + updateMap.put("sSql",sSql);
  62 + dynamicExeDbMapper.updateSql(updateMap);
  63 + }
  64 +
  65 + /***
  66 + * @Author 钱豹
  67 + * @Date 22:45 2026/1/30
  68 + * @Param [updMap]
  69 + * @return void
  70 + * @Description 新增SQL执行
  71 + **/
  72 + public void addSql(Map<String,Object> addMap,String sSql){
  73 + Map<String,Object> addSql = new HashMap<>(addMap);
  74 + addSql.put("sSql",sSql);
  75 + dynamicExeDbMapper.addSql(addSql);
  76 + }
  77 +
  78 + /****
  79 + * @Author 钱豹
  80 + * @Date 22:56 2026/1/30
  81 + * @Param [delMap]
  82 + * @return void
  83 + * @Description 删除QL执行
  84 + **/
  85 + public void delSql(Map<String,Object> delMap,String sSql){
  86 + Map<String,Object> delSql = new HashMap<>(delMap);
  87 + delSql.put("sSql",sSql);
  88 + dynamicExeDbMapper.delSql(delSql);
  89 + }
  90 +
  91 +
  92 + /***
  93 + * @Author 钱豹
  94 + * @Date 22:57 2026/1/30
  95 + * @Param [searMap, sProName]
  96 + * @return java.util.List<java.util.List<java.util.Map<java.lang.String,java.lang.Object>>>
  97 + * @Description 动态SQL执行 默认10个结果集返回
  98 + **/
  99 + public List<List<Map<String,Object>>> getCallProMoreResult(Map<String, Object> searMap) {
  100 + List<List<Map<String,Object>>> outListData = null;
  101 + try{
  102 + //
  103 + outListData = this.dynamicExeDbMapper.getCallProMoreResult(searMap);
  104 + }catch (Exception e){
  105 + log.error("执行失败,失败原因:",e);
  106 + }
  107 + String sMassage = (ObjectUtil.isEmpty(searMap.get(ProcedureConstant.SRETURN)))? StrUtil.EMPTY:searMap.get(ProcedureConstant.SRETURN).toString();
  108 + String sCode = (ObjectUtil.isEmpty(searMap.get(ProcedureConstant.SCODE)))? StrUtil.EMPTY:searMap.get(ProcedureConstant.SCODE).toString();
  109 + if(ObjectUtil.isNotEmpty(sCode)
  110 + && Integer.valueOf(sCode)< 0){
  111 + throw new BusinessException(Integer.valueOf(sCode),sMassage);
  112 + }
  113 + return outListData;
  114 + }
  115 + /***
  116 + * @Author 钱豹
  117 + * @Date 9:33 2026/1/31
  118 + * @Param [searMap, sProName]
  119 + * @return java.util.Map<java.lang.String,java.lang.Object>
  120 + * @Description 调用过程
  121 + **/
  122 + public Map<String,Object> getCallPro(Map<String, Object> searMap, String sProName){
  123 + List<Map<String,Object>> outListData = null;
  124 + long oldRead = System.currentTimeMillis();
  125 + try{
  126 + outListData = this.dynamicExeDbMapper.getCallPro (searMap);
  127 + }catch (Exception e){
  128 + log.error("调用过程异常",e);
  129 + this.doException(e,searMap, sProName);
  130 + }
  131 + String sMsgText = null;
  132 + if(ObjectUtil.isNotEmpty(searMap.get(ProcedureConstant.SRETURN))){
  133 + sMsgText = searMap.get(ProcedureConstant.SRETURN).toString();
  134 + }
  135 + Map<String,Object> outMapData = new HashMap<>(8);
  136 + Set<String> outSet = new HashSet<>();
  137 + if(ObjectUtil.isNotEmpty(searMap.get(ProcedureConstant.OUTSETSTRING))){
  138 + outSet = (Set<String>) searMap.get(ProcedureConstant.OUTSETSTRING);
  139 + }
  140 + //返回出参返回的数据
  141 + outSet.forEach(out->{
  142 + try{
  143 + if(ObjectUtil.isNotEmpty(searMap.get(out))
  144 + && JSONUtil.isJson(searMap.get(out).toString())){
  145 + if(JSONUtil.isJsonObj(searMap.get(out).toString())){
  146 + outMapData.put(out, JSONObject.parseObject(searMap.get(out).toString()));
  147 + }else if(JSONUtil.isJsonArray(searMap.get(out).toString())){
  148 + outMapData.put(out, JSONObject.parseArray(searMap.get(out).toString()));
  149 + }
  150 + }else{
  151 + outMapData.put(out, searMap.get(out));
  152 + }
  153 + }catch (Exception e){
  154 + outMapData.put(out, searMap.get(out));
  155 + }
  156 + });
  157 + Map<String,Object> retMap = new HashMap<>(4);
  158 + retMap.put(ProcedureConstant.OUTLIST,ObjectUtil.isNotEmpty(outListData) ? outListData : new ArrayList<>());
  159 + retMap.put(ProcedureConstant.OUTMAP,ObjectUtil.isNotEmpty(outMapData) ? outMapData : new HashMap<>(4));
  160 + retMap.put(ProcedureConstant.SRETURN,ObjectUtil.isNotEmpty(sMsgText) ? sMsgText : "");
  161 + retMap.put(ProcedureConstant.SCODE,searMap.get(ProcedureConstant.SCODE));
  162 + return retMap;
  163 + }
  164 + /***
  165 + * @Author 钱豹
  166 + * @Date 9:22 2026/1/31
  167 + * @Param [searMap, sProName, msg]
  168 + * @return java.lang.String
  169 + * @Description 过程执行错误异常提醒
  170 + **/
  171 + public String getProExceptionMsg(Map<String, Object> searMap, String sProName , String msg){
  172 + StringBuffer sb = new StringBuffer();
  173 + try{
  174 + //根据过程名称获取过程参数
  175 + List<Map<String, Object>> proList = getProcParam(sProName);
  176 + sb.append("{ CALL ").append(sProName).append("(");
  177 + Stream.iterate(0, i -> i + 1).limit(proList.size()).forEach(i -> {
  178 + if(ProcedureConstant.OUT.equalsIgnoreCase(proList.get(i).get(PARAMETER_MODE).toString())){
  179 + if(i==0){
  180 + sb.append("@").append(proList.get(i).get(PARAMETER_NAME));
  181 + }else{
  182 + sb.append(",@").append(proList.get(i).get(PARAMETER_NAME));
  183 + }
  184 + }else{
  185 + if(ObjectUtil.isEmpty(searMap.get(proList.get(i).get(PARAMETER_NAME)))){
  186 + if(i==0){
  187 + sb.append("NULL");
  188 + }else{
  189 + sb.append(",NULL");
  190 + }
  191 + }else{
  192 + String sControl = ObjectUtil.isNotEmpty(searMap.get(proList.get(i).get(PARAMETER_NAME)))?
  193 + searMap.get(proList.get(i).get(PARAMETER_NAME)).toString(): CharSequenceUtil.EMPTY;
  194 + String sPlit = "'";
  195 + if(sControl.contains("'")){
  196 + sPlit = "\"";
  197 + }
  198 + if(i==0){
  199 + sb.append(sPlit).append(sControl).append(sPlit);
  200 + }else{
  201 + sb.append(",").append(sPlit).append(sControl).append(sPlit);
  202 + }
  203 + }
  204 + }
  205 + });
  206 + sb.append(") ");
  207 + msg = sb.toString();
  208 + }catch (Exception e){
  209 + e.printStackTrace();
  210 + }
  211 + return msg;
  212 + }
  213 +
  214 + public Map<String, Object> getDoProMap(String sProName, Map<String, Object> params) throws BusinessException{
  215 + Map<String,Object> searMap = new HashMap<>(4);
  216 + try{
  217 + //根据过程名称获取过程参数
  218 + //添加公司子公司
  219 +// if((ObjectUtil.isEmpty(params.get("sBrId"))
  220 +// || ObjectUtil.isEmpty(params.get("sSuId")))){
  221 +// params.put("sBrId",params.get("sBrandsId"));
  222 +// params.put("sSuId",params.get("sSubsidiaryId"));
  223 +// }
  224 +// if(ObjectUtil.isEmpty(params.get("sLoginId"))){
  225 +// params.put("sLoginId",params.get("sUserId"));
  226 +// }
  227 + List<Map<String, Object>> proList = getProcParam( sProName);
  228 + //{CALL mytest(#{ownerid,mode=IN,jdbcType=INTEGER},#{examcount,mode=OUT,jdbcType=INTEGER})}
  229 + StringBuffer sb = new StringBuffer();
  230 + sb.append("{CALL ").append(sProName).append("(");
  231 + Set<String> outSet = new HashSet<>(4);
  232 + Stream.iterate(0, i -> i + 1).limit(proList.size()).forEach(i -> {
  233 + if(i==0){
  234 + sb.append("#{").append(proList.get(i).get(PARAMETER_NAME)).append(",mode=").append(proList.get(i).get(PARAMETER_MODE));
  235 + }else{
  236 + sb.append(",#{").append(proList.get(i).get(PARAMETER_NAME)).append(",mode=").append(proList.get(i).get(PARAMETER_MODE));
  237 + }
  238 + if(ProcedureConstant.IN.equalsIgnoreCase(proList.get(i).get(PARAMETER_MODE).toString())){
  239 + if(params.get(proList.get(i).get(PARAMETER_NAME)) != null){
  240 + if(params.get(proList.get(i).get(PARAMETER_NAME)) instanceof List
  241 + || params.get(proList.get(i).get(PARAMETER_NAME)) instanceof Map){
  242 + searMap.put(proList.get(i).get(PARAMETER_NAME).toString(),
  243 + JSONUtil.toJsonStr(params.get(proList.get(i).get(PARAMETER_NAME)))
  244 + );
  245 + }else{
  246 + searMap.put(proList.get(i).get(PARAMETER_NAME).toString(),
  247 + params.get(proList.get(i).get(PARAMETER_NAME)));
  248 + }
  249 + }else{
  250 + searMap.put(proList.get(i).get(PARAMETER_NAME).toString(),null);
  251 + }
  252 +// if(proList.get(i).get(PARAMETER_NAME).toString().startsWith("s")){
  253 +// sb.append(",jdbcType=").append("LONGVARCHAR");
  254 +// }
  255 + }else{
  256 + if(proList.get(i).get(DATA_TYPE).equals(INT)){
  257 + sb.append(",jdbcType=").append("INTEGER");
  258 + searMap.put(proList.get(i).get(PARAMETER_NAME).toString(),0);
  259 + }else{
  260 + searMap.put(proList.get(i).get(PARAMETER_NAME).toString(),proList.get(i).get(PARAMETER_NAME).toString());
  261 + sb.append(",jdbcType=").append("LONGVARCHAR");
  262 + }
  263 + outSet.add(proList.get(i).get(PARAMETER_NAME).toString());
  264 + }
  265 + sb.append("}");
  266 + });
  267 + sb.append(")} ");
  268 + //获取过程返回
  269 + searMap.put("sSql",sb.toString());
  270 + searMap.put("outSet",outSet);
  271 + }catch (Exception e){
  272 + throw new BusinessException(ErrorCode.DB_ERROR,"解析获取过程查询sSql异常。异常原因:"+e.getMessage());
  273 + }
  274 + return searMap;
  275 + }
  276 +
  277 + /***
  278 + * @Author 钱豹
  279 + * @Date 9:25 2026/1/31
  280 + * @Param [proName]
  281 + * @return java.util.List<java.util.Map<java.lang.String,java.lang.Object>>
  282 + * @Description 动态获取过程参数
  283 + **/
  284 + public List<Map<String, Object>> getProcParam(String proName){
  285 + StringBuffer paramSql = new StringBuffer();
  286 + Map<String, Object> serMap=new HashMap<>(4);
  287 + paramSql.append(" SELECT PARAMETER_NAME,PARAMETER_MODE,DATA_TYPE,ORDINAL_POSITION from information_schema.PARAMETERS ");
  288 + paramSql.append(" WHERE SPECIFIC_NAME=#{sProName}");
  289 + paramSql.append(" AND SPECIFIC_SCHEMA= DATABASE() ORDER BY ORDINAL_POSITION ASC");
  290 + serMap.put("sSql", paramSql.toString());
  291 + serMap.put("sProName",proName);
  292 + List<Map<String,Object>> paramList = dynamicExeDbMapper.findSql(serMap);
  293 + if(paramList==null || paramList.size()<1){
  294 + throw new BusinessException(ErrorCode.DB_ERROR,"数据库中未查询到过程,过程名称:"+proName+",请检查!");
  295 + }
  296 + return paramList;
  297 + }
  298 +
  299 + private void doException(Exception e,Map<String, Object> searMap,String sProName){
  300 + String msg = StrUtil.EMPTY;
  301 + String exMsg = "";
  302 + try{
  303 + msg = getProExceptionMsg(searMap, sProName,JSONUtil.toJsonStr(searMap));
  304 + if(ObjectUtil.isNotEmpty( e.getMessage())){
  305 + String[] msA= e.getMessage().split(":");
  306 + exMsg = msA[msA.length-1];
  307 + }
  308 + }catch (Exception ex){
  309 +
  310 + }
  311 + //-10标识过程中返回错误异常,非正常提示异常
  312 + throw new BusinessException(ErrorCode.DB_ERROR,"调用过程执行语句报错,SQL:"+msg+" ; 失败原因:"+ exMsg);
  313 + }
  314 +
  315 +}
0 316 \ No newline at end of file
... ...
src/main/java/com/xly/service/DynamicNl2SqlService.java 0 → 100644
  1 +package com.xly.service;
  2 +
  3 +
  4 +import com.alibaba.fastjson2.JSON;
  5 +
  6 +import com.xly.agent.DynamicTableNl2SqlAiAgent;
  7 +import com.xly.entity.DynamicNl2SqlRequest;
  8 +import com.xly.entity.Nl2SqlResult;
  9 +import com.xly.util.SqlExecuteUtil;
  10 +import com.xly.util.SqlValidateUtil;
  11 +import jakarta.annotation.Resource;
  12 +import org.slf4j.Logger;
  13 +import org.slf4j.LoggerFactory;
  14 +import org.springframework.stereotype.Service;
  15 +
  16 +import java.util.List;
  17 +import java.util.Map;
  18 +
  19 +/**
  20 + * 动态表结构的NL2SQL业务服务层
  21 + * 适配任意表结构、多业务场景
  22 + */
  23 +@Service
  24 +public class DynamicNl2SqlService {
  25 + private static final Logger log = LoggerFactory.getLogger(DynamicNl2SqlService.class);
  26 +
  27 + @Resource
  28 + private DynamicTableNl2SqlAiAgent dynamicTableNl2SqlAiAgent;
  29 +
  30 + @Resource
  31 + private SqlExecuteUtil sqlExecuteUtil;
  32 +
  33 + /**
  34 + * 执行动态表结构的NL2SQL全流程
  35 + * @param request 动态表结构请求(含db名、表名、表结构、用户问题)
  36 + * @return 结构化NL2SQL结果
  37 + */
  38 + public Nl2SqlResult executeDynamicNl2Sql(DynamicNl2SqlRequest request) {
  39 + log.info("开始执行动态表结构NL2SQL流程,请求:{}", JSON.toJSONString(request));
  40 + // 1. 入参非空校验(重点校验表结构和用户问题)
  41 + if (request.getTableStruct() == null || request.getTableStruct().trim().isEmpty()
  42 + || request.getQuestion() == null || request.getQuestion().trim().isEmpty()) {
  43 + throw new IllegalArgumentException("表结构和用户查询问题不能为空");
  44 + }
  45 + // 补全默认值(如数据库名默认取配置中的值)
  46 +
  47 + String tableNames = request.getTableNames() == null ? "" : request.getTableNames().trim();
  48 + String rawSql = "select * from " + tableNames + " where 1=1 ";
  49 + // 2. 调用AI服务生成SQL(传入所有动态参数)
  50 +// String rawSql = dynamicTableNl2SqlAiAgent.generateMysqlSql(
  51 +// tableNames,
  52 +// request.getTableStruct().trim(),
  53 +// request.getQuestion().trim()
  54 +// );
  55 +// if (rawSql == null || rawSql.trim().isEmpty()) {
  56 +// throw new SqlGenerateException("AI服务生成SQL失败,返回结果为空");
  57 +// }
  58 +// log.info("AI服务生成原始SQL:{}", rawSql);
  59 +
  60 + // 3. 清理SQL多余符号 + 生产级强校验(核心安全保障,不可省略)
  61 + String cleanSql = SqlValidateUtil.cleanSqlSymbol(rawSql);
  62 + SqlValidateUtil.validateMysqlSql(cleanSql);
  63 + log.info("SQL清理并强校验通过,可执行SQL:{}", cleanSql);
  64 +
  65 + // 4. 执行SQL获取结构化结果
  66 + List<Map<String, Object>> sqlResult = sqlExecuteUtil.executeSelectSql(cleanSql);
  67 + log.info("MySQL SQL执行完成,返回结果条数:{}", sqlResult.size());
  68 + String resultExplain ="";
  69 + // 5. 调用AI服务生成自然语言解释(传入表结构,让解释更贴合业务)
  70 + String resultJson = JSON.toJSONString(sqlResult);
  71 +// String resultExplain = dynamicTableNl2SqlAiAgent.explainSqlResult(
  72 +// request.getQuestion().trim(),
  73 +// cleanSql,
  74 +// request.getTableStruct().trim(),
  75 +// resultJson
  76 +// );
  77 +
  78 + // 6. 封装结果返回
  79 + Nl2SqlResult nl2SqlResult = new Nl2SqlResult();
  80 + nl2SqlResult.setGenerateSql(cleanSql);
  81 + nl2SqlResult.setSqlResult(sqlResult);
  82 + nl2SqlResult.setResultExplain(resultExplain);
  83 +
  84 + log.info("动态表结构NL2SQL流程执行完成");
  85 + return nl2SqlResult;
  86 + }
  87 +}
0 88 \ No newline at end of file
... ...
src/main/java/com/xly/service/ToolMetaService.java 0 → 100644
  1 +package com.xly.service;
  2 +
  3 +import com.xly.entity.ToolMeta;
  4 +import com.xly.mapper.ToolMetaMapper;
  5 +import lombok.RequiredArgsConstructor;
  6 +import org.springframework.stereotype.Service;
  7 +
  8 +import java.util.List;
  9 +
  10 +/**
  11 + * 动态Tool Service实现
  12 + */
  13 +@Service("aiToolService")
  14 +@RequiredArgsConstructor
  15 +public class ToolMetaService {
  16 +
  17 + private final ToolMetaMapper toolMetaMapper;
  18 +
  19 + public List<ToolMeta> listAllEnable() {
  20 + return toolMetaMapper.findAll();
  21 + }
  22 +
  23 +}
0 24 \ No newline at end of file
... ...
src/main/java/com/xly/service/UserSceneSessionService.java 0 → 100644
  1 +package com.xly.service;
  2 +
  3 +import cn.hutool.core.util.ObjectUtil;
  4 +import com.xly.agent.ChatiAgent;
  5 +import com.xly.agent.DynamicTableNl2SqlAiAgent;
  6 +import com.xly.agent.ErpAiAgent;
  7 +import com.xly.config.OperableChatMemoryProvider;
  8 +import com.xly.entity.SceneDto;
  9 +import com.xly.entity.ToolMeta;
  10 +import com.xly.entity.UserSceneSession;
  11 +import com.xly.mapper.UserSceneSessionMapper;
  12 +import com.xly.runner.AppStartupRunner;
  13 +import dev.langchain4j.memory.chat.ChatMemoryProvider;
  14 +import org.springframework.beans.factory.annotation.Autowired;
  15 +import org.springframework.stereotype.Service;
  16 +
  17 +import java.util.*;
  18 +import java.util.stream.Collectors;
  19 +
  20 +@Service("userSceneSessionService")
  21 +public class UserSceneSessionService {
  22 +
  23 + // 新增核心缓存:用户场景会话状态(记录是否选场景、当前场景、权限内场景)
  24 + public static final Map<String, UserSceneSession> USER_SCENE_SESSION_CACHE = new HashMap<>();
  25 +
  26 + // 原有缓存:Agent实例、会话记忆
  27 + public static final Map<String, ErpAiAgent> ERP_AGENT_CACHE = new HashMap<>();
  28 + public static final Map<String, ChatiAgent> CHAT_AGENT_CACHE = new HashMap<>();
  29 + public static final Map<String, DynamicTableNl2SqlAiAgent> ERP_DynamicTableNl2SqlAiAgent_CACHE = new HashMap<>();
  30 +
  31 +
  32 +
  33 +
  34 +
  35 +
  36 +
  37 + @Autowired
  38 + private UserSceneSessionMapper modelMapper;
  39 +
  40 + public List<Map<String, Object>> getModelAllByUserId(String sUserId){
  41 + return modelMapper.findUserPermissions(sUserId);
  42 + }
  43 +
  44 + public List<ToolMeta> getModle(String sUserId, String sUserType){
  45 + List<Map<String, Object>> modleList = getModelAllByUserId(sUserId);
  46 + Set<String> dataSet = new HashSet<>();
  47 + if(ObjectUtil.isNotEmpty(modleList)){
  48 + modleList.forEach(one->{
  49 + String sKeys = one.get("sKey").toString();
  50 + String[] sIds = sKeys.split("-");
  51 + for(String sId:sIds){
  52 + if(ObjectUtil.isNotEmpty(sId)){
  53 + dataSet.add(sId);
  54 + }
  55 + }
  56 + });
  57 + }
  58 + List<String> data = new ArrayList<>(dataSet);
  59 + if("sysadmin".equals(sUserType)){
  60 + return AppStartupRunner.getAllTools();
  61 + }else {
  62 + return AppStartupRunner.getTools(data);
  63 + }
  64 + }
  65 + /***
  66 + * @Author 钱豹
  67 + * @Date 14:03 2026/2/10
  68 + * @Param []
  69 + * @return void
  70 + * @Description 清除所有记忆
  71 + **/
  72 + public void cleanAllSession(){
  73 + USER_SCENE_SESSION_CACHE.clear();
  74 + ERP_AGENT_CACHE.clear();
  75 + CHAT_AGENT_CACHE.clear();
  76 + ERP_DynamicTableNl2SqlAiAgent_CACHE.clear();
  77 + }
  78 + public UserSceneSession getUserSceneSession(String sUserId, String sUserType,String authorization){
  79 + if (USER_SCENE_SESSION_CACHE.containsKey(sUserId)) {
  80 + return USER_SCENE_SESSION_CACHE.get(sUserId);
  81 + }
  82 + UserSceneSession userSceneSession = new UserSceneSession();
  83 + List<ToolMeta> tools = getModle(sUserId,sUserType);
  84 + List<SceneDto> sceneDtos = getAiAgent(tools);
  85 + userSceneSession.setSceneSelected(false); // 初始状态:未选择场景
  86 + userSceneSession.setAuthorization(authorization);//存入用户token
  87 + userSceneSession.setUserId(sUserId);//存入用户ID
  88 + userSceneSession.setAuthTool(tools);//方法
  89 + userSceneSession.setAuthScenes(sceneDtos);//场景
  90 + // 3. 缓存会话
  91 + USER_SCENE_SESSION_CACHE.put(sUserId, userSceneSession);
  92 + return userSceneSession;
  93 + }
  94 +
  95 + public List<SceneDto> getAiAgent(List<ToolMeta> tools){
  96 + List<String> aiAgentIds = new ArrayList<>();
  97 + tools.forEach(tool->{
  98 + aiAgentIds.add(tool.getSSceneId());
  99 + });
  100 + List<SceneDto> agAll = AppStartupRunner.getAiAgentCache();
  101 + return agAll.stream().filter(one-> aiAgentIds.contains(one.getSId())).collect(Collectors.toList());
  102 + }
  103 +
  104 +}
... ...
src/main/java/com/xly/service/XlyErpService.java 0 → 100644
  1 +package com.xly.service;
  2 +
  3 +import cn.hutool.core.util.ObjectUtil;
  4 +import cn.hutool.core.util.StrUtil;
  5 +import com.alibaba.fastjson2.JSON;
  6 +import com.xly.agent.ChatiAgent;
  7 +import com.xly.agent.DynamicTableNl2SqlAiAgent;
  8 +import com.xly.agent.ErpAiAgent;
  9 +import com.xly.agent.SceneSelectorAiAgent;
  10 +import com.xly.config.OperableChatMemoryProvider;
  11 +import com.xly.constant.CommonConstant;
  12 +import com.xly.constant.ReturnTypeCode;
  13 +import com.xly.entity.*;
  14 +import com.xly.exception.sqlexception.SqlGenerateException;
  15 +import com.xly.mapper.ToolMetaMapper;
  16 +import com.xly.runner.AppStartupRunner;
  17 +import com.xly.tool.DynamicToolProvider;
  18 +import com.xly.util.InputPreprocessor;
  19 +import com.xly.util.SqlValidateUtil;
  20 +import com.xly.util.ValiDataUtil;
  21 +import dev.langchain4j.agent.tool.ToolExecutionRequest;
  22 +import dev.langchain4j.data.message.AiMessage;
  23 +import dev.langchain4j.model.chat.ChatLanguageModel;
  24 +import dev.langchain4j.model.ollama.OllamaChatModel;
  25 +import dev.langchain4j.service.AiServices;
  26 +import lombok.RequiredArgsConstructor;
  27 +import lombok.extern.slf4j.Slf4j;
  28 +import org.python.antlr.ast.Str;
  29 +import org.springframework.stereotype.Service;
  30 +
  31 +import java.math.BigDecimal;
  32 +import java.time.LocalDate;
  33 +import java.util.*;
  34 +
  35 +@Service
  36 +@RequiredArgsConstructor
  37 +@Slf4j
  38 +public class XlyErpService {
  39 + //中文对话模型
  40 + private final OllamaChatModel chatModel;
  41 + private final ChatLanguageModel chatiModel;
  42 + private final ChatLanguageModel sqlChatModel;
  43 + private final SceneSelectorAiAgent sceneSelectorAiAgent;
  44 + private final UserSceneSessionService userSceneSessionService;
  45 + private final DynamicToolProvider dynamicToolProvider;
  46 + private final OperableChatMemoryProvider operableChatMemoryProvider;
  47 + private final DynamicExeDbService dynamicExeDbService;
  48 + private final ToolMetaMapper toolMetaMapper;
  49 +
  50 +
  51 +
  52 + /***
  53 + * @Author 钱豹
  54 + * @Date 19:18 2026/1/27
  55 + * @Param [userInput, userId, sUserType]
  56 + * @return java.lang.String
  57 + * @Description 问答
  58 + **/
  59 + public AiResponseDTO erpUserInput(String userInput, String userId , String sUserType, String authorization) {
  60 + long startTime = System.currentTimeMillis();
  61 + try {
  62 + // 0. 预处理用户输入:去空格、转小写(方便匹配)
  63 + String input= InputPreprocessor.preprocessWithCommons(userInput);
  64 + // 1. 初始化用户场景会话(权限内场景)
  65 + UserSceneSession session = userSceneSessionService.getUserSceneSession(userId, sUserType,authorization);
  66 + session.setAuthorization(authorization);
  67 + session.setSFunPrompts(null);
  68 + // 2. 特殊指令:重置场景(无论是否已选,都可重置)
  69 + if (input.contains("重置") || input.contains("重新选择")) {
  70 + //清除记忆缓存
  71 + operableChatMemoryProvider.clearSpecifiedMemory(userId);
  72 + return AiResponseDTO.builder().aiText(resetUserScene(userId,session)).build();
  73 + }
  74 + //聊天只能体
  75 + if (session.getCurrentScene() != null
  76 + && Objects.equals(session.getCurrentScene().getSSceneNo(), "ChatZone"))
  77 + {
  78 + return getChatiAgent(input, session);
  79 + }
  80 +
  81 + // 3. 未选场景:先展示场景选择界面,处理用户序号选择
  82 + if (!session.isSceneSelected() && ValiDataUtil.me().isPureNumber(input)){
  83 + // 3.1 尝试处理场景选择(输入序号则匹配,否则展示选择提示)
  84 + return handleSceneSelect(userId, input, session);
  85 + }
  86 + // 4. 构建Agent,执行业务交互,如果返回为null,说明大模型没有判段出场景,必判断出后才能继续
  87 + ErpAiAgent aiAgent = createErpAiAgent(userId, input, session);
  88 + // 没有选择到场景,进闲聊模式
  89 + if (aiAgent == null){
  90 + return getChatiAgent (input, session);
  91 + }
  92 + String sResponMessage = aiAgent.chat(userId, input);
  93 +// 调用方法,参数缺失部分提示,就直接使用方法返回的
  94 + if(session.getCurrentTool() != null
  95 + && session.getSFunPrompts()!=null
  96 + ){ // 缺失的参数明细
  97 + sResponMessage = session.getSFunPrompts();
  98 + }
  99 +// if (session.getCurrentTool()== null){
  100 +// sResponMessage = StrUtil.EMPTY;
  101 +// }
  102 + //5.执行工具方法后,清除记忆
  103 + if(session.getBCleanMemory()){
  104 + operableChatMemoryProvider.clearSpecifiedMemory(userId);
  105 + session.setBCleanMemory(false);
  106 + }
  107 +// 6.找到方法并且本方法带表结构描述时,需要调用 自然语言转SQL智能体
  108 + if((ObjectUtil.isNotEmpty(session.getCurrentTool())
  109 + && ObjectUtil.isNotEmpty(session.getCurrentTool().getSInputTabelName())
  110 + && ObjectUtil.isNotEmpty(session.getCurrentTool().getSStructureMemo()))
  111 + ){
  112 + sResponMessage = getDynamicTableSql(session, input, userId, userInput);
  113 + }
  114 + //如果返回空的进入闲聊模式
  115 + if (ObjectUtil.isEmpty(sResponMessage)){
  116 + return getChatiAgent (input, session);
  117 + }
  118 + if (session.getCurrentScene()!= null ){
  119 + return AiResponseDTO.builder().aiText(sResponMessage)
  120 + .sSceneName(session.getCurrentScene().getSSceneName())
  121 + .sMethodName((ObjectUtil.isEmpty(session.getCurrentTool()))?StrUtil.EMPTY:session.getCurrentTool().getSMethodName())
  122 + .sReturnType(ReturnTypeCode.MAKEDOWN.getCode())
  123 + .build();
  124 + }else {
  125 + return AiResponseDTO.builder().aiText("当前场景:没有选择 退回当前场景 请输入 "+ CommonConstant.RESET + sResponMessage).sReturnType(ReturnTypeCode.HTML.getCode()).build();
  126 + }
  127 + } catch (Exception e) {
  128 + return AiResponseDTO.builder().aiText("系统异常:" + e.getMessage() + ",请稍后重试!").sReturnType(ReturnTypeCode.HTML.getCode()).build();
  129 + }
  130 + }
  131 +
  132 +
  133 + /***
  134 + * @Author 钱豹
  135 + * @Date 18:38 2026/2/5
  136 + * @Param [session, input, userId, userInput]
  137 + * @return java.lang.String
  138 + * @Description 获取执行动态SQL
  139 + **/
  140 + private String getDynamicTableSql(UserSceneSession session,String input,String userId,String userInput){
  141 + String resultExplain = StrUtil.EMPTY;
  142 + try{
  143 + // 1. 构建自然语言转SQLAgent,
  144 + DynamicTableNl2SqlAiAgent aiDynamicTableNl2SqlAiAgent = createDynamicTableNl2SqlAiAgent(userId, input, session);
  145 + String tableNames = session.getCurrentTool().getSInputTabelName();
  146 + // "订单表:viw_salsalesorder,客户信息表:elecustomer,结算方式表:sispayment,产品表(无单价,无金额,无数量):viw_product_sort,销售人员表:viw_sissalesman_depart";
  147 + String tableStruct = session.getCurrentTool().getSStructureMemo();
  148 + String rawSql = aiDynamicTableNl2SqlAiAgent.generateMysqlSql(userId,tableNames,tableStruct,userInput);
  149 + if (rawSql == null || rawSql.trim().isEmpty()) {
  150 + throw new SqlGenerateException("AI服务生成SQL失败,返回结果为空");
  151 + }
  152 + // 2. 清理SQL多余符号 + 生产级强校验(核心安全保障,不可省略)
  153 + String cleanSql = SqlValidateUtil.cleanSqlSymbol(rawSql);
  154 + SqlValidateUtil.validateMysqlSql(cleanSql);
  155 + log.info("SQL清理并强校验通过,可执行SQL:{}", cleanSql);
  156 + // 4. 执行SQL获取结构化结果
  157 +// Map<String,Object> params = new HashMap<>();
  158 + List<Map<String, Object>> sqlResult = dynamicExeDbService.findSql(new HashMap<>(),cleanSql);
  159 + // 5. 调用AI服务生成自然语言解释(传入表结构,让解释更贴合业务)
  160 + String resultJson = JSON.toJSONString(sqlResult);
  161 + resultExplain = aiDynamicTableNl2SqlAiAgent.explainSqlResult(
  162 + userId,
  163 + userInput,
  164 + cleanSql,
  165 + tableStruct,
  166 + resultJson
  167 + );
  168 + }catch (Exception e){
  169 + session.setCurrentTool(null);
  170 + resultExplain = "动态SQL执行错误,请提供更具体的问题或指令";
  171 + }
  172 + log.info("动态表结构NL2SQL流程执行完成");
  173 + return resultExplain;
  174 + }
  175 +
  176 +
  177 +
  178 +
  179 + /***
  180 + * @Author 钱豹
  181 + * @Date 11:22 2026/1/31
  182 + * @Param
  183 + * @return
  184 + * @Description 动态参数补齐处理
  185 + **/
  186 + private String dotoolExecutionRequests(AiMessage aiMessage){
  187 + String textTs = aiMessage.text();
  188 + if(aiMessage.hasToolExecutionRequests()){
  189 + List<ToolExecutionRequest> toolExecutionRequests = aiMessage.toolExecutionRequests();
  190 + toolExecutionRequests.forEach(toolRequests->{
  191 + String arguments = toolRequests.arguments();
  192 + log.info(arguments);
  193 + });
  194 + }
  195 + return textTs;
  196 + }
  197 +
  198 + /***
  199 + * 存入全部场景
  200 + * @Author 钱豹
  201 + * @Date 19:06 2026/1/26
  202 + * @Param [sUserId, sUserType]
  203 + * @return java.lang.String
  204 + * 页面刷新/首次进入时调用:初始化用户场景会话,直接返回场景选择引导词
  205 + * 前端页面加载完成后,无需用户输入,直接调用该方法即可显示引导词
  206 + * @param sUserId 用户ID(前端传入,如user-001) sUserType 角色状态
  207 + * @return 场景选择引导词(即原buildSceneSelectHint生成的文案)
  208 + */
  209 + public AiResponseDTO initSceneGuide(String systemText,String sUserId,String sUserType,String authorization) {
  210 + try {
  211 + UserSceneSession userSceneSession = userSceneSessionService.getUserSceneSession( sUserId, sUserType,authorization);
  212 + systemText = userSceneSession.buildSceneSelectHint();
  213 + } catch (Exception e) {
  214 + systemText = "<p style='color:red;'>抱歉,你暂无任何业务场景的访问权限,请联系管理员开通!</p>";
  215 + }
  216 + return AiResponseDTO.builder().aiText(StrUtil.EMPTY).systemText(systemText) .build();
  217 + }
  218 +
  219 +
  220 +
  221 +
  222 + // ====================== 动态构建Agent(支持选定场景/未选场景) ======================
  223 + private DynamicTableNl2SqlAiAgent createDynamicTableNl2SqlAiAgent(String userId, String userInput, UserSceneSession session) {
  224 +// 4. 获取/创建用DynamicTableNl2SqlAiAgent
  225 + DynamicTableNl2SqlAiAgent aiAgent = UserSceneSessionService.ERP_DynamicTableNl2SqlAiAgent_CACHE.get(userId);
  226 + if(ObjectUtil.isEmpty(aiAgent)){
  227 + aiAgent = AiServices.builder(DynamicTableNl2SqlAiAgent.class)
  228 + .chatLanguageModel(sqlChatModel)
  229 + .chatMemoryProvider(operableChatMemoryProvider)
  230 + .toolProvider(dynamicToolProvider)
  231 + .build();
  232 + UserSceneSessionService.ERP_DynamicTableNl2SqlAiAgent_CACHE.put(userId, aiAgent);
  233 + }
  234 + return aiAgent;
  235 + }
  236 +
  237 + // ====================== 动态构建Agent(支持选定场景/未选场景) ======================
  238 + private ErpAiAgent createErpAiAgent(String userId, String userInput, UserSceneSession session) {
  239 +
  240 + // 1. 已选场景:强制绑定该场景工具
  241 + if (session.isSceneSelected() && session.getCurrentScene() != null) {
  242 + dynamicToolProvider.sSceneIdMap.put(userId,session.getCurrentScene().getSId());
  243 + } else {
  244 + // 2. 未选场景:大模型根据输入返加相应的场景
  245 + SceneDto sceneDto = parseSceneByLlm(userId, userInput, session);
  246 + if (sceneDto != null) {
  247 + session.setCurrentScene(sceneDto);
  248 + session.setSceneSelected(true);
  249 + UserSceneSessionService.USER_SCENE_SESSION_CACHE.put(userId, session);
  250 + dynamicToolProvider.sSceneIdMap.put(userId,session.getCurrentScene().getSId());
  251 + }else {return null;}
  252 + }
  253 + // 4. 获取/创建用Agent
  254 + ErpAiAgent aiAgent = UserSceneSessionService.ERP_AGENT_CACHE.get(userId);
  255 + if(ObjectUtil.isEmpty(aiAgent)){
  256 + aiAgent = AiServices.builder(ErpAiAgent.class)
  257 + .chatLanguageModel(chatModel)
  258 + .chatMemoryProvider(operableChatMemoryProvider)
  259 + .toolProvider(dynamicToolProvider)
  260 + .build();
  261 + UserSceneSessionService.ERP_AGENT_CACHE.put(userId, aiAgent);
  262 + log.info("用户{}Agent构建完成,已选场景:{},场景ID{}",
  263 + userId, session.isSceneSelected() ? session.getCurrentScene().getSSceneName() : "未选(全场景匹配)", dynamicToolProvider.sSceneIdMap.get(userId));
  264 + }
  265 + return aiAgent;
  266 + }
  267 +
  268 +
  269 + /**
  270 + * 大模型意图解析核心方法(获取场景)
  271 + * @param userId 用户ID
  272 + * @param userInput 用户输入
  273 + * @param session 用户会话
  274 + * @return 匹配的BusinessScene,null表示解析失败
  275 + */
  276 + private SceneDto parseSceneByLlm(String userId, String userInput, UserSceneSession session) {
  277 + try {
  278 + List<ToolMeta> metasAll = session.getAuthTool();
  279 + // toolMetaMapper.findAll();
  280 + // 1. 构建大模型意图解析请求
  281 + String authScenesDesc =session.buildAuthScenesForLlm(metasAll);
  282 + // 2. 调用大模型解析意图,LangChain4j自动将大模型输出映射为SceneIntentParseResp
  283 +// {{authScenesDesc}}
  284 + SceneIntentParseResp parseResp = sceneSelectorAiAgent.parseSceneIntent(userInput,authScenesDesc);
  285 +// authScenesDesc
  286 + // 3. 解析结果处理
  287 + if (parseResp == null || parseResp.getSceneCode() == null || "NO_MATCH".equals(parseResp.getSceneCode())) {
  288 + log.warn("用户{}大模型未匹配到任何场景,输入:{}", userId, userInput);
  289 + return null;
  290 + }
  291 + // 4. 将场景编码转换为BusinessScene枚举
  292 + String sSceneNo = parseResp.getSceneCode();
  293 + return AppStartupRunner.getAiAgentByCode(sSceneNo);
  294 + } catch (Exception e) {
  295 + log.error("用户{}大模型意图解析失败,输入:{}", userId, userInput, e);
  296 + return null;
  297 + }
  298 + }
  299 +
  300 + /***
  301 + * @Author 钱豹
  302 + * @Date 19:28 2026/1/26
  303 + * @Param [userId, session]
  304 + * @return java.lang.String
  305 + * @Description 重置用户场景选择:恢复为未选状态,清空当前场景,重新展示选择界面
  306 + **/
  307 + private String resetUserScene(String userId, UserSceneSession session) {
  308 + session.setSceneSelected(false);
  309 + session.setBCleanMemory(false);
  310 + session.setCurrentTool(null);
  311 + session.setCurrentScene(null);
  312 + UserSceneSessionService.USER_SCENE_SESSION_CACHE.put(userId, session);
  313 + // 清空Agent缓存
  314 + UserSceneSessionService.ERP_AGENT_CACHE.remove(userId);
  315 + UserSceneSessionService.CHAT_AGENT_CACHE.remove(userId);
  316 + return "场景选择已重置!请重新选择业务场景:\n" + session.buildSceneSelectHint();
  317 + }
  318 +
  319 + /**
  320 + * 处理用户场景选择:输入序号→匹配场景→更新会话状态
  321 + */
  322 + private AiResponseDTO handleSceneSelect(String userId, String userInput, UserSceneSession session) {
  323 + // 1. 尝试根据序号匹配场景
  324 + boolean selectSuccess = session.selectSceneByInput(userInput);
  325 + if (selectSuccess) {
  326 + // 2. 选择成功:更新缓存,返回成功提示
  327 + UserSceneSessionService.USER_SCENE_SESSION_CACHE.put(userId, session);
  328 + // 清空该用户原有Agent缓存(重新构建绑定新场景的Agent)
  329 + UserSceneSessionService.ERP_AGENT_CACHE.remove(userId);
  330 + //清除记忆缓存
  331 + operableChatMemoryProvider.clearSpecifiedMemory(userId);
  332 + String aiText = "智能体选择成功! 现在可以问她相关问题(如" + String.join("、", session.getCurrentScene().getSSceneContext()) + ")";
  333 + return AiResponseDTO.builder().aiText(aiText).sSceneName(session.getCurrentScene().getSSceneName()).build();
  334 + } else {
  335 + // 3. 选择失败:重新展示场景选择提示
  336 + return AiResponseDTO.builder().aiText(session.buildSceneSelectHint()).build();
  337 + }
  338 + }
  339 +
  340 + /***
  341 + * @Author 钱豹
  342 + * @Date 13:32 2026/2/6
  343 + * @Param [input, session]
  344 + * @return java.lang.String
  345 + * @Description 获取智普通智能体
  346 + **/
  347 + private AiResponseDTO getChatiAgent (String input,UserSceneSession session){
  348 + ChatiAgent chatiAgent = UserSceneSessionService.CHAT_AGENT_CACHE.get(session.getUserId());
  349 + if(ObjectUtil.isEmpty(chatiAgent)){
  350 + chatiAgent = AiServices.builder(ChatiAgent.class)
  351 + .chatLanguageModel(chatiModel)
  352 + .chatMemoryProvider(operableChatMemoryProvider)
  353 + .build();
  354 + UserSceneSessionService.CHAT_AGENT_CACHE.put(session.getUserId(), chatiAgent); }
  355 + String sChatMessage = chatiAgent.chat(session.getUserId(), input);
  356 + String systemText = " 你已进入「随便聊聊」专栏, 可"+CommonConstant.RESET;
  357 + return AiResponseDTO.builder().aiText(sChatMessage).sSceneName("随便聊聊").systemText(systemText).sReturnType(ReturnTypeCode.HTML.getCode()).build();
  358 + }
  359 +
  360 +}
0 361 \ No newline at end of file
... ...
src/main/java/com/xly/tool/DynamicToolProvider.java 0 → 100644
  1 +package com.xly.tool;
  2 +
  3 +
  4 +import cn.hutool.core.util.ObjectUtil;
  5 +import cn.hutool.core.util.StrUtil;
  6 +import cn.hutool.json.JSONUtil;
  7 +import com.alibaba.fastjson.JSONObject;
  8 +import com.fasterxml.jackson.core.type.TypeReference;
  9 +
  10 +import com.fasterxml.jackson.databind.ObjectMapper;
  11 +
  12 +import com.xly.config.OperableChatMemoryProvider;
  13 +import com.xly.constant.ErrorCode;
  14 +import com.xly.constant.ProcedureConstant;
  15 +import com.xly.constant.RuleCode;
  16 +import com.xly.constant.UrlErpConstant;
  17 +import com.xly.entity.*;
  18 +import com.xly.exception.dto.BusinessException;
  19 +import com.xly.mapper.ParamRuleMapper;
  20 +import com.xly.mapper.ToolMetaMapper;
  21 +import com.xly.service.DynamicExeDbService;
  22 +import com.xly.service.UserSceneSessionService;
  23 +import com.xly.util.DeepCopyUtils;
  24 +import com.xly.util.HttpsRequestUtil;
  25 +import com.xly.util.JsonUtils;
  26 +import com.xly.util.OkHttpUtil;
  27 +import dev.langchain4j.agent.tool.*;
  28 +
  29 +import dev.langchain4j.data.message.ChatMessage;
  30 +import dev.langchain4j.data.message.ChatMessageType;
  31 +import dev.langchain4j.data.message.ToolExecutionResultMessage;
  32 +
  33 +import dev.langchain4j.data.message.UserMessage;
  34 +import dev.langchain4j.memory.ChatMemory;
  35 +
  36 +import dev.langchain4j.service.tool.ToolExecutor;
  37 +import dev.langchain4j.service.tool.ToolProvider;
  38 +
  39 +import dev.langchain4j.service.tool.ToolProviderRequest;
  40 +
  41 +import dev.langchain4j.service.tool.ToolProviderResult;
  42 +
  43 +
  44 +import lombok.Getter;
  45 +import lombok.RequiredArgsConstructor;
  46 +
  47 +import lombok.Setter;
  48 +import lombok.extern.slf4j.Slf4j;
  49 +
  50 +import org.apache.ibatis.javassist.expr.NewArray;
  51 +import org.springframework.beans.factory.annotation.Value;
  52 +import org.springframework.stereotype.Service;
  53 +
  54 +import java.util.*;
  55 +
  56 +import java.util.concurrent.ConcurrentHashMap;
  57 +import java.util.stream.Collectors;
  58 +import java.util.stream.IntStream;
  59 +
  60 +@Slf4j
  61 +@Service
  62 +@RequiredArgsConstructor
  63 +public class DynamicToolProvider implements ToolProvider {
  64 +
  65 +// private final ToolMetaMapper toolMetaMapper;
  66 + private final ObjectMapper objectMapper;
  67 + private final ToolMetaMapper toolMetaMapper;
  68 + private final ParamRuleMapper paramRuleMapper;
  69 + private final DynamicExeDbService dynamicExeDbService;
  70 + private final OperableChatMemoryProvider operableChatMemoryProvider;
  71 +
  72 +
  73 + // 内存缓存:toolName -> ToolSpecificationHolder
  74 + private final Map<String, ToolSpecificationHolder> toolCache = new ConcurrentHashMap<>();
  75 + public final Map<String, String> sSceneIdMap = new ConcurrentHashMap<>();
  76 +
  77 + private final Map<String, List<ToolSpecificationHolder>> sceneToolCacheMap = new ConcurrentHashMap<>();
  78 + private final List<ParamRule> paramRuleDataAll = new ArrayList<>();
  79 +
  80 + @Value("${erp.baseurl}")
  81 + private String baseUrl;
  82 +
  83 + /***
  84 + * @Author 钱豹
  85 + * @Date 14:05 2026/2/10
  86 + * @Param []
  87 + * @return void
  88 + * @Description 移除所有动态方法缓存
  89 + **/
  90 + public void cleanAllToolProvider() {
  91 + toolCache.clear();
  92 + sceneToolCacheMap.clear();
  93 + paramRuleDataAll.clear();
  94 + }
  95 +
  96 + /**
  97 + * 初始化时加载所有启用的工具到缓存
  98 + */
  99 + @jakarta.annotation.PostConstruct
  100 + public void init() {
  101 + //
  102 + List<ToolMeta> metas = toolMetaMapper.findAll();
  103 + for (ToolMeta meta : metas) {
  104 + try {
  105 + //补全业务类型查询类显示的字段
  106 + doSetToolAIshowfieldShow(meta);
  107 + ToolSpecification spec = buildToolSpecification(meta);
  108 + ToolExecutor executor = createToolExecutor(meta);
  109 + toolCache.put(meta.getSMethodNo(), new ToolSpecificationHolder(spec, executor));
  110 + log.info("已加载动态工具:{}", meta.getSMethodNo());
  111 + String sceneId = meta.getSSceneId();
  112 + List<ToolSpecificationHolder> dataList = new ArrayList<>();
  113 + if(ObjectUtil.isNotEmpty(sceneToolCacheMap.get(sceneId))) {
  114 + dataList = sceneToolCacheMap.get(sceneId);
  115 + }
  116 + dataList.add(new ToolSpecificationHolder(spec, executor));
  117 + sceneToolCacheMap.put(sceneId, dataList);
  118 + } catch (Exception e) {
  119 + e.printStackTrace();
  120 + log.error("构建工具失败,sMethodNo={}", meta.getSMethodNo(), e);
  121 + }
  122 + }
  123 + }
  124 +
  125 + //补全业务类型查询类显示的字段
  126 + //{"1":"存储过程","2":"SQL查询","3":"第三方API","4":"窗体查询","5":"按钮执行","6":"其它"}
  127 + private void doSetToolAIshowfieldShow(ToolMeta meta){
  128 + String sToolId = meta.getSId();
  129 + List<ParamRule> paramRuleData = getParamRuleDataAll();
  130 + List<ParamRule> paramRuleListAll = paramRuleData.stream().filter(one-> sToolId.equals(one.getSParentId())).collect(Collectors.toUnmodifiableList());
  131 + List<ParamRule> paramRuleList = paramRuleListAll.stream().filter(one-> one.getBTipModel()).collect(Collectors.toUnmodifiableList());
  132 + List<ParamRule> paramRuleListCheck = paramRuleListAll.stream().filter(one->one.getBEmpty()).collect(Collectors.toUnmodifiableList());
  133 + paramRuleList = ObjectUtil.isNotEmpty(paramRuleList)?paramRuleList:new ArrayList<>();
  134 + if((meta.getIBizType()==4 || meta.getIBizType()==5)
  135 + && ObjectUtil.isNotEmpty(meta.getSAIshowfield())
  136 + && ObjectUtil.isNotEmpty(meta.getSSrcFormId())
  137 + ){
  138 + String[] sAIshowfield = meta.getSAIshowfield().split(",");
  139 + List<String> mutableList = Arrays.asList(sAIshowfield);
  140 + List<String> sAIshowfieldArry = new ArrayList<>(mutableList);
  141 + sAIshowfieldArry.add("sSlaveId");
  142 + String sSrcFormId = meta.getSSrcFormId();
  143 + //获取对应的窗体配置
  144 + StringBuffer sSql =new StringBuffer().append("SELECT A.sChinese AS label,A.sName,A.sControlName,B.sId AS sFormcustomId,A.sName AS sId FROM gdsconfigformslave AS A ")
  145 + .append("JOIN gdsconfigformmaster AS B ON A.sParentId = B.sId ")
  146 + .append("WHERE B.sParentId = #{sSrcFormId} AND A.sName <> '' AND INSTR(A.sControlName,'Btn')=0 AND (A.bVisible = 1 OR A.sName =#{sName}) ");
  147 + Map<String,Object> searMap = new HashMap<>();
  148 + searMap.put("sSrcFormId",sSrcFormId);
  149 + searMap.put("sName","sSlaveId");
  150 + List<Map<String,Object>> sAIshowfieldShowAll = dynamicExeDbService.findSql(searMap,sSql.toString());
  151 + if(ObjectUtil.isNotEmpty(sAIshowfieldShowAll)){
  152 + sAIshowfieldShowAll = sAIshowfieldShowAll.stream().filter(m-> sAIshowfieldArry.contains(m.get("sName").toString())).collect(Collectors.toUnmodifiableList());
  153 + meta.setSAIshowfieldShow(sAIshowfieldShowAll);
  154 + }
  155 + List<ParamRule> paramRuleListNew = new ArrayList<>(paramRuleList);
  156 + for(int i=0;i<sAIshowfieldShowAll.size();i++){
  157 + Map<String,Object> sAIshowfieldShow = sAIshowfieldShowAll.get(i);
  158 + List<ParamRule> one = paramRuleList.stream().filter(m->m.getSParamValue().equals(sAIshowfieldShow.get("sName"))).collect(Collectors.toUnmodifiableList());
  159 + if(ObjectUtil.isEmpty(one)){
  160 + ParamRule pr = new ParamRule();
  161 + pr.setIOrder(i+1);
  162 + pr.setSParamValue(sAIshowfieldShowAll.get(i).get("sName").toString());
  163 + pr.setSParam(sAIshowfieldShowAll.get(i).get("label").toString());
  164 + pr.setBEmpty(false);
  165 + pr.setSType("array");
  166 + if("sSlaveId".equals(sAIshowfieldShowAll.get(i).get("sName"))){
  167 + pr.setSParam("sSlaveId");
  168 + }
  169 + pr.setBTipModel(true);
  170 + paramRuleListNew.add(pr);
  171 + }
  172 + }
  173 + String sendUrl = baseUrl + UrlErpConstant.getBusinessDataByFormcustomId;
  174 + sendUrl = StrUtil.format(sendUrl,sAIshowfieldShowAll.get(0).get("sFormcustomId"),sSrcFormId);
  175 + meta.setSendUrl(sendUrl);
  176 + paramRuleList = paramRuleListNew;
  177 + paramRuleListCheck = paramRuleListNew;
  178 + paramRuleListAll = paramRuleListNew;
  179 + }
  180 + List<ParamRule> paramRuleListNew = new ArrayList<>(paramRuleList);
  181 + meta.setParamRuleList(paramRuleListNew);
  182 + meta.setParamRuleListCheck(paramRuleListCheck);
  183 + meta.setParamRuleListAll(paramRuleListAll);
  184 + }
  185 +
  186 + @Override
  187 + public ToolProviderResult provideTools(ToolProviderRequest request) {
  188 +// List<ToolSpecification> specs = new ArrayList<>();
  189 + String sUserId = request.chatMemoryId().toString();
  190 + Map<ToolSpecification, ToolExecutor> executors = new HashMap<>();
  191 + // sceneToolCacheMap.get(sSceneIdMap.get(sUserId));
  192 + //获取Session
  193 + UserSceneSession session = UserSceneSessionService.USER_SCENE_SESSION_CACHE.get(sUserId);
  194 + //过滤对应的权限方法
  195 + List<ToolSpecificationHolder> datalist = new ArrayList<>();
  196 + List<ToolMeta> toolMetaAll = session.getAuthTool();
  197 + if(ObjectUtil.isNotEmpty(toolMetaAll)){
  198 + toolMetaAll = toolMetaAll.stream().filter(to-> to.getSSceneId().equals(sSceneIdMap.get(sUserId))).collect(Collectors.toUnmodifiableList());
  199 + if(ObjectUtil.isNotEmpty(toolMetaAll)){
  200 + toolMetaAll.forEach(to->{
  201 + datalist.add(toolCache.get(to.getSMethodNo()));
  202 + });
  203 + }
  204 + }
  205 + datalist.forEach(holder->{
  206 +// specs.add(holder.getToolSpecification());
  207 + executors.put(holder.getToolSpecification(),holder.getToolExecutor());
  208 +// executors.put(holder.getToolSpecification(), holder.getToolExecutor());
  209 + });
  210 + return ToolProviderResult.builder().addAll(executors).build();
  211 + }
  212 +
  213 + /***
  214 + * @Author 钱豹
  215 + * @Date 15:07 2026/1/30
  216 + * @Param [meta]
  217 + * @return dev.langchain4j.agent.tool.ToolSpecification
  218 + * @Description 参数注入 方法引导语注入(初始化调用)
  219 + **/
  220 + private ToolSpecification buildToolSpecification(ToolMeta meta) {
  221 + ToolSpecification.Builder builder = ToolSpecification.builder()
  222 + .name(meta.getSMethodNo());
  223 +// .description(meta.getStoolDesc());
  224 + StringBuffer stoolDesc = new StringBuffer();
  225 + StringBuffer sbt = new StringBuffer();
  226 + StringBuffer xt = new StringBuffer();
  227 + StringBuffer sl = new StringBuffer();
  228 +
  229 + if(ObjectUtil.isNotEmpty(meta.getStoolDesc())){
  230 + stoolDesc.append("MethodNo:").append(meta.getSMethodNo()).append(",核心工作内容:【").append(meta.getSMethodName()).append("】").append(meta.getStoolDesc());
  231 + }
  232 + if("SinglePageQuote".equals(meta.getSMethodNo())){
  233 + log.info(meta.getSParamRules());
  234 + }
  235 + try {
  236 + List<ParamRule> paramRuleData = meta.getParamRuleList();
  237 +// 1.必填参数:客户名称(字符串),产品名称(字符串),数量(数字);
  238 +// 2.选填参数:产品描述(字符串),生产要求(字符串);
  239 +// **强制输出标准JSON对象**:
  240 +// 示例:{\"客户名称\":\"小羚羊软件开发有限公司\",\"产品名称\":\"企业宣传册\",\"数量\":1000,\"产品描述\":\"黑色注意色差\",\"生产要求\":\"上光,覆膜\"}
  241 + Map<String,Object> slMap = new HashMap<>();
  242 + for (ParamRule paramRule : paramRuleData) {
  243 + String paramName = ObjectUtil.isEmpty(paramRule.getSParamValue())?null:paramRule.getSParamValue();
  244 + String paramDesc = ObjectUtil.isEmpty(paramRule.getSParam())?null:paramRule.getSParam();
  245 + String paramType = paramRule.getSType();
  246 + Boolean bEmpty = paramRule.getBEmpty();
  247 + String sExampleValue = paramRule.getSExampleValue();
  248 + if(ObjectUtil.isNotEmpty(sExampleValue)){
  249 + //英文
  250 +// slMap.put(paramName,sExampleValue);
  251 + //中文
  252 + slMap.put(paramDesc,sExampleValue);
  253 + }
  254 + if (paramName == null || paramName.trim().isEmpty()) {
  255 + continue;
  256 + }
  257 + // 构建参数属性
  258 + //{"string":"字符","integer":"数字","double":"浮点","boolean":"布尔型","array":"数组","enum":"枚举"}
  259 + List<JsonSchemaProperty> properties = new ArrayList<>();
  260 + // 添加类型属性
  261 +// String ,仅允许【{}】多选一,严格匹配)
  262 + String sRuleCost = getConstMeg(paramRule.getSParamConfig(),paramRule);
  263 +// 2. 付款方式:字符串类型,互斥枚举值[90天、60天、现结],默认值[现结]
  264 +// 5. 生产要求:数组类型,可多选枚举值[上光、复膜、烫金],无默认值
  265 + switch (paramType.toLowerCase()) {
  266 + case "string":
  267 + if(bEmpty){
  268 + sbt.append(paramDesc).append("(字符串").append(sRuleCost).append(")").append("、");
  269 + }else{
  270 + xt.append(paramDesc).append("(字符串").append(sRuleCost).append(")").append("、");
  271 + }
  272 + properties.add(JsonSchemaProperty.STRING);
  273 + break;
  274 + case "integer":
  275 + case "int":
  276 + if(bEmpty){
  277 + sbt.append(paramDesc).append("(数字").append(sRuleCost).append(")").append("、");
  278 + }else{
  279 + xt.append(paramDesc).append("(数字").append(sRuleCost).append(")").append("、");
  280 + }
  281 + properties.add(JsonSchemaProperty.INTEGER);
  282 + break;
  283 + case "number":
  284 + case "double":
  285 + case "float":
  286 + properties.add(JsonSchemaProperty.NUMBER);
  287 + if(bEmpty){
  288 + sbt.append(paramDesc).append("(浮点").append(sRuleCost).append(")").append("、");
  289 + }else{
  290 + xt.append(paramDesc).append("(浮点").append(sRuleCost).append(")").append("、");
  291 + }
  292 + break;
  293 + case "boolean":
  294 + case "bool":
  295 + if(bEmpty){
  296 + sbt.append(paramDesc).append("(布尔型").append(sRuleCost).append(")").append("、");
  297 + }else{
  298 + xt.append(paramDesc).append("(布尔型").append(sRuleCost).append(")").append("、");
  299 + }
  300 + properties.add(JsonSchemaProperty.BOOLEAN);
  301 + break;
  302 + case "array":
  303 + String sRuleArray = getArrrayBySql(paramRule);
  304 + // 生产要求:数组类型,可多选枚举值[上光、复膜、烫金],无默认值
  305 + if (ObjectUtil.isNotEmpty(sRuleArray)) {
  306 + properties.add(JsonSchemaProperty.enums(sRuleArray.split("/")));
  307 + }
  308 + if(bEmpty){
  309 + //动态SQL 或者写死默认值的 动态SQL只存在一条数据 直接给默认值
  310 + sbt.append(paramDesc).append("(").append("数组类型") .append(",可多选枚举值 [").append(sRuleArray).append("]");
  311 + if(ObjectUtil.isNotEmpty(paramRule.getSDefaultValue()) || (ObjectUtil.isNotEmpty(sRuleArray) && sRuleArray.split("/").length==1)){
  312 + String sDefaultVal = (ObjectUtil.isNotEmpty(sRuleArray) && sRuleArray.split("/").length==1)?sRuleArray:paramRule.getSDefaultValue();
  313 + sbt.append(",默认值[").append(sDefaultVal).append("]");
  314 + }else{
  315 + sbt.append(",无默认值");
  316 + }
  317 + sbt.append(")、");
  318 + }else{
  319 + xt.append(paramDesc).append("(").append("数组类型") .append(",可多选枚举值 [").append(sRuleArray).append("]");
  320 + if(ObjectUtil.isNotEmpty(paramRule.getSDefaultValue())){
  321 + xt.append(",默认值[").append(paramRule.getSDefaultValue()).append("]");
  322 + }else{
  323 + xt.append(",无默认值");
  324 + }
  325 + xt.append(")、");
  326 + }
  327 + properties.add(JsonSchemaProperty.ARRAY);
  328 + // 默认字符串数组
  329 + properties.add(JsonSchemaProperty.items(JsonSchemaProperty.STRING));
  330 + break;
  331 + case "enum":
  332 + // 处理枚举值
  333 + if (ObjectUtil.isNotEmpty(sRuleCost)) {
  334 + properties.add(JsonSchemaProperty.enums(sRuleCost.split("/")));
  335 + }else{
  336 + sRuleCost = getArrrayBySql(paramRule);
  337 + }
  338 + // eg: 付款方式(字符串,互斥枚举值[90天、60天、现结],默认值[现结])
  339 + if(bEmpty){
  340 + sbt.append(paramDesc).append("(").append("字符串") .append(",互斥枚举值 [").append(sRuleCost).append("]");
  341 + if(ObjectUtil.isNotEmpty(paramRule.getSDefaultValue())){
  342 + sbt.append(",默认值[").append(paramRule.getSDefaultValue()).append("]");
  343 + }else{
  344 + sbt.append(",无默认值");
  345 + }
  346 + sbt.append(")、");
  347 + }else{
  348 + xt.append(paramDesc).append("(").append("字符串") .append(",互斥枚举值 [").append(sRuleCost).append("]");
  349 + if(ObjectUtil.isNotEmpty(paramRule.getSDefaultValue())){
  350 + xt.append(",默认值[").append(paramRule.getSDefaultValue()).append("]");
  351 + }else{
  352 + xt.append(",无默认值");
  353 + }
  354 + xt.append(")、");
  355 + }
  356 + properties.add(JsonSchemaProperty.ARRAY);
  357 + // 默认字符串数组
  358 + properties.add(JsonSchemaProperty.items(JsonSchemaProperty.STRING));
  359 + break;
  360 + default:
  361 + properties.add(JsonSchemaProperty.STRING);
  362 + break;
  363 + }
  364 + // 添加描述
  365 + if (!paramDesc.isEmpty()) {
  366 + properties.add(JsonSchemaProperty.description(paramDesc));
  367 + }
  368 + // 检查是否必填
  369 + boolean required = bEmpty;
  370 + // 添加参数
  371 + if (required) {
  372 + builder.addParameter(paramName, properties);
  373 + } else {
  374 + builder.addOptionalParameter(paramName, properties);
  375 + }
  376 + }
  377 +
  378 + if(ObjectUtil.isNotEmpty(sbt)){
  379 + stoolDesc
  380 + .append(System.lineSeparator())
  381 + .append("1.必填参数:")
  382 + .append(sbt);
  383 + }
  384 + if(ObjectUtil.isNotEmpty(xt)){
  385 + stoolDesc
  386 + .append(System.lineSeparator())
  387 + .append("2.选填参数:")
  388 + .append(xt);
  389 + }
  390 + if(ObjectUtil.isNotEmpty(slMap)){
  391 + stoolDesc
  392 + .append(System.lineSeparator())
  393 + .append(sl)
  394 + .append("**强制输出标准JSON对象**:").append(System.lineSeparator())
  395 + .append("示例:").append(JSONUtil.toJsonStr(slMap));
  396 + }
  397 + log.info("方法描述========================{}",stoolDesc);
  398 + } catch (Exception e) {
  399 + e.printStackTrace();
  400 + log.error("Failed to parse parameter rules: {}", meta.getSMethodName(), e);
  401 + // 参数解析失败时,创建无参数的工具规格
  402 + }
  403 + builder.description(stoolDesc.toString());
  404 + return builder.build();
  405 + }
  406 +
  407 + /***
  408 + * @Author 钱豹
  409 + * @Date 23:42 2026/2/3
  410 + * @Param [sConstConfig, sRule]
  411 + * @return java.lang.String
  412 + * @Description 常量类型枚举
  413 + **/
  414 + private String getConstMeg(String sConstConfig,ParamRule paramRule){
  415 + if(!RuleCode.CONST.getCode().equals(paramRule.getSRule())){
  416 + return StrUtil.EMPTY;
  417 + }
  418 + if(StrUtil.isNotEmpty(sConstConfig)){
  419 + Map<String,Object> constMap = JSONUtil.parseObj(sConstConfig);
  420 + StringBuffer sb = new StringBuffer();
  421 + constMap.forEach((k,v)->{
  422 + sb.append(v).append("/");
  423 + });
  424 + sb.delete(sb.length()-1,sb.length());
  425 + paramRule.setSRuleTs(sb.toString());
  426 + return sb.toString();
  427 + }
  428 +
  429 + return StrUtil.EMPTY;
  430 + }
  431 +
  432 + //数组类型枚举
  433 + private String getArrrayBySql(ParamRule paramRule){
  434 + Boolean bCheckArray = !(RuleCode.SQL.getCode().equals(paramRule.getSRule()));
  435 +// {"string":"字符","integer":"数字","double":"浮点","boolean":"布尔型","array":"数组","enum":"枚举"}
  436 + Boolean bCheckArray2= !("array".equals(paramRule.getSType()) || "enum".equals(paramRule.getSType()));
  437 + Boolean bCheckArray3= ObjectUtil.isEmpty(paramRule.getSParamConfig());
  438 + if(bCheckArray || bCheckArray2 || bCheckArray3){
  439 + return StrUtil.EMPTY;
  440 + }
  441 + List<Map<String,Object>> data = dynamicExeDbService.findSql(new HashMap<>(),paramRule.getSParamConfig());
  442 + StringBuffer sb = new StringBuffer();
  443 + if(ObjectUtil.isEmpty(data)){
  444 + return StrUtil.EMPTY;
  445 + }
  446 + data.forEach(one->{
  447 + if(ObjectUtil.isNotEmpty(one.get(paramRule.getSParamValue()))){
  448 + sb.append(one.get(paramRule.getSParamValue())).append("/");
  449 + }
  450 + });
  451 + if(ObjectUtil.isNotEmpty(sb)){
  452 + sb.delete(sb.length()-1,sb.length());
  453 + paramRule.setSRuleTs(sb.toString());
  454 + }
  455 + return sb.toString();
  456 + }
  457 +
  458 +
  459 + /***
  460 + * @Author 钱豹
  461 + * @Date 15:08 2026/1/30
  462 + * @Param []
  463 + * @return java.util.List<com.xly.entity.ParamRule>
  464 + * @Description 获取所有参数
  465 + **/
  466 + private List<ParamRule> getParamRuleDataAll(){
  467 + if(paramRuleDataAll==null || paramRuleDataAll.size()==0){
  468 + paramRuleDataAll.addAll(paramRuleMapper.findAll());
  469 + }
  470 + return paramRuleDataAll;
  471 + }
  472 +
  473 +
  474 + /***
  475 + * @Author 钱豹
  476 + * @Date 15:09 2026/1/30
  477 + * @Param [meta]
  478 + * @return dev.langchain4j.service.tool.ToolExecutor
  479 + * @Description 创建 ToolExecutor,内部包含参数自动补全与校验逻辑(创建执行器)
  480 + **/
  481 + private ToolExecutor createToolExecutor(ToolMeta meta) {
  482 + return (toolExecutionRequest, memoryId) -> {
  483 + UserSceneSession session = UserSceneSessionService.USER_SCENE_SESSION_CACHE.get(memoryId.toString());
  484 + session.setCurrentTool(meta); // 标记一下找到了相应方法
  485 + session.setSFunPrompts(null);
  486 + // 检查条件 - 如果条件满足,直接返回成功结果,不再执行后续逻辑
  487 + if (ObjectUtil.isNotEmpty(meta.getSInputTabelName())
  488 + && ObjectUtil.isNotEmpty(meta.getSStructureMemo())) {
  489 + // 直接返回成功结果,阻止后续执行
  490 + return createEarlySuccessResult(toolExecutionRequest, "执行成功,终止后续执行");
  491 + }
  492 +
  493 + // 1. 解析模型传入的参数
  494 + Map<String, Object> args;
  495 + try {
  496 + args = objectMapper.readValue(toolExecutionRequest.arguments(), new TypeReference<>() {});
  497 + } catch (Exception e) {
  498 + String errorMsg = "参数 JSON 解析失败,请检查参数格式是否正确。"
  499 + + "错误详情:" + e.getMessage();
  500 + log.warn("参数解析失败,tool={}, args={}", meta.getSMethodNo(), toolExecutionRequest.arguments(), e);
  501 + return String.valueOf(errorResult(toolExecutionRequest, errorMsg));
  502 + }
  503 + Map<String, Object> argsOld = DeepCopyUtils.deepCopy(args);
  504 +
  505 + List<ParamRule> paramRuleData = meta.getParamRuleListAll();
  506 + // 2. 【自动补全】应用参数的默认值
  507 + args = applyDefaultValues(args, paramRuleData);
  508 +
  509 + // 2.1 【补全动态参数】动态参数补全
  510 + try{
  511 + args = applyValues(args, meta.getParamRuleListCheck());
  512 + }catch (Exception e){
  513 + log.error("返回信息",e);
  514 + String sTsMsg = e.getMessage();
  515 + session.setSFunPrompts(sTsMsg);
  516 + //存在多个数据返回大模型,需要继续盘问选择出唯一结果
  517 + return String.valueOf(askUserResult(toolExecutionRequest, sTsMsg));
  518 + }
  519 +
  520 + // 3. 【自动校验】检查必填项
  521 + List<String> missing = checkRequiredParams(args, paramRuleData);
  522 + if (!missing.isEmpty()) {
  523 + // 4.1 参数缺失,生成“提问”消息,直接返给客户
  524 + String askMsg = buildAskUserMessage(meta, missing);
  525 + session.setSFunPrompts(askMsg);
  526 + return String.valueOf(askUserResult(toolExecutionRequest, askMsg));
  527 + }
  528 +
  529 + // 6. 【最终确认信息】所有检测通过后,需要和客户确认交互
  530 + List<ChatMessage> chatMessage = operableChatMemoryProvider.getCurrentChatMessages(memoryId.toString());
  531 + ChatMessage userMessage = getLasterUserMssage(chatMessage);
  532 + String input = StrUtil.replace(userMessage.text(),"用户输入:",StrUtil.EMPTY);
  533 +
  534 +// {"0":"查询","1":"执行"} 查询不需要确认
  535 + Boolean isConfirmed = isConfirmed(input) || input.contains("生成") || input.contains("确认");
  536 + if((isConfirmed || 0== meta.getIActionType()) && 5!= meta.getIBizType()){
  537 + // 确认后必填项校验
  538 + List<String> missingAfter = checkConfirmAfterParam(args, paramRuleData);
  539 + if (!missingAfter.isEmpty()) {
  540 + // 4.1 参数缺失,生成“提问”消息,直接返给客户
  541 + String askMsg = buildAskUserMessage(meta, missingAfter);
  542 + session.setSFunPrompts(askMsg);
  543 + return String.valueOf(askUserResult(toolExecutionRequest, askMsg));
  544 + }
  545 + // 7. 【业务校验】执行业务层面的逻辑校验 + 所有校验通过,执行核心业务逻辑
  546 + return executeTool(toolExecutionRequest, meta, args, paramRuleData, memoryId.toString(), session);
  547 + }
  548 + String askconfirmMsg =StrUtil.EMPTY;
  549 + if(0== meta.getIActionType() && 4!= meta.getIBizType() && 5!= meta.getIBizType()){
  550 + askconfirmMsg = buildConfirmUserMessage(meta, args);
  551 + }else if(4== meta.getIBizType() || meta.getIBizType()==5){
  552 + askconfirmMsg = doGetFromData( meta,args,session);
  553 + session.setSFunPrompts(askconfirmMsg);
  554 + operableChatMemoryProvider.get(memoryId).add(UserMessage.from("SYSTEM: 等待用户确认或选择部分数据操作"));
  555 + return String.valueOf(successResult(toolExecutionRequest,askconfirmMsg));
  556 + }else{
  557 + askconfirmMsg =getDefMessage(argsOld,meta.getSControlName());
  558 + }
  559 + // 返回需要确认的结果
  560 + return executeWithConfirmation(toolExecutionRequest,askconfirmMsg,operableChatMemoryProvider.get(memoryId), session, meta).text();
  561 + };
  562 + }
  563 + /***
  564 + * @Author 钱豹
  565 + * @Date 15:16 2026/2/9
  566 + * @Param [argMap]
  567 + * @return java.lang.String
  568 + * @Description MAP转提示
  569 + **/
  570 + private String getDefMessage(Map<String,Object> argMap,String sName){
  571 + StringBuilder markdown = new StringBuilder().append("\n");
  572 + markdown.append("\n---\n");
  573 + // 遍历 Map 生成表格行
  574 + argMap.forEach((key, value) -> {
  575 + String valueStr = value != null ? value.toString() : "";
  576 + markdown.append("- ")
  577 + .append(key)
  578 + .append(": `")
  579 + .append(valueStr)
  580 + .append("`\n");
  581 + });
  582 + markdown.append("\n---\n");
  583 + appendConfirm(markdown,sName);
  584 + return markdown.toString();
  585 + }
  586 + /***
  587 + * @Author 钱豹
  588 + * @Date 14:56 2026/2/9
  589 + * @Param [markdown]
  590 + * @return void
  591 + * @Description 全部确认
  592 + **/
  593 + private void appendConfirmAll(StringBuilder markdown,String sName){
  594 + sName = ObjectUtil.isEmpty(sName)?StrUtil.EMPTY:"["+sName+"]";
  595 + markdown.append("请确认是否执行").append(sName).append("操作?如果全部,直接回复"全部确认",如果部分,选择后回复"部分确认"\n");
  596 + //全部确认 ,部分确认,取消
  597 + markdown.append("回复:&emsp;&emsp;").append("**<a href=\"#\" data-action=\"reset\" data-text=\"全部确认\">全部确认</a>**").append("&emsp;")
  598 + .append("**<a href=\"#\" data-action=\"reset\" data-text=\"确认\">部分确认</a>**").append("&emsp;")
  599 + .append("**<a href=\"#\" data-action=\"reset\" data-text=\"取消\">取消</a>**");
  600 + }
  601 +
  602 + /***
  603 + * @Author 钱豹
  604 + * @Date 14:56 2026/2/9
  605 + * @Param [markdown]
  606 + * @return void
  607 + * @Description 单条确认
  608 + **/
  609 + private void appendConfirm(StringBuilder markdown,String sName){
  610 + sName = ObjectUtil.isEmpty(sName)?StrUtil.EMPTY:"["+sName+"]";
  611 + markdown.append("请确认是否执行").append(sName).append("操作?请直接回复"确认"或"取消"\n");
  612 + //全部确认 ,部分确认,取消
  613 + markdown.append("回复:&emsp;&emsp;").append("**<a href=\"#\" data-action=\"reset\" data-text=\"确认\">确认</a>**").append("&emsp;")
  614 + .append("**<a href=\"#\" data-action=\"reset\" data-text=\"取消\">取消</a>**");
  615 + }
  616 +
  617 + private ChatMessage getLasterUserMssage(List<ChatMessage> chatMessage){
  618 + if(chatMessage!=null){
  619 + for(int i=chatMessage.size()-1;i>0;i--){
  620 + ChatMessage data = chatMessage.get(i);
  621 + ChatMessageType sType = data.type();
  622 + if(ChatMessageType.USER.equals(sType)){
  623 + return chatMessage.get(i);
  624 + }
  625 + }
  626 + }
  627 + return null;
  628 + }
  629 +
  630 + /***
  631 + * @Author 钱豹
  632 + * @Date 13:35 2026/1/31
  633 + * @Param [args, paramDefs]
  634 + * @return java.util.Map<java.lang.String,java.lang.Object>
  635 + * @Description Map 值转换
  636 + **/
  637 + private Map<String, Object> transformationArgs(Map<String, Object> args, List<ParamRule> paramDefs) {
  638 + Map<String, Object> result = new HashMap<>(args);
  639 + paramDefs.forEach(pd->{
  640 + String name = pd.getSParam();
  641 + String sValue = pd.getSParamValue();
  642 + //中文
  643 + Boolean bCheck = result.containsKey(name) && ObjectUtil.isNotEmpty(result.get(name));
  644 + //英文字段
  645 + Boolean bCheck2 = result.containsKey(sValue) && ObjectUtil.isNotEmpty(result.get(sValue));
  646 + if (!bCheck2 && bCheck ) {
  647 + result.put(sValue,args.get(name));
  648 + }
  649 + //常量value -> key 转换 用于后面入库
  650 + if(RuleCode.CONST.getCode().equals(pd.getSRule()) && ObjectUtil.isNotEmpty(result.get(sValue))){
  651 + String sData = result.get(sValue).toString();
  652 + Map<String,Object> configData = JSONUtil.parseObj(pd.getSParamConfig());
  653 + configData.forEach((k,v)->{
  654 + if(sData.equals(v)){
  655 + result.put(sValue,k);
  656 + }
  657 + });
  658 + }
  659 + });
  660 + return result;
  661 + }
  662 +
  663 + /**
  664 + * 动态参数补全SQL
  665 + */
  666 + private Map<String, Object> applyValues(Map<String, Object> args, List<ParamRule> paramDefsCheck) {
  667 + Map<String, Object> result = new HashMap<>(args);
  668 + result = transformationArgs( result, paramDefsCheck);
  669 + //根据iOrder 排序
  670 + List<ParamRule> paramDefs = new ArrayList<>(paramDefsCheck);
  671 + paramDefs.sort(Comparator.comparing(ParamRule::getIOrder));
  672 + for (ParamRule pd : paramDefs) {
  673 + String name = pd.getSParam();
  674 + String sValue = pd.getSParamValue();
  675 + String sRule = pd.getSRule();
  676 + String sType = pd.getSType();
  677 +
  678 + Boolean bCheck = result.containsKey(name);
  679 + bCheck = bCheck && ObjectUtil.isNotEmpty(result.get(name));
  680 + bCheck = bCheck && RuleCode.SQL.getCode().equals(sRule);
  681 + bCheck = bCheck && ObjectUtil.isNotEmpty(pd.getSParamConfig());
  682 +// bCheck = bCheck && !"array".equals(sType);
  683 +
  684 + Boolean bCheck2 = result.containsKey(sValue);
  685 + bCheck2 = bCheck2 && ObjectUtil.isNotEmpty(result.get(sValue));
  686 + bCheck2 = bCheck2 && RuleCode.SQL.getCode().equals(sRule);
  687 + bCheck2 = bCheck2 && ObjectUtil.isNotEmpty(pd.getSParamConfig());
  688 +// bCheck2 = bCheck2 && !"array".equals(sType);
  689 + //存在动态SQL 并且是枚举的需要
  690 + if ((bCheck || bCheck2) &&("enum".equals(sType) || "string".equals(sType))){
  691 + String sSql = pd.getSParamConfig();
  692 + String sKey = bCheck?name:sValue;
  693 + String nameValue = result.get(sKey).toString();
  694 + List<Map<String,Object>> dataList = dynamicExeDbService.findSql(result,sSql);
  695 + //传入的参数无效返回继续盘问消息
  696 + if(ObjectUtil.isEmpty(dataList)){
  697 + throw new BusinessException(ErrorCode.PARAM_REQUIRED,String.format("%s 您描述的%s 不存在,请重新告诉我",name,nameValue));
  698 + }
  699 +
  700 + //如果SQL没有条件 多个数据集中进行匹配 如果只匹配一个也算成功
  701 + if(ObjectUtil.isNotEmpty(args.get(name)) || ObjectUtil.isNotEmpty(args.get(sValue))){
  702 + if(("enum".equals(sType) ||"string".equals(sType)) && (args.get(name) instanceof List || args.get(sValue) instanceof List)){
  703 + //枚举返回了数组 纠正成字符串
  704 + if(args.get(name) instanceof List){
  705 + args.put(name,((List<?>) args.get(name)).get(0));
  706 +// args.put(sValue,((List<?>) args.get(name)).get(0));
  707 + }
  708 + if(args.get(sValue) instanceof List){
  709 + args.put(sValue,((List<?>) args.get(sValue)).get(0));
  710 +// args.put(name,((List<?>) args.get(sValue)).get(0));
  711 + }
  712 + }
  713 + List<Map<String,Object>> dataListNew = dataList.stream().filter(one-> one.get(sValue).equals(args.get(name)) || one.get(sValue).equals(args.get(sValue))).collect(Collectors.toUnmodifiableList());
  714 + if(ObjectUtil.isNotEmpty(dataListNew)){
  715 + dataList = new ArrayList<>();
  716 + dataList.add(dataListNew.get(0));
  717 + }
  718 + }
  719 + if(ObjectUtil.isNotEmpty(dataList) && dataList.size()==1){
  720 + String sCopyTo = pd.getSCopyTo();
  721 + if(ObjectUtil.isEmpty(sCopyTo)){
  722 + result.put(sValue, dataList.get(0).get(sValue));
  723 + result.put(name, dataList.get(0).get(sValue));
  724 + }else{
  725 + //赋值到
  726 + String[] sCopyToA = sCopyTo.split(",");
  727 + for(String sCopyToOne:sCopyToA){
  728 + String[] sCopyToOneA = sCopyToOne.split(":");
  729 + result.put(sCopyToOneA[0], dataList.get(0).get(sCopyToOneA[1]));
  730 + }
  731 + }
  732 + }
  733 + StringBuffer sData=new StringBuffer();
  734 + //存在多个形成提示语
  735 + if(dataList.size()>1){
  736 + List<Map<String, Object>> finalDataList = dataList;
  737 + IntStream.range(0, dataList.size())
  738 + .forEach(iRowNum ->{
  739 + sData.append((iRowNum + 1)).append(".").append(finalDataList.get(iRowNum).get(pd.getSParamValue()))
  740 + .append("\n");
  741 +
  742 + });
  743 + String sParamMissMemo = StrUtil.EMPTY;
  744 + if(ObjectUtil.isEmpty(pd.getSParamMissMemo())){
  745 + sParamMissMemo = pd.getSParam()+"存在多个,请选择:"+sData;
  746 + }else{
  747 + sParamMissMemo = StrUtil.format(pd.getSParamMissMemo(),sData.toString());
  748 + }
  749 + throw new BusinessException(ErrorCode.PARAM_REQUIRED,sParamMissMemo);
  750 + }
  751 + }
  752 + }
  753 + return result;
  754 + }
  755 +
  756 + /**
  757 + * 应用参数的默认值
  758 + */
  759 + private Map<String, Object> applyDefaultValues(Map<String, Object> args, List<ParamRule> paramDefs) {
  760 + Map<String, Object> result = new HashMap<>(args);
  761 + for (ParamRule pd : paramDefs) {
  762 + String name = pd.getSParam();
  763 + if ((!result.containsKey(name) || ObjectUtil.isEmpty(result.get(name)))
  764 + && ObjectUtil.isNotEmpty(pd.getSDefaultValue())
  765 + ) {
  766 + Object defaultValue = pd.getSDefaultValue();
  767 + result.put(name, defaultValue);
  768 + }
  769 + }
  770 + return result;
  771 + }
  772 +
  773 + /**
  774 + * 检查必填参数
  775 + */
  776 + private List<String> checkRequiredParams(Map<String, Object> args, List<ParamRule> paramDefs) {
  777 + Map<String,Object> returnMap = transformationArgs( args, paramDefs);
  778 + return paramDefs.stream()
  779 + .filter(pd -> Boolean.TRUE.equals(pd.getBEmpty()) && pd.getBTipModel())
  780 + .filter(pd ->
  781 + (!returnMap.containsKey(pd.getSParam()) || (ObjectUtil.isEmpty(returnMap.get(pd.getSParam()))))
  782 + && (!returnMap.containsKey(pd.getSParamValue()) || (ObjectUtil.isEmpty(returnMap.get(pd.getSParamValue()))))
  783 + )
  784 + .map(ParamRule::getSParam)
  785 + .toList();
  786 + }
  787 +
  788 + /**
  789 + * 确认后必填参数
  790 + */
  791 + private List<String> checkConfirmAfterParam(Map<String, Object> args, List<ParamRule> paramDefs) {
  792 + Map<String,Object> returnMap = transformationArgs( args, paramDefs);
  793 + return paramDefs.stream()
  794 + .filter(pd -> Boolean.TRUE.equals(pd.getBConfirmAfter()) && pd.getBTipModel())
  795 + .filter(pd ->
  796 + (!returnMap.containsKey(pd.getSParam()) || (ObjectUtil.isEmpty(returnMap.get(pd.getSParam()))))
  797 + && (!returnMap.containsKey(pd.getSParamValue()) || (ObjectUtil.isEmpty(returnMap.get(pd.getSParamValue()))))
  798 + )
  799 + .map(ParamRule::getSParam)
  800 + .toList();
  801 + }
  802 +
  803 + /**
  804 + * 模拟执行工具
  805 + */
  806 + public String executeTool(ToolExecutionRequest toolExecutionRequest,ToolMeta meta, Map<String, Object> args, List<ParamRule> paramRuleData,String userId,UserSceneSession session ) {
  807 + log.info("执行工具:{},参数:{}", meta.getSMethodNo(), args);
  808 + // 2.2 将中文key转换成英文key
  809 + args = transformationArgs( args, paramRuleData);
  810 + String sReturn ="成功";
  811 + try{
  812 + sReturn = executeToolAfter(meta, args,toolExecutionRequest,paramRuleData,session);
  813 + }catch (Exception e) {
  814 + }
  815 + if(meta.getIActionType()==1){
  816 + session.setBCleanMemory(true);
  817 + }
  818 + return sReturn;
  819 +
  820 + }
  821 + /****
  822 + * @Author 钱豹
  823 + * @Date 10:26 2026/2/1
  824 + * @Param
  825 + * @return
  826 + * @Description 返回结果后 执行业务类
  827 + **/
  828 + private String executeToolAfter(ToolMeta meta, Map<String, Object> args,ToolExecutionRequest toolExecutionRequest,List<ParamRule> paramDefs,UserSceneSession session) {
  829 +// {"1":"存储过程","2":"SQL查询","3":"第三方API","4":"窗体查询","5":"按钮执行","6":"其它"}
  830 + String sBizContent = meta.getSBizContent();
  831 + Integer iBizType = meta.getIBizType();
  832 + args.put("sUserId",session.getUserId());
  833 + if(iBizType==1 || iBizType==4){
  834 + Map<String,Object> data = new HashMap<>(args);
  835 + data.put("sData", JSONObject.toJSONString(data));
  836 + Map<String, Object> searMap = this.dynamicExeDbService.getDoProMap(sBizContent, data);
  837 + Map<String,Object> sReturn = this.dynamicExeDbService.getCallPro(searMap,sBizContent);
  838 + Integer sCode = ObjectUtil.isNotEmpty(sReturn.get(ProcedureConstant.SCODE))? Integer.valueOf(sReturn.get(ProcedureConstant.SCODE).toString()):0;
  839 + String sMsgText = ObjectUtil.isNotEmpty(sReturn.get(ProcedureConstant.SRETURN))? sReturn.get(ProcedureConstant.SRETURN).toString():"操作成功";
  840 + if(sCode< 0){
  841 + String msg = ObjectUtil.isEmpty(sMsgText) ?"调用过程sCode:"+Integer.valueOf(searMap.get(ProcedureConstant.SCODE).toString()):sMsgText;
  842 + return String.valueOf(askUserResult(toolExecutionRequest, msg));
  843 + }
  844 + return String.valueOf(successResult(toolExecutionRequest, sMsgText));
  845 + }else if(iBizType==2 && ObjectUtil.isNotEmpty(sBizContent)){
  846 + //SQL查询
  847 + if(sBizContent.toLowerCase().startsWith("update")){
  848 + this.dynamicExeDbService.updateSql(args,sBizContent);
  849 + }else if(sBizContent.toLowerCase().startsWith("delete")){
  850 + this.dynamicExeDbService.delSql(args,sBizContent);
  851 + }else if(sBizContent.toLowerCase().startsWith("insert")){
  852 + this.dynamicExeDbService.addSql(args,sBizContent);
  853 + }else{
  854 + List<Map<String,Object>> retData = this.dynamicExeDbService.findSql(args,sBizContent);
  855 + if(ObjectUtil.isNotEmpty(retData)){
  856 + StringBuffer sb = new StringBuffer();
  857 + retData.forEach(one->{
  858 + one.forEach((k,v)->{
  859 + sb.append(v).append(" ");
  860 + });
  861 + sb.append("<br/>");
  862 + });
  863 + if(ObjectUtil.isNotEmpty(retData)){
  864 + sb.append("请根据这些信息安排今天的工作吧!如果有具体任务需要进一步处理,请告诉我");
  865 + }
  866 + session.setSFunPrompts(sb.toString());
  867 + if("queryTodayTask".equals(meta.getSMethodNo())){
  868 + session.setBCleanMemory(true);
  869 + }
  870 + return String.valueOf(successResult(toolExecutionRequest, sb.toString()));
  871 + }else{
  872 + return String.valueOf(askUserResult(toolExecutionRequest, "未找到对应的数据"));
  873 + }
  874 + }
  875 + }else if(iBizType==3){
  876 + return HttpsRequestUtil.me().doRequestHttp(sBizContent,JSONUtil.toJsonStr(args),
  877 + new HashMap<>(),"POST","JSON");
  878 + }
  879 + return String.valueOf(successResult(toolExecutionRequest, "操作成功"));
  880 + }
  881 +
  882 +
  883 + /***
  884 + * @Author 钱豹
  885 + * @Date 23:38 2026/2/5
  886 + * @Param []
  887 + * @return void
  888 + * @Description 窗体获取数据方法 未清或者明细
  889 + **/
  890 + private String doGetFromData(ToolMeta meta, Map<String, Object> args,UserSceneSession session){
  891 + String sUrl = meta.getSendUrl();
  892 + Map<String,Object> sBody = new HashMap<>();
  893 + sBody.put("pageNum",1);
  894 + sBody.put("pageSize",5);
  895 + log.info("doGetFromData========================");
  896 + List<Map<String,Object>> list = new ArrayList<>();
  897 + if(ObjectUtil.isNotEmpty(args)){
  898 + List<ParamRule> paramDefs = meta.getParamRuleList();
  899 + args.forEach((k,v)->{
  900 + if(ObjectUtil.isNotEmpty(v)){
  901 + List<ParamRule> pdList = paramDefs.stream().filter(m-> m.getSParam().equals(k) || m.getSParamValue().equals(k)).collect(Collectors.toUnmodifiableList());
  902 + List<Object> data = new ArrayList<>();
  903 + if(v instanceof List){
  904 + data.addAll((List<String>) v);
  905 + }else{
  906 + data.add(v);
  907 + }
  908 + data = data.stream().filter(m-> !(m.toString().contains("全部") || m.toString().contains("所有"))).collect(Collectors.toUnmodifiableList());
  909 + if(ObjectUtil.isNotEmpty(data)){
  910 + StringBuffer bFilterValue = new StringBuffer();
  911 + for(int i=0;i<data.size();i++){
  912 + if(i!=0){
  913 + bFilterValue.append(".");
  914 + }
  915 + bFilterValue.append(data.get(i));
  916 + }
  917 + if(pdList!=null){
  918 + Map<String,Object> serOne = new HashMap<>(4);
  919 + serOne.put("bFilterCondition","like");
  920 + serOne.put("bFilterValue",bFilterValue.toString());
  921 + serOne.put("bFilterName",pdList.get(0).getSParamValue());
  922 + list.add(serOne);
  923 + }
  924 + }
  925 + }
  926 + });
  927 + }
  928 + log.info("开始请请求========================{}", sUrl);
  929 + sBody.put("bFilter",list);
  930 + Map<String, Object> headers = new HashMap<>();
  931 + headers.put("Authorization",session.getAuthorization());
  932 + String result;
  933 + try{
  934 + // 1. 获取实例
  935 + result = HttpsRequestUtil.me().doRequestHttp(sUrl,JSONObject.toJSONString(sBody),headers,"POST","JSON");
  936 + log.info("请求URL========================{}", sUrl);
  937 + log.info("请求URLresult========================{}", result);
  938 + log.info("JSON==========================={}", JSONObject.toJSONString(sBody));
  939 + log.info("headers=============================={}", JSONObject.toJSONString(headers));
  940 + log.info("请求URL,JSON,headers=={},{},{}",sUrl,JSONObject.toJSONString(sBody),JSONObject.toJSONString(headers));
  941 + ErpResult erpResult = JsonUtils.toObject(result,ErpResult.class);
  942 + result = buildResultMessageWithTable( meta, erpResult);
  943 + }catch (Exception e){
  944 + result ="执行异常:"+e.getMessage();
  945 + }
  946 + return result;
  947 + }
  948 +
  949 + /**
  950 + * 构建 窗体获取数据方法 未清或者明细
  951 + */
  952 + public String buildResultMessageWithTable(ToolMeta meta,ErpResult erpResult){
  953 +
  954 + ErpDataset dataset = erpResult.getDataset();
  955 + if(dataset==null){
  956 + return ErrorCode.DATA_NOT_FOUND.getMessage();
  957 + }
  958 + List<Map<String, Object>> sAIshowfieldShow = meta.getSAIshowfieldShow();
  959 + List<Map<String, Object>> rows = new ArrayList<>();
  960 + if(ObjectUtil.isNotEmpty(dataset.getRows().get(0))){
  961 + rows = dataset.getRows().get(0).getDataSet();
  962 + }
  963 + List<Map<String, Object>> recordData = findFieldNameByChinese(sAIshowfieldShow, rows);
  964 + int recordCount = dataset != null ? dataset.getTotalCount() : 0;
  965 + StringBuilder markdown = new StringBuilder();
  966 + //状态
  967 + String sStatus = erpResult.getCode()<0?ErrorCode.ERRORMSG.getMessage():ErrorCode.SUCCESSMSG.getMessage();
  968 + markdown.append(sStatus).append("\n");
  969 + if(erpResult.getCode()<0){
  970 + String sMsg = ObjectUtil.isEmpty(erpResult.getMsg())?ErrorCode.WFHYY.getMessage():erpResult.getMsg();
  971 + markdown.append("**原因**: ").append(sMsg);
  972 + return markdown.toString();
  973 + }else{
  974 + markdown.append(" 共 ").append(recordCount).append(" 条记录");
  975 + if(rows.size()<recordCount){
  976 + markdown.append(",显示前").append(rows.size()).append("条。如需查看全部,请指定筛选条件。");
  977 + }
  978 + }
  979 +// markdown.append("\n---\n");
  980 + markdown.append("\n\n").append("| 序号 | ");
  981 + // 动态生成表头
  982 + Set<String> headers = new LinkedHashSet<>();
  983 + for (Map<String, Object> record : sAIshowfieldShow) {
  984 + String chineseName = (String) record.get("label");
  985 + if (chineseName != null && !"sSlaveId".equals(record.get("sName"))) {
  986 + headers.add(chineseName);
  987 + }
  988 + }
  989 + headers.forEach(header -> markdown.append(header).append(" | "));
  990 + markdown.append("\n|").append("---|".repeat(headers.size() + 1)).append("\n");
  991 + // 填充表格数据
  992 + for (int i = 0; i < recordData.size(); i++) {
  993 + // 保存隐藏列的值(如"唯一"字段)
  994 + String uniqueValue = recordData.get(i).get("sSlaveId") != null ? recordData.get(i).get("sSlaveId").toString() : "";
  995 + markdown.append("| ").append(i + 1).append(" | ");
  996 + for (String header : headers) {
  997 + // 这里需要根据你的数据结构来获取对应的值
  998 + Object value = recordData.get(i)!= null ? recordData.get(i).get(header) : null;
  999 + markdown.append(value != null ? value : "—").append(" | ");
  1000 + }
  1001 + // 在行末添加隐藏数据的特殊标记(AI可以解析)
  1002 + markdown.append(" <!-- HIDDEN_DATA:{\"sSlaveId\":\"").append(uniqueValue).append("\"} -->");
  1003 + markdown.append("\n");
  1004 + }
  1005 + markdown.append(">");
  1006 + if(meta.getIBizType()==4){
  1007 + markdown.append("\n---\n");
  1008 + appendConfirmAll(markdown,meta.getSControlName());
  1009 + }
  1010 + return markdown.toString();
  1011 + }
  1012 +
  1013 + // 辅助方法:根据中文名查找字段名(通过映射关系转换)
  1014 + private List<Map<String, Object>> findFieldNameByChinese(List<Map<String, Object>> sAIshowfieldShow,List<Map<String, Object>> rows){
  1015 + //获取映射关系
  1016 + Map<String,String> keyMappings = new HashMap<>();
  1017 + List<String> selectedKeys = new ArrayList<>();
  1018 + sAIshowfieldShow.forEach(one->{
  1019 + keyMappings.put(one.get("sName").toString(),one.get("label").toString());
  1020 + if("sSlaveId".equals(one.get("sName"))){
  1021 + keyMappings.put(one.get("sName").toString(),one.get("sName").toString());
  1022 + }
  1023 + selectedKeys.add(one.get("sName").toString());
  1024 + });
  1025 + List<Map<String, Object>> sRowData = getFilteredDataStream(rows,rows.size(),selectedKeys,keyMappings);
  1026 + return sRowData;
  1027 + }
  1028 +
  1029 + /***
  1030 + * @Author 钱豹
  1031 + * @Date 2:04 2026/2/6
  1032 + * @Param [rows, limit, selectedKeys]
  1033 + * @return java.util.List<java.util.Map<java.lang.String,java.lang.Object>>
  1034 + * @Description 返回指定数量并筛选指定key
  1035 + **/
  1036 + public List<Map<String, Object>> getFilteredDataStream(List<Map<String, Object>> rows,
  1037 + int limit,
  1038 + List<String> selectedKeys,
  1039 + Map<String,String> keyMappings) {
  1040 + if (rows == null || rows.isEmpty()) {
  1041 + return Collections.emptyList();
  1042 + }
  1043 + return rows.stream()
  1044 + .limit(limit) // 限制数量
  1045 + .map(original -> {
  1046 + // 创建新的Map,只包含指定key
  1047 + Map<String, Object> filtered = new HashMap<>();
  1048 + selectedKeys.forEach(key -> {
  1049 + if (original.containsKey(key)) {
  1050 + //指定映射的key 放中文
  1051 + filtered.put(keyMappings.get(key), original.get(key));
  1052 + }
  1053 + });
  1054 + return filtered.isEmpty() ? null : filtered;
  1055 + })
  1056 + .filter(Objects::nonNull)
  1057 + .collect(Collectors.toList());
  1058 + }
  1059 +
  1060 + /**
  1061 + * 构建确认操作消息
  1062 + */
  1063 + private String buildConfirmUserMessage(ToolMeta meta, Map<String, Object> args) {
  1064 + StringBuilder markdown = new StringBuilder();
  1065 + markdown.append("参数提取如下:\n\n");
  1066 + List<ParamRule> paramRuleData = meta.getParamRuleList();
  1067 + paramRuleData.forEach(one->{
  1068 + if(ObjectUtil.isNotEmpty(args.get(one.getSParam()))){
  1069 + markdown.append("- **").append(one.getSParam()).append("**: ").append(args.get(one.getSParam())).append("\n");
  1070 + }
  1071 + });
  1072 + markdown.append("\n---\n");
  1073 + appendConfirm(markdown,meta.getSControlName());
  1074 + return markdown.toString();
  1075 + }
  1076 +
  1077 +
  1078 +
  1079 + /**
  1080 + * 构建提问消息
  1081 + */
  1082 + private String buildAskUserMessage(ToolMeta meta, List<String> missing) {
  1083 + StringBuilder sb = new StringBuilder();
  1084 + sb.append("缺少参数请补全:\n");
  1085 + List<ParamRule> paramRuleData = meta.getParamRuleList();
  1086 + for (String name : missing) {
  1087 + paramRuleData.stream()
  1088 + .filter(pd -> pd.getSParam().equals(name))
  1089 + .findFirst()
  1090 + .ifPresentOrElse(
  1091 + pd -> {
  1092 + String sTs = ObjectUtil.isEmpty(pd.getSRuleTs())?StrUtil.EMPTY:pd.getSRuleTs();
  1093 + sb.append("- **").append(name).append("**: ");
  1094 + if(ObjectUtil.isNotEmpty(pd.getSParamMissMemo())){
  1095 + if(!("string".equals(pd.getSType()) && RuleCode.SQL.equals(pd.getSRule()) && ObjectUtil.isNotEmpty(pd.getSParamConfig())) )
  1096 + {
  1097 + sb.append(StrUtil.format(pd.getSParamMissMemo(),sTs));
  1098 + }
  1099 + }else if(ObjectUtil.isNotEmpty(sTs)){
  1100 + sb.append(sTs);
  1101 + }
  1102 + sb.append("\n");
  1103 + },
  1104 + () -> sb.append("- ").append(name).append("\n")
  1105 + );
  1106 + }
  1107 + return sb.toString();
  1108 + }
  1109 +
  1110 + // 创建提前成功的结果
  1111 + private String createEarlySuccessResult(ToolExecutionRequest request, String message) {
  1112 + // 设置一个标志,告诉执行器不要继续执行
  1113 + return JSONUtil.toJsonStr(Map.of(
  1114 + "status", "success",
  1115 + "message", message,
  1116 + // 关键标志
  1117 + "executionCompleted", true,
  1118 + "data", successResult(request, message)
  1119 + ));
  1120 + }
  1121 + // 创建终止执行的结果
  1122 + private String createTerminationResult(String message) {
  1123 + return JSONUtil.toJsonStr(Map.of(
  1124 + "status", "terminated",
  1125 + "message", message,
  1126 + "shouldContinue", false
  1127 + ));
  1128 + }
  1129 +
  1130 + /***
  1131 + * @Author 钱豹
  1132 + * @Date 10:15 2026/1/31
  1133 + * @Param [request, errorMsg]
  1134 + * @return dev.langchain4j.data.message.ToolExecutionResultMessage
  1135 + * @Description 错误返回
  1136 + **/
  1137 + private ToolExecutionResultMessage errorResult(ToolExecutionRequest request, String errorMsg) {
  1138 + return ToolExecutionResultMessage.from(request, errorMsg);
  1139 + }
  1140 + /***
  1141 + * @Author 钱豹
  1142 + * @Date 10:15 2026/1/31
  1143 + * @Param [request, text]
  1144 + * @return dev.langchain4j.data.message.ToolExecutionResultMessage
  1145 + * @Description 构建正确返回
  1146 + **/
  1147 + private ToolExecutionResultMessage successResult(ToolExecutionRequest request, String text) {
  1148 + return ToolExecutionResultMessage.from(request, text);
  1149 + }
  1150 +
  1151 + /**
  1152 + * 询问用户工具执行结果
  1153 + * @param request 工具执行请求
  1154 + * @param text 回复文本内容
  1155 + * @return 工具执行结果消息
  1156 + * @author 钱豹
  1157 + */
  1158 + private ToolExecutionResultMessage askUserResult(ToolExecutionRequest request, String text) {
  1159 + // 直接返回标准结果
  1160 + return ToolExecutionResultMessage.from(request, text);
  1161 + }
  1162 + /**
  1163 + * 执行方法后需要用户确认的扩展版本
  1164 + */
  1165 + private ToolExecutionResultMessage executeWithConfirmation(ToolExecutionRequest request, String initialResult,ChatMemory chatMemory, UserSceneSession session,ToolMeta meta) {
  1166 +
  1167 + // 第一步:执行原始操作,返回初步结果
  1168 + Map<String, Object> step1Result = new HashMap<>();
  1169 + step1Result.put("initialResult", initialResult);
  1170 + step1Result.put("status", "PENDING_CONFIRMATION");
  1171 + step1Result.put("confirmationRequired", true);
  1172 + step1Result.put("confirmationMessage", initialResult);
  1173 +// // 将确认状态保存到对话记忆
  1174 + chatMemory.add(UserMessage.from("SYSTEM: 等待用户确认操作"));
  1175 + String userMessage = formatConfirmationResult(step1Result);
  1176 + session.setCurrentTool(meta);
  1177 + session.setSFunPrompts(userMessage);
  1178 + // 6. 返回确认请求
  1179 + return ToolExecutionResultMessage.from(request,userMessage);
  1180 + }
  1181 +
  1182 + private String formatConfirmationResult(Map<String, Object> result) {
  1183 + return String.format(
  1184 + """
  1185 + **结果** : %s
  1186 + """,
  1187 + result.get("initialResult"),
  1188 + result.get("confirmationMessage")
  1189 + );
  1190 + }
  1191 +
  1192 + /***
  1193 + * @Author 钱豹
  1194 + * @Date 0:54 2026/2/4
  1195 + * @Param [userResponse]
  1196 + * @return boolean
  1197 + * @Description 检查是确认
  1198 + **/
  1199 + private boolean isConfirmed(String userResponse) {
  1200 + return userResponse.matches("(?i)(确认|全部确认|部分确认|是|yes|confirm|true|是的|可以|没问题|确定|好的|生成|)");
  1201 + }
  1202 +
  1203 +
  1204 +
  1205 +}
... ...
src/main/java/com/xly/tool/ToolSpecificationHolder.java 0 → 100644
  1 +package com.xly.tool;
  2 +
  3 +
  4 +import dev.langchain4j.service.tool.ToolExecutor;
  5 +import dev.langchain4j.agent.tool.ToolSpecification;
  6 +
  7 +public class ToolSpecificationHolder {
  8 + private final ToolSpecification toolSpecification;
  9 + private final ToolExecutor toolExecutor;
  10 + public ToolSpecificationHolder(ToolSpecification toolSpecification, ToolExecutor toolExecutor) {
  11 + this.toolSpecification = toolSpecification;
  12 + this.toolExecutor = toolExecutor;
  13 + }
  14 +
  15 + public ToolSpecification getToolSpecification() {
  16 + return toolSpecification;
  17 + }
  18 +
  19 + public ToolExecutor getToolExecutor() {
  20 + return toolExecutor;
  21 + }}
... ...
src/main/java/com/xly/tts/bean/HealthStatus.java 0 → 100644
  1 +package com.xly.tts.bean;
  2 +
  3 +/**
  4 + * 健康状态内部类
  5 + */
  6 +@lombok.Data
  7 +@lombok.AllArgsConstructor
  8 +@lombok.NoArgsConstructor
  9 +public class HealthStatus {
  10 + private String javaService;
  11 + private String pythonService;
  12 + private long timestamp;
  13 + private String message;
  14 +}
0 15 \ No newline at end of file
... ...
src/main/java/com/xly/tts/bean/ServiceStatus.java 0 → 100644
  1 +package com.xly.tts.bean;
  2 +
  3 +/**
  4 + * 服务状态内部类
  5 + */
  6 +@lombok.Data
  7 +@lombok.AllArgsConstructor
  8 +@lombok.NoArgsConstructor
  9 +public class ServiceStatus {
  10 + private boolean javaService;
  11 + private boolean pythonService;
  12 + private String serviceUrl;
  13 + private String javaApiUrl;
  14 + private java.util.Date timestamp;
  15 +}
0 16 \ No newline at end of file
... ...
src/main/java/com/xly/tts/bean/TTSRequestDTO.java 0 → 100644
  1 +package com.xly.tts.bean;
  2 +
  3 +import lombok.Data;
  4 +
  5 +@Data
  6 +public class TTSRequestDTO {
  7 + private String text;
  8 + private String userid;
  9 + private String usertype;
  10 + private String authorization;
  11 + private String voice = "zh-CN-XiaoxiaoNeural";
  12 + private String rate = "+10%";
  13 + private String volume = "+0%";
  14 + private Boolean voiceless;
  15 +
  16 +
  17 +}
0 18 \ No newline at end of file
... ...
src/main/java/com/xly/tts/bean/TTSResponseDTO.java 0 → 100644
  1 +package com.xly.tts.bean;
  2 +
  3 +import com.xly.constant.ErrorCode;
  4 +import com.xly.constant.ReturnTypeCode;
  5 +import lombok.AllArgsConstructor;
  6 +import lombok.Builder;
  7 +import lombok.Data;
  8 +import lombok.NoArgsConstructor;
  9 +
  10 +import java.io.Serializable;
  11 +import java.util.Map;
  12 +
  13 +/**
  14 + * TTS响应数据传输对象
  15 + */
  16 +@Data
  17 +@Builder
  18 +@NoArgsConstructor
  19 +@AllArgsConstructor
  20 +public class TTSResponseDTO implements Serializable {
  21 +
  22 + private static final long serialVersionUID = 1L;
  23 +
  24 + /**
  25 + * 请求ID
  26 + */
  27 + private String requestId;
  28 +
  29 + /**
  30 + * 状态码:200成功,其他失败
  31 + */
  32 + @Builder.Default
  33 + private Integer code = ErrorCode.SUCCESS.getCode();
  34 +
  35 + /**
  36 + * 状态消息
  37 + */
  38 + @Builder.Default
  39 + private String message = "success";
  40 +
  41 + // 文字部分
  42 + private String originalText;
  43 + private String processedText;
  44 + private String systemText;
  45 + private String voice;
  46 + private Long timestamp;
  47 + private Integer textLength;
  48 +
  49 + // 音频部分(Base64编码或URL)
  50 + private String audioBase64;
  51 + private Integer audioSize;
  52 + private String audioFormat;
  53 +
  54 + // 或者只返回音频URL
  55 + private String audioUrl;
  56 + private String sMsg;
  57 + // 业务代码 例如报价 001
  58 + private String sBusinessCode;
  59 + //业务场景名称
  60 + private String sSceneName;
  61 + //业务方法名称
  62 + private String sMethodName;
  63 +
  64 + private String sReturnType = ReturnTypeCode.MAKEDOWN.getCode();
  65 +
  66 +
  67 +
  68 + /**
  69 + * 创建失败响应
  70 + */
  71 + public static TTSResponseDTO error(ErrorCode code) {
  72 + return TTSResponseDTO.builder()
  73 + .code(code.getCode())
  74 + .message(code.getMessage())
  75 + .timestamp(System.currentTimeMillis())
  76 + .build();
  77 + }
  78 +
  79 + public static TTSResponseDTO error(Integer code, String message) {
  80 + return TTSResponseDTO.builder()
  81 + .code(code != null ? code : 500)
  82 + .message(message != null ? message : "系统错误")
  83 + .timestamp(System.currentTimeMillis())
  84 + .build();
  85 + }
  86 +
  87 + /**
  88 + * 创建失败响应
  89 + */
  90 + public static TTSResponseDTO error(String requestId, Integer code, String message) {
  91 + return TTSResponseDTO.builder()
  92 + .requestId(requestId)
  93 + .code(code != null ? code : 500)
  94 + .message(message != null ? message : "系统错误")
  95 + .timestamp(System.currentTimeMillis())
  96 + .build();
  97 + }
  98 +
  99 +}
0 100 \ No newline at end of file
... ...
src/main/java/com/xly/tts/bean/Voice.java 0 → 100644
  1 +package com.xly.tts.bean;
  2 +
  3 +
  4 +// 常用语音常量
  5 +public class Voice {
  6 + public static final String CHINESE_FEMALE = "zh-CN-XiaoxiaoNeural";
  7 + public static final String CHINESE_MALE = "zh-CN-YunyangNeural";
  8 + public static final String ENGLISH_FEMALE = "en-US-JennyNeural";
  9 + public static final String ENGLISH_MALE = "en-US-GuyNeural";
  10 + public static final String JAPANESE_FEMALE = "ja-JP-NanamiNeural";
  11 + public static final String KOREAN_FEMALE = "ko-KR-SunHiNeural";
  12 +}
0 13 \ No newline at end of file
... ...
src/main/java/com/xly/tts/bean/VoiceGroupDTO.java 0 → 100644
  1 +package com.xly.tts.bean;
  2 +
  3 +import lombok.AllArgsConstructor;
  4 +import lombok.Builder;
  5 +import lombok.Data;
  6 +import lombok.NoArgsConstructor;
  7 +
  8 +import java.io.Serializable;
  9 +import java.util.List;
  10 +
  11 +/**
  12 + * 语音分组数据传输对象
  13 + */
  14 +@Data
  15 +@Builder
  16 +@NoArgsConstructor
  17 +@AllArgsConstructor
  18 +public class VoiceGroupDTO implements Serializable {
  19 +
  20 + private static final long serialVersionUID = 1L;
  21 +
  22 + /**
  23 + * 分组键(如:languageCode, locale, gender)
  24 + */
  25 + private String groupKey;
  26 +
  27 + /**
  28 + * 分组值(如:zh, en-US, Female)
  29 + */
  30 + private String groupValue;
  31 +
  32 + /**
  33 + * 分组显示名称
  34 + */
  35 + private String groupDisplayName;
  36 +
  37 + /**
  38 + * 语音数量
  39 + */
  40 + private Integer voiceCount;
  41 +
  42 + /**
  43 + * 分组内的语音列表
  44 + */
  45 + private List<VoiceInfoDTO> voices;
  46 +
  47 + /**
  48 + * 是否为默认分组
  49 + */
  50 + private Boolean isDefaultGroup;
  51 +
  52 + /**
  53 + * 排序权重
  54 + */
  55 + private Integer sortWeight;
  56 +
  57 + // 静态工厂方法
  58 +
  59 + public static VoiceGroupDTO byLanguage(String languageCode, String displayName,
  60 + List<VoiceInfoDTO> voices) {
  61 + return VoiceGroupDTO.builder()
  62 + .groupKey("language")
  63 + .groupValue(languageCode)
  64 + .groupDisplayName(displayName)
  65 + .voiceCount(voices != null ? voices.size() : 0)
  66 + .voices(voices)
  67 + .sortWeight(getLanguageSortWeight(languageCode))
  68 + .build();
  69 + }
  70 +
  71 + public static VoiceGroupDTO byLocale(String locale, List<VoiceInfoDTO> voices) {
  72 + return VoiceGroupDTO.builder()
  73 + .groupKey("locale")
  74 + .groupValue(locale)
  75 + .groupDisplayName(locale)
  76 + .voiceCount(voices != null ? voices.size() : 0)
  77 + .voices(voices)
  78 + .sortWeight(getLocaleSortWeight(locale))
  79 + .build();
  80 + }
  81 +
  82 + public static VoiceGroupDTO byGender(String gender, List<VoiceInfoDTO> voices) {
  83 + String displayName = "Female".equalsIgnoreCase(gender) ? "女声" : "男声";
  84 + return VoiceGroupDTO.builder()
  85 + .groupKey("gender")
  86 + .groupValue(gender)
  87 + .groupDisplayName(displayName)
  88 + .voiceCount(voices != null ? voices.size() : 0)
  89 + .voices(voices)
  90 + .sortWeight("Female".equalsIgnoreCase(gender) ? 1 : 2)
  91 + .build();
  92 + }
  93 +
  94 + private static Integer getLanguageSortWeight(String languageCode) {
  95 + switch (languageCode.toLowerCase()) {
  96 + case "zh": return 1; // 中文优先
  97 + case "en": return 2; // 英文其次
  98 + case "ja": return 3; // 日语
  99 + case "ko": return 4; // 韩语
  100 + case "fr": return 5; // 法语
  101 + case "de": return 6; // 德语
  102 + case "es": return 7; // 西班牙语
  103 + default: return 100; // 其他语言
  104 + }
  105 + }
  106 +
  107 + private static Integer getLocaleSortWeight(String locale) {
  108 + if (locale == null) return 1000;
  109 +
  110 + if (locale.startsWith("zh-CN")) return 1; // 简体中文
  111 + if (locale.startsWith("zh-TW")) return 2; // 繁体中文
  112 + if (locale.startsWith("zh-HK")) return 3; // 香港中文
  113 + if (locale.startsWith("en-US")) return 4; // 美式英语
  114 + if (locale.startsWith("en-GB")) return 5; // 英式英语
  115 + if (locale.startsWith("ja-JP")) return 6; // 日语
  116 + if (locale.startsWith("ko-KR")) return 7; // 韩语
  117 +
  118 + return 100; // 其他
  119 + }
  120 +}
0 121 \ No newline at end of file
... ...
src/main/java/com/xly/tts/bean/VoiceInfoDTO.java 0 → 100644
  1 +package com.xly.tts.bean;
  2 +
  3 +import lombok.AllArgsConstructor;
  4 +import lombok.Builder;
  5 +import lombok.Data;
  6 +import lombok.NoArgsConstructor;
  7 +
  8 +import java.io.Serializable;
  9 +
  10 +/**
  11 + * 语音信息数据传输对象
  12 + */
  13 +@Data
  14 +@Builder
  15 +@NoArgsConstructor
  16 +@AllArgsConstructor
  17 +public class VoiceInfoDTO implements Serializable {
  18 +
  19 + private static final long serialVersionUID = 1L;
  20 +
  21 + /**
  22 + * 语音唯一标识(如:zh-CN-XiaoxiaoNeural)
  23 + */
  24 + private String name;
  25 +
  26 + /**
  27 + * 语音显示名称(友好名称)
  28 + */
  29 + private String displayName;
  30 +
  31 + /**
  32 + * 语言区域代码(如:zh-CN, en-US, ja-JP)
  33 + */
  34 + private String locale;
  35 +
  36 + /**
  37 + * 性别:Male, Female
  38 + */
  39 + private String gender;
  40 +
  41 + /**
  42 + * 建议的使用代码
  43 + */
  44 + private String suggestedCode;
  45 +
  46 + /**
  47 + * 语音类型:Standard, Neural
  48 + */
  49 + @Builder.Default
  50 + private String voiceType = "Neural";
  51 +
  52 + /**
  53 + * 是否本地可用
  54 + */
  55 + @Builder.Default
  56 + private Boolean localAvailable = true;
  57 +
  58 + /**
  59 + * 状态:available, unavailable, deprecated
  60 + */
  61 + @Builder.Default
  62 + private String status = "available";
  63 +
  64 + /**
  65 + * 采样率
  66 + */
  67 + private Integer sampleRate;
  68 +
  69 + /**
  70 + * 比特率
  71 + */
  72 + private Integer bitRate;
  73 +
  74 + /**
  75 + * 语速范围(如:-50% 到 +50%)
  76 + */
  77 + private String rateRange;
  78 +
  79 + /**
  80 + * 音量范围
  81 + */
  82 + private String volumeRange;
  83 +
  84 + /**
  85 + * 音高范围
  86 + */
  87 + private String pitchRange;
  88 +
  89 + /**
  90 + * 描述信息
  91 + */
  92 + private String description;
  93 +
  94 + /**
  95 + * 创建时间戳
  96 + */
  97 + private Long createTime;
  98 +
  99 + /**
  100 + * 更新时间戳
  101 + */
  102 + private Long updateTime;
  103 +
  104 + /**
  105 + * 是否为默认语音
  106 + */
  107 + @Builder.Default
  108 + private Boolean isDefault = false;
  109 +
  110 + /**
  111 + * 是否为推荐语音
  112 + */
  113 + @Builder.Default
  114 + private Boolean isRecommended = false;
  115 +
  116 + /**
  117 + * 排序权重
  118 + */
  119 + @Builder.Default
  120 + private Integer sortWeight = 0;
  121 +
  122 + /**
  123 + * 额外属性(JSON格式)
  124 + */
  125 + private String extraProperties;
  126 +
  127 + // 便捷方法
  128 +
  129 + /**
  130 + * 获取语言代码(如:zh, en, ja)
  131 + */
  132 + public String getLanguageCode() {
  133 + if (locale != null && locale.contains("-")) {
  134 + return locale.split("-")[0];
  135 + }
  136 + return locale;
  137 + }
  138 +
  139 + /**
  140 + * 获取国家/地区代码(如:CN, US, JP)
  141 + */
  142 + public String getCountryCode() {
  143 + if (locale != null && locale.contains("-")) {
  144 + String[] parts = locale.split("-");
  145 + if (parts.length > 1) {
  146 + return parts[1];
  147 + }
  148 + }
  149 + return "";
  150 + }
  151 +
  152 + /**
  153 + * 是否为中文语音
  154 + */
  155 + public Boolean isChinese() {
  156 + return locale != null && locale.startsWith("zh-");
  157 + }
  158 +
  159 + /**
  160 + * 是否为英文语音
  161 + */
  162 + public Boolean isEnglish() {
  163 + return locale != null && locale.startsWith("en-");
  164 + }
  165 +
  166 + /**
  167 + * 是否为女性语音
  168 + */
  169 + public Boolean isFemale() {
  170 + return "Female".equalsIgnoreCase(gender);
  171 + }
  172 +
  173 + /**
  174 + * 是否为男性语音
  175 + */
  176 + public Boolean isMale() {
  177 + return "Male".equalsIgnoreCase(gender);
  178 + }
  179 +
  180 + /**
  181 + * 获取语音分类
  182 + */
  183 + public String getCategory() {
  184 + if (isChinese()) {
  185 + return "中文语音";
  186 + } else if (isEnglish()) {
  187 + return "英文语音";
  188 + } else if (locale != null) {
  189 + String lang = getLanguageCode();
  190 + switch (lang) {
  191 + case "ja": return "日语语音";
  192 + case "ko": return "韩语语音";
  193 + case "fr": return "法语语音";
  194 + case "de": return "德语语音";
  195 + case "es": return "西班牙语语音";
  196 + default: return "其他语音";
  197 + }
  198 + }
  199 + return "未知";
  200 + }
  201 +
  202 + /**
  203 + * 获取完整的显示名称
  204 + */
  205 + public String getFullDisplayName() {
  206 + StringBuilder sb = new StringBuilder();
  207 +
  208 + if (displayName != null) {
  209 + sb.append(displayName);
  210 + } else if (name != null) {
  211 + sb.append(name);
  212 + }
  213 +
  214 + if (locale != null) {
  215 + sb.append(" (").append(locale).append(")");
  216 + }
  217 +
  218 + if (gender != null) {
  219 + sb.append(" - ").append("Female".equalsIgnoreCase(gender) ? "女声" : "男声");
  220 + }
  221 +
  222 + return sb.toString();
  223 + }
  224 +}
0 225 \ No newline at end of file
... ...
src/main/java/com/xly/tts/bean/VoiceListResponseDTO.java 0 → 100644
  1 +package com.xly.tts.bean;
  2 +
  3 +import lombok.AllArgsConstructor;
  4 +import lombok.Builder;
  5 +import lombok.Data;
  6 +import lombok.NoArgsConstructor;
  7 +
  8 +import java.io.Serializable;
  9 +import java.util.List;
  10 +
  11 +/**
  12 + * 语音列表响应数据传输对象
  13 + */
  14 +@Data
  15 +@Builder
  16 +@NoArgsConstructor
  17 +@AllArgsConstructor
  18 +public class VoiceListResponseDTO implements Serializable {
  19 +
  20 + private static final long serialVersionUID = 1L;
  21 +
  22 + /**
  23 + * 语音列表
  24 + */
  25 + private List<VoiceInfoDTO> voices;
  26 +
  27 + /**
  28 + * 当前页码
  29 + */
  30 + private Integer currentPage;
  31 +
  32 + /**
  33 + * 每页大小
  34 + */
  35 + private Integer pageSize;
  36 +
  37 + /**
  38 + * 总记录数
  39 + */
  40 + private Long totalCount;
  41 +
  42 + /**
  43 + * 总页数
  44 + */
  45 + private Integer totalPages;
  46 +
  47 + /**
  48 + * 是否有下一页
  49 + */
  50 + private Boolean hasNext;
  51 +
  52 + /**
  53 + * 是否有上一页
  54 + */
  55 + private Boolean hasPrevious;
  56 +
  57 + /**
  58 + * 查询条件
  59 + */
  60 + private VoiceQueryDTO query;
  61 +
  62 + /**
  63 + * 按语言分组的数据
  64 + */
  65 + private List<VoiceGroupDTO> groups;
  66 +
  67 + /**
  68 + * 推荐的默认语音
  69 + */
  70 + private VoiceInfoDTO defaultVoice;
  71 +
  72 + /**
  73 + * 时间戳
  74 + */
  75 + private Long timestamp;
  76 +
  77 + /**
  78 + * 状态码
  79 + */
  80 + @Builder.Default
  81 + private Integer code = 200;
  82 +
  83 + /**
  84 + * 状态消息
  85 + */
  86 + @Builder.Default
  87 + private String message = "success";
  88 +
  89 + /**
  90 + * 创建成功响应
  91 + */
  92 + public static VoiceListResponseDTO success(List<VoiceInfoDTO> voices, VoiceQueryDTO query,
  93 + Long totalCount) {
  94 + int pageSize = query != null ? query.getPageSize() : 20;
  95 + int currentPage = query != null ? query.getPage() : 1;
  96 + int totalPages = (int) Math.ceil((double) totalCount / pageSize);
  97 +
  98 + return VoiceListResponseDTO.builder()
  99 + .voices(voices)
  100 + .currentPage(currentPage)
  101 + .pageSize(pageSize)
  102 + .totalCount(totalCount)
  103 + .totalPages(totalPages)
  104 + .hasNext(currentPage < totalPages)
  105 + .hasPrevious(currentPage > 1)
  106 + .query(query)
  107 + .timestamp(System.currentTimeMillis())
  108 + .code(200)
  109 + .message("获取成功")
  110 + .build();
  111 + }
  112 +
  113 + /**
  114 + * 创建失败响应
  115 + */
  116 + public static VoiceListResponseDTO error(String message) {
  117 + return VoiceListResponseDTO.builder()
  118 + .code(500)
  119 + .message(message)
  120 + .timestamp(System.currentTimeMillis())
  121 + .build();
  122 + }
  123 +}
0 124 \ No newline at end of file
... ...
src/main/java/com/xly/tts/bean/VoiceQueryDTO.java 0 → 100644
  1 +package com.xly.tts.bean;
  2 +
  3 +import lombok.AllArgsConstructor;
  4 +import lombok.Builder;
  5 +import lombok.Data;
  6 +import lombok.NoArgsConstructor;
  7 +
  8 +import java.io.Serializable;
  9 +
  10 +/**
  11 + * 语音查询条件数据传输对象
  12 + */
  13 +@Data
  14 +@Builder
  15 +@NoArgsConstructor
  16 +@AllArgsConstructor
  17 +public class VoiceQueryDTO implements Serializable {
  18 +
  19 + private static final long serialVersionUID = 1L;
  20 +
  21 + /**
  22 + * 语言代码(如:zh, en, ja)
  23 + */
  24 + private String languageCode;
  25 +
  26 + /**
  27 + * 国家/地区代码(如:CN, US, JP)
  28 + */
  29 + private String countryCode;
  30 +
  31 + /**
  32 + * 完整区域代码(如:zh-CN, en-US)
  33 + */
  34 + private String locale;
  35 +
  36 + /**
  37 + * 性别:Male, Female, Any
  38 + */
  39 + private String gender;
  40 +
  41 + /**
  42 + * 语音类型:Standard, Neural, All
  43 + */
  44 + private String voiceType;
  45 +
  46 + /**
  47 + * 是否只返回默认语音
  48 + */
  49 + private Boolean defaultOnly;
  50 +
  51 + /**
  52 + * 是否只返回推荐语音
  53 + */
  54 + private Boolean recommendedOnly;
  55 +
  56 + /**
  57 + * 是否返回本地可用语音
  58 + */
  59 + @Builder.Default
  60 + private Boolean localAvailable = true;
  61 +
  62 + /**
  63 + * 状态:available, unavailable, all
  64 + */
  65 + @Builder.Default
  66 + private String status = "available";
  67 +
  68 + /**
  69 + * 搜索关键词(名称或显示名称)
  70 + */
  71 + private String keyword;
  72 +
  73 + /**
  74 + * 页码(从1开始)
  75 + */
  76 + @Builder.Default
  77 + private Integer page = 1;
  78 +
  79 + /**
  80 + * 每页大小
  81 + */
  82 + @Builder.Default
  83 + private Integer pageSize = 20;
  84 +
  85 + /**
  86 + * 排序字段
  87 + */
  88 + private String sortField;
  89 +
  90 + /**
  91 + * 排序方向:asc, desc
  92 + */
  93 + private String sortDirection;
  94 +
  95 + // 便捷方法
  96 +
  97 + /**
  98 + * 获取偏移量
  99 + */
  100 + public Integer getOffset() {
  101 + return (page - 1) * pageSize;
  102 + }
  103 +
  104 + /**
  105 + * 是否查询所有语音
  106 + */
  107 + public Boolean isQueryAll() {
  108 + return "all".equalsIgnoreCase(status);
  109 + }
  110 +
  111 + /**
  112 + * 是否查询女性语音
  113 + */
  114 + public Boolean isQueryFemale() {
  115 + return "female".equalsIgnoreCase(gender);
  116 + }
  117 +
  118 + /**
  119 + * 是否查询男性语音
  120 + */
  121 + public Boolean isQueryMale() {
  122 + return "male".equalsIgnoreCase(gender);
  123 + }
  124 +
  125 + /**
  126 + * 是否为Neural语音
  127 + */
  128 + public Boolean isNeuralVoice() {
  129 + return "neural".equalsIgnoreCase(voiceType);
  130 + }
  131 +}
0 132 \ No newline at end of file
... ...
src/main/java/com/xly/tts/config/RestTemplateConfig.java 0 → 100644
  1 +package com.xly.tts.config;
  2 +
  3 +import org.springframework.context.annotation.Bean;
  4 +import org.springframework.context.annotation.Configuration;
  5 +import org.springframework.http.client.ClientHttpRequestFactory;
  6 +import org.springframework.http.client.SimpleClientHttpRequestFactory;
  7 +import org.springframework.web.client.RestTemplate;
  8 +
  9 +@Configuration
  10 +public class RestTemplateConfig {
  11 +
  12 + @Bean
  13 + public RestTemplate restTemplate() {
  14 + return new RestTemplate(clientHttpRequestFactory());
  15 + }
  16 +
  17 + private ClientHttpRequestFactory clientHttpRequestFactory() {
  18 + SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
  19 + factory.setConnectTimeout(30000);
  20 + factory.setReadTimeout(60000);
  21 + return factory;
  22 + }
  23 +}
0 24 \ No newline at end of file
... ...
src/main/java/com/xly/tts/config/TTSConfig.java 0 → 100644
  1 +package com.xly.tts.config;
  2 +
  3 +import lombok.Data;
  4 +import okhttp3.ConnectionPool;
  5 +import okhttp3.Dispatcher;
  6 +import okhttp3.OkHttpClient;
  7 +import org.springframework.boot.context.properties.ConfigurationProperties;
  8 +import org.springframework.context.annotation.Bean;
  9 +import org.springframework.context.annotation.Configuration;
  10 +
  11 +import java.util.concurrent.TimeUnit;
  12 +
  13 +@Data
  14 +@Configuration
  15 +@ConfigurationProperties(prefix = "tts")
  16 +public class TTSConfig {
  17 +
  18 + private PythonService python = new PythonService();
  19 + private CommandLine commandLine = new CommandLine();
  20 + private OkHttpConfig okHttp = new OkHttpConfig();
  21 +
  22 + @Data
  23 + public static class PythonService {
  24 + private boolean enabled = true;
  25 + private String url = "http://127.0.0.1:8000";
  26 + private int timeout = 30000;
  27 + }
  28 +
  29 + @Data
  30 + public static class CommandLine {
  31 + private boolean enabled = false;
  32 + private String pythonPath = "python";
  33 + private int bufferSize = 4096;
  34 + }
  35 +
  36 + @Data
  37 + public static class OkHttpConfig {
  38 + private int connectTimeout = 30;
  39 + private int readTimeout = 60;
  40 + private int writeTimeout = 60;
  41 + private int maxRequests = 64;
  42 + private int maxRequestsPerHost = 32;
  43 + private int maxIdleConnections = 5;
  44 + private long keepAliveDuration = 5;
  45 + }
  46 +
  47 + @Bean
  48 + public OkHttpClient okHttpClient() {
  49 + Dispatcher dispatcher = new Dispatcher();
  50 + dispatcher.setMaxRequests(okHttp.getMaxRequests());
  51 + dispatcher.setMaxRequestsPerHost(okHttp.getMaxRequestsPerHost());
  52 +
  53 + ConnectionPool connectionPool = new ConnectionPool(
  54 + okHttp.getMaxIdleConnections(),
  55 + okHttp.getKeepAliveDuration(),
  56 + TimeUnit.MINUTES
  57 + );
  58 +
  59 + return new OkHttpClient.Builder()
  60 + .dispatcher(dispatcher)
  61 + .connectionPool(connectionPool)
  62 + .connectTimeout(okHttp.getConnectTimeout(), TimeUnit.SECONDS)
  63 + .readTimeout(okHttp.getReadTimeout(), TimeUnit.SECONDS)
  64 + .writeTimeout(okHttp.getWriteTimeout(), TimeUnit.SECONDS)
  65 + .retryOnConnectionFailure(true)
  66 + .build();
  67 + }
  68 +}
0 69 \ No newline at end of file
... ...
src/main/java/com/xly/tts/service/PythonTtsProxyService.java 0 → 100644
  1 +package com.xly.tts.service;
  2 +
  3 +import cn.hutool.core.util.ObjectUtil;
  4 +import cn.hutool.core.util.StrUtil;
  5 +import com.xly.constant.ReturnTypeCode;
  6 +import com.xly.entity.AiResponseDTO;
  7 +import com.xly.service.XlyErpService;
  8 +import com.xly.tts.bean.*;
  9 +import com.xly.util.AdvancedSymbolRemover;
  10 +import lombok.RequiredArgsConstructor;
  11 +import lombok.extern.slf4j.Slf4j;
  12 +import org.springframework.beans.factory.annotation.Value;
  13 +import org.springframework.core.io.InputStreamResource;
  14 +import org.springframework.http.*;
  15 +import org.springframework.stereotype.Service;
  16 +import org.springframework.web.client.RestTemplate;
  17 +
  18 +import javax.annotation.PostConstruct;
  19 +import java.io.ByteArrayInputStream;
  20 +import java.io.InputStream;
  21 +import java.util.*;
  22 +import java.util.concurrent.CompletableFuture;
  23 +import java.util.concurrent.ExecutorService;
  24 +import java.util.concurrent.Executors;
  25 +
  26 +@Slf4j
  27 +@Service
  28 +@RequiredArgsConstructor
  29 +public class PythonTtsProxyService {
  30 +
  31 + private final RestTemplate restTemplate;
  32 +
  33 + @Value("${tts.python.url:http://localhost:8000}")
  34 + private String pythonServiceUrl;
  35 +
  36 + @Value("${tts.python.timeout:30000}")
  37 + private int timeout;
  38 +
  39 + private ExecutorService executorService;
  40 +
  41 + private final XlyErpService xlyErpService;
  42 +
  43 + @PostConstruct
  44 + public void init() {
  45 + executorService = Executors.newFixedThreadPool(5);
  46 + log.info("Python TTS代理服务初始化完成,Python服务地址: {}", pythonServiceUrl);
  47 + }
  48 +
  49 + public ResponseEntity<TTSResponseDTO> initTool(TTSRequestDTO request) {
  50 + TTSResponseDTO ttsResponse = TTSResponseDTO.builder()
  51 + .code(200)
  52 + .message("success")
  53 + .build();
  54 + return ResponseEntity.ok(ttsResponse);
  55 + }
  56 +
  57 + /**
  58 + * 流式合成语音 - 代理到Python服务
  59 + */
  60 + public ResponseEntity<InputStreamResource> synthesizeStream(TTSRequestDTO request) {
  61 + return getVoiceResult(request);
  62 + }
  63 +
  64 + /**
  65 + * 流式合成语音 - 代理到Python服务
  66 + */
  67 + public ResponseEntity<TTSResponseDTO> synthesizeStreamAi(TTSRequestDTO request) {
  68 + //调用AI返回请求内容
  69 + String userInput = request.getText();
  70 + String sUserId = request.getUserid();
  71 + String sUserType = request.getUsertype();
  72 + String authorization = request.getAuthorization();
  73 + AiResponseDTO voiceText = xlyErpService.erpUserInput(userInput,sUserId,sUserType, authorization);
  74 + return synthesizeStreamAi(request,voiceText);
  75 + }
  76 +
  77 + /***
  78 + * @Author 钱豹
  79 + * @Date 11:16 2026/2/8
  80 + * @Param [request]
  81 + * @return org.springframework.http.ResponseEntity<com.xly.tts.bean.TTSResponseDTO>
  82 + * @Description 初始化加载方法
  83 + **/
  84 + public ResponseEntity<TTSResponseDTO> init(TTSRequestDTO request) {
  85 + //调用AI返回请求内容
  86 + String sUserId = request.getUserid();
  87 + String sUserType = request.getUsertype();
  88 + String authorization = request.getAuthorization();
  89 +// xlyErpService.initSceneGuide(sUserId,sUserType,StrUtil.EMPTY)
  90 + AiResponseDTO voiceText = xlyErpService.initSceneGuide(StrUtil.EMPTY,sUserId,sUserType, authorization);
  91 + voiceText.setSReturnType(ReturnTypeCode.HTML.getCode());
  92 + return synthesizeStreamAi(request,voiceText);
  93 + }
  94 +
  95 + public ResponseEntity<TTSResponseDTO> synthesizeStreamAi(TTSRequestDTO request,AiResponseDTO aiResponseDTO) {
  96 + String aiText = aiResponseDTO.getAiText();
  97 + String systemText = aiResponseDTO.getSystemText();
  98 + if(ObjectUtil.isEmpty(systemText)){
  99 + systemText = StrUtil.EMPTY;
  100 + }
  101 + //移除html
  102 + String voiceTextNew = AdvancedSymbolRemover.removePunctuationHtml(aiText);
  103 + try {
  104 + //如果没有语音直接返回
  105 + if(!request.getVoiceless() || ObjectUtil.isEmpty(voiceTextNew)){
  106 + return ResponseEntity.ok(TTSResponseDTO.builder()
  107 + .code(200)
  108 + .message("success")
  109 + .originalText(request.getText()) // 原始文本
  110 + .processedText(aiText) // AI提示语
  111 + .systemText(systemText) // 系统提示语言
  112 + .voice(request.getVoice())
  113 + .sSceneName(aiResponseDTO.getSSceneName())
  114 + .sMethodName (aiResponseDTO.getSMethodName())
  115 + .sReturnType (aiResponseDTO.getSReturnType())
  116 + .timestamp(System.currentTimeMillis())
  117 + .textLength(request.getText().length())
  118 + .build());
  119 + }
  120 +
  121 + // 构建Python服务请求
  122 + Map<String, Object> pythonRequest = new HashMap<>();
  123 + pythonRequest.put("text", voiceTextNew);
  124 + pythonRequest.put("voice", request.getVoice());
  125 + pythonRequest.put("rate", request.getRate() != null ? request.getRate() : "+10%");
  126 + pythonRequest.put("volume", request.getVolume() != null ? request.getVolume() : "+0%");
  127 + // 发送请求到Python服务
  128 + HttpHeaders headers = new HttpHeaders();
  129 + headers.setContentType(MediaType.APPLICATION_JSON);
  130 + headers.setAccept(Arrays.asList(MediaType.APPLICATION_OCTET_STREAM, MediaType.ALL));
  131 + HttpEntity<Map<String, Object>> entity = new HttpEntity<>(pythonRequest, headers);
  132 + ResponseEntity<byte[]> response = restTemplate.exchange(
  133 + pythonServiceUrl + "/stream-synthesize",
  134 + HttpMethod.POST,
  135 + entity,
  136 + byte[].class
  137 + );
  138 +
  139 + if (response.getStatusCode() == HttpStatus.OK && response.getBody() != null) {
  140 + // 将音频数据转为Base64
  141 + String audioBase64 = Base64.getEncoder().encodeToString(response.getBody());
  142 + // 构建完整的响应DTO
  143 + TTSResponseDTO ttsResponse = TTSResponseDTO.builder()
  144 + .code(200)
  145 + .message("success")
  146 + .originalText(request.getText()) // 原始文本
  147 + .processedText(aiText) // AI提示语
  148 + .systemText(systemText) // 系统提示语言
  149 + .voice(request.getVoice())
  150 + .timestamp(System.currentTimeMillis())
  151 + .textLength((aiText+systemText).length())
  152 + .audioBase64(audioBase64) // Base64编码的音频
  153 + .audioSize(response.getBody().length)
  154 + .sSceneName(aiResponseDTO.getSSceneName())
  155 + .sMethodName (aiResponseDTO.getSMethodName())
  156 + .sReturnType (aiResponseDTO.getSReturnType())
  157 + .audioFormat("audio/mpeg")
  158 + .build();
  159 + return ResponseEntity.ok(ttsResponse);
  160 + } else {
  161 + return ResponseEntity.status(response.getStatusCode())
  162 + .body(TTSResponseDTO.error("python_service_error", 500,
  163 + "Python服务响应失败: " + response.getStatusCode()));
  164 + }
  165 +
  166 + } catch (Exception e) {
  167 +// e.printStackTrace();
  168 + TTSResponseDTO ttsResponse = TTSResponseDTO.builder()
  169 + .code(200)
  170 + .message("success")
  171 + .originalText(request.getText()) // 原始文本
  172 + .voice(request.getVoice())
  173 + .timestamp(System.currentTimeMillis())
  174 + .processedText(aiText) // AI提示语
  175 + .systemText(systemText) // 系统提示语言
  176 + .textLength((aiText+systemText).length())
  177 + .sSceneName(aiResponseDTO.getSSceneName())
  178 + .sMethodName (aiResponseDTO.getSMethodName())
  179 + .sReturnType (aiResponseDTO.getSReturnType())
  180 + .build();
  181 + return ResponseEntity.ok(ttsResponse);
  182 + }
  183 + }
  184 +
  185 + public ResponseEntity<InputStreamResource> getVoiceResult(TTSRequestDTO request) {
  186 + try {
  187 +
  188 + String voiceText = request.getText();
  189 + //移除html
  190 + voiceText = AdvancedSymbolRemover.removePunctuationHtml( voiceText);
  191 + // 构建Python服务请求
  192 + Map<String, Object> pythonRequest = new HashMap<>();
  193 + pythonRequest.put("text", voiceText);
  194 + pythonRequest.put("voice", request.getVoice());
  195 + pythonRequest.put("rate", request.getRate() != null ? request.getRate() : "+0%");
  196 + pythonRequest.put("volume", request.getVolume() != null ? request.getVolume() : "+0%");
  197 + // 发送请求到Python服务
  198 + HttpHeaders headers = new HttpHeaders();
  199 + headers.setContentType(MediaType.APPLICATION_JSON);
  200 + headers.setAccept(Arrays.asList(MediaType.APPLICATION_OCTET_STREAM, MediaType.ALL));
  201 + HttpEntity<Map<String, Object>> entity = new HttpEntity<>(pythonRequest, headers);
  202 + ResponseEntity<byte[]> response = restTemplate.exchange(
  203 + pythonServiceUrl + "/stream-synthesize",
  204 + HttpMethod.POST,
  205 + entity,
  206 + byte[].class
  207 + );
  208 + if (response.getStatusCode() == HttpStatus.OK && response.getBody() != null) {
  209 + InputStream inputStream = new ByteArrayInputStream(response.getBody());
  210 + InputStreamResource resource = new InputStreamResource(inputStream);
  211 + // 构建响应头
  212 + HttpHeaders responseHeaders = new HttpHeaders();
  213 + responseHeaders.setContentType(MediaType.parseMediaType("audio/mpeg"));
  214 + responseHeaders.setContentLength(response.getBody().length);
  215 + responseHeaders.set("Content-Disposition", "inline; filename=\"speech.mp3\"");
  216 + responseHeaders.set("X-TTS-Source", "python-service");
  217 + responseHeaders.set("X-TTS-Voice", request.getVoice());
  218 + return ResponseEntity.ok()
  219 + .headers(responseHeaders)
  220 + .body(resource);
  221 + } else {
  222 + return ResponseEntity.status(response.getStatusCode()).build();
  223 + }
  224 + } catch (Exception e) {
  225 + return fallbackResponse(request);
  226 + }
  227 + }
  228 +
  229 + /**
  230 + * 快速合成接口
  231 + */
  232 + public ResponseEntity<InputStreamResource> quickSynthesize(String text, String voice) {
  233 + TTSRequestDTO request = new TTSRequestDTO();
  234 + request.setText(text);
  235 + request.setVoice(voice);
  236 + return synthesizeStream(request);
  237 + }
  238 +
  239 + /**
  240 + * 异步流式合成
  241 + */
  242 + public CompletableFuture<ResponseEntity<InputStreamResource>> synthesizeStreamAsync(TTSRequestDTO request) {
  243 + return CompletableFuture.supplyAsync(() -> synthesizeStream(request), executorService);
  244 + }
  245 +
  246 + /**
  247 + * 获取可用语音列表
  248 + */
  249 + public List<VoiceInfoDTO> getAvailableVoices() {
  250 + try {
  251 + log.info("从Python服务获取语音列表");
  252 +
  253 + ResponseEntity<Map> response = restTemplate.getForEntity(
  254 + pythonServiceUrl + "/voices",
  255 + Map.class
  256 + );
  257 +
  258 + if (response.getStatusCode() == HttpStatus.OK && response.getBody() != null) {
  259 + Map<String, Object> responseBody = response.getBody();
  260 + List<Map<String, String>> voicesData = (List<Map<String, String>>) responseBody.get("voices");
  261 +
  262 + List<VoiceInfoDTO> voices = new ArrayList<>();
  263 + for (Map<String, String> voiceData : voicesData) {
  264 + VoiceInfoDTO voice = new VoiceInfoDTO();
  265 + voice.setName(voiceData.get("name"));
  266 + voice.setLocale(voiceData.get("locale"));
  267 + voice.setGender(voiceData.get("gender"));
  268 + voice.setDisplayName(voiceData.get("displayName"));
  269 + voices.add(voice);
  270 + }
  271 +
  272 + log.info("从Python服务获取到 {} 个语音", voices.size());
  273 + return voices;
  274 + }
  275 + } catch (Exception e) {
  276 + log.error("获取Python服务语音列表失败: {}", e.getMessage());
  277 + }
  278 +
  279 + // 返回默认语音列表作为降级
  280 + return getDefaultVoices();
  281 + }
  282 +
  283 + /**
  284 + * 获取语音详情
  285 + */
  286 + public VoiceInfoDTO getVoiceDetail(String name) {
  287 + List<VoiceInfoDTO> voices = getAvailableVoices();
  288 + return voices.stream()
  289 + .filter(v -> v.getName().equals(name))
  290 + .findFirst()
  291 + .orElse(null);
  292 + }
  293 +
  294 + /**
  295 + * 健康检查
  296 + */
  297 + public boolean healthCheck() {
  298 + try {
  299 + ResponseEntity<Map> response = restTemplate.getForEntity(
  300 + pythonServiceUrl + "/health",
  301 + Map.class
  302 + );
  303 +
  304 + boolean healthy = response.getStatusCode() == HttpStatus.OK &&
  305 + "healthy".equals(response.getBody().get("status"));
  306 +
  307 + log.info("Python服务健康状态: {}", healthy ? "健康" : "异常");
  308 + return healthy;
  309 +
  310 + } catch (Exception e) {
  311 + log.error("Python服务健康检查失败: {}", e.getMessage());
  312 + return false;
  313 + }
  314 + }
  315 +
  316 + /**
  317 + * 批量合成
  318 + */
  319 + public List<ResponseEntity<InputStreamResource>> batchSynthesize(List<TTSRequestDTO> requests) {
  320 + List<ResponseEntity<InputStreamResource>> results = new ArrayList<>();
  321 +
  322 + for (TTSRequestDTO request : requests) {
  323 + results.add(synthesizeStream(request));
  324 + }
  325 +
  326 + return results;
  327 + }
  328 +
  329 + /**
  330 + * 直接合成(用于测试)
  331 + */
  332 + public ResponseEntity<InputStreamResource> synthesizeDirect(TTSRequestDTO request) {
  333 + return synthesizeStream(request);
  334 + }
  335 +
  336 + /**
  337 + * 关闭服务
  338 + */
  339 + public void shutdown() {
  340 + if (executorService != null) {
  341 + executorService.shutdown();
  342 + }
  343 + log.info("Python TTS代理服务已关闭");
  344 + }
  345 +
  346 + /**
  347 + * 降级响应
  348 + */
  349 + private ResponseEntity<InputStreamResource> fallbackResponse(TTSRequestDTO request) {
  350 + try {
  351 + // 可以返回一个默认的音频文件
  352 + String fallbackText = "对不起,语音合成服务暂时不可用,请稍后重试。";
  353 + TTSRequestDTO fallbackRequest = new TTSRequestDTO();
  354 + fallbackRequest.setText(fallbackText);
  355 + fallbackRequest.setVoice("zh-CN-XiaoxiaoNeural");
  356 + // 这里可以调用本地备用的TTS服务
  357 + return synthesizeStream(fallbackRequest);
  358 +
  359 + } catch (Exception e) {
  360 + log.error("降级响应也失败了: {}", e.getMessage());
  361 + return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
  362 + .header("X-TTS-Error", "服务暂时不可用")
  363 + .body(null);
  364 + }
  365 + }
  366 +
  367 + /**
  368 + * 获取默认语音列表
  369 + */
  370 + private List<VoiceInfoDTO> getDefaultVoices() {
  371 + List<VoiceInfoDTO> defaultVoices = Arrays.asList(
  372 + createVoice("zh-CN-XiaoxiaoNeural", "zh-CN", "Female", "晓晓 - 中文女声"),
  373 + createVoice("zh-CN-YunyangNeural", "zh-CN", "Male", "云扬 - 中文男声"),
  374 + createVoice("en-US-JennyNeural", "en-US", "Female", "Jenny - 英文女声"),
  375 + createVoice("en-US-GuyNeural", "en-US", "Male", "Guy - 英文男声"),
  376 + createVoice("ja-JP-NanamiNeural", "ja-JP", "Female", "七海 - 日文女声"),
  377 + createVoice("ko-KR-SunHiNeural", "ko-KR", "Female", "선히 - 韩文女声")
  378 + );
  379 +
  380 + log.warn("使用默认语音列表,共 {} 个语音", defaultVoices.size());
  381 + return defaultVoices;
  382 + }
  383 +
  384 + private VoiceInfoDTO createVoice(String name, String locale, String gender, String displayName) {
  385 + VoiceInfoDTO voice = new VoiceInfoDTO();
  386 + voice.setName(name);
  387 + voice.setLocale(locale);
  388 + voice.setGender(gender);
  389 + voice.setDisplayName(displayName);
  390 + return voice;
  391 + }
  392 +}
0 393 \ No newline at end of file
... ...
src/main/java/com/xly/util/AdvancedSymbolRemover.java 0 → 100644
  1 +package com.xly.util;
  2 +
  3 +import org.slf4j.Logger;
  4 +import org.slf4j.LoggerFactory;
  5 +
  6 +import java.util.regex.Pattern;
  7 +import java.util.Set;
  8 +
  9 +public class AdvancedSymbolRemover {
  10 +
  11 + // 常用标点符号集合
  12 + private static final String CHINESE_PUNCTUATION = "。,、;:?!「」『』()【】《》<>{}〔〕〖〗〘〙〚〛~·…―--- ";
  13 + private static final String ENGLISH_PUNCTUATION = "!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~";
  14 + private static final Logger log = LoggerFactory.getLogger(AdvancedSymbolRemover.class);
  15 +
  16 +
  17 + /**
  18 + * 移除所有标点符号(保留字母、数字、中文)
  19 + */
  20 + public static String removePunctuationHtml(String text) {
  21 + try{
  22 + if (text == null || text.isEmpty()) return "";
  23 + text = HtmlCleaner.cleanHtml(text);
  24 +
  25 + // 移除中文和英文标点
  26 + text = text.replaceAll("[\\pP\\p{Punct}]", "");
  27 +
  28 + // 可选:只保留字母、数字、汉字、空格
  29 + text = text.replaceAll("[^\\p{L}\\p{N}\\p{Zs}]", "");
  30 + text = text.replaceAll("br", "");
  31 + text = text.replaceAll("<br/>", "");
  32 + text = text.replaceAll("</div>", "");
  33 + text = text.replaceAll("<div>", "");
  34 + text = text.replaceAll("&emsp;", "");
  35 + return text;
  36 + }catch (Exception e){
  37 + }
  38 + return text;
  39 + }
  40 +
  41 + /**
  42 + * 移除所有标点符号(保留字母、数字、中文)
  43 + */
  44 + public static String removePunctuation(String text) {
  45 + if (text == null || text.isEmpty()) return "";
  46 +
  47 + // 移除中文和英文标点
  48 + text = text.replaceAll("[\\pP\\p{Punct}]", "");
  49 +
  50 + // 可选:只保留字母、数字、汉字、空格
  51 + text = text.replaceAll("[^\\p{L}\\p{N}\\p{Zs}]", "");
  52 +
  53 + return text;
  54 + }
  55 +
  56 + /**
  57 + * 智能清理符号(保留必要的分隔符)
  58 + */
  59 + public static String cleanSymbolsSmart(String text) {
  60 + if (text == null) return "";
  61 +
  62 + // 1. 移除HTML标签和实体
  63 + text = removeHtmlTags(text);
  64 +
  65 + // 2. 统一空格
  66 + text = text.replaceAll("\\s+", " ");
  67 +
  68 + // 3. 移除多余标点(保留一个)
  69 + text = removeDuplicateSymbols(text);
  70 +
  71 + // 4. 清理边缘符号
  72 + text = cleanEdgeSymbols(text);
  73 +
  74 + return text.trim();
  75 + }
  76 +
  77 + /**
  78 + * 移除HTML标签
  79 + */
  80 + private static String removeHtmlTags(String text) {
  81 + return text.replaceAll("<[^>]*>", "")
  82 + .replaceAll("&[a-zA-Z0-9#]+;", "");
  83 + }
  84 +
  85 + /**
  86 + * 移除连续重复的符号(如!!!变成!)
  87 + */
  88 + private static String removeDuplicateSymbols(String text) {
  89 + // 处理连续重复的标点
  90 + text = text.replaceAll("([!?。,;:])\\1+", "$1");
  91 +
  92 + // 处理连续重复的其他符号
  93 + text = text.replaceAll("([-_+=*])\\1+", "$1");
  94 +
  95 + return text;
  96 + }
  97 +
  98 + /**
  99 + * 清理文本边缘的符号
  100 + */
  101 + private static String cleanEdgeSymbols(String text) {
  102 + // 移除开头和结尾的符号
  103 + text = text.replaceAll("^[\\p{Punct}\\s]+", "");
  104 + text = text.replaceAll("[\\p{Punct}\\s]+$", "");
  105 +
  106 + // 移除开头结尾的中文符号
  107 + String chinesePunctRegex = "^[" + Pattern.quote(CHINESE_PUNCTUATION) + "\\s]+|" +
  108 + "[" + Pattern.quote(CHINESE_PUNCTUATION) + "\\s]+$";
  109 + text = text.replaceAll(chinesePunctRegex, "");
  110 +
  111 + return text;
  112 + }
  113 +
  114 + /**
  115 + * 只保留字母和数字(最严格的清理)
  116 + */
  117 + public static String keepOnlyAlphanumeric(String text) {
  118 + if (text == null) return "";
  119 +
  120 + // 只保留:字母(包括中文)、数字
  121 + return text.replaceAll("[^\\p{L}\\p{N}]", "");
  122 + }
  123 +
  124 + /**
  125 + * 保留字母、数字和空格
  126 + */
  127 + public static String keepAlphanumericAndSpaces(String text) {
  128 + if (text == null) return "";
  129 +
  130 + // 保留:字母、数字、空格
  131 + return text.replaceAll("[^\\p{L}\\p{N}\\s]", "");
  132 + }
  133 +
  134 + /**
  135 + * 移除控制字符和不可见字符
  136 + */
  137 + public static String removeControlCharacters(String text) {
  138 + if (text == null) return "";
  139 +
  140 + // 移除控制字符(0x00-0x1F, 0x7F)
  141 + text = text.replaceAll("[\\p{Cntrl}&&[^\r\n\t]]", "");
  142 +
  143 + // 移除Unicode格式字符
  144 + text = text.replaceAll("\\p{Cf}", "");
  145 +
  146 + return text;
  147 + }
  148 +
  149 + /**
  150 + * 移除表情符号和特殊Unicode符号
  151 + */
  152 + public static String removeEmojiAndSymbols(String text) {
  153 + if (text == null) return "";
  154 +
  155 + // 移除表情符号
  156 + text = text.replaceAll("[\\x{1F600}-\\x{1F64F}]", "");
  157 + text = text.replaceAll("[\\x{1F300}-\\x{1F5FF}]", "");
  158 + text = text.replaceAll("[\\x{1F680}-\\x{1F6FF}]", "");
  159 + text = text.replaceAll("[\\x{1F700}-\\x{1F77F}]", "");
  160 +
  161 + // 移除杂项符号和象形文字
  162 + text = text.replaceAll("[\\x{1F900}-\\x{1F9FF}]", "");
  163 + text = text.replaceAll("[\\x{2600}-\\x{26FF}]", "");
  164 + text = text.replaceAll("[\\x{2700}-\\x{27BF}]", "");
  165 +
  166 + return text;
  167 + }
  168 +
  169 + /**
  170 + * 保留特定符号(白名单方式)
  171 + */
  172 + public static String keepSpecificSymbols(String text, Set<Character> allowedSymbols) {
  173 + if (text == null) return "";
  174 +
  175 + StringBuilder result = new StringBuilder();
  176 + for (char c : text.toCharArray()) {
  177 + if (Character.isLetterOrDigit(c) ||
  178 + Character.isWhitespace(c) ||
  179 + allowedSymbols.contains(c)) {
  180 + result.append(c);
  181 + }
  182 + }
  183 +
  184 + return result.toString();
  185 + }
  186 +
  187 + /**
  188 + * 按类别移除符号
  189 + */
  190 + public static String removeByCategory(String text, boolean removePunctuation,
  191 + boolean removeDigits, boolean removeSpaces) {
  192 + if (text == null) return "";
  193 +
  194 + String regex = "";
  195 +
  196 + if (removePunctuation) {
  197 + regex += "\\p{P}";
  198 + }
  199 + if (removeDigits) {
  200 + regex += "\\p{N}";
  201 + }
  202 + if (removeSpaces) {
  203 + regex += "\\s";
  204 + }
  205 +
  206 + if (!regex.isEmpty()) {
  207 + return text.replaceAll(regex + "]", "");
  208 + }
  209 +
  210 + return text;
  211 + }
  212 +}
0 213 \ No newline at end of file
... ...
src/main/java/com/xly/util/DeepCopyUtils.java 0 → 100644
  1 +package com.xly.util;
  2 +
  3 +import java.util.*;
  4 +import java.util.function.Function;
  5 +
  6 +public class DeepCopyUtils {
  7 +
  8 + /**
  9 + * 通用Map深拷贝方法
  10 + * @param original 原始Map
  11 + * @param keyCopier key拷贝函数(对于不可变对象如String,可以直接返回原对象)
  12 + * @param valueCopier value拷贝函数
  13 + */
  14 + public static <K, V> Map<K, V> deepCopyMap(
  15 + Map<K, V> original,
  16 + Function<K, K> keyCopier,
  17 + Function<V, V> valueCopier) {
  18 +
  19 + if (original == null) return null;
  20 +
  21 + Map<K, V> copy = new HashMap<>(original.size());
  22 + original.forEach((key, value) -> {
  23 + K copiedKey = keyCopier.apply(key);
  24 + V copiedValue = valueCopier.apply(value);
  25 + copy.put(copiedKey, copiedValue);
  26 + });
  27 + return copy;
  28 + }
  29 +
  30 + /**
  31 + * 自动类型推断的深拷贝(简化版)
  32 + */
  33 + @SuppressWarnings("unchecked")
  34 + public static <T> T deepCopy(T obj) {
  35 + if (obj == null) return null;
  36 +
  37 + if (obj instanceof Map) {
  38 + Map<?, ?> map = (Map<?, ?>) obj;
  39 + Map<Object, Object> copy = new HashMap<>();
  40 + map.forEach((k, v) -> {
  41 + copy.put(deepCopy(k), deepCopy(v));
  42 + });
  43 + return (T) copy;
  44 + } else if (obj instanceof List) {
  45 + List<?> list = (List<?>) obj;
  46 + List<Object> copy = new ArrayList<>();
  47 + list.forEach(item -> copy.add(deepCopy(item)));
  48 + return (T) copy;
  49 + } else if (obj instanceof Set) {
  50 + Set<?> set = (Set<?>) obj;
  51 + Set<Object> copy = new HashSet<>();
  52 + set.forEach(item -> copy.add(deepCopy(item)));
  53 + return (T) copy;
  54 + } else if (obj.getClass().isArray()) {
  55 + // 数组处理
  56 + return cloneArray(obj);
  57 + } else {
  58 + // 基本类型、字符串、不可变对象等返回原对象
  59 + // 如果需要对象拷贝,可以在这里添加序列化或反射拷贝
  60 + return obj;
  61 + }
  62 + }
  63 +
  64 + private static <T> T cloneArray(T array) {
  65 + Class<?> componentType = array.getClass().getComponentType();
  66 +
  67 + if (componentType.isPrimitive()) {
  68 + // 基本类型数组
  69 + int length = java.lang.reflect.Array.getLength(array);
  70 + Object copy = java.lang.reflect.Array.newInstance(componentType, length);
  71 + System.arraycopy(array, 0, copy, 0, length);
  72 + @SuppressWarnings("unchecked")
  73 + T result = (T) copy;
  74 + return result;
  75 + } else {
  76 + // 对象数组
  77 + Object[] objArray = (Object[]) array;
  78 + Object[] copy = (Object[]) java.lang.reflect.Array.newInstance(
  79 + componentType, objArray.length);
  80 + for (int i = 0; i < objArray.length; i++) {
  81 + copy[i] = deepCopy(objArray[i]);
  82 + }
  83 + @SuppressWarnings("unchecked")
  84 + T result = (T) copy;
  85 + return result;
  86 + }
  87 + }
  88 +}
0 89 \ No newline at end of file
... ...
src/main/java/com/xly/util/DynamicTextInjector.java 0 → 100644
  1 +package com.xly.util;
  2 +
  3 +import org.springframework.expression.ExpressionParser;
  4 +import org.springframework.expression.common.TemplateParserContext;
  5 +import org.springframework.expression.spel.standard.SpelExpressionParser;
  6 +import org.springframework.expression.spel.support.StandardEvaluationContext;
  7 +
  8 +import java.util.Map;
  9 +
  10 +/**
  11 + * 动态文本注入工具类
  12 + */
  13 +public class DynamicTextInjector {
  14 +
  15 + /**
  16 + * 使用Spring EL表达式注入
  17 + */
  18 + public static String injectWithSpEL(String template, Map<String, Object> variables) {
  19 + ExpressionParser parser = new SpelExpressionParser();
  20 + StandardEvaluationContext context = new StandardEvaluationContext();
  21 +
  22 + // 设置变量
  23 + variables.forEach(context::setVariable);
  24 +
  25 + // 解析模板
  26 + return parser.parseExpression(template,
  27 + new TemplateParserContext()).getValue(context, String.class);
  28 + }
  29 +
  30 + /**
  31 + * 自定义模板解析
  32 + */
  33 + public static String injectCustom(String template, Map<String, Object> params) {
  34 + String result = template;
  35 +
  36 + // 替换 {{variable}} 格式
  37 + for (Map.Entry<String, Object> entry : params.entrySet()) {
  38 + String placeholder = "{{" + entry.getKey() + "}}";
  39 + result = result.replace(placeholder,
  40 + entry.getValue() != null ? entry.getValue().toString() : "");
  41 + }
  42 +
  43 + // 替换 ${variable} 格式
  44 + for (Map.Entry<String, Object> entry : params.entrySet()) {
  45 + String placeholder = "${" + entry.getKey() + "}";
  46 + result = result.replace(placeholder,
  47 + entry.getValue() != null ? entry.getValue().toString() : "");
  48 + }
  49 +
  50 + return result;
  51 + }
  52 +}
0 53 \ No newline at end of file
... ...
src/main/java/com/xly/util/HtmlCleaner.java 0 → 100644
  1 +package com.xly.util;
  2 +
  3 +import org.jsoup.Jsoup;
  4 +import org.jsoup.safety.Safelist;
  5 +
  6 +/***
  7 + * @Author 钱豹
  8 + * @Date 21:22 2026/2/7
  9 + * @Param
  10 + * @return
  11 + * @Description 移除html工具类
  12 + **/
  13 +public class HtmlCleaner {
  14 + // 方法1:移除所有HTML标签,保留文本
  15 + public static String removeAllHtml(String html) {
  16 + if (html == null) return "";
  17 + return Jsoup.parse(html).text();
  18 + }
  19 +
  20 + // 方法2:允许特定的简单标签(更安全)
  21 + public static String cleanHtml(String html) {
  22 + if (html == null) return "";
  23 +
  24 + // 只保留文本和换行
  25 + return Jsoup.clean(html,
  26 + Safelist.none()
  27 + .addTags("br", "p", "div") // 可选:保留特定标签结构
  28 + .addAttributes("p", "class")
  29 + );
  30 + }
  31 +
  32 + // 方法3:保留基本格式
  33 + public static String cleanWithBasicFormatting(String html) {
  34 + if (html == null) return "";
  35 + return Jsoup.clean(html,
  36 + Safelist.basic()
  37 + .addTags("p", "br", "div")
  38 + );
  39 + }
  40 +
  41 + public static void main(String[] args) {
  42 + String html = "<div><h1>标题</h1><p>这是一段<b>加粗</b>的文字。</p><script>alert('xss')</script></div>";
  43 +
  44 + System.out.println("Jsoup文本提取: " + removeAllHtml(html));
  45 + System.out.println("清理HTML: " + cleanHtml(html));
  46 + }
  47 +}
0 48 \ No newline at end of file
... ...
src/main/java/com/xly/util/HttpsRequestUtil.java 0 → 100644
  1 +package com.xly.util;
  2 +
  3 +import cn.hutool.core.map.MapUtil;
  4 +import cn.hutool.core.util.ObjectUtil;
  5 +import cn.hutool.core.util.StrUtil;
  6 +import cn.hutool.http.Header;
  7 +import cn.hutool.http.HttpRequest;
  8 +import cn.hutool.http.HttpUtil;
  9 +import cn.hutool.json.JSONUtil;
  10 +
  11 +import javax.net.ssl.HttpsURLConnection;
  12 +import javax.net.ssl.SSLContext;
  13 +import javax.net.ssl.SSLSocketFactory;
  14 +import javax.net.ssl.TrustManager;
  15 +import java.io.BufferedReader;
  16 +import java.io.IOException;
  17 +import java.io.InputStreamReader;
  18 +import java.io.PrintWriter;
  19 +import java.net.URL;
  20 +import java.util.HashMap;
  21 +import java.util.Map;
  22 +
  23 +/**\
  24 + * HTTP 调用接口
  25 + * @author qianbao
  26 + */
  27 +public class HttpsRequestUtil {
  28 +
  29 + private static final HttpsRequestUtil me = new HttpsRequestUtil();
  30 +
  31 + private static final String GET ="GET";
  32 + private static final String MAP ="MAP";
  33 + private static final String charset_def ="UTF-8";
  34 + private static final String checkUrlString ="https";
  35 +
  36 +
  37 + public static HttpsRequestUtil me()
  38 + {
  39 + return me;
  40 + }
  41 +
  42 + private static Map<String,String> contentTypeMap = new HashMap<>(4);
  43 + static {
  44 + contentTypeMap.put("JSON","application/json");
  45 + contentTypeMap.put("XML","text/xml");
  46 + contentTypeMap.put("MAP","application/x-www-form-urlencoded; charset=UTF-8");
  47 + }
  48 +
  49 + /** 调用 url ,调用 JSON,头部传参数 */
  50 + public String doRequestHttps(String url, String param, Map<String,Object> headerMap,String sRequestType,String contentType) {
  51 + return this.doRequestHttp( url, param, headerMap, sRequestType, contentType);
  52 + }
  53 + /** 调用 url ,调用 JSON,头部传参数 */
  54 + public String doRequestHttp(String apiUrl, String sBody,
  55 + Map<String,Object> headerMap,
  56 + String sMethod,
  57 + String sBodyType
  58 + ) {
  59 + HttpRequest hr;
  60 + if (GET.equalsIgnoreCase(sMethod)){
  61 + hr = HttpUtil.createGet(apiUrl);
  62 + }else {
  63 + hr = HttpUtil.createPost(apiUrl);
  64 + }
  65 + if(StrUtil.isNotEmpty(sBody)){
  66 + Boolean bParam = "MAP".equals(sBodyType);
  67 + if(bParam){
  68 + if(JSONUtil.isJsonObj(sBody)){
  69 + Map<String,Object> map2 = JSONUtil.parseObj(sBody);
  70 + hr.form(map2);
  71 + }
  72 + }else if(JSONUtil.isJson(sBody)){
  73 + hr.body(JSONUtil.toJsonPrettyStr(sBody));
  74 + }else{
  75 + hr.body(sBody);
  76 + }
  77 + }
  78 +// hr.header(Header.CONTENT_TYPE,contentTypeMap.get(contentType)).charset(charset);
  79 + if(apiUrl.toLowerCase().startsWith(checkUrlString)){
  80 + hr.header("X-Bmob-Application-Id","2f0419a31f9casdfdsf431f6cd297fdd3e28fds4af")
  81 + .header("X-Bmob-REST-API-Key","1e03efdas82178723afdsafsda4be0f305def6708cc6");
  82 + }
  83 + //设置请求超时时间20S
  84 + hr.setConnectionTimeout(6000000);
  85 + hr.setReadTimeout(6000000);
  86 +
  87 + if(MapUtil.isNotEmpty(headerMap)){
  88 + headerMap.forEach((k,v)->{
  89 + String value ="";
  90 + if(ObjectUtil.isNotEmpty(v)){
  91 + value = v.toString();
  92 + }
  93 + hr.header(k,value);
  94 + });
  95 + }
  96 + return hr.execute().body();
  97 + }
  98 + public String doRequestHttps(String url, String param, Map<String,Object> headerMap,String sRequestType,String contentType,String charset) {
  99 + HttpRequest hr;
  100 + if (GET.equalsIgnoreCase(sRequestType)){
  101 + hr = HttpUtil.createGet(url);
  102 + }else {
  103 + hr = HttpUtil.createPost(url);
  104 + }
  105 + if(StrUtil.isNotEmpty(param)){
  106 + Boolean bParam = "MAP".equals(contentType);
  107 + if(bParam){
  108 + if(JSONUtil.isJsonObj(param)){
  109 + Map<String,Object> map2 = JSONUtil.parseObj(param);
  110 + hr.form(map2);
  111 + }
  112 + }else if(JSONUtil.isJson(param)){
  113 + hr.body(JSONUtil.toJsonPrettyStr(param));
  114 + }else{
  115 + hr.body(param);
  116 + }
  117 + }
  118 + hr.header(Header.CONTENT_TYPE,contentTypeMap.get(contentType)).charset(charset)
  119 + .header("X-Bmob-Application-Id","2f0419a31f9casdfdsf431f6cd297fdd3e28fds4af")
  120 + .header("X-Bmob-REST-API-Key","1e03efdas82178723afdsafsda4be0f305def6708cc6");
  121 + //设置请求超时时间20S
  122 + hr.setConnectionTimeout(600000);
  123 + if(MapUtil.isNotEmpty(headerMap)){
  124 + headerMap.forEach((k,v)->{
  125 + String value ="";
  126 + if(ObjectUtil.isNotEmpty(v)){
  127 + value = v.toString();
  128 + }
  129 + hr.header(k,value);
  130 + });
  131 + }
  132 + return hr.execute().body();
  133 + }
  134 +
  135 +}
0 136 \ No newline at end of file
... ...
src/main/java/com/xly/util/InputPreprocessor.java 0 → 100644
  1 +package com.xly.util;
  2 +
  3 +import org.apache.commons.lang3.StringUtils;
  4 +
  5 +public class InputPreprocessor {
  6 +
  7 + public static String preprocessWithCommons(String userInput) {
  8 + if (StringUtils.isBlank(userInput)) {
  9 + return "";
  10 + }
  11 + // normalizeSpace() 已经做了去除首尾空格和合并中间空格
  12 + return StringUtils.normalizeSpace(userInput).toLowerCase();
  13 + }
  14 +}
0 15 \ No newline at end of file
... ...
src/main/java/com/xly/util/IpUtil.java 0 → 100644
  1 +package com.xly.util;
  2 +
  3 +import jakarta.servlet.http.HttpServletRequest;
  4 +
  5 +/***
  6 + * @Author 钱豹
  7 + * @Date 23:08 2026/1/30
  8 + * @Param
  9 + * @return
  10 + * @Description IP 获取工具类
  11 + **/
  12 +public class IpUtil {
  13 +
  14 + public static String getIpAddr(HttpServletRequest request) {
  15 + if (request == null) {
  16 + return "unknown";
  17 + }
  18 +
  19 + String ip = request.getHeader("x-forwarded-for");
  20 + if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {
  21 + ip = request.getHeader("Proxy-Client-IP");
  22 + }
  23 + if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {
  24 + ip = request.getHeader("X-Forwarded-For");
  25 + }
  26 + if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {
  27 + ip = request.getHeader("WL-Proxy-Client-IP");
  28 + }
  29 + if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {
  30 + ip = request.getHeader("X-Real-IP");
  31 + }
  32 + if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {
  33 + ip = request.getRemoteAddr();
  34 + }
  35 +
  36 + return "0:0:0:0:0:0:0:1".equals(ip) ? "127.0.0.1" : ip;
  37 + }
  38 +}
0 39 \ No newline at end of file
... ...
src/main/java/com/xly/util/JsonUtils.java 0 → 100644
  1 +package com.xly.util;
  2 +
  3 +import com.fasterxml.jackson.databind.ObjectMapper;
  4 +import com.fasterxml.jackson.databind.type.CollectionType;
  5 +import com.fasterxml.jackson.databind.type.TypeFactory;
  6 +
  7 +import java.io.IOException;
  8 +import java.util.List;
  9 +
  10 +public class JsonUtils {
  11 +
  12 + private static final ObjectMapper objectMapper = new ObjectMapper();
  13 +
  14 + // JSON字符串转Java对象
  15 + public static <T> T toObject(String json, Class<T> clazz) {
  16 + try {
  17 + return objectMapper.readValue(json, clazz);
  18 + } catch (IOException e) {
  19 + throw new RuntimeException("JSON解析失败", e);
  20 + }
  21 + }
  22 +
  23 + // JSON字符串转List
  24 + public static <T> List<T> toList(String json, Class<T> clazz) {
  25 + try {
  26 + CollectionType listType = TypeFactory.defaultInstance()
  27 + .constructCollectionType(List.class, clazz);
  28 + return objectMapper.readValue(json, listType);
  29 + } catch (IOException e) {
  30 + throw new RuntimeException("JSON解析失败", e);
  31 + }
  32 + }
  33 +
  34 + // 对象转JSON字符串
  35 + public static String toJson(Object obj) {
  36 + try {
  37 + return objectMapper.writeValueAsString(obj);
  38 + } catch (IOException e) {
  39 + throw new RuntimeException("JSON生成失败", e);
  40 + }
  41 + }
  42 +}
0 43 \ No newline at end of file
... ...
src/main/java/com/xly/util/OkHttpUtil.java 0 → 100644
  1 +package com.xly.util;
  2 +
  3 +import okhttp3.*;
  4 +import okio.Buffer;
  5 +
  6 +import java.io.File;
  7 +import java.io.IOException;
  8 +import java.nio.charset.StandardCharsets;
  9 +import java.util.*;
  10 +import java.util.concurrent.TimeUnit;
  11 +
  12 +/**
  13 + * OkHttp3通用工具类
  14 + * 支持GET/POST/PUT/DELETE请求,同步/异步调用,文件上传/下载等
  15 + */
  16 +public class OkHttpUtil {
  17 +
  18 + private static volatile OkHttpUtil instance;
  19 + private OkHttpClient client;
  20 +
  21 + // 默认配置
  22 + private static final long CONNECT_TIMEOUT = 10;
  23 + private static final long READ_TIMEOUT = 30;
  24 + private static final long WRITE_TIMEOUT = 30;
  25 +
  26 + private OkHttpUtil() {
  27 + initClient();
  28 + }
  29 +
  30 + private OkHttpUtil(long connectTimeout, long readTimeout, long writeTimeout) {
  31 + initClient(connectTimeout, readTimeout, writeTimeout);
  32 + }
  33 +
  34 + /**
  35 + * 获取单例实例(默认配置)
  36 + */
  37 + public static OkHttpUtil getInstance() {
  38 + if (instance == null) {
  39 + synchronized (OkHttpUtil.class) {
  40 + if (instance == null) {
  41 + instance = new OkHttpUtil();
  42 + }
  43 + }
  44 + }
  45 + return instance;
  46 + }
  47 +
  48 + /**
  49 + * 获取自定义配置的单例
  50 + */
  51 + public static OkHttpUtil getInstance(long connectTimeout, long readTimeout, long writeTimeout) {
  52 + return new OkHttpUtil(connectTimeout, readTimeout, writeTimeout);
  53 + }
  54 +
  55 + /**
  56 + * 初始化默认客户端
  57 + */
  58 + private void initClient() {
  59 + initClient(CONNECT_TIMEOUT, READ_TIMEOUT, WRITE_TIMEOUT);
  60 + }
  61 +
  62 + /**
  63 + * 初始化自定义客户端
  64 + */
  65 + private void initClient(long connectTimeout, long readTimeout, long writeTimeout) {
  66 + client = new OkHttpClient.Builder()
  67 + .connectTimeout(connectTimeout, TimeUnit.SECONDS)
  68 + .readTimeout(readTimeout, TimeUnit.SECONDS)
  69 + .writeTimeout(writeTimeout, TimeUnit.SECONDS)
  70 + .addInterceptor(new LoggingInterceptor())
  71 + .build();
  72 + }
  73 +
  74 + /**
  75 + * 更新客户端配置
  76 + */
  77 + public void updateClient(OkHttpClient.Builder builder) {
  78 + client = builder.build();
  79 + }
  80 +
  81 + // ==================== 同步请求方法 ====================
  82 +
  83 + /**
  84 + * 同步GET请求
  85 + */
  86 + public String get(String url) throws IOException {
  87 + return get(url, null, null);
  88 + }
  89 +
  90 + public String get(String url, Map<String, String> headers) throws IOException {
  91 + return get(url, headers, null);
  92 + }
  93 +
  94 + public String get(String url, Map<String, String> headers, Map<String, String> params) throws IOException {
  95 + Request request = buildRequest(url, "GET", headers, params, null);
  96 + return executeRequest(request);
  97 + }
  98 +
  99 + /**
  100 + * 同步POST请求 - JSON
  101 + */
  102 + public String postJson(String url, String json) throws IOException {
  103 + return postJson(url, null, json);
  104 + }
  105 +
  106 + public String postJson(String url, Map<String, String> headers, String json) throws IOException {
  107 + RequestBody body = RequestBody.create(json, MediaType.parse("application/json; charset=utf-8"));
  108 + Request request = buildRequest(url, "POST", headers, null, body);
  109 + return executeRequest(request);
  110 + }
  111 +
  112 + /**
  113 + * 同步POST请求 - Form表单
  114 + */
  115 + public String postForm(String url, Map<String, String> formParams) throws IOException {
  116 + return postForm(url, null, formParams);
  117 + }
  118 +
  119 + public String postForm(String url, Map<String, String> headers, Map<String, String> formParams) throws IOException {
  120 + FormBody.Builder builder = new FormBody.Builder();
  121 + if (formParams != null) {
  122 + for (Map.Entry<String, String> entry : formParams.entrySet()) {
  123 + builder.add(entry.getKey(), entry.getValue());
  124 + }
  125 + }
  126 + Request request = buildRequest(url, "POST", headers, null, builder.build());
  127 + return executeRequest(request);
  128 + }
  129 +
  130 + /**
  131 + * 同步POST请求 - 多部分表单(文件上传)
  132 + */
  133 + public String uploadFile(String url, Map<String, String> headers,
  134 + Map<String, String> formParams,
  135 + Map<String, File> files) throws IOException {
  136 + MultipartBody.Builder builder = new MultipartBody.Builder()
  137 + .setType(MultipartBody.FORM);
  138 +
  139 + // 添加普通表单参数
  140 + if (formParams != null) {
  141 + for (Map.Entry<String, String> entry : formParams.entrySet()) {
  142 + builder.addFormDataPart(entry.getKey(), entry.getValue());
  143 + }
  144 + }
  145 +
  146 + // 添加文件
  147 + if (files != null) {
  148 + for (Map.Entry<String, File> entry : files.entrySet()) {
  149 + File file = entry.getValue();
  150 + if (file.exists()) {
  151 + RequestBody fileBody = RequestBody.create(file,
  152 + MediaType.parse("application/octet-stream"));
  153 + builder.addFormDataPart(entry.getKey(), file.getName(), fileBody);
  154 + }
  155 + }
  156 + }
  157 +
  158 + Request request = buildRequest(url, "POST", headers, null, builder.build());
  159 + return executeRequest(request);
  160 + }
  161 +
  162 + /**
  163 + * 同步PUT请求
  164 + */
  165 + public String putJson(String url, String json) throws IOException {
  166 + return putJson(url, null, json);
  167 + }
  168 +
  169 + public String putJson(String url, Map<String, String> headers, String json) throws IOException {
  170 + RequestBody body = RequestBody.create(json, MediaType.parse("application/json; charset=utf-8"));
  171 + Request request = buildRequest(url, "PUT", headers, null, body);
  172 + return executeRequest(request);
  173 + }
  174 +
  175 + /**
  176 + * 同步DELETE请求
  177 + */
  178 + public String delete(String url) throws IOException {
  179 + return delete(url, null);
  180 + }
  181 +
  182 + public String delete(String url, Map<String, String> headers) throws IOException {
  183 + Request request = buildRequest(url, "DELETE", headers, null, null);
  184 + return executeRequest(request);
  185 + }
  186 +
  187 + // ==================== 异步请求方法 ====================
  188 +
  189 + /**
  190 + * 异步GET请求
  191 + */
  192 + public void getAsync(String url, Callback callback) {
  193 + getAsync(url, null, null, callback);
  194 + }
  195 +
  196 + public void getAsync(String url, Map<String, String> headers,
  197 + Map<String, String> params, Callback callback) {
  198 + Request request = buildRequest(url, "GET", headers, params, null);
  199 + executeAsync(request, callback);
  200 + }
  201 +
  202 + /**
  203 + * 异步POST请求 - JSON
  204 + */
  205 + public void postJsonAsync(String url, String json, Callback callback) {
  206 + postJsonAsync(url, null, json, callback);
  207 + }
  208 +
  209 + public void postJsonAsync(String url, Map<String, String> headers,
  210 + String json, Callback callback) {
  211 + RequestBody body = RequestBody.create(json, MediaType.parse("application/json; charset=utf-8"));
  212 + Request request = buildRequest(url, "POST", headers, null, body);
  213 + executeAsync(request, callback);
  214 + }
  215 +
  216 + // ==================== 通用构建方法 ====================
  217 +
  218 + /**
  219 + * 构建请求
  220 + */
  221 + private Request buildRequest(String url, String method,
  222 + Map<String, String> headers,
  223 + Map<String, String> params,
  224 + RequestBody body) {
  225 + // 处理URL参数
  226 + HttpUrl.Builder urlBuilder = Objects.requireNonNull(HttpUrl.parse(url)).newBuilder();
  227 + if (params != null && !params.isEmpty()) {
  228 + for (Map.Entry<String, String> entry : params.entrySet()) {
  229 + urlBuilder.addQueryParameter(entry.getKey(), entry.getValue());
  230 + }
  231 + }
  232 +
  233 + // 构建请求
  234 + Request.Builder requestBuilder = new Request.Builder()
  235 + .url(urlBuilder.build());
  236 +
  237 + // 添加请求头
  238 + if (headers != null && !headers.isEmpty()) {
  239 + for (Map.Entry<String, String> entry : headers.entrySet()) {
  240 + requestBuilder.addHeader(entry.getKey(), entry.getValue());
  241 + }
  242 + }
  243 +
  244 + // 设置请求方法和请求体
  245 + switch (method.toUpperCase()) {
  246 + case "GET":
  247 + requestBuilder.get();
  248 + break;
  249 + case "POST":
  250 + if (body != null) {
  251 + requestBuilder.post(body);
  252 + } else {
  253 + requestBuilder.post(RequestBody.create("", null));
  254 + }
  255 + break;
  256 + case "PUT":
  257 + requestBuilder.put(body != null ? body : RequestBody.create("", null));
  258 + break;
  259 + case "DELETE":
  260 + requestBuilder.delete(body != null ? body : RequestBody.create("", null));
  261 + break;
  262 + default:
  263 + throw new IllegalArgumentException("Unsupported HTTP method: " + method);
  264 + }
  265 +
  266 + return requestBuilder.build();
  267 + }
  268 +
  269 + /**
  270 + * 执行同步请求
  271 + */
  272 + private String executeRequest(Request request) throws IOException {
  273 + try (Response response = client.newCall(request).execute()) {
  274 + if (!response.isSuccessful()) {
  275 + throw new IOException("Unexpected code: " + response.code() + ", message: " + response.message());
  276 + }
  277 + ResponseBody body = response.body();
  278 + return body != null ? body.string() : "";
  279 + }
  280 + }
  281 +
  282 + /**
  283 + * 执行异步请求
  284 + */
  285 + private void executeAsync(Request request, Callback callback) {
  286 + client.newCall(request).enqueue(callback != null ? callback : new DefaultCallback());
  287 + }
  288 +
  289 + // ==================== 工具方法 ====================
  290 +
  291 + /**
  292 + * 将字符串数组转换为Header Map
  293 + * 格式: ["key1", "value1", "key2", "value2", ...]
  294 + */
  295 + public static Map<String, String> arrayToHeaderMap(String[] headerArray) {
  296 + Map<String, String> headers = new HashMap<>();
  297 + if (headerArray == null || headerArray.length % 2 != 0) {
  298 + return headers;
  299 + }
  300 +
  301 + for (int i = 0; i < headerArray.length; i += 2) {
  302 + if (i + 1 < headerArray.length) {
  303 + headers.put(headerArray[i], headerArray[i + 1]);
  304 + }
  305 + }
  306 + return headers;
  307 + }
  308 +
  309 + /**
  310 + * 将字符串数组转换为参数Map
  311 + */
  312 + public static Map<String, String> arrayToParamMap(String[] paramArray) {
  313 + return arrayToHeaderMap(paramArray); // 逻辑相同
  314 + }
  315 +
  316 + /**
  317 + * 构建URL参数字符串
  318 + */
  319 + public static String buildQueryString(Map<String, String> params) {
  320 + if (params == null || params.isEmpty()) {
  321 + return "";
  322 + }
  323 +
  324 + StringBuilder sb = new StringBuilder();
  325 + for (Map.Entry<String, String> entry : params.entrySet()) {
  326 + if (sb.length() > 0) {
  327 + sb.append("&");
  328 + }
  329 + sb.append(entry.getKey())
  330 + .append("=")
  331 + .append(entry.getValue());
  332 + }
  333 + return sb.toString();
  334 + }
  335 +
  336 + /**
  337 + * 下载文件
  338 + */
  339 + public boolean downloadFile(String url, String savePath) throws IOException {
  340 + Request request = new Request.Builder()
  341 + .url(url)
  342 + .build();
  343 +
  344 + try (Response response = client.newCall(request).execute()) {
  345 + if (response.isSuccessful()) {
  346 + ResponseBody body = response.body();
  347 + if (body != null) {
  348 + File file = new File(savePath);
  349 + // 这里可以添加文件写入逻辑
  350 + // 或者使用body.byteStream()处理大文件
  351 + return true;
  352 + }
  353 + }
  354 + return false;
  355 + }
  356 + }
  357 +
  358 + // ==================== 内部类 ====================
  359 +
  360 + /**
  361 + * 默认回调
  362 + */
  363 + private static class DefaultCallback implements Callback {
  364 + @Override
  365 + public void onFailure(Call call, IOException e) {
  366 + System.err.println("Request failed: " + e.getMessage());
  367 + e.printStackTrace();
  368 + }
  369 +
  370 + @Override
  371 + public void onResponse(Call call, Response response) throws IOException {
  372 + if (response.isSuccessful()) {
  373 + String responseData = response.body().string();
  374 + System.out.println("Response: " + responseData);
  375 + } else {
  376 + System.err.println("Request failed with code: " + response.code());
  377 + }
  378 + }
  379 + }
  380 +
  381 + /**
  382 + * 日志拦截器
  383 + */
  384 + private static class LoggingInterceptor implements Interceptor {
  385 + @Override
  386 + public Response intercept(Chain chain) throws IOException {
  387 + Request request = chain.request();
  388 +
  389 + long startTime = System.nanoTime();
  390 + System.out.println(String.format("Sending request %s on %s%n%s",
  391 + request.url(), chain.connection(), request.headers()));
  392 +
  393 + // 打印请求体(如果是可读的)
  394 + if (request.body() != null) {
  395 + Buffer buffer = new Buffer();
  396 + request.body().writeTo(buffer);
  397 + System.out.println("Request Body: " + buffer.readString(StandardCharsets.UTF_8));
  398 + }
  399 +
  400 + Response response = chain.proceed(request);
  401 +
  402 + long endTime = System.nanoTime();
  403 + System.out.println(String.format("Received response for %s in %.1fms%n%s",
  404 + response.request().url(), (endTime - startTime) / 1e6d, response.headers()));
  405 +
  406 + return response;
  407 + }
  408 + }
  409 +
  410 + // ==================== Getter/Setter ====================
  411 +
  412 + public OkHttpClient getClient() {
  413 + return client;
  414 + }
  415 +
  416 + public void setClient(OkHttpClient client) {
  417 + this.client = client;
  418 + }
  419 +
  420 + public static void main(String[] args) {
  421 + // 1. 获取实例
  422 + OkHttpUtil httpUtil = OkHttpUtil.getInstance();
  423 +
  424 + // 2. 同步GET请求
  425 + try {
  426 + // 普通GET
  427 + String result = httpUtil.get("https://api.example.com/data");
  428 + System.out.println(result);
  429 +
  430 + // 带参数的GET
  431 + Map<String, String> params = new HashMap<>();
  432 + params.put("page", "1");
  433 + params.put("size", "10");
  434 + String result2 = httpUtil.get("https://api.example.com/data", null, params);
  435 +
  436 + // 带Header的GET
  437 + String[] headersArray = {"Authorization", "Bearer token123", "Accept", "application/json"};
  438 + Map<String, String> headers = OkHttpUtil.arrayToHeaderMap(headersArray);
  439 + String result3 = httpUtil.get("https://api.example.com/data", headers, params);
  440 +
  441 + } catch (IOException e) {
  442 + e.printStackTrace();
  443 + }
  444 +
  445 + // 3. 同步POST请求
  446 + try {
  447 + // JSON POST
  448 + String json = "{\"name\":\"张三\",\"age\":25}";
  449 + String result = httpUtil.postJson("https://api.example.com/user", json);
  450 +
  451 + // Form POST
  452 + Map<String, String> formParams = new HashMap<>();
  453 + formParams.put("username", "admin");
  454 + formParams.put("password", "123456");
  455 + String result2 = httpUtil.postForm("https://api.example.com/login", formParams);
  456 +
  457 + } catch (IOException e) {
  458 + e.printStackTrace();
  459 + }
  460 +
  461 + // 4. 异步请求
  462 + httpUtil.getAsync("https://api.example.com/data", new Callback() {
  463 + @Override
  464 + public void onFailure(Call call, IOException e) {
  465 + System.err.println("Request failed: " + e.getMessage());
  466 + }
  467 +
  468 + @Override
  469 + public void onResponse(Call call, Response response) throws IOException {
  470 + if (response.isSuccessful()) {
  471 + String responseData = response.body().string();
  472 + System.out.println("Async response: " + responseData);
  473 + }
  474 + }
  475 + });
  476 +
  477 + // 5. 文件上传
  478 + try {
  479 + Map<String, String> formParams = new HashMap<>();
  480 + formParams.put("description", "test file");
  481 +
  482 + Map<String, File> files = new HashMap<>();
  483 + files.put("file", new File("/path/to/file.txt"));
  484 +
  485 + String result = httpUtil.uploadFile(
  486 + "https://api.example.com/upload",
  487 + null,
  488 + formParams,
  489 + files
  490 + );
  491 +
  492 + } catch (IOException e) {
  493 + e.printStackTrace();
  494 + }
  495 + }
  496 +}
0 497 \ No newline at end of file
... ...
src/main/java/com/xly/util/ParamValidateUtil.java 0 → 100644
  1 +package com.xly.util;
  2 +
  3 +import com.fasterxml.jackson.databind.ObjectMapper;
  4 +import com.xly.entity.ParamRule;
  5 +import lombok.RequiredArgsConstructor;
  6 +import lombok.extern.slf4j.Slf4j;
  7 +import org.springframework.stereotype.Component;
  8 +
  9 +import java.util.Map;
  10 +import java.util.Objects;
  11 +
  12 +/**
  13 + * 参数校验工具
  14 + */
  15 +@Slf4j
  16 +@Component
  17 +@RequiredArgsConstructor
  18 +public class ParamValidateUtil {
  19 + private final ObjectMapper objectMapper;
  20 +
  21 + /**
  22 + * 校验参数
  23 + * @param paramRulesJson 数据库中的参数规则JSON
  24 + * @param params 大模型解析的JSON参数
  25 + */
  26 + public void validate(String paramRulesJson, Map<String, Object> params) {
  27 + try {
  28 + // 解析参数规则
  29 + Map<String, ParamRule> ruleMap = objectMapper.readValue(
  30 + paramRulesJson,
  31 + objectMapper.getTypeFactory().constructMapType(
  32 + Map.class, String.class, ParamRule.class
  33 + )
  34 + );
  35 +
  36 + if (Objects.isNull(ruleMap) || ruleMap.isEmpty()) {
  37 + return;
  38 + }
  39 + // 遍历校验
  40 + for (Map.Entry<String, ParamRule> entry : ruleMap.entrySet()) {
  41 + String paramName = entry.getKey();
  42 + ParamRule rule = entry.getValue();
  43 + Object paramValue = params.get(paramName);
  44 +
  45 + // 非空校验
  46 + if (Boolean.TRUE.equals(rule.getBEmpty ()) && (Objects.isNull(paramValue) || paramValue.toString().isBlank())) {
  47 + throw new IllegalArgumentException(paramName + "不能为空");
  48 + }
  49 + // 类型校验 根据参数类型校验
  50 + if (Objects.nonNull(paramValue) && !paramValue.toString().isBlank()) {
  51 + String type = rule.getSType();
  52 +
  53 +// if ("number".equals(type) && ValiDataUtil.me().isPureNumber(paramValue.toString())) {
  54 +// throw new IllegalArgumentException(paramName + "必须为数字类型");
  55 +// }
  56 + }
  57 +
  58 + }
  59 + } catch (Exception e) {
  60 + log.error("参数校验失败", e);
  61 + throw new IllegalArgumentException("参数规则解析失败:" + e.getMessage());
  62 + }
  63 + }
  64 +
  65 +
  66 +}
0 67 \ No newline at end of file
... ...
src/main/java/com/xly/util/SqlExecuteUtil.java 0 → 100644
  1 +package com.xly.util;
  2 +
  3 +
  4 +import com.xly.exception.sqlexception.SqlExecuteException;
  5 +import org.slf4j.Logger;
  6 +import org.slf4j.LoggerFactory;
  7 +import org.springframework.jdbc.core.JdbcTemplate;
  8 +import org.springframework.jdbc.core.RowMapper;
  9 +import org.springframework.stereotype.Component;
  10 +
  11 +import javax.annotation.Resource;
  12 +import java.sql.ResultSet;
  13 +import java.sql.ResultSetMetaData;
  14 +import java.sql.SQLException;
  15 +import java.util.LinkedHashMap;
  16 +import java.util.List;
  17 +import java.util.Map;
  18 +
  19 +/**
  20 + * MySQL SQL执行工具(基于Spring JdbcTemplate)
  21 + */
  22 +@Component
  23 +public class SqlExecuteUtil {
  24 + private static final Logger log = LoggerFactory.getLogger(SqlExecuteUtil.class);
  25 + @Resource
  26 + private JdbcTemplate jdbcTemplate; // Spring自动注入,无需手动配置
  27 +
  28 + /**
  29 + * 执行MySQL SELECT语句,返回结构化结果
  30 + * @param sql 已校验的可执行SQL
  31 + * @return List<Map> 保持列顺序
  32 + */
  33 + public List<Map<String, Object>> executeSelectSql(String sql) {
  34 + try {
  35 + log.info("开始执行MySQL SELECT语句:{}", sql);
  36 + // 自定义RowMapper,解析结果集为LinkedHashMap(保持列顺序)
  37 + return jdbcTemplate.query(sql, (RowMapper<Map<String, Object>>) (rs, rowNum) -> {
  38 + Map<String, Object> rowMap = new LinkedHashMap<>();
  39 + ResultSetMetaData metaData = rs.getMetaData();
  40 + int columnCount = metaData.getColumnCount();
  41 + for (int i = 1; i <= columnCount; i++) {
  42 + rowMap.put(metaData.getColumnName(i), rs.getObject(i));
  43 + }
  44 + return rowMap;
  45 + });
  46 + } catch (Exception e) {
  47 + log.error("MySQL SQL执行失败", e);
  48 + throw new SqlExecuteException("SQL执行失败!错误信息:" + e.getMessage(), e);
  49 + }
  50 + }
  51 +}
0 52 \ No newline at end of file
... ...
src/main/java/com/xly/util/SqlValidateUtil.java 0 → 100644
  1 +package com.xly.util;
  2 +
  3 +
  4 +import com.xly.exception.sqlexception.SqlValidateException;
  5 +import net.sf.jsqlparser.JSQLParserException;
  6 +import net.sf.jsqlparser.parser.CCJSqlParserUtil;
  7 +import net.sf.jsqlparser.statement.Statement;
  8 +import net.sf.jsqlparser.statement.StatementVisitorAdapter;
  9 +import net.sf.jsqlparser.statement.delete.Delete;
  10 +import net.sf.jsqlparser.statement.drop.Drop;
  11 +import net.sf.jsqlparser.statement.insert.Insert;
  12 +import net.sf.jsqlparser.statement.update.Update;
  13 +import org.slf4j.Logger;
  14 +import org.slf4j.LoggerFactory;
  15 +
  16 +import java.util.Arrays;
  17 +import java.util.List;
  18 +import java.util.regex.Pattern;
  19 +
  20 +/**
  21 + * SQL强校验工具(MySQL专属)
  22 + */
  23 +public class SqlValidateUtil {
  24 + private static final Logger log = LoggerFactory.getLogger(SqlValidateUtil.class);
  25 +
  26 + // 危险SQL关键词(生产可根据业务扩展)
  27 + private static final List<String> DANGER_KEYWORDS = Arrays.asList(
  28 + "DROP", "ALTER", "TRUNCATE", "DELETE", "INSERT", "UPDATE", "CREATE",
  29 + "RENAME", "REPLACE", "GRANT", "REVOKE", "CALL", "SHUTDOWN", "LOAD"
  30 + );
  31 + // 关键词匹配正则(忽略大小写,单词边界匹配,避免误判)
  32 + private static final Pattern DANGER_KEYWORD_PATTERN = Pattern.compile(
  33 + "\\b(" + String.join("|", DANGER_KEYWORDS) + ")\\b",
  34 + Pattern.CASE_INSENSITIVE
  35 + );
  36 +
  37 + /**
  38 + * MySQL SQL全量强校验
  39 + * @param sql 生成的SQL语句
  40 + */
  41 + public static void validateMysqlSql(String sql) {
  42 + log.info("开始MySQL SQL强校验,待校验SQL:{}", sql);
  43 + // 1. 空值/空白校验
  44 + if (sql == null || sql.trim().isEmpty()) {
  45 + throw new SqlValidateException("校验失败:生成的SQL语句为空");
  46 + }
  47 + String cleanSql = sql.trim();
  48 +
  49 + // 2. 危险关键词过滤
  50 + if (DANGER_KEYWORD_PATTERN.matcher(cleanSql).find()) {
  51 + throw new SqlValidateException("校验失败:SQL包含危险关键词,禁止执行!危险关键词:" + DANGER_KEYWORDS);
  52 + }
  53 +
  54 + // 3. 语法校验 + 非SELECT语句精准拦截
  55 + try {
  56 + Statement statement = CCJSqlParserUtil.parse(cleanSql);
  57 + // 遍历SQL语句,拦截INSERT/UPDATE/DELETE/DROP等非查询语句
  58 + statement.accept(new StatementVisitorAdapter() {
  59 + @Override
  60 + public void visit(Insert insert) {
  61 + throw new SqlValidateException("校验失败:禁止执行INSERT语句");
  62 + }
  63 +
  64 + @Override
  65 + public void visit(Update update) {
  66 + throw new SqlValidateException("校验失败:禁止执行UPDATE语句");
  67 + }
  68 +
  69 + @Override
  70 + public void visit(Delete delete) {
  71 + throw new SqlValidateException("校验失败:禁止执行DELETE语句");
  72 + }
  73 +
  74 + @Override
  75 + public void visit(Drop drop) {
  76 + throw new SqlValidateException("校验失败:禁止执行DROP语句");
  77 + }
  78 + });
  79 + } catch (JSQLParserException e) {
  80 + throw new SqlValidateException("校验失败:SQL语法错误!错误信息:" + e.getMessage(), e);
  81 + } catch (SqlValidateException e) {
  82 + throw e; // 抛出拦截的非SELECT异常
  83 + } catch (Exception e) {
  84 + throw new SqlValidateException("校验失败:SQL解析异常!错误信息:" + e.getMessage(), e);
  85 + }
  86 +
  87 + log.info("MySQL SQL强校验通过");
  88 + }
  89 +
  90 + /**
  91 + * 清理模型生成的多余符号(```sql/```/换行),避免SQL执行报错
  92 + */
  93 + public static String cleanSqlSymbol(String sql) {
  94 + if (sql == null) {
  95 + return "";
  96 + }
  97 + return sql.replace("```sql", "")
  98 + .replace("```", "")
  99 + .replaceAll("\\n|\\r", " ")
  100 + .trim();
  101 + }
  102 +}
... ...
src/main/java/com/xly/util/ValiDataUtil.java 0 → 100644
  1 +package com.xly.util;
  2 +
  3 +import lombok.extern.slf4j.Slf4j;
  4 +
  5 +@Slf4j
  6 +public class ValiDataUtil {
  7 +
  8 + private static final ValiDataUtil me = new ValiDataUtil();
  9 +
  10 + public static ValiDataUtil me()
  11 + {
  12 + return me;
  13 + }
  14 + /**
  15 + * 判断输入是否为纯数字(用于识别客户的序号选择)
  16 + */
  17 + public boolean isPureNumber(String str) {
  18 + if (str == null || str.trim().isEmpty()) {
  19 + return false;
  20 + }
  21 + return str.trim().matches("^[0-9]+$");
  22 + }
  23 +
  24 +
  25 +
  26 +}
... ...
src/main/java/com/xly/web/PageController.java 0 → 100644
  1 +package com.xly.web;
  2 +
  3 +import org.springframework.stereotype.Controller;
  4 +import org.springframework.web.bind.annotation.GetMapping;
  5 +import org.springframework.web.bind.annotation.RequestMapping;
  6 +
  7 +/**
  8 + * 页面跳转控制器
  9 + * 修复路径映射、视图名称、重定向路径问题
  10 + */
  11 +@Controller
  12 +@RequestMapping("/chat") // 类级别路径前缀:/xlyAi/chat(结合context-path)
  13 +public class PageController {
  14 +//"C:\Users\Administrator\AppData\Local\Programs\Python\Python314\Scripts\tts_cli.py" -t "多层测试" -e cute -o "outputs\2024\01\15\test.mp3"
  15 + /**
  16 + * 聊天页面
  17 + * 访问地址:http://localhost:8099/xlyAi/chat
  18 + */
  19 + @GetMapping // 方法级别无需加路径,继承类的@RequestMapping
  20 + public String chatPage() {
  21 + // 视图名称只需写 "chat",Spring会自动拼接:prefix + "chat" + suffix
  22 + // 最终路径:classpath:/templates/chat.html
  23 + return "chat";
  24 + }
  25 +
  26 + @GetMapping ("/tts")// 方法级别无需加路径,继承类的@RequestMapping
  27 + public String tts() {
  28 + // 视图名称只需写 "chat",Spring会自动拼接:prefix + "chat" + suffix
  29 + // 最终路径:classpath:/templates/chat.html
  30 + return "tts";
  31 + }
  32 +
  33 + /**
  34 + * 首页重定向
  35 + * 访问地址:http://localhost:8099/xlyAi/
  36 + */
  37 + @GetMapping("/../") // 匹配根路径 /xlyAi/(规避@RequestMapping("/chat")的影响)
  38 + public String index() {
  39 + // context-path已包含/xlyAi,重定向只需写相对路径/chat
  40 + return "redirect:/chat";
  41 + }
  42 +}
0 43 \ No newline at end of file
... ...
src/main/java/com/xly/web/TTSStreamController.java 0 → 100644
  1 +package com.xly.web;
  2 +
  3 +import com.xly.runner.AppStartupRunner;
  4 +import com.xly.service.UserSceneSessionService;
  5 +import com.xly.tool.DynamicToolProvider;
  6 +import com.xly.tts.bean.*;
  7 +import com.xly.tts.service.PythonTtsProxyService;
  8 +import lombok.RequiredArgsConstructor;
  9 +import lombok.extern.slf4j.Slf4j;
  10 +import org.springframework.core.io.InputStreamResource;
  11 +import org.springframework.http.ResponseEntity;
  12 +import org.springframework.web.bind.annotation.*;
  13 +import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
  14 +
  15 +import javax.annotation.PostConstruct;
  16 +import javax.annotation.PreDestroy;
  17 +import java.io.InputStream;
  18 +import java.util.List;
  19 +import java.util.concurrent.CompletableFuture;
  20 +
  21 +@Slf4j
  22 +@RestController
  23 +@RequestMapping("/api/tts")
  24 +@RequiredArgsConstructor
  25 +public class TTSStreamController {
  26 +
  27 + private final PythonTtsProxyService pythonTtsProxyService;
  28 + private final DynamicToolProvider dynamicToolProvider;
  29 + private final UserSceneSessionService userSceneSessionService;
  30 +
  31 + private final AppStartupRunner appStartupRunner;
  32 +
  33 +
  34 +
  35 +
  36 + /***
  37 + * @Author 钱豹
  38 + * @Date 14:32 2026/2/10
  39 + * @Param [request]
  40 + * @return org.springframework.http.ResponseEntity<com.xly.tts.bean.TTSResponseDTO>
  41 + * @Description 初始化AI所有变量 热启动
  42 + **/
  43 + @PostMapping("/initTool")
  44 + public ResponseEntity<TTSResponseDTO> initTool(@RequestBody TTSRequestDTO request) {
  45 + appStartupRunner.cleanAllInit();
  46 + userSceneSessionService.cleanAllSession();
  47 + dynamicToolProvider.cleanAllToolProvider();
  48 + //方法重新初始化
  49 + dynamicToolProvider.init();
  50 + return pythonTtsProxyService.initTool(request);
  51 + }
  52 +
  53 + /**
  54 + * 提取报修结构化信息
  55 + */
  56 + @PostMapping("/init")
  57 + public ResponseEntity<TTSResponseDTO> init(@RequestBody TTSRequestDTO request) {
  58 + return pythonTtsProxyService.init(request);
  59 + }
  60 +
  61 + @PostConstruct
  62 + public void init() {
  63 + log.info("TTS Stream Controller initialized");
  64 + log.info("Python TTS Service URL: http://localhost:8000");
  65 + }
  66 +
  67 + @PreDestroy
  68 + public void cleanup() {
  69 + log.info("TTS Stream Controller shutting down");
  70 + pythonTtsProxyService.shutdown();
  71 + }
  72 +
  73 + /**
  74 + * 流式合成语音(代理到Python服务)
  75 + */
  76 + @PostMapping("/stream/query")
  77 + public ResponseEntity<TTSResponseDTO> stream(@RequestBody TTSRequestDTO request) {
  78 + return pythonTtsProxyService.synthesizeStreamAi(request);
  79 + }
  80 +
  81 + /**
  82 + * 流式合成语音(异步)
  83 + */
  84 + @PostMapping("/async-stream")
  85 + public CompletableFuture<ResponseEntity<InputStreamResource>> asyncStreamSynthesize(
  86 + @RequestBody TTSRequestDTO request) {
  87 + log.info("收到异步流式合成请求");
  88 + return pythonTtsProxyService.synthesizeStreamAsync(request);
  89 + }
  90 +
  91 + /**
  92 + * 直接调用Python服务合成
  93 + */
  94 + @PostMapping("/direct-stream")
  95 + public ResponseEntity<InputStreamResource> directStreamSynthesize(@RequestBody TTSRequestDTO request) {
  96 + log.info("收到直接合成请求");
  97 + return pythonTtsProxyService.synthesizeDirect(request);
  98 + }
  99 +
  100 + /**
  101 + * 简化的流式接口
  102 + */
  103 + @PostMapping("/quick-stream")
  104 + public ResponseEntity<InputStreamResource> quickStream(
  105 + @RequestParam String text,
  106 + @RequestParam(defaultValue = "zh-CN-XiaoxiaoNeural") String voice) {
  107 +
  108 + log.info("收到快速合成请求: voice={}, text={}", voice,
  109 + text.length() > 50 ? text.substring(0, 50) + "..." : text);
  110 +
  111 + return pythonTtsProxyService.quickSynthesize(text, voice);
  112 + }
  113 +
  114 + /**
  115 + * GET方式的快速合成接口
  116 + */
  117 + @GetMapping("/quick-stream")
  118 + public ResponseEntity<InputStreamResource> quickStreamGet(
  119 + @RequestParam String text,
  120 + @RequestParam(defaultValue = "zh-CN-XiaoxiaoNeural") String voice) {
  121 + return quickStream(text, voice);
  122 + }
  123 +
  124 + /**
  125 + * 获取所有可用语音
  126 + */
  127 + @GetMapping("/voices")
  128 + public ResponseEntity<List<VoiceInfoDTO>> getVoices() {
  129 + log.info("收到获取语音列表请求");
  130 + List<VoiceInfoDTO> voices = pythonTtsProxyService.getAvailableVoices();
  131 + return ResponseEntity.ok(voices);
  132 + }
  133 +
  134 + /**
  135 + * 获取特定语音详情
  136 + */
  137 + @GetMapping("/voices/{name}")
  138 + public ResponseEntity<VoiceInfoDTO> getVoiceDetail(@PathVariable String name) {
  139 + log.info("收到获取语音详情请求: {}", name);
  140 + VoiceInfoDTO voice = pythonTtsProxyService.getVoiceDetail(name);
  141 + if (voice != null) {
  142 + return ResponseEntity.ok(voice);
  143 + } else {
  144 + return ResponseEntity.notFound().build();
  145 + }
  146 + }
  147 +
  148 + /**
  149 + * 健康检查(同时检查Java服务和Python服务)
  150 + */
  151 + @GetMapping("/health")
  152 + public ResponseEntity<Object> healthCheck() {
  153 + boolean pythonServiceHealthy = pythonTtsProxyService.healthCheck();
  154 +
  155 + HealthStatus healthStatus = new HealthStatus();
  156 + healthStatus.setJavaService("healthy");
  157 + healthStatus.setPythonService(pythonServiceHealthy ? "healthy" : "unhealthy");
  158 + healthStatus.setTimestamp(System.currentTimeMillis());
  159 + healthStatus.setMessage(pythonServiceHealthy ?
  160 + "所有服务运行正常" : "Python TTS服务不可用");
  161 +
  162 + if (pythonServiceHealthy) {
  163 + return ResponseEntity.ok(healthStatus);
  164 + } else {
  165 + return ResponseEntity.status(503).body(healthStatus);
  166 + }
  167 + }
  168 +
  169 + /**
  170 + * 简单健康检查(仅返回状态)
  171 + */
  172 + @GetMapping("/health/simple")
  173 + public ResponseEntity<String> healthCheckSimple() {
  174 + boolean pythonServiceHealthy = pythonTtsProxyService.healthCheck();
  175 + if (pythonServiceHealthy) {
  176 + return ResponseEntity.ok("TTS服务运行正常");
  177 + } else {
  178 + return ResponseEntity.status(503).body("Python TTS服务不可用");
  179 + }
  180 + }
  181 +
  182 + /**
  183 + * 批处理合成
  184 + */
  185 + @PostMapping("/batch")
  186 + public ResponseEntity<List<ResponseEntity<InputStreamResource>>> batchSynthesize(
  187 + @RequestBody List<TTSRequestDTO> requests) {
  188 + log.info("收到批量合成请求,数量: {}", requests.size());
  189 + List<ResponseEntity<InputStreamResource>> results = pythonTtsProxyService.batchSynthesize(requests);
  190 + return ResponseEntity.ok(results);
  191 + }
  192 +
  193 + /**
  194 + * SSE流式输出(Server-Sent Events)
  195 + */
  196 + @GetMapping(value = "/sse-stream", produces = "text/event-stream")
  197 + public ResponseEntity<StreamingResponseBody> sseStream(
  198 + @RequestParam String text,
  199 + @RequestParam(defaultValue = "zh-CN-XiaoxiaoNeural") String voice) {
  200 +
  201 + log.info("收到SSE流式请求: voice={}", voice);
  202 +
  203 + TTSRequestDTO request = new TTSRequestDTO();
  204 + request.setText(text);
  205 + request.setVoice(voice);
  206 +
  207 + StreamingResponseBody responseBody = outputStream -> {
  208 + try {
  209 + outputStream.write(("event: audio-start\ndata: \n\n").getBytes());
  210 + outputStream.flush();
  211 +
  212 + // 调用Python服务获取音频
  213 + ResponseEntity<InputStreamResource> response = pythonTtsProxyService.synthesizeStream(request);
  214 +
  215 + if (response.getBody() != null) {
  216 + InputStream inputStream = response.getBody().getInputStream();
  217 + byte[] buffer = new byte[1024];
  218 + int bytesRead;
  219 +
  220 + int totalBytes = 0;
  221 + while ((bytesRead = inputStream.read(buffer)) != -1) {
  222 + totalBytes += bytesRead;
  223 +
  224 + // 发送进度事件
  225 + String progressEvent = String.format(
  226 + "event: progress\ndata: {\"bytes\":%d}\n\n", totalBytes);
  227 + outputStream.write(progressEvent.getBytes());
  228 + outputStream.flush();
  229 +
  230 + // 发送音频数据(base64编码)
  231 + String base64Data = java.util.Base64.getEncoder().encodeToString(
  232 + java.util.Arrays.copyOfRange(buffer, 0, bytesRead));
  233 + String audioEvent = String.format(
  234 + "event: audio-data\ndata: {\"chunk\":\"%s\"}\n\n", base64Data);
  235 + outputStream.write(audioEvent.getBytes());
  236 + outputStream.flush();
  237 + }
  238 +
  239 + // 发送完成事件
  240 + String completeEvent = String.format(
  241 + "event: audio-complete\ndata: {\"total_bytes\":%d}\n\n", totalBytes);
  242 + outputStream.write(completeEvent.getBytes());
  243 + outputStream.flush();
  244 + } else {
  245 + outputStream.write(("event: error\ndata: {\"message\":\"合成失败\"}\n\n").getBytes());
  246 + outputStream.flush();
  247 + }
  248 +
  249 + } catch (Exception e) {
  250 +// log.error("SSE流式输出异常: {}", e.getMessage(), e);
  251 + try {
  252 + outputStream.write(("event: error\ndata: {\"message\":\"" +
  253 + e.getMessage().replace("\"", "\\\"") + "\"}\n\n").getBytes());
  254 + outputStream.flush();
  255 + } catch (Exception ex) {
  256 + // 忽略关闭错误
  257 + }
  258 + }
  259 + };
  260 +
  261 + return ResponseEntity.ok()
  262 + .header("Content-Type", "text/event-stream")
  263 + .header("Cache-Control", "no-cache")
  264 + .header("X-Accel-Buffering", "no") // 禁用Nginx缓冲
  265 + .body(responseBody);
  266 + }
  267 +
  268 + /**
  269 + * 测试接口
  270 + */
  271 + @GetMapping("/test")
  272 + public ResponseEntity<InputStreamResource> testSynthesis() {
  273 + log.info("收到测试请求");
  274 + TTSRequestDTO request = new TTSRequestDTO();
  275 + request.setText("这是一个测试语音,用于验证Edge-TTS服务是否正常工作。");
  276 + request.setVoice("zh-CN-XiaoxiaoNeural");
  277 + return pythonTtsProxyService.synthesizeStream(request);
  278 + }
  279 +
  280 + /**
  281 + * 状态接口
  282 + */
  283 + @GetMapping("/status")
  284 + public ResponseEntity<Object> getStatus() {
  285 + ServiceStatus status = new ServiceStatus();
  286 + status.setJavaService(true);
  287 + status.setPythonService(pythonTtsProxyService.healthCheck());
  288 + status.setServiceUrl("http://localhost:8000");
  289 + status.setJavaApiUrl("/api/tts");
  290 + status.setTimestamp(new java.util.Date());
  291 +
  292 + return ResponseEntity.ok(status);
  293 + }
  294 +
  295 +
  296 +}
0 297 \ No newline at end of file
... ...
src/main/resources/application.yml 0 → 100644
  1 +#配置日志
  2 +logging:
  3 + config : classpath:logback-spring.xml
  4 + dirpath: D:/xlyweberp/logs/xlyAi
  5 + level:
  6 + root: info
  7 + com.xly: debug
  8 + com.xlyflow: debug
  9 + org.springframework: warn
  10 +
  11 +server:
  12 + port: 8099
  13 + servlet:
  14 + context-path: /xlyAi
  15 + encoding:
  16 + charset: UTF-8
  17 + enabled: true
  18 + compression:
  19 + enabled: true
  20 + mime-types: audio/mpeg
  21 +spring:
  22 + main:
  23 + allow-bean-definition-overriding: true
  24 + application:
  25 + name: xlyAi
  26 + version: 1.0.0
  27 + thymeleaf:
  28 + prefix: classpath:/templates/
  29 + suffix: .html
  30 + mode: HTML
  31 + encoding: UTF-8
  32 + cache: false # 开发时关闭缓存
  33 + mvc:
  34 + static-path-pattern: /static/**
  35 + web:
  36 + resources:
  37 + static-locations: classpath:/static/
  38 + # 禁用默认的静态资源处理规则
  39 + add-mappings: false
  40 + datasource:
  41 + driver-class-name: com.mysql.cj.jdbc.Driver
  42 + url: jdbc:mysql://118.178.19.35:3318/xlyweberp_saas?allowPublicKeyRetrieval=true&keepAlive=true&autoReconnect=true&autoReconnectForPools=true&connectTimeout=30000&socketTimeout=180000&nullCatalogMeansCurrent=true&&allowMultiQueries=true&useSSL=false&useUnicode=true&characterEncoding=utf-8&failOverReadOnly=false&serverTimezone=Asia/Shanghai&zeroDateTimeBehavior=CONVERT_TO_NULL
  43 + username: xlyprint
  44 + password: xlyXLYprint2016
  45 + # 连接池配置(使用HikariCP)
  46 + hikari:
  47 + maximum-pool-size: 20
  48 + minimum-idle: 10
  49 + connection-timeout: 30000
  50 + idle-timeout: 600000
  51 + max-lifetime: 1800000
  52 +
  53 +# application.yml 或 application.properties
  54 +langchain4j:
  55 + ollama:
  56 + # 聊天模型配置(用于一般对话)
  57 + base-url: http://121.43.128.225:11434
  58 + chat-model-name: qwen2.5:7b-instruct
  59 + # SQL/代码模型配置(专门用于代码和SQL生成)
  60 + sql-model-name: qwen2.5-coder:14b
  61 + # 或者如果两个模型在同一服务器,可以使用同一个URL
  62 +
  63 +mybatis:
  64 + mapper-locations: classpath:mapper/*.xml
  65 + type-aliases-package: com.xly.entity
  66 + configuration:
  67 + map-underscore-to-camel-case: true
  68 + log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
  69 +
  70 +
  71 + # 情感预设缓存
  72 + cache:
  73 + enabled: true
  74 + max-size: 100 # 最大缓存条目数
  75 + expire-time: 3600 # 缓存过期时间(秒)
  76 +# TTS配置
  77 +tts:
  78 + python:
  79 + url: http://localhost:8000
  80 + timeout: 30000
  81 + max-connections: 10
  82 +erp:
  83 + baseurl: http://118.178.19.35:8080/xlyEntry_saas
0 84 \ No newline at end of file
... ...
src/main/resources/logback-spring.xml 0 → 100644
  1 +<?xml version="1.0" encoding="UTF-8"?>
  2 +<configuration scan="true" scanPeriod="60 seconds" debug="false">
  3 + <contextName>logback</contextName>
  4 + <!--输出到控制台-->
  5 + <appender name="console" class="ch.qos.logback.core.ConsoleAppender">
  6 + <encoder>
  7 + <pattern>%d{HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n</pattern>
  8 + </encoder>
  9 + </appender>
  10 +
  11 + <!-- 获取springboot中的数据库配置 -->
  12 + <springProperty scope="context" name="driverClassName" source="spring.datasource.driverClassName" defaultValue="driverClassName"/>
  13 + <springProperty scope="context" name="url" source="spring.datasource.url" defaultValue="url"/>
  14 + <springProperty scope="context" name="username" source="spring.datasource.username" defaultValue="username"/>
  15 + <springProperty scope="context" name="password" source="spring.datasource.password" defaultValue="password"/>
  16 +
  17 + <root level="DEBUGE">
  18 + <appender-ref ref="DB" />
  19 + </root>
  20 +
  21 + <!-- name的值是变量的名称,value的值时变量定义的值。通过定义的值会被插入到logger上下文中。定义后,可以使“${}”来使用变量。 -->
  22 + <!--${catalina.home}/log-->
  23 + <!-- <property name="LOG_PATH" value="${catalina.home}/log"/>-->
  24 + <springProperty scope="context" name="logPath" source="logging.dirpath"/>
  25 + <property name="LOG_PATH" value="${logPath}"/>
  26 +
  27 +
  28 + <!-- 彩色日志依赖的渲染类 -->
  29 + <conversionRule conversionWord="clr" converterClass="org.springframework.boot.logging.logback.ColorConverter" />
  30 + <conversionRule conversionWord="wex" converterClass="org.springframework.boot.logging.logback.WhitespaceThrowableProxyConverter" />
  31 + <conversionRule conversionWord="wEx" converterClass="org.springframework.boot.logging.logback.ExtendedWhitespaceThrowableProxyConverter" />
  32 + <!-- 彩色日志格式 -->
  33 + <property name="CONSOLE_LOG_PATTERN" value="${CONSOLE_LOG_PATTERN:-%clr(%d{yyyy-MM-dd HH:mm:ss.SSS}){faint}
  34 + %clr(${LOG_LEVEL_PATTERN:-%5p}) %clr(${PID:- }){magenta} %clr(---){faint} %clr([%15.15t]){faint} %clr(%-40.40logger{39}){cyan} %clr(:){faint} %m%n${LOG_EXCEPTION_CONVERSION_WORD:-%wEx}}"/>
  35 +
  36 + <!--1. 输出到控制台-->
  37 + <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
  38 + <!--此日志appender是为开发使用,只配置最底级别,控制台输出的日志级别是大于或等于此级别的日志信息-->
  39 + <filter class="ch.qos.logback.classic.filter.ThresholdFilter">
  40 + <level>debug</level>
  41 + </filter>
  42 + <encoder>
  43 + <Pattern>${CONSOLE_LOG_PATTERN}</Pattern>
  44 + <!-- 设置字符集 -->
  45 + <charset>UTF-8</charset>
  46 + </encoder>
  47 + </appender>
  48 +
  49 + <!--按天生成日志-->
  50 + <appender name="DEBUG" class="ch.qos.logback.core.rolling.RollingFileAppender">
  51 + <Prudent>true</Prudent>
  52 + <filter class="ch.qos.logback.classic.filter.ThresholdFilter">
  53 + <level>DEBUG</level>
  54 + </filter>
  55 + <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
  56 + <FileNamePattern>
  57 + ${LOG_PATH}/%d{yyyy-MM-dd}/%d{yyyy-MM-dd}.debug-%i.log
  58 + </FileNamePattern>
  59 + <timeBasedFileNamingAndTriggeringPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedFNATP">
  60 + <maxFileSize>100MB</maxFileSize>
  61 + </timeBasedFileNamingAndTriggeringPolicy>
  62 + <!--日志文档保留天数-->
  63 + <maxHistory>5</maxHistory>
  64 + </rollingPolicy>
  65 + <layout class="ch.qos.logback.classic.PatternLayout">
  66 + <Pattern>
  67 + %d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n
  68 + </Pattern>
  69 + </layout>
  70 + </appender>
  71 +
  72 + <appender name="INFO" class="ch.qos.logback.core.rolling.RollingFileAppender">
  73 + <Prudent>true</Prudent>
  74 + <filter class="ch.qos.logback.classic.filter.ThresholdFilter">
  75 + <level>INFO</level>
  76 + </filter>
  77 + <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
  78 + <FileNamePattern>
  79 + ${LOG_PATH}/%d{yyyy-MM-dd}/%d{yyyy-MM-dd}.info-%i.log
  80 + </FileNamePattern>
  81 + <timeBasedFileNamingAndTriggeringPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedFNATP">
  82 + <maxFileSize>100MB</maxFileSize>
  83 + </timeBasedFileNamingAndTriggeringPolicy>
  84 + <!--日志文档保留天数-->
  85 + <maxHistory>5</maxHistory>
  86 + </rollingPolicy>
  87 + <layout class="ch.qos.logback.classic.PatternLayout">
  88 + <Pattern>
  89 + %d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n
  90 + </Pattern>
  91 + </layout>
  92 + </appender>
  93 +
  94 + <appender name="ERROR" class="ch.qos.logback.core.rolling.RollingFileAppender">
  95 + <Prudent>true</Prudent>
  96 + <filter class="ch.qos.logback.classic.filter.ThresholdFilter">
  97 + <level>ERROR</level>
  98 + </filter>
  99 + <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
  100 + <FileNamePattern>
  101 + <!--${catalina.home}/logs/%d{yyyy-MM-dd}/%d{yyyy-MM-dd}.%i.log-->
  102 + ${LOG_PATH}/%d{yyyy-MM-dd}/%d{yyyy-MM-dd}.error-%i.log
  103 + </FileNamePattern>
  104 + <timeBasedFileNamingAndTriggeringPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedFNATP">
  105 + <maxFileSize>100MB</maxFileSize>
  106 + </timeBasedFileNamingAndTriggeringPolicy>
  107 + <!--日志文档保留天数-->
  108 + <maxHistory>5</maxHistory>
  109 + </rollingPolicy>
  110 + <layout class="ch.qos.logback.classic.PatternLayout">
  111 + <Pattern>
  112 + %d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n
  113 + </Pattern>
  114 + </layout>
  115 + </appender>
  116 +
  117 + <logger name="org.mybatis" level="DEBUG"/>
  118 + <logger name="java.sql.Connection" level="DEBUG"/>
  119 + <logger name="java.sql.Statement" level="DEBUG"/>
  120 + <logger name="java.sql.PreparedStatement" level="DEBUG"/>
  121 +
  122 + <logger name="com.xly" additivity="false">
  123 + <appender-ref ref="CONSOLE"/>
  124 + <appender-ref ref="DEBUG" />
  125 + <appender-ref ref="INFO" />
  126 + <appender-ref ref="ERROR" />
  127 + </logger>
  128 +
  129 + <root level="error">
  130 + <appender-ref ref="CONSOLE"/>
  131 + <appender-ref ref="DEBUG" />
  132 + <appender-ref ref="INFO" />
  133 + <appender-ref ref="ERROR" />
  134 + </root>
  135 +
  136 +</configuration>
... ...
src/main/resources/mapper/DynamicExeDbMapper.xml 0 → 100644
  1 +<?xml version="1.0" encoding="UTF-8" ?>
  2 +<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
  3 +<mapper namespace="com.xly.mapper.DynamicExeDbMapper">
  4 +
  5 + <!-- 多数据集返回 -->
  6 + <resultMap id="data1" type="map"></resultMap>
  7 + <resultMap id="data2" type="map"></resultMap>
  8 + <resultMap id="data3" type="map"></resultMap>
  9 + <resultMap id="data4" type="map"></resultMap>
  10 + <resultMap id="data5" type="map"></resultMap>
  11 + <resultMap id="data6" type="map"></resultMap>
  12 + <resultMap id="data7" type="map"></resultMap>
  13 + <resultMap id="data8" type="map"></resultMap>
  14 + <resultMap id="data9" type="map"></resultMap>
  15 + <resultMap id="data10" type="map"></resultMap>
  16 +
  17 + <!-- 查询SQL执行-->
  18 + <select id="findSql" timeout="180" resultType="java.util.LinkedHashMap" parameterType="Map">
  19 + <![CDATA[
  20 + ${sSql}
  21 + ]]>
  22 + </select>
  23 +
  24 +
  25 + <!-- 更新SQL执行-->
  26 + <update id="updateSql" parameterType="Map">
  27 + <![CDATA[
  28 + ${sSql}
  29 + ]]>
  30 + </update>
  31 +
  32 + <!-- 根据sql语句新增 -->
  33 + <insert id="addSql" parameterType="java.util.Map">
  34 + <![CDATA[
  35 + ${sSql}
  36 + ]]>
  37 + </insert>
  38 +
  39 + <!-- 删除QL执行 -->
  40 + <delete id="deleteByMap" parameterType="Map">
  41 + <![CDATA[
  42 + ${sSql}
  43 + ]]>
  44 + </delete>
  45 +
  46 + <!-- 动态执行过程 并且有返回 执行过程 返回多个数据集 ,默认10个 -->
  47 + <select id="getCallProMoreResult"
  48 + resultMap="data1,data2,data3,data4,data5,data6,data7,data8,data9,data10"
  49 + parameterType="java.util.Map"
  50 + timeout="180"
  51 + statementType="CALLABLE" >
  52 + <![CDATA[
  53 + ${sSql}
  54 + ]]>
  55 + </select>
  56 +
  57 + <!-- 动态执行过程 并且有返回 执行过程 返回多个数据集 ,默认10个 -->
  58 + <select id="getCallPro"
  59 + resultMap="data1"
  60 + parameterType="java.util.Map"
  61 + timeout="180"
  62 + statementType="CALLABLE" >
  63 + <![CDATA[
  64 + ${sSql}
  65 + ]]>
  66 + </select>
  67 +
  68 +
  69 +
  70 +</mapper>
0 71 \ No newline at end of file
... ...
src/main/resources/python/stream_server.py 0 → 100644
  1 +# edge_tts_server.py
  2 +# stream_server.py
  3 +import asyncio
  4 +import json
  5 +from fastapi import FastAPI, HTTPException, Response, Request
  6 +from fastapi.middleware.cors import CORSMiddleware
  7 +from fastapi.responses import JSONResponse
  8 +from pydantic import BaseModel, Field, validator
  9 +import edge_tts
  10 +import io
  11 +import logging
  12 +from typing import List, Optional
  13 +from datetime import datetime
  14 +import re
  15 +from functools import lru_cache
  16 +
  17 +# 配置日志
  18 +logging.basicConfig(
  19 + level=logging.INFO,
  20 + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
  21 +)
  22 +logger = logging.getLogger(__name__)
  23 +
  24 +app = FastAPI(
  25 + title="Edge TTS API Service",
  26 + description="用于Java服务代理的TTS服务",
  27 + version="1.0.0"
  28 +)
  29 +
  30 +# CORS配置
  31 +app.add_middleware(
  32 + CORSMiddleware,
  33 + allow_origins=["*"],
  34 + allow_credentials=True,
  35 + allow_methods=["*"],
  36 + allow_headers=["*"],
  37 +)
  38 +
  39 +class TTSRequest(BaseModel):
  40 + text: str = Field(..., min_length=1, max_length=5000)
  41 + voice: str = Field(default="zh-CN-XiaoxiaoNeural")
  42 + rate: str = Field(default="+0%")
  43 + volume: str = Field(default="+0%")
  44 +
  45 + @validator('rate', 'volume')
  46 + def validate_percentage(cls, v):
  47 + if not re.match(r'^[+-]\d+%$', v):
  48 + raise ValueError('格式应为 +10% 或 -20% 等')
  49 + return v
  50 +
  51 + @validator('text')
  52 + def validate_text_length(cls, v):
  53 + if len(v) > 5000:
  54 + raise ValueError('文本长度不能超过5000字符')
  55 + return v
  56 +
  57 +class VoiceInfo(BaseModel):
  58 + name: str
  59 + locale: str
  60 + gender: str
  61 + displayName: Optional[str] = None
  62 +
  63 +class HealthResponse(BaseModel):
  64 + status: str
  65 + service: str
  66 + timestamp: str
  67 + voices_count: Optional[int] = None
  68 +
  69 +@app.get("/")
  70 +async def root():
  71 + """服务根目录"""
  72 + return {
  73 + "service": "Edge TTS API",
  74 + "version": "1.0.0",
  75 + "endpoints": {
  76 + "synthesize": "POST /stream-synthesize",
  77 + "voices": "GET /voices",
  78 + "health": "GET /health"
  79 + }
  80 + }
  81 +
  82 +@app.post("/stream-synthesize")
  83 +async def stream_synthesize(request: TTSRequest):
  84 + """流式合成语音 - 主接口"""
  85 + try:
  86 + logger.info(f"合成请求: voice={request.voice}, text_length={len(request.text)}")
  87 +
  88 + # 创建内存流
  89 + audio_stream = io.BytesIO()
  90 +
  91 + # 使用edge-tts生成语音
  92 + communicate = edge_tts.Communicate(
  93 + text=request.text,
  94 + voice=request.voice,
  95 + rate=request.rate,
  96 + volume=request.volume
  97 + )
  98 +
  99 + # 流式写入音频数据
  100 + async for chunk in communicate.stream():
  101 + if chunk["type"] == "audio":
  102 + audio_stream.write(chunk["data"])
  103 +
  104 + # 获取音频数据
  105 + audio_data = audio_stream.getvalue()
  106 +
  107 + if len(audio_data) == 0:
  108 + raise HTTPException(status_code=500, detail="生成音频为空")
  109 +
  110 + logger.info(f"合成完成: {len(audio_data)} bytes")
  111 +
  112 + # 返回音频流响应
  113 + return Response(
  114 + content=audio_data,
  115 + media_type="audio/mpeg",
  116 + headers={
  117 + "Content-Disposition": "inline; filename=speech.mp3",
  118 + "Content-Length": str(len(audio_data)),
  119 + "Cache-Control": "no-cache, no-store, must-revalidate",
  120 + "Pragma": "no-cache",
  121 + "Expires": "0",
  122 + "X-TTS-Status": "success",
  123 + "X-TTS-Voice": request.voice,
  124 + "X-TTS-Size": str(len(audio_data))
  125 + }
  126 + )
  127 +
  128 + except HTTPException:
  129 + raise
  130 + except Exception as e:
  131 + logger.error(f"合成失败: {str(e)}")
  132 + raise HTTPException(status_code=500, detail=f"语音合成失败: {str(e)}")
  133 +
  134 +@app.get("/voices")
  135 +async def get_voices():
  136 + """获取语音列表"""
  137 + try:
  138 + voices = await edge_tts.list_voices()
  139 + voice_list = []
  140 +
  141 + for voice in voices:
  142 + voice_info = VoiceInfo(
  143 + name=voice.get("ShortName", ""),
  144 + locale=voice.get("Locale", ""),
  145 + gender=voice.get("Gender", ""),
  146 + displayName=voice.get("FriendlyName", "")
  147 + )
  148 + voice_list.append(voice_info.dict())
  149 +
  150 + logger.info(f"返回 {len(voice_list)} 个语音")
  151 +
  152 + return JSONResponse(
  153 + content={"voices": voice_list},
  154 + headers={"Cache-Control": "public, max-age=3600"}
  155 + )
  156 +
  157 + except Exception as e:
  158 + logger.error(f"获取语音列表失败: {str(e)}")
  159 + raise HTTPException(status_code=500, detail=f"获取语音列表失败: {str(e)}")
  160 +
  161 +@app.get("/health")
  162 +async def health_check():
  163 + """健康检查"""
  164 + try:
  165 + # 测试语音服务是否正常
  166 + voices = await edge_tts.list_voices()
  167 + voices_count = len(voices)
  168 +
  169 + response = HealthResponse(
  170 + status="healthy",
  171 + service="edge-tts",
  172 + timestamp=datetime.now().isoformat(),
  173 + voices_count=voices_count
  174 + )
  175 +
  176 + return response.dict()
  177 +
  178 + except Exception as e:
  179 + logger.error(f"健康检查失败: {str(e)}")
  180 + response = HealthResponse(
  181 + status="unhealthy",
  182 + service="edge-tts",
  183 + timestamp=datetime.now().isoformat()
  184 + )
  185 + return JSONResponse(
  186 + content=response.dict(),
  187 + status_code=503
  188 + )
  189 +
  190 +@app.get("/test")
  191 +async def test_synthesis():
  192 + """测试接口"""
  193 + try:
  194 + # 简单的测试合成
  195 + communicate = edge_tts.Communicate(
  196 + text="这是一条测试语音,用于验证服务是否正常工作。",
  197 + voice="zh-CN-XiaoxiaoNeural"
  198 + )
  199 +
  200 + audio_stream = io.BytesIO()
  201 + async for chunk in communicate.stream():
  202 + if chunk["type"] == "audio":
  203 + audio_stream.write(chunk["data"])
  204 +
  205 + audio_data = audio_stream.getvalue()
  206 +
  207 + return Response(
  208 + content=audio_data,
  209 + media_type="audio/mpeg",
  210 + headers={
  211 + "Content-Disposition": "inline; filename=test.mp3",
  212 + "Content-Length": str(len(audio_data))
  213 + }
  214 + )
  215 +
  216 + except Exception as e:
  217 + raise HTTPException(status_code=500, detail=f"测试失败: {str(e)}")
  218 +
  219 +if __name__ == "__main__":
  220 + import uvicorn
  221 +
  222 + logger.info("启动Edge TTS服务...")
  223 + logger.info(f"服务地址: http://0.0.0.0:8000")
  224 +
  225 + uvicorn.run(
  226 + app,
  227 + host="0.0.0.0",
  228 + port=8000,
  229 + log_level="info",
  230 + access_log=True
  231 + )
0 232 \ No newline at end of file
... ...
src/main/resources/python/stream_server.py.bak 0 → 100644
  1 +# stream_server.py
  2 +import asyncio
  3 +import json
  4 +from fastapi import FastAPI, HTTPException, Response
  5 +from fastapi.middleware.cors import CORSMiddleware
  6 +from pydantic import BaseModel
  7 +import edge_tts
  8 +import io
  9 +import logging
  10 +
  11 +# 配置日志
  12 +logging.basicConfig(level=logging.INFO)
  13 +logger = logging.getLogger(__name__)
  14 +
  15 +app = FastAPI(title="Edge TTS Stream API")
  16 +
  17 +# CORS配置
  18 +app.add_middleware(
  19 + CORSMiddleware,
  20 + allow_origins=["*"],
  21 + allow_credentials=True,
  22 + allow_methods=["*"],
  23 + allow_headers=["*"],
  24 +)
  25 +
  26 +class TTSRequest(BaseModel):
  27 + text: str
  28 + voice: str = "zh-CN-XiaoxiaoNeural"
  29 + rate: str = "+0%"
  30 + volume: str = "+0%"
  31 +
  32 +@app.post("/stream-synthesize")
  33 +async def stream_synthesize(request: TTSRequest):
  34 + """流式合成语音"""
  35 + try:
  36 + logger.info(f"开始合成语音: voice={request.voice}, text_length={len(request.text)}")
  37 +
  38 + # 创建内存流
  39 + audio_stream = io.BytesIO()
  40 +
  41 + # 使用edge-tts生成语音
  42 + communicate = edge_tts.Communicate(
  43 + request.text,
  44 + request.voice,
  45 + rate=request.rate,
  46 + volume=request.volume
  47 + )
  48 +
  49 + # 流式写入音频数据
  50 + async for chunk in communicate.stream():
  51 + if chunk["type"] == "audio":
  52 + audio_stream.write(chunk["data"])
  53 +
  54 + # 获取音频数据
  55 + audio_data = audio_stream.getvalue()
  56 + logger.info(f"语音合成完成,大小: {len(audio_data)} bytes")
  57 +
  58 + # 返回音频流响应
  59 + return Response(
  60 + content=audio_data,
  61 + media_type="audio/mpeg",
  62 + headers={
  63 + "Content-Disposition": "inline; filename=speech.mp3",
  64 + "Content-Length": str(len(audio_data)),
  65 + "Cache-Control": "no-cache, no-store, must-revalidate",
  66 + "Pragma": "no-cache",
  67 + "Expires": "0",
  68 + "X-TTS-Status": "success",
  69 + "X-TTS-Voice": request.voice
  70 + }
  71 + )
  72 +
  73 + except Exception as e:
  74 + logger.error(f"语音合成失败: {e}")
  75 + raise HTTPException(status_code=500, detail=str(e))
  76 +
  77 +@app.get("/voices")
  78 +async def get_voices():
  79 + """获取语音列表"""
  80 + try:
  81 + voices = await edge_tts.list_voices()
  82 + voice_list = []
  83 + for voice in voices:
  84 + voice_list.append({
  85 + "name": voice.get("ShortName", ""),
  86 + "locale": voice.get("Locale", ""),
  87 + "gender": voice.get("Gender", ""),
  88 + "displayName": voice.get("FriendlyName", "")
  89 + })
  90 + return {"voices": voice_list}
  91 + except Exception as e:
  92 + logger.error(f"获取语音列表失败: {e}")
  93 + raise HTTPException(status_code=500, detail=str(e))
  94 +
  95 +@app.get("/health")
  96 +async def health_check():
  97 + """健康检查"""
  98 + return {"status": "healthy", "service": "edge-tts-stream"}
  99 +
  100 +if __name__ == "__main__":
  101 + import uvicorn
  102 + uvicorn.run(app, host="0.0.0.0", port=8000, log_level="info")
0 103 \ No newline at end of file
... ...
src/main/resources/templates/chat.html 0 → 100644
  1 +<!DOCTYPE html>
  2 +<html lang="zh-CN">
  3 +<head>
  4 + <meta charset="UTF-8">
  5 + <meta name="viewport" content="width=device-width, initial-scale=1.0">
  6 + <title>AI 印刷助手</title>
  7 + <script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
  8 + <script src="https://cdnjs.cloudflare.com/ajax/libs/markdown-it/13.0.1/markdown-it.min.js"></script>
  9 + <style>
  10 + * {
  11 + margin: 0;
  12 + padding: 0;
  13 + box-sizing: border-box;
  14 + }
  15 +
  16 + body {
  17 + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
  18 + background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
  19 + min-height: 100vh;
  20 + display: flex;
  21 + justify-content: center;
  22 + align-items: center;
  23 + padding: 20px;
  24 + }
  25 +
  26 + .chat-container {
  27 + width: 100%;
  28 + max-width: 900px;
  29 + height: 85vh;
  30 + background: white;
  31 + border-radius: 20px;
  32 + box-shadow: 0 20px 60px rgba(0,0,0,0.15);
  33 + display: flex;
  34 + flex-direction: column;
  35 + overflow: hidden;
  36 + }
  37 +
  38 + .chat-header {
  39 + background: linear-gradient(90deg, #2c3e50, #4a6491);
  40 + color: white;
  41 + padding: 20px;
  42 + display: flex;
  43 + justify-content: space-between;
  44 + align-items: center;
  45 + flex-shrink: 0;
  46 + }
  47 +
  48 + .header-left h1 {
  49 + font-size: 24px;
  50 + font-weight: 600;
  51 + margin-bottom: 5px;
  52 + }
  53 +
  54 + .header-left p {
  55 + opacity: 0.8;
  56 + font-size: 14px;
  57 + }
  58 +
  59 + .header-right {
  60 + display: flex;
  61 + gap: 15px;
  62 + }
  63 +
  64 + .model-selector {
  65 + background: rgba(255,255,255,0.1);
  66 + border: 1px solid rgba(255,255,255,0.2);
  67 + color: #180a4b;
  68 + padding: 8px 15px;
  69 + border-radius: 20px;
  70 + font-size: 14px;
  71 + outline: none;
  72 + cursor: pointer;
  73 + }
  74 +
  75 + .model-selector:hover {
  76 + background: rgba(255,255,255,0.2);
  77 + }
  78 +
  79 + .chat-body {
  80 + display: flex;
  81 + flex: 1;
  82 + overflow: hidden;
  83 + min-height: 0;
  84 + }
  85 +
  86 + .sidebar {
  87 + width: 250px;
  88 + background: #f8f9fa;
  89 + border-right: 1px solid #e9ecef;
  90 + padding: 20px;
  91 + overflow-y: auto;
  92 + flex-shrink: 0;
  93 + }
  94 +
  95 + .sidebar-title {
  96 + font-size: 16px;
  97 + font-weight: 600;
  98 + margin-bottom: 15px;
  99 + color: #2c3e50;
  100 + }
  101 +
  102 + .preset-question {
  103 + background: white;
  104 + border: 1px solid #e9ecef;
  105 + border-radius: 10px;
  106 + padding: 12px 15px;
  107 + margin-bottom: 10px;
  108 + cursor: pointer;
  109 + transition: all 0.3s;
  110 + font-size: 14px;
  111 + }
  112 +
  113 + .preset-question:hover {
  114 + background: #667eea;
  115 + color: white;
  116 + border-color: #667eea;
  117 + transform: translateX(5px);
  118 + }
  119 +
  120 + .chat-main {
  121 + flex: 1;
  122 + display: flex;
  123 + flex-direction: column;
  124 + min-height: 0;
  125 + }
  126 +
  127 + .messages-container {
  128 + flex: 1;
  129 + display: flex;
  130 + flex-direction: column;
  131 + min-height: 0;
  132 + position: relative;
  133 + }
  134 +
  135 + .chat-messages {
  136 + flex: 1;
  137 + overflow-y: auto;
  138 + padding: 20px;
  139 + background: white;
  140 + }
  141 +
  142 + .message {
  143 + margin-bottom: 20px;
  144 + max-width: 80%;
  145 + animation: fadeIn 0.3s ease;
  146 + }
  147 +
  148 + .user-message {
  149 + margin-left: auto;
  150 + }
  151 +
  152 + .ai-message {
  153 + margin-right: auto;
  154 + }
  155 +
  156 + .message-bubble {
  157 + padding: 15px 20px;
  158 + border-radius: 20px;
  159 + position: relative;
  160 + word-wrap: break-word;
  161 + line-height: 1.6;
  162 + }
  163 +
  164 + .user-message .message-bubble {
  165 + background: linear-gradient(90deg, #667eea, #764ba2);
  166 + color: white;
  167 + border-bottom-right-radius: 5px;
  168 + }
  169 +
  170 + .ai-message .message-bubble {
  171 + background: #f8f9fa;
  172 + color: #333;
  173 + border: 1px solid #e9ecef;
  174 + border-bottom-left-radius: 5px;
  175 + }
  176 +
  177 + .message-content {
  178 + font-size: 15px;
  179 + }
  180 +
  181 + .ai-message .message-content code {
  182 + background: #e9ecef;
  183 + padding: 2px 6px;
  184 + border-radius: 4px;
  185 + font-family: 'Courier New', monospace;
  186 + font-size: 14px;
  187 + }
  188 +
  189 + .ai-message .message-content pre {
  190 + background: #f1f3f5;
  191 + padding: 10px;
  192 + border-radius: 8px;
  193 + overflow-x: auto;
  194 + margin: 10px 0;
  195 + border: 1px solid #dee2e6;
  196 + }
  197 +
  198 + .message-meta {
  199 + display: flex;
  200 + justify-content: space-between;
  201 + align-items: center;
  202 + margin-top: 8px;
  203 + font-size: 12px;
  204 + }
  205 +
  206 + .message-time {
  207 + color: #6c757d;
  208 + }
  209 +
  210 + .message-actions {
  211 + display: flex;
  212 + gap: 8px;
  213 + }
  214 +
  215 + .action-btn {
  216 + background: none;
  217 + border: none;
  218 + color: #6c757d;
  219 + cursor: pointer;
  220 + font-size: 12px;
  221 + padding: 2px 5px;
  222 + border-radius: 3px;
  223 + transition: all 0.2s;
  224 + }
  225 +
  226 + .action-btn:hover {
  227 + background: #e9ecef;
  228 + color: #495057;
  229 + }
  230 +
  231 + .typing-indicator {
  232 + display: flex;
  233 + align-items: center;
  234 + padding: 10px 20px;
  235 + background: #f8f9fa;
  236 + border-radius: 20px;
  237 + width: fit-content;
  238 + border: 1px solid #e9ecef;
  239 + margin-bottom: 20px;
  240 + }
  241 +
  242 + .typing-dot {
  243 + width: 8px;
  244 + height: 8px;
  245 + background: #667eea;
  246 + border-radius: 50%;
  247 + margin: 0 2px;
  248 + animation: typing 1.4s infinite;
  249 + }
  250 +
  251 + .typing-dot:nth-child(2) { animation-delay: 0.2s; }
  252 + .typing-dot:nth-child(3) { animation-delay: 0.4s; }
  253 +
  254 + .input-section {
  255 + border-top: 1px solid #e9ecef;
  256 + background: white;
  257 + flex-shrink: 0;
  258 + }
  259 +
  260 + .chat-input-container {
  261 + padding: 20px;
  262 + }
  263 +
  264 + .input-wrapper {
  265 + display: flex;
  266 + gap: 10px;
  267 + }
  268 +
  269 + #messageInput {
  270 + flex: 1;
  271 + padding: 15px 20px;
  272 + border: 2px solid #e9ecef;
  273 + border-radius: 25px;
  274 + font-size: 16px;
  275 + outline: none;
  276 + transition: border-color 0.3s;
  277 + }
  278 +
  279 + #messageInput:focus {
  280 + border-color: #667eea;
  281 + }
  282 +
  283 + #sendButton {
  284 + padding: 15px 30px;
  285 + background: linear-gradient(90deg, #667eea, #764ba2);
  286 + color: white;
  287 + border: none;
  288 + border-radius: 25px;
  289 + font-size: 16px;
  290 + font-weight: 600;
  291 + cursor: pointer;
  292 + transition: all 0.2s;
  293 + white-space: nowrap;
  294 + }
  295 +
  296 + #sendButton:hover {
  297 + transform: translateY(-2px);
  298 + box-shadow: 0 5px 15px rgba(102, 126, 234, 0.4);
  299 + }
  300 +
  301 + #sendButton:disabled {
  302 + opacity: 0.5;
  303 + cursor: not-allowed;
  304 + transform: none;
  305 + box-shadow: none;
  306 + }
  307 +
  308 + .status-bar {
  309 + display: flex;
  310 + justify-content: space-between;
  311 + align-items: center;
  312 + padding: 10px 20px;
  313 + font-size: 14px;
  314 + color: #666;
  315 + background: #f8f9fa;
  316 + border-top: 1px solid #e9ecef;
  317 + }
  318 +
  319 + .api-status {
  320 + display: flex;
  321 + align-items: center;
  322 + gap: 8px;
  323 + }
  324 +
  325 + .status-indicator {
  326 + width: 8px;
  327 + height: 8px;
  328 + border-radius: 50%;
  329 + }
  330 +
  331 + .status-connected {
  332 + background: #28a745;
  333 + animation: pulse 2s infinite;
  334 + }
  335 +
  336 + .status-disconnected {
  337 + background: #dc3545;
  338 + }
  339 +
  340 + .status-connecting {
  341 + background: #ffc107;
  342 + }
  343 +
  344 + @keyframes fadeIn {
  345 + from { opacity: 0; transform: translateY(10px); }
  346 + to { opacity: 1; transform: translateY(0); }
  347 + }
  348 +
  349 + @keyframes typing {
  350 + 0%, 60%, 100% { transform: translateY(0); }
  351 + 30% { transform: translateY(-10px); }
  352 + }
  353 +
  354 + @keyframes pulse {
  355 + 0% { opacity: 1; }
  356 + 50% { opacity: 0.5; }
  357 + 100% { opacity: 1; }
  358 + }
  359 +
  360 + /* 滚动条样式 */
  361 + .chat-messages::-webkit-scrollbar,
  362 + .sidebar::-webkit-scrollbar {
  363 + width: 6px;
  364 + }
  365 +
  366 + .chat-messages::-webkit-scrollbar-track,
  367 + .sidebar::-webkit-scrollbar-track {
  368 + background: #f1f1f1;
  369 + border-radius: 3px;
  370 + }
  371 +
  372 + .chat-messages::-webkit-scrollbar-thumb,
  373 + .sidebar::-webkit-scrollbar-thumb {
  374 + background: #c1c1c1;
  375 + border-radius: 3px;
  376 + }
  377 +
  378 + .chat-messages::-webkit-scrollbar-thumb:hover,
  379 + .sidebar::-webkit-scrollbar-thumb:hover {
  380 + background: #a1a1a1;
  381 + }
  382 +
  383 + /* 底部间隔 */
  384 + .bottom-spacer {
  385 + height: 20px;
  386 + flex-shrink: 0;
  387 + }
  388 +
  389 + /* 响应式设计 */
  390 + @media (max-width: 768px) {
  391 + .chat-container {
  392 + height: 95vh;
  393 + border-radius: 10px;
  394 + }
  395 +
  396 + .sidebar {
  397 + display: none;
  398 + }
  399 +
  400 + .message {
  401 + max-width: 90%;
  402 + }
  403 +
  404 + #sendButton {
  405 + padding: 15px 20px;
  406 + }
  407 +
  408 + .header-right {
  409 + flex-direction: column;
  410 + gap: 8px;
  411 + }
  412 + }
  413 + </style>
  414 +</head>
  415 +<body>
  416 +<div class="chat-container">
  417 + <div class="chat-header">
  418 + <div class="header-left">
  419 + <h1>小羚羊Ai-agent智能体</h1>
  420 + </div>
  421 + <div class="header-right">
  422 + <select class="model-selector" id="modelSelector">
  423 + <option value="process">小羚羊印刷行业大模型</option>
  424 + <option value="general">qwen2.5:14b</option>
  425 + </select>
  426 + <button class="model-selector" onclick="clearChat()">清空对话</button>
  427 + </div>
  428 + </div>
  429 +
  430 + <div class="chat-body">
  431 + <div class="chat-main">
  432 + <div class="messages-container">
  433 + <div class="chat-messages" id="chatMessages">
  434 + <!-- 初始欢迎消息 -->
  435 + <div class="message ai-message">
  436 + <div class="message-bubble">
  437 + <div class="message-content" id="ts">
  438 + <strong></strong><br><br>
  439 + </div>
  440 + <div class="message-meta">
  441 + <span class="message-time" id="welcomeTime"></span>
  442 + </div>
  443 + </div>
  444 + </div>
  445 + </div>
  446 + </div>
  447 +
  448 + <div class="input-section">
  449 + <div class="chat-input-container">
  450 + <div class="input-wrapper">
  451 + <input type="text" id="messageInput" placeholder="输入您的问题..." autocomplete="off">
  452 + <audio id="audioPlayer" controls hidden="hidden"></audio>
  453 + <button id="sendButton" onclick="sendMessage()">发送</button>
  454 + <button id="reset" onclick="reset('重置')">重置</button>
  455 + </div>
  456 + </div>
  457 + </div>
  458 + </div>
  459 + </div>
  460 +</div>
  461 +
  462 +<script>
  463 + let sessionId ="";
  464 + let userid= "17706006510007934913359242990000";
  465 + // let userid= "17502321750004978169421209637000";
  466 + // let usertype= "sysadmin";
  467 + let usertype= "General";
  468 + let authorization= "B52FF9DBEF24EA7F40A160E78A3AFF39D42BEDC4D9CB33A32B7BE6B68F15AF74F3419238942A93E9AD666629E18D159AF7FE144A6407DE745BA0AEC8B235FC1D686C9F04512DE45F9E176530AFD123789C9A98822AD3F0E3100F8DBBB5963377538155B7ADAEE71E899235DC1122F426";
  469 + // let authorization= "1EDB99C9BF070115F7A57AC43D8CB09F0B8C49F979DAB63A2AEA84B372B2B42BF3419238942A93E9AD666629E18D159AF7FE144A6407DE745BA0AEC8B235FC1D7755CEF7BCCED5C5F3A6D8323EB6C67929D9BB4A0103841ED6E33C9191B264BF538155B7ADAEE71E899235DC1122F426";
  470 + let hrefLock = window.location.origin+"/xlyAi";
  471 + // ==================== 配置部分 ====================
  472 + const CONFIG = {
  473 + // Spring Boot 后端 API 地址
  474 + backendUrl: hrefLock,
  475 + // 请求头
  476 + headers: {
  477 + 'Content-Type': 'application/json',
  478 + 'Accept': 'application/json'
  479 + },
  480 +
  481 + // 聊天历史
  482 + maxHistory: 20,
  483 +
  484 + // 流式响应配置
  485 + // streaming: true
  486 + };
  487 +
  488 + // 初始化变量
  489 + let chatHistory = [];
  490 + let currentModel = 'general';
  491 + const md = window.markdownit({
  492 + html: true,
  493 + linkify: true,
  494 + typographer: true
  495 + });
  496 +
  497 + // ==================== 初始化函数 ====================
  498 + $(document).ready(function() {
  499 + // 设置欢迎消息时间
  500 + document.getElementById('welcomeTime').textContent = getCurrentTime();
  501 + // init();
  502 + // 检查后端连接
  503 + // checkBackendStatus();
  504 +
  505 + // 加载聊天历史(从本地存储)
  506 + // loadChatHistory();
  507 +
  508 + // 聚焦输入框
  509 + $('#messageInput').focus();
  510 + // 绑定键盘事件
  511 + bindKeyboardEvents();
  512 +
  513 + // 确保输入区域在底部
  514 + ensureInputAtBottom();
  515 + });
  516 +
  517 + // ==================== 核心功能函数 ====================
  518 + // 生成指定长度的随机字符串(包含大小写字母和数字)
  519 + function generateRandomString(length) {
  520 + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
  521 + let result = '';
  522 + for (let i = 0; i < length; i++) {
  523 + result += chars.charAt(Math.floor(Math.random() * chars.length));
  524 + }
  525 +
  526 + return result;
  527 + }
  528 +
  529 + window.onload =function(){
  530 + // 准备请求数据
  531 + const data = {
  532 + text: "",
  533 + userid: userid,
  534 + // usertype: "General",
  535 + usertype: usertype,
  536 + authorization: authorization,
  537 + voice: "zh-CN-XiaoxiaoNeural",
  538 + rate: "+10%",
  539 + volume: "+0%",
  540 + voiceless: false
  541 + };
  542 +
  543 + let initUrl=CONFIG.backendUrl+"/api/tts/init";
  544 + $.ajax({
  545 + url: initUrl,
  546 + type: 'POST', // 或 'GET'
  547 + async: false, // 关键参数:设置为 false 表示同步
  548 + data:JSON.stringify(data),
  549 + dataType: 'json',
  550 + contentType: 'application/json; charset=UTF-8',
  551 + success: function(response) {
  552 + debugger;
  553 + $("#ts").html((response.processedText + response.systemText) );
  554 + },
  555 + error: function(xhr, status, error) {
  556 + console.log('请求失败:', error);
  557 + }
  558 + });
  559 +
  560 + }
  561 + function reset(message){
  562 + const input = $('#messageInput');
  563 + const button = $('#sendButton');
  564 + // 禁用输入和按钮
  565 + input.val('');
  566 + input.prop('disabled', true);
  567 + button.prop('disabled', true);
  568 + doMessage(input,message,button);
  569 + }
  570 +
  571 + async function sendMessage() {
  572 + const input = $('#messageInput');
  573 + const button = $('#sendButton');
  574 + const message = input.val();
  575 + if (!message) return;
  576 + // 禁用输入和按钮
  577 + input.val('');
  578 + input.prop('disabled', true);
  579 + button.prop('disabled', true);
  580 + doMessage(input,message,button);
  581 + }
  582 +
  583 + // 最简单版本 - 直接放在sendMessage函数里
  584 + async function doMessage(input,message,button) {
  585 + // 添加用户消息
  586 + addMessage(message, 'user');
  587 +
  588 + // 显示"正在思考"
  589 + showTypingIndicator();
  590 +
  591 + try {
  592 + // 准备请求数据
  593 + const requestData = {
  594 + text: message,
  595 + userid: userid,
  596 + // usertype: "General",
  597 + usertype: usertype,
  598 + authorization: authorization,
  599 + voice: "zh-CN-XiaoxiaoNeural",
  600 + rate: "+10%",
  601 + volume: "+0%",
  602 + voiceless: false
  603 + };
  604 +
  605 + // 发送请求
  606 + const response = await fetch(`${CONFIG.backendUrl}/api/tts/stream/query`, {
  607 + method: 'POST',
  608 + headers: { 'Content-Type': 'application/json' },
  609 + body: JSON.stringify(requestData)
  610 + });
  611 +
  612 + const data = await response.json();
  613 +
  614 + // 隐藏"正在思考"
  615 + hideTypingIndicator();
  616 + // console.log("data==",data)
  617 + // 显示AI回复文字
  618 + addMessage((data.processedText + data.systemText) || data.originalText || message, 'ai');
  619 +
  620 + // 播放音频
  621 + if (data.audioBase64) {
  622 + const audioBlob = base64ToBlob(data.audioBase64);
  623 + const audio = new Audio(URL.createObjectURL(audioBlob));
  624 + audio.play();
  625 + }
  626 +
  627 + } catch (error) {
  628 + console.error('错误:', error);
  629 + hideTypingIndicator();
  630 + addMessage(message, 'ai'); // 出错也显示原消息
  631 + } finally {
  632 + // 恢复输入框
  633 + input.prop('disabled', false);
  634 + button.prop('disabled', false);
  635 + input.focus();
  636 + scrollToBottom();
  637 + }
  638 + }
  639 +
  640 + // 工具函数
  641 + function base64ToBlob(base64) {
  642 + const byteCharacters = atob(base64);
  643 + const byteNumbers = new Array(byteCharacters.length);
  644 + for (let i = 0; i < byteCharacters.length; i++) {
  645 + byteNumbers[i] = byteCharacters.charCodeAt(i);
  646 + }
  647 + return new Blob([new Uint8Array(byteNumbers)], { type: 'audio/mpeg' });
  648 + }
  649 +
  650 + // 处理非流式响应
  651 + async function handleNormalResponse(requestData) {
  652 + try {
  653 + console.log("requestData",requestData);
  654 + const response = await fetch(`${CONFIG.backendUrl}/api/tts/stream/query`, {
  655 + method: 'POST',
  656 + headers: CONFIG.headers,
  657 + body: JSON.stringify(requestData)
  658 + });
  659 + if (!response.ok) {
  660 + throw new Error(`HTTP ${response.status}: ${response.statusText}`);
  661 + }
  662 + console.log("123",response);
  663 +
  664 + // // 流式请求
  665 + // const audioBlob = await response.blob();
  666 + // currentAudioBlob = audioBlob;
  667 + // currentAudioUrl = URL.createObjectURL(audioBlob);
  668 + // elements.audioPlayer.src = currentAudioUrl
  669 + // elements.audioPlayer.play();
  670 + //
  671 + // const messageDetail = await response.messageDetail;
  672 + // hideTypingIndicator();
  673 + // // 添加AI回复
  674 + // if (data.data) {
  675 + // addMessage(data.data, 'ai');
  676 + // saveToHistory('assistant', data.content);
  677 + // updateStatus('回答完成', 'connected');
  678 + // }
  679 +
  680 + } catch (error) {
  681 + hideTypingIndicator();
  682 + throw error;
  683 + } finally {
  684 + // 确保输入区域在底部
  685 + ensureInputAtBottom();
  686 + }
  687 + }
  688 +
  689 + // ==================== 界面辅助函数 ====================
  690 +
  691 + // 获取当前时间
  692 + function getCurrentTime() {
  693 + const now = new Date();
  694 + return now.getHours().toString().padStart(2, '0') + ':' +
  695 + now.getMinutes().toString().padStart(2, '0');
  696 + }
  697 +
  698 + // 添加消息到界面
  699 + function addMessage(content, type = 'ai') {
  700 + const messagesDiv = $('#chatMessages');
  701 + const messageId = `msg-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
  702 +
  703 + const messageHtml = `
  704 + <div class="message ${type}-message" id="${messageId}">
  705 + <div class="message-bubble">
  706 + <div class="message-content">${type === 'ai' ? md.render(content) : content}</div>
  707 + <div class="message-meta">
  708 + <span class="message-time">${getCurrentTime()}</span>
  709 + <div class="message-actions">
  710 + <button class="action-btn" onclick="copyMessage('${messageId}')">复制</button>
  711 + </div>
  712 + <div class="message-actions">
  713 + <button class="action-btn" onclick="regenerateMessage('${messageId}')">重新生成</button>
  714 + </div>
  715 + </div>
  716 + </div>
  717 + </div>
  718 + `;
  719 +
  720 + messagesDiv.append(messageHtml);
  721 + scrollToBottom();
  722 +
  723 + return messageId;
  724 + }
  725 +
  726 + // 更新消息内容
  727 + // function updateMessage(messageId, content) {
  728 + // const messageDiv = $(`#${messageId}`);
  729 + // if (messageDiv.length) {
  730 + // messageDiv.find('.message-content').html(md.render(content));
  731 + // scrollToBottom();
  732 + // }
  733 + // }
  734 +
  735 + // 显示/隐藏打字机效果
  736 + function showTypingIndicator() {
  737 + const messagesDiv = $('#chatMessages');
  738 + const typingHtml = `
  739 + <div class="message ai-message" id="typingIndicator">
  740 + <div class="typing-indicator">
  741 + <div class="typing-dot"></div>
  742 + <div class="typing-dot"></div>
  743 + <div class="typing-dot"></div>
  744 + <span style="margin-left: 10px; color: #666; font-size: 14px;">正在思考...</span>
  745 + </div>
  746 + </div>
  747 + `;
  748 + messagesDiv.append(typingHtml);
  749 + scrollToBottom();
  750 + }
  751 +
  752 + function hideTypingIndicator() {
  753 + $('#typingIndicator').remove();
  754 + }
  755 +
  756 + // 更新状态显示
  757 + function updateStatus(text, type = 'connected') {
  758 + const indicator = $('#statusIndicator');
  759 + const statusText = $('#statusText');
  760 + statusText.text(text);
  761 + indicator.removeClass('status-connected status-disconnected status-connecting');
  762 + switch(type) {
  763 + case 'connected':
  764 + indicator.addClass('status-connected');
  765 + break;
  766 + case 'error':
  767 + indicator.addClass('status-disconnected');
  768 + break;
  769 + case 'connecting':
  770 + indicator.addClass('status-connecting');
  771 + break;
  772 + }
  773 + }
  774 +
  775 + // 滚动到底部
  776 + function scrollToBottom() {
  777 + const messagesDiv = $('#chatMessages');
  778 + // 添加延迟确保DOM更新完成
  779 + setTimeout(() => {
  780 + messagesDiv.scrollTop(messagesDiv[0].scrollHeight);
  781 + }, 10);
  782 + }
  783 +
  784 + // 确保输入区域在底部
  785 + function ensureInputAtBottom() {
  786 + // 添加一个小的延迟,确保DOM更新完成
  787 + setTimeout(() => {
  788 + scrollToBottom();
  789 +
  790 + // 添加一个空div来确保底部有空间
  791 + const messagesDiv = $('#chatMessages');
  792 + let bottomSpacer = messagesDiv.find('.bottom-spacer');
  793 + if (bottomSpacer.length === 0) {
  794 + messagesDiv.append('<div class="bottom-spacer"></div>');
  795 + }
  796 + }, 100);
  797 + }
  798 +
  799 + // ==================== 历史管理函数 ====================
  800 +
  801 + // 保存到历史
  802 + function saveToHistory(role, content) {
  803 + chatHistory.push({
  804 + role: role,
  805 + content: content,
  806 + timestamp: Date.now()
  807 + });
  808 +
  809 + // 限制历史长度
  810 + if (chatHistory.length > CONFIG.maxHistory) {
  811 + chatHistory = chatHistory.slice(-CONFIG.maxHistory);
  812 + }
  813 +
  814 + // 保存到本地存储
  815 + localStorage.setItem('chatHistory', JSON.stringify(chatHistory));
  816 + }
  817 +
  818 + // 加载聊天历史
  819 + function loadChatHistory() {
  820 + const saved = localStorage.getItem('chatHistory');
  821 + if (saved) {
  822 + try {
  823 + chatHistory = JSON.parse(saved);
  824 + // 如果有历史消息,加载到界面
  825 + if (chatHistory.length > 0) {
  826 + chatHistory.forEach(item => {
  827 + if (item.role === 'user' || item.role === 'assistant') {
  828 + addMessage(item.content, item.role === 'user' ? 'user' : 'ai');
  829 + }
  830 + });
  831 + ensureInputAtBottom();
  832 + }
  833 + } catch (e) {
  834 + console.error('加载聊天历史失败:', e);
  835 + chatHistory = [];
  836 + }
  837 + }
  838 + }
  839 +
  840 + // ==================== 事件处理函数 ====================
  841 +
  842 + // 预设问题点击
  843 + // function askPresetQuestion(question) {
  844 + // $('#messageInput').val(question);
  845 + // sendMessage();
  846 + // }
  847 +
  848 + // 清空对话
  849 + function clearChat() {
  850 + if (confirm('确定要清空当前对话吗?')) {
  851 + $('#chatMessages').html(`
  852 + <div class="message ai-message">
  853 + <div class="message-bubble">
  854 + <div class="message-content">
  855 + 对话已清空,请开始新的对话。
  856 + </div>
  857 + <div class="message-meta">
  858 + <span class="message-time">${getCurrentTime()}</span>
  859 + </div>
  860 + </div>
  861 + </div>
  862 + `);
  863 + chatHistory = [];
  864 + localStorage.removeItem('chatHistory');
  865 + updateStatus('对话已清空', 'connected');
  866 + sessionId ="";
  867 + // 确保输入区域在底部
  868 + ensureInputAtBottom();
  869 + }
  870 + }
  871 +
  872 + // 复制消息
  873 + function copyMessage(messageId) {
  874 + const messageContent = $(`#${messageId}`).find('.message-content').text();
  875 + navigator.clipboard.writeText(messageContent).then(() => {
  876 + // 显示复制成功的反馈
  877 + const button = $(`#${messageId} .action-btn:first-child`);
  878 + const originalText = button.text();
  879 + button.text('已复制');
  880 + setTimeout(() => {
  881 + button.text(originalText);
  882 + }, 2000);
  883 + });
  884 + }
  885 +
  886 + // 重新生成消息
  887 + function regenerateMessage(messageId) {
  888 + // 找到对应的用户消息
  889 + const messageDiv = $(`#${messageId}`);
  890 + const content = messageDiv.find('.message-content').text();
  891 +
  892 + // 从历史中移除
  893 + chatHistory = chatHistory.filter(item =>
  894 + item.role !== 'assistant' || item.content !== content
  895 + );
  896 +
  897 + // 重新发送
  898 + $('#messageInput').val(content);
  899 + sendMessage();
  900 +
  901 + // 移除旧消息
  902 + messageDiv.remove();
  903 + }
  904 +
  905 + // 错误处理
  906 + function handleError(error) {
  907 + hideTypingIndicator();
  908 +
  909 + const errorMessage = `
  910 + 抱歉,请求出现错误:${error.message}<br><br>
  911 + <strong>可能的原因:</strong><br>
  912 + 1. Spring Boot 后端服务未启动<br>
  913 + 2. API 接口路径不正确<br>
  914 + 3. 网络连接问题<br><br>
  915 + <strong>检查步骤:</strong><br>
  916 + 1. 确保后端服务在端口 8099 运行<br>
  917 + 2. 检查浏览器控制台查看详细错误<br>
  918 + 3. 刷新页面重试
  919 + `;
  920 +
  921 + addMessage(errorMessage, 'ai');
  922 + updateStatus('请求失败', 'error');
  923 + // 确保输入区域在底部
  924 + ensureInputAtBottom();
  925 + }
  926 +
  927 + // 绑定键盘事件
  928 + function bindKeyboardEvents() {
  929 + $('#messageInput').on('keypress', function(e) {
  930 + if (e.which === 13 && !e.shiftKey) {
  931 + e.preventDefault();
  932 + sendMessage();
  933 + }
  934 + });
  935 +
  936 + $(document).on('keydown', function(e) {
  937 + // Ctrl + Enter 发送
  938 + if (e.ctrlKey && e.key === 'Enter') {
  939 + sendMessage();
  940 + }
  941 +
  942 + // ESC 清空输入框
  943 + if (e.key === 'Escape') {
  944 + $('#messageInput').val('');
  945 + }
  946 +
  947 + // 上箭头恢复上一条消息
  948 + if (e.key === 'ArrowUp' && $('#messageInput').val() === '') {
  949 + const lastUserMessage = chatHistory
  950 + .filter(item => item.role === 'user')
  951 + .pop();
  952 + if (lastUserMessage) {
  953 + $('#messageInput').val(lastUserMessage.content);
  954 + e.preventDefault();
  955 + }
  956 + }
  957 + });
  958 + }
  959 +
  960 + // 模型选择器变化
  961 + $('#modelSelector').on('change', function() {
  962 + currentModel = $(this).val();
  963 + updateStatus(`切换到${$(this).find('option:selected').text()}模式`, 'connected');
  964 + });
  965 +
  966 + // 监听窗口大小变化,重新计算布局
  967 + $(window).on('resize', function() {
  968 + ensureInputAtBottom();
  969 + });
  970 +</script>
  971 +</body>
  972 +</html>
0 973 \ No newline at end of file
... ...
src/main/resources/templates/tts.html 0 → 100644
  1 +<!DOCTYPE html>
  2 +<html lang="zh-CN">
  3 +<head>
  4 + <meta charset="UTF-8">
  5 + <meta name="viewport" content="width=device-width, initial-scale=1.0">
  6 + <title>Edge TTS 语音合成</title>
  7 + <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
  8 + <style>
  9 + * {
  10 + margin: 0;
  11 + padding: 0;
  12 + box-sizing: border-box;
  13 + }
  14 +
  15 + :root {
  16 + --primary-color: #4f46e5;
  17 + --secondary-color: #7c3aed;
  18 + --success-color: #10b981;
  19 + --warning-color: #f59e0b;
  20 + --danger-color: #ef4444;
  21 + --light-bg: #f8fafc;
  22 + --dark-bg: #1e293b;
  23 + --text-color: #334155;
  24 + --border-color: #e2e8f0;
  25 + }
  26 +
  27 + body {
  28 + font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
  29 + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  30 + min-height: 100vh;
  31 + padding: 20px;
  32 + color: var(--text-color);
  33 + }
  34 +
  35 + .container {
  36 + max-width: 1200px;
  37 + margin: 0 auto;
  38 + background: white;
  39 + border-radius: 20px;
  40 + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
  41 + overflow: hidden;
  42 + }
  43 +
  44 + .header {
  45 + background: linear-gradient(to right, var(--primary-color), var(--secondary-color));
  46 + color: white;
  47 + padding: 30px;
  48 + text-align: center;
  49 + }
  50 +
  51 + .header h1 {
  52 + font-size: 2.5rem;
  53 + margin-bottom: 10px;
  54 + display: flex;
  55 + align-items: center;
  56 + justify-content: center;
  57 + gap: 15px;
  58 + }
  59 +
  60 + .header p {
  61 + opacity: 0.9;
  62 + font-size: 1.1rem;
  63 + }
  64 +
  65 + .main-content {
  66 + display: grid;
  67 + grid-template-columns: 1fr 1fr;
  68 + gap: 30px;
  69 + padding: 30px;
  70 + }
  71 +
  72 + @media (max-width: 768px) {
  73 + .main-content {
  74 + grid-template-columns: 1fr;
  75 + }
  76 + }
  77 +
  78 + .card {
  79 + background: var(--light-bg);
  80 + border-radius: 15px;
  81 + padding: 25px;
  82 + box-shadow: 0 5px 15px rgba(0, 0, 0, 0.05);
  83 + border: 1px solid var(--border-color);
  84 + }
  85 +
  86 + .card-title {
  87 + font-size: 1.3rem;
  88 + color: var(--primary-color);
  89 + margin-bottom: 20px;
  90 + padding-bottom: 10px;
  91 + border-bottom: 2px solid var(--border-color);
  92 + display: flex;
  93 + align-items: center;
  94 + gap: 10px;
  95 + }
  96 +
  97 + .form-group {
  98 + margin-bottom: 20px;
  99 + }
  100 +
  101 + label {
  102 + display: block;
  103 + margin-bottom: 8px;
  104 + font-weight: 600;
  105 + color: var(--text-color);
  106 + }
  107 +
  108 + textarea, select, input {
  109 + width: 100%;
  110 + padding: 12px 15px;
  111 + border: 2px solid var(--border-color);
  112 + border-radius: 10px;
  113 + font-size: 1rem;
  114 + transition: all 0.3s ease;
  115 + background: white;
  116 + }
  117 +
  118 + textarea:focus, select:focus, input:focus {
  119 + outline: none;
  120 + border-color: var(--primary-color);
  121 + box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1);
  122 + }
  123 +
  124 + textarea {
  125 + min-height: 150px;
  126 + resize: vertical;
  127 + }
  128 +
  129 + .range-group {
  130 + display: grid;
  131 + grid-template-columns: 1fr auto 1fr;
  132 + gap: 15px;
  133 + align-items: center;
  134 + }
  135 +
  136 + .range-value {
  137 + text-align: center;
  138 + font-weight: bold;
  139 + color: var(--primary-color);
  140 + min-width: 50px;
  141 + }
  142 +
  143 + .btn-group {
  144 + display: grid;
  145 + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
  146 + gap: 15px;
  147 + margin-top: 30px;
  148 + }
  149 +
  150 + .btn {
  151 + padding: 14px 25px;
  152 + border: none;
  153 + border-radius: 10px;
  154 + font-size: 1rem;
  155 + font-weight: 600;
  156 + cursor: pointer;
  157 + transition: all 0.3s ease;
  158 + display: flex;
  159 + align-items: center;
  160 + justify-content: center;
  161 + gap: 10px;
  162 + }
  163 +
  164 + .btn-primary {
  165 + background: var(--primary-color);
  166 + color: white;
  167 + }
  168 +
  169 + .btn-primary:hover {
  170 + background: var(--secondary-color);
  171 + transform: translateY(-2px);
  172 + }
  173 +
  174 + .btn-success {
  175 + background: var(--success-color);
  176 + color: white;
  177 + }
  178 +
  179 + .btn-success:hover {
  180 + background: #0da271;
  181 + transform: translateY(-2px);
  182 + }
  183 +
  184 + .btn-warning {
  185 + background: var(--warning-color);
  186 + color: white;
  187 + }
  188 +
  189 + .btn-warning:hover {
  190 + background: #e69a0b;
  191 + transform: translateY(-2px);
  192 + }
  193 +
  194 + .btn-secondary {
  195 + background: #64748b;
  196 + color: white;
  197 + }
  198 +
  199 + .btn-secondary:hover {
  200 + background: #475569;
  201 + transform: translateY(-2px);
  202 + }
  203 +
  204 + .audio-player {
  205 + margin-top: 30px;
  206 + background: white;
  207 + border-radius: 15px;
  208 + padding: 20px;
  209 + box-shadow: 0 5px 15px rgba(0, 0, 0, 0.05);
  210 + }
  211 +
  212 + audio {
  213 + width: 100%;
  214 + border-radius: 10px;
  215 + }
  216 +
  217 + .status-box {
  218 + background: white;
  219 + border-radius: 15px;
  220 + padding: 20px;
  221 + margin-top: 20px;
  222 + border-left: 5px solid var(--primary-color);
  223 + }
  224 +
  225 + .status-header {
  226 + display: flex;
  227 + justify-content: space-between;
  228 + align-items: center;
  229 + margin-bottom: 15px;
  230 + }
  231 +
  232 + .status-indicator {
  233 + display: inline-flex;
  234 + align-items: center;
  235 + gap: 8px;
  236 + padding: 6px 12px;
  237 + border-radius: 20px;
  238 + font-size: 0.9rem;
  239 + font-weight: 600;
  240 + }
  241 +
  242 + .status-healthy {
  243 + background: #d1fae5;
  244 + color: var(--success-color);
  245 + }
  246 +
  247 + .status-unhealthy {
  248 + background: #fee2e2;
  249 + color: var(--danger-color);
  250 + }
  251 +
  252 + .log-box {
  253 + background: var(--dark-bg);
  254 + color: white;
  255 + border-radius: 10px;
  256 + padding: 15px;
  257 + margin-top: 15px;
  258 + max-height: 200px;
  259 + overflow-y: auto;
  260 + font-family: 'Courier New', monospace;
  261 + font-size: 0.9rem;
  262 + }
  263 +
  264 + .log-entry {
  265 + padding: 5px 0;
  266 + border-bottom: 1px solid rgba(255, 255, 255, 0.1);
  267 + }
  268 +
  269 + .log-time {
  270 + color: #94a3b8;
  271 + margin-right: 10px;
  272 + }
  273 +
  274 + .log-info {
  275 + color: #60a5fa;
  276 + }
  277 +
  278 + .log-success {
  279 + color: #34d399;
  280 + }
  281 +
  282 + .log-error {
  283 + color: #f87171;
  284 + }
  285 +
  286 + .voice-grid {
  287 + display: grid;
  288 + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
  289 + gap: 15px;
  290 + margin-top: 15px;
  291 + }
  292 +
  293 + .voice-card {
  294 + background: white;
  295 + border: 2px solid var(--border-color);
  296 + border-radius: 10px;
  297 + padding: 15px;
  298 + cursor: pointer;
  299 + transition: all 0.3s ease;
  300 + }
  301 +
  302 + .voice-card:hover {
  303 + border-color: var(--primary-color);
  304 + transform: translateY(-3px);
  305 + box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1);
  306 + }
  307 +
  308 + .voice-card.selected {
  309 + border-color: var(--primary-color);
  310 + background: rgba(79, 70, 229, 0.05);
  311 + }
  312 +
  313 + .voice-name {
  314 + font-weight: bold;
  315 + margin-bottom: 5px;
  316 + color: var(--text-color);
  317 + }
  318 +
  319 + .voice-details {
  320 + font-size: 0.9rem;
  321 + color: #64748b;
  322 + display: flex;
  323 + justify-content: space-between;
  324 + }
  325 +
  326 + .progress-container {
  327 + margin-top: 20px;
  328 + display: none;
  329 + }
  330 +
  331 + .progress-bar {
  332 + width: 100%;
  333 + height: 6px;
  334 + background: var(--border-color);
  335 + border-radius: 3px;
  336 + overflow: hidden;
  337 + margin-bottom: 10px;
  338 + }
  339 +
  340 + .progress-fill {
  341 + height: 100%;
  342 + background: linear-gradient(to right, var(--primary-color), var(--secondary-color));
  343 + width: 0%;
  344 + transition: width 0.3s ease;
  345 + }
  346 +
  347 + .progress-text {
  348 + text-align: center;
  349 + font-size: 0.9rem;
  350 + color: var(--text-color);
  351 + }
  352 +
  353 + .footer {
  354 + text-align: center;
  355 + padding: 20px;
  356 + color: white;
  357 + opacity: 0.8;
  358 + font-size: 0.9rem;
  359 + background: rgba(0, 0, 0, 0.2);
  360 + border-radius: 0 0 20px 20px;
  361 + }
  362 +
  363 + .notification {
  364 + position: fixed;
  365 + top: 20px;
  366 + right: 20px;
  367 + padding: 15px 20px;
  368 + border-radius: 10px;
  369 + color: white;
  370 + font-weight: 600;
  371 + box-shadow: 0 5px 15px rgba(0, 0, 0, 0.2);
  372 + transform: translateX(500px);
  373 + transition: transform 0.3s ease;
  374 + z-index: 1000;
  375 + }
  376 +
  377 + .notification.show {
  378 + transform: translateX(0);
  379 + }
  380 +
  381 + .notification.success {
  382 + background: var(--success-color);
  383 + }
  384 +
  385 + .notification.error {
  386 + background: var(--danger-color);
  387 + }
  388 +
  389 + .notification.info {
  390 + background: var(--primary-color);
  391 + }
  392 +
  393 + .loading {
  394 + display: inline-block;
  395 + width: 20px;
  396 + height: 20px;
  397 + border: 3px solid rgba(255, 255, 255, 0.3);
  398 + border-radius: 50%;
  399 + border-top-color: white;
  400 + animation: spin 1s ease-in-out infinite;
  401 + }
  402 +
  403 + @keyframes spin {
  404 + to { transform: rotate(360deg); }
  405 + }
  406 +
  407 + .tab-container {
  408 + margin-bottom: 20px;
  409 + }
  410 +
  411 + .tabs {
  412 + display: flex;
  413 + border-bottom: 2px solid var(--border-color);
  414 + }
  415 +
  416 + .tab {
  417 + padding: 12px 25px;
  418 + background: none;
  419 + border: none;
  420 + font-size: 1rem;
  421 + font-weight: 600;
  422 + color: var(--text-color);
  423 + cursor: pointer;
  424 + transition: all 0.3s ease;
  425 + border-bottom: 3px solid transparent;
  426 + }
  427 +
  428 + .tab.active {
  429 + color: var(--primary-color);
  430 + border-bottom-color: var(--primary-color);
  431 + }
  432 +
  433 + .tab-content {
  434 + display: none;
  435 + animation: fadeIn 0.3s ease;
  436 + }
  437 +
  438 + .tab-content.active {
  439 + display: block;
  440 + }
  441 +
  442 + @keyframes fadeIn {
  443 + from { opacity: 0; transform: translateY(10px); }
  444 + to { opacity: 1; transform: translateY(0); }
  445 + }
  446 + </style>
  447 +</head>
  448 +<body>
  449 +<div class="container">
  450 + <div class="header">
  451 + <h1>
  452 + <i class="fas fa-robot"></i>
  453 + Edge TTS 语音合成系统
  454 + </h1>
  455 + <p>支持多种语言,流式音频输出,高质量语音合成</p>
  456 + </div>
  457 +
  458 + <div class="tab-container">
  459 + <div class="tabs">
  460 + <button class="tab active" data-tab="synthesize">语音合成</button>
  461 + <button class="tab" data-tab="batch">批量处理</button>
  462 + <button class="tab" data-tab="voices">语音库</button>
  463 + <button class="tab" data-tab="settings">设置</button>
  464 + </div>
  465 + </div>
  466 +
  467 + <div class="main-content">
  468 + <!-- 语音合成标签页 -->
  469 + <div class="tab-content active" id="synthesize">
  470 + <div class="card">
  471 + <h2 class="card-title">
  472 + <i class="fas fa-microphone-alt"></i>
  473 + 语音合成设置
  474 + </h2>
  475 +
  476 + <div class="form-group">
  477 + <label for="textInput">
  478 + <i class="fas fa-font"></i> 输入文本
  479 + </label>
  480 + <textarea id="textInput" placeholder="请输入要转换为语音的文本...">
  481 +欢迎使用Edge TTS语音合成系统。这是一个高质量的文本转语音服务,支持多种语言和语音风格。您可以选择不同的发音人、调整语速和音量,生成自然流畅的语音。
  482 + </textarea>
  483 + <div style="text-align: right; margin-top: 5px; font-size: 0.9rem; color: #64748b;">
  484 + <span id="charCount">0</span>/5000 字符
  485 + </div>
  486 + </div>
  487 +
  488 + <div class="form-group">
  489 + <label for="voiceSelect">
  490 + <i class="fas fa-user"></i> 选择发音人
  491 + </label>
  492 + <select id="voiceSelect">
  493 + <option value="zh-CN-XiaoxiaoNeural">晓晓 - 中文女声</option>
  494 + <option value="zh-CN-YunyangNeural">云扬 - 中文男声</option>
  495 + <option value="en-US-JennyNeural">Jenny - 英文女声</option>
  496 + <option value="en-US-GuyNeural">Guy - 英文男声</option>
  497 + <option value="ja-JP-NanamiNeural">七海 - 日文女声</option>
  498 + <option value="ko-KR-SunHiNeural">선히 - 韩文女声</option>
  499 + </select>
  500 + </div>
  501 +
  502 + <div class="form-group">
  503 + <label><i class="fas fa-tachometer-alt"></i> 语速控制</label>
  504 + <div class="range-group">
  505 + <input type="range" id="rateControl" min="-50" max="50" value="0">
  506 + <span class="range-value" id="rateValue">+0%</span>
  507 + <div style="font-size: 0.9rem; color: #64748b;">
  508 + <span style="float: left;">慢</span>
  509 + <span style="float: right;">快</span>
  510 + </div>
  511 + </div>
  512 + </div>
  513 +
  514 + <div class="form-group">
  515 + <label><i class="fas fa-volume-up"></i> 音量控制</label>
  516 + <div class="range-group">
  517 + <input type="range" id="volumeControl" min="-50" max="50" value="0">
  518 + <span class="range-value" id="volumeValue">+0%</span>
  519 + <div style="font-size: 0.9rem; color: #64748b;">
  520 + <span style="float: left;">小</span>
  521 + <span style="float: right;">大</span>
  522 + </div>
  523 + </div>
  524 + </div>
  525 +
  526 + <div class="progress-container" id="progressContainer">
  527 + <div class="progress-bar">
  528 + <div class="progress-fill" id="progressFill"></div>
  529 + </div>
  530 + <div class="progress-text" id="progressText">准备合成...</div>
  531 + </div>
  532 +
  533 + <div class="btn-group">
  534 + <button class="btn btn-primary" id="synthesizeBtn">
  535 + <i class="fas fa-play"></i> 合成语音
  536 + </button>
  537 + <button class="btn btn-success" id="streamBtn">
  538 + <i class="fas fa-stream"></i> 流式合成
  539 + </button>
  540 + <button class="btn btn-secondary" id="testBtn">
  541 + <i class="fas fa-vial"></i> 测试语音
  542 + </button>
  543 + <button class="btn btn-warning" id="downloadBtn" disabled>
  544 + <i class="fas fa-download"></i> 下载音频
  545 + </button>
  546 + </div>
  547 + </div>
  548 +
  549 + <div class="card">
  550 + <h2 class="card-title">
  551 + <i class="fas fa-music"></i>
  552 + 音频播放器
  553 + </h2>
  554 +
  555 + <div class="audio-player">
  556 + <audio id="audioPlayer" controls></audio>
  557 + <div style="margin-top: 15px; display: flex; gap: 10px;">
  558 + <button class="btn btn-secondary" id="playBtn" style="flex: 1;">
  559 + <i class="fas fa-play"></i> 播放
  560 + </button>
  561 + <button class="btn btn-secondary" id="pauseBtn" style="flex: 1;">
  562 + <i class="fas fa-pause"></i> 暂停
  563 + </button>
  564 + <button class="btn btn-secondary" id="stopBtn" style="flex: 1;">
  565 + <i class="fas fa-stop"></i> 停止
  566 + </button>
  567 + </div>
  568 + </div>
  569 +
  570 + <div class="status-box">
  571 + <div class="status-header">
  572 + <h3><i class="fas fa-heartbeat"></i> 系统状态</h3>
  573 + <span class="status-indicator status-healthy" id="statusIndicator">
  574 + <i class="fas fa-check-circle"></i> 服务正常
  575 + </span>
  576 + </div>
  577 + <div class="log-box" id="logBox">
  578 + <div class="log-entry">
  579 + <span class="log-time">12:00:00</span>
  580 + <span class="log-info">系统初始化完成</span>
  581 + </div>
  582 + </div>
  583 + <div style="margin-top: 15px; display: flex; gap: 10px;">
  584 + <button class="btn btn-secondary" id="clearLogBtn" style="flex: 1;">
  585 + <i class="fas fa-trash"></i> 清空日志
  586 + </button>
  587 + <button class="btn btn-secondary" id="checkHealthBtn" style="flex: 1;">
  588 + <i class="fas fa-sync-alt"></i> 检查状态
  589 + </button>
  590 + </div>
  591 + </div>
  592 + </div>
  593 + </div>
  594 +
  595 + <!-- 批量处理标签页 -->
  596 + <div class="tab-content" id="batch">
  597 + <div class="card" style="grid-column: 1 / -1;">
  598 + <h2 class="card-title">
  599 + <i class="fas fa-tasks"></i>
  600 + 批量语音合成
  601 + </h2>
  602 +
  603 + <div class="form-group">
  604 + <label><i class="fas fa-list"></i> 批量输入(每行一条文本)</label>
  605 + <textarea id="batchTextInput" placeholder="请输入多条文本,每行一条..." rows="10">
  606 +你好,欢迎使用语音合成系统。
  607 +Hello, welcome to the TTS system.
  608 +こんにちは、音声合成システムへようこそ。
  609 +안녕하세요, 음성 합성 시스템에 오신 것을 환영합니다.
  610 + </textarea>
  611 + </div>
  612 +
  613 + <div class="form-group">
  614 + <label><i class="fas fa-cog"></i> 批量设置</label>
  615 + <div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 15px;">
  616 + <div>
  617 + <label style="font-size: 0.9rem;">语音选择</label>
  618 + <select id="batchVoiceSelect">
  619 + <option value="zh-CN-XiaoxiaoNeural">中文女声</option>
  620 + <option value="en-US-JennyNeural">英文女声</option>
  621 + <option value="ja-JP-NanamiNeural">日文女声</option>
  622 + </select>
  623 + </div>
  624 + <div>
  625 + <label style="font-size: 0.9rem;">输出格式</label>
  626 + <select id="batchFormat">
  627 + <option value="stream">流式播放</option>
  628 + <option value="download">下载文件</option>
  629 + </select>
  630 + </div>
  631 + </div>
  632 + </div>
  633 +
  634 + <div class="btn-group">
  635 + <button class="btn btn-primary" id="batchSynthesizeBtn">
  636 + <i class="fas fa-play-circle"></i> 批量合成
  637 + </button>
  638 + <button class="btn btn-success" id="batchDownloadBtn" disabled>
  639 + <i class="fas fa-file-archive"></i> 打包下载
  640 + </button>
  641 + <button class="btn btn-secondary" id="clearBatchBtn">
  642 + <i class="fas fa-trash"></i> 清空列表
  643 + </button>
  644 + </div>
  645 +
  646 + <div id="batchResults" style="margin-top: 30px;">
  647 + <h3><i class="fas fa-history"></i> 合成结果</h3>
  648 + <div id="batchResultList" style="margin-top: 15px;"></div>
  649 + </div>
  650 + </div>
  651 + </div>
  652 +
  653 + <!-- 语音库标签页 -->
  654 + <div class="tab-content" id="voices" style="grid-column: 1 / -1;">
  655 + <div class="card">
  656 + <h2 class="card-title">
  657 + <i class="fas fa-users"></i>
  658 + 可用语音库
  659 + </h2>
  660 +
  661 + <div style="margin-bottom: 20px;">
  662 + <input type="text" id="voiceSearch" placeholder="搜索语音..." style="width: 300px; max-width: 100%;">
  663 + </div>
  664 +
  665 + <div id="voiceList" class="voice-grid">
  666 + <!-- 语音卡片将通过JS动态加载 -->
  667 + </div>
  668 +
  669 + <div style="margin-top: 30px; text-align: center;">
  670 + <button class="btn btn-primary" id="refreshVoicesBtn">
  671 + <i class="fas fa-sync-alt"></i> 刷新语音列表
  672 + </button>
  673 + </div>
  674 + </div>
  675 + </div>
  676 +
  677 + <!-- 设置标签页 -->
  678 + <div class="tab-content" id="settings" style="grid-column: 1 / -1;">
  679 + <div class="card">
  680 + <h2 class="card-title">
  681 + <i class="fas fa-cog"></i>
  682 + 系统设置
  683 + </h2>
  684 +
  685 + <div class="form-group">
  686 + <label><i class="fas fa-server"></i> 后端API地址</label>
  687 + <input type="text" id="apiUrl" value="http://localhost:8099/xlyAi/api/tts" placeholder="请输入后端API地址">
  688 + </div>
  689 +
  690 + <div class="form-group">
  691 + <label><i class="fas fa-clock"></i> 请求超时设置(秒)</label>
  692 + <input type="number" id="timeoutSetting" value="30" min="5" max="300">
  693 + </div>
  694 +
  695 + <div class="form-group">
  696 + <label><i class="fas fa-volume-mute"></i> 静音设置</label>
  697 + <div>
  698 + <input type="checkbox" id="autoPlayCheckbox">
  699 + <label for="autoPlayCheckbox" style="display: inline; margin-left: 8px;">
  700 + 合成完成后自动播放
  701 + </label>
  702 + </div>
  703 + </div>
  704 +
  705 + <div class="btn-group">
  706 + <button class="btn btn-primary" id="saveSettingsBtn">
  707 + <i class="fas fa-save"></i> 保存设置
  708 + </button>
  709 + <button class="btn btn-secondary" id="resetSettingsBtn">
  710 + <i class="fas fa-undo"></i> 恢复默认
  711 + </button>
  712 + </div>
  713 + </div>
  714 + </div>
  715 + </div>
  716 +
  717 + <div class="footer">
  718 + <p>Edge TTS 语音合成系统 © 2024 | 技术支持:XLY Tech</p>
  719 + <p style="margin-top: 5px; font-size: 0.8rem;">
  720 + 版本: v1.0.0 | 最后更新: 2024-01-15
  721 + </p>
  722 + </div>
  723 +</div>
  724 +
  725 +<div class="notification" id="notification"></div>
  726 +
  727 +<script>
  728 + // 全局变量
  729 + let currentAudioUrl = null;
  730 + let currentAudioBlob = null;
  731 + let logs = [];
  732 + let voices = [];
  733 + // 改为Java服务的地址
  734 + let settings = {
  735 + apiUrl: 'http://localhost:8099/xlyAi/api/tts', // Java服务
  736 + timeout: 30000,
  737 + autoPlay: true
  738 + };
  739 +
  740 + // DOM 元素
  741 + const elements = {
  742 + textInput: document.getElementById('textInput'),
  743 + voiceSelect: document.getElementById('voiceSelect'),
  744 + rateControl: document.getElementById('rateControl'),
  745 + rateValue: document.getElementById('rateValue'),
  746 + volumeControl: document.getElementById('volumeControl'),
  747 + volumeValue: document.getElementById('volumeValue'),
  748 + synthesizeBtn: document.getElementById('synthesizeBtn'),
  749 + streamBtn: document.getElementById('streamBtn'),
  750 + testBtn: document.getElementById('testBtn'),
  751 + downloadBtn: document.getElementById('downloadBtn'),
  752 + audioPlayer: document.getElementById('audioPlayer'),
  753 + playBtn: document.getElementById('playBtn'),
  754 + pauseBtn: document.getElementById('pauseBtn'),
  755 + stopBtn: document.getElementById('stopBtn'),
  756 + statusIndicator: document.getElementById('statusIndicator'),
  757 + logBox: document.getElementById('logBox'),
  758 + clearLogBtn: document.getElementById('clearLogBtn'),
  759 + checkHealthBtn: document.getElementById('checkHealthBtn'),
  760 + charCount: document.getElementById('charCount'),
  761 + progressContainer: document.getElementById('progressContainer'),
  762 + progressFill: document.getElementById('progressFill'),
  763 + progressText: document.getElementById('progressText'),
  764 + notification: document.getElementById('notification')
  765 + };
  766 +
  767 + // 初始化
  768 + document.addEventListener('DOMContentLoaded', () => {
  769 + initEventListeners();
  770 + loadSettings();
  771 + updateCharCount();
  772 + checkServiceHealth();
  773 + loadVoices();
  774 + setupTabs();
  775 + });
  776 +
  777 + // 设置标签页
  778 + function setupTabs() {
  779 + document.querySelectorAll('.tab').forEach(tab => {
  780 + tab.addEventListener('click', () => {
  781 + const tabId = tab.getAttribute('data-tab');
  782 +
  783 + // 更新活动标签
  784 + document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
  785 + document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
  786 +
  787 + tab.classList.add('active');
  788 + document.getElementById(tabId).classList.add('active');
  789 + });
  790 + });
  791 + }
  792 +
  793 + // 初始化事件监听器
  794 + function initEventListeners() {
  795 + // 输入事件
  796 + elements.textInput.addEventListener('input', updateCharCount);
  797 + elements.rateControl.addEventListener('input', updateRateValue);
  798 + elements.volumeControl.addEventListener('input', updateVolumeValue);
  799 +
  800 + // 按钮事件
  801 + elements.synthesizeBtn.addEventListener('click', synthesizeSpeech);
  802 + elements.streamBtn.addEventListener('click', streamSynthesize);
  803 + elements.testBtn.addEventListener('click', testSynthesis);
  804 + elements.downloadBtn.addEventListener('click', downloadAudio);
  805 +
  806 + // 音频控制
  807 + elements.playBtn.addEventListener('click', () => elements.audioPlayer.play());
  808 + elements.pauseBtn.addEventListener('click', () => elements.audioPlayer.pause());
  809 + elements.stopBtn.addEventListener('click', () => {
  810 + elements.audioPlayer.pause();
  811 + elements.audioPlayer.currentTime = 0;
  812 + });
  813 +
  814 + // 系统控制
  815 + elements.clearLogBtn.addEventListener('click', clearLogs);
  816 + elements.checkHealthBtn.addEventListener('click', checkServiceHealth);
  817 +
  818 + // 批量处理
  819 + document.getElementById('batchSynthesizeBtn')?.addEventListener('click', batchSynthesize);
  820 + document.getElementById('clearBatchBtn')?.addEventListener('click', clearBatch);
  821 + document.getElementById('refreshVoicesBtn')?.addEventListener('click', loadVoices);
  822 +
  823 + // 设置
  824 + document.getElementById('saveSettingsBtn')?.addEventListener('click', saveSettings);
  825 + document.getElementById('resetSettingsBtn')?.addEventListener('click', resetSettings);
  826 +
  827 + // 搜索
  828 + document.getElementById('voiceSearch')?.addEventListener('input', filterVoices);
  829 +
  830 + // 音频事件
  831 + elements.audioPlayer.addEventListener('loadeddata', () => {
  832 + logMessage('音频加载完成', 'success');
  833 + });
  834 +
  835 + elements.audioPlayer.addEventListener('error', (e) => {
  836 + logMessage('音频加载失败: ' + e.message, 'error');
  837 + });
  838 + }
  839 +
  840 + // 更新字符计数
  841 + function updateCharCount() {
  842 + const text = elements.textInput.value;
  843 + const count = text.length;
  844 + elements.charCount.textContent = count;
  845 +
  846 + if (count > 5000) {
  847 + elements.charCount.style.color = '#ef4444';
  848 + } else if (count > 4000) {
  849 + elements.charCount.style.color = '#f59e0b';
  850 + } else {
  851 + elements.charCount.style.color = '#64748b';
  852 + }
  853 + }
  854 +
  855 + // 更新语速值
  856 + function updateRateValue() {
  857 + const value = elements.rateControl.value;
  858 + const displayValue = value >= 0 ? `+${value}%` : `${value}%`;
  859 + elements.rateValue.textContent = displayValue;
  860 + }
  861 +
  862 + // 更新音量值
  863 + function updateVolumeValue() {
  864 + const value = elements.volumeControl.value;
  865 + const displayValue = value >= 0 ? `+${value}%` : `${value}%`;
  866 + elements.volumeValue.textContent = displayValue;
  867 + }
  868 +
  869 + // 显示通知
  870 + function showNotification(message, type = 'info') {
  871 + const notification = elements.notification;
  872 + notification.textContent = message;
  873 + notification.className = `notification ${type} show`;
  874 +
  875 + setTimeout(() => {
  876 + notification.classList.remove('show');
  877 + }, 3000);
  878 + }
  879 +
  880 + // 记录日志
  881 + function logMessage(message, type = 'info') {
  882 + const now = new Date();
  883 + const timeString = now.toTimeString().split(' ')[0];
  884 +
  885 + logs.push({ time: timeString, message, type });
  886 +
  887 + // 更新日志显示
  888 + updateLogDisplay();
  889 +
  890 + // 滚动到底部
  891 + elements.logBox.scrollTop = elements.logBox.scrollHeight;
  892 + }
  893 +
  894 + // 更新日志显示
  895 + function updateLogDisplay() {
  896 + const logBox = elements.logBox;
  897 + logBox.innerHTML = '';
  898 +
  899 + logs.slice(-20).forEach(log => {
  900 + const entry = document.createElement('div');
  901 + entry.className = 'log-entry';
  902 + entry.innerHTML = `
  903 + <span class="log-time">${log.time}</span>
  904 + <span class="log-${log.type}">${log.message}</span>
  905 + `;
  906 + logBox.appendChild(entry);
  907 + });
  908 + }
  909 +
  910 + // 清空日志
  911 + function clearLogs() {
  912 + logs = [];
  913 + updateLogDisplay();
  914 + logMessage('日志已清空', 'info');
  915 + }
  916 +
  917 + // 更新进度条
  918 + function updateProgress(percentage, message) {
  919 + elements.progressContainer.style.display = 'block';
  920 + elements.progressFill.style.width = `${percentage}%`;
  921 + elements.progressText.textContent = message;
  922 + }
  923 +
  924 + // 隐藏进度条
  925 + function hideProgress() {
  926 + setTimeout(() => {
  927 + elements.progressContainer.style.display = 'none';
  928 + }, 500);
  929 + }
  930 +
  931 + // 合成语音(同步)
  932 + async function synthesizeSpeech() {
  933 + const text = elements.textInput.value.trim();
  934 + if (!text) {
  935 + showNotification('请输入文本内容', 'error');
  936 + return;
  937 + }
  938 +
  939 + if (text.length > 5000) {
  940 + showNotification('文本过长,请限制在5000字符以内', 'error');
  941 + return;
  942 + }
  943 +
  944 + try {
  945 + updateProgress(10, '准备请求...');
  946 +
  947 + const requestData = {
  948 + text: text,
  949 + voice: elements.voiceSelect.value,
  950 + rate: elements.rateValue.textContent,
  951 + volume: elements.volumeValue.textContent
  952 + };
  953 +
  954 + logMessage('开始合成语音...', 'info');
  955 + showNotification('开始合成语音', 'info');
  956 +
  957 + updateProgress(30, '发送请求到服务器...');
  958 +
  959 + // 调用后端API
  960 + const response = await fetch(`${settings.apiUrl}/stream`, {
  961 + method: 'POST',
  962 + headers: {
  963 + 'Content-Type': 'application/json'
  964 + },
  965 + body: JSON.stringify(requestData)
  966 + });
  967 +
  968 + updateProgress(70, '接收音频数据...');
  969 +
  970 + if (!response.ok) {
  971 + throw new Error(`HTTP ${response.status}: ${response.statusText}`);
  972 + }
  973 +
  974 + const audioBlob = await response.blob();
  975 + currentAudioBlob = audioBlob;
  976 + currentAudioUrl = URL.createObjectURL(audioBlob);
  977 + elements.audioPlayer.src = currentAudioUrl;
  978 +
  979 + // 启用下载按钮
  980 + elements.downloadBtn.disabled = false;
  981 +
  982 + updateProgress(100, '合成完成!');
  983 + logMessage(`语音合成成功,音频大小: ${formatBytes(audioBlob.size)}`, 'success');
  984 + showNotification('语音合成完成', 'success');
  985 +
  986 + // 自动播放
  987 + if (settings.autoPlay) {
  988 + setTimeout(() => {
  989 + elements.audioPlayer.play();
  990 + logMessage('开始播放音频', 'info');
  991 + }, 500);
  992 + }
  993 +
  994 + hideProgress();
  995 +
  996 + } catch (error) {
  997 + console.error('语音合成失败:', error);
  998 + logMessage(`语音合成失败: ${error.message}`, 'error');
  999 + showNotification('语音合成失败', 'error');
  1000 + hideProgress();
  1001 + }
  1002 + }
  1003 +
  1004 + // 流式合成
  1005 + async function streamSynthesize() {
  1006 + const text = elements.textInput.value.trim();
  1007 + if (!text) {
  1008 + showNotification('请输入文本内容', 'error');
  1009 + return;
  1010 + }
  1011 +
  1012 + try {
  1013 + const requestData = {
  1014 + text: text,
  1015 + voice: elements.voiceSelect.value
  1016 + };
  1017 +
  1018 + logMessage('开始流式合成...', 'info');
  1019 + showNotification('开始流式合成', 'info');
  1020 +
  1021 + // 流式请求
  1022 + const response = await fetch(`${settings.apiUrl}/quick-stream?text=${encodeURIComponent(text)}&voice=${requestData.voice}`);
  1023 +
  1024 + if (!response.ok) {
  1025 + throw new Error(`HTTP ${response.status}: ${response.statusText}`);
  1026 + }
  1027 +
  1028 + const audioBlob = await response.blob();
  1029 + currentAudioBlob = audioBlob;
  1030 + currentAudioUrl = URL.createObjectURL(audioBlob);
  1031 + elements.audioPlayer.src = currentAudioUrl;
  1032 + elements.downloadBtn.disabled = false;
  1033 +
  1034 + logMessage(`流式合成完成,音频大小: ${formatBytes(audioBlob.size)}`, 'success');
  1035 + showNotification('流式合成完成', 'success');
  1036 +
  1037 + // 自动播放
  1038 + if (settings.autoPlay) {
  1039 + elements.audioPlayer.play();
  1040 + logMessage('开始播放音频', 'info');
  1041 + }
  1042 +
  1043 + } catch (error) {
  1044 + console.error('流式合成失败:', error);
  1045 + logMessage(`流式合成失败: ${error.message}`, 'error');
  1046 + showNotification('流式合成失败', 'error');
  1047 + }
  1048 + }
  1049 +
  1050 + // 测试合成
  1051 + async function testSynthesis() {
  1052 + const testTexts = [
  1053 + { text: "你好,欢迎使用语音合成系统", voice: "zh-CN-XiaoxiaoNeural" },
  1054 + { text: "Hello, welcome to the TTS system", voice: "en-US-JennyNeural" },
  1055 + { text: "こんにちは、音声合成システムへようこそ", voice: "ja-JP-NanamiNeural" }
  1056 + ];
  1057 +
  1058 + for (const [index, test] of testTexts.entries()) {
  1059 + try {
  1060 + logMessage(`测试语音 ${index + 1}: ${test.text}`, 'info');
  1061 +
  1062 + const response = await fetch(`${settings.apiUrl}/quick-stream?text=${encodeURIComponent(test.text)}&voice=${test.voice}`);
  1063 +
  1064 + if (response.ok) {
  1065 + logMessage(`测试语音 ${index + 1} 成功`, 'success');
  1066 + } else {
  1067 + logMessage(`测试语音 ${index + 1} 失败`, 'error');
  1068 + }
  1069 +
  1070 + // 等待1秒
  1071 + await new Promise(resolve => setTimeout(resolve, 1000));
  1072 +
  1073 + } catch (error) {
  1074 + logMessage(`测试语音 ${index + 1} 失败: ${error.message}`, 'error');
  1075 + }
  1076 + }
  1077 +
  1078 + showNotification('测试完成', 'info');
  1079 + }
  1080 +
  1081 + // 下载音频
  1082 + function downloadAudio() {
  1083 + if (!currentAudioBlob) {
  1084 + showNotification('没有可下载的音频', 'error');
  1085 + return;
  1086 + }
  1087 +
  1088 + const url = window.URL.createObjectURL(currentAudioBlob);
  1089 + const a = document.createElement('a');
  1090 + a.href = url;
  1091 + a.download = `tts_${Date.now()}.mp3`;
  1092 + document.body.appendChild(a);
  1093 + a.click();
  1094 + document.body.removeChild(a);
  1095 + window.URL.revokeObjectURL(url);
  1096 +
  1097 + logMessage('音频文件已下载', 'success');
  1098 + showNotification('音频下载开始', 'success');
  1099 + }
  1100 +
  1101 + // 检查服务健康状态
  1102 + async function checkServiceHealth() {
  1103 + try {
  1104 + logMessage('正在检查服务状态...', 'info');
  1105 +
  1106 + const response = await fetch(`${settings.apiUrl}/health`, {
  1107 + method: 'GET',
  1108 + headers: {
  1109 + 'Accept': 'application/json'
  1110 + }
  1111 + });
  1112 +
  1113 + if (response.ok) {
  1114 + const data = await response.text();
  1115 + elements.statusIndicator.className = 'status-indicator status-healthy';
  1116 + elements.statusIndicator.innerHTML = '<i class="fas fa-check-circle"></i> 服务正常';
  1117 + logMessage(`服务状态: ${data}`, 'success');
  1118 + showNotification('服务运行正常', 'success');
  1119 + return true;
  1120 + } else {
  1121 + throw new Error('服务响应异常');
  1122 + }
  1123 +
  1124 + } catch (error) {
  1125 + console.error('服务健康检查失败:', error);
  1126 + elements.statusIndicator.className = 'status-indicator status-unhealthy';
  1127 + elements.statusIndicator.innerHTML = '<i class="fas fa-exclamation-circle"></i> 服务异常';
  1128 + logMessage(`服务检查失败: ${error.message}`, 'error');
  1129 + showNotification('服务连接失败', 'error');
  1130 + return false;
  1131 + }
  1132 + }
  1133 +
  1134 + // 加载语音列表
  1135 + async function loadVoices() {
  1136 + try {
  1137 + const response = await fetch(`${settings.apiUrl}/voices`);
  1138 +
  1139 + if (!response.ok) {
  1140 + throw new Error('获取语音列表失败');
  1141 + }
  1142 +
  1143 + const data = await response.json();
  1144 + voices = data;
  1145 + renderVoices(data);
  1146 + logMessage(`加载了 ${data.length} 个语音`, 'success');
  1147 +
  1148 + } catch (error) {
  1149 + console.error('加载语音列表失败:', error);
  1150 + logMessage('加载语音列表失败,使用默认语音', 'error');
  1151 + renderDefaultVoices();
  1152 + }
  1153 + }
  1154 +
  1155 + // 渲染语音列表
  1156 + function renderVoices(voiceList) {
  1157 + const voiceGrid = document.getElementById('voiceList');
  1158 + voiceGrid.innerHTML = '';
  1159 +
  1160 + voiceList.forEach(voice => {
  1161 + const voiceCard = document.createElement('div');
  1162 + voiceCard.className = 'voice-card';
  1163 + voiceCard.innerHTML = `
  1164 + <div class="voice-name">${voice.displayName || voice.name}</div>
  1165 + <div class="voice-details">
  1166 + <span>${voice.locale}</span>
  1167 + <span><i class="fas fa-${voice.gender.toLowerCase()}"></i></span>
  1168 + </div>
  1169 + `;
  1170 +
  1171 + voiceCard.addEventListener('click', () => {
  1172 + // 移除所有选中状态
  1173 + document.querySelectorAll('.voice-card').forEach(card => {
  1174 + card.classList.remove('selected');
  1175 + });
  1176 +
  1177 + // 添加选中状态
  1178 + voiceCard.classList.add('selected');
  1179 +
  1180 + // 更新选择框
  1181 + elements.voiceSelect.value = voice.name;
  1182 +
  1183 + logMessage(`选择了语音: ${voice.displayName || voice.name}`, 'info');
  1184 + });
  1185 +
  1186 + voiceGrid.appendChild(voiceCard);
  1187 + });
  1188 + }
  1189 +
  1190 + // 渲染默认语音
  1191 + function renderDefaultVoices() {
  1192 + const defaultVoices = [
  1193 + { name: "zh-CN-XiaoxiaoNeural", locale: "zh-CN", gender: "Female", displayName: "晓晓 - 中文女声" },
  1194 + { name: "zh-CN-YunyangNeural", locale: "zh-CN", gender: "Male", displayName: "云扬 - 中文男声" },
  1195 + { name: "en-US-JennyNeural", locale: "en-US", gender: "Female", displayName: "Jenny - 英文女声" },
  1196 + { name: "en-US-GuyNeural", locale: "en-US", gender: "Male", displayName: "Guy - 英文男声" },
  1197 + { name: "ja-JP-NanamiNeural", locale: "ja-JP", gender: "Female", displayName: "七海 - 日文女声" },
  1198 + { name: "ko-KR-SunHiNeural", locale: "ko-KR", gender: "Female", displayName: "선히 - 韩文女声" }
  1199 + ];
  1200 +
  1201 + renderVoices(defaultVoices);
  1202 + }
  1203 +
  1204 + // 过滤语音
  1205 + function filterVoices() {
  1206 + const searchTerm = document.getElementById('voiceSearch').value.toLowerCase();
  1207 + const filteredVoices = voices.filter(voice =>
  1208 + (voice.displayName && voice.displayName.toLowerCase().includes(searchTerm)) ||
  1209 + (voice.name && voice.name.toLowerCase().includes(searchTerm)) ||
  1210 + (voice.locale && voice.locale.toLowerCase().includes(searchTerm))
  1211 + );
  1212 +
  1213 + renderVoices(filteredVoices);
  1214 + }
  1215 +
  1216 + // 批量合成
  1217 + async function batchSynthesize() {
  1218 + const batchText = document.getElementById('batchTextInput').value.trim();
  1219 + if (!batchText) {
  1220 + showNotification('请输入批量文本', 'error');
  1221 + return;
  1222 + }
  1223 +
  1224 + const texts = batchText.split('\n').filter(line => line.trim());
  1225 + const voice = document.getElementById('batchVoiceSelect').value;
  1226 + const format = document.getElementById('batchFormat').value;
  1227 +
  1228 + logMessage(`开始批量合成 ${texts.length} 条文本`, 'info');
  1229 + showNotification(`开始批量处理 ${texts.length} 条文本`, 'info');
  1230 +
  1231 + const resultList = document.getElementById('batchResultList');
  1232 + resultList.innerHTML = '';
  1233 +
  1234 + for (let i = 0; i < texts.length; i++) {
  1235 + const text = texts[i].trim();
  1236 + if (!text) continue;
  1237 +
  1238 + try {
  1239 + const response = await fetch(`${settings.apiUrl}/quick-stream?text=${encodeURIComponent(text)}&voice=${voice}`);
  1240 +
  1241 + const resultDiv = document.createElement('div');
  1242 + resultDiv.style.cssText = `
  1243 + padding: 10px;
  1244 + margin: 5px 0;
  1245 + background: ${response.ok ? '#d1fae5' : '#fee2e2'};
  1246 + border-radius: 5px;
  1247 + border-left: 4px solid ${response.ok ? '#10b981' : '#ef4444'};
  1248 + `;
  1249 +
  1250 + if (response.ok) {
  1251 + const audioBlob = await response.blob();
  1252 + const audioUrl = URL.createObjectURL(audioBlob);
  1253 +
  1254 + resultDiv.innerHTML = `
  1255 + <strong>#${i + 1}</strong>: ${text.substring(0, 50)}${text.length > 50 ? '...' : ''}
  1256 + <div style="margin-top: 5px; display: flex; gap: 10px;">
  1257 + <audio controls style="flex: 1; height: 30px;"></audio>
  1258 + <button class="btn btn-secondary" style="padding: 5px 10px; font-size: 0.9rem;">
  1259 + <i class="fas fa-download"></i> 下载
  1260 + </button>
  1261 + </div>
  1262 + `;
  1263 +
  1264 + const audioElement = resultDiv.querySelector('audio');
  1265 + audioElement.src = audioUrl;
  1266 +
  1267 + const downloadBtn = resultDiv.querySelector('button');
  1268 + downloadBtn.addEventListener('click', () => {
  1269 + const a = document.createElement('a');
  1270 + a.href = audioUrl;
  1271 + a.download = `batch_${i + 1}_${Date.now()}.mp3`;
  1272 + a.click();
  1273 + });
  1274 +
  1275 + logMessage(`批量项目 ${i + 1} 合成成功`, 'success');
  1276 + } else {
  1277 + resultDiv.innerHTML = `
  1278 + <strong>#${i + 1}</strong>: ${text.substring(0, 50)}${text.length > 50 ? '...' : ''}
  1279 + <div style="color: #ef4444; margin-top: 5px;">合成失败</div>
  1280 + `;
  1281 + logMessage(`批量项目 ${i + 1} 合成失败`, 'error');
  1282 + }
  1283 +
  1284 + resultList.appendChild(resultDiv);
  1285 +
  1286 + // 更新进度
  1287 + const progress = Math.round((i + 1) / texts.length * 100);
  1288 + updateProgress(progress, `处理中... (${i + 1}/${texts.length})`);
  1289 +
  1290 + } catch (error) {
  1291 + logMessage(`批量项目 ${i + 1} 失败: ${error.message}`, 'error');
  1292 + }
  1293 +
  1294 + // 延迟以避免请求过快
  1295 + await new Promise(resolve => setTimeout(resolve, 500));
  1296 + }
  1297 +
  1298 + updateProgress(100, '批量处理完成!');
  1299 + logMessage(`批量处理完成,共 ${texts.length} 条文本`, 'success');
  1300 + showNotification('批量处理完成', 'success');
  1301 +
  1302 + hideProgress();
  1303 + }
  1304 +
  1305 + // 清空批量列表
  1306 + function clearBatch() {
  1307 + document.getElementById('batchTextInput').value = '';
  1308 + document.getElementById('batchResultList').innerHTML = '';
  1309 + logMessage('批量列表已清空', 'info');
  1310 + }
  1311 +
  1312 + // 加载设置
  1313 + function loadSettings() {
  1314 + const savedSettings = localStorage.getItem('tts_settings');
  1315 + if (savedSettings) {
  1316 + settings = JSON.parse(savedSettings);
  1317 + document.getElementById('apiUrl').value = settings.apiUrl;
  1318 + document.getElementById('timeoutSetting').value = settings.timeout / 1000;
  1319 + document.getElementById('autoPlayCheckbox').checked = settings.autoPlay;
  1320 + logMessage('设置已加载', 'info');
  1321 + }
  1322 + }
  1323 +
  1324 + // 保存设置
  1325 + function saveSettings() {
  1326 + settings.apiUrl = document.getElementById('apiUrl').value;
  1327 + settings.timeout = document.getElementById('timeoutSetting').value * 1000;
  1328 + settings.autoPlay = document.getElementById('autoPlayCheckbox').checked;
  1329 +
  1330 + localStorage.setItem('tts_settings', JSON.stringify(settings));
  1331 + logMessage('设置已保存', 'success');
  1332 + showNotification('设置保存成功', 'success');
  1333 +
  1334 + // 重新检查服务状态
  1335 + checkServiceHealth();
  1336 + }
  1337 +
  1338 + // 恢复默认设置
  1339 + function resetSettings() {
  1340 + settings = {
  1341 + apiUrl: 'http://localhost:8099/xlyAi/api/tts',
  1342 + timeout: 30000,
  1343 + autoPlay: true
  1344 + };
  1345 +
  1346 + document.getElementById('apiUrl').value = settings.apiUrl;
  1347 + document.getElementById('timeoutSetting').value = settings.timeout / 1000;
  1348 + document.getElementById('autoPlayCheckbox').checked = settings.autoPlay;
  1349 +
  1350 + localStorage.removeItem('tts_settings');
  1351 + logMessage('设置已恢复默认', 'info');
  1352 + showNotification('设置已恢复默认', 'info');
  1353 + }
  1354 +
  1355 + // 工具函数:格式化字节大小
  1356 + function formatBytes(bytes, decimals = 2) {
  1357 + if (bytes === 0) return '0 Bytes';
  1358 +
  1359 + const k = 1024;
  1360 + const dm = decimals < 0 ? 0 : decimals;
  1361 + const sizes = ['Bytes', 'KB', 'MB', 'GB'];
  1362 +
  1363 + const i = Math.floor(Math.log(bytes) / Math.log(k));
  1364 +
  1365 + return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
  1366 + }
  1367 +
  1368 + // 键盘快捷键
  1369 + document.addEventListener('keydown', (e) => {
  1370 + // Ctrl + Enter: 合成语音
  1371 + if (e.ctrlKey && e.key === 'Enter') {
  1372 + e.preventDefault();
  1373 + synthesizeSpeech();
  1374 + }
  1375 +
  1376 + // Ctrl + S: 流式合成
  1377 + if (e.ctrlKey && e.key === 's') {
  1378 + e.preventDefault();
  1379 + streamSynthesize();
  1380 + }
  1381 +
  1382 + // Ctrl + D: 下载音频
  1383 + if (e.ctrlKey && e.key === 'd') {
  1384 + e.preventDefault();
  1385 + if (!elements.downloadBtn.disabled) {
  1386 + downloadAudio();
  1387 + }
  1388 + }
  1389 +
  1390 + // Space: 播放/暂停
  1391 + if (e.key === ' ' && e.target.tagName !== 'INPUT' && e.target.tagName !== 'TEXTAREA') {
  1392 + e.preventDefault();
  1393 + if (elements.audioPlayer.paused) {
  1394 + elements.audioPlayer.play();
  1395 + } else {
  1396 + elements.audioPlayer.pause();
  1397 + }
  1398 + }
  1399 + });
  1400 +
  1401 + // 初始日志
  1402 + logMessage('系统初始化完成', 'info');
  1403 + logMessage('API地址: ' + settings.apiUrl, 'info');
  1404 + logMessage('准备好进行语音合成', 'success');
  1405 +</script>
  1406 +</body>
  1407 +</html>
0 1408 \ No newline at end of file
... ...