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

23
.gitignore vendored Normal file
View File

@@ -0,0 +1,23 @@
# IDE / OS
.idea/
.vscode/
*.suo
*.ntvs
*.njsproj
*.sln
.DS_Store
Thumbs.db
desktop.ini
# Build and Env
.env.local
# Database / Storage (Docker Compose mounted path)
data/
*.db
*.sqlite
*.sqlite3
# Logs
logs/
*.log

40
AGENTS.md Normal file
View File

@@ -0,0 +1,40 @@
# Project Guidelines
## Scope
- This repository uses a root AGENTS.md as the workspace-wide instruction file. Do not add a parallel .github/copilot-instructions.md unless this file is being intentionally replaced.
- The workspace is organized around four top-level areas: frontend, backend, sql, and root-level business/design documents.
- Project context and business rules are documented in [商品详情与订单页面设计文档.md](商品详情与订单页面设计文档.md), [前端页面UI方案.md](前端页面UI方案.md), [铜壶管理系统开发任务计划书.md](铜壶管理系统开发任务计划书.md), and [详细实施任务拆分.md](详细实施任务拆分.md). Update those docs when core behavior changes.
## Architecture
- frontend: Vue 3 + Vite + TypeScript + Vue Router + Pinia + Element Plus.
- backend: Spring Boot 2.7 + Java 11 + Spring Security + JWT + JDBC + SQLite.
- data: SQLite is the only database in this project. Product images and label templates must remain in SQLite BLOB fields in the file_asset table; do not move them to local disk paths or object storage unless requirements change.
- product detail uses a dedicated route, not a modal. The left-side category tree is part of the core workflow and should remain available during detail navigation.
- permissions are enforced in two places: frontend visibility and backend API authorization. Do not rely on frontend hiding alone.
- order processing is a closed loop: create, edit pending orders, complete with express number, deduct stock, and download labels generated by the backend from order quantities.
## Build And Test
- Frontend install: cd frontend && npm install
- Frontend dev: cd frontend && npm run dev
- Frontend build: cd frontend && npm run build
- Frontend local API target is configured through [frontend/.env.local](frontend/.env.local). Local development currently points VITE_API_BASE_URL to http://localhost:8081.
- Backend test: cd backend && .\mvnw.cmd test
- Backend run: cd backend && .\mvnw.cmd spring-boot:run "-Dspring-boot.run.arguments=--server.port=8081"
- Run backend commands from the backend directory. Do not assume mvn is installed globally; use the Maven Wrapper in backend.
- Prefer port 8081 for local backend runs because 8080 is frequently occupied in this environment.
- After frontend changes, run the frontend build. After backend changes, run backend tests.
## Conventions
- Preserve the existing split between frontend service files, Pinia stores, view components, and backend controller/service/repository packages.
- Keep Vue route components lazily loaded in [frontend/src/router/index.ts](frontend/src/router/index.ts).
- Keep Element Plus on-demand loading configured in [frontend/vite.config.ts](frontend/vite.config.ts). Do not reintroduce global Element Plus registration or full library CSS import in [frontend/src/main.ts](frontend/src/main.ts).
- Frontend HTTP calls should continue to go through the service layer under frontend/src/services rather than ad hoc fetch logic inside views.
- Backend persistence should stay SQLite-friendly and simple; prefer focused JDBC repository methods over adding heavyweight persistence abstractions.
- The project is delivered as source code, SQL scripts, and deployment notes. Do not add Git-flow-specific assumptions to docs or workflow guidance.
## Business Rules
- The system supports two user types: administrator and normal user. Normal-user permissions are assigned by an administrator.
- Pending orders can be edited or deleted. Completed orders are read-only.
- Label downloads are generated by the backend based on current order items and quantities.
- If a category does not require a detail page, do not force it into the standard product-detail workflow.
- Keep stock validation and permission checks consistent across frontend and backend when changing order or catalog behavior.

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

28
docker-compose.yml Normal file
View File

@@ -0,0 +1,28 @@
version: '3.8'
services:
backend:
build:
context: ./backend
dockerfile: Dockerfile
container_name: teapot_backend
restart: always
environment:
# 指定SQLite数据库文件挂载到/app/data中防止容器重启丢失数据
- SPRING_DATASOURCE_URL=jdbc:sqlite:/app/data/teapot-system.db
# NAS服务器的关键确保商品图片或数据库不随容器重启销毁
volumes:
- ./data:/app/data
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
container_name: teapot_frontend
restart: always
depends_on:
- backend
ports:
# 选择避开了NAS常见的80, 443, 3000, 8080等端口用 8090 作为外部访问端。
# 你可以在NAS路由器或面板中直接访问: http://群晖NAS_IP:8090
- "8090:80"

24
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

30
frontend/AGENTS.md Normal file
View File

