1.0.1 初始化铜壶管理系统项目并配置容器化
This commit is contained in:
2
backend/.gitattributes
vendored
Normal file
2
backend/.gitattributes
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
/mvnw text eol=lf
|
||||
*.cmd text eol=crlf
|
||||
33
backend/.gitignore
vendored
Normal file
33
backend/.gitignore
vendored
Normal file
@@ -0,0 +1,33 @@
|
||||
HELP.md
|
||||
target/
|
||||
.mvn/wrapper/maven-wrapper.jar
|
||||
!**/src/main/**/target/
|
||||
!**/src/test/**/target/
|
||||
|
||||
### STS ###
|
||||
.apt_generated
|
||||
.classpath
|
||||
.factorypath
|
||||
.project
|
||||
.settings
|
||||
.springBeans
|
||||
.sts4-cache
|
||||
|
||||
### IntelliJ IDEA ###
|
||||
.idea
|
||||
*.iws
|
||||
*.iml
|
||||
*.ipr
|
||||
|
||||
### NetBeans ###
|
||||
/nbproject/private/
|
||||
/nbbuild/
|
||||
/dist/
|
||||
/nbdist/
|
||||
/.nb-gradle/
|
||||
build/
|
||||
!**/src/main/**/build/
|
||||
!**/src/test/**/build/
|
||||
|
||||
### VS Code ###
|
||||
.vscode/
|
||||
3
backend/.mvn/wrapper/maven-wrapper.properties
vendored
Normal file
3
backend/.mvn/wrapper/maven-wrapper.properties
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
wrapperVersion=3.3.4
|
||||
distributionType=only-script
|
||||
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.14/apache-maven-3.9.14-bin.zip
|
||||
33
backend/AGENTS.md
Normal file
33
backend/AGENTS.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# Backend Guidelines
|
||||
|
||||
## Scope
|
||||
- Applies to files under backend/.
|
||||
- Inherit the root [AGENTS.md](../AGENTS.md) and only override backend-specific expectations here.
|
||||
|
||||
## Stack
|
||||
- Spring Boot 2.7 + Java 11 + Spring Security + JWT + JDBC + SQLite.
|
||||
- Runtime configuration lives in [backend/src/main/resources/application.properties](src/main/resources/application.properties).
|
||||
- The primary business database is the SQLite file referenced by application.properties. Keep product images and label templates in file_asset BLOB fields.
|
||||
|
||||
## Build And Test
|
||||
- Run commands from backend/ and use the Maven Wrapper instead of a global Maven install.
|
||||
- Run tests from backend/: .\mvnw.cmd test
|
||||
- Start the backend locally from backend/: .\mvnw.cmd spring-boot:run "-Dspring-boot.run.arguments=--server.port=8081"
|
||||
- Prefer port 8081 for local runs because 8080 is commonly occupied in this environment.
|
||||
- Run backend tests after any change under backend/.
|
||||
|
||||
## Conventions
|
||||
- Preserve the current package split under [backend/src/main/java/com/teapot/system](src/main/java/com/teapot/system): auth, catalog, order, statistics, user, config, and common.
|
||||
- Keep the controller/service/repository layering intact. New persistence logic should generally live in focused JDBC repositories, not in controllers or ad hoc utility classes.
|
||||
- Keep JSON APIs wrapped in the common response model, while file downloads may continue using ResponseEntity<byte[]> where appropriate.
|
||||
- Keep security enforced through Spring Security and method-level authorization. Do not weaken backend permission checks because the frontend already hides an action.
|
||||
- Keep persistence SQLite-friendly: avoid introducing heavyweight ORM abstractions or storage patterns that assume another database engine.
|
||||
- Asset list endpoints should return metadata only; binary file retrieval remains a dedicated download path.
|
||||
- Tests must stay isolated from the real business database and should continue using target/ or other temporary SQLite files.
|
||||
|
||||
## Backend Business Rules
|
||||
- Pending orders can be edited or deleted; completed orders are read-only.
|
||||
- Completing an order requires express number handling and must keep stock deduction consistent with order item quantities.
|
||||
- Label downloads are generated by the backend from current order items and quantities, using the latest label template data stored in SQLite.
|
||||
- Product image primary/cover behavior, stock validation, and permission checks must stay consistent with frontend expectations.
|
||||
- Categories that do not require a detail page should not be forced into standard product-detail business logic.
|
||||
23
backend/Dockerfile
Normal file
23
backend/Dockerfile
Normal file
@@ -0,0 +1,23 @@
|
||||
FROM maven:3.8.7-eclipse-temurin-11 AS builder
|
||||
WORKDIR /build
|
||||
COPY pom.xml .
|
||||
# 预下载依赖,利用缓存层加速后续构建
|
||||
RUN mvn dependency:go-offline -B
|
||||
COPY src ./src
|
||||
# 打包并跳过测试
|
||||
RUN mvn package -DskipTests
|
||||
|
||||
FROM eclipse-temurin:11-jre-alpine
|
||||
WORKDIR /app
|
||||
COPY --from=builder /build/target/*.jar app.jar
|
||||
|
||||
# 暴露给前端的反向代理端口
|
||||
EXPOSE 8080
|
||||
|
||||
# 覆盖application.properties里的数据库地址,将其挂载到独立volume中
|
||||
ENV SPRING_DATASOURCE_URL=jdbc:sqlite:/app/data/teapot-system.db
|
||||
|
||||
# 挂载数据卷以持久化SQLite和潜在的图片文件
|
||||
VOLUME /app/data
|
||||
|
||||
ENTRYPOINT ["java", "-Dspring.datasource.url=${SPRING_DATASOURCE_URL}", "-Djava.security.egd=file:/dev/./urandom", "-jar", "app.jar"]
|
||||
295
backend/mvnw
vendored
Normal file
295
backend/mvnw
vendored
Normal file
@@ -0,0 +1,295 @@
|
||||
#!/bin/sh
|
||||
# ----------------------------------------------------------------------------
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Apache Maven Wrapper startup batch script, version 3.3.4
|
||||
#
|
||||
# Optional ENV vars
|
||||
# -----------------
|
||||
# JAVA_HOME - location of a JDK home dir, required when download maven via java source
|
||||
# MVNW_REPOURL - repo url base for downloading maven distribution
|
||||
# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
|
||||
# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
set -euf
|
||||
[ "${MVNW_VERBOSE-}" != debug ] || set -x
|
||||
|
||||
# OS specific support.
|
||||
native_path() { printf %s\\n "$1"; }
|
||||
case "$(uname)" in
|
||||
CYGWIN* | MINGW*)
|
||||
[ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")"
|
||||
native_path() { cygpath --path --windows "$1"; }
|
||||
;;
|
||||
esac
|
||||
|
||||
# set JAVACMD and JAVACCMD
|
||||
set_java_home() {
|
||||
# For Cygwin and MinGW, ensure paths are in Unix format before anything is touched
|
||||
if [ -n "${JAVA_HOME-}" ]; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ]; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD="$JAVA_HOME/jre/sh/java"
|
||||
JAVACCMD="$JAVA_HOME/jre/sh/javac"
|
||||
else
|
||||
JAVACMD="$JAVA_HOME/bin/java"
|
||||
JAVACCMD="$JAVA_HOME/bin/javac"
|
||||
|
||||
if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then
|
||||
echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2
|
||||
echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
else
|
||||
JAVACMD="$(
|
||||
'set' +e
|
||||
'unset' -f command 2>/dev/null
|
||||
'command' -v java
|
||||
)" || :
|
||||
JAVACCMD="$(
|
||||
'set' +e
|
||||
'unset' -f command 2>/dev/null
|
||||
'command' -v javac
|
||||
)" || :
|
||||
|
||||
if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then
|
||||
echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# hash string like Java String::hashCode
|
||||
hash_string() {
|
||||
str="${1:-}" h=0
|
||||
while [ -n "$str" ]; do
|
||||
char="${str%"${str#?}"}"
|
||||
h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296))
|
||||
str="${str#?}"
|
||||
done
|
||||
printf %x\\n $h
|
||||
}
|
||||
|
||||
verbose() { :; }
|
||||
[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; }
|
||||
|
||||
die() {
|
||||
printf %s\\n "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
trim() {
|
||||
# MWRAPPER-139:
|
||||
# Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds.
|
||||
# Needed for removing poorly interpreted newline sequences when running in more
|
||||
# exotic environments such as mingw bash on Windows.
|
||||
printf "%s" "${1}" | tr -d '[:space:]'
|
||||
}
|
||||
|
||||
scriptDir="$(dirname "$0")"
|
||||
scriptName="$(basename "$0")"
|
||||
|
||||
# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties
|
||||
while IFS="=" read -r key value; do
|
||||
case "${key-}" in
|
||||
distributionUrl) distributionUrl=$(trim "${value-}") ;;
|
||||
distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;;
|
||||
esac
|
||||
done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties"
|
||||
[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
|
||||
|
||||
case "${distributionUrl##*/}" in
|
||||
maven-mvnd-*bin.*)
|
||||
MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/
|
||||
case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in
|
||||
*AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;;
|
||||
:Darwin*x86_64) distributionPlatform=darwin-amd64 ;;
|
||||
:Darwin*arm64) distributionPlatform=darwin-aarch64 ;;
|
||||
:Linux*x86_64*) distributionPlatform=linux-amd64 ;;
|
||||
*)
|
||||
echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2
|
||||
distributionPlatform=linux-amd64
|
||||
;;
|
||||
esac
|
||||
distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip"
|
||||
;;
|
||||
maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;;
|
||||
*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;;
|
||||
esac
|
||||
|
||||
# apply MVNW_REPOURL and calculate MAVEN_HOME
|
||||
# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash>
|
||||
[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}"
|
||||
distributionUrlName="${distributionUrl##*/}"
|
||||
distributionUrlNameMain="${distributionUrlName%.*}"
|
||||
distributionUrlNameMain="${distributionUrlNameMain%-bin}"
|
||||
MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}"
|
||||
MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")"
|
||||
|
||||
exec_maven() {
|
||||
unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || :
|
||||
exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD"
|
||||
}
|
||||
|
||||
if [ -d "$MAVEN_HOME" ]; then
|
||||
verbose "found existing MAVEN_HOME at $MAVEN_HOME"
|
||||
exec_maven "$@"
|
||||
fi
|
||||
|
||||
case "${distributionUrl-}" in
|
||||
*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;;
|
||||
*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;;
|
||||
esac
|
||||
|
||||
# prepare tmp dir
|
||||
if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then
|
||||
clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; }
|
||||
trap clean HUP INT TERM EXIT
|
||||
else
|
||||
die "cannot create temp dir"
|
||||
fi
|
||||
|
||||
mkdir -p -- "${MAVEN_HOME%/*}"
|
||||
|
||||
# Download and Install Apache Maven
|
||||
verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
|
||||
verbose "Downloading from: $distributionUrl"
|
||||
verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
|
||||
|
||||
# select .zip or .tar.gz
|
||||
if ! command -v unzip >/dev/null; then
|
||||
distributionUrl="${distributionUrl%.zip}.tar.gz"
|
||||
distributionUrlName="${distributionUrl##*/}"
|
||||
fi
|
||||
|
||||
# verbose opt
|
||||
__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR=''
|
||||
[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v
|
||||
|
||||
# normalize http auth
|
||||
case "${MVNW_PASSWORD:+has-password}" in
|
||||
'') MVNW_USERNAME='' MVNW_PASSWORD='' ;;
|
||||
has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;;
|
||||
esac
|
||||
|
||||
if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then
|
||||
verbose "Found wget ... using wget"
|
||||
wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl"
|
||||
elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then
|
||||
verbose "Found curl ... using curl"
|
||||
curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl"
|
||||
elif set_java_home; then
|
||||
verbose "Falling back to use Java to download"
|
||||
javaSource="$TMP_DOWNLOAD_DIR/Downloader.java"
|
||||
targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName"
|
||||
cat >"$javaSource" <<-END
|
||||
public class Downloader extends java.net.Authenticator
|
||||
{
|
||||
protected java.net.PasswordAuthentication getPasswordAuthentication()
|
||||
{
|
||||
return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() );
|
||||
}
|
||||
public static void main( String[] args ) throws Exception
|
||||
{
|
||||
setDefault( new Downloader() );
|
||||
java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() );
|
||||
}
|
||||
}
|
||||
END
|
||||
# For Cygwin/MinGW, switch paths to Windows format before running javac and java
|
||||
verbose " - Compiling Downloader.java ..."
|
||||
"$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java"
|
||||
verbose " - Running Downloader.java ..."
|
||||
"$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")"
|
||||
fi
|
||||
|
||||
# If specified, validate the SHA-256 sum of the Maven distribution zip file
|
||||
if [ -n "${distributionSha256Sum-}" ]; then
|
||||
distributionSha256Result=false
|
||||
if [ "$MVN_CMD" = mvnd.sh ]; then
|
||||
echo "Checksum validation is not supported for maven-mvnd." >&2
|
||||
echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
|
||||
exit 1
|
||||
elif command -v sha256sum >/dev/null; then
|
||||
if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then
|
||||
distributionSha256Result=true
|
||||
fi
|
||||
elif command -v shasum >/dev/null; then
|
||||
if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then
|
||||
distributionSha256Result=true
|
||||
fi
|
||||
else
|
||||
echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2
|
||||
echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ $distributionSha256Result = false ]; then
|
||||
echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2
|
||||
echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# unzip and move
|
||||
if command -v unzip >/dev/null; then
|
||||
unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip"
|
||||
else
|
||||
tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar"
|
||||
fi
|
||||
|
||||
# Find the actual extracted directory name (handles snapshots where filename != directory name)
|
||||
actualDistributionDir=""
|
||||
|
||||
# First try the expected directory name (for regular distributions)
|
||||
if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then
|
||||
if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then
|
||||
actualDistributionDir="$distributionUrlNameMain"
|
||||
fi
|
||||
fi
|
||||
|
||||
# If not found, search for any directory with the Maven executable (for snapshots)
|
||||
if [ -z "$actualDistributionDir" ]; then
|
||||
# enable globbing to iterate over items
|
||||
set +f
|
||||
for dir in "$TMP_DOWNLOAD_DIR"/*; do
|
||||
if [ -d "$dir" ]; then
|
||||
if [ -f "$dir/bin/$MVN_CMD" ]; then
|
||||
actualDistributionDir="$(basename "$dir")"
|
||||
break
|
||||
fi
|
||||
fi
|
||||
done
|
||||
set -f
|
||||
fi
|
||||
|
||||
if [ -z "$actualDistributionDir" ]; then
|
||||
verbose "Contents of $TMP_DOWNLOAD_DIR:"
|
||||
verbose "$(ls -la "$TMP_DOWNLOAD_DIR")"
|
||||
die "Could not find Maven distribution directory in extracted archive"
|
||||
fi
|
||||
|
||||
verbose "Found extracted Maven distribution directory: $actualDistributionDir"
|
||||
printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url"
|
||||
mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME"
|
||||
|
||||
clean || :
|
||||
exec_maven "$@"
|
||||
189
backend/mvnw.cmd
vendored
Normal file
189
backend/mvnw.cmd
vendored
Normal file
@@ -0,0 +1,189 @@
|
||||
<# : batch portion
|
||||
@REM ----------------------------------------------------------------------------
|
||||
@REM Licensed to the Apache Software Foundation (ASF) under one
|
||||
@REM or more contributor license agreements. See the NOTICE file
|
||||
@REM distributed with this work for additional information
|
||||
@REM regarding copyright ownership. The ASF licenses this file
|
||||
@REM to you under the Apache License, Version 2.0 (the
|
||||
@REM "License"); you may not use this file except in compliance
|
||||
@REM with the License. You may obtain a copy of the License at
|
||||
@REM
|
||||
@REM http://www.apache.org/licenses/LICENSE-2.0
|
||||
@REM
|
||||
@REM Unless required by applicable law or agreed to in writing,
|
||||
@REM software distributed under the License is distributed on an
|
||||
@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
@REM KIND, either express or implied. See the License for the
|
||||
@REM specific language governing permissions and limitations
|
||||
@REM under the License.
|
||||
@REM ----------------------------------------------------------------------------
|
||||
|
||||
@REM ----------------------------------------------------------------------------
|
||||
@REM Apache Maven Wrapper startup batch script, version 3.3.4
|
||||
@REM
|
||||
@REM Optional ENV vars
|
||||
@REM MVNW_REPOURL - repo url base for downloading maven distribution
|
||||
@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
|
||||
@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output
|
||||
@REM ----------------------------------------------------------------------------
|
||||
|
||||
@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0)
|
||||
@SET __MVNW_CMD__=
|
||||
@SET __MVNW_ERROR__=
|
||||
@SET __MVNW_PSMODULEP_SAVE=%PSModulePath%
|
||||
@SET PSModulePath=
|
||||
@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @(
|
||||
IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B)
|
||||
)
|
||||
@SET PSModulePath=%__MVNW_PSMODULEP_SAVE%
|
||||
@SET __MVNW_PSMODULEP_SAVE=
|
||||
@SET __MVNW_ARG0_NAME__=
|
||||
@SET MVNW_USERNAME=
|
||||
@SET MVNW_PASSWORD=
|
||||
@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*)
|
||||
@echo Cannot start maven from wrapper >&2 && exit /b 1
|
||||
@GOTO :EOF
|
||||
: end batch / begin powershell #>
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
if ($env:MVNW_VERBOSE -eq "true") {
|
||||
$VerbosePreference = "Continue"
|
||||
}
|
||||
|
||||
# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties
|
||||
$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl
|
||||
if (!$distributionUrl) {
|
||||
Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
|
||||
}
|
||||
|
||||
switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) {
|
||||
"maven-mvnd-*" {
|
||||
$USE_MVND = $true
|
||||
$distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip"
|
||||
$MVN_CMD = "mvnd.cmd"
|
||||
break
|
||||
}
|
||||
default {
|
||||
$USE_MVND = $false
|
||||
$MVN_CMD = $script -replace '^mvnw','mvn'
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
# apply MVNW_REPOURL and calculate MAVEN_HOME
|
||||
# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash>
|
||||
if ($env:MVNW_REPOURL) {
|
||||
$MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" }
|
||||
$distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')"
|
||||
}
|
||||
$distributionUrlName = $distributionUrl -replace '^.*/',''
|
||||
$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$',''
|
||||
|
||||
$MAVEN_M2_PATH = "$HOME/.m2"
|
||||
if ($env:MAVEN_USER_HOME) {
|
||||
$MAVEN_M2_PATH = "$env:MAVEN_USER_HOME"
|
||||
}
|
||||
|
||||
if (-not (Test-Path -Path $MAVEN_M2_PATH)) {
|
||||
New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null
|
||||
}
|
||||
|
||||
$MAVEN_WRAPPER_DISTS = $null
|
||||
if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) {
|
||||
$MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists"
|
||||
} else {
|
||||
$MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists"
|
||||
}
|
||||
|
||||
$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain"
|
||||
$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join ''
|
||||
$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME"
|
||||
|
||||
if (Test-Path -Path "$MAVEN_HOME" -PathType Container) {
|
||||
Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME"
|
||||
Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
|
||||
exit $?
|
||||
}
|
||||
|
||||
if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) {
|
||||
Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl"
|
||||
}
|
||||
|
||||
# prepare tmp dir
|
||||
$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile
|
||||
$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir"
|
||||
$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null
|
||||
trap {
|
||||
if ($TMP_DOWNLOAD_DIR.Exists) {
|
||||
try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
|
||||
catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
|
||||
}
|
||||
}
|
||||
|
||||
New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null
|
||||
|
||||
# Download and Install Apache Maven
|
||||
Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
|
||||
Write-Verbose "Downloading from: $distributionUrl"
|
||||
Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
|
||||
|
||||
$webclient = New-Object System.Net.WebClient
|
||||
if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) {
|
||||
$webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD)
|
||||
}
|
||||
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
||||
$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null
|
||||
|
||||
# If specified, validate the SHA-256 sum of the Maven distribution zip file
|
||||
$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum
|
||||
if ($distributionSha256Sum) {
|
||||
if ($USE_MVND) {
|
||||
Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties."
|
||||
}
|
||||
Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash
|
||||
if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) {
|
||||
Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property."
|
||||
}
|
||||
}
|
||||
|
||||
# unzip and move
|
||||
Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null
|
||||
|
||||
# Find the actual extracted directory name (handles snapshots where filename != directory name)
|
||||
$actualDistributionDir = ""
|
||||
|
||||
# First try the expected directory name (for regular distributions)
|
||||
$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain"
|
||||
$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD"
|
||||
if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) {
|
||||
$actualDistributionDir = $distributionUrlNameMain
|
||||
}
|
||||
|
||||
# If not found, search for any directory with the Maven executable (for snapshots)
|
||||
if (!$actualDistributionDir) {
|
||||
Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object {
|
||||
$testPath = Join-Path $_.FullName "bin/$MVN_CMD"
|
||||
if (Test-Path -Path $testPath -PathType Leaf) {
|
||||
$actualDistributionDir = $_.Name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!$actualDistributionDir) {
|
||||
Write-Error "Could not find Maven distribution directory in extracted archive"
|
||||
}
|
||||
|
||||
Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir"
|
||||
Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null
|
||||
try {
|
||||
Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null
|
||||
} catch {
|
||||
if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) {
|
||||
Write-Error "fail to move MAVEN_HOME"
|
||||
}
|
||||
} finally {
|
||||
try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
|
||||
catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
|
||||
}
|
||||
|
||||
Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
|
||||
83
backend/pom.xml
Normal file
83
backend/pom.xml
Normal file
@@ -0,0 +1,83 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-parent</artifactId>
|
||||
<version>2.7.18</version>
|
||||
<relativePath/>
|
||||
</parent>
|
||||
|
||||
<groupId>com.teapot</groupId>
|
||||
<artifactId>backend</artifactId>
|
||||
<version>0.0.1-SNAPSHOT</version>
|
||||
<name>teapot-system-backend</name>
|
||||
<description>Teapot Management System Backend</description>
|
||||
|
||||
<properties>
|
||||
<java.version>11</java.version>
|
||||
<jjwt.version>0.11.5</jjwt.version>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-web</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-validation</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-security</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-jdbc</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.xerial</groupId>
|
||||
<artifactId>sqlite-jdbc</artifactId>
|
||||
<version>3.45.3.0</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-api</artifactId>
|
||||
<version>${jjwt.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-impl</artifactId>
|
||||
<version>${jjwt.version}</version>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-jackson</artifactId>
|
||||
<version>${jjwt.version}</version>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.security</groupId>
|
||||
<artifactId>spring-security-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.teapot.system;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
|
||||
@SpringBootApplication
|
||||
public class BackendApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(BackendApplication.class, args);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.teapot.system.auth;
|
||||
|
||||
import com.teapot.system.common.ApiResponse;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import javax.validation.Valid;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/auth")
|
||||
public class AuthController {
|
||||
|
||||
private final AuthService authService;
|
||||
|
||||
public AuthController(AuthService authService) {
|
||||
this.authService = authService;
|
||||
}
|
||||
|
||||
@PostMapping("/login")
|
||||
public ApiResponse<LoginResponse> login(@Valid @RequestBody LoginRequest request) {
|
||||
return ApiResponse.success("登录成功", authService.login(request));
|
||||
}
|
||||
|
||||
@PostMapping("/logout")
|
||||
public ApiResponse<Map<String, String>> logout() {
|
||||
return ApiResponse.success(Map.of("message", "退出成功"));
|
||||
}
|
||||
|
||||
@GetMapping("/me")
|
||||
public ApiResponse<CurrentUserResponse> currentUser() {
|
||||
return ApiResponse.success(authService.getCurrentUser());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package com.teapot.system.auth;
|
||||
|
||||
import com.teapot.system.common.ApiException;
|
||||
import com.teapot.system.user.UserAccount;
|
||||
import com.teapot.system.user.UserAccountRepository;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Service
|
||||
public class AuthService {
|
||||
|
||||
private final UserAccountRepository userAccountRepository;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final JwtTokenProvider jwtTokenProvider;
|
||||
|
||||
public AuthService(
|
||||
UserAccountRepository userAccountRepository,
|
||||
PasswordEncoder passwordEncoder,
|
||||
JwtTokenProvider jwtTokenProvider) {
|
||||
this.userAccountRepository = userAccountRepository;
|
||||
this.passwordEncoder = passwordEncoder;
|
||||
this.jwtTokenProvider = jwtTokenProvider;
|
||||
}
|
||||
|
||||
public LoginResponse login(LoginRequest request) {
|
||||
UserAccount user = userAccountRepository.findByUsername(request.getUsername())
|
||||
.orElseThrow(() -> new ApiException("账号或密码错误"));
|
||||
|
||||
if (!"ENABLED".equals(user.getStatus())) {
|
||||
throw new ApiException("账号已停用,请联系管理员");
|
||||
}
|
||||
|
||||
if (!passwordEncoder.matches(request.getPassword(), user.getPasswordHash())) {
|
||||
throw new ApiException("账号或密码错误");
|
||||
}
|
||||
|
||||
List<String> permissions = userAccountRepository.findPermissionsByUserId(user.getId());
|
||||
AuthenticatedUser authenticatedUser = new AuthenticatedUser(
|
||||
user.getId(),
|
||||
user.getUsername(),
|
||||
user.getDisplayName(),
|
||||
user.getUserType(),
|
||||
permissions
|
||||
);
|
||||
String token = jwtTokenProvider.generateToken(authenticatedUser);
|
||||
userAccountRepository.updateLastLoginAt(user.getId());
|
||||
|
||||
return new LoginResponse(
|
||||
token,
|
||||
jwtTokenProvider.getExpireSeconds(),
|
||||
toCurrentUserResponse(authenticatedUser)
|
||||
);
|
||||
}
|
||||
|
||||
public CurrentUserResponse getCurrentUser() {
|
||||
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (authentication == null || !(authentication.getPrincipal() instanceof AuthenticatedUser)) {
|
||||
throw new ApiException("未获取到登录用户信息");
|
||||
}
|
||||
|
||||
AuthenticatedUser user = (AuthenticatedUser) authentication.getPrincipal();
|
||||
return toCurrentUserResponse(user);
|
||||
}
|
||||
|
||||
private CurrentUserResponse toCurrentUserResponse(AuthenticatedUser user) {
|
||||
return new CurrentUserResponse(
|
||||
user.getId(),
|
||||
user.getUsername(),
|
||||
user.getDisplayName(),
|
||||
user.getUserType(),
|
||||
user.getPermissions()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.teapot.system.auth;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public class AuthenticatedUser {
|
||||
|
||||
private final Long id;
|
||||
private final String username;
|
||||
private final String displayName;
|
||||
private final String userType;
|
||||
private final List<String> permissions;
|
||||
|
||||
public AuthenticatedUser(Long id, String username, String displayName, String userType, List<String> permissions) {
|
||||
this.id = id;
|
||||
this.username = username;
|
||||
this.displayName = displayName;
|
||||
this.userType = userType;
|
||||
this.permissions = permissions;
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public String getUsername() {
|
||||
return username;
|
||||
}
|
||||
|
||||
public String getDisplayName() {
|
||||
return displayName;
|
||||
}
|
||||
|
||||
public String getUserType() {
|
||||
return userType;
|
||||
}
|
||||
|
||||
public List<String> getPermissions() {
|
||||
return permissions;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.teapot.system.auth;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public class CurrentUserResponse {
|
||||
|
||||
private final Long id;
|
||||
private final String username;
|
||||
private final String displayName;
|
||||
private final String userType;
|
||||
private final List<String> permissions;
|
||||
|
||||
public CurrentUserResponse(Long id, String username, String displayName, String userType, List<String> permissions) {
|
||||
this.id = id;
|
||||
this.username = username;
|
||||
this.displayName = displayName;
|
||||
this.userType = userType;
|
||||
this.permissions = permissions;
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public String getUsername() {
|
||||
return username;
|
||||
}
|
||||
|
||||
public String getDisplayName() {
|
||||
return displayName;
|
||||
}
|
||||
|
||||
public String getUserType() {
|
||||
return userType;
|
||||
}
|
||||
|
||||
public List<String> getPermissions() {
|
||||
return permissions;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.teapot.system.auth;
|
||||
|
||||
import com.teapot.system.common.ApiResponse;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api")
|
||||
public class HealthController {
|
||||
|
||||
@GetMapping("/health")
|
||||
public ApiResponse<Map<String, String>> health() {
|
||||
return ApiResponse.success(Map.of(
|
||||
"status", "UP",
|
||||
"time", LocalDateTime.now().toString()
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package com.teapot.system.auth;
|
||||
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import javax.servlet.FilterChain;
|
||||
import javax.servlet.ServletException;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Component
|
||||
public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
|
||||
private final JwtTokenProvider jwtTokenProvider;
|
||||
|
||||
public JwtAuthenticationFilter(JwtTokenProvider jwtTokenProvider) {
|
||||
this.jwtTokenProvider = jwtTokenProvider;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
|
||||
throws ServletException, IOException {
|
||||
String authHeader = request.getHeader("Authorization");
|
||||
|
||||
if (authHeader != null && authHeader.startsWith("Bearer ")) {
|
||||
String token = authHeader.substring(7);
|
||||
|
||||
try {
|
||||
AuthenticatedUser user = jwtTokenProvider.parseToken(token);
|
||||
List<SimpleGrantedAuthority> authorities = new ArrayList<>();
|
||||
if ("ADMIN".equals(user.getUserType())) {
|
||||
authorities.add(new SimpleGrantedAuthority("ROLE_ADMIN"));
|
||||
}
|
||||
for (String permission : user.getPermissions()) {
|
||||
authorities.add(new SimpleGrantedAuthority(permission));
|
||||
}
|
||||
|
||||
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
|
||||
user,
|
||||
null,
|
||||
authorities
|
||||
);
|
||||
SecurityContextHolder.getContext().setAuthentication(authentication);
|
||||
} catch (Exception exception) {
|
||||
SecurityContextHolder.clearContext();
|
||||
}
|
||||
}
|
||||
|
||||
filterChain.doFilter(request, response);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package com.teapot.system.auth;
|
||||
|
||||
import io.jsonwebtoken.Claims;
|
||||
import io.jsonwebtoken.Jwts;
|
||||
import io.jsonwebtoken.SignatureAlgorithm;
|
||||
import io.jsonwebtoken.security.Keys;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import javax.annotation.PostConstruct;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.Key;
|
||||
import java.time.Instant;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
|
||||
@Component
|
||||
public class JwtTokenProvider {
|
||||
|
||||
private final String secret;
|
||||
private final long expireHours;
|
||||
private Key key;
|
||||
|
||||
public JwtTokenProvider(
|
||||
@Value("${app.security.jwt-secret}") String secret,
|
||||
@Value("${app.security.jwt-expire-hours}") long expireHours) {
|
||||
this.secret = secret;
|
||||
this.expireHours = expireHours;
|
||||
}
|
||||
|
||||
@PostConstruct
|
||||
public void init() {
|
||||
this.key = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
public String generateToken(AuthenticatedUser user) {
|
||||
Instant now = Instant.now();
|
||||
Instant expiresAt = now.plusSeconds(expireHours * 3600);
|
||||
|
||||
return Jwts.builder()
|
||||
.setSubject(user.getUsername())
|
||||
.claim("uid", user.getId())
|
||||
.claim("displayName", user.getDisplayName())
|
||||
.claim("userType", user.getUserType())
|
||||
.claim("permissions", user.getPermissions())
|
||||
.setIssuedAt(Date.from(now))
|
||||
.setExpiration(Date.from(expiresAt))
|
||||
.signWith(key, SignatureAlgorithm.HS256)
|
||||
.compact();
|
||||
}
|
||||
|
||||
public AuthenticatedUser parseToken(String token) {
|
||||
Claims claims = Jwts.parserBuilder()
|
||||
.setSigningKey(key)
|
||||
.build()
|
||||
.parseClaimsJws(token)
|
||||
.getBody();
|
||||
|
||||
List<String> permissions = claims.get("permissions", List.class);
|
||||
Long userId = claims.get("uid", Number.class).longValue();
|
||||
|
||||
return new AuthenticatedUser(
|
||||
userId,
|
||||
claims.getSubject(),
|
||||
claims.get("displayName", String.class),
|
||||
claims.get("userType", String.class),
|
||||
permissions
|
||||
);
|
||||
}
|
||||
|
||||
public long getExpireSeconds() {
|
||||
return expireHours * 3600;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.teapot.system.auth;
|
||||
|
||||
import javax.validation.constraints.NotBlank;
|
||||
|
||||
public class LoginRequest {
|
||||
|
||||
@NotBlank(message = "账号不能为空")
|
||||
private String username;
|
||||
|
||||
@NotBlank(message = "密码不能为空")
|
||||
private String password;
|
||||
|
||||
public String getUsername() {
|
||||
return username;
|
||||
}
|
||||
|
||||
public void setUsername(String username) {
|
||||
this.username = username;
|
||||
}
|
||||
|
||||
public String getPassword() {
|
||||
return password;
|
||||
}
|
||||
|
||||
public void setPassword(String password) {
|
||||
this.password = password;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.teapot.system.auth;
|
||||
|
||||
public class LoginResponse {
|
||||
|
||||
private final String token;
|
||||
private final long expiresInSeconds;
|
||||
private final CurrentUserResponse user;
|
||||
|
||||
public LoginResponse(String token, long expiresInSeconds, CurrentUserResponse user) {
|
||||
this.token = token;
|
||||
this.expiresInSeconds = expiresInSeconds;
|
||||
this.user = user;
|
||||
}
|
||||
|
||||
public String getToken() {
|
||||
return token;
|
||||
}
|
||||
|
||||
public long getExpiresInSeconds() {
|
||||
return expiresInSeconds;
|
||||
}
|
||||
|
||||
public CurrentUserResponse getUser() {
|
||||
return user;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.teapot.system.auth;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
public final class PermissionCodes {
|
||||
|
||||
public static final String PRODUCT_VIEW = "PRODUCT_VIEW";
|
||||
public static final String PRODUCT_EDIT = "PRODUCT_EDIT";
|
||||
public static final String ASSET_UPLOAD = "ASSET_UPLOAD";
|
||||
public static final String ORDER_VIEW = "ORDER_VIEW";
|
||||
public static final String ORDER_PROCESS = "ORDER_PROCESS";
|
||||
public static final String ORDER_COMPLETE = "ORDER_COMPLETE";
|
||||
public static final String STATISTICS_VIEW = "STATISTICS_VIEW";
|
||||
public static final String USER_MANAGE = "USER_MANAGE";
|
||||
|
||||
public static final Map<String, String> NAME_MAP = Map.of(
|
||||
PRODUCT_VIEW, "商品查看",
|
||||
PRODUCT_EDIT, "商品编辑",
|
||||
ASSET_UPLOAD, "附件上传",
|
||||
ORDER_VIEW, "订单查看",
|
||||
ORDER_PROCESS, "订单处理",
|
||||
ORDER_COMPLETE, "订单完成",
|
||||
STATISTICS_VIEW, "统计查看",
|
||||
USER_MANAGE, "用户管理"
|
||||
);
|
||||
|
||||
private PermissionCodes() {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,435 @@
|
||||
package com.teapot.system.catalog;
|
||||
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.core.ConnectionCallback;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
@Repository
|
||||
public class CatalogRepository {
|
||||
|
||||
private static final String PRODUCT_BUSINESS_TYPE = "PRODUCT";
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
public CatalogRepository(JdbcTemplate jdbcTemplate) {
|
||||
this.jdbcTemplate = jdbcTemplate;
|
||||
}
|
||||
|
||||
public long countCategories() {
|
||||
Integer count = jdbcTemplate.queryForObject("SELECT COUNT(1) FROM category", Integer.class);
|
||||
return count == null ? 0L : count;
|
||||
}
|
||||
|
||||
public long countProducts() {
|
||||
Integer count = jdbcTemplate.queryForObject("SELECT COUNT(1) FROM product_item", Integer.class);
|
||||
return count == null ? 0L : count;
|
||||
}
|
||||
|
||||
public boolean existsProductCategory(Long categoryId) {
|
||||
Integer count = jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(1) FROM category WHERE id = ? AND category_type = 'PRODUCT'",
|
||||
Integer.class,
|
||||
categoryId
|
||||
);
|
||||
return count != null && count > 0;
|
||||
}
|
||||
|
||||
public boolean existsProductSku(String sku, Long excludeProductId) {
|
||||
Integer count;
|
||||
if (excludeProductId == null) {
|
||||
count = jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(1) FROM product_item WHERE sku = ?",
|
||||
Integer.class,
|
||||
sku
|
||||
);
|
||||
} else {
|
||||
count = jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(1) FROM product_item WHERE sku = ? AND id <> ?",
|
||||
Integer.class,
|
||||
sku,
|
||||
excludeProductId
|
||||
);
|
||||
}
|
||||
return count != null && count > 0;
|
||||
}
|
||||
|
||||
public void insertCategory(long id, Long parentId, String name, int sortNo, boolean requiresDetailPage, String categoryType) {
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO category (id, parent_id, name, sort_no, requires_detail_page, category_type) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
id,
|
||||
parentId,
|
||||
name,
|
||||
sortNo,
|
||||
requiresDetailPage ? 1 : 0,
|
||||
categoryType
|
||||
);
|
||||
}
|
||||
|
||||
public void insertProduct(
|
||||
long id,
|
||||
long categoryId,
|
||||
String sku,
|
||||
String name,
|
||||
String modelName,
|
||||
String status,
|
||||
BigDecimal price,
|
||||
int stockQuantity,
|
||||
String remark) {
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO product_item (id, category_id, sku, name, model_name, status, wholesale_price, stock_quantity, remark) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
id,
|
||||
categoryId,
|
||||
sku,
|
||||
name,
|
||||
modelName,
|
||||
status,
|
||||
price,
|
||||
stockQuantity,
|
||||
remark
|
||||
);
|
||||
}
|
||||
|
||||
public Long createProduct(CreateProductRequest request) {
|
||||
return jdbcTemplate.execute((ConnectionCallback<Long>) connection -> {
|
||||
try (PreparedStatement insertStatement = connection.prepareStatement(
|
||||
"INSERT INTO product_item (category_id, sku, name, model_name, status, wholesale_price, stock_quantity, remark) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"
|
||||
);
|
||||
PreparedStatement identityStatement = connection.prepareStatement("SELECT last_insert_rowid()")) {
|
||||
insertStatement.setLong(1, request.getCategoryId());
|
||||
insertStatement.setString(2, request.getSku());
|
||||
insertStatement.setString(3, request.getName());
|
||||
insertStatement.setString(4, request.getModel());
|
||||
insertStatement.setString(5, request.getStatus());
|
||||
insertStatement.setBigDecimal(6, request.getPrice());
|
||||
insertStatement.setInt(7, request.getStock());
|
||||
insertStatement.setString(8, request.getRemark());
|
||||
insertStatement.executeUpdate();
|
||||
|
||||
try (ResultSet identityResult = identityStatement.executeQuery()) {
|
||||
if (identityResult.next()) {
|
||||
return identityResult.getLong(1);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public List<CategoryTreeNodeResponse> findCategoryTree() {
|
||||
List<Map<String, Object>> categories = jdbcTemplate.queryForList(
|
||||
"SELECT id, name FROM category WHERE category_type = 'PRODUCT' ORDER BY sort_no ASC, id ASC"
|
||||
);
|
||||
|
||||
List<CategoryTreeNodeResponse> results = new ArrayList<>();
|
||||
for (Map<String, Object> category : categories) {
|
||||
Long categoryId = ((Number) category.get("id")).longValue();
|
||||
String categoryName = String.valueOf(category.get("name"));
|
||||
List<CategoryProductNodeResponse> children = jdbcTemplate.query(
|
||||
"SELECT id, name FROM product_item WHERE category_id = ? ORDER BY id ASC",
|
||||
(resultSet, rowNum) -> new CategoryProductNodeResponse(
|
||||
resultSet.getLong("id"),
|
||||
resultSet.getString("name"),
|
||||
resultSet.getLong("id")
|
||||
),
|
||||
categoryId
|
||||
);
|
||||
results.add(new CategoryTreeNodeResponse(categoryId, categoryName, children));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
public List<ProductSummaryResponse> findProductsByCategoryId(Long categoryId) {
|
||||
return jdbcTemplate.query(
|
||||
"SELECT id, category_id, model_name, name, sku, wholesale_price, stock_quantity, status, cover_asset_id, remark FROM product_item WHERE category_id = ? ORDER BY id ASC",
|
||||
(resultSet, rowNum) -> new ProductSummaryResponse(
|
||||
resultSet.getLong("id"),
|
||||
resultSet.getLong("category_id"),
|
||||
resultSet.getString("model_name"),
|
||||
resultSet.getString("name"),
|
||||
resultSet.getString("sku"),
|
||||
readDecimal(resultSet, "wholesale_price"),
|
||||
resultSet.getInt("stock_quantity"),
|
||||
statusLabel(resultSet.getString("status")),
|
||||
statusType(resultSet.getString("status")),
|
||||
readNullableLong(resultSet, "cover_asset_id"),
|
||||
resultSet.getString("remark")
|
||||
),
|
||||
categoryId
|
||||
);
|
||||
}
|
||||
|
||||
public List<ProductAssetResponse> findProductAssets(Long productId) {
|
||||
return jdbcTemplate.query(
|
||||
"SELECT id, file_role, file_name, mime_type, file_size, sort_no, is_primary, created_at FROM file_asset WHERE business_type = ? AND business_id = ? ORDER BY CASE WHEN file_role = 'IMAGE' THEN 0 ELSE 1 END ASC, sort_no ASC, id ASC",
|
||||
(resultSet, rowNum) -> new ProductAssetResponse(
|
||||
resultSet.getLong("id"),
|
||||
resultSet.getString("file_role"),
|
||||
resultSet.getString("file_name"),
|
||||
resultSet.getString("mime_type"),
|
||||
resultSet.getLong("file_size"),
|
||||
resultSet.getInt("sort_no"),
|
||||
resultSet.getInt("is_primary") == 1,
|
||||
resultSet.getString("created_at")
|
||||
),
|
||||
PRODUCT_BUSINESS_TYPE,
|
||||
productId
|
||||
);
|
||||
}
|
||||
|
||||
public Optional<ProductAssetResponse> findProductAsset(Long productId, Long assetId) {
|
||||
List<ProductAssetResponse> assets = jdbcTemplate.query(
|
||||
"SELECT id, file_role, file_name, mime_type, file_size, sort_no, is_primary, created_at FROM file_asset WHERE id = ? AND business_type = ? AND business_id = ?",
|
||||
(resultSet, rowNum) -> new ProductAssetResponse(
|
||||
resultSet.getLong("id"),
|
||||
resultSet.getString("file_role"),
|
||||
resultSet.getString("file_name"),
|
||||
resultSet.getString("mime_type"),
|
||||
resultSet.getLong("file_size"),
|
||||
resultSet.getInt("sort_no"),
|
||||
resultSet.getInt("is_primary") == 1,
|
||||
resultSet.getString("created_at")
|
||||
),
|
||||
assetId,
|
||||
PRODUCT_BUSINESS_TYPE,
|
||||
productId
|
||||
);
|
||||
|
||||
return assets.stream().findFirst();
|
||||
}
|
||||
|
||||
public Optional<ProductAssetContent> findProductAssetContent(Long productId, Long assetId) {
|
||||
List<ProductAssetContent> assets = jdbcTemplate.query(
|
||||
"SELECT id, file_role, file_name, mime_type, file_size, content_blob FROM file_asset WHERE id = ? AND business_type = ? AND business_id = ?",
|
||||
(resultSet, rowNum) -> new ProductAssetContent(
|
||||
resultSet.getLong("id"),
|
||||
resultSet.getString("file_role"),
|
||||
resultSet.getString("file_name"),
|
||||
resultSet.getString("mime_type"),
|
||||
resultSet.getLong("file_size"),
|
||||
resultSet.getBytes("content_blob")
|
||||
),
|
||||
assetId,
|
||||
PRODUCT_BUSINESS_TYPE,
|
||||
productId
|
||||
);
|
||||
|
||||
return assets.stream().findFirst();
|
||||
}
|
||||
|
||||
public Optional<ProductAssetResponse> findFirstImageAsset(Long productId) {
|
||||
List<ProductAssetResponse> assets = jdbcTemplate.query(
|
||||
"SELECT id, file_role, file_name, mime_type, file_size, sort_no, is_primary, created_at FROM file_asset WHERE business_type = ? AND business_id = ? AND file_role = 'IMAGE' ORDER BY sort_no ASC, id ASC LIMIT 1",
|
||||
(resultSet, rowNum) -> new ProductAssetResponse(
|
||||
resultSet.getLong("id"),
|
||||
resultSet.getString("file_role"),
|
||||
resultSet.getString("file_name"),
|
||||
resultSet.getString("mime_type"),
|
||||
resultSet.getLong("file_size"),
|
||||
resultSet.getInt("sort_no"),
|
||||
resultSet.getInt("is_primary") == 1,
|
||||
resultSet.getString("created_at")
|
||||
),
|
||||
PRODUCT_BUSINESS_TYPE,
|
||||
productId
|
||||
);
|
||||
|
||||
return assets.stream().findFirst();
|
||||
}
|
||||
|
||||
public ProductAssetResponse insertProductAsset(Long productId, String fileRole, String fileName, String mimeType, long fileSize, byte[] content, boolean isPrimary, String sha256) {
|
||||
int sortNo = nextSortNo(productId, fileRole);
|
||||
Long assetId = jdbcTemplate.execute((ConnectionCallback<Long>) connection -> {
|
||||
try (PreparedStatement insertStatement = connection.prepareStatement(
|
||||
"INSERT INTO file_asset (business_type, business_id, file_role, file_name, mime_type, file_size, content_blob, preview_blob, sort_no, is_primary, sha256) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)");
|
||||
PreparedStatement identityStatement = connection.prepareStatement("SELECT last_insert_rowid()");
|
||||
) {
|
||||
insertStatement.setString(1, PRODUCT_BUSINESS_TYPE);
|
||||
insertStatement.setLong(2, productId);
|
||||
insertStatement.setString(3, fileRole);
|
||||
insertStatement.setString(4, fileName);
|
||||
insertStatement.setString(5, mimeType);
|
||||
insertStatement.setLong(6, fileSize);
|
||||
insertStatement.setBytes(7, content);
|
||||
insertStatement.setBytes(8, null);
|
||||
insertStatement.setInt(9, sortNo);
|
||||
insertStatement.setInt(10, isPrimary ? 1 : 0);
|
||||
insertStatement.setString(11, sha256);
|
||||
insertStatement.executeUpdate();
|
||||
|
||||
try (ResultSet identityResult = identityStatement.executeQuery()) {
|
||||
if (identityResult.next()) {
|
||||
return identityResult.getLong(1);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
if (assetId == null) {
|
||||
throw new IllegalStateException("附件保存后未返回编号");
|
||||
}
|
||||
|
||||
if (assetId != null && "IMAGE".equals(fileRole)) {
|
||||
Long currentCoverAssetId = findCoverAssetId(productId);
|
||||
boolean shouldSetPrimary = isPrimary || currentCoverAssetId == null;
|
||||
if (shouldSetPrimary) {
|
||||
makeImageAssetPrimary(productId, assetId);
|
||||
}
|
||||
}
|
||||
|
||||
return findProductAsset(productId, assetId).orElseThrow(() -> new IllegalStateException("附件保存后无法读取"));
|
||||
}
|
||||
|
||||
public void makeImageAssetPrimary(Long productId, Long assetId) {
|
||||
resetPrimaryImageFlags(productId);
|
||||
markAssetAsPrimary(assetId);
|
||||
updateProductCoverAsset(productId, assetId);
|
||||
}
|
||||
|
||||
public void updateProductImageSortNo(Long productId, Long assetId, int sortNo) {
|
||||
int updatedRows = jdbcTemplate.update(
|
||||
"UPDATE file_asset SET sort_no = ? WHERE id = ? AND business_type = ? AND business_id = ? AND file_role = 'IMAGE'",
|
||||
sortNo,
|
||||
assetId,
|
||||
PRODUCT_BUSINESS_TYPE,
|
||||
productId
|
||||
);
|
||||
|
||||
if (updatedRows != 1) {
|
||||
throw new IllegalStateException("图片排序更新失败");
|
||||
}
|
||||
}
|
||||
|
||||
public void clearProductCoverAsset(Long productId) {
|
||||
jdbcTemplate.update("UPDATE product_item SET cover_asset_id = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?", productId);
|
||||
}
|
||||
|
||||
public void deleteProductAsset(Long productId, Long assetId) {
|
||||
jdbcTemplate.update(
|
||||
"DELETE FROM file_asset WHERE id = ? AND business_type = ? AND business_id = ?",
|
||||
assetId,
|
||||
PRODUCT_BUSINESS_TYPE,
|
||||
productId
|
||||
);
|
||||
}
|
||||
|
||||
public Optional<ProductDetailResponse> findProductById(Long productId) {
|
||||
List<ProductDetailResponse> products = jdbcTemplate.query(
|
||||
"SELECT id, category_id, model_name, name, sku, wholesale_price, stock_quantity, status, remark FROM product_item WHERE id = ?",
|
||||
(resultSet, rowNum) -> new ProductDetailResponse(
|
||||
resultSet.getLong("id"),
|
||||
resultSet.getLong("category_id"),
|
||||
resultSet.getString("model_name"),
|
||||
resultSet.getString("name"),
|
||||
resultSet.getString("sku"),
|
||||
readDecimal(resultSet, "wholesale_price"),
|
||||
resultSet.getInt("stock_quantity"),
|
||||
resultSet.getString("status"),
|
||||
statusType(resultSet.getString("status")),
|
||||
resultSet.getString("remark")
|
||||
),
|
||||
productId
|
||||
);
|
||||
|
||||
return products.stream().findFirst();
|
||||
}
|
||||
|
||||
public void updateProduct(Long productId, UpdateProductRequest request) {
|
||||
jdbcTemplate.update(
|
||||
"UPDATE product_item SET sku = ?, name = ?, model_name = ?, status = ?, wholesale_price = ?, stock_quantity = ?, remark = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
|
||||
request.getSku(),
|
||||
request.getName(),
|
||||
request.getModel(),
|
||||
request.getStatus(),
|
||||
request.getPrice(),
|
||||
request.getStock(),
|
||||
request.getRemark(),
|
||||
productId
|
||||
);
|
||||
}
|
||||
|
||||
private int nextSortNo(Long productId, String fileRole) {
|
||||
Integer maxSortNo = jdbcTemplate.queryForObject(
|
||||
"SELECT COALESCE(MAX(sort_no), 0) FROM file_asset WHERE business_type = ? AND business_id = ? AND file_role = ?",
|
||||
Integer.class,
|
||||
PRODUCT_BUSINESS_TYPE,
|
||||
productId,
|
||||
fileRole
|
||||
);
|
||||
return (maxSortNo == null ? 0 : maxSortNo) + 1;
|
||||
}
|
||||
|
||||
private Long findCoverAssetId(Long productId) {
|
||||
List<Long> results = jdbcTemplate.query(
|
||||
"SELECT cover_asset_id FROM product_item WHERE id = ?",
|
||||
(resultSet, rowNum) -> readNullableLong(resultSet, "cover_asset_id"),
|
||||
productId
|
||||
);
|
||||
return results.isEmpty() ? null : results.get(0);
|
||||
}
|
||||
|
||||
private void resetPrimaryImageFlags(Long productId) {
|
||||
jdbcTemplate.update(
|
||||
"UPDATE file_asset SET is_primary = 0 WHERE business_type = ? AND business_id = ? AND file_role = 'IMAGE'",
|
||||
PRODUCT_BUSINESS_TYPE,
|
||||
productId
|
||||
);
|
||||
}
|
||||
|
||||
private void markAssetAsPrimary(Long assetId) {
|
||||
jdbcTemplate.update("UPDATE file_asset SET is_primary = 1 WHERE id = ?", assetId);
|
||||
}
|
||||
|
||||
private void updateProductCoverAsset(Long productId, Long assetId) {
|
||||
jdbcTemplate.update("UPDATE product_item SET cover_asset_id = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?", assetId, productId);
|
||||
}
|
||||
|
||||
private BigDecimal readDecimal(ResultSet resultSet, String column) throws SQLException {
|
||||
return BigDecimal.valueOf(resultSet.getDouble(column));
|
||||
}
|
||||
|
||||
private Long readNullableLong(ResultSet resultSet, String column) throws SQLException {
|
||||
long value = resultSet.getLong(column);
|
||||
return resultSet.wasNull() ? null : value;
|
||||
}
|
||||
|
||||
private String statusLabel(String status) {
|
||||
switch (status) {
|
||||
case "DISCONTINUED":
|
||||
return "停产";
|
||||
case "HIGH_STOCK":
|
||||
return "库存多";
|
||||
case "LOW_STOCK":
|
||||
return "库存少";
|
||||
case "AVAILABLE":
|
||||
default:
|
||||
return "可售";
|
||||
}
|
||||
}
|
||||
|
||||
private String statusType(String status) {
|
||||
switch (status) {
|
||||
case "DISCONTINUED":
|
||||
return "danger";
|
||||
case "HIGH_STOCK":
|
||||
return "info";
|
||||
case "LOW_STOCK":
|
||||
return "warning";
|
||||
case "AVAILABLE":
|
||||
default:
|
||||
return "success";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
package com.teapot.system.catalog;
|
||||
|
||||
import com.teapot.system.common.ApiException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
public class CatalogService {
|
||||
|
||||
private static final List<String> PRODUCT_STATUSES = List.of("AVAILABLE", "LOW_STOCK", "HIGH_STOCK", "DISCONTINUED");
|
||||
|
||||
private final CatalogRepository catalogRepository;
|
||||
|
||||
public CatalogService(CatalogRepository catalogRepository) {
|
||||
this.catalogRepository = catalogRepository;
|
||||
}
|
||||
|
||||
public List<CategoryTreeNodeResponse> getCategoryTree() {
|
||||
return catalogRepository.findCategoryTree();
|
||||
}
|
||||
|
||||
public List<ProductSummaryResponse> getProductsByCategory(Long categoryId) {
|
||||
return catalogRepository.findProductsByCategoryId(categoryId);
|
||||
}
|
||||
|
||||
public ProductDetailResponse getProduct(Long productId) {
|
||||
return catalogRepository.findProductById(productId)
|
||||
.orElseThrow(() -> new ApiException("商品不存在"));
|
||||
}
|
||||
|
||||
public ProductDetailResponse createProduct(CreateProductRequest request) {
|
||||
if (!catalogRepository.existsProductCategory(request.getCategoryId())) {
|
||||
throw new ApiException("所属类目不存在");
|
||||
}
|
||||
|
||||
normalizeProductRequest(request);
|
||||
if (catalogRepository.existsProductSku(request.getSku(), null)) {
|
||||
throw new ApiException("SKU 已存在");
|
||||
}
|
||||
|
||||
Long productId = catalogRepository.createProduct(request);
|
||||
if (productId == null) {
|
||||
throw new ApiException("商品创建失败");
|
||||
}
|
||||
|
||||
return getProduct(productId);
|
||||
}
|
||||
|
||||
public ProductDetailResponse updateProduct(Long productId, UpdateProductRequest request) {
|
||||
getProduct(productId);
|
||||
normalizeProductRequest(request);
|
||||
if (catalogRepository.existsProductSku(request.getSku(), productId)) {
|
||||
throw new ApiException("SKU 已存在");
|
||||
}
|
||||
catalogRepository.updateProduct(productId, request);
|
||||
return getProduct(productId);
|
||||
}
|
||||
|
||||
public List<ProductAssetResponse> getProductAssets(Long productId) {
|
||||
getProduct(productId);
|
||||
return catalogRepository.findProductAssets(productId);
|
||||
}
|
||||
|
||||
public ProductAssetResponse uploadProductAsset(Long productId, String fileRole, boolean isPrimary, MultipartFile file) {
|
||||
getProduct(productId);
|
||||
|
||||
String normalizedRole = normalizeFileRole(fileRole);
|
||||
if (file == null || file.isEmpty()) {
|
||||
throw new ApiException("上传文件不能为空");
|
||||
}
|
||||
|
||||
String mimeType = file.getContentType() == null || file.getContentType().trim().isEmpty()
|
||||
? "application/octet-stream"
|
||||
: file.getContentType().trim();
|
||||
|
||||
if ("IMAGE".equals(normalizedRole) && !mimeType.startsWith("image/")) {
|
||||
throw new ApiException("图片附件只支持图片类型文件");
|
||||
}
|
||||
|
||||
try {
|
||||
byte[] content = file.getBytes();
|
||||
String fileName = file.getOriginalFilename() == null || file.getOriginalFilename().trim().isEmpty()
|
||||
? ("IMAGE".equals(normalizedRole) ? "image.bin" : "label.bin")
|
||||
: file.getOriginalFilename().trim();
|
||||
return catalogRepository.insertProductAsset(productId, normalizedRole, fileName, mimeType, file.getSize(), content, isPrimary, calculateSha256(content));
|
||||
} catch (IOException exception) {
|
||||
throw new ApiException("读取上传文件失败");
|
||||
}
|
||||
}
|
||||
|
||||
public ProductAssetContent downloadProductAsset(Long productId, Long assetId) {
|
||||
getProduct(productId);
|
||||
ProductAssetContent assetContent = catalogRepository.findProductAssetContent(productId, assetId)
|
||||
.orElseThrow(() -> new ApiException("附件不存在"));
|
||||
if (assetContent.getContent() == null || assetContent.getContent().length == 0) {
|
||||
throw new ApiException("附件内容为空");
|
||||
}
|
||||
return assetContent;
|
||||
}
|
||||
|
||||
public ProductAssetResponse setPrimaryProductAsset(Long productId, Long assetId) {
|
||||
getProduct(productId);
|
||||
ProductAssetResponse asset = catalogRepository.findProductAsset(productId, assetId)
|
||||
.orElseThrow(() -> new ApiException("附件不存在"));
|
||||
|
||||
if (!"IMAGE".equals(asset.getFileRole())) {
|
||||
throw new ApiException("只有图片附件才允许设为主图");
|
||||
}
|
||||
|
||||
catalogRepository.makeImageAssetPrimary(productId, assetId);
|
||||
return catalogRepository.findProductAsset(productId, assetId)
|
||||
.orElseThrow(() -> new ApiException("主图更新失败"));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public List<ProductAssetResponse> reorderProductImageAssets(Long productId, List<Long> assetIds) {
|
||||
getProduct(productId);
|
||||
|
||||
List<ProductAssetResponse> currentImageAssets = catalogRepository.findProductAssets(productId).stream()
|
||||
.filter(asset -> "IMAGE".equals(asset.getFileRole()))
|
||||
.collect(Collectors.toList());
|
||||
|
||||
Set<Long> requestedAssetIds = new LinkedHashSet<>(assetIds);
|
||||
Set<Long> currentAssetIds = currentImageAssets.stream()
|
||||
.map(ProductAssetResponse::getId)
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
if (requestedAssetIds.size() != assetIds.size() || currentImageAssets.size() != assetIds.size() || !currentAssetIds.equals(requestedAssetIds)) {
|
||||
throw new ApiException("图片顺序必须包含当前商品的全部图片且不能重复");
|
||||
}
|
||||
|
||||
int sortNo = 1;
|
||||
for (Long assetId : assetIds) {
|
||||
catalogRepository.updateProductImageSortNo(productId, assetId, sortNo++);
|
||||
}
|
||||
|
||||
return catalogRepository.findProductAssets(productId);
|
||||
}
|
||||
|
||||
public void deleteProductAsset(Long productId, Long assetId) {
|
||||
getProduct(productId);
|
||||
ProductAssetResponse asset = catalogRepository.findProductAsset(productId, assetId)
|
||||
.orElseThrow(() -> new ApiException("附件不存在"));
|
||||
|
||||
catalogRepository.deleteProductAsset(productId, assetId);
|
||||
|
||||
if ("IMAGE".equals(asset.getFileRole())) {
|
||||
catalogRepository.findFirstImageAsset(productId)
|
||||
.ifPresentOrElse(
|
||||
image -> catalogRepository.makeImageAssetPrimary(productId, image.getId()),
|
||||
() -> catalogRepository.clearProductCoverAsset(productId)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public long countCategories() {
|
||||
return catalogRepository.countCategories();
|
||||
}
|
||||
|
||||
public long countProducts() {
|
||||
return catalogRepository.countProducts();
|
||||
}
|
||||
|
||||
public void seedCategory(long id, Long parentId, String name, int sortNo, boolean requiresDetailPage, String categoryType) {
|
||||
catalogRepository.insertCategory(id, parentId, name, sortNo, requiresDetailPage, categoryType);
|
||||
}
|
||||
|
||||
public void seedProduct(long id, long categoryId, String sku, String name, String modelName, String status, java.math.BigDecimal price, int stockQuantity, String remark) {
|
||||
catalogRepository.insertProduct(id, categoryId, sku, name, modelName, status, price, stockQuantity, remark);
|
||||
}
|
||||
|
||||
private void normalizeProductRequest(UpdateProductRequest request) {
|
||||
request.setModel(request.getModel().trim());
|
||||
request.setName(request.getName().trim());
|
||||
request.setSku(request.getSku().trim());
|
||||
request.setStatus(normalizeProductStatus(request.getStatus()));
|
||||
request.setRemark(normalizeOptionalText(request.getRemark()));
|
||||
}
|
||||
|
||||
private String normalizeProductStatus(String status) {
|
||||
if (status == null || status.trim().isEmpty()) {
|
||||
throw new ApiException("商品状态不能为空");
|
||||
}
|
||||
|
||||
String normalizedStatus = status.trim().toUpperCase();
|
||||
if (!PRODUCT_STATUSES.contains(normalizedStatus)) {
|
||||
throw new ApiException("商品状态非法");
|
||||
}
|
||||
|
||||
return normalizedStatus;
|
||||
}
|
||||
|
||||
private String normalizeOptionalText(String value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
String normalizedValue = value.trim();
|
||||
return normalizedValue.isEmpty() ? null : normalizedValue;
|
||||
}
|
||||
|
||||
private String normalizeFileRole(String fileRole) {
|
||||
if (fileRole == null || fileRole.trim().isEmpty()) {
|
||||
throw new ApiException("附件类型不能为空");
|
||||
}
|
||||
|
||||
String normalizedRole = fileRole.trim().toUpperCase();
|
||||
if (!"IMAGE".equals(normalizedRole) && !"LABEL".equals(normalizedRole)) {
|
||||
throw new ApiException("仅支持 IMAGE 或 LABEL 两种附件类型");
|
||||
}
|
||||
|
||||
return normalizedRole;
|
||||
}
|
||||
|
||||
private String calculateSha256(byte[] content) {
|
||||
try {
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
byte[] hash = digest.digest(content);
|
||||
StringBuilder builder = new StringBuilder();
|
||||
for (byte item : hash) {
|
||||
builder.append(String.format("%02x", item));
|
||||
}
|
||||
return builder.toString();
|
||||
} catch (NoSuchAlgorithmException exception) {
|
||||
throw new IllegalStateException("当前环境不支持 SHA-256", exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.teapot.system.catalog;
|
||||
|
||||
import com.teapot.system.common.ApiResponse;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/categories")
|
||||
public class CategoryController {
|
||||
|
||||
private final CatalogService catalogService;
|
||||
|
||||
public CategoryController(CatalogService catalogService) {
|
||||
this.catalogService = catalogService;
|
||||
}
|
||||
|
||||
@GetMapping("/tree")
|
||||
@PreAuthorize("hasRole('ADMIN') or hasAnyAuthority('PRODUCT_VIEW','PRODUCT_EDIT','ASSET_UPLOAD')")
|
||||
public ApiResponse<List<CategoryTreeNodeResponse>> getTree() {
|
||||
return ApiResponse.success(catalogService.getCategoryTree());
|
||||
}
|
||||
|
||||
@GetMapping("/{id}/products")
|
||||
@PreAuthorize("hasRole('ADMIN') or hasAnyAuthority('PRODUCT_VIEW','PRODUCT_EDIT','ASSET_UPLOAD')")
|
||||
public ApiResponse<List<ProductSummaryResponse>> getProductsByCategory(@PathVariable Long id) {
|
||||
return ApiResponse.success(catalogService.getProductsByCategory(id));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.teapot.system.catalog;
|
||||
|
||||
public class CategoryProductNodeResponse {
|
||||
|
||||
private final Long id;
|
||||
private final String name;
|
||||
private final Long productId;
|
||||
|
||||
public CategoryProductNodeResponse(Long id, String name, Long productId) {
|
||||
this.id = id;
|
||||
this.name = name;
|
||||
this.productId = productId;
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public Long getProductId() {
|
||||
return productId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.teapot.system.catalog;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public class CategoryTreeNodeResponse {
|
||||
|
||||
private final Long id;
|
||||
private final String name;
|
||||
private final List<CategoryProductNodeResponse> children;
|
||||
|
||||
public CategoryTreeNodeResponse(Long id, String name, List<CategoryProductNodeResponse> children) {
|
||||
this.id = id;
|
||||
this.name = name;
|
||||
this.children = children;
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public List<CategoryProductNodeResponse> getChildren() {
|
||||
return children;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.teapot.system.catalog;
|
||||
|
||||
import javax.validation.constraints.Min;
|
||||
import javax.validation.constraints.NotNull;
|
||||
|
||||
public class CreateProductRequest extends UpdateProductRequest {
|
||||
|
||||
@NotNull(message = "所属类目不能为空")
|
||||
@Min(value = 1, message = "所属类目非法")
|
||||
private Long categoryId;
|
||||
|
||||
public Long getCategoryId() {
|
||||
return categoryId;
|
||||
}
|
||||
|
||||
public void setCategoryId(Long categoryId) {
|
||||
this.categoryId = categoryId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.teapot.system.catalog;
|
||||
|
||||
public class ProductAssetContent {
|
||||
|
||||
private final Long id;
|
||||
private final String fileRole;
|
||||
private final String fileName;
|
||||
private final String mimeType;
|
||||
private final Long fileSize;
|
||||
private final byte[] content;
|
||||
|
||||
public ProductAssetContent(Long id, String fileRole, String fileName, String mimeType, Long fileSize, byte[] content) {
|
||||
this.id = id;
|
||||
this.fileRole = fileRole;
|
||||
this.fileName = fileName;
|
||||
this.mimeType = mimeType;
|
||||
this.fileSize = fileSize;
|
||||
this.content = content;
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public String getFileRole() {
|
||||
return fileRole;
|
||||
}
|
||||
|
||||
public String getFileName() {
|
||||
return fileName;
|
||||
}
|
||||
|
||||
public String getMimeType() {
|
||||
return mimeType;
|
||||
}
|
||||
|
||||
public Long getFileSize() {
|
||||
return fileSize;
|
||||
}
|
||||
|
||||
public byte[] getContent() {
|
||||
return content;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.teapot.system.catalog;
|
||||
|
||||
public class ProductAssetResponse {
|
||||
|
||||
private final Long id;
|
||||
private final String fileRole;
|
||||
private final String fileName;
|
||||
private final String mimeType;
|
||||
private final Long fileSize;
|
||||
private final Integer sortNo;
|
||||
private final boolean primary;
|
||||
private final String createdAt;
|
||||
|
||||
public ProductAssetResponse(Long id, String fileRole, String fileName, String mimeType, Long fileSize, Integer sortNo, boolean primary, String createdAt) {
|
||||
this.id = id;
|
||||
this.fileRole = fileRole;
|
||||
this.fileName = fileName;
|
||||
this.mimeType = mimeType;
|
||||
this.fileSize = fileSize;
|
||||
this.sortNo = sortNo;
|
||||
this.primary = primary;
|
||||
this.createdAt = createdAt;
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public String getFileRole() {
|
||||
return fileRole;
|
||||
}
|
||||
|
||||
public String getFileName() {
|
||||
return fileName;
|
||||
}
|
||||
|
||||
public String getMimeType() {
|
||||
return mimeType;
|
||||
}
|
||||
|
||||
public Long getFileSize() {
|
||||
return fileSize;
|
||||
}
|
||||
|
||||
public Integer getSortNo() {
|
||||
return sortNo;
|
||||
}
|
||||
|
||||
public boolean isPrimary() {
|
||||
return primary;
|
||||
}
|
||||
|
||||
public String getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package com.teapot.system.catalog;
|
||||
|
||||
import com.teapot.system.common.ApiResponse;
|
||||
import org.springframework.http.ContentDisposition;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RequestPart;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import javax.validation.Valid;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/products")
|
||||
public class ProductController {
|
||||
|
||||
private final CatalogService catalogService;
|
||||
|
||||
public ProductController(CatalogService catalogService) {
|
||||
this.catalogService = catalogService;
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
@PreAuthorize("hasRole('ADMIN') or hasAuthority('PRODUCT_EDIT')")
|
||||
public ApiResponse<ProductDetailResponse> createProduct(@Valid @RequestBody CreateProductRequest request) {
|
||||
return ApiResponse.success("商品已创建", catalogService.createProduct(request));
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
@PreAuthorize("hasRole('ADMIN') or hasAnyAuthority('PRODUCT_VIEW','PRODUCT_EDIT','ASSET_UPLOAD')")
|
||||
public ApiResponse<ProductDetailResponse> getProduct(@PathVariable Long id) {
|
||||
return ApiResponse.success(catalogService.getProduct(id));
|
||||
}
|
||||
|
||||
@GetMapping("/{id}/assets")
|
||||
@PreAuthorize("hasRole('ADMIN') or hasAnyAuthority('PRODUCT_VIEW','PRODUCT_EDIT','ASSET_UPLOAD')")
|
||||
public ApiResponse<List<ProductAssetResponse>> getProductAssets(@PathVariable Long id) {
|
||||
return ApiResponse.success(catalogService.getProductAssets(id));
|
||||
}
|
||||
|
||||
@PostMapping(value = "/{id}/assets", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@PreAuthorize("hasRole('ADMIN') or hasAuthority('ASSET_UPLOAD')")
|
||||
public ApiResponse<ProductAssetResponse> uploadProductAsset(
|
||||
@PathVariable Long id,
|
||||
@RequestParam String fileRole,
|
||||
@RequestParam(defaultValue = "false") boolean isPrimary,
|
||||
@RequestPart("file") MultipartFile file) {
|
||||
return ApiResponse.success("上传成功", catalogService.uploadProductAsset(id, fileRole, isPrimary, file));
|
||||
}
|
||||
|
||||
@GetMapping("/{id}/assets/{assetId}/download")
|
||||
@PreAuthorize("hasRole('ADMIN') or hasAnyAuthority('PRODUCT_VIEW','PRODUCT_EDIT','ASSET_UPLOAD')")
|
||||
public ResponseEntity<byte[]> downloadProductAsset(@PathVariable Long id, @PathVariable Long assetId) {
|
||||
ProductAssetContent assetContent = catalogService.downloadProductAsset(id, assetId);
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentDisposition(ContentDisposition.attachment().filename(assetContent.getFileName(), StandardCharsets.UTF_8).build());
|
||||
return ResponseEntity.ok()
|
||||
.headers(headers)
|
||||
.contentType(resolveMediaType(assetContent.getMimeType()))
|
||||
.contentLength(assetContent.getFileSize())
|
||||
.body(assetContent.getContent());
|
||||
}
|
||||
|
||||
@PutMapping("/{id}/assets/{assetId}/primary")
|
||||
@PreAuthorize("hasRole('ADMIN') or hasAuthority('ASSET_UPLOAD')")
|
||||
public ApiResponse<ProductAssetResponse> setPrimaryProductAsset(@PathVariable Long id, @PathVariable Long assetId) {
|
||||
return ApiResponse.success("主图已更新", catalogService.setPrimaryProductAsset(id, assetId));
|
||||
}
|
||||
|
||||
@PutMapping("/{id}/assets/image-order")
|
||||
@PreAuthorize("hasRole('ADMIN') or hasAuthority('ASSET_UPLOAD')")
|
||||
public ApiResponse<List<ProductAssetResponse>> reorderProductImageAssets(
|
||||
@PathVariable Long id,
|
||||
@Valid @RequestBody ReorderProductImageAssetsRequest request) {
|
||||
return ApiResponse.success("图片顺序已更新", catalogService.reorderProductImageAssets(id, request.getAssetIds()));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}/assets/{assetId}")
|
||||
@PreAuthorize("hasRole('ADMIN') or hasAuthority('ASSET_UPLOAD')")
|
||||
public ApiResponse<java.util.Map<String, String>> deleteProductAsset(@PathVariable Long id, @PathVariable Long assetId) {
|
||||
catalogService.deleteProductAsset(id, assetId);
|
||||
return ApiResponse.success(java.util.Map.of("message", "附件已删除"));
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
@PreAuthorize("hasRole('ADMIN') or hasAuthority('PRODUCT_EDIT')")
|
||||
public ApiResponse<ProductDetailResponse> updateProduct(@PathVariable Long id, @Valid @RequestBody UpdateProductRequest request) {
|
||||
return ApiResponse.success("保存成功", catalogService.updateProduct(id, request));
|
||||
}
|
||||
|
||||
private MediaType resolveMediaType(String mimeType) {
|
||||
try {
|
||||
return MediaType.parseMediaType(mimeType);
|
||||
} catch (Exception exception) {
|
||||
return MediaType.APPLICATION_OCTET_STREAM;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package com.teapot.system.catalog;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
public class ProductDetailResponse {
|
||||
|
||||
private final Long id;
|
||||
private final Long categoryId;
|
||||
private final String model;
|
||||
private final String name;
|
||||
private final String sku;
|
||||
private final BigDecimal price;
|
||||
private final Integer stock;
|
||||
private final String status;
|
||||
private final String statusType;
|
||||
private final String remark;
|
||||
|
||||
public ProductDetailResponse(
|
||||
Long id,
|
||||
Long categoryId,
|
||||
String model,
|
||||
String name,
|
||||
String sku,
|
||||
BigDecimal price,
|
||||
Integer stock,
|
||||
String status,
|
||||
String statusType,
|
||||
String remark) {
|
||||
this.id = id;
|
||||
this.categoryId = categoryId;
|
||||
this.model = model;
|
||||
this.name = name;
|
||||
this.sku = sku;
|
||||
this.price = price;
|
||||
this.stock = stock;
|
||||
this.status = status;
|
||||
this.statusType = statusType;
|
||||
this.remark = remark;
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public Long getCategoryId() {
|
||||
return categoryId;
|
||||
}
|
||||
|
||||
public String getModel() {
|
||||
return model;
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public String getSku() {
|
||||
return sku;
|
||||
}
|
||||
|
||||
public BigDecimal getPrice() {
|
||||
return price;
|
||||
}
|
||||
|
||||
public Integer getStock() {
|
||||
return stock;
|
||||
}
|
||||
|
||||
public String getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
public String getStatusType() {
|
||||
return statusType;
|
||||
}
|
||||
|
||||
public String getRemark() {
|
||||
return remark;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package com.teapot.system.catalog;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
public class ProductSummaryResponse {
|
||||
|
||||
private final Long id;
|
||||
private final Long categoryId;
|
||||
private final String model;
|
||||
private final String name;
|
||||
private final String sku;
|
||||
private final BigDecimal price;
|
||||
private final Integer stock;
|
||||
private final String status;
|
||||
private final String statusType;
|
||||
private final Long coverAssetId;
|
||||
private final String remark;
|
||||
|
||||
public ProductSummaryResponse(
|
||||
Long id,
|
||||
Long categoryId,
|
||||
String model,
|
||||
String name,
|
||||
String sku,
|
||||
BigDecimal price,
|
||||
Integer stock,
|
||||
String status,
|
||||
String statusType,
|
||||
Long coverAssetId,
|
||||
String remark) {
|
||||
this.id = id;
|
||||
this.categoryId = categoryId;
|
||||
this.model = model;
|
||||
this.name = name;
|
||||
this.sku = sku;
|
||||
this.price = price;
|
||||
this.stock = stock;
|
||||
this.status = status;
|
||||
this.statusType = statusType;
|
||||
this.coverAssetId = coverAssetId;
|
||||
this.remark = remark;
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public Long getCategoryId() {
|
||||
return categoryId;
|
||||
}
|
||||
|
||||
public String getModel() {
|
||||
return model;
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public String getSku() {
|
||||
return sku;
|
||||
}
|
||||
|
||||
public BigDecimal getPrice() {
|
||||
return price;
|
||||
}
|
||||
|
||||
public Integer getStock() {
|
||||
return stock;
|
||||
}
|
||||
|
||||
public String getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
public String getStatusType() {
|
||||
return statusType;
|
||||
}
|
||||
|
||||
public Long getCoverAssetId() {
|
||||
return coverAssetId;
|
||||
}
|
||||
|
||||
public String getRemark() {
|
||||
return remark;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.teapot.system.catalog;
|
||||
|
||||
import javax.validation.constraints.NotEmpty;
|
||||
import javax.validation.constraints.NotNull;
|
||||
import java.util.List;
|
||||
|
||||
public class ReorderProductImageAssetsRequest {
|
||||
|
||||
@NotEmpty(message = "图片顺序不能为空")
|
||||
private List<@NotNull(message = "图片编号不能为空") Long> assetIds;
|
||||
|
||||
public List<Long> getAssetIds() {
|
||||
return assetIds;
|
||||
}
|
||||
|
||||
public void setAssetIds(List<Long> assetIds) {
|
||||
this.assetIds = assetIds;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package com.teapot.system.catalog;
|
||||
|
||||
import javax.validation.constraints.DecimalMin;
|
||||
import javax.validation.constraints.Min;
|
||||
import javax.validation.constraints.NotBlank;
|
||||
import javax.validation.constraints.NotNull;
|
||||
import java.math.BigDecimal;
|
||||
|
||||
public class UpdateProductRequest {
|
||||
|
||||
@NotBlank(message = "型号不能为空")
|
||||
private String model;
|
||||
|
||||
@NotBlank(message = "商品名称不能为空")
|
||||
private String name;
|
||||
|
||||
@NotBlank(message = "SKU 不能为空")
|
||||
private String sku;
|
||||
|
||||
@NotNull(message = "价格不能为空")
|
||||
@DecimalMin(value = "0.0", inclusive = true, message = "价格不能为负数")
|
||||
private BigDecimal price;
|
||||
|
||||
@NotNull(message = "库存不能为空")
|
||||
@Min(value = 0, message = "库存不能为负数")
|
||||
private Integer stock;
|
||||
|
||||
@NotBlank(message = "状态不能为空")
|
||||
private String status;
|
||||
|
||||
private String remark;
|
||||
|
||||
public String getModel() {
|
||||
return model;
|
||||
}
|
||||
|
||||
public void setModel(String model) {
|
||||
this.model = model;
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public void setName(String name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
public String getSku() {
|
||||
return sku;
|
||||
}
|
||||
|
||||
public void setSku(String sku) {
|
||||
this.sku = sku;
|
||||
}
|
||||
|
||||
public BigDecimal getPrice() {
|
||||
return price;
|
||||
}
|
||||
|
||||
public void setPrice(BigDecimal price) {
|
||||
this.price = price;
|
||||
}
|
||||
|
||||
public Integer getStock() {
|
||||
return stock;
|
||||
}
|
||||
|
||||
public void setStock(Integer stock) {
|
||||
this.stock = stock;
|
||||
}
|
||||
|
||||
public String getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
public void setStatus(String status) {
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
public String getRemark() {
|
||||
return remark;
|
||||
}
|
||||
|
||||
public void setRemark(String remark) {
|
||||
this.remark = remark;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.teapot.system.common;
|
||||
|
||||
public class ApiException extends RuntimeException {
|
||||
|
||||
public ApiException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.teapot.system.common;
|
||||
|
||||
public class ApiResponse<T> {
|
||||
|
||||
private final boolean success;
|
||||
private final String message;
|
||||
private final T data;
|
||||
|
||||
private ApiResponse(boolean success, String message, T data) {
|
||||
this.success = success;
|
||||
this.message = message;
|
||||
this.data = data;
|
||||
}
|
||||
|
||||
public static <T> ApiResponse<T> success(T data) {
|
||||
return new ApiResponse<>(true, "ok", data);
|
||||
}
|
||||
|
||||
public static <T> ApiResponse<T> success(String message, T data) {
|
||||
return new ApiResponse<>(true, message, data);
|
||||
}
|
||||
|
||||
public static <T> ApiResponse<T> failure(String message) {
|
||||
return new ApiResponse<>(false, message, null);
|
||||
}
|
||||
|
||||
public boolean isSuccess() {
|
||||
return success;
|
||||
}
|
||||
|
||||
public String getMessage() {
|
||||
return message;
|
||||
}
|
||||
|
||||
public T getData() {
|
||||
return data;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.teapot.system.common;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.validation.FieldError;
|
||||
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@RestControllerAdvice
|
||||
public class GlobalExceptionHandler {
|
||||
|
||||
@ExceptionHandler(ApiException.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleApiException(ApiException exception) {
|
||||
return ResponseEntity.badRequest().body(ApiResponse.failure(exception.getMessage()));
|
||||
}
|
||||
|
||||
@ExceptionHandler(MethodArgumentNotValidException.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleValidationException(MethodArgumentNotValidException exception) {
|
||||
String message = exception.getBindingResult().getFieldErrors().stream()
|
||||
.map(FieldError::getDefaultMessage)
|
||||
.collect(Collectors.joining("; "));
|
||||
return ResponseEntity.badRequest().body(ApiResponse.failure(message));
|
||||
}
|
||||
|
||||
@ExceptionHandler(Exception.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleException(Exception exception) {
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(ApiResponse.failure("服务端异常: " + exception.getMessage()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package com.teapot.system.config;
|
||||
|
||||
import com.teapot.system.auth.PermissionCodes;
|
||||
import com.teapot.system.catalog.CatalogService;
|
||||
import com.teapot.system.order.OrderService;
|
||||
import com.teapot.system.user.UserAccountRepository;
|
||||
import org.springframework.boot.CommandLineRunner;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
|
||||
@Component
|
||||
public class BootstrapDataInitializer implements CommandLineRunner {
|
||||
|
||||
private final UserAccountRepository userAccountRepository;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final CatalogService catalogService;
|
||||
private final OrderService orderService;
|
||||
|
||||
public BootstrapDataInitializer(
|
||||
UserAccountRepository userAccountRepository,
|
||||
PasswordEncoder passwordEncoder,
|
||||
CatalogService catalogService,
|
||||
OrderService orderService) {
|
||||
this.userAccountRepository = userAccountRepository;
|
||||
this.passwordEncoder = passwordEncoder;
|
||||
this.catalogService = catalogService;
|
||||
this.orderService = orderService;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run(String... args) {
|
||||
seedAdmin();
|
||||
seedCustomer();
|
||||
seedPacker();
|
||||
seedCategoriesAndProducts();
|
||||
seedOrders();
|
||||
}
|
||||
|
||||
private void seedAdmin() {
|
||||
if (userAccountRepository.existsByUsername("admin")) {
|
||||
return;
|
||||
}
|
||||
|
||||
long userId = userAccountRepository.insertUser(
|
||||
"admin",
|
||||
passwordEncoder.encode("Admin@123"),
|
||||
"系统管理员",
|
||||
"ADMIN",
|
||||
null
|
||||
);
|
||||
userAccountRepository.replacePermissions(userId, List.of(
|
||||
PermissionCodes.PRODUCT_VIEW,
|
||||
PermissionCodes.PRODUCT_EDIT,
|
||||
PermissionCodes.ASSET_UPLOAD,
|
||||
PermissionCodes.ORDER_VIEW,
|
||||
PermissionCodes.ORDER_PROCESS,
|
||||
PermissionCodes.ORDER_COMPLETE,
|
||||
PermissionCodes.STATISTICS_VIEW,
|
||||
PermissionCodes.USER_MANAGE
|
||||
));
|
||||
}
|
||||
|
||||
private void seedCustomer() {
|
||||
if (userAccountRepository.existsByUsername("customer")) {
|
||||
return;
|
||||
}
|
||||
|
||||
long userId = userAccountRepository.insertUser(
|
||||
"customer",
|
||||
passwordEncoder.encode("Customer@123"),
|
||||
"客户演示用户",
|
||||
"NORMAL",
|
||||
1L
|
||||
);
|
||||
userAccountRepository.replacePermissions(userId, List.of(
|
||||
PermissionCodes.PRODUCT_VIEW
|
||||
));
|
||||
}
|
||||
|
||||
private void seedPacker() {
|
||||
if (userAccountRepository.existsByUsername("packer")) {
|
||||
return;
|
||||
}
|
||||
|
||||
long userId = userAccountRepository.insertUser(
|
||||
"packer",
|
||||
passwordEncoder.encode("Packer@123"),
|
||||
"打包工演示用户",
|
||||
"NORMAL",
|
||||
1L
|
||||
);
|
||||
userAccountRepository.replacePermissions(userId, List.of(
|
||||
PermissionCodes.ORDER_VIEW,
|
||||
PermissionCodes.ORDER_PROCESS,
|
||||
PermissionCodes.ORDER_COMPLETE
|
||||
));
|
||||
}
|
||||
|
||||
private void seedCategoriesAndProducts() {
|
||||
if (catalogService.countCategories() == 0) {
|
||||
catalogService.seedCategory(1L, null, "1号普通壶", 1, true, "PRODUCT");
|
||||
catalogService.seedCategory(2L, null, "2号锤纹壶", 2, true, "PRODUCT");
|
||||
catalogService.seedCategory(3L, null, "宣传物料", 3, false, "MATERIAL");
|
||||
}
|
||||
|
||||
if (catalogService.countProducts() == 0) {
|
||||
catalogService.seedProduct(101L, 1L, "TH-001-A", "花纹A", "1号普通壶", "AVAILABLE", new BigDecimal("198"), 82, "常规热销款式,适合批发出货。");
|
||||
catalogService.seedProduct(102L, 1L, "TH-001-B", "花纹B", "1号普通壶", "LOW_STOCK", new BigDecimal("228"), 9, "库存较少,建议控制订单数量。");
|
||||
catalogService.seedProduct(103L, 1L, "TH-001-C", "花纹C", "1号普通壶", "HIGH_STOCK", new BigDecimal("268"), 145, "库存充足,适合大单。");
|
||||
catalogService.seedProduct(201L, 2L, "TH-002-A", "锤纹青古", "2号锤纹壶", "AVAILABLE", new BigDecimal("318"), 36, "主打纹理工艺,适合展示。");
|
||||
}
|
||||
}
|
||||
|
||||
private void seedOrders() {
|
||||
if (orderService.countOrders() > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
orderService.seedOrder(1L, "TH20260411-008", "PENDING", 36, new BigDecimal("5412"), "", "2026-04-11 09:30:00", null);
|
||||
orderService.seedOrderItem(1L, 101L, "花纹A", "TH-001-A", "1号普通壶", new BigDecimal("198"), 10, new BigDecimal("1980"));
|
||||
orderService.seedOrderItem(1L, 102L, "花纹B", "TH-001-B", "1号普通壶", new BigDecimal("228"), 8, new BigDecimal("1824"));
|
||||
orderService.seedOrderItem(1L, 103L, "花纹C", "TH-001-C", "1号普通壶", new BigDecimal("268"), 6, new BigDecimal("1608"));
|
||||
|
||||
orderService.seedOrder(2L, "TH20260410-005", "COMPLETED", 18, new BigDecimal("5004"), "SF847266012210", "2026-04-10 15:10:00", "2026-04-10 18:20:00");
|
||||
orderService.seedOrderItem(2L, 201L, "锤纹青古", "TH-002-A", "2号锤纹壶", new BigDecimal("318"), 12, new BigDecimal("3816"));
|
||||
orderService.seedOrderItem(2L, 101L, "花纹A", "TH-001-A", "1号普通壶", new BigDecimal("198"), 6, new BigDecimal("1188"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.teapot.system.config;
|
||||
|
||||
import com.teapot.system.auth.JwtAuthenticationFilter;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
||||
import org.springframework.web.cors.CorsConfiguration;
|
||||
import org.springframework.web.cors.CorsConfigurationSource;
|
||||
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Configuration
|
||||
@EnableGlobalMethodSecurity(prePostEnabled = true)
|
||||
public class SecurityConfig {
|
||||
|
||||
private final JwtAuthenticationFilter jwtAuthenticationFilter;
|
||||
|
||||
public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter) {
|
||||
this.jwtAuthenticationFilter = jwtAuthenticationFilter;
|
||||
}
|
||||
|
||||
@Bean
|
||||
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
|
||||
http
|
||||
.csrf().disable()
|
||||
.cors().and()
|
||||
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()
|
||||
.authorizeRequests()
|
||||
.antMatchers("/api/auth/login", "/api/health").permitAll()
|
||||
.antMatchers(HttpMethod.OPTIONS, "/**").permitAll()
|
||||
.anyRequest().authenticated()
|
||||
.and()
|
||||
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
|
||||
|
||||
return http.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public PasswordEncoder passwordEncoder() {
|
||||
return new BCryptPasswordEncoder();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public CorsConfigurationSource corsConfigurationSource() {
|
||||
CorsConfiguration configuration = new CorsConfiguration();
|
||||
configuration.setAllowedOriginPatterns(List.of("*"));
|
||||
configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
|
||||
configuration.setAllowedHeaders(List.of("*"));
|
||||
configuration.setAllowCredentials(false);
|
||||
|
||||
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||
source.registerCorsConfiguration("/**", configuration);
|
||||
return source;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.teapot.system.order;
|
||||
|
||||
import javax.validation.constraints.NotBlank;
|
||||
|
||||
public class CompleteOrderRequest {
|
||||
|
||||
@NotBlank(message = "快递单号不能为空")
|
||||
private String expressNo;
|
||||
|
||||
public String getExpressNo() {
|
||||
return expressNo;
|
||||
}
|
||||
|
||||
public void setExpressNo(String expressNo) {
|
||||
this.expressNo = expressNo;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.teapot.system.order;
|
||||
|
||||
import javax.validation.Valid;
|
||||
import javax.validation.constraints.NotEmpty;
|
||||
import java.util.List;
|
||||
|
||||
public class CreateOrderRequest {
|
||||
|
||||
@NotEmpty(message = "订单明细不能为空")
|
||||
@Valid
|
||||
private List<OrderItemRequest> items;
|
||||
|
||||
private String remark;
|
||||
|
||||
public List<OrderItemRequest> getItems() {
|
||||
return items;
|
||||
}
|
||||
|
||||
public void setItems(List<OrderItemRequest> items) {
|
||||
this.items = items;
|
||||
}
|
||||
|
||||
public String getRemark() {
|
||||
return remark;
|
||||
}
|
||||
|
||||
public void setRemark(String remark) {
|
||||
this.remark = remark;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package com.teapot.system.order;
|
||||
|
||||
import com.teapot.system.common.ApiResponse;
|
||||
import org.springframework.http.ContentDisposition;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import javax.validation.Valid;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/orders")
|
||||
public class OrderController {
|
||||
|
||||
private final OrderService orderService;
|
||||
|
||||
public OrderController(OrderService orderService) {
|
||||
this.orderService = orderService;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
@PreAuthorize("hasRole('ADMIN') or hasAnyAuthority('ORDER_VIEW','ORDER_PROCESS','ORDER_COMPLETE')")
|
||||
public ApiResponse<List<OrderSummaryResponse>> listOrders() {
|
||||
return ApiResponse.success(orderService.listOrders());
|
||||
}
|
||||
|
||||
@GetMapping("/{orderNo}")
|
||||
@PreAuthorize("hasRole('ADMIN') or hasAnyAuthority('ORDER_VIEW','ORDER_PROCESS','ORDER_COMPLETE')")
|
||||
public ApiResponse<OrderDetailResponse> getOrder(@PathVariable String orderNo) {
|
||||
return ApiResponse.success(orderService.getOrderDetail(orderNo));
|
||||
}
|
||||
|
||||
@GetMapping("/{orderNo}/labels/download")
|
||||
@PreAuthorize("hasRole('ADMIN') or hasAnyAuthority('ORDER_VIEW','ORDER_PROCESS','ORDER_COMPLETE')")
|
||||
public ResponseEntity<byte[]> downloadOrderLabels(@PathVariable String orderNo) {
|
||||
OrderLabelDownload download = orderService.downloadOrderLabels(orderNo);
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentDisposition(ContentDisposition.attachment().filename(download.getFileName(), StandardCharsets.UTF_8).build());
|
||||
return ResponseEntity.ok()
|
||||
.headers(headers)
|
||||
.contentType(MediaType.parseMediaType(download.getMimeType()))
|
||||
.contentLength(download.getFileSize())
|
||||
.body(download.getContent());
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
@PreAuthorize("hasRole('ADMIN') or hasAuthority('ORDER_PROCESS')")
|
||||
public ApiResponse<OrderDetailResponse> createOrder(@Valid @RequestBody CreateOrderRequest request) {
|
||||
return ApiResponse.success("订单创建成功", orderService.createOrder(request));
|
||||
}
|
||||
|
||||
@PutMapping("/{orderNo}")
|
||||
@PreAuthorize("hasRole('ADMIN') or hasAuthority('ORDER_PROCESS')")
|
||||
public ApiResponse<OrderDetailResponse> updateOrder(@PathVariable String orderNo, @Valid @RequestBody UpdateOrderRequest request) {
|
||||
return ApiResponse.success("订单已更新", orderService.updateOrder(orderNo, request));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{orderNo}")
|
||||
@PreAuthorize("hasRole('ADMIN') or hasAuthority('ORDER_PROCESS')")
|
||||
public ApiResponse<Map<String, String>> deleteOrder(@PathVariable String orderNo) {
|
||||
orderService.deleteOrder(orderNo);
|
||||
return ApiResponse.success(Map.of("message", "订单已删除"));
|
||||
}
|
||||
|
||||
@PostMapping("/{orderNo}/complete")
|
||||
@PreAuthorize("hasRole('ADMIN') or hasAuthority('ORDER_COMPLETE')")
|
||||
public ApiResponse<OrderDetailResponse> completeOrder(@PathVariable String orderNo, @Valid @RequestBody CompleteOrderRequest request) {
|
||||
return ApiResponse.success("订单已完成", orderService.completeOrder(orderNo, request.getExpressNo()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package com.teapot.system.order;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
|
||||
public class OrderDetailResponse {
|
||||
|
||||
private final String id;
|
||||
private final String status;
|
||||
private final String createdAt;
|
||||
private final Integer totalQuantity;
|
||||
private final BigDecimal totalAmount;
|
||||
private final String expressNo;
|
||||
private final String completedAt;
|
||||
private final String remark;
|
||||
private final List<OrderItemResponse> items;
|
||||
private final boolean labelReady;
|
||||
private final List<String> missingLabelProducts;
|
||||
|
||||
public OrderDetailResponse(
|
||||
String id,
|
||||
String status,
|
||||
String createdAt,
|
||||
Integer totalQuantity,
|
||||
BigDecimal totalAmount,
|
||||
String expressNo,
|
||||
String completedAt,
|
||||
String remark,
|
||||
List<OrderItemResponse> items) {
|
||||
this(id, status, createdAt, totalQuantity, totalAmount, expressNo, completedAt, remark, items, true, List.of());
|
||||
}
|
||||
|
||||
public OrderDetailResponse(
|
||||
String id,
|
||||
String status,
|
||||
String createdAt,
|
||||
Integer totalQuantity,
|
||||
BigDecimal totalAmount,
|
||||
String expressNo,
|
||||
String completedAt,
|
||||
String remark,
|
||||
List<OrderItemResponse> items,
|
||||
boolean labelReady,
|
||||
List<String> missingLabelProducts) {
|
||||
this.id = id;
|
||||
this.status = status;
|
||||
this.createdAt = createdAt;
|
||||
this.totalQuantity = totalQuantity;
|
||||
this.totalAmount = totalAmount;
|
||||
this.expressNo = expressNo;
|
||||
this.completedAt = completedAt;
|
||||
this.remark = remark;
|
||||
this.items = items;
|
||||
this.labelReady = labelReady;
|
||||
this.missingLabelProducts = missingLabelProducts == null ? List.of() : List.copyOf(missingLabelProducts);
|
||||
}
|
||||
|
||||
public String getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public String getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
public String getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
public Integer getTotalQuantity() {
|
||||
return totalQuantity;
|
||||
}
|
||||
|
||||
public BigDecimal getTotalAmount() {
|
||||
return totalAmount;
|
||||
}
|
||||
|
||||
public String getExpressNo() {
|
||||
return expressNo;
|
||||
}
|
||||
|
||||
public String getCompletedAt() {
|
||||
return completedAt;
|
||||
}
|
||||
|
||||
public String getRemark() {
|
||||
return remark;
|
||||
}
|
||||
|
||||
public List<OrderItemResponse> getItems() {
|
||||
return items;
|
||||
}
|
||||
|
||||
public boolean isLabelReady() {
|
||||
return labelReady;
|
||||
}
|
||||
|
||||
public List<String> getMissingLabelProducts() {
|
||||
return missingLabelProducts;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.teapot.system.order;
|
||||
|
||||
import javax.validation.constraints.Min;
|
||||
import javax.validation.constraints.NotNull;
|
||||
|
||||
public class OrderItemRequest {
|
||||
|
||||
@NotNull(message = "商品不能为空")
|
||||
private Long productId;
|
||||
|
||||
@NotNull(message = "数量不能为空")
|
||||
@Min(value = 1, message = "数量至少为 1")
|
||||
private Integer quantity;
|
||||
|
||||
public Long getProductId() {
|
||||
return productId;
|
||||
}
|
||||
|
||||
public void setProductId(Long productId) {
|
||||
this.productId = productId;
|
||||
}
|
||||
|
||||
public Integer getQuantity() {
|
||||
return quantity;
|
||||
}
|
||||
|
||||
public void setQuantity(Integer quantity) {
|
||||
this.quantity = quantity;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.teapot.system.order;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
public class OrderItemResponse {
|
||||
|
||||
private final Long productId;
|
||||
private final String name;
|
||||
private final String sku;
|
||||
private final BigDecimal price;
|
||||
private final Integer quantity;
|
||||
private final BigDecimal amount;
|
||||
|
||||
public OrderItemResponse(Long productId, String name, String sku, BigDecimal price, Integer quantity, BigDecimal amount) {
|
||||
this.productId = productId;
|
||||
this.name = name;
|
||||
this.sku = sku;
|
||||
this.price = price;
|
||||
this.quantity = quantity;
|
||||
this.amount = amount;
|
||||
}
|
||||
|
||||
public Long getProductId() {
|
||||
return productId;
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public String getSku() {
|
||||
return sku;
|
||||
}
|
||||
|
||||
public BigDecimal getPrice() {
|
||||
return price;
|
||||
}
|
||||
|
||||
public Integer getQuantity() {
|
||||
return quantity;
|
||||
}
|
||||
|
||||
public BigDecimal getAmount() {
|
||||
return amount;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.teapot.system.order;
|
||||
|
||||
public class OrderLabelDownload {
|
||||
|
||||
private final String fileName;
|
||||
private final String mimeType;
|
||||
private final Long fileSize;
|
||||
private final byte[] content;
|
||||
|
||||
public OrderLabelDownload(String fileName, String mimeType, Long fileSize, byte[] content) {
|
||||
this.fileName = fileName;
|
||||
this.mimeType = mimeType;
|
||||
this.fileSize = fileSize;
|
||||
this.content = content;
|
||||
}
|
||||
|
||||
public String getFileName() {
|
||||
return fileName;
|
||||
}
|
||||
|
||||
public String getMimeType() {
|
||||
return mimeType;
|
||||
}
|
||||
|
||||
public Long getFileSize() {
|
||||
return fileSize;
|
||||
}
|
||||
|
||||
public byte[] getContent() {
|
||||
return content;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.teapot.system.order;
|
||||
|
||||
public class OrderLabelTemplateContent {
|
||||
|
||||
private final String fileName;
|
||||
private final String mimeType;
|
||||
private final Long fileSize;
|
||||
private final byte[] content;
|
||||
|
||||
public OrderLabelTemplateContent(String fileName, String mimeType, Long fileSize, byte[] content) {
|
||||
this.fileName = fileName;
|
||||
this.mimeType = mimeType;
|
||||
this.fileSize = fileSize;
|
||||
this.content = content;
|
||||
}
|
||||
|
||||
public String getFileName() {
|
||||
return fileName;
|
||||
}
|
||||
|
||||
public String getMimeType() {
|
||||
return mimeType;
|
||||
}
|
||||
|
||||
public Long getFileSize() {
|
||||
return fileSize;
|
||||
}
|
||||
|
||||
public byte[] getContent() {
|
||||
return content;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.teapot.system.order;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
public class OrderProductSnapshot {
|
||||
|
||||
private final Long productId;
|
||||
private final String productName;
|
||||
private final String sku;
|
||||
private final String modelName;
|
||||
private final BigDecimal unitPrice;
|
||||
private final Integer stockQuantity;
|
||||
|
||||
public OrderProductSnapshot(Long productId, String productName, String sku, String modelName, BigDecimal unitPrice, Integer stockQuantity) {
|
||||
this.productId = productId;
|
||||
this.productName = productName;
|
||||
this.sku = sku;
|
||||
this.modelName = modelName;
|
||||
this.unitPrice = unitPrice;
|
||||
this.stockQuantity = stockQuantity;
|
||||
}
|
||||
|
||||
public Long getProductId() {
|
||||
return productId;
|
||||
}
|
||||
|
||||
public String getProductName() {
|
||||
return productName;
|
||||
}
|
||||
|
||||
public String getSku() {
|
||||
return sku;
|
||||
}
|
||||
|
||||
public String getModelName() {
|
||||
return modelName;
|
||||
}
|
||||
|
||||
public BigDecimal getUnitPrice() {
|
||||
return unitPrice;
|
||||
}
|
||||
|
||||
public Integer getStockQuantity() {
|
||||
return stockQuantity;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.teapot.system.order;
|
||||
|
||||
public class OrderRecordData {
|
||||
|
||||
private final Long id;
|
||||
private final String orderNo;
|
||||
private final String status;
|
||||
|
||||
public OrderRecordData(Long id, String orderNo, String status) {
|
||||
this.id = id;
|
||||
this.orderNo = orderNo;
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public String getOrderNo() {
|
||||
return orderNo;
|
||||
}
|
||||
|
||||
public String getStatus() {
|
||||
return status;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
package com.teapot.system.order;
|
||||
|
||||
import org.springframework.jdbc.core.ConnectionCallback;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
@Repository
|
||||
public class OrderRepository {
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
public OrderRepository(JdbcTemplate jdbcTemplate) {
|
||||
this.jdbcTemplate = jdbcTemplate;
|
||||
}
|
||||
|
||||
public long countOrders() {
|
||||
Integer count = jdbcTemplate.queryForObject("SELECT COUNT(1) FROM order_record", Integer.class);
|
||||
return count == null ? 0L : count;
|
||||
}
|
||||
|
||||
public void insertOrder(
|
||||
long id,
|
||||
String orderNo,
|
||||
String status,
|
||||
int totalQuantity,
|
||||
BigDecimal totalAmount,
|
||||
String expressNo,
|
||||
String createdAt,
|
||||
String completedAt) {
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO order_record (id, order_no, status, total_quantity, total_amount, express_no, created_at, completed_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
id,
|
||||
orderNo,
|
||||
status,
|
||||
totalQuantity,
|
||||
totalAmount,
|
||||
expressNo,
|
||||
createdAt,
|
||||
completedAt
|
||||
);
|
||||
}
|
||||
|
||||
public void insertOrderItem(
|
||||
long orderId,
|
||||
long productId,
|
||||
String productNameSnapshot,
|
||||
String skuSnapshot,
|
||||
String modelNameSnapshot,
|
||||
BigDecimal unitPrice,
|
||||
int quantity,
|
||||
BigDecimal lineAmount) {
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO order_item (order_id, product_id, product_name_snapshot, sku_snapshot, model_name_snapshot, unit_price, quantity, line_amount) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
orderId,
|
||||
productId,
|
||||
productNameSnapshot,
|
||||
skuSnapshot,
|
||||
modelNameSnapshot,
|
||||
unitPrice,
|
||||
quantity,
|
||||
lineAmount
|
||||
);
|
||||
}
|
||||
|
||||
public List<OrderSummaryResponse> findOrders() {
|
||||
return jdbcTemplate.query(
|
||||
"SELECT order_no, status, created_at, total_quantity, total_amount, express_no FROM order_record ORDER BY created_at DESC, id DESC",
|
||||
(resultSet, rowNum) -> new OrderSummaryResponse(
|
||||
resultSet.getString("order_no"),
|
||||
resultSet.getString("status"),
|
||||
resultSet.getString("created_at"),
|
||||
resultSet.getInt("total_quantity"),
|
||||
readDecimal(resultSet, "total_amount"),
|
||||
resultSet.getString("express_no")
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
public Optional<OrderRecordData> findOrderRecordByOrderNo(String orderNo) {
|
||||
List<OrderRecordData> orders = jdbcTemplate.query(
|
||||
"SELECT id, order_no, status FROM order_record WHERE order_no = ?",
|
||||
(resultSet, rowNum) -> new OrderRecordData(
|
||||
resultSet.getLong("id"),
|
||||
resultSet.getString("order_no"),
|
||||
resultSet.getString("status")
|
||||
),
|
||||
orderNo
|
||||
);
|
||||
|
||||
return orders.stream().findFirst();
|
||||
}
|
||||
|
||||
public Optional<OrderProductSnapshot> findProductSnapshotById(Long productId) {
|
||||
List<OrderProductSnapshot> products = jdbcTemplate.query(
|
||||
"SELECT id, name, sku, model_name, wholesale_price, stock_quantity FROM product_item WHERE id = ?",
|
||||
(resultSet, rowNum) -> new OrderProductSnapshot(
|
||||
resultSet.getLong("id"),
|
||||
resultSet.getString("name"),
|
||||
resultSet.getString("sku"),
|
||||
resultSet.getString("model_name"),
|
||||
readDecimal(resultSet, "wholesale_price"),
|
||||
resultSet.getInt("stock_quantity")
|
||||
),
|
||||
productId
|
||||
);
|
||||
|
||||
return products.stream().findFirst();
|
||||
}
|
||||
|
||||
public int decreaseProductStock(Long productId, int quantity) {
|
||||
return jdbcTemplate.update(
|
||||
"UPDATE product_item SET stock_quantity = stock_quantity - ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND stock_quantity >= ?",
|
||||
quantity,
|
||||
productId,
|
||||
quantity
|
||||
);
|
||||
}
|
||||
|
||||
public Optional<OrderDetailResponse> findOrderByOrderNo(String orderNo) {
|
||||
List<OrderDetailResponse> orders = jdbcTemplate.query(
|
||||
"SELECT id, order_no, status, created_at, total_quantity, total_amount, express_no, completed_at, remark FROM order_record WHERE order_no = ?",
|
||||
(resultSet, rowNum) -> new OrderDetailResponse(
|
||||
resultSet.getString("order_no"),
|
||||
resultSet.getString("status"),
|
||||
resultSet.getString("created_at"),
|
||||
resultSet.getInt("total_quantity"),
|
||||
readDecimal(resultSet, "total_amount"),
|
||||
resultSet.getString("express_no"),
|
||||
resultSet.getString("completed_at"),
|
||||
resultSet.getString("remark"),
|
||||
findItemsByOrderId(resultSet.getLong("id"))
|
||||
),
|
||||
orderNo
|
||||
);
|
||||
|
||||
return orders.stream().findFirst();
|
||||
}
|
||||
|
||||
public Optional<OrderLabelTemplateContent> findLatestLabelTemplateByProductId(Long productId) {
|
||||
List<OrderLabelTemplateContent> templates = jdbcTemplate.query(
|
||||
"SELECT file_name, mime_type, file_size, content_blob FROM file_asset WHERE business_type = 'PRODUCT' AND business_id = ? AND file_role = 'LABEL' ORDER BY sort_no DESC, id DESC LIMIT 1",
|
||||
(resultSet, rowNum) -> new OrderLabelTemplateContent(
|
||||
resultSet.getString("file_name"),
|
||||
resultSet.getString("mime_type"),
|
||||
resultSet.getLong("file_size"),
|
||||
resultSet.getBytes("content_blob")
|
||||
),
|
||||
productId
|
||||
);
|
||||
|
||||
return templates.stream().findFirst();
|
||||
}
|
||||
|
||||
public int nextDailyOrderSequence(String orderPrefix) {
|
||||
Integer value = jdbcTemplate.queryForObject(
|
||||
"SELECT COALESCE(MAX(CAST(SUBSTR(order_no, 12) AS INTEGER)), 0) FROM order_record WHERE order_no LIKE ?",
|
||||
Integer.class,
|
||||
orderPrefix + "%"
|
||||
);
|
||||
return (value == null ? 0 : value) + 1;
|
||||
}
|
||||
|
||||
public long createOrderRecord(String orderNo, int totalQuantity, BigDecimal totalAmount, String remark) {
|
||||
Long orderId = jdbcTemplate.execute((ConnectionCallback<Long>) connection -> {
|
||||
try (PreparedStatement insertStatement = connection.prepareStatement(
|
||||
"INSERT INTO order_record (order_no, status, total_quantity, total_amount, remark) VALUES (?, 'PENDING', ?, ?, ?)");
|
||||
PreparedStatement identityStatement = connection.prepareStatement("SELECT last_insert_rowid()")) {
|
||||
insertStatement.setString(1, orderNo);
|
||||
insertStatement.setInt(2, totalQuantity);
|
||||
insertStatement.setBigDecimal(3, totalAmount);
|
||||
insertStatement.setString(4, remark);
|
||||
insertStatement.executeUpdate();
|
||||
|
||||
try (ResultSet resultSet = identityStatement.executeQuery()) {
|
||||
if (resultSet.next()) {
|
||||
return resultSet.getLong(1);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
if (orderId == null) {
|
||||
throw new IllegalStateException("创建订单后未返回编号");
|
||||
}
|
||||
|
||||
return orderId;
|
||||
}
|
||||
|
||||
public void updatePendingOrder(Long orderId, int totalQuantity, BigDecimal totalAmount, String remark) {
|
||||
jdbcTemplate.update(
|
||||
"UPDATE order_record SET total_quantity = ?, total_amount = ?, remark = ?, express_no = NULL, completed_at = NULL WHERE id = ? AND status = 'PENDING'",
|
||||
totalQuantity,
|
||||
totalAmount,
|
||||
remark,
|
||||
orderId
|
||||
);
|
||||
}
|
||||
|
||||
public void deleteOrderItems(Long orderId) {
|
||||
jdbcTemplate.update("DELETE FROM order_item WHERE order_id = ?", orderId);
|
||||
}
|
||||
|
||||
public void deleteOrderLogs(Long orderId) {
|
||||
jdbcTemplate.update("DELETE FROM order_operation_log WHERE order_id = ?", orderId);
|
||||
}
|
||||
|
||||
public void deletePendingOrder(Long orderId) {
|
||||
jdbcTemplate.update("DELETE FROM order_record WHERE id = ? AND status = 'PENDING'", orderId);
|
||||
}
|
||||
|
||||
public void deleteOrderRecord(Long orderId) {
|
||||
jdbcTemplate.update("DELETE FROM order_record WHERE id = ?", orderId);
|
||||
}
|
||||
|
||||
public void completeOrder(String orderNo, String expressNo) {
|
||||
jdbcTemplate.update(
|
||||
"UPDATE order_record SET status = 'COMPLETED', express_no = ?, completed_at = CURRENT_TIMESTAMP WHERE order_no = ? AND status = 'PENDING'",
|
||||
expressNo,
|
||||
orderNo
|
||||
);
|
||||
}
|
||||
|
||||
private List<OrderItemResponse> findItemsByOrderId(Long orderId) {
|
||||
return jdbcTemplate.query(
|
||||
"SELECT product_id, product_name_snapshot, sku_snapshot, unit_price, quantity, line_amount FROM order_item WHERE order_id = ? ORDER BY id ASC",
|
||||
(resultSet, rowNum) -> new OrderItemResponse(
|
||||
resultSet.getLong("product_id"),
|
||||
resultSet.getString("product_name_snapshot"),
|
||||
resultSet.getString("sku_snapshot"),
|
||||
readDecimal(resultSet, "unit_price"),
|
||||
resultSet.getInt("quantity"),
|
||||
readDecimal(resultSet, "line_amount")
|
||||
),
|
||||
orderId
|
||||
);
|
||||
}
|
||||
|
||||
private BigDecimal readDecimal(ResultSet resultSet, String column) throws SQLException {
|
||||
return BigDecimal.valueOf(resultSet.getDouble(column));
|
||||
}
|
||||
}
|
||||
439
backend/src/main/java/com/teapot/system/order/OrderService.java
Normal file
439
backend/src/main/java/com/teapot/system/order/OrderService.java
Normal file
@@ -0,0 +1,439 @@
|
||||
package com.teapot.system.order;
|
||||
|
||||
import com.teapot.system.common.ApiException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.math.BigDecimal;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipOutputStream;
|
||||
|
||||
@Service
|
||||
public class OrderService {
|
||||
|
||||
private final OrderRepository orderRepository;
|
||||
|
||||
public OrderService(OrderRepository orderRepository) {
|
||||
this.orderRepository = orderRepository;
|
||||
}
|
||||
|
||||
public List<OrderSummaryResponse> listOrders() {
|
||||
List<OrderSummaryResponse> orders = orderRepository.findOrders();
|
||||
List<OrderSummaryResponse> enrichedOrders = new ArrayList<>();
|
||||
for (OrderSummaryResponse order : orders) {
|
||||
enrichedOrders.add(enrichOrderSummary(order));
|
||||
}
|
||||
return enrichedOrders;
|
||||
}
|
||||
|
||||
public OrderDetailResponse getOrderDetail(String orderNo) {
|
||||
return orderRepository.findOrderByOrderNo(orderNo)
|
||||
.map(this::enrichOrderDetail)
|
||||
.orElseThrow(() -> new ApiException("订单不存在"));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public OrderDetailResponse createOrder(CreateOrderRequest request) {
|
||||
PreparedOrder preparedOrder = prepareOrder(request.getItems(), request.getRemark());
|
||||
String orderNo = generateOrderNo();
|
||||
long orderId = orderRepository.createOrderRecord(orderNo, preparedOrder.getTotalQuantity(), preparedOrder.getTotalAmount(), preparedOrder.getRemark());
|
||||
persistOrderItems(orderId, preparedOrder.getLines());
|
||||
return getOrderDetail(orderNo);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public OrderDetailResponse updateOrder(String orderNo, UpdateOrderRequest request) {
|
||||
OrderRecordData orderRecord = requirePendingOrder(orderNo, "只有未完成订单才允许编辑");
|
||||
PreparedOrder preparedOrder = prepareOrder(request.getItems(), request.getRemark());
|
||||
orderRepository.updatePendingOrder(orderRecord.getId(), preparedOrder.getTotalQuantity(), preparedOrder.getTotalAmount(), preparedOrder.getRemark());
|
||||
orderRepository.deleteOrderItems(orderRecord.getId());
|
||||
persistOrderItems(orderRecord.getId(), preparedOrder.getLines());
|
||||
return getOrderDetail(orderNo);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void deleteOrder(String orderNo) {
|
||||
OrderRecordData orderRecord = orderRepository.findOrderRecordByOrderNo(orderNo)
|
||||
.orElseThrow(() -> new ApiException("订单不存在"));
|
||||
if (!"PENDING".equals(orderRecord.getStatus()) && !isCurrentUserAdmin()) {
|
||||
throw new ApiException("只有管理员才允许删除已完成订单");
|
||||
}
|
||||
orderRepository.deleteOrderLogs(orderRecord.getId());
|
||||
orderRepository.deleteOrderItems(orderRecord.getId());
|
||||
orderRepository.deleteOrderRecord(orderRecord.getId());
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public OrderDetailResponse completeOrder(String orderNo, String expressNo) {
|
||||
requirePendingOrder(orderNo, "只有未完成订单才允许完成操作");
|
||||
OrderDetailResponse orderDetail = getOrderDetail(orderNo);
|
||||
for (OrderItemResponse item : orderDetail.getItems()) {
|
||||
int affectedRows = orderRepository.decreaseProductStock(item.getProductId(), item.getQuantity());
|
||||
if (affectedRows == 0) {
|
||||
OrderProductSnapshot product = orderRepository.findProductSnapshotById(item.getProductId())
|
||||
.orElseThrow(() -> new ApiException("商品不存在: " + item.getProductId()));
|
||||
throw new ApiException(buildInsufficientStockMessage(product, item.getQuantity()));
|
||||
}
|
||||
}
|
||||
orderRepository.completeOrder(orderNo, expressNo.trim());
|
||||
return getOrderDetail(orderNo);
|
||||
}
|
||||
|
||||
public OrderLabelDownload downloadOrderLabels(String orderNo) {
|
||||
OrderDetailResponse order = getOrderDetail(orderNo);
|
||||
if (order.getItems() == null || order.getItems().isEmpty()) {
|
||||
throw new ApiException("订单明细不能为空");
|
||||
}
|
||||
|
||||
OrderLabelStatus labelStatus = buildLabelStatus(order.getItems());
|
||||
if (!labelStatus.isReady()) {
|
||||
throw new ApiException("以下商品缺少标签模板: " + String.join("、", labelStatus.getMissingProducts()));
|
||||
}
|
||||
|
||||
Map<Long, OrderLabelTemplateContent> templates = new HashMap<>();
|
||||
|
||||
for (OrderItemResponse item : order.getItems()) {
|
||||
OrderLabelTemplateContent template = orderRepository.findLatestLabelTemplateByProductId(item.getProductId())
|
||||
.orElseThrow(() -> new ApiException("标签模板读取失败: " + item.getSku()));
|
||||
|
||||
templates.put(item.getProductId(), template);
|
||||
}
|
||||
|
||||
byte[] zipContent = createLabelArchive(order, templates);
|
||||
return new OrderLabelDownload(order.getId() + "-labels.zip", "application/zip", (long) zipContent.length, zipContent);
|
||||
}
|
||||
|
||||
public long countOrders() {
|
||||
return orderRepository.countOrders();
|
||||
}
|
||||
|
||||
public void seedOrder(
|
||||
long id,
|
||||
String orderNo,
|
||||
String status,
|
||||
int totalQuantity,
|
||||
BigDecimal totalAmount,
|
||||
String expressNo,
|
||||
String createdAt,
|
||||
String completedAt) {
|
||||
orderRepository.insertOrder(id, orderNo, status, totalQuantity, totalAmount, expressNo, createdAt, completedAt);
|
||||
}
|
||||
|
||||
public void seedOrderItem(
|
||||
long orderId,
|
||||
long productId,
|
||||
String productNameSnapshot,
|
||||
String skuSnapshot,
|
||||
String modelNameSnapshot,
|
||||
BigDecimal unitPrice,
|
||||
int quantity,
|
||||
BigDecimal lineAmount) {
|
||||
orderRepository.insertOrderItem(orderId, productId, productNameSnapshot, skuSnapshot, modelNameSnapshot, unitPrice, quantity, lineAmount);
|
||||
}
|
||||
|
||||
private OrderRecordData requirePendingOrder(String orderNo, String message) {
|
||||
OrderRecordData orderRecord = orderRepository.findOrderRecordByOrderNo(orderNo)
|
||||
.orElseThrow(() -> new ApiException("订单不存在"));
|
||||
if (!"PENDING".equals(orderRecord.getStatus())) {
|
||||
throw new ApiException(message);
|
||||
}
|
||||
return orderRecord;
|
||||
}
|
||||
|
||||
private boolean isCurrentUserAdmin() {
|
||||
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (authentication == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return authentication.getAuthorities().stream()
|
||||
.anyMatch(authority -> "ROLE_ADMIN".equals(authority.getAuthority()));
|
||||
}
|
||||
|
||||
private PreparedOrder prepareOrder(List<OrderItemRequest> items, String remark) {
|
||||
Map<Long, Integer> mergedQuantities = new LinkedHashMap<>();
|
||||
for (OrderItemRequest item : items) {
|
||||
mergedQuantities.merge(item.getProductId(), item.getQuantity(), Integer::sum);
|
||||
}
|
||||
|
||||
if (mergedQuantities.isEmpty()) {
|
||||
throw new ApiException("订单明细不能为空");
|
||||
}
|
||||
|
||||
List<PreparedOrderLine> lines = new java.util.ArrayList<>();
|
||||
int totalQuantity = 0;
|
||||
BigDecimal totalAmount = BigDecimal.ZERO;
|
||||
|
||||
for (Map.Entry<Long, Integer> entry : mergedQuantities.entrySet()) {
|
||||
OrderProductSnapshot product = orderRepository.findProductSnapshotById(entry.getKey())
|
||||
.orElseThrow(() -> new ApiException("商品不存在: " + entry.getKey()));
|
||||
int quantity = entry.getValue();
|
||||
if (quantity > product.getStockQuantity()) {
|
||||
throw new ApiException(buildInsufficientStockMessage(product, quantity));
|
||||
}
|
||||
BigDecimal lineAmount = product.getUnitPrice().multiply(BigDecimal.valueOf(quantity));
|
||||
lines.add(new PreparedOrderLine(product, quantity, lineAmount));
|
||||
totalQuantity += quantity;
|
||||
totalAmount = totalAmount.add(lineAmount);
|
||||
}
|
||||
|
||||
return new PreparedOrder(lines, totalQuantity, totalAmount, normalizeRemark(remark));
|
||||
}
|
||||
|
||||
private void persistOrderItems(Long orderId, List<PreparedOrderLine> lines) {
|
||||
for (PreparedOrderLine line : lines) {
|
||||
orderRepository.insertOrderItem(
|
||||
orderId,
|
||||
line.getProduct().getProductId(),
|
||||
line.getProduct().getProductName(),
|
||||
line.getProduct().getSku(),
|
||||
line.getProduct().getModelName(),
|
||||
line.getProduct().getUnitPrice(),
|
||||
line.getQuantity(),
|
||||
line.getLineAmount()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private String generateOrderNo() {
|
||||
String prefix = "TH" + DateTimeFormatter.BASIC_ISO_DATE.format(LocalDate.now()) + "-";
|
||||
int sequence = orderRepository.nextDailyOrderSequence(prefix);
|
||||
return prefix + String.format("%03d", sequence);
|
||||
}
|
||||
|
||||
private byte[] createLabelArchive(OrderDetailResponse order, Map<Long, OrderLabelTemplateContent> templates) {
|
||||
try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
|
||||
ZipOutputStream zipOutputStream = new ZipOutputStream(outputStream, StandardCharsets.UTF_8)) {
|
||||
writeManifest(zipOutputStream, order, templates);
|
||||
|
||||
for (OrderItemResponse item : order.getItems()) {
|
||||
OrderLabelTemplateContent template = templates.get(item.getProductId());
|
||||
for (int index = 1; index <= item.getQuantity(); index++) {
|
||||
ZipEntry entry = new ZipEntry(buildLabelEntryName(order.getId(), item, template.getFileName(), index));
|
||||
zipOutputStream.putNextEntry(entry);
|
||||
zipOutputStream.write(template.getContent());
|
||||
zipOutputStream.closeEntry();
|
||||
}
|
||||
}
|
||||
|
||||
zipOutputStream.finish();
|
||||
return outputStream.toByteArray();
|
||||
} catch (IOException exception) {
|
||||
throw new ApiException("标签文件导出失败");
|
||||
}
|
||||
}
|
||||
|
||||
private void writeManifest(ZipOutputStream zipOutputStream, OrderDetailResponse order, Map<Long, OrderLabelTemplateContent> templates) throws IOException {
|
||||
StringBuilder builder = new StringBuilder();
|
||||
builder.append("订单号: ").append(order.getId()).append('\n');
|
||||
builder.append("状态: ").append(order.getStatus()).append('\n');
|
||||
builder.append("创建时间: ").append(order.getCreatedAt()).append('\n');
|
||||
builder.append("快递单号: ").append(order.getExpressNo() == null || order.getExpressNo().isBlank() ? "待填写" : order.getExpressNo()).append('\n');
|
||||
builder.append("导出时间: ").append(LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))).append('\n');
|
||||
builder.append('\n');
|
||||
builder.append("明细:\n");
|
||||
|
||||
for (OrderItemResponse item : order.getItems()) {
|
||||
OrderLabelTemplateContent template = templates.get(item.getProductId());
|
||||
builder.append("- ")
|
||||
.append(item.getSku())
|
||||
.append(" | ")
|
||||
.append(item.getName())
|
||||
.append(" | 数量 ")
|
||||
.append(item.getQuantity())
|
||||
.append(" | 模板 ")
|
||||
.append(template.getFileName())
|
||||
.append('\n');
|
||||
}
|
||||
|
||||
zipOutputStream.putNextEntry(new ZipEntry(sanitizeFileComponent(order.getId()) + "/manifest.txt"));
|
||||
zipOutputStream.write(builder.toString().getBytes(StandardCharsets.UTF_8));
|
||||
zipOutputStream.closeEntry();
|
||||
}
|
||||
|
||||
private String buildLabelEntryName(String orderNo, OrderItemResponse item, String fileName, int index) {
|
||||
String safeOrderNo = sanitizeFileComponent(orderNo);
|
||||
String safeItemFolder = sanitizeFileComponent(item.getSku() + "-" + item.getName());
|
||||
String normalizedName = normalizeFileName(fileName);
|
||||
int extensionIndex = normalizedName.lastIndexOf('.');
|
||||
String baseName = extensionIndex > 0 ? normalizedName.substring(0, extensionIndex) : normalizedName;
|
||||
String extension = extensionIndex > 0 ? normalizedName.substring(extensionIndex) : "";
|
||||
int width = Math.max(2, String.valueOf(item.getQuantity()).length());
|
||||
String numberedName = sanitizeFileComponent(baseName) + "-" + String.format("%0" + width + "d", index) + extension;
|
||||
return safeOrderNo + "/" + safeItemFolder + "/" + numberedName;
|
||||
}
|
||||
|
||||
private String normalizeFileName(String fileName) {
|
||||
if (fileName == null || fileName.isBlank()) {
|
||||
return "label.bin";
|
||||
}
|
||||
|
||||
return fileName.trim();
|
||||
}
|
||||
|
||||
private String sanitizeFileComponent(String value) {
|
||||
String sanitized = value == null ? "item" : value.replaceAll("[\\\\/:*?\"<>|]", "_").trim();
|
||||
return sanitized.isEmpty() ? "item" : sanitized;
|
||||
}
|
||||
|
||||
private String buildInsufficientStockMessage(OrderProductSnapshot product, int requestedQuantity) {
|
||||
return "库存不足: "
|
||||
+ product.getSku()
|
||||
+ " "
|
||||
+ product.getProductName()
|
||||
+ ",当前库存 "
|
||||
+ product.getStockQuantity()
|
||||
+ ",请求数量 "
|
||||
+ requestedQuantity;
|
||||
}
|
||||
|
||||
private OrderSummaryResponse enrichOrderSummary(OrderSummaryResponse order) {
|
||||
OrderDetailResponse detail = orderRepository.findOrderByOrderNo(order.getId())
|
||||
.orElseThrow(() -> new ApiException("订单不存在"));
|
||||
OrderLabelStatus labelStatus = buildLabelStatus(detail.getItems());
|
||||
return new OrderSummaryResponse(
|
||||
order.getId(),
|
||||
order.getStatus(),
|
||||
order.getCreatedAt(),
|
||||
order.getTotalQuantity(),
|
||||
order.getTotalAmount(),
|
||||
order.getExpressNo(),
|
||||
labelStatus.isReady(),
|
||||
labelStatus.getMissingProducts()
|
||||
);
|
||||
}
|
||||
|
||||
private OrderDetailResponse enrichOrderDetail(OrderDetailResponse order) {
|
||||
OrderLabelStatus labelStatus = buildLabelStatus(order.getItems());
|
||||
return new OrderDetailResponse(
|
||||
order.getId(),
|
||||
order.getStatus(),
|
||||
order.getCreatedAt(),
|
||||
order.getTotalQuantity(),
|
||||
order.getTotalAmount(),
|
||||
order.getExpressNo(),
|
||||
order.getCompletedAt(),
|
||||
order.getRemark(),
|
||||
order.getItems(),
|
||||
labelStatus.isReady(),
|
||||
labelStatus.getMissingProducts()
|
||||
);
|
||||
}
|
||||
|
||||
private OrderLabelStatus buildLabelStatus(List<OrderItemResponse> items) {
|
||||
List<String> missingProducts = new ArrayList<>();
|
||||
if (items == null || items.isEmpty()) {
|
||||
return new OrderLabelStatus(true, missingProducts);
|
||||
}
|
||||
|
||||
for (OrderItemResponse item : items) {
|
||||
OrderLabelTemplateContent template = orderRepository.findLatestLabelTemplateByProductId(item.getProductId())
|
||||
.orElse(null);
|
||||
if (template == null || template.getContent() == null || template.getContent().length == 0) {
|
||||
String productLabel = item.getSku() + " " + item.getName();
|
||||
if (!missingProducts.contains(productLabel)) {
|
||||
missingProducts.add(productLabel);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new OrderLabelStatus(missingProducts.isEmpty(), missingProducts);
|
||||
}
|
||||
|
||||
private String normalizeRemark(String remark) {
|
||||
if (remark == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
String trimmed = remark.trim();
|
||||
return trimmed.isEmpty() ? null : trimmed;
|
||||
}
|
||||
|
||||
private static final class PreparedOrder {
|
||||
|
||||
private final List<PreparedOrderLine> lines;
|
||||
private final int totalQuantity;
|
||||
private final BigDecimal totalAmount;
|
||||
private final String remark;
|
||||
|
||||
private PreparedOrder(List<PreparedOrderLine> lines, int totalQuantity, BigDecimal totalAmount, String remark) {
|
||||
this.lines = lines;
|
||||
this.totalQuantity = totalQuantity;
|
||||
this.totalAmount = totalAmount;
|
||||
this.remark = remark;
|
||||
}
|
||||
|
||||
private List<PreparedOrderLine> getLines() {
|
||||
return lines;
|
||||
}
|
||||
|
||||
private int getTotalQuantity() {
|
||||
return totalQuantity;
|
||||
}
|
||||
|
||||
private BigDecimal getTotalAmount() {
|
||||
return totalAmount;
|
||||
}
|
||||
|
||||
private String getRemark() {
|
||||
return remark;
|
||||
}
|
||||
}
|
||||
|
||||
private static final class PreparedOrderLine {
|
||||
|
||||
private final OrderProductSnapshot product;
|
||||
private final int quantity;
|
||||
private final BigDecimal lineAmount;
|
||||
|
||||
private PreparedOrderLine(OrderProductSnapshot product, int quantity, BigDecimal lineAmount) {
|
||||
this.product = product;
|
||||
this.quantity = quantity;
|
||||
this.lineAmount = lineAmount;
|
||||
}
|
||||
|
||||
private OrderProductSnapshot getProduct() {
|
||||
return product;
|
||||
}
|
||||
|
||||
private int getQuantity() {
|
||||
return quantity;
|
||||
}
|
||||
|
||||
private BigDecimal getLineAmount() {
|
||||
return lineAmount;
|
||||
}
|
||||
}
|
||||
|
||||
private static final class OrderLabelStatus {
|
||||
|
||||
private final boolean ready;
|
||||
private final List<String> missingProducts;
|
||||
|
||||
private OrderLabelStatus(boolean ready, List<String> missingProducts) {
|
||||
this.ready = ready;
|
||||
this.missingProducts = missingProducts;
|
||||
}
|
||||
|
||||
private boolean isReady() {
|
||||
return ready;
|
||||
}
|
||||
|
||||
private List<String> getMissingProducts() {
|
||||
return missingProducts;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package com.teapot.system.order;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
|
||||
public class OrderSummaryResponse {
|
||||
|
||||
private final String id;
|
||||
private final String status;
|
||||
private final String createdAt;
|
||||
private final Integer totalQuantity;
|
||||
private final BigDecimal totalAmount;
|
||||
private final String expressNo;
|
||||
private final boolean labelReady;
|
||||
private final List<String> missingLabelProducts;
|
||||
|
||||
public OrderSummaryResponse(String id, String status, String createdAt, Integer totalQuantity, BigDecimal totalAmount, String expressNo) {
|
||||
this(id, status, createdAt, totalQuantity, totalAmount, expressNo, true, List.of());
|
||||
}
|
||||
|
||||
public OrderSummaryResponse(
|
||||
String id,
|
||||
String status,
|
||||
String createdAt,
|
||||
Integer totalQuantity,
|
||||
BigDecimal totalAmount,
|
||||
String expressNo,
|
||||
boolean labelReady,
|
||||
List<String> missingLabelProducts) {
|
||||
this.id = id;
|
||||
this.status = status;
|
||||
this.createdAt = createdAt;
|
||||
this.totalQuantity = totalQuantity;
|
||||
this.totalAmount = totalAmount;
|
||||
this.expressNo = expressNo;
|
||||
this.labelReady = labelReady;
|
||||
this.missingLabelProducts = missingLabelProducts == null ? List.of() : List.copyOf(missingLabelProducts);
|
||||
}
|
||||
|
||||
public String getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public String getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
public String getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
public Integer getTotalQuantity() {
|
||||
return totalQuantity;
|
||||
}
|
||||
|
||||
public BigDecimal getTotalAmount() {
|
||||
return totalAmount;
|
||||
}
|
||||
|
||||
public String getExpressNo() {
|
||||
return expressNo;
|
||||
}
|
||||
|
||||
public boolean isLabelReady() {
|
||||
return labelReady;
|
||||
}
|
||||
|
||||
public List<String> getMissingLabelProducts() {
|
||||
return missingLabelProducts;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.teapot.system.order;
|
||||
|
||||
import javax.validation.Valid;
|
||||
import javax.validation.constraints.NotEmpty;
|
||||
import java.util.List;
|
||||
|
||||
public class UpdateOrderRequest {
|
||||
|
||||
@NotEmpty(message = "订单明细不能为空")
|
||||
@Valid
|
||||
private List<OrderItemRequest> items;
|
||||
|
||||
private String remark;
|
||||
|
||||
public List<OrderItemRequest> getItems() {
|
||||
return items;
|
||||
}
|
||||
|
||||
public void setItems(List<OrderItemRequest> items) {
|
||||
this.items = items;
|
||||
}
|
||||
|
||||
public String getRemark() {
|
||||
return remark;
|
||||
}
|
||||
|
||||
public void setRemark(String remark) {
|
||||
this.remark = remark;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.teapot.system.statistics;
|
||||
|
||||
import com.teapot.system.common.ApiResponse;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/statistics")
|
||||
public class StatisticsController {
|
||||
|
||||
private final StatisticsService statisticsService;
|
||||
|
||||
public StatisticsController(StatisticsService statisticsService) {
|
||||
this.statisticsService = statisticsService;
|
||||
}
|
||||
|
||||
@GetMapping("/monthly")
|
||||
@PreAuthorize("hasRole('ADMIN') or hasAuthority('STATISTICS_VIEW')")
|
||||
public ApiResponse<StatisticsSummaryResponse> monthly(
|
||||
@RequestParam(required = false) Integer year,
|
||||
@RequestParam(required = false) Integer month) {
|
||||
return ApiResponse.success(statisticsService.getMonthly(year, month));
|
||||
}
|
||||
|
||||
@GetMapping("/yearly")
|
||||
@PreAuthorize("hasRole('ADMIN') or hasAuthority('STATISTICS_VIEW')")
|
||||
public ApiResponse<StatisticsSummaryResponse> yearly(@RequestParam(required = false) Integer year) {
|
||||
return ApiResponse.success(statisticsService.getYearly(year));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.teapot.system.statistics;
|
||||
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.util.List;
|
||||
|
||||
@Repository
|
||||
public class StatisticsRepository {
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
public StatisticsRepository(JdbcTemplate jdbcTemplate) {
|
||||
this.jdbcTemplate = jdbcTemplate;
|
||||
}
|
||||
|
||||
public List<StatisticsRowResponse> findMonthly(Integer year, Integer month) {
|
||||
return jdbcTemplate.query(
|
||||
"SELECT oi.product_name_snapshot, SUM(oi.quantity) AS total_quantity, oi.unit_price, SUM(oi.line_amount) AS total_amount " +
|
||||
"FROM order_item oi JOIN order_record o ON oi.order_id = o.id " +
|
||||
"WHERE substr(o.created_at, 1, 4) = ? AND substr(o.created_at, 6, 2) = ? AND o.status <> 'CANCELLED' " +
|
||||
"GROUP BY oi.product_name_snapshot, oi.unit_price ORDER BY total_quantity DESC, total_amount DESC",
|
||||
(resultSet, rowNum) -> new StatisticsRowResponse(
|
||||
resultSet.getString("product_name_snapshot"),
|
||||
resultSet.getInt("total_quantity"),
|
||||
readDecimal(resultSet, "unit_price"),
|
||||
readDecimal(resultSet, "total_amount")
|
||||
),
|
||||
String.valueOf(year),
|
||||
String.format("%02d", month)
|
||||
);
|
||||
}
|
||||
|
||||
public List<StatisticsRowResponse> findYearly(Integer year) {
|
||||
return jdbcTemplate.query(
|
||||
"SELECT oi.product_name_snapshot, SUM(oi.quantity) AS total_quantity, oi.unit_price, SUM(oi.line_amount) AS total_amount " +
|
||||
"FROM order_item oi JOIN order_record o ON oi.order_id = o.id " +
|
||||
"WHERE substr(o.created_at, 1, 4) = ? AND o.status <> 'CANCELLED' " +
|
||||
"GROUP BY oi.product_name_snapshot, oi.unit_price ORDER BY total_quantity DESC, total_amount DESC",
|
||||
(resultSet, rowNum) -> new StatisticsRowResponse(
|
||||
resultSet.getString("product_name_snapshot"),
|
||||
resultSet.getInt("total_quantity"),
|
||||
readDecimal(resultSet, "unit_price"),
|
||||
readDecimal(resultSet, "total_amount")
|
||||
),
|
||||
String.valueOf(year)
|
||||
);
|
||||
}
|
||||
|
||||
private BigDecimal readDecimal(ResultSet resultSet, String column) throws SQLException {
|
||||
return BigDecimal.valueOf(resultSet.getDouble(column));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.teapot.system.statistics;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
public class StatisticsRowResponse {
|
||||
|
||||
private final String name;
|
||||
private final Integer quantity;
|
||||
private final BigDecimal price;
|
||||
private final BigDecimal subtotal;
|
||||
|
||||
public StatisticsRowResponse(String name, Integer quantity, BigDecimal price, BigDecimal subtotal) {
|
||||
this.name = name;
|
||||
this.quantity = quantity;
|
||||
this.price = price;
|
||||
this.subtotal = subtotal;
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public Integer getQuantity() {
|
||||
return quantity;
|
||||
}
|
||||
|
||||
public BigDecimal getPrice() {
|
||||
return price;
|
||||
}
|
||||
|
||||
public BigDecimal getSubtotal() {
|
||||
return subtotal;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.teapot.system.statistics;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
|
||||
@Service
|
||||
public class StatisticsService {
|
||||
|
||||
private final StatisticsRepository statisticsRepository;
|
||||
|
||||
public StatisticsService(StatisticsRepository statisticsRepository) {
|
||||
this.statisticsRepository = statisticsRepository;
|
||||
}
|
||||
|
||||
public StatisticsSummaryResponse getMonthly(Integer year, Integer month) {
|
||||
LocalDate now = LocalDate.now();
|
||||
int targetYear = year == null ? now.getYear() : year;
|
||||
int targetMonth = month == null ? now.getMonthValue() : month;
|
||||
List<StatisticsRowResponse> rows = statisticsRepository.findMonthly(targetYear, targetMonth);
|
||||
return buildSummary(targetYear, targetMonth, rows);
|
||||
}
|
||||
|
||||
public StatisticsSummaryResponse getYearly(Integer year) {
|
||||
LocalDate now = LocalDate.now();
|
||||
int targetYear = year == null ? now.getYear() : year;
|
||||
List<StatisticsRowResponse> rows = statisticsRepository.findYearly(targetYear);
|
||||
return buildSummary(targetYear, null, rows);
|
||||
}
|
||||
|
||||
private StatisticsSummaryResponse buildSummary(Integer year, Integer month, List<StatisticsRowResponse> rows) {
|
||||
int totalQuantity = rows.stream().mapToInt(StatisticsRowResponse::getQuantity).sum();
|
||||
BigDecimal totalAmount = rows.stream()
|
||||
.map(StatisticsRowResponse::getSubtotal)
|
||||
.reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||
|
||||
BigDecimal averagePrice = totalQuantity == 0
|
||||
? BigDecimal.ZERO
|
||||
: totalAmount.divide(BigDecimal.valueOf(totalQuantity), 0, RoundingMode.HALF_UP);
|
||||
|
||||
String topProductName = rows.isEmpty() ? "-" : rows.get(0).getName();
|
||||
|
||||
return new StatisticsSummaryResponse(year, month, totalQuantity, totalAmount, averagePrice, topProductName, rows);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package com.teapot.system.statistics;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
|
||||
public class StatisticsSummaryResponse {
|
||||
|
||||
private final Integer year;
|
||||
private final Integer month;
|
||||
private final Integer totalQuantity;
|
||||
private final BigDecimal totalAmount;
|
||||
private final BigDecimal averagePrice;
|
||||
private final String topProductName;
|
||||
private final List<StatisticsRowResponse> rows;
|
||||
|
||||
public StatisticsSummaryResponse(
|
||||
Integer year,
|
||||
Integer month,
|
||||
Integer totalQuantity,
|
||||
BigDecimal totalAmount,
|
||||
BigDecimal averagePrice,
|
||||
String topProductName,
|
||||
List<StatisticsRowResponse> rows) {
|
||||
this.year = year;
|
||||
this.month = month;
|
||||
this.totalQuantity = totalQuantity;
|
||||
this.totalAmount = totalAmount;
|
||||
this.averagePrice = averagePrice;
|
||||
this.topProductName = topProductName;
|
||||
this.rows = rows;
|
||||
}
|
||||
|
||||
public Integer getYear() {
|
||||
return year;
|
||||
}
|
||||
|
||||
public Integer getMonth() {
|
||||
return month;
|
||||
}
|
||||
|
||||
public Integer getTotalQuantity() {
|
||||
return totalQuantity;
|
||||
}
|
||||
|
||||
public BigDecimal getTotalAmount() {
|
||||
return totalAmount;
|
||||
}
|
||||
|
||||
public BigDecimal getAveragePrice() {
|
||||
return averagePrice;
|
||||
}
|
||||
|
||||
public String getTopProductName() {
|
||||
return topProductName;
|
||||
}
|
||||
|
||||
public List<StatisticsRowResponse> getRows() {
|
||||
return rows;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.teapot.system.user;
|
||||
|
||||
import javax.validation.constraints.NotBlank;
|
||||
import javax.validation.constraints.NotEmpty;
|
||||
import java.util.List;
|
||||
|
||||
public class CreateUserRequest {
|
||||
|
||||
@NotBlank(message = "用户名不能为空")
|
||||
private String username;
|
||||
|
||||
@NotBlank(message = "显示名称不能为空")
|
||||
private String displayName;
|
||||
|
||||
@NotBlank(message = "密码不能为空")
|
||||
private String password;
|
||||
|
||||
@NotEmpty(message = "权限不能为空")
|
||||
private List<String> permissions;
|
||||
|
||||
public String getUsername() {
|
||||
return username;
|
||||
}
|
||||
|
||||
public void setUsername(String username) {
|
||||
this.username = username;
|
||||
}
|
||||
|
||||
public String getDisplayName() {
|
||||
return displayName;
|
||||
}
|
||||
|
||||
public void setDisplayName(String displayName) {
|
||||
this.displayName = displayName;
|
||||
}
|
||||
|
||||
public String getPassword() {
|
||||
return password;
|
||||
}
|
||||
|
||||
public void setPassword(String password) {
|
||||
this.password = password;
|
||||
}
|
||||
|
||||
public List<String> getPermissions() {
|
||||
return permissions;
|
||||
}
|
||||
|
||||
public void setPermissions(List<String> permissions) {
|
||||
this.permissions = permissions;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.teapot.system.user;
|
||||
|
||||
import javax.validation.constraints.NotEmpty;
|
||||
import java.util.List;
|
||||
|
||||
public class UpdateUserPermissionsRequest {
|
||||
|
||||
@NotEmpty(message = "权限列表不能为空")
|
||||
private List<String> permissions;
|
||||
|
||||
public List<String> getPermissions() {
|
||||
return permissions;
|
||||
}
|
||||
|
||||
public void setPermissions(List<String> permissions) {
|
||||
this.permissions = permissions;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.teapot.system.user;
|
||||
|
||||
import javax.validation.constraints.NotBlank;
|
||||
|
||||
public class UpdateUserStatusRequest {
|
||||
|
||||
@NotBlank(message = "状态不能为空")
|
||||
private String status;
|
||||
|
||||
public String getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
public void setStatus(String status) {
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.teapot.system.user;
|
||||
|
||||
public class UserAccount {
|
||||
|
||||
private final Long id;
|
||||
private final String username;
|
||||
private final String passwordHash;
|
||||
private final String displayName;
|
||||
private final String userType;
|
||||
private final String status;
|
||||
|
||||
public UserAccount(Long id, String username, String passwordHash, String displayName, String userType, String status) {
|
||||
this.id = id;
|
||||
this.username = username;
|
||||
this.passwordHash = passwordHash;
|
||||
this.displayName = displayName;
|
||||
this.userType = userType;
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public String getUsername() {
|
||||
return username;
|
||||
}
|
||||
|
||||
public String getPasswordHash() {
|
||||
return passwordHash;
|
||||
}
|
||||
|
||||
public String getDisplayName() {
|
||||
return displayName;
|
||||
}
|
||||
|
||||
public String getUserType() {
|
||||
return userType;
|
||||
}
|
||||
|
||||
public String getStatus() {
|
||||
return status;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package com.teapot.system.user;
|
||||
|
||||
import com.teapot.system.auth.PermissionCodes;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.core.RowMapper;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
@Repository
|
||||
public class UserAccountRepository {
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
public UserAccountRepository(JdbcTemplate jdbcTemplate) {
|
||||
this.jdbcTemplate = jdbcTemplate;
|
||||
}
|
||||
|
||||
public Optional<UserAccount> findByUsername(String username) {
|
||||
List<UserAccount> results = jdbcTemplate.query(
|
||||
"SELECT id, username, password_hash, display_name, user_type, status FROM user_account WHERE username = ?",
|
||||
userRowMapper(),
|
||||
username
|
||||
);
|
||||
return results.stream().findFirst();
|
||||
}
|
||||
|
||||
public Optional<UserAccount> findById(Long id) {
|
||||
List<UserAccount> results = jdbcTemplate.query(
|
||||
"SELECT id, username, password_hash, display_name, user_type, status FROM user_account WHERE id = ?",
|
||||
userRowMapper(),
|
||||
id
|
||||
);
|
||||
return results.stream().findFirst();
|
||||
}
|
||||
|
||||
public List<UserAccount> findAll() {
|
||||
return jdbcTemplate.query(
|
||||
"SELECT id, username, password_hash, display_name, user_type, status FROM user_account ORDER BY id ASC",
|
||||
userRowMapper()
|
||||
);
|
||||
}
|
||||
|
||||
public List<String> findPermissionsByUserId(Long userId) {
|
||||
return jdbcTemplate.queryForList(
|
||||
"SELECT permission_code FROM user_permission WHERE user_id = ? ORDER BY permission_code ASC",
|
||||
String.class,
|
||||
userId
|
||||
);
|
||||
}
|
||||
|
||||
public long insertUser(String username, String passwordHash, String displayName, String userType, Long createdBy) {
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO user_account (username, password_hash, display_name, user_type, status, created_by) VALUES (?, ?, ?, ?, 'ENABLED', ?)",
|
||||
username,
|
||||
passwordHash,
|
||||
displayName,
|
||||
userType,
|
||||
createdBy
|
||||
);
|
||||
|
||||
Long id = jdbcTemplate.queryForObject("SELECT id FROM user_account WHERE username = ?", Long.class, username);
|
||||
return id == null ? 0L : id;
|
||||
}
|
||||
|
||||
public void replacePermissions(Long userId, List<String> permissions) {
|
||||
jdbcTemplate.update("DELETE FROM user_permission WHERE user_id = ?", userId);
|
||||
for (String permission : permissions) {
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO user_permission (user_id, permission_code, permission_name) VALUES (?, ?, ?)",
|
||||
userId,
|
||||
permission,
|
||||
PermissionCodes.NAME_MAP.getOrDefault(permission, permission)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public void updateStatus(Long userId, String status) {
|
||||
jdbcTemplate.update(
|
||||
"UPDATE user_account SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
|
||||
status,
|
||||
userId
|
||||
);
|
||||
}
|
||||
|
||||
public void updateLastLoginAt(Long userId) {
|
||||
jdbcTemplate.update(
|
||||
"UPDATE user_account SET last_login_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
|
||||
userId
|
||||
);
|
||||
}
|
||||
|
||||
public boolean existsByUsername(String username) {
|
||||
Integer count = jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(1) FROM user_account WHERE username = ?",
|
||||
Integer.class,
|
||||
username
|
||||
);
|
||||
return count != null && count > 0;
|
||||
}
|
||||
|
||||
private RowMapper<UserAccount> userRowMapper() {
|
||||
return (resultSet, rowNum) -> new UserAccount(
|
||||
resultSet.getLong("id"),
|
||||
resultSet.getString("username"),
|
||||
resultSet.getString("password_hash"),
|
||||
resultSet.getString("display_name"),
|
||||
resultSet.getString("user_type"),
|
||||
resultSet.getString("status")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.teapot.system.user;
|
||||
|
||||
import com.teapot.system.common.ApiResponse;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import javax.validation.Valid;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/users")
|
||||
public class UserController {
|
||||
|
||||
private final UserService userService;
|
||||
|
||||
public UserController(UserService userService) {
|
||||
this.userService = userService;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
@PreAuthorize("hasRole('ADMIN') or hasAuthority('USER_MANAGE')")
|
||||
public ApiResponse<List<UserListItemResponse>> listUsers() {
|
||||
return ApiResponse.success(userService.listUsers());
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
@PreAuthorize("hasRole('ADMIN') or hasAuthority('USER_MANAGE')")
|
||||
public ApiResponse<UserListItemResponse> createUser(@Valid @RequestBody CreateUserRequest request) {
|
||||
return ApiResponse.success("创建成功", userService.createNormalUser(request));
|
||||
}
|
||||
|
||||
@PutMapping("/{id}/permissions")
|
||||
@PreAuthorize("hasRole('ADMIN') or hasAuthority('USER_MANAGE')")
|
||||
public ApiResponse<Map<String, String>> updatePermissions(
|
||||
@PathVariable Long id,
|
||||
@Valid @RequestBody UpdateUserPermissionsRequest request) {
|
||||
userService.updatePermissions(id, request.getPermissions());
|
||||
return ApiResponse.success(Map.of("message", "权限更新成功"));
|
||||
}
|
||||
|
||||
@PutMapping("/{id}/status")
|
||||
@PreAuthorize("hasRole('ADMIN') or hasAuthority('USER_MANAGE')")
|
||||
public ApiResponse<Map<String, String>> updateStatus(
|
||||
@PathVariable Long id,
|
||||
@Valid @RequestBody UpdateUserStatusRequest request) {
|
||||
userService.updateStatus(id, request.getStatus());
|
||||
return ApiResponse.success(Map.of("message", "状态更新成功"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.teapot.system.user;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public class UserListItemResponse {
|
||||
|
||||
private final Long id;
|
||||
private final String username;
|
||||
private final String displayName;
|
||||
private final String userType;
|
||||
private final String status;
|
||||
private final List<String> permissions;
|
||||
|
||||
public UserListItemResponse(Long id, String username, String displayName, String userType, String status, List<String> permissions) {
|
||||
this.id = id;
|
||||
this.username = username;
|
||||
this.displayName = displayName;
|
||||
this.userType = userType;
|
||||
this.status = status;
|
||||
this.permissions = permissions;
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public String getUsername() {
|
||||
return username;
|
||||
}
|
||||
|
||||
public String getDisplayName() {
|
||||
return displayName;
|
||||
}
|
||||
|
||||
public String getUserType() {
|
||||
return userType;
|
||||
}
|
||||
|
||||
public String getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
public List<String> getPermissions() {
|
||||
return permissions;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package com.teapot.system.user;
|
||||
|
||||
import com.teapot.system.auth.AuthenticatedUser;
|
||||
import com.teapot.system.common.ApiException;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
public class UserService {
|
||||
|
||||
private final UserAccountRepository userAccountRepository;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
|
||||
public UserService(UserAccountRepository userAccountRepository, PasswordEncoder passwordEncoder) {
|
||||
this.userAccountRepository = userAccountRepository;
|
||||
this.passwordEncoder = passwordEncoder;
|
||||
}
|
||||
|
||||
public List<UserListItemResponse> listUsers() {
|
||||
return userAccountRepository.findAll().stream()
|
||||
.map(user -> new UserListItemResponse(
|
||||
user.getId(),
|
||||
user.getUsername(),
|
||||
user.getDisplayName(),
|
||||
user.getUserType(),
|
||||
user.getStatus(),
|
||||
userAccountRepository.findPermissionsByUserId(user.getId())
|
||||
))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
public UserListItemResponse createNormalUser(CreateUserRequest request) {
|
||||
if (userAccountRepository.existsByUsername(request.getUsername())) {
|
||||
throw new ApiException("用户名已存在");
|
||||
}
|
||||
|
||||
Long currentUserId = currentUser().getId();
|
||||
long userId = userAccountRepository.insertUser(
|
||||
request.getUsername(),
|
||||
passwordEncoder.encode(request.getPassword()),
|
||||
request.getDisplayName(),
|
||||
"NORMAL",
|
||||
currentUserId
|
||||
);
|
||||
userAccountRepository.replacePermissions(userId, request.getPermissions());
|
||||
|
||||
UserAccount user = userAccountRepository.findById(userId)
|
||||
.orElseThrow(() -> new ApiException("创建用户失败"));
|
||||
|
||||
return new UserListItemResponse(
|
||||
user.getId(),
|
||||
user.getUsername(),
|
||||
user.getDisplayName(),
|
||||
user.getUserType(),
|
||||
user.getStatus(),
|
||||
userAccountRepository.findPermissionsByUserId(user.getId())
|
||||
);
|
||||
}
|
||||
|
||||
public void updatePermissions(Long userId, List<String> permissions) {
|
||||
UserAccount user = userAccountRepository.findById(userId)
|
||||
.orElseThrow(() -> new ApiException("用户不存在"));
|
||||
|
||||
if ("ADMIN".equals(user.getUserType())) {
|
||||
throw new ApiException("管理员账号权限不允许修改");
|
||||
}
|
||||
|
||||
userAccountRepository.replacePermissions(userId, permissions);
|
||||
}
|
||||
|
||||
public void updateStatus(Long userId, String status) {
|
||||
UserAccount user = userAccountRepository.findById(userId)
|
||||
.orElseThrow(() -> new ApiException("用户不存在"));
|
||||
|
||||
if ("ADMIN".equals(user.getUserType())) {
|
||||
throw new ApiException("管理员账号不允许停用");
|
||||
}
|
||||
|
||||
if (!"ENABLED".equals(status) && !"DISABLED".equals(status)) {
|
||||
throw new ApiException("用户状态非法");
|
||||
}
|
||||
|
||||
userAccountRepository.updateStatus(userId, status);
|
||||
}
|
||||
|
||||
private AuthenticatedUser currentUser() {
|
||||
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (authentication == null || !(authentication.getPrincipal() instanceof AuthenticatedUser)) {
|
||||
throw new ApiException("未获取到登录用户");
|
||||
}
|
||||
return (AuthenticatedUser) authentication.getPrincipal();
|
||||
}
|
||||
}
|
||||
12
backend/src/main/resources/application.properties
Normal file
12
backend/src/main/resources/application.properties
Normal file
@@ -0,0 +1,12 @@
|
||||
spring.application.name=teapot-system-backend
|
||||
|
||||
server.port=8080
|
||||
|
||||
spring.datasource.url=jdbc:sqlite:./teapot-system.db
|
||||
spring.datasource.driver-class-name=org.sqlite.JDBC
|
||||
spring.sql.init.mode=always
|
||||
spring.sql.init.schema-locations=classpath:db/schema.sql
|
||||
spring.autoconfigure.exclude=org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration
|
||||
|
||||
app.security.jwt-secret=teapot-system-demo-secret-key-for-jwt-auth-2026
|
||||
app.security.jwt-expire-hours=12
|
||||
102
backend/src/main/resources/db/schema.sql
Normal file
102
backend/src/main/resources/db/schema.sql
Normal file
@@ -0,0 +1,102 @@
|
||||
CREATE TABLE IF NOT EXISTS user_account (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
display_name TEXT NOT NULL,
|
||||
user_type TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
last_login_at TEXT,
|
||||
created_by INTEGER,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_permission (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
permission_code TEXT NOT NULL,
|
||||
permission_name TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(user_id, permission_code)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS category (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
parent_id INTEGER,
|
||||
name TEXT NOT NULL,
|
||||
sort_no INTEGER NOT NULL DEFAULT 0,
|
||||
requires_detail_page INTEGER NOT NULL DEFAULT 1,
|
||||
category_type TEXT NOT NULL DEFAULT 'PRODUCT',
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS product_item (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
category_id INTEGER NOT NULL,
|
||||
sku TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
model_name TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'AVAILABLE',
|
||||
wholesale_price NUMERIC NOT NULL DEFAULT 0,
|
||||
stock_quantity INTEGER NOT NULL DEFAULT 0,
|
||||
cover_asset_id INTEGER,
|
||||
remark TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS file_asset (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
business_type TEXT NOT NULL,
|
||||
business_id INTEGER NOT NULL,
|
||||
file_role TEXT NOT NULL,
|
||||
file_name TEXT NOT NULL,
|
||||
mime_type TEXT NOT NULL,
|
||||
file_size INTEGER NOT NULL,
|
||||
content_blob BLOB,
|
||||
preview_blob BLOB,
|
||||
sort_no INTEGER NOT NULL DEFAULT 0,
|
||||
is_primary INTEGER NOT NULL DEFAULT 0,
|
||||
sha256 TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS order_record (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
order_no TEXT NOT NULL UNIQUE,
|
||||
status TEXT NOT NULL DEFAULT 'PENDING',
|
||||
total_quantity INTEGER NOT NULL DEFAULT 0,
|
||||
total_amount NUMERIC NOT NULL DEFAULT 0,
|
||||
express_no TEXT,
|
||||
remark TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
completed_at TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS order_item (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
order_id INTEGER NOT NULL,
|
||||
product_id INTEGER NOT NULL,
|
||||
product_name_snapshot TEXT NOT NULL,
|
||||
sku_snapshot TEXT NOT NULL,
|
||||
model_name_snapshot TEXT,
|
||||
unit_price NUMERIC NOT NULL DEFAULT 0,
|
||||
quantity INTEGER NOT NULL DEFAULT 0,
|
||||
line_amount NUMERIC NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS order_operation_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
order_id INTEGER NOT NULL,
|
||||
operation_type TEXT NOT NULL,
|
||||
operation_content TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_permission_user_id ON user_permission(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_category_parent_id ON category(parent_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_product_category_id ON product_item(category_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_file_asset_business ON file_asset(business_type, business_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_order_item_order_id ON order_item(order_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_order_log_order_id ON order_operation_log(order_id);
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.teapot.system;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.test.context.TestPropertySource;
|
||||
|
||||
@SpringBootTest
|
||||
@TestPropertySource(properties = {
|
||||
"spring.datasource.url=jdbc:sqlite:./target/backend-application-tests.db",
|
||||
"server.port=0"
|
||||
})
|
||||
class BackendApplicationTests {
|
||||
|
||||
@Test
|
||||
void contextLoads() {
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package com.teapot.system.catalog;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
import org.springframework.test.context.TestPropertySource;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
@SpringBootTest
|
||||
@Transactional
|
||||
@TestPropertySource(properties = {
|
||||
"spring.datasource.url=jdbc:sqlite:./target/catalog-service-integration-tests.db",
|
||||
"server.port=0"
|
||||
})
|
||||
class CatalogServiceIntegrationTests {
|
||||
|
||||
@Autowired
|
||||
private CatalogService catalogService;
|
||||
|
||||
@Test
|
||||
void getProductsByCategoryShouldReturnPrimaryCoverAssetId() {
|
||||
MockMultipartFile image = new MockMultipartFile(
|
||||
"file",
|
||||
"cover-101.jpg",
|
||||
"image/jpeg",
|
||||
"cover-image-for-101".getBytes(StandardCharsets.UTF_8)
|
||||
);
|
||||
|
||||
ProductAssetResponse uploaded = catalogService.uploadProductAsset(101L, "IMAGE", true, image);
|
||||
ProductSummaryResponse product = catalogService.getProductsByCategory(1L).stream()
|
||||
.filter(item -> Long.valueOf(101L).equals(item.getId()))
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new IllegalStateException("未找到商品 101 的摘要"));
|
||||
|
||||
assertThat(product.getCoverAssetId()).isEqualTo(uploaded.getId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void reorderProductImageAssetsShouldPersistSortOrder() {
|
||||
MockMultipartFile firstImage = new MockMultipartFile(
|
||||
"file",
|
||||
"first.jpg",
|
||||
"image/jpeg",
|
||||
"first-image".getBytes(StandardCharsets.UTF_8)
|
||||
);
|
||||
MockMultipartFile secondImage = new MockMultipartFile(
|
||||
"file",
|
||||
"second.jpg",
|
||||
"image/jpeg",
|
||||
"second-image".getBytes(StandardCharsets.UTF_8)
|
||||
);
|
||||
MockMultipartFile thirdImage = new MockMultipartFile(
|
||||
"file",
|
||||
"third.jpg",
|
||||
"image/jpeg",
|
||||
"third-image".getBytes(StandardCharsets.UTF_8)
|
||||
);
|
||||
|
||||
ProductAssetResponse first = catalogService.uploadProductAsset(101L, "IMAGE", true, firstImage);
|
||||
ProductAssetResponse second = catalogService.uploadProductAsset(101L, "IMAGE", false, secondImage);
|
||||
ProductAssetResponse third = catalogService.uploadProductAsset(101L, "IMAGE", false, thirdImage);
|
||||
|
||||
catalogService.reorderProductImageAssets(101L, List.of(second.getId(), third.getId(), first.getId()));
|
||||
|
||||
List<ProductAssetResponse> reorderedImages = catalogService.getProductAssets(101L).stream()
|
||||
.filter(asset -> "IMAGE".equals(asset.getFileRole()))
|
||||
.collect(Collectors.toList());
|
||||
|
||||
assertThat(reorderedImages)
|
||||
.extracting(ProductAssetResponse::getId)
|
||||
.containsExactly(second.getId(), third.getId(), first.getId());
|
||||
assertThat(reorderedImages)
|
||||
.extracting(ProductAssetResponse::getSortNo)
|
||||
.containsExactly(1, 2, 3);
|
||||
assertThat(reorderedImages)
|
||||
.filteredOn(ProductAssetResponse::isPrimary)
|
||||
.extracting(ProductAssetResponse::getId)
|
||||
.containsExactly(first.getId());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package com.teapot.system.order;
|
||||
|
||||
import com.teapot.system.catalog.CatalogService;
|
||||
import com.teapot.system.catalog.ProductDetailResponse;
|
||||
import com.teapot.system.common.ApiException;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
import org.springframework.security.test.context.support.WithMockUser;
|
||||
import org.springframework.test.context.TestPropertySource;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
@SpringBootTest
|
||||
@Transactional
|
||||
@TestPropertySource(properties = {
|
||||
"spring.datasource.url=jdbc:sqlite:./target/order-service-integration-tests.db",
|
||||
"server.port=0"
|
||||
})
|
||||
class OrderServiceIntegrationTests {
|
||||
|
||||
private static final String COMPLETED_ORDER_NO = "TH20260410-005";
|
||||
|
||||
@Autowired
|
||||
private OrderService orderService;
|
||||
|
||||
@Autowired
|
||||
private CatalogService catalogService;
|
||||
|
||||
@Test
|
||||
void createOrderShouldRejectWhenQuantityExceedsStock() {
|
||||
ProductDetailResponse product = catalogService.getProduct(201L);
|
||||
|
||||
CreateOrderRequest request = createOrderRequest(201L, product.getStock() + 1, "超库存创建测试");
|
||||
|
||||
assertThatThrownBy(() -> orderService.createOrder(request))
|
||||
.isInstanceOf(ApiException.class)
|
||||
.hasMessageContaining("库存不足")
|
||||
.hasMessageContaining(product.getSku());
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateOrderShouldRejectWhenQuantityExceedsStock() {
|
||||
OrderDetailResponse createdOrder = orderService.createOrder(createOrderRequest(201L, 1, "编辑库存测试"));
|
||||
ProductDetailResponse product = catalogService.getProduct(201L);
|
||||
|
||||
UpdateOrderRequest request = createUpdateOrderRequest(201L, product.getStock() + 1, "超库存编辑测试");
|
||||
|
||||
assertThatThrownBy(() -> orderService.updateOrder(createdOrder.getId(), request))
|
||||
.isInstanceOf(ApiException.class)
|
||||
.hasMessageContaining("库存不足")
|
||||
.hasMessageContaining(product.getSku());
|
||||
}
|
||||
|
||||
@Test
|
||||
void completeOrderShouldDeductProductStock() {
|
||||
int stockBefore = catalogService.getProduct(201L).getStock();
|
||||
OrderDetailResponse createdOrder = orderService.createOrder(createOrderRequest(201L, 2, "库存扣减测试"));
|
||||
|
||||
OrderDetailResponse completedOrder = orderService.completeOrder(createdOrder.getId(), "SF-TEST-20260411");
|
||||
int stockAfter = catalogService.getProduct(201L).getStock();
|
||||
|
||||
assertThat(completedOrder.getStatus()).isEqualTo("COMPLETED");
|
||||
assertThat(completedOrder.getExpressNo()).isEqualTo("SF-TEST-20260411");
|
||||
assertThat(stockAfter).isEqualTo(stockBefore - 2);
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteCompletedOrderShouldRejectForNonAdmin() {
|
||||
assertThatThrownBy(() -> orderService.deleteOrder(COMPLETED_ORDER_NO))
|
||||
.isInstanceOf(ApiException.class)
|
||||
.hasMessageContaining("只有管理员才允许删除已完成订单");
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(roles = "ADMIN")
|
||||
void adminShouldDeleteCompletedOrder() {
|
||||
orderService.deleteOrder(COMPLETED_ORDER_NO);
|
||||
|
||||
assertThatThrownBy(() -> orderService.getOrderDetail(COMPLETED_ORDER_NO))
|
||||
.isInstanceOf(ApiException.class)
|
||||
.hasMessageContaining("订单不存在");
|
||||
}
|
||||
|
||||
@Test
|
||||
void orderLabelStatusShouldChangeAfterUploadingTemplates() {
|
||||
OrderDetailResponse detailBefore = orderService.getOrderDetail(COMPLETED_ORDER_NO);
|
||||
OrderSummaryResponse summaryBefore = orderService.listOrders().stream()
|
||||
.filter(order -> COMPLETED_ORDER_NO.equals(order.getId()))
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new IllegalStateException("未找到订单摘要"));
|
||||
|
||||
assertThat(detailBefore.isLabelReady()).isFalse();
|
||||
assertThat(detailBefore.getMissingLabelProducts()).anyMatch(item -> item.contains("TH-002-A"));
|
||||
assertThat(summaryBefore.isLabelReady()).isFalse();
|
||||
|
||||
uploadLabelTemplate(201L, "test-201-label.txt");
|
||||
uploadLabelTemplate(101L, "test-101-label.txt");
|
||||
|
||||
OrderDetailResponse detailAfter = orderService.getOrderDetail(COMPLETED_ORDER_NO);
|
||||
OrderSummaryResponse summaryAfter = orderService.listOrders().stream()
|
||||
.filter(order -> COMPLETED_ORDER_NO.equals(order.getId()))
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new IllegalStateException("未找到订单摘要"));
|
||||
OrderLabelDownload labelDownload = orderService.downloadOrderLabels(COMPLETED_ORDER_NO);
|
||||
|
||||
assertThat(detailAfter.isLabelReady()).isTrue();
|
||||
assertThat(detailAfter.getMissingLabelProducts()).isEmpty();
|
||||
assertThat(summaryAfter.isLabelReady()).isTrue();
|
||||
assertThat(summaryAfter.getMissingLabelProducts()).isEmpty();
|
||||
assertThat(labelDownload.getFileName()).isEqualTo(COMPLETED_ORDER_NO + "-labels.zip");
|
||||
assertThat(labelDownload.getFileSize()).isPositive();
|
||||
assertThat(labelDownload.getContent()).isNotEmpty();
|
||||
}
|
||||
|
||||
private CreateOrderRequest createOrderRequest(Long productId, int quantity, String remark) {
|
||||
CreateOrderRequest request = new CreateOrderRequest();
|
||||
request.setRemark(remark);
|
||||
request.setItems(List.of(createOrderItem(productId, quantity)));
|
||||
return request;
|
||||
}
|
||||
|
||||
private UpdateOrderRequest createUpdateOrderRequest(Long productId, int quantity, String remark) {
|
||||
UpdateOrderRequest request = new UpdateOrderRequest();
|
||||
request.setRemark(remark);
|
||||
request.setItems(List.of(createOrderItem(productId, quantity)));
|
||||
return request;
|
||||
}
|
||||
|
||||
private OrderItemRequest createOrderItem(Long productId, int quantity) {
|
||||
OrderItemRequest item = new OrderItemRequest();
|
||||
item.setProductId(productId);
|
||||
item.setQuantity(quantity);
|
||||
return item;
|
||||
}
|
||||
|
||||
private void uploadLabelTemplate(Long productId, String fileName) {
|
||||
MockMultipartFile file = new MockMultipartFile(
|
||||
"file",
|
||||
fileName,
|
||||
"text/plain",
|
||||
("label-template-for-" + productId).getBytes(StandardCharsets.UTF_8)
|
||||
);
|
||||
|
||||
catalogService.uploadProductAsset(productId, "LABEL", false, file);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user