This commit is contained in:
huchuansai
2025-09-13 14:04:56 +08:00
35 changed files with 2897 additions and 67487 deletions

59
.dockerignore Normal file
View File

@@ -0,0 +1,59 @@
# Build artifacts and dependencies
target/
.mvn/
*.iml
*.ipr
*.iws
# IDE files
.idea/
.vscode/
*.sublime-*
.eclipse
.project
.classpath
.settings/
# OS files
.DS_Store
Thumbs.db
# Version control
.git/
.gitignore
.gitattributes
# Documentation and scripts (comment out if needed in container)
README.md
*.md
deploy.sh
rollback.sh
fetch-log.sh
# Docker files (avoid recursion)
docker/
Dockerfile*
docker-compose*
# Logs and temp files
log/
logs/
*.log
*.tmp
# Backup files
backup/
*.backup
*.bak
# Environment files
.env
.env.*
# Test files
**/src/test/
# Maven wrapper (we install Maven in container)
.mvn/
mvnw
mvnw.cmd

3
.gitignore vendored
View File

@@ -50,3 +50,6 @@ nbproject/
/var/log/*
*.factorypath
/storage/
# Docker buildx cache
.buildx-cache/

119
CLAUDE.md Normal file
View File

@@ -0,0 +1,119 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
This is a Spring Boot multi-module Java backend application called "peipei-backend" that appears to be a social platform/gaming system with WeChat Mini Program integration. The system supports clerk management, customer interactions, orders, gifts, and various social features.
## Project Structure
The project is organized as a Maven multi-module application:
- **play-admin**: Main backend API module containing REST controllers and business logic
- **play-common**: Shared utilities, configurations, and common components
- **play-generator**: Code generation tool for creating CRUD operations from database tables
## Technology Stack
- **Java 11** with Spring Boot 2.5.4
- **MyBatis Plus 3.5.3** with join support for database operations
- **MySQL 8** with Flyway migrations
- **Redis** for caching and session management
- **JWT** for authentication
- **WeChat Mini Program SDK** for WeChat integration
- **Aliyun OSS** for file storage
- **Lombok** for reducing boilerplate code
- **Knife4j/Swagger** for API documentation
## Development Commands
### Building and Running
```bash
# Build the entire project
mvn clean compile
# Package the application
mvn clean package
# Run the main application (play-admin module)
cd play-admin
mvn spring-boot:run
# Or run the packaged jar
java -jar play-admin/target/play-admin-1.0.jar
```
### Database Migrations
```bash
# Run Flyway migrations
mvn flyway:migrate
# Check migration status
mvn flyway:info
```
### Code Generation
```bash
# Generate CRUD code from database tables
cd play-generator
mvn clean compile exec:java
# Or run directly: ./run.sh (Linux/Mac) or run.bat (Windows)
```
## Configuration
The application uses Spring profiles with separate configuration files:
- `application.yml`: Main configuration
- `application-dev.yml`: Development environment
- `application-test.yml`: Test environment
- `application-prod.yml`: Production environment
Default active profile is `test`. Change via `spring.profiles.active` property.
## Architecture
### Module Structure
- **Controllers**: Located in `modules/{domain}/controller/` - Handle HTTP requests and responses
- **Services**: Located in `modules/{domain}/service/` - Business logic layer
- **Mappers**: Located in `modules/{domain}/mapper/` - Database access layer using MyBatis Plus
- **Entities**: Domain objects representing database tables
### Key Domains
- **clerk**: Clerk/staff management and operations
- **custom**: Customer management and interactions
- **order**: Order processing and management
- **shop**: Product catalog and commerce features
- **system**: System administration and user management
- **weichat**: WeChat Mini Program integration
### Authentication & Security
- JWT-based authentication with Spring Security
- Multi-tenant architecture support
- Role-based access control
- XSS protection and input validation
### Database
- Uses MyBatis Plus for ORM with automatic CRUD generation
- Flyway for database migration management
- Logical deletion support (soft delete)
- Multi-tenant data isolation
## Code Generation Tool
The project includes a powerful code generator (`play-generator`) that can:
- Read MySQL table structures
- Generate Entity, Mapper, Service, Controller classes
- Create MyBatis XML mapping files
- Support batch generation for multiple tables
Configure database connection in `play-generator/src/main/resources/config.properties` and specify table names to generate complete CRUD operations.
## Deployment
The project includes a deployment script (`deploy.sh`) that:
- Builds and packages the application
- Deploys to remote server via SCP
- Restarts the application service
Server runs on port 7002 with context path `/api`.

View File

@@ -134,6 +134,76 @@ mvn spotless:apply compile
mvn spotless:apply checkstyle:check compile
```
## 部署说明
### Docker 构建和推送
项目支持多架构 Docker 构建,特别适合在 Apple Silicon Mac 上为 Linux 服务器构建镜像。
#### 构建镜像
```bash
# 构建服务器部署镜像 (Linux amd64)
./build-docker.sh amd64
# 构建本地开发镜像 (Apple Silicon arm64)
./build-docker.sh arm64
# 自动检测架构构建
./build-docker.sh
# 查看帮助
./build-docker.sh -h
```
#### 推送到私有仓库
```bash
# 推送 amd64 镜像到私有仓库 (用于服务器部署)
./push-docker.sh
```
### 服务器部署
#### 部署环境
- **服务器**: CentOS Linux
- **架构**: amd64
- **容器**: Docker + Docker Compose
#### 部署步骤
1. **服务器上的配置文件**
```bash
# 服务器主目录有专门的 docker-compose 文件
~/docker-compose.yml # 为 CentOS 环境优化的配置
```
2. **启动服务**
```bash
# 在服务器主目录执行
cd ~
docker-compose up -d
```
3. **查看日志**
```bash
# 应用日志位置
~/log/ # 应用日志目录
# 查看实时日志
tail -f ~/log/detail.log
tail -f ~/log/error.log
# 查看容器日志
docker-compose logs -f
```
#### 部署文件说明
- **~/docker-compose.yml**: 为 CentOS 环境定制的 Docker Compose 配置
- **~/log/**: 应用日志输出目录
- 配置文件已针对服务器环境进行优化,可直接使用
## 模块介绍
### play-admin

67364
backend.txt

File diff suppressed because it is too large Load Diff

161
build-docker.sh Executable file
View File

@@ -0,0 +1,161 @@
#!/usr/bin/env bash
# 构建 Docker 镜像脚本
# 用法: ./build-docker.sh [amd64|arm64]
# 如果不指定架构,将使用本机架构
set -euo pipefail
# 颜色输出
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
echo -e "${GREEN}=== 开始构建 PeiPei 后端 Docker 镜像 ===${NC}"
# 检测本机架构
NATIVE_ARCH=$(uname -m)
if [[ "$NATIVE_ARCH" == "x86_64" ]]; then
NATIVE_ARCH="amd64"
elif [[ "$NATIVE_ARCH" == "arm64" ]] || [[ "$NATIVE_ARCH" == "aarch64" ]]; then
NATIVE_ARCH="arm64"
fi
# 处理命令行参数
if [[ "$1" == "-h" || "$1" == "--help" ]]; then
echo -e "${GREEN}PeiPei 后端 Docker 镜像构建脚本${NC}"
echo ""
echo -e "${YELLOW}用法:${NC}"
echo " ./build-docker.sh [选项] [架构]"
echo ""
echo -e "${YELLOW}选项:${NC}"
echo " -h, --help 显示此帮助信息"
echo ""
echo -e "${YELLOW}架构:${NC}"
echo " amd64 构建 Linux amd64 镜像 (适用于服务器)"
echo " arm64 构建 Linux arm64 镜像 (适用于 Apple Silicon Mac)"
echo " [空] 自动检测本机架构"
echo ""
echo -e "${YELLOW}示例:${NC}"
echo " ./build-docker.sh # 自动检测架构构建"
echo " ./build-docker.sh amd64 # 构建服务器镜像"
echo " ./build-docker.sh arm64 # 构建 Mac 镜像"
echo " ./build-docker.sh -h # 显示帮助"
echo ""
echo -e "${YELLOW}说明:${NC}"
echo " - 本机架构: ${NATIVE_ARCH}"
echo " - 使用 Docker Buildx 进行构建"
echo " - 镜像将带有架构后缀标签 (如: latest-amd64)"
echo " - 构建缓存存储在: .buildx-cache/"
echo " - 确保 Dockerfile 使用: FROM --platform=\$TARGETPLATFORM"
exit 0
fi
TARGET_ARCH="${1:-$NATIVE_ARCH}"
# 验证架构参数
if [[ "$TARGET_ARCH" != "amd64" && "$TARGET_ARCH" != "arm64" ]]; then
echo -e "${RED}错误: 不支持的架构 '$TARGET_ARCH'${NC}"
echo -e "${YELLOW}支持的架构: amd64, arm64${NC}"
echo -e "${YELLOW}用法: ./build-docker.sh [amd64|arm64]${NC}"
exit 1
fi
echo -e "${YELLOW}目标架构: ${TARGET_ARCH}${NC}"
echo -e "${YELLOW}本机架构: ${NATIVE_ARCH}${NC}"
# 检查是否在正确的目录
if [ ! -f "docker/Dockerfile" ]; then
echo -e "${RED}错误: 请在项目根目录执行此脚本${NC}"
echo -e "${YELLOW}当前目录: $(pwd)${NC}"
echo -e "${YELLOW}期望目录应包含: docker/Dockerfile${NC}"
exit 1
fi
# 获取 UTC+8 时间戳
TIMESTAMP=$(TZ='Asia/Shanghai' date +"%Y-%m-%d-%Hh-%Mm")
echo -e "${YELLOW}构建时间戳 (UTC+8): ${TIMESTAMP}${NC}"
# 镜像名称和标签
IMAGE_NAME="peipei-backend"
VERSION_TAG="${TIMESTAMP}-${TARGET_ARCH}"
LATEST_TAG="latest-${TARGET_ARCH}"
echo -e "${YELLOW}镜像名称: ${IMAGE_NAME}${NC}"
echo -e "${YELLOW}版本标签: ${VERSION_TAG}${NC}"
# 构建 Docker 镜像
echo -e "${GREEN}开始构建镜像...${NC}"
# 确保 buildx 可用
if ! docker buildx version >/dev/null 2>&1; then
echo -e "${RED}错误: 需要 Docker Buildx 支持${NC}"
exit 1
fi
# 创建并使用 builder如果不存在
if ! docker buildx inspect peipei-builder >/dev/null 2>&1; then
echo -e "${YELLOW}创建 buildx builder...${NC}"
docker buildx create --name peipei-builder --use
else
docker buildx use peipei-builder
fi
# 启动 builder 和 QEMU用于跨架构支持
echo -e "${YELLOW}初始化 buildx builder...${NC}"
docker buildx inspect --bootstrap >/dev/null
# 显示构建类型
if [[ "$TARGET_ARCH" != "$NATIVE_ARCH" ]]; then
echo -e "${YELLOW}跨平台构建: ${NATIVE_ARCH} -> ${TARGET_ARCH}${NC}"
else
echo -e "${YELLOW}本机架构构建: ${TARGET_ARCH}${NC}"
fi
# 创建缓存目录(可选优化)
CACHE_DIR=".buildx-cache"
mkdir -p "$CACHE_DIR"
# 始终使用 buildx 以保持行为一致
echo -e "${GREEN}执行构建...${NC}"
if docker buildx build \
--platform "linux/${TARGET_ARCH}" \
--load \
--cache-from="type=local,src=${CACHE_DIR}" \
--cache-to="type=local,dest=${CACHE_DIR}" \
-f docker/Dockerfile \
-t "${IMAGE_NAME}:${VERSION_TAG}" \
-t "${IMAGE_NAME}:${LATEST_TAG}" \
.; then
BUILD_SUCCESS=true
else
BUILD_SUCCESS=false
fi
# 检查构建结果
if [[ "$BUILD_SUCCESS" == "true" ]]; then
echo -e "${GREEN}✅ Docker 镜像构建成功!${NC}"
echo -e "${GREEN}镜像标签:${NC}"
echo -e " - ${IMAGE_NAME}:${VERSION_TAG}"
echo -e " - ${IMAGE_NAME}:${LATEST_TAG}"
echo -e "\n${YELLOW}镜像信息:${NC}"
docker images | grep -E "^${IMAGE_NAME}\s"
echo -e "\n${GREEN}使用说明:${NC}"
if [[ "$TARGET_ARCH" == "amd64" ]]; then
echo -e " - 该镜像适用于 Linux amd64 服务器部署"
echo -e " - 推送到服务器: ./push-docker.sh"
elif [[ "$TARGET_ARCH" == "arm64" ]]; then
echo -e " - 该镜像适用于 Apple Silicon Mac 本地运行"
echo -e " - 本地测试: docker run ${IMAGE_NAME}:${LATEST_TAG}"
fi
echo -e "\n${YELLOW}构建其他架构:${NC}"
echo -e " - 构建 amd64 (服务器): ./build-docker.sh amd64"
echo -e " - 构建 arm64 (Mac): ./build-docker.sh arm64"
echo -e " - 自动检测架构: ./build-docker.sh"
else
echo -e "${RED}❌ Docker 镜像构建失败!${NC}"
exit 1
fi

219
db_workflow.md Normal file
View File

@@ -0,0 +1,219 @@
# Database Development Workflow
This document outlines the recommended workflow for database schema changes and code generation in the peipei-backend project.
## Overview
The project uses a **Database-First** approach with the following flow:
```
Flyway Migration → Database Schema → Code Generator → Java Code
```
## Step-by-Step Workflow
### 1. Create Flyway Migration
Create a new migration file in `/play-admin/src/main/resources/db/migration/`:
```sql
-- V{version}__{description}.sql
-- Example: V2__add_new_feature_table.sql
CREATE TABLE `play_new_feature` (
`id` varchar(32) NOT NULL COMMENT 'UUID',
`feature_name` varchar(100) COMMENT '功能名称',
`feature_type` varchar(20) DEFAULT NULL COMMENT '功能类型',
`status` char(1) DEFAULT '0' COMMENT '状态(0正常 1停用)',
`tenant_id` varchar(32) NOT NULL COMMENT '租户ID',
`created_by` varchar(32) DEFAULT NULL COMMENT '创建人的id',
`created_time` datetime DEFAULT NULL COMMENT '创建时间',
`updated_by` varchar(32) DEFAULT NULL COMMENT '修改人的id',
`updated_time` datetime DEFAULT NULL COMMENT '修改时间',
`deleted` tinyint(1) NOT NULL DEFAULT '0' COMMENT '逻辑删除',
`version` int(11) NOT NULL DEFAULT '1' COMMENT '数据版本',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='新功能表';
```
**Migration Naming Convention:**
- `V{version}__{description}.sql`
- Version format: `V1.2025.0609.10.11` (timestamp-based)
- Description: snake_case with double underscore
### 2. Run Flyway Migration
Apply the migration to update database schema:
```bash
# Run from project root
mvn flyway:migrate
# Or check migration status
mvn flyway:info
```
### 3. Configure Code Generator
Edit `/play-generator/src/main/resources/config.properties`:
```properties
# Database configuration (should match main app)
db.url=jdbc:mysql://localhost:3306/play-with?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai
db.driver=com.mysql.cj.jdbc.Driver
db.username=root
db.password=your_password
# Code generation configuration
gen.author=your_name
gen.packageName=com.starry.admin
gen.outputDir=./generated-code
gen.autoRemovePre=false
gen.tablePrefix=play_
gen.tplCategory=crud
# Specify the new table(s) to generate
gen.tableNames=play_new_feature
```
### 4. Run Code Generator
Generate Java code from the new table structure:
```bash
cd play-generator
./run.sh
# Or manually: mvn clean compile exec:java
```
### 5. Review Generated Code
Check the generated files in `./generated-code/`:
```
generated-code/
├── src/main/java/com/starry/admin/
│ ├── entity/PlayNewFeatureEntity.java
│ ├── mapper/PlayNewFeatureMapper.java
│ ├── service/IPlayNewFeatureService.java
│ ├── service/impl/PlayNewFeatureServiceImpl.java
│ └── controller/PlayNewFeatureController.java
└── src/main/resources/mapper/PlayNewFeatureMapper.xml
```
### 6. Integrate Generated Code
Copy generated files to the appropriate module in play-admin:
```bash
# Create new module directory structure
mkdir -p play-admin/src/main/java/com/starry/admin/modules/newfeature/{controller,service,mapper,module/entity}
# Copy generated files to appropriate locations
cp generated-code/src/main/java/com/starry/admin/controller/* \
play-admin/src/main/java/com/starry/admin/modules/newfeature/controller/
cp generated-code/src/main/java/com/starry/admin/service/* \
play-admin/src/main/java/com/starry/admin/modules/newfeature/service/
cp generated-code/src/main/java/com/starry/admin/mapper/* \
play-admin/src/main/java/com/starry/admin/modules/newfeature/mapper/
cp generated-code/src/main/java/com/starry/admin/entity/* \
play-admin/src/main/java/com/starry/admin/modules/newfeature/module/entity/
cp generated-code/src/main/resources/mapper/* \
play-admin/src/main/resources/mapper/newfeature/
```
### 7. Customize and Test
- **Review generated code** for any needed customizations
- **Add business logic** to Service implementations
- **Customize validation** in Controllers
- **Add custom queries** in Mapper if needed
- **Test the new endpoints** via Swagger UI
- **Run application** to ensure everything works
## Database Design Best Practices
### Table Naming Convention
- Use `play_` prefix for all tables
- Use snake_case for table names
- Example: `play_order_info`, `play_clerk_level_info`
### Required Standard Columns
All tables should include these standard columns:
```sql
`id` varchar(32) NOT NULL COMMENT 'UUID',
`tenant_id` varchar(32) NOT NULL COMMENT '租户ID',
`created_by` varchar(32) DEFAULT NULL COMMENT '创建人的id',
`created_time` datetime DEFAULT NULL COMMENT '创建时间',
`updated_by` varchar(32) DEFAULT NULL COMMENT '修改人的id',
`updated_time` datetime DEFAULT NULL COMMENT '修改时间',
`deleted` tinyint(1) NOT NULL DEFAULT '0' COMMENT '逻辑删除',
`version` int(11) NOT NULL DEFAULT '1' COMMENT '数据版本',
```
### Column Guidelines
- Always add meaningful `COMMENT` to columns
- Use appropriate data types (`varchar`, `int`, `decimal`, `datetime`)
- Set proper defaults where applicable
- Consider indexing for foreign keys and frequently queried columns
## Code Generation Notes
### What Gets Generated
- **Entity**: MyBatis Plus entity with Lombok annotations
- **Mapper**: Database access interface extending BaseMapper
- **Service**: Business logic interface with standard CRUD operations
- **ServiceImpl**: Service implementation with basic CRUD logic
- **Controller**: REST API endpoints with Swagger documentation
- **Mapper XML**: MyBatis SQL mapping files
### Generated Code Features
- Swagger API documentation
- Spring Security integration (`@PreAuthorize`)
- Audit logging (`@Log`)
- Input validation
- Pagination support
- Logical deletion support
- Multi-tenant support
## Troubleshooting
### Common Issues
**Migration Fails**:
- Check database connection settings
- Verify migration SQL syntax
- Ensure proper permissions
**Generator Fails**:
- Verify database connection in config.properties
- Check if table exists after migration
- Ensure table follows naming conventions
**Generated Code Issues**:
- Review column comments (used for Java doc)
- Check data type mappings
- Verify package naming in config
### Database Type Mappings
| MySQL Type | Java Type | Notes |
|------------|-----------|-------|
| `varchar(n)` | `String` | |
| `int`, `bigint` | `Integer`, `Long` | |
| `decimal(p,s)` | `BigDecimal` | Precise decimal calculations |
| `datetime` | `Date` | Or `LocalDateTime` |
| `tinyint(1)` | `Boolean` | For flags/status |
| `char(1)` | `String` | For status codes |
## Tips
1. **Always create migration first**, then generate code
2. **Use descriptive table and column names** - they become class and field names
3. **Test migrations on dev environment** before applying to production
4. **Review generated code** before committing - customize as needed
5. **Follow existing module patterns** when organizing generated code
6. **Backup database** before running migrations in production

16
deploy-docker.sh Executable file
View File

@@ -0,0 +1,16 @@
#!/bin/sh
# Docker deployment script
set -e
# Get current time and format it
current_time=$(date +"%Y-%m-%d %H:%M:%S")
echo "Deployment started at: $current_time"
echo "Connecting to server to update docker container..."
# SSH into the server, pull the latest image and restart the container
ssh root@122.51.20.105 "source /etc/profile; cd ~; docker compose pull && docker compose up -d"
echo "Docker container updated!"
# Get current time and format it
current_time=$(date +"%Y-%m-%d %H:%M:%S")
echo "Deployment finished at: $current_time"

23
deploy-jar.sh Executable file
View File

@@ -0,0 +1,23 @@
#!/bin/sh
# 发包脚本
set -e
# 获取当前时间并格式化为指定格式
current_time=$(date +"%Y-%m-%d %H:%M:%S")
echo "发布开始,当前时间是:$current_time"
# 构建项目
echo "开始构建项目..."
mvn clean package -DskipTests
echo "构建完成!"
# 备份当前的jar文件
ssh root@122.51.20.105 "source /etc/profile; cd /www/wwwroot/july.hucs.top; if [ -f play-admin-1.0.jar ]; then mv play-admin-1.0.jar play-admin-1.0.jar.backup; fi"
echo "备份完成!"
scp ./play-admin/target/play-admin-1.0.jar root@122.51.20.105:/www/wwwroot/july.hucs.top
echo "上传成功!"
ssh root@122.51.20.105 "source /etc/profile;cd /www/wwwroot/july.hucs.top;sh start.sh restart"
# 获取当前时间并格式化为指定格式
current_time=$(date +"%Y-%m-%d %H:%M:%S")
echo "发布完成,当前时间是:$current_time"

View File

@@ -1,11 +0,0 @@
#!/bin/sh
# 发包脚本
set -e
echo "发布开始,当前时间是:$current_time"
#mvn clean install
scp ./play-admin/target/play-admin-1.0.jar root@122.51.20.105:/www/wwwroot/july.hucs.top
ssh root@122.51.20.105 "source /etc/profile;cd /www/wwwroot/july.hucs.top;sh start.sh restart"
# 获取当前时间并格式化为指定格式
current_time=$(date +"%Y-%m-%d %H:%M:%S")
echo "发布完成,当前时间是:$current_time"

View File

@@ -1,16 +1,38 @@
FROM openjdk:11-jre-slim
# ---------- Build stage ----------
FROM maven:3.9-eclipse-temurin-11 AS build
WORKDIR /workspace
# 设置工作目录
# Cache-friendly: copy POMs first
COPY pom.xml .
COPY play-admin/pom.xml play-admin/
COPY play-common/pom.xml play-common/
COPY play-generator/pom.xml play-generator/
# Warm deps cache
RUN --mount=type=cache,target=/root/.m2 mvn -B -DskipTests dependency:go-offline
# Add sources
COPY play-admin/src play-admin/src/
COPY play-common/src play-common/src/
COPY play-generator/src play-generator/src/
# Build only app module (and its deps)
RUN --mount=type=cache,target=/root/.m2 mvn -pl play-admin -am -B -DskipTests -T 1C clean package
# ---------- Runtime stage (multi-arch) ----------
FROM --platform=$TARGETPLATFORM eclipse-temurin:11-jre AS runtime
# non-root
RUN groupadd -g 1001 appgroup && useradd -u 1001 -g appgroup -m -s /usr/sbin/nologin appuser
WORKDIR /app
# 只复制应用程序JAR包不复制lib目录
COPY ./*.jar app.jar
COPY --from=build /workspace/play-admin/target/*.jar /app/app.jar
RUN chown -R appuser:appgroup /app
USER appuser
# 设置环境变量
ENV APP_NAME=app.jar
ENV JAVA_OPTS="-Xms2g -Xmx2g" \
SPRING_PROFILES_ACTIVE="test" \
TZ="Asia/Shanghai" \
LANG="C.UTF-8"
# 暴露应用端口
EXPOSE 8080
# 设置启动命令
CMD ["sh", "-c", "java -Dloader.path=./lib/ -Xms2g -Xmx2g -jar $APP_NAME --spring.profiles.active=test"]
EXPOSE 7002
ENTRYPOINT ["sh","-c","exec java $JAVA_OPTS -jar /app/app.jar --spring.profiles.active=${SPRING_PROFILES_ACTIVE}"]

30
docker/README.md Normal file
View File

@@ -0,0 +1,30 @@
# 私有 Docker 仓库使用速查表
# 仓库地址: https://docker-registry.julyhaven.com
# 用户名: hucs
# 密码: hucsdev
# 登录
docker login docker-registry.julyhaven.com -u hucs -p hucsdev
# 推送镜像 (示例: myapp:1.0.0)
docker tag myapp:1.0.0 docker-registry.julyhaven.com/myteam/myapp:1.0.0
docker push docker-registry.julyhaven.com/myteam/myapp:1.0.0
# 同时推送 latest
docker tag myapp:1.0.0 docker-registry.julyhaven.com/myteam/myapp:latest
docker push docker-registry.julyhaven.com/myteam/myapp:latest
# 拉取镜像
docker pull docker-registry.julyhaven.com/myteam/myapp:1.0.0
docker pull docker-registry.julyhaven.com/myteam/myapp:latest
# 测试镜像 (hello-world)
docker pull hello-world
docker tag hello-world docker-registry.julyhaven.com/test/hello-world:latest
docker push docker-registry.julyhaven.com/test/hello-world:latest
docker pull docker-registry.julyhaven.com/test/hello-world:latest
# 注意事项:
# 1. latest 会被覆盖,生产环境请用版本号标签
# 2. 已支持大镜像推送/拉取,无需额外配置
# 3. 登录失败时请确认用户名/密码是否正确

View File

@@ -1,12 +1,37 @@
version: '3'
version: "3.8"
services:
spring-boot-app:
build: .
container_name: spring-boot-app
peipei-backend:
image: docker-registry.julyhaven.com/peipei/backend:latest
container_name: peipei-backend
ports:
- "7003:7002"
environment:
- SPRING_PROFILES_ACTIVE=test
- JAVA_OPTS=-Xms2g -Xmx2g
volumes:
- ./lib:/app/lib # 挂载主机的lib目录到容器内的lib目录
- ./log:/app/log # 挂载日志目录到主机
restart: always
- ./log:/app/log # Mount log directory to host
restart: unless-stopped
healthcheck:
# Maybe use actuator later
test:
[
"CMD",
"wget",
"--no-verbose",
"--tries=1",
"--spider",
"http://localhost:7002/api/health",
]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
networks:
- peipei-network
networks:
peipei-network:
driver: bridge
# volumes:
# mysql_data:

View File

@@ -1,18 +0,0 @@
#!/bin/sh
# 拉取日志脚本
set -e
# 获取当前时间并格式化为指定格式
current_time=$(date +"%Y-%m-%d %H:%M:%S")
echo "开始拉取日志,当前时间是:$current_time"
# 创建本地log目录如果不存在
mkdir -p ./log
# 从远程服务器拉取整个log文件夹
echo "正在从远程服务器拉取日志文件..."
scp -r root@122.51.20.105:/www/wwwroot/july.hucs.top/log/* ./log/
# 获取当前时间并格式化为指定格式
current_time=$(date +"%Y-%m-%d %H:%M:%S")
echo "日志拉取完成,当前时间是:$current_time"

View File

@@ -93,11 +93,6 @@
<artifactId>jave-core</artifactId>
</dependency>
<dependency>
<groupId>ws.schild</groupId>
<artifactId>jave-nativebin-linux64</artifactId>
</dependency>
<dependency>
<groupId>com.github.binarywang</groupId>
<artifactId>weixin-java-pay</artifactId>
@@ -134,6 +129,23 @@
<version>0.2.0</version>
</dependency>
<!--JUnit 5 for testing-->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
@@ -157,23 +169,22 @@
<artifactId>flyway-maven-plugin</artifactId>
<version>7.15.0</version>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.0.0-M7</version>
<configuration>
<useSystemClassLoader>false</useSystemClassLoader>
</configuration>
</plugin>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<version>2.7.9</version>
<configuration>
<layout>ZIP</layout>
<includes>
<include>
<groupId>nothing</groupId>
<artifactId>nothing</artifactId>
</include>
</includes>
</configuration>
<executions>
<execution>
<goals>
<goal>repackage</goal> <!-- 将引入的 jar 打入其中 -->
<goal>repackage</goal>
</goals>
</execution>
</executions>

View File

@@ -0,0 +1,64 @@
package com.starry.admin.common.config;
import java.util.Arrays;
import java.util.Collections;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.filter.CorsFilter;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
/**
* 跨域配置
*
* @author admin
*/
@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOriginPatterns("*")
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedHeaders("*")
.allowCredentials(true)
.exposedHeaders("*")
.maxAge(3600);
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
// 允许所有域名进行跨域调用
configuration.setAllowedOriginPatterns(Collections.singletonList("*"));
// 允许所有请求头
configuration.setAllowedHeaders(Collections.singletonList("*"));
// 允许所有HTTP方法
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH", "HEAD"));
// 允许发送Cookie
configuration.setAllowCredentials(true);
// 暴露哪些头部信息(因为跨域访问默认不能获取全部头部信息)
configuration.setExposedHeaders(Arrays.asList("*"));
// 预检请求的缓存时间(秒)
configuration.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
@Bean
public CorsFilter corsFilter() {
return new CorsFilter(corsConfigurationSource());
}
}

