chore: sync local updates

This commit is contained in:
2026-05-16 19:03:36 +08:00
parent 37983f3b60
commit f1417891ac
101 changed files with 4626 additions and 307 deletions

2
.java-test/.gitattributes vendored Normal file
View File

@@ -0,0 +1,2 @@
/mvnw text eol=lf
*.cmd text eol=crlf

33
.java-test/.gitignore vendored Normal file
View File

@@ -0,0 +1,33 @@
HELP.md
target/
.mvn/wrapper/maven-wrapper.jar
!**/src/main/**/target/
!**/src/test/**/target/
### STS ###
.apt_generated
.classpath
.factorypath
.project
.settings
.springBeans
.sts4-cache
### IntelliJ IDEA ###
.idea
*.iws
*.iml
*.ipr
### NetBeans ###
/nbproject/private/
/nbbuild/
/dist/
/nbdist/
/.nb-gradle/
build/
!**/src/main/**/build/
!**/src/test/**/build/
### VS Code ###
.vscode/

View File

@@ -0,0 +1,3 @@
wrapperVersion=3.3.4
distributionType=only-script
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.14/apache-maven-3.9.14-bin.zip

1
.java-test/compose.yaml Normal file
View File

@@ -0,0 +1 @@
services: { }

295
.java-test/mvnw vendored Normal file
View File

@@ -0,0 +1,295 @@
#!/bin/sh
# ----------------------------------------------------------------------------
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
# ----------------------------------------------------------------------------
# ----------------------------------------------------------------------------
# Apache Maven Wrapper startup batch script, version 3.3.4
#
# Optional ENV vars
# -----------------
# JAVA_HOME - location of a JDK home dir, required when download maven via java source
# MVNW_REPOURL - repo url base for downloading maven distribution
# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output
# ----------------------------------------------------------------------------
set -euf
[ "${MVNW_VERBOSE-}" != debug ] || set -x
# OS specific support.
native_path() { printf %s\\n "$1"; }
case "$(uname)" in
CYGWIN* | MINGW*)
[ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")"
native_path() { cygpath --path --windows "$1"; }
;;
esac
# set JAVACMD and JAVACCMD
set_java_home() {
# For Cygwin and MinGW, ensure paths are in Unix format before anything is touched
if [ -n "${JAVA_HOME-}" ]; then
if [ -x "$JAVA_HOME/jre/sh/java" ]; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
JAVACCMD="$JAVA_HOME/jre/sh/javac"
else
JAVACMD="$JAVA_HOME/bin/java"
JAVACCMD="$JAVA_HOME/bin/javac"
if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then
echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2
echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2
return 1
fi
fi
else
JAVACMD="$(
'set' +e
'unset' -f command 2>/dev/null
'command' -v java
)" || :
JAVACCMD="$(
'set' +e
'unset' -f command 2>/dev/null
'command' -v javac
)" || :
if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then
echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2
return 1
fi
fi
}
# hash string like Java String::hashCode
hash_string() {
str="${1:-}" h=0
while [ -n "$str" ]; do
char="${str%"${str#?}"}"
h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296))
str="${str#?}"
done
printf %x\\n $h
}
verbose() { :; }
[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; }
die() {
printf %s\\n "$1" >&2
exit 1
}
trim() {
# MWRAPPER-139:
# Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds.
# Needed for removing poorly interpreted newline sequences when running in more
# exotic environments such as mingw bash on Windows.
printf "%s" "${1}" | tr -d '[:space:]'
}
scriptDir="$(dirname "$0")"
scriptName="$(basename "$0")"
# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties
while IFS="=" read -r key value; do
case "${key-}" in
distributionUrl) distributionUrl=$(trim "${value-}") ;;
distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;;
esac
done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties"
[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
case "${distributionUrl##*/}" in
maven-mvnd-*bin.*)
MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/
case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in
*AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;;
:Darwin*x86_64) distributionPlatform=darwin-amd64 ;;
:Darwin*arm64) distributionPlatform=darwin-aarch64 ;;
:Linux*x86_64*) distributionPlatform=linux-amd64 ;;
*)
echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2
distributionPlatform=linux-amd64
;;
esac
distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip"
;;
maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;;
*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;;
esac
# apply MVNW_REPOURL and calculate MAVEN_HOME
# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash>
[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}"
distributionUrlName="${distributionUrl##*/}"
distributionUrlNameMain="${distributionUrlName%.*}"
distributionUrlNameMain="${distributionUrlNameMain%-bin}"
MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}"
MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")"
exec_maven() {
unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || :
exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD"
}
if [ -d "$MAVEN_HOME" ]; then
verbose "found existing MAVEN_HOME at $MAVEN_HOME"
exec_maven "$@"
fi
case "${distributionUrl-}" in
*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;;
*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;;
esac
# prepare tmp dir
if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then
clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; }
trap clean HUP INT TERM EXIT
else
die "cannot create temp dir"
fi
mkdir -p -- "${MAVEN_HOME%/*}"
# Download and Install Apache Maven
verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
verbose "Downloading from: $distributionUrl"
verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
# select .zip or .tar.gz
if ! command -v unzip >/dev/null; then
distributionUrl="${distributionUrl%.zip}.tar.gz"
distributionUrlName="${distributionUrl##*/}"
fi
# verbose opt
__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR=''
[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v
# normalize http auth
case "${MVNW_PASSWORD:+has-password}" in
'') MVNW_USERNAME='' MVNW_PASSWORD='' ;;
has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;;
esac
if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then
verbose "Found wget ... using wget"
wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl"
elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then
verbose "Found curl ... using curl"
curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl"
elif set_java_home; then
verbose "Falling back to use Java to download"
javaSource="$TMP_DOWNLOAD_DIR/Downloader.java"
targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName"
cat >"$javaSource" <<-END
public class Downloader extends java.net.Authenticator
{
protected java.net.PasswordAuthentication getPasswordAuthentication()
{
return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() );
}
public static void main( String[] args ) throws Exception
{
setDefault( new Downloader() );
java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() );
}
}
END
# For Cygwin/MinGW, switch paths to Windows format before running javac and java
verbose " - Compiling Downloader.java ..."
"$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java"
verbose " - Running Downloader.java ..."
"$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")"
fi
# If specified, validate the SHA-256 sum of the Maven distribution zip file
if [ -n "${distributionSha256Sum-}" ]; then
distributionSha256Result=false
if [ "$MVN_CMD" = mvnd.sh ]; then
echo "Checksum validation is not supported for maven-mvnd." >&2
echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
exit 1
elif command -v sha256sum >/dev/null; then
if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then
distributionSha256Result=true
fi
elif command -v shasum >/dev/null; then
if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then
distributionSha256Result=true
fi
else
echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2
echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
exit 1
fi
if [ $distributionSha256Result = false ]; then
echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2
echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2
exit 1
fi
fi
# unzip and move
if command -v unzip >/dev/null; then
unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip"
else
tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar"
fi
# Find the actual extracted directory name (handles snapshots where filename != directory name)
actualDistributionDir=""
# First try the expected directory name (for regular distributions)
if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then
if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then
actualDistributionDir="$distributionUrlNameMain"
fi
fi
# If not found, search for any directory with the Maven executable (for snapshots)
if [ -z "$actualDistributionDir" ]; then
# enable globbing to iterate over items
set +f
for dir in "$TMP_DOWNLOAD_DIR"/*; do
if [ -d "$dir" ]; then
if [ -f "$dir/bin/$MVN_CMD" ]; then
actualDistributionDir="$(basename "$dir")"
break
fi
fi
done
set -f
fi
if [ -z "$actualDistributionDir" ]; then
verbose "Contents of $TMP_DOWNLOAD_DIR:"
verbose "$(ls -la "$TMP_DOWNLOAD_DIR")"
die "Could not find Maven distribution directory in extracted archive"
fi
verbose "Found extracted Maven distribution directory: $actualDistributionDir"
printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url"
mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME"
clean || :
exec_maven "$@"

189
.java-test/mvnw.cmd vendored Normal file
View File

@@ -0,0 +1,189 @@
<# : batch portion
@REM ----------------------------------------------------------------------------
@REM Licensed to the Apache Software Foundation (ASF) under one
@REM or more contributor license agreements. See the NOTICE file
@REM distributed with this work for additional information
@REM regarding copyright ownership. The ASF licenses this file
@REM to you under the Apache License, Version 2.0 (the
@REM "License"); you may not use this file except in compliance
@REM with the License. You may obtain a copy of the License at
@REM
@REM http://www.apache.org/licenses/LICENSE-2.0
@REM
@REM Unless required by applicable law or agreed to in writing,
@REM software distributed under the License is distributed on an
@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
@REM KIND, either express or implied. See the License for the
@REM specific language governing permissions and limitations
@REM under the License.
@REM ----------------------------------------------------------------------------
@REM ----------------------------------------------------------------------------
@REM Apache Maven Wrapper startup batch script, version 3.3.4
@REM
@REM Optional ENV vars
@REM MVNW_REPOURL - repo url base for downloading maven distribution
@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output
@REM ----------------------------------------------------------------------------
@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0)
@SET __MVNW_CMD__=
@SET __MVNW_ERROR__=
@SET __MVNW_PSMODULEP_SAVE=%PSModulePath%
@SET PSModulePath=
@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 @(
IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B)
)
@SET PSModulePath=%__MVNW_PSMODULEP_SAVE%
@SET __MVNW_PSMODULEP_SAVE=
@SET __MVNW_ARG0_NAME__=
@SET MVNW_USERNAME=
@SET MVNW_PASSWORD=
@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*)
@echo Cannot start maven from wrapper >&2 && exit /b 1
@GOTO :EOF
: end batch / begin powershell #>
$ErrorActionPreference = "Stop"
if ($env:MVNW_VERBOSE -eq "true") {
$VerbosePreference = "Continue"
}
# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties
$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl
if (!$distributionUrl) {
Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
}
switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) {
"maven-mvnd-*" {
$USE_MVND = $true
$distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip"
$MVN_CMD = "mvnd.cmd"
break
}
default {
$USE_MVND = $false
$MVN_CMD = $script -replace '^mvnw','mvn'
break
}
}
# apply MVNW_REPOURL and calculate MAVEN_HOME
# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash>
if ($env:MVNW_REPOURL) {
$MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" }
$distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')"
}
$distributionUrlName = $distributionUrl -replace '^.*/',''
$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$',''
$MAVEN_M2_PATH = "$HOME/.m2"
if ($env:MAVEN_USER_HOME) {
$MAVEN_M2_PATH = "$env:MAVEN_USER_HOME"
}
if (-not (Test-Path -Path $MAVEN_M2_PATH)) {
New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null
}
$MAVEN_WRAPPER_DISTS = $null
if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) {
$MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists"
} else {
$MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists"
}
$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain"
$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join ''
$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME"
if (Test-Path -Path "$MAVEN_HOME" -PathType Container) {
Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME"
Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
exit $?
}
if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) {
Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl"
}
# prepare tmp dir
$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile
$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir"
$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null
trap {
if ($TMP_DOWNLOAD_DIR.Exists) {
try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
}
}
New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null
# Download and Install Apache Maven
Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
Write-Verbose "Downloading from: $distributionUrl"
Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
$webclient = New-Object System.Net.WebClient
if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) {
$webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD)
}
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null
# If specified, validate the SHA-256 sum of the Maven distribution zip file
$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum
if ($distributionSha256Sum) {
if ($USE_MVND) {
Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties."
}
Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash
if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) {
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."
}
}
# unzip and move
Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null
# Find the actual extracted directory name (handles snapshots where filename != directory name)
$actualDistributionDir = ""
# First try the expected directory name (for regular distributions)
$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain"
$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD"
if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) {
$actualDistributionDir = $distributionUrlNameMain
}
# If not found, search for any directory with the Maven executable (for snapshots)
if (!$actualDistributionDir) {
Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object {
$testPath = Join-Path $_.FullName "bin/$MVN_CMD"
if (Test-Path -Path $testPath -PathType Leaf) {
$actualDistributionDir = $_.Name
}
}
}
if (!$actualDistributionDir) {
Write-Error "Could not find Maven distribution directory in extracted archive"
}
Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir"
Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null
try {
Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null
} catch {
if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) {
Write-Error "fail to move MAVEN_HOME"
}
} finally {
try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
}
Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"

121
.java-test/pom.xml Normal file
View File

@@ -0,0 +1,121 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.5.12</version>
<relativePath/>
</parent>
<groupId>com.smyhub.store</groupId>
<artifactId>mengyastore-backend-java</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>mengyastore-backend-java</name>
<description>萌芽小店 Java Spring Boot 后端</description>
<properties>
<java.version>17</java.version>
</properties>
<dependencies>
<!-- Web MVC -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- JPA / Hibernate -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<!-- MySQL Driver -->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<!-- RabbitMQ AMQP -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-amqp</artifactId>
</dependency>
<!-- Redis -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- Bean Validation -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<!-- JavaMail -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-mail</artifactId>
</dependency>
<!-- Actuator (health, metrics) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<!-- Lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- DevTools -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<scope>runtime</scope>
<optional>true</optional>
</dependency>
<!-- Test -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
</project>

View File

@@ -0,0 +1,17 @@
package com.smyhub.store.mengyastorebackendjava;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.transaction.annotation.EnableTransactionManagement;
@SpringBootApplication
@EnableTransactionManagement
@EnableScheduling
public class MengyastoreBackendJavaApplication {
public static void main(String[] args) {
SpringApplication.run(MengyastoreBackendJavaApplication.class, args);
}
}

View File

