chore: sync local updates
This commit is contained in:
2
.java-test/.gitattributes
vendored
Normal file
2
.java-test/.gitattributes
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
/mvnw text eol=lf
|
||||
*.cmd text eol=crlf
|
||||
33
.java-test/.gitignore
vendored
Normal file
33
.java-test/.gitignore
vendored
Normal 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/
|
||||
3
.java-test/.mvn/wrapper/maven-wrapper.properties
vendored
Normal file
3
.java-test/.mvn/wrapper/maven-wrapper.properties
vendored
Normal 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
1
.java-test/compose.yaml
Normal file
@@ -0,0 +1 @@
|
||||
services: { }
|
||||
295
.java-test/mvnw
vendored
Normal file
295
.java-test/mvnw
vendored
Normal 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
189
.java-test/mvnw.cmd
vendored
Normal 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
121
.java-test/pom.xml
Normal 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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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() : "";
|
||||
}
|
||||
}
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<>();
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.smyhub.store.mengyastorebackendjava.dto.request;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class AdminVerifyRequest {
|
||||
private String token;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.smyhub.store.mengyastorebackendjava.dto.request;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class MaintenanceRequest {
|
||||
private boolean enabled;
|
||||
private String reason = "";
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.smyhub.store.mengyastorebackendjava.dto.request;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class ProductStatusRequest {
|
||||
private boolean active;
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<>();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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("服务器内部错误"));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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> {
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.smyhub.store.mengyastorebackendjava.security;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class SproutGateVerifyResult {
|
||||
|
||||
private boolean valid;
|
||||
|
||||
private SproutGateUser user;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
85
.java-test/src/main/resources/application.yaml
Normal file
85
.java-test/src/main/resources/application.yaml
Normal 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_ENV:development | production(系统状态页「应用环境」)
|
||||
app-env: development
|
||||
# 对应 Go 的 REDIS_ENABLED;false 时系统状态里 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
|
||||
@@ -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
112
.java-test/后端文档.md
Normal file
@@ -0,0 +1,112 @@
|
||||
# 萌芽小店 · 后端(mengyastore-backend-java)
|
||||
|
||||
基于 **Java 17、Spring Boot 3、Spring MVC、Spring Data JPA(Hibernate)** 的 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/ # JPA;entity/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 / rabbitmq;JSON 外层为 `{"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`,请指向本服务监听的 **协议 + 主机 + 端口**。
|
||||
@@ -1,9 +1,9 @@
|
||||
.git
|
||||
.gitignore
|
||||
node_modules
|
||||
dist
|
||||
debug-logs
|
||||
*.exe
|
||||
*.exe~
|
||||
# 勿忽略 dist/:Dockerfile 需 COPY dist/mengyastore-backend-linux-amd64(先在 Windows 运行 dist\build-linux-amd64.bat)
|
||||
Dockerfile
|
||||
docker-compose.yml
|
||||
|
||||
3
mengyastore-backend-go/.gitignore
vendored
3
mengyastore-backend-go/.gitignore
vendored
@@ -4,3 +4,6 @@
|
||||
.env.local
|
||||
.env.production
|
||||
*.exe
|
||||
|
||||
# Windows 本地交叉编译产物(部署前生成,勿提交)
|
||||
dist/mengyastore-backend-linux-amd64
|
||||
|
||||
@@ -1,20 +1,14 @@
|
||||
FROM golang:1.24-alpine AS builder
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -o /out/mengyastore-backend .
|
||||
|
||||
# 运行镜像仅包含 Alpine + 预编译二进制。
|
||||
# 请在 Windows 上先执行 dist\build-linux-amd64.bat,生成 dist/mengyastore-backend-linux-amd64,
|
||||
# 再将本目录同步到服务器并在服务器执行 docker build(无需在服务器安装 Go)。
|
||||
FROM alpine:3.20
|
||||
|
||||
RUN apk add --no-cache ca-certificates tzdata
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /out/mengyastore-backend ./mengyastore-backend
|
||||
COPY dist/mengyastore-backend-linux-amd64 ./mengyastore-backend
|
||||
RUN chmod +x ./mengyastore-backend
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
.PHONY: swagger
|
||||
swagger:
|
||||
swag init -g main.go -o docs --parseDependency --parseInternal
|
||||
go run github.com/swaggo/swag/cmd/swag@v1.8.12 init -g main.go -o docs --parseDependency --parseInternal
|
||||
|
||||
@@ -1,3 +1,15 @@
|
||||
# 构建镜像前在 Windows 执行: dist\build-linux-amd64.bat
|
||||
# 再放入 dist/mengyastore-backend-linux-amd64 后构建(服务器仅需 Docker,无需 Go)
|
||||
#
|
||||
# 与本地 dev.bat 使用同一套库:
|
||||
# - Compose 会自动读取本目录下的 .env(若存在),用于 ${VAR} 替换并传入容器。
|
||||
# - 将本地 mengyastore-backend-go/.env.development 复制到服务器本目录,命名为 .env;
|
||||
# 或至少设置与开发机相同的 DATABASE_DSN。
|
||||
# - 默认 APP_ENV=development:与 go run 且未设 APP_ENV 时一致(空 DSN → 代码内 TestDSN)。
|
||||
# 正式上生产请在 .env 中写 APP_ENV=production。
|
||||
#
|
||||
# 前端「样式和本地 npm run dev 不一样」:部署一般是 npm run build 的产出,经 Nginx 缓存;
|
||||
# 换文案/样式后需重新构建前端并强刷或清理 Service Worker(PWA)。
|
||||
services:
|
||||
backend:
|
||||
build:
|
||||
@@ -6,11 +18,9 @@ services:
|
||||
ports:
|
||||
- "28081:8080"
|
||||
environment:
|
||||
# 生产环境(本地开发请使用 APP_ENV=development + 根目录 .env.development,或 ENV_FILE)
|
||||
APP_ENV: production
|
||||
GIN_MODE: release
|
||||
APP_ENV: ${APP_ENV:-development}
|
||||
GIN_MODE: ${GIN_MODE:-release}
|
||||
TZ: Asia/Shanghai
|
||||
# 通过宿主机 .env 或 export 注入,勿在仓库中写真实 DSN/口令
|
||||
DATABASE_DSN: ${DATABASE_DSN:-}
|
||||
ADMIN_TOKEN: ${ADMIN_TOKEN:-changeme}
|
||||
AUTH_API_URL: ${AUTH_API_URL:-}
|
||||
@@ -19,6 +29,6 @@ services:
|
||||
REDIS_PASSWORD: ${REDIS_PASSWORD:-}
|
||||
REDIS_DB: ${REDIS_DB:-1}
|
||||
RABBITMQ_ENABLED: "false"
|
||||
RABBITMQ_ENV: prod
|
||||
RABBITMQ_URL: ${RABBITMQ_URL}
|
||||
RABBITMQ_ENV: ${RABBITMQ_ENV:-dev}
|
||||
RABBITMQ_URL: ${RABBITMQ_URL:-}
|
||||
restart: unless-stopped
|
||||
|
||||
@@ -18,14 +18,14 @@ const docTemplate = `{
|
||||
"paths": {
|
||||
"/": {
|
||||
"get": {
|
||||
"description": "返回服务描述、主要路径指引与版本(非 OpenAPI 详尽列表,完整契约见 /swagger)。",
|
||||
"description": "访问服务根路径 ` + "`" + `/` + "`" + ` 返回 JSON:服务简介、本地与生产环境说明、主要业务路由分组、运行版本与时间戳。完整请求/响应模型请使用 Swagger UI:` + "`" + `GET /swagger/index.html` + "`" + `。本地一般为 ` + "`" + `http://localhost:8080/` + "`" + `,生产域名为 ` + "`" + `https://store.shumengya.top/` + "`" + `。",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"元信息"
|
||||
],
|
||||
"summary": "API 根信息与端点索引",
|
||||
"summary": "根路径 API 说明与路由索引",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
@@ -44,13 +44,14 @@ const docTemplate = `{
|
||||
"AdminToken": []
|
||||
}
|
||||
],
|
||||
"description": "返回每个有过消息的用户账号等信息,供客服选择会话;需管理令牌 ` + "`" + `X-Admin-Token` + "`" + `。",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"管理端-聊天"
|
||||
],
|
||||
"summary": "全部会话列表",
|
||||
"summary": "管理端:列出全部聊天会话",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
@@ -81,17 +82,18 @@ const docTemplate = `{
|
||||
"AdminToken": []
|
||||
}
|
||||
],
|
||||
"description": "路径参数 ` + "`" + `account` + "`" + ` 为用户账号标识;返回按时间排列的消息数组;需管理令牌。",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"管理端-聊天"
|
||||
],
|
||||
"summary": "指定用户聊天记录",
|
||||
"summary": "管理端:查看某用户的完整聊天记录",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "用户账号",
|
||||
"description": "用户账号(路径)",
|
||||
"name": "account",
|
||||
"in": "path",
|
||||
"required": true
|
||||
@@ -130,6 +132,7 @@ const docTemplate = `{
|
||||
"AdminToken": []
|
||||
}
|
||||
],
|
||||
"description": "路径为对方账号,JSON body 含 ` + "`" + `content` + "`" + `;写入后用户侧拉取可见。",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -139,17 +142,17 @@ const docTemplate = `{
|
||||
"tags": [
|
||||
"管理端-聊天"
|
||||
],
|
||||
"summary": "管理员回复",
|
||||
"summary": "管理端:向用户回复一条消息",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "用户账号",
|
||||
"description": "用户账号(路径)",
|
||||
"name": "account",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"description": "回复内容",
|
||||
"description": "回复正文 JSON",
|
||||
"name": "body",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
@@ -191,17 +194,18 @@ const docTemplate = `{
|
||||
"AdminToken": []
|
||||
}
|
||||
],
|
||||
"description": "删除该账号在系统中的全部聊天消息记录;不可恢复请谨慎使用。",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"管理端-聊天"
|
||||
],
|
||||
"summary": "清空会话",
|
||||
"summary": "管理端:清空与某用户的会话",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "用户账号",
|
||||
"description": "用户账号(路径)",
|
||||
"name": "account",
|
||||
"in": "path",
|
||||
"required": true
|
||||
@@ -242,13 +246,14 @@ const docTemplate = `{
|
||||
"AdminToken": []
|
||||
}
|
||||
],
|
||||
"description": "返回数据库中订单全量列表(含敏感字段),供后台管理;需 ` + "`" + `X-Admin-Token` + "`" + `。",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"管理端-订单"
|
||||
],
|
||||
"summary": "全部订单(管理)",
|
||||
"summary": "管理端获取全部订单",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
@@ -278,17 +283,18 @@ const docTemplate = `{
|
||||
"AdminToken": []
|
||||
}
|
||||
],
|
||||
"description": "按路径中的订单 ID 删除记录;不可恢复请谨慎操作。",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"管理端-订单"
|
||||
],
|
||||
"summary": "删除订单",
|
||||
"summary": "管理端删除指定订单",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "订单 ID",
|
||||
"description": "订单ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
@@ -329,13 +335,14 @@ const docTemplate = `{
|
||||
"AdminToken": []
|
||||
}
|
||||
],
|
||||
"description": "含下架商品、卡密字段等完整数据,仅限管理令牌访问。需在请求头携带 ` + "`" + `X-Admin-Token` + "`" + `。",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"管理端-商品"
|
||||
],
|
||||
"summary": "全部商品(管理)",
|
||||
"summary": "管理端获取全部商品列表",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
@@ -363,6 +370,7 @@ const docTemplate = `{
|
||||
"AdminToken": []
|
||||
}
|
||||
],
|
||||
"description": "提交完整商品载荷:价格、卡密/固定内容、收款码链接、是否上架等。校验失败返回 400。",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -372,10 +380,10 @@ const docTemplate = `{
|
||||
"tags": [
|
||||
"管理端-商品"
|
||||
],
|
||||
"summary": "创建商品",
|
||||
"summary": "管理端创建商品",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "商品字段",
|
||||
"description": "商品各字段(含可选卡密、截图、收款码等)",
|
||||
"name": "body",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
@@ -419,6 +427,7 @@ const docTemplate = `{
|
||||
"AdminToken": []
|
||||
}
|
||||
],
|
||||
"description": "路径参数为商品 ID,请求体为整包覆盖式更新(以服务端绑定逻辑为准)。未找到则 404。",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -428,17 +437,17 @@ const docTemplate = `{
|
||||
"tags": [
|
||||
"管理端-商品"
|
||||
],
|
||||
"summary": "更新商品",
|
||||
"summary": "管理端更新商品",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "商品 ID",
|
||||
"description": "商品ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"description": "商品字段",
|
||||
"description": "待写入的商品字段",
|
||||
"name": "body",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
@@ -486,17 +495,18 @@ const docTemplate = `{
|
||||
"AdminToken": []
|
||||
}
|
||||
],
|
||||
"description": "按 ID 永久删除商品记录(影响以数据库外键/业务逻辑为准),需管理令牌。",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"管理端-商品"
|
||||
],
|
||||
"summary": "删除商品",
|
||||
"summary": "管理端删除商品",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "商品 ID",
|
||||
"description": "商品ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
@@ -531,6 +541,7 @@ const docTemplate = `{
|
||||
"AdminToken": []
|
||||
}
|
||||
],
|
||||
"description": "PATCH 请求体为 ` + "`" + `{ \"active\": true|false }` + "`" + `,用于快速上下架而不改其它字段。",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -540,17 +551,17 @@ const docTemplate = `{
|
||||
"tags": [
|
||||
"管理端-商品"
|
||||
],
|
||||
"summary": "切换上架状态",
|
||||
"summary": "管理端切换商品上架状态",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "商品 ID",
|
||||
"description": "商品ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"description": "{active}",
|
||||
"description": "上架开关 { \\",
|
||||
"name": "body",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
@@ -594,6 +605,7 @@ const docTemplate = `{
|
||||
"AdminToken": []
|
||||
}
|
||||
],
|
||||
"description": "打开维护后前台可展示原因文案;需管理令牌。请求体含 ` + "`" + `maintenance` + "`" + ` 布尔与可选 ` + "`" + `reason` + "`" + `。",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -603,10 +615,10 @@ const docTemplate = `{
|
||||
"tags": [
|
||||
"管理端-站点"
|
||||
],
|
||||
"summary": "设置维护模式",
|
||||
"summary": "设置全站维护模式",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "维护开关与原因",
|
||||
"description": "maintenance 与 reason",
|
||||
"name": "body",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
@@ -650,13 +662,14 @@ const docTemplate = `{
|
||||
"AdminToken": []
|
||||
}
|
||||
],
|
||||
"description": "返回当前站点发件配置;密码字段以占位符脱敏显示,不会返回明文。",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"管理端-站点"
|
||||
],
|
||||
"summary": "获取 SMTP 配置",
|
||||
"summary": "获取发信 SMTP 配置(脱敏)",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
@@ -684,6 +697,7 @@ const docTemplate = `{
|
||||
"AdminToken": []
|
||||
}
|
||||
],
|
||||
"description": "写入 SMTP 主机、端口、账号等;若密码仍为脱敏占位符则保留库中已有密码(实现细节见服务逻辑)。",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -693,10 +707,10 @@ const docTemplate = `{
|
||||
"tags": [
|
||||
"管理端-站点"
|
||||
],
|
||||
"summary": "保存 SMTP 配置",
|
||||
"summary": "保存发信 SMTP 配置",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "SMTP 字段",
|
||||
"description": "SMTP 连接与发件人信息",
|
||||
"name": "body",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
@@ -740,13 +754,14 @@ const docTemplate = `{
|
||||
"AdminToken": []
|
||||
}
|
||||
],
|
||||
"description": "聚合展示配置摘要、数据库/MySQL、RabbitMQ、Redis 等探测结果与进程启动时间,便于运维排查;需管理令牌。",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"管理端-系统"
|
||||
],
|
||||
"summary": "系统运行状态",
|
||||
"summary": "管理端系统运行状态与依赖探活",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
@@ -766,7 +781,7 @@ const docTemplate = `{
|
||||
},
|
||||
"/api/admin/verify": {
|
||||
"post": {
|
||||
"description": "响应为 {\"valid\": true/false},不泄露真实口令。",
|
||||
"description": "请求体 JSON 含 ` + "`" + `token` + "`" + ` 字段;响应仅返回 ` + "`" + `{\"valid\": true|false}` + "`" + `,不会返回真实口令或敏感信息。",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -776,10 +791,10 @@ const docTemplate = `{
|
||||
"tags": [
|
||||
"管理端-认证"
|
||||
],
|
||||
"summary": "校验管理员令牌",
|
||||
"summary": "校验管理端令牌是否有效",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "待校验 token",
|
||||
"description": "待校验的管理员 token(JSON)",
|
||||
"name": "body",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
@@ -805,13 +820,14 @@ const docTemplate = `{
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "需在请求头携带有效的 ` + "`" + `Authorization: Bearer \u003ctoken\u003e` + "`" + `。返回该登录用户在客服系统中与管理员往来的全部消息列表。",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"聊天(用户)"
|
||||
],
|
||||
"summary": "我的聊天消息",
|
||||
"summary": "拉取当前用户聊天记录",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
@@ -839,6 +855,7 @@ const docTemplate = `{
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "需在请求头携带 Bearer。正文为 JSON ` + "`" + `content` + "`" + ` 字段;可能因频率限制返回 429。",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -848,10 +865,10 @@ const docTemplate = `{
|
||||
"tags": [
|
||||
"聊天(用户)"
|
||||
],
|
||||
"summary": "发送用户消息",
|
||||
"summary": "用户发送聊天消息",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "消息正文",
|
||||
"description": "消息正文(JSON)",
|
||||
"name": "body",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
@@ -896,6 +913,7 @@ const docTemplate = `{
|
||||
},
|
||||
"/api/checkout": {
|
||||
"post": {
|
||||
"description": "根据商品配置决定是否需要登录(` + "`" + `requireLogin` + "`" + `)。可附带 ` + "`" + `Authorization: Bearer` + "`" + ` 以关联账号、限购与通知邮箱。付费订单走萌芽支付时需商品已配置收款码;同一商品在他人待支付锁定期内可能返回 409;同一账号存在待支付单时也可能冲突。成功返回订单概要及二维码链接等。",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -905,16 +923,16 @@ const docTemplate = `{
|
||||
"tags": [
|
||||
"订单"
|
||||
],
|
||||
"summary": "结账创建订单",
|
||||
"summary": "结账并创建订单",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Bearer 用户 token(部分商品必填)",
|
||||
"description": "可选;格式 Bearer + 空格 + token,部分商品必填",
|
||||
"name": "Authorization",
|
||||
"in": "header"
|
||||
},
|
||||
{
|
||||
"description": "结账参数",
|
||||
"description": "结账请求体:商品、数量、联系方式等",
|
||||
"name": "body",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
@@ -949,7 +967,7 @@ const docTemplate = `{
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "库存锁定冲突或同用户待支付",
|
||||
"description": "库存锁定冲突(他人正在支付)或同账号待支付单未满间隔",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/handlers.SwaggerErrorBody"
|
||||
}
|
||||
@@ -965,6 +983,7 @@ const docTemplate = `{
|
||||
},
|
||||
"/api/health": {
|
||||
"get": {
|
||||
"description": "用于负载均衡或编排探活:` + "`" + `status` + "`" + ` 恒为 ok 表示 HTTP 服务存活;` + "`" + `rabbitmq` + "`" + ` 为 ` + "`" + `disabled` + "`" + `(未启用消息队列客户端)、` + "`" + `ok` + "`" + `(连接可用)或以 ` + "`" + `error:` + "`" + ` 开头的错误片段。",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -974,7 +993,7 @@ const docTemplate = `{
|
||||
"summary": "健康检查",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "status ok;rabbitmq 为 disabled|ok|error:...",
|
||||
"description": "status 为 ok;rabbitmq 为 disabled、ok 或 error: 前缀错误信息",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
@@ -990,13 +1009,14 @@ const docTemplate = `{
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "必须携带有效 Bearer。列表中处于待支付状态的订单不会包含已分配卡密等敏感字段,防止泄露。",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"订单"
|
||||
],
|
||||
"summary": "我的订单",
|
||||
"summary": "查询当前登录用户的订单列表",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
@@ -1021,17 +1041,18 @@ const docTemplate = `{
|
||||
},
|
||||
"/api/orders/{id}/cancel": {
|
||||
"post": {
|
||||
"description": "仅对允许取消的状态生效(如待支付);已完成订单会冲突。成功时释放预留库存/卡密占用。请勿在公网暴露此能力时省略鉴权策略(实现以代码为准)。",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"订单"
|
||||
],
|
||||
"summary": "取消订单",
|
||||
"summary": "取消待支付订单",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "订单 ID",
|
||||
"description": "订单ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
@@ -1067,17 +1088,18 @@ const docTemplate = `{
|
||||
},
|
||||
"/api/orders/{id}/confirm": {
|
||||
"post": {
|
||||
"description": "用于用户侧「确认」动作:已完成订单直接返回数据;待支付时若仍未到账则 409;已取消或超时 410。手动发货等场景下会触发邮件通知(若配置)。",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"订单"
|
||||
],
|
||||
"summary": "确认订单",
|
||||
"summary": "确认订单(核销/完成流程)",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "订单 ID",
|
||||
"description": "订单ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
@@ -1097,13 +1119,13 @@ const docTemplate = `{
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "待支付未到账等",
|
||||
"description": "待支付但金额未核对到账等",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/handlers.SwaggerErrorBody"
|
||||
}
|
||||
},
|
||||
"410": {
|
||||
"description": "已取消或超时",
|
||||
"description": "订单已取消或已超过待支付时限",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/handlers.SwaggerErrorBody"
|
||||
}
|
||||
@@ -1113,17 +1135,18 @@ const docTemplate = `{
|
||||
},
|
||||
"/api/orders/{id}/payment-status": {
|
||||
"get": {
|
||||
"description": "供前端轮询待支付订单:返回状态、应付快照、过期时间等;不包含卡密等敏感发货内容。路径参数为订单 ID。",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"订单"
|
||||
],
|
||||
"summary": "订单支付状态",
|
||||
"summary": "查询订单支付状态(轮询)",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "订单 ID",
|
||||
"description": "订单ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
@@ -1147,13 +1170,14 @@ const docTemplate = `{
|
||||
},
|
||||
"/api/products": {
|
||||
"get": {
|
||||
"description": "返回当前处于「上架」状态的商品集合,仅包含前台展示所需字段(不含卡密、管理字段等)。无需登录。",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"公开"
|
||||
],
|
||||
"summary": "上架商品列表",
|
||||
"summary": "获取上架商品列表",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
@@ -1172,17 +1196,18 @@ const docTemplate = `{
|
||||
},
|
||||
"/api/products/{id}/view": {
|
||||
"post": {
|
||||
"description": "对指定商品增加浏览计数;是否计入由服务端对访客指纹去重策略决定,用于热门统计。路径参数为商品 ID。",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"公开"
|
||||
],
|
||||
"summary": "记录商品浏览",
|
||||
"summary": "记录商品浏览次数",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "商品 ID",
|
||||
"description": "商品ID(路径)",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
@@ -1206,13 +1231,14 @@ const docTemplate = `{
|
||||
},
|
||||
"/api/site/maintenance": {
|
||||
"get": {
|
||||
"description": "返回当前是否开启全站维护、以及展示给前端的维护原因文案(公开接口,无需管理令牌)。",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"公开"
|
||||
],
|
||||
"summary": "维护模式状态",
|
||||
"summary": "获取维护模式状态",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
@@ -1231,13 +1257,14 @@ const docTemplate = `{
|
||||
},
|
||||
"/api/site/visit": {
|
||||
"post": {
|
||||
"description": "记录访客一次访问并累加全站访问计数;是否计入由服务端指纹等策略决定,响应中含最新总访问量与本次是否计入。",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"公开"
|
||||
],
|
||||
"summary": "记录站点访问",
|
||||
"summary": "上报一次站点访问",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
@@ -1256,13 +1283,14 @@ const docTemplate = `{
|
||||
},
|
||||
"/api/stats": {
|
||||
"get": {
|
||||
"description": "返回全站累计订单数与累计访问量(公开读,用于首页展示等)。",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"公开"
|
||||
],
|
||||
"summary": "站点统计",
|
||||
"summary": "获取站点聚合统计",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
@@ -1281,6 +1309,7 @@ const docTemplate = `{
|
||||
},
|
||||
"/api/webhooks/mengya-pay": {
|
||||
"post": {
|
||||
"description": "由支付渠道/转发服务 POST 原始 JSON;服务端解析到账金额并尝试匹配待支付订单。若配置 ` + "`" + `WEBHOOK_MENGYA_SECRET` + "`" + `,请求头 ` + "`" + `X-Webhook-Secret` + "`" + ` 必须与其一致。本地联调与生产 ` + "`" + `https://store.shumengya.top` + "`" + ` 均使用同一路径,由部署时的域名与 TLS 决定回调 URL。",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -1290,11 +1319,11 @@ const docTemplate = `{
|
||||
"tags": [
|
||||
"Webhook"
|
||||
],
|
||||
"summary": "萌芽支付 Webhook",
|
||||
"summary": "萌芽支付到账 Webhook",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "与 WEBHOOK_MENGYA_SECRET 一致",
|
||||
"description": "与进程环境变量 WEBHOOK_MENGYA_SECRET 一致;未配置密钥时可不填",
|
||||
"name": "X-Webhook-Secret",
|
||||
"in": "header"
|
||||
}
|
||||
@@ -1334,13 +1363,14 @@ const docTemplate = `{
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "需 Bearer 登录。返回该账号已收藏的商品 ID 数组(顺序由存储实现决定)。",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"收藏"
|
||||
],
|
||||
"summary": "收藏列表",
|
||||
"summary": "获取当前用户收藏列表",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
@@ -1368,6 +1398,7 @@ const docTemplate = `{
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "需 Bearer。请求体为 ` + "`" + `{ \"productId\": \"...\" }` + "`" + `;幂等语义由存储层实现(重复添加可忽略或报错以实际为准)。",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -1377,10 +1408,10 @@ const docTemplate = `{
|
||||
"tags": [
|
||||
"收藏"
|
||||
],
|
||||
"summary": "添加收藏",
|
||||
"summary": "添加商品到收藏",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "商品 ID",
|
||||
"description": "包含 productId 的 JSON",
|
||||
"name": "body",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
@@ -1424,17 +1455,18 @@ const docTemplate = `{
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "需 Bearer。路径参数为要移除的商品 ID。",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"收藏"
|
||||
],
|
||||
"summary": "移除收藏",
|
||||
"summary": "从收藏中移除商品",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "商品 ID",
|
||||
"description": "商品ID(路径)",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
@@ -2156,19 +2188,19 @@ const docTemplate = `{
|
||||
},
|
||||
"securityDefinitions": {
|
||||
"AdminToken": {
|
||||
"description": "管理端令牌(也可使用 Authorization 头或 query token,见各接口说明)",
|
||||
"description": "管理端访问令牌:与进程环境变量 ` + "`" + `ADMIN_TOKEN` + "`" + ` 一致。部分管理路由也可使用 ` + "`" + `Authorization` + "`" + ` 头或 Query ` + "`" + `token` + "`" + `(以具体路由实现为准)。",
|
||||
"type": "apiKey",
|
||||
"name": "X-Admin-Token",
|
||||
"in": "header"
|
||||
},
|
||||
"BearerAuth": {
|
||||
"description": "用户访问令牌,格式: Bearer 空格 + token",
|
||||
"description": "用户访问令牌:请求头 ` + "`" + `Authorization` + "`" + `,值为 ` + "`" + `Bearer ` + "`" + ` 后接 SproutGate 返回的 JWT/access token(部分公开接口可不携带)。",
|
||||
"type": "apiKey",
|
||||
"name": "Authorization",
|
||||
"in": "header"
|
||||
},
|
||||
"WebhookSecret": {
|
||||
"description": "与服务端 WEBHOOK_MENGYA_SECRET 一致时校验萌芽支付 Webhook",
|
||||
"description": "当服务端配置了 ` + "`" + `WEBHOOK_MENGYA_SECRET` + "`" + ` 时,萌芽支付到账回调 ` + "`" + `POST /api/webhooks/mengya-pay` + "`" + ` 需携带本头且值与密钥完全一致,否则返回 403。",
|
||||
"type": "apiKey",
|
||||
"name": "X-Webhook-Secret",
|
||||
"in": "header"
|
||||
@@ -2183,7 +2215,7 @@ var SwaggerInfo = &swag.Spec{
|
||||
BasePath: "/",
|
||||
Schemes: []string{"http", "https"},
|
||||
Title: "萌芽小店 API",
|
||||
Description: "商品、下单、站点统计、收藏与客服聊天;用户登录由萌芽账户认证中心(SproutGate)校验。监听地址以 HTTP_LISTEN_ADDR 为准。",
|
||||
Description: "萌芽小店电商后端 HTTP API:商品、下单结账、订单与支付、站点统计与访问、维护模式、收藏、用户/管理员聊天、萌芽支付 Webhook,以及管理端商品/订单/站点与运维状态等。用户身份由萌芽账户认证中心(SproutGate)签发令牌并完成校验。\n\n**运行环境与基地址**\n- **本地开发**:监听地址由环境变量 `HTTP_LISTEN_ADDR` 决定,未设置时默认为 `:8080`,即常见入口 `http://localhost:8080`。可在浏览器打开 `http://localhost:8080/swagger/index.html` 查看本页同款文档并调试。\n- **生产部署**:线上前台/API 域名为 `https://store.shumengya.top`(HTTPS)。若前面还有网关、路径前缀或端口映射,请在拼接请求 URL 时以实际对外发布地址为准;Swagger 中默认 Host 为本地,联调线上时请将浏览器地址或客户端 Base URL 换成生产基地址。\n\n**说明**:本 OpenAPI 由代码注释生成;与进程真实监听、反向代理头无关,仅作契约参考。",
|
||||
InfoInstanceName: "swagger",
|
||||
SwaggerTemplate: docTemplate,
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
],
|
||||
"swagger": "2.0",
|
||||
"info": {
|
||||
"description": "商品、下单、站点统计、收藏与客服聊天;用户登录由萌芽账户认证中心(SproutGate)校验。监听地址以 HTTP_LISTEN_ADDR 为准。",
|
||||
"description": "萌芽小店电商后端 HTTP API:商品、下单结账、订单与支付、站点统计与访问、维护模式、收藏、用户/管理员聊天、萌芽支付 Webhook,以及管理端商品/订单/站点与运维状态等。用户身份由萌芽账户认证中心(SproutGate)签发令牌并完成校验。\n\n**运行环境与基地址**\n- **本地开发**:监听地址由环境变量 `HTTP_LISTEN_ADDR` 决定,未设置时默认为 `:8080`,即常见入口 `http://localhost:8080`。可在浏览器打开 `http://localhost:8080/swagger/index.html` 查看本页同款文档并调试。\n- **生产部署**:线上前台/API 域名为 `https://store.shumengya.top`(HTTPS)。若前面还有网关、路径前缀或端口映射,请在拼接请求 URL 时以实际对外发布地址为准;Swagger 中默认 Host 为本地,联调线上时请将浏览器地址或客户端 Base URL 换成生产基地址。\n\n**说明**:本 OpenAPI 由代码注释生成;与进程真实监听、反向代理头无关,仅作契约参考。",
|
||||
"title": "萌芽小店 API",
|
||||
"contact": {},
|
||||
"version": "1.0.0-go"
|
||||
@@ -15,14 +15,14 @@
|
||||
"paths": {
|
||||
"/": {
|
||||
"get": {
|
||||
"description": "返回服务描述、主要路径指引与版本(非 OpenAPI 详尽列表,完整契约见 /swagger)。",
|
||||
"description": "访问服务根路径 `/` 返回 JSON:服务简介、本地与生产环境说明、主要业务路由分组、运行版本与时间戳。完整请求/响应模型请使用 Swagger UI:`GET /swagger/index.html`。本地一般为 `http://localhost:8080/`,生产域名为 `https://store.shumengya.top/`。",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"元信息"
|
||||
],
|
||||
"summary": "API 根信息与端点索引",
|
||||
"summary": "根路径 API 说明与路由索引",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
@@ -41,13 +41,14 @@
|
||||
"AdminToken": []
|
||||
}
|
||||
],
|
||||
"description": "返回每个有过消息的用户账号等信息,供客服选择会话;需管理令牌 `X-Admin-Token`。",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"管理端-聊天"
|
||||
],
|
||||
"summary": "全部会话列表",
|
||||
"summary": "管理端:列出全部聊天会话",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
@@ -78,17 +79,18 @@
|
||||
"AdminToken": []
|
||||
}
|
||||
],
|
||||
"description": "路径参数 `account` 为用户账号标识;返回按时间排列的消息数组;需管理令牌。",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"管理端-聊天"
|
||||
],
|
||||
"summary": "指定用户聊天记录",
|
||||
"summary": "管理端:查看某用户的完整聊天记录",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "用户账号",
|
||||
"description": "用户账号(路径)",
|
||||
"name": "account",
|
||||
"in": "path",
|
||||
"required": true
|
||||
@@ -127,6 +129,7 @@
|
||||
"AdminToken": []
|
||||
}
|
||||
],
|
||||
"description": "路径为对方账号,JSON body 含 `content`;写入后用户侧拉取可见。",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -136,17 +139,17 @@
|
||||
"tags": [
|
||||
"管理端-聊天"
|
||||
],
|
||||
"summary": "管理员回复",
|
||||
"summary": "管理端:向用户回复一条消息",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "用户账号",
|
||||
"description": "用户账号(路径)",
|
||||
"name": "account",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"description": "回复内容",
|
||||
"description": "回复正文 JSON",
|
||||
"name": "body",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
@@ -188,17 +191,18 @@
|
||||
"AdminToken": []
|
||||
}
|
||||
],
|
||||
"description": "删除该账号在系统中的全部聊天消息记录;不可恢复请谨慎使用。",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"管理端-聊天"
|
||||
],
|
||||
"summary": "清空会话",
|
||||
"summary": "管理端:清空与某用户的会话",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "用户账号",
|
||||
"description": "用户账号(路径)",
|
||||
"name": "account",
|
||||
"in": "path",
|
||||
"required": true
|
||||
@@ -239,13 +243,14 @@
|
||||
"AdminToken": []
|
||||
}
|
||||
],
|
||||
"description": "返回数据库中订单全量列表(含敏感字段),供后台管理;需 `X-Admin-Token`。",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"管理端-订单"
|
||||
],
|
||||
"summary": "全部订单(管理)",
|
||||
"summary": "管理端获取全部订单",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
@@ -275,17 +280,18 @@
|
||||
"AdminToken": []
|
||||
}
|
||||
],
|
||||
"description": "按路径中的订单 ID 删除记录;不可恢复请谨慎操作。",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"管理端-订单"
|
||||
],
|
||||
"summary": "删除订单",
|
||||
"summary": "管理端删除指定订单",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "订单 ID",
|
||||
"description": "订单ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
@@ -326,13 +332,14 @@
|
||||
"AdminToken": []
|
||||
}
|
||||
],
|
||||
"description": "含下架商品、卡密字段等完整数据,仅限管理令牌访问。需在请求头携带 `X-Admin-Token`。",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"管理端-商品"
|
||||
],
|
||||
"summary": "全部商品(管理)",
|
||||
"summary": "管理端获取全部商品列表",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
@@ -360,6 +367,7 @@
|
||||
"AdminToken": []
|
||||
}
|
||||
],
|
||||
"description": "提交完整商品载荷:价格、卡密/固定内容、收款码链接、是否上架等。校验失败返回 400。",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -369,10 +377,10 @@
|
||||
"tags": [
|
||||
"管理端-商品"
|
||||
],
|
||||
"summary": "创建商品",
|
||||
"summary": "管理端创建商品",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "商品字段",
|
||||
"description": "商品各字段(含可选卡密、截图、收款码等)",
|
||||
"name": "body",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
@@ -416,6 +424,7 @@
|
||||
"AdminToken": []
|
||||
}
|
||||
],
|
||||
"description": "路径参数为商品 ID,请求体为整包覆盖式更新(以服务端绑定逻辑为准)。未找到则 404。",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -425,17 +434,17 @@
|
||||
"tags": [
|
||||
"管理端-商品"
|
||||
],
|
||||
"summary": "更新商品",
|
||||
"summary": "管理端更新商品",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "商品 ID",
|
||||
"description": "商品ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"description": "商品字段",
|
||||
"description": "待写入的商品字段",
|
||||
"name": "body",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
@@ -483,17 +492,18 @@
|
||||
"AdminToken": []
|
||||
}
|
||||
],
|
||||
"description": "按 ID 永久删除商品记录(影响以数据库外键/业务逻辑为准),需管理令牌。",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"管理端-商品"
|
||||
],
|
||||
"summary": "删除商品",
|
||||
"summary": "管理端删除商品",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "商品 ID",
|
||||
"description": "商品ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
@@ -528,6 +538,7 @@
|
||||
"AdminToken": []
|
||||
}
|
||||
],
|
||||
"description": "PATCH 请求体为 `{ \"active\": true|false }`,用于快速上下架而不改其它字段。",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -537,17 +548,17 @@
|
||||
"tags": [
|
||||
"管理端-商品"
|
||||
],
|
||||
"summary": "切换上架状态",
|
||||
"summary": "管理端切换商品上架状态",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "商品 ID",
|
||||
"description": "商品ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"description": "{active}",
|
||||
"description": "上架开关 { \\",
|
||||
"name": "body",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
@@ -591,6 +602,7 @@
|
||||
"AdminToken": []
|
||||
}
|
||||
],
|
||||
"description": "打开维护后前台可展示原因文案;需管理令牌。请求体含 `maintenance` 布尔与可选 `reason`。",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -600,10 +612,10 @@
|
||||
"tags": [
|
||||
"管理端-站点"
|
||||
],
|
||||
"summary": "设置维护模式",
|
||||
"summary": "设置全站维护模式",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "维护开关与原因",
|
||||
"description": "maintenance 与 reason",
|
||||
"name": "body",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
@@ -647,13 +659,14 @@
|
||||
"AdminToken": []
|
||||
}
|
||||
],
|
||||
"description": "返回当前站点发件配置;密码字段以占位符脱敏显示,不会返回明文。",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"管理端-站点"
|
||||
],
|
||||
"summary": "获取 SMTP 配置",
|
||||
"summary": "获取发信 SMTP 配置(脱敏)",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
@@ -681,6 +694,7 @@
|
||||
"AdminToken": []
|
||||
}
|
||||
],
|
||||
"description": "写入 SMTP 主机、端口、账号等;若密码仍为脱敏占位符则保留库中已有密码(实现细节见服务逻辑)。",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -690,10 +704,10 @@
|
||||
"tags": [
|
||||
"管理端-站点"
|
||||
],
|
||||
"summary": "保存 SMTP 配置",
|
||||
"summary": "保存发信 SMTP 配置",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "SMTP 字段",
|
||||
"description": "SMTP 连接与发件人信息",
|
||||
"name": "body",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
@@ -737,13 +751,14 @@
|
||||
"AdminToken": []
|
||||
}
|
||||
],
|
||||
"description": "聚合展示配置摘要、数据库/MySQL、RabbitMQ、Redis 等探测结果与进程启动时间,便于运维排查;需管理令牌。",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"管理端-系统"
|
||||
],
|
||||
"summary": "系统运行状态",
|
||||
"summary": "管理端系统运行状态与依赖探活",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
@@ -763,7 +778,7 @@
|
||||
},
|
||||
"/api/admin/verify": {
|
||||
"post": {
|
||||
"description": "响应为 {\"valid\": true/false},不泄露真实口令。",
|
||||
"description": "请求体 JSON 含 `token` 字段;响应仅返回 `{\"valid\": true|false}`,不会返回真实口令或敏感信息。",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -773,10 +788,10 @@
|
||||
"tags": [
|
||||
"管理端-认证"
|
||||
],
|
||||
"summary": "校验管理员令牌",
|
||||
"summary": "校验管理端令牌是否有效",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "待校验 token",
|
||||
"description": "待校验的管理员 token(JSON)",
|
||||
"name": "body",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
@@ -802,13 +817,14 @@
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "需在请求头携带有效的 `Authorization: Bearer \u003ctoken\u003e`。返回该登录用户在客服系统中与管理员往来的全部消息列表。",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"聊天(用户)"
|
||||
],
|
||||
"summary": "我的聊天消息",
|
||||
"summary": "拉取当前用户聊天记录",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
@@ -836,6 +852,7 @@
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "需在请求头携带 Bearer。正文为 JSON `content` 字段;可能因频率限制返回 429。",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -845,10 +862,10 @@
|
||||
"tags": [
|
||||
"聊天(用户)"
|
||||
],
|
||||
"summary": "发送用户消息",
|
||||
"summary": "用户发送聊天消息",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "消息正文",
|
||||
"description": "消息正文(JSON)",
|
||||
"name": "body",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
@@ -893,6 +910,7 @@
|
||||
},
|
||||
"/api/checkout": {
|
||||
"post": {
|
||||
"description": "根据商品配置决定是否需要登录(`requireLogin`)。可附带 `Authorization: Bearer` 以关联账号、限购与通知邮箱。付费订单走萌芽支付时需商品已配置收款码;同一商品在他人待支付锁定期内可能返回 409;同一账号存在待支付单时也可能冲突。成功返回订单概要及二维码链接等。",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -902,16 +920,16 @@
|
||||
"tags": [
|
||||
"订单"
|
||||
],
|
||||
"summary": "结账创建订单",
|
||||
"summary": "结账并创建订单",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Bearer 用户 token(部分商品必填)",
|
||||
"description": "可选;格式 Bearer + 空格 + token,部分商品必填",
|
||||
"name": "Authorization",
|
||||
"in": "header"
|
||||
},
|
||||
{
|
||||
"description": "结账参数",
|
||||
"description": "结账请求体:商品、数量、联系方式等",
|
||||
"name": "body",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
@@ -946,7 +964,7 @@
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "库存锁定冲突或同用户待支付",
|
||||
"description": "库存锁定冲突(他人正在支付)或同账号待支付单未满间隔",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/handlers.SwaggerErrorBody"
|
||||
}
|
||||
@@ -962,6 +980,7 @@
|
||||
},
|
||||
"/api/health": {
|
||||
"get": {
|
||||
"description": "用于负载均衡或编排探活:`status` 恒为 ok 表示 HTTP 服务存活;`rabbitmq` 为 `disabled`(未启用消息队列客户端)、`ok`(连接可用)或以 `error:` 开头的错误片段。",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -971,7 +990,7 @@
|
||||
"summary": "健康检查",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "status ok;rabbitmq 为 disabled|ok|error:...",
|
||||
"description": "status 为 ok;rabbitmq 为 disabled、ok 或 error: 前缀错误信息",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
@@ -987,13 +1006,14 @@
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "必须携带有效 Bearer。列表中处于待支付状态的订单不会包含已分配卡密等敏感字段,防止泄露。",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"订单"
|
||||
],
|
||||
"summary": "我的订单",
|
||||
"summary": "查询当前登录用户的订单列表",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
@@ -1018,17 +1038,18 @@
|
||||
},
|
||||
"/api/orders/{id}/cancel": {
|
||||
"post": {
|
||||
"description": "仅对允许取消的状态生效(如待支付);已完成订单会冲突。成功时释放预留库存/卡密占用。请勿在公网暴露此能力时省略鉴权策略(实现以代码为准)。",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"订单"
|
||||
],
|
||||
"summary": "取消订单",
|
||||
"summary": "取消待支付订单",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "订单 ID",
|
||||
"description": "订单ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
@@ -1064,17 +1085,18 @@
|
||||
},
|
||||
"/api/orders/{id}/confirm": {
|
||||
"post": {
|
||||
"description": "用于用户侧「确认」动作:已完成订单直接返回数据;待支付时若仍未到账则 409;已取消或超时 410。手动发货等场景下会触发邮件通知(若配置)。",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"订单"
|
||||
],
|
||||
"summary": "确认订单",
|
||||
"summary": "确认订单(核销/完成流程)",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "订单 ID",
|
||||
"description": "订单ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
@@ -1094,13 +1116,13 @@
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "待支付未到账等",
|
||||
"description": "待支付但金额未核对到账等",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/handlers.SwaggerErrorBody"
|
||||
}
|
||||
},
|
||||
"410": {
|
||||
"description": "已取消或超时",
|
||||
"description": "订单已取消或已超过待支付时限",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/handlers.SwaggerErrorBody"
|
||||
}
|
||||
@@ -1110,17 +1132,18 @@
|
||||
},
|
||||
"/api/orders/{id}/payment-status": {
|
||||
"get": {
|
||||
"description": "供前端轮询待支付订单:返回状态、应付快照、过期时间等;不包含卡密等敏感发货内容。路径参数为订单 ID。",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"订单"
|
||||
],
|
||||
"summary": "订单支付状态",
|
||||
"summary": "查询订单支付状态(轮询)",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "订单 ID",
|
||||
"description": "订单ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
@@ -1144,13 +1167,14 @@
|
||||
},
|
||||
"/api/products": {
|
||||
"get": {
|
||||
"description": "返回当前处于「上架」状态的商品集合,仅包含前台展示所需字段(不含卡密、管理字段等)。无需登录。",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"公开"
|
||||
],
|
||||
"summary": "上架商品列表",
|
||||
"summary": "获取上架商品列表",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
@@ -1169,17 +1193,18 @@
|
||||
},
|
||||
"/api/products/{id}/view": {
|
||||
"post": {
|
||||
"description": "对指定商品增加浏览计数;是否计入由服务端对访客指纹去重策略决定,用于热门统计。路径参数为商品 ID。",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"公开"
|
||||
],
|
||||
"summary": "记录商品浏览",
|
||||
"summary": "记录商品浏览次数",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "商品 ID",
|
||||
"description": "商品ID(路径)",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
@@ -1203,13 +1228,14 @@
|
||||
},
|
||||
"/api/site/maintenance": {
|
||||
"get": {
|
||||
"description": "返回当前是否开启全站维护、以及展示给前端的维护原因文案(公开接口,无需管理令牌)。",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"公开"
|
||||
],
|
||||
"summary": "维护模式状态",
|
||||
"summary": "获取维护模式状态",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
@@ -1228,13 +1254,14 @@
|
||||
},
|
||||
"/api/site/visit": {
|
||||
"post": {
|
||||
"description": "记录访客一次访问并累加全站访问计数;是否计入由服务端指纹等策略决定,响应中含最新总访问量与本次是否计入。",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"公开"
|
||||
],
|
||||
"summary": "记录站点访问",
|
||||
"summary": "上报一次站点访问",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
@@ -1253,13 +1280,14 @@
|
||||
},
|
||||
"/api/stats": {
|
||||
"get": {
|
||||
"description": "返回全站累计订单数与累计访问量(公开读,用于首页展示等)。",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"公开"
|
||||
],
|
||||
"summary": "站点统计",
|
||||
"summary": "获取站点聚合统计",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
@@ -1278,6 +1306,7 @@
|
||||
},
|
||||
"/api/webhooks/mengya-pay": {
|
||||
"post": {
|
||||
"description": "由支付渠道/转发服务 POST 原始 JSON;服务端解析到账金额并尝试匹配待支付订单。若配置 `WEBHOOK_MENGYA_SECRET`,请求头 `X-Webhook-Secret` 必须与其一致。本地联调与生产 `https://store.shumengya.top` 均使用同一路径,由部署时的域名与 TLS 决定回调 URL。",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -1287,11 +1316,11 @@
|
||||
"tags": [
|
||||
"Webhook"
|
||||
],
|
||||
"summary": "萌芽支付 Webhook",
|
||||
"summary": "萌芽支付到账 Webhook",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "与 WEBHOOK_MENGYA_SECRET 一致",
|
||||
"description": "与进程环境变量 WEBHOOK_MENGYA_SECRET 一致;未配置密钥时可不填",
|
||||
"name": "X-Webhook-Secret",
|
||||
"in": "header"
|
||||
}
|
||||
@@ -1331,13 +1360,14 @@
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "需 Bearer 登录。返回该账号已收藏的商品 ID 数组(顺序由存储实现决定)。",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"收藏"
|
||||
],
|
||||
"summary": "收藏列表",
|
||||
"summary": "获取当前用户收藏列表",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
@@ -1365,6 +1395,7 @@
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "需 Bearer。请求体为 `{ \"productId\": \"...\" }`;幂等语义由存储层实现(重复添加可忽略或报错以实际为准)。",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -1374,10 +1405,10 @@
|
||||
"tags": [
|
||||
"收藏"
|
||||
],
|
||||
"summary": "添加收藏",
|
||||
"summary": "添加商品到收藏",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "商品 ID",
|
||||
"description": "包含 productId 的 JSON",
|
||||
"name": "body",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
@@ -1421,17 +1452,18 @@
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "需 Bearer。路径参数为要移除的商品 ID。",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"收藏"
|
||||
],
|
||||
"summary": "移除收藏",
|
||||
"summary": "从收藏中移除商品",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "商品 ID",
|
||||
"description": "商品ID(路径)",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
@@ -2153,19 +2185,19 @@
|
||||
},
|
||||
"securityDefinitions": {
|
||||
"AdminToken": {
|
||||
"description": "管理端令牌(也可使用 Authorization 头或 query token,见各接口说明)",
|
||||
"description": "管理端访问令牌:与进程环境变量 `ADMIN_TOKEN` 一致。部分管理路由也可使用 `Authorization` 头或 Query `token`(以具体路由实现为准)。",
|
||||
"type": "apiKey",
|
||||
"name": "X-Admin-Token",
|
||||
"in": "header"
|
||||
},
|
||||
"BearerAuth": {
|
||||
"description": "用户访问令牌,格式: Bearer 空格 + token",
|
||||
"description": "用户访问令牌:请求头 `Authorization`,值为 `Bearer ` 后接 SproutGate 返回的 JWT/access token(部分公开接口可不携带)。",
|
||||
"type": "apiKey",
|
||||
"name": "Authorization",
|
||||
"in": "header"
|
||||
},
|
||||
"WebhookSecret": {
|
||||
"description": "与服务端 WEBHOOK_MENGYA_SECRET 一致时校验萌芽支付 Webhook",
|
||||
"description": "当服务端配置了 `WEBHOOK_MENGYA_SECRET` 时,萌芽支付到账回调 `POST /api/webhooks/mengya-pay` 需携带本头且值与密钥完全一致,否则返回 403。",
|
||||
"type": "apiKey",
|
||||
"name": "X-Webhook-Secret",
|
||||
"in": "header"
|
||||
|
||||
@@ -444,14 +444,22 @@ definitions:
|
||||
host: localhost:8080
|
||||
info:
|
||||
contact: {}
|
||||
description: 商品、下单、站点统计、收藏与客服聊天;用户登录由萌芽账户认证中心(SproutGate)校验。监听地址以 HTTP_LISTEN_ADDR
|
||||
为准。
|
||||
description: |-
|
||||
萌芽小店电商后端 HTTP API:商品、下单结账、订单与支付、站点统计与访问、维护模式、收藏、用户/管理员聊天、萌芽支付 Webhook,以及管理端商品/订单/站点与运维状态等。用户身份由萌芽账户认证中心(SproutGate)签发令牌并完成校验。
|
||||
|
||||
**运行环境与基地址**
|
||||
- **本地开发**:监听地址由环境变量 `HTTP_LISTEN_ADDR` 决定,未设置时默认为 `:8080`,即常见入口 `http://localhost:8080`。可在浏览器打开 `http://localhost:8080/swagger/index.html` 查看本页同款文档并调试。
|
||||
- **生产部署**:线上前台/API 域名为 `https://store.shumengya.top`(HTTPS)。若前面还有网关、路径前缀或端口映射,请在拼接请求 URL 时以实际对外发布地址为准;Swagger 中默认 Host 为本地,联调线上时请将浏览器地址或客户端 Base URL 换成生产基地址。
|
||||
|
||||
**说明**:本 OpenAPI 由代码注释生成;与进程真实监听、反向代理头无关,仅作契约参考。
|
||||
title: 萌芽小店 API
|
||||
version: 1.0.0-go
|
||||
paths:
|
||||
/:
|
||||
get:
|
||||
description: 返回服务描述、主要路径指引与版本(非 OpenAPI 详尽列表,完整契约见 /swagger)。
|
||||
description: 访问服务根路径 `/` 返回 JSON:服务简介、本地与生产环境说明、主要业务路由分组、运行版本与时间戳。完整请求/响应模型请使用
|
||||
Swagger UI:`GET /swagger/index.html`。本地一般为 `http://localhost:8080/`,生产域名为
|
||||
`https://store.shumengya.top/`。
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
@@ -460,11 +468,12 @@ paths:
|
||||
schema:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
summary: API 根信息与端点索引
|
||||
summary: 根路径 API 说明与路由索引
|
||||
tags:
|
||||
- 元信息
|
||||
/api/admin/chat:
|
||||
get:
|
||||
description: 返回每个有过消息的用户账号等信息,供客服选择会话;需管理令牌 `X-Admin-Token`。
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
@@ -483,13 +492,14 @@ paths:
|
||||
$ref: '#/definitions/handlers.SwaggerErrorBody'
|
||||
security:
|
||||
- AdminToken: []
|
||||
summary: 全部会话列表
|
||||
summary: 管理端:列出全部聊天会话
|
||||
tags:
|
||||
- 管理端-聊天
|
||||
/api/admin/chat/{account}:
|
||||
delete:
|
||||
description: 删除该账号在系统中的全部聊天消息记录;不可恢复请谨慎使用。
|
||||
parameters:
|
||||
- description: 用户账号
|
||||
- description: 用户账号(路径)
|
||||
in: path
|
||||
name: account
|
||||
required: true
|
||||
@@ -515,12 +525,13 @@ paths:
|
||||
$ref: '#/definitions/handlers.SwaggerErrorBody'
|
||||
security:
|
||||
- AdminToken: []
|
||||
summary: 清空会话
|
||||
summary: 管理端:清空与某用户的会话
|
||||
tags:
|
||||
- 管理端-聊天
|
||||
get:
|
||||
description: 路径参数 `account` 为用户账号标识;返回按时间排列的消息数组;需管理令牌。
|
||||
parameters:
|
||||
- description: 用户账号
|
||||
- description: 用户账号(路径)
|
||||
in: path
|
||||
name: account
|
||||
required: true
|
||||
@@ -546,19 +557,20 @@ paths:
|
||||
$ref: '#/definitions/handlers.SwaggerErrorBody'
|
||||
security:
|
||||
- AdminToken: []
|
||||
summary: 指定用户聊天记录
|
||||
summary: 管理端:查看某用户的完整聊天记录
|
||||
tags:
|
||||
- 管理端-聊天
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
description: 路径为对方账号,JSON body 含 `content`;写入后用户侧拉取可见。
|
||||
parameters:
|
||||
- description: 用户账号
|
||||
- description: 用户账号(路径)
|
||||
in: path
|
||||
name: account
|
||||
required: true
|
||||
type: string
|
||||
- description: 回复内容
|
||||
- description: 回复正文 JSON
|
||||
in: body
|
||||
name: body
|
||||
required: true
|
||||
@@ -585,11 +597,12 @@ paths:
|
||||
$ref: '#/definitions/handlers.SwaggerErrorBody'
|
||||
security:
|
||||
- AdminToken: []
|
||||
summary: 管理员回复
|
||||
summary: 管理端:向用户回复一条消息
|
||||
tags:
|
||||
- 管理端-聊天
|
||||
/api/admin/orders:
|
||||
get:
|
||||
description: 返回数据库中订单全量列表(含敏感字段),供后台管理;需 `X-Admin-Token`。
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
@@ -607,13 +620,14 @@ paths:
|
||||
$ref: '#/definitions/handlers.SwaggerErrorBody'
|
||||
security:
|
||||
- AdminToken: []
|
||||
summary: 全部订单(管理)
|
||||
summary: 管理端获取全部订单
|
||||
tags:
|
||||
- 管理端-订单
|
||||
/api/admin/orders/{id}:
|
||||
delete:
|
||||
description: 按路径中的订单 ID 删除记录;不可恢复请谨慎操作。
|
||||
parameters:
|
||||
- description: 订单 ID
|
||||
- description: 订单ID
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
@@ -639,11 +653,12 @@ paths:
|
||||
$ref: '#/definitions/handlers.SwaggerErrorBody'
|
||||
security:
|
||||
- AdminToken: []
|
||||
summary: 删除订单
|
||||
summary: 管理端删除指定订单
|
||||
tags:
|
||||
- 管理端-订单
|
||||
/api/admin/products:
|
||||
get:
|
||||
description: 含下架商品、卡密字段等完整数据,仅限管理令牌访问。需在请求头携带 `X-Admin-Token`。
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
@@ -661,14 +676,15 @@ paths:
|
||||
$ref: '#/definitions/handlers.SwaggerErrorBody'
|
||||
security:
|
||||
- AdminToken: []
|
||||
summary: 全部商品(管理)
|
||||
summary: 管理端获取全部商品列表
|
||||
tags:
|
||||
- 管理端-商品
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
description: 提交完整商品载荷:价格、卡密/固定内容、收款码链接、是否上架等。校验失败返回 400。
|
||||
parameters:
|
||||
- description: 商品字段
|
||||
- description: 商品各字段(含可选卡密、截图、收款码等)
|
||||
in: body
|
||||
name: body
|
||||
required: true
|
||||
@@ -695,13 +711,14 @@ paths:
|
||||
$ref: '#/definitions/handlers.SwaggerErrorBody'
|
||||
security:
|
||||
- AdminToken: []
|
||||
summary: 创建商品
|
||||
summary: 管理端创建商品
|
||||
tags:
|
||||
- 管理端-商品
|
||||
/api/admin/products/{id}:
|
||||
delete:
|
||||
description: 按 ID 永久删除商品记录(影响以数据库外键/业务逻辑为准),需管理令牌。
|
||||
parameters:
|
||||
- description: 商品 ID
|
||||
- description: 商品ID
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
@@ -723,19 +740,20 @@ paths:
|
||||
$ref: '#/definitions/handlers.SwaggerErrorBody'
|
||||
security:
|
||||
- AdminToken: []
|
||||
summary: 删除商品
|
||||
summary: 管理端删除商品
|
||||
tags:
|
||||
- 管理端-商品
|
||||
put:
|
||||
consumes:
|
||||
- application/json
|
||||
description: 路径参数为商品 ID,请求体为整包覆盖式更新(以服务端绑定逻辑为准)。未找到则 404。
|
||||
parameters:
|
||||
- description: 商品 ID
|
||||
- description: 商品ID
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: string
|
||||
- description: 商品字段
|
||||
- description: 待写入的商品字段
|
||||
in: body
|
||||
name: body
|
||||
required: true
|
||||
@@ -766,20 +784,21 @@ paths:
|
||||
$ref: '#/definitions/handlers.SwaggerErrorBody'
|
||||
security:
|
||||
- AdminToken: []
|
||||
summary: 更新商品
|
||||
summary: 管理端更新商品
|
||||
tags:
|
||||
- 管理端-商品
|
||||
/api/admin/products/{id}/status:
|
||||
patch:
|
||||
consumes:
|
||||
- application/json
|
||||
description: 'PATCH 请求体为 `{ "active": true|false }`,用于快速上下架而不改其它字段。'
|
||||
parameters:
|
||||
- description: 商品 ID
|
||||
- description: 商品ID
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: string
|
||||
- description: '{active}'
|
||||
- description: 上架开关 { \
|
||||
in: body
|
||||
name: body
|
||||
required: true
|
||||
@@ -806,15 +825,16 @@ paths:
|
||||
$ref: '#/definitions/handlers.SwaggerErrorBody'
|
||||
security:
|
||||
- AdminToken: []
|
||||
summary: 切换上架状态
|
||||
summary: 管理端切换商品上架状态
|
||||
tags:
|
||||
- 管理端-商品
|
||||
/api/admin/site/maintenance:
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
description: 打开维护后前台可展示原因文案;需管理令牌。请求体含 `maintenance` 布尔与可选 `reason`。
|
||||
parameters:
|
||||
- description: 维护开关与原因
|
||||
- description: maintenance 与 reason
|
||||
in: body
|
||||
name: body
|
||||
required: true
|
||||
@@ -841,11 +861,12 @@ paths:
|
||||
$ref: '#/definitions/handlers.SwaggerErrorBody'
|
||||
security:
|
||||
- AdminToken: []
|
||||
summary: 设置维护模式
|
||||
summary: 设置全站维护模式
|
||||
tags:
|
||||
- 管理端-站点
|
||||
/api/admin/site/smtp:
|
||||
get:
|
||||
description: 返回当前站点发件配置;密码字段以占位符脱敏显示,不会返回明文。
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
@@ -863,14 +884,15 @@ paths:
|
||||
$ref: '#/definitions/handlers.SwaggerErrorBody'
|
||||
security:
|
||||
- AdminToken: []
|
||||
summary: 获取 SMTP 配置
|
||||
summary: 获取发信 SMTP 配置(脱敏)
|
||||
tags:
|
||||
- 管理端-站点
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
description: 写入 SMTP 主机、端口、账号等;若密码仍为脱敏占位符则保留库中已有密码(实现细节见服务逻辑)。
|
||||
parameters:
|
||||
- description: SMTP 字段
|
||||
- description: SMTP 连接与发件人信息
|
||||
in: body
|
||||
name: body
|
||||
required: true
|
||||
@@ -897,11 +919,12 @@ paths:
|
||||
$ref: '#/definitions/handlers.SwaggerErrorBody'
|
||||
security:
|
||||
- AdminToken: []
|
||||
summary: 保存 SMTP 配置
|
||||
summary: 保存发信 SMTP 配置
|
||||
tags:
|
||||
- 管理端-站点
|
||||
/api/admin/system-status:
|
||||
get:
|
||||
description: 聚合展示配置摘要、数据库/MySQL、RabbitMQ、Redis 等探测结果与进程启动时间,便于运维排查;需管理令牌。
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
@@ -916,16 +939,16 @@ paths:
|
||||
$ref: '#/definitions/handlers.SwaggerErrorBody'
|
||||
security:
|
||||
- AdminToken: []
|
||||
summary: 系统运行状态
|
||||
summary: 管理端系统运行状态与依赖探活
|
||||
tags:
|
||||
- 管理端-系统
|
||||
/api/admin/verify:
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
description: '响应为 {"valid": true/false},不泄露真实口令。'
|
||||
description: '请求体 JSON 含 `token` 字段;响应仅返回 `{"valid": true|false}`,不会返回真实口令或敏感信息。'
|
||||
parameters:
|
||||
- description: 待校验 token
|
||||
- description: 待校验的管理员 token(JSON)
|
||||
in: body
|
||||
name: body
|
||||
required: true
|
||||
@@ -938,11 +961,12 @@ paths:
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/handlers.SwaggerValidBody'
|
||||
summary: 校验管理员令牌
|
||||
summary: 校验管理端令牌是否有效
|
||||
tags:
|
||||
- 管理端-认证
|
||||
/api/chat/messages:
|
||||
get:
|
||||
description: '需在请求头携带有效的 `Authorization: Bearer <token>`。返回该登录用户在客服系统中与管理员往来的全部消息列表。'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
@@ -960,14 +984,15 @@ paths:
|
||||
$ref: '#/definitions/handlers.SwaggerErrorBody'
|
||||
security:
|
||||
- BearerAuth: []
|
||||
summary: 我的聊天消息
|
||||
summary: 拉取当前用户聊天记录
|
||||
tags:
|
||||
- 聊天(用户)
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
description: 需在请求头携带 Bearer。正文为 JSON `content` 字段;可能因频率限制返回 429。
|
||||
parameters:
|
||||
- description: 消息正文
|
||||
- description: 消息正文(JSON)
|
||||
in: body
|
||||
name: body
|
||||
required: true
|
||||
@@ -998,19 +1023,21 @@ paths:
|
||||
$ref: '#/definitions/handlers.SwaggerErrorBody'
|
||||
security:
|
||||
- BearerAuth: []
|
||||
summary: 发送用户消息
|
||||
summary: 用户发送聊天消息
|
||||
tags:
|
||||
- 聊天(用户)
|
||||
/api/checkout:
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
description: '根据商品配置决定是否需要登录(`requireLogin`)。可附带 `Authorization: Bearer` 以关联账号、限购与通知邮箱。付费订单走萌芽支付时需商品已配置收款码;同一商品在他人待支付锁定期内可能返回
|
||||
409;同一账号存在待支付单时也可能冲突。成功返回订单概要及二维码链接等。'
|
||||
parameters:
|
||||
- description: Bearer 用户 token(部分商品必填)
|
||||
- description: 可选;格式 Bearer + 空格 + token,部分商品必填
|
||||
in: header
|
||||
name: Authorization
|
||||
type: string
|
||||
- description: 结账参数
|
||||
- description: 结账请求体:商品、数量、联系方式等
|
||||
in: body
|
||||
name: body
|
||||
required: true
|
||||
@@ -1036,23 +1063,25 @@ paths:
|
||||
schema:
|
||||
$ref: '#/definitions/handlers.SwaggerErrorBody'
|
||||
"409":
|
||||
description: 库存锁定冲突或同用户待支付
|
||||
description: 库存锁定冲突(他人正在支付)或同账号待支付单未满间隔
|
||||
schema:
|
||||
$ref: '#/definitions/handlers.SwaggerErrorBody'
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
$ref: '#/definitions/handlers.SwaggerErrorBody'
|
||||
summary: 结账创建订单
|
||||
summary: 结账并创建订单
|
||||
tags:
|
||||
- 订单
|
||||
/api/health:
|
||||
get:
|
||||
description: 用于负载均衡或编排探活:`status` 恒为 ok 表示 HTTP 服务存活;`rabbitmq` 为 `disabled`(未启用消息队列客户端)、`ok`(连接可用)或以
|
||||
`error:` 开头的错误片段。
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: status ok;rabbitmq 为 disabled|ok|error:...
|
||||
description: 'status 为 ok;rabbitmq 为 disabled、ok 或 error: 前缀错误信息'
|
||||
schema:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
@@ -1061,6 +1090,7 @@ paths:
|
||||
- 健康检查
|
||||
/api/orders:
|
||||
get:
|
||||
description: 必须携带有效 Bearer。列表中处于待支付状态的订单不会包含已分配卡密等敏感字段,防止泄露。
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
@@ -1078,13 +1108,14 @@ paths:
|
||||
$ref: '#/definitions/handlers.SwaggerErrorBody'
|
||||
security:
|
||||
- BearerAuth: []
|
||||
summary: 我的订单
|
||||
summary: 查询当前登录用户的订单列表
|
||||
tags:
|
||||
- 订单
|
||||
/api/orders/{id}/cancel:
|
||||
post:
|
||||
description: 仅对允许取消的状态生效(如待支付);已完成订单会冲突。成功时释放预留库存/卡密占用。请勿在公网暴露此能力时省略鉴权策略(实现以代码为准)。
|
||||
parameters:
|
||||
- description: 订单 ID
|
||||
- description: 订单ID
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
@@ -1108,13 +1139,14 @@ paths:
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
$ref: '#/definitions/handlers.SwaggerErrorBody'
|
||||
summary: 取消订单
|
||||
summary: 取消待支付订单
|
||||
tags:
|
||||
- 订单
|
||||
/api/orders/{id}/confirm:
|
||||
post:
|
||||
description: 用于用户侧「确认」动作:已完成订单直接返回数据;待支付时若仍未到账则 409;已取消或超时 410。手动发货等场景下会触发邮件通知(若配置)。
|
||||
parameters:
|
||||
- description: 订单 ID
|
||||
- description: 订单ID
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
@@ -1131,20 +1163,21 @@ paths:
|
||||
schema:
|
||||
$ref: '#/definitions/handlers.SwaggerErrorBody'
|
||||
"409":
|
||||
description: 待支付未到账等
|
||||
description: 待支付但金额未核对到账等
|
||||
schema:
|
||||
$ref: '#/definitions/handlers.SwaggerErrorBody'
|
||||
"410":
|
||||
description: 已取消或超时
|
||||
description: 订单已取消或已超过待支付时限
|
||||
schema:
|
||||
$ref: '#/definitions/handlers.SwaggerErrorBody'
|
||||
summary: 确认订单
|
||||
summary: 确认订单(核销/完成流程)
|
||||
tags:
|
||||
- 订单
|
||||
/api/orders/{id}/payment-status:
|
||||
get:
|
||||
description: 供前端轮询待支付订单:返回状态、应付快照、过期时间等;不包含卡密等敏感发货内容。路径参数为订单 ID。
|
||||
parameters:
|
||||
- description: 订单 ID
|
||||
- description: 订单ID
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
@@ -1160,11 +1193,12 @@ paths:
|
||||
description: Not Found
|
||||
schema:
|
||||
$ref: '#/definitions/handlers.SwaggerErrorBody'
|
||||
summary: 订单支付状态
|
||||
summary: 查询订单支付状态(轮询)
|
||||
tags:
|
||||
- 订单
|
||||
/api/products:
|
||||
get:
|
||||
description: 返回当前处于「上架」状态的商品集合,仅包含前台展示所需字段(不含卡密、管理字段等)。无需登录。
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
@@ -1176,13 +1210,14 @@ paths:
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
$ref: '#/definitions/handlers.SwaggerErrorBody'
|
||||
summary: 上架商品列表
|
||||
summary: 获取上架商品列表
|
||||
tags:
|
||||
- 公开
|
||||
/api/products/{id}/view:
|
||||
post:
|
||||
description: 对指定商品增加浏览计数;是否计入由服务端对访客指纹去重策略决定,用于热门统计。路径参数为商品 ID。
|
||||
parameters:
|
||||
- description: 商品 ID
|
||||
- description: 商品ID(路径)
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
@@ -1198,11 +1233,12 @@ paths:
|
||||
description: Not Found
|
||||
schema:
|
||||
$ref: '#/definitions/handlers.SwaggerErrorBody'
|
||||
summary: 记录商品浏览
|
||||
summary: 记录商品浏览次数
|
||||
tags:
|
||||
- 公开
|
||||
/api/site/maintenance:
|
||||
get:
|
||||
description: 返回当前是否开启全站维护、以及展示给前端的维护原因文案(公开接口,无需管理令牌)。
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
@@ -1214,11 +1250,12 @@ paths:
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
$ref: '#/definitions/handlers.SwaggerErrorBody'
|
||||
summary: 维护模式状态
|
||||
summary: 获取维护模式状态
|
||||
tags:
|
||||
- 公开
|
||||
/api/site/visit:
|
||||
post:
|
||||
description: 记录访客一次访问并累加全站访问计数;是否计入由服务端指纹等策略决定,响应中含最新总访问量与本次是否计入。
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
@@ -1230,11 +1267,12 @@ paths:
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
$ref: '#/definitions/handlers.SwaggerErrorBody'
|
||||
summary: 记录站点访问
|
||||
summary: 上报一次站点访问
|
||||
tags:
|
||||
- 公开
|
||||
/api/stats:
|
||||
get:
|
||||
description: 返回全站累计订单数与累计访问量(公开读,用于首页展示等)。
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
@@ -1246,15 +1284,18 @@ paths:
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
$ref: '#/definitions/handlers.SwaggerErrorBody'
|
||||
summary: 站点统计
|
||||
summary: 获取站点聚合统计
|
||||
tags:
|
||||
- 公开
|
||||
/api/webhooks/mengya-pay:
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
description: 由支付渠道/转发服务 POST 原始 JSON;服务端解析到账金额并尝试匹配待支付订单。若配置 `WEBHOOK_MENGYA_SECRET`,请求头
|
||||
`X-Webhook-Secret` 必须与其一致。本地联调与生产 `https://store.shumengya.top` 均使用同一路径,由部署时的域名与
|
||||
TLS 决定回调 URL。
|
||||
parameters:
|
||||
- description: 与 WEBHOOK_MENGYA_SECRET 一致
|
||||
- description: 与进程环境变量 WEBHOOK_MENGYA_SECRET 一致;未配置密钥时可不填
|
||||
in: header
|
||||
name: X-Webhook-Secret
|
||||
type: string
|
||||
@@ -1277,11 +1318,12 @@ paths:
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
$ref: '#/definitions/handlers.SwaggerErrorBody'
|
||||
summary: 萌芽支付 Webhook
|
||||
summary: 萌芽支付到账 Webhook
|
||||
tags:
|
||||
- Webhook
|
||||
/api/wishlist:
|
||||
get:
|
||||
description: 需 Bearer 登录。返回该账号已收藏的商品 ID 数组(顺序由存储实现决定)。
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
@@ -1299,14 +1341,15 @@ paths:
|
||||
$ref: '#/definitions/handlers.SwaggerErrorBody'
|
||||
security:
|
||||
- BearerAuth: []
|
||||
summary: 收藏列表
|
||||
summary: 获取当前用户收藏列表
|
||||
tags:
|
||||
- 收藏
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
description: '需 Bearer。请求体为 `{ "productId": "..." }`;幂等语义由存储层实现(重复添加可忽略或报错以实际为准)。'
|
||||
parameters:
|
||||
- description: 商品 ID
|
||||
- description: 包含 productId 的 JSON
|
||||
in: body
|
||||
name: body
|
||||
required: true
|
||||
@@ -1333,13 +1376,14 @@ paths:
|
||||
$ref: '#/definitions/handlers.SwaggerErrorBody'
|
||||
security:
|
||||
- BearerAuth: []
|
||||
summary: 添加收藏
|
||||
summary: 添加商品到收藏
|
||||
tags:
|
||||
- 收藏
|
||||
/api/wishlist/{id}:
|
||||
delete:
|
||||
description: 需 Bearer。路径参数为要移除的商品 ID。
|
||||
parameters:
|
||||
- description: 商品 ID
|
||||
- description: 商品ID(路径)
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
@@ -1365,7 +1409,7 @@ paths:
|
||||
$ref: '#/definitions/handlers.SwaggerErrorBody'
|
||||
security:
|
||||
- BearerAuth: []
|
||||
summary: 移除收藏
|
||||
summary: 从收藏中移除商品
|
||||
tags:
|
||||
- 收藏
|
||||
schemes:
|
||||
@@ -1373,17 +1417,20 @@ schemes:
|
||||
- https
|
||||
securityDefinitions:
|
||||
AdminToken:
|
||||
description: 管理端令牌(也可使用 Authorization 头或 query token,见各接口说明)
|
||||
description: 管理端访问令牌:与进程环境变量 `ADMIN_TOKEN` 一致。部分管理路由也可使用 `Authorization` 头或 Query
|
||||
`token`(以具体路由实现为准)。
|
||||
in: header
|
||||
name: X-Admin-Token
|
||||
type: apiKey
|
||||
BearerAuth:
|
||||
description: '用户访问令牌,格式: Bearer 空格 + token'
|
||||
description: 用户访问令牌:请求头 `Authorization`,值为 `Bearer ` 后接 SproutGate 返回的 JWT/access
|
||||
token(部分公开接口可不携带)。
|
||||
in: header
|
||||
name: Authorization
|
||||
type: apiKey
|
||||
WebhookSecret:
|
||||
description: 与服务端 WEBHOOK_MENGYA_SECRET 一致时校验萌芽支付 Webhook
|
||||
description: 当服务端配置了 `WEBHOOK_MENGYA_SECRET` 时,萌芽支付到账回调 `POST /api/webhooks/mengya-pay`
|
||||
需携带本头且值与密钥完全一致,否则返回 403。
|
||||
in: header
|
||||
name: X-Webhook-Secret
|
||||
type: apiKey
|
||||
|
||||
@@ -8,7 +8,8 @@ import (
|
||||
)
|
||||
|
||||
// GetAllConversations 返回所有用户会话列表。
|
||||
// @Summary 全部会话列表
|
||||
// @Summary 管理端:列出全部聊天会话
|
||||
// @Description 返回每个有过消息的用户账号等信息,供客服选择会话;需管理令牌 `X-Admin-Token`。
|
||||
// @Tags 管理端-聊天
|
||||
// @Produce json
|
||||
// @Security AdminToken
|
||||
@@ -29,11 +30,12 @@ func (h *AdminHandler) GetAllConversations(c *gin.Context) {
|
||||
}
|
||||
|
||||
// GetConversation 返回指定账号的全部消息记录。
|
||||
// @Summary 指定用户聊天记录
|
||||
// @Summary 管理端:查看某用户的完整聊天记录
|
||||
// @Description 路径参数 `account` 为用户账号标识;返回按时间排列的消息数组;需管理令牌。
|
||||
// @Tags 管理端-聊天
|
||||
// @Produce json
|
||||
// @Security AdminToken
|
||||
// @Param account path string true "用户账号"
|
||||
// @Param account path string true "用户账号(路径)"
|
||||
// @Success 200 {object} SwaggerMessagesWrap
|
||||
// @Failure 400 {object} SwaggerErrorBody
|
||||
// @Failure 401 {object} SwaggerErrorBody
|
||||
@@ -61,13 +63,14 @@ type AdminChatPayload struct {
|
||||
}
|
||||
|
||||
// AdminReply 向指定用户发送管理员回复。
|
||||
// @Summary 管理员回复
|
||||
// @Summary 管理端:向用户回复一条消息
|
||||
// @Description 路径为对方账号,JSON body 含 `content`;写入后用户侧拉取可见。
|
||||
// @Tags 管理端-聊天
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security AdminToken
|
||||
// @Param account path string true "用户账号"
|
||||
// @Param body body AdminChatPayload true "回复内容"
|
||||
// @Param account path string true "用户账号(路径)"
|
||||
// @Param body body AdminChatPayload true "回复正文 JSON"
|
||||
// @Success 200 {object} SwaggerOneChatMsgWrap
|
||||
// @Failure 400 {object} SwaggerErrorBody
|
||||
// @Failure 401 {object} SwaggerErrorBody
|
||||
@@ -101,11 +104,12 @@ func (h *AdminHandler) AdminReply(c *gin.Context) {
|
||||
}
|
||||
|
||||
// ClearConversation 清除与指定用户的全部消息记录。
|
||||
// @Summary 清空会话
|
||||
// @Summary 管理端:清空与某用户的会话
|
||||
// @Description 删除该账号在系统中的全部聊天消息记录;不可恢复请谨慎使用。
|
||||
// @Tags 管理端-聊天
|
||||
// @Produce json
|
||||
// @Security AdminToken
|
||||
// @Param account path string true "用户账号"
|
||||
// @Param account path string true "用户账号(路径)"
|
||||
// @Success 200 {object} SwaggerBoolOKWrap
|
||||
// @Failure 400 {object} SwaggerErrorBody
|
||||
// @Failure 401 {object} SwaggerErrorBody
|
||||
|
||||
@@ -7,7 +7,8 @@ import (
|
||||
)
|
||||
|
||||
// ListAllOrders 管理端全部订单。
|
||||
// @Summary 全部订单(管理)
|
||||
// @Summary 管理端获取全部订单
|
||||
// @Description 返回数据库中订单全量列表(含敏感字段),供后台管理;需 `X-Admin-Token`。
|
||||
// @Tags 管理端-订单
|
||||
// @Produce json
|
||||
// @Security AdminToken
|
||||
@@ -28,11 +29,12 @@ func (h *AdminHandler) ListAllOrders(c *gin.Context) {
|
||||
}
|
||||
|
||||
// DeleteOrder 管理端删除订单。
|
||||
// @Summary 删除订单
|
||||
// @Summary 管理端删除指定订单
|
||||
// @Description 按路径中的订单 ID 删除记录;不可恢复请谨慎操作。
|
||||
// @Tags 管理端-订单
|
||||
// @Produce json
|
||||
// @Security AdminToken
|
||||
// @Param id path string true "订单 ID"
|
||||
// @Param id path string true "订单ID"
|
||||
// @Success 200 {object} SwaggerBoolOKWrap
|
||||
// @Failure 400 {object} SwaggerErrorBody
|
||||
// @Failure 401 {object} SwaggerErrorBody
|
||||
|
||||
@@ -47,12 +47,12 @@ type AdminVerifyTokenRequest struct {
|
||||
}
|
||||
|
||||
// VerifyAdminToken 校验请求中的令牌是否正确。
|
||||
// @Summary 校验管理员令牌
|
||||
// @Description 响应为 {"valid": true/false},不泄露真实口令。
|
||||
// @Summary 校验管理端令牌是否有效
|
||||
// @Description 请求体 JSON 含 `token` 字段;响应仅返回 `{"valid": true|false}`,不会返回真实口令或敏感信息。
|
||||
// @Tags 管理端-认证
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body AdminVerifyTokenRequest true "待校验 token"
|
||||
// @Param body body AdminVerifyTokenRequest true "待校验的管理员 token(JSON)"
|
||||
// @Success 200 {object} SwaggerValidBody
|
||||
// @Router /api/admin/verify [post]
|
||||
func (h *AdminHandler) VerifyAdminToken(c *gin.Context) {
|
||||
@@ -65,7 +65,8 @@ func (h *AdminHandler) VerifyAdminToken(c *gin.Context) {
|
||||
}
|
||||
|
||||
// ListAllProducts 管理端商品全量列表(含下架与卡密等敏感字段)。
|
||||
// @Summary 全部商品(管理)
|
||||
// @Summary 管理端获取全部商品列表
|
||||
// @Description 含下架商品、卡密字段等完整数据,仅限管理令牌访问。需在请求头携带 `X-Admin-Token`。
|
||||
// @Tags 管理端-商品
|
||||
// @Produce json
|
||||
// @Security AdminToken
|
||||
@@ -86,12 +87,13 @@ func (h *AdminHandler) ListAllProducts(c *gin.Context) {
|
||||
}
|
||||
|
||||
// CreateProduct 创建商品。
|
||||
// @Summary 创建商品
|
||||
// @Summary 管理端创建商品
|
||||
// @Description 提交完整商品载荷:价格、卡密/固定内容、收款码链接、是否上架等。校验失败返回 400。
|
||||
// @Tags 管理端-商品
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security AdminToken
|
||||
// @Param body body ProductPayload true "商品字段"
|
||||
// @Param body body ProductPayload true "商品各字段(含可选卡密、截图、收款码等)"
|
||||
// @Success 200 {object} SwaggerProductOneBody
|
||||
// @Failure 400 {object} SwaggerErrorBody
|
||||
// @Failure 401 {object} SwaggerErrorBody
|
||||
@@ -157,13 +159,14 @@ func (h *AdminHandler) CreateProduct(c *gin.Context) {
|
||||
}
|
||||
|
||||
// UpdateProduct 更新商品。
|
||||
// @Summary 更新商品
|
||||
// @Summary 管理端更新商品
|
||||
// @Description 路径参数为商品 ID,请求体为整包覆盖式更新(以服务端绑定逻辑为准)。未找到则 404。
|
||||
// @Tags 管理端-商品
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security AdminToken
|
||||
// @Param id path string true "商品 ID"
|
||||
// @Param body body ProductPayload true "商品字段"
|
||||
// @Param id path string true "商品ID"
|
||||
// @Param body body ProductPayload true "待写入的商品字段"
|
||||
// @Success 200 {object} SwaggerProductOneBody
|
||||
// @Failure 400 {object} SwaggerErrorBody
|
||||
// @Failure 401 {object} SwaggerErrorBody
|
||||
@@ -231,13 +234,14 @@ func (h *AdminHandler) UpdateProduct(c *gin.Context) {
|
||||
}
|
||||
|
||||
// ToggleProduct 上架/下架切换。
|
||||
// @Summary 切换上架状态
|
||||
// @Summary 管理端切换商品上架状态
|
||||
// @Description PATCH 请求体为 `{ "active": true|false }`,用于快速上下架而不改其它字段。
|
||||
// @Tags 管理端-商品
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security AdminToken
|
||||
// @Param id path string true "商品 ID"
|
||||
// @Param body body TogglePayload true "{active}"
|
||||
// @Param id path string true "商品ID"
|
||||
// @Param body body TogglePayload true "上架开关 { \"active\": bool }"
|
||||
// @Success 200 {object} SwaggerProductOneBody
|
||||
// @Failure 400 {object} SwaggerErrorBody
|
||||
// @Failure 401 {object} SwaggerErrorBody
|
||||
@@ -262,11 +266,12 @@ func (h *AdminHandler) ToggleProduct(c *gin.Context) {
|
||||
}
|
||||
|
||||
// DeleteProduct 删除商品。
|
||||
// @Summary 删除商品
|
||||
// @Summary 管理端删除商品
|
||||
// @Description 按 ID 永久删除商品记录(影响以数据库外键/业务逻辑为准),需管理令牌。
|
||||
// @Tags 管理端-商品
|
||||
// @Produce json
|
||||
// @Security AdminToken
|
||||
// @Param id path string true "商品 ID"
|
||||
// @Param id path string true "商品ID"
|
||||
// @Success 200 {object} SwaggerBoolOKWrap
|
||||
// @Failure 401 {object} SwaggerErrorBody
|
||||
// @Failure 500 {object} SwaggerErrorBody
|
||||
|
||||
@@ -14,12 +14,13 @@ type MaintenancePayload struct {
|
||||
}
|
||||
|
||||
// SetMaintenance 设置站点维护模式。
|
||||
// @Summary 设置维护模式
|
||||
// @Summary 设置全站维护模式
|
||||
// @Description 打开维护后前台可展示原因文案;需管理令牌。请求体含 `maintenance` 布尔与可选 `reason`。
|
||||
// @Tags 管理端-站点
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security AdminToken
|
||||
// @Param body body MaintenancePayload true "维护开关与原因"
|
||||
// @Param body body MaintenancePayload true "maintenance 与 reason"
|
||||
// @Success 200 {object} SwaggerMaintenanceWrap
|
||||
// @Failure 400 {object} SwaggerErrorBody
|
||||
// @Failure 401 {object} SwaggerErrorBody
|
||||
@@ -47,7 +48,8 @@ func (h *AdminHandler) SetMaintenance(c *gin.Context) {
|
||||
}
|
||||
|
||||
// GetSMTPConfig 获取 SMTP 配置(密码脱敏)。
|
||||
// @Summary 获取 SMTP 配置
|
||||
// @Summary 获取发信 SMTP 配置(脱敏)
|
||||
// @Description 返回当前站点发件配置;密码字段以占位符脱敏显示,不会返回明文。
|
||||
// @Tags 管理端-站点
|
||||
// @Produce json
|
||||
// @Security AdminToken
|
||||
@@ -73,12 +75,13 @@ func (h *AdminHandler) GetSMTPConfig(c *gin.Context) {
|
||||
}
|
||||
|
||||
// SetSMTPConfig 保存 SMTP 配置。
|
||||
// @Summary 保存 SMTP 配置
|
||||
// @Summary 保存发信 SMTP 配置
|
||||
// @Description 写入 SMTP 主机、端口、账号等;若密码仍为脱敏占位符则保留库中已有密码(实现细节见服务逻辑)。
|
||||
// @Tags 管理端-站点
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security AdminToken
|
||||
// @Param body body storage.SMTPConfig true "SMTP 字段"
|
||||
// @Param body body storage.SMTPConfig true "SMTP 连接与发件人信息"
|
||||
// @Success 200 {object} SwaggerStringOKBody
|
||||
// @Failure 400 {object} SwaggerErrorBody
|
||||
// @Failure 401 {object} SwaggerErrorBody
|
||||
|
||||
@@ -30,7 +30,8 @@ func NewSystemStatusHandler(cfg *config.Config, db *gormDB.DB, mqClient *mq.Clie
|
||||
}
|
||||
|
||||
// GetSystemStatus 返回管理后台用 JSON,需通过管理员令牌。
|
||||
// @Summary 系统运行状态
|
||||
// @Summary 管理端系统运行状态与依赖探活
|
||||
// @Description 聚合展示配置摘要、数据库/MySQL、RabbitMQ、Redis 等探测结果与进程启动时间,便于运维排查;需管理令牌。
|
||||
// @Tags 管理端-系统
|
||||
// @Produce json
|
||||
// @Security AdminToken
|
||||
|
||||
@@ -35,7 +35,8 @@ func (h *ChatHandler) requireChatUser(c *gin.Context) (account, name string, ok
|
||||
}
|
||||
|
||||
// GetMyMessages 返回当前登录用户的全部聊天消息。
|
||||
// @Summary 我的聊天消息
|
||||
// @Summary 拉取当前用户聊天记录
|
||||
// @Description 需在请求头携带有效的 `Authorization: Bearer <token>`。返回该登录用户在客服系统中与管理员往来的全部消息列表。
|
||||
// @Tags 聊天(用户)
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
@@ -62,12 +63,13 @@ type ChatMessagePayload struct {
|
||||
}
|
||||
|
||||
// SendMyMessage 向管理员发送一条用户消息。
|
||||
// @Summary 发送用户消息
|
||||
// @Summary 用户发送聊天消息
|
||||
// @Description 需在请求头携带 Bearer。正文为 JSON `content` 字段;可能因频率限制返回 429。
|
||||
// @Tags 聊天(用户)
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param body body ChatMessagePayload true "消息正文"
|
||||
// @Param body body ChatMessagePayload true "消息正文(JSON)"
|
||||
// @Success 200 {object} SwaggerOneChatMsgWrap
|
||||
// @Failure 400 {object} SwaggerErrorBody
|
||||
// @Failure 401 {object} SwaggerErrorBody
|
||||
|
||||
@@ -195,17 +195,18 @@ func qrFromOrder(orderID, productID string) string {
|
||||
}
|
||||
|
||||
// CreateOrder 创建订单(结账)。需登录与否取决于商品 requireLogin;Bearer 可选传入以关联账户与限购。
|
||||
// @Summary 结账创建订单
|
||||
// @Summary 结账并创建订单
|
||||
// @Description 根据商品配置决定是否需要登录(`requireLogin`)。可附带 `Authorization: Bearer` 以关联账号、限购与通知邮箱。付费订单走萌芽支付时需商品已配置收款码;同一商品在他人待支付锁定期内可能返回 409;同一账号存在待支付单时也可能冲突。成功返回订单概要及二维码链接等。
|
||||
// @Tags 订单
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param Authorization header string false "Bearer 用户 token(部分商品必填)"
|
||||
// @Param body body CheckoutPayload true "结账参数"
|
||||
// @Param Authorization header string false "可选;格式 Bearer + 空格 + token,部分商品必填"
|
||||
// @Param body body CheckoutPayload true "结账请求体:商品、数量、联系方式等"
|
||||
// @Success 200 {object} SwaggerCheckoutWrap
|
||||
// @Failure 400 {object} SwaggerErrorBody
|
||||
// @Failure 401 {object} SwaggerErrorBody
|
||||
// @Failure 404 {object} SwaggerErrorBody
|
||||
// @Failure 409 {object} SwaggerErrorBody "库存锁定冲突或同用户待支付"
|
||||
// @Failure 409 {object} SwaggerErrorBody "库存锁定冲突(他人正在支付)或同账号待支付单未满间隔"
|
||||
// @Failure 500 {object} SwaggerErrorBody
|
||||
// @Router /api/checkout [post]
|
||||
func (h *OrderHandler) CreateOrder(c *gin.Context) {
|
||||
@@ -339,6 +340,9 @@ func (h *OrderHandler) CreateOrder(c *gin.Context) {
|
||||
"viewCount": updatedProduct.ViewCount,
|
||||
"status": created.Status,
|
||||
"paymentMethod": payment.MethodMengya,
|
||||
"deliveredCodes": created.DeliveredCodes,
|
||||
"deliveryMode": created.DeliveryMode,
|
||||
"isManual": created.DeliveryMode == "manual",
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": resp})
|
||||
return
|
||||
@@ -469,14 +473,15 @@ func confirmResponse(order models.Order) gin.H {
|
||||
}
|
||||
|
||||
// ConfirmOrder 用户确认订单状态(手动核销、待支付轮询后取货等)。
|
||||
// @Summary 确认订单
|
||||
// @Summary 确认订单(核销/完成流程)
|
||||
// @Description 用于用户侧「确认」动作:已完成订单直接返回数据;待支付时若仍未到账则 409;已取消或超时 410。手动发货等场景下会触发邮件通知(若配置)。
|
||||
// @Tags 订单
|
||||
// @Produce json
|
||||
// @Param id path string true "订单 ID"
|
||||
// @Param id path string true "订单ID"
|
||||
// @Success 200 {object} SwaggerConfirmWrap
|
||||
// @Failure 404 {object} SwaggerErrorBody
|
||||
// @Failure 409 {object} SwaggerErrorBody "待支付未到账等"
|
||||
// @Failure 410 {object} SwaggerErrorBody "已取消或超时"
|
||||
// @Failure 409 {object} SwaggerErrorBody "待支付但金额未核对到账等"
|
||||
// @Failure 410 {object} SwaggerErrorBody "订单已取消或已超过待支付时限"
|
||||
// @Router /api/orders/{id}/confirm [post]
|
||||
func (h *OrderHandler) ConfirmOrder(c *gin.Context) {
|
||||
orderID := c.Param("id")
|
||||
@@ -543,10 +548,11 @@ func (h *OrderHandler) ConfirmOrder(c *gin.Context) {
|
||||
}
|
||||
|
||||
// GetOrderPaymentStatus 前端轮询订单支付状态(不返回卡密)。
|
||||
// @Summary 订单支付状态
|
||||
// @Summary 查询订单支付状态(轮询)
|
||||
// @Description 供前端轮询待支付订单:返回状态、应付快照、过期时间等;不包含卡密等敏感发货内容。路径参数为订单 ID。
|
||||
// @Tags 订单
|
||||
// @Produce json
|
||||
// @Param id path string true "订单 ID"
|
||||
// @Param id path string true "订单ID"
|
||||
// @Success 200 {object} SwaggerPaymentStatusWrap
|
||||
// @Failure 404 {object} SwaggerErrorBody
|
||||
// @Router /api/orders/{id}/payment-status [get]
|
||||
@@ -665,11 +671,12 @@ func clampOneLine(s string, maxRunes int) string {
|
||||
}
|
||||
|
||||
// MengyaPaymentWebhook 萌芽支付到账通知(Body 为渠道 JSON)。若配置 WEBHOOK_MENGYA_SECRET 则必须带 X-Webhook-Secret。
|
||||
// @Summary 萌芽支付 Webhook
|
||||
// @Summary 萌芽支付到账 Webhook
|
||||
// @Description 由支付渠道/转发服务 POST 原始 JSON;服务端解析到账金额并尝试匹配待支付订单。若配置 `WEBHOOK_MENGYA_SECRET`,请求头 `X-Webhook-Secret` 必须与其一致。本地联调与生产 `https://store.shumengya.top` 均使用同一路径,由部署时的域名与 TLS 决定回调 URL。
|
||||
// @Tags Webhook
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param X-Webhook-Secret header string false "与 WEBHOOK_MENGYA_SECRET 一致"
|
||||
// @Param X-Webhook-Secret header string false "与进程环境变量 WEBHOOK_MENGYA_SECRET 一致;未配置密钥时可不填"
|
||||
// @Success 200 {object} SwaggerWebhookMengyaResp
|
||||
// @Failure 400 {object} SwaggerErrorBody
|
||||
// @Failure 403 {object} SwaggerErrorBody
|
||||
@@ -744,10 +751,11 @@ func (h *OrderHandler) MengyaPaymentWebhook(c *gin.Context) {
|
||||
}
|
||||
|
||||
// CancelOrder 用户取消待支付订单并释放预留库存(当前实现不校验 Bearer,凭订单 ID 操作)。
|
||||
// @Summary 取消订单
|
||||
// @Summary 取消待支付订单
|
||||
// @Description 仅对允许取消的状态生效(如待支付);已完成订单会冲突。成功时释放预留库存/卡密占用。请勿在公网暴露此能力时省略鉴权策略(实现以代码为准)。
|
||||
// @Tags 订单
|
||||
// @Produce json
|
||||
// @Param id path string true "订单 ID"
|
||||
// @Param id path string true "订单ID"
|
||||
// @Success 200 {object} SwaggerCancelWrap
|
||||
// @Failure 404 {object} SwaggerErrorBody
|
||||
// @Failure 409 {object} SwaggerErrorBody
|
||||
@@ -782,7 +790,8 @@ func (h *OrderHandler) CancelOrder(c *gin.Context) {
|
||||
}
|
||||
|
||||
// ListMyOrders 当前登录用户的订单列表(待支付条目不返回卡密)。
|
||||
// @Summary 我的订单
|
||||
// @Summary 查询当前登录用户的订单列表
|
||||
// @Description 必须携带有效 Bearer。列表中处于待支付状态的订单不会包含已分配卡密等敏感字段,防止泄露。
|
||||
// @Tags 订单
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
|
||||
@@ -19,7 +19,8 @@ func NewPublicHandler(store *storage.ProductStore) *PublicHandler {
|
||||
}
|
||||
|
||||
// ListProducts 上架商品列表(公开字段,不含卡密与管理信息)。
|
||||
// @Summary 上架商品列表
|
||||
// @Summary 获取上架商品列表
|
||||
// @Description 返回当前处于「上架」状态的商品集合,仅包含前台展示所需字段(不含卡密、管理字段等)。无需登录。
|
||||
// @Tags 公开
|
||||
// @Produce json
|
||||
// @Success 200 {object} SwaggerProductListBody
|
||||
@@ -35,10 +36,11 @@ func (h *PublicHandler) ListProducts(c *gin.Context) {
|
||||
}
|
||||
|
||||
// RecordProductView 记录商品浏览(去重策略由服务端指纹决定)。
|
||||
// @Summary 记录商品浏览
|
||||
// @Summary 记录商品浏览次数
|
||||
// @Description 对指定商品增加浏览计数;是否计入由服务端对访客指纹去重策略决定,用于热门统计。路径参数为商品 ID。
|
||||
// @Tags 公开
|
||||
// @Produce json
|
||||
// @Param id path string true "商品 ID"
|
||||
// @Param id path string true "商品ID(路径)"
|
||||
// @Success 200 {object} SwaggerProductViewWrap
|
||||
// @Failure 404 {object} SwaggerErrorBody
|
||||
// @Router /api/products/{id}/view [post]
|
||||
|
||||
@@ -18,7 +18,8 @@ func NewStatsHandler(orderStore *storage.OrderStore, siteStore *storage.SiteStor
|
||||
}
|
||||
|
||||
// GetStats 订单总数与站点访问总数。
|
||||
// @Summary 站点统计
|
||||
// @Summary 获取站点聚合统计
|
||||
// @Description 返回全站累计订单数与累计访问量(公开读,用于首页展示等)。
|
||||
// @Tags 公开
|
||||
// @Produce json
|
||||
// @Success 200 {object} SwaggerStatsWrap
|
||||
@@ -44,7 +45,8 @@ func (h *StatsHandler) GetStats(c *gin.Context) {
|
||||
}
|
||||
|
||||
// RecordVisit 记录一次站点访问并返回累计访问量。
|
||||
// @Summary 记录站点访问
|
||||
// @Summary 上报一次站点访问
|
||||
// @Description 记录访客一次访问并累加全站访问计数;是否计入由服务端指纹等策略决定,响应中含最新总访问量与本次是否计入。
|
||||
// @Tags 公开
|
||||
// @Produce json
|
||||
// @Success 200 {object} SwaggerVisitWrap
|
||||
@@ -66,7 +68,8 @@ func (h *StatsHandler) RecordVisit(c *gin.Context) {
|
||||
}
|
||||
|
||||
// GetMaintenance 当前维护模式与原因。
|
||||
// @Summary 维护模式状态
|
||||
// @Summary 获取维护模式状态
|
||||
// @Description 返回当前是否开启全站维护、以及展示给前端的维护原因文案(公开接口,无需管理令牌)。
|
||||
// @Tags 公开
|
||||
// @Produce json
|
||||
// @Success 200 {object} SwaggerMaintenanceWrap
|
||||
|
||||
@@ -35,7 +35,8 @@ func (h *WishlistHandler) requireUser(c *gin.Context) (string, bool) {
|
||||
}
|
||||
|
||||
// GetWishlist 当前用户收藏的商品 ID 列表。
|
||||
// @Summary 收藏列表
|
||||
// @Summary 获取当前用户收藏列表
|
||||
// @Description 需 Bearer 登录。返回该账号已收藏的商品 ID 数组(顺序由存储实现决定)。
|
||||
// @Tags 收藏
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
@@ -62,12 +63,13 @@ type WishlistItemPayload struct {
|
||||
}
|
||||
|
||||
// AddToWishlist 添加收藏。
|
||||
// @Summary 添加收藏
|
||||
// @Summary 添加商品到收藏
|
||||
// @Description 需 Bearer。请求体为 `{ "productId": "..." }`;幂等语义由存储层实现(重复添加可忽略或报错以实际为准)。
|
||||
// @Tags 收藏
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param body body WishlistItemPayload true "商品 ID"
|
||||
// @Param body body WishlistItemPayload true "包含 productId 的 JSON"
|
||||
// @Success 200 {object} SwaggerWishlistWrap
|
||||
// @Failure 400 {object} SwaggerErrorBody
|
||||
// @Failure 401 {object} SwaggerErrorBody
|
||||
@@ -92,11 +94,12 @@ func (h *WishlistHandler) AddToWishlist(c *gin.Context) {
|
||||
}
|
||||
|
||||
// RemoveFromWishlist 按商品 ID 移除收藏。
|
||||
// @Summary 移除收藏
|
||||
// @Summary 从收藏中移除商品
|
||||
// @Description 需 Bearer。路径参数为要移除的商品 ID。
|
||||
// @Tags 收藏
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param id path string true "商品 ID"
|
||||
// @Param id path string true "商品ID(路径)"
|
||||
// @Success 200 {object} SwaggerWishlistWrap
|
||||
// @Failure 400 {object} SwaggerErrorBody
|
||||
// @Failure 401 {object} SwaggerErrorBody
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
// @title 萌芽小店 API
|
||||
// @version 1.0.0-go
|
||||
// @description 商品、下单、站点统计、收藏与客服聊天;用户登录由萌芽账户认证中心(SproutGate)校验。监听地址以 HTTP_LISTEN_ADDR 为准。
|
||||
// @description 萌芽小店电商后端 HTTP API:商品、下单结账、订单与支付、站点统计与访问、维护模式、收藏、用户/管理员聊天、萌芽支付 Webhook,以及管理端商品/订单/站点与运维状态等。用户身份由萌芽账户认证中心(SproutGate)签发令牌并完成校验。
|
||||
// @description
|
||||
// @description **运行环境与基地址**
|
||||
// @description - **本地开发**:监听地址由环境变量 `HTTP_LISTEN_ADDR` 决定,未设置时默认为 `:8080`,即常见入口 `http://localhost:8080`。可在浏览器打开 `http://localhost:8080/swagger/index.html` 查看本页同款文档并调试。
|
||||
// @description - **生产部署**:线上前台/API 域名为 `https://store.shumengya.top`(HTTPS)
|
||||
// @description
|
||||
// @description **说明**:本 OpenAPI 由代码注释生成;与进程真实监听、反向代理头无关,仅作契约参考。
|
||||
// @host localhost:8080
|
||||
// @BasePath /
|
||||
// @schemes http https
|
||||
@@ -8,17 +14,17 @@
|
||||
// @securityDefinitions.apikey BearerAuth
|
||||
// @in header
|
||||
// @name Authorization
|
||||
// @description 用户访问令牌,格式: Bearer 空格 + token
|
||||
// @description 用户访问令牌:请求头 `Authorization`,值为 `Bearer ` 后接 SproutGate 返回的 JWT/access token(部分公开接口可不携带)。
|
||||
|
||||
// @securityDefinitions.apikey AdminToken
|
||||
// @in header
|
||||
// @name X-Admin-Token
|
||||
// @description 管理端令牌(也可使用 Authorization 头或 query token,见各接口说明)
|
||||
// @description 管理端访问令牌:与进程环境变量 `ADMIN_TOKEN` 一致。部分管理路由也可使用 `Authorization` 头或 Query `token`(以具体路由实现为准)。
|
||||
|
||||
// @securityDefinitions.apikey WebhookSecret
|
||||
// @in header
|
||||
// @name X-Webhook-Secret
|
||||
// @description 与服务端 WEBHOOK_MENGYA_SECRET 一致时校验萌芽支付 Webhook
|
||||
// @description 当服务端配置了 `WEBHOOK_MENGYA_SECRET` 时,萌芽支付到账回调 `POST /api/webhooks/mengya-pay` 需携带本头且值与密钥完全一致,否则返回 403。
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -84,8 +90,8 @@ func accessLog() gin.HandlerFunc {
|
||||
}
|
||||
|
||||
// rootAPIInfo 浏览器或客户端访问服务根路径时返回 API 说明(JSON)。
|
||||
// @Summary API 根信息与端点索引
|
||||
// @Description 返回服务描述、主要路径指引与版本(非 OpenAPI 详尽列表,完整契约见 /swagger)。
|
||||
// @Summary 根路径 API 说明与路由索引
|
||||
// @Description 访问服务根路径 `/` 返回 JSON:服务简介、本地与生产环境说明、主要业务路由分组、运行版本与时间戳。完整请求/响应模型请使用 Swagger UI:`GET /swagger/index.html`。
|
||||
// @Tags 元信息
|
||||
// @Produce json
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
@@ -97,6 +103,10 @@ func rootAPIInfo(c *gin.Context) {
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"description": "萌芽小店电商后端:商品、下单、站点统计、收藏与客服聊天;用户登录认证由萌芽账户认证中心(SproutGate)校验。",
|
||||
"environments": gin.H{
|
||||
"local": "本地开发:默认 http://localhost:8080(以 HTTP_LISTEN_ADDR 为准)",
|
||||
"production": "生产部署:https://store.shumengya.top(若经反代请用实际对外地址)",
|
||||
},
|
||||
"endpoints": gin.H{
|
||||
"health": "/api/health",
|
||||
"public": "/api/products, /api/checkout, /api/stats, /api/site/*, POST /api/products/:id/view",
|
||||
@@ -116,9 +126,10 @@ func rootAPIInfo(c *gin.Context) {
|
||||
|
||||
// HealthCheck 返回进程与可选 RabbitMQ 探活结果。
|
||||
// @Summary 健康检查
|
||||
// @Description 用于负载均衡或编排探活:`status` 恒为 ok 表示 HTTP 服务存活;`rabbitmq` 为 `disabled`(未启用消息队列客户端)、`ok`(连接可用)或以 `error:` 开头的错误片段。
|
||||
// @Tags 健康检查
|
||||
// @Produce json
|
||||
// @Success 200 {object} map[string]interface{} "status ok;rabbitmq 为 disabled|ok|error:..."
|
||||
// @Success 200 {object} map[string]interface{} "status 为 ok;rabbitmq 为 disabled、ok 或 error: 前缀错误信息"
|
||||
// @Router /api/health [get]
|
||||
func HealthCheck(mqClient *mq.Client) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
@@ -229,6 +240,7 @@ func main() {
|
||||
chatHandler := handlers.NewChatHandler(chatStore, authClient)
|
||||
statusHandler := handlers.NewSystemStatusHandler(cfg, db, mqClient, startedAt)
|
||||
|
||||
// 公开路由
|
||||
r.GET("/api/products", publicHandler.ListProducts)
|
||||
r.POST("/api/checkout", orderHandler.CreateOrder)
|
||||
r.GET("/api/orders/:id/payment-status", orderHandler.GetOrderPaymentStatus)
|
||||
@@ -241,6 +253,7 @@ func main() {
|
||||
r.POST("/api/orders/:id/confirm", orderHandler.ConfirmOrder)
|
||||
r.POST("/api/orders/:id/cancel", orderHandler.CancelOrder)
|
||||
|
||||
// 管理员路由
|
||||
r.POST("/api/admin/verify", adminHandler.VerifyAdminToken)
|
||||
r.GET("/api/admin/products", adminHandler.ListAllProducts)
|
||||
r.POST("/api/admin/products", adminHandler.CreateProduct)
|
||||
@@ -254,6 +267,7 @@ func main() {
|
||||
r.DELETE("/api/admin/orders/:id", adminHandler.DeleteOrder)
|
||||
r.GET("/api/admin/system-status", statusHandler.GetSystemStatus)
|
||||
|
||||
// 收藏夹路由
|
||||
r.GET("/api/wishlist", wishlistHandler.GetWishlist)
|
||||
r.POST("/api/wishlist", wishlistHandler.AddToWishlist)
|
||||
r.DELETE("/api/wishlist/:id", wishlistHandler.RemoveFromWishlist)
|
||||
|
||||
@@ -193,12 +193,13 @@
|
||||
}
|
||||
|
||||
.product-card-description {
|
||||
min-height: 46px;
|
||||
flex: 1 1 auto;
|
||||
min-height: 86px;
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-line-clamp: 6;
|
||||
}
|
||||
|
||||
@media (min-width: 720px) {
|
||||
@@ -235,7 +236,8 @@
|
||||
}
|
||||
|
||||
.product-card-description {
|
||||
min-height: 38px;
|
||||
min-height: 62px;
|
||||
-webkit-line-clamp: 4;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -56,6 +56,7 @@
|
||||
</template>
|
||||
<template v-else>
|
||||
<CheckoutOrderHeader
|
||||
v-if="confirmed || needPayChannel || !(confirming && !needPayChannel)"
|
||||
:order-id="orderResult.orderId"
|
||||
:is-mengya-pending-pay="isMengyaPendingPay"
|
||||
:copy-hint="orderIdCopyHint"
|
||||
@@ -68,6 +69,13 @@
|
||||
:is-manual-delivery="isManualDelivery"
|
||||
/>
|
||||
|
||||
<p
|
||||
v-else-if="confirming && !needPayChannel"
|
||||
class="tag text-[15px] py-8 m-0 text-center"
|
||||
>
|
||||
正在加载购买内容…
|
||||
</p>
|
||||
|
||||
<CheckoutAwaitingFulfillment
|
||||
v-else
|
||||
:order-result="orderResult"
|
||||
@@ -123,6 +131,38 @@ import CheckoutOrderDone from './components/CheckoutOrderDone.vue'
|
||||
/** Shown alone when mengya poll reports cancelled (timeout). Keep in sync with onCancelled below. */
|
||||
const MENGYA_PAYMENT_TIMEOUT_CANCEL_MSG = '订单未在时限内到账,已自动取消'
|
||||
|
||||
/** 统一后端字段(含 snake_case / 大小写差异),并保证数组类型安全 */
|
||||
function normalizeCheckoutResponse(raw) {
|
||||
if (!raw || typeof raw !== 'object') return {}
|
||||
const orderId = raw.orderId ?? raw.order_id
|
||||
const status = raw.status ?? raw.Status ?? ''
|
||||
const rawCodes = raw.deliveredCodes ?? raw.delivered_codes
|
||||
const codes = Array.isArray(rawCodes) ? rawCodes : []
|
||||
const deliveryMode = raw.deliveryMode ?? raw.delivery_mode ?? 'auto'
|
||||
const rawManual = raw.isManual ?? raw.is_manual
|
||||
const isManual = typeof rawManual === 'boolean' ? rawManual : deliveryMode === 'manual'
|
||||
const pay = raw.paymentExpectedTotal ?? raw.payment_expected_total
|
||||
const next = {
|
||||
...raw,
|
||||
orderId,
|
||||
status,
|
||||
deliveredCodes: codes,
|
||||
deliveryMode,
|
||||
isManual
|
||||
}
|
||||
if (typeof pay === 'number') next.paymentExpectedTotal = pay
|
||||
return next
|
||||
}
|
||||
|
||||
/** 结账接口已返回 completed 且含发货结果时,无需再点「确认领取」 */
|
||||
function canRevealCheckoutImmediately(r) {
|
||||
const st = String(r.status || '').toLowerCase()
|
||||
if (!r.orderId || st !== 'completed') return false
|
||||
if ((r.deliveredCodes || []).length > 0) return true
|
||||
if (r.isManual || r.deliveryMode === 'manual') return true
|
||||
return false
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const product = ref(null)
|
||||
@@ -180,10 +220,11 @@ async function finalizeOrderFromServer() {
|
||||
if (!orderResult.value?.orderId) return false
|
||||
try {
|
||||
const result = await confirmOrder(orderResult.value.orderId)
|
||||
deliveredCodes.value = result.deliveredCodes || []
|
||||
isManualDelivery.value = result.isManual || result.deliveryMode === 'manual'
|
||||
const normalized = normalizeCheckoutResponse(result)
|
||||
deliveredCodes.value = normalized.deliveredCodes || []
|
||||
isManualDelivery.value = normalized.isManual || normalized.deliveryMode === 'manual'
|
||||
confirmed.value = true
|
||||
orderResult.value = { ...orderResult.value, ...result }
|
||||
orderResult.value = { ...orderResult.value, ...normalized }
|
||||
stopPollingPayment()
|
||||
stopPaymentCountdown()
|
||||
return true
|
||||
@@ -244,13 +285,42 @@ const submitOrder = async () => {
|
||||
notifyEmail: authState.email || ''
|
||||
}
|
||||
const token = isLoggedIn() ? authState.token : null
|
||||
const response = await createOrder(payload, token)
|
||||
orderResult.value = response
|
||||
const response = normalizeCheckoutResponse(await createOrder(payload, token))
|
||||
if (typeof response.paymentExpectedTotal === 'number') {
|
||||
paidAmountSnapshot.value = response.paymentExpectedTotal
|
||||
} else {
|
||||
paidAmountSnapshot.value = unitPrice.value
|
||||
}
|
||||
|
||||
if (canRevealCheckoutImmediately(response)) {
|
||||
orderResult.value = response
|
||||
deliveredCodes.value = response.deliveredCodes || []
|
||||
isManualDelivery.value = response.isManual || response.deliveryMode === 'manual'
|
||||
confirmed.value = true
|
||||
stopPollingPayment()
|
||||
stopPaymentCountdown()
|
||||
} else if (response.orderId && String(response.status || '').toLowerCase() === 'completed') {
|
||||
orderResult.value = response
|
||||
confirming.value = true
|
||||
confirmError.value = ''
|
||||
try {
|
||||
const result = await confirmOrder(response.orderId)
|
||||
orderResult.value = { ...response, ...normalizeCheckoutResponse(result) }
|
||||
deliveredCodes.value = orderResult.value.deliveredCodes || []
|
||||
isManualDelivery.value =
|
||||
orderResult.value.isManual || orderResult.value.deliveryMode === 'manual'
|
||||
confirmed.value = true
|
||||
stopPollingPayment()
|
||||
stopPaymentCountdown()
|
||||
} catch (err) {
|
||||
confirmError.value = err?.response?.data?.error || '加载购买内容失败,请点击「确认领取」重试'
|
||||
confirmed.value = false
|
||||
} finally {
|
||||
confirming.value = false
|
||||
}
|
||||
} else {
|
||||
orderResult.value = response
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err?.response?.data?.error || '下单失败,请稍候再试'
|
||||
} finally {
|
||||
@@ -264,10 +334,11 @@ const doConfirm = async () => {
|
||||
confirming.value = true
|
||||
try {
|
||||
const result = await confirmOrder(orderResult.value.orderId)
|
||||
deliveredCodes.value = result.deliveredCodes || []
|
||||
isManualDelivery.value = result.isManual || result.deliveryMode === 'manual'
|
||||
const normalized = normalizeCheckoutResponse(result)
|
||||
deliveredCodes.value = normalized.deliveredCodes || []
|
||||
isManualDelivery.value = normalized.isManual || normalized.deliveryMode === 'manual'
|
||||
confirmed.value = true
|
||||
orderResult.value = { ...orderResult.value, ...result }
|
||||
orderResult.value = { ...orderResult.value, ...normalized }
|
||||
stopPollingPayment()
|
||||
stopPaymentCountdown()
|
||||
} catch (err) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user