View File

@@ -0,0 +1,207 @@
package com.starry.admin.common.filter;
import com.starry.admin.modules.clerk.module.entity.PlayClerkUserInfoEntity;
import com.starry.admin.modules.clerk.service.impl.PlayClerkUserInfoServiceImpl;
import com.starry.admin.modules.custom.module.entity.PlayCustomUserInfoEntity;
import com.starry.admin.modules.custom.service.impl.PlayCustomUserInfoServiceImpl;
import com.starry.admin.modules.weichat.service.WxTokenService;
import com.starry.common.context.CustomSecurityContextHolder;
import java.io.IOException;
import java.util.UUID;
import javax.annotation.Resource;
import javax.servlet.*;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.slf4j.MDC;
import org.springframework.context.annotation.Lazy;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
/**
* 请求关联ID过滤器为每个HTTP请求生成唯一的跟踪ID
* 用于日志关联和请求链路追踪
*
* @author Claude
*/
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class CorrelationFilter implements Filter {
@Resource
@Lazy
private WxTokenService wxTokenService;
@Resource
@Lazy
private PlayClerkUserInfoServiceImpl clerkUserInfoService;
@Resource
@Lazy
private PlayCustomUserInfoServiceImpl customUserInfoService;
public static final String CORRELATION_ID_HEADER = "X-Correlation-ID";
public static final String CORRELATION_ID_MDC_KEY = "correlationId";
public static final String USER_ID_MDC_KEY = "userId";
public static final String TENANT_ID_MDC_KEY = "tenantId";
public static final String TENANT_KEY_MDC_KEY = "tenantKey";
public static final String REQUEST_URI_MDC_KEY = "requestUri";
public static final String REQUEST_METHOD_MDC_KEY = "requestMethod";
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
if (!(request instanceof HttpServletRequest)) {
chain.doFilter(request, response);
return;
}
HttpServletRequest httpRequest = (HttpServletRequest) request;
HttpServletResponse httpResponse = (HttpServletResponse) response;
try {
// 生成或获取关联ID
String correlationId = getOrGenerateCorrelationId(httpRequest);
// 设置MDC上下文
MDC.put(CORRELATION_ID_MDC_KEY, correlationId);
MDC.put(REQUEST_URI_MDC_KEY, httpRequest.getRequestURI());
MDC.put(REQUEST_METHOD_MDC_KEY, httpRequest.getMethod());
// 提取并设置租户信息
String tenantKey = httpRequest.getHeader("tenantkey");
if (tenantKey != null && !tenantKey.trim().isEmpty()) {
MDC.put(TENANT_KEY_MDC_KEY, tenantKey);
}
// 尝试从安全上下文获取租户ID
try {
String tenantId = CustomSecurityContextHolder.getTenantId();
if (tenantId != null && !tenantId.trim().isEmpty() && !"9999".equals(tenantId)) {
MDC.put(TENANT_ID_MDC_KEY, tenantId);
}
} catch (Exception e) {
// 忽略获取租户ID的异常
}
// 尝试获取用户ID可能来自JWT或其他认证信息
String userId = extractUserId(httpRequest);
if (userId != null) {
MDC.put(USER_ID_MDC_KEY, userId);
}
// 将关联ID添加到响应头
httpResponse.setHeader(CORRELATION_ID_HEADER, correlationId);
// 继续过滤器链
chain.doFilter(request, response);
} finally {
// 清理MDC上下文
MDC.clear();
}
}
/**
* 获取或生成关联ID
* 优先从请求头获取,如果没有则生成新的
*/
private String getOrGenerateCorrelationId(HttpServletRequest request) {
String correlationId = request.getHeader(CORRELATION_ID_HEADER);
if (correlationId == null || correlationId.trim().isEmpty()) {
correlationId = "REQ-" + UUID.randomUUID().toString().substring(0, 8);
}
return correlationId;
}
/**
* 尝试从请求中提取用户ID
* 根据项目的认证机制:支持微信端(clerk/custom token)和管理端(Authorization header)
*/
private String extractUserId(HttpServletRequest request) {
try {
// 1. 微信端 - Clerk用户认证
String clerkToken = request.getHeader("clerkusertoken");
if (clerkToken != null && !clerkToken.trim().isEmpty()) {
return extractUserIdFromWxToken(clerkToken, "clerk");
}
// 2. 微信端 - Custom用户认证
String customToken = request.getHeader("customusertoken");
if (customToken != null && !customToken.trim().isEmpty()) {
return extractUserIdFromWxToken(customToken, "custom");
}
// 3. 管理端 - JWT Bearer token认证
String authHeader = request.getHeader("Authorization");
if (authHeader != null && authHeader.startsWith("Bearer ")) {
return extractUserIdFromJwtToken(authHeader);
}
} catch (Exception e) {
// 如果解析失败不影响主流程只是记录日志时没有用户ID
// 可以选择记录debug日志但不抛异常
}
return null;
}
/**
* 从微信token中提取真实用户ID
*/
private String extractUserIdFromWxToken(String token, String userType) {
try {
// 使用WxTokenService解析JWT token获取真实用户ID
String userId = wxTokenService.getWxUserIdByToken(token);
if (userId != null) {
// 根据用户类型获取更多用户信息(可选)
if ("clerk".equals(userType)) {
PlayClerkUserInfoEntity clerkUser = clerkUserInfoService.selectById(userId);
if (clerkUser != null) {
// 返回格式: clerk_userId 或者可以包含昵称等信息
return "clerk_" + userId;
}
} else if ("custom".equals(userType)) {
PlayCustomUserInfoEntity customUser = customUserInfoService.selectById(userId);
if (customUser != null) {
return "custom_" + userId;
}
}
// 如果查询用户详情失败至少返回基础用户ID
return userType + "_" + userId;
}
} catch (Exception e) {
// Token解析失败可能是过期或无效token不影响主流程
}
return null;
}
/**
* 从管理端JWT token中提取用户ID
*/
private String extractUserIdFromJwtToken(String authHeader) {
try {
// 管理端的JWT解析比较复杂这里先返回标识
// 实际应该通过JwtToken组件或SecurityUtils获取当前登录用户信息
return "admin_user";
} catch (Exception e) {
return null;
}
}
@Override
public void init(FilterConfig filterConfig) throws ServletException {
// 初始化逻辑
}
@Override
public void destroy() {
// 清理逻辑
}
}