@@ -0,0 +1,33 @@
package com.smyhub.store.mengyastorebackendjava.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
@Data
@Component
@ConfigurationProperties(prefix = "app")
public class AppProperties {
private String adminToken = "changeme";
private String authApiUrl = "https://auth.api.shumengya.top";
/** Mirrors Go APP_ENV: {@code development} | {@code production}; used by system-status panel. */
private String appEnv = "development";
private boolean rabbitmqEnabled = false;
/**
* Mirrors Go REDIS_ENABLED (default on unless explicitly disabled).
*/
private boolean redisEnabled = true;
private String rabbitmqEnv = "dev";
private String redisEnv = "dev";
private String publicApiBaseUrl = "";
private String corsAllowedOrigins = "http://localhost:5173,http://localhost:3000";
}

View File

@@ -0,0 +1,44 @@
package com.smyhub.store.mengyastorebackendjava.config;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.util.StringUtils;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.filter.CorsFilter;
import java.util.Arrays;
import java.util.List;
@Configuration
@RequiredArgsConstructor
public class CorsConfig {
private final AppProperties appProperties;
@Bean
public CorsFilter corsFilter() {
CorsConfiguration config = new CorsConfiguration();
String originsRaw = appProperties.getCorsAllowedOrigins();
if (StringUtils.hasText(originsRaw)) {
List<String> origins = Arrays.asList(originsRaw.split(","));
config.setAllowedOrigins(origins);
} else {
config.addAllowedOriginPattern("*");
}
config.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
config.setAllowedHeaders(List.of(
"Content-Type", "Authorization", "X-Admin-Token",
"X-Requested-With", "Accept", "Accept-Language"
));
config.setAllowCredentials(true);
config.setMaxAge(86400L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", config);
return new CorsFilter(source);
}
}

View File

@@ -0,0 +1,63 @@
package com.smyhub.store.mengyastorebackendjava.config;
import com.smyhub.store.mengyastorebackendjava.mq.RabbitMQTopology;
import lombok.RequiredArgsConstructor;
import org.springframework.amqp.core.*;
import org.springframework.amqp.rabbit.config.SimpleRabbitListenerContainerFactory;
import org.springframework.amqp.rabbit.connection.ConnectionFactory;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.amqp.core.AcknowledgeMode;
import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
@RequiredArgsConstructor
@ConditionalOnProperty(name = "app.rabbitmq-enabled", havingValue = "true")
public class RabbitMQConfig {
private final AppProperties appProperties;
@Bean
public Jackson2JsonMessageConverter jsonMessageConverter() {
return new Jackson2JsonMessageConverter();
}
@Bean
public RabbitTemplate rabbitTemplate(ConnectionFactory connectionFactory) {
RabbitTemplate template = new RabbitTemplate(connectionFactory);
template.setMessageConverter(jsonMessageConverter());
return template;
}
@Bean
public SimpleRabbitListenerContainerFactory rabbitListenerContainerFactory(ConnectionFactory connectionFactory) {
SimpleRabbitListenerContainerFactory factory = new SimpleRabbitListenerContainerFactory();
factory.setConnectionFactory(connectionFactory);
factory.setMessageConverter(jsonMessageConverter());
factory.setPrefetchCount(1);
factory.setAcknowledgeMode(AcknowledgeMode.MANUAL);
return factory;
}
@Bean
public TopicExchange orderEmailExchange() {
String name = RabbitMQTopology.exchangeName(appProperties.getRabbitmqEnv());
return ExchangeBuilder.topicExchange(name).durable(true).build();
}
@Bean
public Queue orderEmailQueue() {
String name = RabbitMQTopology.queueName(appProperties.getRabbitmqEnv());
return QueueBuilder.durable(name).build();
}
@Bean
public Binding orderEmailBinding() {
return BindingBuilder
.bind(orderEmailQueue())
.to(orderEmailExchange())
.with(RabbitMQTopology.ORDER_EMAIL_ROUTING_KEY);
}
}

View File

@@ -0,0 +1,15 @@
package com.smyhub.store.mengyastorebackendjava.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.StringRedisTemplate;
@Configuration
public class RedisConfig {
@Bean
public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory connectionFactory) {
return new StringRedisTemplate(connectionFactory);
}
}

View File

@@ -0,0 +1,20 @@
package com.smyhub.store.mengyastorebackendjava.config;
import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestTemplate;
import java.time.Duration;
@Configuration
public class RestTemplateConfig {
@Bean
public RestTemplate restTemplate(RestTemplateBuilder builder) {
return builder
.connectTimeout(Duration.ofSeconds(5))
.readTimeout(Duration.ofSeconds(8))
.build();
}
}

View File

@@ -0,0 +1,31 @@
package com.smyhub.store.mengyastorebackendjava.config;
import com.smyhub.store.mengyastorebackendjava.security.AdminTokenInterceptor;
import com.smyhub.store.mengyastorebackendjava.security.BearerTokenInterceptor;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
@RequiredArgsConstructor
public class WebMvcConfig implements WebMvcConfigurer {
private final AdminTokenInterceptor adminTokenInterceptor;
private final BearerTokenInterceptor bearerTokenInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(adminTokenInterceptor)
.addPathPatterns("/api/admin/**")
.excludePathPatterns("/api/admin/verify");
registry.addInterceptor(bearerTokenInterceptor)
.addPathPatterns(
"/api/orders",
"/api/wishlist",
"/api/wishlist/**",
"/api/chat/messages"
);
}
}

View File

@@ -0,0 +1,38 @@
package com.smyhub.store.mengyastorebackendjava.controller;
import com.smyhub.store.mengyastorebackendjava.dto.request.ChatMessageRequest;
import com.smyhub.store.mengyastorebackendjava.dto.response.ApiResponse;
import com.smyhub.store.mengyastorebackendjava.dto.response.ChatMessageResponse;
import com.smyhub.store.mengyastorebackendjava.security.CurrentUserHolder;
import com.smyhub.store.mengyastorebackendjava.security.SproutGateUser;
import com.smyhub.store.mengyastorebackendjava.service.ChatService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/chat")
@RequiredArgsConstructor
public class ChatController {
private final ChatService chatService;
@GetMapping("/messages")
public ResponseEntity<ApiResponse<List<ChatMessageResponse>>> getMessages() {
String account = CurrentUserHolder.get().getAccount();
return ResponseEntity.ok(ApiResponse.ok(chatService.getUserMessages(account)));
}
@PostMapping("/messages")
public ResponseEntity<ApiResponse<ChatMessageResponse>> sendMessage(
@Valid @RequestBody ChatMessageRequest req) {
SproutGateUser user = CurrentUserHolder.get();
ChatMessageResponse msg = chatService.sendUserMessage(
user.getAccount(), user.getUsername(), req.getContent()
);
return ResponseEntity.ok(ApiResponse.ok(msg));
}
}

View File

@@ -0,0 +1,55 @@
package com.smyhub.store.mengyastorebackendjava.controller;
import com.smyhub.store.mengyastorebackendjava.config.AppProperties;
import com.smyhub.store.mengyastorebackendjava.dto.response.ApiResponse;
import com.smyhub.store.mengyastorebackendjava.dto.response.HealthResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@Slf4j
@RestController
@RequestMapping("/api")
public class HealthController {
private final AppProperties appProperties;
@Autowired(required = false)
private RabbitTemplate rabbitTemplate;
public HealthController(AppProperties appProperties) {
this.appProperties = appProperties;
}
@GetMapping("/health")
public ResponseEntity<ApiResponse<HealthResponse>> health() {
HealthResponse body = HealthResponse.builder()
.status("ok")
.rabbitmq(checkRabbitMQ())
.build();
return ResponseEntity.ok(ApiResponse.ok(body));
}
private String checkRabbitMQ() {
if (!appProperties.isRabbitmqEnabled()) {
return "disabled";
}
if (rabbitTemplate == null) {
return "disabled";
}
try {
rabbitTemplate.execute(channel -> {
channel.basicQos(1);
return null;
});
return "ok";
} catch (Exception e) {
log.debug("[Health] RabbitMQ ping failed: {}", e.getMessage());
return "error";
}
}
}

View File

@@ -0,0 +1,71 @@
package com.smyhub.store.mengyastorebackendjava.controller;
import com.smyhub.store.mengyastorebackendjava.dto.request.CheckoutRequest;
import com.smyhub.store.mengyastorebackendjava.dto.response.ApiResponse;
import com.smyhub.store.mengyastorebackendjava.security.SproutGateUser;
import com.smyhub.store.mengyastorebackendjava.security.SproutGateVerifyResult;
import com.smyhub.store.mengyastorebackendjava.service.OrderService;
import com.smyhub.store.mengyastorebackendjava.service.SproutGateAuthService;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@RestController
@RequestMapping("/api")
@RequiredArgsConstructor
public class PublicOrderController {
private final OrderService orderService;
private final SproutGateAuthService authService;
@PostMapping("/checkout")
public ResponseEntity<ApiResponse<Map<String, Object>>> checkout(
@Valid @RequestBody CheckoutRequest req,
HttpServletRequest request) {
SproutGateUser user = tryResolveUser(request);
OrderService.CheckoutResult result = orderService.checkout(req, user);
String qrPayload = "order:" + result.orderId() + ":" + result.productId();
String qrUrl = "https://api.qrserver.com/v1/create-qr-code/?size=320x320&data="
+ java.net.URLEncoder.encode(qrPayload, java.nio.charset.StandardCharsets.UTF_8);
var body = new java.util.LinkedHashMap<String, Object>();
body.put("orderId", result.orderId());
body.put("qrCodeUrl", qrUrl);
body.put("productId", result.productId());
body.put("productQty", result.qty());
body.put("viewCount", result.viewCount());
body.put("status", result.status());
body.put("deliveredCodes", result.deliveredCodes());
body.put("deliveryMode", result.deliveryMode());
body.put("isManual", "manual".equalsIgnoreCase(result.deliveryMode()));
return ResponseEntity.ok(ApiResponse.ok(body));
}
@PostMapping("/orders/{id}/confirm")
public ResponseEntity<ApiResponse<Map<String, Object>>> confirmOrder(@PathVariable String id) {
var order = orderService.confirmOrder(id);
return ResponseEntity.ok(ApiResponse.ok(Map.of(
"orderId", order.getId(),
"status", order.getStatus(),
"deliveryMode", order.getDeliveryMode(),
"deliveredCodes", order.getDeliveredCodes(),
"isManual", "manual".equals(order.getDeliveryMode())
)));
}
private SproutGateUser tryResolveUser(HttpServletRequest request) {
String header = request.getHeader("Authorization");
if (!StringUtils.hasText(header) || !header.startsWith("Bearer ")) return null;
String token = header.substring(7);
SproutGateVerifyResult result = authService.verifyToken(token);
return (result.isValid() && result.getUser() != null) ? result.getUser() : null;
}
}

View File

@@ -0,0 +1,57 @@
package com.smyhub.store.mengyastorebackendjava.controller;
import com.smyhub.store.mengyastorebackendjava.dto.response.ApiResponse;
import com.smyhub.store.mengyastorebackendjava.dto.response.ProductResponse;
import com.smyhub.store.mengyastorebackendjava.service.ProductService;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api")
@RequiredArgsConstructor
public class PublicProductController {
private final ProductService productService;
@GetMapping("/products")
public ResponseEntity<ApiResponse<List<ProductResponse>>> listProducts() {
return ResponseEntity.ok(ApiResponse.ok(productService.listPublicProducts()));
}
@PostMapping("/products/{id}/view")
public ResponseEntity<ApiResponse<Map<String, Object>>> recordView(
@PathVariable String id,
HttpServletRequest request) {
String fingerprint = buildFingerprint(request);
ProductResponse p = productService.recordView(id, fingerprint);
return ResponseEntity.ok(ApiResponse.ok(Map.of(
"id", p.getId(),
"viewCount", p.getViewCount(),
"counted", true
)));
}
private String buildFingerprint(HttpServletRequest request) {
String ip = getClientIp(request);
String ua = nvl(request.getHeader("User-Agent"));
String lang = nvl(request.getHeader("Accept-Language"));
return ip + "|" + ua + "|" + lang;
}
private String getClientIp(HttpServletRequest request) {
String forwarded = request.getHeader("X-Forwarded-For");
if (forwarded != null && !forwarded.isBlank()) {
return forwarded.split(",")[0].strip();
}
return request.getRemoteAddr();
}
private String nvl(String s) {
return s != null ? s.strip() : "";
}
}

View File

@@ -0,0 +1,42 @@
package com.smyhub.store.mengyastorebackendjava.controller;
import com.smyhub.store.mengyastorebackendjava.dto.response.ApiResponse;
import com.smyhub.store.mengyastorebackendjava.dto.response.MaintenanceResponse;
import com.smyhub.store.mengyastorebackendjava.dto.response.StatsResponse;
import com.smyhub.store.mengyastorebackendjava.repository.OrderRepository;
import com.smyhub.store.mengyastorebackendjava.service.SiteService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@RestController
@RequestMapping("/api")
@RequiredArgsConstructor
public class StatsController {
private final OrderRepository orderRepository;
private final SiteService siteService;
@GetMapping("/stats")
public ResponseEntity<ApiResponse<StatsResponse>> getStats() {
long total = orderRepository.count();
int visits = siteService.getTotalVisits();
return ResponseEntity.ok(ApiResponse.ok(StatsResponse.builder()
.totalOrders(total)
.totalVisits(visits)
.build()));
}
@PostMapping("/site/visit")
public ResponseEntity<ApiResponse<Map<String, Object>>> recordVisit() {
int total = siteService.incrementVisits();
return ResponseEntity.ok(ApiResponse.ok(Map.of("total", total, "counted", true)));
}
@GetMapping("/site/maintenance")
public ResponseEntity<ApiResponse<MaintenanceResponse>> getMaintenance() {
return ResponseEntity.ok(ApiResponse.ok(siteService.getMaintenance()));
}
}

