commit 8d0b0eb6849b8363c54f497a11d4421a0ce5314d Author: siujamo Date: Thu Dec 25 16:08:50 2025 +0800 feat: 初始提交 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8d548fd --- /dev/null +++ b/.gitignore @@ -0,0 +1,149 @@ +### macOS +# General +.DS_Store +.AppleDouble +.LSOverride + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +### Linux +*~ + +# temporary files which can be created if a process still has a handle open of a deleted file +.fuse_hidden* + +# Metadata left by Dolphin file manager, which comes with KDE Plasma +.directory + +# Linux trash folder which might appear on any partition or disk +.Trash-* + +# .nfs files are created when an open file is removed but is still being accessed +.nfs* + +# Log files created by default by the nohup command +nohup.out + +### Windows + +# Windows thumbnail cache files +Thumbs.db +Thumbs.db:encryptable +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + +### JetBrains IDE +# Covers JetBrains IDEs: IntelliJ, GoLand, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff +.idea + +# Gradle and Maven with auto-import +*.iml +*.ipr + +# File-based project format +*.iws + +# IntelliJ +out/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Editor-based HTTP Client +http-client.private.env.json + +### Gradle +.gradle +**/build/ +!**/src/**/build/ + +!gradle/wrapper/ +!gradle/wrapper/gradle-wrapper.jar +!gradle/wrapper/gradle-wrapper.properties +gradle-app.setting +.gradletasknamecache + +# Eclipse Gradle plugin generated files +# Eclipse Core +.project +# JDT-specific (Eclipse Java Development Tools) +.classpath + +### Server +# Compiled class file +*.class + +# Log file +*.log + +# BlueJ files +*.ctxt + +# Mobile Tools for Java (J2ME) +.mtj.tmp/ + +# Package Files # +*.war +*.nar +*.ear +*.zip +*.tar.gz +*.rar +*.jar + +# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml +hs_err_pid* +replay_pid* + +# Config +config/*.yml +config/*.yaml +config/*.properties + +# Tests +test/ diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..6a9e9ae --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,60 @@ +plugins { + id("java") + id("org.springframework.boot") version "3.5.4" + id("io.spring.dependency-management") version "1.1.7" +} + +val artefactVersion: String by project + +group = "com.onixbyte.helix" +version = artefactVersion + +tasks.withType { + options.encoding = "UTF-8" +} + +java { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + toolchain { + languageVersion = JavaLanguageVersion.of(17) + } +} + +dependencies { + implementation(libs.jspecify.core) + implementation(platform(libs.aws.sdk.bom)) + implementation(libs.aws.sdk.s3) + implementation(libs.commons.io) + implementation(libs.commons.lang) + implementation(libs.commons.collections) + implementation(libs.hypersistence.core) + implementation(platform(libs.onixbyte.versionCatalogue)) + implementation(libs.onixbyte.tuple) + implementation(libs.onixbyte.commonToolbox) + implementation(libs.onixbyte.identityGenerator) + implementation(libs.onixbyte.captcha) + implementation(libs.onixbyte.regions) + implementation(libs.jwt.core) + implementation(libs.spring.boot.configurationProcessor) + implementation(libs.spring.boot.starter.web) + implementation(libs.spring.boot.starter.webFlux) + implementation(libs.spring.boot.starter.validation) + implementation(libs.spring.boot.starter.redis) + implementation(libs.spring.boot.starter.cache) + implementation(libs.spring.boot.starter.security) + implementation(libs.spring.boot.starter.jpa) + implementation(libs.mybatis.starter.core) + implementation(libs.jackson.jsr310) + testImplementation(libs.spring.boot.starter.test) + testImplementation(libs.reactor.test) + testImplementation(libs.spring.security.test) + testImplementation(libs.mybatis.starter.test) + runtimeOnly(libs.postgres.driver) + testRuntimeOnly(libs.h2.database) + testRuntimeOnly(libs.junit.launcher) +} + +tasks.test { + useJUnitPlatform() +} diff --git a/config/application-prod.yml.example b/config/application-prod.yml.example new file mode 100644 index 0000000..101e908 --- /dev/null +++ b/config/application-prod.yml.example @@ -0,0 +1,84 @@ +spring: + data: + redis: + # Redis 主机 + host: ${REDIS_HOST:-localhost} + # Redis 端口 + port: ${REDIS_PORT:-6379} + # Redis 数据库索引 + database: ${REDIS_DATABASE_INDEX:-0} + # Redis 数据库密码 + password: ${REDIS_PASSWORD:-} + datasource: + # 数据库连接池类型 + type: com.zaxxer.hikari.HikariDataSource + # 数据库驱动类 + driver-class-name: org.postgresql.Driver + # 数据库 URL + url: jdbc:postgresql://${PG_HOST:-localhost}:${PG_PORT:-5432}/${PG_DATABASE:-helix} + # 数据库用户 + username: ${PG_USER} + # 数据库密码 + password: ${PG_PASSWORD} + +app: + asset: + # 是否开启 S3 文件存储服务 + enabled: true + # S3 服务端点(若使用非 AWS 提供的 S3 兼容 API,请添加该配置) +# endpoint: https://endpoint.s3.service + # S3 服务区域(详情请见 S3 服务提供商) +# region: apac + # 公开域名 + public-host: https://s3.my.app + # 是否开启 Path Style + path-style: false + # 存储桶名称 + bucket: dev + # S3 访问密钥 ID + access-key-id: ${S3_ACCESS_KEY_ID} + # S3 访问密钥机密 + secret-access-key: ${S3_SECRET_ACCESS_KEY} + # 鉴权配置 + authentication: + # Microsoft Entra ID 鉴权配置 + msal: + # Microsoft Entra ID 提供的客户端 ID + client-id: ${MSAL_CLIENT_ID} + # Microsoft Entra ID 提供的租户 ID + tenant-id: ${MSAL_TENANT_ID} + # 令牌配置 + jwt: + # 令牌签发人 + issuer: Helix Server + # 令牌机密 + secret: ${TOKEN_SECRET:-1234567890abcdefghijklmnopqrstuv} + # 令牌有效期(Ref java.time.Duration) + valid-time: PT2H + # 跨域配置 + cors: + # 是否允许身份验证 + allow-credentials: true + # 是否允许私有网络 + allow-private-network: true + # 允许的请求头列表 + allowed-headers: Content-Type + # 允许的请求方法列表(Ref org.springframework.http.HttpMethod) + # 2025.11.6注 + # 由于 Spring 解析问题,在此处使用小写的情况下会导致在请求头中存在 Origin 时出现 Invalid CORS Request 的问题,请务必使用大写 + allowed-methods: + - GET + - POST + - PUT + - PATCH + - DELETE + # 允许的来源域名 + allowed-origins: '*' + # 要对外暴露的响应头列表 + exposed-headers: X-Authorisation + # 跨域缓存时长 + max-age: PT2H + # Captcha 配置 + captcha: + # Captcha 长度 + length: 6 diff --git a/database/init.d/init-en_GB.sql b/database/init.d/init-en_GB.sql new file mode 100644 index 0000000..629473c --- /dev/null +++ b/database/init.d/init-en_GB.sql @@ -0,0 +1,324 @@ +/** + * IMPORTANT NOTE ON DATABASE CREATION: + * * If you intend to create the database using a user other than the one specified + * for the application (e.g., 'system_admin' creating a database for 'app_user'), + * please ensure this SQL file is executed by the **corresponding database user**. + * * This is crucial for correctly setting up ownership and default privileges. + * * Example: CREATE DATABASE my_database OWNER app_user; + * * Ensure all necessary user roles and permissions are in place **prior** to + * running this initialisation script. + */ + +--- Type Definitions --- +DROP TYPE IF EXISTS USER_STATUS CASCADE; +DROP TYPE IF EXISTS STATUS CASCADE; +DROP TYPE IF EXISTS IDENTITY_PROVIDER CASCADE; +DROP TYPE IF EXISTS SETTING_TYPE CASCADE; + +CREATE TYPE USER_STATUS AS ENUM ('ACTIVE', 'INACTIVE', 'LOCKED'); +CREATE TYPE STATUS AS ENUM ('ACTIVE', 'INACTIVE'); +CREATE TYPE IDENTITY_PROVIDER AS ENUM ('LOCAL', 'OIDC', 'MICROSOFT_ENTRA_ID', 'GOOGLE_OIDC', 'SAML'); +CREATE TYPE SETTING_TYPE AS ENUM ('STRING', 'BOOLEAN', 'INT'); + +--- Departments Table --- +DROP TABLE IF EXISTS departments CASCADE; +CREATE TABLE departments +( + id BIGSERIAL PRIMARY KEY, + name VARCHAR(128) NOT NULL UNIQUE, + parent_id BIGINT NULL REFERENCES departments (id), + sort INT NOT NULL DEFAULT NULL, + status STATUS NOT NULL DEFAULT 'ACTIVE'::STATUS, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX departments_name_index ON departments (name); +CREATE INDEX departments_parent_id_index ON departments (parent_id); + +--- Departments Data Insertion --- +INSERT INTO departments (id, name, parent_id, sort, status) +VALUES (1, 'Company HQ', NULL, 1, 'ACTIVE'::STATUS), + (2, 'Human Resources', 1, 1, 'ACTIVE'::STATUS), + (3, 'Finance', 1, 2, 'ACTIVE'::STATUS), + (4, 'Technology', 1, 3, 'ACTIVE'::STATUS), + (5, 'IT Support', 4, 1, 'ACTIVE'::STATUS), + (6, 'Software Development', 4, 2, 'ACTIVE'::STATUS), + (7, 'Operations', 1, 4, 'INACTIVE'::STATUS); + +--- Positions Table --- +DROP TABLE IF EXISTS positions CASCADE; +CREATE TABLE positions +( + id BIGSERIAL PRIMARY KEY, + name VARCHAR(128) NOT NULL UNIQUE, + code VARCHAR(64) NULL UNIQUE, + description TEXT, + sort INT NOT NULL DEFAULT 0, + status STATUS NOT NULL DEFAULT 'ACTIVE', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX positions_name_index ON positions (name); +CREATE INDEX positions_code_index ON positions (code); +CREATE INDEX positions_name_code_index ON positions (name, code); + +--- Positions Data Insertion --- +INSERT INTO positions (id, name, code, description, sort, status) +VALUES (1, 'HR Manager', 'HR-MGR', + 'Responsible for overseeing recruitment, employee relations, and staff wellbeing.', 1, + 'ACTIVE'), + (2, 'Finance Officer', 'FIN-OFC', + 'Handles accounts, prepares financial statements, and ensures compliance with regulations.', + 2, 'ACTIVE'), + (3, 'IT Support Specialist', 'IT-SPT', + 'Provides technical assistance, manages helpdesk queries, and maintains computer systems.', + 3, 'ACTIVE'), + (4, 'Software Engineer', 'SWE-ENG', + 'Develops and maintains in-house applications, ensuring code quality and system reliability.', + 4, 'ACTIVE'), + (5, 'Operations Coordinator', 'OPS-CRD', + 'Assists with day-to-day logistics, procurement, and office organisation.', 5, 'INACTIVE'); + +--- Users Table --- +DROP TABLE IF EXISTS users CASCADE; +CREATE TABLE users +( + id BIGINT PRIMARY KEY, + username VARCHAR(64) UNIQUE NOT NULL, + password VARCHAR(255), + full_name VARCHAR(128) NOT NULL, + email VARCHAR(128) UNIQUE, + region_code VARCHAR(10), + phone_number VARCHAR(32), + avatar_url TEXT, + status USER_STATUS NOT NULL DEFAULT 'ACTIVE', + department_id BIGINT REFERENCES departments (id), + position_id BIGINT REFERENCES positions (id), + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX users_username_index ON users (username); + +--- Users Table Indexes --- +CREATE UNIQUE INDEX uidx_users_region_abbreviation_phone_number + ON users (region_abbreviation, phone_number); + +--- Users Data Insertion --- +-- NOTE: All phone numbers are generated by ChatGPT, they should not be connected any real person. +INSERT INTO users(id, username, password, full_name, email, region_abbreviation, phone_number, avatar_url, + department_id, position_id, created_at, updated_at) +VALUES (1, 'helix', null, 'Helix Admin', 'admin@helix.onixbyte.dev', 'GB', '7000000000', + 'https://gravatar.com/avatar/6ef4c4033f6aa8e43d06bd5e462a6173cc2a960633473721a6f1289cd1b5146f', + 1, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + (2, 'johndoe', null, 'John Doe', 'johndoe@helix.onixbyte.dev', 'GB', '7000000001', + 'https://gravatar.com/avatar/41bcebddd573747d1bd35ef7fae72ebefd6b47f077d42442a2510d35b0c2db92', + 6, 4, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP); + +--- User Identities Table --- +DROP TABLE IF EXISTS user_identities CASCADE; +CREATE TABLE user_identities +( + user_id BIGINT NOT NULL REFERENCES users (id), + provider IDENTITY_PROVIDER NOT NULL, + external_id VARCHAR(255) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (user_id, provider, external_id) +); + +--- Roles Table --- +DROP TABLE IF EXISTS roles CASCADE; +CREATE TABLE roles +( + id BIGSERIAL PRIMARY KEY, + name VARCHAR(128) NOT NULL UNIQUE, + code VARCHAR(64) NOT NULL UNIQUE, + sort INTEGER NOT NULL, + default_value BOOLEAN NOT NULL DEFAULT FALSE, + description TEXT, + status STATUS NOT NULL DEFAULT 'ACTIVE', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX roles_name_index ON roles (name); +CREATE INDEX roles_code_index ON roles (code); +CREATE INDEX roles_name_code_index ON roles (name, code); + +--- Roles Data Insertion --- +INSERT INTO roles (name, code, sort, default_value, description, status, created_at, updated_at) +VALUES ('Admin', 'admin', 1, FALSE, 'Administrator of this system.', 'ACTIVE'::STATUS, + CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('Normal User', 'user', 2, TRUE, 'Normal user of this system.', 'ACTIVE'::STATUS, + CURRENT_TIMESTAMP, CURRENT_TIMESTAMP); + +--- User Roles Table --- +DROP TABLE IF EXISTS user_roles CASCADE; +CREATE TABLE user_roles +( + user_id BIGINT NOT NULL + CONSTRAINT user_roles_users_id_fk REFERENCES users, + role_id BIGINT NOT NULL + CONSTRAINT user_roles_roles_id_fk REFERENCES roles, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, + CONSTRAINT user_roles_pk PRIMARY KEY (user_id, role_id) +); + +--- User Roles Data Insertion --- +INSERT INTO user_roles +VALUES (1, 1), + (2, 2); + +--- Authorities Table --- +DROP TABLE IF EXISTS authorities CASCADE; +CREATE TABLE authorities +( + id BIGSERIAL PRIMARY KEY, + code VARCHAR(128) NOT NULL UNIQUE, + name VARCHAR(128) NOT NULL, + description TEXT, + status STATUS NOT NULL DEFAULT 'ACTIVE'::STATUS, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX authorities_code_index ON authorities (code); + +--- Authorities Data Insertion --- +INSERT INTO authorities(code, name, description, status, created_at, updated_at) +VALUES ('system:dashboard:read', 'Read Dashboard', 'Read dashboard.', 'ACTIVE'::STATUS, + CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('system:user:read', 'Read User', 'Read user.', 'ACTIVE'::STATUS, CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP), + ('system:user_detail:read', 'Read User', 'Read user detail.', 'ACTIVE'::STATUS, + CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('system:user:write', 'Write User', 'Write user, such as add, edit or delete.', + 'ACTIVE'::STATUS, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('system:department:read', 'Read Department', 'Read departments', 'ACTIVE'::STATUS, + CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('system:department:write', 'Write Department', 'Write departments.', 'ACTIVE'::STATUS, + CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('system:role:read', 'Read Roles', 'Read roles.', 'ACTIVE'::STATUS, CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP), + ('system:role:write', 'Write Roles', 'Write roles.', 'ACTIVE'::STATUS, + CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('system:authority:read', 'Read Authorities', 'Read authorities.', 'ACTIVE'::STATUS, + CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('system:authority:write', 'Write Authorities', 'Write authorities.', + 'ACTIVE'::STATUS, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('system:audit_log:read', 'Read Audit Logs', 'Read audit logs.', 'ACTIVE'::STATUS, + CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('system:sso:write', 'Manage SSO', + 'Manage SSO configurations (such as Microsoft Entra ID, etc.).', 'ACTIVE'::STATUS, + CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('system:setting:write', 'Write System Settings', 'Write system settings.', + 'ACTIVE'::STATUS, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP); + +--- Role Authorities Table --- +DROP TABLE IF EXISTS role_authorities CASCADE; +CREATE TABLE role_authorities +( + role_id BIGINT NOT NULL REFERENCES roles (id), + authority_id BIGINT NOT NULL REFERENCES authorities (id), + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (role_id, authority_id) +); + +--- Role Authorities Data Insertion --- +INSERT INTO role_authorities +VALUES (1, 1, CURRENT_TIMESTAMP), + (1, 2, CURRENT_TIMESTAMP), + (1, 3, CURRENT_TIMESTAMP), + (1, 4, CURRENT_TIMESTAMP), + (1, 5, CURRENT_TIMESTAMP), + (1, 6, CURRENT_TIMESTAMP), + (1, 7, CURRENT_TIMESTAMP), + (1, 8, CURRENT_TIMESTAMP), + (1, 9, CURRENT_TIMESTAMP), + (1, 10, CURRENT_TIMESTAMP), + (1, 11, CURRENT_TIMESTAMP), + (1, 12, CURRENT_TIMESTAMP), + (1, 13, CURRENT_TIMESTAMP), + (2, 1, CURRENT_TIMESTAMP), + (2, 2, CURRENT_TIMESTAMP), + (2, 3, CURRENT_TIMESTAMP), + (2, 5, CURRENT_TIMESTAMP), + (2, 7, CURRENT_TIMESTAMP), + (2, 9, CURRENT_TIMESTAMP), + (2, 11, CURRENT_TIMESTAMP); + +DROP TABLE IF EXISTS assets; +CREATE TABLE assets +( + id BIGSERIAL NOT NULL PRIMARY KEY, + key VARCHAR(255) NOT NULL UNIQUE, + upload_by BIGINT NOT NULL REFERENCES users (id), + upload_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX assets_key_index ON assets (key); + +COMMENT ON TABLE assets IS 'Stores metadata for files or other digital assets within the system.'; +COMMENT ON COLUMN assets.id IS 'The unique identifier for the asset, automatically generated by the database.'; +COMMENT ON COLUMN assets.key IS 'The unique key or path of the asset within the storage system.'; +COMMENT ON COLUMN assets.upload_by IS 'The unique ID of the user who uploaded this asset, referencing the ID in the users table.'; +COMMENT ON COLUMN assets.upload_time IS 'The timestamp indicating when the asset was uploaded to the system.'; + +DROP TABLE IF EXISTS settings; +CREATE TABLE settings +( + id BIGSERIAL NOT NULL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + description VARCHAR(255) NULL, + type SETTING_TYPE NOT NULL, + value VARCHAR(255) NULL, + default_value VARCHAR(255) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX settings_name_index ON settings (name); + +COMMENT ON TABLE settings IS 'Hot-deployable application settings.'; +COMMENT ON COLUMN settings.id IS 'Setting unique identifier.'; +COMMENT ON COLUMN settings.name IS 'Setting name.'; +COMMENT ON COLUMN settings.description IS 'Setting description.'; +COMMENT ON COLUMN settings.type IS 'The type of the value.'; +COMMENT ON COLUMN settings.value IS 'Setting current value.'; +COMMENT ON COLUMN settings.default_value IS 'Setting default value.'; + +INSERT INTO settings(name, description, type, value, default_value) +VALUES ('captcha-setting::enabled', 'Whether captcha is enabled.', 'BOOLEAN'::SETTING_TYPE, 'true', + 'false'), + ('auth-setting::register-enabled', 'Whether register is enabled', 'BOOLEAN'::SETTING_TYPE, + 'true', 'false'); + +DROP TABLE IF EXISTS menus; +CREATE TABLE menus +( + id BIGSERIAL NOT NULL PRIMARY KEY, + name VARCHAR(50) NOT NULL, + parent_id BIGINT NULL DEFAULT NULL, + code VARCHAR(255) NOT NULL, + sort INTEGER NOT NULL DEFAULT 0, + path VARCHAR(255) NULL DEFAULT NULL, + is_external_link BOOLEAN NOT NULL DEFAULT FALSE, + is_visible BOOLEAN NOT NULL DEFAULT TRUE, + status STATUS NOT NULL DEFAULT 'ACTIVE'::STATUS, + authority_code VARCHAR(128) NULL DEFAULT NULL, + icon VARCHAR(128) NULL DEFAULT NULL, + create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_time TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE UNIQUE INDEX menus_code_uindex ON menus (code); + +INSERT INTO menus(id, name, parent_id, code, sort, path, is_external_link, is_visible, status, + authority_code, icon, create_time, update_time) +VALUES (1, '系统管理', NULL, 'system-manage', 99, NULL, FALSE, TRUE, 'ACTIVE'::STATUS, NULL, NULL, + NOW(), NOW()), + (2, '用户管理', 1, 'user-manage', 1, '/users', FALSE, TRUE, 'ACTIVE'::STATUS, + 'system:user:write', NULL, NOW(), NOW()); \ No newline at end of file diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..7c0ddbc --- /dev/null +++ b/gradle.properties @@ -0,0 +1 @@ +artefactVersion=0.0.1 \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..744b7f0 --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,55 @@ +[versions] +jspecifyVersion = "1.0.0" +javaJwtVersion = "4.5.0" +postgresDriverVersion = "42.7.7" +h2Version = "2.2.224" +springSecurityVersion = "6.5.2" +springBootVersion = "3.5.4" +reactorVersion = "3.7.8" +junitPlatformVersion = "1.12.2" +onixbyteVersion = "3.2.0" +onixbyteCaptcha = "1.1.0" +onixbyteRegions = "2025.12.0" +awsSdkVersion = "2.25.48" +commonsIoVersion = "2.16.1" +commonsCollections = "4.5.0" +commonsLangVersion = "3.20.0" +mybatisVersion = "3.0.5" +jacksonVersion = "2.19.2" +hypersistenceVersion = "3.14.0" + +[libraries] +jwt-core = { group = "com.auth0", name = "java-jwt", version.ref = "javaJwtVersion" } +postgres-driver = { group = "org.postgresql", name = "postgresql", version.ref = "postgresDriverVersion" } +h2-database = { group = "com.h2database", name = "h2", version.ref = "h2Version" } +spring-boot-configurationProcessor = { group = "org.springframework.boot", name = "spring-boot-configuration-processor", version.ref = "springBootVersion" } +spring-boot-starter-web = { group = "org.springframework.boot", name = "spring-boot-starter-web", version.ref = "springBootVersion" } +spring-boot-starter-webFlux = { group = "org.springframework.boot", name = "spring-boot-starter-webflux", version.ref = "springBootVersion" } +spring-boot-starter-validation = { group = "org.springframework.boot", name = "spring-boot-starter-validation", version.ref = "springBootVersion" } +spring-boot-starter-redis = { group = "org.springframework.boot", name = "spring-boot-starter-data-redis", version.ref = "springBootVersion" } +spring-boot-starter-cache = { group = "org.springframework.boot", name = "spring-boot-starter-cache", version.ref = "springBootVersion" } +spring-boot-starter-security = { group = "org.springframework.boot", name = "spring-boot-starter-security", version.ref = "springBootVersion" } +spring-boot-starter-test = { group = "org.springframework.boot", name = "spring-boot-starter-test", version.ref = "springBootVersion" } +spring-security-test = { group = "org.springframework.security", name = "spring-security-test", version.ref = "springSecurityVersion" } +reactor-test = { group = "io.projectreactor", name = "reactor-test", version.ref = "reactorVersion" } +junit-launcher = { group = "org.junit.platform", name = "junit-platform-launcher", version.ref = "junitPlatformVersion" } +onixbyte-versionCatalogue = { group = "com.onixbyte", name = "version-catalogue", version.ref = "onixbyteVersion" } +onixbyte-tuple = { group = "com.onixbyte", name = "tuple", version.ref = "onixbyteVersion" } +onixbyte-commonToolbox = { group = "com.onixbyte", name = "common-toolbox", version.ref = "onixbyteVersion" } +onixbyte-identityGenerator = { group = "com.onixbyte", name = "identity-generator", version.ref = "onixbyteVersion" } +onixbyte-captcha = { group = "com.onixbyte", name = "captcha", version.ref = "onixbyteCaptcha" } +onixbyte-regions = { group = "com.onixbyte", name = "regions4j", version.ref = "onixbyteRegions" } +aws-sdk-bom = { group = "software.amazon.awssdk", name = "bom", version.ref = "awsSdkVersion" } +aws-sdk-s3 = { group = "software.amazon.awssdk", name = "s3" } +commons-io = { group = "commons-io", name = "commons-io", version.ref = "commonsIoVersion" } +commons-collections = { group = "org.apache.commons", name = "commons-collections4", version.ref = "commonsCollections" } +mybatis-starter-core = { group = "org.mybatis.spring.boot", name = "mybatis-spring-boot-starter", version.ref = "mybatisVersion" } +mybatis-starter-test = { group = "org.mybatis.spring.boot", name = "mybatis-spring-boot-starter-test", version.ref = "mybatisVersion" } +jackson-jsr310 = { group = "com.fasterxml.jackson.datatype", name = "jackson-datatype-jsr310", version.ref = "jacksonVersion" } +spring-boot-starter-jpa = { group = "org.springframework.boot", name = "spring-boot-starter-data-jpa" } +hypersistence-core = { group = "io.hypersistence", name = "hypersistence-utils-hibernate-63", version.ref = "hypersistenceVersion" } +commons-lang = { group = "org.apache.commons", name = "commons-lang3", version.ref = "commonsLangVersion" } +jspecify-core = { group = "org.jspecify", name = "jspecify", version.ref = "jspecifyVersion" } + +[plugins] + diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..d4081da --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100644 index 0000000..faf9300 --- /dev/null +++ b/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed 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 +# +# https://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. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..9d21a21 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..6f13425 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "helix-server" \ No newline at end of file diff --git a/src/main/java/com/onixbyte/helix/HelixApplication.java b/src/main/java/com/onixbyte/helix/HelixApplication.java new file mode 100644 index 0000000..4bf85ae --- /dev/null +++ b/src/main/java/com/onixbyte/helix/HelixApplication.java @@ -0,0 +1,31 @@ +package com.onixbyte.helix; + +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.cache.annotation.EnableCaching; + +/** + * Application entrance. + * + * @author zihluwang + * @see SpringBootApplication + * @see EnableCaching + * @see SpringApplication + * @since 1.0.0 + */ +@EnableCaching +@SpringBootApplication +public class HelixApplication { + + /** + * Main method that serves as the entry point for the Helix application. + * + * @param args command-line arguments passed to the application, which can be used to override + * default configuration properties or specify runtime options + */ + public static void main(String[] args) { + SpringApplication.run(HelixApplication.class, args); + } + +} diff --git a/src/main/java/com/onixbyte/helix/client/RedisClient.java b/src/main/java/com/onixbyte/helix/client/RedisClient.java new file mode 100644 index 0000000..3c57bdf --- /dev/null +++ b/src/main/java/com/onixbyte/helix/client/RedisClient.java @@ -0,0 +1,127 @@ +package com.onixbyte.helix.client; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Component; + +import java.time.Duration; +import java.util.Objects; + +/** + * A client class for interacting with Redis, providing simple and type-safe access to common Redis + * operations such as setting, retrieving, incrementing, decrementing, and deleting keys. + *

