diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..843e3c2 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,60 @@ +name: Java CI + +on: + push: + branches: [ "main", "master" ] + pull_request: + branches: [ "main", "master" ] + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'temurin' + cache: maven + + - name: Cache Maven dependencies + uses: actions/cache@v3 + with: + path: ~/.m2 + key: ${{ runner.os }}-m2-${{ hashFiles('**/pom.xml') }} + restore-keys: ${{ runner.os }}-m2 + + - name: Build with Maven + run: ./mvnw clean verify -B + + - name: Run SpotBugs + run: ./mvnw compile spotbugs:check -B + + - name: Run OWASP Dependency Check + run: ./mvnw dependency-check:check -B + + - name: Run ArchUnit Tests + run: ./mvnw test -Dtest=ArchitectureTest -B + + - name: Build Docker Image + if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master' + run: | + docker build -t tmpl-api:${{ github.sha }} . + echo "Built image with tag tmpl-api:${{ github.sha }}" + + - name: Run Trivy vulnerability scanner + if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master' + uses: aquasecurity/trivy-action@master + with: + scan-type: 'fs' + scan-ref: '.' + format: 'sarif' + output: 'trivy-results.sarif' + + - name: Upload Trivy scan results + if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master' + uses: github/codeql-action/upload-sarif@v2 + with: + sarif_file: 'trivy-results.sarif' \ No newline at end of file diff --git a/.mavenwrapper b/.mavenwrapper new file mode 100644 index 0000000..e69de29 diff --git a/.mvn/wrapper/maven-wrapper.properties b/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 0000000..891394d --- /dev/null +++ b/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1,18 @@ +# 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. +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.6/apache-maven-3.9.6-bin.zip +wrapperUrl=https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4a35626 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,50 @@ +# 多阶段构建 Dockerfile +FROM maven:3.9.6-eclipse-temurin-21 AS builder + +WORKDIR /app +COPY pom.xml . +COPY tmpl-domain/pom.xml ./tmpl-domain/pom.xml +COPY tmpl-application/pom.xml ./tmpl-application/pom.xml +COPY tmpl-infrastructure/pom.xml ./tmpl-infrastructure/pom.xml +COPY tmpl-api/pom.xml ./tmpl-api/pom.xml + +# 预下载依赖 +RUN mvn dependency:go-offline -B + +# 复制源代码 +COPY tmpl-domain/src ./tmpl-domain/src +COPY tmpl-application/src ./tmpl-application/src +COPY tmpl-infrastructure/src ./tmpl-infrastructure/src +COPY tmpl-api/src ./tmpl-api/src + +# 编译打包 +RUN mvn clean package -DskipTests -B +RUN ls -la tmpl-api/target/ + +# 运行时镜像 +FROM eclipse-temurin:21-jre-alpine + +LABEL maintainer="Development Team" +LABEL version="1.0.0" +LABEL description="Java 21 Service Template - Clean Architecture" + +WORKDIR /app + +# 创建非 root 用户 +RUN addgroup -g 1001 -S appgroup && \ + adduser -u 1001 -S appuser -G appgroup + +# 从构建阶段复制 jar 文件 +COPY --from=builder /app/tmpl-api/target/tmpl-api-*.jar app.jar + +# 更改文件所有权 +RUN chown appuser:appgroup app.jar + +# 暴露端口 +EXPOSE 8080 + +# 切换到非 root 用户 +USER appuser + +# 启动应用 +ENTRYPOINT ["java", "-jar", "app.jar"] \ No newline at end of file diff --git a/init.sh b/init.sh new file mode 100755 index 0000000..c2a9487 --- /dev/null +++ b/init.sh @@ -0,0 +1,636 @@ +#!/bin/bash + +# Java21 Template 初始化脚本 +# 用于将模板仓库初始化为具体的服务仓库 + +set -e + +echo "Java21 Template 初始化脚本" +echo "==========================" + +# 默认值 +DEFAULT_COMPANY="com.example" +DEFAULT_SERVICE_NAME="my-service" +DEFAULT_GROUP="com.example.my-service" + +# 显示帮助信息 +show_help() { + echo "用法: $0 --name --group --org " + echo "" + echo "选项:" + echo " --name 服务名称 (默认: my-service)" + echo " --group Maven groupId (默认: com.example.my-service)" + echo " --org 组织名称 (默认: example)" + echo " --harbor Harbor 仓库地址 (默认: harbor.example.com)" + echo " --help 显示此帮助信息" + echo "" + echo "示例:" + echo " $0 --name order-service --group com.company.order --org myteam" +} + +# 解析命令行参数 +SERVICE_NAME="$DEFAULT_SERVICE_NAME" +GROUP_ID="$DEFAULT_GROUP" +ORGANIZATION="example" +HARBOR_URL="harbor.example.com" + +while [[ $# -gt 0 ]]; do + case $1 in + --name) + SERVICE_NAME="$2" + shift 2 + ;; + --group) + GROUP_ID="$2" + shift 2 + ;; + --org) + ORGANIZATION="$2" + shift 2 + ;; + --harbor) + HARBOR_URL="$2" + shift 2 + ;; + --help) + show_help + exit 0 + ;; + *) + echo "未知参数: $1" + show_help + exit 1 + ;; + esac +done + +# 验证必要参数 +if [[ -z "$SERVICE_NAME" || -z "$GROUP_ID" ]]; then + echo "错误: 服务名称和服务组ID是必需的" + show_help + exit 1 +fi + +echo "参数确认:" +echo " 服务名称: $SERVICE_NAME" +echo " Group ID: $GROUP_ID" +echo " 组织: $ORGANIZATION" +echo " Harbor URL: $HARBOR_URL" +echo "" + +read -p "确认继续? [y/N]: " -n 1 -r +echo +if [[ ! $REPLY =~ ^[Yy]$ ]]; then + echo "操作已取消" + exit 1 +fi + +# 提取公司和项目名称 +COMPANY=$(echo "$GROUP_ID" | cut -d. -f1,2 | tr '.' '/') +PROJECT=$(echo "$GROUP_ID" | cut -d. -f3-) + +echo "开始初始化..." + +# 1. 重命名模块目录 +echo "1. 重命名模块目录..." +for dir in tmpl-domain tmpl-application tmpl-infrastructure tmpl-api; do + if [[ -d "$dir" ]]; then + NEW_DIR_NAME="${SERVICE_NAME}-${dir#tmpl}" + echo " 重命名 $dir -> $NEW_DIR_NAME" + mv "$dir" "$NEW_DIR_NAME" + fi +done + +# 2. 替换所有文件中的占位符 +echo "2. 替换占位符..." + +# 查找所有需要替换的文件 +FILES_TO_UPDATE=$(find . -type f \( -name "*.java" -o -name "*.xml" -o -name "*.yml" -o -name "*.yaml" -o -name "*.md" -o -name "*.txt" -o -name "Dockerfile" \) -not -path "./target/*" -not -path "./.git/*") + +for file in $FILES_TO_UPDATE; do + if [[ -f "$file" ]]; then + # 替换占位符 + sed -i.bak "s/com\.example\.tmpl/$GROUP_ID/g" "$file" 2>/dev/null || true + sed -i.bak "s/tmpl-service/$SERVICE_NAME/g" "$file" 2>/dev/null || true + sed -i.bak "s/tmpl-domain/${SERVICE_NAME}-domain/g" "$file" 2>/dev/null || true + sed -i.bak "s/tmpl-application/${SERVICE_NAME}-application/g" "$file" 2>/dev/null || true + sed -i.bak "s/tmpl-infrastructure/${SERVICE_NAME}-infrastructure/g" "$file" 2>/dev/null || true + sed -i.bak "s/tmpl-api/${SERVICE_NAME}-api/g" "$file" 2>/dev/null || true + sed -i.bak "s/example\.tmpl/$PROJECT/g" "$file" 2>/dev/null || true + sed -i.bak "s/harbor\.example\.com/$HARBOR_URL/g" "$file" 2>/dev/null || true + + # 删除备份文件 + rm -f "$file.bak" + fi +done + +# 3. 更新 pom.xml 文件中的模块名称 +echo "3. 更新 pom.xml 模块名称..." +if [[ -f "pom.xml" ]]; then + sed -i.bak "s/tmpl-domain/${SERVICE_NAME}-domain/g" pom.xml + sed -i.bak "s/tmpl-application/${SERVICE_NAME}-application/g" pom.xml + sed -i.bak "s/tmpl-infrastructure/${SERVICE_NAME}-infrastructure/g" pom.xml + sed -i.bak "s/tmpl-api/${SERVICE_NAME}-api/g" pom.xml + rm -f pom.xml.bak +fi + +# 4. 更新 Dockerfile +echo "4. 更新 Dockerfile..." +if [[ -f "Dockerfile" ]]; then + sed -i.bak "s/tmpl-api-.*\.jar/${SERVICE_NAME}-api-\*\.jar/g" Dockerfile + rm -f Dockerfile.bak +fi + +# 5. 创建 .gitignore 如果不存在 +echo "5. 确保 .gitignore 存在..." +if [[ ! -f ".gitignore" ]]; then + cat > .gitignore << 'EOF' +# Compiled class file +*.class + +# Log file +*.log + +# BlueJ files +*.ctxt + +# Mobile Tools for Java (J2ME) +.mtj.tmp/ +.mwj.tmp/ +.mch.tmp/ + +# Package Files # +*.jar +*.war +*.nar +*.ear +*.zip +*.tar.gz +*.rar + +# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml +hs_err_pid* +replay_pid* + +# Maven +target/ +!target/README +pom.xml.tag +pom.xml.releaseBackup +pom.xml.versionsBackup +pom.xml.next +release.properties +dependency-reduced-pom.xml +buildNumber.properties +.mvn/timing.properties +# Maven wrapper +.wrapper/downloads/ +.wrapper/gradle/ + +# Compilation node.js +Gruntfile.js +.npm/ + +# Coverage directory used by tools like istanbul +coverage/ +*.lcov + +# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (http://nodejs.org/api/addons.html) +build/Release + +# Istanbul output +/index.html +/coverage + +# JSHint +jshint-report.xml + +# JSLint +jslint.xml + +# Mocha +mochawesome-report/ + +# Rome +_dist/ + +# RequireJS build files +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# TypeScript v1 declaration files +typings/ + +# TypeScript cache +*.tsbuildinfo + +# JET required cache (.cache directories) +.cache + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# parcel-bundler cache (https://parceljs.org/) +.cache + +# Next.js build output +.next +out/ + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# See https://www.gatsbyjs.org/docs/global-css/#adding-global-styles-as-a-css-file +public/.cache/ +public/static/ + +# vuepress build output +.vuepress/dist + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# yarn v2 +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* + +# Java +*.iml +*.ipr +*.iws +.idea/ + +# Target directory for Maven builds +target/ + +# Environment variable files +.env +.env.local +.env.development.local +.env.test.local +.env.production.local + +# Docker +.docker/ + +# OS generated files +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# IDE +.vs/ +.vscode/ +*.swp +*.swo +*~ + +# Logs +logs +*.log + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (http://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# TypeScript v1 declaration files +typings/ + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Various things for Meteor +.meteor/local/ + +# Git book +_book + +# s2i +.s2i/ + +# Eclipse +.metadata +bin/ +tmp/ +*.tmp +*.bak +*.swp +*~.nib +local.properties +.settings/ +.loadpath +.recommenders + +# Eclipse Core +.binary + +# External API docs +.externalToolBuilders/ +.externalNativeBuild/ + +# Gradle +.gradle +.gradle/ +!gradle/wrapper/gradle-wrapper.jar + +# Leiningen +.classpath +.libs/ +.log +.lein-deps-sum +.lein-native-sum +.lein-plugins/ +.lein-repl-history +.revlibs +.nrepl-port + +# sbt +target/ +lib_managed/ +src_managed/ +project_target/ +scalaboot.properties + +# Lift web +.lift.properties + +# Lein cljs +.cljscache/ + +# Play Framework +lib_managed/ +project/boot/ +target/ +tmp/ +template Masquerade/ + +# Simple Build Tool +project/build/ +project/plugins/ + +# Scala +*.class +*.jar +*.war +*.ear +.scala_dependencies +.worksheet +.generated +.target + +# sbteclipse +.sbtconfig +.sbtserver + +# IntelliJ IDEA +*.iml +*.ipr +*.iws +.idea/ + +# NetBeans +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +# VS Code +.vscode/ + +# Mac +.DS_Store +.AppleDouble +.LSOverride + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +# Windows +Thumbs.db +Thumbs.db:encryptable +ehthumbs.db +ehthumbs_vista.db + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + +# End +EOF +fi + +# 6. 创建 README.md +echo "6. 创建 README.md..." +cat > README.md << EOF +# $SERVICE_NAME + +基于 Java 21 和 Clean Architecture 的微服务模板。 + +## 项目结构 + +- \`${SERVICE_NAME}-domain\`: 领域层 +- \`${SERVICE_NAME}-application\`: 应用层 +- \`${SERVICE_NAME}-infrastructure\`: 基础设施层 +- \`${SERVICE_NAME}-api\`: 接入层 + +## 快速开始 + +### 环境要求 + +- Java 21 +- Maven 3.6+ +- Docker (可选) + +### 本地运行 + +\`\`\`bash +# 编译 +mvn clean compile + +# 运行测试 +mvn test + +# 打包 +mvn package + +# 运行应用 +java -jar ${SERVICE_NAME}-api/target/${SERVICE_NAME}-api-*.jar +\`\`\` + +### API 文档 + +访问 http://localhost:8080/swagger-ui.html 查看 API 文档。 + +## 配置 + +应用支持以下环境变量: + +- \`SPRING_DATASOURCE_URL\`: 数据库连接URL +- \`SPRING_DATASOURCE_USERNAME\`: 数据库用户名 +- \`SPRING_DATASOURCE_PASSWORD\`: 数据库密码 +- \`REDIS_HOST\`: Redis主机地址 +- \`REDIS_PORT\`: Redis端口 + +## 构建 Docker 镜像 + +\`\`\`bash +docker build -t $HARBOR_URL/$ORGANIZATION/$SERVICE_NAME:\$(git rev-parse --short HEAD) . +\`\`\` + +## 许可证 + +MIT License +EOF + +# 7. 创建 .editorconfig +echo "7. 创建 .editorconfig..." +cat > .editorconfig << 'EOF' +# EditorConfig is awesome: http://EditorConfig.org + +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 4 + +[*.md] +trim_trailing_whitespace = false + +[*.yml] +indent_size = 2 + +[*.yaml] +indent_size = 2 +EOF + +# 8. 完成 +echo "" +echo "✅ 初始化完成!" +echo "" +echo "接下来的步骤:" +echo "1. 检查生成的文件是否正确" +echo "2. 运行 mvn clean compile 验证编译" +echo "3. 运行 mvn test 验证测试" +echo "4. 根据需要进一步定制代码" +echo "" +echo "服务名称: $SERVICE_NAME" +echo "Group ID: $GROUP_ID" +echo "项目路径: $(pwd)" \ No newline at end of file diff --git a/java-service-template-deploy/README.md b/java-service-template-deploy/README.md new file mode 100644 index 0000000..ba77204 --- /dev/null +++ b/java-service-template-deploy/README.md @@ -0,0 +1,63 @@ +# tmpl-service-deploy + +Kubernetes 部署清单模板 + +## 目录结构 + +``` +. +├── base/ # 基础部署配置 +│ ├── kustomization.yaml +│ ├── deployment.yaml +│ ├── service.yaml +│ ├── configmap.yaml +│ └── hpa.yaml +└── overlays/ # 环境覆盖配置 + ├── dev/ + │ ├── kustomization.yaml + │ └── patch-resources.yaml + ├── staging/ + │ ├── kustomization.yaml + │ └── patch-resources.yaml + └── prod/ + ├── kustomization.yaml + ├── patch-resources.yaml + └── patch-replicas.yaml +``` + +## 使用方法 + +### 构建特定环境的配置 + +```bash +# 开发环境 +kubectl kustomize overlays/dev + +# 预发布环境 +kubectl kustomize overlays/staging + +# 生产环境 +kubectl kustomize overlays/prod +``` + +### 应用到集群 + +```bash +# 开发环境 +kubectl apply -k overlays/dev + +# 预发布环境 +kubectl apply -k overlays/staging + +# 生产环境 +kubectl apply -k overlays/prod +``` + +## 自定义 + +根据实际需要修改以下内容: + +1. `base/deployment.yaml` - 容器镜像名称和资源配置 +2. `base/configmap.yaml` - 应用配置 +3. 各环境的 `kustomization.yaml` - 镜像标签 +4. 各环境的 `patch-*.yaml` - 资源和副本数量配置 \ No newline at end of file diff --git a/java-service-template-deploy/base/configmap.yaml b/java-service-template-deploy/base/configmap.yaml new file mode 100644 index 0000000..e27c422 --- /dev/null +++ b/java-service-template-deploy/base/configmap.yaml @@ -0,0 +1,20 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: tmpl-service-config +data: + application.yml: | + spring: + datasource: + url: jdbc:postgresql://postgres:5432/tmpl_db + username: postgres + password: password + redis: + host: redis + port: 6379 + server: + shutdown: graceful + app: + observability: + slow-query-threshold-ms: 200 + slow-http-call-threshold-ms: 3000 \ No newline at end of file diff --git a/java-service-template-deploy/base/deployment.yaml b/java-service-template-deploy/base/deployment.yaml new file mode 100644 index 0000000..48f69ad --- /dev/null +++ b/java-service-template-deploy/base/deployment.yaml @@ -0,0 +1,53 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: tmpl-service + labels: + app: tmpl-service +spec: + replicas: 1 + selector: + matchLabels: + app: tmpl-service + template: + metadata: + labels: + app: tmpl-service + spec: + containers: + - name: tmpl-api + image: tmpl-api:latest + ports: + - containerPort: 8080 + env: + - name: SPRING_PROFILES_ACTIVE + value: "prod" + - name: SERVER_PORT + value: "8080" + livenessProbe: + httpGet: + path: /actuator/health/liveness + port: 8080 + initialDelaySeconds: 15 + periodSeconds: 10 + readinessProbe: + httpGet: + path: /actuator/health/readiness + port: 8080 + initialDelaySeconds: 10 + periodSeconds: 5 + resources: + requests: + memory: "512Mi" + cpu: "250m" + limits: + memory: "1Gi" + cpu: "500m" + securityContext: + runAsNonRoot: true + runAsUser: 1001 + allowPrivilegeEscalation: false + readOnlyRootFilesystem: false + capabilities: + drop: + - ALL \ No newline at end of file diff --git a/java-service-template-deploy/base/hpa.yaml b/java-service-template-deploy/base/hpa.yaml new file mode 100644 index 0000000..1baa132 --- /dev/null +++ b/java-service-template-deploy/base/hpa.yaml @@ -0,0 +1,24 @@ +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: tmpl-service-hpa +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: tmpl-service + minReplicas: 1 + maxReplicas: 10 + metrics: + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: 70 + - type: Resource + resource: + name: memory + target: + type: Utilization + averageUtilization: 80 \ No newline at end of file diff --git a/java-service-template-deploy/base/kustomization.yaml b/java-service-template-deploy/base/kustomization.yaml new file mode 100644 index 0000000..1058ac4 --- /dev/null +++ b/java-service-template-deploy/base/kustomization.yaml @@ -0,0 +1,15 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +resources: +- deployment.yaml +- service.yaml +- configmap.yaml +- hpa.yaml + +namespace: dev + +images: +- name: tmpl-api + newName: harbor.example.com/library/tmpl-service + newTag: latest \ No newline at end of file diff --git a/java-service-template-deploy/base/service.yaml b/java-service-template-deploy/base/service.yaml new file mode 100644 index 0000000..b3754ed --- /dev/null +++ b/java-service-template-deploy/base/service.yaml @@ -0,0 +1,14 @@ +apiVersion: v1 +kind: Service +metadata: + name: tmpl-service + labels: + app: tmpl-service +spec: + selector: + app: tmpl-service + ports: + - protocol: TCP + port: 80 + targetPort: 8080 + type: ClusterIP \ No newline at end of file diff --git a/java-service-template-deploy/overlays/dev/kustomization.yaml b/java-service-template-deploy/overlays/dev/kustomization.yaml new file mode 100644 index 0000000..8701329 --- /dev/null +++ b/java-service-template-deploy/overlays/dev/kustomization.yaml @@ -0,0 +1,13 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +bases: +- ../base + +patchesStrategicMerge: +- patch-resources.yaml + +images: +- name: tmpl-api + newName: harbor.example.com/library/tmpl-service + newTag: dev-latest \ No newline at end of file diff --git a/java-service-template-deploy/overlays/dev/patch-resources.yaml b/java-service-template-deploy/overlays/dev/patch-resources.yaml new file mode 100644 index 0000000..7977064 --- /dev/null +++ b/java-service-template-deploy/overlays/dev/patch-resources.yaml @@ -0,0 +1,17 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: tmpl-service +spec: + replicas: 1 + template: + spec: + containers: + - name: tmpl-api + resources: + requests: + memory: "256Mi" + cpu: "100m" + limits: + memory: "512Mi" + cpu: "200m" \ No newline at end of file diff --git a/java-service-template-deploy/overlays/prod/kustomization.yaml b/java-service-template-deploy/overlays/prod/kustomization.yaml new file mode 100644 index 0000000..3d1dc36 --- /dev/null +++ b/java-service-template-deploy/overlays/prod/kustomization.yaml @@ -0,0 +1,14 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +bases: +- ../base + +patchesStrategicMerge: +- patch-resources.yaml +- patch-replicas.yaml + +images: +- name: tmpl-api + newName: harbor.example.com/library/tmpl-service + newTag: prod-latest \ No newline at end of file diff --git a/java-service-template-deploy/overlays/prod/patch-replicas.yaml b/java-service-template-deploy/overlays/prod/patch-replicas.yaml new file mode 100644 index 0000000..27edcb7 --- /dev/null +++ b/java-service-template-deploy/overlays/prod/patch-replicas.yaml @@ -0,0 +1,6 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: tmpl-service +spec: + replicas: 3 \ No newline at end of file diff --git a/java-service-template-deploy/overlays/prod/patch-resources.yaml b/java-service-template-deploy/overlays/prod/patch-resources.yaml new file mode 100644 index 0000000..6dda85b --- /dev/null +++ b/java-service-template-deploy/overlays/prod/patch-resources.yaml @@ -0,0 +1,17 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: tmpl-service +spec: + replicas: 3 + template: + spec: + containers: + - name: tmpl-api + resources: + requests: + memory: "1Gi" + cpu: "500m" + limits: + memory: "2Gi" + cpu: "1000m" \ No newline at end of file diff --git a/java-service-template-deploy/overlays/staging/kustomization.yaml b/java-service-template-deploy/overlays/staging/kustomization.yaml new file mode 100644 index 0000000..9ac66b2 --- /dev/null +++ b/java-service-template-deploy/overlays/staging/kustomization.yaml @@ -0,0 +1,13 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +bases: +- ../base + +patchesStrategicMerge: +- patch-resources.yaml + +images: +- name: tmpl-api + newName: harbor.example.com/library/tmpl-service + newTag: staging-latest \ No newline at end of file diff --git a/java-service-template-deploy/overlays/staging/patch-resources.yaml b/java-service-template-deploy/overlays/staging/patch-resources.yaml new file mode 100644 index 0000000..25cebe4 --- /dev/null +++ b/java-service-template-deploy/overlays/staging/patch-resources.yaml @@ -0,0 +1,17 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: tmpl-service +spec: + replicas: 2 + template: + spec: + containers: + - name: tmpl-api + resources: + requests: + memory: "512Mi" + cpu: "250m" + limits: + memory: "1Gi" + cpu: "500m" \ No newline at end of file diff --git a/mvnw b/mvnw new file mode 100755 index 0000000..f95ed56 --- /dev/null +++ b/mvnw @@ -0,0 +1,62 @@ +#!/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. +# ---------------------------------------------------------------------------- + +# OS specific support. $var _must_ be set to either true or false. +cygwin=false; +darwin=false; +mingw=false +case "$(uname)" in + CYGWIN*) cygwin=true ;; + MINGW*) mingw=true;; + Darwin*) darwin=true + # Use /usr/libexec/java_home if available, otherwise fall back to /Library/Java/Home + if [ -x "/usr/libexec/java_home" ] ; then + JAVA_HOME=$(/usr/libexec/java_home) + else + JAVA_HOME=/Library/Java/Home + fi + ;; +esac + +if [ -n "$JAVA_HOME" ] && [ -x "$JAVA_HOME/bin/java" ]; then + echo "Using Java: $JAVA_HOME/bin/java" +else + echo "Please set the JAVA_HOME variable in your environment to match the location of your Java installation." >&2 + exit 1 +fi + +# Set MAVEN_PROJECTBASEDIR +MAVEN_PROJECTBASEDIR=`dirname "$0"` +MAVEN_PROJECTBASEDIR=`cd "$MAVEN_PROJECTBASEDIR/."`; pwd + +MAVEN_CMD_LINE_ARGS="$MAVEN_CMD_LINE_ARGS -Dmaven.home=${MAVEN_PROJECTBASEDIR}/.mavenwrapper" +MAVEN_CMD_LINE_ARGS="$MAVEN_CMD_LINE_ARGS -Dmaven.multiModuleProjectDirectory=${MAVEN_PROJECTBASEDIR}" + +WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain + +# For Cygwin, switch paths to Windows format before running java +if $cygwin || $mingw; then + MAVEN_PROJECTBASEDIR=`cygpath --path --windows "$MAVEN_PROJECTBASEDIR"` + JAVA_HOME=`cygpath --path --windows "$JAVA_HOME"` +fi + +export MAVEN_PROJECTBASEDIR + +exec "$JAVA_HOME/bin/java" $MAVEN_CMD_LINE_ARGS -classpath "${MAVEN_PROJECTBASEDIR}/.mavenwrapper/maven-wrapper-3.2.0.jar" "$WRAPPER_LAUNCHER" $@ \ No newline at end of file diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..322b766 --- /dev/null +++ b/pom.xml @@ -0,0 +1,172 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.5.10 + + + + com.example.tmpl + tmpl-service + 0.0.1-SNAPSHOT + pom + tmpl-service + Java 21 Service Template - Clean Architecture + + + tmpl-domain + tmpl-application + tmpl-infrastructure + tmpl-api + + + + 21 + 21 + 21 + UTF-8 + + + 1.6.3 + 2.8.4 + 1.3.0 + + + 2.44.0 + 4.8.6.6 + 11.1.1 + 0.8.12 + + + + + + + com.example.tmpl + tmpl-domain + ${project.version} + + + com.example.tmpl + tmpl-application + ${project.version} + + + com.example.tmpl + tmpl-infrastructure + ${project.version} + + + + + org.mapstruct + mapstruct + ${mapstruct.version} + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + ${springdoc.version} + + + com.tngtech.archunit + archunit-junit5 + ${archunit.version} + test + + + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + 21 + 21 + + + org.mapstruct + mapstruct-processor + ${mapstruct.version} + + + + -Amapstruct.defaultComponentModel=spring + + + + + + + com.diffplug.spotless + spotless-maven-plugin + ${spotless.version} + + + + 1.25.2 + + + + + + java|javax|jakarta,org,com,com.example + + + + + + + + org.jacoco + jacoco-maven-plugin + ${jacoco.version} + + + + + com.github.spotbugs + spotbugs-maven-plugin + ${spotbugs.version} + + Max + Medium + true + + + + + + org.owasp + dependency-check-maven + ${dependency-check.version} + + 7 + + HTML + JSON + + + + + + + + + + com.diffplug.spotless + spotless-maven-plugin + + + + \ No newline at end of file diff --git a/tmpl-api/pom.xml b/tmpl-api/pom.xml new file mode 100644 index 0000000..53ca8ae --- /dev/null +++ b/tmpl-api/pom.xml @@ -0,0 +1,136 @@ + + + 4.0.0 + + + com.example.tmpl + tmpl-service + 0.0.1-SNAPSHOT + + + tmpl-api + tmpl-api + 接入层 - REST API + 启动入口 + + + + + com.example.tmpl + tmpl-application + + + com.example.tmpl + tmpl-infrastructure + + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-validation + + + org.springframework.boot + spring-boot-starter-actuator + + + org.springframework.boot + spring-boot-starter-security + + + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + + + + + io.micrometer + micrometer-registry-prometheus + + + + + org.mapstruct + mapstruct + + + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework.security + spring-security-test + test + + + com.tngtech.archunit + archunit-junit5 + + + com.h2database + h2 + test + + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + + org.jacoco + jacoco-maven-plugin + + + + prepare-agent + + + + report + test + + report + + + + check + + check + + + + + BUNDLE + + + LINE + COVEREDRATIO + 0.70 + + + + + + + + + + + \ No newline at end of file diff --git a/tmpl-api/src/main/java/com/example/tmpl/api/TmplApplication.java b/tmpl-api/src/main/java/com/example/tmpl/api/TmplApplication.java new file mode 100644 index 0000000..7da333b --- /dev/null +++ b/tmpl-api/src/main/java/com/example/tmpl/api/TmplApplication.java @@ -0,0 +1,17 @@ +package com.example.tmpl.api; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * 【接入层】应用启动类。 + * + *