View File

@@ -0,0 +1,27 @@
package com.smyhub.store.mengyastorebackendjava.controller;
import com.smyhub.store.mengyastorebackendjava.dto.response.ApiResponse;
import com.smyhub.store.mengyastorebackendjava.dto.response.OrderResponse;
import com.smyhub.store.mengyastorebackendjava.security.CurrentUserHolder;
import com.smyhub.store.mengyastorebackendjava.service.OrderService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequestMapping("/api")
@RequiredArgsConstructor
public class UserOrderController {
private final OrderService orderService;
@GetMapping("/orders")
public ResponseEntity<ApiResponse<List<OrderResponse>>> myOrders() {
String account = CurrentUserHolder.get().getAccount();
return ResponseEntity.ok(ApiResponse.ok(orderService.getMyOrders(account)));
}
}

View File

@@ -0,0 +1,45 @@
package com.smyhub.store.mengyastorebackendjava.controller;
import com.smyhub.store.mengyastorebackendjava.dto.response.ApiResponse;
import com.smyhub.store.mengyastorebackendjava.security.CurrentUserHolder;
import com.smyhub.store.mengyastorebackendjava.service.WishlistService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/wishlist")
@RequiredArgsConstructor
public class WishlistController {
private final WishlistService wishlistService;
@GetMapping
public ResponseEntity<ApiResponse<Map<String, List<String>>>> getWishlist() {
String account = CurrentUserHolder.get().getAccount();
List<String> items = wishlistService.getWishlistIds(account);
return ResponseEntity.ok(ApiResponse.ok(Map.of("items", items)));
}
@PostMapping
public ResponseEntity<ApiResponse<Map<String, List<String>>>> addToWishlist(
@RequestBody Map<String, String> body) {
String productId = body.get("productId");
String account = CurrentUserHolder.get().getAccount();
wishlistService.addToWishlist(account, productId);
List<String> items = wishlistService.getWishlistIds(account);
return ResponseEntity.ok(ApiResponse.ok(Map.of("items", items)));
}
@DeleteMapping("/{productId}")
public ResponseEntity<ApiResponse<Map<String, List<String>>>> removeFromWishlist(
@PathVariable String productId) {
String account = CurrentUserHolder.get().getAccount();
wishlistService.removeFromWishlist(account, productId);
List<String> items = wishlistService.getWishlistIds(account);
return ResponseEntity.ok(ApiResponse.ok(Map.of("items", items)));
}
}

View File

@@ -0,0 +1,46 @@
package com.smyhub.store.mengyastorebackendjava.controller.admin;
import com.smyhub.store.mengyastorebackendjava.dto.request.AdminChatMessageRequest;
import com.smyhub.store.mengyastorebackendjava.dto.response.ApiResponse;
import com.smyhub.store.mengyastorebackendjava.dto.response.ChatMessageResponse;
import com.smyhub.store.mengyastorebackendjava.service.ChatService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/admin/chat")
@RequiredArgsConstructor
public class AdminChatController {
private final ChatService chatService;
@GetMapping
public ResponseEntity<ApiResponse<Map<String, List<ChatMessageResponse>>>> listConversations() {
return ResponseEntity.ok(ApiResponse.ok(chatService.listAllConversations()));
}
@GetMapping("/{account}")
public ResponseEntity<ApiResponse<List<ChatMessageResponse>>> getConversation(
@PathVariable String account) {
return ResponseEntity.ok(ApiResponse.ok(chatService.getConversation(account)));
}
@PostMapping("/{account}")
public ResponseEntity<ApiResponse<ChatMessageResponse>> replyToUser(
@PathVariable String account,
@Valid @RequestBody AdminChatMessageRequest req) {
ChatMessageResponse msg = chatService.sendAdminMessage(account, req.getContent());
return ResponseEntity.ok(ApiResponse.ok(msg));
}
@DeleteMapping("/{account}")
public ResponseEntity<ApiResponse<Void>> clearConversation(@PathVariable String account) {
chatService.clearConversation(account);
return ResponseEntity.ok(ApiResponse.ok(null));
}
}

View File

@@ -0,0 +1,29 @@
package com.smyhub.store.mengyastorebackendjava.controller.admin;
import com.smyhub.store.mengyastorebackendjava.dto.response.ApiResponse;
import com.smyhub.store.mengyastorebackendjava.dto.response.OrderResponse;
import com.smyhub.store.mengyastorebackendjava.service.OrderService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/admin/orders")
@RequiredArgsConstructor
public class AdminOrderController {
private final OrderService orderService;
@GetMapping
public ResponseEntity<ApiResponse<List<OrderResponse>>> listOrders() {
return ResponseEntity.ok(ApiResponse.ok(orderService.listAllOrders()));
}
@DeleteMapping("/{id}")
public ResponseEntity<ApiResponse<Void>> deleteOrder(@PathVariable String id) {
orderService.deleteOrder(id);
return ResponseEntity.ok(ApiResponse.ok(null));
}
}

View File

@@ -0,0 +1,52 @@
package com.smyhub.store.mengyastorebackendjava.controller.admin;
import com.smyhub.store.mengyastorebackendjava.dto.request.AdminProductRequest;
import com.smyhub.store.mengyastorebackendjava.dto.request.ProductStatusRequest;
import com.smyhub.store.mengyastorebackendjava.dto.response.ApiResponse;
import com.smyhub.store.mengyastorebackendjava.dto.response.ProductResponse;
import com.smyhub.store.mengyastorebackendjava.service.ProductService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/admin/products")
@RequiredArgsConstructor
public class AdminProductController {
private final ProductService productService;
@GetMapping
public ResponseEntity<ApiResponse<List<ProductResponse>>> listProducts() {
return ResponseEntity.ok(ApiResponse.ok(productService.listAllProducts()));
}
@PostMapping
public ResponseEntity<ApiResponse<ProductResponse>> createProduct(
@Valid @RequestBody AdminProductRequest req) {
return ResponseEntity.ok(ApiResponse.ok(productService.createProduct(req)));
}
@PutMapping("/{id}")
public ResponseEntity<ApiResponse<ProductResponse>> updateProduct(
@PathVariable String id,
@Valid @RequestBody AdminProductRequest req) {
return ResponseEntity.ok(ApiResponse.ok(productService.updateProduct(id, req)));
}
@PatchMapping("/{id}/status")
public ResponseEntity<ApiResponse<ProductResponse>> toggleStatus(
@PathVariable String id,
@RequestBody ProductStatusRequest req) {
return ResponseEntity.ok(ApiResponse.ok(productService.toggleStatus(id, req.isActive())));
}
@DeleteMapping("/{id}")
public ResponseEntity<ApiResponse<Void>> deleteProduct(@PathVariable String id) {
productService.deleteProduct(id);
return ResponseEntity.ok(ApiResponse.ok(null));
}
}

View File

@@ -0,0 +1,38 @@
package com.smyhub.store.mengyastorebackendjava.controller.admin;
import com.smyhub.store.mengyastorebackendjava.dto.request.MaintenanceRequest;
import com.smyhub.store.mengyastorebackendjava.dto.request.SmtpConfigRequest;
import com.smyhub.store.mengyastorebackendjava.dto.response.ApiResponse;
import com.smyhub.store.mengyastorebackendjava.dto.response.MaintenanceResponse;
import com.smyhub.store.mengyastorebackendjava.dto.response.SmtpConfigResponse;
import com.smyhub.store.mengyastorebackendjava.service.SiteService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/admin/site")
@RequiredArgsConstructor
public class AdminSiteController {
private final SiteService siteService;
@PostMapping("/maintenance")
public ResponseEntity<ApiResponse<MaintenanceResponse>> setMaintenance(
@RequestBody MaintenanceRequest req) {
siteService.setMaintenance(req.isEnabled(), req.getReason());
return ResponseEntity.ok(ApiResponse.ok(siteService.getMaintenance()));
}
@GetMapping("/smtp")
public ResponseEntity<ApiResponse<SmtpConfigResponse>> getSmtp() {
return ResponseEntity.ok(ApiResponse.ok(siteService.getSmtpConfigResponse(true)));
}
@PostMapping("/smtp")
public ResponseEntity<ApiResponse<SmtpConfigResponse>> setSmtp(
@RequestBody SmtpConfigRequest req) {
siteService.setSmtpConfig(req);
return ResponseEntity.ok(ApiResponse.ok(siteService.getSmtpConfigResponse(true)));
}
}

View File

@@ -0,0 +1,25 @@
package com.smyhub.store.mengyastorebackendjava.controller.admin;
import com.smyhub.store.mengyastorebackendjava.dto.response.ApiResponse;
import com.smyhub.store.mengyastorebackendjava.service.SystemStatusService;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
@RestController
@RequestMapping("/api/admin")
@RequiredArgsConstructor
public class AdminStatusController {
private final SystemStatusService systemStatusService;
@GetMapping("/system-status")
public ResponseEntity<ApiResponse<Map<String, Object>>> getSystemStatus(HttpServletRequest request) {
return ResponseEntity.ok(ApiResponse.ok(systemStatusService.buildStatus(request)));
}
}

View File

@@ -0,0 +1,30 @@
package com.smyhub.store.mengyastorebackendjava.controller.admin;
import com.smyhub.store.mengyastorebackendjava.config.AppProperties;
import com.smyhub.store.mengyastorebackendjava.dto.request.AdminVerifyRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@RestController
@RequestMapping("/api/admin")
@RequiredArgsConstructor
public class AdminVerifyController {
private final AppProperties appProperties;
/**
* Matches Go backend: top-level {@code {"valid": true/false}} (frontend reads {@code response.data.valid} from axios).
*/
@PostMapping("/verify")
public ResponseEntity<Map<String, Boolean>> verify(@RequestBody(required = false) AdminVerifyRequest req) {
if (req == null || !StringUtils.hasText(req.getToken())) {
return ResponseEntity.ok(Map.of("valid", false));
}
boolean valid = req.getToken().equals(appProperties.getAdminToken());
return ResponseEntity.ok(Map.of("valid", valid));
}
}

View File

@@ -0,0 +1,11 @@
package com.smyhub.store.mengyastorebackendjava.dto.request;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
@Data
public class AdminChatMessageRequest {
@NotBlank(message = "消息内容不能为空")
private String content;
}

View File

@@ -0,0 +1,46 @@
package com.smyhub.store.mengyastorebackendjava.dto.request;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
@Data
public class AdminProductRequest {
@NotBlank(message = "商品名称不能为空")
private String name;
private double price;
private double discountPrice;
private List<String> tags = new ArrayList<>();
private String coverUrl;
private List<String> screenshotUrls = new ArrayList<>();
private String verificationUrl;
private String description;
private boolean active = true;
private boolean requireLogin;
private int maxPerAccount;
private String deliveryMode = "auto";
private String fulfillmentType = "card";
private String fixedContent;
private boolean showNote = true;
private boolean showContact = true;
private List<String> codes = new ArrayList<>();
}

View File

@@ -0,0 +1,8 @@
package com.smyhub.store.mengyastorebackendjava.dto.request;
import lombok.Data;
@Data
public class AdminVerifyRequest {
private String token;
}

View File

@@ -0,0 +1,11 @@
package com.smyhub.store.mengyastorebackendjava.dto.request;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
@Data
public class ChatMessageRequest {
@NotBlank(message = "消息内容不能为空")
private String content;
}

View File

@@ -0,0 +1,21 @@
package com.smyhub.store.mengyastorebackendjava.dto.request;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
@Data
public class CheckoutRequest {
@NotBlank(message = "productId 不能为空")
private String productId;
private int quantity = 1;
private String note;
private String contactPhone;
private String contactEmail;
private String notifyEmail;
}

View File

@@ -0,0 +1,9 @@
package com.smyhub.store.mengyastorebackendjava.dto.request;
import lombok.Data;
@Data
public class MaintenanceRequest {
private boolean enabled;
private String reason = "";
}

View File

@@ -0,0 +1,8 @@
package com.smyhub.store.mengyastorebackendjava.dto.request;
import lombok.Data;
@Data
public class ProductStatusRequest {
private boolean active;
}

View File

@@ -0,0 +1,13 @@
package com.smyhub.store.mengyastorebackendjava.dto.request;
import lombok.Data;
@Data
public class SmtpConfigRequest {
private boolean enabled = true;
private String email;
private String password;
private String fromName;
private String host = "smtp.qq.com";
private String port = "465";
}

View File

@@ -0,0 +1,25 @@
package com.smyhub.store.mengyastorebackendjava.dto.response;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.Getter;
@Getter
@JsonInclude(JsonInclude.Include.NON_NULL)
public class ApiResponse<T> {
private final T data;
private final String error;
private ApiResponse(T data, String error) {
this.data = data;
this.error = error;
}
public static <T> ApiResponse<T> ok(T data) {
return new ApiResponse<>(data, null);
}
public static <T> ApiResponse<T> error(String message) {
return new ApiResponse<>(null, message);
}
}

View File

@@ -0,0 +1,18 @@
package com.smyhub.store.mengyastorebackendjava.dto.response;
import lombok.Builder;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@Builder
public class ChatMessageResponse {
private String id;
private String accountId;
private String accountName;
private String content;
private LocalDateTime sentAt;
private boolean fromAdmin;
}

View File

@@ -0,0 +1,11 @@
package com.smyhub.store.mengyastorebackendjava.dto.response;
import lombok.Builder;
import lombok.Data;
@Data
@Builder
public class HealthResponse {
private String status;
private String rabbitmq;
}

View File