+ * This class abstracts the direct usage of {@link RedisTemplate} for value operations. + * + * @author zihluwang + */ +@Component +public class RedisClient { + + private final RedisTemplate redisTemplate; + + /** + * Constructs a new RedisClient with the specified {@link RedisTemplate}. + * + * @param redisTemplate the template used for Redis interaction + */ + @Autowired + public RedisClient(RedisTemplate redisTemplate) { + this.redisTemplate = redisTemplate; + } + + /** + * Set a value for a given key in Redis. + * + * @param key the key to set + * @param value the value associated with the key + * @param the type of the value + */ + public void set(String key, T value) { + redisTemplate.opsForValue().set(key, value); + } + + /** + * Set a value for a given key in Redis with a specified expiry timeout. + * + * @param key the key to set + * @param value the value associated with the key + * @param timeout the time after which the key should expire + * @param the type of the value + */ + public void set(String key, T value, Duration timeout) { + redisTemplate.opsForValue().set(key, value, timeout); + } + + /** + * Get the value associated with a given key from Redis. + *

+ * The returned object is of type {@code Object} and may require manual casting. + * + * @param key the key to retrieve + * @return the value associated with the key, or {@code null} if the key does not exist + */ + public Object get(String key) { + return redisTemplate.opsForValue().get(key); + } + + /** + * Get the value associated with a given key from Redis, attempting to cast it to the + * specified type. + * + * @param key the key to retrieve + * @param type the class type to cast the retrieved value to + * @param the type of the expected value + * @return the value associated with the key, cast to type T, or {@code null} if the key does + * not exist + * @throws IllegalStateException if the retrieved value cannot be cast to the specified type + */ + public T get(String key, Class type) { + var value = redisTemplate.opsForValue().get(key); + if (Objects.isNull(value)) { + return null; + } + + if (type.isInstance(value)) { + return type.cast(value); + } + + throw new IllegalStateException("Cannot cast " + value.getClass().getName() + " to " + type.getName()); + } + + /** + * Increment the value of the key by one. + *

+ * If the key does not exist, it is created and set to 0 before the increment operation. If the + * value stored at the key is not an integer, an exception may be thrown by Redis. + * + * @param key the key to increment + * @return the new value of the key after the increment + */ + public Long increment(String key) { + return redisTemplate.opsForValue().increment(key); + } + + /** + * Decrement the value of the key by one. + *

+ * If the key does not exist, it is created and set to 0 before the decrement operation. If the + * value stored at the key is not an integer, an exception may be thrown by Redis. + * + * @param key the key to decrement + * @return the new value of the key after the decrement + */ + public Long decrement(String key) { + return redisTemplate.opsForValue().decrement(key); + } + + /** + * Delete a key from Redis. + * + * @param key the key to delete + * @return {@code true} if the key was deleted, {@code false} if the key did not exist + */ + public boolean delete(String key) { + return redisTemplate.delete(key); + } +} diff --git a/src/main/java/com/onixbyte/helix/client/TokenClient.java b/src/main/java/com/onixbyte/helix/client/TokenClient.java new file mode 100644 index 0000000..b6af92f --- /dev/null +++ b/src/main/java/com/onixbyte/helix/client/TokenClient.java @@ -0,0 +1,57 @@ +package com.onixbyte.helix.client; + +import com.auth0.jwt.JWT; +import com.auth0.jwt.algorithms.Algorithm; +import com.onixbyte.helix.domain.entity.User; +import com.onixbyte.helix.properties.TokenProperties; +import com.onixbyte.helix.utils.DateTimeUtil; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; + +/** + * A client class responsible for generating JSON Web Tokens (JWT) for user authentication and + * authorisation purposes. + *

+ * It uses the {@link com.auth0.jwt.JWT} library to create and sign tokens based on user details and + * configured token properties. + * + * @author zihluwang + */ +@Component +public class TokenClient { + + private final Algorithm algorithm; + private final TokenProperties tokenProperties; + + /** + * Constructs a new TokenClient with the necessary algorithm and token properties. + * + * @param algorithm the signing algorithm used to secure the JWT + * @param tokenProperties the configuration properties for the token, such as issuer and + * validity period + */ + public TokenClient(Algorithm algorithm, TokenProperties tokenProperties) { + this.algorithm = algorithm; + this.tokenProperties = tokenProperties; + } + + /** + * Generate a JSON Web Token to the current user. + * + * @param user the current user for whom the token is being generated + * @return a JWT string + */ + public String generateToken(User user) { + var issuedAt = LocalDateTime.now(); + var expiresAt = issuedAt.plus(tokenProperties.validTime()); + + return JWT.create() + .withSubject(user.getUsername()) + .withAudience("Helix Web") + .withIssuer(tokenProperties.issuer()) + .withIssuedAt(DateTimeUtil.asInstant(issuedAt)) + .withExpiresAt(DateTimeUtil.asInstant(expiresAt)) + .sign(algorithm); + } +} \ No newline at end of file diff --git a/src/main/java/com/onixbyte/helix/common/datetime/DateTimeFormatters.java b/src/main/java/com/onixbyte/helix/common/datetime/DateTimeFormatters.java new file mode 100644 index 0000000..5a51cac --- /dev/null +++ b/src/main/java/com/onixbyte/helix/common/datetime/DateTimeFormatters.java @@ -0,0 +1,45 @@ +package com.onixbyte.helix.common.datetime; + +import java.time.format.DateTimeFormatter; + +/** + * Utility class providing predefined {@link DateTimeFormatter} instances for common date and + * time patterns. These formatters can be used to parse and format {@code java.time} objects + * consistently throughout an application. + * + * @author zihluwang + */ +public class DateTimeFormatters { + + /** + * A {@link DateTimeFormatter} for formatting and parsing full date and time with seconds, using + * the pattern "yyyy-MM-dd HH:mm:ss". + *

+ * Example: "{@code 2023-10-27 15:30:45}" + */ + public static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + + /** + * A {@link DateTimeFormatter} for formatting and parsing dates only, using the + * pattern "yyyy-MM-dd". + *

+ * Example: "{@code 2023-10-27}" + */ + public static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + + /** + * A {@link DateTimeFormatter} for formatting and parsing times only, using the + * pattern "HH:mm:ss". + *

+ * Example: "{@code 15:30:45}" + */ + public static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("HH:mm:ss"); + + /** + * A {@link DateTimeFormatter} for formatting and parsing year and month only, using the + * pattern "yyyy-MM". + *

+ * Example: "{@code 2023-10}" + */ + public static final DateTimeFormatter YEAR_MONTH_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM"); +} diff --git a/src/main/java/com/onixbyte/helix/common/jackson/JacksonModules.java b/src/main/java/com/onixbyte/helix/common/jackson/JacksonModules.java new file mode 100644 index 0000000..78430fb --- /dev/null +++ b/src/main/java/com/onixbyte/helix/common/jackson/JacksonModules.java @@ -0,0 +1,34 @@ +package com.onixbyte.helix.common.jackson; + +import com.fasterxml.jackson.databind.module.SimpleModule; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateDeserializer; +import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateTimeDeserializer; +import com.fasterxml.jackson.datatype.jsr310.deser.LocalTimeDeserializer; +import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateSerializer; +import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer; +import com.fasterxml.jackson.datatype.jsr310.ser.LocalTimeSerializer; +import com.onixbyte.helix.common.datetime.DateTimeFormatters; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; + +public class JacksonModules { + + public static final SimpleModule DATE_TIME_MODULE = initialiseDateTimeModule(); + + private static SimpleModule initialiseDateTimeModule() { + var javaTimeModule = new JavaTimeModule(); + javaTimeModule.addSerializer(LocalDateTime.class, new LocalDateTimeSerializer(DateTimeFormatters.DATE_TIME_FORMATTER)); + javaTimeModule.addDeserializer(LocalDateTime.class, new LocalDateTimeDeserializer(DateTimeFormatters.DATE_TIME_FORMATTER)); + + javaTimeModule.addSerializer(LocalDate.class, new LocalDateSerializer(DateTimeFormatters.DATE_FORMATTER)); + javaTimeModule.addDeserializer(LocalDate.class, new LocalDateDeserializer(DateTimeFormatters.DATE_FORMATTER)); + + javaTimeModule.addSerializer(LocalTime.class, new LocalTimeSerializer(DateTimeFormatters.TIME_FORMATTER)); + javaTimeModule.addDeserializer(LocalTime.class, new LocalTimeDeserializer(DateTimeFormatters.TIME_FORMATTER)); + + return javaTimeModule; + } +} diff --git a/src/main/java/com/onixbyte/helix/common/regex/Patterns.java b/src/main/java/com/onixbyte/helix/common/regex/Patterns.java new file mode 100644 index 0000000..43d5394 --- /dev/null +++ b/src/main/java/com/onixbyte/helix/common/regex/Patterns.java @@ -0,0 +1,12 @@ +package com.onixbyte.helix.common.regex; + +import java.util.regex.Pattern; + +public class Patterns { + + public static final Pattern EMAIL = Pattern.compile("^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$"); + + public static final Pattern IMAGE_URL = Pattern.compile("^https?://.*\\.(?:png|jpg|jpeg|gif|webp|svg|avif)(?:\\?.*)?$", Pattern.CASE_INSENSITIVE); + + public static final Pattern GRAVATAR_IMAGE_URL = Pattern.compile("^https?://(?:[a-z0-9-]+\\.)?gravatar\\.com/avatar/([a-f0-9]{32})(?:\\?.*)?$", Pattern.CASE_INSENSITIVE); +} diff --git a/src/main/java/com/onixbyte/helix/config/AssetConfig.java b/src/main/java/com/onixbyte/helix/config/AssetConfig.java new file mode 100644 index 0000000..be792c6 --- /dev/null +++ b/src/main/java/com/onixbyte/helix/config/AssetConfig.java @@ -0,0 +1,67 @@ +package com.onixbyte.helix.config; + +import com.onixbyte.helix.properties.AssetProperties; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.S3Configuration; + +import java.net.URI; +import java.net.URISyntaxException; +import java.util.Optional; + +/** + * Configuration class for asset storage services. + *

+ * Enables configuration properties for S3 file storage services. Individual service beans are + * created by their respective service classes to better support conditional configuration. + * + * @author zihluwang + * @since 1.0.0 + */ +@Configuration +@EnableConfigurationProperties({AssetProperties.class}) +public class AssetConfig { + + /** + * S3Client to store assets into S3 service. + * + * @param assetProperties asset properties + * @return an S3 Client reference to custom S3 configuration properties + */ + @Bean + public S3Client s3Client(AssetProperties assetProperties) { + // initialise AWS credentials + var credentials = AwsBasicCredentials.create( + assetProperties.accessKeyId(), + assetProperties.secretAccessKey() + ); + + // prepare s3 client + var s3ClientBuilder = S3Client.builder() + .region(Region.of(assetProperties.region())) + .credentialsProvider(StaticCredentialsProvider.create(credentials)); + + // override endpoint + Optional.ofNullable(assetProperties.endpoint()) + .ifPresent((endpoint) -> { + try { + s3ClientBuilder.endpointOverride(new URI(endpoint)); + } catch (URISyntaxException e) { + throw new IllegalArgumentException("Endpoint is not valid."); + } + }); + + // set path style + s3ClientBuilder.serviceConfiguration(S3Configuration.builder() + .pathStyleAccessEnabled(assetProperties.pathStyle()) + .build() + ); + + return s3ClientBuilder.build(); + } +} diff --git a/src/main/java/com/onixbyte/helix/config/AuthenticationConfig.java b/src/main/java/com/onixbyte/helix/config/AuthenticationConfig.java new file mode 100644 index 0000000..d5aff8a --- /dev/null +++ b/src/main/java/com/onixbyte/helix/config/AuthenticationConfig.java @@ -0,0 +1,28 @@ +package com.onixbyte.helix.config; + +import com.onixbyte.helix.properties.ApplicationProperties; +import com.onixbyte.helix.properties.MsalProperties; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +/** + * Configuration class for authentication-related components and properties. + *

+ * This configuration class is responsible for enabling and managing custom configuration properties + * related to authentication mechanisms within the Helix application. It specifically enables the + * {@link MsalProperties} configuration properties to support Microsoft Authentication Library + * (MSAL) integration. + *

+ * The class serves as a central point for authentication configuration, ensuring that all + * authentication-related properties are properly loaded and made available to the Spring + * application context. + * + * @author zihluwang + * @since 1.0.0 + * @see MsalProperties + * @see EnableConfigurationProperties + */ +@Configuration +@EnableConfigurationProperties({MsalProperties.class, ApplicationProperties.class}) +public class AuthenticationConfig { +} diff --git a/src/main/java/com/onixbyte/helix/config/CacheConfig.java b/src/main/java/com/onixbyte/helix/config/CacheConfig.java new file mode 100644 index 0000000..97b4aff --- /dev/null +++ b/src/main/java/com/onixbyte/helix/config/CacheConfig.java @@ -0,0 +1,99 @@ +package com.onixbyte.helix.config; + +import com.onixbyte.helix.extension.redis.serializer.JacksonSerialiser; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.cache.RedisCacheConfiguration; +import org.springframework.data.redis.cache.RedisCacheManager; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.RedisSerializationContext; +import org.springframework.data.redis.serializer.RedisSerializer; + +import java.time.Duration; + +/** + * Configuration class for Redis-based caching components. + *

+ * This configuration class provides beans for Redis cache management and template operations + * within the Helix application. It configures custom serialisation strategies using + * {@link GenericJackson2JsonRedisSerializer} for values and string serialisation for keys, + * ensuring optimal performance and compatibility with JSON-based data structures. + *

+ * The configuration includes: + *

+ * + * @author zihluwang + * @see RedisCacheManager + * @see RedisTemplate + * @see GenericJackson2JsonRedisSerializer + * @since 1.0.0 + */ +@Configuration +public class CacheConfig { + + /** + * Creates a custom Redis cache manager with JSON serialisation support. + *

+ * This method configures a {@link RedisCacheManager} that uses string serialisation for cache + * keys and {@link GenericJackson2JsonRedisSerializer} for cache values. This setup ensures that + * complex objects can be stored and retrieved from Redis cache whilst maintaining readability + * and compatibility with JSON-based systems. + * + * @param connectionFactory the Redis connection factory used to establish connections + * @return a configured {@link RedisCacheManager} with custom serialisation settings + * @see RedisCacheManager + * @see GenericJackson2JsonRedisSerializer + * @see RedisSerializationContext + */ + @Bean + public RedisCacheManager cacheManager( + RedisConnectionFactory connectionFactory + ) { + var _keySerializer = RedisSerializer.string(); + + var cacheConfiguration = RedisCacheConfiguration.defaultCacheConfig() + .serializeKeysWith(RedisSerializationContext.SerializationPair + .fromSerializer(_keySerializer)) + .serializeValuesWith(RedisSerializationContext.SerializationPair + .fromSerializer(JacksonSerialiser.INSTANCE)) + .entryTtl(Duration.ofMinutes(90L)); + + return RedisCacheManager.RedisCacheManagerBuilder + .fromConnectionFactory(connectionFactory) + .cacheDefaults(cacheConfiguration) + .build(); + } + + /** + * Creates a Redis template for direct Redis operations with custom serialisation. + *

+ * This method configures a {@link RedisTemplate} that uses string serialisation for keys + * and {@link GenericJackson2JsonRedisSerializer} for values. This template provides low-level + * access to Redis operations whilst ensuring consistent serialisation strategies across + * the application. + *

+ * The template is fully configured and ready for use after bean creation. + * + * @param connectionFactory the Redis connection factory used to establish connections + * @return a fully configured {@link RedisTemplate} for Redis operations + * @see RedisTemplate + * @see GenericJackson2JsonRedisSerializer + * @see RedisSerializer + */ + @Bean + public RedisTemplate redisTemplate( + RedisConnectionFactory connectionFactory + ) { + var redisTemplate = new RedisTemplate(); + redisTemplate.setConnectionFactory(connectionFactory); + redisTemplate.setKeySerializer(RedisSerializer.string()); + redisTemplate.setValueSerializer(JacksonSerialiser.INSTANCE); + redisTemplate.afterPropertiesSet(); + return redisTemplate; + } +} diff --git a/src/main/java/com/onixbyte/helix/config/CaptchaConfig.java b/src/main/java/com/onixbyte/helix/config/CaptchaConfig.java new file mode 100644 index 0000000..14f7b50 --- /dev/null +++ b/src/main/java/com/onixbyte/helix/config/CaptchaConfig.java @@ -0,0 +1,24 @@ +package com.onixbyte.helix.config; + +import com.onixbyte.captcha.Producer; +import com.onixbyte.captcha.impl.DefaultCaptchaProducer; +import com.onixbyte.captcha.text.impl.DefaultTextProducer; +import com.onixbyte.helix.properties.CaptchaProperties; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +@EnableConfigurationProperties({CaptchaProperties.class}) +public class CaptchaConfig { + + @Bean + public Producer producer(CaptchaProperties captchaProperties) { + var textProducer = DefaultTextProducer.builder() + .length(captchaProperties.length()) + .build(); + return DefaultCaptchaProducer.builder() + .textProducer(textProducer) + .build(); + } +} diff --git a/src/main/java/com/onixbyte/helix/config/GuidConfig.java b/src/main/java/com/onixbyte/helix/config/GuidConfig.java new file mode 100644 index 0000000..690b6ca --- /dev/null +++ b/src/main/java/com/onixbyte/helix/config/GuidConfig.java @@ -0,0 +1,53 @@ +package com.onixbyte.helix.config; + +import com.onixbyte.identitygenerator.IdentityGenerator; +import com.onixbyte.identitygenerator.impl.SequentialUuidGenerator; +import com.onixbyte.identitygenerator.impl.SnowflakeIdentityGenerator; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Configuration class for GUID (Globally Unique Identifier) generation components. + *

+ * This configuration class provides beans for generating unique identifiers throughout the + * Helix application. It utilises the Snowflake algorithm implementation to ensure globally unique, + * time-ordered identifiers that are suitable for distributed systems. + *

+ * The Snowflake algorithm generates 64-bit identifiers composed of: + *

+ * + * @author zihluwang + * @since 1.0.0 + * @see IdentityGenerator + * @see SnowflakeIdentityGenerator + */ +@Configuration +public class GuidConfig { + + /** + * Creates a Snowflake-based identity generator for user IDs. + *

+ * This method configures a {@link SnowflakeIdentityGenerator} with machine ID and data centre + * ID both set to 0. The generator produces unique 64-bit Long identifiers suitable for user + * entity primary keys in distributed environments. + *

+ * The generated IDs are: + *

+ * + * @return a configured {@link SnowflakeIdentityGenerator} instance for generating user IDs + * @see SnowflakeIdentityGenerator + * @see IdentityGenerator + */ + @Bean + public IdentityGenerator userIdentityGenerator() { + return new SnowflakeIdentityGenerator(0x0, 0x0); + } +} diff --git a/src/main/java/com/onixbyte/helix/config/JacksonConfig.java b/src/main/java/com/onixbyte/helix/config/JacksonConfig.java new file mode 100644 index 0000000..cc686d3 --- /dev/null +++ b/src/main/java/com/onixbyte/helix/config/JacksonConfig.java @@ -0,0 +1,20 @@ +package com.onixbyte.helix.config; + +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import com.onixbyte.helix.common.jackson.JacksonModules; +import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class JacksonConfig { + + @Bean + public Jackson2ObjectMapperBuilderCustomizer jacksonCustomiser() { + return (builder) -> { + builder.modules(JacksonModules.DATE_TIME_MODULE); + builder.featuresToDisable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + }; + } +} diff --git a/src/main/java/com/onixbyte/helix/config/MyBatisConfig.java b/src/main/java/com/onixbyte/helix/config/MyBatisConfig.java new file mode 100644 index 0000000..b852fe2 --- /dev/null +++ b/src/main/java/com/onixbyte/helix/config/MyBatisConfig.java @@ -0,0 +1,9 @@ +package com.onixbyte.helix.config; + +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.context.annotation.Configuration; + +@Configuration +@MapperScan(basePackages = {"com.onixbyte.helix.mapper"}) +public class MyBatisConfig { +} diff --git a/src/main/java/com/onixbyte/helix/config/SecurityConfig.java b/src/main/java/com/onixbyte/helix/config/SecurityConfig.java new file mode 100644 index 0000000..7d80d9d --- /dev/null +++ b/src/main/java/com/onixbyte/helix/config/SecurityConfig.java @@ -0,0 +1,209 @@ +package com.onixbyte.helix.config; + +import com.auth0.jwt.algorithms.Algorithm; +import com.onixbyte.helix.filter.TokenAuthenticationFilter; +import com.onixbyte.helix.properties.CorsProperties; +import com.onixbyte.helix.properties.TokenProperties; +import com.onixbyte.helix.security.entrypoint.UnauthorisedAuthenticationEntryPoint; +import com.onixbyte.helix.security.provider.UsernamePasswordAuthenticationProvider; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.ProviderManager; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.access.ExceptionTranslationFilter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import java.util.List; +import java.util.stream.Stream; + +/** + * Configuration class for Spring Security components and policies. + *

+ * This configuration class establishes comprehensive security settings for the Helix application, + * including CORS policies, authentication mechanisms, password encoding, and JWT token handling. + * It configures a stateless security architecture suitable for modern web applications and APIs. + *

+ * Key security features configured: + *

    + *
  • CORS (Cross-Origin Resource Sharing) configuration
  • + *
  • Stateless session management
  • + *
  • JWT-based authentication with HMAC256 algorithm
  • + *
  • BCrypt password encoding
  • + *
  • Method-level security annotations
  • + *
  • Custom authentication providers
  • + *
+ * + * @author zihluwang + * @see EnableWebSecurity + * @see EnableMethodSecurity + * @see TokenProperties + * @see CorsProperties + * @since 1.0.0 + */ +@Configuration +@EnableWebSecurity +@EnableMethodSecurity +@EnableConfigurationProperties({TokenProperties.class, CorsProperties.class}) +public class SecurityConfig { + + /** + * Creates a CORS configuration source based on application properties. + *

+ * This method configures Cross-Origin Resource Sharing (CORS) policies using the + * {@link CorsProperties} configuration. It sets up allowed origins, headers, methods, + * credentials handling, and other CORS-related settings to enable secure cross-origin requests + * from web browsers. + *

+ * The configuration is applied globally to all endpoints (/**) within the application. + * + * @param properties the CORS configuration properties containing allowed origins, headers, + * methods, etc + * @return a configured {@link CorsConfigurationSource} for handling cross-origin requests + * @see CorsProperties + * @see CorsConfiguration + * @see UrlBasedCorsConfigurationSource + */ + @Bean + public CorsConfigurationSource corsConfigurationSource( + CorsProperties properties + ) { + var corsConfiguration = new CorsConfiguration(); + corsConfiguration.setAllowCredentials(properties.allowCredentials()); + corsConfiguration.setAllowedOrigins(List.of(properties.allowedOrigins())); + corsConfiguration.setAllowedHeaders(List.of(properties.allowedHeaders())); + corsConfiguration.setAllowedMethods(Stream.of(properties.allowedMethods()) + .map(HttpMethod::name) + .toList()); + corsConfiguration.setMaxAge(properties.maxAge()); + corsConfiguration.setAllowPrivateNetwork(properties.allowPrivateNetwork()); + corsConfiguration.setExposedHeaders(List.of(properties.exposedHeaders())); + + var corsConfigurationSource = new UrlBasedCorsConfigurationSource(); + corsConfigurationSource.registerCorsConfiguration("/**", corsConfiguration); + return corsConfigurationSource; + } + + /** + * Configures the Spring Security filter chain for HTTP requests. + *

+ * This method establishes the core security policies for the application, including: + *

    + *
  • CORS configuration integration
  • + *
  • CSRF protection disabled (suitable for stateless APIs)
  • + *
  • Stateless session management
  • + *
  • Request authorization rules with public and protected endpoints
  • + *
+ *

+ * The configuration permits access to error pages and authentication endpoints whilst requiring + * authentication for all other requests. Logout endpoints require authentication to prevent + * unauthorised session termination. + * + * @param httpSecurity the HTTP security configuration builder + * @param corsConfigurationSource the CORS configuration source for cross-origin requests + * @return a configured {@link SecurityFilterChain} for processing HTTP requests + * @throws Exception if any exception occurs during security filter chain construction + * @see HttpSecurity + * @see SecurityFilterChain + * @see SessionCreationPolicy#STATELESS + */ + @Bean + public SecurityFilterChain securityFilterChain( + HttpSecurity httpSecurity, + CorsConfigurationSource corsConfigurationSource, + TokenAuthenticationFilter tokenAuthenticationFilter, + UnauthorisedAuthenticationEntryPoint unauthorisedAuthenticationEntryPoint + ) throws Exception { + return httpSecurity + .cors((cors) -> cors + .configurationSource(corsConfigurationSource)) + .csrf(AbstractHttpConfigurer::disable) + .sessionManagement((customiser) -> customiser + .sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests((customiser) -> customiser + .requestMatchers("/error", "/error/**").permitAll() + .requestMatchers("/captcha", "/captcha/**").permitAll() + .requestMatchers("/auth/**").permitAll() + .requestMatchers("/auth/logout").authenticated() + .anyRequest().authenticated() + ) + .exceptionHandling((exceptionHandling) -> exceptionHandling + .authenticationEntryPoint(unauthorisedAuthenticationEntryPoint)) + .addFilterAfter(tokenAuthenticationFilter, ExceptionTranslationFilter.class) + .build(); + } + + /** + * Creates a password encoder for secure password hashing. + *

+ * This method provides a {@link BCryptPasswordEncoder} instance that uses the BCrypt hashing + * algorithm to securely encode passwords. BCrypt is a adaptive hash function designed for + * password hashing that includes a salt to protect against rainbow table attacks and is + * computationally expensive to resist brute-force attacks. + *

+ * The encoder is used throughout the application for password verification during + * authentication and for encoding new passwords during user registration. + * + * @return a {@link BCryptPasswordEncoder} instance for secure password operations + * @see BCryptPasswordEncoder + * @see PasswordEncoder + */ + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + /** + * Creates an authentication manager with custom authentication providers. + *

+ * This method configures a {@link ProviderManager} that coordinates multiple authentication + * providers to handle different authentication mechanisms within the application. The manager + * attempts authentication using each configured provider until one succeeds or all fail. + *

+ * Currently configured for extensibility to support various authentication providers such as + * Microsoft Entra ID, local database authentication, or other identity providers as needed. + * + * @return a {@link ProviderManager} instance configured with authentication providers + * @see ProviderManager + * @see AuthenticationManager + */ + @Bean + public AuthenticationManager authenticationManager( + UsernamePasswordAuthenticationProvider usernamePasswordAuthenticationProvider + ) { + return new ProviderManager( + usernamePasswordAuthenticationProvider + ); + } + + /** + * Creates the JWT signing algorithm using application token properties. + *

+ * This method configures an HMAC256 algorithm instance using the secret key specified in + * the {@link TokenProperties}. The algorithm is used for signing and verifying JWT tokens + * throughout the application, ensuring token integrity and authenticity. + *

+ * HMAC256 provides a good balance of security and performance for JWT token signing in + * most applications. + * + * @param properties the token configuration properties containing the signing secret + * @return a configured {@link Algorithm} instance for JWT token operations + * @see Algorithm + * @see TokenProperties + */ + @Bean + public Algorithm algorithm(TokenProperties properties) { + return Algorithm.HMAC256(properties.secret()); + } +} diff --git a/src/main/java/com/onixbyte/helix/config/SpringDataConfig.java b/src/main/java/com/onixbyte/helix/config/SpringDataConfig.java new file mode 100644 index 0000000..476eda4 --- /dev/null +++ b/src/main/java/com/onixbyte/helix/config/SpringDataConfig.java @@ -0,0 +1,9 @@ +package com.onixbyte.helix.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; + +@Configuration +@EnableJpaRepositories(basePackages = {"com.onixbyte.helix.repository"}) +public class SpringDataConfig { +} diff --git a/src/main/java/com/onixbyte/helix/config/WebFluxConfig.java b/src/main/java/com/onixbyte/helix/config/WebFluxConfig.java new file mode 100644 index 0000000..6dae4eb --- /dev/null +++ b/src/main/java/com/onixbyte/helix/config/WebFluxConfig.java @@ -0,0 +1,57 @@ +package com.onixbyte.helix.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.reactive.function.client.WebClient; + +/** + * Configuration class for Spring WebFlux reactive web components. + *

+ * This configuration class provides beans for reactive web programming components within the Helix + * application. It configures WebClient instances and other reactive web-related components that + * enable non-blocking, asynchronous HTTP communication with external services. + *

+ * The configuration supports: + *

    + *
  • Reactive HTTP client configuration
  • + *
  • Non-blocking I/O operations
  • + *
  • Asynchronous request/response handling
  • + *
+ * + * @author zihluwang + * @since 1.0.0 + * @see WebClient + * @see Configuration + */ +@Configuration +public class WebFluxConfig { + + /** + * Creates a reactive WebClient for HTTP communication with external services. + *

+ * This method configures a {@link WebClient} instance that provides a modern, reactive approach + * to HTTP client operations. The WebClient supports non-blocking I/O and is built on top of + * Reactor Netty, making it suitable for high-performance, scalable applications. + *

+ * The client is configured with default settings and can be used throughout the application for + * making HTTP requests to external APIs, microservices, or other web resources in a reactive, + * non-blocking manner. + *

+ * Key features: + *

    + *
  • Non-blocking I/O operations
  • + *
  • Reactive streams support
  • + *
  • Built-in support for JSON serialisation/deserialisation
  • + *
  • Configurable timeouts and retry mechanisms
  • + *
+ * + * @return a configured {@link WebClient} instance for reactive HTTP operations + * @see WebClient + * @see WebClient.Builder + */ + @Bean + public WebClient webClient() { + return WebClient.builder() + .build(); + } +} diff --git a/src/main/java/com/onixbyte/helix/constant/AssetPrefix.java b/src/main/java/com/onixbyte/helix/constant/AssetPrefix.java new file mode 100644 index 0000000..09c9612 --- /dev/null +++ b/src/main/java/com/onixbyte/helix/constant/AssetPrefix.java @@ -0,0 +1,10 @@ +package com.onixbyte.helix.constant; + +public class AssetPrefix { + + public static final String UPLOADS = "uploads"; + + public static final String AVATARS = "avatars"; + + public static final String PROFILES = "profiles"; +} diff --git a/src/main/java/com/onixbyte/helix/constant/CacheName.java b/src/main/java/com/onixbyte/helix/constant/CacheName.java new file mode 100644 index 0000000..8c60a6e --- /dev/null +++ b/src/main/java/com/onixbyte/helix/constant/CacheName.java @@ -0,0 +1,18 @@ +package com.onixbyte.helix.constant; + +public class CacheName { + + public static final String USER = "user"; + + public static final String AUTHORITIES_OF_USER = "user-authorities"; + + public static final String ASSET = "asset"; + + public static final String SETTING = "setting"; + + public static final String CAPTCHA = "captcha"; + + public static final String CAPTCHA_SETTING = "captcha-setting"; + + public static final String AUTH_SETTING = "auth-setting"; +} diff --git a/src/main/java/com/onixbyte/helix/constant/ExternalHost.java b/src/main/java/com/onixbyte/helix/constant/ExternalHost.java new file mode 100644 index 0000000..5512434 --- /dev/null +++ b/src/main/java/com/onixbyte/helix/constant/ExternalHost.java @@ -0,0 +1,26 @@ +package com.onixbyte.helix.constant; + +/** + * Constants for external host configurations and endpoints. + *

+ * This utility class provides centralised definitions for external service hosts, API endpoints, + * and third-party integration points used throughout the Helix application. It serves as a single + * source of truth for external service configurations, promoting maintainability and consistency. + *

+ * The class is designed to hold static final constants representing: + *

    + *
  • External API base URLs
  • + *
  • Third-party service endpoints
  • + *
  • Integration service hosts
  • + *
  • External resource locations
  • + *
+ *

+ * This class cannot be instantiated as it serves purely as a constant container. + * + * @author zihluwang + * @since 1.0.0 + */ +public final class ExternalHost { + + +} diff --git a/src/main/java/com/onixbyte/helix/constant/FileType.java b/src/main/java/com/onixbyte/helix/constant/FileType.java new file mode 100644 index 0000000..45f55a4 --- /dev/null +++ b/src/main/java/com/onixbyte/helix/constant/FileType.java @@ -0,0 +1,23 @@ +package com.onixbyte.helix.constant; + +public interface FileType { + + String getExtension(); + + enum Image implements FileType { + JPEG("jpeg"), + PNG("png") + ; + + private final String extension; + + Image(String extension) { + this.extension = extension; + } + + @Override + public String getExtension() { + return extension; + } + } +} diff --git a/src/main/java/com/onixbyte/helix/constant/IdentityProvider.java b/src/main/java/com/onixbyte/helix/constant/IdentityProvider.java new file mode 100644 index 0000000..9e06844 --- /dev/null +++ b/src/main/java/com/onixbyte/helix/constant/IdentityProvider.java @@ -0,0 +1,40 @@ +package com.onixbyte.helix.constant; + +import com.onixbyte.helix.config.AuthenticationConfig; + +/** + * Enumeration of supported identity providers for user authentication. + *

+ * This enumeration defines the various identity providers that the Helix application supports for + * user authentication and authorisation. Each provider represents a different authentication + * mechanism or external identity service that can be used to verify user credentials and establish + * user sessions. + *

+ * The application supports both local authentication (using internal user database) and external + * identity providers (such as Microsoft Entra ID) to provide flexible authentication options for + * different deployment scenarios and organisational requirements. + * + * @author zihluwang + * @since 1.0.0 + * @see AuthenticationConfig + */ +public enum IdentityProvider { + + /** + * Local identity provider using the application's internal user database. + *

+ * This provider authenticates users against locally stored credentials, typically using + * username/email and password combinations. User accounts are managed entirely within the Helix + * application's database. + */ + LOCAL, + + /** + * Microsoft Entra ID (formerly Azure Active Directory) identity provider. + *

+ * This provider enables authentication through Microsoft's cloud-based identity and access + * management service. Users authenticate using their organisational Microsoft accounts, + * supporting features such as single sign-on (SSO) and multi-factor authentication (MFA). + */ + MICROSOFT_ENTRA_ID +} diff --git a/src/main/java/com/onixbyte/helix/constant/SettingName.java b/src/main/java/com/onixbyte/helix/constant/SettingName.java new file mode 100644 index 0000000..b539d96 --- /dev/null +++ b/src/main/java/com/onixbyte/helix/constant/SettingName.java @@ -0,0 +1,8 @@ +package com.onixbyte.helix.constant; + +public class SettingName { + + public static final String CAPTCHA_ENABLED = "captcha-enabled"; + + public static final String REGISTER_ENABLED = "register-enabled"; +} diff --git a/src/main/java/com/onixbyte/helix/constant/SettingType.java b/src/main/java/com/onixbyte/helix/constant/SettingType.java new file mode 100644 index 0000000..a4dfdd3 --- /dev/null +++ b/src/main/java/com/onixbyte/helix/constant/SettingType.java @@ -0,0 +1,9 @@ +package com.onixbyte.helix.constant; + +public enum SettingType { + + BOOLEAN, + STRING, + INT, + ; +} diff --git a/src/main/java/com/onixbyte/helix/constant/Status.java b/src/main/java/com/onixbyte/helix/constant/Status.java new file mode 100644 index 0000000..5b2a80d --- /dev/null +++ b/src/main/java/com/onixbyte/helix/constant/Status.java @@ -0,0 +1,43 @@ +package com.onixbyte.helix.constant; + +/** + * Enumeration representing general status states for system entities. + *

+ * This enumeration provides a standardised way to represent the operational status of various + * system entities, resources, or components within the Helix application. It offers a binary state + * model that can be applied across different domain objects to indicate their current + * operational condition. + *

+ * The status values are designed to be generic and reusable across multiple contexts, such as + * system configurations, feature toggles, service states, or any other binary operational + * indicators within the application. + * + * @author zihluwang + * @since 1.0.0 + */ +public enum Status { + + /** + * Indicates that the entity is currently active and operational. + *

+ * When an entity has an ACTIVE status, it means the entity is: + *

    + *
  • Currently enabled and functioning
  • + *
  • Available for use by the system or users
  • + *
  • Participating in normal application operations
  • + *
+ */ + ACTIVE, + + /** + * Indicates that the entity is currently inactive or disabled. + *

+ * When an entity has an INACTIVE status, it means the entity is: + *

    + *
  • Currently disabled or not functioning
  • + *
  • Temporarily or permanently unavailable
  • + *
  • Excluded from normal application operations
  • + *
+ */ + INACTIVE +} diff --git a/src/main/java/com/onixbyte/helix/constant/UserStatus.java b/src/main/java/com/onixbyte/helix/constant/UserStatus.java new file mode 100644 index 0000000..bbc05a3 --- /dev/null +++ b/src/main/java/com/onixbyte/helix/constant/UserStatus.java @@ -0,0 +1,60 @@ +package com.onixbyte.helix.constant; + +/** + * Enumeration representing the various states of user accounts within the system. + *

+ * This enumeration defines the possible status values for user accounts in the Helix application, + * providing a standardised way to manage user account lifecycle and access control. Each status + * represents a different level of account accessibility and operational capability within + * the system. + *

+ * User status directly affects authentication, authorisation, and system access permissions. + * The status is typically managed through administrative functions and security policies to ensure + * proper access control and account management. + * + * @author zihluwang + * @since 1.0.0 + * @see com.onixbyte.helix.config.SecurityConfiguration + */ +public enum UserStatus { + + /** + * Indicates that the user account is active and fully operational. + *

+ * An ACTIVE user account has: + *

    + *
  • Full access to system features and resources
  • + *
  • Ability to authenticate and establish sessions
  • + *
  • Normal operational privileges as per assigned roles
  • + *
  • No restrictions on account usage
  • + *
+ */ + ACTIVE, + + /** + * Indicates that the user account is inactive or disabled. + *

+ * An INACTIVE user account: + *

    + *
  • Cannot authenticate or access the system
  • + *
  • Is temporarily or permanently disabled
  • + *
  • Retains user data but blocks all system access
  • + *
  • May be reactivated by administrators if appropriate
  • + *
+ */ + INACTIVE, + + /** + * Indicates that the user account is locked due to security concerns. + *

+ * A LOCKED user account: + *

    + *
  • Is temporarily blocked from system access
  • + *
  • May result from failed authentication attempts
  • + *
  • Could be locked due to security policy violations
  • + *
  • Requires administrative intervention to unlock
  • + *
  • Maintains user data whilst preventing access
  • + *
+ */ + LOCKED +} diff --git a/src/main/java/com/onixbyte/helix/controller/AssetController.java b/src/main/java/com/onixbyte/helix/controller/AssetController.java new file mode 100644 index 0000000..be1f185 --- /dev/null +++ b/src/main/java/com/onixbyte/helix/controller/AssetController.java @@ -0,0 +1,81 @@ +package com.onixbyte.helix.controller; + +import com.onixbyte.helix.constant.AssetPrefix; +import com.onixbyte.helix.domain.web.response.FileUploadResponse; +import com.onixbyte.helix.exception.BizException; +import com.onixbyte.helix.service.AssetService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +/** + * REST controller for file storage operations. Provides endpoints for uploading, downloading, and + * deleting assets using the configured storage service. + * + * @author zihluwang + * @since 1.0.0 + */ +@RestController +@RequestMapping("/assets") +public class AssetController { + + private static final Logger log = LoggerFactory.getLogger(AssetController.class); + + private final AssetService assetService; + + /** + * Constructs a new FileController with the specified file service. + * + * @param assetService the file service to use for file operations + */ + public AssetController(AssetService assetService) { + this.assetService = assetService; + } + + /** + * Uploads a file to the configured storage service. + * + * @param file the multipart file to upload + * @return ResponseEntity containing the file URL and metadata, or error message + */ + @PostMapping + public ResponseEntity uploadFile( + @RequestParam MultipartFile file + ) { + try { + if (file.isEmpty()) { + throw new BizException(HttpStatus.BAD_REQUEST, "File cannot be empty."); + } + + var fileUrl = assetService.uploadFile(AssetPrefix.UPLOADS, file); + + return ResponseEntity.ok() + .header("Location", fileUrl) + .body(new FileUploadResponse( + file.getOriginalFilename(), + file.getContentType(), + file.getSize(), + fileUrl + )); + } catch (Exception e) { + log.error("File upload failed: {}", e.getMessage(), e); + throw new BizException(HttpStatus.INTERNAL_SERVER_ERROR, + "Failed upload file: " + e.getMessage()); + } + } + + /** + * Delete an asset by asset ID. + * + * @param assetId asset ID + */ + @DeleteMapping("/{id:\\d+}") + public void deleteFile( + @PathVariable("id") Long assetId + ) { + assetService.deleteAsset(assetId); + } +} diff --git a/src/main/java/com/onixbyte/helix/controller/AuthController.java b/src/main/java/com/onixbyte/helix/controller/AuthController.java new file mode 100644 index 0000000..dbb49b6 --- /dev/null +++ b/src/main/java/com/onixbyte/helix/controller/AuthController.java @@ -0,0 +1,39 @@ +package com.onixbyte.helix.controller; + +import com.onixbyte.helix.domain.web.request.UsernamePasswordLoginRequest; +import com.onixbyte.helix.domain.web.response.LoginSuccessResponse; +import com.onixbyte.helix.service.AuthService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/auth") +public class AuthController { + + private static final Logger log = LoggerFactory.getLogger(AuthController.class); + private final AuthService authService; + + public AuthController(AuthService authService) { + this.authService = authService; + } + + /** + * Perform login with username and password. + * + * @param request login request + * @return detailed user info and authentication token + */ + @PostMapping("/login") + public LoginSuccessResponse loginWithUsernameAndPassword( + @Validated @RequestBody UsernamePasswordLoginRequest request + ) { + return authService.login(request); + } + + @GetMapping("/register-enabled") + public boolean getRegisterEnabled() { + return authService.getRegisterEnabled(); + } +} diff --git a/src/main/java/com/onixbyte/helix/controller/CaptchaController.java b/src/main/java/com/onixbyte/helix/controller/CaptchaController.java new file mode 100644 index 0000000..8afd721 --- /dev/null +++ b/src/main/java/com/onixbyte/helix/controller/CaptchaController.java @@ -0,0 +1,29 @@ +package com.onixbyte.helix.controller; + +import com.onixbyte.helix.domain.web.response.CaptchaResponse; +import com.onixbyte.helix.service.CaptchaService; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Optional; + +@RestController +@RequestMapping("/captcha") +public class CaptchaController { + + private final CaptchaService captchaService; + + public CaptchaController(CaptchaService captchaService) { + this.captchaService = captchaService; + } + + @GetMapping + public ResponseEntity getCaptcha() { + var captchaTuple = captchaService.buildCaptcha(); + return Optional.ofNullable(captchaTuple) + .map(ResponseEntity::ok) + .orElseGet(() -> ResponseEntity.noContent().build()); + } +} diff --git a/src/main/java/com/onixbyte/helix/controller/DepartmentController.java b/src/main/java/com/onixbyte/helix/controller/DepartmentController.java new file mode 100644 index 0000000..09565cd --- /dev/null +++ b/src/main/java/com/onixbyte/helix/controller/DepartmentController.java @@ -0,0 +1,31 @@ +package com.onixbyte.helix.controller; + +import com.onixbyte.helix.domain.entity.Department; +import com.onixbyte.helix.domain.model.TreeNode; +import com.onixbyte.helix.service.DepartmentService; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RestController +@RequestMapping("/departments") +public class DepartmentController { + + private final DepartmentService departmentService; + + public DepartmentController(DepartmentService departmentService) { + this.departmentService = departmentService; + } + + @GetMapping("/tree") + public TreeNode getDepartmentTree() { + return departmentService.getDepartmentTree(); + } + + @GetMapping + public List getDepartments() { + return departmentService.getDepartments(); + } +} diff --git a/src/main/java/com/onixbyte/helix/controller/ExceptionController.java b/src/main/java/com/onixbyte/helix/controller/ExceptionController.java new file mode 100644 index 0000000..e47df94 --- /dev/null +++ b/src/main/java/com/onixbyte/helix/controller/ExceptionController.java @@ -0,0 +1,97 @@ +package com.onixbyte.helix.controller; + +import com.onixbyte.helix.domain.web.response.BizExceptionResponse; +import com.onixbyte.helix.exception.BizException; +import jakarta.validation.ConstraintViolationException; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.time.LocalDateTime; +import java.util.stream.Collectors; + +/** + * Global exception handler for the Helix application. + *

+ * This controller advice provides centralised exception handling across all controllers in + * the application. It intercepts exceptions thrown during request processing and converts them into + * appropriate HTTP responses with standardised error formats. + *

+ * The controller handles various types of exceptions including: + *

    + *
  • Business logic exceptions ({@link BizException})
  • + *
  • Bean validation constraint violations ({@link ConstraintViolationException})
  • + *
+ *

+ * All error responses are formatted consistently using {@link BizExceptionResponse} to provide a + * uniform API error structure for client applications. + * + * @author zihluwang + * @see BizException + * @see BizExceptionResponse + * @see RestControllerAdvice + * @since 1.0.0 + */ +@RestControllerAdvice +public class ExceptionController { + + + /** + * Handles business logic exceptions thrown throughout the application. + *

+ * This method intercepts {@link BizException} instances and converts them into appropriate HTTP + * responses. The HTTP status code is determined by the exception's status property, whilst the + * error message is extracted from the exception and included in the response body. + *

+ * The response includes a timestamp indicating when the error occurred and the specific error + * message describing the business logic violation. + * + * @param ex the business exception that was thrown + * @return a {@link ResponseEntity} containing the error response with appropriate HTTP status + * and {@link BizExceptionResponse} body + * @see BizException + * @see BizExceptionResponse + */ + @ExceptionHandler(BizException.class) + public ResponseEntity handleBizException(BizException ex) { + return ResponseEntity.status(ex.getStatus()) + .body(new BizExceptionResponse( + LocalDateTime.now(), + ex.getMessage()) + ); + } + + /** + * Handles bean validation constraint violation exceptions. + *

+ * This method processes {@link ConstraintViolationException} instances that occur when bean + * validation constraints are violated during request processing. It extracts all constraint + * violations, formats them into a readable error message, and returns a standardised + * error response. + *

+ * The error message includes the property path and violation message for each constraint that + * was violated, separated by commas for multiple violations. The response is automatically + * assigned a {@code 400 Bad Request} status. + * + * @param ex the constraint violation exception containing validation errors + * @return a {@link BizExceptionResponse} containing the formatted validation + * error messages and timestamp + * @see ConstraintViolationException + * @see BizExceptionResponse + * @see jakarta.validation.constraints + */ + @ExceptionHandler(ConstraintViolationException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public BizExceptionResponse handleConstraintViolation(ConstraintViolationException ex) { + var errorMessage = ex.getConstraintViolations().stream() + .map((violation) -> violation.getPropertyPath() + ": " + violation.getMessage()) + .collect(Collectors.joining(", ")); + + return new BizExceptionResponse( + LocalDateTime.now(), + errorMessage + ); + } +} diff --git a/src/main/java/com/onixbyte/helix/controller/MenuController.java b/src/main/java/com/onixbyte/helix/controller/MenuController.java new file mode 100644 index 0000000..e7b218f --- /dev/null +++ b/src/main/java/com/onixbyte/helix/controller/MenuController.java @@ -0,0 +1,26 @@ +package com.onixbyte.helix.controller; + +import com.onixbyte.helix.domain.entity.Menu; +import com.onixbyte.helix.domain.model.TreeNode; +import com.onixbyte.helix.service.MenuService; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RestController +@RequestMapping("/menus") +public class MenuController { + + private final MenuService menuService; + + public MenuController(MenuService menuService) { + this.menuService = menuService; + } + + @GetMapping + public List> getMenuTree() { + return menuService.getMenuTree(); + } +} diff --git a/src/main/java/com/onixbyte/helix/controller/PositionController.java b/src/main/java/com/onixbyte/helix/controller/PositionController.java new file mode 100644 index 0000000..25d8681 --- /dev/null +++ b/src/main/java/com/onixbyte/helix/controller/PositionController.java @@ -0,0 +1,31 @@ +package com.onixbyte.helix.controller; + +import com.onixbyte.helix.domain.entity.Position; +import com.onixbyte.helix.service.PositionService; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/positions") +public class PositionController { + + private final PositionService positionService; + + public PositionController(PositionService positionService) { + this.positionService = positionService; + } + + @GetMapping + public Page getPositions( + @RequestParam(required = false, defaultValue = "1") Integer pageNum, + @RequestParam(required = false, defaultValue = "10") Integer pageSize + ) { + var pageRequest = PageRequest.of(pageNum - 1, pageSize, Sort.by(Sort.Order.asc("id"))); + return positionService.getPositions(pageRequest); + } +} diff --git a/src/main/java/com/onixbyte/helix/controller/RoleController.java b/src/main/java/com/onixbyte/helix/controller/RoleController.java new file mode 100644 index 0000000..8f25535 --- /dev/null +++ b/src/main/java/com/onixbyte/helix/controller/RoleController.java @@ -0,0 +1,31 @@ +package com.onixbyte.helix.controller; + +import com.onixbyte.helix.domain.entity.Role; +import com.onixbyte.helix.domain.web.request.QueryRoleRequest; +import com.onixbyte.helix.service.RoleService; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/roles") +public class RoleController { + + private final RoleService roleService; + + public RoleController(RoleService roleService) { + this.roleService = roleService; + } + + @GetMapping + public Page getRoles( + @RequestParam(required = false, defaultValue = "1") Integer pageNum, + @RequestParam(required = false, defaultValue = "10") Integer pageSize, + @Validated @ModelAttribute QueryRoleRequest request + ) { + var pageRequest = PageRequest.of(pageNum - 1, pageSize, Sort.by(Sort.Order.asc("id"))); + return roleService.getRoles(pageRequest, request); + } +} diff --git a/src/main/java/com/onixbyte/helix/controller/SettingController.java b/src/main/java/com/onixbyte/helix/controller/SettingController.java new file mode 100644 index 0000000..8adb7d3 --- /dev/null +++ b/src/main/java/com/onixbyte/helix/controller/SettingController.java @@ -0,0 +1,14 @@ +package com.onixbyte.helix.controller; + +import com.onixbyte.helix.domain.entity.Setting; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping +public class SettingController { + + +} diff --git a/src/main/java/com/onixbyte/helix/controller/UserController.java b/src/main/java/com/onixbyte/helix/controller/UserController.java new file mode 100644 index 0000000..30d9f8e --- /dev/null +++ b/src/main/java/com/onixbyte/helix/controller/UserController.java @@ -0,0 +1,84 @@ +package com.onixbyte.helix.controller; + +import com.onixbyte.helix.domain.entity.User; +import com.onixbyte.helix.domain.web.request.AddUserRequest; +import com.onixbyte.helix.domain.web.request.QueryUserRequest; +import com.onixbyte.helix.domain.web.request.ResetPasswordRequest; +import com.onixbyte.helix.domain.web.request.UpdateUserRequest; +import com.onixbyte.helix.domain.web.response.UserDetailResponse; +import com.onixbyte.helix.service.UserService; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/users") +public class UserController { + + private final UserService userService; + + public UserController(UserService userService) { + this.userService = userService; + } + + /** + * Get user list. + * + * @param pageNum page number + * @param pageSize page size + * @return paginated user list + */ + @PreAuthorize("hasAnyAuthority('system:user:read')") + @GetMapping + public Page queryUsers( + @RequestParam(required = false, defaultValue = "1") Integer pageNum, + @RequestParam(required = false, defaultValue = "10") Integer pageSize, + @Validated @ModelAttribute QueryUserRequest request + ) { + var pageRequest = PageRequest.of(pageNum - 1, pageSize, Sort.by(Sort.Order.asc("id"))); + return userService.queryUserDetailsPage(pageRequest, request); + } + + /** + * Get user by user ID. + * + * @param userId user ID + * @return paginated user list + */ + @PreAuthorize("hasAnyAuthority('system:user:read')") + @GetMapping("/{userId:\\d+}") + public UserDetailResponse getUserDetailByUserId(@PathVariable Long userId) { + return userService.getUserDetailByUserId(userId); + } + + @PostMapping + @PreAuthorize("hasAnyAuthority('system:user:write')") + public ResponseEntity addUser(@Validated @RequestBody AddUserRequest request) { + userService.addUser(request); + return ResponseEntity.ok(null); + } + + @PutMapping + public ResponseEntity editUser(@Validated @RequestBody UpdateUserRequest request) { + userService.updateUser(request); + return ResponseEntity.ok(null); + } + + @PreAuthorize("hasAnyAuthority('system:user:reset-password')") + @PatchMapping("/reset-password") + public ResponseEntity resetPassword(@Validated @RequestBody ResetPasswordRequest request) { + userService.resetPassword(request); + return ResponseEntity.ok(null); + } + + @PreAuthorize("hasAnyAuthority('system:user:write')") + @DeleteMapping("/{userId:\\d+}") + public ResponseEntity deleteUser(@PathVariable Long userId) { + userService.deleteUser(userId); + return ResponseEntity.ok(null); + } +} diff --git a/src/main/java/com/onixbyte/helix/domain/common/Treeable.java b/src/main/java/com/onixbyte/helix/domain/common/Treeable.java new file mode 100644 index 0000000..f15a9d9 --- /dev/null +++ b/src/main/java/com/onixbyte/helix/domain/common/Treeable.java @@ -0,0 +1,34 @@ +package com.onixbyte.helix.domain.common; + +/** + * Represents an element that can be part of a tree structure. + *

+ * This interface provides the basic methods necessary for an object to be identified, + * linked to a parent, and sorted within its siblings in a tree-like hierarchy. + * + * @param the type of the key used for identification + */ +public interface Treeable { + /** + * Retrieves the sort order value for this element amongst its siblings. + * + * @return the integer sort value + */ + Integer getSort(); + + /** + * Retrieves the unique identifier for this element. + * + * @return the element's unique key + */ + K getId(); + + /** + * Retrieves the identifier of this element's parent. + *

+ * If the element is a root element, this method should return {@code null}. + * + * @return the key of the parent element, or {@code null} if it is a root + */ + K getParentId(); +} diff --git a/src/main/java/com/onixbyte/helix/domain/database/query/wrapper/QueryRoleWrapper.java b/src/main/java/com/onixbyte/helix/domain/database/query/wrapper/QueryRoleWrapper.java new file mode 100644 index 0000000..29a2a8d --- /dev/null +++ b/src/main/java/com/onixbyte/helix/domain/database/query/wrapper/QueryRoleWrapper.java @@ -0,0 +1,44 @@ +package com.onixbyte.helix.domain.database.query.wrapper; + +import com.onixbyte.helix.constant.Status; + +public class QueryRoleWrapper { + private String name; + private String code; + private Status status; + + public QueryRoleWrapper() { + } + + public QueryRoleWrapper(String name, String code, Status status) { + this.name = name; + this.code = code; + this.status = status; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getCode() { + return code; + } + + public void setCode(String code) { + this.code = code; + } + + public Status getStatus() { + return status; + } + + public void setStatus(Status status) { + this.status = status; + } + + +} diff --git a/src/main/java/com/onixbyte/helix/domain/database/query/wrapper/QueryUserWrapper.java b/src/main/java/com/onixbyte/helix/domain/database/query/wrapper/QueryUserWrapper.java new file mode 100644 index 0000000..1ee80ae --- /dev/null +++ b/src/main/java/com/onixbyte/helix/domain/database/query/wrapper/QueryUserWrapper.java @@ -0,0 +1,84 @@ +package com.onixbyte.helix.domain.database.query.wrapper; + +import com.onixbyte.helix.constant.UserStatus; + +import java.time.LocalDateTime; + +public class QueryUserWrapper { + private Long departmentId; + private String username; + private String regionAbbreviation; + private String phoneNumber; + private UserStatus status; + private LocalDateTime createdAtStart; + private LocalDateTime createdAtEnd; + + public QueryUserWrapper() { + } + + public QueryUserWrapper(Long departmentId, String username, String regionAbbreviation, String phoneNumber, UserStatus status, LocalDateTime createdAtStart, LocalDateTime createdAtEnd) { + this.departmentId = departmentId; + this.username = username; + this.regionAbbreviation = regionAbbreviation; + this.phoneNumber = phoneNumber; + this.status = status; + this.createdAtStart = createdAtStart; + this.createdAtEnd = createdAtEnd; + } + + public Long getDepartmentId() { + return departmentId; + } + + public void setDepartmentId(Long departmentId) { + this.departmentId = departmentId; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getRegionAbbreviation() { + return regionAbbreviation; + } + + public void setRegionAbbreviation(String regionAbbreviation) { + this.regionAbbreviation = regionAbbreviation; + } + + public String getPhoneNumber() { + return phoneNumber; + } + + public void setPhoneNumber(String phoneNumber) { + this.phoneNumber = phoneNumber; + } + + public UserStatus getStatus() { + return status; + } + + public void setStatus(UserStatus status) { + this.status = status; + } + + public LocalDateTime getCreatedAtStart() { + return createdAtStart; + } + + public void setCreatedAtStart(LocalDateTime createdAtStart) { + this.createdAtStart = createdAtStart; + } + + public LocalDateTime getCreatedAtEnd() { + return createdAtEnd; + } + + public void setCreatedAtEnd(LocalDateTime createdAtEnd) { + this.createdAtEnd = createdAtEnd; + } +} diff --git a/src/main/java/com/onixbyte/helix/domain/entity/Asset.java b/src/main/java/com/onixbyte/helix/domain/entity/Asset.java new file mode 100644 index 0000000..c133dc5 --- /dev/null +++ b/src/main/java/com/onixbyte/helix/domain/entity/Asset.java @@ -0,0 +1,126 @@ +package com.onixbyte.helix.domain.entity; + +import jakarta.persistence.*; + +import java.time.LocalDateTime; +import java.util.Objects; + +@Entity +@Table(name = "assets") +public class Asset { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(unique = true, nullable = false) + private String key; + + @Column(nullable = false) + private Long uploadBy; + + @Column(nullable = false) + private LocalDateTime uploadTime; + + public Asset() { + } + + public Asset(Long id, String key, Long uploadBy, LocalDateTime uploadTime) { + this.id = id; + this.key = key; + this.uploadBy = uploadBy; + this.uploadTime = uploadTime; + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getKey() { + return key; + } + + public void setKey(String key) { + this.key = key; + } + + public Long getUploadBy() { + return uploadBy; + } + + public void setUploadBy(Long uploadBy) { + this.uploadBy = uploadBy; + } + + public LocalDateTime getUploadTime() { + return uploadTime; + } + + public void setUploadTime(LocalDateTime uploadTime) { + this.uploadTime = uploadTime; + } + + @Override + public boolean equals(Object o) { + if (o == null || getClass() != o.getClass()) return false; + Asset that = (Asset) o; + return Objects.equals(id, that.id) && Objects.equals(key, that.key) && Objects.equals(uploadBy, that.uploadBy) && Objects.equals(uploadTime, that.uploadTime); + } + + @Override + public int hashCode() { + return Objects.hash(id, key, uploadBy, uploadTime); + } + + @Override + public String toString() { + return "Attachment{" + + "id=" + id + + ", key='" + key + '\'' + + ", uploadBy=" + uploadBy + + ", uploadTime=" + uploadTime + + '}'; + } + + public static AssetBuilder builder() { + return new AssetBuilder(); + } + + public static class AssetBuilder { + private Long id; + private String key; + private Long uploadBy; + private LocalDateTime uploadTime; + + private AssetBuilder() { + } + + public AssetBuilder id(Long id) { + this.id = id; + return this; + } + + public AssetBuilder key(String key) { + this.key = key; + return this; + } + + public AssetBuilder uploadBy(Long uploadBy) { + this.uploadBy = uploadBy; + return this; + } + + public AssetBuilder uploadTime(LocalDateTime uploadTime) { + this.uploadTime = uploadTime; + return this; + } + + public Asset build() { + return new Asset(id, key, uploadBy, uploadTime); + } + } +} diff --git a/src/main/java/com/onixbyte/helix/domain/entity/Authority.java b/src/main/java/com/onixbyte/helix/domain/entity/Authority.java new file mode 100644 index 0000000..1851591 --- /dev/null +++ b/src/main/java/com/onixbyte/helix/domain/entity/Authority.java @@ -0,0 +1,265 @@ +package com.onixbyte.helix.domain.entity; + +import com.onixbyte.helix.constant.Status; +import jakarta.persistence.*; +import org.hibernate.annotations.JdbcType; +import org.hibernate.annotations.Type; +import org.hibernate.dialect.PostgreSQLEnumJdbcType; +import org.springframework.security.core.GrantedAuthority; +import java.time.LocalDateTime; +import java.util.Objects; + +/** + * Represents an authority (permission) entity within the access control system. + *

+ * Authorities define specific permissions or capabilities that can be granted to roles. They + * represent the finest level of access control granularity, allowing for precise + * permission management. Authorities are typically associated with specific actions or resources + * within the application. + * + * @author zihluwang + * @version 1.0 + * @since 1.0 + */ +@Entity +@Table(name = "authorities") +public class Authority { + + /** + * The unique identifier for the authority. + *

+ * This serves as the primary key in the database and is used for all internal references to the + * authority entity. + */ + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + /** + * The unique code identifier for the authority. + *

+ * This field contains a system-level identifier that uniquely identifies the permission. It is + * typically used in code for permission checks and should follow a consistent naming convention + * (e.g., "USER_READ", "ADMIN_WRITE"). + */ + @Column(unique = true, nullable = false, length = 128) + private String code; + + /** + * The human-readable name of the authority. + *

+ * This field contains the display name of the authority as it should appear in user interfaces + * and administrative panels for permission management. + */ + @Column(nullable = false, length = 128) + private String name; + + /** + * A detailed description of what this authority grants. + *

+ * This field provides additional context about what specific permissions or capabilities this + * authority represents, helping administrators understand the implications of granting + * this authority. + */ + @Column + private String description; + + /** + * The current status of the authority. + *

+ * This field determines whether the authority is active, inactive, or in any other state as + * defined by the {@link Status} enumeration. + */ + @Column(nullable = false) + @Enumerated + @JdbcType(PostgreSQLEnumJdbcType.class) + private Status status; + + /** + * The timestamp when this authority record was created. + *

+ * This field is automatically set when the authority entity is first persisted and provides + * audit information about when the authority was established. + */ + @Column + private LocalDateTime createdAt; + + /** + * The timestamp when this authority record was last updated. + *

+ * This field is automatically updated whenever any changes are made to the authority entity and + * provides audit information about the most recent modification. + */ + @Column + 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 Status getStatus() { + return status; + } + + public void setStatus(Status 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; + } + + public Authority() { + } + + public Authority(Long id, String code, String name, String description, Status status, LocalDateTime createdAt, LocalDateTime updatedAt) { + this.id = id; + this.code = code; + this.name = name; + this.description = description; + this.status = status; + this.createdAt = createdAt; + this.updatedAt = updatedAt; + } + + @Override + public boolean equals(Object o) { + if (o == null || getClass() != o.getClass()) return false; + Authority authority = (Authority) o; + return Objects.equals(id, authority.id) && Objects.equals(code, authority.code) && Objects.equals(name, authority.name) && Objects.equals(description, authority.description) && status == authority.status && Objects.equals(createdAt, authority.createdAt) && Objects.equals(updatedAt, authority.updatedAt); + } + + @Override + public int hashCode() { + return Objects.hash(id, code, name, description, status, createdAt, updatedAt); + } + + @Override + public String toString() { + return "Authority{" + + "id=" + id + + ", code='" + code + '\'' + + ", name='" + name + '\'' + + ", description='" + description + '\'' + + ", status=" + status + + ", createdAt=" + createdAt + + ", updatedAt=" + updatedAt + + '}'; + } + + /** + * Creates a new Builder instance for constructing Authority objects. + * + * @return a new AuthorityBuilder instance + */ + public static AuthorityBuilder builder() { + return new AuthorityBuilder(); + } + + /** + * Builder class for constructing Authority instances with a fluent API. + *

+ * This builder provides a convenient way to construct Authority objects with optional parameters, + * following the Builder pattern for improved readability and maintainability. + */ + public static class AuthorityBuilder { + private Long id; + private String code; + private String name; + private String description; + private Status status; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + + private AuthorityBuilder() { + } + + public AuthorityBuilder id(Long id) { + this.id = id; + return this; + } + + public AuthorityBuilder code(String code) { + this.code = code; + return this; + } + + public AuthorityBuilder name(String name) { + this.name = name; + return this; + } + + public AuthorityBuilder description(String description) { + this.description = description; + return this; + } + + public AuthorityBuilder status(Status status) { + this.status = status; + return this; + } + + public AuthorityBuilder createdAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + return this; + } + + public AuthorityBuilder updatedAt(LocalDateTime updatedAt) { + this.updatedAt = updatedAt; + return this; + } + + /** + * Builds and returns a new Authority instance with the configured properties. + * + * @return a new Authority instance + */ + public Authority build() { + return new Authority(id, code, name, description, status, createdAt, updatedAt); + } + } + + public GrantedAuthority asGrantedAuthority() { + return this::getCode; + } +} diff --git a/src/main/java/com/onixbyte/helix/domain/entity/Department.java b/src/main/java/com/onixbyte/helix/domain/entity/Department.java new file mode 100644 index 0000000..d4f0d40 --- /dev/null +++ b/src/main/java/com/onixbyte/helix/domain/entity/Department.java @@ -0,0 +1,290 @@ +package com.onixbyte.helix.domain.entity; + +import com.onixbyte.helix.constant.Status; +import com.onixbyte.helix.domain.common.Treeable; +import jakarta.persistence.*; +import org.hibernate.annotations.JdbcType; +import org.hibernate.dialect.PostgreSQLEnumJdbcType; + +import java.time.LocalDateTime; +import java.util.Objects; + + +/** + * Represents a department entity within the organisational hierarchy. + *

+ * This entity models departments as hierarchical structures where each department can have a + * parent department, creating a tree-like organisational structure. Departments are used to group + * users and define organisational boundaries within the Helix system. + *

+ * + * @author zihluwang + * @version 1.0 + * @since 1.0 + */ +@Entity +@Table(name = "departments") +public class Department implements Treeable { + + /** + * The unique identifier for the department. + *

+ * This serves as the primary key in the database and is used for all + * internal references to the department entity. + *

+ */ + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + /** + * The name of the department. + *

+ * This field contains the human-readable name of the department as it + * should appear in the organisational chart and user interfaces. + *

+ */ + @Column(length = 128, nullable = false) + private String name; + + /** + * The identifier of the parent department. + *

+ * This field establishes the hierarchical relationship between departments. + * A null value indicates that this is a root-level department with no parent. + *

+ */ + @Column(nullable = false) + private Long parentId; + + /** + * The sort order for displaying departments. + *

+ * This field determines the order in which departments should be displayed + * when listed alongside their siblings in the hierarchy. Lower values + * indicate higher priority in sorting. + *

+ */ + @Column(nullable = false) + private Integer sort; + + /** + * The current status of the department. + *

+ * This field determines whether the department is active, inactive, or in any + * other state as defined by the {@link Status} enumeration. + *

+ */ + @Column(nullable = false) + @Enumerated + @JdbcType(PostgreSQLEnumJdbcType.class) + private Status status; + + /** + * The timestamp when this department record was created. + *

+ * This field is automatically set when the department entity is first persisted + * and provides audit information about when the department was established. + *

+ */ + @Column + private LocalDateTime createdAt; + + /** + * The timestamp when this department record was last updated. + *

+ * This field is automatically updated whenever any changes are made to the + * department entity and provides audit information about the most recent modification. + *

+ */ + @Column + private LocalDateTime updatedAt; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public Long getParentId() { + return parentId; + } + + public void setParentId(Long parentId) { + this.parentId = parentId; + } + + public Integer getSort() { + return sort; + } + + public void setSort(Integer sort) { + this.sort = sort; + } + + public Status getStatus() { + return status; + } + + public void setStatus(Status 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; + } + + /** + * Default constructor for Department. + *

+ * Creates a new Department instance with all fields initialised to their default values. + * This constructor is typically used by JPA and other frameworks for entity instantiation. + *

+ */ + public Department() { + } + + /** + * Constructs a new Department with all specified parameters. + *

+ * This constructor allows for the creation of a fully initialised Department entity + * with all field values provided at instantiation time. + *

+ * + * @param id the unique identifier for the department + * @param name the name of the department + * @param parentId the identifier of the parent department (null for root departments) + * @param sort the sort order for display purposes + * @param status the current status of the department + * @param createdAt the timestamp when the department was created + * @param updatedAt the timestamp when the department was last updated + */ + public Department(Long id, String name, Long parentId, Integer sort, Status status, LocalDateTime createdAt, LocalDateTime updatedAt) { + this.id = id; + this.name = name; + this.parentId = parentId; + this.sort = sort; + this.status = status; + this.createdAt = createdAt; + this.updatedAt = updatedAt; + } + + @Override + public boolean equals(Object o) { + if (o == null || getClass() != o.getClass()) return false; + Department that = (Department) o; + return Objects.equals(id, that.id) && Objects.equals(name, that.name) && Objects.equals(parentId, that.parentId) && Objects.equals(sort, that.sort) && status == that.status && Objects.equals(createdAt, that.createdAt) && Objects.equals(updatedAt, that.updatedAt); + } + + @Override + public int hashCode() { + return Objects.hash(id, name, parentId, sort, status, createdAt, updatedAt); + } + + @Override + public String toString() { + return "Department{" + + "id=" + id + + ", name='" + name + '\'' + + ", parentId=" + parentId + + ", sort=" + sort + + ", status=" + status + + ", createdAt=" + createdAt + + ", updatedAt=" + updatedAt + + '}'; + } + + /** + * Creates a new Builder instance for constructing Department objects. + * + * @return a new DepartmentBuilder instance + */ + public static DepartmentBuilder builder() { + return new DepartmentBuilder(); + } + + /** + * Builder class for constructing Department instances with a fluent API. + *

+ * This builder provides a convenient way to construct Department objects with optional parameters, + * following the Builder pattern for improved readability and maintainability. + */ + public static class DepartmentBuilder { + private Long id; + private String name; + private Long parentId; + private Integer sort; + private Status status; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + + private DepartmentBuilder() { + } + + public DepartmentBuilder id(Long id) { + this.id = id; + return this; + } + + public DepartmentBuilder name(String name) { + this.name = name; + return this; + } + + public DepartmentBuilder parentId(Long parentId) { + this.parentId = parentId; + return this; + } + + public DepartmentBuilder sort(Integer sort) { + this.sort = sort; + return this; + } + + public DepartmentBuilder status(Status status) { + this.status = status; + return this; + } + + public DepartmentBuilder createdAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + return this; + } + + public DepartmentBuilder updatedAt(LocalDateTime updatedAt) { + this.updatedAt = updatedAt; + return this; + } + + /** + * Builds and returns a new Department instance with the configured properties. + * + * @return a new Department instance + */ + public Department build() { + return new Department(id, name, parentId, sort, status, createdAt, updatedAt); + } + } +} diff --git a/src/main/java/com/onixbyte/helix/domain/entity/Menu.java b/src/main/java/com/onixbyte/helix/domain/entity/Menu.java new file mode 100644 index 0000000..c45dd3b --- /dev/null +++ b/src/main/java/com/onixbyte/helix/domain/entity/Menu.java @@ -0,0 +1,227 @@ +package com.onixbyte.helix.domain.entity; + +import com.onixbyte.helix.constant.Status; +import com.onixbyte.helix.domain.common.Treeable; +import jakarta.persistence.*; +import org.hibernate.annotations.JdbcType; +import org.hibernate.dialect.PostgreSQLEnumJdbcType; + +import java.time.LocalDateTime; +import java.util.Objects; + +@Entity +@Table(name = "menus") +public class Menu implements Treeable { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, length = 50) + private String name; + + @Column + private Long parentId; + + @Column(nullable = false) + private String code; + + @Column(nullable = false) + private Integer sort; + + @Column + private String path; + + @Column + private Boolean isExternalLink; + + @Column + private Boolean isVisible; + + @Column(nullable = false) + @Enumerated + @JdbcType(PostgreSQLEnumJdbcType.class) + private Status status; + + @Column(length = 128) + private String authorityCode; + + @Column(length = 128) + private String icon; + + @Column + private LocalDateTime createdAt; + + @Column + private LocalDateTime updatedAt; + + public Menu() { + } + + public Menu(Long id, String name, Long parentId, String code, Integer sort, String path, Boolean isExternalLink, Boolean isVisible, Status status, String authorityCode, String icon, LocalDateTime createdAt, LocalDateTime updatedAt) { + this.id = id; + this.name = name; + this.parentId = parentId; + this.code = code; + this.sort = sort; + this.path = path; + this.isExternalLink = isExternalLink; + this.isVisible = isVisible; + this.status = status; + this.authorityCode = authorityCode; + this.icon = icon; + this.createdAt = createdAt; + this.updatedAt = updatedAt; + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public Long getParentId() { + return parentId; + } + + public void setParentId(Long parentId) { + this.parentId = parentId; + } + + public String getCode() { + return code; + } + + public void setCode(String code) { + this.code = code; + } + + public Boolean getExternalLink() { + return isExternalLink; + } + + public void setExternalLink(Boolean externalLink) { + isExternalLink = externalLink; + } + + public Boolean getVisible() { + return isVisible; + } + + public void setVisible(Boolean visible) { + isVisible = visible; + } + + public Integer getSort() { + return sort; + } + + public void setSort(Integer sort) { + this.sort = sort; + } + + public String getPath() { + return path; + } + + public void setPath(String path) { + this.path = path; + } + + public Boolean getIsExternalLink() { + return isExternalLink; + } + + public void setIsExternalLink(Boolean isExternalLink) { + this.isExternalLink = isExternalLink; + } + + public Boolean getIsVisible() { + return isVisible; + } + + public void setIsVisible(Boolean isVisible) { + this.isVisible = isVisible; + } + + public Status getStatus() { + return status; + } + + public void setStatus(Status status) { + this.status = status; + } + + public String getAuthorityCode() { + return authorityCode; + } + + public void setAuthorityCode(String authorityCode) { + this.authorityCode = authorityCode; + } + + public String getIcon() { + return icon; + } + + public void setIcon(String icon) { + this.icon = icon; + } + + 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; + } + + @Override + public boolean equals(Object o) { + if (o == null || getClass() != o.getClass()) return false; + Menu menu = (Menu) o; + return Objects.equals(id, menu.id) && Objects.equals(name, menu.name) && Objects.equals(parentId, menu.parentId) && Objects.equals(code, menu.code) && Objects.equals(sort, menu.sort) && Objects.equals(path, menu.path) && Objects.equals(isExternalLink, menu.isExternalLink) && Objects.equals(isVisible, menu.isVisible) && status == menu.status && Objects.equals(authorityCode, menu.authorityCode) && Objects.equals(icon, menu.icon) && Objects.equals(createdAt, menu.createdAt) && Objects.equals(updatedAt, menu.updatedAt); + } + + @Override + public int hashCode() { + return Objects.hash(id, name, parentId, code, sort, path, isExternalLink, isVisible, status, authorityCode, icon, createdAt, updatedAt); + } + + @Override + public String toString() { + return "Menu{" + + "id=" + id + + ", name='" + name + '\'' + + ", parentId=" + parentId + + ", code=" + code + + ", sort=" + sort + + ", path=" + path + + ", isExternalLink=" + isExternalLink + + ", isVisible=" + isVisible + + ", status=" + status + + ", authorityCode='" + authorityCode + '\'' + + ", icon='" + icon + '\'' + + ", createdAt=" + createdAt + + ", updatedAt=" + updatedAt + + '}'; + } +} diff --git a/src/main/java/com/onixbyte/helix/domain/entity/Position.java b/src/main/java/com/onixbyte/helix/domain/entity/Position.java new file mode 100644 index 0000000..fc0733a --- /dev/null +++ b/src/main/java/com/onixbyte/helix/domain/entity/Position.java @@ -0,0 +1,284 @@ +package com.onixbyte.helix.domain.entity; + +import com.onixbyte.helix.constant.Status; +import jakarta.persistence.*; +import org.hibernate.annotations.JdbcType; +import org.hibernate.dialect.PostgreSQLEnumJdbcType; + +import java.time.LocalDateTime; +import java.util.Objects; + +/** + * Represents a position entity within the organisational structure. + *

+ * Positions define job roles or titles that can be assigned to users within the organisation. They + * provide a way to categorise users based on their responsibilities and functions, complementing + * the department-based organisational hierarchy. Positions can be used for reporting, + * access control, and organisational management purposes. + * + * @author zihluwang + * @version 1.0 + * @since 1.0 + */ +@Entity +@Table(name = "positions") +public class Position { + + /** + * The unique identifier for the position. + *

+ * This serves as the primary key in the database and is used for all internal references to the + * position entity. + */ + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + /** + * The human-readable name of the position. + *

+ * This field contains the job title or position name as it should appear in + * organisational charts, user profiles, and administrative interfaces. + */ + @Column(nullable = false, length = 128, unique = true) + private String name; + + /** + * The unique code identifier for the position. + *

+ * This field contains a system-level identifier that uniquely identifies the position. It is + * typically used for integration purposes and should follow a consistent naming convention. + */ + @Column(nullable = false, length = 64, unique = true) + private String code; + + /** + * A detailed description of the position's responsibilities and requirements. + *

+ * This field provides additional context about the role, including key responsibilities, + * required skills, or other relevant information that helps define what this position entails. + */ + @Column + private String description; + + /** + * The sort order for displaying positions. + *

+ * This field determines the order in which positions should be displayed in lists and + * selection interfaces. Lower values indicate higher priority in sorting, which can reflect + * organisational hierarchy or importance. + */ + @Column(nullable = false) + private Integer sort; + + /** + * The current status of the position. + *

+ * This field determines whether the position is active, inactive, or in any other state as + * defined by the {@link Status} enumeration. + */ + @Column(nullable = false) + @Enumerated + @JdbcType(PostgreSQLEnumJdbcType.class) + private Status status; + + /** + * The timestamp when this position record was created. + *

+ * This field is automatically set when the position entity is first persisted and provides + * audit information about when the position was established. + */ + @Column + private LocalDateTime createdAt; + + /** + * The timestamp when this position record was last updated. + *

+ * This field is automatically updated whenever any changes are made to the position entity and + * provides audit information about the most recent modification. + */ + @Column + private LocalDateTime updatedAt; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getCode() { + return code; + } + + public void setCode(String code) { + this.code = code; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public Integer getSort() { + return sort; + } + + public void setSort(Integer sort) { + this.sort = sort; + } + + public Status getStatus() { + return status; + } + + public void setStatus(Status 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; + } + + public Position() { + } + + public Position(Long id, String name, String code, String description, Integer sort, Status status, LocalDateTime createdAt, LocalDateTime updatedAt) { + this.id = id; + this.name = name; + this.code = code; + this.description = description; + this.sort = sort; + this.status = status; + this.createdAt = createdAt; + this.updatedAt = updatedAt; + } + + @Override + public boolean equals(Object o) { + if (o == null || getClass() != o.getClass()) return false; + Position position = (Position) o; + return Objects.equals(id, position.id) && Objects.equals(name, position.name) && Objects.equals(code, position.code) && Objects.equals(description, position.description) && Objects.equals(sort, position.sort) && status == position.status && Objects.equals(createdAt, position.createdAt) && Objects.equals(updatedAt, position.updatedAt); + } + + @Override + public int hashCode() { + return Objects.hash(id, name, code, description, sort, status, createdAt, updatedAt); + } + + @Override + public String toString() { + return "Position{" + + "id=" + id + + ", name='" + name + '\'' + + ", code='" + code + '\'' + + ", description='" + description + '\'' + + ", sort=" + sort + + ", status=" + status + + ", createdAt=" + createdAt + + ", updatedAt=" + updatedAt + + '}'; + } + + /** + * Creates a new Builder instance for constructing Position objects. + * + * @return a new PositionBuilder instance + */ + public static PositionBuilder builder() { + return new PositionBuilder(); + } + + /** + * Builder class for constructing Position instances with a fluent API. + *

+ * This builder provides a convenient way to construct Position objects with optional parameters, + * following the Builder pattern for improved readability and maintainability. + */ + public static class PositionBuilder { + private Long id; + private String name; + private String code; + private String description; + private Integer sort; + private Status status; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + + private PositionBuilder() { + } + + public PositionBuilder id(Long id) { + this.id = id; + return this; + } + + public PositionBuilder name(String name) { + this.name = name; + return this; + } + + public PositionBuilder code(String code) { + this.code = code; + return this; + } + + public PositionBuilder description(String description) { + this.description = description; + return this; + } + + public PositionBuilder sort(Integer sort) { + this.sort = sort; + return this; + } + + public PositionBuilder status(Status status) { + this.status = status; + return this; + } + + public PositionBuilder createdAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + return this; + } + + public PositionBuilder updatedAt(LocalDateTime updatedAt) { + this.updatedAt = updatedAt; + return this; + } + + /** + * Builds and returns a new Position instance with the configured properties. + * + * @return a new Position instance + */ + public Position build() { + return new Position(id, name, code, description, sort, status, createdAt, updatedAt); + } + } +} diff --git a/src/main/java/com/onixbyte/helix/domain/entity/Role.java b/src/main/java/com/onixbyte/helix/domain/entity/Role.java new file mode 100644 index 0000000..d000fba --- /dev/null +++ b/src/main/java/com/onixbyte/helix/domain/entity/Role.java @@ -0,0 +1,308 @@ +package com.onixbyte.helix.domain.entity; + +import com.onixbyte.helix.constant.Status; +import jakarta.persistence.*; +import org.hibernate.annotations.JdbcType; +import org.hibernate.dialect.PostgreSQLEnumJdbcType; + +import java.time.LocalDateTime; +import java.util.Objects; + +/** + * Represents a role entity within the access control system. + *

+ * Roles define sets of permissions and responsibilities that can be assigned to users. They form + * the foundation of the role-based access control (RBAC) system, allowing for flexible and scalable + * permission management across the Helix application. + * + * @author zihluwang + * @version 1.0 + * @since 1.0 + */ +@Entity +@Table(name = "roles") +public class Role { + + /** + * The unique identifier for the role. + *

+ * This serves as the primary key in the database and is used for all internal references to the + * role entity. + */ + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + /** + * The human-readable name of the role. + *

+ * This field contains the display name of the role as it should appear in user interfaces and + * administrative panels. + */ + @Column(nullable = false, unique = true, length = 128) + private String name; + + /** + * The unique code identifier for the role. + *

+ * This field contains a system-level identifier that is typically used in code + * and configuration. It should be unique across all roles and follow a consistent + * naming convention. + */ + @Column(nullable = false, unique = true, length = 64) + private String code; + + /** + * The sort order for displaying roles. + *

+ * This field determines the order in which roles should be displayed in lists and + * selection interfaces. Lower values indicate higher priority in sorting. + */ + @Column(nullable = false) + private Integer sort; + + /** + * Indicates whether this role is assigned by default to new users. + *

+ * When set to true, this role will be automatically assigned to newly created user accounts. + * This is useful for defining baseline permissions that all users should have. + */ + @Column + private Boolean defaultValue; + + /** + * A detailed description of the role's purpose and permissions. + *

+ * This field provides additional context about what the role represents and what capabilities + * it grants to users who are assigned to it. + */ + @Column + private String description; + + /** + * The current status of the role. + *

+ * This field determines whether the role is active, inactive, or in any other state as defined + * by the {@link Status} enumeration. + */ + @Column(nullable = false) + @Enumerated + @JdbcType(PostgreSQLEnumJdbcType.class) + private Status status; + + /** + * The timestamp when this role record was created. + *

+ * This field is automatically set when the role entity is first persisted and provides audit + * information about when the role was established. + */ + @Column + private LocalDateTime createdAt; + + /** + * The timestamp when this role record was last updated. + *

+ * This field is automatically updated whenever any changes are made to the role entity and + * provides audit information about the most recent modification. + */ + @Column + private LocalDateTime updatedAt; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getCode() { + return code; + } + + public void setCode(String code) { + this.code = code; + } + + public Integer getSort() { + return sort; + } + + public void setSort(Integer sort) { + this.sort = sort; + } + + public Boolean getDefaultValue() { + return defaultValue; + } + + public void setDefaultValue(Boolean defaultValue) { + this.defaultValue = defaultValue; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public Status getStatus() { + return status; + } + + public void setStatus(Status 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; + } + + public Role() { + } + + public Role(Long id, String name, String code, Integer sort, Boolean defaultValue, String description, Status status, LocalDateTime createdAt, LocalDateTime updatedAt) { + this.id = id; + this.name = name; + this.code = code; + this.sort = sort; + this.defaultValue = defaultValue; + this.description = description; + this.status = status; + this.createdAt = createdAt; + this.updatedAt = updatedAt; + } + + @Override + public boolean equals(Object o) { + if (o == null || getClass() != o.getClass()) return false; + Role role = (Role) o; + return Objects.equals(id, role.id) && Objects.equals(name, role.name) && Objects.equals(code, role.code) && Objects.equals(sort, role.sort) && Objects.equals(defaultValue, role.defaultValue) && Objects.equals(description, role.description) && status == role.status && Objects.equals(createdAt, role.createdAt) && Objects.equals(updatedAt, role.updatedAt); + } + + @Override + public int hashCode() { + return Objects.hash(id, name, code, sort, defaultValue, description, status, createdAt, updatedAt); + } + + @Override + public String toString() { + return "Role{" + + "id=" + id + + ", name='" + name + '\'' + + ", code='" + code + '\'' + + ", sort=" + sort + + ", defaultValue=" + defaultValue + + ", description='" + description + '\'' + + ", status=" + status + + ", createdAt=" + createdAt + + ", updatedAt=" + updatedAt + + '}'; + } + + /** + * Creates a new Builder instance for constructing Role objects. + * + * @return a new RoleBuilder instance + */ + public static RoleBuilder builder() { + return new RoleBuilder(); + } + + /** + * Builder class for constructing Role instances with a fluent API. + *

+ * This builder provides a convenient way to construct Role objects with optional parameters, + * following the Builder pattern for improved readability and maintainability. + */ + public static class RoleBuilder { + private Long id; + private String name; + private String code; + private Integer sort; + private Boolean defaultValue; + private String description; + private Status status; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + + private RoleBuilder() { + } + + public RoleBuilder id(Long id) { + this.id = id; + return this; + } + + public RoleBuilder name(String name) { + this.name = name; + return this; + } + + public RoleBuilder code(String code) { + this.code = code; + return this; + } + + public RoleBuilder sort(Integer sort) { + this.sort = sort; + return this; + } + + public RoleBuilder defaultValue(Boolean defaultValue) { + this.defaultValue = defaultValue; + return this; + } + + public RoleBuilder description(String description) { + this.description = description; + return this; + } + + public RoleBuilder status(Status status) { + this.status = status; + return this; + } + + public RoleBuilder createdAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + return this; + } + + public RoleBuilder updatedAt(LocalDateTime updatedAt) { + this.updatedAt = updatedAt; + return this; + } + + /** + * Builds and returns a new Role instance with the configured properties. + * + * @return a new Role instance + */ + public Role build() { + return new Role(id, name, code, sort, defaultValue, description, status, createdAt, updatedAt); + } + } +} diff --git a/src/main/java/com/onixbyte/helix/domain/entity/RoleAuthority.java b/src/main/java/com/onixbyte/helix/domain/entity/RoleAuthority.java new file mode 100644 index 0000000..ff876ea --- /dev/null +++ b/src/main/java/com/onixbyte/helix/domain/entity/RoleAuthority.java @@ -0,0 +1,125 @@ +package com.onixbyte.helix.domain.entity; + +import com.onixbyte.helix.domain.entity.embeddable.RoleAuthorityId; +import jakarta.persistence.*; +import java.time.LocalDateTime; +import java.util.Objects; + +/** + * Represents the association entity between a Role and an Authority in a many-to-many relationship. + *

+ * This entity is mapped to the 'role_authorities' table and uses a composite primary key + * {@code @EmbeddedId} defined in the RoleAuthorityId class. It also includes the 'createdAt' + * auditing field. + */ +@Entity +@Table(name = "role_authorities") +public class RoleAuthority { + + /** + * The composite primary key of the association, mapped to the 'role_id' and 'authority_id' columns. + *

+ * This field embeds the RoleAuthorityId object which holds the foreign keys to the Role and Authority tables. + */ + @EmbeddedId + private RoleAuthorityId id; + + /** + * The timestamp when this role-authority association was created. + *

+ * This field is automatically set upon creation in the database. {@code @Column} maps the field + * to the 'created_at' column. + */ + @Column(nullable = false, updatable = false) + private LocalDateTime createdAt; + + public Long getRoleId() { + return this.id != null ? this.id.getRoleId() : null; + } + + public Long getAuthorityId() { + return this.id != null ? this.id.getAuthorityId() : null; + } + + public RoleAuthorityId getId() { + return id; + } + + public void setId(RoleAuthorityId id) { + this.id = id; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + } + + public RoleAuthority() { + // Initialise the ID for safety when created via JPA + this.id = new RoleAuthorityId(); + } + + public RoleAuthority(Long roleId, Long authorityId, LocalDateTime createdAt) { + this.id = new RoleAuthorityId(roleId, authorityId); + this.createdAt = createdAt; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + RoleAuthority that = (RoleAuthority) o; + // Only check the primary key for entity equality + return Objects.equals(id, that.id); + } + + @Override + public int hashCode() { + // Hash code based on the primary key + return Objects.hash(id); + } + + @Override + public String toString() { + return "RoleAuthority{" + + "roleId=" + (id != null ? id.getRoleId() : "null") + + ", authorityId=" + (id != null ? id.getAuthorityId() : "null") + + ", createdAt=" + createdAt + + '}'; + } + + public static RoleAuthorityBuilder builder() { + return new RoleAuthorityBuilder(); + } + + public static class RoleAuthorityBuilder { + private Long roleId; + private Long authorityId; + private LocalDateTime createdAt; + + private RoleAuthorityBuilder() { + } + + public RoleAuthorityBuilder roleId(Long roleId) { + this.roleId = roleId; + return this; + } + + public RoleAuthorityBuilder authorityId(Long authorityId) { + this.authorityId = authorityId; + return this; + } + + public RoleAuthorityBuilder createdAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + return this; + } + + public RoleAuthority build() { + return new RoleAuthority(roleId, authorityId, createdAt); + } + } +} diff --git a/src/main/java/com/onixbyte/helix/domain/entity/Setting.java b/src/main/java/com/onixbyte/helix/domain/entity/Setting.java new file mode 100644 index 0000000..6bb6ce6 --- /dev/null +++ b/src/main/java/com/onixbyte/helix/domain/entity/Setting.java @@ -0,0 +1,269 @@ +package com.onixbyte.helix.domain.entity; + +import com.onixbyte.helix.constant.SettingType; +import jakarta.persistence.*; +import org.hibernate.annotations.JdbcType; +import org.hibernate.dialect.PostgreSQLEnumJdbcType; + +import java.time.LocalDateTime; +import java.util.Objects; + +/** + * Represents a hot-deployable application setting, stored in the 'settings' database table. + *

+ * This entity allows for dynamic configuration changes without application restarts. + */ +@Entity +@Table(name = "settings") +public class Setting { + + /** + * Setting unique identifier, mapped to the primary key 'id'. + *

+ * Uses BIGSERIAL (Long) and is set to auto-increment by the database. + */ + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + /** + * Setting name. Used as a unique key for retrieval. + */ + @Column(nullable = false, unique = true) + private String name; + + /** + * Setting description. + */ + @Column + private String description; + + /** + * The type of the value (e.g., BOOLEAN, INT, STRING). + *

+ * Mapped to the custom SQL type SETTING_TYPE, typically handled by JPA as an Enum. + */ + @Column(nullable = false) + @Enumerated + @JdbcType(PostgreSQLEnumJdbcType.class) + private SettingType type; + + /** + * Setting current value. Stored as a string regardless of the actual type. + */ + @Column + private String value; + + /** + * Setting default value. + */ + @Column(nullable = false) + private String defaultValue; + + /** + * The timestamp when this setting was created. + *

+ * Set only on creation and remains unchanged. + */ + @Column(nullable = false, updatable = false) + private LocalDateTime createdAt; + + /** + * The timestamp when this setting was last updated. + *

+ * Updated on every change to the entity. + */ + @Column(nullable = false) + private LocalDateTime updatedAt; + + @PrePersist + protected void onCreate() { + this.createdAt = LocalDateTime.now(); + this.updatedAt = LocalDateTime.now(); + } + + @PreUpdate + protected void onUpdate() { + this.updatedAt = LocalDateTime.now(); + } + + public Setting() { + } + + public Setting(Long id, String name, String description, SettingType type, String value, String defaultValue, LocalDateTime createdAt, LocalDateTime updatedAt) { + this.id = id; + this.name = name; + this.description = description; + this.type = type; + this.value = value; + this.defaultValue = defaultValue; + this.createdAt = createdAt; + this.updatedAt = updatedAt; + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + 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 SettingType getType() { + return type; + } + + public void setType(SettingType type) { + this.type = type; + } + + public String getValue() { + return value; + } + + public void setValue(String value) { + this.value = value; + } + + public String getDefaultValue() { + return defaultValue; + } + + public void setDefaultValue(String defaultValue) { + this.defaultValue = defaultValue; + } + + 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; + } + + public String fetchValueOrDefault() { + if (Objects.nonNull(value) && !value.isBlank()) { + return value; + } + return defaultValue; + } + + public Boolean asBoolean() { + var val = fetchValueOrDefault(); + if (type == SettingType.BOOLEAN) { + return Boolean.parseBoolean(val); + } else { + return null; + } + } + + public Integer asInt() { + try { + var val = fetchValueOrDefault(); + if (type == SettingType.INT) { + return Integer.parseInt(val); + } + return null; + } catch (NumberFormatException e) { + return null; + } + } + + public Long asLong() { + try { + var val = fetchValueOrDefault(); + if (type == SettingType.INT) { + return Long.parseLong(val); + } + return null; + } catch (NumberFormatException e) { + return null; + } + } + + public static SettingBuilder builder() { + return new SettingBuilder(); + } + + public static class SettingBuilder { + private Long id; + private String name; + private String description; + private SettingType type; + private String value; + private String defaultValue; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + + private SettingBuilder() { + } + + public SettingBuilder id(Long id) { + this.id = id; + return this; + } + + public SettingBuilder name(String name) { + this.name = name; + return this; + } + + public SettingBuilder description(String description) { + this.description = description; + return this; + } + + public SettingBuilder type(SettingType type) { + this.type = type; + return this; + } + + public SettingBuilder value(String value) { + this.value = value; + return this; + } + + public SettingBuilder defaultValue(String defaultValue) { + this.defaultValue = defaultValue; + return this; + } + + public SettingBuilder createdAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + return this; + } + + public SettingBuilder updatedAt(LocalDateTime updatedAt) { + this.updatedAt = updatedAt; + return this; + } + + public Setting build() { + return new Setting(id, name, description, type, value, defaultValue, createdAt, updatedAt); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/onixbyte/helix/domain/entity/User.java b/src/main/java/com/onixbyte/helix/domain/entity/User.java new file mode 100644 index 0000000..eae7198 --- /dev/null +++ b/src/main/java/com/onixbyte/helix/domain/entity/User.java @@ -0,0 +1,453 @@ +package com.onixbyte.helix.domain.entity; + +import com.onixbyte.helix.constant.UserStatus; +import jakarta.persistence.*; // 导入 Jakarta Persistence API +import org.hibernate.annotations.JdbcType; +import org.hibernate.dialect.PostgreSQLEnumJdbcType; + +import java.time.LocalDateTime; +import java.util.Objects; + +/** + * Represents a user entity in the Helix system. + *

+ * This entity encapsulates all user-related information including authentication credentials, + * personal details, contact information, and organisational associations. Users are the core + * entities that interact with the system and are associated with departments and positions within + * the organisational hierarchy. + * + * @author zihluwang + * @version 1.0 + * @since 1.0 + */ +@Entity +@Table( + name = "users", + uniqueConstraints = { + @UniqueConstraint(name = "uidx_users_username", columnNames = {"username"}), + @UniqueConstraint(name = "uidx_users_email", columnNames = {"email"}), + @UniqueConstraint(name = "uidx_users_region_abbreviation_phone_number", columnNames = {"region_abbreviation", "phone_number"}) + }, + indexes = {@Index(name = "users_username_index", columnList = "username")} +) +public class User { + + /** + * The unique identifier for the user. + *

+ * This serves as the primary key in the database and is used for all internal references to the + * user entity. Since the SQL uses `BIGINT PRIMARY KEY` without `SERIAL`, we assume the ID is assigned manually or by an external service. + */ + @Id + @Column(nullable = false) + private Long id; + + /** + * The unique username for authentication purposes. + */ + @Column(nullable = false, length = 64) + private String username; + + /** + * The encrypted password for user authentication. + */ + @Column + private String password; + + /** + * The user's complete full name. + */ + @Column(nullable = false, length = 128) + private String fullName; + + /** + * The user's email address. + */ + @Column(length = 128) + private String email; + + /** + * The region abbreviation for the user's phone number. + */ + @Column(length = 10) + private String regionAbbreviation; + + /** + * The user's phone number without the country code. + */ + @Column(length = 32) + private String phoneNumber; + + /** + * The URL to the user's avatar image. + */ + @Column(columnDefinition = "TEXT") + private String avatarUrl; + + /** + * The current status of the user account. + *

+ * Mapped to the custom SQL type USER_STATUS. + */ + @Column(nullable = false) + @Enumerated + @JdbcType(PostgreSQLEnumJdbcType.class) + private UserStatus status; + + /** + * The identifier of the department to which this user belongs. + *

+ * {@code @ManyToOne} is the standard way to map a foreign key (department_id) in JPA. + * You might replace this with a direct {@code @ManyToOne} mapping to the Department entity later. + */ + @Column + private Long departmentId; + + /** + * The identifier of the position held by this user. + */ + @Column + private Long positionId; + + /** + * The timestamp when this user record was created. + */ + @Column(nullable = false, updatable = false) + private LocalDateTime createdAt; + + /** + * The timestamp when this user record was last updated. + */ + @Column(nullable = false) + private LocalDateTime updatedAt; + + // --- JPA Lifecycle Callbacks for Auditing --- + + @PrePersist + protected void onCreate() { + if (this.createdAt == null) { + this.createdAt = LocalDateTime.now(); + } + if (this.updatedAt == null) { + this.updatedAt = LocalDateTime.now(); + } + + // Ensure status default is applied if not set + if (this.status == null) { + this.status = UserStatus.ACTIVE; + } + } + + @PreUpdate + protected void onUpdate() { + this.updatedAt = LocalDateTime.now(); + } + + // --- Constructors, Getters, Setters, and Builder (omitted for brevity) --- + + public User() { + } + + public User(Long id, String username, String password, String fullName, String email, String regionAbbreviation, String phoneNumber, String avatarUrl, UserStatus status, Long departmentId, Long positionId, LocalDateTime createdAt, LocalDateTime updatedAt) { + this.id = id; + this.username = username; + this.password = password; + this.fullName = fullName; + this.email = email; + this.regionAbbreviation = regionAbbreviation; + this.phoneNumber = phoneNumber; + this.avatarUrl = avatarUrl; + this.status = status; + this.departmentId = departmentId; + this.positionId = positionId; + this.createdAt = createdAt; + this.updatedAt = updatedAt; + } + + /** + * Gets the unique identifier for this user. + * + * @return the user's unique identifier + */ + public Long getId() { + return id; + } + + /** + * Sets the unique identifier for this user. + * + * @param id the user's unique identifier + */ + public void setId(Long id) { + this.id = id; + } + + /** + * Gets the username for authentication. + * + * @return the username + */ + public String getUsername() { + return username; + } + + /** + * Sets the username for authentication. + * + * @param username the username, must be unique across the system + */ + public void setUsername(String username) { + this.username = username; + } + + /** + * Gets the encrypted password. + * + * @return the encrypted password + */ + public String getPassword() { + return password; + } + + /** + * Sets the encrypted password. + * + * @param password the encrypted password (never plain text) + */ + public void setPassword(String password) { + this.password = password; + } + + public String getFullName() { + return fullName; + } + + public void setFullName(String fullName) { + this.fullName = fullName; + } + + /** + * Gets the user's email address. + * + * @return the email address + */ + public String getEmail() { + return email; + } + + /** + * Sets the user's email address. + * + * @param email the email address, must be unique across the system + */ + public void setEmail(String email) { + this.email = email; + } + + public String getRegionAbbreviation() { + return regionAbbreviation; + } + + public void setRegionAbbreviation(String countryCode) { + this.regionAbbreviation = countryCode; + } + + public String getPhoneNumber() { + return phoneNumber; + } + + public void setPhoneNumber(String phoneNumber) { + this.phoneNumber = phoneNumber; + } + + public String getAvatarUrl() { + return avatarUrl; + } + + public void setAvatarUrl(String avatarUrl) { + this.avatarUrl = avatarUrl; + } + + /** + * Gets the current status of the user account. + * + * @return the user status + */ + public UserStatus getStatus() { + return status; + } + + /** + * Sets the current status of the user account. + * + * @param status the user status + */ + public void setStatus(UserStatus status) { + this.status = status; + } + + public Long getDepartmentId() { + return departmentId; + } + + public void setDepartmentId(Long departmentId) { + this.departmentId = departmentId; + } + + public Long getPositionId() { + return positionId; + } + + public void setPositionId(Long positionId) { + this.positionId = positionId; + } + + 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; + } + + @Override + public boolean equals(Object o) { + if (o == null || getClass() != o.getClass()) return false; + User user = (User) o; + return Objects.equals(id, user.id); // Typically only check the Primary Key for entity equality + } + + @Override + public int hashCode() { + return Objects.hash(id); // Hash code based on the Primary Key + } + + @Override + public String toString() { + return "User{" + + "id=" + id + + ", username='" + username + '\'' + + ", fullName='" + fullName + '\'' + + ", email='" + email + '\'' + + ", status=" + status + + ", departmentId=" + departmentId + + ", positionId=" + positionId + + '}'; + } + + /** + * Creates a new Builder instance for constructing User objects. + * + * @return a new UserBuilder instance + */ + public static UserBuilder builder() { + return new UserBuilder(); + } + + /** + * Builder class for constructing User instances with a fluent API. + *

+ * This builder provides a convenient way to construct User objects with optional parameters, + * following the Builder pattern for improved readability and maintainability. + */ + public static class UserBuilder { + private Long id; + private String username; + private String password; + private String fullName; + private String email; + private String regionAbbreviation; + private String phoneNumber; + private String avatarUrl; + private UserStatus status; + private Long departmentId; + private Long positionId; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + + private UserBuilder() { + } + + public UserBuilder id(Long id) { + this.id = id; + return this; + } + + public UserBuilder username(String username) { + this.username = username; + return this; + } + + public UserBuilder password(String password) { + this.password = password; + return this; + } + + public UserBuilder fullName(String fullName) { + this.fullName = fullName; + return this; + } + + public UserBuilder email(String email) { + this.email = email; + return this; + } + + public UserBuilder regionAbbreviation(String regionAbbreviation) { + this.regionAbbreviation = regionAbbreviation; + return this; + } + + public UserBuilder phoneNumber(String phoneNumber) { + this.phoneNumber = phoneNumber; + return this; + } + + public UserBuilder avatarUrl(String avatarUrl) { + this.avatarUrl = avatarUrl; + return this; + } + + public UserBuilder status(UserStatus status) { + this.status = status; + return this; + } + + public UserBuilder departmentId(Long departmentId) { + this.departmentId = departmentId; + return this; + } + + public UserBuilder positionId(Long positionId) { + this.positionId = positionId; + return this; + } + + public UserBuilder createdAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + return this; + } + + public UserBuilder updatedAt(LocalDateTime updatedAt) { + this.updatedAt = updatedAt; + return this; + } + + /** + * Builds and returns a new User instance with the configured properties. + * + * @return a new User instance + */ + public User build() { + return new User(id, username, password, fullName, email, regionAbbreviation, phoneNumber, avatarUrl, status, departmentId, positionId, createdAt, updatedAt); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/onixbyte/helix/domain/entity/UserIdentity.java b/src/main/java/com/onixbyte/helix/domain/entity/UserIdentity.java new file mode 100644 index 0000000..caf8d02 --- /dev/null +++ b/src/main/java/com/onixbyte/helix/domain/entity/UserIdentity.java @@ -0,0 +1,227 @@ +package com.onixbyte.helix.domain.entity; + +import com.onixbyte.helix.constant.IdentityProvider; +import com.onixbyte.helix.domain.entity.embeddable.UserIdentityId; +import jakarta.persistence.*; // 导入 Jakarta Persistence API +import java.time.LocalDateTime; +import java.util.Objects; + +/** + * Represents an external identity mapping for a user. + *

+ * This entity manages the relationship between internal user accounts and external identity + * providers (such as OAuth providers, LDAP systems, or other authentication services). It enables + * users to authenticate using external credentials while maintaining a consistent internal user + * identity within the Helix system. + * + * @author zihluwang + * @version 1.0 + * @since 1.0 + */ +@Entity +@Table(name = "user_identities") +public class UserIdentity { + + /** + * The composite primary key for the entity, composed of userId, provider, and externalId. + */ + @EmbeddedId + private UserIdentityId id; + + /** + * The timestamp when this identity mapping was created. + */ + @Column(nullable = false, updatable = false) + private LocalDateTime createdAt; + + /** + * The timestamp when this identity mapping was last updated. + */ + @Column(nullable = false) + private LocalDateTime updatedAt; + + // --- JPA Lifecycle Callbacks for Auditing --- + + @PrePersist + protected void onCreate() { + if (this.createdAt == null) { + this.createdAt = LocalDateTime.now(); + } + if (this.updatedAt == null) { + this.updatedAt = LocalDateTime.now(); + } + } + + @PreUpdate + protected void onUpdate() { + this.updatedAt = LocalDateTime.now(); + } + + + // --- Getters and Setters (Delegating to EmbeddedId) --- + + /** + * Gets the identifier of the internal user account from the composite primary key. + * @return the user ID + */ + public Long getUserId() { + return this.id != null ? this.id.getUserId() : null; + } + + /** + * Sets the identifier of the internal user account within the composite primary key. + * @param userId the user ID + */ + public void setUserId(Long userId) { + if (this.id == null) this.id = new UserIdentityId(); + this.id.setUserId(userId); + } + + /** + * Gets the external identity provider from the composite primary key. + * @return the provider + */ + public IdentityProvider getProvider() { + return this.id != null ? this.id.getProvider() : null; + } + + /** + * Sets the external identity provider within the composite primary key. + * @param provider the provider + */ + public void setProvider(IdentityProvider provider) { + if (this.id == null) this.id = new UserIdentityId(); + this.id.setProvider(provider); + } + + /** + * Gets the unique identifier from the external provider from the composite primary key. + * @return the external ID + */ + public String getExternalId() { + return this.id != null ? this.id.getExternalId() : null; + } + + /** + * Sets the unique identifier from the external provider within the composite primary key. + * @param externalId the external ID + */ + public void setExternalId(String externalId) { + if (this.id == null) this.id = new UserIdentityId(); + this.id.setExternalId(externalId); + } + + 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; + } + + // --- Constructors (Adjusted for EmbeddedId) --- + + public UserIdentity() { + this.id = new UserIdentityId(); // Initialize ID object for safety + } + + public UserIdentity(Long userId, IdentityProvider provider, String externalId, LocalDateTime createdAt, LocalDateTime updatedAt) { + this.id = new UserIdentityId(userId, provider, externalId); + this.createdAt = createdAt; + this.updatedAt = updatedAt; + } + + // --- Overrides (Simplified to use the Id object for entity equality) --- + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + UserIdentity that = (UserIdentity) o; + return Objects.equals(id, that.id); // Entity equality based on primary key + } + + @Override + public int hashCode() { + return Objects.hash(id); // Hash code based on primary key + } + + @Override + public String toString() { + return "UserIdentity{" + + "userId=" + getUserId() + + ", provider=" + getProvider() + + ", externalId='" + getExternalId() + '\'' + + ", createdAt=" + createdAt + + ", updatedAt=" + updatedAt + + '}'; + } + + /** + * Creates a new Builder instance for constructing UserIdentity objects. + * + * @return a new UserIdentityBuilder instance + */ + public static UserIdentityBuilder builder() { + return new UserIdentityBuilder(); + } + + /** + * Builder class for constructing UserIdentity instances with a fluent API. + *

+ * This builder provides a convenient way to construct UserIdentity objects with optional parameters, + * following the Builder pattern for improved readability and maintainability. + */ + public static class UserIdentityBuilder { + private Long userId; + private IdentityProvider provider; + private String externalId; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + + private UserIdentityBuilder() { + } + + public UserIdentityBuilder userId(Long userId) { + this.userId = userId; + return this; + } + + public UserIdentityBuilder provider(IdentityProvider provider) { + this.provider = provider; + return this; + } + + public UserIdentityBuilder externalId(String externalId) { + this.externalId = externalId; + return this; + } + + public UserIdentityBuilder createdAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + return this; + } + + public UserIdentityBuilder updatedAt(LocalDateTime updatedAt) { + this.updatedAt = updatedAt; + return this; + } + + /** + * Builds and returns a new UserIdentity instance with the configured properties. + * + * @return a new UserIdentity instance + */ + public UserIdentity build() { + return new UserIdentity(userId, provider, externalId, createdAt, updatedAt); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/onixbyte/helix/domain/entity/UserRole.java b/src/main/java/com/onixbyte/helix/domain/entity/UserRole.java new file mode 100644 index 0000000..794bf40 --- /dev/null +++ b/src/main/java/com/onixbyte/helix/domain/entity/UserRole.java @@ -0,0 +1,176 @@ +package com.onixbyte.helix.domain.entity; + +import com.onixbyte.helix.domain.entity.embeddable.UserRoleId; +import jakarta.persistence.*; +import java.time.LocalDateTime; +import java.util.Objects; + +/** + * Represents the association between users and roles in the access control system. + *

+ * This entity implements the many-to-many relationship between users and roles, allowing users to + * be assigned multiple roles and roles to be assigned to multiple users. It forms a fundamental + * part of the role-based access control (RBAC) system by defining which roles are assigned to + * each user, thereby determining their permissions and access levels within the system. + * + * @author zihluwang + * @version 1.0 + * @since 1.0 + */ +@Entity +@Table(name = "user_roles") +public class UserRole { + + /** + * The composite primary key of the association, mapped to the 'user_id' and 'role_id' columns. + *

+ * This field embeds the UserRoleId object which holds the foreign keys to the User and Role tables. + */ + @EmbeddedId + private UserRoleId id; + + /** + * The timestamp when this user-role assignment was created. + *

+ * This field is automatically set upon creation in the database. + */ + @Column(nullable = false, updatable = false) + private LocalDateTime createdAt; + + // --- JPA Lifecycle Callbacks for Auditing --- + + @PrePersist + protected void onCreate() { + if (this.createdAt == null) { + this.createdAt = LocalDateTime.now(); + } + } + + // --- Getters and Setters (Delegating to EmbeddedId and fields) --- + + /** + * Gets the identifier of the role from the composite primary key. + * + * @return the role's unique identifier + */ + public Long getRoleId() { + return this.id != null ? this.id.getRoleId() : null; + } + + /** + * Sets the identifier of the role within the composite primary key. + * + * @param roleId the role's unique identifier + */ + public void setRoleId(Long roleId) { + if (this.id == null) this.id = new UserRoleId(); + this.id.setRoleId(roleId); + } + + /** + * Gets the identifier of the user from the composite primary key. + * + * @return the user's unique identifier + */ + public Long getUserId() { + return this.id != null ? this.id.getUserId() : null; + } + + /** + * Sets the identifier of the user within the composite primary key. + * + * @param userId the user's unique identifier + */ + public void setUserId(Long userId) { + if (this.id == null) this.id = new UserRoleId(); + this.id.setUserId(userId); + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + } + + public UserRole() { + this.id = new UserRoleId(); + } + + public UserRole(Long roleId, Long userId, LocalDateTime createdAt) { + this.id = new UserRoleId(userId, roleId); + this.createdAt = createdAt; + } + + // --- Overrides (Simplified to use the Id object for entity equality) --- + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + UserRole userRole = (UserRole) o; + return Objects.equals(id, userRole.id); // Entity equality based on primary key + } + + @Override + public int hashCode() { + return Objects.hash(id); // Hash code based on primary key + } + + @Override + public String toString() { + return "UserRole{" + + "roleId=" + getRoleId() + + ", userId=" + getUserId() + + ", createdAt=" + createdAt + + '}'; + } + + // --- Builder Class (Adjusted to build the Id object) --- + + /** + * Creates a new Builder instance for constructing UserRole objects. + * + * @return a new UserRoleBuilder instance + */ + public static UserRoleBuilder builder() { + return new UserRoleBuilder(); + } + + /** + * Builder class for constructing UserRole instances with a fluent API. + */ + public static class UserRoleBuilder { + private Long roleId; + private Long userId; + private LocalDateTime createdAt; + + private UserRoleBuilder() { + } + + public UserRoleBuilder roleId(Long roleId) { + this.roleId = roleId; + return this; + } + + public UserRoleBuilder userId(Long userId) { + this.userId = userId; + return this; + } + + public UserRoleBuilder createdAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + return this; + } + + /** + * Builds and returns a new UserRole instance with the configured properties. + * + * @return a new UserRole instance + */ + public UserRole build() { + return new UserRole(roleId, userId, createdAt); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/onixbyte/helix/domain/entity/embeddable/RoleAuthorityId.java b/src/main/java/com/onixbyte/helix/domain/entity/embeddable/RoleAuthorityId.java new file mode 100644 index 0000000..0153899 --- /dev/null +++ b/src/main/java/com/onixbyte/helix/domain/entity/embeddable/RoleAuthorityId.java @@ -0,0 +1,70 @@ +package com.onixbyte.helix.domain.entity.embeddable; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; + +import java.io.Serial; +import java.io.Serializable; +import java.util.Objects; + +/** + * Represents the composite primary key for the RoleAuthority association entity. + */ +@Embeddable +public class RoleAuthorityId implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * The identifier of the role, mapping to the 'role_id' column. + *

+ * This field should match the corresponding field name in the RoleAuthority entity + * if the naming is non-standard, but typically matches the column name in the database. + */ + @Column(nullable = false) + private Long roleId; + + /** + * The identifier of the authority, mapping to the 'authority_id' column. + */ + @Column(nullable = false) + private Long authorityId; + + public RoleAuthorityId() { + } + + public RoleAuthorityId(Long roleId, Long authorityId) { + this.roleId = roleId; + this.authorityId = authorityId; + } + + public Long getRoleId() { + return roleId; + } + + public void setRoleId(Long roleId) { + this.roleId = roleId; + } + + public Long getAuthorityId() { + return authorityId; + } + + public void setAuthorityId(Long authorityId) { + this.authorityId = authorityId; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + RoleAuthorityId that = (RoleAuthorityId) o; + return Objects.equals(roleId, that.roleId) && Objects.equals(authorityId, that.authorityId); + } + + @Override + public int hashCode() { + return Objects.hash(roleId, authorityId); + } +} diff --git a/src/main/java/com/onixbyte/helix/domain/entity/embeddable/UserIdentityId.java b/src/main/java/com/onixbyte/helix/domain/entity/embeddable/UserIdentityId.java new file mode 100644 index 0000000..f400040 --- /dev/null +++ b/src/main/java/com/onixbyte/helix/domain/entity/embeddable/UserIdentityId.java @@ -0,0 +1,97 @@ +package com.onixbyte.helix.domain.entity.embeddable; + +import com.onixbyte.helix.constant.IdentityProvider; +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import org.hibernate.annotations.JdbcType; +import org.hibernate.dialect.PostgreSQLEnumJdbcType; + +import java.io.Serial; +import java.io.Serializable; +import java.util.Objects; + +/** + * Represents the composite primary key for the UserIdentity entity. + *

+ * This key is composed of the internal user ID, the identity provider, and the external ID + * from that provider. + */ +@Embeddable +public class UserIdentityId implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * The identifier of the internal user account, corresponding to the 'user_id' column. + *

+ * This also serves as a foreign key reference to the User entity. + */ + @Column(nullable = false) + private Long userId; + + /** + * The external identity provider, corresponding to the 'provider' column. + */ + @Column(nullable = false) + @Enumerated + @JdbcType(PostgreSQLEnumJdbcType.class) + private IdentityProvider provider; + + /** + * The unique identifier from the external provider, corresponding to the 'external_id' column. + */ + @Column(nullable = false) + private String externalId; + + // --- Constructors --- + + public UserIdentityId() { + } + + public UserIdentityId(Long userId, IdentityProvider provider, String externalId) { + this.userId = userId; + this.provider = provider; + this.externalId = externalId; + } + + // --- Getters and Setters (Omitted for brevity, but should exist) --- + public Long getUserId() { + return userId; + } + + public void setUserId(Long userId) { + this.userId = userId; + } + + public IdentityProvider getProvider() { + return provider; + } + + public void setProvider(IdentityProvider provider) { + this.provider = provider; + } + + public String getExternalId() { + return externalId; + } + + public void setExternalId(String externalId) { + this.externalId = externalId; + } + + // --- equals and hashCode (REQUIRED for composite keys) --- + @Override + public boolean equals(Object o) { + if (o == null || getClass() != o.getClass()) return false; + UserIdentityId that = (UserIdentityId) o; + return Objects.equals(userId, that.userId) && provider == that.provider && Objects.equals(externalId, that.externalId); + } + + @Override + public int hashCode() { + return Objects.hash(userId, provider, externalId); + } +} diff --git a/src/main/java/com/onixbyte/helix/domain/entity/embeddable/UserRoleId.java b/src/main/java/com/onixbyte/helix/domain/entity/embeddable/UserRoleId.java new file mode 100644 index 0000000..23de928 --- /dev/null +++ b/src/main/java/com/onixbyte/helix/domain/entity/embeddable/UserRoleId.java @@ -0,0 +1,72 @@ +package com.onixbyte.helix.domain.entity.embeddable; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; + +import java.io.Serial; +import java.io.Serializable; +import java.util.Objects; + +/** + * Represents the composite primary key for the UserRole association entity. + *

+ * This class combines the userId and roleId to uniquely identify a user's role assignment. + */ +@Embeddable +public class UserRoleId implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * The identifier of the user in this association, mapping to the 'user_id' column. + */ + @Column(nullable = false) + private Long userId; + + /** + * The identifier of the role in this association, mapping to the 'role_id' column. + */ + @Column(nullable = false) + private Long roleId; + + // --- Constructors --- + public UserRoleId() { + } + + public UserRoleId(Long userId, Long roleId) { + this.userId = userId; + this.roleId = roleId; + } + + // --- Getters and Setters --- + 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; + } + + // --- equals and hashCode (REQUIRED for composite keys) --- + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + UserRoleId that = (UserRoleId) o; + return Objects.equals(userId, that.userId) && Objects.equals(roleId, that.roleId); + } + + @Override + public int hashCode() { + return Objects.hash(userId, roleId); + } +} diff --git a/src/main/java/com/onixbyte/helix/domain/model/TreeNode.java b/src/main/java/com/onixbyte/helix/domain/model/TreeNode.java new file mode 100644 index 0000000..3748a6b --- /dev/null +++ b/src/main/java/com/onixbyte/helix/domain/model/TreeNode.java @@ -0,0 +1,18 @@ +package com.onixbyte.helix.domain.model; + +import java.util.ArrayList; +import java.util.List; + +public record TreeNode( + T item, + List> children +) { + + /** + * Helper constructor for building. + * @param item the item + */ + public TreeNode(T item) { + this(item, new ArrayList<>()); + } +} diff --git a/src/main/java/com/onixbyte/helix/domain/view/AuthorityView.java b/src/main/java/com/onixbyte/helix/domain/view/AuthorityView.java new file mode 100644 index 0000000..1db4664 --- /dev/null +++ b/src/main/java/com/onixbyte/helix/domain/view/AuthorityView.java @@ -0,0 +1,264 @@ +package com.onixbyte.helix.domain.view; + +import com.onixbyte.helix.constant.Status; +import com.onixbyte.helix.domain.entity.Authority; + +import java.time.LocalDateTime; +import java.util.Objects; + +/** + * View object for Authority entity. + *

+ * This class represents the view layer abstraction of an Authority, providing a simplified + * representation suitable for presentation in user interfaces and API responses. + * + * @author Zihlu Wang + * @version 1.0.0 + * @since 1.0.0 + */ +public class AuthorityView { + + /** + * The unique identifier for the authority. + */ + private Long id; + + /** + * The unique code identifier for the authority. + */ + private String code; + + /** + * The human-readable name of the authority. + */ + private String name; + + /** + * A detailed description of what this authority grants. + */ + private String description; + + /** + * The current status of the authority. + */ + private Status status; + + /** + * The timestamp when this authority record was created. + */ + private LocalDateTime createdAt; + + /** + * The timestamp when this authority record was last updated. + */ + private LocalDateTime updatedAt; + + /** + * Default constructor for serialisation frameworks. + */ + public AuthorityView() { + } + + /** + * Constructs a new AuthorityView with all fields specified. + * + * @param id the unique identifier + * @param code the unique code identifier + * @param name the human-readable name + * @param description the detailed description + * @param status the current status + * @param createdAt the creation timestamp + * @param updatedAt the last update timestamp + */ + public AuthorityView(Long id, String code, String name, String description, Status status, + LocalDateTime createdAt, LocalDateTime updatedAt) { + this.id = id; + this.code = code; + this.name = name; + this.description = description; + this.status = status; + this.createdAt = createdAt; + this.updatedAt = updatedAt; + } + + /** + * Creates an AuthorityView from an Authority entity. + * + * @param entity the Authority entity + * @return a new AuthorityView instance + */ + public static AuthorityView fromEntity(Authority entity) { + if (entity == null) { + return null; + } + return new AuthorityView( + entity.getId(), + entity.getCode(), + entity.getName(), + entity.getDescription(), + entity.getStatus(), + entity.getCreatedAt(), + entity.getUpdatedAt() + ); + } + + 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 Status getStatus() { + return status; + } + + public void setStatus(Status 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; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + AuthorityView that = (AuthorityView) o; + return Objects.equals(id, that.id) && + Objects.equals(code, that.code) && + Objects.equals(name, that.name) && + Objects.equals(description, that.description) && + status == that.status && + Objects.equals(createdAt, that.createdAt) && + Objects.equals(updatedAt, that.updatedAt); + } + + @Override + public int hashCode() { + return Objects.hash(id, code, name, description, status, createdAt, updatedAt); + } + + @Override + public String toString() { + return "AuthorityView{" + + "id=" + id + + ", code='" + code + '\'' + + ", name='" + name + '\'' + + ", description='" + description + '\'' + + ", status=" + status + + ", createdAt=" + createdAt + + ", updatedAt=" + updatedAt + + '}'; + } + + /** + * Returns a builder for constructing AuthorityView instances. + * + * @return a new AuthorityViewBuilder instance + */ + public static AuthorityViewBuilder builder() { + return new AuthorityViewBuilder(); + } + + /** + * Builder class for constructing {@link AuthorityView} instances. + *

+ * This builder provides a fluent interface for creating AuthorityView objects with optional + * parameters, following the builder pattern for improved readability and maintainability. + */ + public static class AuthorityViewBuilder { + private Long id; + private String code; + private String name; + private String description; + private Status status; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + + private AuthorityViewBuilder() { + } + + public AuthorityViewBuilder id(Long id) { + this.id = id; + return this; + } + + public AuthorityViewBuilder code(String code) { + this.code = code; + return this; + } + + public AuthorityViewBuilder name(String name) { + this.name = name; + return this; + } + + public AuthorityViewBuilder description(String description) { + this.description = description; + return this; + } + + public AuthorityViewBuilder status(Status status) { + this.status = status; + return this; + } + + public AuthorityViewBuilder createdAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + return this; + } + + public AuthorityViewBuilder updatedAt(LocalDateTime updatedAt) { + this.updatedAt = updatedAt; + return this; + } + + /** + * Builds and returns a new AuthorityView instance with the configured parameters. + * + * @return a new AuthorityView instance + */ + public AuthorityView build() { + return new AuthorityView(id, code, name, description, status, createdAt, updatedAt); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/onixbyte/helix/domain/view/DepartmentView.java b/src/main/java/com/onixbyte/helix/domain/view/DepartmentView.java new file mode 100644 index 0000000..a72c58b --- /dev/null +++ b/src/main/java/com/onixbyte/helix/domain/view/DepartmentView.java @@ -0,0 +1,270 @@ +package com.onixbyte.helix.domain.view; + +import com.onixbyte.helix.constant.Status; +import com.onixbyte.helix.domain.entity.Department; + +import java.time.LocalDateTime; +import java.util.Objects; + +/** + * View object for Department entity. + *

+ * This class represents the view layer abstraction of a Department, providing a simplified + * representation suitable for presentation in user interfaces and API responses. + * + * @author Zihlu Wang + * @version 1.0.0 + * @since 1.0.0 + */ +public class DepartmentView { + + /** + * The unique identifier for the department. + */ + private Long id; + + /** + * The name of the department. + */ + private String name; + + /** + * The identifier of the parent department. + */ + private Long parentId; + + /** + * The sort order for displaying departments. + */ + private Integer sort; + + /** + * The current status of the department. + */ + private Status status; + + /** + * The timestamp when this department record was created. + */ + private LocalDateTime createdAt; + + /** + * The timestamp when this department record was last updated. + */ + private LocalDateTime updatedAt; + + /** + * Default constructor for serialisation frameworks. + */ + public DepartmentView() { + } + + /** + * Constructs a new DepartmentView with all fields specified. + * + * @param id the unique identifier + * @param name the name of the department + * @param parentId the identifier of the parent department + * @param sort the sort order for displaying departments + * @param status the current status + * @param createdAt the creation timestamp + * @param updatedAt the last update timestamp + */ + public DepartmentView(Long id, String name, Long parentId, Integer sort, + Status status, LocalDateTime createdAt, LocalDateTime updatedAt) { + this.id = id; + this.name = name; + this.parentId = parentId; + this.sort = sort; + this.status = status; + this.createdAt = createdAt; + this.updatedAt = updatedAt; + } + + /** + * Creates a DepartmentView from a Department entity. + * + * @param entity the Department entity + * @return a new DepartmentView instance + */ + public static DepartmentView fromEntity(Department entity) { + if (entity == null) { + return null; + } + return new DepartmentView( + entity.getId(), + entity.getName(), + entity.getParentId(), + entity.getSort(), + entity.getStatus(), + entity.getCreatedAt(), + entity.getUpdatedAt() + ); + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public Long getParentId() { + return parentId; + } + + public void setParentId(Long parentId) { + this.parentId = parentId; + } + + public Integer getSort() { + return sort; + } + + public void setSort(Integer sort) { + this.sort = sort; + } + + public Status getStatus() { + return status; + } + + public void setStatus(Status 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; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + DepartmentView that = (DepartmentView) o; + return Objects.equals(id, that.id) && + Objects.equals(name, that.name) && + Objects.equals(parentId, that.parentId) && + Objects.equals(sort, that.sort) && + status == that.status && + Objects.equals(createdAt, that.createdAt) && + Objects.equals(updatedAt, that.updatedAt); + } + + @Override + public int hashCode() { + return Objects.hash(id, name, parentId, sort, status, createdAt, updatedAt); + } + + @Override + public String toString() { + return "DepartmentView{" + + "id=" + id + + ", name='" + name + '\'' + + ", parentId=" + parentId + + ", sort=" + sort + + ", status=" + status + + ", createdAt=" + createdAt + + ", updatedAt=" + updatedAt + + '}'; + } + + /** + * Returns a builder for constructing DepartmentView instances. + * + * @return a new DepartmentViewBuilder instance + */ + public static DepartmentViewBuilder builder() { + return new DepartmentViewBuilder(); + } + + /** + * Builder class for constructing {@link DepartmentView} instances. + *

+ * This builder provides a fluent interface for creating DepartmentView objects with optional + * parameters, following the builder pattern for improved readability and maintainability. + */ + public static class DepartmentViewBuilder { + private Long id; + private String name; + private Long parentId; + private String treePath; + private Integer sort; + private Status status; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + + private DepartmentViewBuilder() { + } + + public DepartmentViewBuilder id(Long id) { + this.id = id; + return this; + } + + public DepartmentViewBuilder name(String name) { + this.name = name; + return this; + } + + public DepartmentViewBuilder parentId(Long parentId) { + this.parentId = parentId; + return this; + } + + public DepartmentViewBuilder treePath(String treePath) { + this.treePath = treePath; + return this; + } + + public DepartmentViewBuilder sort(Integer sort) { + this.sort = sort; + return this; + } + + public DepartmentViewBuilder status(Status status) { + this.status = status; + return this; + } + + public DepartmentViewBuilder createdAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + return this; + } + + public DepartmentViewBuilder updatedAt(LocalDateTime updatedAt) { + this.updatedAt = updatedAt; + return this; + } + + /** + * Builds and returns a new DepartmentView instance with the configured parameters. + * + * @return a new DepartmentView instance + */ + public DepartmentView build() { + return new DepartmentView(id, name, parentId, sort, status, createdAt, updatedAt); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/onixbyte/helix/domain/view/PositionView.java b/src/main/java/com/onixbyte/helix/domain/view/PositionView.java new file mode 100644 index 0000000..8989f2b --- /dev/null +++ b/src/main/java/com/onixbyte/helix/domain/view/PositionView.java @@ -0,0 +1,271 @@ +package com.onixbyte.helix.domain.view; + +import com.onixbyte.helix.constant.Status; +import com.onixbyte.helix.domain.entity.Position; + +import java.time.LocalDateTime; +import java.util.Objects; + +/** + * View object for Position entity. + *

+ * This class represents a view of the Position entity, providing a data transfer object + * for position information in the organisational structure. + * + * @author zihluwang + * @version 1.0 + * @since 1.0 + */ +public class PositionView { + + /** + * The unique identifier for the position. + */ + private Long id; + + /** + * The human-readable name of the position. + */ + private String name; + + /** + * The unique code identifier for the position. + */ + private String code; + + /** + * A detailed description of the position's responsibilities and requirements. + */ + private String description; + + /** + * The sort order for displaying positions. + */ + private Integer sort; + + /** + * The current status of the position. + */ + private Status status; + + /** + * The timestamp when this position record was created. + */ + private LocalDateTime createdAt; + + /** + * The timestamp when this position record was last updated. + */ + private LocalDateTime updatedAt; + + /** + * Default constructor. + */ + public PositionView() { + } + + /** + * Constructor with all fields. + */ + public PositionView(Long id, String name, String code, String description, Integer sort, + Status status, LocalDateTime createdAt, LocalDateTime updatedAt) { + this.id = id; + this.name = name; + this.code = code; + this.description = description; + this.sort = sort; + this.status = status; + this.createdAt = createdAt; + this.updatedAt = updatedAt; + } + + /** + * Creates a PositionView from a Position entity. + * + * @param position the Position entity + * @return the PositionView object + */ + public static PositionView fromEntity(Position position) { + if (position == null) { + return null; + } + return new PositionView( + position.getId(), + position.getName(), + position.getCode(), + position.getDescription(), + position.getSort(), + position.getStatus(), + position.getCreatedAt(), + position.getUpdatedAt() + ); + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getCode() { + return code; + } + + public void setCode(String code) { + this.code = code; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public Integer getSort() { + return sort; + } + + public void setSort(Integer sort) { + this.sort = sort; + } + + public Status getStatus() { + return status; + } + + public void setStatus(Status 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; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + PositionView that = (PositionView) o; + return Objects.equals(id, that.id) && + Objects.equals(name, that.name) && + Objects.equals(code, that.code) && + Objects.equals(description, that.description) && + Objects.equals(sort, that.sort) && + status == that.status && + Objects.equals(createdAt, that.createdAt) && + Objects.equals(updatedAt, that.updatedAt); + } + + @Override + public int hashCode() { + return Objects.hash(id, name, code, description, sort, status, createdAt, updatedAt); + } + + @Override + public String toString() { + return "PositionView{" + + "id=" + id + + ", name='" + name + '\'' + + ", code='" + code + '\'' + + ", description='" + description + '\'' + + ", sort=" + sort + + ", status=" + status + + ", createdAt=" + createdAt + + ", updatedAt=" + updatedAt + + '}'; + } + + /** + * Creates a new builder instance. + * + * @return a new PositionViewBuilder + */ + public static PositionViewBuilder builder() { + return new PositionViewBuilder(); + } + + /** + * Builder class for PositionView. + */ + public static class PositionViewBuilder { + private Long id; + private String name; + private String code; + private String description; + private Integer sort; + private Status status; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + + private PositionViewBuilder() { + } + + public PositionViewBuilder id(Long id) { + this.id = id; + return this; + } + + public PositionViewBuilder name(String name) { + this.name = name; + return this; + } + + public PositionViewBuilder code(String code) { + this.code = code; + return this; + } + + public PositionViewBuilder description(String description) { + this.description = description; + return this; + } + + public PositionViewBuilder sort(Integer sort) { + this.sort = sort; + return this; + } + + public PositionViewBuilder status(Status status) { + this.status = status; + return this; + } + + public PositionViewBuilder createdAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + return this; + } + + public PositionViewBuilder updatedAt(LocalDateTime updatedAt) { + this.updatedAt = updatedAt; + return this; + } + + public PositionView build() { + return new PositionView(id, name, code, description, sort, status, createdAt, updatedAt); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/onixbyte/helix/domain/view/RoleAuthorityView.java b/src/main/java/com/onixbyte/helix/domain/view/RoleAuthorityView.java new file mode 100644 index 0000000..f37e22c --- /dev/null +++ b/src/main/java/com/onixbyte/helix/domain/view/RoleAuthorityView.java @@ -0,0 +1,154 @@ +package com.onixbyte.helix.domain.view; + +import com.onixbyte.helix.domain.entity.RoleAuthority; + +import java.time.LocalDateTime; +import java.util.Objects; + +/** + * View object for RoleAuthority entity. + *

+ * This class represents a view of the RoleAuthority entity, providing a data transfer object + * for role-authority association information in the access control system. + * + * @author zihluwang + * @version 1.0 + * @since 1.0 + */ +public class RoleAuthorityView { + + /** + * The identifier of the role in this association. + */ + private Long roleId; + + /** + * The identifier of the authority in this association. + */ + private Long authorityId; + + /** + * The timestamp when this role-authority association was created. + */ + private LocalDateTime createdAt; + + /** + * Default constructor. + */ + public RoleAuthorityView() { + } + + /** + * Constructor with all fields. + */ + public RoleAuthorityView(Long roleId, Long authorityId, LocalDateTime createdAt) { + this.roleId = roleId; + this.authorityId = authorityId; + this.createdAt = createdAt; + } + + /** + * Creates a RoleAuthorityView from a RoleAuthority entity. + * + * @param roleAuthority the RoleAuthority entity + * @return the RoleAuthorityView object + */ + public static RoleAuthorityView fromEntity(RoleAuthority roleAuthority) { + if (roleAuthority == null) { + return null; + } + return new RoleAuthorityView( + roleAuthority.getRoleId(), + roleAuthority.getAuthorityId(), + roleAuthority.getCreatedAt() + ); + } + + public Long getRoleId() { + return roleId; + } + + public void setRoleId(Long roleId) { + this.roleId = roleId; + } + + public Long getAuthorityId() { + return authorityId; + } + + public void setAuthorityId(Long authorityId) { + this.authorityId = authorityId; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + RoleAuthorityView that = (RoleAuthorityView) o; + return Objects.equals(roleId, that.roleId) && + Objects.equals(authorityId, that.authorityId) && + Objects.equals(createdAt, that.createdAt); + } + + @Override + public int hashCode() { + return Objects.hash(roleId, authorityId, createdAt); + } + + @Override + public String toString() { + return "RoleAuthorityView{" + + "roleId=" + roleId + + ", authorityId=" + authorityId + + ", createdAt=" + createdAt + + '}'; + } + + /** + * Creates a new builder instance. + * + * @return a new RoleAuthorityViewBuilder + */ + public static RoleAuthorityViewBuilder builder() { + return new RoleAuthorityViewBuilder(); + } + + /** + * Builder class for RoleAuthorityView. + */ + public static class RoleAuthorityViewBuilder { + private Long roleId; + private Long authorityId; + private LocalDateTime createdAt; + + private RoleAuthorityViewBuilder() { + } + + public RoleAuthorityViewBuilder roleId(Long roleId) { + this.roleId = roleId; + return this; + } + + public RoleAuthorityViewBuilder authorityId(Long authorityId) { + this.authorityId = authorityId; + return this; + } + + public RoleAuthorityViewBuilder createdAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + return this; + } + + public RoleAuthorityView build() { + return new RoleAuthorityView(roleId, authorityId, createdAt); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/onixbyte/helix/domain/view/RoleView.java b/src/main/java/com/onixbyte/helix/domain/view/RoleView.java new file mode 100644 index 0000000..dc0bc79 --- /dev/null +++ b/src/main/java/com/onixbyte/helix/domain/view/RoleView.java @@ -0,0 +1,294 @@ +package com.onixbyte.helix.domain.view; + +import com.onixbyte.helix.constant.Status; +import com.onixbyte.helix.domain.entity.Role; + +import java.time.LocalDateTime; +import java.util.Objects; + +/** + * View object for Role entity. + *

+ * This class represents a view of the Role entity, providing a data transfer object + * for role information within the access control system. + * + * @author zihluwang + * @version 1.0 + * @since 1.0 + */ +public class RoleView { + + /** + * The unique identifier for the role. + */ + private Long id; + + /** + * The human-readable name of the role. + */ + private String name; + + /** + * The unique code identifier for the role. + */ + private String code; + + /** + * The sort order for displaying roles. + */ + private Integer sort; + + /** + * Indicates whether this role is assigned by default to new users. + */ + private Boolean defaultValue; + + /** + * A detailed description of the role's purpose and permissions. + */ + private String description; + + /** + * The current status of the role. + */ + private Status status; + + /** + * The timestamp when this role record was created. + */ + private LocalDateTime createdAt; + + /** + * The timestamp when this role record was last updated. + */ + private LocalDateTime updatedAt; + + /** + * Default constructor. + */ + public RoleView() { + } + + /** + * Constructor with all fields. + */ + public RoleView(Long id, String name, String code, Integer sort, Boolean defaultValue, + String description, Status status, LocalDateTime createdAt, LocalDateTime updatedAt) { + this.id = id; + this.name = name; + this.code = code; + this.sort = sort; + this.defaultValue = defaultValue; + this.description = description; + this.status = status; + this.createdAt = createdAt; + this.updatedAt = updatedAt; + } + + /** + * Creates a RoleView from a Role entity. + * + * @param role the Role entity + * @return the RoleView object + */ + public static RoleView fromEntity(Role role) { + if (role == null) { + return null; + } + return new RoleView( + role.getId(), + role.getName(), + role.getCode(), + role.getSort(), + role.getDefaultValue(), + role.getDescription(), + role.getStatus(), + role.getCreatedAt(), + role.getUpdatedAt() + ); + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getCode() { + return code; + } + + public void setCode(String code) { + this.code = code; + } + + public Integer getSort() { + return sort; + } + + public void setSort(Integer sort) { + this.sort = sort; + } + + public Boolean getDefaultValue() { + return defaultValue; + } + + public void setDefaultValue(Boolean defaultValue) { + this.defaultValue = defaultValue; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public Status getStatus() { + return status; + } + + public void setStatus(Status 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; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + RoleView roleView = (RoleView) o; + return Objects.equals(id, roleView.id) && + Objects.equals(name, roleView.name) && + Objects.equals(code, roleView.code) && + Objects.equals(sort, roleView.sort) && + Objects.equals(defaultValue, roleView.defaultValue) && + Objects.equals(description, roleView.description) && + status == roleView.status && + Objects.equals(createdAt, roleView.createdAt) && + Objects.equals(updatedAt, roleView.updatedAt); + } + + @Override + public int hashCode() { + return Objects.hash(id, name, code, sort, defaultValue, description, status, createdAt, updatedAt); + } + + @Override + public String toString() { + return "RoleView{" + + "id=" + id + + ", name='" + name + '\'' + + ", code='" + code + '\'' + + ", sort=" + sort + + ", defaultValue=" + defaultValue + + ", description='" + description + '\'' + + ", status=" + status + + ", createdAt=" + createdAt + + ", updatedAt=" + updatedAt + + '}'; + } + + /** + * Creates a new builder instance. + * + * @return a new RoleViewBuilder + */ + public static RoleViewBuilder builder() { + return new RoleViewBuilder(); + } + + /** + * Builder class for RoleView. + */ + public static class RoleViewBuilder { + private Long id; + private String name; + private String code; + private Integer sort; + private Boolean defaultValue; + private String description; + private Status status; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + + private RoleViewBuilder() { + } + + public RoleViewBuilder id(Long id) { + this.id = id; + return this; + } + + public RoleViewBuilder name(String name) { + this.name = name; + return this; + } + + public RoleViewBuilder code(String code) { + this.code = code; + return this; + } + + public RoleViewBuilder sort(Integer sort) { + this.sort = sort; + return this; + } + + public RoleViewBuilder defaultValue(Boolean defaultValue) { + this.defaultValue = defaultValue; + return this; + } + + public RoleViewBuilder description(String description) { + this.description = description; + return this; + } + + public RoleViewBuilder status(Status status) { + this.status = status; + return this; + } + + public RoleViewBuilder createdAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + return this; + } + + public RoleViewBuilder updatedAt(LocalDateTime updatedAt) { + this.updatedAt = updatedAt; + return this; + } + + public RoleView build() { + return new RoleView(id, name, code, sort, defaultValue, description, status, createdAt, updatedAt); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/onixbyte/helix/domain/view/UserIdentityView.java b/src/main/java/com/onixbyte/helix/domain/view/UserIdentityView.java new file mode 100644 index 0000000..d261996 --- /dev/null +++ b/src/main/java/com/onixbyte/helix/domain/view/UserIdentityView.java @@ -0,0 +1,202 @@ +package com.onixbyte.helix.domain.view; + +import com.onixbyte.helix.constant.IdentityProvider; +import com.onixbyte.helix.domain.entity.UserIdentity; + +import java.time.LocalDateTime; +import java.util.Objects; + +/** + * View object for UserIdentity entity. + *

+ * This class represents a view of the UserIdentity entity, providing a data transfer object + * for external identity mapping information in the Helix system. + * + * @author zihluwang + * @version 1.0 + * @since 1.0 + */ +public class UserIdentityView { + + /** + * The identifier of the internal user account. + */ + private Long userId; + + /** + * The external identity provider. + */ + private IdentityProvider provider; + + /** + * The unique identifier from the external provider. + */ + private String externalId; + + /** + * The timestamp when this identity mapping was created. + */ + private LocalDateTime createdAt; + + /** + * The timestamp when this identity mapping was last updated. + */ + private LocalDateTime updatedAt; + + /** + * Default constructor. + */ + public UserIdentityView() { + } + + /** + * Constructor with all fields. + */ + public UserIdentityView(Long userId, IdentityProvider provider, String externalId, + LocalDateTime createdAt, LocalDateTime updatedAt) { + this.userId = userId; + this.provider = provider; + this.externalId = externalId; + this.createdAt = createdAt; + this.updatedAt = updatedAt; + } + + /** + * Creates a UserIdentityView from a UserIdentity entity. + * + * @param userIdentity the UserIdentity entity + * @return the UserIdentityView object + */ + public static UserIdentityView fromEntity(UserIdentity userIdentity) { + if (userIdentity == null) { + return null; + } + return new UserIdentityView( + userIdentity.getUserId(), + userIdentity.getProvider(), + userIdentity.getExternalId(), + userIdentity.getCreatedAt(), + userIdentity.getUpdatedAt() + ); + } + + public Long getUserId() { + return userId; + } + + public void setUserId(Long userId) { + this.userId = userId; + } + + public IdentityProvider getProvider() { + return provider; + } + + public void setProvider(IdentityProvider provider) { + this.provider = provider; + } + + public String getExternalId() { + return externalId; + } + + public void setExternalId(String externalId) { + this.externalId = externalId; + } + + 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; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + UserIdentityView that = (UserIdentityView) o; + return Objects.equals(userId, that.userId) && + provider == that.provider && + Objects.equals(externalId, that.externalId) && + Objects.equals(createdAt, that.createdAt) && + Objects.equals(updatedAt, that.updatedAt); + } + + @Override + public int hashCode() { + return Objects.hash(userId, provider, externalId, createdAt, updatedAt); + } + + @Override + public String toString() { + return "UserIdentityView{" + + "userId=" + userId + + ", provider=" + provider + + ", externalId='" + externalId + '\'' + + ", createdAt=" + createdAt + + ", updatedAt=" + updatedAt + + '}'; + } + + /** + * Creates a new builder instance. + * + * @return a new UserIdentityViewBuilder + */ + public static UserIdentityViewBuilder builder() { + return new UserIdentityViewBuilder(); + } + + /** + * Builder class for UserIdentityView. + */ + public static class UserIdentityViewBuilder { + private Long userId; + private IdentityProvider provider; + private String externalId; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + + private UserIdentityViewBuilder() { + } + + public UserIdentityViewBuilder userId(Long userId) { + this.userId = userId; + return this; + } + + public UserIdentityViewBuilder provider(IdentityProvider provider) { + this.provider = provider; + return this; + } + + public UserIdentityViewBuilder externalId(String externalId) { + this.externalId = externalId; + return this; + } + + public UserIdentityViewBuilder createdAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + return this; + } + + public UserIdentityViewBuilder updatedAt(LocalDateTime updatedAt) { + this.updatedAt = updatedAt; + return this; + } + + public UserIdentityView build() { + return new UserIdentityView(userId, provider, externalId, createdAt, updatedAt); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/onixbyte/helix/domain/view/UserRoleView.java b/src/main/java/com/onixbyte/helix/domain/view/UserRoleView.java new file mode 100644 index 0000000..042528a --- /dev/null +++ b/src/main/java/com/onixbyte/helix/domain/view/UserRoleView.java @@ -0,0 +1,154 @@ +package com.onixbyte.helix.domain.view; + +import com.onixbyte.helix.domain.entity.UserRole; + +import java.time.LocalDateTime; +import java.util.Objects; + +/** + * View object for UserRole entity. + *

+ * This class represents a view of the UserRole entity, providing a data transfer object + * for user-role association information in the access control system. + * + * @author zihluwang + * @version 1.0 + * @since 1.0 + */ +public class UserRoleView { + + /** + * The identifier of the role in this association. + */ + private Long roleId; + + /** + * The identifier of the user in this association. + */ + private Long userId; + + /** + * The timestamp when this user-role assignment was created. + */ + private LocalDateTime createdAt; + + /** + * Default constructor. + */ + public UserRoleView() { + } + + /** + * Constructor with all fields. + */ + public UserRoleView(Long roleId, Long userId, LocalDateTime createdAt) { + this.roleId = roleId; + this.userId = userId; + this.createdAt = createdAt; + } + + /** + * Creates a UserRoleView from a UserRole entity. + * + * @param userRole the UserRole entity + * @return the UserRoleView object + */ + public static UserRoleView fromEntity(UserRole userRole) { + if (userRole == null) { + return null; + } + return new UserRoleView( + userRole.getRoleId(), + userRole.getUserId(), + userRole.getCreatedAt() + ); + } + + public Long getRoleId() { + return roleId; + } + + public void setRoleId(Long roleId) { + this.roleId = roleId; + } + + public Long getUserId() { + return userId; + } + + public void setUserId(Long userId) { + this.userId = userId; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + UserRoleView that = (UserRoleView) o; + return Objects.equals(roleId, that.roleId) && + Objects.equals(userId, that.userId) && + Objects.equals(createdAt, that.createdAt); + } + + @Override + public int hashCode() { + return Objects.hash(roleId, userId, createdAt); + } + + @Override + public String toString() { + return "UserRoleView{" + + "roleId=" + roleId + + ", userId=" + userId + + ", createdAt=" + createdAt + + '}'; + } + + /** + * Creates a new builder instance. + * + * @return a new UserRoleViewBuilder + */ + public static UserRoleViewBuilder builder() { + return new UserRoleViewBuilder(); + } + + /** + * Builder class for UserRoleView. + */ + public static class UserRoleViewBuilder { + private Long roleId; + private Long userId; + private LocalDateTime createdAt; + + private UserRoleViewBuilder() { + } + + public UserRoleViewBuilder roleId(Long roleId) { + this.roleId = roleId; + return this; + } + + public UserRoleViewBuilder userId(Long userId) { + this.userId = userId; + return this; + } + + public UserRoleViewBuilder createdAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + return this; + } + + public UserRoleView build() { + return new UserRoleView(roleId, userId, createdAt); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/onixbyte/helix/domain/view/UserView.java b/src/main/java/com/onixbyte/helix/domain/view/UserView.java new file mode 100644 index 0000000..df86f39 --- /dev/null +++ b/src/main/java/com/onixbyte/helix/domain/view/UserView.java @@ -0,0 +1,366 @@ +package com.onixbyte.helix.domain.view; + +import com.onixbyte.helix.constant.UserStatus; +import com.onixbyte.helix.domain.entity.User; + +import java.time.LocalDateTime; +import java.util.Objects; + +/** + * View object for User entity. + *

+ * This class represents a view of the User entity, providing a data transfer object + * for user information in the Helix system. + * + * @author zihluwang + * @version 1.0 + * @since 1.0 + */ +public class UserView { + + /** + * The unique identifier for the user. + */ + private Long id; + + /** + * The unique username for authentication purposes. + */ + private String username; + + /** + * The user's complete full name. + */ + private String fullName; + + /** + * The user's email address. + */ + private String email; + + /** + * The country code for the user's phone number. + */ + private String regionAbbreviation; + + /** + * The user's phone number without the country code. + */ + private String phoneNumber; + + /** + * The URL to the user's avatar image. + */ + private String avatarUrl; + + /** + * The current status of the user account. + */ + private UserStatus status; + + /** + * The identifier of the department to which this user belongs. + */ + private Long departmentId; + + /** + * The identifier of the position held by this user. + */ + private Long positionId; + + /** + * The timestamp when this user record was created. + */ + private LocalDateTime createdAt; + + /** + * The timestamp when this user record was last updated. + */ + private LocalDateTime updatedAt; + + /** + * Default constructor. + */ + public UserView() { + } + + /** + * Constructor with all fields (excluding password for security). + */ + public UserView(Long id, String username, String fullName, String email, String regionAbbreviation, + String phoneNumber, String avatarUrl, UserStatus status, Long departmentId, + Long positionId, LocalDateTime createdAt, LocalDateTime updatedAt) { + this.id = id; + this.username = username; + this.fullName = fullName; + this.email = email; + this.regionAbbreviation = regionAbbreviation; + this.phoneNumber = phoneNumber; + this.avatarUrl = avatarUrl; + this.status = status; + this.departmentId = departmentId; + this.positionId = positionId; + this.createdAt = createdAt; + this.updatedAt = updatedAt; + } + + /** + * Creates a UserView from a User entity (excluding password for security). + * + * @param user the User entity + * @return the UserView object + */ + public static UserView fromEntity(User user) { + if (user == null) { + return null; + } + return new UserView( + user.getId(), + user.getUsername(), + user.getFullName(), + user.getEmail(), + user.getRegionAbbreviation(), + user.getPhoneNumber(), + user.getAvatarUrl(), + user.getStatus(), + user.getDepartmentId(), + user.getPositionId(), + user.getCreatedAt(), + user.getUpdatedAt() + ); + } + + 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 getFullName() { + return fullName; + } + + public void setFullName(String fullName) { + this.fullName = fullName; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + public String getRegionAbbreviation() { + return regionAbbreviation; + } + + public void setRegionAbbreviation(String regionAbbreviation) { + this.regionAbbreviation = regionAbbreviation; + } + + public String getPhoneNumber() { + return phoneNumber; + } + + public void setPhoneNumber(String phoneNumber) { + this.phoneNumber = phoneNumber; + } + + public String getAvatarUrl() { + return avatarUrl; + } + + public void setAvatarUrl(String avatarUrl) { + this.avatarUrl = avatarUrl; + } + + public UserStatus getStatus() { + return status; + } + + public void setStatus(UserStatus status) { + this.status = status; + } + + public Long getDepartmentId() { + return departmentId; + } + + public void setDepartmentId(Long departmentId) { + this.departmentId = departmentId; + } + + public Long getPositionId() { + return positionId; + } + + public void setPositionId(Long positionId) { + this.positionId = positionId; + } + + 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; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + UserView userView = (UserView) o; + return Objects.equals(id, userView.id) && + Objects.equals(username, userView.username) && + Objects.equals(fullName, userView.fullName) && + Objects.equals(email, userView.email) && + Objects.equals(regionAbbreviation, userView.regionAbbreviation) && + Objects.equals(phoneNumber, userView.phoneNumber) && + Objects.equals(avatarUrl, userView.avatarUrl) && + status == userView.status && + Objects.equals(departmentId, userView.departmentId) && + Objects.equals(positionId, userView.positionId) && + Objects.equals(createdAt, userView.createdAt) && + Objects.equals(updatedAt, userView.updatedAt); + } + + @Override + public int hashCode() { + return Objects.hash(id, username, fullName, email, regionAbbreviation, phoneNumber, avatarUrl, + status, departmentId, positionId, createdAt, updatedAt); + } + + @Override + public String toString() { + return "UserView{" + + "id=" + id + + ", username='" + username + '\'' + + ", fullName='" + fullName + '\'' + + ", email='" + email + '\'' + + ", regionAbbreviation='" + regionAbbreviation + '\'' + + ", phoneNumber='" + phoneNumber + '\'' + + ", avatarUrl='" + avatarUrl + '\'' + + ", status=" + status + + ", departmentId=" + departmentId + + ", positionId=" + positionId + + ", createdAt=" + createdAt + + ", updatedAt=" + updatedAt + + '}'; + } + + /** + * Creates a new builder instance. + * + * @return a new UserViewBuilder + */ + public static UserViewBuilder builder() { + return new UserViewBuilder(); + } + + /** + * Builder class for UserView. + */ + public static class UserViewBuilder { + private Long id; + private String username; + private String fullName; + private String email; + private String regionAbbreviation; + private String phoneNumber; + private String avatarUrl; + private UserStatus status; + private Long departmentId; + private Long positionId; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + + private UserViewBuilder() { + } + + public UserViewBuilder id(Long id) { + this.id = id; + return this; + } + + public UserViewBuilder username(String username) { + this.username = username; + return this; + } + + public UserViewBuilder fullName(String fullName) { + this.fullName = fullName; + return this; + } + + public UserViewBuilder email(String email) { + this.email = email; + return this; + } + + public UserViewBuilder regionAbbreviation(String regionAbbreviation) { + this.regionAbbreviation = regionAbbreviation; + return this; + } + + public UserViewBuilder phoneNumber(String phoneNumber) { + this.phoneNumber = phoneNumber; + return this; + } + + public UserViewBuilder avatarUrl(String avatarUrl) { + this.avatarUrl = avatarUrl; + return this; + } + + public UserViewBuilder status(UserStatus status) { + this.status = status; + return this; + } + + public UserViewBuilder departmentId(Long departmentId) { + this.departmentId = departmentId; + return this; + } + + public UserViewBuilder positionId(Long positionId) { + this.positionId = positionId; + return this; + } + + public UserViewBuilder createdAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + return this; + } + + public UserViewBuilder updatedAt(LocalDateTime updatedAt) { + this.updatedAt = updatedAt; + return this; + } + + public UserView build() { + return new UserView(id, username, fullName, email, regionAbbreviation, phoneNumber, avatarUrl, + status, departmentId, positionId, createdAt, updatedAt); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/onixbyte/helix/domain/web/request/AddUserRequest.java b/src/main/java/com/onixbyte/helix/domain/web/request/AddUserRequest.java new file mode 100644 index 0000000..9162a5c --- /dev/null +++ b/src/main/java/com/onixbyte/helix/domain/web/request/AddUserRequest.java @@ -0,0 +1,24 @@ +package com.onixbyte.helix.domain.web.request; + +import com.onixbyte.helix.constant.UserStatus; +import jakarta.validation.constraints.NotBlank; + +import java.util.List; + +public record AddUserRequest( + @NotBlank(message = "Username cannot be empty.") + String username, + @NotBlank(message = "Password cannot be empty.") + String password, + @NotBlank(message = "Full name cannot be empty.") + String fullName, + String email, + String regionAbbreviation, + String phoneNumber, + String avatarUrl, + UserStatus status, + Long departmentId, + Long positionId, + List roleIds +) { +} diff --git a/src/main/java/com/onixbyte/helix/domain/web/request/QueryRoleRequest.java b/src/main/java/com/onixbyte/helix/domain/web/request/QueryRoleRequest.java new file mode 100644 index 0000000..6497c75 --- /dev/null +++ b/src/main/java/com/onixbyte/helix/domain/web/request/QueryRoleRequest.java @@ -0,0 +1,13 @@ +package com.onixbyte.helix.domain.web.request; + +import jakarta.validation.constraints.Pattern; + +public record QueryRoleRequest( + String name, + String code, + @Pattern( + regexp = "^(ACTIVE|INACTIVE)?$", + message = "状态仅可以是 ACTIVE、INACTIVE 其中之一") + String status +) { +} diff --git a/src/main/java/com/onixbyte/helix/domain/web/request/QueryUserRequest.java b/src/main/java/com/onixbyte/helix/domain/web/request/QueryUserRequest.java new file mode 100644 index 0000000..9d53f08 --- /dev/null +++ b/src/main/java/com/onixbyte/helix/domain/web/request/QueryUserRequest.java @@ -0,0 +1,19 @@ +package com.onixbyte.helix.domain.web.request; + +import jakarta.validation.constraints.Pattern; + +import java.time.LocalDateTime; + +public record QueryUserRequest( + Long departmentId, + String username, + String regionAbbreviation, + String phoneNumber, + @Pattern( + regexp = "^(ACTIVE|INACTIVE|LOCKED)?$", + message = "状态仅可以是 ACTIVE、INACTIVE 或 LOCKED 其中之一") + String status, + LocalDateTime createdAtStart, + LocalDateTime createdAtEnd +) { +} diff --git a/src/main/java/com/onixbyte/helix/domain/web/request/ResetPasswordRequest.java b/src/main/java/com/onixbyte/helix/domain/web/request/ResetPasswordRequest.java new file mode 100644 index 0000000..2ee0a27 --- /dev/null +++ b/src/main/java/com/onixbyte/helix/domain/web/request/ResetPasswordRequest.java @@ -0,0 +1,10 @@ +package com.onixbyte.helix.domain.web.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +public record ResetPasswordRequest( + @NotNull(message = "用户 ID 不能为空") Long id, + @NotBlank(message = "密码不能为空") String password +) { +} diff --git a/src/main/java/com/onixbyte/helix/domain/web/request/UpdateUserRequest.java b/src/main/java/com/onixbyte/helix/domain/web/request/UpdateUserRequest.java new file mode 100644 index 0000000..4c53655 --- /dev/null +++ b/src/main/java/com/onixbyte/helix/domain/web/request/UpdateUserRequest.java @@ -0,0 +1,22 @@ +package com.onixbyte.helix.domain.web.request; + +import com.onixbyte.helix.constant.UserStatus; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; + +import java.util.List; + +public record UpdateUserRequest( + @NotNull(message = "User ID cannot be null") + @Positive(message = "User ID must be positive") + Long id, + String fullName, + String email, + String regionAbbreviation, + String phoneNumber, + String avatarUrl, + UserStatus status, + Long departmentId, + Long positionId +) { +} diff --git a/src/main/java/com/onixbyte/helix/domain/web/request/UsernamePasswordLoginRequest.java b/src/main/java/com/onixbyte/helix/domain/web/request/UsernamePasswordLoginRequest.java new file mode 100644 index 0000000..8e95e11 --- /dev/null +++ b/src/main/java/com/onixbyte/helix/domain/web/request/UsernamePasswordLoginRequest.java @@ -0,0 +1,9 @@ +package com.onixbyte.helix.domain.web.request; + +public record UsernamePasswordLoginRequest( + String username, + String password, + String uuid, + String captcha +) { +} diff --git a/src/main/java/com/onixbyte/helix/domain/web/response/BizExceptionResponse.java b/src/main/java/com/onixbyte/helix/domain/web/response/BizExceptionResponse.java new file mode 100644 index 0000000..d7c4e07 --- /dev/null +++ b/src/main/java/com/onixbyte/helix/domain/web/response/BizExceptionResponse.java @@ -0,0 +1,38 @@ +package com.onixbyte.helix.domain.web.response; + +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * Record representing a standardised business exception response. + *

+ * This record encapsulates the essential information returned to clients when business logic + * exceptions occur within the Helix application. It provides a consistent structure for + * error communication, ensuring that all business exception responses follow the same format and + * contain the necessary information for client-side error handling. + *

+ * The response includes a timestamp indicating when the exception occurred and a human-readable + * message explaining the nature of the error. This standardised format enables consistent error + * handling across different client applications and API consumers. + *

+ * As a record, this class is immutable and provides automatic implementations of {@code equals()}, + * {@code hashCode()}, and {@code toString()} methods, making it suitable for use in functional + * programming patterns and ensuring thread safety in concurrent environments. + *

+ * This response entity is typically used by exception handlers and error processing components to + * communicate business rule violations, validation failures, and other application-specific errors + * to API clients. + * + * @param timestamp the timestamp when the exception occurred, providing temporal context for error + * tracking and debugging + * @param message a human-readable explanation of the exception, suitable for display to end users + * or logging purposes + * @author zihluwang + * @see Serializable + * @since 1.0.0 + */ +public record BizExceptionResponse( + LocalDateTime timestamp, + String message +) implements Serializable { +} diff --git a/src/main/java/com/onixbyte/helix/domain/web/response/CaptchaResponse.java b/src/main/java/com/onixbyte/helix/domain/web/response/CaptchaResponse.java new file mode 100644 index 0000000..9ff3265 --- /dev/null +++ b/src/main/java/com/onixbyte/helix/domain/web/response/CaptchaResponse.java @@ -0,0 +1,7 @@ +package com.onixbyte.helix.domain.web.response; + +public record CaptchaResponse( + String captcha, + String uuid +) { +} diff --git a/src/main/java/com/onixbyte/helix/domain/web/response/FileUploadResponse.java b/src/main/java/com/onixbyte/helix/domain/web/response/FileUploadResponse.java new file mode 100644 index 0000000..65e887c --- /dev/null +++ b/src/main/java/com/onixbyte/helix/domain/web/response/FileUploadResponse.java @@ -0,0 +1,9 @@ +package com.onixbyte.helix.domain.web.response; + +public record FileUploadResponse( + String originalFileName, + String contentType, + Long size, + String url +) { +} diff --git a/src/main/java/com/onixbyte/helix/domain/web/response/LoginSuccessResponse.java b/src/main/java/com/onixbyte/helix/domain/web/response/LoginSuccessResponse.java new file mode 100644 index 0000000..94f1643 --- /dev/null +++ b/src/main/java/com/onixbyte/helix/domain/web/response/LoginSuccessResponse.java @@ -0,0 +1,13 @@ +package com.onixbyte.helix.domain.web.response; + +import com.onixbyte.helix.domain.entity.User; + +public record LoginSuccessResponse( + String accessToken, + User user +) { + + public LoginSuccessResponse { + user.setPassword(null); + } +} diff --git a/src/main/java/com/onixbyte/helix/domain/web/response/UserDetailResponse.java b/src/main/java/com/onixbyte/helix/domain/web/response/UserDetailResponse.java new file mode 100644 index 0000000..7c50647 --- /dev/null +++ b/src/main/java/com/onixbyte/helix/domain/web/response/UserDetailResponse.java @@ -0,0 +1,252 @@ +package com.onixbyte.helix.domain.web.response; + +import com.onixbyte.helix.constant.UserStatus; + +import java.time.LocalDateTime; + +public class UserDetailResponse { + private String id; + private String username; + private String fullName; + private String email; + private String regionAbbreviation; + private String phoneNumber; + private String avatarUrl; + private UserStatus status; + private Long departmentId; + private String departmentName; + private Long positionId; + private String positionName; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + + public UserDetailResponse() { + } + + public UserDetailResponse(String id, String username, String fullName, String email, String regionAbbreviation, String phoneNumber, String avatarUrl, UserStatus status, Long departmentId, String departmentName, Long positionId, String positionName, LocalDateTime createdAt, LocalDateTime updatedAt) { + this.id = id; + this.username = username; + this.fullName = fullName; + this.email = email; + this.regionAbbreviation = regionAbbreviation; + this.phoneNumber = phoneNumber; + this.avatarUrl = avatarUrl; + this.status = status; + this.departmentId = departmentId; + this.departmentName = departmentName; + this.positionId = positionId; + this.positionName = positionName; + this.createdAt = createdAt; + this.updatedAt = updatedAt; + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getFullName() { + return fullName; + } + + public void setFullName(String fullName) { + this.fullName = fullName; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + public String getRegionAbbreviation() { + return regionAbbreviation; + } + + public void setRegionAbbreviation(String regionAbbreviation) { + this.regionAbbreviation = regionAbbreviation; + } + + public String getPhoneNumber() { + return phoneNumber; + } + + public void setPhoneNumber(String phoneNumber) { + this.phoneNumber = phoneNumber; + } + + public String getAvatarUrl() { + return avatarUrl; + } + + public void setAvatarUrl(String avatarUrl) { + this.avatarUrl = avatarUrl; + } + + public UserStatus getStatus() { + return status; + } + + public void setStatus(UserStatus status) { + this.status = status; + } + + public Long getDepartmentId() { + return departmentId; + } + + public void setDepartmentId(Long departmentId) { + this.departmentId = departmentId; + } + + public String getDepartmentName() { + return departmentName; + } + + public void setDepartmentName(String departmentName) { + this.departmentName = departmentName; + } + + public Long getPositionId() { + return positionId; + } + + public void setPositionId(Long positionId) { + this.positionId = positionId; + } + + public String getPositionName() { + return positionName; + } + + public void setPositionName(String positionName) { + this.positionName = positionName; + } + + 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; + } + + public static UserDetailResponseBuilder builder() { + return new UserDetailResponseBuilder(); + } + + public static class UserDetailResponseBuilder { + private String id; + private String username; + private String fullName; + private String email; + private String regionAbbreviation; + private String phoneNumber; + private String avatarUrl; + private UserStatus status; + private Long departmentId; + private String departmentName; + private Long positionId; + private String positionName; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + + private UserDetailResponseBuilder() { + } + + public UserDetailResponseBuilder id(String id) { + this.id = id; + return this; + } + + public UserDetailResponseBuilder username(String username) { + this.username = username; + return this; + } + + public UserDetailResponseBuilder fullName(String fullName) { + this.fullName = fullName; + return this; + } + + public UserDetailResponseBuilder email(String email) { + this.email = email; + return this; + } + + public UserDetailResponseBuilder regionAbbreviation(String regionAbbreviation) { + this.regionAbbreviation = regionAbbreviation; + return this; + } + + public UserDetailResponseBuilder phoneNumber(String phoneNumber) { + this.phoneNumber = phoneNumber; + return this; + } + + public UserDetailResponseBuilder avatarUrl(String avatarUrl) { + this.avatarUrl = avatarUrl; + return this; + } + + public UserDetailResponseBuilder status(UserStatus status) { + this.status = status; + return this; + } + + public UserDetailResponseBuilder departmentId(Long departmentId) { + this.departmentId = departmentId; + return this; + } + + public UserDetailResponseBuilder departmentName(String departmentName) { + this.departmentName = departmentName; + return this; + } + + public UserDetailResponseBuilder positionId(Long positionId) { + this.positionId = positionId; + return this; + } + + public UserDetailResponseBuilder positionName(String positionName) { + this.positionName = positionName; + return this; + } + + public UserDetailResponseBuilder createdAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + return this; + } + + public UserDetailResponseBuilder updatedAt(LocalDateTime updatedAt) { + this.updatedAt = updatedAt; + return this; + } + + public UserDetailResponse build() { + return new UserDetailResponse(id, username, fullName, email, regionAbbreviation, phoneNumber, avatarUrl, status, departmentId, departmentName, positionId, positionName, createdAt, updatedAt); + } + } +} diff --git a/src/main/java/com/onixbyte/helix/exception/BizException.java b/src/main/java/com/onixbyte/helix/exception/BizException.java new file mode 100644 index 0000000..f898daf --- /dev/null +++ b/src/main/java/com/onixbyte/helix/exception/BizException.java @@ -0,0 +1,74 @@ +package com.onixbyte.helix.exception; + +import org.springframework.http.HttpStatus; + +/** + * Custom runtime exception for business logic violations and application-specific errors. + *

+ * This exception is designed to handle business rule violations, validation failures, and other + * application-specific error conditions that occur during normal operation. Unlike + * system exceptions, business exceptions are expected and should be handled gracefully by the + * application's error handling mechanisms. + *

+ * Each business exception carries an HTTP status code that indicates the appropriate response + * status to return to clients when the exception occurs. This enables consistent error handling + * across REST API endpoints and provides meaningful HTTP responses to API consumers. + *

+ * Common use cases include: + *

    + *
  • Resource not found scenarios (404 Not Found)
  • + *
  • Validation failures (400 Bad Request)
  • + *
  • Authorisation violations (403 Forbidden)
  • + *
  • Business rule violations (422 Unprocessable Entity)
  • + *
  • Conflict situations (409 Conflict)
  • + *
+ *

+ * The exception integrates seamlessly with Spring Boot's exception handling framework and can be + * caught by global exception handlers to produce standardised error responses. + * + * @author zihluwang + * @see RuntimeException + * @see org.springframework.http.HttpStatus + * @since 1.0.0 + */ +public class BizException extends RuntimeException { + + /** + * The HTTP status code associated with this business exception. + *

+ * This status code indicates the appropriate HTTP response status that should be returned to + * clients when this exception occurs. It enables consistent error handling across + * REST API endpoints. + */ + private final HttpStatus status; + + /** + * Constructs a new business exception with the specified HTTP status and message. + * + * @param message the detailed error message explaining the business logic violation + */ + public BizException(String message) { + super(message); + this.status = HttpStatus.INTERNAL_SERVER_ERROR; + } + + /** + * Constructs a new business exception with the specified HTTP status and message. + * + * @param status the HTTP status code to associate with this exception + * @param message the detailed error message explaining the business logic violation + */ + public BizException(HttpStatus status, String message) { + super(message); + this.status = status; + } + + /** + * Returns the HTTP status code associated with this business exception. + * + * @return the HTTP status code that should be used in the error response + */ + public HttpStatus getStatus() { + return status; + } +} diff --git a/src/main/java/com/onixbyte/helix/extension/package-info.java b/src/main/java/com/onixbyte/helix/extension/package-info.java new file mode 100644 index 0000000..5bdf552 --- /dev/null +++ b/src/main/java/com/onixbyte/helix/extension/package-info.java @@ -0,0 +1,43 @@ +/** + * Extension and plugin support package for the Helix application. + *

+ * This package is designed to contain extension points, plugin interfaces, + * and extensibility mechanisms that allow the Helix application to be + * extended with additional functionality without modifying core components. + *

+ * Intended Contents: + *

    + *
  • Extension Interfaces: Define contracts for pluggable components
  • + *
  • Plugin Loaders: Mechanisms for discovering and loading extensions
  • + *
  • + * Extension Points: Well-defined points where custom functionality can + * be injected + *
  • + *
  • Custom Annotations: Annotations for marking and configuring extensions
  • + *
+ *

+ * Design Principles: + *

    + *
  • + * Loose Coupling: Extensions should be loosely coupled to core functionality + *
  • + *
  • + * Service Provider Interface (SPI): Use SPI patterns for plugin discovery + *
  • + *
  • + * Configuration-Driven: Allow extensions to be configured through + * application properties + *
  • + *
  • + * Lifecycle Management: Provide proper initialisation and cleanup for extensions + *
  • + *
+ *

+ * This package follows the extensibility patterns commonly used in enterprise applications to + * support customisation and third-party integrations whilst maintaining system stability + * and performance. + * + * @author zihluwang + * @since 1.0.0 + */ +package com.onixbyte.helix.extension; \ No newline at end of file diff --git a/src/main/java/com/onixbyte/helix/extension/redis/serializer/JacksonSerialiser.java b/src/main/java/com/onixbyte/helix/extension/redis/serializer/JacksonSerialiser.java new file mode 100644 index 0000000..01cef3b --- /dev/null +++ b/src/main/java/com/onixbyte/helix/extension/redis/serializer/JacksonSerialiser.java @@ -0,0 +1,21 @@ +package com.onixbyte.helix.extension.redis.serializer; + +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; + +public class JacksonSerialiser { + + public static final GenericJackson2JsonRedisSerializer INSTANCE = initialiseSerializer(); + + private static GenericJackson2JsonRedisSerializer initialiseSerializer() { + var serializer = new GenericJackson2JsonRedisSerializer(); + + serializer.configure((configurer) -> { + configurer.registerModule(new JavaTimeModule()); + configurer.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + }); + + return serializer; + } +} diff --git a/src/main/java/com/onixbyte/helix/filter/TokenAuthenticationFilter.java b/src/main/java/com/onixbyte/helix/filter/TokenAuthenticationFilter.java new file mode 100644 index 0000000..a22d041 --- /dev/null +++ b/src/main/java/com/onixbyte/helix/filter/TokenAuthenticationFilter.java @@ -0,0 +1,81 @@ +package com.onixbyte.helix.filter; + +import com.auth0.jwt.JWT; +import com.auth0.jwt.algorithms.Algorithm; +import com.auth0.jwt.exceptions.JWTVerificationException; +import com.onixbyte.helix.manager.AuthorityManager; +import com.onixbyte.helix.manager.UserManager; +import com.onixbyte.helix.security.authentication.UsernamePasswordAuthentication; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.jspecify.annotations.NonNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.Objects; + +@Component +public class TokenAuthenticationFilter extends OncePerRequestFilter { + + private final static Logger log = LoggerFactory.getLogger(TokenAuthenticationFilter.class); + + private final Algorithm algorithm; + private final UserManager userManager; + private final AuthorityManager authorityManager; + + public TokenAuthenticationFilter(Algorithm algorithm, UserManager userManager, AuthorityManager authorityManager) { + this.algorithm = algorithm; + this.userManager = userManager; + this.authorityManager = authorityManager; + } + + @Override + protected void doFilterInternal( + @NonNull HttpServletRequest request, + @NonNull HttpServletResponse response, + @NonNull FilterChain filterChain + ) throws ServletException, IOException { + var token = request.getHeader("Authorization"); + if (Objects.isNull(token) || token.isBlank()) { + filterChain.doFilter(request, response); + return; + } + + if (!token.startsWith("Bearer ")) { + filterChain.doFilter(request, response); + return; + } + + token = token.substring(7); + var verifier = JWT.require(algorithm) + .withIssuer("Helix Server") + .build(); + + try { + var decodedToken = verifier.verify(token); + var username = decodedToken.getSubject(); + + var user = userManager.selectByUsername(username); + var authorities = authorityManager.queryByUserId(user.getId()) + .stream() + .map((authority) -> (GrantedAuthority) authority::getCode) + .toList(); + + user.setPassword(null); + + var authentication = UsernamePasswordAuthentication.authenticated(user, authorities); + SecurityContextHolder.getContext().setAuthentication(authentication); + filterChain.doFilter(request, response); + } catch (JWTVerificationException e) { + log.error("JWT verification failed.", e); + filterChain.doFilter(request, response); + } + } +} diff --git a/src/main/java/com/onixbyte/helix/manager/ApplicationManager.java b/src/main/java/com/onixbyte/helix/manager/ApplicationManager.java new file mode 100644 index 0000000..d03f130 --- /dev/null +++ b/src/main/java/com/onixbyte/helix/manager/ApplicationManager.java @@ -0,0 +1,18 @@ +package com.onixbyte.helix.manager; + +import com.onixbyte.helix.properties.ApplicationProperties; +import org.springframework.stereotype.Component; + +@Component +public class ApplicationManager { + + private final ApplicationProperties applicationProperties; + + public ApplicationManager(ApplicationProperties applicationProperties) { + this.applicationProperties = applicationProperties; + } + + public String getDefaultEmail() { + return applicationProperties.defaultEmail(); + } +} diff --git a/src/main/java/com/onixbyte/helix/manager/AssetManager.java b/src/main/java/com/onixbyte/helix/manager/AssetManager.java new file mode 100644 index 0000000..b394abe --- /dev/null +++ b/src/main/java/com/onixbyte/helix/manager/AssetManager.java @@ -0,0 +1,40 @@ +package com.onixbyte.helix.manager; + +import com.onixbyte.helix.constant.CacheName; +import com.onixbyte.helix.domain.entity.Asset; +import com.onixbyte.helix.exception.BizException; +import com.onixbyte.helix.mapper.AssetMapper; +import com.onixbyte.helix.repository.AssetRepository; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.CachePut; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Component; + +@Component +public class AssetManager { + + private final AssetMapper assetMapper; + private final AssetRepository assetRepository; + + public AssetManager(AssetMapper assetMapper, AssetRepository assetRepository) { + this.assetMapper = assetMapper; + this.assetRepository = assetRepository; + } + + @CachePut(cacheNames = CacheName.ASSET, key = "#result.id", unless = "#result == null") + public Asset save(Asset asset) { + return assetRepository.save(asset); + } + + @Cacheable(cacheNames = CacheName.ASSET, key = "#assetId") + public Asset queryByAssetId(Long assetId) { + return assetRepository.findById(assetId) + .orElse(null); + } + + @CacheEvict(cacheNames = CacheName.ASSET, key = "#assetId") + public void deleteById(Long assetId) { + assetRepository.deleteById(assetId); + } +} diff --git a/src/main/java/com/onixbyte/helix/manager/AuthorityManager.java b/src/main/java/com/onixbyte/helix/manager/AuthorityManager.java new file mode 100644 index 0000000..8b11921 --- /dev/null +++ b/src/main/java/com/onixbyte/helix/manager/AuthorityManager.java @@ -0,0 +1,24 @@ +package com.onixbyte.helix.manager; + +import com.onixbyte.helix.constant.CacheName; +import com.onixbyte.helix.domain.entity.Authority; +import com.onixbyte.helix.mapper.AuthorityMapper; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Component +public class AuthorityManager { + + private final AuthorityMapper authorityMapper; + + public AuthorityManager(AuthorityMapper authorityMapper) { + this.authorityMapper = authorityMapper; + } + + @Cacheable(cacheNames = CacheName.AUTHORITIES_OF_USER, key = "#userId") + public List queryByUserId(Long userId) { + return authorityMapper.selectByUserId(userId); + } +} diff --git a/src/main/java/com/onixbyte/helix/manager/CaptchaManager.java b/src/main/java/com/onixbyte/helix/manager/CaptchaManager.java new file mode 100644 index 0000000..605918a --- /dev/null +++ b/src/main/java/com/onixbyte/helix/manager/CaptchaManager.java @@ -0,0 +1,39 @@ +package com.onixbyte.helix.manager; + +import com.onixbyte.helix.client.RedisClient; +import com.onixbyte.helix.constant.CacheName; +import com.onixbyte.helix.properties.CaptchaProperties; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.cache.Cache; +import org.springframework.data.redis.cache.RedisCacheManager; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Component; + +import java.time.Duration; +import java.util.Objects; + +@Component +public class CaptchaManager { + + private final RedisClient redisClient; + + @Autowired + public CaptchaManager(RedisClient redisClient) { + this.redisClient = redisClient; + } + + public void setCaptcha(String uuid, String captchaCode) { + redisClient.set(buildCacheKey(uuid), captchaCode, Duration.ofMinutes(5L)); + } + + public String getCaptcha(String uuid) { + var key = buildCacheKey(uuid); + var captcha = redisClient.get(key, String.class); + redisClient.delete(key); + return captcha; + } + + private String buildCacheKey(String uuid) { + return "captcha::" + uuid; + } +} diff --git a/src/main/java/com/onixbyte/helix/manager/DepartmentManager.java b/src/main/java/com/onixbyte/helix/manager/DepartmentManager.java new file mode 100644 index 0000000..9967c5c --- /dev/null +++ b/src/main/java/com/onixbyte/helix/manager/DepartmentManager.java @@ -0,0 +1,26 @@ +package com.onixbyte.helix.manager; + +import com.onixbyte.helix.domain.entity.Department; +import com.onixbyte.helix.mapper.DepartmentMapper; +import com.onixbyte.helix.repository.DepartmentRepository; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; + +import java.util.*; + +@Component +public class DepartmentManager { + + private final DepartmentMapper departmentMapper; + private final DepartmentRepository departmentRepository; + + public DepartmentManager(DepartmentMapper departmentMapper, DepartmentRepository departmentRepository) { + this.departmentMapper = departmentMapper; + this.departmentRepository = departmentRepository; + } + + public Page selectAll(Pageable pageable) { + return departmentRepository.findAll(pageable); + } +} diff --git a/src/main/java/com/onixbyte/helix/manager/MenuManager.java b/src/main/java/com/onixbyte/helix/manager/MenuManager.java new file mode 100644 index 0000000..e22f356 --- /dev/null +++ b/src/main/java/com/onixbyte/helix/manager/MenuManager.java @@ -0,0 +1,23 @@ +package com.onixbyte.helix.manager; + +import com.onixbyte.helix.domain.entity.Menu; +import com.onixbyte.helix.mapper.MenuMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Component +public class MenuManager { + private static final Logger log = LoggerFactory.getLogger(MenuManager.class); + private final MenuMapper menuMapper; + + public MenuManager(MenuMapper menuMapper) { + this.menuMapper = menuMapper; + } + + public List

selectActiveMenusByUserId(Long userId) { + return menuMapper.selectActiveMenusByUserId(userId); + } +} diff --git a/src/main/java/com/onixbyte/helix/manager/PositionManager.java b/src/main/java/com/onixbyte/helix/manager/PositionManager.java new file mode 100644 index 0000000..d9c9454 --- /dev/null +++ b/src/main/java/com/onixbyte/helix/manager/PositionManager.java @@ -0,0 +1,25 @@ +package com.onixbyte.helix.manager; + +import com.onixbyte.helix.domain.entity.Position; +import com.onixbyte.helix.mapper.PositionMapper; +import com.onixbyte.helix.repository.PositionRepository; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; + +@Component +public class PositionManager { + + private final PositionMapper positionMapper; + private final PositionRepository positionRepository; + + public PositionManager(PositionMapper positionMapper, PositionRepository positionRepository) { + this.positionMapper = positionMapper; + this.positionRepository = positionRepository; + } + + public Page selectAll(Pageable pageable) { + return positionRepository.findAll(pageable); + } +} diff --git a/src/main/java/com/onixbyte/helix/manager/RoleManager.java b/src/main/java/com/onixbyte/helix/manager/RoleManager.java new file mode 100644 index 0000000..3bc32e1 --- /dev/null +++ b/src/main/java/com/onixbyte/helix/manager/RoleManager.java @@ -0,0 +1,45 @@ +package com.onixbyte.helix.manager; + +import com.onixbyte.helix.domain.database.query.wrapper.QueryRoleWrapper; +import com.onixbyte.helix.domain.entity.Role; +import com.onixbyte.helix.exception.BizException; +import com.onixbyte.helix.mapper.RoleMapper; +import com.onixbyte.helix.repository.RoleRepository; +import org.springframework.data.domain.Example; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Optional; + +@Component +public class RoleManager { + + private final RoleMapper roleMapper; + private final RoleRepository roleRepository; + + public RoleManager(RoleMapper roleMapper, RoleRepository roleRepository) { + this.roleMapper = roleMapper; + this.roleRepository = roleRepository; + } + + public void validateRoles(List roleIds) { + if (!roleMapper.areRolesExisted(roleIds)) { + throw new BizException(HttpStatus.BAD_REQUEST, "Role does not exist in database."); + } + } + + public Optional getRole(Role example) { + return roleRepository.findOne(Example.of(example)); + } + + public Page selectAll(Pageable pageable, QueryRoleWrapper wrapper) { + var records = roleMapper.selectAll(pageable, wrapper); + var total = roleMapper.count(wrapper); + + return new PageImpl<>(records, pageable, total); + } +} diff --git a/src/main/java/com/onixbyte/helix/manager/SettingManager.java b/src/main/java/com/onixbyte/helix/manager/SettingManager.java new file mode 100644 index 0000000..c0ebc22 --- /dev/null +++ b/src/main/java/com/onixbyte/helix/manager/SettingManager.java @@ -0,0 +1,21 @@ +package com.onixbyte.helix.manager; + +import com.onixbyte.helix.domain.entity.Setting; +import com.onixbyte.helix.repository.SettingRepository; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.stereotype.Component; + +@Component +public class SettingManager { + + private final SettingRepository settingRepository; + + public SettingManager(SettingRepository settingRepository) { + this.settingRepository = settingRepository; + } + + @Cacheable(cacheNames = "setting", key = "#name") + public Setting getSettingByName(String name) { + return settingRepository.getSettingByName(name); + } +} diff --git a/src/main/java/com/onixbyte/helix/manager/UserManager.java b/src/main/java/com/onixbyte/helix/manager/UserManager.java new file mode 100644 index 0000000..3459404 --- /dev/null +++ b/src/main/java/com/onixbyte/helix/manager/UserManager.java @@ -0,0 +1,123 @@ +package com.onixbyte.helix.manager; + +import com.onixbyte.helix.common.regex.Patterns; +import com.onixbyte.helix.constant.CacheName; +import com.onixbyte.helix.domain.database.query.wrapper.QueryUserWrapper; +import com.onixbyte.helix.domain.entity.User; +import com.onixbyte.helix.domain.web.response.UserDetailResponse; +import com.onixbyte.helix.exception.BizException; +import com.onixbyte.helix.mapper.UserMapper; +import com.onixbyte.helix.repository.UserRepository; +import com.onixbyte.region.Region; +import org.apache.commons.lang3.StringUtils; +import org.springframework.cache.annotation.CachePut; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.data.domain.Example; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.http.HttpStatus; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; + +@Component +public class UserManager { + + private final UserMapper userMapper; + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + + public UserManager(UserMapper userMapper, UserRepository userRepository, PasswordEncoder passwordEncoder) { + this.userMapper = userMapper; + this.userRepository = userRepository; + this.passwordEncoder = passwordEncoder; + } + + /** + * Get user by username, and cache this user by username. + * + * @param username username + * @return user + */ + @Cacheable(cacheNames = CacheName.USER, key = "#username", unless = "#result == null") + public User selectByUsername(String username) { + return userRepository.findOne(Example.of(User.builder() + .username(username) + .build())) + .orElse(null); + } + + /** + * Query paginated users. + * + * @param pageable page request + * @return page result + */ + public Page selectUserDetailsPage(Pageable pageable, QueryUserWrapper wrapper) { + var result = userMapper.selectListWithDetails(pageable, wrapper); + var total = userMapper.count(wrapper); + return new PageImpl<>(result, pageable, total); + } + + @CachePut(cacheNames = CacheName.USER, key = "#user.username") + public User save(User user) { + return userRepository.save(user); + } + + public UserDetailResponse queryUserDetailByUserId(Long userId) { + return userMapper.selectWithDetailByUserId(userId); + } + + public void deleteById(Long userId) { + userRepository.deleteById(userId); + } + + @CachePut(cacheNames = CacheName.USER, key = "#result.username", unless = "#result == null") + @Transactional(rollbackFor = Throwable.class) + public User updateUser(User user) { + var userToUpdate = userRepository.findById(user.getId()) + .orElseThrow(() -> new BizException(HttpStatus.BAD_REQUEST, "找不到 ID 为" + user.getId() + "的用户信息")); + + Optional.ofNullable(user.getPassword()) + .filter(StringUtils::isNotBlank) + .map(passwordEncoder::encode) + .ifPresent(userToUpdate::setPassword); + + Optional.ofNullable(user.getFullName()) + .filter(StringUtils::isNotBlank) + .ifPresent(userToUpdate::setFullName); + + Optional.ofNullable(user.getEmail()) + .filter(StringUtils::isNotBlank) + .filter((email) -> Patterns.EMAIL.asPredicate().test(email)) + .ifPresent(userToUpdate::setEmail); + + Optional.ofNullable(user.getRegionAbbreviation()) + .filter(StringUtils::isNotBlank) + .filter(Region::isValidAbbreviation) + .ifPresent(userToUpdate::setRegionAbbreviation); + + Optional.ofNullable(user.getPhoneNumber()) + .filter(StringUtils::isNotBlank) + .ifPresent(userToUpdate::setPhoneNumber); + + Optional.ofNullable(user.getAvatarUrl()) + .filter(StringUtils::isNotBlank) + .filter((avatarUrl) -> Patterns.IMAGE_URL.asPredicate().test(avatarUrl) || Patterns.GRAVATAR_IMAGE_URL.asPredicate().test(avatarUrl)) + .ifPresent(userToUpdate::setAvatarUrl); + + Optional.ofNullable(user.getStatus()) + .ifPresent(userToUpdate::setStatus); + + Optional.ofNullable(user.getDepartmentId()) + .ifPresent(userToUpdate::setDepartmentId); + + Optional.ofNullable(user.getPositionId()) + .ifPresent(userToUpdate::setPositionId); + + return userToUpdate; + } +} diff --git a/src/main/java/com/onixbyte/helix/manager/UserRoleManager.java b/src/main/java/com/onixbyte/helix/manager/UserRoleManager.java new file mode 100644 index 0000000..efe476b --- /dev/null +++ b/src/main/java/com/onixbyte/helix/manager/UserRoleManager.java @@ -0,0 +1,34 @@ +package com.onixbyte.helix.manager; + +import com.onixbyte.helix.domain.entity.UserRole; +import com.onixbyte.helix.exception.BizException; +import com.onixbyte.helix.mapper.UserRoleMapper; +import com.onixbyte.helix.repository.UserRoleRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Component +public class UserRoleManager { + + private static final Logger log = LoggerFactory.getLogger(UserRoleManager.class); + private final UserRoleMapper userRoleMapper; + private final UserRoleRepository userRoleRepository; + + public UserRoleManager(UserRoleMapper userRoleMapper, UserRoleRepository userRoleRepository) { + this.userRoleMapper = userRoleMapper; + this.userRoleRepository = userRoleRepository; + } + + public List saveBatch(List userRoles) { + return userRoleRepository.saveAll(userRoles); + } + + public void deleteByUserId(Long userId) { + var affectedRows = userRoleRepository.deleteByUserId(userId); + log.info("用户 {} 的角色关联被全部移除(共 {} 条)。", userId, affectedRows); + } +} diff --git a/src/main/java/com/onixbyte/helix/manager/package-info.java b/src/main/java/com/onixbyte/helix/manager/package-info.java new file mode 100644 index 0000000..27b5fd9 --- /dev/null +++ b/src/main/java/com/onixbyte/helix/manager/package-info.java @@ -0,0 +1,60 @@ +/** + * Business logic management and orchestration package for the Helix application. + *

+ * This package is designed to contain manager classes that orchestrate complex business operations, + * coordinate between multiple services, and handle cross-cutting concerns that span multiple + * domain boundaries. + *

+ * Manager Pattern: Managers in this package serve as facades or coordinators that + * encapsulate complex business workflows, typically involving multiple services, repositories, + * or external systems. They provide a higher-level abstraction over individual service components. + *

+ * Intended Contents: + *

    + *
  • + * Workflow Managers: Orchestrate multi-step business processes + *
  • + *
  • + * Integration Managers: Coordinate interactions with external systems + *
  • + *
  • + * Transaction Managers: Handle complex transactional scenarios + *
  • + *
  • + * Cache Managers: Manage caching strategies and cache invalidation + *
  • + *
  • + * Event Managers: Coordinate event publishing and handling + *
  • + *
+ *

+ * Design Guidelines: + *

    + *
  • + * Single Responsibility: Each manager should focus on a specific business domain + * or cross-cutting concern + *
  • + *
  • + * Service Coordination: Managers should delegate to services rather than + * implementing business logic directly + *
  • + *
  • + * Transaction Boundaries: Define clear transactional boundaries for + * complex operations + *
  • + *
  • + * Error Handling: Provide comprehensive error handling and rollback mechanisms + * for complex workflows + *
  • + *
+ *

+ * Managers typically sit between the controller layer and the service layer, providing a + * coordination point for complex operations that require multiple service interactions or + * cross-cutting concerns. + * + * @author zihluwang + * @since 1.0.0 + * @see com.onixbyte.helix.service + * @see org.springframework.transaction.annotation.Transactional + */ +package com.onixbyte.helix.manager; \ No newline at end of file diff --git a/src/main/java/com/onixbyte/helix/mapper/AssetMapper.java b/src/main/java/com/onixbyte/helix/mapper/AssetMapper.java new file mode 100644 index 0000000..55c1a36 --- /dev/null +++ b/src/main/java/com/onixbyte/helix/mapper/AssetMapper.java @@ -0,0 +1,12 @@ +package com.onixbyte.helix.mapper; + +import org.apache.ibatis.annotations.Mapper; + +/** + * Asset related database operations. + * + * @author zihluwang + */ +@Mapper +public interface AssetMapper { +} diff --git a/src/main/java/com/onixbyte/helix/mapper/AuthorityMapper.java b/src/main/java/com/onixbyte/helix/mapper/AuthorityMapper.java new file mode 100644 index 0000000..84a3394 --- /dev/null +++ b/src/main/java/com/onixbyte/helix/mapper/AuthorityMapper.java @@ -0,0 +1,19 @@ +package com.onixbyte.helix.mapper; + +import com.onixbyte.helix.domain.entity.Authority; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +@Mapper +public interface AuthorityMapper { + + /** + * Select authorities that is granted to the specific user. + * + * @param userId user ID + * @return authorities + */ + List selectByUserId(@Param("userId") Long userId); +} diff --git a/src/main/java/com/onixbyte/helix/mapper/DepartmentMapper.java b/src/main/java/com/onixbyte/helix/mapper/DepartmentMapper.java new file mode 100644 index 0000000..eba2e85 --- /dev/null +++ b/src/main/java/com/onixbyte/helix/mapper/DepartmentMapper.java @@ -0,0 +1,7 @@ +package com.onixbyte.helix.mapper; + +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface DepartmentMapper { +} diff --git a/src/main/java/com/onixbyte/helix/mapper/MenuMapper.java b/src/main/java/com/onixbyte/helix/mapper/MenuMapper.java new file mode 100644 index 0000000..320067e --- /dev/null +++ b/src/main/java/com/onixbyte/helix/mapper/MenuMapper.java @@ -0,0 +1,12 @@ +package com.onixbyte.helix.mapper; + +import com.onixbyte.helix.domain.entity.Menu; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +@Mapper +public interface MenuMapper { + List

selectActiveMenusByUserId(@Param("userId") Long userId); +} diff --git a/src/main/java/com/onixbyte/helix/mapper/PositionMapper.java b/src/main/java/com/onixbyte/helix/mapper/PositionMapper.java new file mode 100644 index 0000000..f7a8afb --- /dev/null +++ b/src/main/java/com/onixbyte/helix/mapper/PositionMapper.java @@ -0,0 +1,7 @@ +package com.onixbyte.helix.mapper; + +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface PositionMapper { +} diff --git a/src/main/java/com/onixbyte/helix/mapper/RoleMapper.java b/src/main/java/com/onixbyte/helix/mapper/RoleMapper.java new file mode 100644 index 0000000..3cfdff8 --- /dev/null +++ b/src/main/java/com/onixbyte/helix/mapper/RoleMapper.java @@ -0,0 +1,22 @@ +package com.onixbyte.helix.mapper; + +import com.onixbyte.helix.domain.database.query.wrapper.QueryRoleWrapper; +import com.onixbyte.helix.domain.entity.Role; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.springframework.data.domain.Pageable; + +import java.util.List; + +@Mapper +public interface RoleMapper { + + boolean areRolesExisted(@Param("roleIds") List roleIds); + + List selectAll( + @Param("pageable") Pageable page, + @Param("wrapper") QueryRoleWrapper wrapper + ); + + Integer count(@Param("wrapper") QueryRoleWrapper wrapper); +} diff --git a/src/main/java/com/onixbyte/helix/mapper/SettingMapper.java b/src/main/java/com/onixbyte/helix/mapper/SettingMapper.java new file mode 100644 index 0000000..fcfaf41 --- /dev/null +++ b/src/main/java/com/onixbyte/helix/mapper/SettingMapper.java @@ -0,0 +1,7 @@ +package com.onixbyte.helix.mapper; + +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface SettingMapper { +} diff --git a/src/main/java/com/onixbyte/helix/mapper/UserMapper.java b/src/main/java/com/onixbyte/helix/mapper/UserMapper.java new file mode 100644 index 0000000..79ab8d7 --- /dev/null +++ b/src/main/java/com/onixbyte/helix/mapper/UserMapper.java @@ -0,0 +1,22 @@ +package com.onixbyte.helix.mapper; + +import com.onixbyte.helix.domain.database.query.wrapper.QueryUserWrapper; +import com.onixbyte.helix.domain.web.response.UserDetailResponse; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.springframework.data.domain.Pageable; + +import java.util.List; + +@Mapper +public interface UserMapper { + + int count(@Param("wrapper") QueryUserWrapper request); + + List selectListWithDetails( + @Param("pageable") Pageable pageable, + @Param("wrapper") QueryUserWrapper wrapper + ); + + UserDetailResponse selectWithDetailByUserId(@Param("id") Long userId); +} diff --git a/src/main/java/com/onixbyte/helix/mapper/UserRoleMapper.java b/src/main/java/com/onixbyte/helix/mapper/UserRoleMapper.java new file mode 100644 index 0000000..a825db2 --- /dev/null +++ b/src/main/java/com/onixbyte/helix/mapper/UserRoleMapper.java @@ -0,0 +1,7 @@ +package com.onixbyte.helix.mapper; + +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface UserRoleMapper { +} diff --git a/src/main/java/com/onixbyte/helix/processor/package-info.java b/src/main/java/com/onixbyte/helix/processor/package-info.java new file mode 100644 index 0000000..7dec55b --- /dev/null +++ b/src/main/java/com/onixbyte/helix/processor/package-info.java @@ -0,0 +1,59 @@ +/** + * Data processing and transformation components for the Helix application. + *

+ * This package contains processors that handle data transformation, validation, and business + * logic processing. Processors in this package follow the Chain of Responsibility pattern and + * Strategy pattern to provide flexible and extensible data processing capabilities. + *

+ * Processing Architecture: The Helix application uses a processor-based + * architecture for complex data transformations and business rule applications. Processors can be + * chained together to create sophisticated data processing pipelines. + *

+ * Intended Contents: + *

    + *
  • Data Processors: Transform and validate incoming data
  • + *
  • Business Rule Processors: Apply business logic and rules
  • + *
  • Validation Processors: Perform complex validation operations
  • + *
  • Pipeline Processors: Orchestrate multiple processing steps
  • + *
  • Event Processors: Handle domain events and notifications
  • + *
+ *

+ * Design Principles: + *

    + *
  • + * Single Responsibility: Each processor handles one specific type of processing + * or transformation + *
  • + *
  • + * Composability: Processors can be combined to create complex + * processing workflows + *
  • + *
  • + * Immutability: Processors should not modify input data directly; instead, + * they should return processed results + *
  • + *
  • + * Error Handling: Processors should handle errors gracefully and provide + * meaningful error messages + *
  • + *
+ *

+ * Common Patterns: + *

    + *
  • + * Chain of Responsibility: Link processors together for sequential processing + *
  • + *
  • Strategy Pattern: Allow runtime selection of processing algorithms
  • + *
  • Template Method: Define processing skeleton with customisable steps
  • + *
  • Observer Pattern: Notify interested parties of processing events
  • + *
+ *

+ * All processors in this package should be stateless and thread-safe to support concurrent + * processing scenarios. + * + * @author zihluwang + * @since 1.0.0 + * @see com.onixbyte.helix.service + * @see com.onixbyte.helix.domain.entity + */ +package com.onixbyte.helix.processor; \ No newline at end of file diff --git a/src/main/java/com/onixbyte/helix/properties/ApplicationProperties.java b/src/main/java/com/onixbyte/helix/properties/ApplicationProperties.java new file mode 100644 index 0000000..1ce7d27 --- /dev/null +++ b/src/main/java/com/onixbyte/helix/properties/ApplicationProperties.java @@ -0,0 +1,10 @@ +package com.onixbyte.helix.properties; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.bind.DefaultValue; + +@ConfigurationProperties(prefix = "app.common") +public record ApplicationProperties( + @DefaultValue("default@helix.onixbyte.dev") String defaultEmail +) { +} diff --git a/src/main/java/com/onixbyte/helix/properties/AssetProperties.java b/src/main/java/com/onixbyte/helix/properties/AssetProperties.java new file mode 100644 index 0000000..538c475 --- /dev/null +++ b/src/main/java/com/onixbyte/helix/properties/AssetProperties.java @@ -0,0 +1,41 @@ +package com.onixbyte.helix.properties; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.bind.DefaultValue; + +/** + * Configuration properties for S3-compatible file storage services. + *

+ * This class encapsulates the essential settings required to connect to and utilise S3-based file + * storage providers. It supports custom endpoints, regions, and credentials, allowing integration + * with AWS S3 or compatible services.
+ *

+ * The configuration is intentionally minimal, with sensible defaults for common providers. Use this + * class to enable S3 storage, specify connection details, and select the provider as appropriate. + *

+ * Example usage in application.yml: + *

{@code
+ *
+ * }
+ * + * @param endpoint endpoint URL for the S3 service + * @param publicHost public host for access file anonymously + * @param pathStyle whether to enable path style for this s3 service + * @param bucket indicates which bucket is the target + * @param region region in which the S3 bucket is located + * @param accessKeyId access key ID used for authentication with the S3 provider + * @param secretAccessKey secret access key used for authentication with the S3 provider + * @author zihluwang + * @since 1.0.0 + */ +@ConfigurationProperties(prefix = "app.asset") +public record AssetProperties( + String endpoint, + String publicHost, + @DefaultValue("false") boolean pathStyle, + String bucket, + String region, + String accessKeyId, + String secretAccessKey +) { +} diff --git a/src/main/java/com/onixbyte/helix/properties/CaptchaProperties.java b/src/main/java/com/onixbyte/helix/properties/CaptchaProperties.java new file mode 100644 index 0000000..98eb110 --- /dev/null +++ b/src/main/java/com/onixbyte/helix/properties/CaptchaProperties.java @@ -0,0 +1,11 @@ +package com.onixbyte.helix.properties; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.bind.DefaultValue; + +@ConfigurationProperties(prefix = "app.captcha") +public record CaptchaProperties( + @DefaultValue("6") int length +) { +} + diff --git a/src/main/java/com/onixbyte/helix/properties/CorsProperties.java b/src/main/java/com/onixbyte/helix/properties/CorsProperties.java new file mode 100644 index 0000000..480be0c --- /dev/null +++ b/src/main/java/com/onixbyte/helix/properties/CorsProperties.java @@ -0,0 +1,82 @@ +package com.onixbyte.helix.properties; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.bind.DefaultValue; +import org.springframework.http.HttpMethod; + +import java.time.Duration; + +/** + * Configuration properties class for Cross-Origin Resource Sharing (CORS) settings. + *

+ * This class provides a type-safe way to bind CORS-related configuration properties from + * application configuration files (such as {@code application.yml} or + * {@code application.properties}) to a Java object. All properties are prefixed with + * {@code app.cors} and are automatically mapped by Spring Boot's configuration property + * binding mechanism. + *

+ * CORS Overview: Cross-Origin Resource Sharing is a security feature implemented + * by web browsers that restricts web pages from making requests to a different domain than the one + * serving the web page. This class allows fine-grained control over CORS policies for the Helix + * application's REST APIs. + *

+ * Configuration Example: + *

{@code
+ * app:
+ *   cors:
+ *     allowed-origins:
+ *       - "https://example.com"
+ *       - "https://app.example.com"
+ *     allowed-methods:
+ *       - GET
+ *       - POST
+ *       - PUT
+ *       - DELETE
+ *     allowed-headers:
+ *       - "Content-Type"
+ *       - "Authorization"
+ *     allow-credentials: true
+ *     max-age: PT1H
+ * }
+ *

+ * Security Considerations: When configuring CORS, be mindful of + * security implications. Avoid using wildcard ({@code "*"}) for origins when + * {@code allowCredentials} is set to {@code true}, as this can expose the application to + * security vulnerabilities. + *

+ * The class follows JavaBean conventions with getter and setter methods for each property, + * making it compatible with Spring's property binding and validation frameworks. + * + * @param allowedHeaders HTTP header names that are permitted in the actual request + * @param allowedMethods HTTP methods (e.g., GET, POST, PUT) that are permitted for cross-origin requests + * @param allowedOrigins origins (domains) that are allowed to make cross-origin requests. Use "*" to + * allow all origins. + * @param allowCredentials indicating whether the client is allowed to send credentials (cookies, + * HTTP authentication) with the cross-origin request + * @param allowPrivateNetwork A flag indicating whether requests from a private network IP address + * to a public network IP address are allowed + * @param maxAge maximum amount of time for which the results of a preflight request + * can be cached by the client + * @param exposedHeaders HTTP header names that are allowed to be exposed to the client in a + * cross-origin response + * @author zihluwang + * @see ConfigurationProperties + * @see org.springframework.web.cors.CorsConfiguration + * @see org.springframework.web.servlet.config.annotation.CorsRegistry + * @since 1.0.0 + */ +@ConfigurationProperties(prefix = "app.cors") +public record CorsProperties( + @DefaultValue({"Content-Type", "Authorization"}) + String[] allowedHeaders, + @DefaultValue({"GET", "POST", "PUT", "PATCH", "DELETE"}) + HttpMethod[] allowedMethods, + String[] allowedOrigins, + boolean allowCredentials, + boolean allowPrivateNetwork, + @DefaultValue("PT1H30M") + Duration maxAge, + @DefaultValue({"Content-Type", "Authorization"}) + String[] exposedHeaders +) { +} diff --git a/src/main/java/com/onixbyte/helix/properties/MsalProperties.java b/src/main/java/com/onixbyte/helix/properties/MsalProperties.java new file mode 100644 index 0000000..5172c4c --- /dev/null +++ b/src/main/java/com/onixbyte/helix/properties/MsalProperties.java @@ -0,0 +1,49 @@ +package com.onixbyte.helix.properties; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * Configuration properties for Microsoft Authentication Library (MSAL) integration. + *

+ * This class provides configuration binding for Microsoft Entra ID (formerly Azure Active Directory) + * authentication settings. It enables the Helix application to authenticate users through + * Microsoft's identity platform using the MSAL library, supporting single sign-on (SSO) + * and enterprise identity management scenarios. + *

+ * MSAL Integration: The Microsoft Authentication Library provides a unified + * way to authenticate users and acquire tokens for accessing Microsoft services and APIs. + * This configuration class specifically handles the core identifiers required for establishing + * a connection with Microsoft Entra ID. + *

+ * Configuration Example: + *

{@code
+ * app:
+ *   authentication:
+ *     msal:
+ *       client-id: "12345678-1234-1234-1234-123456789012"
+ *       tenant-id: "87654321-4321-4321-4321-210987654321"
+ * }
+ *

+ * Security Note: The client ID is considered public information and can be + * safely stored in configuration files. However, ensure that any client secrets (if used) + * are stored securely and not exposed in version control or logs. + *

+ * All properties are prefixed with {@code app.authentication.msal} and are automatically + * bound by Spring Boot's configuration property mechanism. + * + * @param tenantId directory (tenant) ID of the Microsoft Entra ID tenant where the application + * is registered + * @param clientId application (client) ID assigned to the application when registered in + * Microsoft Entra ID + * @author zihluwang + * @see ConfigurationProperties + * @see MSAL Overview + * @see Microsoft Entra ID Platform + * @since 1.0.0 + */ +@ConfigurationProperties(prefix = "app.authentication.msal") +public record MsalProperties( + String tenantId, + String clientId +) { +} diff --git a/src/main/java/com/onixbyte/helix/properties/TokenProperties.java b/src/main/java/com/onixbyte/helix/properties/TokenProperties.java new file mode 100644 index 0000000..63fc23d --- /dev/null +++ b/src/main/java/com/onixbyte/helix/properties/TokenProperties.java @@ -0,0 +1,57 @@ +package com.onixbyte.helix.properties; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +import java.time.Duration; + +/** + * Configuration properties for JSON Web Token (JWT) generation and validation. + *

+ * This class provides configuration binding for JWT-related settings used throughout + * the Helix application for user authentication and authorisation. It encapsulates + * the essential parameters required for creating, signing, and validating JWT tokens + * in a secure and consistent manner. + *

+ * JWT Overview: JSON Web Tokens are a compact, URL-safe means of + * representing claims to be transferred between two parties. In the Helix application, + * JWTs are used to maintain user session state and carry authentication information + * across API requests without requiring server-side session storage. + *

+ * Configuration Example: + *

{@code
+ * app:
+ *   jwt:
+ *     issuer: "helix-server"
+ *     secret: "your-256-bit-secret-key-here"
+ *     valid-time: PT24H  # 24 hours
+ * }
+ *

+ * Security Considerations: + *

    + *
  • Secret Key: The secret must be kept confidential and should be + * at least 256 bits (32 characters) long for HS256 algorithm security.
  • + *
  • Token Expiry: Set appropriate validity periods to balance + * user experience with security requirements.
  • + *
  • Issuer Validation: The issuer claim helps prevent token + * misuse across different applications or environments.
  • + *
+ *

+ * All properties are prefixed with {@code app.jwt} and are automatically bound + * by Spring Boot's configuration property mechanism. + * + * @param issuer name of the issuer + * @param secret secret to sign a token + * @param validTime validity duration for JWT tokens + * @author zihluwang + * @see ConfigurationProperties + * @see RFC 7519 - JSON Web Token (JWT) + * @see JWT.io - JSON Web Tokens Introduction + * @since 1.0.0 + */ +@ConfigurationProperties(prefix = "app.jwt") +public record TokenProperties( + String issuer, + String secret, + Duration validTime +) { +} diff --git a/src/main/java/com/onixbyte/helix/repository/AssetRepository.java b/src/main/java/com/onixbyte/helix/repository/AssetRepository.java new file mode 100644 index 0000000..487ccc6 --- /dev/null +++ b/src/main/java/com/onixbyte/helix/repository/AssetRepository.java @@ -0,0 +1,9 @@ +package com.onixbyte.helix.repository; + +import com.onixbyte.helix.domain.entity.Asset; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface AssetRepository extends JpaRepository { +} diff --git a/src/main/java/com/onixbyte/helix/repository/AuthorityRepository.java b/src/main/java/com/onixbyte/helix/repository/AuthorityRepository.java new file mode 100644 index 0000000..96800c2 --- /dev/null +++ b/src/main/java/com/onixbyte/helix/repository/AuthorityRepository.java @@ -0,0 +1,9 @@ +package com.onixbyte.helix.repository; + +import com.onixbyte.helix.domain.entity.Authority; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface AuthorityRepository extends JpaRepository { +} diff --git a/src/main/java/com/onixbyte/helix/repository/DepartmentRepository.java b/src/main/java/com/onixbyte/helix/repository/DepartmentRepository.java new file mode 100644 index 0000000..70ceeab --- /dev/null +++ b/src/main/java/com/onixbyte/helix/repository/DepartmentRepository.java @@ -0,0 +1,9 @@ +package com.onixbyte.helix.repository; + +import com.onixbyte.helix.domain.entity.Department; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface DepartmentRepository extends JpaRepository { +} diff --git a/src/main/java/com/onixbyte/helix/repository/MenuRepository.java b/src/main/java/com/onixbyte/helix/repository/MenuRepository.java new file mode 100644 index 0000000..d56903c --- /dev/null +++ b/src/main/java/com/onixbyte/helix/repository/MenuRepository.java @@ -0,0 +1,9 @@ +package com.onixbyte.helix.repository; + +import com.onixbyte.helix.domain.entity.Menu; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface MenuRepository extends JpaRepository { +} diff --git a/src/main/java/com/onixbyte/helix/repository/PositionRepository.java b/src/main/java/com/onixbyte/helix/repository/PositionRepository.java new file mode 100644 index 0000000..59bd864 --- /dev/null +++ b/src/main/java/com/onixbyte/helix/repository/PositionRepository.java @@ -0,0 +1,9 @@ +package com.onixbyte.helix.repository; + +import com.onixbyte.helix.domain.entity.Position; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface PositionRepository extends JpaRepository { +} diff --git a/src/main/java/com/onixbyte/helix/repository/RoleAuthorityRepository.java b/src/main/java/com/onixbyte/helix/repository/RoleAuthorityRepository.java new file mode 100644 index 0000000..972639f --- /dev/null +++ b/src/main/java/com/onixbyte/helix/repository/RoleAuthorityRepository.java @@ -0,0 +1,10 @@ +package com.onixbyte.helix.repository; + +import com.onixbyte.helix.domain.entity.RoleAuthority; +import com.onixbyte.helix.domain.entity.embeddable.RoleAuthorityId; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface RoleAuthorityRepository extends JpaRepository { +} diff --git a/src/main/java/com/onixbyte/helix/repository/RoleRepository.java b/src/main/java/com/onixbyte/helix/repository/RoleRepository.java new file mode 100644 index 0000000..57430e8 --- /dev/null +++ b/src/main/java/com/onixbyte/helix/repository/RoleRepository.java @@ -0,0 +1,9 @@ +package com.onixbyte.helix.repository; + +import com.onixbyte.helix.domain.entity.Role; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface RoleRepository extends JpaRepository { +} diff --git a/src/main/java/com/onixbyte/helix/repository/SettingRepository.java b/src/main/java/com/onixbyte/helix/repository/SettingRepository.java new file mode 100644 index 0000000..c7d9917 --- /dev/null +++ b/src/main/java/com/onixbyte/helix/repository/SettingRepository.java @@ -0,0 +1,10 @@ +package com.onixbyte.helix.repository; + +import com.onixbyte.helix.domain.entity.Setting; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface SettingRepository extends JpaRepository { + Setting getSettingByName(String name); +} diff --git a/src/main/java/com/onixbyte/helix/repository/UserIdentityRepository.java b/src/main/java/com/onixbyte/helix/repository/UserIdentityRepository.java new file mode 100644 index 0000000..932c91c --- /dev/null +++ b/src/main/java/com/onixbyte/helix/repository/UserIdentityRepository.java @@ -0,0 +1,10 @@ +package com.onixbyte.helix.repository; + +import com.onixbyte.helix.domain.entity.UserIdentity; +import com.onixbyte.helix.domain.entity.embeddable.UserIdentityId; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface UserIdentityRepository extends JpaRepository { +} diff --git a/src/main/java/com/onixbyte/helix/repository/UserRepository.java b/src/main/java/com/onixbyte/helix/repository/UserRepository.java new file mode 100644 index 0000000..eced186 --- /dev/null +++ b/src/main/java/com/onixbyte/helix/repository/UserRepository.java @@ -0,0 +1,10 @@ +package com.onixbyte.helix.repository; + +import com.onixbyte.helix.domain.entity.User; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface UserRepository extends JpaRepository { + User getUserById(Long id); +} diff --git a/src/main/java/com/onixbyte/helix/repository/UserRoleRepository.java b/src/main/java/com/onixbyte/helix/repository/UserRoleRepository.java new file mode 100644 index 0000000..8cd6a7b --- /dev/null +++ b/src/main/java/com/onixbyte/helix/repository/UserRoleRepository.java @@ -0,0 +1,16 @@ +package com.onixbyte.helix.repository; + +import com.onixbyte.helix.domain.entity.UserRole; +import com.onixbyte.helix.domain.entity.embeddable.UserRoleId; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.stereotype.Repository; + +@Repository +public interface UserRoleRepository extends JpaRepository { + + @Modifying + @Query("DELETE FROM UserRole ur WHERE ur.id.userId = :userId") + int deleteByUserId(Long userId); +} diff --git a/src/main/java/com/onixbyte/helix/security/authentication/UsernamePasswordAuthentication.java b/src/main/java/com/onixbyte/helix/security/authentication/UsernamePasswordAuthentication.java new file mode 100644 index 0000000..2b90d25 --- /dev/null +++ b/src/main/java/com/onixbyte/helix/security/authentication/UsernamePasswordAuthentication.java @@ -0,0 +1,80 @@ +package com.onixbyte.helix.security.authentication; + +import com.onixbyte.helix.domain.entity.User; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.CredentialsContainer; +import org.springframework.security.core.GrantedAuthority; +import java.util.Collection; +import java.util.List; + +public class UsernamePasswordAuthentication implements Authentication, CredentialsContainer { + private final String username; + private String password; + private boolean authenticated; + private User user; + private List authorities; + + private UsernamePasswordAuthentication(String username, String password, boolean authenticated, User user, List authorities) { + this.username = username; + this.password = password; + this.authenticated = authenticated; + this.user = user; + this.authorities = authorities; + } + + public static UsernamePasswordAuthentication unauthenticated(String username, String password) { + return new UsernamePasswordAuthentication(username, password, false, null, List.of()); + } + + public static UsernamePasswordAuthentication authenticated(User user, List authorities) { + return new UsernamePasswordAuthentication(user.getUsername(), null, true, user, authorities); + } + + @Override + public Collection getAuthorities() { + return authorities; + } + + @Override + public String getCredentials() { + return password; + } + + @Override + public User getDetails() { + return user; + } + + @Override + public String getPrincipal() { + return username; + } + + @Override + public boolean isAuthenticated() { + return authenticated; + } + + @Override + public void setAuthenticated(boolean authenticated) throws IllegalArgumentException { + this.authenticated = authenticated; + } + + @Override + public String getName() { + return username; + } + + @Override + public void eraseCredentials() { + this.password = null; + } + + public void setDetails(User user) { + this.user = user; + } + + public void setAuthorities(List authorities) { + this.authorities = authorities; + } +} diff --git a/src/main/java/com/onixbyte/helix/security/entrypoint/UnauthorisedAuthenticationEntryPoint.java b/src/main/java/com/onixbyte/helix/security/entrypoint/UnauthorisedAuthenticationEntryPoint.java new file mode 100644 index 0000000..4c3818d --- /dev/null +++ b/src/main/java/com/onixbyte/helix/security/entrypoint/UnauthorisedAuthenticationEntryPoint.java @@ -0,0 +1,36 @@ +package com.onixbyte.helix.security.entrypoint; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.onixbyte.helix.domain.web.response.BizExceptionResponse; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.time.LocalDateTime; + +@Component +public class UnauthorisedAuthenticationEntryPoint implements AuthenticationEntryPoint { + + private final ObjectMapper objectMapper; + + public UnauthorisedAuthenticationEntryPoint(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + @Override + public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException { + response.setStatus(HttpStatus.UNAUTHORIZED.value()); + response.setContentType("application/json;charset=UTF-8"); + response.getWriter().write(objectMapper.writeValueAsString(new BizExceptionResponse( + LocalDateTime.now(), + authException.getMessage() + ))); + response.getWriter().flush(); + } +} + diff --git a/src/main/java/com/onixbyte/helix/security/provider/UsernamePasswordAuthenticationProvider.java b/src/main/java/com/onixbyte/helix/security/provider/UsernamePasswordAuthenticationProvider.java new file mode 100644 index 0000000..134ba66 --- /dev/null +++ b/src/main/java/com/onixbyte/helix/security/provider/UsernamePasswordAuthenticationProvider.java @@ -0,0 +1,74 @@ +package com.onixbyte.helix.security.provider; + +import com.onixbyte.helix.domain.entity.Authority; +import com.onixbyte.helix.exception.BizException; +import com.onixbyte.helix.manager.AuthorityManager; +import com.onixbyte.helix.manager.UserManager; +import com.onixbyte.helix.security.authentication.UsernamePasswordAuthentication; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Component; + +import java.util.Objects; + +@Component +public class UsernamePasswordAuthenticationProvider implements AuthenticationProvider { + + private static final Logger log = LoggerFactory.getLogger(UsernamePasswordAuthenticationProvider.class); + private final UserManager userManager; + private final PasswordEncoder passwordEncoder; + private final AuthorityManager authorityManager; + + public UsernamePasswordAuthenticationProvider(UserManager userManager, PasswordEncoder passwordEncoder, AuthorityManager authorityManager) { + this.userManager = userManager; + this.passwordEncoder = passwordEncoder; + this.authorityManager = authorityManager; + } + + @Override + public Authentication authenticate(Authentication authentication) throws AuthenticationException { + if (!(authentication instanceof UsernamePasswordAuthentication usernamePasswordAuthentication)) { + throw new BizException(HttpStatus.INTERNAL_SERVER_ERROR, "用户认证失败,请稍后再试。"); + } + + // get user from database + var user = userManager.selectByUsername(usernamePasswordAuthentication.getPrincipal()); + if (Objects.isNull(user)) { + log.error("User {} is trying to authenticate but no user found.", usernamePasswordAuthentication.getPrincipal()); + throw new BizException(HttpStatus.UNAUTHORIZED, "用户名或密码错误。"); + } + + // validate password + if (!passwordEncoder.matches(usernamePasswordAuthentication.getCredentials(), user.getPassword())) { + log.error("User {} is trying to authenticate but password is incorrect.", usernamePasswordAuthentication.getPrincipal()); + throw new BizException(HttpStatus.UNAUTHORIZED, "用户名或密码错误。"); + } + + // erase credentials + user.setPassword(null); + usernamePasswordAuthentication.eraseCredentials(); + + // get authorities + var authorities = authorityManager.queryByUserId(user.getId()) + .stream() + .map(Authority::asGrantedAuthority) + .toList(); + + // set values + usernamePasswordAuthentication.setAuthenticated(true); + usernamePasswordAuthentication.setDetails(user); + usernamePasswordAuthentication.setAuthorities(authorities); + + return usernamePasswordAuthentication; + } + + @Override + public boolean supports(Class authentication) { + return UsernamePasswordAuthentication.class.isAssignableFrom(authentication); + } +} diff --git a/src/main/java/com/onixbyte/helix/service/AssetService.java b/src/main/java/com/onixbyte/helix/service/AssetService.java new file mode 100644 index 0000000..32bcfc8 --- /dev/null +++ b/src/main/java/com/onixbyte/helix/service/AssetService.java @@ -0,0 +1,118 @@ +package com.onixbyte.helix.service; + +import com.onixbyte.helix.domain.entity.Asset; +import com.onixbyte.helix.exception.BizException; +import com.onixbyte.helix.manager.AssetManager; +import com.onixbyte.helix.properties.AssetProperties; +import com.onixbyte.helix.utils.SecurityUtil; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.DeleteObjectRequest; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; + +import java.io.IOException; +import java.time.LocalDateTime; +import java.util.Objects; + +/** + * Service interface for file storage operations. + *

+ * Provides a unified interface for file upload, download, and deletion operations that can be + * implemented by different storage backends (local filesystem, S3-compatible services, etc.). + * + * @author zihluwang + * @since 1.0.0 + */ +@Service +public class AssetService { + + private final AssetProperties assetProperties; + private final S3Client s3Client; + private final AssetManager assetManager; + + public AssetService( + AssetProperties assetProperties, + S3Client s3Client, + AssetManager assetManager + ) { + this.assetProperties = assetProperties; + this.s3Client = s3Client; + this.assetManager = assetManager; + } + + /** + * Uploads a file to the configured storage backend. The file will be validated according to the + * storage configuration and stored with a unique filename to prevent conflicts. + * + * @param prefix prefix to the file, should not started with a {@code /} or {@code ..} + * @param file the multipart file to upload + * @return the URL or path that can be used to access the uploaded file + * @throws IllegalArgumentException if the file is invalid (empty, too large, wrong extension, + * etc.) + * @throws RuntimeException if the upload operation fails + */ + @Transactional(rollbackFor = {Throwable.class}) + public String uploadFile(String prefix, MultipartFile file) throws IOException { + if (Objects.isNull(prefix) || prefix.isBlank() || prefix.startsWith("/") || prefix.startsWith("..")) { + throw new BizException( + HttpStatus.INTERNAL_SERVER_ERROR, + "Prefix must not be empty, and should not start with '/' or '..'." + ); + } + + var currentUser = SecurityUtil.getCurrentUser(); + + var fullKey = buildFullKey(prefix, file.getOriginalFilename()); + + var request = PutObjectRequest.builder() + .bucket(assetProperties.bucket()) + .key(fullKey) + .contentType(file.getContentType()) + .contentLength(file.getSize()) + .build(); + + s3Client.putObject(request, RequestBody.fromInputStream(file.getInputStream(), file.getSize())); + + assetManager.save(Asset.builder() + .key(fullKey) + .uploadBy(currentUser.getId()) + .uploadTime(LocalDateTime.now()) + .build()); + + var fileUrlBuilder = new StringBuilder(assetProperties.publicHost()); + if (assetProperties.pathStyle()) { + fileUrlBuilder.append(assetProperties.bucket()); + } + fileUrlBuilder.append("/").append(fullKey); + return fileUrlBuilder.toString(); + } + + private String buildFullKey(String prefix, String fileName) { + return String.format("%s/%s", prefix, fileName); + } + + /** + * Delete file with given asset ID. + * + * @param assetId ID of the asset + */ + @Transactional(rollbackFor = Throwable.class) + public void deleteAsset(Long assetId) { + var currentUser = SecurityUtil.getCurrentUser(); + var asset = assetManager.queryByAssetId(assetId); + + if (!Objects.equals(currentUser.getId(), asset.getUploadBy())) { + throw new BizException(HttpStatus.FORBIDDEN, "You are not able to delete an asset that is not uploaded by you."); + } + + assetManager.deleteById(assetId); + s3Client.deleteObject(DeleteObjectRequest.builder() + .bucket(assetProperties.bucket()) + .key(asset.getKey()) + .build()); + } +} diff --git a/src/main/java/com/onixbyte/helix/service/AuthService.java b/src/main/java/com/onixbyte/helix/service/AuthService.java new file mode 100644 index 0000000..1103f5e --- /dev/null +++ b/src/main/java/com/onixbyte/helix/service/AuthService.java @@ -0,0 +1,94 @@ +package com.onixbyte.helix.service; + +import com.onixbyte.helix.client.TokenClient; +import com.onixbyte.helix.constant.SettingName; +import com.onixbyte.helix.domain.entity.Setting; +import com.onixbyte.helix.domain.web.request.UsernamePasswordLoginRequest; +import com.onixbyte.helix.domain.web.response.LoginSuccessResponse; +import com.onixbyte.helix.exception.BizException; +import com.onixbyte.helix.manager.CaptchaManager; +import com.onixbyte.helix.manager.SettingManager; +import com.onixbyte.helix.security.authentication.UsernamePasswordAuthentication; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.stereotype.Service; + +import java.util.Objects; +import java.util.Optional; + +@Service +public class AuthService { + + private static final Logger log = LoggerFactory.getLogger(AuthService.class); + + private final CaptchaManager captchaManager; + private final AuthenticationManager authenticationManager; + private final TokenClient tokenClient; + private final SettingManager settingManager; + + @Autowired + public AuthService( + CaptchaManager captchaManager, + AuthenticationManager authenticationManager, + TokenClient tokenClient, + SettingManager settingManager + ) { + this.captchaManager = captchaManager; + this.authenticationManager = authenticationManager; + this.tokenClient = tokenClient; + this.settingManager = settingManager; + } + + /** + * Perform user login. + * + * @param request user login request + * @return user information and user identity token + * @throws BizException if the user does not exist, or the username and password are incorrect + */ + public LoginSuccessResponse login(UsernamePasswordLoginRequest request) { + var captchaEnabled = Optional.ofNullable(settingManager.getSettingByName(SettingName.CAPTCHA_ENABLED)) + .map(Setting::asBoolean) + .orElse(false); + if (captchaEnabled) { + var uuid = request.uuid(); + var rawCaptcha = captchaManager.getCaptcha(uuid); + + if (Objects.isNull(rawCaptcha) || rawCaptcha.isBlank()) { + throw new BizException(HttpStatus.BAD_REQUEST, "未找到验证码"); + } + if (!rawCaptcha.equalsIgnoreCase(request.captcha())) { + throw new BizException(HttpStatus.BAD_REQUEST, "验证码错误"); + } + } + + var _authentication = authenticationManager.authenticate(UsernamePasswordAuthentication + .unauthenticated(request.username(), request.password())); + if (!(_authentication instanceof UsernamePasswordAuthentication authentication)) { + log.error( + "Type mismatched, required type is UsernamePasswordAuthentication but got {}.", + _authentication.getClass() + ); + throw new BizException(HttpStatus.INTERNAL_SERVER_ERROR, + "Cannot perform login due to server crashes."); + } + + var token = tokenClient.generateToken(authentication.getDetails()); + + return new LoginSuccessResponse(token, authentication.getDetails()); + } + + /** + * Get whether the registration function is turned on. + * + * @return {@code true} if registration is enabled, otherwise {@code false} + */ + public boolean getRegisterEnabled() { + return Optional.ofNullable(settingManager.getSettingByName(SettingName.REGISTER_ENABLED)) + .map(Setting::asBoolean) + .orElse(false); + } +} diff --git a/src/main/java/com/onixbyte/helix/service/CaptchaService.java b/src/main/java/com/onixbyte/helix/service/CaptchaService.java new file mode 100644 index 0000000..3160965 --- /dev/null +++ b/src/main/java/com/onixbyte/helix/service/CaptchaService.java @@ -0,0 +1,76 @@ +package com.onixbyte.helix.service; + +import com.onixbyte.captcha.Producer; +import com.onixbyte.helix.constant.FileType; +import com.onixbyte.helix.constant.SettingName; +import com.onixbyte.helix.domain.entity.Setting; +import com.onixbyte.helix.domain.web.response.CaptchaResponse; +import com.onixbyte.helix.exception.BizException; +import com.onixbyte.helix.manager.CaptchaManager; +import com.onixbyte.helix.manager.SettingManager; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.util.FastByteArrayOutputStream; + +import javax.imageio.ImageIO; +import java.io.IOException; +import java.util.Base64; +import java.util.Optional; +import java.util.UUID; + +@Service +public class CaptchaService { + + private final CaptchaManager captchaManager; + private final SettingManager settingManager; + + @Autowired + public CaptchaService(CaptchaManager captchaManager, SettingManager settingManager) { + this.captchaManager = captchaManager; + this.settingManager = settingManager; + } + + private Producer producer; + + @Autowired(required = false) + public void setProducer(Producer producer) { + this.producer = producer; + } + + /** + * Build captcha information. + * + * @return left value is data URL of captcha image, and right value is the identifier of + * the captcha code + */ + public CaptchaResponse buildCaptcha() { + var captchaEnabled = Optional.ofNullable(settingManager.getSettingByName(SettingName.CAPTCHA_ENABLED)) + .map(Setting::asBoolean) + .orElse(false); + if (!captchaEnabled) { + return null; + } + + // 生成 UUID 及验证码 + var uuid = UUID.randomUUID().toString().replaceAll("-", ""); + var captchaCode = producer.createText(); + + // 将验证码保存到缓存中 + captchaManager.setCaptcha(uuid, captchaCode); + + // 生成验证码图片 + var captchaImage = producer.createImage(captchaCode); + try (var byteArrayOutputStream = new FastByteArrayOutputStream()) { + ImageIO.write(captchaImage, FileType.Image.JPEG.getExtension(), byteArrayOutputStream); + var captchaDataUrl = "data:image/jpeg;base64," + + Base64.getEncoder().encodeToString(byteArrayOutputStream.toByteArray()); + return new CaptchaResponse(captchaDataUrl, uuid); + } catch (IOException e) { + throw new BizException("无法生成验证码图片。"); + } + } + + public String getCaptcha(String uuid) { + return captchaManager.getCaptcha(uuid); + } +} diff --git a/src/main/java/com/onixbyte/helix/service/DepartmentService.java b/src/main/java/com/onixbyte/helix/service/DepartmentService.java new file mode 100644 index 0000000..15f03c4 --- /dev/null +++ b/src/main/java/com/onixbyte/helix/service/DepartmentService.java @@ -0,0 +1,30 @@ +package com.onixbyte.helix.service; + +import com.onixbyte.helix.domain.entity.Department; +import com.onixbyte.helix.domain.model.TreeNode; +import com.onixbyte.helix.manager.DepartmentManager; +import com.onixbyte.helix.utils.TreeUtil; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +public class DepartmentService { + + private final DepartmentManager departmentManager; + + public DepartmentService(DepartmentManager departmentManager) { + this.departmentManager = departmentManager; + } + + public TreeNode getDepartmentTree() { + var departments = departmentManager.selectAll(Pageable.unpaged()); + return TreeUtil.buildTree(departments.getContent()); + } + + public List getDepartments() { + return departmentManager.selectAll(Pageable.unpaged()).getContent(); + } +} diff --git a/src/main/java/com/onixbyte/helix/service/MenuService.java b/src/main/java/com/onixbyte/helix/service/MenuService.java new file mode 100644 index 0000000..618ff89 --- /dev/null +++ b/src/main/java/com/onixbyte/helix/service/MenuService.java @@ -0,0 +1,28 @@ +package com.onixbyte.helix.service; + +import com.onixbyte.helix.domain.entity.Menu; +import com.onixbyte.helix.domain.model.TreeNode; +import com.onixbyte.helix.manager.MenuManager; +import com.onixbyte.helix.utils.SecurityUtil; +import com.onixbyte.helix.utils.TreeUtil; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +public class MenuService { + private static final Logger log = LoggerFactory.getLogger(MenuService.class); + private final MenuManager menuManager; + + public MenuService(MenuManager menuManager) { + this.menuManager = menuManager; + } + + public List> getMenuTree() { + var user = SecurityUtil.getCurrentUser(); + var menus = menuManager.selectActiveMenusByUserId(user.getId()); + return TreeUtil.buildForest(menus); + } +} diff --git a/src/main/java/com/onixbyte/helix/service/PositionService.java b/src/main/java/com/onixbyte/helix/service/PositionService.java new file mode 100644 index 0000000..905231b --- /dev/null +++ b/src/main/java/com/onixbyte/helix/service/PositionService.java @@ -0,0 +1,23 @@ +package com.onixbyte.helix.service; + +import com.onixbyte.helix.domain.entity.Position; +import com.onixbyte.helix.manager.PositionManager; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +public class PositionService { + + private final PositionManager positionManager; + + public PositionService(PositionManager positionManager) { + this.positionManager = positionManager; + } + + public Page getPositions(Pageable pageable) { + return positionManager.selectAll(pageable); + } +} diff --git a/src/main/java/com/onixbyte/helix/service/RoleService.java b/src/main/java/com/onixbyte/helix/service/RoleService.java new file mode 100644 index 0000000..15a18c6 --- /dev/null +++ b/src/main/java/com/onixbyte/helix/service/RoleService.java @@ -0,0 +1,42 @@ +package com.onixbyte.helix.service; + +import com.onixbyte.helix.constant.Status; +import com.onixbyte.helix.domain.database.query.wrapper.QueryRoleWrapper; +import com.onixbyte.helix.domain.entity.Role; +import com.onixbyte.helix.domain.web.request.QueryRoleRequest; +import com.onixbyte.helix.manager.RoleManager; +import org.apache.commons.lang3.StringUtils; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; + +import java.util.Optional; + +@Service +public class RoleService { + + private final RoleManager roleManager; + + public RoleService(RoleManager roleManager) { + this.roleManager = roleManager; + } + + public Page getRoles(Pageable pageable, QueryRoleRequest request) { + QueryRoleWrapper wrapper = new QueryRoleWrapper(); + + Optional.ofNullable(request.name()) + .filter(StringUtils::isNotBlank) + .ifPresent(wrapper::setName); + + Optional.ofNullable(request.code()) + .filter(StringUtils::isNotBlank) + .ifPresent(wrapper::setCode); + + Optional.ofNullable(request.status()) + .filter(StringUtils::isNotBlank) + .map(Status::valueOf) + .ifPresent(wrapper::setStatus); + + return roleManager.selectAll(pageable, wrapper); + } +} diff --git a/src/main/java/com/onixbyte/helix/service/SettingService.java b/src/main/java/com/onixbyte/helix/service/SettingService.java new file mode 100644 index 0000000..9ddbbc0 --- /dev/null +++ b/src/main/java/com/onixbyte/helix/service/SettingService.java @@ -0,0 +1,7 @@ +package com.onixbyte.helix.service; + +import org.springframework.stereotype.Service; + +@Service +public class SettingService { +} diff --git a/src/main/java/com/onixbyte/helix/service/UserService.java b/src/main/java/com/onixbyte/helix/service/UserService.java new file mode 100644 index 0000000..a33343a --- /dev/null +++ b/src/main/java/com/onixbyte/helix/service/UserService.java @@ -0,0 +1,167 @@ +package com.onixbyte.helix.service; + +import com.onixbyte.common.util.HashUtil; +import com.onixbyte.helix.constant.UserStatus; +import com.onixbyte.helix.domain.database.query.wrapper.QueryUserWrapper; +import com.onixbyte.helix.domain.entity.Role; +import com.onixbyte.helix.domain.entity.User; +import com.onixbyte.helix.domain.entity.UserRole; +import com.onixbyte.helix.domain.web.request.AddUserRequest; +import com.onixbyte.helix.domain.web.request.QueryUserRequest; +import com.onixbyte.helix.domain.web.request.ResetPasswordRequest; +import com.onixbyte.helix.domain.web.request.UpdateUserRequest; +import com.onixbyte.helix.domain.web.response.UserDetailResponse; +import com.onixbyte.helix.exception.BizException; +import com.onixbyte.helix.manager.ApplicationManager; +import com.onixbyte.helix.manager.RoleManager; +import com.onixbyte.helix.manager.UserManager; +import com.onixbyte.helix.manager.UserRoleManager; +import com.onixbyte.identitygenerator.IdentityGenerator; +import org.apache.commons.collections4.CollectionUtils; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +@Service +public class UserService { + + private final UserManager userManager; + private final IdentityGenerator userIdentityGenerator; + private final RoleManager roleManager; + private final UserRoleManager userRoleManager; + private final PasswordEncoder passwordEncoder; + private final ApplicationManager applicationManager; + + public UserService( + UserManager userManager, + IdentityGenerator userIdentityGenerator, + RoleManager roleManager, + UserRoleManager userRoleManager, + PasswordEncoder passwordEncoder, + ApplicationManager applicationManager + ) { + this.userManager = userManager; + this.userIdentityGenerator = userIdentityGenerator; + this.roleManager = roleManager; + this.userRoleManager = userRoleManager; + this.passwordEncoder = passwordEncoder; + this.applicationManager = applicationManager; + } + + public Page queryUserDetailsPage(Pageable pageable, QueryUserRequest request) { + var wrapper = new QueryUserWrapper(); + wrapper.setDepartmentId(request.departmentId()); + wrapper.setUsername(request.username()); + wrapper.setRegionAbbreviation(request.regionAbbreviation()); + wrapper.setPhoneNumber(request.phoneNumber()); + wrapper.setStatus(Optional.ofNullable(request.status()) + .filter((status) -> !status.isBlank()) + .map(UserStatus::valueOf) + .orElse(null)); + wrapper.setCreatedAtStart(request.createdAtStart()); + wrapper.setCreatedAtEnd(request.createdAtEnd()); + + return userManager.selectUserDetailsPage(pageable, wrapper); + } + + @Transactional(rollbackFor = Throwable.class) + public void addUser(AddUserRequest request) { + var createTime = LocalDateTime.now(); + + // validate all roles are existed + if (CollectionUtils.isNotEmpty(request.roleIds())) { + roleManager.validateRoles(request.roleIds()); + } + + // Get user email or use default email address + var userEmail = Optional.ofNullable(request.email()) + .orElse(applicationManager.getDefaultEmail()); + + // Gravatar is used by default when user didn't set an avatar + var avatarUrl = Optional.ofNullable(request.avatarUrl()) + .orElseGet(() -> "https://gravatar.com/avatar/" + HashUtil.sha256(userEmail) + "/?d=identicon"); + + // Get user status, default to `ACTIVE` + var status = Optional.ofNullable(request.status()) + .orElse(UserStatus.ACTIVE); + + // Build user information + var user = userManager.save(User.builder() + .id(userIdentityGenerator.nextId()) + .username(request.username()) + .password(passwordEncoder.encode(request.password())) + .fullName(request.fullName()) + .email(request.email()) + .regionAbbreviation(request.regionAbbreviation()) + .phoneNumber(request.phoneNumber()) + .avatarUrl(avatarUrl) + .status(status) + .departmentId(request.departmentId()) + .positionId(request.positionId()) + .createdAt(createTime) + .updatedAt(createTime) + .build()); + + // Get role IDs + var roleIds = Optional.ofNullable(request.roleIds()) + .filter(CollectionUtils::isNotEmpty) + .orElseGet(() -> List.of(roleManager.getRole(Role.builder() + .defaultValue(true) + .build()) + .map(Role::getId) + .orElseThrow(() -> new BizException("No default role specified.")))); + + // Build bindings + var userRoleBindings = roleIds + .stream() + .map((roleId) -> UserRole.builder() + .userId(user.getId()) + .roleId(roleId) + .createdAt(createTime) + .build() + ) + .toList(); + + // Save user and role bindings + userRoleManager.saveBatch(userRoleBindings); + } + + @Transactional(rollbackFor = Throwable.class) + public void updateUser(UpdateUserRequest request) { + userManager.updateUser(User.builder() + .id(request.id()) + .fullName(request.fullName()) + .email(request.email()) + .regionAbbreviation(request.regionAbbreviation()) + .phoneNumber(request.phoneNumber()) + .avatarUrl(request.avatarUrl()) + .status(request.status()) + .departmentId(request.departmentId()) + .positionId(request.positionId()) + .build()); + } + + public UserDetailResponse getUserDetailByUserId(Long userId) { + return userManager.queryUserDetailByUserId(userId); + } + + @Transactional(rollbackFor = Throwable.class) + public void resetPassword(ResetPasswordRequest request) { + userManager.updateUser(User.builder() + .id(request.id()) + .password(request.password()) + .build()); + } + + @Transactional(rollbackFor = Throwable.class) + public void deleteUser(Long userId) { + userRoleManager.deleteByUserId(userId); + userManager.deleteById(userId); + } +} diff --git a/src/main/java/com/onixbyte/helix/utils/DateTimeUtil.java b/src/main/java/com/onixbyte/helix/utils/DateTimeUtil.java new file mode 100644 index 0000000..6517a4d --- /dev/null +++ b/src/main/java/com/onixbyte/helix/utils/DateTimeUtil.java @@ -0,0 +1,13 @@ +package com.onixbyte.helix.utils; + +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; + +public class DateTimeUtil { + + public static Instant asInstant(LocalDateTime localDateTime) { + return localDateTime.atZone(ZoneId.systemDefault()) + .toInstant(); + } +} diff --git a/src/main/java/com/onixbyte/helix/utils/EncodeUtil.java b/src/main/java/com/onixbyte/helix/utils/EncodeUtil.java new file mode 100644 index 0000000..5be5e06 --- /dev/null +++ b/src/main/java/com/onixbyte/helix/utils/EncodeUtil.java @@ -0,0 +1,94 @@ +package com.onixbyte.helix.utils; + +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; + +/** + * Utility class providing encoding and decoding operations for web applications. + *

+ * This utility class offers methods for handling URL encoding operations that are commonly required + * in web applications, particularly for ensuring compatibility between Java server-side encoding + * and JavaScript client-side encoding standards. + *

+ * The primary focus is on providing encoding methods that precisely replicate JavaScript's built-in + * encoding functions, ensuring consistent behaviour across different platforms and environments. + * This is particularly important for applications that need to maintain encoding consistency + * between server-side Java code and client-side JavaScript code. + *

+ * All methods in this utility class are static and the class is designed to be used + * without instantiation. The encoding operations follow web standards and are optimised for + * performance and reliability. + * + * @author zihluwang + * @see java.net.URLEncoder + * @see java.nio.charset.StandardCharsets + * @since 1.0.0 + */ +public class EncodeUtil { + + /** + * Encodes a string to be used in a URI component, precisely replicating JavaScript's + * {@code encodeURIComponent} function. + *

+ * This method provides exact compatibility with JavaScript's {@code encodeURIComponent} + * function by first using Java's standard {@link URLEncoder} and then applying specific + * corrections to match JavaScript's encoding behaviour. + *

+ * The method handles the following character encoding differences: + *

    + *
  • Converts "+" to "{@code %20}" for proper space encoding
  • + *
  • Preserves "*" characters (decoded from "{@code %2A}")
  • + *
  • Preserves "'" characters (decoded from "{@code %27}")
  • + *
  • Preserves "(" characters (decoded from "{@code %28}")
  • + *
  • Preserves ")" characters (decoded from "{@code %29}")
  • + *
+ *

+ * Characters that remain unencoded in both implementations: {@code - _ . ! ~ * ' ( )} + *

+ * This method is particularly useful for applications that need to maintain encoding + * consistency between server-side Java operations and client-side JavaScript operations, such + * as when generating URLs or handling form data. + * + * @param content the string component to encode for URI usage + * @return a URI-component-safe encoded string that matches JavaScript's + * {@code encodeURIComponent} output + * @throws NullPointerException if the content parameter is null + * @see java.net.URLEncoder#encode(String, java.nio.charset.Charset) + * @see MDN encodeURIComponent Documentation + */ + public static String encodeUriComponent(String content) { + String encodedComponent; + + // First, we use the standard Java URLEncoder. + // + // It's crucial to specify the character encoding, with UTF-8 being the web standard. + encodedComponent = URLEncoder.encode(content, StandardCharsets.UTF_8); + + // Next, we must manually correct the output of URLEncoder to match encodeURIComponent's + // behaviour. + // + // JavaScript's encodeURIComponent does not encode these characters: - _ . ! ~ * ' ( ) + // URLEncoder, however, does encode some of them. + + // Replace "+" with "%20" (space character) + encodedComponent = encodedComponent.replace("+", "%20"); + + // URLEncoder encodes "*" as "%2A", but encodeURIComponent does not, so we decode it back. + encodedComponent = encodedComponent.replace("%2A", "*"); + + // URLEncoder encodes "'" as "%27", but encodeURIComponent does not. + encodedComponent = encodedComponent.replace("%27", "'"); + + // URLEncoder encodes "(" as "%28", but encodeURIComponent does not. + encodedComponent = encodedComponent.replace("%28", "("); + + // URLEncoder encodes ")" as "%29", but encodeURIComponent does not. + encodedComponent = encodedComponent.replace("%29", ")"); + + // Note: The characters '!', '~' are also not encoded by encodeURIComponent. + // + // Modern versions of URLEncoder (in recent JDKs) correctly leave '!' and '~' unencoded, + // so explicit replacements for "%21" and "%7E" are typically no longer necessary. + return encodedComponent; + } +} diff --git a/src/main/java/com/onixbyte/helix/utils/SecurityUtil.java b/src/main/java/com/onixbyte/helix/utils/SecurityUtil.java new file mode 100644 index 0000000..5165508 --- /dev/null +++ b/src/main/java/com/onixbyte/helix/utils/SecurityUtil.java @@ -0,0 +1,26 @@ +package com.onixbyte.helix.utils; + +import com.onixbyte.helix.domain.entity.User; +import com.onixbyte.helix.exception.BizException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.context.SecurityContextHolder; + +public class SecurityUtil { + + private static final Logger log = LoggerFactory.getLogger(SecurityUtil.class); + + public static User getCurrentUser() { + var _details = SecurityContextHolder.getContext() + .getAuthentication() + .getDetails(); + + if (!(_details instanceof User user)) { + log.error("Authentication details is {}", _details); + throw new BizException(HttpStatus.INTERNAL_SERVER_ERROR, "Cannot retrieve user information from context."); + } + + return user; + } +} diff --git a/src/main/java/com/onixbyte/helix/utils/TreeUtil.java b/src/main/java/com/onixbyte/helix/utils/TreeUtil.java new file mode 100644 index 0000000..e5ba853 --- /dev/null +++ b/src/main/java/com/onixbyte/helix/utils/TreeUtil.java @@ -0,0 +1,130 @@ +package com.onixbyte.helix.utils; + +import com.onixbyte.helix.domain.common.Treeable; +import com.onixbyte.helix.domain.model.TreeNode; +import com.onixbyte.helix.exception.BizException; +import org.apache.commons.collections4.CollectionUtils; +import org.springframework.http.HttpStatus; + +import java.util.*; +import java.util.stream.Collectors; + +public class TreeUtil { + + /** + * Builds a tree from a flat list of items that implement Treeable. + * + * @param items the flat list of treeable items + * @param the type extending Treeable + * @return a single root TreeNode + */ + public static > TreeNode buildTree(List items) { + if (CollectionUtils.isEmpty(items)) { + return null; + } + + // A map to quickly access the TreeNode corresponding to a node ID. + // This is crucial for building the hierarchical links between parent and child nodes. + var idToNodeMap = items.stream() + .collect(Collectors.toMap(Treeable::getId, (item) -> new TreeNode<>(item, new ArrayList<>()))); + + // Get root node + var rootItems = items.stream() + .filter((item) -> item.getParentId() == null) + .toList(); + + // Ensure only 1 root node is included + if (rootItems.size() > 1) { + throw new BizException(HttpStatus.INTERNAL_SERVER_ERROR, "Multiple root items found in given values."); + } + + // Get root item and build root node + var rootItem = rootItems.get(0); + var rootNode = new TreeNode(rootItem); + idToNodeMap.remove(rootItem.getId()); + + // Iterate through all departments to build the hierarchy. + for (var item : items) { + var currentNode = idToNodeMap.get(item.getId()); + // If it has a parentId, find its parent TreeNode and add the current node to its children. + var parentNode = Objects.equals(item.getParentId(), rootItem.getId()) ? rootNode : idToNodeMap.get(item.getParentId()); + if (parentNode != null) { // Ensure the parent actually exists in the provided list + parentNode.children().add(currentNode); + } + } + + sortChildrenRecursively(rootNode.children()); + + return rootNode; + } + + /** + * Builds a forest from a flat list of items that implement Treeable. + * + * @param items the flat list of treeable items + * @param the type extending Treeable + * @return a list of root TreeNodes + */ + public static > List> buildForest(List items) { + if (CollectionUtils.isEmpty(items)) { + return Collections.emptyList(); + } + + // A map to quickly access the TreeNode corresponding to a node ID. + // This is crucial for building the hierarchical links between parent and child nodes. + var idToNodeMap = items.stream() + .collect(Collectors.toMap(Treeable::getId, (item) -> new TreeNode<>(item, new ArrayList<>()))); + + // A list to store the top-level (root) departments. + var rootNodes = new ArrayList>(); + + // Iterate through all departments to build the hierarchy. + for (var item : items) { + var currentNode = idToNodeMap.get(item.getId()); + + if (Objects.isNull(item.getParentId())) { + // If a department has no parentId, it's a root department. + rootNodes.add(currentNode); + } else { + // If it has a parentId, find its parent TreeNode and add the current node to its children. + var parentNode = idToNodeMap.get(item.getParentId()); + if (parentNode != null) { // Ensure the parent actually exists in the provided list + parentNode.children().add(currentNode); + } + } + } + + // 4. Sort the children lists for all nodes, starting from the root nodes, to ensure consistent display order. + sortChildrenRecursively(rootNodes); + + return rootNodes; + } + + /** + * Recursively sorts the children list of each TreeNode based on the 'sort' field of its Department item. + * This method traverses the tree structure to apply sorting at every level. + * + * @param nodes The list of TreeNodes to sort (e.g., the initial list of root nodes, or a sub-list of children). + */ + private static > void sortChildrenRecursively(List> nodes) { + if (nodes == null || nodes.isEmpty()) { + return; + } + + // Define the comparator for sorting children based on the 'sort' field of the menu item. + var comparator = Comparator., Integer>comparing( + (node) -> node.item().getSort(), + Comparator.nullsLast(Comparator.naturalOrder()) // Handle null 'sort' values by placing them last. + ); + + // Sort the current list of nodes (e.g., root nodes, or a parent's immediate children). + nodes.sort(comparator); + + // Recursively apply sorting to the children of each node in the current list. + for (var node : nodes) { + if (node.children() != null && !node.children().isEmpty()) { + sortChildrenRecursively(node.children()); + } + } + } +} diff --git a/src/main/java/com/onixbyte/helix/validation/group/OnCreate.java b/src/main/java/com/onixbyte/helix/validation/group/OnCreate.java new file mode 100644 index 0000000..d9799f8 --- /dev/null +++ b/src/main/java/com/onixbyte/helix/validation/group/OnCreate.java @@ -0,0 +1,64 @@ +package com.onixbyte.helix.validation.group; + +/** + * Validation group interface for create operations. + *

+ * This marker interface is used with Bean Validation (JSR-303/JSR-380) to define a validation group + * that is specifically applied during entity creation operations. By using validation groups, + * different validation rules can be applied depending on the context of the operation (create vs. + * update vs. other scenarios). + *

+ * Usage Pattern: This interface is typically used in conjunction with validation + * annotations such as {@code @NotNull}, {@code @NotBlank}, {@code @Valid}, etc., to specify that + * certain validation constraints should only be applied when creating new entities. + *

+ * Example Usage: + *

{@code
+ * public class User {
+ *     @NotNull(groups = OnCreate.class, message = "Username is required for new users")
+ *     @Size(min = 3, max = 50, groups = OnCreate.class)
+ *     private String username;
+ *     
+ *     @NotNull(groups = OnCreate.class, message = "Password is required for new users")
+ *     @Size(min = 8, groups = OnCreate.class)
+ *     private String password;
+ *     
+ *     // Other fields...
+ * }
+ * 
+ * // In a REST controller:
+ * @PostMapping("/users")
+ * public ResponseEntity createUser(
+ *     @Validated(OnCreate.class) @RequestBody User user) {
+ *     // Create user logic
+ * }
+ * }
+ *

+ * Benefits of Validation Groups: + *

    + *
  • + * Context-Specific Validation: Apply different validation rules for different + * operations (e.g., password required on create but not on update) + *
  • + *
  • + * Flexible Constraint Application: Enable or disable specific validations based + * on the operation context + *
  • + *
  • + * Cleaner Code: Avoid complex conditional validation logic by using declarative + * group-based validation + *
  • + *
+ *

+ * This interface contains no methods and serves purely as a marker for the Bean Validation + * framework to identify which constraints should be applied during create operations. + * + * @author zihluwang + * @since 1.0.0 + * @see OnUpdate + * @see jakarta.validation.groups.Default + * @see org.springframework.validation.annotation.Validated + * @see Bean Validation Specification - Validation Groups + */ +public interface OnCreate { +} diff --git a/src/main/java/com/onixbyte/helix/validation/group/OnUpdate.java b/src/main/java/com/onixbyte/helix/validation/group/OnUpdate.java new file mode 100644 index 0000000..dcb17c0 --- /dev/null +++ b/src/main/java/com/onixbyte/helix/validation/group/OnUpdate.java @@ -0,0 +1,84 @@ +package com.onixbyte.helix.validation.group; + +/** + * Validation group interface for update operations. + *

+ * This marker interface is used with Bean Validation (JSR-303/JSR-380) to define a validation group + * that is specifically applied during entity update operations. By using validation groups, + * different validation rules can be applied depending on the context of the operation (update vs. + * create vs. other scenarios). + *

+ * Usage Pattern: This interface is typically used in conjunction with validation + * annotations such as {@code @NotNull}, {@code @NotBlank}, {@code @Valid}, etc., to specify that + * certain validation constraints should only be applied when updating existing entities. + *

+ * Example Usage: + *

{@code
+ * public class User {
+ *     @NotNull(groups = OnUpdate.class, message = "ID is required for updates")
+ *     @Positive(groups = OnUpdate.class)
+ *     private Long id;
+ *     
+ *     @Size(min = 3, max = 50, groups = OnUpdate.class)
+ *     private String username;
+ *     
+ *     // Password might be optional on update
+ *     @Size(min = 8, groups = OnUpdate.class)
+ *     private String password;
+ *     
+ *     // Other fields...
+ * }
+ * 
+ * // In a REST controller:
+ * @PutMapping("/users/{id}")
+ * public ResponseEntity updateUser(
+ *     @PathVariable Long id,
+ *     @Validated(OnUpdate.class) @RequestBody User user) {
+ *     // Update user logic
+ * }
+ * }
+ *

+ * Common Update Validation Scenarios: + *

    + *
  • + * ID Validation: Ensure entity ID is present and valid for updates + *
  • + *
  • + * Optional Fields: Allow certain fields to be optional during updates that were + * required during creation + *
  • + *
  • + * Partial Updates: Support PATCH operations where only modified fields + * need validation + *
  • + *
  • + * Version Control: Validate version fields for optimistic locking + *
  • + *
+ *

+ * Benefits of Update-Specific Validation: + *

    + *
  • + * Flexible Field Requirements: Different fields may be required or optional + * compared to create operations + *
  • + *
  • + * Identity Validation: Ensure proper entity identification for update operations + *
  • + *
  • + * Conditional Logic: Apply validation rules specific to modification scenarios + *
  • + *
+ *

+ * This interface contains no methods and serves purely as a marker for the Bean Validation + * framework to identify which constraints should be applied during update operations. + * + * @author zihluwang + * @since 1.0.0 + * @see OnCreate + * @see jakarta.validation.groups.Default + * @see org.springframework.validation.annotation.Validated + * @see Bean Validation Specification - Validation Groups + */ +public interface OnUpdate { +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..5d853f8 --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,29 @@ +spring: + application: + name: helix-server + cache: + type: redis + redis: + time-to-live: PT1H30M + jpa: + properties: + hibernate: + transaction: + jta: + # No need to use distributed transaction manager for 1 datasource. + platform: org.hibernate.engine.transaction.jta.platform.internal.NoJtaPlatform + hibernate: + ddl-auto: validate + open-in-view: false + datasource: + hikari: + minimum-idle: 1 + maximum-pool-size: 10 + +mybatis: + configuration: + log-impl: org.apache.ibatis.logging.slf4j.Slf4jImpl + map-underscore-to-camel-case: true + type-aliases-package: com.onixbyte.helix.domain.entity + type-handlers-package: com.onixbyte.helix.extension.mybatis.handler + mapper-locations: classpath:/mapper/*.xml diff --git a/src/main/resources/mapper/AssetMapper.xml b/src/main/resources/mapper/AssetMapper.xml new file mode 100644 index 0000000..a329395 --- /dev/null +++ b/src/main/resources/mapper/AssetMapper.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/src/main/resources/mapper/AuthorityMapper.xml b/src/main/resources/mapper/AuthorityMapper.xml new file mode 100644 index 0000000..ea9d26b --- /dev/null +++ b/src/main/resources/mapper/AuthorityMapper.xml @@ -0,0 +1,12 @@ + + + + + \ No newline at end of file diff --git a/src/main/resources/mapper/DepartmentMapper.xml b/src/main/resources/mapper/DepartmentMapper.xml new file mode 100644 index 0000000..1521ffa --- /dev/null +++ b/src/main/resources/mapper/DepartmentMapper.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/src/main/resources/mapper/MenuMapper.xml b/src/main/resources/mapper/MenuMapper.xml new file mode 100644 index 0000000..f161b13 --- /dev/null +++ b/src/main/resources/mapper/MenuMapper.xml @@ -0,0 +1,38 @@ + + + + + \ No newline at end of file diff --git a/src/main/resources/mapper/PositionMapper.xml b/src/main/resources/mapper/PositionMapper.xml new file mode 100644 index 0000000..eba56ca --- /dev/null +++ b/src/main/resources/mapper/PositionMapper.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/src/main/resources/mapper/RoleMapper.xml b/src/main/resources/mapper/RoleMapper.xml new file mode 100644 index 0000000..fff52e0 --- /dev/null +++ b/src/main/resources/mapper/RoleMapper.xml @@ -0,0 +1,70 @@ + + + + + + + + + \ No newline at end of file diff --git a/src/main/resources/mapper/SettingMapper.xml b/src/main/resources/mapper/SettingMapper.xml new file mode 100644 index 0000000..941f5ea --- /dev/null +++ b/src/main/resources/mapper/SettingMapper.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/src/main/resources/mapper/UserMapper.xml b/src/main/resources/mapper/UserMapper.xml new file mode 100644 index 0000000..bf5e209 --- /dev/null +++ b/src/main/resources/mapper/UserMapper.xml @@ -0,0 +1,228 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/main/resources/mapper/UserRoleMapper.xml b/src/main/resources/mapper/UserRoleMapper.xml new file mode 100644 index 0000000..28ba798 --- /dev/null +++ b/src/main/resources/mapper/UserRoleMapper.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file