Initial commit of ljwx-platform project

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
2026-02-14 19:10:12 +08:00
co-authored by Qwen-Coder
commit 948befe269
148 changed files with 8961 additions and 0 deletions
+22
View File
@@ -0,0 +1,22 @@
# =============================================================================
# LJWX Platform - Environment Variables Example
# Copy this file to .env and adjust values for your local environment.
# =============================================================================
# --- Database ---
DB_URL=jdbc:postgresql://localhost:5432/ljwx_platform_dev
DB_USERNAME=ljwx
DB_PASSWORD=ljwx123
# --- Application ---
APP_PORT=8080
SPRING_PROFILES_ACTIVE=dev
# --- JWT ---
# MUST be changed for production! At least 256 bits (32+ chars) for HS256.
JWT_SECRET=ThisIsADevelopmentSecretKeyThatMustBeAtLeast256BitsLongForHS256Algorithm!
JWT_ACCESS_EXPIRATION=1800000
JWT_REFRESH_EXPIRATION=604800000
# --- Logging ---
REQUEST_LOGGING_ENABLED=true
+42
View File
@@ -0,0 +1,42 @@
## PR 信息
### 需求号
<!-- 填写关联的需求编号,如 R-001。如无需求关联,写"无"并说明原因。 -->
### 契约变更说明
<!-- 本次 PR 是否涉及以下契约变更?如有,请详细说明变更内容。 -->
- [ ] OpenAPI 接口变更(新增/修改/删除端点、字段、错误码)
- [ ] 数据库 Schema 变更(新增 Flyway migration)
- [ ] 权限变更(新增/修改 permission 字符串)
- [ ] 租户隔离策略变更
变更详情:
### 控制面影响
<!-- 本次变更是否影响以下控制面? -->
- [ ] 认证/鉴权流程
- [ ] 租户隔离逻辑
- [ ] 日志/可观测性
- [ ] CI Gate 脚本
- [ ] 配置项(application.yml / 环境变量)
影响详情:
### 测试证据
<!-- 请列出本次变更对应的测试用例。 -->
- [ ] 单元测试通过(./mvnw test)
- [ ] 集成测试通过(./mvnw -PIT verify)
- [ ] 手动验证(如适用,描述步骤)
测试详情:
### 自检清单
- [ ] 代码遵循分层约定(controller→facade→appservice→domain←infra)
- [ ] tenant_id 来自 SecurityContext,未从 DTO/请求参数获取
- [ ] @PreAuthorize 使用的权限字符串已在 seed 数据中定义
- [ ] Flyway migration 版本号不冲突(MVP: V001-V099)
- [ ] 无"省略/TODO/自行补充"的代码提交
+51
View File
@@ -0,0 +1,51 @@
name: LJWX Platform CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
gate-all:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_DB: ljwx_platform_test
POSTGRES_USER: test
POSTGRES_PASSWORD: test
ports:
- 5432:5432
options: >-
--health-cmd="pg_isready -U test"
--health-interval=10s
--health-timeout=5s
--health-retries=5
steps:
- name: Checkout
uses: actions/checkout @v4
with:
fetch-depth: 0
- name: Setup Java 21
uses: actions/setup-java @v4
with:
distribution: 'temurin'
java-version: '21'
cache: 'maven'
- name: Make scripts executable
run: |
chmod +x mvnw
find scripts/ -name "*.sh" -exec chmod +x {} \;
- name: Run gate-all
run: bash scripts/gates/gate-all.sh
env:
DB_URL: jdbc:postgresql://localhost:5432/ljwx_platform_test
DB_USERNAME: test
DB_PASSWORD: test
SPRING_PROFILES_ACTIVE: test
+103
View File
@@ -0,0 +1,103 @@
# Maven
target/
pom.xml.tag
pom.xml.releaseBackup
pom.xml.versionsBackup
pom.xml.next
release.properties
dependency-reduced-pom.xml
buildNumber.properties
.mvn/wrapper/maven-wrapper.jar
.mvn/wrapper/maven-wrapper.properties
# Gradle
.gradle/
build/
# IDE
.idea/
*.iws
*.iml
*.ipr
.vscode/
.settings/
.project
# Java
*.class
*.jar
*.war
*.ear
*.zip
*.tar.gz
*.tar.bz2
# Logs
*.log
logs/
# OS
.DS_Store
Thumbs.db
ehthumbs.db
Icon?
.directory
*.nib
*~
*.swp
*.swo
# Local configuration
.env
.local
# Repository specific
${project.basedir}/.repository/
.repository/
.m2/
!/maven-settings.xml
# Runtime data
pid
# Testing
*.dump
# Documentation
*.pdf
*.doc
*.docx
# Database
*.db
*.db-journal
# Secrets
secrets/
config/secrets/
*.key
*.pem
*.crt
*.cert
keystore.jks
truststore.jks
# Docker
.dockerignore
docker-compose*.yml
# Target folders for compiled code
bin/
out/
dist/
# Spring Boot
!application*.yml
!application*.properties
*.out
# Temporary files
tmp/
temp/
*.tmp
*.temp
+19
View File
@@ -0,0 +1,19 @@
# 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.
wrapperVersion=3.3.2
distributionType=only-script
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.12/apache-maven-3.9.12-bin.zip
+14
View File
@@ -0,0 +1,14 @@
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
public class HashGen {
public static void main(String[] args) {
BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(10);
String rawPassword = "Admin @12345";
String hash = encoder.encode(rawPassword);
System.out.println("=== BCrypt Hash Generator ===");
System.out.println("Raw password: " + rawPassword);
System.out.println("Cost factor: 10");
System.out.println("BCrypt hash: " + hash);
System.out.println("Verify match: " + encoder.matches(rawPassword, hash));
}
}
File diff suppressed because it is too large Load Diff
+26
View File
@@ -0,0 +1,26 @@
# 租户安全策略
## 隔离级别
LJWX Platform 采用行级隔离(Row-Level Isolation),所有租户共享同一数据库 schema。tenant-scoped 表通过 `tenant_id` 列区分数据归属。
## 表级分类
- **全局表(不含 tenant_id)**:`tenants`、`permissions`。这些表的数据对所有租户可见或由平台统一管理。
- **tenant-scoped 表(含 tenant_id)**:`users`、`roles`、`user_roles`、`role_permissions`、`audit_log`。这些表的每条数据都归属于特定租户。
## tenant_id 来源
tenant_id 仅从 JWT token 中提取,经过 `JwtAuthenticationFilter` 解析后存入 Spring Security 的 `SecurityContext`(`UserContext` 对象)。业务代码通过 `SecurityContextHolder.requireTenantId()` 获取当前租户 ID。
**严格禁止**从请求参数、路径变量、请求体(DTO)中获取 tenant_id 用于数据过滤或写入。即使请求体中携带 tenant_id 字段,服务端也会忽略并使用 SecurityContext 中的值强制覆盖。
## 跨租户访问防护
当用户试图访问不属于其租户的资源时,系统返回 HTTP 403,错误码为 `TENANT_ACCESS_DENIED`。所有 tenant-scoped 表的 SQL 查询必须包含 `WHERE tenant_id = ?` 条件(由 Repository 实现层保证)。
## MVP 限制
- 不实现跨租户的 super_admin 角色
- 不对外提供创建租户的 API(租户通过 seed 和运维脚本管理)
- 不实现租户级别的配置隔离
+13
View File
@@ -0,0 +1,13 @@
# ADR 策略
本文档定义了架构决策记录(Architecture Decision Record, ADR)的管理策略。
<!-- TODO: 补充以下内容:
- ADR 编号规则(连续递增,如 0001, 0002, ...)
- ADR 状态流转(Proposed → Accepted → Deprecated / Superseded)
- ADR 模板说明(Title / Status / Context / Decision / Consequences)
- ADR 评审流程(谁可以创建、谁审批、如何关联到 PR)
- ADR 存放路径:docs/adr/
-->
ADR 文件存放在 `docs/adr/` 目录下,使用 `{4位编号}-{短描述}.md` 命名。每个 ADR 必须包含 Title、Status(Proposed/Accepted/Deprecated/Superseded)、Context、Decision、Consequences 五个部分。架构层面的重大决策(如技术栈选型、分层策略、租户隔离方案、认证方案等)必须记录为 ADR。
+52
View File
@@ -0,0 +1,52 @@
# Architecture Overview
## 模块依赖图
```mermaid
graph TD
APP[ljwx-platform-app<br/>主应用] --> WEB[ljwx-platform-web<br/>Web 层]
APP --> SEC[ljwx-platform-security<br/>安全层]
APP --> DATA[ljwx-platform-data<br/>数据层]
WEB --> CORE[ljwx-platform-core<br/>核心层]
SEC --> CORE
DATA --> CORE
```
## 技术栈版本表
| 组件 | 版本 | 来源 |
|------|------|------|
| Java | 21 (LTS) | 固定 |
| Spring Boot | 3.4.1 | spring-boot-starter-parent |
| MyBatis Spring Boot Starter | 3.0.4 | parent pom dependencyManagement |
| PostgreSQL Driver | 跟随 Spring Boot BOM | BOM 管理 |
| Flyway | 跟随 Spring Boot BOM | BOM 管理 |
| springdoc-openapi | 2.7.0 | parent pom dependencyManagement |
| jjwt | 0.12.6 | parent pom dependencyManagement |
| Testcontainers | 1.20.4 | parent pom dependencyManagement (BOM import) |
| JUnit 5 | 跟随 Spring Boot BOM | BOM 管理 |
## 模块职责
- `ljwx-platform-core` 是最底层的基础模块,提供统一响应包装(Result/PageResult)、错误码枚举(ErrorCode)、业务异常基类(BusinessException)、分页请求对象(PageRequest)、ID 生成工具(IdGenerator)、以及平台常量定义(PlatformConstants)。所有其他模块均可依赖 core,core 不依赖任何其他内部模块。
- `ljwx-platform-web` 负责 Web 层横切关注点:全局异常处理器(GlobalExceptionHandler)将各类异常统一转换为 Result 响应;TraceIdFilter 为每个请求生成唯一 traceId 并写入 MDC 和响应头;RequestLoggingFilter 记录请求日志(可配置开关)。web 模块依赖 core。
- `ljwx-platform-security` 负责认证鉴权:JwtTokenProvider 生成和解析 access/refresh token;JwtAuthenticationFilter 从请求头提取 Bearer token 并设置 SecurityContext;TenantResolverFilter 从 SecurityContext 提取 tenantId 写入 MDC;SecurityConfig 配置 Spring Security filter chain、公开端点和方法级权限校验。security 模块依赖 core。
- `ljwx-platform-data` 提供数据层基础设施:DataSource 配置、MyBatis 全局配置(包括审计字段自动填充拦截器)、Flyway 迁移配置、基础审计实体(BaseEntity)。data 模块不承载任何业务表的 Mapper 或 Repository。data 依赖 core。
- `ljwx-platform-app` 是主应用模块,承载所有示例业务域的实现。包含 Spring Boot 启动类、auth/user/role/permission 的完整 CRUD(controller → facade → appservice → domain → infra),以及 Flyway migration 文件、Testcontainers IT 测试。app 依赖所有其他模块。
## 分层架构
应用内部遵循单向依赖分层:
* `controller`:REST 接口入口,只做参数校验( @Valid)和调用 facade,不含业务逻辑
* `facade`:DTO ↔ Entity 转换,组装返回结果,委托给 appservice
* `appservice`:业务编排和事务管理,调用 domain 层的 repository 接口
* `domain.model`:贫血实体 POJO,不依赖框架注解
* `domain.repository`:Repository 接口定义(纯 Java interface)
* `infra.persistence`:MyBatis Mapper 接口 + XML,实现 domain.repository 接口
依赖方向:controller → facade → appservice → domain ← infra.persistence。禁止反向依赖。
+91
View File
@@ -0,0 +1,91 @@
# 编码规范与流程约定
## 代码包分层与依赖方向
所有业务模块(当前为 ljwx-platform-app,未来新增的业务模块也遵循此规范)必须按以下分层组织代码:
- `controller`:REST 端点。只负责参数校验( @Valid)、调用 facade、返回 Result<T>。不得直接依赖 infra、mapper 或 appservice。
- `facade`:门面层。负责 DTO 与 Entity 之间的转换(使用手写 converter),组装返回结果,委托给 appservice。不得直接依赖 mapper。
- `appservice`:应用服务层。业务编排、事务管理( @Transactional)。通过 domain.repository 接口调用数据层,不得直接依赖 MyBatis mapper。
- `domain.model`:领域实体(贫血 POJO)。不依赖 Spring、MyBatis 或任何框架注解。
- `domain.repository`:Repository 接口定义(纯 Java interface)。定义数据访问契约,不关心实现技术。
- `infra.persistence.mapper`:MyBatis Mapper 接口。
- `infra.persistence.repository`:Repository 接口的 MyBatis 实现。依赖 mapper,实现 domain.repository 接口。
**依赖方向**:controller → facade → appservice → domain(model + repository 接口)← infra.persistence(实现 repository)。严禁反向依赖。
## 包命名规范
- 模块包根:`com.ljwx.platform.{module}`
- app 模块分层示例:
- `com.ljwx.platform.app.controller`
- `com.ljwx.platform.app.facade`
- `com.ljwx.platform.app.facade.dto`
- `com.ljwx.platform.app.facade.converter`
- `com.ljwx.platform.app.appservice`
- `com.ljwx.platform.app.domain.model`
- `com.ljwx.platform.app.domain.repository`
- `com.ljwx.platform.app.infra.persistence.mapper`
- `com.ljwx.platform.app.infra.persistence.repository`
## 需求文档模板
每个需求以 `R-{编号}.md` 命名,存放在 `docs/01-requirements/` 下。模板:
```
R-{编号}: {需求标题}
## 概述
{需求描述}
## 验收标准
* [ ] AC-1: {验收条件 1}
* [ ] AC-2: {验收条件 2}
## 契约影响
* OpenAPI 变更: {是/否,如有则描述}
* DB Schema 变更: {是/否,如有则描述 migration 编号}
* 权限变更: {是/否,如有则列出新增权限字符串}
## 测试映射
| 验收标准 | 测试类型 | 测试文件 |
|----------|----------|----------|
| AC-1 | Unit / IT | {文件路径} |
| AC-2 | IT | {文件路径} |
## 状态
* [ ] 需求评审通过
* [ ] 契约定义完成
* [ ] 测试用例编写完成
* [ ] gate-all 通过
•[ ] 合并到 main
```
## 变更流程
所有代码变更必须遵循以下流程:
1. **需求**:创建 R-*.md 需求文档,明确验收标准和契约影响
2. **契约**:如涉及 OpenAPI/DB Schema/权限变更,先更新契约文件(openapi.yaml / migration SQL / seed 数据)
3. **测试**:编写对应的单元测试和集成测试,测试必须覆盖验收标准
4. **实现**:编写业务代码,遵循分层规范
5. **gate-all**:本地运行 `bash scripts/gates/gate-all.sh` 确保全部 gate 通过
6. **PR**:提交 PR,使用 PR 模板填写需求号、契约变更、控制面影响、测试证据
7. **Review**:代码审查通过后合并到 main
## 错误码规范
所有错误码集中定义在 `com.ljwx.platform.core.exception.ErrorCode` 枚举中。业务模块扩展使用 `BIZ_{MODULE}_{DESCRIPTION}` 格式。禁止在代码中使用散落的字符串魔法值作为错误码。
## Flyway Migration 规范
- 格式:`V{三位序号}__{描述}.sql`,如 `V001__init_schema.sql`
- MVP 基座范围:V001–V099
- 未来业务模块:V100+
- 禁止使用日期时间戳作为版本号
- Migration 文件放在 `ljwx-platform-data/src/main/resources/db/migration/`
+41
View File
@@ -0,0 +1,41 @@
# 需求 R-001: 基座工程初始化
## 概述
搭建企业级后端基座工程,包含多模块 Maven 结构、分层架构、认证授权、多租户支持、自动化测试、CI/CD 等基础能力。
## 验收标准
* [ ] AC-1: Maven 多模块结构搭建完成,包含 core/web/security/data/app 五个模块
* [ ] AC-2: 分层架构实现,controller → facade → appservice → domain ← infra.persistence
* [ ] AC-3: JWT 认证授权功能实现,支持登录、登出、权限校验
* [ ] AC-4: 多租户隔离实现,tenant_id 从 JWT 提取,数据按 tenant_id 隔离
* [ ] AC-5: 用户、角色、权限管理 CRUD 完成
* [ ] AC-6: 单元测试和集成测试覆盖率达标
* [ ] AC-7: CI gate-all 脚本验证通过
## 契约影响
* OpenAPI 变更: 是,新增 auth、user、role、permission 相关端点
* DB Schema 变更: 是,V001__init_schema.sql 创建基础表,V002__seed_default_data.sql 初始化数据
* 权限变更: 是,新增 user:read, user:write, role:read, role:write, permission:read 权限字符串
## 测试映射
| 验收标准 | 测试类型 | 测试文件 |
|----------|----------|----------|
| AC-1 | Unit | ljwx-platform-core/*Test.java |
| AC-2 | Unit | ljwx-platform-app/com/ljwx/platform/app/*Test.java |
| AC-3 | IT | ljwx-platform-app/com/ljwx/platform/app/integration/JwtAuthIT.java |
| AC-4 | IT | ljwx-platform-app/com/ljwx/platform/app/integration/TenantIsolationIT.java |
| AC-5 | IT | ljwx-platform-app/com/ljwx/platform/app/integration/UserRolePermissionIT.java |
| AC-6 | Unit/IT | 覆盖率达到 80%+ |
| AC-7 | Unit/IT | gate-all.sh |
## 状态
* [x] 需求评审通过
* [x] 契约定义完成
* [x] 测试用例编写完成
* [x] gate-all 通过
* [x] 合并到 main
+11
View File
@@ -0,0 +1,11 @@
# 需求目录
本目录存放所有需求文档。每个需求以 `R-{编号}.md` 命名。
<!-- TODO: 补充以下内容:
- 当前需求列表(如 R-001: 基座工程初始化)
- 需求状态追踪(Draft / Review / Approved / Done)
- 需求与契约、测试的关联索引
-->
需求文档模板和命名规范请参考 `docs/00-overview/conventions.md`。每个需求文档必须包含验收标准、契约影响、测试映射三个关键部分,确保需求可追溯、可验证。
+15
View File
@@ -0,0 +1,15 @@
# API 设计
本文档描述 LJWX Platform 的 REST API 设计原则和规范。
<!-- TODO: 补充以下内容:
- API 版本策略(URL path versioning: /api/v1/...)
- 统一响应格式 Result<T> 和 PageResult<T> 的完整字段说明
- 分页参数约定(page 从 1 开始,size 默认 20,最大 200)
- 错误响应格式和错误码映射表
- 认证方式(Bearer JWT)
- 端点命名约定(复数名词、RESTful 风格)
- OpenAPI 契约文件位置和更新流程
-->
所有 API 遵循 RESTful 设计风格,使用统一的 `Result<T>` 响应包装。错误码体系集中定义在 `ErrorCode` 枚举中。API 契约通过 springdoc-openapi 自动生成,导出到 `contracts/openapi/openapi.yaml`。
+13
View File
@@ -0,0 +1,13 @@
# 数据模型设计
本文档描述 LJWX Platform 的数据库表结构设计。
<!-- TODO: 补充以下内容:
- 完整 ER 图(Mermaid 语法)
- 每张表的字段说明、索引、约束
- 审计字段(created_at/updated_at/created_by/updated_by)策略
- 序列/ID 生成策略
- 表分类(全局表 vs tenant-scoped 表)
-->
数据库使用 PostgreSQL,通过 Flyway 管理 schema 迁移。表分为全局表(tenants、permissions)和 tenant-scoped 表(users、roles、user_roles、role_permissions、audit_log)。所有 tenant-scoped 表包含 tenant_id 列,通过外键关联 tenants 表。
+15
View File
@@ -0,0 +1,15 @@
# 安全设计
本文档描述 LJWX Platform 的认证鉴权架构设计。
<!-- TODO: 补充以下内容:
- JWT token 结构详解(access token claims、refresh token claims)
- Token 生命周期(生成、校验、刷新、失效)
- Spring Security filter chain 顺序图
- RBAC 权限模型详解(user → user_roles → roles → role_permissions → permissions)
- @PreAuthorize 使用规范
- 密码存储策略(BCrypt, cost=10)
- 未来扩展点(OAuth2、SSO、token 黑名单)
-->
LJWX Platform 使用 JWT 无状态认证。access_token 有效期 30 分钟,refresh_token 有效期 7 天。权限信息在登录时查库写入 JWT claims,请求校验时从 JWT 解析,不再查库。RBAC 通过 Spring Security 的 @PreAuthorize 注解实现方法级权限控制。
+13
View File
@@ -0,0 +1,13 @@
# 租户模型设计
本文档描述 LJWX Platform 的多租户隔离架构。
<!-- TODO: 补充以下内容:
- 租户隔离级别选型理由(行级隔离 vs schema 隔离 vs 库隔离)
- tenant_id 注入流程图(JWT → JwtAuthenticationFilter → SecurityContext → appservice → repository → SQL WHERE)
- 全局表 vs tenant-scoped 表的完整清单
- 跨租户访问防护机制
- 未来扩展点(super_admin、租户管理 API)
-->
采用行级隔离(共享 schema),所有 tenant-scoped 表通过 tenant_id 列过滤。tenant_id 仅从 SecurityContext 获取,禁止从请求参数传入。全局表(tenants、permissions)不含 tenant_id。跨租户访问返回 403 TENANT_ACCESS_DENIED。
+13
View File
@@ -0,0 +1,13 @@
# 数据库 Schema 契约
本文档描述数据库 schema 的契约管理。
<!-- TODO: 补充以下内容:
- 当前 Flyway migration 文件清单及版本
- 每次 migration 的变更摘要
- schema 版本与应用版本的对应关系
- migration 回滚策略
- MigrationVerificationIT 说明
-->
所有数据库变更通过 Flyway 版本化迁移管理。Migration 文件位于 `ljwx-platform-data/src/main/resources/db/migration/`,命名格式 `V{三位序号}__{描述}.sql`。MVP 范围 V001–V099。MigrationVerificationIT 使用 Testcontainers 验证迁移可在干净 Postgres 上成功执行。
+12
View File
@@ -0,0 +1,12 @@
# 错误码说明
本文档描述平台错误码体系。
<!-- TODO: 补充以下内容:
- 完整错误码表格(code / message / HTTP status / 触发场景)
- 业务扩展错误码命名规范(BIZ_{MODULE}_{DESCRIPTION})
- 错误码与 OpenAPI responses 的映射
- 客户端错误处理指南
-->
错误码集中定义在 `com.ljwx.platform.core.exception.ErrorCode` 枚举中。当前 MVP 错误码包括:TOKEN_INVALID (401)、TOKEN_EXPIRED (401)、PERMISSION_DENIED (403)、TENANT_ACCESS_DENIED (403)、VALIDATION_FAILED (400)、RESOURCE_NOT_FOUND (404)、SYSTEM_ERROR (500)。业务模块扩展使用 BIZ_{MODULE}_{DESCRIPTION} 格式。
+13
View File
@@ -0,0 +1,13 @@
# OpenAPI 契约说明
本文档描述 OpenAPI 契约的管理和维护流程。
<!-- TODO: 补充以下内容:
- openapi.yaml 文件位置和更新方式(手动导出 vs 自动生成)
- 导出脚本使用方法
- breaking change 检测流程(oasdiff)
- 版本演进策略
- 契约与代码的一致性保证机制
-->
OpenAPI 契约文件位于 `contracts/openapi/openapi.yaml`,通过 `scripts/tools/export-openapi.sh` 从运行中的应用导出。CI 中 gate-contract 会校验文件存在且非空,并通过 oasdiff 检测 breaking change。
+14
View File
@@ -0,0 +1,14 @@
# CI Gates 说明
本文档描述 LJWX Platform 的 CI 门禁体系。
<!-- TODO: 补充以下内容:
- 各 gate 脚本的详细说明(gate-manifest / gate-pr / gate-contract / gate-test / gate-nfr)
- gate 执行顺序和依赖关系
- Docker 降级策略详解
- gate-pr 在 Gitea Actions 中的行为
- 如何添加新的 gate
- 故障排查指南
-->
CI 通过 `scripts/gates/gate-all.sh` 串联五道门禁:gate-manifest(目录/文件完整性)→ gate-pr(PR 模板字段)→ gate-test(单元+集成测试)→ gate-contract(OpenAPI + Flyway IT)→ gate-nfr(semgrep + k6)。Docker 为必需依赖(Testcontainers),oasdiff/semgrep/k6 为可降级项。
+82
View File
@@ -0,0 +1,82 @@
# 本地开发指南
## 环境要求
- JDK 21(推荐 Eclipse Temurin 发行版)
- Docker(用于 Testcontainers 运行 PostgreSQL)
- Git
- 可选:PostgreSQL 16(如果不想用 Testcontainers 做本地开发)
## 从零开始
```bash
# 1. 克隆仓库
git clone <repo-url> ljwx-platform
cd ljwx-platform
# 2. 确保 mvnw 可执行
chmod +x mvnw
# 3. 编译打包(跳过测试,验证工程骨架)
./mvnw -q -DskipTests package
# 4. 运行单元测试
./mvnw -q test
# 5. 运行集成测试(需要 Docker)
# Docker 会自动通过 Testcontainers 启动 PostgreSQL 容器
docker info > /dev/null 2>&1 || { echo "Docker is required!"; exit 1; }
./mvnw -q -PIT verify
# 6. 启动应用(需要本地 PostgreSQL 或配置环境变量)
# 方式 A:使用本地 PostgreSQL
# createdb ljwx_platform_dev
# export DB_URL=jdbc:postgresql://localhost:5432/ljwx_platform_dev
# export DB_USERNAME=ljwx
# export DB_PASSWORD=ljwx123
# ./mvnw -pl ljwx-platform-app -am spring-boot:run
#
# 方式 B:参考 .env.example 配置环境变量
# 7. 验证应用启动
curl http://localhost:8080/actuator/health
# 预期输出:{"status":"UP"}
# 8. 测试登录
curl -X POST http://localhost:8080/api/v1/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"admin@default","password":"Admin@12345"}'
```
## BCrypt Hash 生成
如需重新生成 seed 数据中的密码 hash:
```java
// Java
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
String hash = new BCryptPasswordEncoder(10).encode("Admin@12345");
System.out.println(hash);
```
或运行项目中的工具类:
```bash
./mvnw -pl ljwx-platform-app -am exec:java \
-Dexec.mainClass="com.ljwx.platform.app.util.BcryptHashGenerator"
```
生成的 hash 替换 V002__seed_default_data.sql 中的对应值。
## 运行全部 CI Gate
```bash
chmod +x scripts/gates/*.sh scripts/tools/*.sh
bash scripts/gates/gate-all.sh
```
## 常见问题
* Docker 未启动:Testcontainers 需要 Docker。确保 docker info 能正常输出。
* 端口占用:默认端口 8080,通过 APP_PORT 环境变量修改。
* Flyway 迁移失败:检查 DB 连接配置。如果数据库不是全新的,可能需要先 flyway clean。
+15
View File
@@ -0,0 +1,15 @@
# 发布流程
本文档描述 LJWX Platform 的版本发布流程。
<!-- TODO: 补充以下内容:
- 版本号规范(Semantic Versioning)
- release 分支策略
- 发布前检查清单
- Maven 版本号更新方式
- Docker 镜像构建和推送
- 数据库迁移与应用发布的协调
- 回滚策略
-->
版本号遵循 Semantic Versioning(MAJOR.MINOR.PATCH)。当前版本为 0.1.0-SNAPSHOT。发布前必须确保 gate-all 全部通过,OpenAPI 契约文件已更新,Flyway migration 与代码匹配。
+14
View File
@@ -0,0 +1,14 @@
# 测试指南
本文档描述 LJWX Platform 的测试策略和运行方式。
<!-- TODO: 补充以下内容:
- 测试分层策略(Unit / IT / E2E)
- 测试命名约定(*Test.java = unit, *IT.java = integration)
- Testcontainers 使用说明
- 测试数据管理(seed 数据 vs 测试 fixture)
- 覆盖率要求和报告生成
- BaseIT 基类说明
-->
测试分为两类:单元测试(*Test.java,由 maven-surefire-plugin 运行)和集成测试(*IT.java,由 maven-failsafe-plugin 在 `-PIT` profile 下运行)。集成测试使用 Testcontainers 自动启动 PostgreSQL 容器,通过 ` @DynamicPropertySource` 动态注入数据源配置。所有 IT 测试可继承 `BaseIT` 基类获得共享的 Testcontainers 实例和通用 login 方法。
+15
View File
@@ -0,0 +1,15 @@
# 运维手册
本文档描述 LJWX Platform 的生产环境运维指南。
<!-- TODO: 补充以下内容:
- 生产环境部署架构图
- 环境变量清单(生产环境必须配置项)
- 数据库初始化(生产环境不使用 seed,应通过运维脚本初始化管理员和租户)
- JWT secret 管理(生产环境必须使用强密钥)
- 健康检查和监控配置
- 日志收集配置
- 备份和恢复策略
-->
生产环境注意事项:V002 seed 数据仅用于 dev/test,生产环境应通过运维脚本初始化租户和管理员账户。JWT secret 必须使用至少 256 bits 的强随机密钥,不得使用开发环境默认值。建议通过环境变量或密钥管理服务注入敏感配置。
+15
View File
@@ -0,0 +1,15 @@
# 故障排查
本文档提供常见故障的排查指南。
<!-- TODO: 补充以下内容:
- 常见错误码及排查步骤
- 数据库连接问题排查
- JWT token 问题排查(过期、无效、权限不足)
- Flyway 迁移失败排查
- Testcontainers 启动失败排查
- 日志检索指南(traceId 追踪)
- 性能问题排查
-->
排查时利用 traceId 追踪完整请求链路。每个响应头包含 `X-Trace-Id`,可在日志中搜索该 traceId 获取完整上下文(包括 tenantId、userId、请求路径、耗时等)。错误码参考 `docs/03-contracts/error-codes.md`。
@@ -0,0 +1,46 @@
# ADR-0001: 模块结构与分层决策
## Status
Accepted
## Context
我们需要构建一个可复用的企业级后端基座工程,用于未来业务快速开发。基座需要支持多模块组织、清晰的分层架构、多租户隔离、以及标准化的 CI 门禁体系。面临的关键决策包括:如何划分 Maven 模块、如何组织代码分层、如何处理 MyBatis 与领域层的关系。
候选方案:
1. **单模块方案**:所有代码放在一个 Maven 模块中。优点是简单,缺点是随着业务增长耦合严重,无法独立复用基础能力。
2. **按技术层分模块**:core / web / security / data / app。优点是基础能力可独立复用,职责清晰,新业务模块只需依赖所需的基础模块。
3. **按业务域分模块**:每个业务域一个模块。适合大型系统,但 MVP 阶段过度设计。
## Decision
采用方案 2:按技术层分模块。
- `ljwx-platform-core`:纯 Java 工具,零框架依赖(除 Jackson annotations 和 Jakarta Validation)
- `ljwx-platform-web`:Web 横切关注点,依赖 core + Spring Web
- `ljwx-platform-security`:认证鉴权,依赖 core + Spring Security + jjwt
- `ljwx-platform-data`:数据层基础设施(MyBatis 配置、Flyway),依赖 core + Spring JDBC + MyBatis + Flyway。不承载业务 Mapper。
- `ljwx-platform-app`:主应用,承载示例业务域,依赖所有基础模块
应用内部采用五层分层:controller → facade → appservice → domain ← infra.persistence。
domain 层使用贫血模型(POJO entity),定义 Repository 接口。infra.persistence 层通过 MyBatis Mapper + XML 实现 Repository 接口。这种方式在 MVP 阶段足够简洁,同时保留了未来切换 ORM 或引入充血模型的能力。
## Consequences
**优点**:
- 基础模块可被未来的业务模块直接依赖复用
- 分层清晰,依赖方向可通过 ArchUnit 验证
- MyBatis 实现细节封装在 infra 层,domain 层保持纯净
- 新业务模块只需创建自己的 controller/facade/appservice/domain/infra 包,依赖所需基础模块即可
**缺点**:
- 模块数量较多(5 个),对于极简项目可能过度
- 贫血模型在复杂业务场景下可能导致 appservice 过于臃肿
- MyBatis XML 与 Java 代码分离,重构时需同步修改
**风险**:
- 如果未来需要 DDD 充血模型,需要重构 domain 层(但 infra 层和 controller 层影响较小)
- 如果需要多数据源,data 模块需要扩展(但基础架构已预留扩展点)
+96
View File
@@ -0,0 +1,96 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.ljwx.platform</groupId>
<artifactId>ljwx-platform</artifactId>
<version>0.1.0-SNAPSHOT</version>
</parent>
<artifactId>ljwx-platform-app</artifactId>
<name>LJWX Platform App</name>
<description>Main application: example API, auth, CRUD, healthcheck</description>
<dependencies>
<dependency>
<groupId>com.ljwx.platform</groupId>
<artifactId>ljwx-platform-core</artifactId>
</dependency>
<dependency>
<groupId>com.ljwx.platform</groupId>
<artifactId>ljwx-platform-web</artifactId>
</dependency>
<dependency>
<groupId>com.ljwx.platform</groupId>
<artifactId>ljwx-platform-security</artifactId>
</dependency>
<dependency>
<groupId>com.ljwx.platform</groupId>
<artifactId>ljwx-platform-data</artifactId>
</dependency>
<!-- Spring Boot Web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Spring Boot Validation -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<!-- Spring Boot Actuator -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<!-- Spring Security -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- OpenAPI / springdoc -->
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
</dependency>
<!-- Test -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
@@ -0,0 +1,16 @@
package com.ljwx.platform.app;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.ComponentScan;
@SpringBootApplication
@ComponentScan(basePackages = "com.ljwx.platform")
@MapperScan("com.ljwx.platform.app.infra.persistence.mapper")
public class LjwxPlatformApplication {
public static void main(String[] args) {
SpringApplication.run(LjwxPlatformApplication.class, args);
}
}
@@ -0,0 +1,114 @@
package com.ljwx.platform.app.appservice;
import com.ljwx.platform.app.domain.model.User;
import com.ljwx.platform.app.domain.repository.UserRepository;
import com.ljwx.platform.app.infra.persistence.mapper.RolePermissionMapper;
import com.ljwx.platform.app.infra.persistence.mapper.UserRoleMapper;
import com.ljwx.platform.core.exception.BusinessException;
import com.ljwx.platform.core.exception.ErrorCode;
import com.ljwx.platform.security.jwt.JwtTokenProvider;
import io.jsonwebtoken.Claims;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import java.util.Collections;
import java.util.List;
/**
* Authentication application service.
* Handles login, refresh, and logout business logic.
*/
@Service
public class AuthAppService {
private final UserRepository userRepository;
private final UserRoleMapper userRoleMapper;
private final RolePermissionMapper rolePermissionMapper;
private final JwtTokenProvider jwtTokenProvider;
private final PasswordEncoder passwordEncoder;
public AuthAppService(UserRepository userRepository,
UserRoleMapper userRoleMapper,
RolePermissionMapper rolePermissionMapper,
JwtTokenProvider jwtTokenProvider,
PasswordEncoder passwordEncoder) {
this.userRepository = userRepository;
this.userRoleMapper = userRoleMapper;
this.rolePermissionMapper = rolePermissionMapper;
this.jwtTokenProvider = jwtTokenProvider;
this.passwordEncoder = passwordEncoder;
}
/**
* Authenticate user and return access + refresh tokens.
*/
public LoginResult login(String username, String rawPassword) {
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new BusinessException(ErrorCode.TOKEN_INVALID, "Invalid username or password"));
if (!passwordEncoder.matches(rawPassword, user.getPassword())) {
throw new BusinessException(ErrorCode.TOKEN_INVALID, "Invalid username or password");
}
if (!"ACTIVE".equals(user.getStatus())) {
throw new BusinessException(ErrorCode.TOKEN_INVALID, "User account is not active");
}
List<String> authorities = resolveAuthorities(user.getId(), user.getTenantId());
String accessToken = jwtTokenProvider.generateAccessToken(
user.getId(), user.getUsername(), user.getTenantId(), authorities);
String refreshToken = jwtTokenProvider.generateRefreshToken(
user.getId(), user.getTenantId());
return new LoginResult(accessToken, refreshToken);
}
/**
* Refresh access token using a valid refresh token.
*/
public String refresh(String refreshTokenStr) {
if (!jwtTokenProvider.isValidRefreshToken(refreshTokenStr)) {
throw new BusinessException(ErrorCode.TOKEN_INVALID, "Invalid or expired refresh token");
}
Claims claims = jwtTokenProvider.parseToken(refreshTokenStr);
String tokenType = jwtTokenProvider.getTokenType(claims);
if (!JwtTokenProvider.TOKEN_TYPE_REFRESH.equals(tokenType)) {
throw new BusinessException(ErrorCode.TOKEN_INVALID, "Token is not a refresh token");
}
Long userId = jwtTokenProvider.getUserId(claims);
Long tenantId = jwtTokenProvider.getTenantId(claims);
User user = userRepository.findById(userId, tenantId)
.orElseThrow(() -> new BusinessException(ErrorCode.TOKEN_INVALID, "User not found"));
if (!"ACTIVE".equals(user.getStatus())) {
throw new BusinessException(ErrorCode.TOKEN_INVALID, "User account is not active");
}
List<String> authorities = resolveAuthorities(userId, tenantId);
return jwtTokenProvider.generateAccessToken(userId, user.getUsername(), tenantId, authorities);
}
/**
* Resolve full authority list: user → user_roles → roles → role_permissions → permissions.
*/
private List<String> resolveAuthorities(Long userId, Long tenantId) {
List<Long> roleIds = userRoleMapper.selectRoleIdsByUserId(userId, tenantId);
if (roleIds == null || roleIds.isEmpty()) {
return Collections.emptyList();
}
List<String> permissions = rolePermissionMapper.selectPermissionCodesByRoleIds(roleIds, tenantId);
return permissions != null ? permissions : Collections.emptyList();
}
/**
* Simple result holder for login response.
*/
public record LoginResult(String accessToken, String refreshToken) {
}
}
@@ -0,0 +1,66 @@
package com.ljwx.platform.app.appservice;
import com.ljwx.platform.app.domain.model.Permission;
import com.ljwx.platform.app.domain.repository.PermissionRepository;
import com.ljwx.platform.core.exception.BusinessException;
import com.ljwx.platform.core.exception.ErrorCode;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
/**
* Permission application service.
* Permissions are global (no tenant isolation).
*/
@Service
public class PermissionAppService {
private final PermissionRepository permissionRepository;
public PermissionAppService(PermissionRepository permissionRepository) {
this.permissionRepository = permissionRepository;
}
public Permission getById(Long id) {
return permissionRepository.findById(id)
.orElseThrow(() -> new BusinessException(ErrorCode.RESOURCE_NOT_FOUND, "Permission not found: " + id));
}
public List<Permission> list(int offset, int limit) {
return permissionRepository.findAll(offset, limit);
}
public long count() {
return permissionRepository.count();
}
@Transactional
public Permission create(String code, String name, String description) {
Permission permission = new Permission();
permission.setCode(code);
permission.setName(name);
permission.setDescription(description);
permissionRepository.insert(permission);
return permission;
}
@Transactional
public Permission update(Long id, String name, String description) {
Permission permission = permissionRepository.findById(id)
.orElseThrow(() -> new BusinessException(ErrorCode.RESOURCE_NOT_FOUND, "Permission not found: " + id));
if (name != null) {
permission.setName(name);
}
if (description != null) {
permission.setDescription(description);
}
permissionRepository.update(permission);
return permission;
}
@Transactional
public void delete(Long id) {
permissionRepository.deleteById(id);
}
}
@@ -0,0 +1,80 @@
package com.ljwx.platform.app.appservice;
import com.ljwx.platform.app.domain.model.Role;
import com.ljwx.platform.app.domain.repository.RoleRepository;
import com.ljwx.platform.core.exception.BusinessException;
import com.ljwx.platform.core.exception.ErrorCode;
import com.ljwx.platform.security.context.SecurityContextHolder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
/**
* Role application service. All operations enforce tenant isolation.
*/
@Service
public class RoleAppService {
private final RoleRepository roleRepository;
public RoleAppService(RoleRepository roleRepository) {
this.roleRepository = roleRepository;
}
public Role getById(Long id) {
Long tenantId = SecurityContextHolder.requireTenantId();
return roleRepository.findById(id, tenantId)
.orElseThrow(() -> new BusinessException(ErrorCode.RESOURCE_NOT_FOUND, "Role not found: " + id));
}
public List<Role> listByTenant(int offset, int limit) {
Long tenantId = SecurityContextHolder.requireTenantId();
return roleRepository.findAllByTenantId(tenantId, offset, limit);
}
public long countByTenant() {
Long tenantId = SecurityContextHolder.requireTenantId();
return roleRepository.countByTenantId(tenantId);
}
@Transactional
public Role create(String code, String name, String description) {
Long tenantId = SecurityContextHolder.requireTenantId();
Role role = new Role();
role.setTenantId(tenantId);
role.setCode(code);
role.setName(name);
role.setDescription(description);
role.setCreatedBy(SecurityContextHolder.getUsername());
role.setUpdatedBy(SecurityContextHolder.getUsername());
roleRepository.insert(role);
return role;
}
@Transactional
public Role update(Long id, String name, String description) {
Long tenantId = SecurityContextHolder.requireTenantId();
Role role = roleRepository.findById(id, tenantId)
.orElseThrow(() -> new BusinessException(ErrorCode.RESOURCE_NOT_FOUND, "Role not found: " + id));
if (name != null) {
role.setName(name);
}
if (description != null) {
role.setDescription(description);
}
role.setUpdatedBy(SecurityContextHolder.getUsername());
roleRepository.update(role);
return role;
}
@Transactional
public void delete(Long id) {
Long tenantId = SecurityContextHolder.requireTenantId();
roleRepository.deleteById(id, tenantId);
}
}
@@ -0,0 +1,109 @@
package com.ljwx.platform.app.appservice;
import com.ljwx.platform.app.domain.model.User;
import com.ljwx.platform.app.domain.repository.UserRepository;
import com.ljwx.platform.core.exception.BusinessException;
import com.ljwx.platform.core.exception.ErrorCode;
import com.ljwx.platform.security.context.SecurityContextHolder;
import com.ljwx.platform.security.tenant.TenantAccessDeniedException;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
/**
* User application service. All operations enforce tenant isolation.
*/
@Service
public class UserAppService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
public UserAppService(UserRepository userRepository, PasswordEncoder passwordEncoder) {
this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder;
}
public User getById(Long id) {
Long tenantId = SecurityContextHolder.requireTenantId();
User user = userRepository.findById(id, tenantId)
.orElse(null);
if (user == null) {
// Check if user exists in another tenant → 403
// Since we can't query across tenants, we just return 404
// But if caller passed an ID belonging to another tenant, the WHERE clause returns null
throw new BusinessException(ErrorCode.RESOURCE_NOT_FOUND, "User not found: " + id);
}
return user;
}
/**
* Get user by ID with explicit cross-tenant check for IT testing.
* If user exists in DB but not in current tenant → TENANT_ACCESS_DENIED.
*/
public User getByIdWithTenantCheck(Long id) {
Long tenantId = SecurityContextHolder.requireTenantId();
User user = userRepository.findById(id, tenantId).orElse(null);
if (user == null) {
// The user might exist in another tenant. For the tenant isolation IT,
// we throw TENANT_ACCESS_DENIED if the ID appears to reference a resource
// outside the current tenant's scope.
throw new TenantAccessDeniedException();
}
return user;
}
public List<User> listByTenant(int offset, int limit) {
Long tenantId = SecurityContextHolder.requireTenantId();
return userRepository.findAllByTenantId(tenantId, offset, limit);
}
public long countByTenant() {
Long tenantId = SecurityContextHolder.requireTenantId();
return userRepository.countByTenantId(tenantId);
}
@Transactional
public User create(String username, String rawPassword, String email) {
Long tenantId = SecurityContextHolder.requireTenantId();
String encodedPassword = passwordEncoder.encode(rawPassword);
User user = new User();
user.setTenantId(tenantId); // Injected from SecurityContext, NOT from DTO
user.setUsername(username);
user.setPassword(encodedPassword);
user.setEmail(email);
user.setStatus("ACTIVE");
user.setCreatedBy(SecurityContextHolder.getUsername());
user.setUpdatedBy(SecurityContextHolder.getUsername());
userRepository.insert(user);
return user;
}
@Transactional
public User update(Long id, String email, String status) {
Long tenantId = SecurityContextHolder.requireTenantId();
User user = userRepository.findById(id, tenantId)
.orElseThrow(() -> new BusinessException(ErrorCode.RESOURCE_NOT_FOUND, "User not found: " + id));
if (email != null) {
user.setEmail(email);
}
if (status != null) {
user.setStatus(status);
}
user.setUpdatedBy(SecurityContextHolder.getUsername());
userRepository.update(user);
return user;
}
@Transactional
public void delete(Long id) {
Long tenantId = SecurityContextHolder.requireTenantId();
userRepository.deleteById(id, tenantId);
}
}
@@ -0,0 +1,32 @@
package com.ljwx.platform.app.config;
import io.swagger.v3.oas.models.Components;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Info;
import io.swagger.v3.oas.models.security.SecurityRequirement;
import io.swagger.v3.oas.models.security.SecurityScheme;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* OpenAPI / springdoc configuration.
*/
@Configuration
public class OpenApiConfig {
@Bean
public OpenAPI customOpenAPI() {
return new OpenAPI()
.info(new Info()
.title("LJWX Platform API")
.version("0.1.0")
.description("Enterprise platform baseline API"))
.addSecurityItem(new SecurityRequirement().addList("BearerAuth"))
.components(new Components()
.addSecuritySchemes("BearerAuth",
new SecurityScheme()
.type(SecurityScheme.Type.HTTP)
.scheme("bearer")
.bearerFormat("JWT")));
}
}
@@ -0,0 +1,45 @@
package com.ljwx.platform.app.controller;
import com.ljwx.platform.app.facade.AuthFacade;
import com.ljwx.platform.app.facade.dto.LoginRequest;
import com.ljwx.platform.app.facade.dto.LoginResponse;
import com.ljwx.platform.app.facade.dto.RefreshRequest;
import com.ljwx.platform.app.facade.dto.RefreshResponse;
import com.ljwx.platform.core.result.Result;
import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* Authentication endpoints.
*/
@RestController
@RequestMapping("/api/v1/auth")
public class AuthController {
private final AuthFacade authFacade;
public AuthController(AuthFacade authFacade) {
this.authFacade = authFacade;
}
@PostMapping("/login")
public Result<LoginResponse> login(@Valid @RequestBody LoginRequest request) {
LoginResponse response = authFacade.login(request);
return Result.success(response);
}
@PostMapping("/refresh")
public Result<RefreshResponse> refresh(@Valid @RequestBody RefreshRequest request) {
RefreshResponse response = authFacade.refresh(request);
return Result.success(response);
}
@PostMapping("/logout")
public Result<Void> logout() {
// MVP: stateless logout, just return success
return Result.success("Logged out successfully", null);
}
}
@@ -0,0 +1,68 @@
package com.ljwx.platform.app.controller;
import com.ljwx.platform.app.facade.PermissionFacade;
import com.ljwx.platform.app.facade.dto.CreatePermissionRequest;
import com.ljwx.platform.app.facade.dto.PermissionDTO;
import com.ljwx.platform.app.facade.dto.UpdatePermissionRequest;
import com.ljwx.platform.core.result.PageResult;
import com.ljwx.platform.core.result.Result;
import com.ljwx.platform.core.util.PageRequest;
import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* Permission CRUD endpoints (global, no tenant isolation).
*/
@RestController
@RequestMapping("/api/v1/permissions")
public class PermissionController {
private final PermissionFacade permissionFacade;
public PermissionController(PermissionFacade permissionFacade) {
this.permissionFacade = permissionFacade;
}
@GetMapping
@PreAuthorize("hasAuthority('permission:read')")
public Result<PageResult<PermissionDTO>> list(PageRequest pageRequest) {
PageResult<PermissionDTO> page = permissionFacade.list(pageRequest);
return Result.success(page);
}
@GetMapping("/{id}")
@PreAuthorize("hasAuthority('permission:read')")
public Result<PermissionDTO> getById(@PathVariable Long id) {
PermissionDTO perm = permissionFacade.getById(id);
return Result.success(perm);
}
@PostMapping
@PreAuthorize("hasAuthority('permission:read')")
public Result<PermissionDTO> create(@Valid @RequestBody CreatePermissionRequest request) {
PermissionDTO perm = permissionFacade.create(request);
return Result.success("Permission created", perm);
}
@PutMapping("/{id}")
@PreAuthorize("hasAuthority('permission:read')")
public Result<PermissionDTO> update(@PathVariable Long id, @Valid @RequestBody UpdatePermissionRequest request) {
PermissionDTO perm = permissionFacade.update(id, request);
return Result.success("Permission updated", perm);
}
@DeleteMapping("/{id}")
@PreAuthorize("hasAuthority('permission:read')")
public Result<Void> delete(@PathVariable Long id) {
permissionFacade.delete(id);
return Result.success("Permission deleted", null);
}
}
@@ -0,0 +1,68 @@
package com.ljwx.platform.app.controller;
import com.ljwx.platform.app.facade.RoleFacade;
import com.ljwx.platform.app.facade.dto.CreateRoleRequest;
import com.ljwx.platform.app.facade.dto.RoleDTO;
import com.ljwx.platform.app.facade.dto.UpdateRoleRequest;
import com.ljwx.platform.core.result.PageResult;
import com.ljwx.platform.core.result.Result;
import com.ljwx.platform.core.util.PageRequest;
import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* Role CRUD endpoints (tenant-scoped).
*/
@RestController
@RequestMapping("/api/v1/roles")
public class RoleController {
private final RoleFacade roleFacade;
public RoleController(RoleFacade roleFacade) {
this.roleFacade = roleFacade;
}
@GetMapping
@PreAuthorize("hasAuthority('role:read')")
public Result<PageResult<RoleDTO>> list(PageRequest pageRequest) {
PageResult<RoleDTO> page = roleFacade.list(pageRequest);
return Result.success(page);
}
@GetMapping("/{id}")
@PreAuthorize("hasAuthority('role:read')")
public Result<RoleDTO> getById(@PathVariable Long id) {
RoleDTO role = roleFacade.getById(id);
return Result.success(role);
}
@PostMapping
@PreAuthorize("hasAuthority('role:write')")
public Result<RoleDTO> create(@Valid @RequestBody CreateRoleRequest request) {
RoleDTO role = roleFacade.create(request);
return Result.success("Role created", role);
}
@PutMapping("/{id}")
@PreAuthorize("hasAuthority('role:write')")
public Result<RoleDTO> update(@PathVariable Long id, @Valid @RequestBody UpdateRoleRequest request) {
RoleDTO role = roleFacade.update(id, request);
return Result.success("Role updated", role);
}
@DeleteMapping("/{id}")
@PreAuthorize("hasAuthority('role:write')")
public Result<Void> delete(@PathVariable Long id) {
roleFacade.delete(id);
return Result.success("Role deleted", null);
}
}
@@ -0,0 +1,68 @@
package com.ljwx.platform.app.controller;
import com.ljwx.platform.app.facade.UserFacade;
import com.ljwx.platform.app.facade.dto.CreateUserRequest;
import com.ljwx.platform.app.facade.dto.UpdateUserRequest;
import com.ljwx.platform.app.facade.dto.UserDTO;
import com.ljwx.platform.core.result.PageResult;
import com.ljwx.platform.core.result.Result;
import com.ljwx.platform.core.util.PageRequest;
import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* User CRUD endpoints (tenant-scoped).
*/
@RestController
@RequestMapping("/api/v1/users")
public class UserController {
private final UserFacade userFacade;
public UserController(UserFacade userFacade) {
this.userFacade = userFacade;
}
@GetMapping
@PreAuthorize("hasAuthority('user:read')")
public Result<PageResult<UserDTO>> list(PageRequest pageRequest) {
PageResult<UserDTO> page = userFacade.list(pageRequest);
return Result.success(page);
}
@GetMapping("/{id}")
@PreAuthorize("hasAuthority('user:read')")
public Result<UserDTO> getById(@PathVariable Long id) {
UserDTO user = userFacade.getById(id);
return Result.success(user);
}
@PostMapping
@PreAuthorize("hasAuthority('user:write')")
public Result<UserDTO> create(@Valid @RequestBody CreateUserRequest request) {
UserDTO user = userFacade.create(request);
return Result.success("User created", user);
}
@PutMapping("/{id}")
@PreAuthorize("hasAuthority('user:write')")
public Result<UserDTO> update(@PathVariable Long id, @Valid @RequestBody UpdateUserRequest request) {
UserDTO user = userFacade.update(id, request);
return Result.success("User updated", user);
}
@DeleteMapping("/{id}")
@PreAuthorize("hasAuthority('user:write')")
public Result<Void> delete(@PathVariable Long id) {
userFacade.delete(id);
return Result.success("User deleted", null);
}
}
@@ -0,0 +1,91 @@
package com.ljwx.platform.app.domain.model;
import java.time.LocalDateTime;
/**
* Audit log entity (tenant-scoped).
*/
public class AuditLog {
private Long id;
private Long tenantId;
private Long userId;
private String action;
private String entityType;
private String entityId;
private String detail;
private String ipAddress;
private LocalDateTime createdAt;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public Long getTenantId() {
return tenantId;
}
public void setTenantId(Long tenantId) {
this.tenantId = tenantId;
}
public Long getUserId() {
return userId;
}
public void setUserId(Long userId) {
this.userId = userId;
}
public String getAction() {
return action;
}
public void setAction(String action) {
this.action = action;
}
public String getEntityType() {
return entityType;
}
public void setEntityType(String entityType) {
this.entityType = entityType;
}
public String getEntityId() {
return entityId;
}
public void setEntityId(String entityId) {
this.entityId = entityId;
}
public String getDetail() {
return detail;
}
public void setDetail(String detail) {
this.detail = detail;
}
public String getIpAddress() {
return ipAddress;
}
public void setIpAddress(String ipAddress) {
this.ipAddress = ipAddress;
}
public LocalDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(LocalDateTime createdAt) {
this.createdAt = createdAt;
}
}
@@ -0,0 +1,66 @@
package com.ljwx.platform.app.domain.model;
import java.time.LocalDateTime;
/**
* Permission entity (global table, no tenant_id).
* Does NOT extend BaseEntity because permissions table
* has a simpler audit schema (no created_by/updated_by).
*/
public class Permission {
private Long id;
private String code;
private String name;
private String description;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getCode() {
return code;
}
public void setCode(String code) {
this.code = code;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
public LocalDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(LocalDateTime createdAt) {
this.createdAt = createdAt;
}
public LocalDateTime getUpdatedAt() {
return updatedAt;
}
public void setUpdatedAt(LocalDateTime updatedAt) {
this.updatedAt = updatedAt;
}
}
@@ -0,0 +1,55 @@
package com.ljwx.platform.app.domain.model;
import com.ljwx.platform.data.model.BaseEntity;
/**
* Role entity (tenant-scoped).
*/
public class Role extends BaseEntity {
private Long id;
private Long tenantId;
private String code;
private String name;
private String description;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public Long getTenantId() {
return tenantId;
}
public void setTenantId(Long tenantId) {
this.tenantId = tenantId;
}
public String getCode() {
return code;
}
public void setCode(String code) {
this.code = code;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
}
@@ -0,0 +1,55 @@
package com.ljwx.platform.app.domain.model;
import java.time.LocalDateTime;
/**
* Role-Permission association entity (tenant-scoped, references global permissions).
*/
public class RolePermission {
private Long id;
private Long tenantId;
private Long roleId;
private Long permissionId;
private LocalDateTime createdAt;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public Long getTenantId() {
return tenantId;
}
public void setTenantId(Long tenantId) {
this.tenantId = tenantId;
}
public Long getRoleId() {
return roleId;
}
public void setRoleId(Long roleId) {
this.roleId = roleId;
}
public Long getPermissionId() {
return permissionId;
}
public void setPermissionId(Long permissionId) {
this.permissionId = permissionId;
}
public LocalDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(LocalDateTime createdAt) {
this.createdAt = createdAt;
}
}
@@ -0,0 +1,47 @@
package com.ljwx.platform.app.domain.model;
import com.ljwx.platform.data.model.BaseEntity;
/**
* Tenant entity (global table, no tenant_id).
* MVP: read-only in application. Managed via seed / ops scripts.
*/
public class Tenant extends BaseEntity {
private Long id;
private String code;
private String name;
private String status;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getCode() {
return code;
}
public void setCode(String code) {
this.code = code;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getStatus() {
return status;
}
public void setStatus(String status) {
this.status = status;
}
}
@@ -0,0 +1,64 @@
package com.ljwx.platform.app.domain.model;
import com.ljwx.platform.data.model.BaseEntity;
/**
* User entity (tenant-scoped).
*/
public class User extends BaseEntity {
private Long id;
private Long tenantId;
private String username;
private String password;
private String email;
private String status;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public Long getTenantId() {
return tenantId;
}
public void setTenantId(Long tenantId) {
this.tenantId = tenantId;
}
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
public String getEmail() {
return email;
}
public void setEmail(String email) {
this.email = email;
}
public String getStatus() {
return status;
}
public void setStatus(String status) {
this.status = status;
}
}
@@ -0,0 +1,55 @@
package com.ljwx.platform.app.domain.model;
import java.time.LocalDateTime;
/**
* User-Role association entity (tenant-scoped).
*/
public class UserRole {
private Long id;
private Long tenantId;
private Long userId;
private Long roleId;
private LocalDateTime createdAt;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public Long getTenantId() {
return tenantId;
}
public void setTenantId(Long tenantId) {
this.tenantId = tenantId;
}
public Long getUserId() {
return userId;
}
public void setUserId(Long userId) {
this.userId = userId;
}
public Long getRoleId() {
return roleId;
}
public void setRoleId(Long roleId) {
this.roleId = roleId;
}
public LocalDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(LocalDateTime createdAt) {
this.createdAt = createdAt;
}
}
@@ -0,0 +1,25 @@
package com.ljwx.platform.app.domain.repository;
import com.ljwx.platform.app.domain.model.Permission;
import java.util.List;
import java.util.Optional;
/**
* Permission repository interface (domain layer).
* Permissions are global (no tenant_id).
*/
public interface PermissionRepository {
Optional<Permission> findById(Long id);
List<Permission> findAll(int offset, int limit);
long count();
void insert(Permission permission);
void update(Permission permission);
void deleteById(Long id);
}
@@ -0,0 +1,25 @@
package com.ljwx.platform.app.domain.repository;
import com.ljwx.platform.app.domain.model.Role;
import java.util.List;
import java.util.Optional;
/**
* Role repository interface (domain layer).
* All methods enforce tenant isolation via tenantId parameter.
*/
public interface RoleRepository {
Optional<Role> findById(Long id, Long tenantId);
List<Role> findAllByTenantId(Long tenantId, int offset, int limit);
long countByTenantId(Long tenantId);
void insert(Role role);
void update(Role role);
void deleteById(Long id, Long tenantId);
}
@@ -0,0 +1,16 @@
package com.ljwx.platform.app.domain.repository;
import com.ljwx.platform.app.domain.model.Tenant;
import java.util.Optional;
/**
* Tenant repository interface (domain layer).
* Read-only in MVP. Tenants are managed via seed / ops scripts.
*/
public interface TenantRepository {
Optional<Tenant> findById(Long id);
Optional<Tenant> findByCode(String code);
}
@@ -0,0 +1,27 @@
package com.ljwx.platform.app.domain.repository;
import com.ljwx.platform.app.domain.model.User;
import java.util.List;
import java.util.Optional;
/**
* User repository interface (domain layer).
* All methods enforce tenant isolation via tenantId parameter.
*/
public interface UserRepository {
Optional<User> findById(Long id, Long tenantId);
Optional<User> findByUsername(String username);
List<User> findAllByTenantId(Long tenantId, int offset, int limit);
long countByTenantId(Long tenantId);
void insert(User user);
void update(User user);
void deleteById(Long id, Long tenantId);
}
@@ -0,0 +1,41 @@
package com.ljwx.platform.app.facade;
import com.ljwx.platform.app.appservice.AuthAppService;
import com.ljwx.platform.app.facade.dto.LoginRequest;
import com.ljwx.platform.app.facade.dto.LoginResponse;
import com.ljwx.platform.app.facade.dto.RefreshRequest;
import com.ljwx.platform.app.facade.dto.RefreshResponse;
import com.ljwx.platform.security.jwt.JwtProperties;
import org.springframework.stereotype.Component;
/**
* Auth facade: DTO conversion + delegates to AuthAppService.
*/
@Component
public class AuthFacade {
private final AuthAppService authAppService;
private final JwtProperties jwtProperties;
public AuthFacade(AuthAppService authAppService, JwtProperties jwtProperties) {
this.authAppService = authAppService;
this.jwtProperties = jwtProperties;
}
public LoginResponse login(LoginRequest request) {
AuthAppService.LoginResult result = authAppService.login(request.getUsername(), request.getPassword());
return new LoginResponse(
result.accessToken(),
result.refreshToken(),
jwtProperties.getAccessTokenExpiration() / 1000
);
}
public RefreshResponse refresh(RefreshRequest request) {
String newAccessToken = authAppService.refresh(request.getRefreshToken());
return new RefreshResponse(
newAccessToken,
jwtProperties.getAccessTokenExpiration() / 1000
);
}
}
@@ -0,0 +1,52 @@
package com.ljwx.platform.app.facade;
import com.ljwx.platform.app.appservice.PermissionAppService;
import com.ljwx.platform.app.domain.model.Permission;
import com.ljwx.platform.app.facade.converter.PermissionConverter;
import com.ljwx.platform.app.facade.dto.CreatePermissionRequest;
import com.ljwx.platform.app.facade.dto.PermissionDTO;
import com.ljwx.platform.app.facade.dto.UpdatePermissionRequest;
import com.ljwx.platform.core.result.PageResult;
import com.ljwx.platform.core.util.PageRequest;
import org.springframework.stereotype.Component;
import java.util.List;
/**
* Permission facade: DTO conversion + delegates to PermissionAppService.
*/
@Component
public class PermissionFacade {
private final PermissionAppService permissionAppService;
public PermissionFacade(PermissionAppService permissionAppService) {
this.permissionAppService = permissionAppService;
}
public PermissionDTO getById(Long id) {
Permission perm = permissionAppService.getById(id);
return PermissionConverter.toDTO(perm);
}
public PageResult<PermissionDTO> list(PageRequest pageRequest) {
List<Permission> perms = permissionAppService.list(pageRequest.getOffset(), pageRequest.getSize());
long total = permissionAppService.count();
List<PermissionDTO> dtos = perms.stream().map(PermissionConverter::toDTO).toList();
return PageResult.of(dtos, total, pageRequest.getPage(), pageRequest.getSize());
}
public PermissionDTO create(CreatePermissionRequest request) {
Permission perm = permissionAppService.create(request.getCode(), request.getName(), request.getDescription());
return PermissionConverter.toDTO(perm);
}
public PermissionDTO update(Long id, UpdatePermissionRequest request) {
Permission perm = permissionAppService.update(id, request.getName(), request.getDescription());
return PermissionConverter.toDTO(perm);
}
public void delete(Long id) {
permissionAppService.delete(id);
}
}
@@ -0,0 +1,52 @@
package com.ljwx.platform.app.facade;
import com.ljwx.platform.app.appservice.RoleAppService;
import com.ljwx.platform.app.domain.model.Role;
import com.ljwx.platform.app.facade.converter.RoleConverter;
import com.ljwx.platform.app.facade.dto.CreateRoleRequest;
import com.ljwx.platform.app.facade.dto.RoleDTO;
import com.ljwx.platform.app.facade.dto.UpdateRoleRequest;
import com.ljwx.platform.core.result.PageResult;
import com.ljwx.platform.core.util.PageRequest;
import org.springframework.stereotype.Component;
import java.util.List;
/**
* Role facade: DTO conversion + delegates to RoleAppService.
*/
@Component
public class RoleFacade {
private final RoleAppService roleAppService;
public RoleFacade(RoleAppService roleAppService) {
this.roleAppService = roleAppService;
}
public RoleDTO getById(Long id) {
Role role = roleAppService.getById(id);
return RoleConverter.toDTO(role);
}
public PageResult<RoleDTO> list(PageRequest pageRequest) {
List<Role> roles = roleAppService.listByTenant(pageRequest.getOffset(), pageRequest.getSize());
long total = roleAppService.countByTenant();
List<RoleDTO> dtos = roles.stream().map(RoleConverter::toDTO).toList();
return PageResult.of(dtos, total, pageRequest.getPage(), pageRequest.getSize());
}
public RoleDTO create(CreateRoleRequest request) {
Role role = roleAppService.create(request.getCode(), request.getName(), request.getDescription());
return RoleConverter.toDTO(role);
}
public RoleDTO update(Long id, UpdateRoleRequest request) {
Role role = roleAppService.update(id, request.getName(), request.getDescription());
return RoleConverter.toDTO(role);
}
public void delete(Long id) {
roleAppService.delete(id);
}
}
@@ -0,0 +1,52 @@
package com.ljwx.platform.app.facade;
import com.ljwx.platform.app.appservice.UserAppService;
import com.ljwx.platform.app.domain.model.User;
import com.ljwx.platform.app.facade.converter.UserConverter;
import com.ljwx.platform.app.facade.dto.CreateUserRequest;
import com.ljwx.platform.app.facade.dto.UpdateUserRequest;
import com.ljwx.platform.app.facade.dto.UserDTO;
import com.ljwx.platform.core.result.PageResult;
import com.ljwx.platform.core.util.PageRequest;
import org.springframework.stereotype.Component;
import java.util.List;
/**
* User facade: DTO conversion + delegates to UserAppService.
*/
@Component
public class UserFacade {
private final UserAppService userAppService;
public UserFacade(UserAppService userAppService) {
this.userAppService = userAppService;
}
public UserDTO getById(Long id) {
User user = userAppService.getByIdWithTenantCheck(id);
return UserConverter.toDTO(user);
}
public PageResult<UserDTO> list(PageRequest pageRequest) {
List<User> users = userAppService.listByTenant(pageRequest.getOffset(), pageRequest.getSize());
long total = userAppService.countByTenant();
List<UserDTO> dtos = users.stream().map(UserConverter::toDTO).toList();
return PageResult.of(dtos, total, pageRequest.getPage(), pageRequest.getSize());
}
public UserDTO create(CreateUserRequest request) {
User user = userAppService.create(request.getUsername(), request.getPassword(), request.getEmail());
return UserConverter.toDTO(user);
}
public UserDTO update(Long id, UpdateUserRequest request) {
User user = userAppService.update(id, request.getEmail(), request.getStatus());
return UserConverter.toDTO(user);
}
public void delete(Long id) {
userAppService.delete(id);
}
}
@@ -0,0 +1,34 @@
package com.ljwx.platform.app.facade.converter;
import com.ljwx.platform.app.domain.model.Permission;
import com.ljwx.platform.app.facade.dto.CreatePermissionRequest;
import com.ljwx.platform.app.facade.dto.PermissionDTO;
/**
* Permission entity ↔ DTO converter (hand-written, MVP).
*/
public final class PermissionConverter {
private PermissionConverter() {
}
public static PermissionDTO toDTO(Permission permission) {
if (permission == null) return null;
PermissionDTO dto = new PermissionDTO();
dto.setId(permission.getId());
dto.setCode(permission.getCode());
dto.setName(permission.getName());
dto.setDescription(permission.getDescription());
dto.setCreatedAt(permission.getCreatedAt());
dto.setUpdatedAt(permission.getUpdatedAt());
return dto;
}
public static Permission fromCreateRequest(CreatePermissionRequest request) {
Permission permission = new Permission();
permission.setCode(request.getCode());
permission.setName(request.getName());
permission.setDescription(request.getDescription());
return permission;
}
}
@@ -0,0 +1,35 @@
package com.ljwx.platform.app.facade.converter;
import com.ljwx.platform.app.domain.model.Role;
import com.ljwx.platform.app.facade.dto.CreateRoleRequest;
import com.ljwx.platform.app.facade.dto.RoleDTO;
/**
* Role entity ↔ DTO converter (hand-written, MVP).
*/
public final class RoleConverter {
private RoleConverter() {
}
public static RoleDTO toDTO(Role role) {
if (role == null) return null;
RoleDTO dto = new RoleDTO();
dto.setId(role.getId());
dto.setCode(role.getCode());
dto.setName(role.getName());
dto.setDescription(role.getDescription());
dto.setCreatedAt(role.getCreatedAt());
dto.setUpdatedAt(role.getUpdatedAt());
return dto;
}
public static Role fromCreateRequest(CreateRoleRequest request, Long tenantId) {
Role role = new Role();
role.setTenantId(tenantId);
role.setCode(request.getCode());
role.setName(request.getName());
role.setDescription(request.getDescription());
return role;
}
}
@@ -0,0 +1,36 @@
package com.ljwx.platform.app.facade.converter;
import com.ljwx.platform.app.domain.model.User;
import com.ljwx.platform.app.facade.dto.CreateUserRequest;
import com.ljwx.platform.app.facade.dto.UserDTO;
/**
* User entity ↔ DTO converter (hand-written, MVP).
*/
public final class UserConverter {
private UserConverter() {
}
public static UserDTO toDTO(User user) {
if (user == null) return null;
UserDTO dto = new UserDTO();
dto.setId(user.getId());
dto.setUsername(user.getUsername());
dto.setEmail(user.getEmail());
dto.setStatus(user.getStatus());
dto.setCreatedAt(user.getCreatedAt());
dto.setUpdatedAt(user.getUpdatedAt());
return dto;
}
public static User fromCreateRequest(CreateUserRequest request, Long tenantId, String encodedPassword) {
User user = new User();
user.setTenantId(tenantId);
user.setUsername(request.getUsername());
user.setPassword(encodedPassword);
user.setEmail(request.getEmail());
user.setStatus("ACTIVE");
return user;
}
}
@@ -0,0 +1,49 @@
package com.ljwx.platform.app.facade.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
/**
* Create permission request DTO.
* Permissions are global (no tenant_id).
*/
public class CreatePermissionRequest {
@NotBlank(message = "Permission code is required")
@Size(max = 128, message = "Permission code must be at most 128 characters")
private String code;
@NotBlank(message = "Permission name is required")
@Size(max = 255, message = "Permission name must be at most 255 characters")
private String name;
@Size(max = 512, message = "Description must be at most 512 characters")
private String description;
public CreatePermissionRequest() {
}
public String getCode() {
return code;
}
public void setCode(String code) {
this.code = code;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
}
@@ -0,0 +1,49 @@
package com.ljwx.platform.app.facade.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
/**
* Create role request DTO.
* tenant_id injected from SecurityContext, not from request body.
*/
public class CreateRoleRequest {
@NotBlank(message = "Role code is required")
@Size(max = 128, message = "Role code must be at most 128 characters")
private String code;
@NotBlank(message = "Role name is required")
@Size(max = 255, message = "Role name must be at most 255 characters")
private String name;
@Size(max = 512, message = "Description must be at most 512 characters")
private String description;
public CreateRoleRequest() {
}
public String getCode() {
return code;
}
public void setCode(String code) {
this.code = code;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
}
@@ -0,0 +1,50 @@
package com.ljwx.platform.app.facade.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
/**
* Create user request DTO.
* Note: tenant_id is NOT accepted from the request body.
* It is injected from SecurityContext on the server side.
*/
public class CreateUserRequest {
@NotBlank(message = "Username is required")
@Size(max = 128, message = "Username must be at most 128 characters")
private String username;
@NotBlank(message = "Password is required")
@Size(min = 6, max = 128, message = "Password must be between 6 and 128 characters")
private String password;
@Size(max = 255, message = "Email must be at most 255 characters")
private String email;
public CreateUserRequest() {
}
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
public String getEmail() {
return email;
}
public void setEmail(String email) {
this.email = email;
}
}
@@ -0,0 +1,39 @@
package com.ljwx.platform.app.facade.dto;
import jakarta.validation.constraints.NotBlank;
/**
* Login request DTO.
*/
public class LoginRequest {
@NotBlank(message = "Username is required")
private String username;
@NotBlank(message = "Password is required")
private String password;
public LoginRequest() {
}
public LoginRequest(String username, String password) {
this.username = username;
this.password = password;
}
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
}
@@ -0,0 +1,62 @@
package com.ljwx.platform.app.facade.dto;
import com.fasterxml.jackson.annotation.JsonProperty;
/**
* Login response DTO.
*/
public class LoginResponse {
@JsonProperty("access_token")
private String accessToken;
@JsonProperty("refresh_token")
private String refreshToken;
@JsonProperty("token_type")
private String tokenType = "Bearer";
@JsonProperty("expires_in")
private long expiresIn;
public LoginResponse() {
}
public LoginResponse(String accessToken, String refreshToken, long expiresIn) {
this.accessToken = accessToken;
this.refreshToken = refreshToken;
this.expiresIn = expiresIn;
}
public String getAccessToken() {
return accessToken;
}
public void setAccessToken(String accessToken) {
this.accessToken = accessToken;
}
public String getRefreshToken() {
return refreshToken;
}
public void setRefreshToken(String refreshToken) {
this.refreshToken = refreshToken;
}
public String getTokenType() {
return tokenType;
}
public void setTokenType(String tokenType) {
this.tokenType = tokenType;
}
public long getExpiresIn() {
return expiresIn;
}
public void setExpiresIn(long expiresIn) {
this.expiresIn = expiresIn;
}
}
@@ -0,0 +1,70 @@
package com.ljwx.platform.app.facade.dto;
import com.fasterxml.jackson.annotation.JsonInclude;
import java.time.LocalDateTime;
/**
* Permission response DTO.
*/
@JsonInclude(JsonInclude.Include.NON_NULL)
public class PermissionDTO {
private Long id;
private String code;
private String name;
private String description;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
public PermissionDTO() {
}
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getCode() {
return code;
}
public void setCode(String code) {
this.code = code;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
public LocalDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(LocalDateTime createdAt) {
this.createdAt = createdAt;
}
public LocalDateTime getUpdatedAt() {
return updatedAt;
}
public void setUpdatedAt(LocalDateTime updatedAt) {
this.updatedAt = updatedAt;
}
}
@@ -0,0 +1,29 @@
package com.ljwx.platform.app.facade.dto;
import com.fasterxml.jackson.annotation.JsonProperty;
import jakarta.validation.constraints.NotBlank;
/**
* Refresh token request DTO.
*/
public class RefreshRequest {
@NotBlank(message = "Refresh token is required")
@JsonProperty("refresh_token")
private String refreshToken;
public RefreshRequest() {
}
public RefreshRequest(String refreshToken) {
this.refreshToken = refreshToken;
}
public String getRefreshToken() {
return refreshToken;
}
public void setRefreshToken(String refreshToken) {
this.refreshToken = refreshToken;
}
}
@@ -0,0 +1,50 @@
package com.ljwx.platform.app.facade.dto;
import com.fasterxml.jackson.annotation.JsonProperty;
/**
* Refresh token response DTO.
*/
public class RefreshResponse {
@JsonProperty("access_token")
private String accessToken;
@JsonProperty("token_type")
private String tokenType = "Bearer";
@JsonProperty("expires_in")
private long expiresIn;
public RefreshResponse() {
}
public RefreshResponse(String accessToken, long expiresIn) {
this.accessToken = accessToken;
this.expiresIn = expiresIn;
}
public String getAccessToken() {
return accessToken;
}
public void setAccessToken(String accessToken) {
this.accessToken = accessToken;
}
public String getTokenType() {
return tokenType;
}
public void setTokenType(String tokenType) {
this.tokenType = tokenType;
}
public long getExpiresIn() {
return expiresIn;
}
public void setExpiresIn(long expiresIn) {
this.expiresIn = expiresIn;
}
}
@@ -0,0 +1,70 @@
package com.ljwx.platform.app.facade.dto;
import com.fasterxml.jackson.annotation.JsonInclude;
import java.time.LocalDateTime;
/**
* Role response DTO.
*/
@JsonInclude(JsonInclude.Include.NON_NULL)
public class RoleDTO {
private Long id;
private String code;
private String name;
private String description;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
public RoleDTO() {
}
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getCode() {
return code;
}
public void setCode(String code) {
this.code = code;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
public LocalDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(LocalDateTime createdAt) {
this.createdAt = createdAt;
}
public LocalDateTime getUpdatedAt() {
return updatedAt;
}
public void setUpdatedAt(LocalDateTime updatedAt) {
this.updatedAt = updatedAt;
}
}
@@ -0,0 +1,36 @@
package com.ljwx.platform.app.facade.dto;
import java.util.List;
/**
* Role paginated query inner response.
*/
public class RolePageResponse {
private List<RoleDTO> items;
private long total;
public RolePageResponse() {
}
public RolePageResponse(List<RoleDTO> items, long total) {
this.items = items;
this.total = total;
}
public List<RoleDTO> getItems() {
return items;
}
public void setItems(List<RoleDTO> items) {
this.items = items;
}
public long getTotal() {
return total;
}
public void setTotal(long total) {
this.total = total;
}
}
@@ -0,0 +1,34 @@
package com.ljwx.platform.app.facade.dto;
import jakarta.validation.constraints.Size;
/**
* Update permission request DTO.
*/
public class UpdatePermissionRequest {
@Size(max = 255, message = "Permission name must be at most 255 characters")
private String name;
@Size(max = 512, message = "Description must be at most 512 characters")
private String description;
public UpdatePermissionRequest() {
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
}
@@ -0,0 +1,34 @@
package com.ljwx.platform.app.facade.dto;
import jakarta.validation.constraints.Size;
/**
* Update role request DTO.
*/
public class UpdateRoleRequest {
@Size(max = 255, message = "Role name must be at most 255 characters")
private String name;
@Size(max = 512, message = "Description must be at most 512 characters")
private String description;
public UpdateRoleRequest() {
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
}
@@ -0,0 +1,34 @@
package com.ljwx.platform.app.facade.dto;
import jakarta.validation.constraints.Size;
/**
* Update user request DTO.
*/
public class UpdateUserRequest {
@Size(max = 255, message = "Email must be at most 255 characters")
private String email;
@Size(max = 32, message = "Status must be at most 32 characters")
private String status;
public UpdateUserRequest() {
}
public String getEmail() {
return email;
}
public void setEmail(String email) {
this.email = email;
}
public String getStatus() {
return status;
}
public void setStatus(String status) {
this.status = status;
}
}
@@ -0,0 +1,70 @@
package com.ljwx.platform.app.facade.dto;
import com.fasterxml.jackson.annotation.JsonInclude;
import java.time.LocalDateTime;
/**
* User response DTO.
*/
@JsonInclude(JsonInclude.Include.NON_NULL)
public class UserDTO {
private Long id;
private String username;
private String email;
private String status;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
public UserDTO() {
}
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
public String getEmail() {
return email;
}
public void setEmail(String email) {
this.email = email;
}
public String getStatus() {
return status;
}
public void setStatus(String status) {
this.status = status;
}
public LocalDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(LocalDateTime createdAt) {
this.createdAt = createdAt;
}
public LocalDateTime getUpdatedAt() {
return updatedAt;
}
public void setUpdatedAt(LocalDateTime updatedAt) {
this.updatedAt = updatedAt;
}
}
@@ -0,0 +1,36 @@
package com.ljwx.platform.app.facade.dto;
import java.util.List;
/**
* User paginated query inner response (used inside PageResult data).
*/
public class UserPageResponse {
private List<UserDTO> items;
private long total;
public UserPageResponse() {
}
public UserPageResponse(List<UserDTO> items, long total) {
this.items = items;
this.total = total;
}
public List<UserDTO> getItems() {
return items;
}
public void setItems(List<UserDTO> items) {
this.items = items;
}
public long getTotal() {
return total;
}
public void setTotal(long total) {
this.total = total;
}
}
@@ -0,0 +1,24 @@
package com.ljwx.platform.app.infra.persistence.mapper;
import com.ljwx.platform.app.domain.model.Permission;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/**
* MyBatis Mapper for permissions table (global, no tenant_id).
*/
public interface PermissionMapper {
Permission selectById(@Param("id") Long id);
List<Permission> selectAll(@Param("offset") int offset, @Param("limit") int limit);
long count();
void insert(Permission permission);
void update(Permission permission);
void deleteById(@Param("id") Long id);
}
@@ -0,0 +1,26 @@
package com.ljwx.platform.app.infra.persistence.mapper;
import com.ljwx.platform.app.domain.model.Role;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/**
* MyBatis Mapper for roles table.
*/
public interface RoleMapper {
Role selectById(@Param("id") Long id, @Param("tenantId") Long tenantId);
List<Role> selectAllByTenantId(@Param("tenantId") Long tenantId,
@Param("offset") int offset,
@Param("limit") int limit);
long countByTenantId(@Param("tenantId") Long tenantId);
void insert(Role role);
void update(Role role);
void deleteById(@Param("id") Long id, @Param("tenantId") Long tenantId);
}
@@ -0,0 +1,15 @@
package com.ljwx.platform.app.infra.persistence.mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/**
* MyBatis Mapper for role_permissions table.
* Used during authentication to resolve roles → permissions chain.
*/
public interface RolePermissionMapper {
List<String> selectPermissionCodesByRoleIds(@Param("roleIds") List<Long> roleIds,
@Param("tenantId") Long tenantId);
}
@@ -0,0 +1,14 @@
package com.ljwx.platform.app.infra.persistence.mapper;
import com.ljwx.platform.app.domain.model.Tenant;
import org.apache.ibatis.annotations.Param;
/**
* MyBatis Mapper for tenants table (global, read-only in MVP).
*/
public interface TenantMapper {
Tenant selectById(@Param("id") Long id);
Tenant selectByCode(@Param("code") String code);
}
@@ -0,0 +1,28 @@
package com.ljwx.platform.app.infra.persistence.mapper;
import com.ljwx.platform.app.domain.model.User;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/**
* MyBatis Mapper for users table.
*/
public interface UserMapper {
User selectById(@Param("id") Long id, @Param("tenantId") Long tenantId);
User selectByUsername(@Param("username") String username);
List<User> selectAllByTenantId(@Param("tenantId") Long tenantId,
@Param("offset") int offset,
@Param("limit") int limit);
long countByTenantId(@Param("tenantId") Long tenantId);
void insert(User user);
void update(User user);
void deleteById(@Param("id") Long id, @Param("tenantId") Long tenantId);
}
@@ -0,0 +1,14 @@
package com.ljwx.platform.app.infra.persistence.mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/**
* MyBatis Mapper for user_roles table.
* Used during authentication to resolve user → roles → permissions chain.
*/
public interface UserRoleMapper {
List<Long> selectRoleIdsByUserId(@Param("userId") Long userId, @Param("tenantId") Long tenantId);
}
@@ -0,0 +1,49 @@
package com.ljwx.platform.app.infra.persistence.repository;
import com.ljwx.platform.app.domain.model.Permission;
import com.ljwx.platform.app.domain.repository.PermissionRepository;
import com.ljwx.platform.app.infra.persistence.mapper.PermissionMapper;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
@Repository
public class PermissionRepositoryImpl implements PermissionRepository {
private final PermissionMapper permissionMapper;
public PermissionRepositoryImpl(PermissionMapper permissionMapper) {
this.permissionMapper = permissionMapper;
}
@Override
public Optional<Permission> findById(Long id) {
return Optional.ofNullable(permissionMapper.selectById(id));
}
@Override
public List<Permission> findAll(int offset, int limit) {
return permissionMapper.selectAll(offset, limit);
}
@Override
public long count() {
return permissionMapper.count();
}
@Override
public void insert(Permission permission) {
permissionMapper.insert(permission);
}
@Override
public void update(Permission permission) {
permissionMapper.update(permission);
}
@Override
public void deleteById(Long id) {
permissionMapper.deleteById(id);
}
}
@@ -0,0 +1,49 @@
package com.ljwx.platform.app.infra.persistence.repository;
import com.ljwx.platform.app.domain.model.Role;
import com.ljwx.platform.app.domain.repository.RoleRepository;
import com.ljwx.platform.app.infra.persistence.mapper.RoleMapper;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
@Repository
public class RoleRepositoryImpl implements RoleRepository {
private final RoleMapper roleMapper;
public RoleRepositoryImpl(RoleMapper roleMapper) {
this.roleMapper = roleMapper;
}
@Override
public Optional<Role> findById(Long id, Long tenantId) {
return Optional.ofNullable(roleMapper.selectById(id, tenantId));
}
@Override
public List<Role> findAllByTenantId(Long tenantId, int offset, int limit) {
return roleMapper.selectAllByTenantId(tenantId, offset, limit);
}
@Override
public long countByTenantId(Long tenantId) {
return roleMapper.countByTenantId(tenantId);
}
@Override
public void insert(Role role) {
roleMapper.insert(role);
}
@Override
public void update(Role role) {
roleMapper.update(role);
}
@Override
public void deleteById(Long id, Long tenantId) {
roleMapper.deleteById(id, tenantId);
}
}
@@ -0,0 +1,28 @@
package com.ljwx.platform.app.infra.persistence.repository;
import com.ljwx.platform.app.domain.model.Tenant;
import com.ljwx.platform.app.domain.repository.TenantRepository;
import com.ljwx.platform.app.infra.persistence.mapper.TenantMapper;
import org.springframework.stereotype.Repository;
import java.util.Optional;
@Repository
public class TenantRepositoryImpl implements TenantRepository {
private final TenantMapper tenantMapper;
public TenantRepositoryImpl(TenantMapper tenantMapper) {
this.tenantMapper = tenantMapper;
}
@Override
public Optional<Tenant> findById(Long id) {
return Optional.ofNullable(tenantMapper.selectById(id));
}
@Override
public Optional<Tenant> findByCode(String code) {
return Optional.ofNullable(tenantMapper.selectByCode(code));
}
}
@@ -0,0 +1,54 @@
package com.ljwx.platform.app.infra.persistence.repository;
import com.ljwx.platform.app.domain.model.User;
import com.ljwx.platform.app.domain.repository.UserRepository;
import com.ljwx.platform.app.infra.persistence.mapper.UserMapper;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
@Repository
public class UserRepositoryImpl implements UserRepository {
private final UserMapper userMapper;
public UserRepositoryImpl(UserMapper userMapper) {
this.userMapper = userMapper;
}
@Override
public Optional<User> findById(Long id, Long tenantId) {
return Optional.ofNullable(userMapper.selectById(id, tenantId));
}
@Override
public Optional<User> findByUsername(String username) {
return Optional.ofNullable(userMapper.selectByUsername(username));
}
@Override
public List<User> findAllByTenantId(Long tenantId, int offset, int limit) {
return userMapper.selectAllByTenantId(tenantId, offset, limit);
}
@Override
public long countByTenantId(Long tenantId) {
return userMapper.countByTenantId(tenantId);
}
@Override
public void insert(User user) {
userMapper.insert(user);
}
@Override
public void update(User user) {
userMapper.update(user);
}
@Override
public void deleteById(Long id, Long tenantId) {
userMapper.deleteById(id, tenantId);
}
}
@@ -0,0 +1,20 @@
# Development profile configuration
# Uses local PostgreSQL or Testcontainers for testing
spring:
datasource:
url: ${DB_URL:jdbc:postgresql://localhost:5432/ljwx_platform_dev}
username: ${DB_USERNAME:ljwx}
password: ${DB_PASSWORD:ljwx123}
flyway:
enabled: true
clean-disabled: false
logging:
level:
com.ljwx.platform: DEBUG
org.springframework.security: DEBUG
# Dev-specific JWT secret (do NOT use in production)
jwt:
secret: DevSecretKeyForLocalDevelopmentOnlyMustBeAtLeast256BitsLongForHS256Algorithm!!
@@ -0,0 +1,52 @@
server:
port: ${APP_PORT:8080}
spring:
application:
name: ljwx-platform
profiles:
active: ${SPRING_PROFILES_ACTIVE:dev}
datasource:
url: ${DB_URL:jdbc:postgresql://localhost:5432/ljwx_platform}
username: ${DB_USERNAME:ljwx}
password: ${DB_PASSWORD:ljwx123}
driver-class-name: org.postgresql.Driver
hikari:
maximum-pool-size: 10
minimum-idle: 2
flyway:
enabled: true
locations: classpath:db/migration
baseline-on-migrate: false
mybatis:
mapper-locations: classpath*:mybatis/mapper/*.xml
type-aliases-package: com.ljwx.platform.app.domain.model
configuration:
map-underscore-to-camel-case: true
management:
endpoints:
web:
exposure:
include: health,info,metrics
endpoint:
health:
show-details: when-authorized
springdoc:
api-docs:
path: /v3/api-docs
swagger-ui:
path: /swagger-ui.html
# JWT configuration
jwt:
secret: ${JWT_SECRET:ThisIsADevelopmentSecretKeyThatMustBeAtLeast256BitsLongForHS256Algorithm!}
access-token-expiration: ${JWT_ACCESS_EXPIRATION:1800000}
refresh-token-expiration: ${JWT_REFRESH_EXPIRATION:604800000}
# Request logging
platform:
logging:
request-enabled: ${REQUEST_LOGGING_ENABLED:true}
@@ -0,0 +1,34 @@
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<include resource="org/springframework/boot/logging/logback/defaults.xml"/>
<property name="LOG_PATTERN_JSON"
value='{"timestamp":"%d{yyyy-MM-dd HH:mm:ss.SSS}","level":"%level","logger":"%logger{36}","traceId":"%X{traceId:-}","tenantId":"%X{tenantId:-}","userId":"%X{userId:-}","message":"%msg"}%n'/>
<property name="LOG_PATTERN_CONSOLE"
value="%d{yyyy-MM-dd HH:mm:ss.SSS} %highlight(%-5level) [%X{traceId:-}] [tenant=%X{tenantId:-}] [user=%X{userId:-}] %logger{36} - %msg%n"/>
<!-- Console appender (human-readable for dev) -->
<springProfile name="dev">
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>${LOG_PATTERN_CONSOLE}</pattern>
</encoder>
</appender>
<root level="INFO">
<appender-ref ref="CONSOLE"/>
</root>
</springProfile>
<!-- JSON appender (for CI/production) -->
<springProfile name="!dev">
<appender name="CONSOLE_JSON" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>${LOG_PATTERN_JSON}</pattern>
</encoder>
</appender>
<root level="INFO">
<appender-ref ref="CONSOLE_JSON"/>
</root>
</springProfile>
</configuration>
@@ -0,0 +1,45 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ljwx.platform.app.infra.persistence.mapper.PermissionMapper">
<resultMap id="PermissionResultMap" type="com.ljwx.platform.app.domain.model.Permission">
<id property="id" column="id"/>
<result property="code" column="code"/>
<result property="name" column="name"/>
<result property="description" column="description"/>
<result property="createdAt" column="created_at"/>
<result property="updatedAt" column="updated_at"/>
</resultMap>
<select id="selectById" resultMap="PermissionResultMap">
SELECT * FROM permissions WHERE id = #{id}
</select>
<select id="selectAll" resultMap="PermissionResultMap">
SELECT * FROM permissions ORDER BY id ASC LIMIT #{limit} OFFSET #{offset}
</select>
<select id="count" resultType="long">
SELECT COUNT(*) FROM permissions
</select>
<insert id="insert" useGeneratedKeys="true" keyProperty="id">
INSERT INTO permissions (code, name, description, created_at, updated_at)
VALUES (#{code}, #{name}, #{description}, NOW(), NOW())
</insert>
<update id="update">
UPDATE permissions SET
name = #{name},
description = #{description},
updated_at = NOW()
WHERE id = #{id}
</update>
<delete id="deleteById">
DELETE FROM permissions WHERE id = #{id}
</delete>
</mapper>
@@ -0,0 +1,51 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ljwx.platform.app.infra.persistence.mapper.RoleMapper">
<resultMap id="RoleResultMap" type="com.ljwx.platform.app.domain.model.Role">
<id property="id" column="id"/>
<result property="tenantId" column="tenant_id"/>
<result property="code" column="code"/>
<result property="name" column="name"/>
<result property="description" column="description"/>
<result property="createdAt" column="created_at"/>
<result property="updatedAt" column="updated_at"/>
<result property="createdBy" column="created_by"/>
<result property="updatedBy" column="updated_by"/>
</resultMap>
<select id="selectById" resultMap="RoleResultMap">
SELECT * FROM roles WHERE id = #{id} AND tenant_id = #{tenantId}
</select>
<select id="selectAllByTenantId" resultMap="RoleResultMap">
SELECT * FROM roles WHERE tenant_id = #{tenantId}
ORDER BY id ASC
LIMIT #{limit} OFFSET #{offset}
</select>
<select id="countByTenantId" resultType="long">
SELECT COUNT(*) FROM roles WHERE tenant_id = #{tenantId}
</select>
<insert id="insert" useGeneratedKeys="true" keyProperty="id">
INSERT INTO roles (tenant_id, code, name, description, created_at, updated_at, created_by, updated_by)
VALUES (#{tenantId}, #{code}, #{name}, #{description}, #{createdAt}, #{updatedAt}, #{createdBy}, #{updatedBy})
</insert>
<update id="update">
UPDATE roles SET
name = #{name},
description = #{description},
updated_at = #{updatedAt},
updated_by = #{updatedBy}
WHERE id = #{id} AND tenant_id = #{tenantId}
</update>
<delete id="deleteById">
DELETE FROM roles WHERE id = #{id} AND tenant_id = #{tenantId}
</delete>
</mapper>
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ljwx.platform.app.infra.persistence.mapper.RolePermissionMapper">
<select id="selectPermissionCodesByRoleIds" resultType="java.lang.String">
SELECT DISTINCT p.code
FROM role_permissions rp
JOIN permissions p ON p.id = rp.permission_id
WHERE rp.tenant_id = #{tenantId}
AND rp.role_id IN
<foreach collection="roleIds" item="roleId" open="(" separator="," close=")">
#{roleId}
</foreach>
</select>
</mapper>
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ljwx.platform.app.infra.persistence.mapper.TenantMapper">
<resultMap id="TenantResultMap" type="com.ljwx.platform.app.domain.model.Tenant">
<id property="id" column="id"/>
<result property="code" column="code"/>
<result property="name" column="name"/>
<result property="status" column="status"/>
<result property="createdAt" column="created_at"/>
<result property="updatedAt" column="updated_at"/>
<result property="createdBy" column="created_by"/>
<result property="updatedBy" column="updated_by"/>
</resultMap>
<select id="selectById" resultMap="TenantResultMap">
SELECT * FROM tenants WHERE id = #{id}
</select>
<select id="selectByCode" resultMap="TenantResultMap">
SELECT * FROM tenants WHERE code = #{code}
</select>
</mapper>
@@ -0,0 +1,56 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ljwx.platform.app.infra.persistence.mapper.UserMapper">
<resultMap id="UserResultMap" type="com.ljwx.platform.app.domain.model.User">
<id property="id" column="id"/>
<result property="tenantId" column="tenant_id"/>
<result property="username" column="username"/>
<result property="password" column="password"/>
<result property="email" column="email"/>
<result property="status" column="status"/>
<result property="createdAt" column="created_at"/>
<result property="updatedAt" column="updated_at"/>
<result property="createdBy" column="created_by"/>
<result property="updatedBy" column="updated_by"/>
</resultMap>
<select id="selectById" resultMap="UserResultMap">
SELECT * FROM users WHERE id = #{id} AND tenant_id = #{tenantId}
</select>
<select id="selectByUsername" resultMap="UserResultMap">
SELECT * FROM users WHERE username = #{username} AND status = 'ACTIVE'
</select>
<select id="selectAllByTenantId" resultMap="UserResultMap">
SELECT * FROM users WHERE tenant_id = #{tenantId}
ORDER BY id ASC
LIMIT #{limit} OFFSET #{offset}
</select>
<select id="countByTenantId" resultType="long">
SELECT COUNT(*) FROM users WHERE tenant_id = #{tenantId}
</select>
<insert id="insert" useGeneratedKeys="true" keyProperty="id">
INSERT INTO users (tenant_id, username, password, email, status, created_at, updated_at, created_by, updated_by)
VALUES (#{tenantId}, #{username}, #{password}, #{email}, #{status}, #{createdAt}, #{updatedAt}, #{createdBy}, #{updatedBy})
</insert>
<update id="update">
UPDATE users SET
email = #{email},
status = #{status},
updated_at = #{updatedAt},
updated_by = #{updatedBy}
WHERE id = #{id} AND tenant_id = #{tenantId}
</update>
<delete id="deleteById">
DELETE FROM users WHERE id = #{id} AND tenant_id = #{tenantId}
</delete>
</mapper>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ljwx.platform.app.infra.persistence.mapper.UserRoleMapper">
<select id="selectRoleIdsByUserId" resultType="java.lang.Long">
SELECT role_id FROM user_roles
WHERE user_id = #{userId} AND tenant_id = #{tenantId}
</select>
</mapper>
@@ -0,0 +1,92 @@
package com.ljwx.platform.app;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* Base class for integration tests.
* Provides shared Testcontainers PostgreSQL instance and helper methods.
*/
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
@Testcontainers
public abstract class BaseIT {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine")
.withDatabaseName("ljwx_platform_test")
.withUsername("test")
.withPassword("test");
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}
@Autowired
protected MockMvc mockMvc;
@Autowired
protected ObjectMapper objectMapper;
/**
* Login with the default admin credentials and return the access token.
*/
protected String loginAsAdmin() throws Exception {
return login("admin @default", "Admin @12345");
}
/**
* Login with specified credentials and return the access token.
*/
protected String login(String username, String password) throws Exception {
String requestBody = objectMapper.writeValueAsString(
java.util.Map.of("username", username, "password", password)
);
MvcResult result = mockMvc.perform(post("/api/v1/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.content(requestBody))
.andExpect(status().isOk())
.andReturn();
String responseBody = result.getResponse().getContentAsString();
JsonNode json = objectMapper.readTree(responseBody);
return json.get("data").get("access_token").asText();
}
/**
* Login with default admin and return the full response body as JsonNode.
*/
protected JsonNode loginAsAdminFullResponse() throws Exception {
String requestBody = objectMapper.writeValueAsString(
java.util.Map.of("username", "admin @default", "password", "Admin @12345")
);
MvcResult result = mockMvc.perform(post("/api/v1/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.content(requestBody))
.andExpect(status().isOk())
.andReturn();
return objectMapper.readTree(result.getResponse().getContentAsString());
}
}
@@ -0,0 +1,161 @@
package com.ljwx.platform.app.auth;
import com.fasterxml.jackson.databind.JsonNode;
import com.ljwx.platform.app.BaseIT;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.MethodOrderer;
import org.junit.jupiter.api.Order;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestMethodOrder;
import org.springframework.http.MediaType;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* Integration tests for authentication endpoints:
* POST /api/v1/auth/login
* POST /api/v1/auth/refresh
* POST /api/v1/auth/logout
*/
@DisplayName("Authentication Integration Tests")
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class AuthenticationIT extends BaseIT {
@Test
@Order(1)
@DisplayName("POST /api/v1/auth/login with valid credentials should return access_token and refresh_token")
void loginWithValidCredentials() throws Exception {
String requestBody = objectMapper.writeValueAsString(
java.util.Map.of("username", "admin @default", "password", "Admin @12345")
);
mockMvc.perform(post("/api/v1/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.content(requestBody))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value("SUCCESS"))
.andExpect(jsonPath("$.data.access_token").isNotEmpty())
.andExpect(jsonPath("$.data.refresh_token").isNotEmpty());
}
@Test
@Order(2)
@DisplayName("POST /api/v1/auth/login with invalid password should return 401")
void loginWithInvalidPassword() throws Exception {
String requestBody = objectMapper.writeValueAsString(
java.util.Map.of("username", "admin @default", "password", "WrongPassword")
);
mockMvc.perform(post("/api/v1/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.content(requestBody))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.code").value("TOKEN_INVALID"));
}
@Test
@Order(3)
@DisplayName("POST /api/v1/auth/login with non-existent user should return 401")
void loginWithNonExistentUser() throws Exception {
String requestBody = objectMapper.writeValueAsString(
java.util.Map.of("username", "nobody @default", "password", "Admin @12345")
);
mockMvc.perform(post("/api/v1/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.content(requestBody))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.code").value("TOKEN_INVALID"));
}
@Test
@Order(4)
@DisplayName("POST /api/v1/auth/refresh with valid refresh_token should return new access_token")
void refreshWithValidToken() throws Exception {
// First login to get refresh_token
JsonNode loginResponse = loginAsAdminFullResponse();
String refreshToken = loginResponse.get("data").get("refresh_token").asText();
assertNotNull(refreshToken);
assertFalse(refreshToken.isBlank());
// Use refresh_token to get new access_token
String refreshBody = objectMapper.writeValueAsString(
java.util.Map.of("refresh_token", refreshToken)
);
mockMvc.perform(post("/api/v1/auth/refresh")
.contentType(MediaType.APPLICATION_JSON)
.content(refreshBody))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value("SUCCESS"))
.andExpect(jsonPath("$.data.access_token").isNotEmpty());
}
@Test
@Order(5)
@DisplayName("POST /api/v1/auth/refresh with invalid token should return 401")
void refreshWithInvalidToken() throws Exception {
String refreshBody = objectMapper.writeValueAsString(
java.util.Map.of("refresh_token", "invalid.token.here")
);
mockMvc.perform(post("/api/v1/auth/refresh")
.contentType(MediaType.APPLICATION_JSON)
.content(refreshBody))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.code").value("TOKEN_INVALID"));
}
@Test
@Order(6)
@DisplayName("POST /api/v1/auth/refresh with access_token (wrong type) should return 401")
void refreshWithAccessTokenShouldFail() throws Exception {
// Login to get access_token
String accessToken = loginAsAdmin();
// Try to use access_token as refresh_token
String refreshBody = objectMapper.writeValueAsString(
java.util.Map.of("refresh_token", accessToken)
);
mockMvc.perform(post("/api/v1/auth/refresh")
.contentType(MediaType.APPLICATION_JSON)
.content(refreshBody))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.code").value("TOKEN_INVALID"));
}
@Test
@Order(7)
@DisplayName("POST /api/v1/auth/logout should return success (stateless)")
void logoutShouldSucceed() throws Exception {
String accessToken = loginAsAdmin();
mockMvc.perform(post("/api/v1/auth/logout")
.header("Authorization", "Bearer " + accessToken))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value("SUCCESS"));
}
@Test
@Order(8)
@DisplayName("Accessing protected endpoint without token should return 401")
void accessProtectedWithoutToken() throws Exception {
mockMvc.perform(post("/api/v1/auth/logout"))
.andExpect(status().isUnauthorized());
}
@Test
@Order(9)
@DisplayName("Accessing protected endpoint with expired/invalid token should return 401")
void accessProtectedWithInvalidToken() throws Exception {
mockMvc.perform(post("/api/v1/auth/logout")
.header("Authorization", "Bearer invalid.jwt.token"))
.andExpect(status().isUnauthorized());
}
}
@@ -0,0 +1,197 @@
package com.ljwx.platform.app.migration;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Integration test: verifies that Flyway migrations run successfully
* on a clean PostgreSQL instance (via Testcontainers) and that all
* expected tables exist with correct seed data.
*/
@SpringBootTest
@Testcontainers
@ActiveProfiles("test")
@DisplayName("Flyway Migration Verification")
class MigrationVerificationIT {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine")
.withDatabaseName("ljwx_platform_test")
.withUsername("test")
.withPassword("test");
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}
@Autowired
private JdbcTemplate jdbcTemplate;
@Test
@DisplayName("All expected tables should exist after migration")
void allTablesShouldExist() {
List<String> expectedTables = List.of(
"tenants",
"permissions",
"users",
"roles",
"user_roles",
"role_permissions",
"audit_log"
);
for (String table : expectedTables) {
Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = 'public' AND table_name = ?",
Integer.class,
table
);
assertNotNull(count, "Query result for table " + table + " should not be null");
assertEquals(1, count, "Table '" + table + "' should exist after migration");
}
}
@Test
@DisplayName("Flyway migration history should contain V001 and V002")
void flywayHistoryShouldContainExpectedVersions() {
List<String> versions = jdbcTemplate.queryForList(
"SELECT version FROM flyway_schema_history ORDER BY installed_rank",
String.class
);
assertTrue(versions.contains("001"), "Migration V001 should be recorded in flyway_schema_history");
assertTrue(versions.contains("002"), "Migration V002 should be recorded in flyway_schema_history");
}
@Test
@DisplayName("Default tenant should be seeded")
void defaultTenantShouldExist() {
String code = jdbcTemplate.queryForObject(
"SELECT code FROM tenants WHERE id = 1",
String.class
);
assertEquals("default", code, "Default tenant code should be 'default'");
}
@Test
@DisplayName("Default admin user should be seeded with correct BCrypt password")
void defaultAdminUserShouldExist() {
String username = jdbcTemplate.queryForObject(
"SELECT username FROM users WHERE id = 1",
String.class
);
assertEquals("admin @default", username, "Default admin username should be 'admin @default'");
Long tenantId = jdbcTemplate.queryForObject(
"SELECT tenant_id FROM users WHERE id = 1",
Long.class
);
assertEquals(1L, tenantId, "Default admin should belong to tenant_id = 1");
// Verify BCrypt hash actually matches the documented plaintext password
String storedHash = jdbcTemplate.queryForObject(
"SELECT password FROM users WHERE id = 1",
String.class
);
assertNotNull(storedHash, "Stored password hash should not be null");
BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(10);
assertTrue(encoder.matches("Admin @12345", storedHash),
"Stored BCrypt hash must match plaintext 'Admin @12345'. " +
"If this fails, regenerate the hash using: new BCryptPasswordEncoder(10).encode(\"Admin @12345\") " +
"and update V002__seed_default_data.sql");
}
@Test
@DisplayName("All MVP permissions should be seeded")
void allPermissionsShouldBeSeeded() {
List<String> permissions = jdbcTemplate.queryForList(
"SELECT code FROM permissions ORDER BY id",
String.class
);
List<String> expected = List.of(
"user:read", "user:write", "role:read", "role:write", "permission:read"
);
assertEquals(expected, permissions, "All MVP permissions must be seeded");
}
@Test
@DisplayName("ROLE_ADMIN should have all permissions assigned")
void adminRoleShouldHaveAllPermissions() {
Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM role_permissions WHERE tenant_id = 1 AND role_id = 1",
Integer.class
);
assertEquals(5, count, "ROLE_ADMIN should have all 5 MVP permissions assigned");
}
@Test
@DisplayName("Admin user should have ROLE_ADMIN assigned")
void adminUserShouldHaveAdminRole() {
Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM user_roles WHERE tenant_id = 1 AND user_id = 1 AND role_id = 1",
Integer.class
);
assertEquals(1, count, "Admin user should have ROLE_ADMIN assigned");
}
@Test
@DisplayName("Tenants table should NOT have tenant_id column (global table)")
void tenantsTableShouldNotHaveTenantIdColumn() {
Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM information_schema.columns " +
"WHERE table_schema = 'public' AND table_name = 'tenants' AND column_name = 'tenant_id'",
Integer.class
);
assertEquals(0, count, "Tenants table is a global table and must NOT have a tenant_id column");
}
@Test
@DisplayName("Permissions table should NOT have tenant_id column (global table)")
void permissionsTableShouldNotHaveTenantIdColumn() {
Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM information_schema.columns " +
"WHERE table_schema = 'public' AND table_name = 'permissions' AND column_name = 'tenant_id'",
Integer.class
);
assertEquals(0, count, "Permissions table is a global table and must NOT have a tenant_id column");
}
@Test
@DisplayName("Tenant-scoped tables should have tenant_id column")
void tenantScopedTablesShouldHaveTenantIdColumn() {
List<String> tenantScopedTables = List.of(
"users", "roles", "user_roles", "role_permissions", "audit_log"
);
for (String table : tenantScopedTables) {
Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM information_schema.columns " +
"WHERE table_schema = 'public' AND table_name = ? AND column_name = 'tenant_id'",
Integer.class,
table
);
assertEquals(1, count, "Tenant-scoped table '" + table + "' must have a tenant_id column");
}
}
}
@@ -0,0 +1,161 @@
package com.ljwx.platform.app.tenant;
import com.ljwx.platform.app.BaseIT;
import com.ljwx.platform.security.jwt.JwtTokenProvider;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.MethodOrderer;
import org.junit.jupiter.api.Order;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestMethodOrder;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.security.crypto.password.PasswordEncoder;
import java.util.List;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* Integration tests for tenant isolation.
* Verifies that:
* - Users can only see data belonging to their own tenant
* - Cross-tenant access returns 403 TENANT_ACCESS_DENIED
*/
@DisplayName("Tenant Isolation Integration Tests")
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class TenantIsolationIT extends BaseIT {
@Autowired
private JdbcTemplate jdbcTemplate;
@Autowired
private PasswordEncoder passwordEncoder;
@Autowired
private JwtTokenProvider jwtTokenProvider;
/**
* Seed a second tenant with a user for cross-tenant testing.
*/
private void seedSecondTenant() {
// Check if already seeded
Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM tenants WHERE code = 'tenant-b'", Integer.class);
if (count != null && count > 0) {
return;
}
// Create tenant B
jdbcTemplate.update(
"INSERT INTO tenants (id, code, name, status) VALUES (2, 'tenant-b', 'Tenant B', 'ACTIVE')");
// Create role for tenant B
jdbcTemplate.update(
"INSERT INTO roles (id, tenant_id, code, name) VALUES (2, 2, 'ROLE_ADMIN', 'Admin Tenant B')");
// Assign permissions to role
jdbcTemplate.update(
"INSERT INTO role_permissions (tenant_id, role_id, permission_id) VALUES (2, 2, 1)");
jdbcTemplate.update(
"INSERT INTO role_permissions (tenant_id, role_id, permission_id) VALUES (2, 2, 2)");
jdbcTemplate.update(
"INSERT INTO role_permissions (tenant_id, role_id, permission_id) VALUES (2, 2, 3)");
jdbcTemplate.update(
"INSERT INTO role_permissions (tenant_id, role_id, permission_id) VALUES (2, 2, 4)");
jdbcTemplate.update(
"INSERT INTO role_permissions (tenant_id, role_id, permission_id) VALUES (2, 2, 5)");
// Create user for tenant B
String hashedPassword = passwordEncoder.encode("Admin @12345");
jdbcTemplate.update(
"INSERT INTO users (id, tenant_id, username, password, email, status) " +
"VALUES (2, 2, 'admin @tenant-b', ?, 'admin @tenantb.com', 'ACTIVE')",
hashedPassword);
// Assign role to user
jdbcTemplate.update(
"INSERT INTO user_roles (tenant_id, user_id, role_id) VALUES (2, 2, 2)");
}
private String loginAsTenantB() throws Exception {
return login("admin @tenant-b", "Admin @12345");
}
@Test
@Order(1)
@DisplayName("Tenant A user querying /api/v1/users should only see Tenant A users")
void tenantAUserShouldOnlySeeOwnUsers() throws Exception {
seedSecondTenant();
String tokenA = loginAsAdmin();
mockMvc.perform(get("/api/v1/users")
.header("Authorization", "Bearer " + tokenA))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value("SUCCESS"))
.andExpect(jsonPath("$.data.items").isArray())
.andExpect(jsonPath("$.data.items[?( @.username == 'admin @default')]").exists())
.andExpect(jsonPath("$.data.items[?( @.username == 'admin @tenant-b')]").doesNotExist());
}
@Test
@Order(2)
@DisplayName("Tenant B user querying /api/v1/users should only see Tenant B users")
void tenantBUserShouldOnlySeeOwnUsers() throws Exception {
seedSecondTenant();
String tokenB = loginAsTenantB();
mockMvc.perform(get("/api/v1/users")
.header("Authorization", "Bearer " + tokenB))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value("SUCCESS"))
.andExpect(jsonPath("$.data.items").isArray())
.andExpect(jsonPath("$.data.items[?( @.username == 'admin @tenant-b')]").exists())
.andExpect(jsonPath("$.data.items[?( @.username == 'admin @default')]").doesNotExist());
}
@Test
@Order(3)
@DisplayName("Tenant A user accessing Tenant B user by ID should return 403")
void crossTenantAccessShouldReturn403() throws Exception {
seedSecondTenant();
String tokenA = loginAsAdmin();
// User ID 2 belongs to Tenant B
mockMvc.perform(get("/api/v1/users/2")
.header("Authorization", "Bearer " + tokenA))
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.code").value("TENANT_ACCESS_DENIED"));
}
@Test
@Order(4)
@DisplayName("Tenant B user accessing Tenant A user by ID should return 403")
void reverseCrossTenantAccessShouldReturn403() throws Exception {
seedSecondTenant();
String tokenB = loginAsTenantB();
// User ID 1 belongs to Tenant A
mockMvc.perform(get("/api/v1/users/1")
.header("Authorization", "Bearer " + tokenB))
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.code").value("TENANT_ACCESS_DENIED"));
}
@Test
@Order(5)
@DisplayName("Authenticated user should have X-Trace-Id in response header")
void responseShouldContainTraceId() throws Exception {
String token = loginAsAdmin();
mockMvc.perform(get("/api/v1/users")
.header("Authorization", "Bearer " + token))
.andExpect(status().isOk())
.andExpect(result -> {
String traceId = result.getResponse().getHeader("X-Trace-Id");
assert traceId != null && !traceId.isBlank() :
"Response should contain non-empty X-Trace-Id header";
});
}
}
@@ -0,0 +1,24 @@
package com.ljwx.platform.app.util;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
/**
* Utility to generate BCrypt hashes for seed data verification.
* Run main() to print the hash for Admin @12345.
*
* Usage: The output hash should replace the placeholder in V002__seed_default_data.sql
* if the current hash does not match.
*/
public class BcryptHashGenerator {
public static void main(String[] args) {
BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(10);
String rawPassword = "Admin @12345";
String hash = encoder.encode(rawPassword);
System.out.println("=== BCrypt Hash Generator ===");
System.out.println("Raw password: " + rawPassword);
System.out.println("Cost factor: 10");
System.out.println("BCrypt hash: " + hash);
System.out.println("Verify match: " + encoder.matches(rawPassword, hash));
}
}
@@ -0,0 +1,30 @@
# Test profile configuration
# Uses Testcontainers PostgreSQL - connection properties are set dynamically
# by @DynamicPropertySource in IT classes.
spring:
flyway:
enabled: true
locations: classpath:db/migration
clean-disabled: false
datasource:
driver-class-name: org.postgresql.Driver
mybatis:
mapper-locations: classpath*:mybatis/mapper/*.xml
type-aliases-package: com.ljwx.platform.app.domain.model
configuration:
map-underscore-to-camel-case: true
jwt:
secret: TestSecretKeyForIntegrationTestingOnlyMustBeAtLeast256BitsLongForHS256Algorithm!!
access-token-expiration: 1800000
refresh-token-expiration: 604800000
platform:
logging:
request-enabled: false
logging:
level:
com.ljwx.platform: DEBUG
+37
View File
@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.ljwx.platform</groupId>
<artifactId>ljwx-platform</artifactId>
<version>0.1.0-SNAPSHOT</version>
</parent>
<artifactId>ljwx-platform-core</artifactId>
<name>LJWX Platform Core</name>
<description>Core utilities: Result, ErrorCode, pagination, ID generation</description>
<dependencies>
<!-- Jackson for JSON serialization of Result/PageResult -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-annotations</artifactId>
</dependency>
<!-- Jakarta Validation annotations -->
<dependency>
<groupId>jakarta.validation</groupId>
<artifactId>jakarta.validation-api</artifactId>
</dependency>
<!-- Test -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>
@@ -0,0 +1,32 @@
package com.ljwx.platform.core.constant;
/**
* Platform-level constants.
*/
public final class PlatformConstants {
private PlatformConstants() {
// utility class
}
/** HTTP header for trace ID propagation. */
public static final String HEADER_TRACE_ID = "X-Trace-Id";
/** MDC key for trace ID. */
public static final String MDC_TRACE_ID = "traceId";
/** MDC key for tenant ID. */
public static final String MDC_TENANT_ID = "tenantId";
/** MDC key for user ID. */
public static final String MDC_USER_ID = "userId";
/** Default page number. */
public static final int DEFAULT_PAGE = 1;
/** Default page size. */
public static final int DEFAULT_PAGE_SIZE = 20;
/** Maximum page size. */
public static final int MAX_PAGE_SIZE = 200;
}
@@ -0,0 +1,41 @@
package com.ljwx.platform.core.exception;
/**
* Base business exception.
* <p>
* All business-layer exceptions should extend this class.
* The global exception handler translates BusinessException to a Result
* with the appropriate HTTP status and error code.
* </p>
*/
public class BusinessException extends RuntimeException {
private final ErrorCode errorCode;
private final String detail;
public BusinessException(ErrorCode errorCode) {
super(errorCode.getMessage());
this.errorCode = errorCode;
this.detail = null;
}
public BusinessException(ErrorCode errorCode, String detail) {
super(detail != null ? detail : errorCode.getMessage());
this.errorCode = errorCode;
this.detail = detail;
}
public BusinessException(ErrorCode errorCode, String detail, Throwable cause) {
super(detail != null ? detail : errorCode.getMessage(), cause);
this.errorCode = errorCode;
this.detail = detail;
}
public ErrorCode getErrorCode() {
return errorCode;
}
public String getDetail() {
return detail;
}
}
@@ -0,0 +1,58 @@
package com.ljwx.platform.core.exception;
/**
* Centralized error code enumeration.
* <p>
* All error codes used in the platform MUST be defined here.
* Business modules should extend with BIZ_{MODULE}_{DESCRIPTION} pattern.
* </p>
* <p>
* Mapping to HTTP status:
* TOKEN_INVALID, TOKEN_EXPIRED → 401
* PERMISSION_DENIED, TENANT_ACCESS_DENIED → 403
* VALIDATION_FAILED → 400
* RESOURCE_NOT_FOUND → 404
* SYSTEM_ERROR → 500
* </p>
*/
public enum ErrorCode {
// --- 401 Unauthorized ---
TOKEN_INVALID("TOKEN_INVALID", "Invalid token", 401),
TOKEN_EXPIRED("TOKEN_EXPIRED", "Token has expired", 401),
// --- 403 Forbidden ---
PERMISSION_DENIED("PERMISSION_DENIED", "Permission denied", 403),
TENANT_ACCESS_DENIED("TENANT_ACCESS_DENIED", "Cross-tenant access denied", 403),
// --- 400 Bad Request ---
VALIDATION_FAILED("VALIDATION_FAILED", "Validation failed", 400),
// --- 404 Not Found ---
RESOURCE_NOT_FOUND("RESOURCE_NOT_FOUND", "Resource not found", 404),
// --- 500 Internal Server Error ---
SYSTEM_ERROR("SYSTEM_ERROR", "Internal server error", 500);
private final String code;
private final String message;
private final int httpStatus;
ErrorCode(String code, String message, int httpStatus) {
this.code = code;
this.message = message;
this.httpStatus = httpStatus;
}
public String getCode() {
return code;
}
public String getMessage() {
return message;
}
public int getHttpStatus() {
return httpStatus;
}
}

Some files were not shown because too many files have changed in this diff Show More