@@ -0,0 +1,11 @@
package com.smyhub.store.mengyastorebackendjava.dto.response;
import lombok.Builder;
import lombok.Data;
@Data
@Builder
public class MaintenanceResponse {
private boolean maintenance;
private String reason;
}

View File

@@ -0,0 +1,27 @@
package com.smyhub.store.mengyastorebackendjava.dto.response;
import lombok.Builder;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.List;
@Data
@Builder
public class OrderResponse {
private String id;
private String productId;
private String productName;
private String userAccount;
private String userName;
private int quantity;
private List<String> deliveredCodes;
private String status;
private String deliveryMode;
private String note;
private String contactPhone;
private String contactEmail;
private String notifyEmail;
private LocalDateTime createdAt;
}

View File

@@ -0,0 +1,37 @@
package com.smyhub.store.mengyastorebackendjava.dto.response;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.Builder;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.List;
@Data
@Builder
@JsonInclude(JsonInclude.Include.NON_NULL)
public class ProductResponse {
private String id;
private String name;
private double price;
private double discountPrice;
private List<String> tags;
private int quantity;
private String coverUrl;
private List<String> screenshotUrls;
private String verificationUrl;
private List<String> codes;
private int viewCount;
private String description;
private boolean active;
private boolean requireLogin;
private int maxPerAccount;
private int totalSold;
private String deliveryMode;
private String fulfillmentType;
private String fixedContent;
private boolean showNote;
private boolean showContact;
private LocalDateTime createdAt;
}

View File

@@ -0,0 +1,15 @@
package com.smyhub.store.mengyastorebackendjava.dto.response;
import lombok.Builder;
import lombok.Data;
@Data
@Builder
public class SmtpConfigResponse {
private boolean enabled;
private String email;
private String password;
private String fromName;
private String host;
private String port;
}

View File

@@ -0,0 +1,11 @@
package com.smyhub.store.mengyastorebackendjava.dto.response;
import lombok.Builder;
import lombok.Data;
@Data
@Builder
public class StatsResponse {
private long totalOrders;
private int totalVisits;
}

View File

@@ -0,0 +1,12 @@
package com.smyhub.store.mengyastorebackendjava.dto.response;
import lombok.Builder;
import lombok.Data;
@Data
@Builder
public class WishlistResponse {
private Long id;
private String accountId;
private String productId;
}

View File

@@ -0,0 +1,33 @@
package com.smyhub.store.mengyastorebackendjava.entity;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
@Entity
@Table(name = "chat_messages")
@Getter
@Setter
public class ChatMessage {
@Id
@Column(length = 36)
private String id;
@Column(name = "account_id", length = 255, nullable = false)
private String accountId;
@Column(name = "account_name", length = 255)
private String accountName;
@Column(columnDefinition = "text", nullable = false)
private String content;
@Column(name = "sent_at", nullable = false)
private LocalDateTime sentAt;
@Column(name = "from_admin", columnDefinition = "tinyint(1) default 0")
private boolean fromAdmin;
}

View File

@@ -0,0 +1,61 @@
package com.smyhub.store.mengyastorebackendjava.entity;
import com.smyhub.store.mengyastorebackendjava.entity.converter.StringListConverter;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
@Entity
@Table(name = "orders")
@Getter
@Setter
public class Order {
@Id
@Column(length = 36)
private String id;
@Column(name = "product_id", length = 36, nullable = false)
private String productId;
@Column(name = "product_name", length = 255, nullable = false)
private String productName;
@Column(name = "user_account", length = 255)
private String userAccount;
@Column(name = "user_name", length = 255)
private String userName;
@Column(nullable = false, columnDefinition = "int default 1")
private int quantity;
@Convert(converter = StringListConverter.class)
@Column(name = "delivered_codes", columnDefinition = "json")
private List<String> deliveredCodes = new ArrayList<>();
@Column(length = 20, nullable = false, columnDefinition = "varchar(20) default 'pending'")
private String status = "pending";
@Column(name = "delivery_mode", length = 20, columnDefinition = "varchar(20) default 'auto'")
private String deliveryMode = "auto";
@Column(columnDefinition = "text")
private String note;
@Column(name = "contact_phone", length = 50)
private String contactPhone;
@Column(name = "contact_email", length = 255)
private String contactEmail;
@Column(name = "notify_email", length = 255)
private String notifyEmail;
@Column(name = "created_at")
private LocalDateTime createdAt;
}

View File

@@ -0,0 +1,80 @@
package com.smyhub.store.mengyastorebackendjava.entity;
import com.smyhub.store.mengyastorebackendjava.entity.converter.StringListConverter;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
@Entity
@Table(name = "products")
@Getter
@Setter
public class Product {
@Id
@Column(length = 36)
private String id;
@Column(length = 255, nullable = false)
private String name;
@Column(nullable = false, columnDefinition = "double default 0")
private double price;
@Column(columnDefinition = "double default 0")
private double discountPrice;
@Convert(converter = StringListConverter.class)
@Column(columnDefinition = "json")
private List<String> tags = new ArrayList<>();
@Column(length = 500)
private String coverUrl;
@Convert(converter = StringListConverter.class)
@Column(columnDefinition = "json")
private List<String> screenshotUrls = new ArrayList<>();
@Column(length = 500, columnDefinition = "varchar(500) default ''")
private String verificationUrl;
@Column(columnDefinition = "text")
private String description;
@Column(columnDefinition = "tinyint(1) default 1")
private boolean active = true;
@Column(columnDefinition = "tinyint(1) default 0")
private boolean requireLogin;
@Column(columnDefinition = "int default 0")
private int maxPerAccount;
@Column(columnDefinition = "int default 0")
private int totalSold;
@Column(columnDefinition = "int default 0")
private int viewCount;
@Column(length = 20, columnDefinition = "varchar(20) default 'auto'")
private String deliveryMode = "auto";
@Column(length = 16, columnDefinition = "varchar(16) default 'card'")
private String fulfillmentType = "card";
@Column(columnDefinition = "text")
private String fixedContent;
@Column(columnDefinition = "tinyint(1) default 1")
private boolean showNote = true;
@Column(columnDefinition = "tinyint(1) default 1")
private boolean showContact = true;
@Column
private LocalDateTime createdAt;
}

View File

@@ -0,0 +1,29 @@
package com.smyhub.store.mengyastorebackendjava.entity;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Entity
@Table(name = "product_codes")
@Getter
@Setter
@NoArgsConstructor
public class ProductCode {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "product_id", length = 36, nullable = false)
private String productId;
@Column(columnDefinition = "text", nullable = false)
private String code;
public ProductCode(String productId, String code) {
this.productId = productId;
this.code = code;
}
}

View File

@@ -0,0 +1,26 @@
package com.smyhub.store.mengyastorebackendjava.entity;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Entity
@Table(name = "site_settings")
@Getter
@Setter
@NoArgsConstructor
public class SiteSetting {
@Id
@Column(name = "`key`", length = 64)
private String key;
@Column(columnDefinition = "text")
private String value;
public SiteSetting(String key, String value) {
this.key = key;
this.value = value;
}
}

View File

@@ -0,0 +1,30 @@
package com.smyhub.store.mengyastorebackendjava.entity;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Entity
@Table(name = "wishlists",
uniqueConstraints = @UniqueConstraint(name = "idx_wishlist", columnNames = {"account_id", "product_id"}))
@Getter
@Setter
@NoArgsConstructor
public class Wishlist {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "account_id", length = 255, nullable = false)
private String accountId;
@Column(name = "product_id", length = 36, nullable = false)
private String productId;
public Wishlist(String accountId, String productId) {
this.accountId = accountId;
this.productId = productId;
}
}

View File

@@ -0,0 +1,39 @@
package com.smyhub.store.mengyastorebackendjava.entity.converter;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.persistence.AttributeConverter;
import jakarta.persistence.Converter;
import java.util.ArrayList;
import java.util.List;
@Converter
public class StringListConverter implements AttributeConverter<List<String>, String> {
private static final ObjectMapper MAPPER = new ObjectMapper();
@Override
public String convertToDatabaseColumn(List<String> attribute) {
if (attribute == null) {
return "[]";
}
try {
return MAPPER.writeValueAsString(attribute);
} catch (Exception e) {
return "[]";
}
}
@Override
public List<String> convertToEntityAttribute(String dbData) {
if (dbData == null || dbData.isBlank()) {
return new ArrayList<>();
}
try {
return MAPPER.readValue(dbData, new TypeReference<>() {});
} catch (Exception e) {
return new ArrayList<>();
}
}
}

View File

@@ -0,0 +1,21 @@
package com.smyhub.store.mengyastorebackendjava.exception;
import org.springframework.http.HttpStatus;
public class BusinessException extends RuntimeException {
private final HttpStatus status;
public BusinessException(String message) {
this(message, HttpStatus.BAD_REQUEST);
}
public BusinessException(String message, HttpStatus status) {
super(message);
this.status = status;
}
public HttpStatus getStatus() {
return status;
}
}

View File

@@ -0,0 +1,35 @@
package com.smyhub.store.mengyastorebackendjava.exception;
import com.smyhub.store.mengyastorebackendjava.dto.response.ApiResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(BusinessException.class)
public ResponseEntity<ApiResponse<Void>> handleBusiness(BusinessException ex) {
return ResponseEntity.status(ex.getStatus()).body(ApiResponse.error(ex.getMessage()));
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiResponse<Void>> handleValidation(MethodArgumentNotValidException ex) {
String message = ex.getBindingResult().getAllErrors().stream()
.findFirst()
.map(e -> e.getDefaultMessage())
.orElse("请求参数有误");
return ResponseEntity.badRequest().body(ApiResponse.error(message));
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiResponse<Void>> handleGeneric(Exception ex) {
log.error("Unhandled exception", ex);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ApiResponse.error("服务器内部错误"));
}
}

View File

@@ -0,0 +1,64 @@
package com.smyhub.store.mengyastorebackendjava.mq;
import com.rabbitmq.client.Channel;
import com.smyhub.store.mengyastorebackendjava.service.EmailService;
import com.smyhub.store.mengyastorebackendjava.service.SiteService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.core.Message;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.amqp.rabbit.listener.api.ChannelAwareMessageListener;
import org.springframework.amqp.support.AmqpHeaders;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.messaging.handler.annotation.Header;
import org.springframework.stereotype.Component;
@Slf4j
@Component
@RequiredArgsConstructor
@ConditionalOnProperty(name = "app.rabbitmq-enabled", havingValue = "true")
public class OrderEmailConsumer {
private final SiteService siteService;
private final EmailService emailService;
@RabbitListener(queues = "#{T(com.smyhub.store.mengyastorebackendjava.mq.RabbitMQTopology).queueName(@appProperties.rabbitmqEnv)}", ackMode = "MANUAL")
public void onMessage(OrderEmailPayload payload, Message message, Channel channel,
@Header(AmqpHeaders.DELIVERY_TAG) long deliveryTag) throws Exception {
if (payload == null || payload.getToEmail() == null || payload.getToEmail().isBlank()) {
channel.basicAck(deliveryTag, false);
return;
}
SiteService.SmtpConfig cfg = siteService.getSmtpConfig();
if (!cfg.isConfigured()) {
log.info("[MQ] skip email order={}: smtp not configured", payload.getOrderId());
channel.basicAck(deliveryTag, false);
return;
}
EmailService.OrderNotifyData data = new EmailService.OrderNotifyData();
data.toEmail = payload.getToEmail();
data.toName = payload.getToName();
data.productName = payload.getProductName();
data.orderId = payload.getOrderId();
data.quantity = payload.getQuantity();
data.codes = payload.getCodes();
data.isManual = payload.isManual();
try {
emailService.sendOrderNotify(cfg, data);
channel.basicAck(deliveryTag, false);
log.info("[MQ] email ok order={} to={}", payload.getOrderId(), payload.getToEmail());
} catch (Exception e) {
log.warn("[MQ] send email fail order={}: {}", payload.getOrderId(), e.getMessage());
boolean redelivered = message.getMessageProperties().isRedelivered();
if (redelivered) {
log.warn("[MQ] drop order={} after failed redelivery", payload.getOrderId());
channel.basicAck(deliveryTag, false);
} else {
channel.basicNack(deliveryTag, false, true);
}
}
}
}

View File

@@ -0,0 +1,21 @@
package com.smyhub.store.mengyastorebackendjava.mq;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class OrderEmailPayload {
private String toEmail;
private String toName;
private String productName;
private String orderId;
private int quantity;
private List<String> codes;
private boolean isManual;
}

View File

@@ -0,0 +1,36 @@
package com.smyhub.store.mengyastorebackendjava.mq;
import com.smyhub.store.mengyastorebackendjava.config.AppProperties;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.AmqpException;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.stereotype.Component;
@Slf4j
@Component
@RequiredArgsConstructor
public class OrderEmailProducer {
private final RabbitTemplate rabbitTemplate;
private final AppProperties appProperties;
/**
* Publishes an order email payload to RabbitMQ.
* Returns true on success, false on failure (caller should fall back to direct email).
*/
public boolean publish(OrderEmailPayload payload) {
if (!appProperties.isRabbitmqEnabled()) {
return false;
}
String exchange = RabbitMQTopology.exchangeName(appProperties.getRabbitmqEnv());
try {
rabbitTemplate.convertAndSend(exchange, RabbitMQTopology.ORDER_EMAIL_ROUTING_KEY, payload);
log.info("[MQ] queued order email order={} to={}", payload.getOrderId(), payload.getToEmail());
return true;
} catch (AmqpException e) {
log.warn("[MQ] publish failed, will fallback to direct email: {}", e.getMessage());
return false;
}
}
}

View File