@@ -0,0 +1,30 @@
# Frontend Guidelines
## Scope
- Applies to files under frontend/.
- Inherit the root [AGENTS.md](../AGENTS.md) and only override frontend-specific expectations here.
## Stack
- Vue 3 + Vite + TypeScript + Vue Router + Pinia + Element Plus.
- Local API base URL is configured through [frontend/.env.local](.env.local) and should point to http://localhost:8081 for local development.
## Build And Test
- Install dependencies from frontend/: npm install
- Start the dev server from frontend/: npm run dev
- Build from frontend/: npm run build
- Run the frontend build after any change under frontend/.
## Conventions
- Keep route-level pages lazily loaded in [frontend/src/router/index.ts](src/router/index.ts).
- Preserve the current split between views, components, services, stores, layouts, and types. Do not move API calls into view files when they belong in [frontend/src/services](src/services).
- Keep permission-aware navigation and access rules centered in [frontend/src/router/index.ts](src/router/index.ts), [frontend/src/stores/auth.ts](src/stores/auth.ts), and [frontend/src/services/access.ts](src/services/access.ts).
- Keep Element Plus on-demand loading configured in [frontend/vite.config.ts](vite.config.ts). Do not reintroduce global Element Plus registration or full-library CSS import in [frontend/src/main.ts](src/main.ts).
- When adding heavy UI dependencies or shared utilities, preserve the current manual chunk strategy in [frontend/vite.config.ts](vite.config.ts) and watch for new build-size regressions.
- Reuse shared business components such as [frontend/src/components/OrderEditorDialog.vue](src/components/OrderEditorDialog.vue) instead of duplicating order editing flows.
- Preserve the established visual language from [前端页面UI方案.md](../前端页面UI方案.md): warm copper palette, fixed left navigation, and dedicated detail routes instead of modal-driven workflows.
## Frontend Business Rules
- Product detail remains a standalone route and must keep the left category tree visible during navigation.
- Read-only users may browse product and order information but must not see edit-only actions.
- Product and order screens should reflect the same permission and stock rules enforced by the backend, not looser frontend-only behavior.
- Label download entry points stay in order detail flows, while product detail handles image and label template management.

18
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,18 @@
FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
# NAS部署非常关键的一步由于走Nginx同域反代需将URL置空以使用相对路径避免局域网IP锁死
ENV VITE_API_BASE_URL=""
RUN npm run build
FROM nginx:alpine
# 获取vite构件结果
COPY --from=builder /app/dist /usr/share/nginx/html
# 覆盖Nginx默认配置
COPY nginx.conf /etc/nginx/conf.d/default.conf
# 暴露的Web服务器访问端口
EXPOSE 80

5
frontend/README.md Normal file
View File

@@ -0,0 +1,5 @@
# Vue 3 + TypeScript + Vite
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).

39
frontend/components.d.ts vendored Normal file
View File

@@ -0,0 +1,39 @@
/* eslint-disable */
// @ts-nocheck
// biome-ignore lint: disable
// oxlint-disable
// ------
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
ElAlert: typeof import('element-plus/es')['ElAlert']
ElButton: typeof import('element-plus/es')['ElButton']
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup']
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
ElDialog: typeof import('element-plus/es')['ElDialog']
ElEmpty: typeof import('element-plus/es')['ElEmpty']
ElInput: typeof import('element-plus/es')['ElInput']
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
ElOption: typeof import('element-plus/es')['ElOption']
ElPagination: typeof import('element-plus/es')['ElPagination']
ElSelect: typeof import('element-plus/es')['ElSelect']
ElSwitch: typeof import('element-plus/es')['ElSwitch']
ElTable: typeof import('element-plus/es')['ElTable']
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
ElTag: typeof import('element-plus/es')['ElTag']
ElTooltip: typeof import('element-plus/es')['ElTooltip']
HelloWorld: typeof import('./src/components/HelloWorld.vue')['default']
OrderEditorDialog: typeof import('./src/components/OrderEditorDialog.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
}
export interface GlobalDirectives {
vLoading: typeof import('element-plus/es')['ElLoadingDirective']
}
}

13
frontend/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

29
frontend/nginx.conf Normal file
View File

@@ -0,0 +1,29 @@
server {
listen 80;
listen [::]:80;
server_name localhost;
# 开启 gzip 压缩
gzip on;
gzip_min_length 1k;
gzip_comp_level 6;
gzip_types text/plain text/css text/javascript application/json application/javascript application/x-javascript application/xml;
location / {
root /usr/share/nginx/html;
index index.html index.htm;
# 支持 Vue Router history 模式
try_files $uri $uri/ /index.html;
}
# 反向代理所有后端的 API 请求 -> backend服务容器 (Docker compose 网络内的名字和暴露的端口)
location /api/ {
proxy_pass http://backend:8080/api/;
# 传递客户端真实IP给后端
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}

1891
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

27
frontend/package.json Normal file
View File

@@ -0,0 +1,27 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.2",
"element-plus": "^2.13.7",
"pinia": "^3.0.4",
"vue": "^3.5.32",
"vue-router": "^5.0.4"
},
"devDependencies": {
"@types/node": "^24.12.2",
"@vitejs/plugin-vue": "^6.0.5",
"@vue/tsconfig": "^0.9.1",
"typescript": "~6.0.2",
"unplugin-vue-components": "^32.0.0",
"vite": "^8.0.4",
"vue-tsc": "^3.2.6"
}
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

24
frontend/public/icons.svg Normal file
View File

@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

3
frontend/src/App.vue Normal file
View File

@@ -0,0 +1,3 @@
<template>
<RouterView />
</template>

View File

@@ -0,0 +1,25 @@
<svg width="256" height="256" viewBox="0 0 256 256" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="256" height="256" rx="24" fill="#F6F7F5"/>
<g fill="#24373B">
<rect x="8" y="8" width="98" height="10" rx="5"/>
<rect x="8" y="238" width="98" height="10" rx="5"/>
<rect x="148" y="8" width="100" height="10" rx="5"/>
<rect x="148" y="238" width="100" height="10" rx="5"/>
<rect x="6" y="72" width="10" height="56" rx="5"/>
<rect x="6" y="160" width="10" height="64" rx="5"/>
<rect x="240" y="42" width="10" height="68" rx="5"/>
<rect x="240" y="142" width="10" height="76" rx="5"/>
</g>
<rect x="142" y="28" width="92" height="200" rx="14" fill="#24373B"/>
<text x="58" y="54" fill="#24373B" font-family="KaiTi, STKaiti, 'Source Han Serif SC', serif" font-size="30" font-weight="700">
<tspan x="58" dy="22"></tspan>
<tspan x="58" dy="44"></tspan>
<tspan x="58" dy="44"></tspan>
<tspan x="58" dy="44"></tspan>
</text>
<text x="188" y="58" fill="#FFFBF5" text-anchor="middle" font-family="KaiTi, STKaiti, 'Source Han Serif SC', serif" font-size="44" font-weight="700">
<tspan x="188" dy="26"></tspan>
<tspan x="188" dy="56"></tspan>
<tspan x="188" dy="56"></tspan>
</text>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

