diff --git a/.classpath b/.classpath
deleted file mode 100644
index df66b20..0000000
--- a/.classpath
+++ /dev/null
@@ -1,57 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..491cbae
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,12 @@
+SPRING_DATASOURCE_URL=jdbc:mysql://localhost:3306/blog_db?useUnicode=true&characterEncoding=utf8&useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Shanghai
+SPRING_DATASOURCE_USERNAME=your_username
+SPRING_DATASOURCE_PASSWORD=your_password
+
+MAIL_HOST=smtp.qq.com
+MAIL_PORT=465
+MAIL_USERNAME=your@qq.com
+MAIL_PASSWORD=your-app-code
+MAIL_PROTOCOL=smtps
+
+# Upload directory override (optional). Default is ${user.dir}/uploads
+APP_UPLOAD_DIR=C:/data/blog/uploads
diff --git a/.factorypath b/.factorypath
deleted file mode 100644
index b9002e8..0000000
--- a/.factorypath
+++ /dev/null
@@ -1,84 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..b222778
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,28 @@
+name: CI
+
+on:
+ push:
+ branches: [ "**" ]
+ pull_request:
+ branches: [ "**" ]
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ - name: Set up JDK 17
+ uses: actions/setup-java@v4
+ with:
+ distribution: temurin
+ java-version: '17'
+ cache: maven
+ - name: Run tests
+ env:
+ SPRING_PROFILES_ACTIVE: test
+ run: mvn -B -ntp test
+ - name: Package
+ env:
+ SPRING_PROFILES_ACTIVE: test
+ run: mvn -B -ntp -DskipTests package
diff --git a/.gitignore b/.gitignore
index f5c105a..3e0ae84 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,10 +2,56 @@
/target/
*.log
+# Compiled artifacts
+*.class
+*.jar
+
# IntelliJ
/.idea/
*.iml
+# Visual Studio / Visual Studio Code
+/.vs/
+/.vscode/
+
# OS
Thumbs.db
.DS_Store
+
+# Eclipse
+/.settings/
+.classpath
+.project
+.factorypath
+
+# Local database dumps / backups
+/blog_db_backup.sql
+*.sql.bak
+*.dump
+/backups/
+/backup/
+
+# Local runtime data
+uploads/*
+!uploads/.gitkeep
+
+# Local config / secrets
+.env
+.env.*
+application-local.yml
+application-local.yaml
+application-local.properties
+src/main/resources/application-local.yml
+src/main/resources/application-dev-local.yml
+src/main/resources/application-prod-local.yml
+
+# Root-level stray compiled or source artifacts
+/com/
+
+# Logs
+logs/
+
+# Local Spring Boot config (do not commit secrets)
+application-local.yml
+application-local.yaml
+application-local.properties
diff --git a/.project b/.project
deleted file mode 100644
index 52f6c05..0000000
--- a/.project
+++ /dev/null
@@ -1,34 +0,0 @@
-
-
- blog-system
-
-
-
-
-
- org.eclipse.jdt.core.javabuilder
-
-
-
-
- org.eclipse.m2e.core.maven2Builder
-
-
-
-
-
- org.eclipse.jdt.core.javanature
- org.eclipse.m2e.core.maven2Nature
-
-
-
- 1772106509438
-
- 30
-
- org.eclipse.core.resources.regexFilterMatcher
- node_modules|\.git|__CREATED_BY_JAVA_LANGUAGE_SERVER__
-
-
-
-
diff --git a/.settings/org.eclipse.core.resources.prefs b/.settings/org.eclipse.core.resources.prefs
deleted file mode 100644
index 839d647..0000000
--- a/.settings/org.eclipse.core.resources.prefs
+++ /dev/null
@@ -1,5 +0,0 @@
-eclipse.preferences.version=1
-encoding//src/main/java=UTF-8
-encoding//src/main/resources=UTF-8
-encoding//src/test/java=UTF-8
-encoding/=UTF-8
diff --git a/.settings/org.eclipse.jdt.apt.core.prefs b/.settings/org.eclipse.jdt.apt.core.prefs
deleted file mode 100644
index dfa4f3a..0000000
--- a/.settings/org.eclipse.jdt.apt.core.prefs
+++ /dev/null
@@ -1,4 +0,0 @@
-eclipse.preferences.version=1
-org.eclipse.jdt.apt.aptEnabled=true
-org.eclipse.jdt.apt.genSrcDir=target/generated-sources/annotations
-org.eclipse.jdt.apt.genTestSrcDir=target/generated-test-sources/test-annotations
diff --git a/.settings/org.eclipse.jdt.core.prefs b/.settings/org.eclipse.jdt.core.prefs
deleted file mode 100644
index 5b72ee1..0000000
--- a/.settings/org.eclipse.jdt.core.prefs
+++ /dev/null
@@ -1,20 +0,0 @@
-eclipse.preferences.version=1
-org.eclipse.jdt.core.compiler.annotation.missingNonNullByDefaultAnnotation=ignore
-org.eclipse.jdt.core.compiler.annotation.nonnull=org.springframework.lang.NonNull
-org.eclipse.jdt.core.compiler.annotation.nonnullbydefault=org.springframework.lang.NonNullApi
-org.eclipse.jdt.core.compiler.annotation.nullable=org.springframework.lang.Nullable
-org.eclipse.jdt.core.compiler.annotation.nullanalysis=enabled
-org.eclipse.jdt.core.compiler.codegen.methodParameters=generate
-org.eclipse.jdt.core.compiler.codegen.targetPlatform=17
-org.eclipse.jdt.core.compiler.compliance=17
-org.eclipse.jdt.core.compiler.problem.enablePreviewFeatures=disabled
-org.eclipse.jdt.core.compiler.problem.forbiddenReference=warning
-org.eclipse.jdt.core.compiler.problem.nullAnnotationInferenceConflict=warning
-org.eclipse.jdt.core.compiler.problem.nullReference=warning
-org.eclipse.jdt.core.compiler.problem.nullSpecViolation=warning
-org.eclipse.jdt.core.compiler.problem.potentialNullReference=warning
-org.eclipse.jdt.core.compiler.problem.reportPreviewFeatures=ignore
-org.eclipse.jdt.core.compiler.problem.syntacticNullAnalysisForFields=enabled
-org.eclipse.jdt.core.compiler.processAnnotations=enabled
-org.eclipse.jdt.core.compiler.release=enabled
-org.eclipse.jdt.core.compiler.source=17
diff --git a/.settings/org.eclipse.m2e.core.prefs b/.settings/org.eclipse.m2e.core.prefs
deleted file mode 100644
index f897a7f..0000000
--- a/.settings/org.eclipse.m2e.core.prefs
+++ /dev/null
@@ -1,4 +0,0 @@
-activeProfiles=
-eclipse.preferences.version=1
-resolveWorkspaceProjects=true
-version=1
diff --git a/.vscode/settings.json b/.vscode/settings.json
deleted file mode 100644
index e012065..0000000
--- a/.vscode/settings.json
+++ /dev/null
@@ -1,4 +0,0 @@
-{
- "java.compile.nullAnalysis.mode": "automatic",
- "java.configuration.updateBuildConfiguration": "interactive"
-}
\ No newline at end of file
diff --git a/README.md b/README.md
index 7d189be..a06a44c 100644
--- a/README.md
+++ b/README.md
@@ -50,10 +50,9 @@ CREATE DATABASE blog_db CHARACTER SET utf8mb4;
```
2) 配置数据库与基础参数
-编辑 `src/main/resources/application.yml`,设置:
-- spring.datasource.url/username/password
-- JPA 配置(已默认 ddl-auto: update)
-- Thymeleaf 与日志级别等
+- 公共配置:`src/main/resources/application.yml`(端口、Multipart、日志、上传白名单等)
+- 开发环境:`src/main/resources/application-dev.yml`(本地 MySQL、JPA validate、Flyway)
+- 生产环境:`src/main/resources/application-prod.yml`(从环境变量读取数据源与邮件,JPA validate、Flyway)
3) 配置邮件(用于邮箱验证码)
- 支持环境变量(推荐)或直接在 yml 中配置:
@@ -82,7 +81,12 @@ spring:
4) 启动
```bash
-mvn spring-boot:run
+# 开发(dev)
+mvn spring-boot:run -Dspring-boot.run.profiles=dev
+
+# 生产(prod)
+# 需注入 SPRING_DATASOURCE_URL/USERNAME/PASSWORD 等环境变量
+SPRING_PROFILES_ACTIVE=prod java -jar target/blog-*.jar
```
访问地址:
@@ -147,7 +151,8 @@ mvn spring-boot:run
- 登录记录 LoginRecord:user(可空)、time、ip、ua、success
- 操作日志 ActionLog:user、time、action、detail
-> JPA 配置为 `ddl-auto: update`,开发期会自动迁移建表,生产环境请改为 `validate` 并显式维护迁移脚本(Flyway/Liquibase)。
+> 数据库迁移
+> 本分支已接入 Flyway,JPA 配置为 `ddl-auto: validate`。首次在已有库上运行时通过 `baseline-on-migrate: true` 做基线,后续按 `db/migration` 版本化迁移。
## 登录记录与操作日志实现说明
- Spring Security
@@ -157,9 +162,23 @@ mvn spring-boot:run
- 个人中心页面仅展示最近 20 条(可按需分页拓展)
## 图片上传与正文渲染
+- 控制器仅负责鉴权与调用服务:`UploadService.storeImage/loadImage`
+- 服务实现包含:目录创建、大小限制、MIME/扩展名白名单校验、防路径穿越、随机文件名、类型识别返回 `Content-Type`
- 编辑页支持本地图片上传后自动在正文插入 `
`,详情页按 HTML 渲染
- 请确保存储位置与访问接口已在控制器中放行或加鉴权
+## 测试
+- 服务层:`LocalUploadServiceTest`
+ - 覆盖:正常上传、空文件、非法扩展名、非法 MIME、超大文件、缺失文件读取
+- 启动验证:`SmokeTest`(加载应用上下文)
+- 运行:
+ - `mvn test`
+
+## CI(GitHub Actions)
+- 默认工作流:Java 17 + Maven
+ - 执行 `mvn -B -ntp test` 与 `mvn -B -ntp -DskipTests package`
+ - 可在 Pull Request 中自动验证构建与测试
+
## 搜索与筛选/排序组合
- 搜索支持标题/正文/标签名模糊匹配
- 排序(最新/最热/阅读最多)可与分类、标签、搜索自由组合,所有链接均保留当前组合条件
@@ -193,26 +212,28 @@ src/
```
## 配置与环境变量
-- 基础数据库:`spring.datasource.url` / `username` / `password`
-- JPA:`spring.jpa.hibernate.ddl-auto`(开发期 `update`,生产建议 `validate`)
-- 邮件:`spring.mail.*`(见上文“配置邮件”)
-- 可选环境变量(示例):
- - `MAIL_HOST`、`MAIL_PORT`、`MAIL_USERNAME`、`MAIL_PASSWORD`、`MAIL_PROTOCOL`
- - `APP_BASE_URL`(用于生成邮件中的链接,可按需扩展)
+- Profiles
+ - `application.yml`:公共配置(含 `spring.profiles.active: dev`)
+ - `application-dev.yml`:本地数据库/邮件、`ddl-auto: validate`、`flyway.enabled: true`
+ - `application-prod.yml`:从环境变量读取数据库/邮件、`ddl-auto: validate`、`flyway.enabled: true`
+- 数据库(生产建议用环境变量)
+ - `SPRING_DATASOURCE_URL` / `SPRING_DATASOURCE_USERNAME` / `SPRING_DATASOURCE_PASSWORD`
+- 邮件:`MAIL_HOST` / `MAIL_PORT` / `MAIL_USERNAME` / `MAIL_PASSWORD` / `MAIL_PROTOCOL`
+- 上传目录:`APP_UPLOAD_DIR`(默认 `${user.dir}/uploads`)
## 构建与运行
-- 开发运行:
- ```
- mvn spring-boot:run
- ```
-- 打包可执行 Jar:
- ```
- mvn -DskipTests package
- java -jar target/blog-*.jar
- ```
-- 常见 JVM 参数(按需选择):
- - `-Xms512m -Xmx512m` 固定堆内存
- - `-Dserver.port=8080` 修改端口
+- 构建:
+ - `mvn clean package`
+- 开发:
+ - `mvn spring-boot:run -Dspring-boot.run.profiles=dev`
+- 生产:
+ - `SPRING_PROFILES_ACTIVE=prod` 并注入数据源/邮件环境变量后运行 Jar
+ - 常用 JVM 参数:`-Xms512m -Xmx512m`、`-Dserver.port=8080`
+
+## 数据库迁移(Flyway)
+- 现有数据库首次接入:已启用 `baseline-on-migrate: true`,基线版本 `1`
+- 自定义迁移:在 `src/main/resources/db/migration/` 新增 `V{N}__{desc}.sql`
+- 示例:`V2__set_admin_role.sql`(将 admin 设为 `ROLE_ADMIN`、其余空角色补为 `ROLE_USER`)
## 生产部署示例
- 反向代理(Nginx):
@@ -231,6 +252,120 @@ src/
- SSL:申请证书后将 `listen 443 ssl` 与 `ssl_certificate`/`ssl_certificate_key` 加入上面配置。
- 数据库迁移:生产环境建议使用 Flyway/Liquibase 管理 SQL 迁移,避免 `ddl-auto` 修改结构。
+## 外网部署详细步骤(内网穿透方案)
+
+### 1. 注册 natapp 账号
+1. 访问:https://natapp.cn/
+2. 点击右上角 "注册"
+3. 填写邮箱、密码完成注册
+4. 登录账号
+
+### 2. 创建隧道
+1. 登录后进入 "购买隧道" 页面
+2. 选择 "免费隧道"(或者付费隧道,更稳定)
+3. 配置隧道信息:
+ - 隧道名称:随意填写(如:myblog)
+ - 隧道协议:选择 `http`
+ - 本地端口:填写 `8080`(项目端口)
+ - 域名:可以选择随机域名或自定义域名(需要付费)
+4. 点击 "购买"(免费隧道直接获取)
+
+### 3. 下载 natapp 客户端
+1. 进入 "我的隧道" 页面
+2. 找到刚创建的隧道
+3. 点击 "客户端下载"
+4. 根据系统选择下载:
+ - Windows:`natapp_windows_amd64.zip`
+ - Mac:`natapp_darwin_amd64.zip`
+ - Linux:`natapp_linux_amd64.zip`
+
+## 仓库规范与目录
+- 标准结构:仅保留 src/、pom.xml、README.md、.gitignore 等必要文件
+- IDE/本地文件不纳入版本库:.settings/、.vscode/、.classpath、.project、.factorypath
+- 上传目录不纳入版本库:uploads/*(仅保留占位 uploads/.gitkeep)
+- 根目录若出现 com/** 这类历史遗留副本,不再跟踪,源码以 src/main/java/** 为准
+
+## 外部化配置说明
+- 示例配置:src/main/resources/application-example.yml、.env.example
+- 支持通过环境变量或 profile 覆盖默认值(参考 Spring Boot Externalized Configuration)
+- 上传目录可配置:app.upload-dir 或 APP_UPLOAD_DIR,默认 ${user.dir}/uploads
+
+### 4. 获取 authtoken
+1. 在 "我的隧道" 页面
+2. 找到你的隧道
+3. 复制 **authtoken**(一串随机字符)
+
+### 5. 启动项目
+在终端中启动 Spring Boot 项目:
+```bash
+cd blog-system-template
+mvn spring-boot:run
+```
+确保项目在 `http://localhost:8080` 正常运行。
+
+### 6. 运行 natapp 客户端
+**Windows 系统:**
+1. 解压下载的 `natapp_windows_amd64.zip`
+2. 打开命令提示符(CMD)或 PowerShell
+3. 进入 natapp 解压目录
+4. 运行命令:
+ ```bash
+ natapp.exe -authtoken=你的authtoken
+ ```
+
+**Mac/Linux 系统:**
+1. 解压下载的压缩包
+2. 打开终端
+3. 进入 natapp 解压目录
+4. 运行命令:
+ ```bash
+ chmod +x natapp
+ ./natapp -authtoken=你的authtoken
+ ```
+
+### 7. 获取外网访问地址
+natapp 启动成功后,会显示类似信息:
+```
+Tunnel Status online
+Version 2.3.9
+Forwarding http://abc123.natappfree.cc -> http://127.0.0.1:8080
+Web Interface http://127.0.0.1:4040
+Total Connections 0
+Avg Conn Time 0.00ms
+```
+其中 `http://abc123.natappfree.cc` 就是你的外网访问地址!
+
+### 8. 测试访问
+在浏览器中访问你的外网地址:
+- 首页:`http://abc123.natappfree.cc/`
+- 登录:`http://abc123.natappfree.cc/auth/login`
+- 后台:`http://abc123.natappfree.cc/admin`
+
+### 9. 保持隧道运行
+**Windows 后台运行:**
+```bash
+start /b natapp.exe -authtoken=你的authtoken
+```
+
+**Mac/Linux 后台运行:**
+```bash
+nohup ./natapp -authtoken=你的authtoken &
+```
+
+### 10. 其他内网穿透服务
+| 服务 | 免费方案 | 优点 | 缺点 |
+|------|---------|------|------|
+| **natapp.cn** | 有免费隧道 | 国内访问快、操作简单 | 免费域名随机 |
+| **ngrok.cc** | 有免费隧道 | 稳定性好 | 免费隧道有限 |
+| **cloudflare tunnel** | 完全免费 | 无限流量、稳定 | 配置稍复杂 |
+| **花生壳** | 有免费版 | 老牌服务 | 免费版限制多 |
+
+### 11. 注意事项
+- 免费隧道的域名是随机的,每次重启会变化
+- 建议使用付费隧道获取固定域名
+- 内网穿透服务可能有访问速度限制,不建议用于生产环境
+- 确保本地网络稳定,避免断开连接
+
## 接口速查(选摘)
- 认证与账户
- `POST /auth/login` 登录
diff --git a/com/example/blog/FixDb.class b/com/example/blog/FixDb.class
deleted file mode 100644
index 746589d..0000000
Binary files a/com/example/blog/FixDb.class and /dev/null differ
diff --git a/pom.xml b/pom.xml
index 99bb51c..fc368d5 100644
--- a/pom.xml
+++ b/pom.xml
@@ -70,6 +70,17 @@
spring-boot-starter-mail
+
+ org.flywaydb
+ flyway-core
+
+
+
+ com.h2database
+ h2
+ test
+
+
org.springframework.boot
spring-boot-starter-test
diff --git a/src/main/java/com/example/blog/BlogApplication.java b/src/main/java/com/example/blog/BlogApplication.java
index 522972c..65f19f2 100644
--- a/src/main/java/com/example/blog/BlogApplication.java
+++ b/src/main/java/com/example/blog/BlogApplication.java
@@ -2,8 +2,11 @@
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.boot.context.properties.EnableConfigurationProperties;
+import com.example.blog.config.UploadProperties;
@SpringBootApplication
+@EnableConfigurationProperties(UploadProperties.class)
public class BlogApplication {
public static void main(String[] args) {
SpringApplication.run(BlogApplication.class, args);
diff --git a/src/main/java/com/example/blog/config/SecurityConfig.java b/src/main/java/com/example/blog/config/SecurityConfig.java
index 8525d64..6e96773 100644
--- a/src/main/java/com/example/blog/config/SecurityConfig.java
+++ b/src/main/java/com/example/blog/config/SecurityConfig.java
@@ -4,6 +4,7 @@
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.dao.DaoAuthenticationProvider;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
@@ -27,13 +28,15 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti
.csrf(Customizer.withDefaults())
.authorizeHttpRequests(auth -> auth
.requestMatchers(
- "/", "/articles", "/articles/search", "/articles/{slug}", "/articles/{slug}/comments", "/auth/**",
+ "/", "/articles", "/articles/search", "/articles/{slug}", "/articles/{slug}/comments", "/articles/image/**",
+ "/auth/**",
"/error", "/error/**",
"/favicon.ico",
"/css/**", "/js/**", "/images/**",
"/actuator/health", "/actuator/health/**"
).permitAll()
- .requestMatchers("/articles/editor/**", "/articles/editor").authenticated()
+ .requestMatchers("/articles/editor/**", "/articles/editor").hasRole("ADMIN")
+ .requestMatchers(HttpMethod.POST, "/articles/upload-image", "/articles/*/delete").hasRole("ADMIN")
.requestMatchers("/admin/**", "/actuator/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
diff --git a/src/main/java/com/example/blog/config/UploadProperties.java b/src/main/java/com/example/blog/config/UploadProperties.java
new file mode 100644
index 0000000..303ed1f
--- /dev/null
+++ b/src/main/java/com/example/blog/config/UploadProperties.java
@@ -0,0 +1,45 @@
+package com.example.blog.config;
+
+import org.springframework.boot.context.properties.ConfigurationProperties;
+
+import java.util.List;
+
+@ConfigurationProperties(prefix = "app.upload")
+public class UploadProperties {
+ private String dir = System.getProperty("user.dir") + "/uploads";
+ private long maxSize = 5 * 1024 * 1024;
+ private List allowedExtensions = List.of(".jpg", ".jpeg", ".png", ".gif", ".webp");
+ private List allowedContentTypes = List.of("image/jpeg", "image/png", "image/gif", "image/webp");
+
+ public String getDir() {
+ return dir;
+ }
+
+ public void setDir(String dir) {
+ this.dir = dir;
+ }
+
+ public long getMaxSize() {
+ return maxSize;
+ }
+
+ public void setMaxSize(long maxSize) {
+ this.maxSize = maxSize;
+ }
+
+ public List getAllowedExtensions() {
+ return allowedExtensions;
+ }
+
+ public void setAllowedExtensions(List allowedExtensions) {
+ this.allowedExtensions = allowedExtensions;
+ }
+
+ public List getAllowedContentTypes() {
+ return allowedContentTypes;
+ }
+
+ public void setAllowedContentTypes(List allowedContentTypes) {
+ this.allowedContentTypes = allowedContentTypes;
+ }
+}
diff --git a/src/main/java/com/example/blog/controller/ArticleController.java b/src/main/java/com/example/blog/controller/ArticleController.java
index 6b55ded..d254abd 100644
--- a/src/main/java/com/example/blog/controller/ArticleController.java
+++ b/src/main/java/com/example/blog/controller/ArticleController.java
@@ -14,15 +14,11 @@
import org.springframework.web.bind.annotation.*;
import org.springframework.http.ResponseEntity;
import org.springframework.core.io.Resource;
-import org.springframework.core.io.FileSystemResource;
import org.springframework.web.multipart.MultipartFile;
import java.util.Set;
import java.util.Map;
-import java.util.UUID;
-import java.nio.file.Files;
-import java.nio.file.Path;
-import java.nio.file.Paths;
+import com.example.blog.service.UploadService;
@Controller
@RequestMapping("/articles")
@@ -33,6 +29,7 @@ public class ArticleController {
private final CommentService commentService;
private final TaxonomyService taxonomyService;
private final com.example.blog.service.UserService userService;
+ private final UploadService uploadService;
@GetMapping
public String list(@RequestParam(defaultValue = "0") int page,
@@ -150,7 +147,7 @@ public String likeComment(@PathVariable String slug, @PathVariable Long id) {
@GetMapping("/editor/new")
public String createForm(Model model) {
com.example.blog.entity.User currentUser = userService.getCurrentUserOrThrow();
- if (!"admin".equals(currentUser.getUsername())) {
+ if (!currentUser.isAdmin()) {
return "redirect:/?error=no_permission";
}
java.util.List drafts = articleService.listDrafts(currentUser.getId());
@@ -166,7 +163,7 @@ public String createForm(Model model) {
@GetMapping("/editor/{id}")
public String editForm(@PathVariable Long id, Model model) {
com.example.blog.entity.User currentUser = userService.getCurrentUserOrThrow();
- if (!"admin".equals(currentUser.getUsername())) {
+ if (!currentUser.isAdmin()) {
return "redirect:/?error=no_permission";
}
Article a = articleService.getById(id);
@@ -195,7 +192,7 @@ public String create(@ModelAttribute("form") @Valid ArticleForm form,
@RequestParam(required = false) Long id,
Model model) {
com.example.blog.entity.User currentUser = userService.getCurrentUserOrThrow();
- if (!"admin".equals(currentUser.getUsername())) {
+ if (!currentUser.isAdmin()) {
return "redirect:/?error=no_permission";
}
if (br.hasErrors()) {
@@ -208,6 +205,10 @@ public String create(@ModelAttribute("form") @Valid ArticleForm form,
@PostMapping("/{id}/delete")
public String delete(@PathVariable Long id) {
+ com.example.blog.entity.User currentUser = userService.getCurrentUserOrThrow();
+ if (!currentUser.isAdmin()) {
+ return "redirect:/?error=no_permission";
+ }
articleService.delete(id);
return "redirect:/";
}
@@ -215,35 +216,25 @@ public String delete(@PathVariable Long id) {
@PostMapping("/upload-image")
public ResponseEntity> uploadImage(@RequestParam("file") MultipartFile file) throws java.io.IOException {
com.example.blog.entity.User currentUser = userService.getCurrentUserOrThrow();
- if (!"admin".equals(currentUser.getUsername())) {
+ if (!currentUser.isAdmin()) {
return ResponseEntity.status(403).body(Map.of("error", "no_permission"));
}
- if (file.isEmpty()) {
- return ResponseEntity.badRequest().body(Map.of("error", "empty_file"));
- }
- String original = file.getOriginalFilename();
- String ext = "";
- if (original != null && original.contains(".")) {
- ext = original.substring(original.lastIndexOf(".")).toLowerCase();
- }
- String name = UUID.randomUUID().toString().replace("-", "") + ext;
- Path dir = Paths.get(System.getProperty("user.dir"), "uploads");
- if (!Files.exists(dir)) {
- Files.createDirectories(dir);
+ try {
+ String filename = uploadService.storeImage(file);
+ return ResponseEntity.ok(Map.of("url", "/articles/image/" + filename));
+ } catch (IllegalArgumentException e) {
+ return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
}
- Path target = dir.resolve(name);
- Files.write(target, file.getBytes());
- String url = "/articles/image/" + name;
- return ResponseEntity.ok(Map.of("url", url));
}
@GetMapping("/image/{filename}")
public ResponseEntity getImage(@PathVariable String filename) throws java.io.IOException {
- Path file = Paths.get(System.getProperty("user.dir"), "uploads", filename);
- if (!Files.exists(file)) {
+ Resource resource = uploadService.loadImage(filename);
+ if (resource == null) {
return ResponseEntity.notFound().build();
}
- FileSystemResource resource = new FileSystemResource(file.toFile());
- return ResponseEntity.ok(resource);
+ return ResponseEntity.ok()
+ .contentType(uploadService.getMediaType(filename))
+ .body(resource);
}
}
diff --git a/src/main/java/com/example/blog/entity/User.java b/src/main/java/com/example/blog/entity/User.java
index 22275d1..6c2cbcb 100644
--- a/src/main/java/com/example/blog/entity/User.java
+++ b/src/main/java/com/example/blog/entity/User.java
@@ -55,4 +55,8 @@ public void prePersist() {
if (createdAt == null) createdAt = Instant.now();
if (role == null) role = Role.ROLE_USER;
}
+
+ public boolean isAdmin() {
+ return this.role == Role.ROLE_ADMIN;
+ }
}
diff --git a/src/main/java/com/example/blog/service/UploadService.java b/src/main/java/com/example/blog/service/UploadService.java
new file mode 100644
index 0000000..03062bf
--- /dev/null
+++ b/src/main/java/com/example/blog/service/UploadService.java
@@ -0,0 +1,11 @@
+package com.example.blog.service;
+
+import org.springframework.core.io.Resource;
+import org.springframework.http.MediaType;
+import org.springframework.web.multipart.MultipartFile;
+
+public interface UploadService {
+ String storeImage(MultipartFile file) throws java.io.IOException;
+ Resource loadImage(String filename) throws java.io.IOException;
+ MediaType getMediaType(String filename);
+}
diff --git a/src/main/java/com/example/blog/service/impl/LocalUploadService.java b/src/main/java/com/example/blog/service/impl/LocalUploadService.java
new file mode 100644
index 0000000..d1c909f
--- /dev/null
+++ b/src/main/java/com/example/blog/service/impl/LocalUploadService.java
@@ -0,0 +1,80 @@
+package com.example.blog.service.impl;
+
+import com.example.blog.config.UploadProperties;
+import com.example.blog.service.UploadService;
+import org.springframework.core.io.FileSystemResource;
+import org.springframework.core.io.Resource;
+import org.springframework.http.MediaType;
+import org.springframework.stereotype.Service;
+import org.springframework.util.StringUtils;
+import org.springframework.web.multipart.MultipartFile;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.UUID;
+
+@Service
+public class LocalUploadService implements UploadService {
+ private final UploadProperties properties;
+
+ public LocalUploadService(UploadProperties properties) {
+ this.properties = properties;
+ }
+
+ @Override
+ public String storeImage(MultipartFile file) throws IOException {
+ if (file == null || file.isEmpty()) {
+ throw new IllegalArgumentException("empty_file");
+ }
+ if (file.getSize() > properties.getMaxSize()) {
+ throw new IllegalArgumentException("file_too_large");
+ }
+ String contentType = file.getContentType();
+ if (contentType == null || !properties.getAllowedContentTypes().contains(contentType.toLowerCase())) {
+ throw new IllegalArgumentException("invalid_content_type");
+ }
+ String original = StringUtils.cleanPath(file.getOriginalFilename() == null ? "" : file.getOriginalFilename());
+ String ext = "";
+ int idx = original.lastIndexOf('.');
+ if (idx >= 0) {
+ ext = original.substring(idx).toLowerCase();
+ }
+ if (!properties.getAllowedExtensions().contains(ext)) {
+ throw new IllegalArgumentException("invalid_extension");
+ }
+ String filename = UUID.randomUUID().toString().replace("-", "") + ext;
+ Path dir = Paths.get(properties.getDir()).normalize();
+ Files.createDirectories(dir);
+ Path target = dir.resolve(filename).normalize();
+ if (!target.startsWith(dir)) {
+ throw new IllegalArgumentException("invalid_path");
+ }
+ Files.copy(file.getInputStream(), target);
+ return filename;
+ }
+
+ @Override
+ public Resource loadImage(String filename) throws IOException {
+ Path dir = Paths.get(properties.getDir()).normalize();
+ Path file = dir.resolve(filename).normalize();
+ if (!file.startsWith(dir) || !Files.exists(file) || !Files.isReadable(file)) {
+ return null;
+ }
+ return new FileSystemResource(file.toFile());
+ }
+
+ @Override
+ public MediaType getMediaType(String filename) {
+ if (filename == null) {
+ return MediaType.APPLICATION_OCTET_STREAM;
+ }
+ String lower = filename.toLowerCase();
+ if (lower.endsWith(".png")) return MediaType.IMAGE_PNG;
+ if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return MediaType.IMAGE_JPEG;
+ if (lower.endsWith(".gif")) return MediaType.IMAGE_GIF;
+ if (lower.endsWith(".webp")) return MediaType.parseMediaType("image/webp");
+ return MediaType.APPLICATION_OCTET_STREAM;
+ }
+}
diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml
new file mode 100644
index 0000000..1fe80f6
--- /dev/null
+++ b/src/main/resources/application-dev.yml
@@ -0,0 +1,40 @@
+server:
+ error:
+ include-message: always
+ include-stacktrace: always
+
+spring:
+ datasource:
+ url: jdbc:mysql://localhost:3306/blog_db?useUnicode=true&characterEncoding=utf8&useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Shanghai
+ username: root
+ password: xjliieea691
+ driver-class-name: com.mysql.cj.jdbc.Driver
+ jpa:
+ hibernate:
+ ddl-auto: validate
+ show-sql: true
+ thymeleaf:
+ cache: false
+ mail:
+ host: ${MAIL_HOST:smtp.qq.com}
+ port: ${MAIL_PORT:465}
+ username: ${MAIL_USERNAME:}
+ password: ${MAIL_PASSWORD:}
+ protocol: ${MAIL_PROTOCOL:smtps}
+ properties:
+ mail:
+ smtp:
+ auth: true
+ ssl:
+ enable: true
+ debug: false
+
+logging:
+ level:
+ org.thymeleaf: DEBUG
+
+ flyway:
+ enabled: true
+ baseline-on-migrate: true
+ baseline-version: 1
+ locations: classpath:db/migration
diff --git a/src/main/resources/application-example.yml b/src/main/resources/application-example.yml
new file mode 100644
index 0000000..2721eec
--- /dev/null
+++ b/src/main/resources/application-example.yml
@@ -0,0 +1,47 @@
+server:
+ port: 8080
+
+spring:
+ datasource:
+ url: jdbc:mysql://localhost:3306/blog_db?useUnicode=true&characterEncoding=utf8&useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Shanghai
+ username: your_username
+ password: your_password
+ jpa:
+ hibernate:
+ ddl-auto: update
+ open-in-view: false
+ show-sql: true
+ properties:
+ hibernate:
+ format_sql: true
+ thymeleaf:
+ cache: false
+ mail:
+ host: smtp.example.com
+ port: 465
+ username: your_email@example.com
+ password: your_mail_app_password
+ protocol: smtps
+ properties:
+ mail:
+ smtp:
+ auth: true
+ ssl:
+ enable: true
+ debug: false
+
+app:
+ upload:
+ dir: ${user.dir}/uploads
+ max-size: 5242880
+ allowed-extensions:
+ - .jpg
+ - .jpeg
+ - .png
+ - .gif
+ - .webp
+ allowed-content-types:
+ - image/jpeg
+ - image/png
+ - image/gif
+ - image/webp
diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml
new file mode 100644
index 0000000..f324421
--- /dev/null
+++ b/src/main/resources/application-prod.yml
@@ -0,0 +1,38 @@
+server:
+ error:
+ include-message: never
+ include-stacktrace: never
+
+spring:
+ datasource:
+ url: ${SPRING_DATASOURCE_URL}
+ username: ${SPRING_DATASOURCE_USERNAME}
+ password: ${SPRING_DATASOURCE_PASSWORD}
+ flyway:
+ enabled: true
+ baseline-on-migrate: true
+ baseline-version: 1
+ locations: classpath:db/migration
+ jpa:
+ hibernate:
+ ddl-auto: validate
+ show-sql: false
+ thymeleaf:
+ cache: true
+ mail:
+ host: ${MAIL_HOST}
+ port: ${MAIL_PORT:465}
+ username: ${MAIL_USERNAME}
+ password: ${MAIL_PASSWORD}
+ protocol: ${MAIL_PROTOCOL:smtps}
+ properties:
+ mail:
+ smtp:
+ auth: true
+ ssl:
+ enable: true
+ debug: false
+
+logging:
+ level:
+ org.thymeleaf: INFO
diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml
index d6422ee..e55cc1e 100644
--- a/src/main/resources/application.yml
+++ b/src/main/resources/application.yml
@@ -1,45 +1,38 @@
server:
port: 8080
- error:
- include-message: always
- include-binding-errors: always
- include-stacktrace: always
spring:
- datasource:
- url: jdbc:mysql://localhost:3306/blog_db?useUnicode=true&characterEncoding=utf8&useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Shanghai
- username: root
- password: xjliieea691
+ profiles:
+ active: dev
servlet:
multipart:
max-file-size: 5MB
max-request-size: 10MB
jpa:
- hibernate:
- ddl-auto: update
open-in-view: false
- show-sql: true
properties:
hibernate:
format_sql: true
thymeleaf:
cache: false
- mail:
- host: ${MAIL_HOST:smtp.qq.com}
- port: ${MAIL_PORT:465}
- username: ${MAIL_USERNAME:2054469475@qq.com}
- password: ${MAIL_PASSWORD:yxxkfqdhqmqvcfdd}
- protocol: ${MAIL_PROTOCOL:smtps}
- properties:
- mail:
- smtp:
- auth: true
- ssl:
- enable: true
- debug: false
+
+app:
+ upload:
+ dir: ${APP_UPLOAD_DIR:${user.dir}/uploads}
+ max-size: 5242880
+ allowed-extensions:
+ - .jpg
+ - .jpeg
+ - .png
+ - .gif
+ - .webp
+ allowed-content-types:
+ - image/jpeg
+ - image/png
+ - image/gif
+ - image/webp
logging:
level:
- org.thymeleaf: DEBUG
org.springframework.web: INFO
org.springframework.security: INFO
diff --git a/src/main/resources/db/migration/V2__set_admin_role.sql b/src/main/resources/db/migration/V2__set_admin_role.sql
new file mode 100644
index 0000000..d4148a7
--- /dev/null
+++ b/src/main/resources/db/migration/V2__set_admin_role.sql
@@ -0,0 +1,7 @@
+UPDATE users
+SET role = 'ROLE_ADMIN'
+WHERE username = 'admin' AND (role IS NULL OR role = 'ROLE_USER');
+
+UPDATE users
+SET role = 'ROLE_USER'
+WHERE role IS NULL;
diff --git a/src/main/resources/templates/admin/stats.html b/src/main/resources/templates/admin/stats.html
index c94a733..b5923b0 100644
--- a/src/main/resources/templates/admin/stats.html
+++ b/src/main/resources/templates/admin/stats.html
@@ -95,9 +95,67 @@ 系统统
-
-
-
统计图表已暂时关闭,仅保留数据概览与系统信息。
+
+
+
+
+
+
+