diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..7b961de --- /dev/null +++ b/.dockerignore @@ -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 \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..f14a01f --- /dev/null +++ b/CLAUDE.md @@ -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 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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..44a731b --- /dev/null +++ b/Dockerfile @@ -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 \"$@", "--"] \ No newline at end of file diff --git a/init.sh b/init.sh new file mode 100755 index 0000000..6158c2a --- /dev/null +++ b/init.sh @@ -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 Service name, lowercase with hyphens (required) +# --package 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|${OLD_GROUP_ID}|${NEW_GROUP_ID}|g" \ + -e "s|java-service-template|${SERVICE}|g" \ + -e "s|${OLD_PREFIX}-|${SERVICE}-|g" \ + -e "s|${OLD_PREFIX}-|${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 "============================================================" \ No newline at end of file diff --git a/tmpl-infrastructure/pom.xml b/tmpl-infrastructure/pom.xml index 730d5bf..a41f7ba 100644 --- a/tmpl-infrastructure/pom.xml +++ b/tmpl-infrastructure/pom.xml @@ -2,44 +2,48 @@ + https://maven.apache.org/xsd/maven-4.0.0.xsd"> 4.0.0 com.ljwx.tmpl java-service-template - 0.1.0-SNAPSHOT + 0.0.1-SNAPSHOT tmpl-infrastructure - tmpl-infrastructure - 基础设施层 - JPA / Redis / 外部适配器 + Infrastructure layer — persistence adapters, external service clients + com.ljwx.tmpl tmpl-application + ${project.version} - + org.springframework.boot spring-boot-starter-data-jpa - - org.springframework.boot spring-boot-starter-data-redis - + org.mapstruct mapstruct + + org.mapstruct + mapstruct-processor + provided + - + org.springframework.boot spring-boot-starter-test diff --git a/tmpl-infrastructure/src/main/java/com/ljwx/tmpl/infrastructure/persistence/BaseAuditPO.java b/tmpl-infrastructure/src/main/java/com/ljwx/tmpl/infrastructure/persistence/BaseAuditPO.java new file mode 100644 index 0000000..d1a5161 --- /dev/null +++ b/tmpl-infrastructure/src/main/java/com/ljwx/tmpl/infrastructure/persistence/BaseAuditPO.java @@ -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. + * + *

Auto-populated by Spring Data JPA Auditing: + *

    + *
  • {@code createUserId} — set on insert, never updated
  • + *
  • {@code updateUserId} — set on insert and every update
  • + *
  • {@code createTime} — set on insert, never updated
  • + *
  • {@code updateTime} — set on insert and every update
  • + *
+ * + *

Managed fields: + *

    + *
  • {@code deleted} — soft-delete flag, default {@code false}
  • + *
  • {@code version} — optimistic lock, managed by JPA {@code @Version}
  • + *
+ * + *

Audit fields have no public setter — 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 + */ +} \ No newline at end of file diff --git a/tmpl-infrastructure/src/main/java/com/ljwx/tmpl/infrastructure/persistence/order/OrderDao.java b/tmpl-infrastructure/src/main/java/com/ljwx/tmpl/infrastructure/persistence/order/OrderDao.java index 600624f..7ddd1c2 100644 --- a/tmpl-infrastructure/src/main/java/com/ljwx/tmpl/infrastructure/persistence/order/OrderDao.java +++ b/tmpl-infrastructure/src/main/java/com/ljwx/tmpl/infrastructure/persistence/order/OrderDao.java @@ -10,68 +10,40 @@ import java.util.Optional; * Infrastructure adapter implementing the domain * {@link OrderRepository} port. * - *

Delegates to the package-private {@link OrderJpaRepository} and - * converts between domain {@link Order} and persistence {@link OrderPO}. - * - *

This is the only public class in this package that - * external code should depend on (via the domain interface). + *

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 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); } } \ No newline at end of file diff --git a/tmpl-infrastructure/src/main/java/com/ljwx/tmpl/infrastructure/persistence/order/OrderMapper.java b/tmpl-infrastructure/src/main/java/com/ljwx/tmpl/infrastructure/persistence/order/OrderMapper.java new file mode 100644 index 0000000..08f974f --- /dev/null +++ b/tmpl-infrastructure/src/main/java/com/ljwx/tmpl/infrastructure/persistence/order/OrderMapper.java @@ -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. + * + *

Package-private — only {@link OrderDao} uses this mapper. + * + *

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); +} \ No newline at end of file diff --git a/tmpl-infrastructure/src/main/java/com/ljwx/tmpl/infrastructure/persistence/order/OrderPO.java b/tmpl-infrastructure/src/main/java/com/ljwx/tmpl/infrastructure/persistence/order/OrderPO.java index 3a32370..9f8fb9e 100644 --- a/tmpl-infrastructure/src/main/java/com/ljwx/tmpl/infrastructure/persistence/order/OrderPO.java +++ b/tmpl-infrastructure/src/main/java/com/ljwx/tmpl/infrastructure/persistence/order/OrderPO.java @@ -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}. * - *

Audit fields are auto-populated by Spring Data JPA Auditing: - *

    - *
  • {@code createUserId} / {@code updateUserId} — via {@code AuditorAware}
  • - *
  • {@code createTime} / {@code updateTime} — via {@code @CreatedDate} / {@code @LastModifiedDate}
  • - *
+ *

Extends {@link BaseAuditPO} for audit fields, soft-delete, + * and optimistic lock. This class only declares business fields. * - *

This class is {@code public} because Hibernate requires reflective access - * for proxy creation. External code should never depend on this - * class directly — use {@link OrderDao} instead. + *

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; } } \ No newline at end of file