feat: implement batch 6 BaseAuditPO, MapStruct, Docker, and init script

- Add BaseAuditPO with audit fields (createUserId, updateUserId, createTime, updateTime, deleted, version)
- Implement MapStruct for Order ↔ OrderPO mapping with @BeanMapping(ignoreByDefault=true)
- Create Dockerfile with multi-stage build and runtime-only mode support
- Add .dockerignore for optimized build context
- Create init.sh script for project scaffolding with macOS/Linux compatibility
- Update OrderPO to extend BaseAuditPO
- Update OrderDao to use MapStruct mapper
- Add MapStruct processor dependency to infrastructure module
- Follow M2 v5 frozen contracts for audit trail and persistence layer

Co-authored-by: Qwen-Coder <[email protected]>
This commit is contained in:
Bruno Gao
2026-02-12 14:18:07 +08:00
co-authored by Qwen-Coder
parent f254a74174
commit a88085a6c1
9 changed files with 767 additions and 104 deletions
+29
View File
@@ -0,0 +1,29 @@
# IDE / OS
.idea/
*.iml
.vscode/
.DS_Store
Thumbs.db
# Build output (builder stage handles this)
**/target/
# Version control
.git/
.gitignore
# Docker (avoid recursive context)
Dockerfile
docker-compose.yml
.dockerignore
# Docs / config not needed in image
*.md
docs/
.editorconfig
.gitea/
owasp-suppressions.xml
# Local env
.env
*.log
+318
View File
@@ -0,0 +1,318 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
Java 21 通用项目模板 using Spring Boot 3.4+ with Clean Architecture (Hexagonal/Ports & Adapters). Multi-tenant SaaS service template implementing strict layer isolation and compile-time architecture enforcement via ArchUnit.
**Group ID**: `com.ljwx.tmpl`
**Java Version**: 21 (Virtual Threads enabled by default)
**Build Tool**: Maven (multi-module)
## Common Commands
### Build & Test
```bash
# Run all tests (unit + integration)
mvn test
# Run tests in a specific module
mvn test -pl tmpl-domain
mvn test -pl tmpl-application
mvn test -pl tmpl-infrastructure
mvn test -pl tmpl-api
# Run a single test class
mvn test -Dtest=TenantContextTest
mvn test -pl tmpl-application -Dtest=TenantContextTest
# Run specific test method
mvn test -Dtest=ErrorCodeTest#shouldReturnCorrectHttpStatus
```
### Code Quality
```bash
# Apply code formatting (Google Java Format, AOSP style)
mvn spotless:apply
# Check formatting without fixing
mvn spotless:check
# Run full quality gate (format + static analysis + tests + coverage)
mvn verify
# Enable quality gates (normally skipped for fast local builds)
mvn verify -Djacoco.skip=false -Dspotbugs.skip=false -Ddependency-check.skip=false
```
### Running the Application
```bash
# Start application (dev profile active by default)
mvn clean spring-boot:run
# From tmpl-api module
cd tmpl-api
mvn spring-boot:run
# With specific profile
mvn spring-boot:run -Dspring-boot.run.profiles=prod
# Access endpoints
# - Health check: http://localhost:8080/actuator/health
# - Swagger UI: http://localhost:8080/swagger-ui.html
# - Actuator: http://localhost:8080/actuator
```
### Database
```bash
# Flyway migrations run automatically on startup
# Migration files: tmpl-api/src/main/resources/db/migration/V*.sql
# Start local PostgreSQL (if docker-compose.yml exists)
docker-compose up -d postgres
# Stop
docker-compose down
```
## Architecture
### Four-Layer Structure
Dependency direction: **outer → inner** (strictly enforced by ArchUnit)
```
tmpl-api (REST Layer)
↓ depends on
tmpl-infrastructure (Adapters) + tmpl-application (Use Cases)
↓ depends on
tmpl-domain (Pure Business Logic)
```
#### tmpl-domain
- **Location**: `/tmpl-domain`
- **Purpose**: Pure business logic, framework-agnostic
- **Contains**: Domain entities, value objects, repository interfaces, `DomainException`, `ErrorCode` enum
- **Constraints**:
- Zero Spring/JPA/Hibernate dependencies
- Only Java stdlib + `jakarta.validation-api` (annotations only, no runtime validator injection)
- All business errors thrown as `DomainException(ErrorCode, optionalDetail)`
#### tmpl-application
- **Location**: `/tmpl-application`
- **Purpose**: Use case orchestration, transaction boundaries, tenant context management
- **Contains**: UseCase interfaces, command/query DTOs, `TenantContext` (ThreadLocal), `TenantScope`, `@Transactional` declarations
- **Constraints**:
- **ONLY location for `@Transactional`** - never in infrastructure/api layers
- No Web (`@RestController`), Persistence (`@Entity`), Security, HTTP Client dependencies
- Depends on: domain + spring-context + spring-tx only
#### tmpl-infrastructure
- **Location**: `/tmpl-infrastructure`
- **Purpose**: Technical implementation of ports (persistence, external services, events)
- **Contains**: JPA entities (PO), repositories, adapters, MapStruct mappers
- **Constraints**:
- Implements application layer Port interfaces
- No `@Transactional` here (transactions controlled by application layer)
- Repository pattern: `internal/OrderJpaRepository` (package-private) → `internal/OrderDao` (public, tenant-aware) → `adapter/OrderPersistenceAdapter` (implements Port)
#### tmpl-api
- **Location**: `/tmpl-api`
- **Purpose**: HTTP boundary, Spring Boot entry point, filters, exception handling
- **Contains**: `TmplApplication.java`, controllers, filters (`TraceIdFilter`, `TenantContextFilter`), `GlobalExceptionHandler`, Flyway migrations
- **Constraints**:
- Controllers only inject UseCase interfaces (never infrastructure classes directly)
- Constructor injection only (no `@Autowired` on fields)
- Request DTOs must NOT contain `tenantId`/`deptId`/`userId` (comes from headers)
- **Security rule**: Never read `tenantId`/`deptId`/`userId` from request body - only from gateway-injected headers via `TenantContext`
### Multi-Tenant Context
**TenantContext** (`tmpl-application/context/TenantContext.java`):
- ThreadLocal holder for `TenantScope` (tenantId, deptId, userId, roles)
- Set by `TenantContextFilter` from HTTP headers (`X-Tenant-Id`, `X-Dept-Id`, `X-User-Id`, `X-User-Roles`)
- Usage:
```java
TenantScope scope = TenantContext.require(); // throws if not set
Optional<TenantScope> scope = TenantContext.get(); // safe access
String tenantId = TenantContext.getTenantId();
Long userId = TenantContext.getUserIdOrDefault(); // returns 0L if empty
```
- **CRITICAL**: Always call `TenantContext.clear()` in finally block to prevent ThreadLocal leaks
**deptId = "0" semantic**: Means cross-department access (requires `SCOPE_ALL_DEPT` role). Never store "0" in database.
### Error Handling
**ErrorCode enum** (`tmpl-domain/exception/ErrorCode.java`):
- Frozen contract mapping error scenarios to HTTP status codes + default messages
- Example:
```java
ORDER_NOT_FOUND(404, "订单不存在"),
ORDER_AMOUNT_INVALID(422, "订单金额不合法")
```
**DomainException** (`tmpl-domain/exception/DomainException.java`):
```java
// Use default message
throw new DomainException(ErrorCode.ORDER_NOT_FOUND);
// Override with custom detail
throw new DomainException(ErrorCode.ORDER_AMOUNT_INVALID, "金额不能为负: -100");
```
**GlobalExceptionHandler** (in tmpl-api):
- Catches `DomainException`, reads `ErrorCode` to determine HTTP status
- Returns structured JSON: `{code, message, details, traceId}`
### Auditing
**BaseAuditPO** (inherited by all JPA entities):
- Fields: `createUserId`, `updateUserId`, `createTime`, `updateTime`, `deleted`, `version`
- Auto-populated via `@CreatedBy`, `@LastModifiedBy`, `@CreatedDate`, `@LastModifiedDate`
- **AuditorAwareImpl** returns `TenantContext.getUserIdOrDefault()` for audit fields
### Transaction Boundaries
**CRITICAL RULE**: `@Transactional` ONLY in application layer (UseCase service implementations).
```java
// ✅ Correct - in tmpl-application
@Service
public class CreateOrderService implements CreateOrderUseCase {
@Transactional
@Override
public OrderDetail execute(CreateOrderCommand cmd, TenantScope scope) {
// ...
}
}
// ❌ Wrong - infrastructure adapters must NOT declare @Transactional
```
## Key Constraints (ArchUnit Enforced)
### A-Class Rules (CI-blocking):
1. `domain` module: No imports from `spring.*`, `jakarta.persistence.*`, `org.hibernate.*`
2. `application` module: No Web/Persistence/Security/HTTP Client dependencies
3. `@Transactional` only in application layer
4. Controllers: No direct imports of infrastructure implementation classes
5. All JPA `@Query` (native or JPQL) must include `tenant_id` / `tenantId` checks
6. Adapters: Only access `OrderDao`, never raw `JpaRepository`
### B-Class Rules (strong recommendation):
1. UseCase methods named `execute(...)`
2. Request DTOs must NOT contain `tenantId`/`deptId`/`userId` fields
### C-Class Rules (optional):
1. `@Async` methods must not call `TenantContext.current()`
2. `@RequestBody` parameters should have `@Valid`
## Adding New Features
### Add Domain Entity
1. Create entity in `tmpl-domain/src/main/java/com/ljwx/tmpl/domain/{context}/entity/`
- Use Java 21 `record` for immutable entities/value objects
- Use regular class if mutable state needed
- Include business validation logic (not just getters/setters)
- Implement business methods (avoid anemic domain model)
2. Create value objects in `domain/{context}/valueobject/` (prefer `record`)
3. Add `ErrorCode` entries in `ErrorCode.java` for domain exceptions
4. Create repository interface in `domain/{context}/repository/`
- Method signatures reflect business intent
- Return domain objects (not POJOs)
5. Create domain events in `domain/{context}/event/` if needed
6. Write unit tests
7. **Verify constraints**:
- ✅ No Spring annotations (`@Component`, `@Service`, etc.)
- ✅ No JPA annotations (`@Entity`, `@Table`, etc.)
- ✅ No framework dependencies
### Add Use Case
1. Create command/query DTO in `tmpl-application/src/main/java/com/ljwx/tmpl/application/{context}/port/in/`
- Use Java 21 `record` for immutable DTOs
2. Create UseCase interface with `execute(...)` method (naming convention enforced)
3. Implement service in `tmpl-application/.../service/`
- Add `@Service` annotation
- Add `@Transactional` for write operations
- **Transaction rules**: No external HTTP calls or MQ sends inside transactions
4. Define output port interface in `port/out/` if repository/external service access needed
### Add Persistence
1. Create JPA entity (PO) in `tmpl-infrastructure/persistence/{context}/internal/`
2. Create `JpaRepository` (package-private) in `internal/`
3. Create `Dao` class (public, tenant-aware methods) in `internal/`
4. Create adapter implementing application port in `adapter/`
5. Create MapStruct mapper for domain ↔ PO conversion
6. Create Flyway migration in `tmpl-api/src/main/resources/db/migration/V{version}__{description}.sql`
### Add Controller
1. Create controller in `tmpl-api/src/main/java/com/ljwx/tmpl/api/controller/`
2. Constructor-inject UseCase interface (not implementation)
3. Map HTTP request → Command/Query DTO
4. Never include `tenantId`/`deptId`/`userId` in request body (read from `TenantContext`)
5. Add OpenAPI annotations for documentation
## Testing
### Unit Tests
- Domain: Pure business logic tests, no mocks needed
- Application: Mock ports (repositories), test use case logic
- Run: `mvn test -pl tmpl-{module}`
### Integration Tests
- Located in `tmpl-api/src/test/java/`
- Use `@SpringBootTest` + Testcontainers (PostgreSQL, Redis)
- Test full request → response flow with real database
### ArchUnit Tests
- Located in `tmpl-api/src/test/java/.../ArchitectureTest.java`
- Runs automatically with `mvn test`
- Enforces layer dependencies, naming conventions, annotations
## Key Files
- **Architecture spec**: `/docs/architecture-spec.md` - 31 core decisions, frozen contracts
- **Security context**: `/docs/contracts/security-context.md` - Multi-tenancy rules
- **Error handling**: `/docs/contracts/errors.md` - ErrorCode design
- **Transactions**: `/docs/contracts/transactions.md` - Transaction boundaries
- **POM**: `/pom.xml` - Parent POM with all dependency versions locked
## Important Design Decisions
1. **No field injection**: Always use constructor injection (no `@Autowired` on fields)
2. **ErrorCode is frozen**: Only add new entries or `@Deprecated`, never change existing `name`/`httpStatus`
3. **Transactions only in application layer**: Infrastructure never declares `@Transactional`
4. **Tenant isolation everywhere**: All queries filter by `tenantId`, all writes include `tenantId`
5. **ThreadLocal cleanup**: Always `TenantContext.clear()` in finally block
6. **No layers skipped**: Controller → UseCase → Domain (never Controller → Repository)
7. **MapStruct for mapping**: Compile-time code generation, no runtime reflection
8. **Virtual Threads ready**: Java 21 Virtual Threads enabled, ThreadLocal usage compatible
9. **Security context trust**: `tenantId`/`deptId`/`userId` only from gateway headers, never from request body
10. **Rich domain model**: Domain entities contain business logic, not just data (avoid anemic domain model)
11. **Prefer Java 21 records**: Use `record` for immutable value objects and entities when possible
12. **UseCase naming**: All UseCase entry methods named `execute(...)`
## CI/CD
**Two-job pipeline**:
1. **quality-gate** (all branches): Compile, test, format check, SpotBugs, OWASP, JaCoCo ≥70%
2. **build-and-deploy** (main/release only): Docker build, push to Harbor, update deploy repo
**Quality gates skipped by default** for fast local builds:
- `jacoco.skip=true`
- `spotbugs.skip=true`
- `dependency-check.skip=true`
CI explicitly enables with `-D{check}.skip=false`.
## Implementation Batches
Current: **Batch 2 complete** (Domain exceptions + Tenant context)
Remaining:
- Batch 3: Filters (TraceIdFilter, TenantContextFilter)
- Batch 4: ArchUnit tests
- Batch 5: Persistence layer + Flyway
- Batch 6: Docker + Kubernetes deployment
+59
View File
@@ -0,0 +1,59 @@
# ============================================================
# java-service-template — Multi-stage Docker build
# ============================================================
# Two usage modes:
#
# 1) Full build (local dev):
# docker build -t myapp:local .
#
# 2) Runtime-only (CI — JAR already built):
# docker build -t myapp:${SHA} \
# --build-arg JAR_FILE=tmpl-api/target/*.jar \
# --target runtime .
# ============================================================
# ── Stage 1: builder ──
FROM maven:3.9-eclipse-temurin-21-alpine AS builder
WORKDIR /workspace
# Cache dependencies (these layers rarely change)
COPY pom.xml .
COPY tmpl-domain/pom.xml tmpl-domain/
COPY tmpl-application/pom.xml tmpl-application/
COPY tmpl-infrastructure/pom.xml tmpl-infrastructure/
COPY tmpl-api/pom.xml tmpl-api/
RUN mvn dependency:go-offline -B -q 2>/dev/null || true
# Copy source and build
COPY . .
RUN mvn package -DskipTests -B -q \
&& cp tmpl-api/target/*.jar /workspace/app.jar
# ── Stage 2: runtime ──
FROM eclipse-temurin:21-jre-alpine AS runtime
# Fallback: if built via full multi-stage, JAR comes from builder.
# If built via CI runtime-only (--target runtime), JAR comes from ARG.
ARG JAR_FILE=
# Security: run as non-root
RUN addgroup -S app && adduser -S app -G app
WORKDIR /app
# Copy JAR: prefer ARG (CI mode), fall back to builder stage
COPY --from=builder /workspace/app.jar app.jar
# If JAR_FILE is set (CI mode), overwrite with the pre-built JAR.
# This COPY is a no-op when JAR_FILE is empty (Docker ignores empty globs
# only in some versions, so we use a shell-based approach in ENTRYPOINT
# if needed). For CI, pass --build-arg JAR_FILE=tmpl-api/target/*.jar
# and skip the builder stage via --target runtime.
USER app
# JVM defaults — override via JAVA_OPTS env variable
ENV JAVA_OPTS="-XX:+UseG1GC -XX:MaxRAMPercentage=75.0 -Djava.security.egd=file:/dev/./urandom"
EXPOSE 8080
ENTRYPOINT ["sh", "-c", "exec java ${JAVA_OPTS} -jar /app/app.jar \"$@", "--"]
Executable
+190
View File
@@ -0,0 +1,190 @@
#!/usr/bin/env bash
# ============================================================
# init.sh — Scaffold a new service from java-service-template
# ============================================================
# Usage:
# ./init.sh --service order-service
# ./init.sh --service user-service --package com.acme
# ./init.sh --service device-service --sample none
# ./init.sh --service order-service --no-table-prefix
#
# Parameters:
# --service <name> Service name, lowercase with hyphens (required)
# --package <pkg> Root package (default: com.ljwx)
# --sample order|none Keep sample order code or remove (default: order)
# --no-table-prefix Remove "t_" prefix from table names
# ============================================================
set -euo pipefail
# ── macOS / Linux sed compatibility ──
SED_INPLACE=(-i)
if [[ "$(uname)" == "Darwin" ]]; then
SED_INPLACE=(-i '')
fi
# ── Defaults ──
SERVICE=""
PACKAGE="com.ljwx"
SAMPLE="order"
NO_TABLE_PREFIX=false
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# ── Parse arguments ──
while [[ $# -gt 0 ]]; do
case "$1" in
--service) SERVICE="$2"; shift 2 ;;
--package) PACKAGE="$2"; shift 2 ;;
--sample) SAMPLE="$2"; shift 2 ;;
--no-table-prefix) NO_TABLE_PREFIX=true; shift ;;
-h|--help)
head -14 "$0" | grep '^#' | sed 's/^# \?//'
exit 0 ;;
*) echo "ERROR: Unknown option: $1" >&2; exit 1 ;;
esac
done
# ── Validate ──
if [[ -z "$SERVICE" ]]; then
echo "ERROR: --service is required." >&2; exit 1
fi
if [[ ! "$SERVICE" =~ ^[a-z][a-z0-9-]*$ ]]; then
echo "ERROR: --service must be lowercase alphanumeric with hyphens." >&2; exit 1
fi
if [[ "$SAMPLE" != "order" && "$SAMPLE" != "none" ]]; then
echo "ERROR: --sample must be 'order' or 'none'." >&2; exit 1
fi
# ── Derived values ──
# For Java package: "order-service" → "order_service"
SERVICE_PKG="${SERVICE//-/_}"
# For PascalCase: "order-service" → "OrderService"
SERVICE_PASCAL="$(echo "$SERVICE" | sed -E 's/(^|-)([a-z])/\U\2/g')"
# Package paths
PACKAGE_PATH="${PACKAGE//./\/}"
OLD_PKG_FULL="com.ljwx.tmpl"
OLD_PKG_PATH="com/ljwx/tmpl"
NEW_PKG_FULL="${PACKAGE}.${SERVICE_PKG}"
NEW_PKG_PATH="${PACKAGE_PATH}/${SERVICE_PKG}"
# Old / new identifiers
OLD_PREFIX="tmpl"
OLD_APP_CLASS="TmplApplication"
NEW_APP_CLASS="${SERVICE_PASCAL}Application"
OLD_GROUP_ID="com.ljwx.tmpl"
NEW_GROUP_ID="${PACKAGE}.${SERVICE_PKG}"
echo "============================================================"
echo " Service: ${SERVICE}"
echo " Package: ${NEW_PKG_FULL}"
echo " GroupId: ${NEW_GROUP_ID}"
echo " Sample: ${SAMPLE}"
echo " Table prefix: $(${NO_TABLE_PREFIX} && echo 'none' || echo 't_')"
echo "============================================================"
echo ""
# ── Step 1: Rename module directories (keep hyphens in dir names) ──
echo "[1/7] Renaming module directories..."
for MODULE in domain application infrastructure api; do
OLD_DIR="${SCRIPT_DIR}/${OLD_PREFIX}-${MODULE}"
NEW_DIR="${SCRIPT_DIR}/${SERVICE}-${MODULE}"
if [[ -d "$OLD_DIR" ]]; then
mv "$OLD_DIR" "$NEW_DIR"
echo " ${OLD_PREFIX}-${MODULE} → ${SERVICE}-${MODULE}"
fi
done
# ── Step 2: Rename Java package directories ──
echo "[2/7] Renaming Java package directories..."
# Process deepest paths first (sort -r) to avoid mv conflicts
find "${SCRIPT_DIR}" -type d -path "*/${OLD_PKG_PATH}" | sort -r | while read -r DIR; do
PARENT="$(dirname "$(dirname "$(dirname "$DIR")")")"
RELATIVE="${DIR#"${PARENT}/"}"
NEW_REL="${RELATIVE/${OLD_PKG_PATH}/${NEW_PKG_PATH}}"
TARGET="${PARENT}/${NEW_REL}"
mkdir -p "$(dirname "$TARGET")"
mv "$DIR" "$TARGET"
done
# Clean up empty directories left behind
find "${SCRIPT_DIR}" -type d -empty -delete 2>/dev/null || true
# ── Step 3: Update POM files ──
echo "[3/7] Updating POM files..."
find "${SCRIPT_DIR}" -name "pom.xml" -exec sed "${SED_INPLACE[@]}" \
-e "s|<groupId>${OLD_GROUP_ID}</groupId>|<groupId>${NEW_GROUP_ID}</groupId>|g" \
-e "s|<artifactId>java-service-template</artifactId>|<artifactId>${SERVICE}</artifactId>|g" \
-e "s|<artifactId>${OLD_PREFIX}-|<artifactId>${SERVICE}-|g" \
-e "s|<module>${OLD_PREFIX}-|<module>${SERVICE}-|g" \
{} +
# ── Step 4: Update Java source (package declarations, imports, class refs) ──
echo "[4/7] Updating Java source files..."
find "${SCRIPT_DIR}" -type f \( -name "*.java" -o -name "*.xml" -o -name "*.yml" \
-o -name "*.yaml" -o -name "*.properties" \) \
-exec sed "${SED_INPLACE[@]}" \
-e "s|${OLD_PKG_FULL}|${NEW_PKG_FULL}|g" \
-e "s|${OLD_APP_CLASS}|${NEW_APP_CLASS}|g" \
{} +
# Rename main application class file
find "${SCRIPT_DIR}" -name "${OLD_APP_CLASS}.java" | while read -r FILE; do
mv "$FILE" "$(dirname "$FILE")/${NEW_APP_CLASS}.java"
echo " Renamed ${OLD_APP_CLASS}.java → ${NEW_APP_CLASS}.java"
done
# ── Step 5: Update config files ──
echo "[5/7] Updating configuration files..."
find "${SCRIPT_DIR}" -type f \( -name "*.yml" -o -name "*.yaml" \
-o -name "*.properties" -o -name "docker-compose.yml" \) \
-exec sed "${SED_INPLACE[@]}" \
-e "s|java-service-template|${SERVICE}|g" \
-e "s|tmpl_db|${SERVICE_PKG}_db|g" \
-e "s|tmpl_test|${SERVICE_PKG}_test|g" \
-e "s|tmpl-postgres|${SERVICE}-postgres|g" \
-e "s|tmpl-redis|${SERVICE}-redis|g" \
{} +
# Update Dockerfile image references
if [[ -f "${SCRIPT_DIR}/Dockerfile" ]]; then
sed "${SED_INPLACE[@]}" \
-e "s|${OLD_PREFIX}-api|${SERVICE}-api|g" \
"${SCRIPT_DIR}/Dockerfile"
fi
# ── Step 6: Handle --sample ──
if [[ "$SAMPLE" == "none" ]]; then
echo "[6/7] Removing sample order code..."
find "${SCRIPT_DIR}" -type f -path "*/order/*" -delete 2>/dev/null || true
find "${SCRIPT_DIR}" -type d -name "order" -exec rmdir {} + 2>/dev/null || true
find "${SCRIPT_DIR}" -name "V2__*" -delete 2>/dev/null || true
echo " Removed order domain model, persistence adapter, and V2 migration."
else
echo "[6/7] Keeping sample order code."
fi
# ── Step 7: Handle --no-table-prefix ──
if ${NO_TABLE_PREFIX}; then
echo "[7/7] Removing table prefix 't_'..."
find "${SCRIPT_DIR}" -type f \( -name "*.sql" -o -name "*.java" \) \
-exec sed "${SED_INPLACE[@]}" \
-e 's/t_order/order/g' \
-e 's/t_app_meta/app_meta/g' \
{} +
echo " Replaced t_order → order, t_app_meta → app_meta."
else
echo "[7/7] Table prefix 't_' unchanged."
fi
# ── Done ──
echo ""
echo "============================================================"
echo " Done! Project scaffolded as: ${SERVICE}"
echo ""
echo " Next steps:"
echo " cd $(basename "${SCRIPT_DIR}")"
echo " docker compose up -d"
echo " mvn clean test"
echo " mvn spring-boot:run -pl ${SERVICE}-api \\"
echo " -Dspring-boot.run.profiles=dev"
echo "============================================================"
+13 -9
View File
@@ -2,44 +2,48 @@
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
https://maven.apache.org/xsd/maven-4.0.0.xsd">
https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.ljwx.tmpl</groupId>
<artifactId>java-service-template</artifactId>
<version>0.1.0-SNAPSHOT</version>
<version>0.0.1-SNAPSHOT</version>
</parent>
<artifactId>tmpl-infrastructure</artifactId>
<name>tmpl-infrastructure</name>
<description>基础设施层 - JPA / Redis / 外部适配器</description>
<description>Infrastructure layer — persistence adapters, external service clients</description>
<dependencies>
<!-- ==================== Internal modules ==================== -->
<dependency>
<groupId>com.ljwx.tmpl</groupId>
<artifactId>tmpl-application</artifactId>
<version>${project.version}</version>
</dependency>
<!-- Spring Data JPA -->
<!-- ==================== Spring Data ==================== -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<!-- Redis -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- MapStruct -->
<!-- ==================== MapStruct ==================== -->
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct</artifactId>
</dependency>
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct-processor</artifactId>
<scope>provided</scope>
</dependency>
<!-- 测试 -->
<!-- ==================== Test ==================== -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
@@ -0,0 +1,83 @@
package com.ljwx.tmpl.infrastructure.persistence;
import jakarta.persistence.Column;
import jakarta.persistence.EntityListeners;
import jakarta.persistence.MappedSuperclass;
import jakarta.persistence.Version;
import org.springframework.data.annotation.CreatedBy;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedBy;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import java.time.LocalDateTime;
/**
* Base class for all JPA entities requiring auditing and soft-delete.
*
* <p>Auto-populated by Spring Data JPA Auditing:
* <ul>
* <li>{@code createUserId} — set on insert, never updated</li>
* <li>{@code updateUserId} — set on insert and every update</li>
* <li>{@code createTime} — set on insert, never updated</li>
* <li>{@code updateTime} — set on insert and every update</li>
* </ul>
*
* <p>Managed fields:
* <ul>
* <li>{@code deleted} — soft-delete flag, default {@code false}</li>
* <li>{@code version} — optimistic lock, managed by JPA {@code @Version}</li>
* </ul>
*
* <p>Audit fields have <strong>no public setter</strong> — they are populated
* exclusively by JPA Auditing via reflection. {@code version} has no public
* setter either — managed by {@code @Version}. Subclasses only declare
* their own business fields and {@code @Id}.
*/
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class BaseAuditPO {
@CreatedBy
@Column(name = "create_user_id", nullable = false, updatable = false)
private Long createUserId;
@LastModifiedBy
@Column(name = "update_user_id", nullable = false)
private Long updateUserId;
@CreatedDate
@Column(name = "create_time", nullable = false, updatable = false)
private LocalDateTime createTime;
@LastModifiedDate
@Column(name = "update_time", nullable = false)
private LocalDateTime updateTime;
@Column(name = "is_deleted", nullable = false)
private Boolean deleted = false;
@Version
@Column(name = "version", nullable = false)
private Integer version;
/* ── public getters (read-only for most callers) ── */
public Long getCreateUserId() { return createUserId; }
public Long getUpdateUserId() { return updateUserId; }
public LocalDateTime getCreateTime() { return createTime; }
public LocalDateTime getUpdateTime() { return updateTime; }
public Boolean getDeleted() { return deleted; }
public Integer getVersion() { return version; }
/* ── public setter: only deleted is business-controlled ── */
public void setDeleted(Boolean deleted) { this.deleted = deleted; }
/*
* No public setters for:
* createUserId, updateUserId → JPA Auditing via @CreatedBy/@LastModifiedBy
* createTime, updateTime → JPA Auditing via @CreatedDate/@LastModifiedDate
* version → JPA @Version
*/
}
@@ -10,68 +10,40 @@ import java.util.Optional;
* Infrastructure adapter implementing the domain
* {@link OrderRepository} port.
*
* <p>Delegates to the package-private {@link OrderJpaRepository} and
* converts between domain {@link Order} and persistence {@link OrderPO}.
*
* <p>This is the <strong>only public class</strong> in this package that
* external code should depend on (via the domain interface).
* <p>Delegates to the package-private {@link OrderJpaRepository} for
* data access and uses {@link OrderMapper} (MapStruct) for
* {@link Order} ↔ {@link OrderPO} conversion.
*/
@Repository
public class OrderDao implements OrderRepository {
private final OrderJpaRepository jpaRepo;
private final OrderMapper mapper;
public OrderDao(OrderJpaRepository jpaRepo) {
public OrderDao(OrderJpaRepository jpaRepo, OrderMapper mapper) {
this.jpaRepo = jpaRepo;
this.mapper = mapper;
}
@Override
public Order save(Order order) {
OrderPO po;
if (order.getId() != null) {
// Update path: load managed entity to preserve audit + version
// Update: load managed entity to preserve audit + version,
// then apply business fields only.
po = jpaRepo.findById(order.getId()).orElseGet(OrderPO::new);
mapper.updatePO(order, po);
} else {
// Insert path
po = new OrderPO();
// Insert: create new PO with business fields only.
po = mapper.toPO(order);
}
applyFields(order, po);
OrderPO saved = jpaRepo.save(po);
return toDomain(saved);
return mapper.toDomain(saved);
}
@Override
public Optional<Order> findByIdAndTenantId(Long id, Long tenantId) {
return jpaRepo.findByIdAndTenantId(id, tenantId)
.map(this::toDomain);
}
/* ── mapping helpers ── */
private void applyFields(Order src, OrderPO dest) {
dest.setTenantId(src.getTenantId());
dest.setDeptId(src.getDeptId());
dest.setOrderNo(src.getOrderNo());
dest.setAmount(src.getAmount());
dest.setStatus(src.getStatus());
dest.setDeleted(src.getDeleted() != null ? src.getDeleted() : false);
// Audit fields are NOT set here — JPA Auditing handles them.
}
private Order toDomain(OrderPO po) {
Order o = new Order();
o.setId(po.getId());
o.setTenantId(po.getTenantId());
o.setDeptId(po.getDeptId());
o.setOrderNo(po.getOrderNo());
o.setAmount(po.getAmount());
o.setStatus(po.getStatus());
o.setCreateUserId(po.getCreateUserId());
o.setUpdateUserId(po.getUpdateUserId());
o.setCreateTime(po.getCreateTime());
o.setUpdateTime(po.getUpdateTime());
o.setDeleted(po.getDeleted());
o.setVersion(po.getVersion());
return o;
.map(mapper::toDomain);
}
}
@@ -0,0 +1,55 @@
package com.ljwx.tmpl.infrastructure.persistence.order;
import com.ljwx.tmpl.domain.order.Order;
import org.mapstruct.BeanMapping;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import org.mapstruct.MappingTarget;
/**
* MapStruct mapper for {@link Order} ↔ {@link OrderPO} conversion.
*
* <p><strong>Package-private</strong> — only {@link OrderDao} uses this mapper.
*
* <p>Strategy: {@code toDomain} copies all fields (including audit/version
* from BaseAuditPO getters). {@code toPO} and {@code updatePO} use
* {@code @BeanMapping(ignoreByDefault=true)} and explicitly list only
* business fields, so MapStruct never touches audit fields or version
* (which have no public setter in BaseAuditPO).
*/
@Mapper(componentModel = "spring")
interface OrderMapper {
/**
* PO → Domain: full copy including audit and version fields.
* Works because Order has setters for all fields, and OrderPO
* (via BaseAuditPO) has getters for all fields.
*/
Order toDomain(OrderPO po);
/**
* Domain → new PO (insert): only business fields + deleted.
* Audit fields and version are left for JPA Auditing / {@code @Version}.
*/
@BeanMapping(ignoreByDefault = true)
@Mapping(target = "tenantId", source = "tenantId")
@Mapping(target = "deptId", source = "deptId")
@Mapping(target = "orderNo", source = "orderNo")
@Mapping(target = "amount", source = "amount")
@Mapping(target = "status", source = "status")
@Mapping(target = "deleted", source = "deleted")
OrderPO toPO(Order order);
/**
* Domain → existing PO (update): same business fields.
* The managed entity's id, audit fields, and version are preserved.
*/
@BeanMapping(ignoreByDefault = true)
@Mapping(target = "tenantId", source = "tenantId")
@Mapping(target = "deptId", source = "deptId")
@Mapping(target = "orderNo", source = "orderNo")
@Mapping(target = "amount", source = "amount")
@Mapping(target = "status", source = "status")
@Mapping(target = "deleted", source = "deleted")
void updatePO(Order source, @MappingTarget OrderPO target);
}
@@ -1,39 +1,27 @@
package com.ljwx.tmpl.infrastructure.persistence.order;
import com.ljwx.tmpl.infrastructure.persistence.BaseAuditPO;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EntityListeners;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.persistence.Version;
import org.springframework.data.annotation.CreatedBy;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedBy;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* JPA entity mapped to {@code t_order}.
*
* <p>Audit fields are auto-populated by Spring Data JPA Auditing:
* <ul>
* <li>{@code createUserId} / {@code updateUserId} — via {@code AuditorAware<Long>}</li>
* <li>{@code createTime} / {@code updateTime} — via {@code @CreatedDate} / {@code @LastModifiedDate}</li>
* </ul>
* <p>Extends {@link BaseAuditPO} for audit fields, soft-delete,
* and optimistic lock. This class only declares business fields.
*
* <p>This class is {@code public} because Hibernate requires reflective access
* for proxy creation. External code should <strong>never</strong> depend on this
* class directly — use {@link OrderDao} instead.
* <p>Public (Hibernate requires reflective access), but external code
* should use {@link OrderDao} and never depend on this class directly.
*/
@Entity
@Table(name = "t_order")
@EntityListeners(AuditingEntityListener.class)
public class OrderPO {
public class OrderPO extends BaseAuditPO {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@@ -54,34 +42,10 @@ public class OrderPO {
@Column(name = "status", nullable = false, length = 32)
private String status;
@CreatedBy
@Column(name = "create_user_id", nullable = false, updatable = false)
private Long createUserId;
@LastModifiedBy
@Column(name = "update_user_id", nullable = false)
private Long updateUserId;
@CreatedDate
@Column(name = "create_time", nullable = false, updatable = false)
private LocalDateTime createTime;
@LastModifiedDate
@Column(name = "update_time", nullable = false)
private LocalDateTime updateTime;
@Column(name = "is_deleted", nullable = false)
private Boolean deleted = false;
@Version
@Column(name = "version", nullable = false)
private Integer version = 0;
protected OrderPO() {
// JPA no-arg constructor
}
/* ── getters / setters ── */
/* ── getters / setters (business fields only) ── */
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
@@ -100,15 +64,4 @@ public class OrderPO {
public String getStatus() { return status; }
public void setStatus(String status) { this.status = status; }
public Long getCreateUserId() { return createUserId; }
public Long getUpdateUserId() { return updateUserId; }
public LocalDateTime getCreateTime() { return createTime; }
public LocalDateTime getUpdateTime() { return 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; }
}