@@ -0,0 +1,16 @@
package com.smyhub.store.mengyastorebackendjava.mq;
public final class RabbitMQTopology {
private RabbitMQTopology() {}
public static final String ORDER_EMAIL_ROUTING_KEY = "order.email.notify";
public static String exchangeName(String env) {
return "ex.mengyastore." + env + ".events";
}
public static String queueName(String env) {
return "q.mengyastore." + env + ".order_email";
}
}

View File

@@ -0,0 +1,19 @@
package com.smyhub.store.mengyastorebackendjava.repository;
import com.smyhub.store.mengyastorebackendjava.entity.ChatMessage;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Repository
public interface ChatMessageRepository extends JpaRepository<ChatMessage, String> {
List<ChatMessage> findByAccountIdOrderBySentAtAsc(String accountId);
List<ChatMessage> findAllByOrderByAccountIdAscSentAtAsc();
@Transactional
void deleteByAccountId(String accountId);
}

View File

@@ -0,0 +1,20 @@
package com.smyhub.store.mengyastorebackendjava.repository;
import com.smyhub.store.mengyastorebackendjava.entity.Order;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface OrderRepository extends JpaRepository<Order, String> {
List<Order> findByUserAccountOrderByCreatedAtDesc(String userAccount);
List<Order> findAllByOrderByCreatedAtDesc();
@Query("SELECT COALESCE(SUM(o.quantity), 0) FROM Order o WHERE o.userAccount = :account AND o.productId = :productId AND o.status <> 'cancelled'")
int sumQuantityByUserAccountAndProductId(@Param("account") String account, @Param("productId") String productId);
}

View File

@@ -0,0 +1,19 @@
package com.smyhub.store.mengyastorebackendjava.repository;
import com.smyhub.store.mengyastorebackendjava.entity.ProductCode;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Repository
public interface ProductCodeRepository extends JpaRepository<ProductCode, Long> {
List<ProductCode> findByProductId(String productId);
long countByProductId(String productId);
@Transactional
void deleteByProductId(String productId);
}

View File

@@ -0,0 +1,29 @@
package com.smyhub.store.mengyastorebackendjava.repository;
import com.smyhub.store.mengyastorebackendjava.entity.Product;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Repository
public interface ProductRepository extends JpaRepository<Product, String> {
List<Product> findByActiveTrueOrderByCreatedAtDesc();
List<Product> findAllByOrderByCreatedAtDesc();
@Transactional
@Modifying
@Query("UPDATE Product p SET p.totalSold = p.totalSold + :count WHERE p.id = :id")
void incrementTotalSold(@Param("id") String id, @Param("count") int count);
@Transactional
@Modifying
@Query("UPDATE Product p SET p.viewCount = p.viewCount + 1 WHERE p.id = :id")
void incrementViewCount(@Param("id") String id);
}

View File

@@ -0,0 +1,9 @@
package com.smyhub.store.mengyastorebackendjava.repository;
import com.smyhub.store.mengyastorebackendjava.entity.SiteSetting;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface SiteSettingRepository extends JpaRepository<SiteSetting, String> {
}

View File

@@ -0,0 +1,22 @@
package com.smyhub.store.mengyastorebackendjava.repository;
import com.smyhub.store.mengyastorebackendjava.entity.Wishlist;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Optional;
@Repository
public interface WishlistRepository extends JpaRepository<Wishlist, Long> {
List<Wishlist> findByAccountId(String accountId);
Optional<Wishlist> findByAccountIdAndProductId(String accountId, String productId);
boolean existsByAccountIdAndProductId(String accountId, String productId);
@Transactional
void deleteByAccountIdAndProductId(String accountId, String productId);
}

View File

@@ -0,0 +1,46 @@
package com.smyhub.store.mengyastorebackendjava.security;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.smyhub.store.mengyastorebackendjava.config.AppProperties;
import com.smyhub.store.mengyastorebackendjava.dto.response.ApiResponse;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.servlet.HandlerInterceptor;
@Component
@RequiredArgsConstructor
public class AdminTokenInterceptor implements HandlerInterceptor {
private final AppProperties appProperties;
private final ObjectMapper objectMapper;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String token = resolveToken(request);
if (StringUtils.hasText(token) && token.equals(appProperties.getAdminToken())) {
return true;
}
response.setStatus(HttpStatus.UNAUTHORIZED.value());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding("UTF-8");
objectMapper.writeValue(response.getWriter(), ApiResponse.error("unauthorized"));
return false;
}
private String resolveToken(HttpServletRequest request) {
String token = request.getHeader("X-Admin-Token");
if (StringUtils.hasText(token)) {
return token;
}
token = request.getHeader("Authorization");
if (StringUtils.hasText(token)) {
return token;
}
return request.getParameter("token");
}
}

View File

@@ -0,0 +1,49 @@
package com.smyhub.store.mengyastorebackendjava.security;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.smyhub.store.mengyastorebackendjava.dto.response.ApiResponse;
import com.smyhub.store.mengyastorebackendjava.service.SproutGateAuthService;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.servlet.HandlerInterceptor;
@Component
@RequiredArgsConstructor
public class BearerTokenInterceptor implements HandlerInterceptor {
private final SproutGateAuthService authService;
private final ObjectMapper objectMapper;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String header = request.getHeader("Authorization");
if (!StringUtils.hasText(header) || !header.startsWith("Bearer ")) {
return sendUnauthorized(response, "请先登录");
}
String token = header.substring(7);
SproutGateVerifyResult result = authService.verifyToken(token);
if (!result.isValid() || result.getUser() == null) {
return sendUnauthorized(response, "登录已过期,请重新登录");
}
CurrentUserHolder.set(result.getUser());
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
CurrentUserHolder.clear();
}
private boolean sendUnauthorized(HttpServletResponse response, String message) throws Exception {
response.setStatus(HttpStatus.UNAUTHORIZED.value());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding("UTF-8");
objectMapper.writeValue(response.getWriter(), ApiResponse.error(message));
return false;
}
}

View File

@@ -0,0 +1,20 @@
package com.smyhub.store.mengyastorebackendjava.security;
public final class CurrentUserHolder {
private static final ThreadLocal<SproutGateUser> HOLDER = new ThreadLocal<>();
private CurrentUserHolder() {}
public static void set(SproutGateUser user) {
HOLDER.set(user);
}
public static SproutGateUser get() {
return HOLDER.get();
}
public static void clear() {
HOLDER.remove();
}
}

View File

@@ -0,0 +1,13 @@
package com.smyhub.store.mengyastorebackendjava.security;
import lombok.Data;
@Data
public class SproutGateUser {
private String account;
private String username;
private String email;
}

View File

@@ -0,0 +1,11 @@
package com.smyhub.store.mengyastorebackendjava.security;
import lombok.Data;
@Data
public class SproutGateVerifyResult {
private boolean valid;
private SproutGateUser user;
}

View File

@@ -0,0 +1,86 @@
package com.smyhub.store.mengyastorebackendjava.service;
import com.smyhub.store.mengyastorebackendjava.dto.response.ChatMessageResponse;
import com.smyhub.store.mengyastorebackendjava.entity.ChatMessage;
import com.smyhub.store.mengyastorebackendjava.exception.BusinessException;
import com.smyhub.store.mengyastorebackendjava.repository.ChatMessageRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
@Service
@RequiredArgsConstructor
public class ChatService {
private final ChatMessageRepository chatMessageRepository;
private final Map<String, LocalDateTime> lastSentMap = new ConcurrentHashMap<>();
public List<ChatMessageResponse> getUserMessages(String accountId) {
return chatMessageRepository.findByAccountIdOrderBySentAtAsc(accountId)
.stream().map(this::toResponse).toList();
}
@Transactional
public ChatMessageResponse sendUserMessage(String accountId, String accountName, String content) {
LocalDateTime last = lastSentMap.get(accountId);
if (last != null && last.plusSeconds(1).isAfter(LocalDateTime.now())) {
throw new BusinessException("发送太频繁,请稍后再试", HttpStatus.TOO_MANY_REQUESTS);
}
lastSentMap.put(accountId, LocalDateTime.now());
ChatMessage msg = new ChatMessage();
msg.setId(UUID.randomUUID().toString());
msg.setAccountId(accountId);
msg.setAccountName(accountName);
msg.setContent(content);
msg.setSentAt(LocalDateTime.now());
msg.setFromAdmin(false);
return toResponse(chatMessageRepository.save(msg));
}
public Map<String, List<ChatMessageResponse>> listAllConversations() {
Map<String, List<ChatMessageResponse>> result = new LinkedHashMap<>();
chatMessageRepository.findAllByOrderByAccountIdAscSentAtAsc().forEach(row -> {
result.computeIfAbsent(row.getAccountId(), k -> new ArrayList<>()).add(toResponse(row));
});
return result;
}
public List<ChatMessageResponse> getConversation(String accountId) {
return chatMessageRepository.findByAccountIdOrderBySentAtAsc(accountId)
.stream().map(this::toResponse).toList();
}
@Transactional
public ChatMessageResponse sendAdminMessage(String accountId, String content) {
ChatMessage msg = new ChatMessage();
msg.setId(UUID.randomUUID().toString());
msg.setAccountId(accountId);
msg.setAccountName("管理员");
msg.setContent(content);
msg.setSentAt(LocalDateTime.now());
msg.setFromAdmin(true);
return toResponse(chatMessageRepository.save(msg));
}
@Transactional
public void clearConversation(String accountId) {
chatMessageRepository.deleteByAccountId(accountId);
}
private ChatMessageResponse toResponse(ChatMessage m) {
return ChatMessageResponse.builder()
.id(m.getId())
.accountId(m.getAccountId())
.accountName(m.getAccountName())
.content(m.getContent())
.sentAt(m.getSentAt())
.fromAdmin(m.isFromAdmin())
.build();
}
}

View File

@@ -0,0 +1,105 @@
package com.smyhub.store.mengyastorebackendjava.service;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import javax.net.ssl.SSLSocketFactory;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Properties;
import jakarta.mail.*;
import jakarta.mail.internet.*;
@Slf4j
@Service
public class EmailService {
private static final DateTimeFormatter FORMATTER =
DateTimeFormatter.ofPattern("yyyy年MM月dd日 HH:mm:ss");
public void sendOrderNotify(SiteService.SmtpConfig cfg, OrderNotifyData data) {
if (!cfg.isConfigured() || data.toEmail == null || data.toEmail.isBlank()) {
return;
}
try {
Properties props = buildMailProperties(cfg);
Session session = Session.getInstance(props, new Authenticator() {
@Override
protected PasswordAuthentication getPasswordAuthentication() {
return new PasswordAuthentication(cfg.email, cfg.password);
}
});
Message message = new MimeMessage(session);
message.setFrom(new InternetAddress(cfg.email, cfg.fromName, "UTF-8"));
message.setRecipients(Message.RecipientType.TO, InternetAddress.parse(data.toEmail));
message.setSubject(data.isManual ? "【萌芽小店】您的订单正在处理中" : "【萌芽小店】您的订单已发货");
message.setContent(buildBody(data), "text/plain; charset=UTF-8");
Transport.send(message);
log.info("[Email] 发送成功 order={} to={}", data.orderId, data.toEmail);
} catch (Exception e) {
log.error("[Email] 发送失败 order={} to={}: {}", data.orderId, data.toEmail, e.getMessage());
}
}
private Properties buildMailProperties(SiteService.SmtpConfig cfg) {
Properties props = new Properties();
props.put("mail.smtp.host", cfg.host);
props.put("mail.smtp.port", cfg.port);
props.put("mail.smtp.auth", "true");
if ("465".equals(cfg.port)) {
props.put("mail.smtp.ssl.enable", "true");
props.put("mail.smtp.socketFactory.port", "465");
props.put("mail.smtp.socketFactory.class", SSLSocketFactory.class.getName());
props.put("mail.smtp.socketFactory.fallback", "false");
} else {
props.put("mail.smtp.starttls.enable", "true");
}
return props;
}
private String buildBody(OrderNotifyData data) {
String now = LocalDateTime.now().format(FORMATTER);
String recipient = (data.toName != null && !data.toName.isBlank()) ? data.toName : "用户";
StringBuilder sb = new StringBuilder();
sb.append("尊敬的 ").append(recipient).append("\n\n");
sb.append(" 您好!感谢您在萌芽小店的支持与购买。\n\n");
sb.append("────────────────────────────────\n");
sb.append(" 订单信息\n");
sb.append("────────────────────────────────\n");
sb.append(" 商品名称:").append(data.productName).append("\n");
sb.append(" 订单编号:").append(data.orderId).append("\n");
sb.append(" 购买数量:").append(data.quantity).append("\n");
sb.append(" 通知时间:").append(now).append("\n");
sb.append("────────────────────────────────\n\n");
if (data.isManual) {
sb.append(" 您的订单已成功提交,目前正在等待人工审核与处理。\n");
sb.append(" 工作人员将尽快为您安排发货,请耐心等候。\n");
sb.append(" 发货完成后,我们将另行发送邮件通知。\n\n");
} else {
sb.append(" 您的订单已完成自动发货,发货内容如下:\n\n");
if (data.codes != null) {
for (int i = 0; i < data.codes.size(); i++) {
sb.append(" [").append(i + 1).append("] ").append(data.codes.get(i)).append("\n");
}
sb.append("\n");
}
sb.append(" 请妥善保管以上发货内容,切勿泄露给他人。\n\n");
}
sb.append(" 如有任何疑问,请联系在线客服,我们将竭诚为您服务。\n\n");
sb.append("────────────────────────────────\n");
sb.append(" 此邮件由系统自动发送,请勿直接回复。\n");
sb.append("────────────────────────────────\n");
return sb.toString();
}
public static class OrderNotifyData {
public String toEmail;
public String toName;
public String productName;
public String orderId;
public int quantity;
public List<String> codes;
public boolean isManual;
}
}

