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