Initial commit of ljwx-platform project
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
@@ -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
|
||||
@@ -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/自行补充"的代码提交
|
||||
@@ -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
@@ -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
@@ -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
|
||||
@@ -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
@@ -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 和运维脚本管理)
|
||||
- 不实现租户级别的配置隔离
|
||||
@@ -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。
|
||||
@@ -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。禁止反向依赖。
|
||||
@@ -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/`
|
||||
@@ -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
|
||||
@@ -0,0 +1,11 @@
|
||||
# 需求目录
|
||||
|
||||
本目录存放所有需求文档。每个需求以 `R-{编号}.md` 命名。
|
||||
|
||||
<!-- TODO: 补充以下内容:
|
||||
- 当前需求列表(如 R-001: 基座工程初始化)
|
||||
- 需求状态追踪(Draft / Review / Approved / Done)
|
||||
- 需求与契约、测试的关联索引
|
||||
-->
|
||||
|
||||
需求文档模板和命名规范请参考 `docs/00-overview/conventions.md`。每个需求文档必须包含验收标准、契约影响、测试映射三个关键部分,确保需求可追溯、可验证。
|
||||
@@ -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`。
|
||||
@@ -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 表。
|
||||
@@ -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 注解实现方法级权限控制。
|
||||
@@ -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。
|
||||
@@ -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 上成功执行。
|
||||
@@ -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} 格式。
|
||||
@@ -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。
|
||||
@@ -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 为可降级项。
|
||||
@@ -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。
|
||||
@@ -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 与代码匹配。
|
||||
@@ -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 方法。
|
||||
@@ -0,0 +1,15 @@
|
||||
# 运维手册
|
||||
|
||||
本文档描述 LJWX Platform 的生产环境运维指南。
|
||||
|
||||
<!-- TODO: 补充以下内容:
|
||||
- 生产环境部署架构图
|
||||
- 环境变量清单(生产环境必须配置项)
|
||||
- 数据库初始化(生产环境不使用 seed,应通过运维脚本初始化管理员和租户)
|
||||
- JWT secret 管理(生产环境必须使用强密钥)
|
||||
- 健康检查和监控配置
|
||||
- 日志收集配置
|
||||
- 备份和恢复策略
|
||||
-->
|
||||
|
||||
生产环境注意事项:V002 seed 数据仅用于 dev/test,生产环境应通过运维脚本初始化租户和管理员账户。JWT secret 必须使用至少 256 bits 的强随机密钥,不得使用开发环境默认值。建议通过环境变量或密钥管理服务注入敏感配置。
|
||||
@@ -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 模块需要扩展(但基础架构已预留扩展点)
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
+66
@@ -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);
|
||||
}
|
||||
}
|
||||
+68
@@ -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;
|
||||
}
|
||||
}
|
||||
+55
@@ -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;
|
||||
}
|
||||
}
|
||||
+25
@@ -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);
|
||||
}
|
||||
+25
@@ -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);
|
||||
}
|
||||
+16
@@ -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);
|
||||
}
|
||||
+27
@@ -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);
|
||||
}
|
||||
}
|
||||
+34
@@ -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;
|
||||
}
|
||||
}
|
||||
+35
@@ -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;
|
||||
}
|
||||
}
|
||||
+36
@@ -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;
|
||||
}
|
||||
}
|
||||
+49
@@ -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;
|
||||
}
|
||||
}
|
||||
+49
@@ -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;
|
||||
}
|
||||
}
|
||||
+50
@@ -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;
|
||||
}
|
||||
}
|
||||
+36
@@ -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;
|
||||
}
|
||||
}
|
||||
+34
@@ -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;
|
||||
}
|
||||
}
|
||||
+34
@@ -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;
|
||||
}
|
||||
}
|
||||
+34
@@ -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;
|
||||
}
|
||||
}
|
||||
+36
@@ -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;
|
||||
}
|
||||
}
|
||||
+24
@@ -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);
|
||||
}
|
||||
+26
@@ -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);
|
||||
}
|
||||
+15
@@ -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);
|
||||
}
|
||||
+14
@@ -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);
|
||||
}
|
||||
+28
@@ -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);
|
||||
}
|
||||
+14
@@ -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);
|
||||
}
|
||||
+49
@@ -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);
|
||||
}
|
||||
}
|
||||
+49
@@ -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);
|
||||
}
|
||||
}
|
||||
+28
@@ -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));
|
||||
}
|
||||
}
|
||||
+54
@@ -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());
|
||||
}
|
||||
}
|
||||
+197
@@ -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
|
||||
@@ -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>
|
||||
+32
@@ -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;
|
||||
}
|
||||
+41
@@ -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
Reference in New Issue
Block a user