View File

@@ -0,0 +1,198 @@
package com.smyhub.store.mengyastorebackendjava.service;
import com.smyhub.store.mengyastorebackendjava.dto.request.CheckoutRequest;
import com.smyhub.store.mengyastorebackendjava.dto.response.OrderResponse;
import com.smyhub.store.mengyastorebackendjava.entity.Order;
import com.smyhub.store.mengyastorebackendjava.entity.Product;
import com.smyhub.store.mengyastorebackendjava.exception.BusinessException;
import com.smyhub.store.mengyastorebackendjava.mq.OrderEmailPayload;
import com.smyhub.store.mengyastorebackendjava.mq.OrderEmailProducer;
import com.smyhub.store.mengyastorebackendjava.repository.OrderRepository;
import com.smyhub.store.mengyastorebackendjava.security.SproutGateUser;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.UUID;
@Slf4j
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository;
private final ProductService productService;
private final SiteService siteService;
private final EmailService emailService;
private final OrderEmailProducer orderEmailProducer;
@Transactional
public CheckoutResult checkout(CheckoutRequest req, SproutGateUser optionalUser) {
String userAccount = optionalUser != null ? optionalUser.getAccount() : "";
String userName = optionalUser != null ? optionalUser.getUsername() : "";
String userEmail = optionalUser != null ? optionalUser.getEmail() : "";
int qty = req.getQuantity() <= 0 ? 1 : req.getQuantity();
Product product = productService.findById(req.getProductId().strip());
if (!product.isActive()) {
throw new BusinessException("商品暂时无法购买");
}
if (product.isRequireLogin() && !StringUtils.hasText(userAccount)) {
throw new BusinessException("该商品需要登录后才能购买", HttpStatus.UNAUTHORIZED);
}
if (product.getMaxPerAccount() > 0 && StringUtils.hasText(userAccount)) {
int purchased = orderRepository.sumQuantityByUserAccountAndProductId(userAccount, product.getId());
if (purchased + qty > product.getMaxPerAccount()) {
int remain = product.getMaxPerAccount() - purchased;
if (remain <= 0) {
throw new BusinessException(String.format("每个账户最多购买 %d 个,您已达上限", product.getMaxPerAccount()));
}
throw new BusinessException(String.format("每个账户最多购买 %d 个,您还可购买 %d 个", product.getMaxPerAccount(), remain));
}
}
List<String> deliveredCodes;
boolean isFixed = "fixed".equalsIgnoreCase(product.getFulfillmentType());
if (isFixed) {
String content = product.getFixedContent() != null ? product.getFixedContent().strip() : "";
if (content.isEmpty()) {
throw new BusinessException("该商品为固定内容发货,但未配置发货内容,请联系管理员");
}
deliveredCodes = Collections.nCopies(qty, content);
} else {
deliveredCodes = productService.popCodes(product.getId(), qty);
}
String notifyEmail = StringUtils.hasText(userEmail) ? userEmail
: StringUtils.hasText(req.getNotifyEmail()) ? req.getNotifyEmail().strip()
: StringUtils.hasText(req.getContactEmail()) ? req.getContactEmail().strip()
: "";
Order order = new Order();
order.setId(UUID.randomUUID().toString());
order.setProductId(product.getId());
order.setProductName(product.getName());
order.setUserAccount(userAccount);
order.setUserName(userName);
order.setQuantity(qty);
order.setDeliveredCodes(new ArrayList<>(deliveredCodes));
order.setStatus("completed");
order.setDeliveryMode("auto");
order.setNote(req.getNote() != null ? req.getNote().strip() : "");
order.setContactPhone(req.getContactPhone() != null ? req.getContactPhone().strip() : "");
order.setContactEmail(req.getContactEmail() != null ? req.getContactEmail().strip() : "");
order.setNotifyEmail(notifyEmail);
order.setCreatedAt(LocalDateTime.now());
orderRepository.save(order);
productService.incrementSold(product.getId(), qty);
if (StringUtils.hasText(notifyEmail)) {
sendOrderEmail(notifyEmail, userName, product.getName(), order.getId(), qty, new ArrayList<>(deliveredCodes), false);
}
return new CheckoutResult(
order.getId(),
product.getId(),
qty,
product.getViewCount(),
order.getStatus(),
new ArrayList<>(deliveredCodes),
order.getDeliveryMode());
}
public OrderResponse confirmOrder(String orderId) {
Order order = orderRepository.findById(orderId)
.orElseThrow(() -> new BusinessException("订单不存在", HttpStatus.NOT_FOUND));
order.setStatus("confirmed");
orderRepository.save(order);
boolean isManual = "manual".equals(order.getDeliveryMode());
if (isManual) {
String email = StringUtils.hasText(order.getNotifyEmail())
? order.getNotifyEmail() : order.getContactEmail();
if (StringUtils.hasText(email)) {
sendOrderEmail(email, order.getUserName(), order.getProductName(),
order.getId(), order.getQuantity(), order.getDeliveredCodes(), false);
}
}
return toResponse(order);
}
public List<OrderResponse> getMyOrders(String userAccount) {
return orderRepository.findByUserAccountOrderByCreatedAtDesc(userAccount)
.stream().map(this::toResponse).toList();
}
public List<OrderResponse> listAllOrders() {
return orderRepository.findAllByOrderByCreatedAtDesc()
.stream().map(this::toResponse).toList();
}
@Transactional
public void deleteOrder(String orderId) {
if (!orderRepository.existsById(orderId)) {
throw new BusinessException("订单不存在", HttpStatus.NOT_FOUND);
}
orderRepository.deleteById(orderId);
}
private void sendOrderEmail(String toEmail, String toName, String productName,
String orderId, int qty, List<String> codes, boolean isManual) {
SiteService.SmtpConfig cfg = siteService.getSmtpConfig();
if (!cfg.isConfigured()) return;
OrderEmailPayload payload = new OrderEmailPayload(toEmail, toName, productName, orderId, qty, codes, isManual);
boolean queued = orderEmailProducer.publish(payload);
if (!queued) {
EmailService.OrderNotifyData data = new EmailService.OrderNotifyData();
data.toEmail = toEmail;
data.toName = toName;
data.productName = productName;
data.orderId = orderId;
data.quantity = qty;
data.codes = codes;
data.isManual = isManual;
Thread emailThread = new Thread(() -> emailService.sendOrderNotify(cfg, data));
emailThread.setDaemon(true);
emailThread.start();
}
}
private OrderResponse toResponse(Order o) {
return OrderResponse.builder()
.id(o.getId())
.productId(o.getProductId())
.productName(o.getProductName())
.userAccount(o.getUserAccount())
.userName(o.getUserName())
.quantity(o.getQuantity())
.deliveredCodes(o.getDeliveredCodes())
.status(o.getStatus())
.deliveryMode(o.getDeliveryMode())
.note(o.getNote())
.contactPhone(o.getContactPhone())
.contactEmail(o.getContactEmail())
.notifyEmail(o.getNotifyEmail())
.createdAt(o.getCreatedAt())
.build();
}
public record CheckoutResult(
String orderId,
String productId,
int qty,
int viewCount,
String status,
java.util.List<String> deliveredCodes,
String deliveryMode) {
}
}

View File

@@ -0,0 +1,246 @@
package com.smyhub.store.mengyastorebackendjava.service;
import com.smyhub.store.mengyastorebackendjava.dto.request.AdminProductRequest;
import com.smyhub.store.mengyastorebackendjava.dto.response.ProductResponse;
import com.smyhub.store.mengyastorebackendjava.entity.Product;
import com.smyhub.store.mengyastorebackendjava.entity.ProductCode;
import com.smyhub.store.mengyastorebackendjava.exception.BusinessException;
import com.smyhub.store.mengyastorebackendjava.repository.ProductCodeRepository;
import com.smyhub.store.mengyastorebackendjava.repository.ProductRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.security.MessageDigest;
import java.time.LocalDateTime;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
@Service
@RequiredArgsConstructor
public class ProductService {
private static final String DEFAULT_COVER_URL = "https://img.shumengya.top/i/2026/01/04/695a55058c37f.png";
private static final int VIEW_COOLDOWN_HOURS = 6;
private static final int MAX_SCREENSHOT_URLS = 5;
private final ProductRepository productRepository;
private final ProductCodeRepository productCodeRepository;
private final Map<String, LocalDateTime> recentViews = new ConcurrentHashMap<>();
public List<ProductResponse> listPublicProducts() {
return productRepository.findByActiveTrueOrderByCreatedAtDesc().stream()
.map(p -> toResponse(p, loadCodeCount(p), false, false))
.toList();
}
public List<ProductResponse> listAllProducts() {
return productRepository.findAllByOrderByCreatedAtDesc().stream()
.map(p -> toResponse(p, loadCodeCount(p), true, true))
.toList();
}
public ProductResponse getProductForAdmin(String id) {
Product p = findById(id);
List<String> codes = productCodeRepository.findByProductId(id).stream()
.map(ProductCode::getCode).toList();
return toResponse(p, (long) codes.size(), true, true, codes);
}
@Transactional
public ProductResponse createProduct(AdminProductRequest req) {
Product p = new Product();
p.setId(UUID.randomUUID().toString());
p.setCreatedAt(LocalDateTime.now());
applyRequest(p, req);
productRepository.save(p);
replaceCodes(p.getId(), sanitizeCodes(req.getCodes()));
return getProductForAdmin(p.getId());
}
@Transactional
public ProductResponse updateProduct(String id, AdminProductRequest req) {
Product p = findById(id);
applyRequest(p, req);
productRepository.save(p);
replaceCodes(id, sanitizeCodes(req.getCodes()));
return getProductForAdmin(id);
}
@Transactional
public ProductResponse toggleStatus(String id, boolean active) {
Product p = findById(id);
p.setActive(active);
productRepository.save(p);
long count = productCodeRepository.countByProductId(id);
return toResponse(p, count, true, false);
}
@Transactional
public void deleteProduct(String id) {
findById(id);
productCodeRepository.deleteByProductId(id);
productRepository.deleteById(id);
}
public ProductResponse recordView(String id, String fingerprint) {
cleanupRecentViews();
String key = buildViewKey(id, fingerprint);
LocalDateTime last = recentViews.get(key);
LocalDateTime now = LocalDateTime.now();
boolean counted = false;
if (last == null || last.plusHours(VIEW_COOLDOWN_HOURS).isBefore(now)) {
productRepository.incrementViewCount(id);
recentViews.put(key, now);
counted = true;
}
Product p = findById(id);
return ProductResponse.builder()
.id(p.getId())
.viewCount(p.getViewCount())
.build();
}
@Transactional
public void incrementSold(String id, int count) {
productRepository.incrementTotalSold(id, count);
}
public Product findById(String id) {
return productRepository.findById(id)
.orElseThrow(() -> new BusinessException("商品不存在", HttpStatus.NOT_FOUND));
}
@Transactional
public List<String> popCodes(String productId, int count) {
List<ProductCode> codes = productCodeRepository.findByProductId(productId);
if (codes.size() < count) {
throw new BusinessException("卡密不足");
}
List<String> popped = codes.subList(0, count).stream().map(ProductCode::getCode).toList();
codes.subList(0, count).forEach(c -> productCodeRepository.deleteById(c.getId()));
return popped;
}
private long loadCodeCount(Product p) {
if ("fixed".equalsIgnoreCase(p.getFulfillmentType())) {
return -1;
}
return productCodeRepository.countByProductId(p.getId());
}
private void replaceCodes(String productId, List<String> codes) {
productCodeRepository.deleteByProductId(productId);
if (!codes.isEmpty()) {
List<ProductCode> rows = codes.stream()
.map(c -> new ProductCode(productId, c)).toList();
productCodeRepository.saveAll(rows);
}
}
private void applyRequest(Product p, AdminProductRequest req) {
p.setName(req.getName());
p.setPrice(req.getPrice());
double discount = req.getDiscountPrice();
p.setDiscountPrice((discount > 0 && discount < req.getPrice()) ? discount : 0);
p.setTags(sanitizeTags(req.getTags()));
String cover = req.getCoverUrl();
p.setCoverUrl(StringUtils.hasText(cover) ? cover : DEFAULT_COVER_URL);
List<String> screenshots = req.getScreenshotUrls();
if (screenshots != null && screenshots.size() > MAX_SCREENSHOT_URLS) {
screenshots = screenshots.subList(0, MAX_SCREENSHOT_URLS);
}
p.setScreenshotUrls(screenshots != null ? screenshots : new ArrayList<>());
p.setVerificationUrl(req.getVerificationUrl() != null ? req.getVerificationUrl().strip() : "");
p.setDescription(req.getDescription());
p.setActive(req.isActive());
p.setRequireLogin(req.isRequireLogin());
p.setMaxPerAccount(req.getMaxPerAccount());
p.setDeliveryMode(StringUtils.hasText(req.getDeliveryMode()) ? req.getDeliveryMode() : "auto");
String ft = normalizeFullfillmentType(req.getFulfillmentType());
p.setFulfillmentType(ft);
if ("fixed".equals(ft)) {
p.setFixedContent(req.getFixedContent() != null ? req.getFixedContent().strip() : "");
} else {
p.setFixedContent("");
}
p.setShowNote(req.isShowNote());
p.setShowContact(req.isShowContact());
}
private ProductResponse toResponse(Product p, long codeCount, boolean includeCodes, boolean includeFixedContent) {
return toResponse(p, codeCount, includeCodes, includeFixedContent, null);
}
private ProductResponse toResponse(Product p, long codeCount, boolean includeCodes, boolean includeFixedContent, List<String> codes) {
int qty = "fixed".equalsIgnoreCase(p.getFulfillmentType()) ? -1 : (int) codeCount;
return ProductResponse.builder()
.id(p.getId())
.name(p.getName())
.price(p.getPrice())
.discountPrice(p.getDiscountPrice())
.tags(p.getTags())
.quantity(qty)
.coverUrl(p.getCoverUrl())
.screenshotUrls(p.getScreenshotUrls())
.verificationUrl(p.getVerificationUrl())
.codes(includeCodes ? codes : null)
.viewCount(p.getViewCount())
.description(p.getDescription())
.active(p.isActive())
.requireLogin(p.isRequireLogin())
.maxPerAccount(p.getMaxPerAccount())
.totalSold(p.getTotalSold())
.deliveryMode(p.getDeliveryMode())
.fulfillmentType(p.getFulfillmentType())
.fixedContent(includeFixedContent ? p.getFixedContent() : null)
.showNote(p.isShowNote())
.showContact(p.isShowContact())
.createdAt(p.getCreatedAt())
.build();
}
private static String normalizeFullfillmentType(String t) {
if (t != null && "fixed".equalsIgnoreCase(t.strip())) return "fixed";
return "card";
}
private static List<String> sanitizeCodes(List<String> codes) {
if (codes == null) return new ArrayList<>();
Set<String> seen = new LinkedHashSet<>();
for (String code : codes) {
String trimmed = code != null ? code.strip() : "";
if (!trimmed.isEmpty()) seen.add(trimmed);
}
return new ArrayList<>(seen);
}
private static List<String> sanitizeTags(List<String> tags) {
if (tags == null) return new ArrayList<>();
Set<String> seen = new LinkedHashSet<>();
for (String tag : tags) {
String t = tag != null ? tag.strip() : "";
if (!t.isEmpty() && seen.size() < 20) seen.add(t);
}
return new ArrayList<>(seen);
}
private static String buildViewKey(String id, String fingerprint) {
try {
MessageDigest md = MessageDigest.getInstance("SHA-256");
byte[] hash = md.digest((id + "|" + fingerprint).getBytes());
StringBuilder hex = new StringBuilder();
for (byte b : hash) hex.append(String.format("%02x", b));
return hex.toString();
} catch (Exception e) {
return id + "|" + fingerprint;
}
}
private void cleanupRecentViews() {
LocalDateTime cutoff = LocalDateTime.now().minusHours(VIEW_COOLDOWN_HOURS);
recentViews.entrySet().removeIf(e -> e.getValue().isBefore(cutoff));
}
}

