diff --git a/.java-test/.gitattributes b/.java-test/.gitattributes new file mode 100644 index 0000000..3b41682 --- /dev/null +++ b/.java-test/.gitattributes @@ -0,0 +1,2 @@ +/mvnw text eol=lf +*.cmd text eol=crlf diff --git a/.java-test/.gitignore b/.java-test/.gitignore new file mode 100644 index 0000000..667aaef --- /dev/null +++ b/.java-test/.gitignore @@ -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/ diff --git a/.java-test/.mvn/wrapper/maven-wrapper.properties b/.java-test/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 0000000..c595b00 --- /dev/null +++ b/.java-test/.mvn/wrapper/maven-wrapper.properties @@ -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 diff --git a/.java-test/compose.yaml b/.java-test/compose.yaml new file mode 100644 index 0000000..4c71203 --- /dev/null +++ b/.java-test/compose.yaml @@ -0,0 +1 @@ +services: { } diff --git a/.java-test/mvnw b/.java-test/mvnw new file mode 100644 index 0000000..bd8896b --- /dev/null +++ b/.java-test/mvnw @@ -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-,maven-mvnd--}/ +[ -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 "$@" diff --git a/.java-test/mvnw.cmd b/.java-test/mvnw.cmd new file mode 100644 index 0000000..92450f9 --- /dev/null +++ b/.java-test/mvnw.cmd @@ -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-,maven-mvnd--}/ +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" diff --git a/.java-test/pom.xml b/.java-test/pom.xml new file mode 100644 index 0000000..503ea79 --- /dev/null +++ b/.java-test/pom.xml @@ -0,0 +1,121 @@ + + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 3.5.12 + + + com.smyhub.store + mengyastore-backend-java + 0.0.1-SNAPSHOT + mengyastore-backend-java + 萌芽小店 Java Spring Boot 后端 + + 17 + + + + + org.springframework.boot + spring-boot-starter-web + + + + + org.springframework.boot + spring-boot-starter-data-jpa + + + + + com.mysql + mysql-connector-j + runtime + + + + + org.springframework.boot + spring-boot-starter-amqp + + + + + org.springframework.boot + spring-boot-starter-data-redis + + + + + org.springframework.boot + spring-boot-starter-validation + + + + + org.springframework.boot + spring-boot-starter-mail + + + + + org.springframework.boot + spring-boot-starter-actuator + + + + + org.projectlombok + lombok + true + + + + + org.springframework.boot + spring-boot-devtools + runtime + true + + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + + org.projectlombok + lombok + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + org.projectlombok + lombok + + + + + + + + diff --git a/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/MengyastoreBackendJavaApplication.java b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/MengyastoreBackendJavaApplication.java new file mode 100644 index 0000000..c247a17 --- /dev/null +++ b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/MengyastoreBackendJavaApplication.java @@ -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); + } +} diff --git a/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/config/AppProperties.java b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/config/AppProperties.java new file mode 100644 index 0000000..733c7f7 --- /dev/null +++ b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/config/AppProperties.java @@ -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"; +} diff --git a/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/config/CorsConfig.java b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/config/CorsConfig.java new file mode 100644 index 0000000..5932584 --- /dev/null +++ b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/config/CorsConfig.java @@ -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 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); + } +} diff --git a/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/config/RabbitMQConfig.java b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/config/RabbitMQConfig.java new file mode 100644 index 0000000..03b46bd --- /dev/null +++ b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/config/RabbitMQConfig.java @@ -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); + } +} diff --git a/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/config/RedisConfig.java b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/config/RedisConfig.java new file mode 100644 index 0000000..2f65a2e --- /dev/null +++ b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/config/RedisConfig.java @@ -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); + } +} diff --git a/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/config/RestTemplateConfig.java b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/config/RestTemplateConfig.java new file mode 100644 index 0000000..c2470bb --- /dev/null +++ b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/config/RestTemplateConfig.java @@ -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(); + } +} diff --git a/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/config/WebMvcConfig.java b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/config/WebMvcConfig.java new file mode 100644 index 0000000..def68e4 --- /dev/null +++ b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/config/WebMvcConfig.java @@ -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" + ); + } +} diff --git a/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/controller/ChatController.java b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/controller/ChatController.java new file mode 100644 index 0000000..c8d7935 --- /dev/null +++ b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/controller/ChatController.java @@ -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>> getMessages() { + String account = CurrentUserHolder.get().getAccount(); + return ResponseEntity.ok(ApiResponse.ok(chatService.getUserMessages(account))); + } + + @PostMapping("/messages") + public ResponseEntity> 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)); + } +} diff --git a/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/controller/HealthController.java b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/controller/HealthController.java new file mode 100644 index 0000000..920e33d --- /dev/null +++ b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/controller/HealthController.java @@ -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> 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"; + } + } +} diff --git a/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/controller/PublicOrderController.java b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/controller/PublicOrderController.java new file mode 100644 index 0000000..0b9288b --- /dev/null +++ b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/controller/PublicOrderController.java @@ -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>> 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(); + 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>> 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; + } +} diff --git a/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/controller/PublicProductController.java b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/controller/PublicProductController.java new file mode 100644 index 0000000..b0b53b2 --- /dev/null +++ b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/controller/PublicProductController.java @@ -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>> listProducts() { + return ResponseEntity.ok(ApiResponse.ok(productService.listPublicProducts())); + } + + @PostMapping("/products/{id}/view") + public ResponseEntity>> 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() : ""; + } +} diff --git a/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/controller/StatsController.java b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/controller/StatsController.java new file mode 100644 index 0000000..9b5ad4b --- /dev/null +++ b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/controller/StatsController.java @@ -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> 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>> recordVisit() { + int total = siteService.incrementVisits(); + return ResponseEntity.ok(ApiResponse.ok(Map.of("total", total, "counted", true))); + } + + @GetMapping("/site/maintenance") + public ResponseEntity> getMaintenance() { + return ResponseEntity.ok(ApiResponse.ok(siteService.getMaintenance())); + } +} diff --git a/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/controller/UserOrderController.java b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/controller/UserOrderController.java new file mode 100644 index 0000000..e94a208 --- /dev/null +++ b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/controller/UserOrderController.java @@ -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>> myOrders() { + String account = CurrentUserHolder.get().getAccount(); + return ResponseEntity.ok(ApiResponse.ok(orderService.getMyOrders(account))); + } +} diff --git a/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/controller/WishlistController.java b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/controller/WishlistController.java new file mode 100644 index 0000000..c28cc1f --- /dev/null +++ b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/controller/WishlistController.java @@ -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>>> getWishlist() { + String account = CurrentUserHolder.get().getAccount(); + List items = wishlistService.getWishlistIds(account); + return ResponseEntity.ok(ApiResponse.ok(Map.of("items", items))); + } + + @PostMapping + public ResponseEntity>>> addToWishlist( + @RequestBody Map body) { + String productId = body.get("productId"); + String account = CurrentUserHolder.get().getAccount(); + wishlistService.addToWishlist(account, productId); + List items = wishlistService.getWishlistIds(account); + return ResponseEntity.ok(ApiResponse.ok(Map.of("items", items))); + } + + @DeleteMapping("/{productId}") + public ResponseEntity>>> removeFromWishlist( + @PathVariable String productId) { + String account = CurrentUserHolder.get().getAccount(); + wishlistService.removeFromWishlist(account, productId); + List items = wishlistService.getWishlistIds(account); + return ResponseEntity.ok(ApiResponse.ok(Map.of("items", items))); + } +} diff --git a/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/controller/admin/AdminChatController.java b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/controller/admin/AdminChatController.java new file mode 100644 index 0000000..5f29d53 --- /dev/null +++ b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/controller/admin/AdminChatController.java @@ -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>>> listConversations() { + return ResponseEntity.ok(ApiResponse.ok(chatService.listAllConversations())); + } + + @GetMapping("/{account}") + public ResponseEntity>> getConversation( + @PathVariable String account) { + return ResponseEntity.ok(ApiResponse.ok(chatService.getConversation(account))); + } + + @PostMapping("/{account}") + public ResponseEntity> 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> clearConversation(@PathVariable String account) { + chatService.clearConversation(account); + return ResponseEntity.ok(ApiResponse.ok(null)); + } +} diff --git a/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/controller/admin/AdminOrderController.java b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/controller/admin/AdminOrderController.java new file mode 100644 index 0000000..b77ce5e --- /dev/null +++ b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/controller/admin/AdminOrderController.java @@ -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>> listOrders() { + return ResponseEntity.ok(ApiResponse.ok(orderService.listAllOrders())); + } + + @DeleteMapping("/{id}") + public ResponseEntity> deleteOrder(@PathVariable String id) { + orderService.deleteOrder(id); + return ResponseEntity.ok(ApiResponse.ok(null)); + } +} diff --git a/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/controller/admin/AdminProductController.java b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/controller/admin/AdminProductController.java new file mode 100644 index 0000000..f8c9ceb --- /dev/null +++ b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/controller/admin/AdminProductController.java @@ -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>> listProducts() { + return ResponseEntity.ok(ApiResponse.ok(productService.listAllProducts())); + } + + @PostMapping + public ResponseEntity> createProduct( + @Valid @RequestBody AdminProductRequest req) { + return ResponseEntity.ok(ApiResponse.ok(productService.createProduct(req))); + } + + @PutMapping("/{id}") + public ResponseEntity> updateProduct( + @PathVariable String id, + @Valid @RequestBody AdminProductRequest req) { + return ResponseEntity.ok(ApiResponse.ok(productService.updateProduct(id, req))); + } + + @PatchMapping("/{id}/status") + public ResponseEntity> toggleStatus( + @PathVariable String id, + @RequestBody ProductStatusRequest req) { + return ResponseEntity.ok(ApiResponse.ok(productService.toggleStatus(id, req.isActive()))); + } + + @DeleteMapping("/{id}") + public ResponseEntity> deleteProduct(@PathVariable String id) { + productService.deleteProduct(id); + return ResponseEntity.ok(ApiResponse.ok(null)); + } +} diff --git a/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/controller/admin/AdminSiteController.java b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/controller/admin/AdminSiteController.java new file mode 100644 index 0000000..aa194b9 --- /dev/null +++ b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/controller/admin/AdminSiteController.java @@ -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> setMaintenance( + @RequestBody MaintenanceRequest req) { + siteService.setMaintenance(req.isEnabled(), req.getReason()); + return ResponseEntity.ok(ApiResponse.ok(siteService.getMaintenance())); + } + + @GetMapping("/smtp") + public ResponseEntity> getSmtp() { + return ResponseEntity.ok(ApiResponse.ok(siteService.getSmtpConfigResponse(true))); + } + + @PostMapping("/smtp") + public ResponseEntity> setSmtp( + @RequestBody SmtpConfigRequest req) { + siteService.setSmtpConfig(req); + return ResponseEntity.ok(ApiResponse.ok(siteService.getSmtpConfigResponse(true))); + } +} diff --git a/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/controller/admin/AdminStatusController.java b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/controller/admin/AdminStatusController.java new file mode 100644 index 0000000..8c12b34 --- /dev/null +++ b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/controller/admin/AdminStatusController.java @@ -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>> getSystemStatus(HttpServletRequest request) { + return ResponseEntity.ok(ApiResponse.ok(systemStatusService.buildStatus(request))); + } +} diff --git a/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/controller/admin/AdminVerifyController.java b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/controller/admin/AdminVerifyController.java new file mode 100644 index 0000000..0f3eb69 --- /dev/null +++ b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/controller/admin/AdminVerifyController.java @@ -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> 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)); + } +} diff --git a/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/dto/request/AdminChatMessageRequest.java b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/dto/request/AdminChatMessageRequest.java new file mode 100644 index 0000000..61fbc0c --- /dev/null +++ b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/dto/request/AdminChatMessageRequest.java @@ -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; +} diff --git a/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/dto/request/AdminProductRequest.java b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/dto/request/AdminProductRequest.java new file mode 100644 index 0000000..a802d04 --- /dev/null +++ b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/dto/request/AdminProductRequest.java @@ -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 tags = new ArrayList<>(); + + private String coverUrl; + + private List 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 codes = new ArrayList<>(); +} diff --git a/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/dto/request/AdminVerifyRequest.java b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/dto/request/AdminVerifyRequest.java new file mode 100644 index 0000000..93bfaf2 --- /dev/null +++ b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/dto/request/AdminVerifyRequest.java @@ -0,0 +1,8 @@ +package com.smyhub.store.mengyastorebackendjava.dto.request; + +import lombok.Data; + +@Data +public class AdminVerifyRequest { + private String token; +} diff --git a/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/dto/request/ChatMessageRequest.java b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/dto/request/ChatMessageRequest.java new file mode 100644 index 0000000..f175ab0 --- /dev/null +++ b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/dto/request/ChatMessageRequest.java @@ -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; +} diff --git a/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/dto/request/CheckoutRequest.java b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/dto/request/CheckoutRequest.java new file mode 100644 index 0000000..b2dc47d --- /dev/null +++ b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/dto/request/CheckoutRequest.java @@ -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; +} diff --git a/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/dto/request/MaintenanceRequest.java b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/dto/request/MaintenanceRequest.java new file mode 100644 index 0000000..7a79769 --- /dev/null +++ b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/dto/request/MaintenanceRequest.java @@ -0,0 +1,9 @@ +package com.smyhub.store.mengyastorebackendjava.dto.request; + +import lombok.Data; + +@Data +public class MaintenanceRequest { + private boolean enabled; + private String reason = ""; +} diff --git a/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/dto/request/ProductStatusRequest.java b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/dto/request/ProductStatusRequest.java new file mode 100644 index 0000000..d2867cf --- /dev/null +++ b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/dto/request/ProductStatusRequest.java @@ -0,0 +1,8 @@ +package com.smyhub.store.mengyastorebackendjava.dto.request; + +import lombok.Data; + +@Data +public class ProductStatusRequest { + private boolean active; +} diff --git a/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/dto/request/SmtpConfigRequest.java b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/dto/request/SmtpConfigRequest.java new file mode 100644 index 0000000..74fc31f --- /dev/null +++ b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/dto/request/SmtpConfigRequest.java @@ -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"; +} diff --git a/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/dto/response/ApiResponse.java b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/dto/response/ApiResponse.java new file mode 100644 index 0000000..8dc3ff2 --- /dev/null +++ b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/dto/response/ApiResponse.java @@ -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 { + + private final T data; + private final String error; + + private ApiResponse(T data, String error) { + this.data = data; + this.error = error; + } + + public static ApiResponse ok(T data) { + return new ApiResponse<>(data, null); + } + + public static ApiResponse error(String message) { + return new ApiResponse<>(null, message); + } +} diff --git a/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/dto/response/ChatMessageResponse.java b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/dto/response/ChatMessageResponse.java new file mode 100644 index 0000000..4357e51 --- /dev/null +++ b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/dto/response/ChatMessageResponse.java @@ -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; +} diff --git a/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/dto/response/HealthResponse.java b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/dto/response/HealthResponse.java new file mode 100644 index 0000000..968bb44 --- /dev/null +++ b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/dto/response/HealthResponse.java @@ -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; +} diff --git a/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/dto/response/MaintenanceResponse.java b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/dto/response/MaintenanceResponse.java new file mode 100644 index 0000000..81f7d79 --- /dev/null +++ b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/dto/response/MaintenanceResponse.java @@ -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; +} diff --git a/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/dto/response/OrderResponse.java b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/dto/response/OrderResponse.java new file mode 100644 index 0000000..8c45825 --- /dev/null +++ b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/dto/response/OrderResponse.java @@ -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 deliveredCodes; + private String status; + private String deliveryMode; + private String note; + private String contactPhone; + private String contactEmail; + private String notifyEmail; + private LocalDateTime createdAt; +} diff --git a/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/dto/response/ProductResponse.java b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/dto/response/ProductResponse.java new file mode 100644 index 0000000..0081ee0 --- /dev/null +++ b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/dto/response/ProductResponse.java @@ -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 tags; + private int quantity; + private String coverUrl; + private List screenshotUrls; + private String verificationUrl; + private List 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; +} diff --git a/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/dto/response/SmtpConfigResponse.java b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/dto/response/SmtpConfigResponse.java new file mode 100644 index 0000000..a3e2ed7 --- /dev/null +++ b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/dto/response/SmtpConfigResponse.java @@ -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; +} diff --git a/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/dto/response/StatsResponse.java b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/dto/response/StatsResponse.java new file mode 100644 index 0000000..61c4d4d --- /dev/null +++ b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/dto/response/StatsResponse.java @@ -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; +} diff --git a/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/dto/response/WishlistResponse.java b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/dto/response/WishlistResponse.java new file mode 100644 index 0000000..6f7dd60 --- /dev/null +++ b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/dto/response/WishlistResponse.java @@ -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; +} diff --git a/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/entity/ChatMessage.java b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/entity/ChatMessage.java new file mode 100644 index 0000000..9c838a9 --- /dev/null +++ b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/entity/ChatMessage.java @@ -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; +} diff --git a/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/entity/Order.java b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/entity/Order.java new file mode 100644 index 0000000..37f5aae --- /dev/null +++ b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/entity/Order.java @@ -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 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; +} diff --git a/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/entity/Product.java b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/entity/Product.java new file mode 100644 index 0000000..b53834b --- /dev/null +++ b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/entity/Product.java @@ -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 tags = new ArrayList<>(); + + @Column(length = 500) + private String coverUrl; + + @Convert(converter = StringListConverter.class) + @Column(columnDefinition = "json") + private List 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; +} diff --git a/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/entity/ProductCode.java b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/entity/ProductCode.java new file mode 100644 index 0000000..52373df --- /dev/null +++ b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/entity/ProductCode.java @@ -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; + } +} diff --git a/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/entity/SiteSetting.java b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/entity/SiteSetting.java new file mode 100644 index 0000000..9862ec3 --- /dev/null +++ b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/entity/SiteSetting.java @@ -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; + } +} diff --git a/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/entity/Wishlist.java b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/entity/Wishlist.java new file mode 100644 index 0000000..241730f --- /dev/null +++ b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/entity/Wishlist.java @@ -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; + } +} diff --git a/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/entity/converter/StringListConverter.java b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/entity/converter/StringListConverter.java new file mode 100644 index 0000000..4e41cb5 --- /dev/null +++ b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/entity/converter/StringListConverter.java @@ -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, String> { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + @Override + public String convertToDatabaseColumn(List attribute) { + if (attribute == null) { + return "[]"; + } + try { + return MAPPER.writeValueAsString(attribute); + } catch (Exception e) { + return "[]"; + } + } + + @Override + public List convertToEntityAttribute(String dbData) { + if (dbData == null || dbData.isBlank()) { + return new ArrayList<>(); + } + try { + return MAPPER.readValue(dbData, new TypeReference<>() {}); + } catch (Exception e) { + return new ArrayList<>(); + } + } +} diff --git a/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/exception/BusinessException.java b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/exception/BusinessException.java new file mode 100644 index 0000000..0820ba6 --- /dev/null +++ b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/exception/BusinessException.java @@ -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; + } +} diff --git a/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/exception/GlobalExceptionHandler.java b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..8909453 --- /dev/null +++ b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/exception/GlobalExceptionHandler.java @@ -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> handleBusiness(BusinessException ex) { + return ResponseEntity.status(ex.getStatus()).body(ApiResponse.error(ex.getMessage())); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity> 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> handleGeneric(Exception ex) { + log.error("Unhandled exception", ex); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(ApiResponse.error("服务器内部错误")); + } +} diff --git a/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/mq/OrderEmailConsumer.java b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/mq/OrderEmailConsumer.java new file mode 100644 index 0000000..5ac3096 --- /dev/null +++ b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/mq/OrderEmailConsumer.java @@ -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); + } + } + } +} diff --git a/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/mq/OrderEmailPayload.java b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/mq/OrderEmailPayload.java new file mode 100644 index 0000000..1fc88fc --- /dev/null +++ b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/mq/OrderEmailPayload.java @@ -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 codes; + private boolean isManual; +} diff --git a/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/mq/OrderEmailProducer.java b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/mq/OrderEmailProducer.java new file mode 100644 index 0000000..dd0689c --- /dev/null +++ b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/mq/OrderEmailProducer.java @@ -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; + } + } +} diff --git a/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/mq/RabbitMQTopology.java b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/mq/RabbitMQTopology.java new file mode 100644 index 0000000..7db7820 --- /dev/null +++ b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/mq/RabbitMQTopology.java @@ -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"; + } +} diff --git a/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/repository/ChatMessageRepository.java b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/repository/ChatMessageRepository.java new file mode 100644 index 0000000..76c2bcf --- /dev/null +++ b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/repository/ChatMessageRepository.java @@ -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 { + + List findByAccountIdOrderBySentAtAsc(String accountId); + + List findAllByOrderByAccountIdAscSentAtAsc(); + + @Transactional + void deleteByAccountId(String accountId); +} diff --git a/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/repository/OrderRepository.java b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/repository/OrderRepository.java new file mode 100644 index 0000000..871c421 --- /dev/null +++ b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/repository/OrderRepository.java @@ -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 { + + List findByUserAccountOrderByCreatedAtDesc(String userAccount); + + List 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); +} diff --git a/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/repository/ProductCodeRepository.java b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/repository/ProductCodeRepository.java new file mode 100644 index 0000000..44181b7 --- /dev/null +++ b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/repository/ProductCodeRepository.java @@ -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 { + + List findByProductId(String productId); + + long countByProductId(String productId); + + @Transactional + void deleteByProductId(String productId); +} diff --git a/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/repository/ProductRepository.java b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/repository/ProductRepository.java new file mode 100644 index 0000000..79a3065 --- /dev/null +++ b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/repository/ProductRepository.java @@ -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 { + + List findByActiveTrueOrderByCreatedAtDesc(); + + List 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); +} diff --git a/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/repository/SiteSettingRepository.java b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/repository/SiteSettingRepository.java new file mode 100644 index 0000000..4412918 --- /dev/null +++ b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/repository/SiteSettingRepository.java @@ -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 { +} diff --git a/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/repository/WishlistRepository.java b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/repository/WishlistRepository.java new file mode 100644 index 0000000..0dce62b --- /dev/null +++ b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/repository/WishlistRepository.java @@ -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 { + + List findByAccountId(String accountId); + + Optional findByAccountIdAndProductId(String accountId, String productId); + + boolean existsByAccountIdAndProductId(String accountId, String productId); + + @Transactional + void deleteByAccountIdAndProductId(String accountId, String productId); +} diff --git a/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/security/AdminTokenInterceptor.java b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/security/AdminTokenInterceptor.java new file mode 100644 index 0000000..4d3de58 --- /dev/null +++ b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/security/AdminTokenInterceptor.java @@ -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"); + } +} diff --git a/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/security/BearerTokenInterceptor.java b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/security/BearerTokenInterceptor.java new file mode 100644 index 0000000..bd26c46 --- /dev/null +++ b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/security/BearerTokenInterceptor.java @@ -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; + } +} diff --git a/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/security/CurrentUserHolder.java b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/security/CurrentUserHolder.java new file mode 100644 index 0000000..84171cb --- /dev/null +++ b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/security/CurrentUserHolder.java @@ -0,0 +1,20 @@ +package com.smyhub.store.mengyastorebackendjava.security; + +public final class CurrentUserHolder { + + private static final ThreadLocal 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(); + } +} diff --git a/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/security/SproutGateUser.java b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/security/SproutGateUser.java new file mode 100644 index 0000000..982b541 --- /dev/null +++ b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/security/SproutGateUser.java @@ -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; +} diff --git a/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/security/SproutGateVerifyResult.java b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/security/SproutGateVerifyResult.java new file mode 100644 index 0000000..47efdc5 --- /dev/null +++ b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/security/SproutGateVerifyResult.java @@ -0,0 +1,11 @@ +package com.smyhub.store.mengyastorebackendjava.security; + +import lombok.Data; + +@Data +public class SproutGateVerifyResult { + + private boolean valid; + + private SproutGateUser user; +} diff --git a/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/service/ChatService.java b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/service/ChatService.java new file mode 100644 index 0000000..a2022bd --- /dev/null +++ b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/service/ChatService.java @@ -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 lastSentMap = new ConcurrentHashMap<>(); + + public List 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> listAllConversations() { + Map> result = new LinkedHashMap<>(); + chatMessageRepository.findAllByOrderByAccountIdAscSentAtAsc().forEach(row -> { + result.computeIfAbsent(row.getAccountId(), k -> new ArrayList<>()).add(toResponse(row)); + }); + return result; + } + + public List 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(); + } +} diff --git a/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/service/EmailService.java b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/service/EmailService.java new file mode 100644 index 0000000..882172d --- /dev/null +++ b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/service/EmailService.java @@ -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 codes; + public boolean isManual; + } +} diff --git a/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/service/OrderService.java b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/service/OrderService.java new file mode 100644 index 0000000..e5a1e09 --- /dev/null +++ b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/service/OrderService.java @@ -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 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 getMyOrders(String userAccount) { + return orderRepository.findByUserAccountOrderByCreatedAtDesc(userAccount) + .stream().map(this::toResponse).toList(); + } + + public List 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 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 deliveredCodes, + String deliveryMode) { + } +} diff --git a/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/service/ProductService.java b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/service/ProductService.java new file mode 100644 index 0000000..f131593 --- /dev/null +++ b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/service/ProductService.java @@ -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 recentViews = new ConcurrentHashMap<>(); + + public List listPublicProducts() { + return productRepository.findByActiveTrueOrderByCreatedAtDesc().stream() + .map(p -> toResponse(p, loadCodeCount(p), false, false)) + .toList(); + } + + public List listAllProducts() { + return productRepository.findAllByOrderByCreatedAtDesc().stream() + .map(p -> toResponse(p, loadCodeCount(p), true, true)) + .toList(); + } + + public ProductResponse getProductForAdmin(String id) { + Product p = findById(id); + List 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 popCodes(String productId, int count) { + List codes = productCodeRepository.findByProductId(productId); + if (codes.size() < count) { + throw new BusinessException("卡密不足"); + } + List 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 codes) { + productCodeRepository.deleteByProductId(productId); + if (!codes.isEmpty()) { + List 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 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 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 sanitizeCodes(List codes) { + if (codes == null) return new ArrayList<>(); + Set 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 sanitizeTags(List tags) { + if (tags == null) return new ArrayList<>(); + Set 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)); + } +} diff --git a/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/service/SiteService.java b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/service/SiteService.java new file mode 100644 index 0000000..56080d6 --- /dev/null +++ b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/service/SiteService.java @@ -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(); + } + } +} diff --git a/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/service/SproutGateAuthService.java b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/service/SproutGateAuthService.java new file mode 100644 index 0000000..3ff175f --- /dev/null +++ b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/service/SproutGateAuthService.java @@ -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 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; + } +} diff --git a/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/service/SystemStatusService.java b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/service/SystemStatusService.java new file mode 100644 index 0000000..d0aae74 --- /dev/null +++ b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/service/SystemStatusService.java @@ -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 buildStatus(HttpServletRequest request) { + Map root = new LinkedHashMap<>(); + root.put("backend", backendInfo(request)); + root.put("mysql", mysqlInfo()); + root.put("redis", redisInfo()); + root.put("rabbitmq", rabbitmqInfo()); + return root; + } + + private Map backendInfo(HttpServletRequest request) { + Map 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 mysqlInfo() { + Map 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 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 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 redisInfo() { + Map 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 rabbitmqInfo() { + String env = appProperties.getRabbitmqEnv(); + Map 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; + } +} diff --git a/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/service/WishlistService.java b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/service/WishlistService.java new file mode 100644 index 0000000..026bac1 --- /dev/null +++ b/.java-test/src/main/java/com/smyhub/store/mengyastorebackendjava/service/WishlistService.java @@ -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 getWishlistIds(String accountId) { + return wishlistRepository.findByAccountId(accountId).stream() + .map(Wishlist::getProductId) + .toList(); + } + + public List 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(); + } +} diff --git a/.java-test/src/main/resources/application.yaml b/.java-test/src/main/resources/application.yaml new file mode 100644 index 0000000..6bcf749 --- /dev/null +++ b/.java-test/src/main/resources/application.yaml @@ -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 diff --git a/.java-test/src/test/java/com/smyhub/store/mengyastorebackendjava/MengyastoreBackendJavaApplicationTests.java b/.java-test/src/test/java/com/smyhub/store/mengyastorebackendjava/MengyastoreBackendJavaApplicationTests.java new file mode 100644 index 0000000..60c913a --- /dev/null +++ b/.java-test/src/test/java/com/smyhub/store/mengyastorebackendjava/MengyastoreBackendJavaApplicationTests.java @@ -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() { + } + +} diff --git a/.java-test/后端文档.md b/.java-test/后端文档.md new file mode 100644 index 0000000..3c59996 --- /dev/null +++ b/.java-test/后端文档.md @@ -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`,请指向本服务监听的 **协议 + 主机 + 端口**。 diff --git a/mengyastore-backend-go/.dockerignore b/mengyastore-backend-go/.dockerignore index 4c13373..6ce7dc6 100644 --- a/mengyastore-backend-go/.dockerignore +++ b/mengyastore-backend-go/.dockerignore @@ -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 diff --git a/mengyastore-backend-go/.gitignore b/mengyastore-backend-go/.gitignore index 5cf9eea..aa6bd75 100644 --- a/mengyastore-backend-go/.gitignore +++ b/mengyastore-backend-go/.gitignore @@ -4,3 +4,6 @@ .env.local .env.production *.exe + +# Windows 本地交叉编译产物(部署前生成,勿提交) +dist/mengyastore-backend-linux-amd64 diff --git a/mengyastore-backend-go/Dockerfile b/mengyastore-backend-go/Dockerfile index 9047a3c..11e01c0 100644 --- a/mengyastore-backend-go/Dockerfile +++ b/mengyastore-backend-go/Dockerfile @@ -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 diff --git a/mengyastore-backend-go/Makefile b/mengyastore-backend-go/Makefile index 3d5bc23..bbe22ef 100644 --- a/mengyastore-backend-go/Makefile +++ b/mengyastore-backend-go/Makefile @@ -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 diff --git a/mengyastore-backend-go/docker-compose.yml b/mengyastore-backend-go/docker-compose.yml index 90586f1..2647bd6 100644 --- a/mengyastore-backend-go/docker-compose.yml +++ b/mengyastore-backend-go/docker-compose.yml @@ -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 diff --git a/mengyastore-backend-go/docs/docs.go b/mengyastore-backend-go/docs/docs.go index e6c1863..8744450 100644 --- a/mengyastore-backend-go/docs/docs.go +++ b/mengyastore-backend-go/docs/docs.go @@ -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, } diff --git a/mengyastore-backend-go/docs/swagger.json b/mengyastore-backend-go/docs/swagger.json index a0cf097..8f08048 100644 --- a/mengyastore-backend-go/docs/swagger.json +++ b/mengyastore-backend-go/docs/swagger.json @@ -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" diff --git a/mengyastore-backend-go/docs/swagger.yaml b/mengyastore-backend-go/docs/swagger.yaml index 32dd346..568b121 100644 --- a/mengyastore-backend-go/docs/swagger.yaml +++ b/mengyastore-backend-go/docs/swagger.yaml @@ -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 `。返回该登录用户在客服系统中与管理员往来的全部消息列表。' 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 diff --git a/mengyastore-backend-go/internal/handlers/admin_chat.go b/mengyastore-backend-go/internal/handlers/admin_chat.go index e23a2e0..b8512a5 100644 --- a/mengyastore-backend-go/internal/handlers/admin_chat.go +++ b/mengyastore-backend-go/internal/handlers/admin_chat.go @@ -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 diff --git a/mengyastore-backend-go/internal/handlers/admin_orders.go b/mengyastore-backend-go/internal/handlers/admin_orders.go index ce56f53..911fd1b 100644 --- a/mengyastore-backend-go/internal/handlers/admin_orders.go +++ b/mengyastore-backend-go/internal/handlers/admin_orders.go @@ -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 diff --git a/mengyastore-backend-go/internal/handlers/admin_product.go b/mengyastore-backend-go/internal/handlers/admin_product.go index 454e665..56616a5 100644 --- a/mengyastore-backend-go/internal/handlers/admin_product.go +++ b/mengyastore-backend-go/internal/handlers/admin_product.go @@ -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 diff --git a/mengyastore-backend-go/internal/handlers/admin_site.go b/mengyastore-backend-go/internal/handlers/admin_site.go index 5670eae..dbf2722 100644 --- a/mengyastore-backend-go/internal/handlers/admin_site.go +++ b/mengyastore-backend-go/internal/handlers/admin_site.go @@ -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 diff --git a/mengyastore-backend-go/internal/handlers/admin_status.go b/mengyastore-backend-go/internal/handlers/admin_status.go index e5414da..6234b82 100644 --- a/mengyastore-backend-go/internal/handlers/admin_status.go +++ b/mengyastore-backend-go/internal/handlers/admin_status.go @@ -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 diff --git a/mengyastore-backend-go/internal/handlers/chat.go b/mengyastore-backend-go/internal/handlers/chat.go index ff2d76d..fae1a08 100644 --- a/mengyastore-backend-go/internal/handlers/chat.go +++ b/mengyastore-backend-go/internal/handlers/chat.go @@ -35,7 +35,8 @@ func (h *ChatHandler) requireChatUser(c *gin.Context) (account, name string, ok } // GetMyMessages 返回当前登录用户的全部聊天消息。 -// @Summary 我的聊天消息 +// @Summary 拉取当前用户聊天记录 +// @Description 需在请求头携带有效的 `Authorization: Bearer `。返回该登录用户在客服系统中与管理员往来的全部消息列表。 // @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 diff --git a/mengyastore-backend-go/internal/handlers/order.go b/mengyastore-backend-go/internal/handlers/order.go index 93cf428..4528919 100644 --- a/mengyastore-backend-go/internal/handlers/order.go +++ b/mengyastore-backend-go/internal/handlers/order.go @@ -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 diff --git a/mengyastore-backend-go/internal/handlers/public.go b/mengyastore-backend-go/internal/handlers/public.go index c512bb4..c9ffcd1 100644 --- a/mengyastore-backend-go/internal/handlers/public.go +++ b/mengyastore-backend-go/internal/handlers/public.go @@ -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] diff --git a/mengyastore-backend-go/internal/handlers/stats.go b/mengyastore-backend-go/internal/handlers/stats.go index 2a6316b..eac7b2a 100644 --- a/mengyastore-backend-go/internal/handlers/stats.go +++ b/mengyastore-backend-go/internal/handlers/stats.go @@ -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 diff --git a/mengyastore-backend-go/internal/handlers/wishlist.go b/mengyastore-backend-go/internal/handlers/wishlist.go index af8ce3f..a4c627d 100644 --- a/mengyastore-backend-go/internal/handlers/wishlist.go +++ b/mengyastore-backend-go/internal/handlers/wishlist.go @@ -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 diff --git a/mengyastore-backend-go/main.go b/mengyastore-backend-go/main.go index b14a08c..821777b 100644 --- a/mengyastore-backend-go/main.go +++ b/mengyastore-backend-go/main.go @@ -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) diff --git a/mengyastore-frontend/src/assets/styles.css b/mengyastore-frontend/src/assets/styles.css index ba168b4..336bf3a 100644 --- a/mengyastore-frontend/src/assets/styles.css +++ b/mengyastore-frontend/src/assets/styles.css @@ -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; } } diff --git a/mengyastore-frontend/src/modules/store/CheckoutPage.vue b/mengyastore-frontend/src/modules/store/CheckoutPage.vue index 1e1ae24..cbb807e 100644 --- a/mengyastore-frontend/src/modules/store/CheckoutPage.vue +++ b/mengyastore-frontend/src/modules/store/CheckoutPage.vue @@ -56,6 +56,7 @@