View File

@@ -0,0 +1,93 @@
<script setup lang="ts">
import { ref } from 'vue'
import viteLogo from '../assets/vite.svg'
import heroImg from '../assets/hero.png'
import vueLogo from '../assets/vue.svg'
const count = ref(0)
</script>
<template>
<section id="center">
<div class="hero">
<img :src="heroImg" class="base" width="170" height="179" alt="" />
<img :src="vueLogo" class="framework" alt="Vue logo" />
<img :src="viteLogo" class="vite" alt="Vite logo" />
</div>
<div>
<h1>Get started</h1>
<p>Edit <code>src/App.vue</code> and save to test <code>HMR</code></p>
</div>
<button class="counter" @click="count++">Count is {{ count }}</button>
</section>
<div class="ticks"></div>
<section id="next-steps">
<div id="docs">
<svg class="icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#documentation-icon"></use>
</svg>
<h2>Documentation</h2>
<p>Your questions, answered</p>
<ul>
<li>
<a href="https://vite.dev/" target="_blank">
<img class="logo" :src="viteLogo" alt="" />
Explore Vite
</a>
</li>
<li>
<a href="https://vuejs.org/" target="_blank">
<img class="button-icon" :src="vueLogo" alt="" />
Learn more
</a>
</li>
</ul>
</div>
<div id="social">
<svg class="icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#social-icon"></use>
</svg>
<h2>Connect with us</h2>
<p>Join the Vite community</p>
<ul>
<li>
<a href="https://github.com/vitejs/vite" target="_blank">
<svg class="button-icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#github-icon"></use>
</svg>
GitHub
</a>
</li>
<li>
<a href="https://chat.vite.dev/" target="_blank">
<svg class="button-icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#discord-icon"></use>
</svg>
Discord
</a>
</li>
<li>
<a href="https://x.com/vite_js" target="_blank">
<svg class="button-icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#x-icon"></use>
</svg>
X.com
</a>
</li>
<li>
<a href="https://bsky.app/profile/vite.dev" target="_blank">
<svg class="button-icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#bluesky-icon"></use>
</svg>
Bluesky
</a>
</li>
</ul>
</div>
</section>
<div class="ticks"></div>
<section id="spacer"></section>
</template>

View File

