You need to sign in before continuing.
Commit f3eace43b81d305ca3d1e9a373ab3367d614a5eb
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
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
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("回复:  ").append("**<a href=\"#\" data-action=\"reset\" data-text=\"全部确认\">全部确认</a>**").append(" ") | |
| 598 | + .append("**<a href=\"#\" data-action=\"reset\" data-text=\"确认\">部分确认</a>**").append(" ") | |
| 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("回复:  ").append("**<a href=\"#\" data-action=\"reset\" data-text=\"确认\">确认</a>**").append(" ") | |
| 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(" ", ""); | |
| 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 | ... | ... |