diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d0038b5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/.idea/ +/.vscode/ diff --git a/doc/blog-system-requirements.md b/doc/jog-system-requirements.md similarity index 100% rename from doc/blog-system-requirements.md rename to doc/jog-system-requirements.md diff --git a/doc/blog-system-technical-design.md b/doc/jog-system-technical-design.md similarity index 100% rename from doc/blog-system-technical-design.md rename to doc/jog-system-technical-design.md diff --git a/jog-admin/pom.xml b/jog-admin/pom.xml new file mode 100644 index 0000000..98d2955 --- /dev/null +++ b/jog-admin/pom.xml @@ -0,0 +1,74 @@ + + + 4.0.0 + + fun.nojava + jog-system + 1.0.0 + + + jog-admin + Jog Admin Application + + + + fun.nojava + jog-module-system + + + fun.nojava + jog-module-blog + + + fun.nojava + jog-module-comment + + + com.baomidou + mybatis-plus-spring-boot3-starter + + + com.mysql + mysql-connector-j + runtime + + + org.flywaydb + flyway-core + + + org.flywaydb + flyway-mysql + + + org.projectlombok + lombok + true + + + org.springframework.boot + spring-boot-starter-test + test + + + + + jog-admin + + + org.springframework.boot + spring-boot-maven-plugin + + + + org.projectlombok + lombok + + + + + + + diff --git a/jog-admin/src/main/resources/application.yml b/jog-admin/src/main/resources/application.yml new file mode 100644 index 0000000..2484461 --- /dev/null +++ b/jog-admin/src/main/resources/application.yml @@ -0,0 +1,70 @@ +server: + port: 8080 + servlet: + context-path: / + +spring: + application: + name: jog-system + datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + url: jdbc:mysql://localhost:3306/jog_db?useUnicode=true&characterEncoding=utf8mb4&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true + username: root + password: root + data: + redis: + host: localhost + port: 6379 + database: 0 + timeout: 5000ms + lettuce: + pool: + max-active: 20 + max-idle: 10 + min-idle: 5 + mail: + host: smtp.example.com + port: 587 + username: noreply@example.com + password: your-mail-password + properties: + mail: + smtp: + auth: true + starttls: + enable: true + flyway: + enabled: true + locations: classpath:db/migration + baseline-on-migrate: true + encoding: UTF-8 + +mybatis-plus: + mapper-locations: classpath*:/mapper/**/*.xml + configuration: + map-underscore-to-camel-case: true + log-impl: org.apache.ibatis.logging.stdout.StdOutImpl + global-config: + db-config: + logic-delete-field: deleted + logic-delete-value: 1 + logic-not-delete-value: 0 + +jwt: + secret: YourSuperSecretKeyForJwtTokenGenerationMustBeAtLeast256BitsLongForHS256Algorithm + access-token-expire: 7200 + refresh-token-expire: 86400 + refresh-token-expire-remember: 1209600 + +springdoc: + api-docs: + enabled: true + path: /v3/api-docs + swagger-ui: + enabled: true + path: /swagger-ui.html + +logging: + level: + fun.nojava: DEBUG + org.springframework.security: DEBUG diff --git a/jog-admin/src/main/resources/db/migration/V1__init_schema.sql b/jog-admin/src/main/resources/db/migration/V1__init_schema.sql new file mode 100644 index 0000000..2f9b638 --- /dev/null +++ b/jog-admin/src/main/resources/db/migration/V1__init_schema.sql @@ -0,0 +1,207 @@ +-- V1__init_schema.sql +-- 博客系统数据库初始化脚本 + +CREATE DATABASE IF NOT EXISTS blog_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +USE blog_db; + +-- 用户表 +CREATE TABLE IF NOT EXISTS sys_user ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + username VARCHAR(50) DEFAULT NULL COMMENT '用户名', + email VARCHAR(100) NOT NULL COMMENT '邮箱', + phone VARCHAR(20) DEFAULT NULL COMMENT '手机号', + password VARCHAR(255) NOT NULL COMMENT '密码', + nickname VARCHAR(50) DEFAULT NULL COMMENT '昵称', + avatar VARCHAR(500) DEFAULT NULL COMMENT '头像URL', + user_type TINYINT NOT NULL DEFAULT 1 COMMENT '用户类型:0管理员 1普通用户', + status TINYINT NOT NULL DEFAULT 0 COMMENT '状态:0正常 1锁定', + locked_until DATETIME DEFAULT NULL COMMENT '锁定截止时间', + login_fail_count INT NOT NULL DEFAULT 0 COMMENT '登录失败次数', + email_verified TINYINT(1) NOT NULL DEFAULT 0 COMMENT '邮箱是否验证', + last_login_ip VARCHAR(50) DEFAULT NULL COMMENT '最后登录IP', + last_login_time DATETIME DEFAULT NULL COMMENT '最后登录时间', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除', + UNIQUE KEY uk_email (email), + UNIQUE KEY uk_username (username), + KEY idx_phone (phone) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户表'; + +-- 用户会话表 +CREATE TABLE IF NOT EXISTS sys_user_session ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT NOT NULL COMMENT '用户ID', + token VARCHAR(500) NOT NULL COMMENT '访问令牌', + device_fingerprint VARCHAR(100) DEFAULT NULL COMMENT '设备指纹', + ip_address VARCHAR(50) DEFAULT NULL COMMENT 'IP地址', + user_agent VARCHAR(500) DEFAULT NULL COMMENT 'User-Agent', + expire_time DATETIME NOT NULL COMMENT '过期时间', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY idx_user_id (user_id), + KEY idx_token (token(255)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户会话表'; + +-- 登录日志表 +CREATE TABLE IF NOT EXISTS sys_login_log ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT DEFAULT NULL COMMENT '用户ID', + username VARCHAR(100) DEFAULT NULL COMMENT '登录账号', + login_type TINYINT NOT NULL COMMENT '登录类型:0管理员 1用户', + result TINYINT NOT NULL COMMENT '结果:0失败 1成功', + fail_reason VARCHAR(200) DEFAULT NULL COMMENT '失败原因', + ip_address VARCHAR(50) DEFAULT NULL COMMENT 'IP地址', + device_fingerprint VARCHAR(100) DEFAULT NULL COMMENT '设备指纹', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY idx_user_id (user_id), + KEY idx_created_at (created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='登录日志表'; + +-- 用户关注表 +CREATE TABLE IF NOT EXISTS sys_user_follow ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + follower_id BIGINT NOT NULL COMMENT '关注者ID', + followee_id BIGINT NOT NULL COMMENT '被关注者ID', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uk_follow (follower_id, followee_id), + KEY idx_followee (followee_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户关注表'; + +-- 通知表 +CREATE TABLE IF NOT EXISTS sys_notification ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + receiver_id BIGINT NOT NULL COMMENT '接收者ID', + sender_id BIGINT DEFAULT NULL COMMENT '发送者ID', + type TINYINT NOT NULL COMMENT '类型:0回复 1点赞 2系统', + content VARCHAR(500) NOT NULL COMMENT '内容', + related_id BIGINT DEFAULT NULL COMMENT '关联ID', + is_read TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否已读', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY idx_receiver (receiver_id), + KEY idx_created_at (created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='通知表'; + +-- 系统配置表 +CREATE TABLE IF NOT EXISTS sys_config ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + config_key VARCHAR(100) NOT NULL COMMENT '配置键', + config_value VARCHAR(500) NOT NULL COMMENT '配置值', + description VARCHAR(200) DEFAULT NULL COMMENT '描述', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_config_key (config_key) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='系统配置表'; + +-- 敏感词表 +CREATE TABLE IF NOT EXISTS sys_sensitive_word ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + word VARCHAR(50) NOT NULL COMMENT '敏感词', + replacement VARCHAR(50) DEFAULT '*' COMMENT '替换词', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uk_word (word) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='敏感词表'; + +-- 分类表 +CREATE TABLE IF NOT EXISTS blog_category ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(50) NOT NULL COMMENT '分类名称', + description VARCHAR(200) DEFAULT NULL COMMENT '描述', + enabled TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否启用', + sort INT NOT NULL DEFAULT 0 COMMENT '排序', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_name (name) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='分类表'; + +-- 标签表 +CREATE TABLE IF NOT EXISTS blog_tag ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(50) NOT NULL COMMENT '标签名称', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uk_name (name) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='标签表'; + +-- 文章表 +CREATE TABLE IF NOT EXISTS blog_article ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT NOT NULL COMMENT '作者ID', + category_id BIGINT DEFAULT NULL COMMENT '分类ID', + title VARCHAR(200) NOT NULL COMMENT '标题', + summary VARCHAR(500) DEFAULT NULL COMMENT '摘要', + content LONGTEXT NOT NULL COMMENT '内容', + cover_image VARCHAR(500) DEFAULT NULL COMMENT '封面图URL', + status TINYINT NOT NULL DEFAULT 0 COMMENT '状态:0草稿 1已发布 2回收站', + visibility TINYINT NOT NULL DEFAULT 0 COMMENT '可见性:0公开 1私密 2仅粉丝', + view_count INT NOT NULL DEFAULT 0 COMMENT '浏览量', + like_count INT NOT NULL DEFAULT 0 COMMENT '点赞数', + comment_count INT NOT NULL DEFAULT 0 COMMENT '评论数', + allow_comment TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否允许评论', + published_at DATETIME DEFAULT NULL COMMENT '发布时间', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除', + KEY idx_user_id (user_id), + KEY idx_category_id (category_id), + KEY idx_status (status), + KEY idx_published_at (published_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='文章表'; + +-- 文章标签关联表 +CREATE TABLE IF NOT EXISTS blog_article_tag ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + article_id BIGINT NOT NULL COMMENT '文章ID', + tag_id BIGINT NOT NULL COMMENT '标签ID', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uk_article_tag (article_id, tag_id), + KEY idx_tag_id (tag_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='文章标签关联表'; + +-- 文章点赞表 +CREATE TABLE IF NOT EXISTS blog_article_like ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + article_id BIGINT NOT NULL COMMENT '文章ID', + user_id BIGINT NOT NULL COMMENT '用户ID', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uk_article_user (article_id, user_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='文章点赞表'; + +-- 评论表 +CREATE TABLE IF NOT EXISTS blog_comment ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + article_id BIGINT NOT NULL COMMENT '文章ID', + user_id BIGINT NOT NULL COMMENT '评论者ID', + parent_id BIGINT DEFAULT NULL COMMENT '父评论ID', + reply_to_user_id BIGINT DEFAULT NULL COMMENT '回复目标用户ID', + content VARCHAR(1000) NOT NULL COMMENT '评论内容', + level TINYINT NOT NULL DEFAULT 1 COMMENT '评论层级', + status TINYINT NOT NULL DEFAULT 1 COMMENT '状态:0待审核 1已通过 2已屏蔽 3已删除', + like_count INT NOT NULL DEFAULT 0 COMMENT '点赞数', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除', + KEY idx_article_id (article_id), + KEY idx_user_id (user_id), + KEY idx_parent_id (parent_id), + KEY idx_status (status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='评论表'; + +-- 评论点赞表 +CREATE TABLE IF NOT EXISTS blog_comment_like ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + comment_id BIGINT NOT NULL COMMENT '评论ID', + user_id BIGINT NOT NULL COMMENT '用户ID', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uk_comment_user (comment_id, user_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='评论点赞表'; + +-- 图片表 +CREATE TABLE IF NOT EXISTS blog_image ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT NOT NULL COMMENT '上传者ID', + original_name VARCHAR(200) NOT NULL COMMENT '原始文件名', + stored_name VARCHAR(200) NOT NULL COMMENT '存储文件名', + file_path VARCHAR(500) NOT NULL COMMENT '文件路径', + url VARCHAR(500) NOT NULL COMMENT '访问URL', + file_size BIGINT NOT NULL COMMENT '文件大小(字节)', + mime_type VARCHAR(50) NOT NULL COMMENT 'MIME类型', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY idx_user_id (user_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='图片表'; diff --git a/jog-admin/src/main/resources/db/migration/V2__init_data.sql b/jog-admin/src/main/resources/db/migration/V2__init_data.sql new file mode 100644 index 0000000..57a8629 --- /dev/null +++ b/jog-admin/src/main/resources/db/migration/V2__init_data.sql @@ -0,0 +1,29 @@ +-- V2__init_data.sql +-- 初始化数据 + +USE blog_db; + +-- 插入管理员账号(密码:Admin@123) +INSERT INTO sys_user (username, email, password, nickname, user_type, status, email_verified) +VALUES ('admin', 'admin@blog.com', '$2a$10$N.zmdr9k7uOCQb376NoUnuTJ8iAt6Z5EHsM8lE9lBOsl7iKTVKIUi', '系统管理员', 0, 0, 1); + +-- 插入默认分类 +INSERT INTO blog_category (name, description, enabled, sort) VALUES +('技术', '技术相关文章', 1, 1), +('生活', '生活随笔', 1, 2), +('读书', '读书笔记', 1, 3); + +-- 插入默认标签 +INSERT INTO blog_tag (name) VALUES +('Java'), +('Spring Boot'), +('Vue'), +('前端'), +('数据库'); + +-- 插入系统配置 +INSERT INTO sys_config (config_key, config_value, description) VALUES +('site_name', '我的博客', '站点名称'), +('site_description', '一个简洁的博客系统', '站点描述'), +('comment_audit', 'false', '评论是否需要审核'), +('max_comment_level', '3', '最大评论层级'); diff --git a/jog-common/pom.xml b/jog-common/pom.xml new file mode 100644 index 0000000..7bf63da --- /dev/null +++ b/jog-common/pom.xml @@ -0,0 +1,42 @@ + + + 4.0.0 + + fun.nojava + jog-system + 1.0.0 + + + jog-common + Jog Common + + + + org.projectlombok + lombok + true + + + com.fasterxml.jackson.core + jackson-annotations + + + jakarta.validation + jakarta.validation-api + + + org.springframework + spring-web + + + org.springframework.data + spring-data-commons + + + io.swagger.core.v3 + swagger-annotations-jakarta + + + diff --git a/jog-common/src/main/java/fun/nojava/common/annotation/RateLimit.java b/jog-common/src/main/java/fun/nojava/common/annotation/RateLimit.java new file mode 100644 index 0000000..56a130a --- /dev/null +++ b/jog-common/src/main/java/fun/nojava/common/annotation/RateLimit.java @@ -0,0 +1,18 @@ +package fun.nojava.common.annotation; + +import java.lang.annotation.*; +import java.util.concurrent.TimeUnit; + +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface RateLimit { + + String key() default ""; + + int maxCount() default 10; + + int duration() default 60; + + TimeUnit unit() default TimeUnit.SECONDS; +} diff --git a/jog-common/src/main/java/fun/nojava/common/constant/BlogConstants.java b/jog-common/src/main/java/fun/nojava/common/constant/BlogConstants.java new file mode 100644 index 0000000..1eac7bb --- /dev/null +++ b/jog-common/src/main/java/fun/nojava/common/constant/BlogConstants.java @@ -0,0 +1,14 @@ +package fun.nojava.common.constant; + +public final class BlogConstants { + + private BlogConstants() {} + + public static final int DEFAULT_PAGE_SIZE = 15; + public static final int MAX_PAGE_SIZE = 100; + public static final int ARTICLE_SUMMARY_LENGTH = 120; + public static final int MAX_TAGS_PER_ARTICLE = 5; + public static final int MAX_COMMENT_LEVEL = 3; + public static final int IMAGE_MAX_SIZE_MB = 10; + public static final long DRAFT_AUTO_SAVE_INTERVAL_SECONDS = 30L; +} diff --git a/jog-common/src/main/java/fun/nojava/common/constant/SecurityConstants.java b/jog-common/src/main/java/fun/nojava/common/constant/SecurityConstants.java new file mode 100644 index 0000000..782b3f7 --- /dev/null +++ b/jog-common/src/main/java/fun/nojava/common/constant/SecurityConstants.java @@ -0,0 +1,29 @@ +package fun.nojava.common.constant; + +public final class SecurityConstants { + + private SecurityConstants() {} + + public static final String TOKEN_HEADER = "Authorization"; + public static final String TOKEN_PREFIX = "Bearer "; + public static final String REFRESH_TOKEN_KEY = "refresh:token:"; + public static final String CAPTCHA_KEY = "captcha:"; + public static final String LOGIN_FAIL_COUNT_KEY = "login:fail:"; + public static final String IP_BLOCK_KEY = "ip:block:"; + public static final String REGISTER_LIMIT_KEY = "register:limit:"; + + public static final int MAX_LOGIN_FAIL_ADMIN = 5; + public static final int MAX_LOGIN_FAIL_USER = 5; + public static final int CAPTCHA_TRIGGER_COUNT = 3; + public static final int ADMIN_LOCK_MINUTES = 30; + public static final int USER_LOCK_MINUTES = 15; + public static final int IP_BLOCK_THRESHOLD = 10; + public static final int IP_BLOCK_MINUTES = 15; + public static final int REGISTER_LIMIT_PER_HOUR = 3; + + public static final int MAX_ACTIVE_SESSIONS = 3; + + public static final long ACCESS_TOKEN_EXPIRE_SECONDS = 7200L; + public static final long REFRESH_TOKEN_EXPIRE_SECONDS = 86400L; + public static final long REFRESH_TOKEN_REMEMBER_EXPIRE_SECONDS = 1209600L; +} diff --git a/jog-common/src/main/java/fun/nojava/common/enums/ArticleStatus.java b/jog-common/src/main/java/fun/nojava/common/enums/ArticleStatus.java new file mode 100644 index 0000000..0a839f0 --- /dev/null +++ b/jog-common/src/main/java/fun/nojava/common/enums/ArticleStatus.java @@ -0,0 +1,25 @@ +package fun.nojava.common.enums; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum ArticleStatus { + + DRAFT(0, "草稿"), + PUBLISHED(1, "已发布"), + RECYCLE(2, "回收站"); + + private final int code; + private final String description; + + public static ArticleStatus fromCode(int code) { + for (ArticleStatus status : values()) { + if (status.code == code) { + return status; + } + } + throw new IllegalArgumentException("Invalid ArticleStatus code: " + code); + } +} diff --git a/jog-common/src/main/java/fun/nojava/common/enums/ArticleVisibility.java b/jog-common/src/main/java/fun/nojava/common/enums/ArticleVisibility.java new file mode 100644 index 0000000..55f5b0b --- /dev/null +++ b/jog-common/src/main/java/fun/nojava/common/enums/ArticleVisibility.java @@ -0,0 +1,25 @@ +package fun.nojava.common.enums; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum ArticleVisibility { + + PUBLIC(0, "公开"), + PRIVATE(1, "私密"), + FOLLOWERS_ONLY(2, "仅粉丝可见"); + + private final int code; + private final String description; + + public static ArticleVisibility fromCode(int code) { + for (ArticleVisibility v : values()) { + if (v.code == code) { + return v; + } + } + throw new IllegalArgumentException("Invalid ArticleVisibility code: " + code); + } +} diff --git a/jog-common/src/main/java/fun/nojava/common/enums/CommentStatus.java b/jog-common/src/main/java/fun/nojava/common/enums/CommentStatus.java new file mode 100644 index 0000000..b2b5e72 --- /dev/null +++ b/jog-common/src/main/java/fun/nojava/common/enums/CommentStatus.java @@ -0,0 +1,26 @@ +package fun.nojava.common.enums; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum CommentStatus { + + PENDING(0, "待审核"), + APPROVED(1, "已通过"), + BLOCKED(2, "已屏蔽"), + DELETED(3, "已删除"); + + private final int code; + private final String description; + + public static CommentStatus fromCode(int code) { + for (CommentStatus status : values()) { + if (status.code == code) { + return status; + } + } + throw new IllegalArgumentException("Invalid CommentStatus code: " + code); + } +} diff --git a/jog-common/src/main/java/fun/nojava/common/enums/LoginType.java b/jog-common/src/main/java/fun/nojava/common/enums/LoginType.java new file mode 100644 index 0000000..beec38b --- /dev/null +++ b/jog-common/src/main/java/fun/nojava/common/enums/LoginType.java @@ -0,0 +1,15 @@ +package fun.nojava.common.enums; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum LoginType { + + ADMIN(0, "管理员登录"), + USER(1, "用户登录"); + + private final int code; + private final String description; +} diff --git a/jog-common/src/main/java/fun/nojava/common/enums/NotificationType.java b/jog-common/src/main/java/fun/nojava/common/enums/NotificationType.java new file mode 100644 index 0000000..177c642 --- /dev/null +++ b/jog-common/src/main/java/fun/nojava/common/enums/NotificationType.java @@ -0,0 +1,16 @@ +package fun.nojava.common.enums; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum NotificationType { + + COMMENT_REPLY(0, "回复评论"), + LIKE(1, "点赞"), + SYSTEM(2, "系统通知"); + + private final int code; + private final String description; +} diff --git a/jog-common/src/main/java/fun/nojava/common/enums/UserStatus.java b/jog-common/src/main/java/fun/nojava/common/enums/UserStatus.java new file mode 100644 index 0000000..4eabd34 --- /dev/null +++ b/jog-common/src/main/java/fun/nojava/common/enums/UserStatus.java @@ -0,0 +1,24 @@ +package fun.nojava.common.enums; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum UserStatus { + + NORMAL(0, "正常"), + LOCKED(1, "锁定"); + + private final int code; + private final String description; + + public static UserStatus fromCode(int code) { + for (UserStatus status : values()) { + if (status.code == code) { + return status; + } + } + throw new IllegalArgumentException("Invalid UserStatus code: " + code); + } +} diff --git a/jog-common/src/main/java/fun/nojava/common/enums/UserType.java b/jog-common/src/main/java/fun/nojava/common/enums/UserType.java new file mode 100644 index 0000000..85b7855 --- /dev/null +++ b/jog-common/src/main/java/fun/nojava/common/enums/UserType.java @@ -0,0 +1,24 @@ +package fun.nojava.common.enums; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum UserType { + + ADMIN(0, "超级管理员"), + USER(1, "注册用户"); + + private final int code; + private final String description; + + public static UserType fromCode(int code) { + for (UserType type : values()) { + if (type.code == code) { + return type; + } + } + throw new IllegalArgumentException("Invalid UserType code: " + code); + } +} diff --git a/jog-common/src/main/java/fun/nojava/common/exception/BusinessException.java b/jog-common/src/main/java/fun/nojava/common/exception/BusinessException.java new file mode 100644 index 0000000..1e5ca71 --- /dev/null +++ b/jog-common/src/main/java/fun/nojava/common/exception/BusinessException.java @@ -0,0 +1,24 @@ +package fun.nojava.common.exception; + +import lombok.Getter; + +@Getter +public class BusinessException extends RuntimeException { + + private final int code; + + public BusinessException(int code, String message) { + super(message); + this.code = code; + } + + public BusinessException(ErrorCode errorCode) { + super(errorCode.getMessage()); + this.code = errorCode.getCode(); + } + + public BusinessException(ErrorCode errorCode, String message) { + super(message); + this.code = errorCode.getCode(); + } +} diff --git a/jog-common/src/main/java/fun/nojava/common/exception/ErrorCode.java b/jog-common/src/main/java/fun/nojava/common/exception/ErrorCode.java new file mode 100644 index 0000000..3ea2b8c --- /dev/null +++ b/jog-common/src/main/java/fun/nojava/common/exception/ErrorCode.java @@ -0,0 +1,48 @@ +package fun.nojava.common.exception; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum ErrorCode { + + BAD_REQUEST(400, "参数错误"), + UNAUTHORIZED(401, "未认证"), + FORBIDDEN(403, "无权限"), + NOT_FOUND(404, "资源不存在"), + METHOD_NOT_ALLOWED(405, "请求方法不允许"), + CONFLICT(409, "操作冲突"), + RATE_LIMITED(429, "请求过于频繁"), + INTERNAL_ERROR(500, "服务器内部错误"), + + ACCOUNT_LOCKED(1001, "账号已被锁定"), + ACCOUNT_DISABLED(1002, "账号已被禁用"), + EMAIL_NOT_VERIFIED(1003, "邮箱未验证"), + EMAIL_ALREADY_EXISTS(1004, "邮箱已被注册"), + PASSWORD_TOO_WEAK(1005, "密码强度不足"), + LOGIN_FAIL_TOO_MANY(1006, "登录失败次数过多"), + IP_BLOCKED(1007, "IP已被临时封禁"), + CAPTCHA_REQUIRED(1008, "需要输入验证码"), + CAPTCHA_INVALID(1009, "验证码无效"), + TOKEN_INVALID(1010, "Token无效"), + TOKEN_EXPIRED(1011, "Token已过期"), + SESSION_KICKED(1012, "会话已被踢出"), + + ARTICLE_NOT_FOUND(2001, "文章不存在"), + ARTICLE_NO_PERMISSION(2002, "无权操作该文章"), + TAG_LIMIT_EXCEEDED(2003, "标签数量超出限制"), + CATEGORY_NOT_FOUND(2004, "分类不存在"), + CATEGORY_DISABLED(2005, "分类已禁用"), + CATEGORY_HAS_ARTICLES(2006, "分类下存在文章,无法删除"), + UPLOAD_FILE_INVALID(2007, "上传文件不合法"), + UPLOAD_FILE_TOO_LARGE(2008, "上传文件过大"), + + COMMENT_NOT_FOUND(3001, "评论不存在"), + COMMENT_AUDIT_PENDING(3002, "评论待审核"), + COMMENT_NO_PERMISSION(3003, "无权操作该评论"), + COMMENT_LEVEL_EXCEEDED(3004, "评论层级超限"); + + private final int code; + private final String message; +} diff --git a/jog-common/src/main/java/fun/nojava/common/model/ApiResult.java b/jog-common/src/main/java/fun/nojava/common/model/ApiResult.java new file mode 100644 index 0000000..c907654 --- /dev/null +++ b/jog-common/src/main/java/fun/nojava/common/model/ApiResult.java @@ -0,0 +1,47 @@ +package fun.nojava.common.model; + +import com.fasterxml.jackson.annotation.JsonInclude; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +@Schema(description = "统一响应结果") +public class ApiResult { + + @Schema(description = "业务状态码") + private int code; + + @Schema(description = "提示信息") + private String message; + + @Schema(description = "响应数据") + private T data; + + @Schema(description = "响应时间戳") + private long timestamp; + + public static ApiResult success() { + return new ApiResult<>(200, "success", null, System.currentTimeMillis()); + } + + public static ApiResult success(T data) { + return new ApiResult<>(200, "success", data, System.currentTimeMillis()); + } + + public static ApiResult success(String message, T data) { + return new ApiResult<>(200, message, data, System.currentTimeMillis()); + } + + public static ApiResult error(int code, String message) { + return new ApiResult<>(code, message, null, System.currentTimeMillis()); + } + + public static ApiResult error(fun.nojava.common.exception.ErrorCode errorCode) { + return new ApiResult<>(errorCode.getCode(), errorCode.getMessage(), null, System.currentTimeMillis()); + } +} diff --git a/jog-common/src/main/java/fun/nojava/common/model/IdVO.java b/jog-common/src/main/java/fun/nojava/common/model/IdVO.java new file mode 100644 index 0000000..942440d --- /dev/null +++ b/jog-common/src/main/java/fun/nojava/common/model/IdVO.java @@ -0,0 +1,16 @@ +package fun.nojava.common.model; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "ID返回") +public class IdVO { + + @Schema(description = "ID") + private Long id; +} diff --git a/jog-common/src/main/java/fun/nojava/common/model/PageParam.java b/jog-common/src/main/java/fun/nojava/common/model/PageParam.java new file mode 100644 index 0000000..f7e6187 --- /dev/null +++ b/jog-common/src/main/java/fun/nojava/common/model/PageParam.java @@ -0,0 +1,20 @@ +package fun.nojava.common.model; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import lombok.Data; + +@Data +@Schema(description = "分页请求参数") +public class PageParam { + + @Schema(description = "页码,从1开始") + @Min(value = 1, message = "页码最小为1") + private int page = 1; + + @Schema(description = "每页条数") + @Min(value = 1, message = "每页条数最小为1") + @Max(value = 100, message = "每页条数最大为100") + private int pageSize = 15; +} diff --git a/jog-common/src/main/java/fun/nojava/common/model/PageResult.java b/jog-common/src/main/java/fun/nojava/common/model/PageResult.java new file mode 100644 index 0000000..aae3dca --- /dev/null +++ b/jog-common/src/main/java/fun/nojava/common/model/PageResult.java @@ -0,0 +1,27 @@ +package fun.nojava.common.model; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "分页结果") +public class PageResult { + + @Schema(description = "数据列表") + private List records; + + @Schema(description = "总记录数") + private long total; + + @Schema(description = "当前页码") + private int page; + + @Schema(description = "每页条数") + private int pageSize; +} diff --git a/jog-common/src/main/java/fun/nojava/common/util/ApiResultUtils.java b/jog-common/src/main/java/fun/nojava/common/util/ApiResultUtils.java new file mode 100644 index 0000000..45e5e09 --- /dev/null +++ b/jog-common/src/main/java/fun/nojava/common/util/ApiResultUtils.java @@ -0,0 +1,27 @@ +package fun.nojava.common.util; + +import fun.nojava.common.model.ApiResult; +import fun.nojava.common.model.PageResult; +import org.springframework.data.domain.Page; + +import java.util.List; + +public final class ApiResultUtils { + + private ApiResultUtils() {} + + public static ApiResult> page(List records, long total, int page, int pageSize) { + PageResult pageResult = new PageResult<>(records, total, page, pageSize); + return ApiResult.success(pageResult); + } + + public static ApiResult> page(Page springPage) { + PageResult pageResult = new PageResult<>( + springPage.getContent(), + springPage.getTotalElements(), + springPage.getNumber() + 1, + springPage.getSize() + ); + return ApiResult.success(pageResult); + } +} diff --git a/jog-framework/pom.xml b/jog-framework/pom.xml new file mode 100644 index 0000000..851840f --- /dev/null +++ b/jog-framework/pom.xml @@ -0,0 +1,71 @@ + + + 4.0.0 + + fun.nojava + jog-system + 1.0.0 + + + jog-framework + Jog Framework + + + + fun.nojava + jog-common + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-security + + + org.springframework.boot + spring-boot-starter-aop + + + org.springframework.boot + spring-boot-starter-data-redis + + + io.jsonwebtoken + jjwt-api + + + io.jsonwebtoken + jjwt-impl + + + io.jsonwebtoken + jjwt-jackson + + + org.jsoup + jsoup + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + + + com.baomidou + mybatis-plus-spring-boot3-starter + + + org.projectlombok + lombok + true + + + jakarta.servlet + jakarta.servlet-api + provided + + + diff --git a/jog-framework/src/main/java/fun/nojava/framework/aspect/LogAspect.java b/jog-framework/src/main/java/fun/nojava/framework/aspect/LogAspect.java new file mode 100644 index 0000000..287bc7d --- /dev/null +++ b/jog-framework/src/main/java/fun/nojava/framework/aspect/LogAspect.java @@ -0,0 +1,60 @@ +package fun.nojava.framework.aspect; + +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.springframework.stereotype.Component; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +@Slf4j +@Aspect +@Component +@RequiredArgsConstructor +public class LogAspect { + + @Around("@annotation(org.springframework.web.bind.annotation.PostMapping) || " + + "@annotation(org.springframework.web.bind.annotation.PutMapping) || " + + "@annotation(org.springframework.web.bind.annotation.DeleteMapping) || " + + "@annotation(org.springframework.web.bind.annotation.PatchMapping)") + public Object logAround(ProceedingJoinPoint joinPoint) throws Throwable { + ServletRequestAttributes attributes = + (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); + if (attributes == null) { + return joinPoint.proceed(); + } + + HttpServletRequest request = attributes.getRequest(); + String method = request.getMethod(); + String uri = request.getRequestURI(); + String ip = getClientIp(request); + + log.info("Request: {} {} from IP: {}", method, uri, ip); + + try { + Object result = joinPoint.proceed(); + log.info("Response: {} {} - success", method, uri); + return result; + } catch (Exception e) { + log.error("Response: {} {} - error: {}", method, uri, e.getMessage()); + throw e; + } + } + + private String getClientIp(HttpServletRequest request) { + String ip = request.getHeader("X-Forwarded-For"); + if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) { + ip = request.getHeader("X-Real-IP"); + } + if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) { + ip = request.getRemoteAddr(); + } + if (ip != null && ip.contains(",")) { + ip = ip.split(",")[0].trim(); + } + return ip; + } +} diff --git a/jog-framework/src/main/java/fun/nojava/framework/aspect/RateLimitAspect.java b/jog-framework/src/main/java/fun/nojava/framework/aspect/RateLimitAspect.java new file mode 100644 index 0000000..9831347 --- /dev/null +++ b/jog-framework/src/main/java/fun/nojava/framework/aspect/RateLimitAspect.java @@ -0,0 +1,62 @@ +package fun.nojava.framework.aspect; + +import fun.nojava.common.annotation.RateLimit; +import fun.nojava.common.exception.BusinessException; +import fun.nojava.common.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.reflect.MethodSignature; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.script.DefaultRedisScript; +import org.springframework.stereotype.Component; + +import java.lang.reflect.Method; +import java.util.Collections; +import java.util.concurrent.TimeUnit; + +@Slf4j +@Aspect +@Component +@RequiredArgsConstructor +public class RateLimitAspect { + + private final StringRedisTemplate redisTemplate; + + private static final String LUA_SCRIPT = + "local current = redis.call('INCR', KEYS[1]) " + + "if current == 1 then " + + " redis.call('EXPIRE', KEYS[1], ARGV[2]) " + + "end " + + "if current > tonumber(ARGV[1]) then " + + " return 0 " + + "end " + + "return 1"; + + @Around("@annotation(fun.nojava.common.annotation.RateLimit)") + public Object around(ProceedingJoinPoint joinPoint) throws Throwable { + MethodSignature signature = (MethodSignature) joinPoint.getSignature(); + Method method = signature.getMethod(); + RateLimit rateLimit = method.getAnnotation(RateLimit.class); + + String key = "rate_limit:" + rateLimit.key(); + long duration = rateLimit.unit().toSeconds(rateLimit.duration()); + + DefaultRedisScript redisScript = new DefaultRedisScript<>(LUA_SCRIPT, Long.class); + Long result = redisTemplate.execute( + redisScript, + Collections.singletonList(key), + String.valueOf(rateLimit.maxCount()), + String.valueOf(duration) + ); + + if (result == null || result == 0) { + log.warn("Rate limit exceeded for key: {}", key); + throw new BusinessException(ErrorCode.RATE_LIMITED); + } + + return joinPoint.proceed(); + } +} diff --git a/jog-framework/src/main/java/fun/nojava/framework/config/JwtProperties.java b/jog-framework/src/main/java/fun/nojava/framework/config/JwtProperties.java new file mode 100644 index 0000000..89bc6cc --- /dev/null +++ b/jog-framework/src/main/java/fun/nojava/framework/config/JwtProperties.java @@ -0,0 +1,25 @@ +package fun.nojava.framework.config; + +import io.jsonwebtoken.security.Keys; +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import java.nio.charset.StandardCharsets; + +@Data +@Component +@ConfigurationProperties(prefix = "jwt") +public class JwtProperties { + + private String secret; + private long accessTokenExpire = 7200L; + private long refreshTokenExpire = 86400L; + private long refreshTokenExpireRemember = 1209600L; + + public SecretKey getSigningKey() { + byte[] keyBytes = secret.getBytes(StandardCharsets.UTF_8); + return Keys.hmacShaKeyFor(keyBytes); + } +} diff --git a/jog-framework/src/main/java/fun/nojava/framework/config/SecurityConfig.java b/jog-framework/src/main/java/fun/nojava/framework/config/SecurityConfig.java new file mode 100644 index 0000000..ccc4ee0 --- /dev/null +++ b/jog-framework/src/main/java/fun/nojava/framework/config/SecurityConfig.java @@ -0,0 +1,80 @@ +package fun.nojava.framework.config; + +import fun.nojava.framework.security.JwtAuthenticationFilter; +import fun.nojava.framework.security.JwtAuthenticationEntryPoint; +import lombok.RequiredArgsConstructor; +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.config.annotation.authentication.configuration.AuthenticationConfiguration; +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.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import java.util.List; + +@Configuration +@EnableWebSecurity +@EnableMethodSecurity +@RequiredArgsConstructor +public class SecurityConfig { + + private final JwtAuthenticationFilter jwtAuthenticationFilter; + private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint; + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http + .csrf(AbstractHttpConfigurer::disable) + .cors(cors -> cors.configurationSource(corsConfigurationSource())) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .exceptionHandling(ex -> ex.authenticationEntryPoint(jwtAuthenticationEntryPoint)) + .authorizeHttpRequests(auth -> auth + .requestMatchers("/api/public/**").permitAll() + .requestMatchers("/api/oauth/**").permitAll() + .requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll() + .requestMatchers("/static/**", "/index.html", "/").permitAll() + .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() + .requestMatchers("/api/admin/**").hasRole("ADMIN") + .requestMatchers("/api/app/**").hasAnyRole("USER", "ADMIN") + .anyRequest().permitAll() + ) + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } + + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration config = new CorsConfiguration(); + config.setAllowedOriginPatterns(List.of("*")); + config.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")); + config.setAllowedHeaders(List.of("*")); + config.setAllowCredentials(true); + config.setMaxAge(3600L); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", config); + return source; + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + @Bean + public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception { + return config.getAuthenticationManager(); + } +} diff --git a/jog-framework/src/main/java/fun/nojava/framework/config/WebConfig.java b/jog-framework/src/main/java/fun/nojava/framework/config/WebConfig.java new file mode 100644 index 0000000..02ba668 --- /dev/null +++ b/jog-framework/src/main/java/fun/nojava/framework/config/WebConfig.java @@ -0,0 +1,20 @@ +package fun.nojava.framework.config; + +import fun.nojava.framework.security.JwtTokenProvider; +import fun.nojava.framework.filter.XssFilter; +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class WebConfig { + + @Bean + public FilterRegistrationBean xssFilterRegistration() { + FilterRegistrationBean registration = new FilterRegistrationBean<>(); + registration.setFilter(new XssFilter()); + registration.addUrlPatterns("/api/*"); + registration.setOrder(1); + return registration; + } +} diff --git a/jog-framework/src/main/java/fun/nojava/framework/filter/XssFilter.java b/jog-framework/src/main/java/fun/nojava/framework/filter/XssFilter.java new file mode 100644 index 0000000..4ccb954 --- /dev/null +++ b/jog-framework/src/main/java/fun/nojava/framework/filter/XssFilter.java @@ -0,0 +1,19 @@ +package fun.nojava.framework.filter; + +import fun.nojava.framework.wrapper.XssHttpServletRequestWrapper; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +public class XssFilter extends OncePerRequestFilter { + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + filterChain.doFilter(new XssHttpServletRequestWrapper(request), response); + } +} diff --git a/jog-framework/src/main/java/fun/nojava/framework/handler/GlobalExceptionHandler.java b/jog-framework/src/main/java/fun/nojava/framework/handler/GlobalExceptionHandler.java new file mode 100644 index 0000000..406d12b --- /dev/null +++ b/jog-framework/src/main/java/fun/nojava/framework/handler/GlobalExceptionHandler.java @@ -0,0 +1,50 @@ +package fun.nojava.framework.handler; + +import fun.nojava.common.exception.BusinessException; +import fun.nojava.common.exception.ErrorCode; +import fun.nojava.common.model.ApiResult; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.util.stream.Collectors; + +@Slf4j +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(BusinessException.class) + public ApiResult handleBusinessException(BusinessException e) { + log.warn("Business exception: code={}, message={}", e.getCode(), e.getMessage()); + return ApiResult.error(e.getCode(), e.getMessage()); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public ApiResult handleValidationException(MethodArgumentNotValidException e) { + String message = e.getBindingResult().getFieldErrors().stream() + .map(FieldError::getDefaultMessage) + .collect(Collectors.joining("; ")); + log.warn("Validation error: {}", message); + return ApiResult.error(ErrorCode.BAD_REQUEST.getCode(), message); + } + + @ExceptionHandler(AccessDeniedException.class) + @ResponseStatus(HttpStatus.FORBIDDEN) + public ApiResult handleAccessDeniedException(AccessDeniedException e) { + log.warn("Access denied: {}", e.getMessage()); + return ApiResult.error(ErrorCode.FORBIDDEN); + } + + @ExceptionHandler(Exception.class) + @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) + public ApiResult handleException(Exception e) { + log.error("Unexpected error", e); + return ApiResult.error(ErrorCode.INTERNAL_ERROR); + } +} diff --git a/jog-framework/src/main/java/fun/nojava/framework/security/JwtAuthenticationEntryPoint.java b/jog-framework/src/main/java/fun/nojava/framework/security/JwtAuthenticationEntryPoint.java new file mode 100644 index 0000000..7c1b4eb --- /dev/null +++ b/jog-framework/src/main/java/fun/nojava/framework/security/JwtAuthenticationEntryPoint.java @@ -0,0 +1,35 @@ +package fun.nojava.framework.security; + +import fun.nojava.common.model.ApiResult; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.MediaType; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Slf4j +@Component +@RequiredArgsConstructor +public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint { + + private final ObjectMapper objectMapper; + + @Override + public void commence(HttpServletRequest request, HttpServletResponse response, + AuthenticationException authException) throws IOException { + log.warn("Unauthorized access: {} {}", request.getMethod(), request.getRequestURI()); + + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding("UTF-8"); + + ApiResult> result = ApiResult.error(401, "未认证,请先登录"); + response.getWriter().write(objectMapper.writeValueAsString(result)); + } +} diff --git a/jog-framework/src/main/java/fun/nojava/framework/security/JwtAuthenticationFilter.java b/jog-framework/src/main/java/fun/nojava/framework/security/JwtAuthenticationFilter.java new file mode 100644 index 0000000..3b0aa7f --- /dev/null +++ b/jog-framework/src/main/java/fun/nojava/framework/security/JwtAuthenticationFilter.java @@ -0,0 +1,62 @@ +package fun.nojava.framework.security; + +import fun.nojava.common.constant.SecurityConstants; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.List; + +@Slf4j +@Component +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtTokenProvider jwtTokenProvider; + private final TokenBlacklistService tokenBlacklistService; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + String token = resolveToken(request); + + if (StringUtils.hasText(token) && jwtTokenProvider.validateToken(token)) { + if (tokenBlacklistService.isAccessTokenBlacklisted(token)) { + filterChain.doFilter(request, response); + return; + } + + Long userId = jwtTokenProvider.getUserIdFromToken(token); + String role = jwtTokenProvider.getRoleFromToken(token); + + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken( + userId, + null, + List.of(new SimpleGrantedAuthority("ROLE_" + role)) + ); + + SecurityContextHolder.getContext().setAuthentication(authentication); + } + + filterChain.doFilter(request, response); + } + + private String resolveToken(HttpServletRequest request) { + String bearerToken = request.getHeader(SecurityConstants.TOKEN_HEADER); + if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(SecurityConstants.TOKEN_PREFIX)) { + return bearerToken.substring(SecurityConstants.TOKEN_PREFIX.length()); + } + return null; + } +} diff --git a/jog-framework/src/main/java/fun/nojava/framework/security/JwtTokenProvider.java b/jog-framework/src/main/java/fun/nojava/framework/security/JwtTokenProvider.java new file mode 100644 index 0000000..be45b6a --- /dev/null +++ b/jog-framework/src/main/java/fun/nojava/framework/security/JwtTokenProvider.java @@ -0,0 +1,91 @@ +package fun.nojava.framework.security; + +import fun.nojava.common.constant.SecurityConstants; +import fun.nojava.framework.config.JwtProperties; +import io.jsonwebtoken.*; +import io.jsonwebtoken.security.Keys; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import java.nio.charset.StandardCharsets; +import java.util.Date; + +@Slf4j +@Component +@RequiredArgsConstructor +public class JwtTokenProvider { + + private final JwtProperties jwtProperties; + + public String generateAccessToken(Long userId, String role) { + Date now = new Date(); + Date expiryDate = new Date(now.getTime() + jwtProperties.getAccessTokenExpire() * 1000); + + return Jwts.builder() + .subject(String.valueOf(userId)) + .claim("role", role) + .issuedAt(now) + .expiration(expiryDate) + .signWith(jwtProperties.getSigningKey()) + .compact(); + } + + public String generateRefreshToken(Long userId) { + Date now = new Date(); + Date expiryDate = new Date(now.getTime() + jwtProperties.getRefreshTokenExpire() * 1000); + + return Jwts.builder() + .subject(String.valueOf(userId)) + .claim("type", "refresh") + .issuedAt(now) + .expiration(expiryDate) + .signWith(jwtProperties.getSigningKey()) + .compact(); + } + + public String generateRefreshToken(Long userId, boolean rememberMe) { + long expireSeconds = rememberMe + ? jwtProperties.getRefreshTokenExpireRemember() + : jwtProperties.getRefreshTokenExpire(); + Date now = new Date(); + Date expiryDate = new Date(now.getTime() + expireSeconds * 1000); + + return Jwts.builder() + .subject(String.valueOf(userId)) + .claim("type", "refresh") + .issuedAt(now) + .expiration(expiryDate) + .signWith(jwtProperties.getSigningKey()) + .compact(); + } + + public Long getUserIdFromToken(String token) { + Claims claims = parseToken(token); + return Long.parseLong(claims.getSubject()); + } + + public String getRoleFromToken(String token) { + Claims claims = parseToken(token); + return claims.get("role", String.class); + } + + public boolean validateToken(String token) { + try { + parseToken(token); + return true; + } catch (JwtException | IllegalArgumentException e) { + log.warn("JWT validation failed: {}", e.getMessage()); + return false; + } + } + + private Claims parseToken(String token) { + return Jwts.parser() + .verifyWith(jwtProperties.getSigningKey()) + .build() + .parseSignedClaims(token) + .getPayload(); + } +} diff --git a/jog-framework/src/main/java/fun/nojava/framework/security/TokenBlacklistService.java b/jog-framework/src/main/java/fun/nojava/framework/security/TokenBlacklistService.java new file mode 100644 index 0000000..17691c6 --- /dev/null +++ b/jog-framework/src/main/java/fun/nojava/framework/security/TokenBlacklistService.java @@ -0,0 +1,43 @@ +package fun.nojava.framework.security; + +import fun.nojava.common.constant.SecurityConstants; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Component; + +import java.util.concurrent.TimeUnit; + +@Component +@RequiredArgsConstructor +public class TokenBlacklistService { + + private final StringRedisTemplate redisTemplate; + + public void blacklistAccessToken(String token, long remainingSeconds) { + String key = "blacklist:access:" + token; + redisTemplate.opsForValue().set(key, "1", remainingSeconds, TimeUnit.SECONDS); + } + + public void storeRefreshToken(Long userId, String refreshToken, long expireSeconds) { + String key = SecurityConstants.REFRESH_TOKEN_KEY + userId; + redisTemplate.opsForValue().set(key, refreshToken, expireSeconds, TimeUnit.SECONDS); + } + + public boolean isRefreshTokenValid(Long userId, String refreshToken) { + String key = SecurityConstants.REFRESH_TOKEN_KEY + userId; + String stored = redisTemplate.opsForValue().get(key); + return refreshToken.equals(stored); + } + + public boolean isAccessTokenBlacklisted(String token) { + String key = "blacklist:access:" + token; + return Boolean.TRUE.equals(redisTemplate.hasKey(key)); + } + + public void removeRefreshToken(Long userId) { + String key = SecurityConstants.REFRESH_TOKEN_KEY + userId; + redisTemplate.delete(key); + } +} diff --git a/jog-framework/src/main/java/fun/nojava/framework/util/WebUtils.java b/jog-framework/src/main/java/fun/nojava/framework/util/WebUtils.java new file mode 100644 index 0000000..ffd828d --- /dev/null +++ b/jog-framework/src/main/java/fun/nojava/framework/util/WebUtils.java @@ -0,0 +1,48 @@ +package fun.nojava.framework.util; + +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +public final class WebUtils { + + private WebUtils() {} + + public static String getClientIp() { + ServletRequestAttributes attributes = + (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); + if (attributes == null) { + return "unknown"; + } + return getClientIp(attributes.getRequest()); + } + + public static String getClientIp(HttpServletRequest request) { + String ip = request.getHeader("X-Forwarded-For"); + if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) { + ip = request.getHeader("X-Real-IP"); + } + if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) { + ip = request.getRemoteAddr(); + } + if (ip != null && ip.contains(",")) { + ip = ip.split(",")[0].trim(); + } + return ip; + } + + public static String getUserAgent() { + ServletRequestAttributes attributes = + (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); + if (attributes == null) { + return ""; + } + return attributes.getRequest().getHeader("User-Agent"); + } + + public static String generateDeviceFingerprint(HttpServletRequest request) { + String ua = request.getHeader("User-Agent"); + String accept = request.getHeader("Accept-Language"); + return String.valueOf((ua + accept).hashCode()); + } +} diff --git a/jog-framework/src/main/java/fun/nojava/framework/wrapper/XssHttpServletRequestWrapper.java b/jog-framework/src/main/java/fun/nojava/framework/wrapper/XssHttpServletRequestWrapper.java new file mode 100644 index 0000000..b6e933e --- /dev/null +++ b/jog-framework/src/main/java/fun/nojava/framework/wrapper/XssHttpServletRequestWrapper.java @@ -0,0 +1,45 @@ +package fun.nojava.framework.wrapper; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletRequestWrapper; +import org.jsoup.Jsoup; +import org.jsoup.safety.Safelist; + +public class XssHttpServletRequestWrapper extends HttpServletRequestWrapper { + + public XssHttpServletRequestWrapper(HttpServletRequest request) { + super(request); + } + + @Override + public String getParameter(String name) { + String value = super.getParameter(name); + return cleanXss(value); + } + + @Override + public String[] getParameterValues(String name) { + String[] values = super.getParameterValues(name); + if (values == null) { + return null; + } + String[] cleaned = new String[values.length]; + for (int i = 0; i < values.length; i++) { + cleaned[i] = cleanXss(values[i]); + } + return cleaned; + } + + @Override + public String getHeader(String name) { + String value = super.getHeader(name); + return cleanXss(value); + } + + private String cleanXss(String value) { + if (value == null || value.isEmpty()) { + return value; + } + return Jsoup.clean(value, Safelist.basic()); + } +} diff --git a/jog-frontend/index.html b/jog-frontend/index.html new file mode 100644 index 0000000..5d17e47 --- /dev/null +++ b/jog-frontend/index.html @@ -0,0 +1,12 @@ + + + + + + 博客系统 + + + + + + diff --git a/jog-frontend/package.json b/jog-frontend/package.json new file mode 100644 index 0000000..be044bf --- /dev/null +++ b/jog-frontend/package.json @@ -0,0 +1,30 @@ +{ + "name": "jog-frontend", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "vue": "^3.5.13", + "vue-router": "^4.5.0", + "pinia": "^2.3.0", + "axios": "^1.7.9", + "element-plus": "^2.9.1", + "@element-plus/icons-vue": "^2.3.1", + "@tiptap/vue-3": "^2.11.5", + "@tiptap/starter-kit": "^2.11.5", + "@tiptap/extension-image": "^2.11.5", + "@tiptap/extension-link": "^2.11.5", + "@tiptap/extension-placeholder": "^2.11.5" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.2.1", + "vite": "^6.0.7", + "unplugin-auto-import": "^0.18.6", + "unplugin-vue-components": "^0.27.5" + } +} diff --git a/jog-frontend/src/App.vue b/jog-frontend/src/App.vue new file mode 100644 index 0000000..696f8b7 --- /dev/null +++ b/jog-frontend/src/App.vue @@ -0,0 +1,14 @@ + + + + + + + diff --git a/jog-frontend/src/api/admin.js b/jog-frontend/src/api/admin.js new file mode 100644 index 0000000..6a26fda --- /dev/null +++ b/jog-frontend/src/api/admin.js @@ -0,0 +1,13 @@ +import request from '@/utils/request' + +export function getUserList(params) { + return request.get('/api/admin/user/list', { params }) +} + +export function resetUserPassword(id, newPassword) { + return request.put(`/api/admin/user/${id}/reset-password`, null, { params: { newPassword } }) +} + +export function updateUserStatus(id, status) { + return request.put(`/api/admin/user/${id}/status`, null, { params: { status } }) +} diff --git a/jog-frontend/src/api/article.js b/jog-frontend/src/api/article.js new file mode 100644 index 0000000..449cca6 --- /dev/null +++ b/jog-frontend/src/api/article.js @@ -0,0 +1,69 @@ +import request from '@/utils/request' + +export function getPublicArticles(params) { + return request.get('/api/public/article/list', { params }) +} + +export function getArticleDetail(id) { + return request.get(`/api/public/article/${id}`) +} + +export function createArticle(data) { + return request.post('/api/app/article', data) +} + +export function updateArticle(id, data) { + return request.put(`/api/app/article/${id}`, data) +} + +export function deleteArticle(id) { + return request.delete(`/api/app/article/${id}`) +} + +export function getMyArticles(params) { + return request.get('/api/app/article/mine', { params }) +} + +export function moveToRecycleBin(id) { + return request.put(`/api/app/article/${id}/recycle`) +} + +export function restoreArticle(id) { + return request.put(`/api/app/article/${id}/restore`) +} + +export function toggleArticleLike(id) { + return request.post(`/api/app/article/${id}/like`) +} + +export function getCategories() { + return request.get('/api/public/category/list') +} + +export function getTags() { + return request.get('/api/public/tag/list') +} + +export function createCategory(data) { + return request.post('/api/admin/category', data) +} + +export function updateCategory(id, data) { + return request.put(`/api/admin/category/${id}`, data) +} + +export function deleteCategory(id) { + return request.delete(`/api/admin/category/${id}`) +} + +export function createTag(name) { + return request.post('/api/admin/tag', null, { params: { name } }) +} + +export function getAdminArticles(params) { + return request.get('/api/admin/article/list', { params }) +} + +export function adminDeleteArticle(id) { + return request.delete(`/api/admin/article/${id}`) +} diff --git a/jog-frontend/src/api/auth.js b/jog-frontend/src/api/auth.js new file mode 100644 index 0000000..af78abc --- /dev/null +++ b/jog-frontend/src/api/auth.js @@ -0,0 +1,41 @@ +import request from '@/utils/request' + +export function adminLogin(data) { + return request.post('/api/public/admin/login', data) +} + +export function userLogin(data) { + return request.post('/api/public/user/login', data) +} + +export function register(data) { + return request.post('/api/public/user/register', data) +} + +export function getCaptcha() { + return request.get('/api/public/captcha') +} + +export function refreshToken(token) { + return request.post('/api/public/refresh-token', null, { params: { refreshToken: token } }) +} + +export function logout() { + return request.post('/api/app/user/logout') +} + +export function getCurrentUser() { + return request.get('/api/app/user/me') +} + +export function getUserInfo(id) { + return request.get(`/api/app/user/${id}`) +} + +export function followUser(id) { + return request.post(`/api/app/user/${id}/follow`) +} + +export function unfollowUser(id) { + return request.delete(`/api/app/user/${id}/follow`) +} diff --git a/jog-frontend/src/api/comment.js b/jog-frontend/src/api/comment.js new file mode 100644 index 0000000..19e0dd0 --- /dev/null +++ b/jog-frontend/src/api/comment.js @@ -0,0 +1,29 @@ +import request from '@/utils/request' + +export function getArticleComments(articleId) { + return request.get(`/api/app/comment/article/${articleId}`) +} + +export function createComment(data) { + return request.post('/api/app/comment', data) +} + +export function deleteComment(id) { + return request.delete(`/api/app/comment/${id}`) +} + +export function toggleCommentLike(id) { + return request.post(`/api/app/comment/${id}/like`) +} + +export function getAdminComments(params) { + return request.get('/api/admin/comment/list', { params }) +} + +export function auditComment(id, status) { + return request.put(`/api/admin/comment/${id}/audit`, null, { params: { status } }) +} + +export function adminDeleteComment(id) { + return request.delete(`/api/admin/comment/${id}`) +} diff --git a/jog-frontend/src/layouts/AdminLayout.vue b/jog-frontend/src/layouts/AdminLayout.vue new file mode 100644 index 0000000..77b01dd --- /dev/null +++ b/jog-frontend/src/layouts/AdminLayout.vue @@ -0,0 +1,82 @@ + + + + + 管理后台 + + + + 仪表盘 + + + + 用户管理 + + + + 文章管理 + + + + 评论管理 + + + + 分类管理 + + + + + + 返回前台 + 退出 + + + + + + + + + + + + diff --git a/jog-frontend/src/layouts/AppLayout.vue b/jog-frontend/src/layouts/AppLayout.vue new file mode 100644 index 0000000..0cc7d7e --- /dev/null +++ b/jog-frontend/src/layouts/AppLayout.vue @@ -0,0 +1,41 @@ + + + + + + + + 控制台 + + + + 我的文章 + + + + 写文章 + + + + + + + + + + + + + diff --git a/jog-frontend/src/layouts/BlogLayout.vue b/jog-frontend/src/layouts/BlogLayout.vue new file mode 100644 index 0000000..802cbaf --- /dev/null +++ b/jog-frontend/src/layouts/BlogLayout.vue @@ -0,0 +1,106 @@ + + + + + Jog + + + 首页 + + + + + + + + {{ userStore.userInfo?.nickname || '用户' }} + + + + 控制台 + 管理后台 + 退出登录 + + + + + + 登录 + 注册 + + + + + + + + Jog © 2026 + + + + + + diff --git a/jog-frontend/src/main.js b/jog-frontend/src/main.js new file mode 100644 index 0000000..84b89e4 --- /dev/null +++ b/jog-frontend/src/main.js @@ -0,0 +1,19 @@ +import { createApp } from 'vue' +import { createPinia } from 'pinia' +import ElementPlus from 'element-plus' +import 'element-plus/dist/index.css' +import zhCn from 'element-plus/dist/locale/zh-cn.mjs' +import * as ElementPlusIconsVue from '@element-plus/icons-vue' +import App from './App.vue' +import router from './router' + +const app = createApp(App) + +for (const [key, component] of Object.entries(ElementPlusIconsVue)) { + app.component(key, component) +} + +app.use(createPinia()) +app.use(router) +app.use(ElementPlus, { locale: zhCn }) +app.mount('#app') diff --git a/jog-frontend/src/router/index.js b/jog-frontend/src/router/index.js new file mode 100644 index 0000000..02ebb6d --- /dev/null +++ b/jog-frontend/src/router/index.js @@ -0,0 +1,119 @@ +import { createRouter, createWebHistory } from 'vue-router' + +const routes = [ + { + path: '/login', + name: 'Login', + component: () => import('@/views/Login.vue'), + meta: { title: '登录' }, + }, + { + path: '/register', + name: 'Register', + component: () => import('@/views/Register.vue'), + meta: { title: '注册' }, + }, + { + path: '/', + component: () => import('@/layouts/BlogLayout.vue'), + children: [ + { + path: '', + name: 'Home', + component: () => import('@/views/Home.vue'), + meta: { title: '首页' }, + }, + { + path: 'article/:id', + name: 'ArticleDetail', + component: () => import('@/views/ArticleDetail.vue'), + meta: { title: '文章详情' }, + }, + ], + }, + { + path: '/app', + component: () => import('@/layouts/AppLayout.vue'), + meta: { requiresAuth: true }, + children: [ + { + path: 'dashboard', + name: 'Dashboard', + component: () => import('@/views/app/Dashboard.vue'), + meta: { title: '控制台', requiresAuth: true }, + }, + { + path: 'articles', + name: 'MyArticles', + component: () => import('@/views/app/MyArticles.vue'), + meta: { title: '我的文章', requiresAuth: true }, + }, + { + path: 'editor', + name: 'NewArticle', + component: () => import('@/views/app/ArticleEditor.vue'), + meta: { title: '写文章', requiresAuth: true }, + }, + { + path: 'editor/:id', + name: 'EditArticle', + component: () => import('@/views/app/ArticleEditor.vue'), + meta: { title: '编辑文章', requiresAuth: true }, + }, + ], + }, + { + path: '/admin', + component: () => import('@/layouts/AdminLayout.vue'), + meta: { requiresAuth: true, requiresAdmin: true }, + children: [ + { + path: '', + name: 'AdminDashboard', + component: () => import('@/views/admin/Dashboard.vue'), + meta: { title: '管理后台', requiresAuth: true, requiresAdmin: true }, + }, + { + path: 'users', + name: 'AdminUsers', + component: () => import('@/views/admin/Users.vue'), + meta: { title: '用户管理', requiresAuth: true, requiresAdmin: true }, + }, + { + path: 'articles', + name: 'AdminArticles', + component: () => import('@/views/admin/Articles.vue'), + meta: { title: '文章管理', requiresAuth: true, requiresAdmin: true }, + }, + { + path: 'comments', + name: 'AdminComments', + component: () => import('@/views/admin/Comments.vue'), + meta: { title: '评论管理', requiresAuth: true, requiresAdmin: true }, + }, + { + path: 'categories', + name: 'AdminCategories', + component: () => import('@/views/admin/Categories.vue'), + meta: { title: '分类管理', requiresAuth: true, requiresAdmin: true }, + }, + ], + }, +] + +const router = createRouter({ + history: createWebHistory(), + routes, +}) + +router.beforeEach((to, from, next) => { + document.title = to.meta.title ? `${to.meta.title} - 博客系统` : '博客系统' + const token = localStorage.getItem('access_token') + if (to.meta.requiresAuth && !token) { + next({ name: 'Login', query: { redirect: to.fullPath } }) + } else { + next() + } +}) + +export default router diff --git a/jog-frontend/src/stores/user.js b/jog-frontend/src/stores/user.js new file mode 100644 index 0000000..d7af2e5 --- /dev/null +++ b/jog-frontend/src/stores/user.js @@ -0,0 +1,80 @@ +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' +import { adminLogin, userLogin, register, getCurrentUser, logout as apiLogout } from '@/api/auth' + +export const useUserStore = defineStore('user', () => { + const token = ref(localStorage.getItem('access_token') || '') + const refreshToken = ref(localStorage.getItem('refresh_token') || '') + const userInfo = ref(JSON.parse(localStorage.getItem('user_info') || 'null')) + + const isLoggedIn = computed(() => !!token.value) + const isAdmin = computed(() => userInfo.value?.role === 'ADMIN') + + async function loginAdmin(data) { + const res = await adminLogin(data) + const loginData = res.data + setTokens(loginData.accessToken, loginData.refreshToken) + userInfo.value = { ...loginData.userInfo, role: 'ADMIN' } + localStorage.setItem('user_info', JSON.stringify(userInfo.value)) + return loginData + } + + async function loginUser(data) { + const res = await userLogin(data) + const loginData = res.data + setTokens(loginData.accessToken, loginData.refreshToken) + userInfo.value = { ...loginData.userInfo, role: 'USER' } + localStorage.setItem('user_info', JSON.stringify(userInfo.value)) + return loginData + } + + async function registerUser(data) { + return await register(data) + } + + async function fetchUserInfo() { + const res = await getCurrentUser() + const role = isAdmin.value ? 'ADMIN' : 'USER' + userInfo.value = { ...res.data, role } + localStorage.setItem('user_info', JSON.stringify(userInfo.value)) + } + + async function logout() { + try { + await apiLogout() + } catch { + // ignore + } + clearAuth() + } + + function setTokens(accessToken, refresh) { + token.value = accessToken + refreshToken.value = refresh + localStorage.setItem('access_token', accessToken) + localStorage.setItem('refresh_token', refresh) + } + + function clearAuth() { + token.value = '' + refreshToken.value = '' + userInfo.value = null + localStorage.removeItem('access_token') + localStorage.removeItem('refresh_token') + localStorage.removeItem('user_info') + } + + return { + token, + refreshToken, + userInfo, + isLoggedIn, + isAdmin, + loginAdmin, + loginUser, + registerUser, + fetchUserInfo, + logout, + clearAuth, + } +}) diff --git a/jog-frontend/src/utils/request.js b/jog-frontend/src/utils/request.js new file mode 100644 index 0000000..d1a8581 --- /dev/null +++ b/jog-frontend/src/utils/request.js @@ -0,0 +1,74 @@ +import axios from 'axios' +import { ElMessage } from 'element-plus' +import router from '@/router' + +const request = axios.create({ + baseURL: '', + timeout: 15000, +}) + +request.interceptors.request.use( + (config) => { + const token = localStorage.getItem('access_token') + if (token) { + config.headers.Authorization = `Bearer ${token}` + } + return config + }, + (error) => Promise.reject(error) +) + +request.interceptors.response.use( + (response) => { + const res = response.data + if (res.code !== 200) { + ElMessage.error(res.message || '请求失败') + if (res.code === 401) { + localStorage.removeItem('access_token') + localStorage.removeItem('refresh_token') + localStorage.removeItem('user_info') + router.push({ name: 'Login' }) + } + return Promise.reject(new Error(res.message)) + } + return res + }, + async (error) => { + if (error.response) { + const status = error.response.status + if (status === 401) { + const refreshToken = localStorage.getItem('refresh_token') + if (refreshToken) { + try { + const res = await axios.post('/api/public/refresh-token', null, { + params: { refreshToken }, + }) + if (res.data.code === 200) { + localStorage.setItem('access_token', res.data.data.accessToken) + localStorage.setItem('refresh_token', res.data.data.refreshToken) + error.config.headers.Authorization = `Bearer ${res.data.data.accessToken}` + return request(error.config) + } + } catch { + // refresh failed + } + } + localStorage.removeItem('access_token') + localStorage.removeItem('refresh_token') + localStorage.removeItem('user_info') + router.push({ name: 'Login' }) + } else if (status === 403) { + ElMessage.error('无权限访问') + } else if (status === 429) { + ElMessage.error('请求过于频繁,请稍后再试') + } else { + ElMessage.error(error.response.data?.message || '服务器错误') + } + } else { + ElMessage.error('网络连接失败') + } + return Promise.reject(error) + } +) + +export default request diff --git a/jog-frontend/src/views/ArticleDetail.vue b/jog-frontend/src/views/ArticleDetail.vue new file mode 100644 index 0000000..44aa978 --- /dev/null +++ b/jog-frontend/src/views/ArticleDetail.vue @@ -0,0 +1,170 @@ + + + + {{ article.title }} + + {{ article.authorNickname }} + {{ article.viewCount }} + {{ article.commentCount }} + {{ formatDate(article.publishedAt) }} + {{ article.categoryName }} + + + {{ tag.name }} + + + + + + + {{ article.liked ? '已点赞' : '点赞' }} ({{ article.likeCount }}) + + + + 评论 ({{ article.commentCount }}) + + + 发表评论 + + + 登录 后可以评论 + + + + + + {{ comment.nickname }} + {{ formatDate(comment.createdAt) }} + + {{ comment.content }} + + + 回复 + + + {{ comment.likeCount }} + + + + + {{ reply.nickname }} + 回复 {{ reply.replyToNickname }} + :{{ reply.content }} + {{ formatDate(reply.createdAt) }} + + + + + + + + + + + + diff --git a/jog-frontend/src/views/Home.vue b/jog-frontend/src/views/Home.vue new file mode 100644 index 0000000..da5ab50 --- /dev/null +++ b/jog-frontend/src/views/Home.vue @@ -0,0 +1,114 @@ + + + + 欢迎来到博客系统 + 分享知识,记录生活 + + + + + + + + + + + + + + + {{ article.title }} + {{ article.summary }} + + {{ article.authorNickname }} + {{ article.viewCount }} + {{ article.commentCount }} + {{ formatDate(article.publishedAt) }} + + + + + + + + + + + + diff --git a/jog-frontend/src/views/Login.vue b/jog-frontend/src/views/Login.vue new file mode 100644 index 0000000..6d38ce7 --- /dev/null +++ b/jog-frontend/src/views/Login.vue @@ -0,0 +1,134 @@ + + + + 登录 + + + + + + + + + + + + + + + + + 登录 + + + + + + + + + + + + + 记住我 + + + 登录 + + + + + + + + + + + + diff --git a/jog-frontend/src/views/Register.vue b/jog-frontend/src/views/Register.vue new file mode 100644 index 0000000..56a3dab --- /dev/null +++ b/jog-frontend/src/views/Register.vue @@ -0,0 +1,95 @@ + + + + 注册 + + + + + + + + + + + + 注册 + + + + + + + + + + diff --git a/jog-frontend/src/views/admin/Articles.vue b/jog-frontend/src/views/admin/Articles.vue new file mode 100644 index 0000000..f65c7bc --- /dev/null +++ b/jog-frontend/src/views/admin/Articles.vue @@ -0,0 +1,79 @@ + + + 文章管理 + + + + + + + + + + + + + + + + + + + + + {{ statusText(row) }} + + + + + 删除 + + + + + + + + diff --git a/jog-frontend/src/views/admin/Categories.vue b/jog-frontend/src/views/admin/Categories.vue new file mode 100644 index 0000000..24218c9 --- /dev/null +++ b/jog-frontend/src/views/admin/Categories.vue @@ -0,0 +1,108 @@ + + + 分类管理 + + 新增分类 + + + + + + + + + {{ row.enabled ? '启用' : '禁用' }} + + + + + 编辑 + 删除 + + + + + + + + + + + + + + + + + + + + + 取消 + 确定 + + + + + + diff --git a/jog-frontend/src/views/admin/Comments.vue b/jog-frontend/src/views/admin/Comments.vue new file mode 100644 index 0000000..b442fc5 --- /dev/null +++ b/jog-frontend/src/views/admin/Comments.vue @@ -0,0 +1,75 @@ + + + 评论管理 + + + + + + + + + + + + + + + + {{ ['待审核', '已通过', '已屏蔽', '已删除'][row.status] }} + + + + + + 通过 + 屏蔽 + 删除 + + + + + + + + diff --git a/jog-frontend/src/views/admin/Dashboard.vue b/jog-frontend/src/views/admin/Dashboard.vue new file mode 100644 index 0000000..d0a2079 --- /dev/null +++ b/jog-frontend/src/views/admin/Dashboard.vue @@ -0,0 +1,22 @@ + + + 管理后台仪表盘 + + + + + + + + + + + + + + + + + + diff --git a/jog-frontend/src/views/admin/Users.vue b/jog-frontend/src/views/admin/Users.vue new file mode 100644 index 0000000..b65482f --- /dev/null +++ b/jog-frontend/src/views/admin/Users.vue @@ -0,0 +1,73 @@ + + + 用户管理 + + + + + + + + + + + + + + 重置密码 + + {{ row.status === 1 ? '解锁' : '锁定' }} + + + + + + + + + diff --git a/jog-frontend/src/views/app/ArticleEditor.vue b/jog-frontend/src/views/app/ArticleEditor.vue new file mode 100644 index 0000000..7fae489 --- /dev/null +++ b/jog-frontend/src/views/app/ArticleEditor.vue @@ -0,0 +1,159 @@ + + + {{ isEdit ? '编辑文章' : '写文章' }} + + + + + + + + + + + + + + + + + + + B + I + H2 + H3 + 列表 + 引用 + 分割线 + + + + + + + + + + + 公开 + 私密 + 仅粉丝 + + + + 保存草稿 + 发布 + + + + + + + + diff --git a/jog-frontend/src/views/app/Dashboard.vue b/jog-frontend/src/views/app/Dashboard.vue new file mode 100644 index 0000000..a89e745 --- /dev/null +++ b/jog-frontend/src/views/app/Dashboard.vue @@ -0,0 +1,22 @@ + + + 控制台 + 欢迎,{{ userStore.userInfo?.nickname }} + + + + + + + + + + + + + + + diff --git a/jog-frontend/src/views/app/MyArticles.vue b/jog-frontend/src/views/app/MyArticles.vue new file mode 100644 index 0000000..8af2268 --- /dev/null +++ b/jog-frontend/src/views/app/MyArticles.vue @@ -0,0 +1,72 @@ + + + + 我的文章 + 写文章 + + + + + + + + {{ formatDate(row.publishedAt) }} + + + + 编辑 + 回收站 + 删除 + + + + + + + + diff --git a/jog-frontend/vite.config.js b/jog-frontend/vite.config.js new file mode 100644 index 0000000..e8acd01 --- /dev/null +++ b/jog-frontend/vite.config.js @@ -0,0 +1,39 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' +import AutoImport from 'unplugin-auto-import/vite' +import Components from 'unplugin-vue-components/vite' +import { ElementPlusResolver } from 'unplugin-vue-components/resolvers' +import { resolve } from 'path' + +export default defineConfig({ + plugins: [ + vue(), + AutoImport({ + resolvers: [ElementPlusResolver()], + imports: ['vue', 'vue-router', 'pinia'], + dts: 'src/auto-imports.d.ts', + }), + Components({ + resolvers: [ElementPlusResolver()], + dts: 'src/components.d.ts', + }), + ], + resolve: { + alias: { + '@': resolve(__dirname, 'src'), + }, + }, + server: { + port: 5173, + proxy: { + '/api': { + target: 'http://localhost:8080', + changeOrigin: true, + }, + }, + }, + build: { + outDir: '../jog-admin/src/main/resources/static', + emptyOutDir: true, + }, +}) diff --git a/jog-module-blog/pom.xml b/jog-module-blog/pom.xml new file mode 100644 index 0000000..0815f71 --- /dev/null +++ b/jog-module-blog/pom.xml @@ -0,0 +1,26 @@ + + + 4.0.0 + + fun.nojava + jog-system + 1.0.0 + + + jog-module-blog + Jog Module - Blog + + + + fun.nojava + jog-module-system + + + org.projectlombok + lombok + true + + + diff --git a/jog-module-comment/pom.xml b/jog-module-comment/pom.xml new file mode 100644 index 0000000..7d9640d --- /dev/null +++ b/jog-module-comment/pom.xml @@ -0,0 +1,26 @@ + + + 4.0.0 + + fun.nojava + jog-system + 1.0.0 + + + jog-module-comment + Jog Module - Comment + + + + fun.nojava + jog-module-blog + + + org.projectlombok + lombok + true + + + diff --git a/jog-module-system/pom.xml b/jog-module-system/pom.xml new file mode 100644 index 0000000..dcabc06 --- /dev/null +++ b/jog-module-system/pom.xml @@ -0,0 +1,30 @@ + + + 4.0.0 + + fun.nojava + jog-system + 1.0.0 + + + jog-module-system + Jog Module - System + + + + fun.nojava + jog-framework + + + org.springframework.boot + spring-boot-starter-mail + + + org.projectlombok + lombok + true + + + diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..d93c04f --- /dev/null +++ b/pom.xml @@ -0,0 +1,114 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.4.5 + + + + fun.nojava + jog-system + 1.0.0 + pom + Jog System + + + jog-common + jog-framework + jog-module-system + jog-module-blog + jog-module-comment + jog-admin + + + + 17 + 17 + 17 + UTF-8 + 3.5.9 + 0.12.6 + 2.8.6 + 2.2.26 + 1.18.1 + 5.8.34 + + + + + + fun.nojava + jog-common + ${project.version} + + + fun.nojava + jog-framework + ${project.version} + + + fun.nojava + jog-module-system + ${project.version} + + + fun.nojava + jog-module-blog + ${project.version} + + + fun.nojava + jog-module-comment + ${project.version} + + + + com.baomidou + mybatis-plus-spring-boot3-starter + ${mybatis-plus.version} + + + io.jsonwebtoken + jjwt-api + ${jjwt.version} + + + io.jsonwebtoken + jjwt-impl + ${jjwt.version} + runtime + + + io.jsonwebtoken + jjwt-jackson + ${jjwt.version} + runtime + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + ${springdoc.version} + + + io.swagger.core.v3 + swagger-annotations-jakarta + ${swagger-annotations.version} + + + org.jsoup + jsoup + ${jsoup.version} + + + cn.hutool + hutool-all + ${hutool.version} + + + +
分享知识,记录生活
{{ article.summary }}
欢迎,{{ userStore.userInfo?.nickname }}
评论 ({{ article.commentCount }})
+