@@ -0,0 +1,321 @@
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { getCategoryProductsApi } from '../services/catalog'
import { createOrderApi, updateOrderApi } from '../services/order'
import { useAuthStore } from '../stores/auth'
import { useCatalogStore } from '../stores/catalog'
import type { ProductSummary } from '../types/catalog'
import type { OrderDetail, OrderMutationItem, OrderMutationPayload } from '../types/order'
interface OrderEditorLine {
productId: number | null
quantity: number
}
const props = defineProps<{
modelValue: boolean
mode: 'create' | 'edit'
order?: OrderDetail | null
presetItems?: OrderMutationItem[]
}>()
const emit = defineEmits<{
(event: 'update:modelValue', value: boolean): void
(event: 'saved', value: OrderDetail): void
}>()
const authStore = useAuthStore()
const catalogStore = useCatalogStore()
const loadingProducts = ref(false)
const saving = ref(false)
const loadedProductDataVersion = ref(-1)
const products = ref<ProductSummary[]>([])
const form = reactive({
remark: '',
items: [createEmptyLine()] as OrderEditorLine[],
})
const dialogVisible = computed({
get: () => props.modelValue,
set: (value: boolean) => emit('update:modelValue', value),
})
const dialogTitle = computed(() => (props.mode === 'create' ? '新增订单' : '编辑订单'))
const productMap = computed(() => new Map(products.value.map((product) => [product.id, product])))
const mergedQuantityMap = computed(() => {
const quantityMap = new Map<number, number>()
form.items.forEach((item) => {
if (!item.productId) {
return
}
quantityMap.set(item.productId, (quantityMap.get(item.productId) ?? 0) + Number(item.quantity || 0))
})
return quantityMap
})
const totalQuantity = computed(() => form.items.reduce((sum, item) => sum + Number(item.quantity || 0), 0))
const totalAmount = computed(() => form.items.reduce((sum, item) => sum + lineSubtotal(item), 0))
function createEmptyLine(): OrderEditorLine {
return {
productId: null,
quantity: 1,
}
}
function resetForm(order?: OrderDetail | null, presetItems?: OrderMutationItem[]) {
form.remark = order?.remark ?? ''
if (order?.items.length) {
form.items = order.items.map((item) => ({ productId: item.productId, quantity: item.quantity }))
return
}
if (presetItems && presetItems.length > 0) {
form.items = presetItems.map((item) => ({ productId: item.productId, quantity: item.quantity }))
return
}
form.items = [createEmptyLine()]
}
function lineSubtotal(item: OrderEditorLine) {
const product = item.productId ? productMap.value.get(item.productId) : undefined
return product ? product.price * item.quantity : 0
}
function addItem() {
form.items.push(createEmptyLine())
}
function removeItem(index: number) {
if (form.items.length === 1) {
form.items.splice(0, 1, createEmptyLine())
return
}
form.items.splice(index, 1)
}
function productLabel(product: ProductSummary) {
return `${product.model} · ${product.name} / ${product.sku}`
}
function requestedQuantity(productId: number | null) {
if (!productId) {
return 0
}
return mergedQuantityMap.value.get(productId) ?? 0
}
function stockShortage(productId: number | null) {
if (!productId) {
return 0
}
const product = productMap.value.get(productId)
if (!product) {
return 0
}
return Math.max(0, requestedQuantity(productId) - product.stock)
}
async function ensureProductsLoaded() {
if (!authStore.token) {
return
}
const shouldReload = loadedProductDataVersion.value !== catalogStore.productDataVersion
if (products.value.length > 0 && !shouldReload) {
return
}
loadingProducts.value = true
try {
await catalogStore.loadCategoryTree(authStore.token)
const productGroups = await Promise.all(
catalogStore.categoryTree.map((node) => getCategoryProductsApi(node.id, authStore.token as string)),
)
const uniqueProducts = new Map<number, ProductSummary>()
productGroups.flat().forEach((product) => {
uniqueProducts.set(product.id, product)
})
products.value = Array.from(uniqueProducts.values()).sort((left, right) => {
if (left.categoryId !== right.categoryId) {
return left.categoryId - right.categoryId
}
return left.id - right.id
})
loadedProductDataVersion.value = catalogStore.productDataVersion
} catch (error) {
ElMessage.error(error instanceof Error ? `商品清单加载失败:${error.message}` : '商品清单加载失败')
} finally {
loadingProducts.value = false
}
}
async function submit() {
if (!authStore.token) {
ElMessage.error('缺少登录令牌')
return
}
const hasInvalidRow = form.items.some((item) => !item.productId || item.quantity < 1)
if (hasInvalidRow) {
ElMessage.warning('请完整选择商品并填写有效数量')
return
}
const insufficientProduct = form.items
.map((item) => item.productId)
.filter((productId): productId is number => productId !== null)
.map((productId) => {
const product = productMap.value.get(productId)
if (!product) {
return null
}
const selectedQuantity = requestedQuantity(productId)
if (selectedQuantity <= product.stock) {
return null
}
return { product, selectedQuantity }
})
.find((item) => item !== null)
if (insufficientProduct) {
ElMessage.warning(`库存不足:${insufficientProduct.product.model} · ${insufficientProduct.product.name} 当前库存 ${insufficientProduct.product.stock},已选 ${insufficientProduct.selectedQuantity}`)
return
}
const payload: OrderMutationPayload = {
remark: form.remark.trim(),
items: form.items.map((item) => ({
productId: Number(item.productId),
quantity: Number(item.quantity),
})),
}
saving.value = true
try {
const detail = props.mode === 'create'
? await createOrderApi(payload, authStore.token)
: await updateOrderApi(props.order?.id ?? '', payload, authStore.token)
emit('saved', detail)
dialogVisible.value = false
ElMessage.success(props.mode === 'create' ? '订单创建成功' : '订单已更新')
} catch (error) {
ElMessage.error(error instanceof Error ? `订单保存失败:${error.message}` : '订单保存失败')
} finally {
saving.value = false
}
}
watch(
() => props.modelValue,
async (visible) => {
if (!visible) {
return
}
await ensureProductsLoaded()
resetForm(props.order ?? null, props.mode === 'create' ? props.presetItems : undefined)
},
)
watch(
() => props.order,
(order) => {
if (props.modelValue && props.mode === 'edit') {
resetForm(order ?? null)
}
},
)
watch(
() => props.presetItems,
(presetItems) => {
if (props.modelValue && props.mode === 'create') {
resetForm(null, presetItems)
}
},
)
</script>
<template>
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="900px" destroy-on-close>
<div class="page-stack" v-loading="loadingProducts">
<div class="detail-field detail-field--full">
<span>订单备注</span>
<el-input v-model="form.remark" type="textarea" :rows="3" placeholder="可填写客户说明、包装要求等备注" />
</div>
<section class="surface-panel order-editor-panel">
<div class="surface-panel__head">
<div>
<h3>订单明细</h3>
<p>订单金额将按当前商品价格自动汇总</p>
</div>
<el-button type="primary" plain @click="addItem">新增一行</el-button>
</div>
<div class="order-editor-list">
<article v-for="(item, index) in form.items" :key="index" class="order-editor-line">
<div class="order-editor-line__row">
<el-select v-model="item.productId" filterable placeholder="选择商品" class="order-editor-line__product">
<el-option v-for="product in products" :key="product.id" :label="productLabel(product)" :value="product.id" />
</el-select>
<el-input-number v-model="item.quantity" :min="1" :step="1" />
<el-button plain @click="removeItem(index)">删除</el-button>
</div>
<div class="order-editor-line__meta">
<span>SKU{{ item.productId ? productMap.get(item.productId)?.sku ?? '-' : '未选择' }}</span>
<span>单价{{ item.productId ? productMap.get(item.productId)?.price ?? 0 : 0 }}</span>
<span>库存{{ item.productId ? productMap.get(item.productId)?.stock ?? 0 : 0 }}</span>
<span v-if="item.productId && stockShortage(item.productId) > 0" class="order-editor-line__warning">
库存不足当前库存 {{ productMap.get(item.productId)?.stock ?? 0 }}已选 {{ requestedQuantity(item.productId) }}
</span>
<strong>小计{{ lineSubtotal(item) }}</strong>
</div>
</article>
</div>
</section>
<section class="stats-grid stats-grid--three">
<article class="stats-card">
<span>明细行数</span>
<strong>{{ form.items.length }}</strong>
<p>支持同一订单内添加多个商品明细</p>
</article>
<article class="stats-card">
<span>总数量</span>
<strong>{{ totalQuantity }}</strong>
<p>用于同步订单快照数量统计</p>
</article>
<article class="stats-card">
<span>总金额</span>
<strong>{{ totalAmount }}</strong>
<p>按当前商品价格自动汇总</p>
</article>
</section>
</div>
<template #footer>
<div class="dialog-footer-actions">
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="submit">保存订单</el-button>
</div>
</template>
</el-dialog>
</template>