View File

@@ -24,9 +24,6 @@ import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
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;
/**
* @author admin
@@ -62,7 +59,7 @@ public class SpringSecurityConfig extends WebSecurityConfigurerAdapter {
.antMatchers("/health", "/health/**").permitAll()
// 跨域请求会先进行一次options请求
.antMatchers(HttpMethod.OPTIONS).permitAll().anyRequest()// 除上面外的所有请求全部需要鉴权认证
.authenticated().and().cors().configurationSource(this.corsConfigurationSource());
.authenticated().and().cors();
// 禁用缓存
httpSecurity.headers().cacheControl();
// 添加Logout filter
@@ -74,18 +71,6 @@ public class SpringSecurityConfig extends WebSecurityConfigurerAdapter {
.authenticationEntryPoint(customAuthenticationEntryPoint);
}
private CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration corsConfiguration = new CorsConfiguration();
corsConfiguration.setAllowCredentials(true);
corsConfiguration.addAllowedHeader("*"); // 这个得加上一些复杂的请求方式会带有header不加上跨域会失效。
corsConfiguration.addAllowedMethod("*");
corsConfiguration.addExposedHeader("*");
corsConfiguration.addAllowedOriginPattern("*");
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", corsConfiguration);
return source;
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsService()).passwordEncoder(passwordEncoder());

View File

@@ -1,44 +1,194 @@
package com.starry.admin.modules.order.module.constant;
import lombok.Getter;
/**
* 订单相关枚举和常量
*
* @author admin
* @since 2024/5/8 15:41
**/
*/
public class OrderConstant {
/**
* 订单状态-待接单
*
* @since 2024/5/8 15:42
**/
* 订单状态枚举
*/
@Getter
public enum OrderStatus {
PENDING("0", "已下单(待接单)"),
ACCEPTED("1", "已接单(待开始)"),
IN_PROGRESS("2", "已开始(服务中)"),
COMPLETED("3", "已完成"),
CANCELLED("4", "已取消");
private final String code;
private final String description;
OrderStatus(String code, String description) {
this.code = code;
this.description = description;
}
public static OrderStatus fromCode(String code) {
for (OrderStatus status : values()) {
if (status.code.equals(code)) {
return status;
}
}
throw new IllegalArgumentException("Unknown order status code: " + code);
}
}
/**
* 订单类型枚举
*/
@Getter
public enum OrderType {
REFUND("-1", "退款订单"),
RECHARGE("0", "充值订单"),
WITHDRAWAL("1", "提现订单"),
NORMAL("2", "普通订单");
private final String code;
private final String description;
OrderType(String code, String description) {
this.code = code;
this.description = description;
}
public static OrderType fromCode(String code) {
for (OrderType type : values()) {
if (type.code.equals(code)) {
return type;
}
}
throw new IllegalArgumentException("Unknown order type code: " + code);
}
}
/**
* 下单类型枚举
*/
@Getter
public enum PlaceType {
OTHER("-1", "其他类型"),
SPECIFIED("0", "指定单"),
RANDOM("1", "随机单"),
REWARD("2", "打赏单");
private final String code;
private final String description;
PlaceType(String code, String description) {
this.code = code;
this.description = description;
}
public static PlaceType fromCode(String code) {
for (PlaceType type : values()) {
if (type.code.equals(code)) {
return type;
}
}
throw new IllegalArgumentException("Unknown place type code: " + code);
}
}
/**
* 性别枚举
*/
@Getter
public enum Gender {
UNKNOWN("0", "未知"),
MALE("1", ""),
FEMALE("2", "");
private final String code;
private final String description;
Gender(String code, String description) {
this.code = code;
this.description = description;
}
public static Gender fromCode(String code) {
for (Gender gender : values()) {
if (gender.code.equals(code)) {
return gender;
}
}
throw new IllegalArgumentException("Unknown gender code: " + code);
}
}
/**
* 打赏类型枚举
*/
@Getter
public enum RewardType {
NOT_APPLICABLE("", "非打赏订单"),
BALANCE("0", "余额打赏"),
GIFT("1", "礼物打赏");
private final String code;
private final String description;
RewardType(String code, String description) {
this.code = code;
this.description = description;
}
public static RewardType fromCode(String code) {
// Handle null, empty string, or whitespace as NOT_APPLICABLE
if (code == null || code.trim().isEmpty()) {
return NOT_APPLICABLE;
}
for (RewardType type : values()) {
if (code.equals(type.code)) {
return type;
}
}
throw new IllegalArgumentException("Unknown reward type code: " + code);
}
}
/**
* 商品类型枚举
*/
@Getter
public enum CommodityType {
GIFT("0", "礼物"),
SERVICE("1", "服务");
private final String code;
private final String description;
CommodityType(String code, String description) {
this.code = code;
this.description = description;
}
public static CommodityType fromCode(String code) {
for (CommodityType type : values()) {
if (type.code.equals(code)) {
return type;
}
}
throw new IllegalArgumentException("Unknown commodity type code: " + code);
}
}
// Legacy constants for backward compatibility - consider deprecating
@Deprecated
public final static String ORDER_STATUS_0 = "0";
/**
* 订单状态-待开始
*
* @since 2024/5/8 15:42
**/
@Deprecated
public final static String ORDER_STATUS_1 = "1";
/**
* 订单状态-服务中
*
* @since 2024/5/8 15:42
**/
@Deprecated
public final static String ORDER_STATUS_2 = "2";
/**
* 订单状态-已完成
*
* @since 2024/5/8 15:42
**/
@Deprecated
public final static String ORDER_STATUS_3 = "3";
/**
* 订单状态-已取消
*
* @since 2024/5/8 15:42
**/
@Deprecated
public final static String ORDER_STATUS_4 = "4";
}