View File

@@ -0,0 +1,119 @@
package com.smyhub.store.mengyastorebackendjava.service;
import com.smyhub.store.mengyastorebackendjava.dto.request.SmtpConfigRequest;
import com.smyhub.store.mengyastorebackendjava.dto.response.MaintenanceResponse;
import com.smyhub.store.mengyastorebackendjava.dto.response.SmtpConfigResponse;
import com.smyhub.store.mengyastorebackendjava.entity.SiteSetting;
import com.smyhub.store.mengyastorebackendjava.repository.SiteSettingRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
public class SiteService {
private static final String KEY_TOTAL_VISITS = "totalVisits";
private static final String KEY_MAINTENANCE = "maintenance";
private static final String KEY_MAINTENANCE_REASON = "maintenanceReason";
private static final String KEY_SMTP_ENABLED = "smtpEnabled";
private static final String KEY_SMTP_EMAIL = "smtpEmail";
private static final String KEY_SMTP_PASSWORD = "smtpPassword";
private static final String KEY_SMTP_FROM_NAME = "smtpFromName";
private static final String KEY_SMTP_HOST = "smtpHost";
private static final String KEY_SMTP_PORT = "smtpPort";
private final SiteSettingRepository repo;
private String get(String key) {
return repo.findById(key).map(SiteSetting::getValue).orElse(null);
}
private void set(String key, String value) {
repo.save(new SiteSetting(key, value));
}
public int getTotalVisits() {
String v = get(KEY_TOTAL_VISITS);
if (v == null || v.isBlank()) return 0;
try { return Integer.parseInt(v); } catch (NumberFormatException e) { return 0; }
}
@Transactional
public int incrementVisits() {
int current = getTotalVisits() + 1;
set(KEY_TOTAL_VISITS, String.valueOf(current));
return current;
}
public MaintenanceResponse getMaintenance() {
String enabled = get(KEY_MAINTENANCE);
String reason = get(KEY_MAINTENANCE_REASON);
return MaintenanceResponse.builder()
.maintenance("true".equals(enabled))
.reason(reason != null ? reason : "")
.build();
}
@Transactional
public void setMaintenance(boolean enabled, String reason) {
set(KEY_MAINTENANCE, enabled ? "true" : "false");
set(KEY_MAINTENANCE_REASON, reason != null ? reason : "");
}
public SmtpConfig getSmtpConfig() {
SmtpConfig cfg = new SmtpConfig();
String enabledStr = get(KEY_SMTP_ENABLED);
cfg.enabled = !"false".equals(enabledStr);
cfg.email = nvl(get(KEY_SMTP_EMAIL), "");
cfg.password = nvl(get(KEY_SMTP_PASSWORD), "");
cfg.fromName = nvl(get(KEY_SMTP_FROM_NAME), "萌芽小店");
cfg.host = nvl(get(KEY_SMTP_HOST), "smtp.qq.com");
cfg.port = nvl(get(KEY_SMTP_PORT), "465");
return cfg;
}
@Transactional
public void setSmtpConfig(SmtpConfigRequest req) {
set(KEY_SMTP_ENABLED, req.isEnabled() ? "true" : "false");
set(KEY_SMTP_EMAIL, nvl(req.getEmail(), ""));
set(KEY_SMTP_PASSWORD, nvl(req.getPassword(), ""));
set(KEY_SMTP_FROM_NAME, nvl(req.getFromName(), ""));
set(KEY_SMTP_HOST, nvl(req.getHost(), "smtp.qq.com"));
set(KEY_SMTP_PORT, nvl(req.getPort(), "465"));
}
public SmtpConfigResponse getSmtpConfigResponse(boolean maskPassword) {
SmtpConfig cfg = getSmtpConfig();
return SmtpConfigResponse.builder()
.enabled(cfg.enabled)
.email(cfg.email)
.password(maskPassword ? maskStr(cfg.password) : cfg.password)
.fromName(cfg.fromName)
.host(cfg.host)
.port(cfg.port)
.build();
}
private static String nvl(String s, String def) {
return (s != null && !s.isBlank()) ? s : def;
}
private static String maskStr(String s) {
if (s == null || s.length() <= 2) return "***";
return s.substring(0, 1) + "***" + s.substring(s.length() - 1);
}
public static class SmtpConfig {
public boolean enabled = true;
public String email = "";
public String password = "";
public String fromName = "萌芽小店";
public String host = "smtp.qq.com";
public String port = "465";
public boolean isConfigured() {
return enabled && !email.isBlank() && !password.isBlank() && !host.isBlank();
}
}
}

View File

@@ -0,0 +1,37 @@
package com.smyhub.store.mengyastorebackendjava.service;
import com.smyhub.store.mengyastorebackendjava.config.AppProperties;
import com.smyhub.store.mengyastorebackendjava.security.SproutGateVerifyResult;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
import java.util.Map;
@Slf4j
@Service
@RequiredArgsConstructor
public class SproutGateAuthService {
private final RestTemplate restTemplate;
private final AppProperties appProperties;
public SproutGateVerifyResult verifyToken(String token) {
try {
String url = appProperties.getAuthApiUrl() + "/api/auth/verify";
ResponseEntity<SproutGateVerifyResult> resp = restTemplate.postForEntity(
url, Map.of("token", token), SproutGateVerifyResult.class
);
if (resp.getStatusCode().is2xxSuccessful() && resp.getBody() != null) {
return resp.getBody();
}
} catch (Exception e) {
log.warn("[SproutGate] token verify failed: {}", e.getMessage());
}
SproutGateVerifyResult invalid = new SproutGateVerifyResult();
invalid.setValid(false);
return invalid;
}
}

View File

@@ -0,0 +1,296 @@
package com.smyhub.store.mengyastorebackendjava.service;
import com.smyhub.store.mengyastorebackendjava.config.AppProperties;
import com.smyhub.store.mengyastorebackendjava.mq.RabbitMQTopology;
import com.zaxxer.hikari.HikariDataSource;
import com.zaxxer.hikari.HikariPoolMXBean;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.connection.RedisConnection;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.ResultSet;
import java.sql.Statement;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* Builds the same JSON shape as Go {@code admin_status.go} for the admin system-status panel.
*/
@Service
@RequiredArgsConstructor
public class SystemStatusService {
private static final Instant START_TIME = Instant.now();
private final AppProperties appProperties;
private final DataSource dataSource;
private final RedisConnectionFactory redisConnectionFactory;
private final RabbitTemplate rabbitTemplate;
@Value("${spring.datasource.url:}")
private String datasourceUrl;
@Value("${spring.datasource.username:}")
private String datasourceUsername;
@Value("${server.port:8080}")
private int serverPort;
@Value("${server.address:}")
private String serverAddress;
@Value("${spring.rabbitmq.host:}")
private String rabbitHost;
@Value("${spring.rabbitmq.port:5672}")
private int rabbitPort;
@Value("${spring.rabbitmq.virtual-host:/}")
private String rabbitVhost;
@Value("${spring.rabbitmq.username:}")
private String rabbitUser;
@Value("${spring.data.redis.host:}")
private String redisHost;
@Value("${spring.data.redis.port:6379}")
private int redisPort;
@Value("${spring.data.redis.database:0}")
private int redisDb;
public Map<String, Object> buildStatus(HttpServletRequest request) {
Map<String, Object> root = new LinkedHashMap<>();
root.put("backend", backendInfo(request));
root.put("mysql", mysqlInfo());
root.put("redis", redisInfo());
root.put("rabbitmq", rabbitmqInfo());
return root;
}
private Map<String, Object> backendInfo(HttpServletRequest request) {
Map<String, Object> m = new LinkedHashMap<>();
m.put("status", "ok");
m.put("appEnv", normalizeAppEnv(appProperties.getAppEnv()));
m.put("ginMode", "");
String host = request.getServerName();
int port = request.getServerPort();
String hostHeader = request.getHeader("Host");
m.put("requestHost", StringUtils.hasText(hostHeader) ? hostHeader : host + ":" + port);
String listen = formatListenAddr();
m.put("listenAddr", listen);
String publicBase = appProperties.getPublicApiBaseUrl() != null
? appProperties.getPublicApiBaseUrl().strip() : "";
if (publicBase.isEmpty()) {
String proto = request.getHeader("X-Forwarded-Proto");
if (!StringUtils.hasText(proto)) {
proto = request.isSecure() ? "https" : "http";
}
if (StringUtils.hasText(hostHeader)) {
publicBase = proto + "://" + hostHeader;
} else {
publicBase = proto + "://" + host + ":" + port;
}
}
m.put("publicBaseUrl", publicBase);
m.put("uptimeSeconds", (int) (Instant.now().getEpochSecond() - START_TIME.getEpochSecond()));
m.put("authApiConfigured", StringUtils.hasText(appProperties.getAuthApiUrl()));
m.put("rabbitmqEnabled", appProperties.isRabbitmqEnabled());
m.put("redisEnabled", appProperties.isRedisEnabled());
return m;
}
private String normalizeAppEnv(String raw) {
if (raw == null) return "development";
return switch (raw.strip().toLowerCase()) {
case "production", "prod" -> "production";
default -> "development";
};
}
private String formatListenAddr() {
String addr = serverAddress != null ? serverAddress.strip() : "";
if (addr.isEmpty() || "0.0.0.0".equals(addr)) {
return ":" + serverPort;
}
return addr + ":" + serverPort;
}
private Map<String, Object> mysqlInfo() {
Map<String, Object> m = new LinkedHashMap<>();
parseJdbcMysqlUrl(datasourceUrl, datasourceUsername, m);
try (Connection conn = dataSource.getConnection();
Statement stmt = conn.createStatement();
ResultSet rs = stmt.executeQuery("SELECT VERSION()")) {
if (rs.next()) {
m.put("version", rs.getString(1).trim());
}
m.put("status", "ok");
appendPoolStats(m);
} catch (Exception e) {
m.put("status", "error");
m.put("message", e.getMessage());
}
return m;
}
private void appendPoolStats(Map<String, Object> m) {
try {
if (dataSource instanceof HikariDataSource hikari) {
HikariPoolMXBean pool = hikari.getHikariPoolMXBean();
if (pool != null) {
int active = pool.getActiveConnections();
int idle = pool.getIdleConnections();
m.put("openConnections", active + idle);
m.put("inUse", active);
m.put("idle", idle);
m.put("waitCount", pool.getThreadsAwaitingConnection());
}
}
} catch (Exception ignored) {
// optional metrics
}
}
private static void parseJdbcMysqlUrl(String jdbcUrl, String username, Map<String, Object> out) {
if (jdbcUrl == null || jdbcUrl.isBlank()) {
out.put("configured", false);
return;
}
try {
String rest = jdbcUrl.substring("jdbc:mysql://".length());
int q = rest.indexOf('?');
String core = q >= 0 ? rest.substring(0, q) : rest;
int slash = core.indexOf('/');
String hostPort = slash >= 0 ? core.substring(0, slash) : core;
String database = "";
if (slash >= 0 && slash < core.length() - 1) {
database = core.substring(slash + 1);
}
String host;
String port;
if (hostPort.contains(":")) {
int c = hostPort.lastIndexOf(':');
host = hostPort.substring(0, c);
port = hostPort.substring(c + 1);
} else {
host = hostPort;
port = "3306";
}
out.put("configured", true);
out.put("host", host);
out.put("port", port);
out.put("database", database);
out.put("user", username != null ? username : "");
} catch (Exception e) {
out.put("configured", false);
out.put("dsnParseError", e.getMessage());
}
}
private Map<String, Object> redisInfo() {
Map<String, Object> m = new LinkedHashMap<>();
m.put("enabled", appProperties.isRedisEnabled());
m.put("env", appProperties.getRedisEnv());
m.put("host", redisHost);
m.put("port", redisPort);
m.put("dbIndex", redisDb);
if (!appProperties.isRedisEnabled()) {
m.put("status", "disabled");
return m;
}
if (!StringUtils.hasText(redisHost)) {
m.put("status", "misconfigured");
m.put("message", "已启用但未配置 spring.data.redis.host");
return m;
}
try (RedisConnection conn = redisConnectionFactory.getConnection()) {
String pong = new String(conn.ping());
if (!"PONG".equalsIgnoreCase(pong)) {
m.put("status", "degraded");
m.put("message", "PING 返回异常: " + pong);
return m;
}
java.util.Properties infoProps = conn.info();
if (infoProps == null || infoProps.isEmpty()) {
m.put("status", "degraded");
m.put("message", "PING 成功但无法读取 INFO");
return m;
}
Long dbSize = conn.dbSize();
m.put("status", "ok");
m.put("redisVersion", nullToEmpty(infoProps.getProperty("redis_version")));
m.put("usedMemoryHuman", nullToEmpty(infoProps.getProperty("used_memory_human")));
m.put("keysApprox", dbSize);
} catch (Exception e) {
m.put("status", "error");
m.put("message", e.getMessage());
}
return m;
}
private static String nullToEmpty(String s) {
return s != null ? s : "";
}
private Map<String, Object> rabbitmqInfo() {
String env = appProperties.getRabbitmqEnv();
Map<String, Object> m = new LinkedHashMap<>();
m.put("enabled", appProperties.isRabbitmqEnabled());
m.put("env", env);
m.put("exchange", RabbitMQTopology.exchangeName(env));
m.put("queue", RabbitMQTopology.queueName(env));
m.put("routingKey", RabbitMQTopology.ORDER_EMAIL_ROUTING_KEY);
m.put("brokerHost", rabbitHost);
m.put("brokerPort", String.valueOf(rabbitPort));
m.put("vhost", rabbitVhost);
m.put("brokerUser", rabbitUser);
if (!appProperties.isRabbitmqEnabled()) {
m.put("status", "disabled");
return m;
}
if (!StringUtils.hasText(rabbitHost)) {
m.put("status", "misconfigured");
m.put("message", "已启用但未配置 spring.rabbitmq.host");
return m;
}
try {
int[] mqStats = rabbitTemplate.execute(channel -> {
var ok = channel.queueDeclarePassive(RabbitMQTopology.queueName(env));
return new int[]{ok.getMessageCount(), ok.getConsumerCount()};
});
if (mqStats != null && mqStats.length >= 2) {
m.put("messagesReady", mqStats[0]);
m.put("consumers", mqStats[1]);
}
m.put("status", "ok");
} catch (Exception e) {
String msg = e.getMessage() != null ? e.getMessage() : e.getClass().getSimpleName();
if (msg.contains("NOT_FOUND") || msg.contains("404")) {
m.put("status", "degraded");
m.put("message", "已连接但队列不存在或未声明: " + msg);
} else {
m.put("status", "error");
m.put("message", msg);
}
}
return m;
}
}