View File

@@ -0,0 +1,156 @@
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { RouterLink, RouterView, useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import brandMark from '../assets/brand-mark.svg'
import { ORDER_PERMISSIONS, PRODUCT_PERMISSIONS, STATISTICS_PERMISSIONS, USER_MANAGE_PERMISSIONS } from '../services/access'
import { useAuthStore } from '../stores/auth'
import { useCatalogStore } from '../stores/catalog'
import { useOrdersStore } from '../stores/orders'
const route = useRoute()
const router = useRouter()
const authStore = useAuthStore()
const catalogStore = useCatalogStore()
const ordersStore = useOrdersStore()
const pendingOrderCount = computed(() => (canSeeOrders.value ? ordersStore.pendingCount : 0))
const canSeeProducts = computed(() => authStore.canAny(PRODUCT_PERMISSIONS))
const canSeeOrders = computed(() => authStore.canAny(ORDER_PERMISSIONS))
const canSeeStatistics = computed(() => authStore.canAny(STATISTICS_PERMISSIONS))
const canSeeUsers = computed(() => authStore.canAny(USER_MANAGE_PERMISSIONS))
const pageTitle = computed(() => String(route.meta.title ?? '铜壶管理系统'))
const productTree = computed(() => catalogStore.categoryTree)
const firstCategoryId = computed(() => catalogStore.firstCategoryId)
const pendingOrderLabel = computed(() => (canSeeOrders.value ? `待处理订单 ${pendingOrderCount.value}` : '无订单权限'))
async function initializeLayoutData() {
if (!authStore.token) {
return
}
const tasks: Promise<unknown>[] = []
if (canSeeProducts.value) {
tasks.push(
catalogStore.loadCategoryTree(authStore.token).catch((error) => {
ElMessage.warning(error instanceof Error ? `商品目录加载失败:${error.message}` : '商品目录加载失败')
}),
)
}
if (canSeeOrders.value) {
tasks.push(
ordersStore.loadOrders(authStore.token).catch((error) => {
ElMessage.warning(error instanceof Error ? `订单摘要加载失败:${error.message}` : '订单摘要加载失败')
}),
)
}
await Promise.all(tasks)
}
function logout() {
authStore.logout()
ElMessage.success('已退出登录')
void router.replace({ name: 'login' })
}
onMounted(() => {
void initializeLayoutData()
})
</script>
<template>
<div class="app-shell">
<aside class="app-sidebar">
<div class="app-sidebar__brand">
<img class="brand-mark" :src="brandMark" alt="铜壶管理系统品牌标识" />
<div>
<strong>铜壶管理系统</strong>
</div>
</div>
<section v-if="canSeeProducts" class="app-sidebar__section" v-loading="catalogStore.loading">
<p class="app-sidebar__label">商品目录</p>
<p v-if="!catalogStore.loading && productTree.length === 0" class="app-sidebar__state">暂无商品目录</p>
<div class="tree-group" v-for="node in productTree" :key="node.id">
<RouterLink
class="tree-node"
:class="{ 'is-active': String(route.params.categoryId ?? '') === String(node.id) }"
:to="{ name: 'product-center', params: { categoryId: String(node.id) } }"
>
{{ node.name }}
</RouterLink>
<div class="tree-children">
<RouterLink
v-for="child in node.children"
:key="child.id"
class="tree-child"
:class="{ 'is-current': String(route.params.productId ?? '') === String(child.productId) }"
:to="{ name: 'product-detail', params: { productId: String(child.productId) } }"
>
{{ child.name }}
</RouterLink>
</div>
</div>
</section>
<section class="app-sidebar__section">
<p class="app-sidebar__label">业务模块</p>
<RouterLink
v-if="canSeeProducts"
class="nav-item"
:class="{ 'is-active': route.name === 'product-center' || route.name === 'product-detail' }"
:to="{ name: 'product-center', params: { categoryId: firstCategoryId } }"
>
商品中心
</RouterLink>
<RouterLink
v-if="canSeeOrders"
class="nav-item"
:class="{ 'is-active': route.name === 'orders' || route.name === 'order-detail' }"
:to="{ name: 'orders' }"
>
订单中心
</RouterLink>
<RouterLink
v-if="canSeeStatistics"
class="nav-item"
:class="{ 'is-active': route.name === 'statistics' }"
:to="{ name: 'statistics' }"
>
经营统计
</RouterLink>
<RouterLink
v-if="canSeeUsers"
class="nav-item"
:class="{ 'is-active': route.name === 'users' }"
:to="{ name: 'users' }"
>
用户管理
</RouterLink>
</section>
</aside>
<div class="app-main">
<header class="app-topbar">
<div>
<div class="app-topbar__title">{{ pageTitle }}</div>
</div>
<div class="app-topbar__actions">
<div class="topbar-search">搜索 SKU / 商品名 / 订单号</div>
<div class="topbar-chip topbar-chip--brand">{{ pendingOrderLabel }}</div>
<div class="topbar-chip">{{ authStore.user?.displayName }} · {{ authStore.user?.userType === 'ADMIN' ? '管理员' : '普通用户' }}</div>
<button class="topbar-link" @click="logout">退出登录</button>
</div>
</header>
<main class="app-content">
<RouterView />
</main>
</div>
</div>
</template>

9
frontend/src/main.ts Normal file
View File

@@ -0,0 +1,9 @@
import { createApp } from 'vue'
import 'element-plus/es/components/message/style/css'
import 'element-plus/es/components/message-box/style/css'
import App from './App.vue'
import router from './router'
import { pinia } from './stores'
import './style.css'
createApp(App).use(pinia).use(router).mount('#app')

View File

@@ -0,0 +1,111 @@
import { createRouter, createWebHistory } from 'vue-router'
import { pinia } from '../stores'
import { useAuthStore } from '../stores/auth'
import { resolveHomeRoute } from '../services/access'
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/login',
name: 'login',
component: () => import('../views/auth/LoginView.vue'),
meta: { title: '登录' },
},
{
path: '/',
component: () => import('../layouts/AppLayout.vue'),
meta: { requiresAuth: true },
children: [
{
path: '',
redirect: '/categories/1',
},
{
path: 'categories/:categoryId',
name: 'product-center',
component: () => import('../views/product/ProductCenterView.vue'),
meta: {
title: '商品中心',
permissions: ['PRODUCT_VIEW', 'PRODUCT_EDIT', 'ASSET_UPLOAD'],
},
},
{
path: 'products/:productId',
name: 'product-detail',
component: () => import('../views/product/ProductDetailView.vue'),
meta: {
title: '商品详情',
permissions: ['PRODUCT_VIEW', 'PRODUCT_EDIT', 'ASSET_UPLOAD'],
},
},
{
path: 'orders',
name: 'orders',
component: () => import('../views/order/OrdersView.vue'),
meta: {
title: '订单中心',
permissions: ['ORDER_VIEW', 'ORDER_PROCESS', 'ORDER_COMPLETE'],
},
},
{
path: 'orders/:orderId',
name: 'order-detail',
component: () => import('../views/order/OrderDetailView.vue'),
meta: {
title: '订单详情',
permissions: ['ORDER_VIEW', 'ORDER_PROCESS', 'ORDER_COMPLETE'],
},
},
{
path: 'statistics',
name: 'statistics',
component: () => import('../views/statistics/StatisticsView.vue'),
meta: {
title: '经营统计',
permissions: ['STATISTICS_VIEW'],
},
},
{
path: 'system/users',
name: 'users',
component: () => import('../views/system/UsersView.vue'),
meta: {
title: '用户管理',
permissions: ['USER_MANAGE'],
},
},
],
},
],
})
router.beforeEach(async (to) => {
const authStore = useAuthStore(pinia)
if (!authStore.initialized) {
await authStore.bootstrap()
}
if (to.name === 'login' && authStore.isAuthenticated) {
return resolveHomeRoute(authStore.user)
}
const requiresAuth = to.matched.some((record) => record.meta.requiresAuth)
if (requiresAuth && !authStore.isAuthenticated) {
return { name: 'login' }
}
const permissions = to.matched.flatMap((record) => {
const metaPermissions = record.meta.permissions
return Array.isArray(metaPermissions) ? metaPermissions : []
})
if (permissions.length > 0 && !authStore.canAny(permissions)) {
return resolveHomeRoute(authStore.user)
}
return true
})
export default router