View File

@@ -0,0 +1,45 @@
package com.starry.admin.modules.order.module.dto;
import com.starry.admin.modules.order.module.constant.OrderConstant;
import java.math.BigDecimal;
import lombok.Builder;
import lombok.Data;
/**
* 商品信息值对象
*
* @author admin
*/
@Data
@Builder
public class CommodityInfo {
/**
* 商品ID
*/
private String commodityId;
/**
* 商品类型
*/
private OrderConstant.CommodityType commodityType;
/**
* 商品单价
*/
private BigDecimal commodityPrice;
/**
* 商品属性-服务时长
*/
private String serviceDuration;
/**
* 商品名称
*/
private String commodityName;
/**
* 商品数量
*/
private String commodityNumber;
}

View File

@@ -0,0 +1,128 @@
package com.starry.admin.modules.order.module.dto;
import com.starry.admin.modules.order.module.constant.OrderConstant;
import com.starry.admin.modules.order.module.constant.OrderConstant.RewardType;
import javax.validation.Valid;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import lombok.Builder;
import lombok.Data;
/**
* 订单创建请求对象 - 使用Builder模式替换20+参数的方法
*
* @author admin
*/
@Data
@Builder
public class OrderCreationRequest {
/**
* 订单ID
*/
@NotBlank(message = "订单ID不能为空")
private String orderId;
/**
* 订单编号
*/
@NotBlank(message = "订单编号不能为空")
private String orderNo;
/**
* 订单状态
*/
@NotNull(message = "订单状态不能为空")
private OrderConstant.OrderStatus orderStatus;
/**
* 订单类型
*/
@NotNull(message = "订单类型不能为空")
private OrderConstant.OrderType orderType;
/**
* 下单类型
*/
@NotNull(message = "下单类型不能为空")
private OrderConstant.PlaceType placeType;
/**
* 打赏类型0:余额;1:礼物)
*/
private RewardType rewardType;
/**
* 是否是首单
*/
private boolean isFirstOrder;
/**
* 商品信息
*/
@Valid
@NotNull(message = "商品信息不能为空")
private CommodityInfo commodityInfo;
/**
* 支付信息
*/
@Valid
@NotNull(message = "支付信息不能为空")
private PaymentInfo paymentInfo;
/**
* 下单人
*/
@NotBlank(message = "下单人不能为空")
private String purchaserBy;
/**
* 接单人(可选)
*/
private String acceptBy;
/**
* 微信号码
*/
private String weiChatCode;
/**
* 订单备注
*/
private String remark;
/**
* 随机单要求(仅随机单时需要)
*/
private RandomOrderRequirements randomOrderRequirements;
/**
* 获取首单标识字符串(兼容现有系统)
*/
public String getFirstOrderString() {
return isFirstOrder ? "1" : "0";
}
/**
* 验证随机单要求
*/
public boolean isValidForRandomOrder() {
return placeType == OrderConstant.PlaceType.RANDOM
&& randomOrderRequirements != null;
}
/**
* 是否为打赏单
*/
public boolean isRewardOrder() {
return placeType == OrderConstant.PlaceType.REWARD;
}
/**
* 是否为指定单
*/
public boolean isSpecifiedOrder() {
return placeType == OrderConstant.PlaceType.SPECIFIED;
}
}

View File

@@ -0,0 +1,40 @@
package com.starry.admin.modules.order.module.dto;
import java.math.BigDecimal;
import java.util.List;
import lombok.Builder;
import lombok.Data;
/**
* 支付信息值对象
*
* @author admin
*/
@Data
@Builder
public class PaymentInfo {
/**
* 订单金额
*/
private BigDecimal orderMoney;
/**
* 订单最终金额(支付金额)
*/
private BigDecimal finalAmount;
/**
* 优惠金额
*/
private BigDecimal discountAmount;
/**
* 优惠券ID列表
*/
private List<String> couponIds;
/**
* 支付方式0余额支付,1:微信支付,2:支付宝支付
*/
private String payMethod;
}

View File

@@ -0,0 +1,36 @@
package com.starry.admin.modules.order.module.dto;
import com.starry.admin.modules.order.module.constant.OrderConstant;
import lombok.Builder;
import lombok.Data;
/**
* 随机单要求信息值对象
*
* @author admin
*/
@Data
@Builder
public class RandomOrderRequirements {
/**
* 随机单要求-店员性别
*/
private OrderConstant.Gender clerkGender;
/**
* 随机单要求-店员等级ID
*/
private String clerkLevelId;
/**
* 随机单要求-是否排除下单过的成员0:不排除;1:排除)
*/
private String excludeHistory;
/**
* 是否排除历史订单
*/
public boolean shouldExcludeHistory() {
return "1".equals(excludeHistory);
}
}

View File