View File

@@ -0,0 +1,61 @@
package com.smyhub.store.mengyastorebackendjava.service;
import com.smyhub.store.mengyastorebackendjava.dto.response.WishlistResponse;
import com.smyhub.store.mengyastorebackendjava.entity.Wishlist;
import com.smyhub.store.mengyastorebackendjava.exception.BusinessException;
import com.smyhub.store.mengyastorebackendjava.repository.WishlistRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service
@RequiredArgsConstructor
public class WishlistService {
private final WishlistRepository wishlistRepository;
public List<String> getWishlistIds(String accountId) {
return wishlistRepository.findByAccountId(accountId).stream()
.map(Wishlist::getProductId)
.toList();
}
public List<WishlistResponse> getWishlist(String accountId) {
return wishlistRepository.findByAccountId(accountId).stream()
.map(this::toResponse)
.toList();
}
@Transactional
public WishlistResponse addToWishlist(String accountId, String productId) {
if (wishlistRepository.existsByAccountIdAndProductId(accountId, productId)) {
throw new BusinessException("该商品已在心愿单中");
}
try {
Wishlist saved = wishlistRepository.save(new Wishlist(accountId, productId));
return toResponse(saved);
} catch (DataIntegrityViolationException e) {
throw new BusinessException("该商品已在心愿单中");
}
}
@Transactional
public void removeFromWishlist(String accountId, String productId) {
if (!wishlistRepository.existsByAccountIdAndProductId(accountId, productId)) {
throw new BusinessException("心愿单中不存在该商品", HttpStatus.NOT_FOUND);
}
wishlistRepository.deleteByAccountIdAndProductId(accountId, productId);
}
private WishlistResponse toResponse(Wishlist w) {
return WishlistResponse.builder()
.id(w.getId())
.accountId(w.getAccountId())
.productId(w.getProductId())
.build();
}
}

View File

@@ -0,0 +1,85 @@
spring:
application:
name: mengyastore-backend-java
profiles:
active: dev
datasource:
url: jdbc:mysql://10.1.1.100:3306/mengyastore-test?charset=utf8mb4&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true
username: mengyastore-test
password: mengyastore-test
driver-class-name: com.mysql.cj.jdbc.Driver
jpa:
hibernate:
ddl-auto: update
show-sql: false
properties:
hibernate:
dialect: org.hibernate.dialect.MySQLDialect
format_sql: false
rabbitmq:
host: 10.1.1.233
port: 5672
username: admin
# 请将此密码替换为真实的 RabbitMQ 密码,然后将 app.rabbitmq-enabled 设为 true
password: shumengya520
virtual-host: mengyastore-dev
listener:
simple:
acknowledge-mode: manual
prefetch: 1
data:
redis:
host: 10.1.1.100
port: 6379
password: tyh@19900420
database: 1
timeout: 3000ms
mail:
host: smtp.qq.com
port: 465
username: ""
password: ""
properties:
mail:
smtp:
auth: true
ssl:
enable: true
socketFactory:
port: 465
class: javax.net.ssl.SSLSocketFactory
fallback: false
server:
port: 8080
app:
admin-token: shumengya520
auth-api-url: https://auth.api.shumengya.top
# 对应 Go 的 APP_ENVdevelopment | production系统状态页「应用环境」
app-env: development
# 对应 Go 的 REDIS_ENABLEDfalse 时系统状态里 Redis 为「未启用」且不再探测
redis-enabled: true
# 对应 Go 后端的 RABBITMQ_ENABLED默认关闭密码配置正确后再改为 true
rabbitmq-enabled: true
rabbitmq-env: dev
redis-env: dev
public-api-base-url: ""
cors-allowed-origins: "http://localhost:5173,http://localhost:3000,http://localhost:5174"
# SMTP 在库里配置前无用户名密码;关闭邮件健康检查避免启动后打 ERROR 日志
management:
health:
mail:
enabled: false
logging:
level:
com.smyhub.store: INFO
org.springframework.amqp: WARN
org.springframework.data.redis: WARN

View File

@@ -0,0 +1,13 @@
package com.smyhub.store.mengyastorebackendjava;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
@SpringBootTest
class MengyastoreBackendJavaApplicationTests {
@Test
void contextLoads() {
}
}

112
.java-test/后端文档.md Normal file
View File

@@ -0,0 +1,112 @@
# 萌芽小店 · 后端mengyastore-backend-java
基于 **Java 17、Spring Boot 3、Spring MVC、Spring Data JPAHibernate** 的 REST API配套 **Vue 3** 前端联调;可在 **IntelliJ IDEA** 中运行 `MengyastoreBackendJavaApplication` 进行开发与测试。
## 技术栈与 Maven 依赖
| 依赖 | 用途 |
|------|------|
| spring-boot-starter-web | REST、Jackson |
| spring-boot-starter-data-jpa | 实体、Repository、事务 |
| mysql-connector-j | MySQL JDBC |
| spring-boot-starter-amqp | RabbitMQ |
| spring-boot-starter-data-redis | Redis |
| spring-boot-starter-validation | Bean 校验 |
| spring-boot-starter-mail | Jakarta Mail业务 SMTP 配置来自数据库) |
| spring-boot-starter-actuator | 健康检查(默认关闭 Mail 指标以免未配 SMTP 报错) |
| lombok | Getter 等样板代码 |
## 包结构
```
src/main/java/com/smyhub/store/mengyastorebackendjava/
├── MengyastoreBackendJavaApplication.java
├── config/ # AppProperties、CORS、RabbitMQ、Redis、RestTemplate、WebMvc
├── controller/ # 按域拆分admin/ 为管理端
├── dto/request|response/
├── entity/ # JPAentity/converter 处理 JSON 列
├── exception/ # BusinessException、GlobalExceptionHandler
├── mq/ # 拓扑、Payload、Producer、Consumer随 app.rabbitmq-enabled
├── repository/
├── security/ # 管理令牌与用户 Bearer 拦截器、ThreadLocal 用户
└── service/ # 业务与 SystemStatusService 等
```
## API 路由一览
### 公开
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/api/health` | 健康检查;含 RabbitMQ 状态(`ok` / `error` / `disabled` |
| GET | `/api/products` | 上架商品列表(不含卡密与 `fixedContent` |
| POST | `/api/products/{id}/view` | 记录浏览(进程内去重) |
| GET | `/api/stats` | 订单数、访问量 |
| POST | `/api/site/visit` | 站点访问计数 |
| GET | `/api/site/maintenance` | 维护状态 |
| POST | `/api/checkout` | 创建订单(可选 `Authorization: Bearer` |
| POST | `/api/orders/{id}/confirm` | 确认订单 |
### 用户Bearer远程认证服务校验
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/api/orders` | 当前用户订单 |
| GET / POST | `/api/wishlist` | 列表POST body `{"productId":"…"}` |
| DELETE | `/api/wishlist/{id}` | 删除,路径参数为商品 ID |
| GET / POST | `/api/chat/messages` | 消息列表;发送(带发送频控) |
### 管理端(`X-Admin-Token`;兼容 `Authorization`、`?token=`
| 方法 | 路径 | 说明 |
|------|------|------|
| POST | `/api/admin/verify` | 请求体 `{"token":"…"}`**响应顶层** `{"valid":true/false}` |
| GET / POST / PUT / PATCH / DELETE | `/api/admin/products` | 商品 CRUD 与上下架 |
| POST | `/api/admin/site/maintenance` | 维护模式 |
| GET / POST | `/api/admin/site/smtp` | SMTP 配置读/写 |
| GET | `/api/admin/orders` | 订单列表 |
| DELETE | `/api/admin/orders/{id}` | 删除订单 |
| GET / POST / DELETE | `/api/admin/chat``/api/admin/chat/{account}` | 会话、回复、清空 |
| GET | `/api/admin/system-status` | 聚合 backend / mysql / redis / rabbitmqJSON 外层为 `{"data":{…},"error":null}` |
其它管理接口的成功响应多为 `{"data":…,"error":null}` 形式。
## 数据库表(摘要)
开发环境常用 `spring.jpa.hibernate.ddl-auto: update` 同步表结构。
**products** 商品信息;`fulfillment_type``card`(卡密库存)/ `fixed`(固定内容);`fixed_content` 不在公开列表返回;含价格、标签 JSON、封面、截图 JSON、`active``require_login``max_per_account``total_sold``view_count``delivery_mode` 等。
**product_codes** 每行一条卡密,关联 `product_id`;固定内容商品可不设。
**orders** `delivered_codes` JSON、用户信息、`status``delivery_mode``notify_email`、联系方式等。
**site_settings** 键值对,如 `totalVisits``maintenance``smtpHost``smtpPassword` 等。
**wishlists** 用户账号与商品 ID 唯一约束。
**chat_messages** 会话消息、`from_admin`、发送时间等。
## 配置说明application.yaml
| 项 | 说明 |
|----|------|
| `spring.datasource.*` | MySQL |
| `spring.jpa.hibernate.ddl-auto` | 如 `update` |
| `server.port` | HTTP 端口,需与前端 `VITE_API_BASE_URL` 一致 |
| `spring.rabbitmq.*` | Broker`app.rabbitmq-enabled=false` 时不注册监听器与拓扑,避免无法连接时启动失败 |
| `spring.data.redis.*` | Redis`app.redis-enabled=false` 时系统状态中 Redis 标记为未启用 |
| `app.admin-token` | 管理后台令牌 |
| `app.auth-api-url` | 用户 token 远程校验服务根地址(`POST …/api/auth/verify` |
| `app.app-env` | `development` / `production` |
| `app.rabbitmq-enabled` | 是否启用 RabbitMQ 发信与消费者 |
| `app.redis-enabled` | 是否在系统状态中探测 Redis |
| `app.rabbitmq-env` / `app.redis-env` | `dev` / `prod`,影响交换机与队列名 |
| `app.cors-allowed-origins` | 逗号分隔的允许源 |
| `management.health.mail.enabled` | 建议 `false` 直至配置 SMTP |
**CORS**`CorsConfig`,包含 `Authorization``X-Admin-Token` 等请求头。
## 前端联调
前端 axios `baseURL` 来自环境变量或当前页 `origin`,请指向本服务监听的 **协议 + 主机 + 端口**