View File

@@ -0,0 +1,58 @@
import type { RouteLocationRaw } from 'vue-router'
import type { CurrentUser } from '../types/auth'
export const PRODUCT_PERMISSIONS = ['PRODUCT_VIEW', 'PRODUCT_EDIT', 'ASSET_UPLOAD']
export const ORDER_PERMISSIONS = ['ORDER_VIEW', 'ORDER_PROCESS', 'ORDER_COMPLETE']
export const STATISTICS_PERMISSIONS = ['STATISTICS_VIEW']
export const USER_MANAGE_PERMISSIONS = ['USER_MANAGE']
export const PERMISSION_OPTIONS = [
{ code: 'PRODUCT_VIEW', label: '商品查看', group: '商品' },
{ code: 'PRODUCT_EDIT', label: '商品编辑', group: '商品' },
{ code: 'ASSET_UPLOAD', label: '附件上传', group: '商品' },
{ code: 'ORDER_VIEW', label: '订单查看', group: '订单' },
{ code: 'ORDER_PROCESS', label: '订单处理', group: '订单' },
{ code: 'ORDER_COMPLETE', label: '订单完成', group: '订单' },
{ code: 'STATISTICS_VIEW', label: '统计查看', group: '统计' },
{ code: 'USER_MANAGE', label: '用户管理', group: '系统' },
] as const
export const PERMISSION_LABEL_MAP: Record<string, string> = Object.fromEntries(
PERMISSION_OPTIONS.map((option) => [option.code, option.label]),
)
export function hasAnyPermission(user: CurrentUser | null, permissions: string[]) {
if (!user) {
return false
}
if (user.userType === 'ADMIN') {
return true
}
return permissions.some((permission) => user.permissions.includes(permission))
}
export function hasPermission(user: CurrentUser | null, permission: string) {
return hasAnyPermission(user, [permission])
}
export function resolveHomeRoute(user: CurrentUser | null): RouteLocationRaw {
if (hasAnyPermission(user, PRODUCT_PERMISSIONS)) {
return { name: 'product-center', params: { categoryId: '1' } }
}
if (hasAnyPermission(user, ORDER_PERMISSIONS)) {
return { name: 'orders' }
}
if (hasAnyPermission(user, STATISTICS_PERMISSIONS)) {
return { name: 'statistics' }
}
if (hasAnyPermission(user, USER_MANAGE_PERMISSIONS)) {
return { name: 'users' }
}
return { name: 'login' }
}

View File

