初版代码提交

This commit is contained in:
nojava
2026-05-17 15:55:06 +08:00
parent 6f90488760
commit 74425deb5d
70 changed files with 3647 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
/.idea/
/.vscode/
+74
View File
@@ -0,0 +1,74 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>fun.nojava</groupId>
<artifactId>jog-system</artifactId>
<version>1.0.0</version>
</parent>
<artifactId>jog-admin</artifactId>
<name>Jog Admin Application</name>
<dependencies>
<dependency>
<groupId>fun.nojava</groupId>
<artifactId>jog-module-system</artifactId>
</dependency>
<dependency>
<groupId>fun.nojava</groupId>
<artifactId>jog-module-blog</artifactId>
</dependency>
<dependency>
<groupId>fun.nojava</groupId>
<artifactId>jog-module-comment</artifactId>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-mysql</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<finalName>jog-admin</finalName>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
</project>
@@ -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
@@ -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='图片表';
@@ -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', '最大评论层级');
+42
View File
@@ -0,0 +1,42 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>fun.nojava</groupId>
<artifactId>jog-system</artifactId>
<version>1.0.0</version>
</parent>
<artifactId>jog-common</artifactId>
<name>Jog Common</name>
<dependencies>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-annotations</artifactId>
</dependency>
<dependency>
<groupId>jakarta.validation</groupId>
<artifactId>jakarta.validation-api</artifactId>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.data</groupId>
<artifactId>spring-data-commons</artifactId>
</dependency>
<dependency>
<groupId>io.swagger.core.v3</groupId>
<artifactId>swagger-annotations-jakarta</artifactId>
</dependency>
</dependencies>
</project>
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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;
}
@@ -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;
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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();
}
}
@@ -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;
}
@@ -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<T> {
@Schema(description = "业务状态码")
private int code;
@Schema(description = "提示信息")
private String message;
@Schema(description = "响应数据")
private T data;
@Schema(description = "响应时间戳")
private long timestamp;
public static <T> ApiResult<T> success() {
return new ApiResult<>(200, "success", null, System.currentTimeMillis());
}
public static <T> ApiResult<T> success(T data) {
return new ApiResult<>(200, "success", data, System.currentTimeMillis());
}
public static <T> ApiResult<T> success(String message, T data) {
return new ApiResult<>(200, message, data, System.currentTimeMillis());
}
public static <T> ApiResult<T> error(int code, String message) {
return new ApiResult<>(code, message, null, System.currentTimeMillis());
}
public static <T> ApiResult<T> error(fun.nojava.common.exception.ErrorCode errorCode) {
return new ApiResult<>(errorCode.getCode(), errorCode.getMessage(), null, System.currentTimeMillis());
}
}
@@ -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;
}
@@ -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;
}
@@ -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<T> {
@Schema(description = "数据列表")
private List<T> records;
@Schema(description = "总记录数")
private long total;
@Schema(description = "当前页码")
private int page;
@Schema(description = "每页条数")
private int pageSize;
}
@@ -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 <T> ApiResult<PageResult<T>> page(List<T> records, long total, int page, int pageSize) {
PageResult<T> pageResult = new PageResult<>(records, total, page, pageSize);
return ApiResult.success(pageResult);
}
public static <T> ApiResult<PageResult<T>> page(Page<T> springPage) {
PageResult<T> pageResult = new PageResult<>(
springPage.getContent(),
springPage.getTotalElements(),
springPage.getNumber() + 1,
springPage.getSize()
);
return ApiResult.success(pageResult);
}
}
+71
View File
@@ -0,0 +1,71 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>fun.nojava</groupId>
<artifactId>jog-system</artifactId>
<version>1.0.0</version>
</parent>
<artifactId>jog-framework</artifactId>
<name>Jog Framework</name>
<dependencies>
<dependency>
<groupId>fun.nojava</groupId>
<artifactId>jog-common</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
</dependency>
<dependency>
<groupId>org.jsoup</groupId>
<artifactId>jsoup</artifactId>
</dependency>
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>jakarta.servlet</groupId>
<artifactId>jakarta.servlet-api</artifactId>
<scope>provided</scope>
</dependency>
</dependencies>
</project>
@@ -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;
}
}
@@ -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<Long> 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();
}
}
@@ -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);
}
}
@@ -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();
}
}
@@ -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<XssFilter> xssFilterRegistration() {
FilterRegistrationBean<XssFilter> registration = new FilterRegistrationBean<>();
registration.setFilter(new XssFilter());
registration.addUrlPatterns("/api/*");
registration.setOrder(1);
return registration;
}
}
@@ -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);
}
}
@@ -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<Void> 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<Void> 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<Void> handleAccessDeniedException(AccessDeniedException e) {
log.warn("Access denied: {}", e.getMessage());
return ApiResult.error(ErrorCode.FORBIDDEN);
}
@ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public ApiResult<Void> handleException(Exception e) {
log.error("Unexpected error", e);
return ApiResult.error(ErrorCode.INTERNAL_ERROR);
}
}
@@ -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));
}
}
@@ -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;
}
}
@@ -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();
}
}
@@ -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);
}
}
@@ -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());
}
}
@@ -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());
}
}
+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>博客系统</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+30
View File
@@ -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"
}
}
+14
View File
@@ -0,0 +1,14 @@
<template>
<router-view />
</template>
<script setup>
</script>
<style>
body {
margin: 0;
padding: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
}
</style>
+13
View File
@@ -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 } })
}
+69
View File
@@ -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}`)
}
+41
View File
@@ -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`)
}
+29
View File
@@ -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}`)
}
+82
View File
@@ -0,0 +1,82 @@
<template>
<div class="admin-layout">
<el-container>
<el-aside width="220px">
<div class="admin-logo">管理后台</div>
<el-menu :default-active="$route.path" router background-color="#304156" text-color="#bfcbd9" active-text-color="#409eff">
<el-menu-item index="/admin">
<el-icon><DataAnalysis /></el-icon>
<span>仪表盘</span>
</el-menu-item>
<el-menu-item index="/admin/users">
<el-icon><User /></el-icon>
<span>用户管理</span>
</el-menu-item>
<el-menu-item index="/admin/articles">
<el-icon><Document /></el-icon>
<span>文章管理</span>
</el-menu-item>
<el-menu-item index="/admin/comments">
<el-icon><ChatDotSquare /></el-icon>
<span>评论管理</span>
</el-menu-item>
<el-menu-item index="/admin/categories">
<el-icon><Menu /></el-icon>
<span>分类管理</span>
</el-menu-item>
</el-menu>
</el-aside>
<el-container>
<el-header class="admin-header">
<el-button @click="$router.push('/')">返回前台</el-button>
<el-button @click="handleLogout">退出</el-button>
</el-header>
<el-main>
<router-view />
</el-main>
</el-container>
</el-container>
</div>
</template>
<script setup>
import { useUserStore } from '@/stores/user'
import { useRouter } from 'vue-router'
const userStore = useUserStore()
const router = useRouter()
async function handleLogout() {
await userStore.logout()
router.push('/login')
}
</script>
<style scoped>
.admin-layout {
min-height: 100vh;
}
.el-aside {
background: #304156;
}
.admin-logo {
height: 60px;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 18px;
font-weight: bold;
}
.admin-header {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 10px;
background: #fff;
border-bottom: 1px solid #e4e7ed;
}
.el-main {
background: #f5f7fa;
}
</style>
+41
View File
@@ -0,0 +1,41 @@
<template>
<div class="app-layout">
<el-container>
<el-aside width="200px">
<el-menu :default-active="$route.path" router>
<el-menu-item index="/app/dashboard">
<el-icon><DataAnalysis /></el-icon>
<span>控制台</span>
</el-menu-item>
<el-menu-item index="/app/articles">
<el-icon><Document /></el-icon>
<span>我的文章</span>
</el-menu-item>
<el-menu-item index="/app/editor">
<el-icon><EditPen /></el-icon>
<span>写文章</span>
</el-menu-item>
</el-menu>
</el-aside>
<el-main>
<router-view />
</el-main>
</el-container>
</div>
</template>
<script setup>
</script>
<style scoped>
.app-layout {
min-height: 100vh;
}
.el-aside {
background: #fff;
border-right: 1px solid #e4e7ed;
}
.el-main {
background: #f5f7fa;
}
</style>
+106
View File
@@ -0,0 +1,106 @@
<template>
<div class="jog-layout">
<el-header class="header">
<div class="header-content">
<router-link to="/" class="logo">Jog</router-link>
<el-menu mode="horizontal" :ellipsis="false" class="nav-menu">
<el-menu-item index="home">
<router-link to="/">首页</router-link>
</el-menu-item>
</el-menu>
<div class="header-right">
<template v-if="userStore.isLoggedIn">
<el-dropdown>
<span class="user-info">
<el-avatar :size="28" :src="userStore.userInfo?.avatar" />
{{ userStore.userInfo?.nickname || '用户' }}
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="$router.push('/app/dashboard')">控制台</el-dropdown-item>
<el-dropdown-item v-if="userStore.isAdmin" @click="$router.push('/admin')">管理后台</el-dropdown-item>
<el-dropdown-item divided @click="handleLogout">退出登录</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</template>
<template v-else>
<el-button type="primary" @click="$router.push('/login')">登录</el-button>
<el-button @click="$router.push('/register')">注册</el-button>
</template>
</div>
</div>
</el-header>
<el-main class="main">
<router-view />
</el-main>
<el-footer class="footer">Jog &copy; 2026</el-footer>
</div>
</template>
<script setup>
import { useUserStore } from '@/stores/user'
import { useRouter } from 'vue-router'
const userStore = useUserStore()
const router = useRouter()
async function handleLogout() {
await userStore.logout()
router.push('/')
}
</script>
<style scoped>
.jog-layout {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.header {
border-bottom: 1px solid #e4e7ed;
background: #fff;
}
.header-content {
max-width: 1200px;
margin: 0 auto;
display: flex;
align-items: center;
height: 60px;
padding: 0 20px;
}
.logo {
font-size: 20px;
font-weight: bold;
color: #409eff;
text-decoration: none;
margin-right: 40px;
}
.nav-menu {
flex: 1;
border-bottom: none;
}
.header-right {
display: flex;
align-items: center;
gap: 10px;
}
.user-info {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
.main {
flex: 1;
max-width: 1200px;
width: 100%;
margin: 0 auto;
padding: 20px;
}
.footer {
text-align: center;
color: #909399;
border-top: 1px solid #e4e7ed;
}
</style>
+19
View File
@@ -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')
+119
View File
@@ -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
+80
View File
@@ -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,
}
})
+74
View File
@@ -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
+170
View File
@@ -0,0 +1,170 @@
<template>
<div class="article-detail" v-loading="loading">
<template v-if="article">
<h1>{{ article.title }}</h1>
<div class="meta">
<span><el-icon><User /></el-icon> {{ article.authorNickname }}</span>
<span><el-icon><View /></el-icon> {{ article.viewCount }}</span>
<span><el-icon><ChatDotSquare /></el-icon> {{ article.commentCount }}</span>
<span><el-icon><Timer /></el-icon> {{ formatDate(article.publishedAt) }}</span>
<el-tag v-if="article.categoryName" size="small">{{ article.categoryName }}</el-tag>
</div>
<div class="tags">
<el-tag v-for="tag in article.tags" :key="tag.id" type="info" size="small">{{ tag.name }}</el-tag>
</div>
<el-divider />
<div class="content" v-html="article.content"></div>
<el-divider />
<div class="actions">
<el-button :type="article.liked ? 'danger' : 'default'" @click="handleLike">
<el-icon><Star /></el-icon> {{ article.liked ? '已点赞' : '点赞' }} ({{ article.likeCount }})
</el-button>
</div>
<div class="comments-section">
<h3>评论 ({{ article.commentCount }})</h3>
<div v-if="userStore.isLoggedIn" class="comment-form">
<el-input v-model="newComment" type="textarea" :rows="3" placeholder="写下你的评论..." />
<el-button type="primary" @click="submitComment" :loading="submitting" style="margin-top: 8px">发表评论</el-button>
</div>
<div v-else class="login-tip">
<router-link to="/login">登录</router-link> 后可以评论
</div>
<div class="comment-list">
<div v-for="comment in comments" :key="comment.id" class="comment-item">
<div class="comment-header">
<el-avatar :size="32" :src="comment.avatar" />
<span class="nickname">{{ comment.nickname }}</span>
<span class="time">{{ formatDate(comment.createdAt) }}</span>
</div>
<div class="comment-body">{{ comment.content }}</div>
<div class="comment-actions">
<el-button text size="small" @click="handleReply(comment)">
<el-icon><ChatDotSquare /></el-icon> 回复
</el-button>
<el-button text size="small" @click="toggleCommentLike(comment)">
<el-icon><Star /></el-icon> {{ comment.likeCount }}
</el-button>
</div>
<div v-if="comment.children && comment.children.length" class="replies">
<div v-for="reply in comment.children" :key="reply.id" class="reply-item">
<span class="nickname">{{ reply.nickname }}</span>
<span v-if="reply.replyToNickname" class="reply-to">回复 {{ reply.replyToNickname }}</span>
{{ reply.content }}
<span class="time">{{ formatDate(reply.createdAt) }}</span>
</div>
</div>
</div>
</div>
</div>
</template>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { getArticleDetail, toggleArticleLike } from '@/api/article'
import { getArticleComments, createComment, toggleCommentLike } from '@/api/comment'
import { useUserStore } from '@/stores/user'
import { ElMessage } from 'element-plus'
const route = useRoute()
const userStore = useUserStore()
const article = ref(null)
const comments = ref([])
const loading = ref(true)
const newComment = ref('')
const submitting = ref(false)
const replyTo = ref(null)
async function loadArticle() {
loading.value = true
const res = await getArticleDetail(route.params.id)
article.value = res.data
loading.value = false
}
async function loadComments() {
const res = await getArticleComments(route.params.id)
comments.value = res.data
}
async function handleLike() {
if (!userStore.isLoggedIn) {
ElMessage.warning('请先登录')
return
}
const res = await toggleArticleLike(article.value.id)
article.value.liked = res.data
article.value.likeCount += res.data ? 1 : -1
}
function handleReply(comment) {
replyTo.value = comment
newComment.value = `@${comment.nickname} `
}
async function submitComment() {
if (!newComment.value.trim()) return
submitting.value = true
try {
const data = {
articleId: article.value.id,
content: newComment.value,
}
if (replyTo.value) {
data.parentId = replyTo.value.id
data.replyToUserId = replyTo.value.userId
}
await createComment(data)
ElMessage.success('评论成功')
newComment.value = ''
replyTo.value = null
loadComments()
} finally {
submitting.value = false
}
}
async function handleCommentLike(comment) {
if (!userStore.isLoggedIn) {
ElMessage.warning('请先登录')
return
}
const res = await toggleCommentLike(comment.id)
comment.liked = res.data
comment.likeCount += res.data ? 1 : -1
}
function formatDate(dateStr) {
if (!dateStr) return ''
return new Date(dateStr).toLocaleString('zh-CN')
}
onMounted(() => {
loadArticle()
loadComments()
})
</script>
<style scoped>
.article-detail { max-width: 800px; margin: 0 auto; }
.article-detail h1 { font-size: 28px; margin-bottom: 16px; }
.meta { display: flex; gap: 16px; align-items: center; color: #909399; font-size: 14px; margin-bottom: 12px; }
.meta span { display: flex; align-items: center; gap: 4px; }
.tags { display: flex; gap: 8px; margin-bottom: 16px; }
.content { line-height: 1.8; font-size: 16px; }
.actions { margin: 20px 0; }
.comments-section { margin-top: 30px; }
.comment-form { margin-bottom: 20px; }
.login-tip { color: #909399; margin-bottom: 20px; }
.comment-item { padding: 16px 0; border-bottom: 1px solid #f0f0f0; }
.comment-header { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
.nickname { font-weight: 500; }
.time { color: #909399; font-size: 12px; }
.comment-body { margin-left: 40px; }
.comment-actions { margin-left: 40px; margin-top: 4px; }
.replies { margin-left: 40px; padding: 12px; background: #f5f7fa; border-radius: 4px; margin-top: 8px; }
.reply-item { padding: 4px 0; font-size: 14px; }
.reply-to { color: #409eff; }
</style>
+114
View File
@@ -0,0 +1,114 @@
<template>
<div class="home">
<div class="hero">
<h1>欢迎来到博客系统</h1>
<p>分享知识记录生活</p>
</div>
<div class="filter-bar">
<el-select v-model="filters.categoryId" placeholder="选择分类" clearable @change="loadArticles">
<el-option v-for="cat in categories" :key="cat.id" :label="cat.name" :value="cat.id" />
</el-select>
<el-input v-model="filters.keyword" placeholder="搜索文章" clearable style="width: 300px" @keyup.enter="loadArticles">
<template #append>
<el-button @click="loadArticles"><el-icon><Search /></el-icon></el-button>
</template>
</el-input>
</div>
<div class="article-list">
<el-card v-for="article in articles" :key="article.id" class="article-card" @click="goDetail(article.id)">
<div class="article-content">
<h3>{{ article.title }}</h3>
<p class="summary">{{ article.summary }}</p>
<div class="meta">
<span><el-icon><User /></el-icon> {{ article.authorNickname }}</span>
<span><el-icon><View /></el-icon> {{ article.viewCount }}</span>
<span><el-icon><ChatDotSquare /></el-icon> {{ article.commentCount }}</span>
<span><el-icon><Timer /></el-icon> {{ formatDate(article.publishedAt) }}</span>
</div>
</div>
<el-image v-if="article.coverImage" :src="article.coverImage" class="cover" fit="cover" />
</el-card>
</div>
<el-pagination
v-model:current-page="filters.page"
:page-size="filters.pageSize"
:total="total"
layout="prev, pager, next"
@current-change="loadArticles"
/>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { getPublicArticles, getCategories } from '@/api/article'
const router = useRouter()
const articles = ref([])
const categories = ref([])
const total = ref(0)
const filters = ref({ page: 1, pageSize: 15, categoryId: null, keyword: '' })
async function loadArticles() {
const res = await getPublicArticles(filters.value)
articles.value = res.data.records
total.value = res.data.total
}
async function loadCategories() {
const res = await getCategories()
categories.value = res.data
}
function goDetail(id) {
router.push(`/article/${id}`)
}
function formatDate(dateStr) {
if (!dateStr) return ''
return new Date(dateStr).toLocaleDateString('zh-CN')
}
onMounted(() => {
loadArticles()
loadCategories()
})
</script>
<style scoped>
.hero {
text-align: center;
padding: 60px 0 30px;
}
.hero h1 { font-size: 32px; color: #303133; }
.hero p { color: #909399; font-size: 16px; }
.filter-bar {
display: flex;
gap: 16px;
margin-bottom: 20px;
align-items: center;
}
.article-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.article-card {
cursor: pointer;
transition: box-shadow 0.3s;
}
.article-card:hover { box-shadow: 0 4px 12px rgba(0,0,0,0.1); }
.article-card :deep(.el-card__body) {
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.article-content { flex: 1; }
.article-content h3 { margin: 0 0 8px; font-size: 18px; }
.summary { color: #606266; font-size: 14px; margin: 0 0 12px; }
.meta { display: flex; gap: 16px; color: #909399; font-size: 13px; }
.meta span { display: flex; align-items: center; gap: 4px; }
.cover { width: 160px; height: 100px; border-radius: 4px; margin-left: 16px; }
.el-pagination { margin-top: 20px; justify-content: center; }
</style>
+134
View File
@@ -0,0 +1,134 @@
<template>
<div class="login-page">
<el-card class="login-card">
<h2>登录</h2>
<el-tabs v-model="loginType">
<el-tab-pane label="管理员登录" name="admin">
<el-form :model="adminForm" :rules="rules" ref="adminFormRef" @submit.prevent="handleAdminLogin">
<el-form-item prop="account">
<el-input v-model="adminForm.account" placeholder="用户名或手机号" prefix-icon="User" />
</el-form-item>
<el-form-item prop="password">
<el-input v-model="adminForm.password" type="password" placeholder="密码" prefix-icon="Lock" show-password />
</el-form-item>
<el-form-item v-if="showCaptcha" prop="captchaCode">
<div style="display: flex; gap: 8px;">
<el-input v-model="adminForm.captchaCode" placeholder="验证码" />
<img :src="captchaImage" @click="loadCaptcha" style="cursor: pointer; height: 40px;" />
</div>
</el-form-item>
<el-form-item>
<el-button type="primary" native-type="submit" :loading="loading" style="width: 100%">登录</el-button>
</el-form-item>
</el-form>
</el-tab-pane>
<el-tab-pane label="用户登录" name="user">
<el-form :model="userForm" :rules="rules" ref="userFormRef" @submit.prevent="handleUserLogin">
<el-form-item prop="email">
<el-input v-model="userForm.email" placeholder="邮箱" prefix-icon="Message" />
</el-form-item>
<el-form-item prop="password">
<el-input v-model="userForm.password" type="password" placeholder="密码" prefix-icon="Lock" show-password />
</el-form-item>
<el-form-item>
<el-checkbox v-model="userForm.rememberMe">记住我</el-checkbox>
</el-form-item>
<el-form-item>
<el-button type="primary" native-type="submit" :loading="loading" style="width: 100%">登录</el-button>
</el-form-item>
</el-form>
</el-tab-pane>
</el-tabs>
<div class="footer-link">
还没有账号<router-link to="/register">立即注册</router-link>
</div>
</el-card>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useUserStore } from '@/stores/user'
import { getCaptcha } from '@/api/auth'
import { ElMessage } from 'element-plus'
const router = useRouter()
const route = useRoute()
const userStore = useUserStore()
const loginType = ref('user')
const loading = ref(false)
const showCaptcha = ref(false)
const captchaImage = ref('')
const adminForm = ref({ account: '', password: '', captchaKey: '', captchaCode: '' })
const userForm = ref({ email: '', password: '', rememberMe: false })
const adminFormRef = ref()
const userFormRef = ref()
const rules = {
account: [{ required: true, message: '请输入账号', trigger: 'blur' }],
email: [{ required: true, message: '请输入邮箱', trigger: 'blur' }],
password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
captchaCode: [{ required: true, message: '请输入验证码', trigger: 'blur' }],
}
async function loadCaptcha() {
const res = await getCaptcha()
adminForm.value.captchaKey = res.data.captchaKey
captchaImage.value = res.data.captchaImage
showCaptcha.value = true
}
async function handleAdminLogin() {
await adminFormRef.value.validate()
loading.value = true
try {
await userStore.loginAdmin(adminForm.value)
ElMessage.success('登录成功')
router.push(route.query.redirect || '/admin')
} catch (e) {
if (e?.response?.data?.code === 1008) {
loadCaptcha()
}
} finally {
loading.value = false
}
}
async function handleUserLogin() {
await userFormRef.value.validate()
loading.value = true
try {
await userStore.loginUser(userForm.value)
ElMessage.success('登录成功')
router.push(route.query.redirect || '/app/dashboard')
} finally {
loading.value = false
}
}
</script>
<style scoped>
.login-page {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background: #f5f7fa;
}
.login-card {
width: 420px;
padding: 20px;
}
.login-card h2 {
text-align: center;
margin-bottom: 20px;
}
.footer-link {
text-align: center;
margin-top: 16px;
color: #909399;
}
</style>
+95
View File
@@ -0,0 +1,95 @@
<template>
<div class="register-page">
<el-card class="register-card">
<h2>注册</h2>
<el-form :model="form" :rules="rules" ref="formRef" @submit.prevent="handleRegister">
<el-form-item prop="email">
<el-input v-model="form.email" placeholder="邮箱" prefix-icon="Message" />
</el-form-item>
<el-form-item prop="password">
<el-input v-model="form.password" type="password" placeholder="密码(8-32位,含大小写字母、数字和特殊字符中的至少三类)" prefix-icon="Lock" show-password />
</el-form-item>
<el-form-item prop="confirmPassword">
<el-input v-model="form.confirmPassword" type="password" placeholder="确认密码" prefix-icon="Lock" show-password />
</el-form-item>
<el-form-item>
<el-button type="primary" native-type="submit" :loading="loading" style="width: 100%">注册</el-button>
</el-form-item>
</el-form>
<div class="footer-link">
已有账号<router-link to="/login">立即登录</router-link>
</div>
</el-card>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useUserStore } from '@/stores/user'
import { ElMessage } from 'element-plus'
const router = useRouter()
const userStore = useUserStore()
const loading = ref(false)
const formRef = ref()
const form = ref({ email: '', password: '', confirmPassword: '' })
const validateConfirm = (rule, value, callback) => {
if (value !== form.value.password) {
callback(new Error('两次密码不一致'))
} else {
callback()
}
}
const rules = {
email: [
{ required: true, message: '请输入邮箱', trigger: 'blur' },
{ type: 'email', message: '邮箱格式不正确', trigger: 'blur' },
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 8, max: 32, message: '密码长度8-32位', trigger: 'blur' },
],
confirmPassword: [
{ required: true, message: '请确认密码', trigger: 'blur' },
{ validator: validateConfirm, trigger: 'blur' },
],
}
async function handleRegister() {
await formRef.value.validate()
loading.value = true
try {
await userStore.registerUser(form.value)
ElMessage.success('注册成功,请查看邮箱完成验证')
router.push('/login')
} finally {
loading.value = false
}
}
</script>
<style scoped>
.register-page {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background: #f5f7fa;
}
.register-card {
width: 420px;
padding: 20px;
}
.register-card h2 {
text-align: center;
margin-bottom: 20px;
}
.footer-link {
text-align: center;
margin-top: 16px;
color: #909399;
}
</style>
+79
View File
@@ -0,0 +1,79 @@
<template>
<div>
<h2>文章管理</h2>
<div style="margin-bottom: 16px; display: flex; gap: 12px;">
<el-input v-model="keyword" placeholder="搜索文章" clearable style="width: 300px" @keyup.enter="loadArticles">
<template #append>
<el-button @click="loadArticles"><el-icon><Search /></el-icon></el-button>
</template>
</el-input>
<el-select v-model="status" placeholder="状态" clearable @change="loadArticles">
<el-option label="草稿" :value="0" />
<el-option label="已发布" :value="1" />
<el-option label="回收站" :value="2" />
</el-select>
</div>
<el-table :data="articles" v-loading="loading">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="title" label="标题" />
<el-table-column prop="authorNickname" label="作者" width="120" />
<el-table-column prop="viewCount" label="浏览" width="80" />
<el-table-column prop="likeCount" label="点赞" width="80" />
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="statusType(row)">{{ statusText(row) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="120">
<template #default="{ row }">
<el-button text type="danger" @click="handleDelete(row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
v-model:current-page="page"
:page-size="15"
:total="total"
layout="prev, pager, next"
@current-change="loadArticles"
style="margin-top: 20px; justify-content: center;"
/>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { getAdminArticles, adminDeleteArticle } from '@/api/article'
import { ElMessage, ElMessageBox } from 'element-plus'
const articles = ref([])
const loading = ref(false)
const page = ref(1)
const total = ref(0)
const keyword = ref('')
const status = ref(null)
async function loadArticles() {
loading.value = true
const res = await getAdminArticles({ page: page.value, pageSize: 15, keyword: keyword.value, status: status.value })
articles.value = res.data.records
total.value = res.data.total
loading.value = false
}
async function handleDelete(id) {
await ElMessageBox.confirm('确定删除该文章?', '警告', { type: 'warning' })
await adminDeleteArticle(id)
ElMessage.success('已删除')
loadArticles()
}
function statusType(row) {
return [null, 'success', 'warning', 'info'][row.status] || ''
}
function statusText(row) {
return ['草稿', '已发布', '回收站'][row.status] || '未知'
}
onMounted(loadArticles)
</script>
+108
View File
@@ -0,0 +1,108 @@
<template>
<div>
<h2>分类管理</h2>
<div style="margin-bottom: 16px;">
<el-button type="primary" @click="showAddDialog">新增分类</el-button>
</div>
<el-table :data="categories" v-loading="loading">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="name" label="名称" />
<el-table-column prop="description" label="描述" />
<el-table-column prop="articleCount" label="文章数" width="100" />
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="row.enabled ? 'success' : 'danger'">{{ row.enabled ? '启用' : '禁用' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="180">
<template #default="{ row }">
<el-button text type="primary" @click="showEditDialog(row)">编辑</el-button>
<el-button text type="danger" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-dialog v-model="dialogVisible" :title="isEdit ? '编辑分类' : '新增分类'" width="400px">
<el-form :model="form" label-width="80px">
<el-form-item label="名称">
<el-input v-model="form.name" />
</el-form-item>
<el-form-item label="描述">
<el-input v-model="form.description" type="textarea" :rows="2" />
</el-form-item>
<el-form-item label="启用">
<el-switch v-model="form.enabled" />
</el-form-item>
<el-form-item label="排序">
<el-input-number v-model="form.sort" :min="0" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmit">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { getCategories, createCategory, updateCategory, deleteCategory } from '@/api/article'
import { ElMessage, ElMessageBox } from 'element-plus'
const categories = ref([])
const loading = ref(false)
const dialogVisible = ref(false)
const isEdit = ref(false)
const editingId = ref(null)
const form = ref({ name: '', description: '', enabled: true, sort: 0 })
async function loadCategories() {
loading.value = true
const res = await getCategories()
categories.value = res.data
loading.value = false
}
function showAddDialog() {
isEdit.value = false
editingId.value = null
form.value = { name: '', description: '', enabled: true, sort: 0 }
dialogVisible.value = true
}
function showEditDialog(row) {
isEdit.value = true
editingId.value = row.id
form.value = { name: row.name, description: row.description, enabled: row.enabled, sort: row.sort }
dialogVisible.value = true
}
async function handleSubmit() {
if (!form.value.name) {
ElMessage.warning('请输入分类名称')
return
}
if (isEdit.value) {
await updateCategory(editingId.value, form.value)
} else {
await createCategory(form.value)
}
ElMessage.success('操作成功')
dialogVisible.value = false
loadCategories()
}
async function handleDelete(row) {
await ElMessageBox.confirm(`确定删除分类"${row.name}"`, '提示')
try {
await deleteCategory(row.id)
ElMessage.success('已删除')
loadCategories()
} catch {
// error handled by interceptor
}
}
onMounted(loadCategories)
</script>
+75
View File
@@ -0,0 +1,75 @@
<template>
<div>
<h2>评论管理</h2>
<div style="margin-bottom: 16px; display: flex; gap: 12px;">
<el-select v-model="status" placeholder="状态" clearable @change="loadComments">
<el-option label="待审核" :value="0" />
<el-option label="已通过" :value="1" />
<el-option label="已屏蔽" :value="2" />
</el-select>
</div>
<el-table :data="comments" v-loading="loading">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="nickname" label="评论者" width="120" />
<el-table-column prop="content" label="内容" show-overflow-tooltip />
<el-table-column prop="articleId" label="文章ID" width="80" />
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="[null, 'success', 'danger'][row.status] || 'info'">
{{ ['待审核', '已通过', '已屏蔽', '已删除'][row.status] }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="200">
<template #default="{ row }">
<el-button text type="success" @click="handleAudit(row.id, 1)">通过</el-button>
<el-button text type="warning" @click="handleAudit(row.id, 2)">屏蔽</el-button>
<el-button text type="danger" @click="handleDelete(row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
v-model:current-page="page"
:page-size="15"
:total="total"
layout="prev, pager, next"
@current-change="loadComments"
style="margin-top: 20px; justify-content: center;"
/>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { getAdminComments, auditComment, adminDeleteComment } from '@/api/comment'
import { ElMessage, ElMessageBox } from 'element-plus'
const comments = ref([])
const loading = ref(false)
const page = ref(1)
const total = ref(0)
const status = ref(null)
async function loadComments() {
loading.value = true
const res = await getAdminComments({ page: page.value, pageSize: 15, status: status.value })
comments.value = res.data.records
total.value = res.data.total
loading.value = false
}
async function handleAudit(id, auditStatus) {
await auditComment(id, auditStatus)
ElMessage.success('操作成功')
loadComments()
}
async function handleDelete(id) {
await ElMessageBox.confirm('确定删除该评论?', '警告', { type: 'warning' })
await adminDeleteComment(id)
ElMessage.success('已删除')
loadComments()
}
onMounted(loadComments)
</script>
@@ -0,0 +1,22 @@
<template>
<div>
<h2>管理后台仪表盘</h2>
<el-row :gutter="20" style="margin-top: 20px;">
<el-col :span="6">
<el-card><el-statistic title="用户数" :value="0" /></el-card>
</el-col>
<el-col :span="6">
<el-card><el-statistic title="文章数" :value="0" /></el-card>
</el-col>
<el-col :span="6">
<el-card><el-statistic title="评论数" :value="0" /></el-card>
</el-col>
<el-col :span="6">
<el-card><el-statistic title="今日访问" :value="0" /></el-card>
</el-col>
</el-row>
</div>
</template>
<script setup>
</script>
+73
View File
@@ -0,0 +1,73 @@
<template>
<div>
<h2>用户管理</h2>
<div style="margin-bottom: 16px; display: flex; gap: 12px;">
<el-input v-model="keyword" placeholder="搜索用户" clearable style="width: 300px" @keyup.enter="loadUsers">
<template #append>
<el-button @click="loadUsers"><el-icon><Search /></el-icon></el-button>
</template>
</el-input>
</div>
<el-table :data="users" v-loading="loading">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="nickname" label="昵称" width="150" />
<el-table-column prop="email" label="邮箱" />
<el-table-column label="操作" width="240">
<template #default="{ row }">
<el-button text type="warning" @click="handleResetPassword(row.id)">重置密码</el-button>
<el-button text :type="row.status === 1 ? 'success' : 'danger'" @click="handleToggleStatus(row)">
{{ row.status === 1 ? '解锁' : '锁定' }}
</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
v-model:current-page="page"
:page-size="15"
:total="total"
layout="prev, pager, next"
@current-change="loadUsers"
style="margin-top: 20px; justify-content: center;"
/>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { getUserList, resetUserPassword, updateUserStatus } from '@/api/admin'
import { ElMessage, ElMessageBox } from 'element-plus'
const users = ref([])
const loading = ref(false)
const page = ref(1)
const total = ref(0)
const keyword = ref('')
async function loadUsers() {
loading.value = true
const res = await getUserList({ page: page.value, pageSize: 15, keyword: keyword.value })
users.value = res.data.records
total.value = res.data.total
loading.value = false
}
async function handleResetPassword(id) {
const { value } = await ElMessageBox.prompt('请输入新密码', '重置密码', {
inputPattern: /^.{8,32}$/,
inputErrorMessage: '密码长度8-32位',
})
await resetUserPassword(id, value)
ElMessage.success('密码已重置')
}
async function handleToggleStatus(row) {
const newStatus = row.status === 0 ? 1 : 0
const action = newStatus === 1 ? '锁定' : '解锁'
await ElMessageBox.confirm(`确定${action}该用户?`, '提示')
await updateUserStatus(row.id, newStatus)
ElMessage.success(`${action}`)
loadUsers()
}
onMounted(loadUsers)
</script>
@@ -0,0 +1,159 @@
<template>
<div class="article-editor">
<h2>{{ isEdit ? '编辑文章' : '写文章' }}</h2>
<el-form :model="form" label-width="80px">
<el-form-item label="标题">
<el-input v-model="form.title" placeholder="请输入标题" />
</el-form-item>
<el-form-item label="分类">
<el-select v-model="form.categoryId" placeholder="选择分类" clearable>
<el-option v-for="cat in categories" :key="cat.id" :label="cat.name" :value="cat.id" />
</el-select>
</el-form-item>
<el-form-item label="标签">
<el-select v-model="form.tagIds" multiple placeholder="选择标签(最多5个)" :max-collapse-tags="5">
<el-option v-for="tag in tags" :key="tag.id" :label="tag.name" :value="tag.id" />
</el-select>
</el-form-item>
<el-form-item label="内容">
<div class="editor-wrapper">
<div v-if="editor" class="toolbar">
<el-button-group>
<el-button size="small" @click="editor.chain().focus().toggleBold().run()" :type="editor.isActive('bold') ? 'primary' : ''">B</el-button>
<el-button size="small" @click="editor.chain().focus().toggleItalic().run()" :type="editor.isActive('italic') ? 'primary' : ''">I</el-button>
<el-button size="small" @click="editor.chain().focus().toggleHeading({ level: 2 }).run()" :type="editor.isActive('heading', { level: 2 }) ? 'primary' : ''">H2</el-button>
<el-button size="small" @click="editor.chain().focus().toggleHeading({ level: 3 }).run()" :type="editor.isActive('heading', { level: 3 }) ? 'primary' : ''">H3</el-button>
<el-button size="small" @click="editor.chain().focus().toggleBulletList().run()" :type="editor.isActive('bulletList') ? 'primary' : ''">列表</el-button>
<el-button size="small" @click="editor.chain().focus().toggleBlockquote().run()" :type="editor.isActive('blockquote') ? 'primary' : ''">引用</el-button>
<el-button size="small" @click="editor.chain().focus().setHorizontalRule().run()">分割线</el-button>
</el-button-group>
</div>
<EditorContent :editor="editor" class="editor-content" />
</div>
</el-form-item>
<el-form-item label="摘要">
<el-input v-model="form.summary" type="textarea" :rows="2" placeholder="文章摘要" />
</el-form-item>
<el-form-item label="可见性">
<el-radio-group v-model="form.visibility">
<el-radio :value="0">公开</el-radio>
<el-radio :value="1">私密</el-radio>
<el-radio :value="2">仅粉丝</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSubmit(false)" :loading="saving">保存草稿</el-button>
<el-button type="success" @click="handleSubmit(true)" :loading="saving">发布</el-button>
</el-form-item>
</el-form>
</div>
</template>
<script setup>
import { ref, onMounted, onBeforeUnmount, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useEditor, EditorContent } from '@tiptap/vue-3'
import StarterKit from '@tiptap/starter-kit'
import Placeholder from '@tiptap/extension-placeholder'
import { createArticle, updateArticle, getArticleDetail, getCategories, getTags } from '@/api/article'
import { ElMessage } from 'element-plus'
const route = useRoute()
const router = useRouter()
const isEdit = computed(() => !!route.params.id)
const saving = ref(false)
const categories = ref([])
const tags = ref([])
const form = ref({
title: '',
categoryId: null,
tagIds: [],
summary: '',
visibility: 0,
allowComment: true,
})
const editor = useEditor({
extensions: [
StarterKit,
Placeholder.configure({ placeholder: '开始写作...' }),
],
content: '',
})
async function loadCategories() {
const res = await getCategories()
categories.value = res.data
}
async function loadTags() {
const res = await getTags()
tags.value = res.data
}
async function loadArticle() {
if (!isEdit.value) return
const res = await getArticleDetail(route.params.id)
const article = res.data
form.value.title = article.title
form.value.categoryId = article.categoryId
form.value.tagIds = article.tags?.map(t => t.id) || []
form.value.summary = article.summary
form.value.visibility = article.visibility
if (editor.value) {
editor.value.commands.setContent(article.content || '')
}
}
async function handleSubmit(publish) {
if (!form.value.title) {
ElMessage.warning('请输入标题')
return
}
saving.value = true
try {
const data = {
...form.value,
content: editor.value?.getHTML() || '',
publish,
}
if (isEdit.value) {
await updateArticle(route.params.id, data)
ElMessage.success('更新成功')
} else {
const res = await createArticle(data)
ElMessage.success(publish ? '发布成功' : '已保存草稿')
if (!isEdit.value) {
router.push(`/app/editor/${res.data}`)
}
}
} finally {
saving.value = false
}
}
onMounted(() => {
loadCategories()
loadTags()
loadArticle()
})
onBeforeUnmount(() => {
editor.value?.destroy()
})
</script>
<style scoped>
.article-editor { max-width: 900px; }
.editor-wrapper { border: 1px solid #dcdfe6; border-radius: 4px; width: 100%; }
.toolbar { padding: 8px; border-bottom: 1px solid #dcdfe6; background: #fafafa; }
.editor-content { min-height: 400px; padding: 16px; }
.editor-content :deep(.tiptap) { outline: none; min-height: 360px; }
.editor-content :deep(.tiptap p.is-editor-empty:first-child::before) {
content: attr(data-placeholder);
float: left;
color: #adb5bd;
pointer-events: none;
height: 0;
}
</style>
+22
View File
@@ -0,0 +1,22 @@
<template>
<div>
<h2>控制台</h2>
<p>欢迎{{ userStore.userInfo?.nickname }}</p>
<el-row :gutter="20" style="margin-top: 20px;">
<el-col :span="8">
<el-card><el-statistic title="我的文章" :value="0" /></el-card>
</el-col>
<el-col :span="8">
<el-card><el-statistic title="总浏览量" :value="0" /></el-card>
</el-col>
<el-col :span="8">
<el-card><el-statistic title="获赞数" :value="0" /></el-card>
</el-col>
</el-row>
</div>
</template>
<script setup>
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
</script>
+72
View File
@@ -0,0 +1,72 @@
<template>
<div>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
<h2>我的文章</h2>
<el-button type="primary" @click="$router.push('/app/editor')">写文章</el-button>
</div>
<el-table :data="articles" v-loading="loading">
<el-table-column prop="title" label="标题" />
<el-table-column prop="categoryName" label="分类" width="120" />
<el-table-column prop="viewCount" label="浏览" width="80" />
<el-table-column prop="likeCount" label="点赞" width="80" />
<el-table-column prop="publishedAt" label="发布时间" width="180">
<template #default="{ row }">{{ formatDate(row.publishedAt) }}</template>
</el-table-column>
<el-table-column label="操作" width="200">
<template #default="{ row }">
<el-button text type="primary" @click="$router.push(`/app/editor/${row.id}`)">编辑</el-button>
<el-button text type="warning" @click="handleRecycle(row.id)">回收站</el-button>
<el-button text type="danger" @click="handleDelete(row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
v-model:current-page="page"
:page-size="15"
:total="total"
layout="prev, pager, next"
@current-change="loadArticles"
style="margin-top: 20px; justify-content: center;"
/>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { getMyArticles, moveToRecycleBin, deleteArticle } from '@/api/article'
import { ElMessage, ElMessageBox } from 'element-plus'
const articles = ref([])
const loading = ref(false)
const page = ref(1)
const total = ref(0)
async function loadArticles() {
loading.value = true
const res = await getMyArticles({ page: page.value, pageSize: 15 })
articles.value = res.data.records
total.value = res.data.total
loading.value = false
}
async function handleRecycle(id) {
await ElMessageBox.confirm('确定移入回收站?', '提示')
await moveToRecycleBin(id)
ElMessage.success('已移入回收站')
loadArticles()
}
async function handleDelete(id) {
await ElMessageBox.confirm('确定永久删除?此操作不可恢复', '警告', { type: 'warning' })
await deleteArticle(id)
ElMessage.success('已删除')
loadArticles()
}
function formatDate(dateStr) {
if (!dateStr) return '-'
return new Date(dateStr).toLocaleString('zh-CN')
}
onMounted(loadArticles)
</script>
+39
View File
@@ -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,
},
})
+26
View File
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>fun.nojava</groupId>
<artifactId>jog-system</artifactId>
<version>1.0.0</version>
</parent>
<artifactId>jog-module-blog</artifactId>
<name>Jog Module - Blog</name>
<dependencies>
<dependency>
<groupId>fun.nojava</groupId>
<artifactId>jog-module-system</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
</dependencies>
</project>
+26
View File
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>fun.nojava</groupId>
<artifactId>jog-system</artifactId>
<version>1.0.0</version>
</parent>
<artifactId>jog-module-comment</artifactId>
<name>Jog Module - Comment</name>
<dependencies>
<dependency>
<groupId>fun.nojava</groupId>
<artifactId>jog-module-blog</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
</dependencies>
</project>
+30
View File
@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>fun.nojava</groupId>
<artifactId>jog-system</artifactId>
<version>1.0.0</version>
</parent>
<artifactId>jog-module-system</artifactId>
<name>Jog Module - System</name>
<dependencies>
<dependency>
<groupId>fun.nojava</groupId>
<artifactId>jog-framework</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-mail</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
</dependencies>
</project>
+114
View File
@@ -0,0 +1,114 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.4.5</version>
<relativePath/>
</parent>
<groupId>fun.nojava</groupId>
<artifactId>jog-system</artifactId>
<version>1.0.0</version>
<packaging>pom</packaging>
<name>Jog System</name>
<modules>
<module>jog-common</module>
<module>jog-framework</module>
<module>jog-module-system</module>
<module>jog-module-blog</module>
<module>jog-module-comment</module>
<module>jog-admin</module>
</modules>
<properties>
<java.version>17</java.version>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<mybatis-plus.version>3.5.9</mybatis-plus.version>
<jjwt.version>0.12.6</jjwt.version>
<springdoc.version>2.8.6</springdoc.version>
<swagger-annotations.version>2.2.26</swagger-annotations.version>
<jsoup.version>1.18.1</jsoup.version>
<hutool.version>5.8.34</hutool.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>fun.nojava</groupId>
<artifactId>jog-common</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>fun.nojava</groupId>
<artifactId>jog-framework</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>fun.nojava</groupId>
<artifactId>jog-module-system</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>fun.nojava</groupId>
<artifactId>jog-module-blog</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>fun.nojava</groupId>
<artifactId>jog-module-comment</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
<version>${mybatis-plus.version}</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>${jjwt.version}</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>${jjwt.version}</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>${jjwt.version}</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>${springdoc.version}</version>
</dependency>
<dependency>
<groupId>io.swagger.core.v3</groupId>
<artifactId>swagger-annotations-jakarta</artifactId>
<version>${swagger-annotations.version}</version>
</dependency>
<dependency>
<groupId>org.jsoup</groupId>
<artifactId>jsoup</artifactId>
<version>${jsoup.version}</version>
</dependency>
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>${hutool.version}</version>
</dependency>
</dependencies>
</dependencyManagement>
</project>