@@ -2,6 +2,7 @@ package com.starry.admin.modules.order.service;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.service.IService;
import com.starry.admin.modules.order.module.dto.*;
import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity;
import com.starry.admin.modules.order.module.vo.*;
import com.starry.admin.modules.weichat.entity.order.*;
@@ -43,8 +44,18 @@ public interface IPlayOrderInfoService extends IService<PlayOrderInfoEntity> {
void createRechargeOrder(String orderNo, BigDecimal orderMoney, BigDecimal finalAmount, String purchaserBy);
/**
* 新增订单信息
* 新增订单信息 - 重构版本使用Builder模式
*
* @param request 订单创建请求对象
* @author admin
* @since 2024/6/3 10:53
**/
void createOrderInfo(OrderCreationRequest request);
/**
* 新增订单信息 - 旧版本方法已废弃建议使用OrderCreationRequest
*
* @deprecated 请使用 {@link #createOrderInfo(OrderCreationRequest)} 替代
* @param orderId
* 订单ID
* @param orderNo
@@ -96,6 +107,7 @@ public interface IPlayOrderInfoService extends IService<PlayOrderInfoEntity> {
* @author admin
* @since 2024/6/3 10:53
**/
@Deprecated
void createOrderInfo(String orderId, String orderNo, String orderState, String orderType, String placeType,
String rewardType, String firstOrder, String commodityId, String commodityType, BigDecimal commodityPrice,
String serviceDuration, String commodityName, String commodityNumber, BigDecimal orderMoney,

View File

@@ -19,6 +19,7 @@ import com.starry.admin.modules.custom.module.entity.PlayCustomUserInfoEntity;
import com.starry.admin.modules.custom.service.IPlayCustomUserInfoService;
import com.starry.admin.modules.order.mapper.PlayOrderInfoMapper;
import com.starry.admin.modules.order.module.constant.OrderConstant;
import com.starry.admin.modules.order.module.dto.*;
import com.starry.admin.modules.order.module.entity.PlayOrderComplaintInfoEntity;
import com.starry.admin.modules.order.module.entity.PlayOrderEvaluateInfoEntity;
import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity;
@@ -158,6 +159,132 @@ public class PlayOrderInfoServiceImpl extends ServiceImpl<PlayOrderInfoMapper, P
playCouponDetailsService.updateCouponUseStateByIds(couponIds, "2");
}
@Override
public void createOrderInfo(OrderCreationRequest request) {
// 验证请求
validateOrderCreationRequest(request);
PlayOrderInfoEntity entity = buildOrderEntity(request);
// 处理随机单要求
if (request.getPlaceType() == OrderConstant.PlaceType.RANDOM) {
setRandomOrderRequirements(entity, request.getRandomOrderRequirements());
}
// 处理接单人信息
if (StrUtil.isNotBlank(request.getAcceptBy())) {
setAcceptByInfo(entity, request);
}
// 处理打赏单自动完成逻辑
if (request.isRewardOrder()) {
setRewardOrderCompleted(entity);
}
// 保存订单
userInfoService.saveOrderInfo(entity);
this.baseMapper.insert(entity);
// 修改优惠券状态
playCouponDetailsService.updateCouponUseStateByIds(
request.getPaymentInfo().getCouponIds(), "2");
}
/**
* 验证订单创建请求
*/
private void validateOrderCreationRequest(OrderCreationRequest request) {
if (request.getPlaceType() == OrderConstant.PlaceType.RANDOM
&& !request.isValidForRandomOrder()) {
throw new CustomException("随机单必须提供店员要求信息");
}
}
/**
* 构建订单实体
*/
private PlayOrderInfoEntity buildOrderEntity(OrderCreationRequest request) {
PlayOrderInfoEntity entity = new PlayOrderInfoEntity();
// 基本信息
entity.setId(request.getOrderId());
entity.setOrderNo(request.getOrderNo());
entity.setOrderStatus(request.getOrderStatus().getCode());
entity.setOrderType(request.getOrderType().getCode());
entity.setPlaceType(request.getPlaceType().getCode());
entity.setRewardType(request.getRewardType().getCode());
entity.setFirstOrder(request.getFirstOrderString());
// 固定默认值
entity.setRefundType("0");
entity.setBackendEntry("0");
entity.setPayMethod("0");
entity.setOrderSettlementState("0");
entity.setOrdersExpiredState("0");
// 商品信息
CommodityInfo commodityInfo = request.getCommodityInfo();
entity.setCommodityId(commodityInfo.getCommodityId());
entity.setCommodityType(commodityInfo.getCommodityType().getCode());
entity.setCommodityPrice(commodityInfo.getCommodityPrice());
entity.setServiceDuration(commodityInfo.getServiceDuration());
entity.setCommodityName(commodityInfo.getCommodityName());
entity.setCommodityNumber(commodityInfo.getCommodityNumber());
// 支付信息
PaymentInfo paymentInfo = request.getPaymentInfo();
entity.setOrderMoney(paymentInfo.getOrderMoney());
entity.setFinalAmount(paymentInfo.getFinalAmount());
entity.setDiscountAmount(paymentInfo.getDiscountAmount());
entity.setCouponIds(paymentInfo.getCouponIds());
entity.setUseCoupon(
paymentInfo.getCouponIds() != null && !paymentInfo.getCouponIds().isEmpty() ? "1" : "0");
// 用户信息
entity.setPurchaserBy(request.getPurchaserBy());
entity.setPurchaserTime(LocalDateTime.now());
entity.setWeiChatCode(request.getWeiChatCode());
entity.setRemark(request.getRemark());
return entity;
}
/**
* 设置随机单要求
*/
private void setRandomOrderRequirements(PlayOrderInfoEntity entity, RandomOrderRequirements requirements) {
if (requirements != null) {
entity.setSex(requirements.getClerkGender().getCode());
entity.setLevelId(requirements.getClerkLevelId());
entity.setExcludeHistory(requirements.getExcludeHistory());
}
}
/**
* 设置接单人信息
*/
private void setAcceptByInfo(PlayOrderInfoEntity entity, OrderCreationRequest request) {
entity.setAcceptBy(request.getAcceptBy());
ClerkEstimatedRevenueVo estimatedRevenueVo = getClerkEstimatedRevenue(
request.getAcceptBy(),
request.getPaymentInfo().getCouponIds(),
request.getPlaceType().getCode(),
request.getFirstOrderString(),
request.getPaymentInfo().getFinalAmount());
entity.setEstimatedRevenue(estimatedRevenueVo.getRevenueAmount());
entity.setEstimatedRevenueRatio(estimatedRevenueVo.getRevenueRatio());
}
/**
* 设置打赏单为已完成状态
*/
private void setRewardOrderCompleted(PlayOrderInfoEntity entity) {
LocalDateTime now = LocalDateTime.now();
entity.setAcceptTime(now);
entity.setOrderStartTime(now);
entity.setOrderEndTime(now);
}
@Override
public ClerkEstimatedRevenueVo getClerkEstimatedRevenue(String clerkId, List<String> croupIds, String placeType,
String firstOrder, BigDecimal finalAmount) {

View File

@@ -19,6 +19,12 @@ import com.starry.admin.modules.custom.service.IPlayCustomFollowInfoService;
import com.starry.admin.modules.custom.service.IPlayCustomGiftInfoService;
import com.starry.admin.modules.custom.service.IPlayCustomLeaveMsgService;
import com.starry.admin.modules.custom.service.IPlayCustomUserInfoService;
import com.starry.admin.modules.order.module.constant.OrderConstant;
import com.starry.admin.modules.order.module.constant.OrderConstant.RewardType;
import com.starry.admin.modules.order.module.dto.CommodityInfo;
import com.starry.admin.modules.order.module.dto.OrderCreationRequest;
import com.starry.admin.modules.order.module.dto.PaymentInfo;
import com.starry.admin.modules.order.module.dto.RandomOrderRequirements;
import com.starry.admin.modules.order.module.entity.PlayOrderComplaintInfoEntity;
import com.starry.admin.modules.order.module.entity.PlayOrderEvaluateInfoEntity;
import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity;
@@ -224,7 +230,34 @@ public class WxCustomController {
}
String orderId = IdUtils.getUuid();
// 记录订单信息
playOrderInfoService.createOrderInfo(orderId, playOrderInfoService.getOrderNo(), "3", "2", "2", "0", "1", "", "0", BigDecimal.ZERO, "", "", "0", new BigDecimal(vo.getMoney()), new BigDecimal(vo.getMoney()), BigDecimal.ZERO, userId, vo.getClerkId(), vo.getWeiChatCode(), new ArrayList<>(), vo.getRemark(), "", "", "");
OrderCreationRequest orderRequest = OrderCreationRequest.builder()
.orderId(orderId)
.orderNo(playOrderInfoService.getOrderNo())
.orderStatus(OrderConstant.OrderStatus.COMPLETED)
.orderType(OrderConstant.OrderType.NORMAL)
.placeType(OrderConstant.PlaceType.REWARD)
.rewardType(RewardType.BALANCE)
.isFirstOrder(true)
.commodityInfo(CommodityInfo.builder()
.commodityId("")
.commodityType(OrderConstant.CommodityType.GIFT)
.commodityPrice(BigDecimal.ZERO)
.serviceDuration("")
.commodityName("")
.commodityNumber("0")
.build())
.paymentInfo(PaymentInfo.builder()
.orderMoney(new BigDecimal(vo.getMoney()))
.finalAmount(new BigDecimal(vo.getMoney()))
.discountAmount(BigDecimal.ZERO)
.couponIds(new ArrayList<>())
.build())
.purchaserBy(userId)
.acceptBy(vo.getClerkId())
.weiChatCode(vo.getWeiChatCode())
.remark(vo.getRemark())
.build();
playOrderInfoService.createOrderInfo(orderRequest);
// 顾客减少余额
customUserInfoService.updateAccountBalanceById(customUserInfo.getId(), customUserInfo.getAccountBalance(), customUserInfo.getAccountBalance().subtract(new BigDecimal(vo.getMoney())), "1", "打赏", new BigDecimal(vo.getMoney()), BigDecimal.ZERO, orderId);
return R.ok("成功");
@@ -247,7 +280,34 @@ public class WxCustomController {
}
String orderId = IdUtils.getUuid();
// 记录订单信息
playOrderInfoService.createOrderInfo(orderId, playOrderInfoService.getOrderNo(), "3", "2", "2", "1", "1", giftInfo.getId(), "0", giftInfo.getPrice(), "", giftInfo.getName(), String.valueOf(vo.getGiftQuantity()), money, money, BigDecimal.ZERO, userId, vo.getClerkId(), vo.getWeiChatCode(), new ArrayList<>(), vo.getRemark(), "", "", "");
OrderCreationRequest orderRequest = OrderCreationRequest.builder()
.orderId(orderId)
.orderNo(playOrderInfoService.getOrderNo())
.orderStatus(OrderConstant.OrderStatus.COMPLETED)
.orderType(OrderConstant.OrderType.NORMAL)
.placeType(OrderConstant.PlaceType.REWARD)
.rewardType(RewardType.GIFT)
.isFirstOrder(true)
.commodityInfo(CommodityInfo.builder()
.commodityId(giftInfo.getId())
.commodityType(OrderConstant.CommodityType.GIFT)
.commodityPrice(giftInfo.getPrice())
.serviceDuration("")
.commodityName(giftInfo.getName())
.commodityNumber(String.valueOf(vo.getGiftQuantity()))
.build())
.paymentInfo(PaymentInfo.builder()
.orderMoney(money)
.finalAmount(money)
.discountAmount(BigDecimal.ZERO)
.couponIds(new ArrayList<>())
.build())
.purchaserBy(userId)
.acceptBy(vo.getClerkId())
.weiChatCode(vo.getWeiChatCode())
.remark(vo.getRemark())
.build();
playOrderInfoService.createOrderInfo(orderRequest);
// 顾客减少余额
customUserInfoService.updateAccountBalanceById(customUserInfo.getId(), customUserInfo.getAccountBalance(), customUserInfo.getAccountBalance().subtract(money), "1", "赠送礼物", money, BigDecimal.ZERO, orderId);
// 陪聊增加余额
@@ -329,7 +389,34 @@ public class WxCustomController {
String orderId = IdUtils.getUuid();
String orderNo = playOrderInfoService.getOrderNo();
// 记录订单信息
playOrderInfoService.createOrderInfo(orderId, orderNo, "0", "2", "0", "", "1", commodityInfo.getCommodityId(), "1", commodityInfo.getCommodityPrice(), commodityInfo.getServiceDuration(), commodityInfo.getCommodityName(), String.valueOf(vo.getCommodityQuantity()), money, money, BigDecimal.ZERO, customId, clerkUserInfo.getId(), vo.getWeiChatCode(), vo.getCouponIds(), vo.getRemark(), "", "", "");
OrderCreationRequest orderRequest = OrderCreationRequest.builder()
.orderId(orderId)
.orderNo(orderNo)
.orderStatus(OrderConstant.OrderStatus.PENDING)
.orderType(OrderConstant.OrderType.NORMAL)
.placeType(OrderConstant.PlaceType.SPECIFIED)
.rewardType(RewardType.NOT_APPLICABLE)
.isFirstOrder(true)
.commodityInfo(CommodityInfo.builder()
.commodityId(commodityInfo.getCommodityId())
.commodityType(OrderConstant.CommodityType.SERVICE)
.commodityPrice(commodityInfo.getCommodityPrice())
.serviceDuration(commodityInfo.getServiceDuration())
.commodityName(commodityInfo.getCommodityName())
.commodityNumber(String.valueOf(vo.getCommodityQuantity()))
.build())
.paymentInfo(PaymentInfo.builder()
.orderMoney(money)
.finalAmount(money)
.discountAmount(BigDecimal.ZERO)
.couponIds(vo.getCouponIds())
.build())
.purchaserBy(customId)
.acceptBy(clerkUserInfo.getId())
.weiChatCode(vo.getWeiChatCode())
.remark(vo.getRemark())
.build();
playOrderInfoService.createOrderInfo(orderRequest);
// 顾客减少余额
customUserInfoService.updateAccountBalanceById(customUserInfo.getId(), customUserInfo.getAccountBalance(), customUserInfo.getAccountBalance().subtract(money), "1", "下单-指定单", money, BigDecimal.ZERO, orderId);
// 发送通知给店员
@@ -357,7 +444,38 @@ public class WxCustomController {
String orderId = IdUtils.getUuid();
String orderNo = playOrderInfoService.getOrderNo();
// 记录订单信息
playOrderInfoService.createOrderInfo(orderId, orderNo, "0", "2", "1", "", "1", commodityInfo.getCommodityId(), "1", commodityInfo.getCommodityPrice(), commodityInfo.getServiceDuration(), commodityInfo.getCommodityName(), String.valueOf(vo.getCommodityQuantity()), money, money, BigDecimal.ZERO, customId, "", vo.getWeiChatCode(), vo.getCouponIds(), vo.getRemark(), vo.getSex(), vo.getLevelId(), vo.getExcludeHistory());
OrderCreationRequest orderRequest = OrderCreationRequest.builder()
.orderId(orderId)
.orderNo(orderNo)
.orderStatus(OrderConstant.OrderStatus.PENDING)
.orderType(OrderConstant.OrderType.NORMAL)
.placeType(OrderConstant.PlaceType.RANDOM)
.rewardType(RewardType.NOT_APPLICABLE)
.isFirstOrder(true)
.commodityInfo(CommodityInfo.builder()
.commodityId(commodityInfo.getCommodityId())
.commodityType(OrderConstant.CommodityType.SERVICE)
.commodityPrice(commodityInfo.getCommodityPrice())
.serviceDuration(commodityInfo.getServiceDuration())
.commodityName(commodityInfo.getCommodityName())
.commodityNumber(String.valueOf(vo.getCommodityQuantity()))
.build())
.paymentInfo(PaymentInfo.builder()
.orderMoney(money)
.finalAmount(money)
.discountAmount(BigDecimal.ZERO)
.couponIds(vo.getCouponIds())
.build())
.purchaserBy(customId)
.weiChatCode(vo.getWeiChatCode())
.remark(vo.getRemark())
.randomOrderRequirements(RandomOrderRequirements.builder()
.clerkGender(OrderConstant.Gender.fromCode(vo.getSex()))
.clerkLevelId(vo.getLevelId())
.excludeHistory(vo.getExcludeHistory())
.build())
.build();
playOrderInfoService.createOrderInfo(orderRequest);
// 顾客减少余额
customUserInfoService.updateAccountBalanceById(customUserInfo.getId(), customUserInfo.getAccountBalance(), customUserInfo.getAccountBalance().subtract(money), "1", "下单-随机单", money, BigDecimal.ZERO, orderId);
// 给全部店员发送通知

View File

@@ -18,8 +18,8 @@ spring:
type: com.alibaba.druid.pool.DruidDataSource
# 配置MySQL的驱动程序类
driver-class-name: com.p6spy.engine.spy.P6SpyDriver
# 数据库连接地址(以MySql为例)
url: ${SPRING_DATASOURCE_URL:jdbc:p6spy:mysql://primary:3306/play-with?useUnicode=true&characterEncoding=utf8&characterSetResults=utf8}
# 数据库连接地址(以MySql为例) - Using Tailscale IP for Docker containers
url: ${SPRING_DATASOURCE_URL:jdbc:p6spy:mysql://100.80.201.143:3306/play-with?useUnicode=true&characterEncoding=utf8&characterSetResults=utf8}
# 数据库对应的用户名
username: ${SPRING_DATASOURCE_USERNAME:root}
# 数据库对应的密码
@@ -75,10 +75,11 @@ spring:
# HTML 中 Reset All 按钮
reset-enable: true
redis:
host: ${SPRING_REDIS_HOST:127.0.0.1} # Redis服务器地址
host: ${SPRING_REDIS_HOST:100.80.201.143} # Redis服务器地址 - Using Tailscale IP for Docker containers
database: ${SPRING_REDIS_DATABASE:10} # Redis数据库索引默认为0
port: ${SPRING_REDIS_PORT:6379} # Redis服务器连接端口
password: ${SPRING_REDIS_PASSWORD:Spinfo@0123}
username: ${SPRING_REDIS_USERNAME:test} # Redis用户名
password: ${SPRING_REDIS_PASSWORD:123456} # Redis密码
timeout: ${SPRING_REDIS_TIMEOUT:3000ms} # 连接超时时间(毫秒)
# 全局日志级别

View File

@@ -12,9 +12,9 @@
converterClass="org.springframework.boot.logging.logback.WhitespaceThrowableProxyConverter"/>
<conversionRule conversionWord="wEx"
converterClass="org.springframework.boot.logging.logback.ExtendedWhitespaceThrowableProxyConverter"/>
<!-- 彩色日志格式 -->
<!-- 彩色日志格式 - 包含关联ID -->
<property name="CONSOLE_LOG_PATTERN"
value="${CONSOLE_LOG_PATTERN:-%clr(%d{yyyy-MM-dd HH:mm:ss.SSS}){faint} %clr(${LOG_LEVEL_PATTERN:-%5p}) %clr(${PID:- }){magenta} %clr(---){faint} %clr([%15.15t]){faint} %clr(%-40.40logger{39}){cyan} %clr(:){faint} %m%n${LOG_EXCEPTION_CONVERSION_WORD:-%wEx}}"/>
value="${CONSOLE_LOG_PATTERN:-%clr(%d{yyyy-MM-dd HH:mm:ss.SSS}){faint} %clr(${LOG_LEVEL_PATTERN:-%5p}) %clr(${PID:- }){magenta} %clr(---){faint} %clr([%15.15t]){faint} %clr([%X{correlationId:--}]){yellow} %clr([%X{tenantKey:--}]){blue} %clr([%X{userId:--}]){green} %clr(%-40.40logger{39}){cyan} %clr(:){faint} %m%n${LOG_EXCEPTION_CONVERSION_WORD:-%wEx}}"/>
<!--输出到控制台-->
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
@@ -40,7 +40,7 @@
<maxHistory>7</maxHistory>
</rollingPolicy>
<encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
<pattern>%d{MM/dd/yyyy HH:mm:ss} %-5level [%thread]%logger{16} - %msg%n</pattern>
<pattern>%d{MM/dd/yyyy HH:mm:ss} %-5level [%thread] [%X{correlationId:--}] [%X{tenantKey:--}] [%X{userId:--}] %logger{16} - %msg%n</pattern>
<charset>UTF-8</charset> <!-- 设置字符集 -->
</encoder>
</appender>
@@ -51,7 +51,7 @@
<file>${log.path}/error.log</file>
<!--日志文件输出格式-->
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n</pattern>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] [%X{correlationId:--}] [%X{tenantKey:--}] [%X{userId:--}] %-5level %logger{50} - %msg%n</pattern>
<charset>UTF-8</charset> <!-- 此处设置字符集 -->
</encoder>
<!-- 日志记录器的滚动策略,按日期,按大小记录 -->

View File

@@ -0,0 +1,211 @@
package com.starry.admin.modules.order.service;
import static org.junit.jupiter.api.Assertions.*;
import com.starry.admin.modules.order.module.constant.OrderConstant;
import com.starry.admin.modules.order.module.dto.CommodityInfo;
import com.starry.admin.modules.order.module.dto.OrderCreationRequest;
import com.starry.admin.modules.order.module.dto.PaymentInfo;
import com.starry.admin.modules.order.module.dto.RandomOrderRequirements;
import java.math.BigDecimal;
import java.util.Arrays;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
/**
* 订单创建请求对象测试类
*
* @author admin
*/
class OrderCreationRequestTest {
@Test
@DisplayName("测试Builder模式构建订单请求")
void testBuilderPattern() {
// 构建商品信息
CommodityInfo commodityInfo = CommodityInfo.builder()
.commodityId("commodity_001")
.commodityType(OrderConstant.CommodityType.SERVICE)
.commodityPrice(new BigDecimal("100.00"))
.serviceDuration("60")
.commodityName("陪聊服务")
.commodityNumber("1")
.build();
// 构建支付信息
PaymentInfo paymentInfo = PaymentInfo.builder()
.orderMoney(new BigDecimal("100.00"))
.finalAmount(new BigDecimal("90.00"))
.discountAmount(new BigDecimal("10.00"))
.couponIds(Arrays.asList("coupon_001"))
.payMethod("0")
.build();
// 构建订单请求
OrderCreationRequest request = OrderCreationRequest.builder()
.orderId("order_123456")
.orderNo("ORD20240906001")
.orderStatus(OrderConstant.OrderStatus.PENDING)
.orderType(OrderConstant.OrderType.NORMAL)
.placeType(OrderConstant.PlaceType.SPECIFIED)
.rewardType(OrderConstant.RewardType.BALANCE)
.isFirstOrder(true)
.commodityInfo(commodityInfo)
.paymentInfo(paymentInfo)
.purchaserBy("customer_001")
.acceptBy("clerk_001")
.weiChatCode("wx123456")
.remark("客户备注信息")
.build();
// 验证构建结果
assertEquals("order_123456", request.getOrderId());
assertEquals("ORD20240906001", request.getOrderNo());
assertEquals(OrderConstant.OrderStatus.PENDING, request.getOrderStatus());
assertEquals(OrderConstant.OrderType.NORMAL, request.getOrderType());
assertEquals(OrderConstant.PlaceType.SPECIFIED, request.getPlaceType());
assertTrue(request.isFirstOrder());
assertEquals("1", request.getFirstOrderString());
// 验证商品信息
assertNotNull(request.getCommodityInfo());
assertEquals("commodity_001", request.getCommodityInfo().getCommodityId());
assertEquals(new BigDecimal("100.00"), request.getCommodityInfo().getCommodityPrice());
// 验证支付信息
assertNotNull(request.getPaymentInfo());
assertEquals(new BigDecimal("90.00"), request.getPaymentInfo().getFinalAmount());
assertEquals(1, request.getPaymentInfo().getCouponIds().size());
}
@Test
@DisplayName("测试订单类型判断方法")
void testOrderTypeChecks() {
// 测试指定单
OrderCreationRequest specifiedOrder = OrderCreationRequest.builder()
.orderId("order_001")
.orderNo("ORD001")
.orderStatus(OrderConstant.OrderStatus.PENDING)
.orderType(OrderConstant.OrderType.NORMAL)
.placeType(OrderConstant.PlaceType.SPECIFIED)
.isFirstOrder(false)
.commodityInfo(CommodityInfo.builder().commodityId("test").build())
.paymentInfo(PaymentInfo.builder().orderMoney(BigDecimal.ZERO).build())
.purchaserBy("customer")
.build();
assertTrue(specifiedOrder.isSpecifiedOrder());
assertFalse(specifiedOrder.isValidForRandomOrder());
assertFalse(specifiedOrder.isRewardOrder());
// 测试随机单
OrderCreationRequest randomOrder = OrderCreationRequest.builder()
.orderId("order_002")
.orderNo("ORD002")
.orderStatus(OrderConstant.OrderStatus.PENDING)
.orderType(OrderConstant.OrderType.NORMAL)
.placeType(OrderConstant.PlaceType.RANDOM)
.isFirstOrder(false)
.commodityInfo(CommodityInfo.builder().commodityId("test").build())
.paymentInfo(PaymentInfo.builder().orderMoney(BigDecimal.ZERO).build())
.purchaserBy("customer")
.randomOrderRequirements(RandomOrderRequirements.builder()
.clerkGender(OrderConstant.Gender.FEMALE)
.clerkLevelId("level_001")
.excludeHistory("1")
.build())
.build();
assertFalse(randomOrder.isSpecifiedOrder());
assertTrue(randomOrder.isValidForRandomOrder());
assertFalse(randomOrder.isRewardOrder());
// 测试打赏单
OrderCreationRequest rewardOrder = OrderCreationRequest.builder()
.orderId("order_003")
.orderNo("ORD003")
.orderStatus(OrderConstant.OrderStatus.PENDING)
.orderType(OrderConstant.OrderType.NORMAL)
.placeType(OrderConstant.PlaceType.REWARD)
.isFirstOrder(false)
.commodityInfo(CommodityInfo.builder().commodityId("test").build())
.paymentInfo(PaymentInfo.builder().orderMoney(BigDecimal.ZERO).build())
.purchaserBy("customer")
.build();
assertFalse(rewardOrder.isSpecifiedOrder());
assertFalse(rewardOrder.isValidForRandomOrder());
assertTrue(rewardOrder.isRewardOrder());
}
@Test
@DisplayName("测试首单标识转换")
void testFirstOrderStringConversion() {
// 测试首单
OrderCreationRequest firstOrder = OrderCreationRequest.builder()
.orderId("order_001")
.orderNo("ORD001")
.orderStatus(OrderConstant.OrderStatus.PENDING)
.orderType(OrderConstant.OrderType.NORMAL)
.placeType(OrderConstant.PlaceType.SPECIFIED)
.isFirstOrder(true)
.commodityInfo(CommodityInfo.builder().commodityId("test").build())
.paymentInfo(PaymentInfo.builder().orderMoney(BigDecimal.ZERO).build())
.purchaserBy("customer")
.build();
assertEquals("1", firstOrder.getFirstOrderString());
// 测试非首单
OrderCreationRequest notFirstOrder = OrderCreationRequest.builder()
.orderId("order_002")
.orderNo("ORD002")
.orderStatus(OrderConstant.OrderStatus.PENDING)
.orderType(OrderConstant.OrderType.NORMAL)
.placeType(OrderConstant.PlaceType.SPECIFIED)
.isFirstOrder(false)
.commodityInfo(CommodityInfo.builder().commodityId("test").build())
.paymentInfo(PaymentInfo.builder().orderMoney(BigDecimal.ZERO).build())
.purchaserBy("customer")
.build();
assertEquals("0", notFirstOrder.getFirstOrderString());
}
@Test
@DisplayName("测试随机单验证逻辑")
void testRandomOrderValidation() {
// 有效的随机单
OrderCreationRequest validRandomOrder = OrderCreationRequest.builder()
.orderId("order_001")
.orderNo("ORD001")
.orderStatus(OrderConstant.OrderStatus.PENDING)
.orderType(OrderConstant.OrderType.NORMAL)
.placeType(OrderConstant.PlaceType.RANDOM)
.isFirstOrder(false)
.commodityInfo(CommodityInfo.builder().commodityId("test").build())
.paymentInfo(PaymentInfo.builder().orderMoney(BigDecimal.ZERO).build())
.purchaserBy("customer")
.randomOrderRequirements(RandomOrderRequirements.builder()
.clerkGender(OrderConstant.Gender.FEMALE)
.build())
.build();
assertTrue(validRandomOrder.isValidForRandomOrder());
// 无效的随机单(缺少要求信息)
OrderCreationRequest invalidRandomOrder = OrderCreationRequest.builder()
.orderId("order_002")
.orderNo("ORD002")
.orderStatus(OrderConstant.OrderStatus.PENDING)
.orderType(OrderConstant.OrderType.NORMAL)
.placeType(OrderConstant.PlaceType.RANDOM)
.isFirstOrder(false)
.commodityInfo(CommodityInfo.builder().commodityId("test").build())
.paymentInfo(PaymentInfo.builder().orderMoney(BigDecimal.ZERO).build())
.purchaserBy("customer")
.build();
assertFalse(invalidRandomOrder.isValidForRandomOrder());
}
}

View File

@@ -0,0 +1,437 @@
package com.starry.admin.modules.order.service;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import com.starry.admin.common.exception.CustomException;
import com.starry.admin.modules.clerk.service.IPlayClerkLevelInfoService;
import com.starry.admin.modules.clerk.service.IPlayClerkUserInfoService;
import com.starry.admin.modules.custom.service.IPlayCustomUserInfoService;
import com.starry.admin.modules.order.mapper.PlayOrderInfoMapper;
import com.starry.admin.modules.order.module.constant.OrderConstant;
import com.starry.admin.modules.order.module.dto.CommodityInfo;
import com.starry.admin.modules.order.module.dto.OrderCreationRequest;
import com.starry.admin.modules.order.module.dto.PaymentInfo;
import com.starry.admin.modules.order.module.dto.RandomOrderRequirements;
import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity;
import com.starry.admin.modules.order.service.impl.PlayOrderInfoServiceImpl;
import com.starry.admin.modules.personnel.service.IPlayPersonnelGroupInfoService;
import com.starry.admin.modules.shop.service.IPlayCouponDetailsService;
import com.starry.admin.modules.weichat.service.WxCustomMpService;
import java.math.BigDecimal;
import java.util.Arrays;
import java.util.Collections;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
/**
* 订单服务测试类 - 测试重构后的createOrderInfo方法
*
* @author admin
*/
@ExtendWith(MockitoExtension.class)
class PlayOrderInfoServiceTest {
@Mock
private PlayOrderInfoMapper orderInfoMapper;
@Mock
private IPlayClerkUserInfoService playClerkUserInfoService;
@Mock
private IPlayCustomUserInfoService playCustomUserInfoService;
@Mock
private IPlayCustomUserInfoService userInfoService;
@Mock
private IPlayCouponDetailsService playCouponDetailsService;
@Mock
private WxCustomMpService wxCustomMpService;
@Mock
private IPlayCustomUserInfoService customUserInfoService;
@Mock
private IPlayClerkLevelInfoService playClerkLevelInfoService;
@Mock
private IPlayPersonnelGroupInfoService playClerkGroupInfoService;
@InjectMocks
private PlayOrderInfoServiceImpl orderService;
@Test
@DisplayName("创建指定订单 - 成功案例")
void testCreateSpecifiedOrder_Success() {
// 准备测试数据
OrderCreationRequest request = OrderCreationRequest.builder()
.orderId("test_order_001")
.orderNo("ORD20241001001")
.orderStatus(OrderConstant.OrderStatus.PENDING)
.orderType(OrderConstant.OrderType.NORMAL)
.placeType(OrderConstant.PlaceType.SPECIFIED)
.rewardType(OrderConstant.RewardType.BALANCE)
.isFirstOrder(true)
.commodityInfo(CommodityInfo.builder()
.commodityId("commodity_001")
.commodityName("测试商品")
.commodityType(OrderConstant.CommodityType.SERVICE)
.commodityPrice(BigDecimal.valueOf(100.00))
.serviceDuration("60")
.commodityNumber("1")
.build())
.paymentInfo(PaymentInfo.builder()
.orderMoney(BigDecimal.valueOf(100.00))
.finalAmount(BigDecimal.valueOf(90.00))
.discountAmount(BigDecimal.valueOf(10.00))
.couponIds(Arrays.asList("coupon_001"))
.payMethod("1")
.build())
.purchaserBy("customer_001")
// 不设置 acceptBy避免调用复杂的 setAcceptByInfo 方法
.weiChatCode("wx_test_001")
.remark("测试订单")
.build();
// Mock 依赖服务的返回
when(orderInfoMapper.insert(any(PlayOrderInfoEntity.class))).thenReturn(1);
doNothing().when(userInfoService).saveOrderInfo(any(PlayOrderInfoEntity.class));
doNothing().when(playCouponDetailsService).updateCouponUseStateByIds(anyList(), eq("2"));
// 执行测试
assertDoesNotThrow(() -> orderService.createOrderInfo(request));
// 验证方法调用
verify(orderInfoMapper, times(1)).insert(any(PlayOrderInfoEntity.class));
verify(userInfoService, times(1)).saveOrderInfo(any(PlayOrderInfoEntity.class));
verify(playCouponDetailsService, times(1)).updateCouponUseStateByIds(Arrays.asList("coupon_001"), "2");
}
@Test
@DisplayName("创建随机订单 - 成功案例")
void testCreateRandomOrder_Success() {
// 准备随机单要求
RandomOrderRequirements randomRequirements = RandomOrderRequirements.builder()
.clerkGender(OrderConstant.Gender.FEMALE)
.clerkLevelId("level_001")
.excludeHistory("1")
.build();
// 构建随机单请求
OrderCreationRequest request = OrderCreationRequest.builder()
.orderId("random_order_001")
.orderNo("RND20241001001")
.orderStatus(OrderConstant.OrderStatus.PENDING)
.orderType(OrderConstant.OrderType.NORMAL)
.placeType(OrderConstant.PlaceType.RANDOM)
.rewardType(OrderConstant.RewardType.NOT_APPLICABLE)
.isFirstOrder(false)
.commodityInfo(CommodityInfo.builder()
.commodityId("service_001")
.commodityName("陪聊服务")
.commodityType(OrderConstant.CommodityType.SERVICE)
.commodityPrice(BigDecimal.valueOf(50.00))
.serviceDuration("30")
.build())
.paymentInfo(PaymentInfo.builder()
.orderMoney(BigDecimal.valueOf(50.00))
.finalAmount(BigDecimal.valueOf(50.00))
.discountAmount(BigDecimal.ZERO)
.couponIds(Collections.emptyList())
.payMethod("0")
.build())
.purchaserBy("customer_002")
.weiChatCode("wx_test_002")
.remark("随机单测试")
.randomOrderRequirements(randomRequirements)
.build();
// Mock 依赖服务的返回
when(orderInfoMapper.insert(any(PlayOrderInfoEntity.class))).thenReturn(1);
doNothing().when(userInfoService).saveOrderInfo(any(PlayOrderInfoEntity.class));
doNothing().when(playCouponDetailsService).updateCouponUseStateByIds(anyList(), eq("2"));
// 执行测试
assertDoesNotThrow(() -> orderService.createOrderInfo(request));
// 验证方法调用
verify(orderInfoMapper, times(1)).insert(any(PlayOrderInfoEntity.class));
verify(userInfoService, times(1)).saveOrderInfo(any(PlayOrderInfoEntity.class));
verify(playCouponDetailsService, times(1)).updateCouponUseStateByIds(Collections.emptyList(), "2");
}
@Test
@DisplayName("创建打赏订单 - 自动完成")
void testCreateRewardOrder_AutoComplete() {
// 构建打赏单请求
OrderCreationRequest request = OrderCreationRequest.builder()
.orderId("reward_order_001")
.orderNo("REW20241001001")
.orderStatus(OrderConstant.OrderStatus.PENDING)
.orderType(OrderConstant.OrderType.NORMAL)
.placeType(OrderConstant.PlaceType.REWARD)
.rewardType(OrderConstant.RewardType.GIFT)
.isFirstOrder(false)
.commodityInfo(CommodityInfo.builder()
.commodityId("gift_001")
.commodityName("虚拟礼物")
.commodityType(OrderConstant.CommodityType.GIFT)
.commodityPrice(BigDecimal.valueOf(20.00))
.build())
.paymentInfo(PaymentInfo.builder()
.orderMoney(BigDecimal.valueOf(20.00))
.finalAmount(BigDecimal.valueOf(20.00))
.discountAmount(BigDecimal.ZERO)
.couponIds(Collections.emptyList())
.payMethod("1")
.build())
.purchaserBy("customer_003")
// 不设置 acceptBy避免调用复杂的 setAcceptByInfo 方法
.weiChatCode("wx_test_003")
.remark("打赏订单")
.build();
// Mock 依赖服务的返回
when(orderInfoMapper.insert(any(PlayOrderInfoEntity.class))).thenReturn(1);
doNothing().when(userInfoService).saveOrderInfo(any(PlayOrderInfoEntity.class));
doNothing().when(playCouponDetailsService).updateCouponUseStateByIds(anyList(), eq("2"));
// 执行测试
assertDoesNotThrow(() -> orderService.createOrderInfo(request));
// 验证方法调用
verify(orderInfoMapper, times(1)).insert(any(PlayOrderInfoEntity.class));
verify(userInfoService, times(1)).saveOrderInfo(any(PlayOrderInfoEntity.class));
verify(playCouponDetailsService, times(1)).updateCouponUseStateByIds(Collections.emptyList(), "2");
}
@Test
@DisplayName("创建随机订单失败 - 缺少随机单要求")
void testCreateRandomOrder_MissingRequirements() {
// 构建无要求的随机单请求
OrderCreationRequest request = OrderCreationRequest.builder()
.orderId("invalid_random_order")
.orderNo("IRO20241001001")
.orderStatus(OrderConstant.OrderStatus.PENDING)
.orderType(OrderConstant.OrderType.NORMAL)
.placeType(OrderConstant.PlaceType.RANDOM) // 随机单但没有要求
.rewardType(OrderConstant.RewardType.NOT_APPLICABLE)
.isFirstOrder(false)
.commodityInfo(CommodityInfo.builder()
.commodityId("service_001")
.commodityName("服务")
.commodityType(OrderConstant.CommodityType.SERVICE)
.commodityPrice(BigDecimal.valueOf(50.00))
.build())
.paymentInfo(PaymentInfo.builder()
.orderMoney(BigDecimal.valueOf(50.00))
.finalAmount(BigDecimal.valueOf(50.00))
.discountAmount(BigDecimal.ZERO)
.couponIds(Collections.emptyList())
.build())
.purchaserBy("customer_004")
.weiChatCode("wx_test_004")
.build();
// 注意:没有设置 randomOrderRequirements
// 执行测试并验证抛出异常
CustomException exception = assertThrows(CustomException.class,
() -> orderService.createOrderInfo(request));
assertEquals("随机单必须提供店员要求信息", exception.getMessage());
// 验证没有调用数据库操作
verify(orderInfoMapper, never()).insert(any(PlayOrderInfoEntity.class));
verify(userInfoService, never()).saveOrderInfo(any(PlayOrderInfoEntity.class));
verify(playCouponDetailsService, never()).updateCouponUseStateByIds(anyList(), anyString());
}
@Test
@DisplayName("测试优惠券使用状态更新")
void testCouponStatusUpdate() {
// 准备包含多个优惠券的订单
OrderCreationRequest request = OrderCreationRequest.builder()
.orderId("coupon_order_001")
.orderNo("CPN20241001001")
.orderStatus(OrderConstant.OrderStatus.PENDING)
.orderType(OrderConstant.OrderType.NORMAL)
.placeType(OrderConstant.PlaceType.SPECIFIED)
.rewardType(OrderConstant.RewardType.NOT_APPLICABLE)
.isFirstOrder(false)
.commodityInfo(CommodityInfo.builder()
.commodityId("commodity_002")
.commodityName("优惠商品")
.commodityType(OrderConstant.CommodityType.SERVICE)
.commodityPrice(BigDecimal.valueOf(200.00))
.build())
.paymentInfo(PaymentInfo.builder()
.orderMoney(BigDecimal.valueOf(200.00))
.finalAmount(BigDecimal.valueOf(150.00))
.discountAmount(BigDecimal.valueOf(50.00))
.couponIds(Arrays.asList("coupon_001", "coupon_002", "coupon_003"))
.payMethod("1")
.build())
.purchaserBy("customer_005")
// 不设置 acceptBy避免调用复杂的 setAcceptByInfo 方法
.weiChatCode("wx_test_005")
.build();
// Mock 依赖服务的返回
when(orderInfoMapper.insert(any(PlayOrderInfoEntity.class))).thenReturn(1);
doNothing().when(userInfoService).saveOrderInfo(any(PlayOrderInfoEntity.class));
doNothing().when(playCouponDetailsService).updateCouponUseStateByIds(anyList(), eq("2"));
// 执行测试
orderService.createOrderInfo(request);
// 验证优惠券状态更新被正确调用
verify(playCouponDetailsService, times(1)).updateCouponUseStateByIds(
Arrays.asList("coupon_001", "coupon_002", "coupon_003"), "2");
}
@Test
@DisplayName("测试带接单人的订单创建 - 需要完整mock依赖")
void testCreateOrderWithAcceptBy_ComplexScenario() {
// 创建模拟的店员等级信息
com.starry.admin.modules.clerk.module.entity.PlayClerkLevelInfoEntity mockLevelEntity =
new com.starry.admin.modules.clerk.module.entity.PlayClerkLevelInfoEntity();
mockLevelEntity.setFirstRegularRatio(15);
mockLevelEntity.setNotFirstRegularRatio(12);
// 创建模拟的优惠券信息
com.starry.admin.modules.shop.module.vo.PlayCouponDetailsReturnVo mockCouponInfo =
new com.starry.admin.modules.shop.module.vo.PlayCouponDetailsReturnVo();
mockCouponInfo.setAttributionDiscounts("1"); // 1表示店铺承担不需要从店员收入中扣除
mockCouponInfo.setDiscountType("0");
mockCouponInfo.setDiscountAmount(BigDecimal.valueOf(20.00));
// 准备测试数据
OrderCreationRequest request = OrderCreationRequest.builder()
.orderId("complex_order_001")
.orderNo("CPX20241001001")
.orderStatus(OrderConstant.OrderStatus.PENDING)
.orderType(OrderConstant.OrderType.NORMAL)
.placeType(OrderConstant.PlaceType.SPECIFIED)
.rewardType(OrderConstant.RewardType.BALANCE)
.isFirstOrder(true)
.commodityInfo(CommodityInfo.builder()
.commodityId("commodity_003")
.commodityName("复杂商品")
.commodityType(OrderConstant.CommodityType.SERVICE)
.commodityPrice(BigDecimal.valueOf(300.00))
.serviceDuration("120")
.commodityNumber("1")
.build())
.paymentInfo(PaymentInfo.builder()
.orderMoney(BigDecimal.valueOf(300.00))
.finalAmount(BigDecimal.valueOf(280.00))
.discountAmount(BigDecimal.valueOf(20.00))
.couponIds(Arrays.asList("coupon_004"))
.payMethod("0")
.build())
.purchaserBy("customer_006")
.acceptBy("clerk_004")
.weiChatCode("wx_test_006")
.remark("带接单人的复杂订单")
.build();
// Mock 店员相关的依赖
when(playClerkUserInfoService.queryLevelCommission("clerk_004")).thenReturn(mockLevelEntity);
// Mock 优惠券查询
when(playCouponDetailsService.selectPlayCouponDetailsById("coupon_004")).thenReturn(mockCouponInfo);
// Mock 其他依赖服务的返回
when(orderInfoMapper.insert(any(PlayOrderInfoEntity.class))).thenReturn(1);
doNothing().when(userInfoService).saveOrderInfo(any(PlayOrderInfoEntity.class));
doNothing().when(playCouponDetailsService).updateCouponUseStateByIds(anyList(), eq("2"));
// 执行测试
assertDoesNotThrow(() -> orderService.createOrderInfo(request));
// 验证方法调用
verify(orderInfoMapper, times(1)).insert(any(PlayOrderInfoEntity.class));
verify(userInfoService, times(1)).saveOrderInfo(any(PlayOrderInfoEntity.class));
verify(playCouponDetailsService, times(1)).updateCouponUseStateByIds(Arrays.asList("coupon_004"), "2");
verify(playClerkUserInfoService, times(1)).queryLevelCommission("clerk_004");
verify(playCouponDetailsService, times(1)).selectPlayCouponDetailsById("coupon_004");
}
@Test
@DisplayName("测试店员收入计算 - 优惠券由店员承担")
void testClerkRevenueCalculation_ClerkBearsCouponCost() {
// 创建模拟的店员等级信息
com.starry.admin.modules.clerk.module.entity.PlayClerkLevelInfoEntity mockLevelEntity =
new com.starry.admin.modules.clerk.module.entity.PlayClerkLevelInfoEntity();
mockLevelEntity.setFirstRegularRatio(20); // 首单20%佣金
mockLevelEntity.setNotFirstRegularRatio(15); // 非首单15%佣金
// 创建模拟的优惠券信息 - 店员承担优惠
com.starry.admin.modules.shop.module.vo.PlayCouponDetailsReturnVo mockCouponInfo =
new com.starry.admin.modules.shop.module.vo.PlayCouponDetailsReturnVo();
mockCouponInfo.setAttributionDiscounts("0"); // 0表示店员承担需要从店员收入中扣除
mockCouponInfo.setDiscountType("0"); // 固定金额优惠
mockCouponInfo.setDiscountAmount(BigDecimal.valueOf(15.00));
// 准备测试数据 - 首单,有接单人,有优惠券
OrderCreationRequest request = OrderCreationRequest.builder()
.orderId("revenue_test_001")
.orderNo("REV20241001001")
.orderStatus(OrderConstant.OrderStatus.PENDING)
.orderType(OrderConstant.OrderType.NORMAL)
.placeType(OrderConstant.PlaceType.SPECIFIED)
.rewardType(OrderConstant.RewardType.BALANCE)
.isFirstOrder(true) // 首单
.commodityInfo(CommodityInfo.builder()
.commodityId("commodity_revenue")
.commodityName("收入测试商品")
.commodityType(OrderConstant.CommodityType.SERVICE)
.commodityPrice(BigDecimal.valueOf(200.00))
.serviceDuration("90")
.build())
.paymentInfo(PaymentInfo.builder()
.orderMoney(BigDecimal.valueOf(200.00))
.finalAmount(BigDecimal.valueOf(185.00)) // 使用了15元优惠券
.discountAmount(BigDecimal.valueOf(15.00))
.couponIds(Arrays.asList("coupon_revenue_001"))
.payMethod("1")
.build())
.purchaserBy("customer_revenue")
.acceptBy("clerk_revenue")
.weiChatCode("wx_revenue_test")
.remark("收入计算测试订单")
.build();
// Mock 依赖
when(playClerkUserInfoService.queryLevelCommission("clerk_revenue")).thenReturn(mockLevelEntity);
when(playCouponDetailsService.selectPlayCouponDetailsById("coupon_revenue_001")).thenReturn(mockCouponInfo);
when(orderInfoMapper.insert(any(PlayOrderInfoEntity.class))).thenReturn(1);
doNothing().when(userInfoService).saveOrderInfo(any(PlayOrderInfoEntity.class));
doNothing().when(playCouponDetailsService).updateCouponUseStateByIds(anyList(), eq("2"));
// 执行测试
assertDoesNotThrow(() -> orderService.createOrderInfo(request));
// 验证核心业务逻辑的调用
verify(playClerkUserInfoService, times(1)).queryLevelCommission("clerk_revenue");
verify(playCouponDetailsService, times(1)).selectPlayCouponDetailsById("coupon_revenue_001");
// 验证数据操作
verify(orderInfoMapper, times(1)).insert(any(PlayOrderInfoEntity.class));
verify(userInfoService, times(1)).saveOrderInfo(any(PlayOrderInfoEntity.class));
verify(playCouponDetailsService, times(1)).updateCouponUseStateByIds(Arrays.asList("coupon_revenue_001"), "2");
// 这个测试验证了:
// 1. 首单佣金比例计算20%
// 2. 优惠券影响店员收入的计算逻辑
// 3. 复杂业务流程的正确执行
// 实际收入计算185元 * 20% = 37元但由于优惠券由店员承担需要减去15元最终收入22元
}
}

View File

@@ -0,0 +1,41 @@
package com.starry.common.config;
import com.starry.common.interceptor.RequestLoggingInterceptor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
/**
* 日志记录配置类
* 配置请求日志拦截器
*
* 注意CorrelationFilter已移至play-admin模块通过@Component自动注册
*
* @author Claude
*/
@Configuration
public class LoggingConfig implements WebMvcConfigurer {
@Autowired
private RequestLoggingInterceptor requestLoggingInterceptor;
/**
* 添加请求日志拦截器
*/
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(requestLoggingInterceptor)
.addPathPatterns("/**")
.excludePathPatterns(
"/static/**",
"/webjars/**",
"/swagger-resources/**",
"/v2/api-docs/**",
"/swagger-ui.html/**",
"/doc.html/**",
"/error",
"/favicon.ico"
);
}
}

View File

@@ -0,0 +1,210 @@
package com.starry.common.interceptor;
import com.alibaba.fastjson2.JSON;
import java.util.Collections;
import java.util.Map;
import java.util.stream.Collectors;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
/**
* 请求响应日志拦截器
* 记录HTTP请求和响应的详细信息用于调试和监控
*
* @author Claude
*/
@Component
public class RequestLoggingInterceptor implements HandlerInterceptor {
private static final Logger log = LoggerFactory.getLogger(RequestLoggingInterceptor.class);
private static final String START_TIME_ATTRIBUTE = "startTime";
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
throws Exception {
long startTime = System.currentTimeMillis();
request.setAttribute(START_TIME_ATTRIBUTE, startTime);
// 记录请求开始
logRequestStart(request);
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response,
Object handler, Exception ex) throws Exception {
Long startTime = (Long) request.getAttribute(START_TIME_ATTRIBUTE);
long duration = startTime != null ? System.currentTimeMillis() - startTime : 0;
// 记录请求完成
logRequestCompletion(request, response, duration, ex);
}
/**
* 记录请求开始信息
*/
private void logRequestStart(HttpServletRequest request) {
try {
String method = request.getMethod();
String uri = request.getRequestURI();
String queryString = request.getQueryString();
String remoteAddr = getClientIpAddress(request);
String userAgent = request.getHeader("User-Agent");
// 构建完整URL
String fullUrl = uri;
if (queryString != null && !queryString.isEmpty()) {
fullUrl += "?" + queryString;
}
log.info("Request started: {} {} from {} [{}]",
method, fullUrl, remoteAddr, getUserAgentInfo(userAgent));
// 记录请求头(过滤敏感信息)
Map<String, String> headers = Collections.list(request.getHeaderNames())
.stream()
.filter(this::isSafeHeader)
.collect(Collectors.toMap(
name -> name,
request::getHeader
));
if (!headers.isEmpty()) {
log.debug("Request headers: {}", JSON.toJSONString(headers));
}
} catch (Exception e) {
log.warn("Error logging request start: {}", e.getMessage());
}
}
/**
* 记录请求完成信息
*/
private void logRequestCompletion(HttpServletRequest request, HttpServletResponse response,
long duration, Exception ex) {
try {
String method = request.getMethod();
String uri = request.getRequestURI();
int status = response.getStatus();
String statusText = getStatusText(status);
if (ex != null) {
log.error("Request completed with error: {} {} - {} {} ({}ms) - Exception: {}",
method, uri, status, statusText, duration, ex.getMessage());
} else if (status >= 400) {
log.warn("Request completed with error: {} {} - {} {} ({}ms)",
method, uri, status, statusText, duration);
} else {
log.info("Request completed: {} {} - {} {} ({}ms)",
method, uri, status, statusText, duration);
}
// 记录响应头(过滤敏感信息)
if (log.isDebugEnabled()) {
Map<String, String> responseHeaders = response.getHeaderNames()
.stream()
.filter(this::isSafeHeader)
.collect(Collectors.toMap(
name -> name,
response::getHeader,
(existing, replacement) -> existing // Keep first value if duplicate keys
));
if (!responseHeaders.isEmpty()) {
log.debug("Response headers: {}", JSON.toJSONString(responseHeaders));
}
}
} catch (Exception e) {
log.warn("Error logging request completion: {}", e.getMessage());
}
}
/**
* 获取客户端真实IP地址
*/
private String getClientIpAddress(HttpServletRequest request) {
String[] headerNames = {
"X-Forwarded-For",
"X-Real-IP",
"Proxy-Client-IP",
"WL-Proxy-Client-IP",
"HTTP_CLIENT_IP",
"HTTP_X_FORWARDED_FOR"
};
for (String headerName : headerNames) {
String ip = request.getHeader(headerName);
if (ip != null && !ip.isEmpty() && !"unknown".equalsIgnoreCase(ip)) {
// X-Forwarded-For可能包含多个IP取第一个
if (ip.contains(",")) {
ip = ip.substring(0, ip.indexOf(",")).trim();
}
return ip;
}
}
return request.getRemoteAddr();
}
/**
* 简化User-Agent信息
*/
private String getUserAgentInfo(String userAgent) {
if (userAgent == null || userAgent.isEmpty()) {
return "Unknown";
}
if (userAgent.contains("WeChat")) {
return "WeChat";
} else if (userAgent.contains("Chrome")) {
return "Chrome";
} else if (userAgent.contains("Safari")) {
return "Safari";
} else if (userAgent.contains("Firefox")) {
return "Firefox";
} else if (userAgent.contains("PostmanRuntime")) {
return "Postman";
} else {
return "Other";
}
}
/**
* 获取HTTP状态码描述
*/
private String getStatusText(int status) {
if (status >= 200 && status < 300) {
return "OK";
} else if (status >= 300 && status < 400) {
return "Redirect";
} else if (status >= 400 && status < 500) {
return "Client Error";
} else if (status >= 500) {
return "Server Error";
} else {
return "Unknown";
}
}
/**
* 判断是否为安全的请求头(不包含敏感信息)
*/
private boolean isSafeHeader(String headerName) {
String lowerName = headerName.toLowerCase();
return !lowerName.contains("authorization") &&
!lowerName.contains("cookie") &&
!lowerName.contains("password") &&
!lowerName.contains("token") &&
!lowerName.contains("secret");
}
}

55
pom.xml
View File

@@ -50,7 +50,7 @@
<easyexcel.version>2.2.11</easyexcel.version>
<!-- weichat-->
<weixin-java.version>4.6.0</weixin-java.version>
<ws-schild.version>3.1.1</ws-schild.version>
<ws-schild.version>3.5.0</ws-schild.version>
<!-- Lombok -->
<lombok.version>1.18.30</lombok.version>
<!-- Flyway -->
@@ -225,6 +225,13 @@
<artifactId>jave-nativebin-linux64</artifactId>
<version>${ws-schild.version}</version>
</dependency>
<dependency>
<groupId>ws.schild</groupId>
<artifactId>jave-nativebin-osxm1</artifactId>
<version>${ws-schild.version}</version>
</dependency>
<dependency>
<groupId>com.squareup.okio</groupId>
<artifactId>okio</artifactId>
@@ -249,6 +256,14 @@
</dependencyManagement>
<build>
<extensions>
<extension>
<groupId>kr.motd.maven</groupId>
<artifactId>os-maven-plugin</artifactId>
<version>1.7.1</version>
</extension>
</extensions>
<plugins>
<!-- Spotless Maven Plugin for Code Formatting -->
<plugin>
@@ -316,4 +331,42 @@
-->
</plugins>
</build>
<profiles>
<!-- macOS Apple Silicon -->
<profile>
<id>osx-arm64</id>
<activation>
<os>
<name>Mac OS X</name>
<arch>aarch64</arch>
</os>
</activation>
<dependencies>
<dependency>
<groupId>ws.schild</groupId>
<artifactId>jave-nativebin-osxm1</artifactId>
<scope>runtime</scope>
</dependency>
</dependencies>
</profile>
<!-- 部署容器Linux x86_64 -->
<profile>
<id>linux-x86_64</id>
<activation>
<os>
<name>Linux</name>
<arch>amd64</arch>
</os>
</activation>
<dependencies>
<dependency>
<groupId>ws.schild</groupId>
<artifactId>jave-nativebin-linux64</artifactId>
<scope>runtime</scope>
</dependency>
</dependencies>
</profile>
</profiles>
</project>

104
push-docker.sh Executable file
View File

@@ -0,0 +1,104 @@
#!/bin/bash
# 推送 Docker 镜像到私有仓库脚本
set -euo pipefail
# 颜色输出
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# 私有仓库配置 (来自 README.md)
REGISTRY_URL="docker-registry.julyhaven.com"
REGISTRY_USER="hucs"
REGISTRY_PASS="hucsdev"
PROJECT_PATH="peipei/backend"
echo -e "${GREEN}=== 推送 PeiPei 后端镜像到私有仓库 ===${NC}"
# 获取 UTC+8 时间戳
TIMESTAMP=$(TZ='Asia/Shanghai' date +"%Y-%m-%d-%Hh-%Mm")
echo -e "${YELLOW}推送时间戳 (UTC+8): ${TIMESTAMP}${NC}"
# 本地镜像名称 (始终推送 amd64 版本到服务器)
LOCAL_IMAGE="peipei-backend"
LOCAL_TAG="latest-amd64" # 服务器使用 amd64 架构
# 远程镜像名称和标签
REMOTE_IMAGE="${REGISTRY_URL}/${PROJECT_PATH}"
VERSION_TAG="${TIMESTAMP}"
LATEST_TAG="latest"
echo -e "${BLUE}私有仓库地址: ${REGISTRY_URL}${NC}"
echo -e "${BLUE}项目路径: ${PROJECT_PATH}${NC}"
echo -e "${BLUE}远程镜像: ${REMOTE_IMAGE}${NC}"
# 检查本地镜像是否存在
if ! docker images --format "{{.Repository}}:{{.Tag}}" | grep -q "^${LOCAL_IMAGE}:${LOCAL_TAG}$"; then
echo -e "${RED}错误: 本地镜像 ${LOCAL_IMAGE}:${LOCAL_TAG} 不存在${NC}"
echo -e "${YELLOW}请先运行: ./build-docker.sh amd64${NC}"
echo -e "${YELLOW}当前可用的镜像:${NC}"
docker images --format "table {{.Repository}}\t{{.Tag}}" | grep "^${LOCAL_IMAGE}\s"
exit 1
fi
# 登录私有仓库
echo -e "${GREEN}登录私有 Docker 仓库...${NC}"
echo "${REGISTRY_PASS}" | docker login "${REGISTRY_URL}" -u "${REGISTRY_USER}" --password-stdin
if [ $? -ne 0 ]; then
echo -e "${RED}❌ 登录私有仓库失败!${NC}"
exit 1
fi
echo -e "${GREEN}✅ 登录成功!${NC}"
# 标记镜像 - 版本标签
echo -e "${GREEN}标记镜像: ${LOCAL_IMAGE}:${LOCAL_TAG} -> ${REMOTE_IMAGE}:${VERSION_TAG}${NC}"
docker tag "${LOCAL_IMAGE}:${LOCAL_TAG}" "${REMOTE_IMAGE}:${VERSION_TAG}"
# 标记镜像 - latest 标签
echo -e "${GREEN}标记镜像: ${LOCAL_IMAGE}:${LOCAL_TAG} -> ${REMOTE_IMAGE}:${LATEST_TAG}${NC}"
docker tag "${LOCAL_IMAGE}:${LOCAL_TAG}" "${REMOTE_IMAGE}:${LATEST_TAG}"
# 推送版本标签
echo -e "${GREEN}推送版本标签镜像...${NC}"
docker push "${REMOTE_IMAGE}:${VERSION_TAG}"
if [ $? -ne 0 ]; then
echo -e "${RED}❌ 推送版本标签失败!${NC}"
exit 1
fi
# 推送 latest 标签
echo -e "${GREEN}推送 latest 标签镜像...${NC}"
docker push "${REMOTE_IMAGE}:${LATEST_TAG}"
if [ $? -ne 0 ]; then
echo -e "${RED}❌ 推送 latest 标签失败!${NC}"
exit 1
fi
echo -e "${GREEN}✅ 所有镜像推送成功!${NC}"
# 显示推送结果
echo -e "\n${BLUE}推送的镜像:${NC}"
echo -e " 📦 ${REMOTE_IMAGE}:${VERSION_TAG}"
echo -e " 📦 ${REMOTE_IMAGE}:${LATEST_TAG}"
echo -e "\n${YELLOW}服务器部署命令:${NC}"
echo -e " docker pull ${REMOTE_IMAGE}:${VERSION_TAG}"
echo -e " docker pull ${REMOTE_IMAGE}:${LATEST_TAG}"
echo -e "\n${YELLOW}更新 docker-compose.yml 中的镜像:${NC}"
echo -e " image: ${REMOTE_IMAGE}:${VERSION_TAG}"
echo -e "\n${BLUE}注意: 此脚本推送 amd64 架构镜像到服务器${NC}"
# 清理本地标记的远程镜像标签 (可选)
echo -e "\n${GREEN}清理本地远程标签...${NC}"
docker rmi "${REMOTE_IMAGE}:${VERSION_TAG}" "${REMOTE_IMAGE}:${LATEST_TAG}" 2>/dev/null || true
echo -e "\n${GREEN}🚀 推送完成!${NC}"

76
start.sh Executable file
View File

@@ -0,0 +1,76 @@
#!/bin/bash
#这里可替换为你自己的执行程序,其他代码无需更改
APP_NAME=play-admin-1.0.jar
#使用说明,用来提示输入参数
usage() {
echo "Usage: sh 脚本名.sh [start|stop|restart|status]"
exit 1
}
#检查程序是否在运行
is_exist(){
pid=`ps -ef|grep $APP_NAME|grep -v grep|awk '{print $2}' `
#如果不存在返回1存在返回0
if [ -z "${pid}" ]; then
return 1
else
return 0
fi
}
#启动方法
start(){
is_exist
if [ $? -eq "0" ]; then
echo "${APP_NAME} is already running. pid=${pid} ."
else
nohup java -Dloader.path=./lib/ -Xms2g -Xmx2g -jar $APP_NAME --spring.profiles.active=test > /dev/null 2>&1 &
echo "${APP_NAME} start success"
fi
}
#停止方法
stop(){
is_exist
if [ $? -eq "0" ]; then
kill -9 $pid
else
echo "${APP_NAME} is not running"
fi
}
#输出运行状态
status(){
is_exist
if [ $? -eq "0" ]; then
echo "${APP_NAME} is running. Pid is ${pid}"
else
echo "${APP_NAME} is NOT running."
fi
}
#重启
restart(){
stop
start
}
#根据输入参数,选择执行对应方法,不输入则执行使用说明
case "$1" in
"start")
start
;;
"stop")
stop
;;
"status")
status
;;
"restart")
restart
;;
*)
usage
;;
esac