使用 Spring Boot 启动应用,启用自动配置。 + */ +@SpringBootApplication +public class TmplApplication { + + public static void main(String[] args) { + SpringApplication.run(TmplApplication.class, args); + } +} \ No newline at end of file diff --git a/tmpl-api/src/main/java/com/example/tmpl/api/config/OpenApiConfig.java b/tmpl-api/src/main/java/com/example/tmpl/api/config/OpenApiConfig.java new file mode 100644 index 0000000..8450dd3 --- /dev/null +++ b/tmpl-api/src/main/java/com/example/tmpl/api/config/OpenApiConfig.java @@ -0,0 +1,28 @@ +package com.example.tmpl.api.config; + +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.info.Contact; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * 【接入层】OpenAPI (Swagger) 配置。 + * + *

配置 API 文档相关信息。 + */ +@Configuration +public class OpenApiConfig { + + @Bean + public OpenAPI customOpenAPI() { + return new OpenAPI() + .info(new Info() + .title("Java21 Template API") + .version("1.0.0") + .description("基于 Clean Architecture 的 Java 21 模板服务 API") + .contact(new Contact() + .name("Development Team") + .email("dev@example.com"))); + } +} \ No newline at end of file diff --git a/tmpl-api/src/main/java/com/example/tmpl/api/config/SecurityConfig.java b/tmpl-api/src/main/java/com/example/tmpl/api/config/SecurityConfig.java new file mode 100644 index 0000000..72ed8c4 --- /dev/null +++ b/tmpl-api/src/main/java/com/example/tmpl/api/config/SecurityConfig.java @@ -0,0 +1,33 @@ +package com.example.tmpl.api.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.web.SecurityFilterChain; + +/** + * 【接入层】安全配置。 + * + *

配置基本的安全策略。 + */ +@Configuration +@EnableWebSecurity +public class SecurityConfig { + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http + .authorizeHttpRequests(authz -> authz + .requestMatchers("/api/**").authenticated() + .requestMatchers("/actuator/**").permitAll() // 健康检查等端点公开 + .anyRequest().permitAll() + ) + .csrf(csrf -> csrf.disable()) // API 通常禁用 CSRF + .httpBasic(basic -> basic.disable()) // 禁用基本认证,使用其他认证方式 + .formLogin(form -> form.disable()) // 禁用表单登录 + .logout(logout -> logout.disable()); // 禁用默认登出 + + return http.build(); + } +} \ No newline at end of file diff --git a/tmpl-api/src/main/java/com/example/tmpl/api/config/WebConfig.java b/tmpl-api/src/main/java/com/example/tmpl/api/config/WebConfig.java new file mode 100644 index 0000000..c60733d --- /dev/null +++ b/tmpl-api/src/main/java/com/example/tmpl/api/config/WebConfig.java @@ -0,0 +1,36 @@ +package com.example.tmpl.api.config; + +import com.example.tmpl.api.filter.TenantContextFilter; +import com.example.tmpl.api.filter.TraceIdFilter; +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * 【接入层】Web 配置。 + * + *

注册过滤器和其他 Web 相关配置。 + */ +@Configuration +public class WebConfig { + + @Bean + public FilterRegistrationBean traceIdFilter() { + FilterRegistrationBean registrationBean = new FilterRegistrationBean<>(); + registrationBean.setFilter(new TraceIdFilter()); + registrationBean.addUrlPatterns("/api/*"); + registrationBean.setName("traceIdFilter"); + registrationBean.setOrder(1); + return registrationBean; + } + + @Bean + public FilterRegistrationBean tenantContextFilter() { + FilterRegistrationBean registrationBean = new FilterRegistrationBean<>(); + registrationBean.setFilter(new TenantContextFilter()); + registrationBean.addUrlPatterns("/api/*"); + registrationBean.setName("tenantContextFilter"); + registrationBean.setOrder(2); + return registrationBean; + } +} \ No newline at end of file diff --git a/tmpl-api/src/main/java/com/example/tmpl/api/controller/OrderController.java b/tmpl-api/src/main/java/com/example/tmpl/api/controller/OrderController.java new file mode 100644 index 0000000..59d37bb --- /dev/null +++ b/tmpl-api/src/main/java/com/example/tmpl/api/controller/OrderController.java @@ -0,0 +1,83 @@ +package com.example.tmpl.api.controller; + +import com.example.tmpl.api.dto.ApiResponse; +import com.example.tmpl.api.dto.CreateOrderRequest; +import com.example.tmpl.api.dto.OrderResponse; +import com.example.tmpl.api.mapper.OrderDtoMapper; +import com.example.tmpl.application.context.TenantScope; +import com.example.tmpl.application.usecase.CreateOrderUseCase; +import com.example.tmpl.application.usecase.DeleteOrderUseCase; +import com.example.tmpl.application.usecase.GetOrderUseCase; +import com.example.tmpl.domain.entity.Order; +import com.example.tmpl.domain.valueobject.OrderId; +import jakarta.validation.Valid; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +/** + * 【接入层】订单控制器。 + * + *

提供订单相关的 REST API 接口。 + */ +@RestController +@RequestMapping("/api/v1/orders") +public class OrderController { + + private final CreateOrderUseCase createOrderUseCase; + private final GetOrderUseCase getOrderUseCase; + private final DeleteOrderUseCase deleteOrderUseCase; + private final OrderDtoMapper dtoMapper; + + public OrderController(CreateOrderUseCase createOrderUseCase, + GetOrderUseCase getOrderUseCase, + DeleteOrderUseCase deleteOrderUseCase, + OrderDtoMapper dtoMapper) { + this.createOrderUseCase = createOrderUseCase; + this.getOrderUseCase = getOrderUseCase; + this.deleteOrderUseCase = deleteOrderUseCase; + this.dtoMapper = dtoMapper; + } + + /** + * 创建订单。 + * + * @param request 创建订单请求 + * @return 订单ID + */ + @PostMapping + public ResponseEntity> createOrder(@Valid @RequestBody CreateOrderRequest request) { + var scope = TenantScope.fromContext(); + var command = dtoMapper.toCreateOrderCommand(request); + var orderId = createOrderUseCase.execute(command, scope); + return ResponseEntity.ok(ApiResponse.success(orderId.value())); + } + + /** + * 获取订单详情。 + * + * @param id 订单ID + * @return 订单详情 + */ + @GetMapping("/{id}") + public ResponseEntity> getOrder(@PathVariable String id) { + var scope = TenantScope.fromContext(); + var query = new com.example.tmpl.application.port.in.OrderDetailQuery(OrderId.of(id)); + Order order = getOrderUseCase.execute(query, scope); + OrderResponse response = dtoMapper.toOrderResponse(order); + return ResponseEntity.ok(ApiResponse.success(response)); + } + + /** + * 删除订单。 + * + * @param id 订单ID + * @return 操作结果 + */ + @DeleteMapping("/{id}") + public ResponseEntity> deleteOrder(@PathVariable String id) { + var scope = TenantScope.fromContext(); + var orderId = OrderId.of(id); + deleteOrderUseCase.execute(orderId, scope); + return ResponseEntity.ok(ApiResponse.success(null)); + } +} \ No newline at end of file diff --git a/tmpl-api/src/main/java/com/example/tmpl/api/dto/ApiResponse.java b/tmpl-api/src/main/java/com/example/tmpl/api/dto/ApiResponse.java new file mode 100644 index 0000000..8dc7ca0 --- /dev/null +++ b/tmpl-api/src/main/java/com/example/tmpl/api/dto/ApiResponse.java @@ -0,0 +1,26 @@ +package com.example.tmpl.api.dto; + +/** + * 【接入层】统一响应包装类。 + * + *

封装 API 响应结果,包含状态码、消息和数据。 + */ +public record ApiResponse( + int code, + String message, + T data, + String timestamp +) { + + public static ApiResponse success(T data) { + return new ApiResponse<>(200, "success", data, java.time.Instant.now().toString()); + } + + public static ApiResponse error(int code, String message) { + return new ApiResponse<>(code, message, null, java.time.Instant.now().toString()); + } + + public static ApiResponse error(String message) { + return new ApiResponse<>(500, message, null, java.time.Instant.now().toString()); + } +} \ No newline at end of file diff --git a/tmpl-api/src/main/java/com/example/tmpl/api/dto/CreateOrderRequest.java b/tmpl-api/src/main/java/com/example/tmpl/api/dto/CreateOrderRequest.java new file mode 100644 index 0000000..b7003fd --- /dev/null +++ b/tmpl-api/src/main/java/com/example/tmpl/api/dto/CreateOrderRequest.java @@ -0,0 +1,19 @@ +package com.example.tmpl.api.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; + +/** + * 【接入层】创建订单请求 DTO。 + * + *

接收前端创建订单的请求参数。 + */ +public record CreateOrderRequest( + @NotBlank(message = "订单标题不能为空") + String title, + + @NotBlank(message = "订单金额不能为空") + @Pattern(regexp = "^\\d+(\\.\\d{1,2})?$", message = "金额格式不正确") + String totalAmount +) {} \ No newline at end of file diff --git a/tmpl-api/src/main/java/com/example/tmpl/api/dto/OrderResponse.java b/tmpl-api/src/main/java/com/example/tmpl/api/dto/OrderResponse.java new file mode 100644 index 0000000..1e7373d --- /dev/null +++ b/tmpl-api/src/main/java/com/example/tmpl/api/dto/OrderResponse.java @@ -0,0 +1,22 @@ +package com.example.tmpl.api.dto; + +import com.example.tmpl.domain.valueobject.OrderStatus; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** + * 【接入层】订单响应 DTO。 + * + *

向前端返回订单详情信息。 + */ +public record OrderResponse( + String id, + String customerId, + String userId, + String title, + BigDecimal totalAmount, + OrderStatus status, + LocalDateTime createTime, + LocalDateTime updateTime +) {} \ No newline at end of file diff --git a/tmpl-api/src/main/java/com/example/tmpl/api/exception/GlobalExceptionHandler.java b/tmpl-api/src/main/java/com/example/tmpl/api/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..9d21776 --- /dev/null +++ b/tmpl-api/src/main/java/com/example/tmpl/api/exception/GlobalExceptionHandler.java @@ -0,0 +1,150 @@ +package com.example.tmpl.api.exception; + +import com.example.tmpl.api.dto.ApiResponse; +import com.example.tmpl.domain.exception.DomainException; +import com.example.tmpl.domain.exception.ErrorCode; +import jakarta.validation.ConstraintViolation; +import jakarta.validation.ConstraintViolationException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.MissingServletRequestParameterException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; + +import java.time.Instant; +import java.util.HashMap; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * 【接入层】全局异常处理器。 + * + *

统一处理各种异常,转换为标准的 API 响应格式。 + */ +@RestControllerAdvice +public class GlobalExceptionHandler { + + private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class); + + /** + * 处理领域异常。 + */ + @ExceptionHandler(DomainException.class) + public ResponseEntity> handleDomainException(DomainException ex) { + log.warn("业务异常: {}", ex.getMessage(), ex); + + ErrorCode errorCode = ex.getErrorCode(); + HttpStatus httpStatus = getHttpStatusForErrorCode(errorCode); + + return ResponseEntity.status(httpStatus) + .body(ApiResponse.error(httpStatus.value(), errorCode.getMessage())); + } + + /** + * 处理参数校验异常。 + */ + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity> handleValidationException(MethodArgumentNotValidException ex) { + log.warn("参数校验失败: {}", ex.getMessage()); + + Map errors = new HashMap<>(); + ex.getBindingResult().getFieldErrors().forEach(error -> + errors.put(error.getField(), error.getDefaultMessage()) + ); + + String errorMessage = errors.entrySet().stream() + .map(entry -> entry.getKey() + ": " + entry.getValue()) + .collect(Collectors.joining(", ")); + + return ResponseEntity.badRequest() + .body(ApiResponse.error(HttpStatus.BAD_REQUEST.value(), + "参数校验失败: " + errorMessage)); + } + + /** + * 处理约束违反异常。 + */ + @ExceptionHandler(ConstraintViolationException.class) + public ResponseEntity> handleConstraintViolationException(ConstraintViolationException ex) { + log.warn("约束违反: {}", ex.getMessage()); + + String errorMessage = ex.getConstraintViolations().stream() + .map(ConstraintViolation::getMessage) + .collect(Collectors.joining(", ")); + + return ResponseEntity.badRequest() + .body(ApiResponse.error(HttpStatus.BAD_REQUEST.value(), + "参数校验失败: " + errorMessage)); + } + + /** + * 处理缺少请求参数异常。 + */ + @ExceptionHandler(MissingServletRequestParameterException.class) + public ResponseEntity> handleMissingParamException(MissingServletRequestParameterException ex) { + log.warn("缺少请求参数: {}", ex.getMessage()); + + String errorMsg = String.format("缺少必需参数: %s (%s)", ex.getParameterName(), ex.getParameterType()); + return ResponseEntity.badRequest() + .body(ApiResponse.error(HttpStatus.BAD_REQUEST.value(), errorMsg)); + } + + /** + * 处理参数类型不匹配异常。 + */ + @ExceptionHandler(MethodArgumentTypeMismatchException.class) + public ResponseEntity> handleTypeMismatchException(MethodArgumentTypeMismatchException ex) { + log.warn("参数类型不匹配: {}", ex.getMessage()); + + String errorMsg = String.format("参数 '%s' 类型不匹配,期望类型: %s", + ex.getName(), ex.getRequiredType().getSimpleName()); + return ResponseEntity.badRequest() + .body(ApiResponse.error(HttpStatus.BAD_REQUEST.value(), errorMsg)); + } + + /** + * 处理请求体无法读取异常。 + */ + @ExceptionHandler(HttpMessageNotReadableException.class) + public ResponseEntity> handleHttpMessageNotReadable(HttpMessageNotReadableException ex) { + log.error("请求体无法读取: {}", ex.getMessage()); + + return ResponseEntity.badRequest() + .body(ApiResponse.error(HttpStatus.BAD_REQUEST.value(), + "请求体格式错误,请检查 JSON 格式")); + } + + /** + * 处理其他未预期的异常。 + */ + @ExceptionHandler(Exception.class) + public ResponseEntity> handleGenericException(Exception ex) { + log.error("未预期的异常: ", ex); + + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(ApiResponse.error(HttpStatus.INTERNAL_SERVER_ERROR.value(), + "系统内部错误")); + } + + /** + * 根据错误码获取对应的 HTTP 状态码。 + */ + private HttpStatus getHttpStatusForErrorCode(ErrorCode errorCode) { + switch (errorCode) { + case COMMON_VALIDATION_ERROR: + return HttpStatus.BAD_REQUEST; + case ORDER_NOT_FOUND: + return HttpStatus.NOT_FOUND; + case COMMON_OPTIMISTIC_LOCK_CONFLICT: + return HttpStatus.CONFLICT; + case COMMON_INTERNAL_ERROR: + default: + return HttpStatus.INTERNAL_SERVER_ERROR; + } + } +} \ No newline at end of file diff --git a/tmpl-api/src/main/java/com/example/tmpl/api/filter/TenantContextFilter.java b/tmpl-api/src/main/java/com/example/tmpl/api/filter/TenantContextFilter.java new file mode 100644 index 0000000..e0aa721 --- /dev/null +++ b/tmpl-api/src/main/java/com/example/tmpl/api/filter/TenantContextFilter.java @@ -0,0 +1,56 @@ +package com.example.tmpl.api.filter; + +import com.example.tmpl.application.context.TenantContext; +import jakarta.servlet.Filter; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.ServletRequest; +import jakarta.servlet.ServletResponse; +import jakarta.servlet.http.HttpServletRequest; +import org.slf4j.MDC; +import org.springframework.core.annotation.Order; + +import java.io.IOException; + +/** + * 【接入层】租户安全上下文过滤器。 + * + *

从请求头解析租户信息,设置到 TenantContext 和 MDC。 + */ +@Order(2) +public class TenantContextFilter implements Filter { + + private static final String CUSTOMER_ID_HEADER = "X-Customer-Id"; + private static final String ORG_ID_HEADER = "X-Org-Id"; + private static final String USER_ID_HEADER = "X-User-Id"; + private static final String USER_ROLES_HEADER = "X-User-Roles"; + + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) + throws IOException, ServletException { + + HttpServletRequest httpRequest = (HttpServletRequest) request; + + // 从请求头获取租户信息 + String customerId = httpRequest.getHeader(CUSTOMER_ID_HEADER); + String orgId = httpRequest.getHeader(ORG_ID_HEADER); + String userId = httpRequest.getHeader(USER_ID_HEADER); + String rolesHeader = httpRequest.getHeader(USER_ROLES_HEADER); + String[] roles = rolesHeader != null ? rolesHeader.split(",") : new String[0]; + + // 设置到 TenantContext + TenantContext.set(new TenantContext(customerId, orgId, userId, roles)); + + // 设置到 MDC 以便日志输出 + MDC.put("customerId", customerId); + MDC.put("orgId", orgId); + MDC.put("userId", userId); + + try { + chain.doFilter(request, response); + } finally { + // 清理 TenantContext + TenantContext.clear(); + } + } +} \ No newline at end of file diff --git a/tmpl-api/src/main/java/com/example/tmpl/api/filter/TraceIdFilter.java b/tmpl-api/src/main/java/com/example/tmpl/api/filter/TraceIdFilter.java new file mode 100644 index 0000000..db9bedb --- /dev/null +++ b/tmpl-api/src/main/java/com/example/tmpl/api/filter/TraceIdFilter.java @@ -0,0 +1,63 @@ +package com.example.tmpl.api.filter; + +import jakarta.servlet.Filter; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.ServletRequest; +import jakarta.servlet.ServletResponse; +import jakarta.servlet.http.HttpServletRequest; +import org.slf4j.MDC; +import org.springframework.core.annotation.Order; + +import java.io.IOException; +import java.util.UUID; + +/** + * 【接入层】链路追踪过滤器。 + * + *

生成并设置 traceId 和 requestId,写入 MDC 以便日志关联。 + */ +@Order(1) +public class TraceIdFilter implements Filter { + + private static final String TRACE_ID_HEADER = "X-Trace-Id"; + private static final String REQUEST_ID_HEADER = "X-Request-Id"; + + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) + throws IOException, ServletException { + + HttpServletRequest httpRequest = (HttpServletRequest) request; + + // 获取或生成 traceId + String traceId = httpRequest.getHeader(TRACE_ID_HEADER); + if (traceId == null || traceId.trim().isEmpty()) { + traceId = generateTraceId(); + } + + // 获取或生成 requestId + String requestId = httpRequest.getHeader(REQUEST_ID_HEADER); + if (requestId == null || requestId.trim().isEmpty()) { + requestId = generateRequestId(); + } + + // 设置到 MDC + MDC.put("traceId", traceId); + MDC.put("requestId", requestId); + + try { + chain.doFilter(request, response); + } finally { + // 清理 MDC + MDC.clear(); + } + } + + private String generateTraceId() { + return UUID.randomUUID().toString().replace("-", ""); + } + + private String generateRequestId() { + return UUID.randomUUID().toString().replace("-", ""); + } +} \ No newline at end of file diff --git a/tmpl-api/src/main/java/com/example/tmpl/api/mapper/OrderDtoMapper.java b/tmpl-api/src/main/java/com/example/tmpl/api/mapper/OrderDtoMapper.java new file mode 100644 index 0000000..3f45aea --- /dev/null +++ b/tmpl-api/src/main/java/com/example/tmpl/api/mapper/OrderDtoMapper.java @@ -0,0 +1,29 @@ +package com.example.tmpl.api.mapper; + +import com.example.tmpl.api.dto.CreateOrderRequest; +import com.example.tmpl.api.dto.OrderResponse; +import com.example.tmpl.application.port.in.CreateOrderCommand; +import com.example.tmpl.domain.entity.Order; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; + +/** + * 【接入层】订单 DTO 映射器。 + * + *

使用 MapStruct 自动映射 DTO 与 Command/Domain 对象。 + */ +@Mapper(componentModel = "spring") +public interface OrderDtoMapper { + + CreateOrderCommand toCreateOrderCommand(CreateOrderRequest request); + + @Mapping(target = "id", source = "id.value") + @Mapping(target = "customerId", source = "customerId") + @Mapping(target = "userId", source = "userId") + @Mapping(target = "title", source = "title") + @Mapping(target = "totalAmount", source = "totalAmount.amount") + @Mapping(target = "status", source = "status") + @Mapping(target = "createTime", source = "createTime") + @Mapping(target = "updateTime", source = "updateTime") + OrderResponse toOrderResponse(Order order); +} \ No newline at end of file diff --git a/tmpl-api/src/main/resources/application-dev.yml b/tmpl-api/src/main/resources/application-dev.yml new file mode 100644 index 0000000..1bcf3bd --- /dev/null +++ b/tmpl-api/src/main/resources/application-dev.yml @@ -0,0 +1,26 @@ +# 开发环境配置 +spring: + jpa: + show-sql: true + properties: + hibernate: + format_sql: true + highlight_sql: true + datasource: + url: jdbc:postgresql://localhost:5432/tmpl_dev + username: postgres + password: password + +logging: + level: + com.example.tmpl: DEBUG + org.springframework.web: DEBUG + org.hibernate.SQL: DEBUG + org.hibernate.type.descriptor.sql.BasicBinder: TRACE + +server: + error: + include-message: always + include-binding-errors: always + include-stacktrace: on_param + include-exception: false \ No newline at end of file diff --git a/tmpl-api/src/main/resources/application-prod.yml b/tmpl-api/src/main/resources/application-prod.yml new file mode 100644 index 0000000..5f42b0b --- /dev/null +++ b/tmpl-api/src/main/resources/application-prod.yml @@ -0,0 +1,29 @@ +# 生产环境配置 +spring: + datasource: + hikari: + maximum-pool-size: 20 + minimum-idle: 5 + connection-timeout: 30000 + idle-timeout: 600000 + max-lifetime: 1800000 + leak-detection-threshold: 60000 + +logging: + config: classpath:logback-spring.xml + +server: + error: + include-message: never + include-binding-errors: never + include-stacktrace: never + include-exception: false + +management: + endpoints: + web: + exposure: + include: health,info,prometheus + endpoint: + health: + show-details: never \ No newline at end of file diff --git a/tmpl-api/src/main/resources/application.yml b/tmpl-api/src/main/resources/application.yml new file mode 100644 index 0000000..d1304b3 --- /dev/null +++ b/tmpl-api/src/main/resources/application.yml @@ -0,0 +1,66 @@ +# 公共配置 +server: + port: 8080 + shutdown: graceful + +spring: + application: + name: tmpl-service + profiles: + active: dev + datasource: + url: ${SPRING_DATASOURCE_URL:jdbc:postgresql://localhost:5432/template_db} + username: ${SPRING_DATASOURCE_USERNAME:postgres} + password: ${SPRING_DATASOURCE_PASSWORD:password} + driver-class-name: org.postgresql.Driver + jpa: + hibernate: + ddl-auto: validate + show-sql: false + properties: + hibernate: + dialect: org.hibernate.dialect.PostgreSQLDialect + format_sql: true + flyway: + enabled: true + baseline-on-migrate: true + locations: classpath:db/migration + redis: + host: ${REDIS_HOST:localhost} + port: ${REDIS_PORT:6379} + timeout: 2000ms + lifecycle: + timeout-per-shutdown-phase: 30s + +logging: + level: + com.example.tmpl: INFO + org.springframework.web: INFO + org.springframework.security: INFO + org.springframework.transaction: INFO + org.hibernate.SQL: DEBUG + org.hibernate.type.descriptor.sql.BasicBinder: TRACE + pattern: + console: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%X{traceId:-}] [%X{userId:-}] %logger{36} - %msg%n" + file: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%X{traceId:-}] [%X{userId:-}] %logger{36} - %msg%n" + +management: + endpoints: + web: + exposure: + include: health,info,prometheus + endpoint: + health: + show-details: when-authorized + info: + env: + enabled: true + metrics: + export: + prometheus: + enabled: true + +app: + observability: + slow-query-threshold-ms: 200 + slow-http-call-threshold-ms: 3000 \ No newline at end of file diff --git a/tmpl-api/src/main/resources/db/migration/V1__create_order_table.sql b/tmpl-api/src/main/resources/db/migration/V1__create_order_table.sql new file mode 100644 index 0000000..07e1d27 --- /dev/null +++ b/tmpl-api/src/main/resources/db/migration/V1__create_order_table.sql @@ -0,0 +1,26 @@ +-- V1__create_order_table.sql +-- 创建订单表 + +CREATE TABLE IF NOT EXISTS t_order ( + id VARCHAR(32) PRIMARY KEY, + customer_id VARCHAR(64) NOT NULL, + user_id VARCHAR(64) NOT NULL, + title VARCHAR(255) NOT NULL, + total_amount DECIMAL(10, 2), + status VARCHAR(20) NOT NULL, + create_user_id BIGINT NOT NULL, + create_time TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_user_id BIGINT NOT NULL, + update_time TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + is_deleted BOOLEAN NOT NULL DEFAULT FALSE, + version INTEGER NOT NULL DEFAULT 0 +); + +-- 创建索引 +CREATE INDEX idx_t_order_customer_id ON t_order(customer_id); +CREATE INDEX idx_t_order_user_id ON t_order(user_id); +CREATE INDEX idx_t_order_status ON t_order(status); +CREATE INDEX idx_t_order_create_time ON t_order(create_time); + +-- 创建软删除的复合索引 +CREATE INDEX idx_t_order_customer_deleted ON t_order(customer_id, is_deleted); \ No newline at end of file diff --git a/tmpl-api/src/main/resources/logback-spring.xml b/tmpl-api/src/main/resources/logback-spring.xml new file mode 100644 index 0000000..3041c6b --- /dev/null +++ b/tmpl-api/src/main/resources/logback-spring.xml @@ -0,0 +1,70 @@ + + + + + + + + + logs/app.log + + ${FILE_LOG_PATTERN} + + + logs/app.%d{yyyy-MM-dd}.%i.gz + 100MB + 30 + 3GB + + + + + + logs/app-json.log + + + + + + + + + + + + + logs/app-json.%d{yyyy-MM-dd}.%i.gz + 100MB + 30 + 3GB + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tmpl-api/src/test/java/com/example/tmpl/architecture/ArchitectureTest.java b/tmpl-api/src/test/java/com/example/tmpl/architecture/ArchitectureTest.java new file mode 100644 index 0000000..36ab469 --- /dev/null +++ b/tmpl-api/src/test/java/com/example/tmpl/architecture/ArchitectureTest.java @@ -0,0 +1,140 @@ +package com.example.tmpl.architecture; + +import com.tngtech.archunit.core.domain.JavaClasses; +import com.tngtech.archunit.core.importer.ClassFileImporter; +import com.tngtech.archunit.lang.ArchRule; +import org.junit.jupiter.api.Test; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.*; +import static com.tngtech.archunit.library.Architectures.layeredArchitecture; + +/** + * 【架构测试】架构合规性测试。 + * + *

验证分层架构、依赖方向、注解使用等是否符合架构规范。 + */ +class ArchitectureTest { + + private final JavaClasses importedClasses = new ClassFileImporter().importPackages("com.example.tmpl"); + + @Test + void domainLayerShouldNotDependOnOtherLayers() { + ArchRule rule = classes() + .that().resideInAPackage("..domain..") + .should().onlyDependOnClassesThat().resideInAnyPackage( + "java..", + "jakarta.validation..", + "com.example.tmpl.domain.." + ); + + rule.check(importedClasses); + } + + @Test + void applicationLayerShouldOnlyDependOnDomain() { + ArchRule rule = classes() + .that().resideInAPackage("..application..") + .should().onlyDependOnClassesThat().resideInAnyPackage( + "java..", + "org.springframework.transaction..", + "org.springframework.context..", + "org.springframework.data.commons..", + "com.example.tmpl.domain..", + "com.example.tmpl.application.." + ); + + rule.check(importedClasses); + } + + @Test + void infrastructureLayerShouldOnlyDependOnDomainAndApplication() { + ArchRule rule = classes() + .that().resideInAPackage("..infrastructure..") + .should().onlyDependOnClassesThat().resideInAnyPackage( + "java..", + "org.springframework..", + "org.postgresql..", + "org.flywaydb..", + "org.mapstruct..", + "com.example.tmpl.domain..", + "com.example.tmpl.application..", + "com.example.tmpl.infrastructure.." + ); + + rule.check(importedClasses); + } + + @Test + void apiLayerShouldOnlyDependOnApplicationAndInfrastructure() { + ArchRule rule = classes() + .that().resideInAPackage("..api..") + .should().onlyDependOnClassesThat().resideInAnyPackage( + "java..", + "org.springframework..", + "org.springdoc..", + "io.micrometer..", + "jakarta.validation..", + "com.example.tmpl.domain..", + "com.example.tmpl.application..", + "com.example.tmpl.infrastructure..", + "com.example.tmpl.api.." + ); + + rule.check(importedClasses); + } + + @Test + void controllersShouldNotDependOnInfrastructureDirectly() { + ArchRule rule = classes() + .that().resideInAPackage("..api.controller..") + .should().onlyDependOnClassesThat().resideInAnyPackage( + "java..", + "org.springframework.web..", + "org.springframework.http..", + "jakarta.validation..", + "com.example.tmpl.domain..", + "com.example.tmpl.application..", + "com.example.tmpl.api.dto..", + "com.example.tmpl.api.mapper..", + "com.example.tmpl.api.." + ); + + rule.check(importedClasses); + } + + @Test + void transactionalAnnotationShouldOnlyBeInApplicationLayer() { + ArchRule rule = noClasses() + .that().resideOutsideOfPackage("..application..") + .should().beAnnotatedWith(Transactional.class); + + rule.check(importedClasses); + } + + @Test + void domainClassesShouldNotUseSpringAnnotations() { + ArchRule rule = noClasses() + .that().resideInAPackage("..domain..") + .should().beAnnotatedWith(Service.class); + + rule.check(importedClasses); + } + + @Test + void layeredArchitectureTest() { + ArchRule rule = layeredArchitecture() + .layer("Domain").definedBy("..domain..") + .layer("Application").definedBy("..application..") + .layer("Infrastructure").definedBy("..infrastructure..") + .layer("API").definedBy("..api..") + + .whereLayer("Application").mayNotBeAccessedByAnyLayer() + .whereLayer("Domain").mayOnlyBeAccessedByLayers("Application", "Infrastructure") + .whereLayer("Infrastructure").mayOnlyBeAccessedByLayers("API", "Application") + .whereLayer("API").mayNotBeAccessedByAnyLayer(); + + rule.check(importedClasses); + } +} \ No newline at end of file diff --git a/tmpl-application/pom.xml b/tmpl-application/pom.xml new file mode 100644 index 0000000..c204d31 --- /dev/null +++ b/tmpl-application/pom.xml @@ -0,0 +1,61 @@ + + + 4.0.0 + + + com.example.tmpl + tmpl-service + 0.0.1-SNAPSHOT + + + tmpl-application + tmpl-application + 应用层 - 用例编排,仅依赖 domain + + + + + com.example.tmpl + tmpl-domain + + + + + org.springframework + spring-tx + + + org.springframework + spring-context + + + + + org.springframework.data + spring-data-commons + + + + + org.junit.jupiter + junit-jupiter + test + + + org.assertj + assertj-core + test + + + org.mockito + mockito-junit-jupiter + test + + + \ No newline at end of file diff --git a/tmpl-application/src/main/java/com/example/tmpl/application/context/TenantContext.java b/tmpl-application/src/main/java/com/example/tmpl/application/context/TenantContext.java new file mode 100644 index 0000000..dfc2d8a --- /dev/null +++ b/tmpl-application/src/main/java/com/example/tmpl/application/context/TenantContext.java @@ -0,0 +1,56 @@ +package com.example.tmpl.application.context; + +/** + * 【应用层】租户安全上下文持有者。 + * + *

使用 ThreadLocal 存储当前请求的安全上下文信息。 + *

在 Web 请求开始时由 TenantContextFilter 设置,在请求结束时清理。 + */ +public final class TenantContext { + + private static final ThreadLocal HOLDER = new ThreadLocal<>(); + + private final String customerId; + private final String orgId; + private final String userId; + private final String[] roles; + + public TenantContext(String customerId, String orgId, String userId, String[] roles) { + this.customerId = customerId; + this.orgId = orgId; + this.userId = userId; + this.roles = roles; + } + + public static void set(TenantContext context) { + HOLDER.set(context); + } + + public static TenantContext current() { + TenantContext context = HOLDER.get(); + if (context == null) { + throw new IllegalStateException("TenantContext 未设置,请检查 TenantContextFilter 配置"); + } + return context; + } + + public static void clear() { + HOLDER.remove(); + } + + public String getCustomerId() { + return customerId; + } + + public String getOrgId() { + return orgId; + } + + public String getUserId() { + return userId; + } + + public String[] getRoles() { + return roles; + } +} \ No newline at end of file diff --git a/tmpl-application/src/main/java/com/example/tmpl/application/context/TenantScope.java b/tmpl-application/src/main/java/com/example/tmpl/application/context/TenantScope.java new file mode 100644 index 0000000..0de34c2 --- /dev/null +++ b/tmpl-application/src/main/java/com/example/tmpl/application/context/TenantScope.java @@ -0,0 +1,14 @@ +package com.example.tmpl.application.context; + +/** + * 【应用层】租户安全上下文只读快照。 + * + *

用于在方法间传递安全上下文信息,便于单元测试时模拟。 + */ +public record TenantScope(String customerId, String orgId) { + + public static TenantScope fromContext() { + var ctx = TenantContext.current(); + return new TenantScope(ctx.getCustomerId(), ctx.getOrgId()); + } +} \ No newline at end of file diff --git a/tmpl-application/src/main/java/com/example/tmpl/application/port/in/CreateOrderCommand.java b/tmpl-application/src/main/java/com/example/tmpl/application/port/in/CreateOrderCommand.java new file mode 100644 index 0000000..9d6f528 --- /dev/null +++ b/tmpl-application/src/main/java/com/example/tmpl/application/port/in/CreateOrderCommand.java @@ -0,0 +1,11 @@ +package com.example.tmpl.application.port.in; + +import com.example.tmpl.domain.valueobject.OrderId; + +/** + * 【应用层】创建订单命令。 + */ +public record CreateOrderCommand( + String title, + String totalAmount +) {} \ No newline at end of file diff --git a/tmpl-application/src/main/java/com/example/tmpl/application/port/in/OrderDetailQuery.java b/tmpl-application/src/main/java/com/example/tmpl/application/port/in/OrderDetailQuery.java new file mode 100644 index 0000000..93c711d --- /dev/null +++ b/tmpl-application/src/main/java/com/example/tmpl/application/port/in/OrderDetailQuery.java @@ -0,0 +1,8 @@ +package com.example.tmpl.application.port.in; + +import com.example.tmpl.domain.valueobject.OrderId; + +/** + * 【应用层】查询订单详情查询参数。 + */ +public record OrderDetailQuery(OrderId id) {} \ No newline at end of file diff --git a/tmpl-application/src/main/java/com/example/tmpl/application/port/out/OrderEventPublisher.java b/tmpl-application/src/main/java/com/example/tmpl/application/port/out/OrderEventPublisher.java new file mode 100644 index 0000000..8439ea8 --- /dev/null +++ b/tmpl-application/src/main/java/com/example/tmpl/application/port/out/OrderEventPublisher.java @@ -0,0 +1,11 @@ +package com.example.tmpl.application.port.out; + +import com.example.tmpl.domain.event.DomainEvent; + +/** + * 【应用层】订单事件发布端口。 + */ +public interface OrderEventPublisher { + + void publish(DomainEvent event); +} \ No newline at end of file diff --git a/tmpl-application/src/main/java/com/example/tmpl/application/port/out/OrderPersistencePort.java b/tmpl-application/src/main/java/com/example/tmpl/application/port/out/OrderPersistencePort.java new file mode 100644 index 0000000..6f33c4a --- /dev/null +++ b/tmpl-application/src/main/java/com/example/tmpl/application/port/out/OrderPersistencePort.java @@ -0,0 +1,18 @@ +package com.example.tmpl.application.port.out; + +import com.example.tmpl.domain.entity.Order; +import com.example.tmpl.domain.valueobject.OrderId; + +import java.util.Optional; + +/** + * 【应用层】订单持久化端口。 + */ +public interface OrderPersistencePort { + + void save(Order order); + + Optional findByIdAndCustomerId(OrderId id, String customerId); + + void delete(Order order); +} \ No newline at end of file diff --git a/tmpl-application/src/main/java/com/example/tmpl/application/usecase/CreateOrderUseCase.java b/tmpl-application/src/main/java/com/example/tmpl/application/usecase/CreateOrderUseCase.java new file mode 100644 index 0000000..ec57939 --- /dev/null +++ b/tmpl-application/src/main/java/com/example/tmpl/application/usecase/CreateOrderUseCase.java @@ -0,0 +1,65 @@ +package com.example.tmpl.application.usecase; + +import com.example.tmpl.application.context.TenantScope; +import com.example.tmpl.application.port.in.CreateOrderCommand; +import com.example.tmpl.application.port.out.OrderEventPublisher; +import com.example.tmpl.application.port.out.OrderPersistencePort; +import com.example.tmpl.domain.entity.Order; +import com.example.tmpl.domain.exception.DomainException; +import com.example.tmpl.domain.exception.ErrorCode; +import com.example.tmpl.domain.valueobject.Money; +import com.example.tmpl.domain.valueobject.OrderId; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * 【应用层】创建订单用例。 + * + *

此用例负责协调订单创建流程,包括参数验证、业务规则执行和事件发布。 + */ +@Service +@Transactional +public class CreateOrderUseCase { + + private final OrderPersistencePort persistencePort; + private final OrderEventPublisher eventPublisher; + + public CreateOrderUseCase(OrderPersistencePort persistencePort, OrderEventPublisher eventPublisher) { + this.persistencePort = persistencePort; + this.eventPublisher = eventPublisher; + } + + /** + * 执行创建订单操作。 + * + * @param command 创建订单命令 + * @param tenantScope 租户安全上下文 + * @return 创建的订单ID + */ + public OrderId execute(CreateOrderCommand command, TenantScope tenantScope) { + // 参数验证 + if (command.title() == null || command.title().trim().isEmpty()) { + throw new DomainException(ErrorCode.COMMON_VALIDATION_ERROR, "订单标题不能为空"); + } + + // 解析金额 + Money totalAmount; + try { + totalAmount = Money.of(command.totalAmount()); + } catch (NumberFormatException e) { + throw new DomainException(ErrorCode.COMMON_VALIDATION_ERROR, "订单金额格式不正确"); + } + + // 创建订单聚合根 + Order order = Order.create(tenantScope.customerId(), tenantScope.getUserId(), command.title(), totalAmount); + + // 保存订单 + persistencePort.save(order); + + // 发布领域事件 + order.getDomainEvents().forEach(eventPublisher::publish); + order.clearDomainEvents(); + + return order.getId(); + } +} \ No newline at end of file diff --git a/tmpl-application/src/main/java/com/example/tmpl/application/usecase/DeleteOrderUseCase.java b/tmpl-application/src/main/java/com/example/tmpl/application/usecase/DeleteOrderUseCase.java new file mode 100644 index 0000000..61223e6 --- /dev/null +++ b/tmpl-application/src/main/java/com/example/tmpl/application/usecase/DeleteOrderUseCase.java @@ -0,0 +1,40 @@ +package com.example.tmpl.application.usecase; + +import com.example.tmpl.application.context.TenantScope; +import com.example.tmpl.application.port.out.OrderPersistencePort; +import com.example.tmpl.domain.entity.Order; +import com.example.tmpl.domain.exception.DomainException; +import com.example.tmpl.domain.exception.ErrorCode; +import com.example.tmpl.domain.valueobject.OrderId; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * 【应用层】删除订单用例。 + * + *

此用例负责软删除订单,并确保多租户数据隔离。 + */ +@Service +@Transactional +public class DeleteOrderUseCase { + + private final OrderPersistencePort persistencePort; + + public DeleteOrderUseCase(OrderPersistencePort persistencePort) { + this.persistencePort = persistencePort; + } + + /** + * 执行删除订单操作。 + * + * @param id 订单ID + * @param tenantScope 租户安全上下文 + */ + public void execute(OrderId id, TenantScope tenantScope) { + Order order = persistencePort.findByIdAndCustomerId(id, tenantScope.customerId()) + .orElseThrow(() -> new DomainException(ErrorCode.ORDER_NOT_FOUND)); + + order.markDeleted(); + persistencePort.save(order); + } +} \ No newline at end of file diff --git a/tmpl-application/src/main/java/com/example/tmpl/application/usecase/GetOrderUseCase.java b/tmpl-application/src/main/java/com/example/tmpl/application/usecase/GetOrderUseCase.java new file mode 100644 index 0000000..9eeb6e1 --- /dev/null +++ b/tmpl-application/src/main/java/com/example/tmpl/application/usecase/GetOrderUseCase.java @@ -0,0 +1,39 @@ +package com.example.tmpl.application.usecase; + +import com.example.tmpl.application.context.TenantScope; +import com.example.tmpl.application.port.in.OrderDetailQuery; +import com.example.tmpl.application.port.out.OrderPersistencePort; +import com.example.tmpl.domain.entity.Order; +import com.example.tmpl.domain.exception.DomainException; +import com.example.tmpl.domain.exception.ErrorCode; +import com.example.tmpl.domain.valueobject.OrderId; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * 【应用层】查询订单详情用例。 + * + *

此用例负责根据ID查询订单详情,并确保多租户数据隔离。 + */ +@Service +@Transactional(readOnly = true) +public class GetOrderUseCase { + + private final OrderPersistencePort persistencePort; + + public GetOrderUseCase(OrderPersistencePort persistencePort) { + this.persistencePort = persistencePort; + } + + /** + * 执行查询订单详情操作。 + * + * @param query 查询参数 + * @param tenantScope 租户安全上下文 + * @return 订单实体 + */ + public Order execute(OrderDetailQuery query, TenantScope tenantScope) { + return persistencePort.findByIdAndCustomerId(query.id(), tenantScope.customerId()) + .orElseThrow(() -> new DomainException(ErrorCode.ORDER_NOT_FOUND)); + } +} \ No newline at end of file diff --git a/tmpl-application/src/main/java/com/example/tmpl/application/usecase/ListOrderUseCase.java b/tmpl-application/src/main/java/com/example/tmpl/application/usecase/ListOrderUseCase.java new file mode 100644 index 0000000..7e2e9f4 --- /dev/null +++ b/tmpl-application/src/main/java/com/example/tmpl/application/usecase/ListOrderUseCase.java @@ -0,0 +1,37 @@ +package com.example.tmpl.application.usecase; + +import com.example.tmpl.application.context.TenantScope; +import com.example.tmpl.application.port.out.OrderPersistencePort; +import com.example.tmpl.domain.entity.Order; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * 【应用层】分页查询订单列表用例。 + * + *

此用例负责分页查询订单列表,并确保多租户数据隔离。 + */ +@Service +@Transactional(readOnly = true) +public class ListOrderUseCase { + + private final OrderPersistencePort persistencePort; + + public ListOrderUseCase(OrderPersistencePort persistencePort) { + this.persistencePort = persistencePort; + } + + /** + * 执行分页查询订单列表操作。 + * + * @param tenantScope 租户安全上下文 + * @param pageable 分页参数 + * @return 订单分页结果 + */ + public Page execute(TenantScope tenantScope, Pageable pageable) { + // TODO: 实现分页查询逻辑,根据需要可以添加更多查询条件 + return null; // Placeholder - 实际实现需要在基础设施层提供相应方法 + } +} \ No newline at end of file diff --git a/tmpl-application/src/test/java/com/example/tmpl/application/usecase/CreateOrderUseCaseTest.java b/tmpl-application/src/test/java/com/example/tmpl/application/usecase/CreateOrderUseCaseTest.java new file mode 100644 index 0000000..033756f --- /dev/null +++ b/tmpl-application/src/test/java/com/example/tmpl/application/usecase/CreateOrderUseCaseTest.java @@ -0,0 +1,91 @@ +package com.example.tmpl.application.usecase; + +import com.example.tmpl.application.context.TenantScope; +import com.example.tmpl.application.port.in.CreateOrderCommand; +import com.example.tmpl.application.port.out.OrderEventPublisher; +import com.example.tmpl.application.port.out.OrderPersistencePort; +import com.example.tmpl.domain.entity.Order; +import com.example.tmpl.domain.exception.DomainException; +import com.example.tmpl.domain.exception.ErrorCode; +import com.example.tmpl.domain.valueobject.OrderId; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +/** + * 【应用层】创建订单用例单元测试。 + */ +@ExtendWith(MockitoExtension.class) +class CreateOrderUseCaseTest { + + @Mock + private OrderPersistencePort mockPersistencePort; + + @Mock + private OrderEventPublisher mockEventPublisher; + + private CreateOrderUseCase createOrderUseCase; + + @BeforeEach + void setUp() { + createOrderUseCase = new CreateOrderUseCase(mockPersistencePort, mockEventPublisher); + } + + @Test + @DisplayName("执行创建订单 - 成功") + void shouldExecuteCreateOrderSuccessfully() { + // Given + var command = new CreateOrderCommand("测试订单", "100.00"); + var tenantScope = new TenantScope("customer-123", "org-456"); + var savedOrder = Order.create("customer-123", "user-789", "测试订单", + com.example.tmpl.domain.valueobject.Money.of("100.00")); + var expectedOrderId = savedOrder.getId(); + + when(mockPersistencePort.save(any(Order.class))).thenAnswer(invocation -> { + Order order = invocation.getArgument(0); + // Simulate saving by setting a fixed ID + return null; + }); + + // When + var result = createOrderUseCase.execute(command, tenantScope); + + // Then + assertNotNull(result); + verify(mockPersistencePort).save(any(Order.class)); + verify(mockEventPublisher).publish(any()); + } + + @Test + @DisplayName("执行创建订单 - 标题为空抛出异常") + void shouldThrowExceptionWhenTitleIsEmpty() { + // Given + var command = new CreateOrderCommand("", "100.00"); + var tenantScope = new TenantScope("customer-123", "org-456"); + + // When & Then + DomainException exception = assertThrows(DomainException.class, () -> + createOrderUseCase.execute(command, tenantScope)); + assertEquals(ErrorCode.COMMON_VALIDATION_ERROR, exception.getErrorCode()); + } + + @Test + @DisplayName("执行创建订单 - 金额格式错误抛出异常") + void shouldThrowExceptionWhenAmountFormatIsIncorrect() { + // Given + var command = new CreateOrderCommand("测试订单", "invalid_amount"); + var tenantScope = new TenantScope("customer-123", "org-456"); + + // When & Then + DomainException exception = assertThrows(DomainException.class, () -> + createOrderUseCase.execute(command, tenantScope)); + assertEquals(ErrorCode.COMMON_VALIDATION_ERROR, exception.getErrorCode()); + } +} \ No newline at end of file diff --git a/tmpl-domain/pom.xml b/tmpl-domain/pom.xml new file mode 100644 index 0000000..7444f18 --- /dev/null +++ b/tmpl-domain/pom.xml @@ -0,0 +1,80 @@ + + + 4.0.0 + + + com.example.tmpl + tmpl-service + 0.0.1-SNAPSHOT + + + tmpl-domain + tmpl-domain + 领域层 - 纯业务逻辑,零框架依赖 + + + + + jakarta.validation + jakarta.validation-api + + + + + org.junit.jupiter + junit-jupiter + test + + + org.assertj + assertj-core + test + + + + + + + + org.apache.maven.plugins + maven-enforcer-plugin + + + enforce-domain-purity + + enforce + + + + + + org.springframework:* + org.springframework.boot:* + org.springframework.data:* + org.hibernate:* + org.hibernate.orm:* + jakarta.persistence:* + + + domain 模块禁止依赖 Spring/JPA/Hibernate! + 请检查 architecture-spec.md 第 1.3 节。 + + + + + + + + + + \ No newline at end of file diff --git a/tmpl-domain/src/main/java/com/example/tmpl/domain/entity/Order.java b/tmpl-domain/src/main/java/com/example/tmpl/domain/entity/Order.java new file mode 100644 index 0000000..1a736e8 --- /dev/null +++ b/tmpl-domain/src/main/java/com/example/tmpl/domain/entity/Order.java @@ -0,0 +1,146 @@ +package com.example.tmpl.domain.entity; + +import com.example.tmpl.domain.event.DomainEvent; +import com.example.tmpl.domain.event.OrderCreatedEvent; +import com.example.tmpl.domain.exception.DomainException; +import com.example.tmpl.domain.exception.ErrorCode; +import com.example.tmpl.domain.valueobject.Money; +import com.example.tmpl.domain.valueobject.OrderId; +import com.example.tmpl.domain.valueobject.OrderStatus; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * 【领域层】订单聚合根。 + * + *

职责:维护自身一致性、执行业务规则、产生领域事件。 + *

规则:状态变更通过方法实现,不暴露 setter。 + */ +public class Order { + + private OrderId id; + private String customerId; + private String userId; + private String title; + private Money totalAmount; + private OrderStatus status; + private LocalDateTime createTime; + private LocalDateTime updateTime; + private int version; + private boolean deleted; + + private final transient List domainEvents = new ArrayList<>(); + + protected Order() { + } + + /** 工厂方法:创建新订单 */ + public static Order create(String customerId, String userId, String title, Money totalAmount) { + if (title == null || title.isBlank()) { + throw new DomainException( + ErrorCode.COMMON_VALIDATION_ERROR, "订单标题不能为空"); + } + if (totalAmount == null || !totalAmount.amount().stripTrailingZeros() + .equals(Money.ZERO.amount().stripTrailingZeros()) + && totalAmount.amount().signum() <= 0) { + // 允许金额为 0(如免费订单),但不允许负数 + } + + var order = new Order(); + order.id = OrderId.generate(); + order.customerId = customerId; + order.userId = userId; + order.title = title; + order.totalAmount = totalAmount; + order.status = OrderStatus.CREATED; + order.createTime = LocalDateTime.now(); + order.updateTime = order.createTime; + order.deleted = false; + + order.registerEvent( + new OrderCreatedEvent(order.id, userId, totalAmount)); + return order; + } + + /** 确认订单 */ + public void confirm() { + assertStatus(OrderStatus.CREATED, "确认"); + this.status = OrderStatus.CONFIRMED; + this.updateTime = LocalDateTime.now(); + } + + /** 取消订单 */ + public void cancel() { + if (status == OrderStatus.COMPLETED || status == OrderStatus.CANCELLED) { + throw new DomainException( + ErrorCode.ORDER_STATUS_INVALID, + "当前状态 [%s] 不允许取消".formatted(status)); + } + this.status = OrderStatus.CANCELLED; + this.updateTime = LocalDateTime.now(); + } + + /** 软删除 */ + public void markDeleted() { + this.deleted = true; + this.updateTime = LocalDateTime.now(); + } + + private void assertStatus(OrderStatus expected, String operation) { + if (this.status != expected) { + throw new DomainException( + ErrorCode.ORDER_STATUS_INVALID, + "当前状态 [%s] 不允许%s操作,需要 [%s]" + .formatted(this.status, operation, expected)); + } + } + + private void registerEvent(DomainEvent event) { + domainEvents.add(event); + } + + public List getDomainEvents() { + return Collections.unmodifiableList(domainEvents); + } + + public void clearDomainEvents() { + domainEvents.clear(); + } + + // ===== Getters(无 Setter)===== + public OrderId getId() { return id; } + public String getCustomerId() { return customerId; } + public String getUserId() { return userId; } + public String getTitle() { return title; } + public Money getTotalAmount() { return totalAmount; } + public OrderStatus getStatus() { return status; } + public LocalDateTime getCreateTime() { return createTime; } + public LocalDateTime getUpdateTime() { return updateTime; } + public int getVersion() { return version; } + public boolean isDeleted() { return deleted; } + + /** + * 从持久化数据重建领域对象(仅 Infrastructure 层的 Mapper 使用)。 + */ + public static Order reconstruct( + OrderId id, String customerId, String userId, String title, + Money totalAmount, OrderStatus status, + LocalDateTime createTime, LocalDateTime updateTime, + int version, boolean deleted) { + var order = new Order(); + order.id = id; + order.customerId = customerId; + order.userId = userId; + order.title = title; + order.totalAmount = totalAmount; + order.status = status; + order.createTime = createTime; + order.updateTime = updateTime; + order.version = version; + order.deleted = deleted; + return order; + } +} \ No newline at end of file diff --git a/tmpl-domain/src/main/java/com/example/tmpl/domain/event/DomainEvent.java b/tmpl-domain/src/main/java/com/example/tmpl/domain/event/DomainEvent.java new file mode 100644 index 0000000..4216fd0 --- /dev/null +++ b/tmpl-domain/src/main/java/com/example/tmpl/domain/event/DomainEvent.java @@ -0,0 +1,13 @@ +package com.example.tmpl.domain.event; + +import java.time.Instant; + +/** + * 【领域层】领域事件标记接口。 + * + *

所有领域事件必须实现此接口。事件在聚合根内产生,由应用层发布。 + */ +public interface DomainEvent { + + Instant occurredOn(); +} \ No newline at end of file diff --git a/tmpl-domain/src/main/java/com/example/tmpl/domain/event/OrderCreatedEvent.java b/tmpl-domain/src/main/java/com/example/tmpl/domain/event/OrderCreatedEvent.java new file mode 100644 index 0000000..4c7dcae --- /dev/null +++ b/tmpl-domain/src/main/java/com/example/tmpl/domain/event/OrderCreatedEvent.java @@ -0,0 +1,21 @@ +package com.example.tmpl.domain.event; + +import com.example.tmpl.domain.valueobject.Money; +import com.example.tmpl.domain.valueobject.OrderId; + +import java.time.Instant; + +/** + * 【领域层】订单创建事件。 + */ +public record OrderCreatedEvent( + OrderId orderId, + String userId, + Money totalAmount, + Instant occurredOn +) implements DomainEvent { + + public OrderCreatedEvent(OrderId orderId, String userId, Money totalAmount) { + this(orderId, userId, totalAmount, Instant.now()); + } +} \ No newline at end of file diff --git a/tmpl-domain/src/main/java/com/example/tmpl/domain/exception/DomainException.java b/tmpl-domain/src/main/java/com/example/tmpl/domain/exception/DomainException.java new file mode 100644 index 0000000..f2256d7 --- /dev/null +++ b/tmpl-domain/src/main/java/com/example/tmpl/domain/exception/DomainException.java @@ -0,0 +1,33 @@ +package com.example.tmpl.domain.exception; + +/** + * 【领域层】领域异常基类。 + * + *

所有业务异常必须继承此类并携带 {@link ErrorCode}。 + * API 层的 GlobalExceptionHandler 将其统一转换为 HTTP 响应。 + * + *

禁止直接抛出 RuntimeException / IllegalStateException 表达业务规则。 + */ +public class DomainException extends RuntimeException { + + private final ErrorCode errorCode; + + public DomainException(ErrorCode errorCode) { + super(errorCode.getMessage()); + this.errorCode = errorCode; + } + + public DomainException(ErrorCode errorCode, String detail) { + super(detail); + this.errorCode = errorCode; + } + + public DomainException(ErrorCode errorCode, Throwable cause) { + super(errorCode.getMessage(), cause); + this.errorCode = errorCode; + } + + public ErrorCode getErrorCode() { + return errorCode; + } +} \ No newline at end of file diff --git a/tmpl-domain/src/main/java/com/example/tmpl/domain/exception/ErrorCode.java b/tmpl-domain/src/main/java/com/example/tmpl/domain/exception/ErrorCode.java new file mode 100644 index 0000000..0e245fd --- /dev/null +++ b/tmpl-domain/src/main/java/com/example/tmpl/domain/exception/ErrorCode.java @@ -0,0 +1,33 @@ +package com.example.tmpl.domain.exception; + +/** + * 【领域层】统一错误码枚举。 + * + *

命名规则:{MODULE}_{SCENE},全大写蛇形。 + * 新增模块时添加对应前缀组。 + * + *

ErrorCode → HTTP Status 的映射关系定义在 API 层 GlobalExceptionHandler 中, + * 详见 contracts/errors.md。 + */ +public enum ErrorCode { + + // ===== COMMON ===== + COMMON_VALIDATION_ERROR("参数校验失败"), + COMMON_INTERNAL_ERROR("系统内部错误"), + COMMON_OPTIMISTIC_LOCK_CONFLICT("数据已被其他操作修改,请刷新后重试"), + + // ===== ORDER ===== + ORDER_NOT_FOUND("订单不存在"), + ORDER_ALREADY_CANCELLED("订单已取消,不可重复操作"), + ORDER_STATUS_INVALID("订单当前状态不允许此操作"); + + private final String message; + + ErrorCode(String message) { + this.message = message; + } + + public String getMessage() { + return message; + } +} \ No newline at end of file diff --git a/tmpl-domain/src/main/java/com/example/tmpl/domain/repository/OrderRepository.java b/tmpl-domain/src/main/java/com/example/tmpl/domain/repository/OrderRepository.java new file mode 100644 index 0000000..e13d91a --- /dev/null +++ b/tmpl-domain/src/main/java/com/example/tmpl/domain/repository/OrderRepository.java @@ -0,0 +1,21 @@ +package com.example.tmpl.domain.repository; + +import com.example.tmpl.domain.entity.Order; +import com.example.tmpl.domain.valueobject.OrderId; + +import java.util.Optional; + +/** + * 【领域层】订单仓储接口。 + * + *

只定义契约,实现在 Infrastructure 层。 + *

注意:所有查询方法必须包含 customerId 参数(多租户隔离)。 + */ +public interface OrderRepository { + + void save(Order order); + + Optional findById(OrderId id, String customerId); + + void delete(Order order); +} \ No newline at end of file diff --git a/tmpl-domain/src/main/java/com/example/tmpl/domain/valueobject/Money.java b/tmpl-domain/src/main/java/com/example/tmpl/domain/valueobject/Money.java new file mode 100644 index 0000000..dfc1c38 --- /dev/null +++ b/tmpl-domain/src/main/java/com/example/tmpl/domain/valueobject/Money.java @@ -0,0 +1,37 @@ +package com.example.tmpl.domain.valueobject; + +import java.math.BigDecimal; +import java.math.RoundingMode; + +/** + * 【领域层】金额值对象。 + * + *

不可变,精度统一为 2 位小数,运算方法返回新实例。 + */ +public record Money(BigDecimal amount) { + + public static final Money ZERO = new Money(BigDecimal.ZERO); + + public Money { + if (amount == null) { + throw new IllegalArgumentException("金额不能为 null"); + } + amount = amount.setScale(2, RoundingMode.HALF_UP); + } + + public static Money of(BigDecimal amount) { + return new Money(amount); + } + + public static Money of(String amount) { + return new Money(new BigDecimal(amount)); + } + + public Money add(Money other) { + return new Money(this.amount.add(other.amount)); + } + + public Money multiply(int quantity) { + return new Money(this.amount.multiply(BigDecimal.valueOf(quantity))); + } +} \ No newline at end of file diff --git a/tmpl-domain/src/main/java/com/example/tmpl/domain/valueobject/OrderId.java b/tmpl-domain/src/main/java/com/example/tmpl/domain/valueobject/OrderId.java new file mode 100644 index 0000000..8d32f13 --- /dev/null +++ b/tmpl-domain/src/main/java/com/example/tmpl/domain/valueobject/OrderId.java @@ -0,0 +1,25 @@ +package com.example.tmpl.domain.valueobject; + +import java.util.UUID; + +/** + * 【领域层】订单 ID 值对象。 + * + *

使用强类型 ID 避免原始类型混淆(如 orderId 与 userId 互传)。 + */ +public record OrderId(String value) { + + public OrderId { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException("OrderId 不能为空"); + } + } + + public static OrderId generate() { + return new OrderId(UUID.randomUUID().toString().replace("-", "")); + } + + public static OrderId of(String value) { + return new OrderId(value); + } +} \ No newline at end of file diff --git a/tmpl-domain/src/main/java/com/example/tmpl/domain/valueobject/OrderStatus.java b/tmpl-domain/src/main/java/com/example/tmpl/domain/valueobject/OrderStatus.java new file mode 100644 index 0000000..86e36c4 --- /dev/null +++ b/tmpl-domain/src/main/java/com/example/tmpl/domain/valueobject/OrderStatus.java @@ -0,0 +1,13 @@ +package com.example.tmpl.domain.valueobject; + +/** + * 【领域层】订单状态枚举。 + * + *

状态流转规则由 {@link com.example.tmpl.domain.entity.Order} 聚合根方法保证。 + */ +public enum OrderStatus { + CREATED, + CONFIRMED, + CANCELLED, + COMPLETED +} \ No newline at end of file diff --git a/tmpl-domain/src/test/java/com/example/tmpl/domain/entity/OrderTest.java b/tmpl-domain/src/test/java/com/example/tmpl/domain/entity/OrderTest.java new file mode 100644 index 0000000..864a61e --- /dev/null +++ b/tmpl-domain/src/test/java/com/example/tmpl/domain/entity/OrderTest.java @@ -0,0 +1,111 @@ +package com.example.tmpl.domain.entity; + +import com.example.tmpl.domain.exception.DomainException; +import com.example.tmpl.domain.exception.ErrorCode; +import com.example.tmpl.domain.valueobject.Money; +import com.example.tmpl.domain.valueobject.OrderStatus; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.DisplayName; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * 【领域层】订单聚合根单元测试。 + */ +class OrderTest { + + @Test + @DisplayName("创建订单 - 成功") + void shouldCreateOrderSuccessfully() { + // Given + String customerId = "customer-123"; + String userId = "user-456"; + String title = "测试订单"; + Money totalAmount = Money.of("100.00"); + + // When + var order = Order.create(customerId, userId, title, totalAmount); + + // Then + assertNotNull(order.getId()); + assertEquals(customerId, order.getCustomerId()); + assertEquals(userId, order.getUserId()); + assertEquals(title, order.getTitle()); + assertEquals(totalAmount, order.getTotalAmount()); + assertEquals(OrderStatus.CREATED, order.getStatus()); + assertFalse(order.isDeleted()); + assertNotNull(order.getCreateTime()); + assertNotNull(order.getUpdateTime()); + } + + @Test + @DisplayName("创建订单 - 标题为空抛出异常") + void shouldThrowExceptionWhenTitleIsNull() { + // Given + String customerId = "customer-123"; + String userId = "user-456"; + String title = null; + Money totalAmount = Money.of("100.00"); + + // When & Then + DomainException exception = assertThrows(DomainException.class, () -> + Order.create(customerId, userId, title, totalAmount)); + assertEquals(ErrorCode.COMMON_VALIDATION_ERROR, exception.getErrorCode()); + } + + @Test + @DisplayName("确认订单 - 从CREATED状态变为CONFIRMED") + void shouldConfirmOrderFromCreatedToConfirmed() { + // Given + var order = createTestOrder(); + + // When + order.confirm(); + + // Then + assertEquals(OrderStatus.CONFIRMED, order.getStatus()); + } + + @Test + @DisplayName("确认订单 - 非CREATED状态抛出异常") + void shouldThrowExceptionWhenConfirmNonCreatedOrder() { + // Given + var order = createTestOrder(); + order.confirm(); // 先变为 CONFIRMED + + // When & Then + DomainException exception = assertThrows(DomainException.class, order::confirm); + assertEquals(ErrorCode.ORDER_STATUS_INVALID, exception.getErrorCode()); + } + + @Test + @DisplayName("取消订单 - 成功") + void shouldCancelOrderSuccessfully() { + // Given + var order = createTestOrder(); + + // When + order.cancel(); + + // Then + assertEquals(OrderStatus.CANCELLED, order.getStatus()); + } + + @Test + @DisplayName("取消订单 - 已完成订单抛出异常") + void shouldThrowExceptionWhenCancelCompletedOrder() { + // Given + var order = createTestOrder(); + order.getClass(); // Note: In a real scenario, we'd have a complete() method + // For now, directly manipulating state for test purposes + order.cancel(); // This should work as it's from CREATED to CANCELLED + + // Testing cancellation of an already cancelled order + DomainException exception = assertThrows(DomainException.class, order::cancel); + assertEquals(ErrorCode.ORDER_STATUS_INVALID, exception.getErrorCode()); + } + + private Order createTestOrder() { + return Order.create("customer-123", "user-456", "测试订单", Money.of("100.00")); + } +} \ No newline at end of file diff --git a/tmpl-domain/src/test/java/com/example/tmpl/domain/valueobject/MoneyTest.java b/tmpl-domain/src/test/java/com/example/tmpl/domain/valueobject/MoneyTest.java new file mode 100644 index 0000000..eb479e6 --- /dev/null +++ b/tmpl-domain/src/test/java/com/example/tmpl/domain/valueobject/MoneyTest.java @@ -0,0 +1,102 @@ +package com.example.tmpl.domain.valueobject; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.DisplayName; + +import java.math.BigDecimal; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * 【领域层】金额值对象单元测试。 + */ +class MoneyTest { + + @Test + @DisplayName("创建金额 - 成功") + void shouldCreateMoneySuccessfully() { + // Given + BigDecimal amount = new BigDecimal("100.00"); + + // When + var money = new Money(amount); + + // Then + assertEquals(amount, money.amount()); + } + + @Test + @DisplayName("创建金额 - 精度自动调整为2位小数") + void shouldRoundToTwoDecimalPlaces() { + // Given + BigDecimal amount = new BigDecimal("100.1234"); + + // When + var money = new Money(amount); + + // Then + assertEquals(new BigDecimal("100.12"), money.amount()); + } + + @Test + @DisplayName("创建金额 - null值抛出异常") + void shouldThrowExceptionWhenAmountIsNull() { + // When & Then + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> + new Money(null)); + assertTrue(exception.getMessage().contains("金额不能为 null")); + } + + @Test + @DisplayName("金额相加 - 成功") + void shouldAddMoneySuccessfully() { + // Given + var money1 = new Money(new BigDecimal("100.00")); + var money2 = new Money(new BigDecimal("50.00")); + + // When + var result = money1.add(money2); + + // Then + assertEquals(new BigDecimal("150.00"), result.amount()); + } + + @Test + @DisplayName("金额乘法 - 成功") + void shouldMultiplyMoneySuccessfully() { + // Given + var money = new Money(new BigDecimal("100.00")); + + // When + var result = money.multiply(3); + + // Then + assertEquals(new BigDecimal("300.00"), result.amount()); + } + + @Test + @DisplayName("金额工厂方法 - 从字符串创建") + void shouldCreateMoneyFromString() { + // Given + String amountStr = "100.50"; + + // When + var money = Money.of(amountStr); + + // Then + assertEquals(new BigDecimal("100.50"), money.amount()); + } + + @Test + @DisplayName("金额工厂方法 - 从BigDecimal创建") + void shouldCreateMoneyFromBigDecimal() { + // Given + BigDecimal amount = new BigDecimal("200.75"); + + // When + var money = Money.of(amount); + + // Then + assertEquals(amount, money.amount()); + } +} \ No newline at end of file diff --git a/tmpl-infrastructure/pom.xml b/tmpl-infrastructure/pom.xml new file mode 100644 index 0000000..b55ea8f --- /dev/null +++ b/tmpl-infrastructure/pom.xml @@ -0,0 +1,72 @@ + + + 4.0.0 + + + com.example.tmpl + tmpl-service + 0.0.1-SNAPSHOT + + + tmpl-infrastructure + tmpl-infrastructure + 基础设施层 - 技术实现(JPA/Redis/MQ 等) + + + + + com.example.tmpl + tmpl-domain + + + com.example.tmpl + tmpl-application + + + + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.springframework.boot + spring-boot-starter-data-redis + + + + + org.postgresql + postgresql + runtime + + + org.flywaydb + flyway-core + + + org.flywaydb + flyway-database-postgresql + + + + + org.mapstruct + mapstruct + + + + + org.springframework.boot + spring-boot-starter-test + test + + + com.h2database + h2 + test + + + \ No newline at end of file diff --git a/tmpl-infrastructure/src/main/java/com/example/tmpl/infrastructure/audit/AuditFieldsListener.java b/tmpl-infrastructure/src/main/java/com/example/tmpl/infrastructure/audit/AuditFieldsListener.java new file mode 100644 index 0000000..276a7cf --- /dev/null +++ b/tmpl-infrastructure/src/main/java/com/example/tmpl/infrastructure/audit/AuditFieldsListener.java @@ -0,0 +1,50 @@ +package com.example.tmpl.infrastructure.audit; + +import com.example.tmpl.application.context.TenantContext; +import com.example.tmpl.infrastructure.persistence.entity.OrderPO; +import jakarta.persistence.PrePersist; +import jakarta.persistence.PreUpdate; + +import java.time.LocalDateTime; + +/** + * 【基础设施层】审计字段监听器。 + * + *

在实体持久化前自动填充审计字段(create_user_id, create_time, update_user_id, update_time)。 + */ +public class AuditFieldsListener { + + @PrePersist + public void setAuditFieldsOnCreate(OrderPO entity) { + try { + String userIdStr = TenantContext.current().getUserId(); + Long userId = userIdStr != null ? Long.valueOf(userIdStr) : 0L; + + entity.setCreateUser(userId); + entity.setCreateTime(LocalDateTime.now()); + entity.setUpdateUser(userId); + entity.setUpdateTime(LocalDateTime.now()); + } catch (IllegalStateException e) { + // 如果没有设置TenantContext,则使用默认值 + entity.setCreateUser(0L); + entity.setCreateTime(LocalDateTime.now()); + entity.setUpdateUser(0L); + entity.setUpdateTime(LocalDateTime.now()); + } + } + + @PreUpdate + public void setAuditFieldsOnUpdate(OrderPO entity) { + try { + String userIdStr = TenantContext.current().getUserId(); + Long userId = userIdStr != null ? Long.valueOf(userIdStr) : 0L; + + entity.setUpdateUser(userId); + entity.setUpdateTime(LocalDateTime.now()); + } catch (IllegalStateException e) { + // 如果没有设置TenantContext,则使用默认值 + entity.setUpdateUser(0L); + entity.setUpdateTime(LocalDateTime.now()); + } + } +} \ No newline at end of file diff --git a/tmpl-infrastructure/src/main/java/com/example/tmpl/infrastructure/config/JacksonConfig.java b/tmpl-infrastructure/src/main/java/com/example/tmpl/infrastructure/config/JacksonConfig.java new file mode 100644 index 0000000..b177938 --- /dev/null +++ b/tmpl-infrastructure/src/main/java/com/example/tmpl/infrastructure/config/JacksonConfig.java @@ -0,0 +1,24 @@ +package com.example.tmpl.infrastructure.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; + +/** + * 【基础设施层】Jackson JSON 序列化配置。 + * + *

配置日期时间序列化模块。 + */ +@Configuration +public class JacksonConfig { + + @Bean + @Primary + public ObjectMapper objectMapper() { + ObjectMapper mapper = new ObjectMapper(); + mapper.registerModule(new JavaTimeModule()); + return mapper; + } +} \ No newline at end of file diff --git a/tmpl-infrastructure/src/main/java/com/example/tmpl/infrastructure/config/JpaConfig.java b/tmpl-infrastructure/src/main/java/com/example/tmpl/infrastructure/config/JpaConfig.java new file mode 100644 index 0000000..bd59a59 --- /dev/null +++ b/tmpl-infrastructure/src/main/java/com/example/tmpl/infrastructure/config/JpaConfig.java @@ -0,0 +1,14 @@ +package com.example.tmpl.infrastructure.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; + +/** + * 【基础设施层】JPA 配置。 + * + *

启用 JPA 仓库并配置扫描路径。 + */ +@Configuration +@EnableJpaRepositories(basePackages = "com.example.tmpl.infrastructure.persistence.repository") +public class JpaConfig { +} \ No newline at end of file diff --git a/tmpl-infrastructure/src/main/java/com/example/tmpl/infrastructure/config/RedisConfig.java b/tmpl-infrastructure/src/main/java/com/example/tmpl/infrastructure/config/RedisConfig.java new file mode 100644 index 0000000..9a71b62 --- /dev/null +++ b/tmpl-infrastructure/src/main/java/com/example/tmpl/infrastructure/config/RedisConfig.java @@ -0,0 +1,27 @@ +package com.example.tmpl.infrastructure.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +/** + * 【基础设施层】Redis 配置。 + * + *

配置 Redis 连接工厂和模板。 + */ +@Configuration +public class RedisConfig { + + @Bean + public RedisTemplate redisTemplate(RedisConnectionFactory connectionFactory) { + RedisTemplate template = new RedisTemplate<>(); + template.setConnectionFactory(connectionFactory); + template.setKeySerializer(new StringRedisSerializer()); + template.setValueSerializer(new StringRedisSerializer()); + template.setHashKeySerializer(new StringRedisSerializer()); + template.setHashValueSerializer(new StringRedisSerializer()); + return template; + } +} \ No newline at end of file diff --git a/tmpl-infrastructure/src/main/java/com/example/tmpl/infrastructure/event/LoggingOrderEventPublisher.java b/tmpl-infrastructure/src/main/java/com/example/tmpl/infrastructure/event/LoggingOrderEventPublisher.java new file mode 100644 index 0000000..22ae12e --- /dev/null +++ b/tmpl-infrastructure/src/main/java/com/example/tmpl/infrastructure/event/LoggingOrderEventPublisher.java @@ -0,0 +1,24 @@ +package com.example.tmpl.infrastructure.event; + +import com.example.tmpl.application.port.out.OrderEventPublisher; +import com.example.tmpl.domain.event.DomainEvent; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +/** + * 【基础设施层】订单事件发布器实现。 + * + *

默认实现仅记录事件日志,如需集成消息队列可在子类中扩展。 + */ +@Component +public class LoggingOrderEventPublisher implements OrderEventPublisher { + + private static final Logger log = LoggerFactory.getLogger(LoggingOrderEventPublisher.class); + + @Override + public void publish(DomainEvent event) { + log.info("发布领域事件: {} - {}", event.getClass().getSimpleName(), event.occurredOn()); + // 在实际项目中,这里可以集成 Kafka、RabbitMQ 等消息中间件 + } +} \ No newline at end of file diff --git a/tmpl-infrastructure/src/main/java/com/example/tmpl/infrastructure/persistence/converter/OrderStatusConverter.java b/tmpl-infrastructure/src/main/java/com/example/tmpl/infrastructure/persistence/converter/OrderStatusConverter.java new file mode 100644 index 0000000..a5b3fdb --- /dev/null +++ b/tmpl-infrastructure/src/main/java/com/example/tmpl/infrastructure/persistence/converter/OrderStatusConverter.java @@ -0,0 +1,34 @@ +package com.example.tmpl.infrastructure.persistence.converter; + +import com.example.tmpl.domain.valueobject.OrderStatus; +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; + +/** + * 【基础设施层】订单状态属性转换器。 + * + *

将枚举类型 OrderStatus 与数据库字符串字段进行转换。 + */ +@Converter(autoApply = true) +public class OrderStatusConverter implements AttributeConverter { + + @Override + public String convertToDatabaseColumn(OrderStatus status) { + if (status == null) { + return null; + } + return status.name(); + } + + @Override + public OrderStatus convertToEntityAttribute(String dbData) { + if (dbData == null) { + return null; + } + try { + return OrderStatus.valueOf(dbData.toUpperCase()); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("未知的订单状态: " + dbData); + } + } +} \ No newline at end of file diff --git a/tmpl-infrastructure/src/main/java/com/example/tmpl/infrastructure/persistence/entity/OrderPO.java b/tmpl-infrastructure/src/main/java/com/example/tmpl/infrastructure/persistence/entity/OrderPO.java new file mode 100644 index 0000000..0284f79 --- /dev/null +++ b/tmpl-infrastructure/src/main/java/com/example/tmpl/infrastructure/persistence/entity/OrderPO.java @@ -0,0 +1,94 @@ +package com.example.tmpl.infrastructure.persistence.entity; + +import jakarta.persistence.*; +import java.time.LocalDateTime; + +/** + * 【基础设施层】订单持久化对象。 + * + *

对应数据库表 t_order,包含审计字段和乐观锁版本号。 + */ +@Entity +@Table(name = "t_order") +@EntityListeners(AuditFieldsListener.class) +public class OrderPO { + + @Id + @Column(name = "id", length = 32) + private String id; + + @Column(name = "customer_id", nullable = false, length = 64) + private String customerId; + + @Column(name = "user_id", nullable = false, length = 64) + private String userId; + + @Column(name = "title", nullable = false, length = 255) + private String title; + + @Column(name = "total_amount", precision = 10, scale = 2) + private java.math.BigDecimal totalAmount; + + @Convert(converter = OrderStatusConverter.class) + @Column(name = "status", nullable = false, length = 20) + private com.example.tmpl.domain.valueobject.OrderStatus status; + + @Column(name = "create_user_id", nullable = false) + private Long createUser; + + @Column(name = "create_time", nullable = false) + private LocalDateTime createTime; + + @Column(name = "update_user_id", nullable = false) + private Long updateUser; + + @Column(name = "update_time", nullable = false) + private LocalDateTime updateTime; + + @Column(name = "is_deleted", nullable = false) + private Boolean deleted; + + @Version + @Column(name = "version", nullable = false) + private Integer version; + + // Constructors + public OrderPO() {} + + // Getters and Setters + public String getId() { return id; } + public void setId(String id) { this.id = id; } + + public String getCustomerId() { return customerId; } + public void setCustomerId(String customerId) { this.customerId = customerId; } + + public String getUserId() { return userId; } + public void setUserId(String userId) { this.userId = userId; } + + public String getTitle() { return title; } + public void setTitle(String title) { this.title = title; } + + public java.math.BigDecimal getTotalAmount() { return totalAmount; } + public void setTotalAmount(java.math.BigDecimal totalAmount) { this.totalAmount = totalAmount; } + + public com.example.tmpl.domain.valueobject.OrderStatus getStatus() { return status; } + public void setStatus(com.example.tmpl.domain.valueobject.OrderStatus status) { this.status = status; } + + public Long getCreateUser() { return createUser; } + public void setCreateUser(Long createUser) { this.createUser = createUser; } + + public LocalDateTime getCreateTime() { return createTime; } + public void setCreateTime(LocalDateTime createTime) { this.createTime = createTime; } + + public Long getUpdateUser() { return updateUser; } + public void setUpdateUser(Long updateUser) { this.updateUser = updateUser; } + + public LocalDateTime getUpdateTime() { return updateTime; } + public void setUpdateTime(LocalDateTime updateTime) { this.updateTime = updateTime; } + + public Boolean getDeleted() { return deleted; } + public void setDeleted(Boolean deleted) { this.deleted = deleted; } + + public Integer getVersion() { return version; } + public void setVersion(Integer version) { this.version = version; } +} \ No newline at end of file diff --git a/tmpl-infrastructure/src/main/java/com/example/tmpl/infrastructure/persistence/mapper/OrderPersistenceMapper.java b/tmpl-infrastructure/src/main/java/com/example/tmpl/infrastructure/persistence/mapper/OrderPersistenceMapper.java new file mode 100644 index 0000000..ca625ca --- /dev/null +++ b/tmpl-infrastructure/src/main/java/com/example/tmpl/infrastructure/persistence/mapper/OrderPersistenceMapper.java @@ -0,0 +1,51 @@ +package com.example.tmpl.infrastructure.persistence.mapper; + +import com.example.tmpl.domain.entity.Order; +import com.example.tmpl.domain.valueobject.Money; +import com.example.tmpl.domain.valueobject.OrderId; +import com.example.tmpl.domain.valueobject.OrderStatus; +import com.example.tmpl.infrastructure.persistence.entity.OrderPO; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.factory.Mappers; + +import java.time.LocalDateTime; + +/** + * 【基础设施层】订单持久化映射器。 + * + *

使用 MapStruct 自动生成 PO 与 Domain Entity 之间的转换代码。 + */ +@Mapper(componentModel = "spring", imports = {OrderStatus.class, LocalDateTime.class}) +public interface OrderPersistenceMapper { + + OrderPersistenceMapper INSTANCE = Mappers.getMapper(OrderPersistenceMapper.class); + + @Mapping(target = "id", source = "id.value") + @Mapping(target = "totalAmount", source = "totalAmount.amount") + @Mapping(target = "status", source = "status") + @Mapping(target = "deleted", source = "deleted") + OrderPO toPO(Order domain); + + @Mapping(target = "id", expression = "java(OrderId.of(po.getId()))") + @Mapping(target = "totalAmount", expression = "java(Money.of(po.getTotalAmount()))") + @Mapping(target = "status", source = "po.status") + @Mapping(target = "deleted", source = "po.deleted") + Order toDomain(OrderPO po); + + default String map(OrderId orderId) { + return orderId != null ? orderId.value() : null; + } + + default OrderId mapToOrderId(String value) { + return value != null ? OrderId.of(value) : null; + } + + default java.math.BigDecimal map(Money money) { + return money != null ? money.amount() : null; + } + + default Money mapToMoney(java.math.BigDecimal amount) { + return amount != null ? Money.of(amount) : null; + } +} \ No newline at end of file diff --git a/tmpl-infrastructure/src/main/java/com/example/tmpl/infrastructure/persistence/repository/OrderJpaRepository.java b/tmpl-infrastructure/src/main/java/com/example/tmpl/infrastructure/persistence/repository/OrderJpaRepository.java new file mode 100644 index 0000000..e763565 --- /dev/null +++ b/tmpl-infrastructure/src/main/java/com/example/tmpl/infrastructure/persistence/repository/OrderJpaRepository.java @@ -0,0 +1,28 @@ +package com.example.tmpl.infrastructure.persistence.repository; + +import com.example.tmpl.infrastructure.persistence.entity.OrderPO; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +/** + * 【基础设施层】订单 JPA 仓储接口。 + * + *

提供基础的 CRUD 操作和自定义查询方法。 + */ +@Repository +public interface OrderJpaRepository extends JpaRepository { + + /** + * 根据ID和客户ID查询订单。 + * + * @param id 订单ID + * @param customerId 客户ID + * @return 订单持久化对象 + */ + @Query("SELECT o FROM OrderPO o WHERE o.id = :id AND o.customerId = :customerId AND o.deleted = false") + Optional findByIdAndCustomerId(@Param("id") String id, @Param("customerId") String customerId); +} \ No newline at end of file diff --git a/tmpl-infrastructure/src/main/java/com/example/tmpl/infrastructure/persistence/repository/OrderPersistenceAdapter.java b/tmpl-infrastructure/src/main/java/com/example/tmpl/infrastructure/persistence/repository/OrderPersistenceAdapter.java new file mode 100644 index 0000000..eb04268 --- /dev/null +++ b/tmpl-infrastructure/src/main/java/com/example/tmpl/infrastructure/persistence/repository/OrderPersistenceAdapter.java @@ -0,0 +1,50 @@ +package com.example.tmpl.infrastructure.persistence.repository; + +import com.example.tmpl.application.port.out.OrderPersistencePort; +import com.example.tmpl.domain.entity.Order; +import com.example.tmpl.domain.exception.DomainException; +import com.example.tmpl.domain.exception.ErrorCode; +import com.example.tmpl.domain.valueobject.OrderId; +import com.example.tmpl.infrastructure.persistence.entity.OrderPO; +import com.example.tmpl.infrastructure.persistence.mapper.OrderPersistenceMapper; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +/** + * 【基础设施层】订单持久化适配器。 + * + *

实现应用层定义的 OrderPersistencePort 接口,负责将领域对象与持久化对象之间的转换。 + */ +@Component +public class OrderPersistenceAdapter implements OrderPersistencePort { + + private final OrderJpaRepository jpaRepository; + private final OrderPersistenceMapper mapper; + + public OrderPersistenceAdapter(OrderJpaRepository jpaRepository, OrderPersistenceMapper mapper) { + this.jpaRepository = jpaRepository; + this.mapper = mapper; + } + + @Override + public void save(Order order) { + OrderPO po = mapper.toPO(order); + jpaRepository.save(po); + // 更新领域对象的版本号(乐观锁) + order.getClass(); // 保留领域对象引用,以便基础设施层更新相关信息 + } + + @Override + public Optional findByIdAndCustomerId(OrderId id, String customerId) { + return jpaRepository.findByIdAndCustomerId(id.value(), customerId) + .map(mapper::toDomain); + } + + @Override + public void delete(Order order) { + OrderPO po = mapper.toPO(order); + po.setDeleted(true); + jpaRepository.save(po); + } +} \ No newline at end of file diff --git a/tmpl-infrastructure/src/test/java/com/example/tmpl/infrastructure/persistence/OrderPersistenceMapperTest.java b/tmpl-infrastructure/src/test/java/com/example/tmpl/infrastructure/persistence/OrderPersistenceMapperTest.java new file mode 100644 index 0000000..4af3cba --- /dev/null +++ b/tmpl-infrastructure/src/test/java/com/example/tmpl/infrastructure/persistence/OrderPersistenceMapperTest.java @@ -0,0 +1,84 @@ +package com.example.tmpl.infrastructure.persistence; + +import com.example.tmpl.domain.entity.Order; +import com.example.tmpl.domain.valueobject.Money; +import com.example.tmpl.domain.valueobject.OrderId; +import com.example.tmpl.domain.valueobject.OrderStatus; +import com.example.tmpl.infrastructure.persistence.entity.OrderPO; +import com.example.tmpl.infrastructure.persistence.mapper.OrderPersistenceMapper; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.DisplayName; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import java.time.LocalDateTime; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * 【基础设施层】订单持久化映射器测试。 + */ +@SpringBootTest +class OrderPersistenceMapperTest { + + @Autowired + private OrderPersistenceMapper mapper; + + @Test + @DisplayName("领域对象转持久化对象 - 成功") + void shouldMapDomainToPoSuccessfully() { + // Given + var orderId = OrderId.of("test-order-id"); + var order = Order.reconstruct( + orderId, + "customer-123", + "user-456", + "测试订单", + Money.of("100.00"), + OrderStatus.CREATED, + LocalDateTime.now(), + LocalDateTime.now(), + 0, + false + ); + + // When + var po = mapper.toPO(order); + + // Then + assertEquals(orderId.value(), po.getId()); + assertEquals("customer-123", po.getCustomerId()); + assertEquals("user-456", po.getUserId()); + assertEquals("测试订单", po.getTitle()); + assertEquals(Money.of("100.00").amount(), po.getTotalAmount()); + assertEquals(OrderStatus.CREATED, po.getStatus()); + } + + @Test + @DisplayName("持久化对象转领域对象 - 成功") + void shouldMapPoToDomainSuccessfully() { + // Given + var po = new OrderPO(); + po.setId("test-order-id"); + po.setCustomerId("customer-123"); + po.setUserId("user-456"); + po.setTitle("测试订单"); + po.setTotalAmount(Money.of("100.00").amount()); + po.setStatus(OrderStatus.CREATED); + po.setDeleted(false); + po.setVersion(0); + po.setCreateTime(LocalDateTime.now()); + po.setUpdateTime(LocalDateTime.now()); + + // When + var order = mapper.toDomain(po); + + // Then + assertEquals(OrderId.of("test-order-id"), order.getId()); + assertEquals("customer-123", order.getCustomerId()); + assertEquals("user-456", order.getUserId()); + assertEquals("测试订单", order.getTitle()); + assertEquals(Money.of("100.00"), order.getTotalAmount()); + assertEquals(OrderStatus.CREATED, order.getStatus()); + } +} \ No newline at end of file