@@ -0,0 +1,86 @@
import type { ApiResponse } from '../types/auth'
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:8080'
function resolveHeaders(options: RequestInit) {
const headers = new Headers(options.headers ?? {})
const isFormData = typeof FormData !== 'undefined' && options.body instanceof FormData
if (!headers.has('Content-Type') && options.body && !isFormData) {
headers.set('Content-Type', 'application/json')
}
return headers
}
function extractFileName(contentDisposition: string | null) {
if (!contentDisposition) {
return null
}
const utf8Match = contentDisposition.match(/filename\*=UTF-8''([^;]+)/i)
if (utf8Match?.[1]) {
return decodeURIComponent(utf8Match[1])
}
const simpleMatch = contentDisposition.match(/filename="?([^";]+)"?/i)
return simpleMatch?.[1] ?? null
}
export async function request<T>(
path: string,
options: RequestInit = {},
token?: string,
): Promise<T> {
const headers = resolveHeaders(options)
if (token) {
headers.set('Authorization', `Bearer ${token}`)
}
const response = await fetch(`${API_BASE_URL}${path}`, {
...options,
headers,
})
const result = (await response.json()) as ApiResponse<T>
if (!response.ok || !result.success) {
throw new Error(result.message || '请求失败')
}
return result.data
}
export async function requestBlob(
path: string,
options: RequestInit = {},
token?: string,
): Promise<{ blob: Blob; fileName: string | null; mimeType: string | null }> {
const headers = resolveHeaders(options)
if (token) {
headers.set('Authorization', `Bearer ${token}`)
}
const response = await fetch(`${API_BASE_URL}${path}`, {
...options,
headers,
})
if (!response.ok) {
const contentType = response.headers.get('Content-Type') ?? ''
if (contentType.includes('application/json')) {
const result = (await response.json()) as ApiResponse<null>
throw new Error(result.message || '请求失败')
}
throw new Error(await response.text())
}
return {
blob: await response.blob(),
fileName: extractFileName(response.headers.get('Content-Disposition')),
mimeType: response.headers.get('Content-Type'),
}
}

View File

@@ -0,0 +1,45 @@
import { request, requestBlob } from './api'
import type { DownloadedAssetFile, ProductAsset, ProductAssetRole } from '../types/asset'
export function getProductAssetsApi(productId: string | number, token: string) {
return request<ProductAsset[]>(`/api/products/${productId}/assets`, undefined, token)
}
export function uploadProductAssetApi(
productId: string | number,
payload: { fileRole: ProductAssetRole; file: File; isPrimary?: boolean },
token: string,
) {
const formData = new FormData()
formData.set('fileRole', payload.fileRole)
formData.set('isPrimary', payload.isPrimary ? 'true' : 'false')
formData.set('file', payload.file)
return request<ProductAsset>(`/api/products/${productId}/assets`, {
method: 'POST',
body: formData,
}, token)
}
export function downloadProductAssetApi(productId: string | number, assetId: string | number, token: string): Promise<DownloadedAssetFile> {
return requestBlob(`/api/products/${productId}/assets/${assetId}/download`, undefined, token)
}
export function setPrimaryProductAssetApi(productId: string | number, assetId: string | number, token: string) {
return request<ProductAsset>(`/api/products/${productId}/assets/${assetId}/primary`, {
method: 'PUT',
}, token)
}
export function reorderProductImageAssetsApi(productId: string | number, assetIds: number[], token: string) {
return request<ProductAsset[]>(`/api/products/${productId}/assets/image-order`, {
method: 'PUT',
body: JSON.stringify({ assetIds }),
}, token)
}
export function deleteProductAssetApi(productId: string | number, assetId: string | number, token: string) {
return request<{ message: string }>(`/api/products/${productId}/assets/${assetId}`, {
method: 'DELETE',
}, token)
}

View File

@@ -0,0 +1,13 @@
import { request } from './api'
import type { CurrentUser, LoginResponsePayload } from '../types/auth'
export function loginApi(payload: { username: string; password: string }) {
return request<LoginResponsePayload>('/api/auth/login', {
method: 'POST',
body: JSON.stringify(payload),
})
}
export function getCurrentUserApi(token: string) {
return request<CurrentUser>('/api/auth/me', undefined, token)
}

View File

@@ -0,0 +1,65 @@
import { request } from './api'
import type {
CreateProductPayload,
CategoryTreeNode,
ProductDetail,
ProductStatusCode,
ProductStatusTagType,
ProductSummary,
UpdateProductPayload,
} from '../types/catalog'
export const PRODUCT_STATUS_OPTIONS: Array<{ label: string; value: ProductStatusCode }> = [
{ label: '可售', value: 'AVAILABLE' },
{ label: '库存少', value: 'LOW_STOCK' },
{ label: '库存多', value: 'HIGH_STOCK' },
{ label: '停产', value: 'DISCONTINUED' },
]
const PRODUCT_STATUS_LABELS: Record<ProductStatusCode, string> = {
AVAILABLE: '可售',
LOW_STOCK: '库存少',
HIGH_STOCK: '库存多',
DISCONTINUED: '停产',
}
const PRODUCT_STATUS_TYPES: Record<ProductStatusCode, ProductStatusTagType> = {
AVAILABLE: 'success',
LOW_STOCK: 'warning',
HIGH_STOCK: 'info',
DISCONTINUED: 'danger',
}
export function getProductStatusLabel(status: ProductStatusCode) {
return PRODUCT_STATUS_LABELS[status]
}
export function getProductStatusType(status: ProductStatusCode) {
return PRODUCT_STATUS_TYPES[status]
}
export function getCategoryTreeApi(token: string) {
return request<CategoryTreeNode[]>('/api/categories/tree', undefined, token)
}
export function getCategoryProductsApi(categoryId: string | number, token: string) {
return request<ProductSummary[]>(`/api/categories/${categoryId}/products`, undefined, token)
}
export function getProductDetailApi(productId: string | number, token: string) {
return request<ProductDetail>(`/api/products/${productId}`, undefined, token)
}
export function createProductApi(payload: CreateProductPayload, token: string) {
return request<ProductDetail>('/api/products', {
method: 'POST',
body: JSON.stringify(payload),
}, token)
}
export function updateProductApi(productId: string | number, payload: UpdateProductPayload, token: string) {
return request<ProductDetail>(`/api/products/${productId}`, {
method: 'PUT',
body: JSON.stringify(payload),
}, token)
}

