1.0.1 初始化铜壶管理系统项目并配置容器化

This commit is contained in:
Hongying Li
2026-04-12 18:28:37 +08:00
parent 72239c24fd
commit 5dd16d9809
129 changed files with 15165 additions and 0 deletions

2
backend/.gitattributes vendored Normal file
View File

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

33
backend/.gitignore vendored Normal file
View File

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

View File

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

33
backend/AGENTS.md Normal file
View 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
View 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
View File

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

189
backend/mvnw.cmd vendored Normal file
View File

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

83
backend/pom.xml Normal file
View 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>

View File

@@ -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);
}
}

View File

@@ -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());
}
}

View File

@@ -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()
);
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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()
));
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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() {
}
}

View File

@@ -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";
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -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));
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1,8 @@
package com.teapot.system.common;
public class ApiException extends RuntimeException {
public ApiException(String message) {
super(message);
}
}

View File

@@ -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;
}
}

View File

@@ -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()));
}
}

View File

@@ -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"));
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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()));
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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));
}
}

View 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;
}
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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));
}
}

View File

@@ -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));
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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")
);
}
}

View File

@@ -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", "状态更新成功"));
}
}

View File

@@ -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;
}
}

View File

@@ -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();
}
}

View 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

View 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);

View File

@@ -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() {
}
}

View File

@@ -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());
}
}

View File

@@ -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);
}
}