View File

@@ -0,0 +1,142 @@
import type { UserListItem } from '../types/auth'
export const productNodes = [
{
id: '1',
name: '1号普通壶',
children: [
{ id: '101', name: '花纹A', productId: '101' },
{ id: '102', name: '花纹B', productId: '102' },
{ id: '103', name: '花纹C', productId: '103' },
],
},
{
id: '2',
name: '2号锤纹壶',
children: [
{ id: '201', name: '锤纹青古', productId: '201' },
{ id: '202', name: '锤纹亮铜', productId: '202' },
],
},
]
export const productCards = [
{
id: '101',
categoryId: '1',
model: '1号普通壶',
name: '花纹A',
sku: 'TH-001-A',
price: 198,
stock: 82,
status: '可售',
statusType: 'success',
remark: '常规热销款式,适合批发出货。',
},
{
id: '102',
categoryId: '1',
model: '1号普通壶',
name: '花纹B',
sku: 'TH-001-B',
price: 228,
stock: 9,
status: '库存少',
statusType: 'warning',
remark: '库存较少,建议控制订单数量。',
},
{
id: '103',
categoryId: '1',
model: '1号普通壶',
name: '花纹C',
sku: 'TH-001-C',
price: 268,
stock: 145,
status: '库存多',
statusType: 'info',
remark: '库存充足,适合大单。',
},
{
id: '201',
categoryId: '2',
model: '2号锤纹壶',
name: '锤纹青古',
sku: 'TH-002-A',
price: 318,
stock: 36,
status: '可售',
statusType: 'success',
remark: '主打纹理工艺,适合展示。',
},
]
export const orderList = [
{
id: 'TH20260411-008',
status: 'PENDING',
createdAt: '2026-04-11 09:30',
totalQuantity: 36,
totalAmount: 7820,
expressNo: '',
items: [
{ name: '花纹A', sku: 'TH-001-A', price: 198, quantity: 10, amount: 1980 },
{ name: '花纹B', sku: 'TH-001-B', price: 228, quantity: 8, amount: 1824 },
{ name: '花纹C', sku: 'TH-001-C', price: 268, quantity: 6, amount: 1608 },
],
},
{
id: 'TH20260410-005',
status: 'COMPLETED',
createdAt: '2026-04-10 15:10',
totalQuantity: 22,
totalAmount: 5280,
expressNo: 'SF847266012210',
items: [
{ name: '锤纹青古', sku: 'TH-002-A', price: 318, quantity: 12, amount: 3816 },
{ name: '花纹A', sku: 'TH-001-A', price: 198, quantity: 6, amount: 1188 },
],
},
]
export const statisticsRows = [
{ name: '花纹A', quantity: 58, price: 198, subtotal: 11484 },
{ name: '花纹B', quantity: 22, price: 228, subtotal: 5016 },
{ name: '锤纹青古', quantity: 31, price: 318, subtotal: 9858 },
]
export const demoUsers: UserListItem[] = [
{
id: 1,
username: 'admin',
displayName: '系统管理员',
userType: 'ADMIN',
status: 'ENABLED',
permissions: [
'PRODUCT_VIEW',
'PRODUCT_EDIT',
'ASSET_UPLOAD',
'ORDER_VIEW',
'ORDER_PROCESS',
'ORDER_COMPLETE',
'STATISTICS_VIEW',
'USER_MANAGE',
],
},
{
id: 2,
username: 'customer',
displayName: '客户演示用户',
userType: 'NORMAL',
status: 'ENABLED',
permissions: ['PRODUCT_VIEW'],
},
{
id: 3,
username: 'packer',
displayName: '打包工演示用户',
userType: 'NORMAL',
status: 'ENABLED',
permissions: ['ORDER_VIEW', 'ORDER_PROCESS', 'ORDER_COMPLETE'],
},
]

View File

@@ -0,0 +1,41 @@
import { request, requestBlob } from './api'
import type { OrderDetail, OrderMutationPayload, OrderSummary } from '../types/order'
export function getOrdersApi(token: string) {
return request<OrderSummary[]>('/api/orders', undefined, token)
}
export function createOrderApi(payload: OrderMutationPayload, token: string) {
return request<OrderDetail>('/api/orders', {
method: 'POST',
body: JSON.stringify(payload),
}, token)
}
export function getOrderDetailApi(orderNo: string, token: string) {
return request<OrderDetail>(`/api/orders/${orderNo}`, undefined, token)
}
export function updateOrderApi(orderNo: string, payload: OrderMutationPayload, token: string) {
return request<OrderDetail>(`/api/orders/${orderNo}`, {
method: 'PUT',
body: JSON.stringify(payload),
}, token)
}
export function deleteOrderApi(orderNo: string, token: string) {
return request<{ message: string }>(`/api/orders/${orderNo}`, {
method: 'DELETE',
}, token)
}
export function completeOrderApi(orderNo: string, expressNo: string, token: string) {
return request<OrderDetail>(`/api/orders/${orderNo}/complete`, {
method: 'POST',
body: JSON.stringify({ expressNo }),
}, token)
}
export function downloadOrderLabelsApi(orderNo: string, token: string) {
return requestBlob(`/api/orders/${orderNo}/labels/download`, undefined, token)
}

Some files were not shown because too many files have changed in this diff Show More