Compare commits

..

1 Commits

Author SHA1 Message Date
Tim
1fd31184a7 Sort reactions by count with stable fallback order 2025-09-17 20:23:26 +08:00
360 changed files with 12706 additions and 16926 deletions

View File

@@ -1,9 +1,10 @@
--- ---
name: 新功能建议 name: 新功能建议
about: 请为该项目提出一个想法 about: 请为该项目提出一个想法
title: "" title: ''
labels: "" labels: ''
assignees: "" assignees: ''
--- ---
**你的功能请求是否与某个问题相关?请描述。** **你的功能请求是否与某个问题相关?请描述。**

View File

@@ -1,9 +1,10 @@
--- ---
name: 错误/Bug报告 name: 错误/Bug报告
about: 创建报告以帮助我们改进 about: 创建报告以帮助我们改进
title: "" title: ''
labels: "" labels: ''
assignees: "" assignees: ''
--- ---
**描述 Bug** **描述 Bug**
@@ -25,16 +26,16 @@ assignees: ""
**桌面端(请完成以下信息):** **桌面端(请完成以下信息):**
- 操作系统:\[例如 iOS] * 操作系统:\[例如 iOS]
- 浏览器:\[例如 Chrome、Safari] * 浏览器:\[例如 Chrome、Safari]
- 版本:\[例如 22] * 版本:\[例如 22]
**移动端(请完成以下信息):** **移动端(请完成以下信息):**
- 设备:\[例如 iPhone6] * 设备:\[例如 iPhone6]
- 操作系统:\[例如 iOS8.1] * 操作系统:\[例如 iOS8.1]
- 浏览器:\[例如 系统自带浏览器、Safari] * 浏览器:\[例如 系统自带浏览器、Safari]
- 版本:\[例如 22] * 版本:\[例如 22]
**附加上下文** **附加上下文**
在此添加与问题相关的其他上下文信息。 在此添加与问题相关的其他上下文信息。

View File

@@ -12,7 +12,7 @@ jobs:
build-and-deploy: build-and-deploy:
runs-on: ubuntu-latest runs-on: ubuntu-latest
environment: Deploy environment: Deploy
if: ${{ !github.event.repository.fork }} # 只有非 fork 才执行 if: ${{ !github.event.repository.fork }} # 只有非 fork 才执行
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@@ -32,3 +32,4 @@ jobs:
secrets: inherit secrets: inherit
with: with:
build-id: ${{ github.run_id }} build-id: ${{ github.run_id }}

View File

@@ -3,7 +3,7 @@ name: CI & CD
on: on:
workflow_dispatch: workflow_dispatch:
schedule: schedule:
- cron: "0 19 * * *" # 每天 UTC 19:00相当于北京时间凌晨3点 - cron: "0 19 * * *" # 每天 UTC 19:00相当于北京时间凌晨3点
jobs: jobs:
build-and-deploy: build-and-deploy:

View File

@@ -8,21 +8,21 @@ This isnt an exhaustive list of things that you cant do. Rather, take it i
This code of conduct applies to all spaces managed by the OpenIsle project or . This includes IRC, the mailing lists, the issue tracker, DSF events, and any other forums created by the project team which the community uses for communication. In addition, violations of this code outside these spaces may affect a person's ability to participate within them. This code of conduct applies to all spaces managed by the OpenIsle project or . This includes IRC, the mailing lists, the issue tracker, DSF events, and any other forums created by the project team which the community uses for communication. In addition, violations of this code outside these spaces may affect a person's ability to participate within them.
If you believe someone is violating the code of conduct, we ask that you report it by emailing [](mailto:). For more details please see our If you believe someone is violating the code of conduct, we ask that you report it by emailing [](mailto:). For more details please see our
- **Be friendly and patient.** - **Be friendly and patient.**
- **Be welcoming.** We strive to be a community that welcomes and supports people of all backgrounds and identities. This includes, but is not limited to members of any race, ethnicity, culture, national origin, colour, immigration status, social and economic class, educational level, sex, sexual orientation, gender identity and expression, age, size, family status, political belief, religion, and mental and physical ability. - **Be welcoming.** We strive to be a community that welcomes and supports people of all backgrounds and identities. This includes, but is not limited to members of any race, ethnicity, culture, national origin, colour, immigration status, social and economic class, educational level, sex, sexual orientation, gender identity and expression, age, size, family status, political belief, religion, and mental and physical ability.
- **Be considerate.** Your work will be used by other people, and you in turn will depend on the work of others. Any decision you take will affect users and colleagues, and you should take those consequences into account when making decisions. Remember that we're a world-wide community, so you might not be communicating in someone else's primary language. - **Be considerate.** Your work will be used by other people, and you in turn will depend on the work of others. Any decision you take will affect users and colleagues, and you should take those consequences into account when making decisions. Remember that we're a world-wide community, so you might not be communicating in someone else's primary language.
- **Be respectful.** Not all of us will agree all the time, but disagreement is no excuse for poor behavior and poor manners. We might all experience some frustration now and then, but we cannot allow that frustration to turn into a personal attack. Its important to remember that a community where people feel uncomfortable or threatened is not a productive one. Members of the OpenIsle community should be respectful when dealing with other members as well as with people outside the OpenIsle community. - **Be respectful.** Not all of us will agree all the time, but disagreement is no excuse for poor behavior and poor manners. We might all experience some frustration now and then, but we cannot allow that frustration to turn into a personal attack. Its important to remember that a community where people feel uncomfortable or threatened is not a productive one. Members of the OpenIsle community should be respectful when dealing with other members as well as with people outside the OpenIsle community.
- **Be careful in the words that you choose.** We are a community of professionals, and we conduct ourselves professionally. Be kind to others. Do not insult or put down other participants. Harassment and other exclusionary behavior aren't acceptable. This includes, but is not limited to: - **Be careful in the words that you choose.** We are a community of professionals, and we conduct ourselves professionally. Be kind to others. Do not insult or put down other participants. Harassment and other exclusionary behavior aren't acceptable. This includes, but is not limited to:
- Violent threats or language directed against another person. - Violent threats or language directed against another person.
- Discriminatory jokes and language. - Discriminatory jokes and language.
- Posting sexually explicit or violent material. - Posting sexually explicit or violent material.
- Posting (or threatening to post) other people's personally identifying information ("doxing"). - Posting (or threatening to post) other people's personally identifying information ("doxing").
- Personal insults, especially those using racist or sexist terms. - Personal insults, especially those using racist or sexist terms.
- Unwelcome sexual attention. - Unwelcome sexual attention.
- Advocating for, or encouraging, any of the above behavior. - Advocating for, or encouraging, any of the above behavior.
- Repeated harassment of others. In general, if someone asks you to stop, then stop. - Repeated harassment of others. In general, if someone asks you to stop, then stop.
- **When we disagree, try to understand why.** Disagreements, both social and technical, happen all the time and OpenIsle is no exception. It is important that we resolve disagreements and differing views constructively. Remember that were different. The strength of OpenIsle comes from its varied community, people from a wide range of backgrounds. Different people have different perspectives on issues. Being unable to understand why someone holds a viewpoint doesnt mean that theyre wrong. Dont forget that it is human to err and blaming each other doesnt get us anywhere. Instead, focus on helping to resolve issues and learning from mistakes. - **When we disagree, try to understand why.** Disagreements, both social and technical, happen all the time and OpenIsle is no exception. It is important that we resolve disagreements and differing views constructively. Remember that were different. The strength of OpenIsle comes from its varied community, people from a wide range of backgrounds. Different people have different perspectives on issues. Being unable to understand why someone holds a viewpoint doesnt mean that theyre wrong. Dont forget that it is human to err and blaming each other doesnt get us anywhere. Instead, focus on helping to resolve issues and learning from mistakes.
Original text courtesy of the [Speak Up! project](http://web.archive.org/web/20141109123859/http://speakup.io/coc.html). Original text courtesy of the [Speak Up! project](http://web.archive.org/web/20141109123859/http://speakup.io/coc.html).

View File

@@ -1,25 +1,16 @@
- [前置工作](#前置工作) - [前置工作](#前置工作)
- [启动后端服务](#启动后端服务) - [启动后端服务](#启动后端服务)
- [本地 IDEA](#本地-idea) - [本地 IDEA](#本地-idea)
- [配置环境变量](#配置环境变量) - [配置环境变量](#配置环境变量)
- [配置 IDEA 参数](#配置-idea-参数) - [配置 IDEA 参数](#配置-idea-参数)
- [配置 MySQL](#配置-mysql) - [配置 MySQL](#配置-mysql)
- [配置 Redis](#配置-redis) - [Docker 环境](#docker-环境)
- [配置 RabbitMQ](#配置-rabbitmq) - [配置环境变量](#配置环境变量-1)
- [Docker 环境](#docker-环境) - [构建并启动镜像](#构建并启动镜像)
- [配置环境变量](#配置环境变量-1)
- [构建并启动镜像](#构建并启动镜像)
- [启动前端服务](#启动前端服务) - [启动前端服务](#启动前端服务)
- [配置环境变量](#配置环境变量-2) - [配置环境变量](#配置环境变量-2)
- [安装依赖和运行](#安装依赖和运行) - [安装依赖和运行](#安装依赖和运行)
- [其他配置](#其他配置) - [其他配置](#其他配置)
- [配置第三方登录以GitHub为例](#配置第三方登录以GitHub为例)
- [配置Resend邮箱服务](#配置Resend邮箱服务)
- [API文档](#api文档)
- [OpenAPI文档](#openapi文档)
- [部署时间线以及文档时效性](#部署时间线以及文档时效性)
- [OpenAPI文档使用](#OpenAPI文档使用)
- [OpenAPI文档应用场景](#OpenAPI文档应用场景)
## 前置工作 ## 前置工作
@@ -31,9 +22,9 @@ cd OpenIsle
``` ```
- 后端开发环境 - 后端开发环境
- JDK 17+ - JDK 17+
- 前端开发环境 - 前端开发环境
- Node.JS 20+ - Node.JS 20+
## 启动后端服务 ## 启动后端服务
@@ -54,15 +45,15 @@ IDEA 打开 `backend/` 文件夹。
1. 生成环境变量文件 1. 生成环境变量文件
```shell ```shell
cp open-isle.env.example open-isle.env cp open-isle.env.example open-isle.env
``` ```
`open-isle.env.example` 是环境变量模板,`open-isle.env` 才是真正读取的内容 `open-isle.env.example` 是环境变量模板,`open-isle.env` 才是真正读取的内容
2. 修改环境变量,留下需要的,比如你要开发 Google 登录业务,就需要谷歌相关的变量,数据库是一定要的 2. 修改环境变量,留下需要的,比如你要开发 Google 登录业务,就需要谷歌相关的变量,数据库是一定要的
![环境变量](assets/contributing/backend_img_7.png) ![环境变量](assets/contributing/backend_img_7.png)
3. 应用环境文件,选择刚刚的 `open-isle.env` 3. 应用环境文件,选择刚刚的 `open-isle.env`
@@ -81,11 +72,11 @@ SERVER_PORT=8082
- 设置 JDK 版本为 java 17 - 设置 JDK 版本为 java 17
- 设置 VM Option最好运行在其他端口非 `8080`,这里设置 `8081` - 设置 VM Option最好运行在其他端口非 `8080`,这里设置 `8081`
若上面在环境变量中设置了端口,那这里就不需要再额外设置 若上面在环境变量中设置了端口,那这里就不需要再额外设置
```shell ```shell
-Dserver.port=8081 -Dserver.port=8081
``` ```
![配置1](assets/contributing/backend_img_3.png) ![配置1](assets/contributing/backend_img_3.png)
@@ -97,97 +88,37 @@ SERVER_PORT=8082
> 如果不知道怎么配置数据库可以参考 [Docker 环境](#docker-环境) 章节 > 如果不知道怎么配置数据库可以参考 [Docker 环境](#docker-环境) 章节
1. 本机配置 MySQL 服务(网上很多教程,忽略) 1. 本机配置 MySQL 服务(网上很多教程,忽略)
- 可以用 Laragon自带 MySQL 包括 Nodejs版本建议 `6.x``7` 以后需要 Lisence
- [下载地址](https://github.com/leokhoa/laragon/releases) + 可以用 Laragon自带 MySQL 包括 Nodejs版本建议 `6.x``7` 以后需要 Lisence
+ [下载地址](https://github.com/leokhoa/laragon/releases)
2. 填写环境变量 2. 填写环境变量
![环境变量](assets/contributing/backend_img_6.png) ![环境变量](assets/contributing/backend_img_6.png)
```ini ```ini
MYSQL_URL=jdbc:mysql://<数据库地址>:<端口>/<数据库名>?useUnicode=yes&characterEncoding=UTF-8&useInformationSchema=true&useSSL=false&serverTimezone=UTC MYSQL_URL=jdbc:mysql://<数据库地址>:<端口>/<数据库名>?useUnicode=yes&characterEncoding=UTF-8&useInformationSchema=true&useSSL=false&serverTimezone=UTC
MYSQL_USER=<数据库用户名> MYSQL_USER=<数据库用户名>
MYSQL_PASSWORD=<数据库密码> MYSQL_PASSWORD=<数据库密码>
``` ```
3. 执行 [`db/init/init_script.sql`](backend/src/main/resources/db/init/init_script.sql) 脚本,导入基本的数据 3. 执行 [`db/init/init_script.sql`](backend/src/main/resources/db/init/init_script.sql) 脚本,导入基本的数据
管理员:**admin/123456** 管理员:**admin/123456**
普通用户1**user1/123456** 普通用户1**user1/123456**
普通用户2**user2/123456** 普通用户2**user2/123456**
![初始化脚本](assets/contributing/resources_img.png) ![初始化脚本](assets/contributing/resources_img.png)
#### 配置 Redis #### 配置 Redis
后端的登录态缓存、访问频控等都依赖 Redis请确保本地有可用的 Redis 实例。 填写环境变量 `.env` 中的 Redis 相关配置并启动 Redis
1. **启动 Redis 服务**(已有服务可跳过) ```ini
REDIS_HOST=<Redis 地址>
REDIS_PORT=<Redis 端口>
```
```bash 处理完环境问题直接跑起来就能通了
docker run --name openisle-redis -p 6379:6379 -d redis:7-alpine
```
该命令会在本机暴露 `6379` 端口。若你已有其他端口的 Redis可以根据实际情况调整映射关系。
2. **在 `backend/open-isle.env` 中填写连接信息**
```ini
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
# 可选:若需要切换逻辑库,可新增此变量,默认使用 0 号库
REDIS_DATABASE=0
```
`application.properties` 中的默认值为 `localhost:6379`、数据库 `0`,如果你的环境恰好一致,也可以不额外填写;显式声明可以避免 IDE/运行时读取到意外配置。
3. **验证连接**
```bash
redis-cli -h 127.0.0.1 -p 6379 ping
```
启动后端后,日志中会出现 `Redis connection established ...`(来自 `RedisConnectionLogger`),说明已成功连通。
#### 配置 RabbitMQ
消息通知和 WebSocket 推送链路依赖 RabbitMQ。后端会自动声明交换机与队列确保本地 RabbitMQ 可用即可。
1. **启动 RabbitMQ 服务**(推荐包含管理界面)
```bash
docker run --name openisle-rabbitmq \
-e RABBITMQ_DEFAULT_USER=openisle \
-e RABBITMQ_DEFAULT_PASS=openisle \
-p 5672:5672 -p 15672:15672 \
-d rabbitmq:3.13-management
```
管理界面位于 http://127.0.0.1:15672 ,可用于查看队列、交换机等资源。
2. **同步填写后端与 WebSocket 服务的环境变量**
```ini
# backend/open-isle.env
RABBITMQ_HOST=127.0.0.1
RABBITMQ_PORT=5672
RABBITMQ_USERNAME=openisle
RABBITMQ_PASSWORD=openisle
# 如果需要启动 websocket_service也需要在 websocket_service.env 中保持一致
```
如果沿用 RabbitMQ 默认的 `guest/guest`可以不显式设置Spring Boot 会回退到 `application.properties` 中的默认值 (`localhost:5672`、`guest/guest`、虚拟主机 `/`)。
3. **确认自动声明的资源**
- 交换机:`openisle-exchange`
- 旧版兼容队列:`notifications-queue`
- 分片队列:`notifications-queue-0` ~ `notifications-queue-f`(共 16 个,对应路由键 `notifications.shard.0` ~ `notifications.shard.f`
- 队列持久化默认开启,来自 `rabbitmq.queue.durable=true`,如需仅在本地短暂测试,可在 `application.properties` 中调整该配置。
启动后端时可在日志中看到 `=== 开始主动声明 RabbitMQ 组件 ===` 与后续的声明结果,也可以在管理界面中查看是否创建成功。
完成 Redis 与 RabbitMQ 配置后,即可继续启动后端服务。
![运行画面](assets/contributing/backend_img_4.png) ![运行画面](assets/contributing/backend_img_4.png)
@@ -203,9 +134,9 @@ cd docker/
- `backend/open-isle.env`:后端环境变量,配置同上,见 [配置环境变量](#配置环境变量)。 - `backend/open-isle.env`:后端环境变量,配置同上,见 [配置环境变量](#配置环境变量)。
- `docker/.env`Docker Compose 环境变量,主要配置 MySQL 相关 - `docker/.env`Docker Compose 环境变量,主要配置 MySQL 相关
```shell ```shell
cp .env.example .env cp .env.example .env
``` ```
> [!TIP] > [!TIP]
> 使用单独的 `.env` 文件是为了兼容线上环境或已启用 MySQL 服务的情况,如果只是想快速体验或者启动统一的环境,则推荐使用本方式。 > 使用单独的 `.env` 文件是为了兼容线上环境或已启用 MySQL 服务的情况,如果只是想快速体验或者启动统一的环境,则推荐使用本方式。
@@ -245,21 +176,21 @@ cd frontend_nuxt/
- 利用预发环境:**(⚠️ 强烈推荐只开发前端的朋友使用该环境)** - 利用预发环境:**(⚠️ 强烈推荐只开发前端的朋友使用该环境)**
```shell ```shell
cp .env.staging.example .env cp .env.staging.example .env
``` ```
- 利用生产环境 - 利用生产环境
```shell ```shell
cp .env.production.example .env cp .env.production.example .env
``` ```
- 利用本地环境 - 利用本地环境
```shell ```shell
cp .env.dev.example .env cp .env.dev.example .env
``` ```
若依赖本机部署的后端,需要修改 `.env` 中的 `NUXT_PUBLIC_API_BASE_URL` 值与后端服务端口一致 若依赖本机部署的后端,需要修改 `.env` 中的 `NUXT_PUBLIC_API_BASE_URL` 值与后端服务端口一致
@@ -279,23 +210,23 @@ npm run dev
## 其他配置 ## 其他配置
### 配置第三方登录GitHub为例 ### 配置第三方登录,这里以 GitHub 为例
- 修改 `application.properties` 配置 - 修改 `application.properties` 配置
![后端配置](assets/contributing/backend_img.png) ![后端配置](assets/contributing/backend_img.png)
- 修改 `.env` 配置 - 修改 `.env` 配置
![前端](assets/contributing/fontend_img.png) ![前端](assets/contributing/fontend_img.png)
- 配置第三方登录回调地址 - 配置第三方登录回调地址
![github配置](assets/contributing/github_img.png) ![github配置](assets/contributing/github_img.png)
![github配置2](assets/contributing/github_img_2.png) ![github配置2](assets/contributing/github_img_2.png)
### 配置Resend邮箱服务 ### 配置 Resend 邮箱服务
https://resend.com/emails 创建账号并登录 https://resend.com/emails 创建账号并登录
@@ -316,42 +247,8 @@ https://resend.com/emails 创建账号并登录
`RESEND_API_KEY`**刚刚复制的 Key** `RESEND_API_KEY`**刚刚复制的 Key**
![image-20250906151218330](assets/contributing/image-20250906151218330.png) ![image-20250906151218330](assets/contributing/image-20250906151218330.png)
## API文档 ## 开源共建和API文档
### OpenAPI文档
https://docs.open-isle.com
### 部署时间线以及文档时效性
我已经将API Docs的部署融合进本站CI & CD中目前如下
- 每次合入main之后都会构建预发环境 http://staging.open-isle.com/ ,现在文档是紧随其后进行部署也就是说代码合入main之后如果是新增后台接口就可以立即通过OpenAPI文档页面进行查看和调试但是如果想通过OpenAPI调试需要选择预发环境的
- 每日凌晨三点会构建并重新部署正式环境届时当日合入main的新后台API也可以通过OpenAPI文档页面调试
![CleanShot 2025-09-10 at 12.04.48@2x.png](https://openisle-1307107697.cos.accelerate.myqcloud.com/dynamic_assert/168303009f4047ca828344957e911ff1.png)
👆如图是合入main之后构建预发+docs的情形总大约耗时4分钟左右
### OpenAPI文档使用
- 预发环境/正式环境切换可以通过如下位置切换API环境
![CleanShot 2025-09-10 at 12.08.00@2x.png](https://openisle-1307107697.cos.accelerate.myqcloud.com/dynamic_assert/f9fb7a0f020d4a0e94159d7820783224.png)
- API分两种一种是需要鉴权需登录后的token另一种是直接访问可以直接访问的GET请求直接点击Send即可调试如下👇比如本站的推荐流rss: /api/rss: https://docs.open-isle.com/openapi/feed
![CleanShot 2025-09-10 at 12.09.48@2x.png](https://openisle-1307107697.cos.accelerate.myqcloud.com/dynamic_assert/2afb42e0c96340559dd42854905ca5fc.png)
- 需要登陆的API比如关注取消关注发帖等则需要提供token目前在“API与调试”可获取自身token可点击link看看👉 https://www.open-isle.com/about?tab=api
![CleanShot 2025-09-10 at 12.11.07@2x.png](https://openisle-1307107697.cos.accelerate.myqcloud.com/dynamic_assert/74033f1b9cc14f2fab3cbe3b7fe306d8.png)
copy完token之后粘贴到Bear之后, 即可发送调试, 如下👇大家亦可自行尝试https://docs.open-isle.com/openapi/me
![CleanShot 2025-09-10 at 12.13.00@2x.png](https://openisle-1307107697.cos.accelerate.myqcloud.com/dynamic_assert/63913fe2e70541a486651e35c723765e.png)
#### OpenAPI文档应用场景
- 方便大部分前端调试的需求,如果有只想做前端/客户端的同学参与本项目,该平台会大大提高效率
- 自动化:有自动化发帖/自动化操作的需求,亦可通过该平台实现或调试
- API文档: https://docs.open-isle.com/openapi - API文档: https://docs.open-isle.com/openapi

View File

@@ -1,23 +0,0 @@
{
"printWidth": 100,
"tabWidth": 2,
"useTabs": false,
"semi": false,
"singleQuote": true,
"trailingComma": "all",
"endOfLine": "lf",
"proseWrap": "preserve",
"plugins": ["prettier-plugin-java"],
"overrides": [
{
"files": "*.java",
"options": {
"printWidth": 100,
"tabWidth": 2,
"semi": true,
"singleQuote": false,
"trailingComma": "es5"
}
}
]
}

View File

@@ -7,8 +7,7 @@ import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication @SpringBootApplication
@EnableScheduling @EnableScheduling
public class OpenIsleApplication { public class OpenIsleApplication {
public static void main(String[] args) {
public static void main(String[] args) { SpringApplication.run(OpenIsleApplication.class, args);
SpringApplication.run(OpenIsleApplication.class, args); }
}
} }

View File

@@ -3,40 +3,37 @@ package com.openisle.config;
import com.openisle.model.Activity; import com.openisle.model.Activity;
import com.openisle.model.ActivityType; import com.openisle.model.ActivityType;
import com.openisle.repository.ActivityRepository; import com.openisle.repository.ActivityRepository;
import java.time.LocalDate;
import java.time.LocalDateTime;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.boot.CommandLineRunner; import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import java.time.LocalDate;
import java.time.LocalDateTime;
@Component @Component
@RequiredArgsConstructor @RequiredArgsConstructor
public class ActivityInitializer implements CommandLineRunner { public class ActivityInitializer implements CommandLineRunner {
private final ActivityRepository activityRepository;
private final ActivityRepository activityRepository; @Override
public void run(String... args) {
if (activityRepository.findByType(ActivityType.MILK_TEA) == null) {
Activity a = new Activity();
a.setTitle("🎡建站送奶茶活动");
a.setType(ActivityType.MILK_TEA);
a.setIcon("https://icons.veryicon.com/png/o/food--drinks/delicious-food-1/coffee-36.png");
a.setContent("为了有利于建站推广以及激励发布内容我们推出了建站送奶茶的活动前50名达到level 1的用户可以联系站长获取奶茶/咖啡一杯");
activityRepository.save(a);
}
@Override if (activityRepository.findByType(ActivityType.INVITE_POINTS) == null) {
public void run(String... args) { Activity a = new Activity();
if (activityRepository.findByType(ActivityType.MILK_TEA) == null) { a.setTitle("🎁邀请码送积分活动");
Activity a = new Activity(); a.setType(ActivityType.INVITE_POINTS);
a.setTitle("🎡建站送奶茶活动"); a.setIcon("https://img.icons8.com/color/96/gift.png");
a.setType(ActivityType.MILK_TEA); a.setContent("使用邀请码注册或邀请好友即可获得积分奖励,快来参与吧!");
a.setIcon("https://icons.veryicon.com/png/o/food--drinks/delicious-food-1/coffee-36.png"); a.setStartTime(LocalDateTime.now());
a.setContent( a.setEndTime(LocalDate.of(LocalDate.now().getYear(), 10, 1).atStartOfDay());
"为了有利于建站推广以及激励发布内容我们推出了建站送奶茶的活动前50名达到level 1的用户可以联系站长获取奶茶/咖啡一杯" activityRepository.save(a);
); }
activityRepository.save(a);
} }
if (activityRepository.findByType(ActivityType.INVITE_POINTS) == null) {
Activity a = new Activity();
a.setTitle("🎁邀请码送积分活动");
a.setType(ActivityType.INVITE_POINTS);
a.setIcon("https://img.icons8.com/color/96/gift.png");
a.setContent("使用邀请码注册或邀请好友即可获得积分奖励,快来参与吧!");
a.setStartTime(LocalDateTime.now());
a.setEndTime(LocalDate.of(LocalDate.now().getYear(), 10, 1).atStartOfDay());
activityRepository.save(a);
}
}
} }

View File

@@ -1,23 +1,23 @@
package com.openisle.config; package com.openisle.config;
import java.util.concurrent.Executor;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync; import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import java.util.concurrent.Executor;
@Configuration @Configuration
@EnableAsync @EnableAsync
public class AsyncConfig { public class AsyncConfig {
@Bean(name = "notificationExecutor")
@Bean(name = "notificationExecutor") public Executor notificationExecutor() {
public Executor notificationExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(2);
executor.setCorePoolSize(2); executor.setMaxPoolSize(10);
executor.setMaxPoolSize(10); executor.setQueueCapacity(100);
executor.setQueueCapacity(100); executor.setThreadNamePrefix("notification-");
executor.setThreadNamePrefix("notification-"); executor.initialize();
executor.initialize(); return executor;
return executor; }
}
} }

View File

@@ -7,9 +7,6 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.jsontype.impl.LaissezFaireSubTypeValidator; import com.fasterxml.jackson.databind.jsontype.impl.LaissezFaireSubTypeValidator;
import com.fasterxml.jackson.datatype.hibernate6.Hibernate6Module; import com.fasterxml.jackson.datatype.hibernate6.Hibernate6Module;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import java.time.Duration;
import java.util.HashMap;
import java.util.Map;
import org.springframework.cache.CacheManager; import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.EnableCaching; import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
@@ -24,6 +21,10 @@ import org.springframework.data.redis.serializer.RedisSerializationContext;
import org.springframework.data.redis.serializer.RedisSerializer; import org.springframework.data.redis.serializer.RedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer; import org.springframework.data.redis.serializer.StringRedisSerializer;
import java.time.Duration;
import java.util.HashMap;
import java.util.Map;
/** /**
* Redis 缓存配置类 * Redis 缓存配置类
* @author smallclover * @author smallclover
@@ -33,107 +34,96 @@ import org.springframework.data.redis.serializer.StringRedisSerializer;
@EnableCaching @EnableCaching
public class CachingConfig { public class CachingConfig {
// 标签缓存名 // 标签缓存名
public static final String TAG_CACHE_NAME = "openisle_tags"; public static final String TAG_CACHE_NAME="openisle_tags";
// 分类缓存名 // 分类缓存名
public static final String CATEGORY_CACHE_NAME = "openisle_categories"; public static final String CATEGORY_CACHE_NAME="openisle_categories";
// 在线人数缓存名 // 在线人数缓存名
public static final String ONLINE_CACHE_NAME = "openisle_online"; public static final String ONLINE_CACHE_NAME="openisle_online";
// 注册验证码 // 注册验证码
public static final String VERIFY_CACHE_NAME = "openisle_verify"; public static final String VERIFY_CACHE_NAME="openisle_verify";
// 发帖频率限制 // 发帖频率限制
public static final String LIMIT_CACHE_NAME = "openisle_limit"; public static final String LIMIT_CACHE_NAME="openisle_limit";
// 用户访问统计 // 用户访问统计
public static final String VISIT_CACHE_NAME = "openisle_visit"; public static final String VISIT_CACHE_NAME="openisle_visit";
// 文章缓存 // 文章缓存
public static final String POST_CACHE_NAME = "openisle_posts"; public static final String POST_CACHE_NAME="openisle_posts";
/** /**
* 自定义Redis的序列化器 * 自定义Redis的序列化器
* @return * @return
*/ */
@Bean @Bean()
@Primary @Primary
public RedisSerializer<Object> redisSerializer() { public RedisSerializer<Object> redisSerializer() {
// 注册 JavaTimeModule 來支持 Java 8 的日期和时间 API,否则回报一下错误同时还要引入jsr310 // 注册 JavaTimeModule 來支持 Java 8 的日期和时间 API,否则回报一下错误同时还要引入jsr310
// org.springframework.data.redis.serializer.SerializationException: Could not write JSON: Java 8 date/time type `java.time.LocalDateTime` not supported by default: // org.springframework.data.redis.serializer.SerializationException: Could not write JSON: Java 8 date/time type `java.time.LocalDateTime` not supported by default:
// add Module "com.fasterxml.jackson.datatype:jackson-datatype-jsr310" to enable handling // add Module "com.fasterxml.jackson.datatype:jackson-datatype-jsr310" to enable handling
// (through reference chain: java.util.ArrayList[0]->com.openisle.dto.TagDto["createdAt"]) // (through reference chain: java.util.ArrayList[0]->com.openisle.dto.TagDto["createdAt"])
// 设置可见性,允许序列化所有元素 // 设置可见性,允许序列化所有元素
ObjectMapper objectMapper = new ObjectMapper(); ObjectMapper objectMapper = new ObjectMapper();
objectMapper.registerModule(new JavaTimeModule()); objectMapper.registerModule(new JavaTimeModule());
// Hibernate6Module 可以自动处理懒加载代理对象。 // Hibernate6Module 可以自动处理懒加载代理对象。
// Tag对象的creator是FetchType.LAZY // Tag对象的creator是FetchType.LAZY
objectMapper.registerModule( objectMapper.registerModule(new Hibernate6Module()
new Hibernate6Module() .disable(Hibernate6Module.Feature.USE_TRANSIENT_ANNOTATION)
.disable(Hibernate6Module.Feature.USE_TRANSIENT_ANNOTATION) // 将 Hibernate 特有的集合类型转换为标准 Java 集合类型
// 将 Hibernate 特有的集合类型转换为标准 Java 集合类型 // 避免序列化时出现 org.hibernate.collection.spi.PersistentSet 这样的类型信息
// 避免序列化时出现 org.hibernate.collection.spi.PersistentSet 这样的类型信息 .configure(Hibernate6Module.Feature.REPLACE_PERSISTENT_COLLECTIONS, true));
.configure(Hibernate6Module.Feature.REPLACE_PERSISTENT_COLLECTIONS, true) // service的时候带上类型信息
); // 启用类型信息,避免 LinkedHashMap 问题
// service的时候带上类型信息 objectMapper.activateDefaultTyping(
// 启用类型信息,避免 LinkedHashMap 问题 LaissezFaireSubTypeValidator.instance,
objectMapper.activateDefaultTyping( ObjectMapper.DefaultTyping.NON_FINAL,
LaissezFaireSubTypeValidator.instance, JsonTypeInfo.As.PROPERTY
ObjectMapper.DefaultTyping.NON_FINAL, );
JsonTypeInfo.As.PROPERTY objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
); return new GenericJackson2JsonRedisSerializer(objectMapper);
objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY); }
return new GenericJackson2JsonRedisSerializer(objectMapper);
}
/** /**
* 配置 Spring Cache 使用 RedisCacheManager * 配置 Spring Cache 使用 RedisCacheManager
*/ */
@Bean @Bean
public CacheManager cacheManager( public CacheManager cacheManager(RedisConnectionFactory connectionFactory, RedisSerializer<Object> redisSerializer) {
RedisConnectionFactory connectionFactory,
RedisSerializer<Object> redisSerializer
) {
RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ZERO) // 默认缓存不过期
.serializeKeysWith(
RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer())
)
.serializeValuesWith(
RedisSerializationContext.SerializationPair.fromSerializer(redisSerializer)
)
.disableCachingNullValues(); // 禁止缓存 null 值
// 个别缓存单独设置 TTL 时间 RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
Map<String, RedisCacheConfiguration> cacheConfigs = new HashMap<>(); .entryTtl(Duration.ZERO) // 默认缓存不过期
RedisCacheConfiguration oneHourConfig = config.entryTtl(Duration.ofHours(1)); .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
RedisCacheConfiguration tenMinutesConfig = config.entryTtl(Duration.ofMinutes(10)); .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(redisSerializer))
cacheConfigs.put(TAG_CACHE_NAME, oneHourConfig); .disableCachingNullValues(); // 禁止缓存 null 值
cacheConfigs.put(CATEGORY_CACHE_NAME, oneHourConfig);
cacheConfigs.put(POST_CACHE_NAME, tenMinutesConfig);
return RedisCacheManager.builder(connectionFactory) // 个别缓存单独设置 TTL 时间
.cacheDefaults(config) Map<String, RedisCacheConfiguration> cacheConfigs = new HashMap<>();
.withInitialCacheConfigurations(cacheConfigs) RedisCacheConfiguration oneHourConfig = config.entryTtl(Duration.ofHours(1));
.build(); RedisCacheConfiguration tenMinutesConfig = config.entryTtl(Duration.ofMinutes(10));
} cacheConfigs.put(TAG_CACHE_NAME, oneHourConfig);
cacheConfigs.put(CATEGORY_CACHE_NAME, oneHourConfig);
cacheConfigs.put(POST_CACHE_NAME, tenMinutesConfig);
/** return RedisCacheManager.builder(connectionFactory)
* 配置 RedisTemplate支持直接操作 Redis .cacheDefaults(config)
*/ .withInitialCacheConfigurations(cacheConfigs)
@Bean .build();
public RedisTemplate<String, Object> redisTemplate( }
RedisConnectionFactory connectionFactory,
RedisSerializer<Object> redisSerializer
) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(connectionFactory);
// key 和 hashKey 使用 String 序列化 /**
template.setKeySerializer(new StringRedisSerializer()); * 配置 RedisTemplate支持直接操作 Redis
template.setHashKeySerializer(new StringRedisSerializer()); */
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory, RedisSerializer<Object> redisSerializer) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(connectionFactory);
// value 和 hashValue 使用 JSON 序列化 // key 和 hashKey 使用 String 序列化
template.setValueSerializer(redisSerializer); template.setKeySerializer(new StringRedisSerializer());
template.setHashValueSerializer(redisSerializer); template.setHashKeySerializer(new StringRedisSerializer());
return template; // value 和 hashValue 使用 JSON 序列化
} template.setValueSerializer(redisSerializer);
template.setHashValueSerializer(redisSerializer);
return template;
}
} }

View File

@@ -9,29 +9,24 @@ import org.springframework.stereotype.Component;
@Component @Component
@RequiredArgsConstructor @RequiredArgsConstructor
public class ChannelInitializer implements CommandLineRunner { public class ChannelInitializer implements CommandLineRunner {
private final MessageConversationRepository conversationRepository;
private final MessageConversationRepository conversationRepository; @Override
public void run(String... args) {
if (conversationRepository.countByChannelTrue() == 0) {
MessageConversation chat = new MessageConversation();
chat.setChannel(true);
chat.setName("吹水群");
chat.setDescription("吹水聊天");
chat.setAvatar("https://openisle-1307107697.cos.accelerate.myqcloud.com/dynamic_assert/32647273e2334d14adfd4a6ce9db0643.jpeg");
conversationRepository.save(chat);
@Override MessageConversation tech = new MessageConversation();
public void run(String... args) { tech.setChannel(true);
if (conversationRepository.countByChannelTrue() == 0) { tech.setName("技术讨论群");
MessageConversation chat = new MessageConversation(); tech.setDescription("讨论技术相关话题");
chat.setChannel(true); tech.setAvatar("https://openisle-1307107697.cos.accelerate.myqcloud.com/dynamic_assert/5edde9a5864e471caa32491dbcdaa8b2.png");
chat.setName("吹水群"); conversationRepository.save(tech);
chat.setDescription("吹水聊天"); }
chat.setAvatar(
"https://openisle-1307107697.cos.accelerate.myqcloud.com/dynamic_assert/32647273e2334d14adfd4a6ce9db0643.jpeg"
);
conversationRepository.save(chat);
MessageConversation tech = new MessageConversation();
tech.setChannel(true);
tech.setName("技术讨论群");
tech.setDescription("讨论技术相关话题");
tech.setAvatar(
"https://openisle-1307107697.cos.accelerate.myqcloud.com/dynamic_assert/5edde9a5864e471caa32491dbcdaa8b2.png"
);
conversationRepository.save(tech);
} }
}
} }

View File

@@ -3,25 +3,23 @@ package com.openisle.config;
import jakarta.servlet.ServletException; import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import org.springframework.security.access.AccessDeniedException; import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler; import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import java.io.IOException;
/** /**
* Returns 401 Unauthorized when an authenticated user lacks required privileges. * Returns 401 Unauthorized when an authenticated user lacks required privileges.
*/ */
@Component @Component
public class CustomAccessDeniedHandler implements AccessDeniedHandler { public class CustomAccessDeniedHandler implements AccessDeniedHandler {
@Override
@Override public void handle(HttpServletRequest request,
public void handle( HttpServletResponse response,
HttpServletRequest request, AccessDeniedException accessDeniedException) throws IOException, ServletException {
HttpServletResponse response, response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
AccessDeniedException accessDeniedException response.setContentType("application/json");
) throws IOException, ServletException { response.getWriter().write("{\"error\": \"Unauthorized\"}");
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); }
response.setContentType("application/json");
response.getWriter().write("{\"error\": \"Unauthorized\"}");
}
} }

View File

@@ -6,6 +6,7 @@ import io.swagger.v3.oas.models.info.Info;
import io.swagger.v3.oas.models.security.SecurityRequirement; import io.swagger.v3.oas.models.security.SecurityRequirement;
import io.swagger.v3.oas.models.security.SecurityScheme; import io.swagger.v3.oas.models.security.SecurityScheme;
import io.swagger.v3.oas.models.servers.Server; import io.swagger.v3.oas.models.servers.Server;
import java.util.List; import java.util.List;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@@ -17,42 +18,43 @@ import org.springframework.context.annotation.Configuration;
@RequiredArgsConstructor @RequiredArgsConstructor
public class OpenApiConfig { public class OpenApiConfig {
private final SpringDocProperties springDocProperties; private final SpringDocProperties springDocProperties;
@Value("${springdoc.info.title}") @Value("${springdoc.info.title}")
private String title; private String title;
@Value("${springdoc.info.description}") @Value("${springdoc.info.description}")
private String description; private String description;
@Value("${springdoc.info.version}") @Value("${springdoc.info.version}")
private String version; private String version;
@Value("${springdoc.info.scheme}") @Value("${springdoc.info.scheme}")
private String scheme; private String scheme;
@Value("${springdoc.info.header}") @Value("${springdoc.info.header}")
private String header; private String header;
@Bean @Bean
public OpenAPI openAPI() { public OpenAPI openAPI() {
SecurityScheme securityScheme = new SecurityScheme() SecurityScheme securityScheme = new SecurityScheme()
.type(SecurityScheme.Type.HTTP) .type(SecurityScheme.Type.HTTP)
.scheme(scheme.toLowerCase()) .scheme(scheme.toLowerCase())
.bearerFormat("JWT") .bearerFormat("JWT")
.in(SecurityScheme.In.HEADER) .in(SecurityScheme.In.HEADER)
.name(header); .name(header);
List<Server> servers = springDocProperties List<Server> servers = springDocProperties.getServers().stream()
.getServers() .map(s -> new Server().url(s.getUrl()).description(s.getDescription()))
.stream() .collect(Collectors.toList());
.map(s -> new Server().url(s.getUrl()).description(s.getDescription()))
.collect(Collectors.toList());
return new OpenAPI() return new OpenAPI()
.servers(servers) .servers(servers)
.info(new Info().title(title).description(description).version(version)) .info(new Info()
.components(new Components().addSecuritySchemes("JWT", securityScheme)) .title(title)
.addSecurityItem(new SecurityRequirement().addList("JWT")); .description(description)
} .version(version))
.components(new Components().addSecuritySchemes("JWT", securityScheme))
.addSecurityItem(new SecurityRequirement().addList("JWT"));
}
} }

View File

@@ -10,27 +10,22 @@ import org.springframework.stereotype.Component;
@Component @Component
@RequiredArgsConstructor @RequiredArgsConstructor
public class PointGoodInitializer implements CommandLineRunner { public class PointGoodInitializer implements CommandLineRunner {
private final PointGoodRepository pointGoodRepository;
private final PointGoodRepository pointGoodRepository; @Override
public void run(String... args) {
if (pointGoodRepository.count() == 0) {
PointGood g1 = new PointGood();
g1.setName("GPT Plus 1 个月");
g1.setCost(20000);
g1.setImage("https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/icons/chatgpt.png");
pointGoodRepository.save(g1);
@Override PointGood g2 = new PointGood();
public void run(String... args) { g2.setName("奶茶");
if (pointGoodRepository.count() == 0) { g2.setCost(5000);
PointGood g1 = new PointGood(); g2.setImage("https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/icons/coffee.png");
g1.setName("GPT Plus 1 个月"); pointGoodRepository.save(g2);
g1.setCost(20000); }
g1.setImage(
"https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/icons/chatgpt.png"
);
pointGoodRepository.save(g1);
PointGood g2 = new PointGood();
g2.setName("奶茶");
g2.setCost(5000);
g2.setImage(
"https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/icons/coffee.png"
);
pointGoodRepository.save(g2);
} }
}
} }

View File

@@ -1,9 +1,5 @@
package com.openisle.config; package com.openisle.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.PostConstruct;
import java.util.ArrayList;
import java.util.List;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.core.Binding; import org.springframework.amqp.core.Binding;
@@ -11,209 +7,199 @@ import org.springframework.amqp.core.BindingBuilder;
import org.springframework.amqp.core.Queue; import org.springframework.amqp.core.Queue;
import org.springframework.amqp.core.TopicExchange; import org.springframework.amqp.core.TopicExchange;
import org.springframework.amqp.rabbit.connection.ConnectionFactory; import org.springframework.amqp.rabbit.connection.ConnectionFactory;
import org.springframework.amqp.rabbit.core.RabbitAdmin;
import org.springframework.amqp.rabbit.core.RabbitTemplate; import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter; import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter;
import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.CommandLineRunner;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.amqp.rabbit.core.RabbitAdmin;
import org.springframework.boot.CommandLineRunner;
import org.springframework.context.annotation.DependsOn; import org.springframework.context.annotation.DependsOn;
import jakarta.annotation.PostConstruct;
import java.util.ArrayList;
import java.util.List;
@Configuration @Configuration
@RequiredArgsConstructor @RequiredArgsConstructor
@Slf4j @Slf4j
public class RabbitMQConfig { public class RabbitMQConfig {
public static final String EXCHANGE_NAME = "openisle-exchange"; public static final String EXCHANGE_NAME = "openisle-exchange";
// 保持向后兼容的常量 // 保持向后兼容的常量
public static final String QUEUE_NAME = "notifications-queue"; public static final String QUEUE_NAME = "notifications-queue";
public static final String ROUTING_KEY = "notifications.routingkey"; public static final String ROUTING_KEY = "notifications.routingkey";
// 硬编码为16以匹配ShardingStrategy中的十六进制分片逻辑
private final int queueCount = 16;
@Value("${rabbitmq.queue.durable}")
private boolean queueDurable;
// 硬编码为16以匹配ShardingStrategy中的十六进制分片逻辑 @PostConstruct
private final int queueCount = 16; public void init() {
log.info("RabbitMQ配置初始化: 队列数量={}, 持久化={}", queueCount, queueDurable);
@Value("${rabbitmq.queue.durable}")
private boolean queueDurable;
@PostConstruct
public void init() {
log.info("RabbitMQ配置初始化: 队列数量={}, 持久化={}", queueCount, queueDurable);
}
@Bean
public TopicExchange exchange() {
return new TopicExchange(EXCHANGE_NAME);
}
/**
* 创建所有分片队列, 使用十六进制后缀 (0-f)
*/
@Bean
public List<Queue> shardedQueues() {
log.info("开始创建分片队列 Bean...");
List<Queue> queues = new ArrayList<>();
for (int i = 0; i < queueCount; i++) {
String shardKey = Integer.toHexString(i);
String queueName = "notifications-queue-" + shardKey;
Queue queue = new Queue(queueName, queueDurable);
queues.add(queue);
} }
log.info("分片队列 Bean 创建完成,总数: {}", queues.size()); @Bean
return queues; public TopicExchange exchange() {
} return new TopicExchange(EXCHANGE_NAME);
/**
* 创建所有分片绑定, 使用十六进制路由键 (notifications.shard.0 - notifications.shard.f)
*/
@Bean
public List<Binding> shardedBindings(
TopicExchange exchange,
@Qualifier("shardedQueues") List<Queue> shardedQueues
) {
log.info("开始创建分片绑定 Bean...");
List<Binding> bindings = new ArrayList<>();
if (shardedQueues != null) {
for (Queue queue : shardedQueues) {
String queueName = queue.getName();
String shardKey = queueName.substring("notifications-queue-".length());
String routingKey = "notifications.shard." + shardKey;
Binding binding = BindingBuilder.bind(queue).to(exchange).with(routingKey);
bindings.add(binding);
}
} }
log.info("分片绑定 Bean 创建完成,总数: {}", bindings.size()); /**
return bindings; * 创建所有分片队列, 使用十六进制后缀 (0-f)
} */
@Bean
public List<Queue> shardedQueues() {
log.info("开始创建分片队列 Bean...");
List<Queue> queues = new ArrayList<>();
for (int i = 0; i < queueCount; i++) {
String shardKey = Integer.toHexString(i);
String queueName = "notifications-queue-" + shardKey;
Queue queue = new Queue(queueName, queueDurable);
queues.add(queue);
}
log.info("分片队列 Bean 创建完成,总数: {}", queues.size());
return queues;
}
/** /**
* 保持向后兼容的单队列配置(可选) * 创建所有分片绑定, 使用十六进制路由键 (notifications.shard.0 - notifications.shard.f)
*/ */
@Bean @Bean
public Queue legacyQueue() { public List<Binding> shardedBindings(TopicExchange exchange, @Qualifier("shardedQueues") List<Queue> shardedQueues) {
return new Queue(QUEUE_NAME, queueDurable); log.info("开始创建分片绑定 Bean...");
} List<Binding> bindings = new ArrayList<>();
if (shardedQueues != null) {
/** for (Queue queue : shardedQueues) {
* 保持向后兼容的单队列绑定(可选) String queueName = queue.getName();
*/ String shardKey = queueName.substring("notifications-queue-".length());
@Bean String routingKey = "notifications.shard." + shardKey;
public Binding legacyBinding(Queue legacyQueue, TopicExchange exchange) { Binding binding = BindingBuilder.bind(queue).to(exchange).with(routingKey);
return BindingBuilder.bind(legacyQueue).to(exchange).with(ROUTING_KEY); bindings.add(binding);
}
@Bean
public Jackson2JsonMessageConverter messageConverter() {
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.registerModule(new com.fasterxml.jackson.datatype.jsr310.JavaTimeModule());
objectMapper.disable(
com.fasterxml.jackson.databind.SerializationFeature.WRITE_DATES_AS_TIMESTAMPS
);
return new Jackson2JsonMessageConverter(objectMapper);
}
@Bean
public RabbitAdmin rabbitAdmin(ConnectionFactory connectionFactory) {
return new RabbitAdmin(connectionFactory);
}
@Bean
public RabbitTemplate rabbitTemplate(ConnectionFactory connectionFactory) {
RabbitTemplate template = new RabbitTemplate(connectionFactory);
template.setMessageConverter(messageConverter());
return template;
}
/**
* 使用 CommandLineRunner 确保在应用完全启动后声明队列到 RabbitMQ
* 这样可以确保 RabbitAdmin 和所有 Bean 都已正确初始化
*/
@Bean
@DependsOn({ "rabbitAdmin", "shardedQueues", "exchange" })
public CommandLineRunner queueDeclarationRunner(
RabbitAdmin rabbitAdmin,
@Qualifier("shardedQueues") List<Queue> shardedQueues,
TopicExchange exchange,
Queue legacyQueue,
@Qualifier("shardedBindings") List<Binding> shardedBindings,
Binding legacyBinding
) {
return args -> {
log.info("=== 开始主动声明 RabbitMQ 组件 ===");
try {
// 声明交换
rabbitAdmin.declareExchange(exchange);
// 声明分片队列 - 检查存在性
log.info("开始检查并声明 {} 个分片队列...", shardedQueues.size());
int successCount = 0;
int skippedCount = 0;
for (Queue queue : shardedQueues) {
String queueName = queue.getName();
try {
// 使用 declareQueue 的返回值判断队列是否已存在
// 如果队列已存在且配置匹配declareQueue 会返回现有队列信息
// 如果不匹配或不存在,会创建新队列
rabbitAdmin.declareQueue(queue);
successCount++;
} catch (org.springframework.amqp.AmqpIOException e) {
if (
e.getMessage().contains("PRECONDITION_FAILED") && e.getMessage().contains("durable")
) {
skippedCount++;
} }
} catch (Exception e) {
log.error("队列声明失败: {}, 错误: {}", queueName, e.getMessage());
}
} }
log.info(
"分片队列处理完成: 成功 {}, 跳过 {}, 总数 {}", log.info("分片绑定 Bean 创建完成,总数: {}", bindings.size());
successCount, return bindings;
skippedCount, }
shardedQueues.size()
);
// 声明分片绑定 /**
log.info("开始声明 {} 个分片绑定...", shardedBindings.size()); * 保持向后兼容的单队列配置(可选)
int bindingSuccessCount = 0; */
for (Binding binding : shardedBindings) { @Bean
try { public Queue legacyQueue() {
rabbitAdmin.declareBinding(binding); return new Queue(QUEUE_NAME, queueDurable);
bindingSuccessCount++; }
} catch (Exception e) {
log.error("绑定声明失败: {}", e.getMessage());
}
}
log.info("分片绑定声明完成: 成功 {}/{}", bindingSuccessCount, shardedBindings.size());
// 声明遗留队列和绑定 - 检查存在性 /**
try { * 保持向后兼容的单队列绑定(可选)
rabbitAdmin.declareQueue(legacyQueue); */
rabbitAdmin.declareBinding(legacyBinding); @Bean
log.info("遗留队列和绑定就绪: {} (已存在或新创建)", QUEUE_NAME); public Binding legacyBinding(Queue legacyQueue, TopicExchange exchange) {
} catch (org.springframework.amqp.AmqpIOException e) { return BindingBuilder.bind(legacyQueue).to(exchange).with(ROUTING_KEY);
if ( }
e.getMessage().contains("PRECONDITION_FAILED") && e.getMessage().contains("durable")
) {
log.warn("遗留队列已存在但 durable 设置不匹配: {}, 保持现有队列", QUEUE_NAME);
} else {
log.error("遗留队列声明失败: {}, 错误: {}", QUEUE_NAME, e.getMessage());
}
} catch (Exception e) {
log.error("遗留队列声明失败: {}, 错误: {}", QUEUE_NAME, e.getMessage());
}
log.info("=== RabbitMQ 组件声明完成 ==="); @Bean
log.info("请检查 RabbitMQ 管理界面确认队列已正确创建"); public Jackson2JsonMessageConverter messageConverter() {
} catch (Exception e) { ObjectMapper objectMapper = new ObjectMapper();
log.error("RabbitMQ 组件声明过程中发生严重错误", e); objectMapper.registerModule(new com.fasterxml.jackson.datatype.jsr310.JavaTimeModule());
} objectMapper.disable(com.fasterxml.jackson.databind.SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
}; return new Jackson2JsonMessageConverter(objectMapper);
} }
}
@Bean
public RabbitAdmin rabbitAdmin(ConnectionFactory connectionFactory) {
return new RabbitAdmin(connectionFactory);
}
@Bean
public RabbitTemplate rabbitTemplate(ConnectionFactory connectionFactory) {
RabbitTemplate template = new RabbitTemplate(connectionFactory);
template.setMessageConverter(messageConverter());
return template;
}
/**
* 使用 CommandLineRunner 确保在应用完全启动后声明队列到 RabbitMQ
* 这样可以确保 RabbitAdmin 和所有 Bean 都已正确初始化
*/
@Bean
@DependsOn({"rabbitAdmin", "shardedQueues", "exchange"})
public CommandLineRunner queueDeclarationRunner(RabbitAdmin rabbitAdmin,
@Qualifier("shardedQueues") List<Queue> shardedQueues,
TopicExchange exchange,
Queue legacyQueue,
@Qualifier("shardedBindings") List<Binding> shardedBindings,
Binding legacyBinding) {
return args -> {
log.info("=== 开始主动声明 RabbitMQ 组件 ===");
try {
// 声明交换
rabbitAdmin.declareExchange(exchange);
// 声明分片队列 - 检查存在性
log.info("开始检查并声明 {} 个分片队列...", shardedQueues.size());
int successCount = 0;
int skippedCount = 0;
for (Queue queue : shardedQueues) {
String queueName = queue.getName();
try {
// 使用 declareQueue 的返回值判断队列是否已存在
// 如果队列已存在且配置匹配declareQueue 会返回现有队列信息
// 如果不匹配或不存在,会创建新队列
rabbitAdmin.declareQueue(queue);
successCount++;
} catch (org.springframework.amqp.AmqpIOException e) {
if (e.getMessage().contains("PRECONDITION_FAILED") && e.getMessage().contains("durable")) {
skippedCount++;
}
} catch (Exception e) {
log.error("队列声明失败: {}, 错误: {}", queueName, e.getMessage());
}
}
log.info("分片队列处理完成: 成功 {}, 跳过 {}, 总数 {}", successCount, skippedCount, shardedQueues.size());
// 声明分片绑定
log.info("开始声明 {} 个分片绑定...", shardedBindings.size());
int bindingSuccessCount = 0;
for (Binding binding : shardedBindings) {
try {
rabbitAdmin.declareBinding(binding);
bindingSuccessCount++;
} catch (Exception e) {
log.error("绑定声明失败: {}", e.getMessage());
}
}
log.info("分片绑定声明完成: 成功 {}/{}", bindingSuccessCount, shardedBindings.size());
// 声明遗留队列和绑定 - 检查存在性
try {
rabbitAdmin.declareQueue(legacyQueue);
rabbitAdmin.declareBinding(legacyBinding);
log.info("遗留队列和绑定就绪: {} (已存在或新创建)", QUEUE_NAME);
} catch (org.springframework.amqp.AmqpIOException e) {
if (e.getMessage().contains("PRECONDITION_FAILED") && e.getMessage().contains("durable")) {
log.warn("遗留队列已存在但 durable 设置不匹配: {}, 保持现有队列", QUEUE_NAME);
} else {
log.error("遗留队列声明失败: {}, 错误: {}", QUEUE_NAME, e.getMessage());
}
} catch (Exception e) {
log.error("遗留队列声明失败: {}, 错误: {}", QUEUE_NAME, e.getMessage());
}
log.info("=== RabbitMQ 组件声明完成 ===");
log.info("请检查 RabbitMQ 管理界面确认队列已正确创建");
} catch (Exception e) {
log.error("RabbitMQ 组件声明过程中发生严重错误", e);
}
};
}
}

View File

@@ -13,23 +13,23 @@ import org.springframework.stereotype.Component;
@Slf4j @Slf4j
public class RedisConnectionLogger implements InitializingBean { public class RedisConnectionLogger implements InitializingBean {
private final RedisConnectionFactory connectionFactory; private final RedisConnectionFactory connectionFactory;
public RedisConnectionLogger(RedisConnectionFactory connectionFactory) { public RedisConnectionLogger(RedisConnectionFactory connectionFactory) {
this.connectionFactory = connectionFactory; this.connectionFactory = connectionFactory;
} }
@Override @Override
public void afterPropertiesSet() { public void afterPropertiesSet() {
try (var connection = connectionFactory.getConnection()) { try (var connection = connectionFactory.getConnection()) {
connection.ping(); connection.ping();
if (connectionFactory instanceof LettuceConnectionFactory lettuce) { if (connectionFactory instanceof LettuceConnectionFactory lettuce) {
log.info("Redis connection established at {}:{}", lettuce.getHostName(), lettuce.getPort()); log.info("Redis connection established at {}:{}", lettuce.getHostName(), lettuce.getPort());
} else { } else {
log.info("Redis connection established"); log.info("Redis connection established");
} }
} catch (Exception e) { } catch (Exception e) {
log.error("Failed to connect to Redis", e); log.error("Failed to connect to Redis", e);
}
} }
}
} }

View File

@@ -2,20 +2,19 @@ package com.openisle.config;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.TaskScheduler;
import org.springframework.scheduling.annotation.EnableScheduling; import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler; import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;
import org.springframework.scheduling.TaskScheduler;
@Configuration @Configuration
@EnableScheduling @EnableScheduling
public class SchedulerConfig { public class SchedulerConfig {
@Bean
@Bean public TaskScheduler taskScheduler() {
public TaskScheduler taskScheduler() { ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler(); scheduler.setPoolSize(2);
scheduler.setPoolSize(2); scheduler.setThreadNamePrefix("lottery-");
scheduler.setThreadNamePrefix("lottery-"); scheduler.initialize();
scheduler.initialize(); return scheduler;
return scheduler; }
}
} }

View File

@@ -1,17 +1,9 @@
package com.openisle.config; package com.openisle.config;
import com.openisle.repository.UserRepository;
import com.openisle.service.JwtService; import com.openisle.service.JwtService;
import com.openisle.service.UserVisitService; import com.openisle.service.UserVisitService;
import jakarta.servlet.FilterChain; import com.openisle.repository.UserRepository;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.time.LocalDate;
import java.util.List;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.core.RedisTemplate;
@@ -30,268 +22,202 @@ import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.access.AccessDeniedHandler; import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.filter.OncePerRequestFilter;
import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource; import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource; import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.filter.OncePerRequestFilter; import org.springframework.beans.factory.annotation.Value;
import java.time.LocalDate;
import java.util.List;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
@Configuration @Configuration
@RequiredArgsConstructor @RequiredArgsConstructor
public class SecurityConfig { public class SecurityConfig {
private final JwtService jwtService;
private final UserRepository userRepository;
private final AccessDeniedHandler customAccessDeniedHandler;
private final UserVisitService userVisitService;
@Value("${app.website-url}")
private String websiteUrl;
private final JwtService jwtService; private final RedisTemplate redisTemplate;
private final UserRepository userRepository;
private final AccessDeniedHandler customAccessDeniedHandler;
private final UserVisitService userVisitService;
@Value("${app.website-url}") @Bean
private String websiteUrl; public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
private final RedisTemplate redisTemplate; @Bean
public UserDetailsService userDetailsService() {
return username -> userRepository.findByUsername(username)
.<UserDetails>map(user -> org.springframework.security.core.userdetails.User
.withUsername(user.getUsername())
.password(user.getPassword())
.authorities(user.getRole().name())
.build())
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
}
@Bean @Bean
public PasswordEncoder passwordEncoder() { public AuthenticationManager authenticationManager(HttpSecurity http, PasswordEncoder passwordEncoder, UserDetailsService userDetailsService) throws Exception {
return new BCryptPasswordEncoder(); return http.getSharedObject(AuthenticationManagerBuilder.class)
} .userDetailsService(userDetailsService)
.passwordEncoder(passwordEncoder)
.and()
.build();
}
@Bean @Bean
public UserDetailsService userDetailsService() { public CorsConfigurationSource corsConfigurationSource() {
return username -> CorsConfiguration cfg = new CorsConfiguration();
userRepository cfg.setAllowedOrigins(List.of(
.findByUsername(username) "http://127.0.0.1:8080",
.<UserDetails>map(user -> "http://127.0.0.1:8081",
org.springframework.security.core.userdetails.User.withUsername(user.getUsername()) "http://127.0.0.1:8082",
.password(user.getPassword()) "http://127.0.0.1:3000",
.authorities(user.getRole().name()) "http://127.0.0.1:3001",
.build() "http://127.0.0.1",
) "http://localhost:8080",
.orElseThrow(() -> new UsernameNotFoundException("User not found")); "http://localhost:8081",
} "http://localhost:8082",
"http://localhost:3000",
"http://localhost:3001",
"http://localhost",
"http://30.211.97.238:3000",
"http://30.211.97.238",
"http://192.168.7.98",
"http://192.168.7.98:3000",
"https://petstore.swagger.io",
// 允许自建OpenAPI地址
"https://docs.open-isle.com",
"https://www.docs.open-isle.com",
websiteUrl,
websiteUrl.replace("://www.", "://")
));
cfg.setAllowedMethods(List.of("GET","POST","PUT","DELETE","OPTIONS"));
cfg.setAllowedHeaders(List.of("*"));
cfg.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", cfg);
return source;
}
@Bean @Bean
public AuthenticationManager authenticationManager( public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
HttpSecurity http, http.csrf(csrf -> csrf.disable())
PasswordEncoder passwordEncoder, .cors(Customizer.withDefaults())
UserDetailsService userDetailsService .headers(h -> h.frameOptions(f -> f.sameOrigin()))
) throws Exception { .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
return http .exceptionHandling(eh -> eh.accessDeniedHandler(customAccessDeniedHandler))
.getSharedObject(AuthenticationManagerBuilder.class) .authorizeHttpRequests(auth -> auth
.userDetailsService(userDetailsService) .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
.passwordEncoder(passwordEncoder) .requestMatchers("/api/ws/**", "/api/sockjs/**").permitAll()
.and() .requestMatchers("/api/v3/api-docs/**").permitAll()
.build(); .requestMatchers(HttpMethod.POST, "/api/auth/**").permitAll()
} .requestMatchers(HttpMethod.GET, "/api/posts/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/comments/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/categories/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/tags/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/config/**").permitAll()
.requestMatchers(HttpMethod.POST,"/api/auth/google").permitAll()
.requestMatchers(HttpMethod.POST,"/api/auth/reason").permitAll()
.requestMatchers(HttpMethod.GET, "/api/search/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/users/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/medals/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/push/public-key").permitAll()
.requestMatchers(HttpMethod.GET, "/api/reaction-types").permitAll()
.requestMatchers(HttpMethod.GET, "/api/activities/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/sitemap.xml").permitAll()
.requestMatchers(HttpMethod.GET, "/api/channels").permitAll()
.requestMatchers(HttpMethod.GET, "/api/rss").permitAll()
.requestMatchers(HttpMethod.GET, "/api/online/**").permitAll()
.requestMatchers(HttpMethod.POST, "/api/online/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/point-goods").permitAll()
.requestMatchers(HttpMethod.POST, "/api/point-goods").permitAll()
.requestMatchers(HttpMethod.POST, "/api/categories/**").hasAuthority("ADMIN")
.requestMatchers(HttpMethod.POST, "/api/tags/**").authenticated()
.requestMatchers(HttpMethod.DELETE, "/api/categories/**").hasAuthority("ADMIN")
.requestMatchers(HttpMethod.DELETE, "/api/tags/**").hasAuthority("ADMIN")
.requestMatchers(HttpMethod.GET, "/api/stats/**").hasAuthority("ADMIN")
.requestMatchers("/api/admin/**").hasAuthority("ADMIN")
.anyRequest().authenticated()
)
.addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class)
.addFilterAfter(userVisitFilter(), UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean @Bean
public CorsConfigurationSource corsConfigurationSource() { public OncePerRequestFilter jwtAuthenticationFilter() {
CorsConfiguration cfg = new CorsConfiguration(); return new OncePerRequestFilter() {
cfg.setAllowedOrigins( @Override
List.of( protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
"http://127.0.0.1:8080", // 让预检请求直接通过
"http://127.0.0.1:8081", if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
"http://127.0.0.1:8082", filterChain.doFilter(request, response);
"http://127.0.0.1:3000", return;
"http://127.0.0.1:3001", }
"http://127.0.0.1", String authHeader = request.getHeader("Authorization");
"http://localhost:8080", String uri = request.getRequestURI();
"http://localhost:8081",
"http://localhost:8082",
"http://localhost:3000",
"http://localhost:3001",
"http://localhost",
"http://30.211.97.238:3000",
"http://30.211.97.238",
"http://192.168.7.90",
"http://192.168.7.90:3000",
"https://petstore.swagger.io",
// 允许自建OpenAPI地址
"https://docs.open-isle.com",
"https://www.docs.open-isle.com",
websiteUrl,
websiteUrl.replace("://www.", "://")
)
);
cfg.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
cfg.setAllowedHeaders(List.of("*"));
cfg.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", cfg);
return source;
}
@Bean boolean publicGet = "GET".equalsIgnoreCase(request.getMethod()) &&
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { (uri.startsWith("/api/posts") || uri.startsWith("/api/comments") ||
http uri.startsWith("/api/categories") || uri.startsWith("/api/tags") ||
.csrf(csrf -> csrf.disable()) uri.startsWith("/api/search") || uri.startsWith("/api/users") ||
.cors(Customizer.withDefaults()) uri.startsWith("/api/reaction-types") || uri.startsWith("/api/config") ||
.headers(h -> h.frameOptions(f -> f.sameOrigin())) uri.startsWith("/api/activities") || uri.startsWith("/api/push/public-key") ||
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) uri.startsWith("/api/point-goods") || uri.startsWith("/api/channels") ||
.exceptionHandling(eh -> eh.accessDeniedHandler(customAccessDeniedHandler)) uri.startsWith("/api/sitemap.xml") || uri.startsWith("/api/medals") ||
.authorizeHttpRequests(auth -> uri.startsWith("/api/rss"));
auth
.requestMatchers(HttpMethod.OPTIONS, "/**")
.permitAll()
.requestMatchers("/api/ws/**", "/api/sockjs/**")
.permitAll()
.requestMatchers("/api/v3/api-docs/**")
.permitAll()
.requestMatchers(HttpMethod.POST, "/api/auth/**")
.permitAll()
.requestMatchers(HttpMethod.GET, "/api/posts/**")
.permitAll()
.requestMatchers(HttpMethod.GET, "/api/comments/**")
.permitAll()
.requestMatchers(HttpMethod.GET, "/api/categories/**")
.permitAll()
.requestMatchers(HttpMethod.GET, "/api/tags/**")
.permitAll()
.requestMatchers(HttpMethod.GET, "/api/config/**")
.permitAll()
.requestMatchers(HttpMethod.POST, "/api/auth/google")
.permitAll()
.requestMatchers(HttpMethod.POST, "/api/auth/reason")
.permitAll()
.requestMatchers(HttpMethod.GET, "/api/search/**")
.permitAll()
.requestMatchers(HttpMethod.GET, "/api/users/**")
.permitAll()
.requestMatchers(HttpMethod.GET, "/api/medals/**")
.permitAll()
.requestMatchers(HttpMethod.GET, "/api/push/public-key")
.permitAll()
.requestMatchers(HttpMethod.GET, "/api/reaction-types")
.permitAll()
.requestMatchers(HttpMethod.GET, "/api/activities/**")
.permitAll()
.requestMatchers(HttpMethod.GET, "/api/sitemap.xml")
.permitAll()
.requestMatchers(HttpMethod.GET, "/api/channels")
.permitAll()
.requestMatchers(HttpMethod.GET, "/api/rss")
.permitAll()
.requestMatchers(HttpMethod.GET, "/api/online/**")
.permitAll()
.requestMatchers(HttpMethod.POST, "/api/online/**")
.permitAll()
.requestMatchers(HttpMethod.GET, "/api/point-goods")
.permitAll()
.requestMatchers(HttpMethod.POST, "/api/point-goods")
.permitAll()
.requestMatchers(HttpMethod.POST, "/api/categories/**")
.hasAuthority("ADMIN")
.requestMatchers(HttpMethod.POST, "/api/tags/**")
.authenticated()
.requestMatchers(HttpMethod.DELETE, "/api/categories/**")
.hasAuthority("ADMIN")
.requestMatchers(HttpMethod.DELETE, "/api/tags/**")
.hasAuthority("ADMIN")
.requestMatchers(HttpMethod.GET, "/api/stats/**")
.hasAuthority("ADMIN")
.requestMatchers("/api/admin/**")
.hasAuthority("ADMIN")
.anyRequest()
.authenticated()
)
.addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class)
.addFilterAfter(userVisitFilter(), UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean if (authHeader != null && authHeader.startsWith("Bearer ")) {
public OncePerRequestFilter jwtAuthenticationFilter() { String token = authHeader.substring(7);
return new OncePerRequestFilter() { try {
@Override String username = jwtService.validateAndGetSubject(token);
protected void doFilterInternal( UserDetails userDetails = userDetailsService().loadUserByUsername(username);
HttpServletRequest request, UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
HttpServletResponse response, userDetails, null, userDetails.getAuthorities());
FilterChain filterChain org.springframework.security.core.context.SecurityContextHolder.getContext().setAuthentication(authToken);
) throws ServletException, IOException { } catch (Exception e) {
// 让预检请求直接通过 response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
if ("OPTIONS".equalsIgnoreCase(request.getMethod())) { response.setContentType("application/json");
filterChain.doFilter(request, response); response.getWriter().write("{\"error\": \"Invalid or expired token\"}");
return; return;
} }
String authHeader = request.getHeader("Authorization"); } else if (!uri.startsWith("/api/auth") && !publicGet
String uri = request.getRequestURI(); && !uri.startsWith("/api/ws") && !uri.startsWith("/api/sockjs")
&& !uri.startsWith("/api/v3/api-docs")
&& !uri.startsWith("/api/online")) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json");
response.getWriter().write("{\"error\": \"Missing token\"}");
return;
}
boolean publicGet = filterChain.doFilter(request, response);
"GET".equalsIgnoreCase(request.getMethod()) && }
(uri.startsWith("/api/posts") || };
uri.startsWith("/api/comments") || }
uri.startsWith("/api/categories") ||
uri.startsWith("/api/tags") ||
uri.startsWith("/api/search") ||
uri.startsWith("/api/users") ||
uri.startsWith("/api/reaction-types") ||
uri.startsWith("/api/config") ||
uri.startsWith("/api/activities") ||
uri.startsWith("/api/push/public-key") ||
uri.startsWith("/api/point-goods") ||
uri.startsWith("/api/channels") ||
uri.startsWith("/api/sitemap.xml") ||
uri.startsWith("/api/medals") ||
uri.startsWith("/api/rss"));
if (authHeader != null && authHeader.startsWith("Bearer ")) { @Bean
String token = authHeader.substring(7); public OncePerRequestFilter userVisitFilter() {
try { return new OncePerRequestFilter() {
String username = jwtService.validateAndGetSubject(token); @Override
UserDetails userDetails = userDetailsService().loadUserByUsername(username); protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken( var auth = org.springframework.security.core.context.SecurityContextHolder.getContext().getAuthentication();
userDetails, if (auth != null && auth.isAuthenticated() && !(auth instanceof org.springframework.security.authentication.AnonymousAuthenticationToken)) {
null, String key = CachingConfig.VISIT_CACHE_NAME+":"+ LocalDate.now();
userDetails.getAuthorities() redisTemplate.opsForSet().add(key, auth.getName());
); }
org.springframework.security.core.context.SecurityContextHolder.getContext().setAuthentication( filterChain.doFilter(request, response);
authToken }
); };
} catch (Exception e) { }
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json");
response.getWriter().write("{\"error\": \"Invalid or expired token\"}");
return;
}
} else if (
!uri.startsWith("/api/auth") &&
!publicGet &&
!uri.startsWith("/api/ws") &&
!uri.startsWith("/api/sockjs") &&
!uri.startsWith("/api/v3/api-docs") &&
!uri.startsWith("/api/online")
) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json");
response.getWriter().write("{\"error\": \"Missing token\"}");
return;
}
filterChain.doFilter(request, response);
}
};
}
@Bean
public OncePerRequestFilter userVisitFilter() {
return new OncePerRequestFilter() {
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain
) throws ServletException, IOException {
var auth =
org.springframework.security.core.context.SecurityContextHolder.getContext().getAuthentication();
if (
auth != null &&
auth.isAuthenticated() &&
!(auth instanceof
org.springframework.security.authentication.AnonymousAuthenticationToken)
) {
String key = CachingConfig.VISIT_CACHE_NAME + ":" + LocalDate.now();
redisTemplate.opsForSet().add(key, auth.getName());
}
filterChain.doFilter(request, response);
}
};
}
} }

View File

@@ -8,8 +8,7 @@ import lombok.NoArgsConstructor;
@AllArgsConstructor @AllArgsConstructor
@NoArgsConstructor @NoArgsConstructor
public class ShardInfo { public class ShardInfo {
private int shardIndex;
private int shardIndex; private String queueName;
private String queueName; private String routingKey;
private String routingKey; }
}

View File

@@ -1,87 +1,84 @@
package com.openisle.config; package com.openisle.config;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
import java.util.stream.Collectors;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
import java.util.Map;
import java.util.stream.Collectors;
@Component @Component
@Slf4j @Slf4j
public class ShardingStrategy { public class ShardingStrategy {
// 固定为16以匹配RabbitMQConfig中的十六进制分片逻辑 // 固定为16以匹配RabbitMQConfig中的十六进制分片逻辑
private static final int QUEUE_COUNT = 16; private static final int QUEUE_COUNT = 16;
// 分片分布统计 // 分片分布统计
private final Map<Integer, AtomicLong> shardCounts = new ConcurrentHashMap<>(); private final Map<Integer, AtomicLong> shardCounts = new ConcurrentHashMap<>();
/** /**
* 根据用户名获取分片信息(基于哈希值首字符) * 根据用户名获取分片信息(基于哈希值首字符)
*/ */
public ShardInfo getShardInfo(String username) { public ShardInfo getShardInfo(String username) {
if (username == null || username.isEmpty()) { if (username == null || username.isEmpty()) {
// 空用户名默认分到第0个分片 // 空用户名默认分到第0个分片
return getShardInfoByIndex(0); return getShardInfoByIndex(0);
}
// 计算用户名的哈希值并转为十六进制字符串
String hash = Integer.toHexString(Math.abs(username.hashCode()));
// 取哈希值的第一个字符 (0-9, a-f)
char firstChar = hash.charAt(0);
// 十六进制字符映射到队列
int shard = getShardFromHexChar(firstChar);
recordShardUsage(shard);
log.debug("Username '{}' -> hash '{}' -> firstChar '{}' -> shard {}",
username, hash, firstChar, shard);
return getShardInfoByIndex(shard);
}
/**
* 将十六进制字符映射到分片索引
*/
private int getShardFromHexChar(char hexChar) {
int charValue;
if (hexChar >= '0' && hexChar <= '9') {
charValue = hexChar - '0'; // 0-9
} else if (hexChar >= 'a' && hexChar <= 'f') {
charValue = hexChar - 'a' + 10; // 10-15
} else {
// 异常情况默认为0
charValue = 0;
}
// 映射到队列数量范围内
return charValue % QUEUE_COUNT;
}
/**
* 根据分片索引获取分片信息
*/
private ShardInfo getShardInfoByIndex(int shard) {
String shardKey = Integer.toHexString(shard);
return new ShardInfo(
shard,
"notifications-queue-" + shardKey,
"notifications.shard." + shardKey
);
}
/**
* 记录分片使用统计
*/
private void recordShardUsage(int shard) {
shardCounts.computeIfAbsent(shard, k -> new AtomicLong(0)).incrementAndGet();
} }
// 计算用户名的哈希值并转为十六进制字符串 }
String hash = Integer.toHexString(Math.abs(username.hashCode()));
// 取哈希值的第一个字符 (0-9, a-f)
char firstChar = hash.charAt(0);
// 十六进制字符映射到队列
int shard = getShardFromHexChar(firstChar);
recordShardUsage(shard);
log.debug(
"Username '{}' -> hash '{}' -> firstChar '{}' -> shard {}",
username,
hash,
firstChar,
shard
);
return getShardInfoByIndex(shard);
}
/**
* 将十六进制字符映射到分片索引
*/
private int getShardFromHexChar(char hexChar) {
int charValue;
if (hexChar >= '0' && hexChar <= '9') {
charValue = hexChar - '0'; // 0-9
} else if (hexChar >= 'a' && hexChar <= 'f') {
charValue = hexChar - 'a' + 10; // 10-15
} else {
// 异常情况默认为0
charValue = 0;
}
// 映射到队列数量范围内
return charValue % QUEUE_COUNT;
}
/**
* 根据分片索引获取分片信息
*/
private ShardInfo getShardInfoByIndex(int shard) {
String shardKey = Integer.toHexString(shard);
return new ShardInfo(
shard,
"notifications-queue-" + shardKey,
"notifications.shard." + shardKey
);
}
/**
* 记录分片使用统计
*/
private void recordShardUsage(int shard) {
shardCounts.computeIfAbsent(shard, k -> new AtomicLong(0)).incrementAndGet();
}
}

View File

@@ -10,13 +10,11 @@ import org.springframework.stereotype.Component;
@Component @Component
@ConfigurationProperties(prefix = "springdoc.api-docs") @ConfigurationProperties(prefix = "springdoc.api-docs")
public class SpringDocProperties { public class SpringDocProperties {
private List<ServerConfig> servers = new ArrayList<>();
private List<ServerConfig> servers = new ArrayList<>(); @Data
public static class ServerConfig {
@Data private String url;
public static class ServerConfig { private String description;
}
private String url;
private String description;
}
} }

View File

@@ -14,27 +14,23 @@ import org.springframework.stereotype.Component;
@Component @Component
@RequiredArgsConstructor @RequiredArgsConstructor
public class SystemUserInitializer implements CommandLineRunner { public class SystemUserInitializer implements CommandLineRunner {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
private final UserRepository userRepository; @Override
private final PasswordEncoder passwordEncoder; public void run(String... args) {
userRepository.findByUsername("system").orElseGet(() -> {
@Override User system = new User();
public void run(String... args) { system.setUsername("system");
userRepository system.setEmail("system@openisle.local");
.findByUsername("system") // todo(tim): raw password 采用环境变量
.orElseGet(() -> { system.setPassword(passwordEncoder.encode("system"));
User system = new User(); system.setRole(Role.USER);
system.setUsername("system"); system.setVerified(true);
system.setEmail("system@openisle.local"); system.setApproved(true);
// todo(tim): raw password 采用环境变量 system.setAvatar("https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/image.png");
system.setPassword(passwordEncoder.encode("system")); return userRepository.save(system);
system.setRole(Role.USER); });
system.setVerified(true); }
system.setApproved(true);
system.setAvatar(
"https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/image.png"
);
return userRepository.save(system);
});
}
} }

View File

@@ -9,75 +9,65 @@ import com.openisle.model.ActivityType;
import com.openisle.model.User; import com.openisle.model.User;
import com.openisle.service.ActivityService; import com.openisle.service.ActivityService;
import com.openisle.service.UserService; import com.openisle.service.UserService;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.ArraySchema; import io.swagger.v3.oas.annotations.media.ArraySchema;
import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import java.util.List; import java.util.List;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
@RestController @RestController
@RequestMapping("/api/activities") @RequestMapping("/api/activities")
@RequiredArgsConstructor @RequiredArgsConstructor
public class ActivityController { public class ActivityController {
private final ActivityService activityService;
private final UserService userService;
private final ActivityMapper activityMapper;
private final ActivityService activityService; @GetMapping
private final UserService userService; @Operation(summary = "List activities", description = "Retrieve all activities")
private final ActivityMapper activityMapper; @ApiResponse(responseCode = "200", description = "List of activities",
content = @Content(array = @ArraySchema(schema = @Schema(implementation = ActivityDto.class))))
@GetMapping public List<ActivityDto> list() {
@Operation(summary = "List activities", description = "Retrieve all activities") return activityService.list().stream()
@ApiResponse( .map(activityMapper::toDto)
responseCode = "200", .collect(Collectors.toList());
description = "List of activities",
content = @Content(array = @ArraySchema(schema = @Schema(implementation = ActivityDto.class)))
)
public List<ActivityDto> list() {
return activityService.list().stream().map(activityMapper::toDto).collect(Collectors.toList());
}
@GetMapping("/milk-tea")
@Operation(summary = "Milk tea info", description = "Get milk tea activity information")
@ApiResponse(
responseCode = "200",
description = "Milk tea info",
content = @Content(schema = @Schema(implementation = MilkTeaInfoDto.class))
)
public MilkTeaInfoDto milkTea() {
Activity a = activityService.getByType(ActivityType.MILK_TEA);
long count = activityService.countParticipants(a);
if (!a.isEnded() && count >= 50) {
activityService.end(a);
} }
MilkTeaInfoDto info = new MilkTeaInfoDto();
info.setRedeemCount(count);
info.setEnded(a.isEnded());
return info;
}
@PostMapping("/milk-tea/redeem") @GetMapping("/milk-tea")
@Operation(summary = "Redeem milk tea", description = "Redeem milk tea activity reward") @Operation(summary = "Milk tea info", description = "Get milk tea activity information")
@ApiResponse( @ApiResponse(responseCode = "200", description = "Milk tea info",
responseCode = "200", content = @Content(schema = @Schema(implementation = MilkTeaInfoDto.class)))
description = "Redeem result", public MilkTeaInfoDto milkTea() {
content = @Content(schema = @Schema(implementation = java.util.Map.class)) Activity a = activityService.getByType(ActivityType.MILK_TEA);
) long count = activityService.countParticipants(a);
@SecurityRequirement(name = "JWT") if (!a.isEnded() && count >= 50) {
public java.util.Map<String, String> redeemMilkTea( activityService.end(a);
@RequestBody MilkTeaRedeemRequest req, }
Authentication auth MilkTeaInfoDto info = new MilkTeaInfoDto();
) { info.setRedeemCount(count);
User user = userService.findByIdentifier(auth.getName()).orElseThrow(); info.setEnded(a.isEnded());
Activity a = activityService.getByType(ActivityType.MILK_TEA); return info;
boolean first = activityService.redeem(a, user, req.getContact()); }
if (first) {
return java.util.Map.of("message", "redeemed"); @PostMapping("/milk-tea/redeem")
@Operation(summary = "Redeem milk tea", description = "Redeem milk tea activity reward")
@ApiResponse(responseCode = "200", description = "Redeem result",
content = @Content(schema = @Schema(implementation = java.util.Map.class)))
@SecurityRequirement(name = "JWT")
public java.util.Map<String, String> redeemMilkTea(@RequestBody MilkTeaRedeemRequest req, Authentication auth) {
User user = userService.findByIdentifier(auth.getName()).orElseThrow();
Activity a = activityService.getByType(ActivityType.MILK_TEA);
boolean first = activityService.redeem(a, user, req.getContact());
if (first) {
return java.util.Map.of("message", "redeemed");
}
return java.util.Map.of("message", "updated");
} }
return java.util.Map.of("message", "updated");
}
} }

View File

@@ -19,31 +19,24 @@ import org.springframework.web.bind.annotation.*;
@RequestMapping("/api/admin/comments") @RequestMapping("/api/admin/comments")
@RequiredArgsConstructor @RequiredArgsConstructor
public class AdminCommentController { public class AdminCommentController {
private final CommentService commentService;
private final CommentMapper commentMapper;
private final CommentService commentService; @PostMapping("/{id}/pin")
private final CommentMapper commentMapper; @SecurityRequirement(name = "JWT")
@Operation(summary = "Pin comment", description = "Pin a comment by its id")
@ApiResponse(responseCode = "200", description = "Pinned comment",
content = @Content(schema = @Schema(implementation = CommentDto.class)))
public CommentDto pin(@PathVariable Long id, Authentication auth) {
return commentMapper.toDto(commentService.pinComment(auth.getName(), id));
}
@PostMapping("/{id}/pin") @PostMapping("/{id}/unpin")
@SecurityRequirement(name = "JWT") @SecurityRequirement(name = "JWT")
@Operation(summary = "Pin comment", description = "Pin a comment by its id") @Operation(summary = "Unpin comment", description = "Remove pin from a comment")
@ApiResponse( @ApiResponse(responseCode = "200", description = "Unpinned comment",
responseCode = "200", content = @Content(schema = @Schema(implementation = CommentDto.class)))
description = "Pinned comment", public CommentDto unpin(@PathVariable Long id, Authentication auth) {
content = @Content(schema = @Schema(implementation = CommentDto.class)) return commentMapper.toDto(commentService.unpinComment(auth.getName(), id));
) }
public CommentDto pin(@PathVariable Long id, Authentication auth) {
return commentMapper.toDto(commentService.pinComment(auth.getName(), id));
}
@PostMapping("/{id}/unpin")
@SecurityRequirement(name = "JWT")
@Operation(summary = "Unpin comment", description = "Remove pin from a comment")
@ApiResponse(
responseCode = "200",
description = "Unpinned comment",
content = @Content(schema = @Schema(implementation = CommentDto.class))
)
public CommentDto unpin(@PathVariable Long id, Authentication auth) {
return commentMapper.toDto(commentService.unpinComment(auth.getName(), id));
}
} }

View File

@@ -17,56 +17,44 @@ import org.springframework.web.bind.annotation.*;
@RequestMapping("/api/admin/config") @RequestMapping("/api/admin/config")
@RequiredArgsConstructor @RequiredArgsConstructor
public class AdminConfigController { public class AdminConfigController {
private final PostService postService;
private final PasswordValidator passwordValidator;
private final AiUsageService aiUsageService;
private final RegisterModeService registerModeService;
private final PostService postService; @GetMapping
private final PasswordValidator passwordValidator; @SecurityRequirement(name = "JWT")
private final AiUsageService aiUsageService; @Operation(summary = "Get configuration", description = "Retrieve application configuration settings")
private final RegisterModeService registerModeService; @ApiResponse(responseCode = "200", description = "Current configuration",
content = @Content(schema = @Schema(implementation = ConfigDto.class)))
public ConfigDto getConfig() {
ConfigDto dto = new ConfigDto();
dto.setPublishMode(postService.getPublishMode());
dto.setPasswordStrength(passwordValidator.getStrength());
dto.setAiFormatLimit(aiUsageService.getFormatLimit());
dto.setRegisterMode(registerModeService.getRegisterMode());
return dto;
}
@GetMapping @PostMapping
@SecurityRequirement(name = "JWT") @SecurityRequirement(name = "JWT")
@Operation( @Operation(summary = "Update configuration", description = "Update application configuration settings")
summary = "Get configuration", @ApiResponse(responseCode = "200", description = "Updated configuration",
description = "Retrieve application configuration settings" content = @Content(schema = @Schema(implementation = ConfigDto.class)))
) public ConfigDto updateConfig(@RequestBody ConfigDto dto) {
@ApiResponse( if (dto.getPublishMode() != null) {
responseCode = "200", postService.setPublishMode(dto.getPublishMode());
description = "Current configuration", }
content = @Content(schema = @Schema(implementation = ConfigDto.class)) if (dto.getPasswordStrength() != null) {
) passwordValidator.setStrength(dto.getPasswordStrength());
public ConfigDto getConfig() { }
ConfigDto dto = new ConfigDto(); if (dto.getAiFormatLimit() != null) {
dto.setPublishMode(postService.getPublishMode()); aiUsageService.setFormatLimit(dto.getAiFormatLimit());
dto.setPasswordStrength(passwordValidator.getStrength()); }
dto.setAiFormatLimit(aiUsageService.getFormatLimit()); if (dto.getRegisterMode() != null) {
dto.setRegisterMode(registerModeService.getRegisterMode()); registerModeService.setRegisterMode(dto.getRegisterMode());
return dto; }
} return getConfig();
}
@PostMapping
@SecurityRequirement(name = "JWT")
@Operation(
summary = "Update configuration",
description = "Update application configuration settings"
)
@ApiResponse(
responseCode = "200",
description = "Updated configuration",
content = @Content(schema = @Schema(implementation = ConfigDto.class))
)
public ConfigDto updateConfig(@RequestBody ConfigDto dto) {
if (dto.getPublishMode() != null) {
postService.setPublishMode(dto.getPublishMode());
}
if (dto.getPasswordStrength() != null) {
passwordValidator.setStrength(dto.getPasswordStrength());
}
if (dto.getAiFormatLimit() != null) {
aiUsageService.setFormatLimit(dto.getAiFormatLimit());
}
if (dto.getRegisterMode() != null) {
registerModeService.setRegisterMode(dto.getRegisterMode());
}
return getConfig();
}
} }

View File

@@ -5,25 +5,21 @@ import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import java.util.Map;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
/** /**
* Simple admin demo endpoint. * Simple admin demo endpoint.
*/ */
@RestController @RestController
public class AdminController { public class AdminController {
@GetMapping("/api/admin/hello")
@GetMapping("/api/admin/hello") @SecurityRequirement(name = "JWT")
@SecurityRequirement(name = "JWT") @Operation(summary = "Admin greeting", description = "Returns a greeting for admin users")
@Operation(summary = "Admin greeting", description = "Returns a greeting for admin users") @ApiResponse(responseCode = "200", description = "Greeting payload",
@ApiResponse( content = @Content(schema = @Schema(implementation = Map.class)))
responseCode = "200", public Map<String, String> adminHello() {
description = "Greeting payload", return Map.of("message", "Hello, Admin User");
content = @Content(schema = @Schema(implementation = Map.class)) }
)
public Map<String, String> adminHello() {
return Map.of("message", "Hello, Admin User");
}
} }

View File

@@ -9,11 +9,12 @@ import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import java.util.List;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.stream.Collectors;
/** /**
* Endpoints for administrators to manage posts. * Endpoints for administrators to manage posts.
*/ */
@@ -21,109 +22,71 @@ import org.springframework.web.bind.annotation.*;
@RequestMapping("/api/admin/posts") @RequestMapping("/api/admin/posts")
@RequiredArgsConstructor @RequiredArgsConstructor
public class AdminPostController { public class AdminPostController {
private final PostService postService;
private final PostMapper postMapper;
private final PostService postService; @GetMapping("/pending")
private final PostMapper postMapper; @SecurityRequirement(name = "JWT")
@Operation(summary = "List pending posts", description = "Retrieve posts awaiting approval")
@ApiResponse(responseCode = "200", description = "Pending posts",
content = @Content(array = @ArraySchema(schema = @Schema(implementation = PostSummaryDto.class))))
public List<PostSummaryDto> pendingPosts() {
return postService.listPendingPosts().stream()
.map(postMapper::toSummaryDto)
.collect(Collectors.toList());
}
@GetMapping("/pending") @PostMapping("/{id}/approve")
@SecurityRequirement(name = "JWT") @SecurityRequirement(name = "JWT")
@Operation(summary = "List pending posts", description = "Retrieve posts awaiting approval") @Operation(summary = "Approve post", description = "Approve a pending post")
@ApiResponse( @ApiResponse(responseCode = "200", description = "Approved post",
responseCode = "200", content = @Content(schema = @Schema(implementation = PostSummaryDto.class)))
description = "Pending posts", public PostSummaryDto approve(@PathVariable Long id) {
content = @Content( return postMapper.toSummaryDto(postService.approvePost(id));
array = @ArraySchema(schema = @Schema(implementation = PostSummaryDto.class)) }
)
)
public List<PostSummaryDto> pendingPosts() {
return postService
.listPendingPosts()
.stream()
.map(postMapper::toSummaryDto)
.collect(Collectors.toList());
}
@PostMapping("/{id}/approve") @PostMapping("/{id}/reject")
@SecurityRequirement(name = "JWT") @SecurityRequirement(name = "JWT")
@Operation(summary = "Approve post", description = "Approve a pending post") @Operation(summary = "Reject post", description = "Reject a pending post")
@ApiResponse( @ApiResponse(responseCode = "200", description = "Rejected post",
responseCode = "200", content = @Content(schema = @Schema(implementation = PostSummaryDto.class)))
description = "Approved post", public PostSummaryDto reject(@PathVariable Long id) {
content = @Content(schema = @Schema(implementation = PostSummaryDto.class)) return postMapper.toSummaryDto(postService.rejectPost(id));
) }
public PostSummaryDto approve(@PathVariable Long id) {
return postMapper.toSummaryDto(postService.approvePost(id));
}
@PostMapping("/{id}/reject") @PostMapping("/{id}/pin")
@SecurityRequirement(name = "JWT") @SecurityRequirement(name = "JWT")
@Operation(summary = "Reject post", description = "Reject a pending post") @Operation(summary = "Pin post", description = "Pin a post to the top")
@ApiResponse( @ApiResponse(responseCode = "200", description = "Pinned post",
responseCode = "200", content = @Content(schema = @Schema(implementation = PostSummaryDto.class)))
description = "Rejected post", public PostSummaryDto pin(@PathVariable Long id, org.springframework.security.core.Authentication auth) {
content = @Content(schema = @Schema(implementation = PostSummaryDto.class)) return postMapper.toSummaryDto(postService.pinPost(id, auth.getName()));
) }
public PostSummaryDto reject(@PathVariable Long id) {
return postMapper.toSummaryDto(postService.rejectPost(id));
}
@PostMapping("/{id}/pin") @PostMapping("/{id}/unpin")
@SecurityRequirement(name = "JWT") @SecurityRequirement(name = "JWT")
@Operation(summary = "Pin post", description = "Pin a post to the top") @Operation(summary = "Unpin post", description = "Remove a post from the top")
@ApiResponse( @ApiResponse(responseCode = "200", description = "Unpinned post",
responseCode = "200", content = @Content(schema = @Schema(implementation = PostSummaryDto.class)))
description = "Pinned post", public PostSummaryDto unpin(@PathVariable Long id, org.springframework.security.core.Authentication auth) {
content = @Content(schema = @Schema(implementation = PostSummaryDto.class)) return postMapper.toSummaryDto(postService.unpinPost(id, auth.getName()));
) }
public PostSummaryDto pin(
@PathVariable Long id,
org.springframework.security.core.Authentication auth
) {
return postMapper.toSummaryDto(postService.pinPost(id, auth.getName()));
}
@PostMapping("/{id}/unpin") @PostMapping("/{id}/rss-exclude")
@SecurityRequirement(name = "JWT") @SecurityRequirement(name = "JWT")
@Operation(summary = "Unpin post", description = "Remove a post from the top") @Operation(summary = "Exclude from RSS", description = "Exclude a post from RSS feed")
@ApiResponse( @ApiResponse(responseCode = "200", description = "Updated post",
responseCode = "200", content = @Content(schema = @Schema(implementation = PostSummaryDto.class)))
description = "Unpinned post", public PostSummaryDto excludeFromRss(@PathVariable Long id, org.springframework.security.core.Authentication auth) {
content = @Content(schema = @Schema(implementation = PostSummaryDto.class)) return postMapper.toSummaryDto(postService.excludeFromRss(id, auth.getName()));
) }
public PostSummaryDto unpin(
@PathVariable Long id,
org.springframework.security.core.Authentication auth
) {
return postMapper.toSummaryDto(postService.unpinPost(id, auth.getName()));
}
@PostMapping("/{id}/rss-exclude") @PostMapping("/{id}/rss-include")
@SecurityRequirement(name = "JWT") @SecurityRequirement(name = "JWT")
@Operation(summary = "Exclude from RSS", description = "Exclude a post from RSS feed") @Operation(summary = "Include in RSS", description = "Include a post in the RSS feed")
@ApiResponse( @ApiResponse(responseCode = "200", description = "Updated post",
responseCode = "200", content = @Content(schema = @Schema(implementation = PostSummaryDto.class)))
description = "Updated post", public PostSummaryDto includeInRss(@PathVariable Long id, org.springframework.security.core.Authentication auth) {
content = @Content(schema = @Schema(implementation = PostSummaryDto.class)) return postMapper.toSummaryDto(postService.includeInRss(id, auth.getName()));
) }
public PostSummaryDto excludeFromRss(
@PathVariable Long id,
org.springframework.security.core.Authentication auth
) {
return postMapper.toSummaryDto(postService.excludeFromRss(id, auth.getName()));
}
@PostMapping("/{id}/rss-include")
@SecurityRequirement(name = "JWT")
@Operation(summary = "Include in RSS", description = "Include a post in the RSS feed")
@ApiResponse(
responseCode = "200",
description = "Updated post",
content = @Content(schema = @Schema(implementation = PostSummaryDto.class))
)
public PostSummaryDto includeInRss(
@PathVariable Long id,
org.springframework.security.core.Authentication auth
) {
return postMapper.toSummaryDto(postService.includeInRss(id, auth.getName()));
}
} }

View File

@@ -11,47 +11,39 @@ import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import java.util.List;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.stream.Collectors;
@RestController @RestController
@RequestMapping("/api/admin/tags") @RequestMapping("/api/admin/tags")
@RequiredArgsConstructor @RequiredArgsConstructor
public class AdminTagController { public class AdminTagController {
private final TagService tagService;
private final PostService postService;
private final TagMapper tagMapper;
private final TagService tagService; @GetMapping("/pending")
private final PostService postService; @SecurityRequirement(name = "JWT")
private final TagMapper tagMapper; @Operation(summary = "List pending tags", description = "Retrieve tags awaiting approval")
@ApiResponse(responseCode = "200", description = "Pending tags",
content = @Content(array = @ArraySchema(schema = @Schema(implementation = TagDto.class))))
public List<TagDto> pendingTags() {
return tagService.listPendingTags().stream()
.map(t -> tagMapper.toDto(t, postService.countPostsByTag(t.getId())))
.collect(Collectors.toList());
}
@GetMapping("/pending") @PostMapping("/{id}/approve")
@SecurityRequirement(name = "JWT") @SecurityRequirement(name = "JWT")
@Operation(summary = "List pending tags", description = "Retrieve tags awaiting approval") @Operation(summary = "Approve tag", description = "Approve a pending tag")
@ApiResponse( @ApiResponse(responseCode = "200", description = "Approved tag",
responseCode = "200", content = @Content(schema = @Schema(implementation = TagDto.class)))
description = "Pending tags", public TagDto approve(@PathVariable Long id) {
content = @Content(array = @ArraySchema(schema = @Schema(implementation = TagDto.class))) Tag tag = tagService.approveTag(id);
) long count = postService.countPostsByTag(tag.getId());
public List<TagDto> pendingTags() { return tagMapper.toDto(tag, count);
return tagService }
.listPendingTags()
.stream()
.map(t -> tagMapper.toDto(t, postService.countPostsByTag(t.getId())))
.collect(Collectors.toList());
}
@PostMapping("/{id}/approve")
@SecurityRequirement(name = "JWT")
@Operation(summary = "Approve tag", description = "Approve a pending tag")
@ApiResponse(
responseCode = "200",
description = "Approved tag",
content = @Content(schema = @Schema(implementation = TagDto.class))
)
public TagDto approve(@PathVariable Long id) {
Tag tag = tagService.approveTag(id);
long count = postService.countPostsByTag(tag.getId());
return tagMapper.toDto(tag, count);
}
} }

View File

@@ -3,9 +3,9 @@ package com.openisle.controller;
import com.openisle.model.Notification; import com.openisle.model.Notification;
import com.openisle.model.NotificationType; import com.openisle.model.NotificationType;
import com.openisle.model.User; import com.openisle.model.User;
import com.openisle.service.EmailSender;
import com.openisle.repository.NotificationRepository; import com.openisle.repository.NotificationRepository;
import com.openisle.repository.UserRepository; import com.openisle.repository.UserRepository;
import com.openisle.service.EmailSender;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.security.SecurityRequirement;
@@ -18,56 +18,46 @@ import org.springframework.web.bind.annotation.*;
@RequestMapping("/api/admin/users") @RequestMapping("/api/admin/users")
@RequiredArgsConstructor @RequiredArgsConstructor
public class AdminUserController { public class AdminUserController {
private final UserRepository userRepository;
private final NotificationRepository notificationRepository;
private final EmailSender emailSender;
@Value("${app.website-url}")
private String websiteUrl;
private final UserRepository userRepository; @PostMapping("/{id}/approve")
private final NotificationRepository notificationRepository; @SecurityRequirement(name = "JWT")
private final EmailSender emailSender; @Operation(summary = "Approve user", description = "Approve a pending user registration")
@ApiResponse(responseCode = "200", description = "User approved")
@Value("${app.website-url}") public ResponseEntity<?> approve(@PathVariable Long id) {
private String websiteUrl; User user = userRepository.findById(id).orElseThrow();
user.setApproved(true);
@PostMapping("/{id}/approve") userRepository.save(user);
@SecurityRequirement(name = "JWT") markRegisterRequestNotificationsRead(user);
@Operation(summary = "Approve user", description = "Approve a pending user registration") emailSender.sendEmail(user.getEmail(), "您的注册已审核通过",
@ApiResponse(responseCode = "200", description = "User approved") "🎉您的注册已经审核通过, 点击以访问网站: " + websiteUrl);
public ResponseEntity<?> approve(@PathVariable Long id) { return ResponseEntity.ok().build();
User user = userRepository.findById(id).orElseThrow(); }
user.setApproved(true);
userRepository.save(user); @PostMapping("/{id}/reject")
markRegisterRequestNotificationsRead(user); @SecurityRequirement(name = "JWT")
emailSender.sendEmail( @Operation(summary = "Reject user", description = "Reject a pending user registration")
user.getEmail(), @ApiResponse(responseCode = "200", description = "User rejected")
"您的注册已审核通过", public ResponseEntity<?> reject(@PathVariable Long id) {
"🎉您的注册已经审核通过, 点击以访问网站: " + websiteUrl User user = userRepository.findById(id).orElseThrow();
); user.setApproved(false);
return ResponseEntity.ok().build(); userRepository.save(user);
} markRegisterRequestNotificationsRead(user);
emailSender.sendEmail(user.getEmail(), "您的注册已被管理员拒绝",
@PostMapping("/{id}/reject") "您的注册被管理员拒绝, 点击链接可以重新填写理由申请: " + websiteUrl);
@SecurityRequirement(name = "JWT") return ResponseEntity.ok().build();
@Operation(summary = "Reject user", description = "Reject a pending user registration") }
@ApiResponse(responseCode = "200", description = "User rejected")
public ResponseEntity<?> reject(@PathVariable Long id) { private void markRegisterRequestNotificationsRead(User applicant) {
User user = userRepository.findById(id).orElseThrow(); java.util.List<Notification> notifs =
user.setApproved(false); notificationRepository.findByTypeAndFromUser(NotificationType.REGISTER_REQUEST, applicant);
userRepository.save(user); for (Notification n : notifs) {
markRegisterRequestNotificationsRead(user); n.setRead(true);
emailSender.sendEmail( }
user.getEmail(), notificationRepository.saveAll(notifs);
"您的注册已被管理员拒绝",
"您的注册被管理员拒绝, 点击链接可以重新填写理由申请: " + websiteUrl
);
return ResponseEntity.ok().build();
}
private void markRegisterRequestNotificationsRead(User applicant) {
java.util.List<Notification> notifs = notificationRepository.findByTypeAndFromUser(
NotificationType.REGISTER_REQUEST,
applicant
);
for (Notification n : notifs) {
n.setRead(true);
} }
notificationRepository.saveAll(notifs);
}
} }

View File

@@ -1,13 +1,7 @@
package com.openisle.controller; package com.openisle.controller;
import com.openisle.service.AiUsageService;
import com.openisle.service.OpenAiService; import com.openisle.service.OpenAiService;
import io.swagger.v3.oas.annotations.Operation; import com.openisle.service.AiUsageService;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import java.util.Map;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication; import org.springframework.security.core.Authentication;
@@ -15,40 +9,41 @@ import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import java.util.Map;
@RestController @RestController
@RequestMapping("/api/ai") @RequestMapping("/api/ai")
@RequiredArgsConstructor @RequiredArgsConstructor
public class AiController { public class AiController {
private final OpenAiService openAiService; private final OpenAiService openAiService;
private final AiUsageService aiUsageService; private final AiUsageService aiUsageService;
@PostMapping("/format") @PostMapping("/format")
@Operation(summary = "Format markdown", description = "Format text via AI") @Operation(summary = "Format markdown", description = "Format text via AI")
@ApiResponse( @ApiResponse(responseCode = "200", description = "Formatted content",
responseCode = "200", content = @Content(schema = @Schema(implementation = Map.class)))
description = "Formatted content", @SecurityRequirement(name = "JWT")
content = @Content(schema = @Schema(implementation = Map.class)) public ResponseEntity<Map<String, String>> format(@RequestBody Map<String, String> req,
) Authentication auth) {
@SecurityRequirement(name = "JWT") String text = req.get("text");
public ResponseEntity<Map<String, String>> format( if (text == null) {
@RequestBody Map<String, String> req, return ResponseEntity.badRequest().build();
Authentication auth }
) { int limit = aiUsageService.getFormatLimit();
String text = req.get("text"); int used = aiUsageService.getCount(auth.getName());
if (text == null) { if (limit > 0 && used >= limit) {
return ResponseEntity.badRequest().build(); return ResponseEntity.status(429).build();
}
aiUsageService.incrementAndGetCount(auth.getName());
return openAiService.formatMarkdown(text)
.map(t -> ResponseEntity.ok(Map.of("content", t)))
.orElse(ResponseEntity.status(500).build());
} }
int limit = aiUsageService.getFormatLimit();
int used = aiUsageService.getCount(auth.getName());
if (limit > 0 && used >= limit) {
return ResponseEntity.status(429).build();
}
aiUsageService.incrementAndGetCount(auth.getName());
return openAiService
.formatMarkdown(text)
.map(t -> ResponseEntity.ok(Map.of("content", t)))
.orElse(ResponseEntity.status(500).build());
}
} }

View File

File diff suppressed because it is too large Load Diff

View File

@@ -8,120 +8,88 @@ import com.openisle.mapper.PostMapper;
import com.openisle.model.Category; import com.openisle.model.Category;
import com.openisle.service.CategoryService; import com.openisle.service.CategoryService;
import com.openisle.service.PostService; import com.openisle.service.PostService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.ArraySchema; import io.swagger.v3.oas.annotations.media.ArraySchema;
import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponse;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
@RestController @RestController
@RequestMapping("/api/categories") @RequestMapping("/api/categories")
@RequiredArgsConstructor @RequiredArgsConstructor
public class CategoryController { public class CategoryController {
private final CategoryService categoryService;
private final PostService postService;
private final PostMapper postMapper;
private final CategoryMapper categoryMapper;
private final CategoryService categoryService; @PostMapping
private final PostService postService; @Operation(summary = "Create category", description = "Create a new category")
private final PostMapper postMapper; @ApiResponse(responseCode = "200", description = "Created category",
private final CategoryMapper categoryMapper; content = @Content(schema = @Schema(implementation = CategoryDto.class)))
public CategoryDto create(@RequestBody CategoryRequest req) {
Category c = categoryService.createCategory(req.getName(), req.getDescription(), req.getIcon(), req.getSmallIcon());
long count = postService.countPostsByCategory(c.getId());
return categoryMapper.toDto(c, count);
}
@PostMapping @PutMapping("/{id}")
@Operation(summary = "Create category", description = "Create a new category") @Operation(summary = "Update category", description = "Update an existing category")
@ApiResponse( @ApiResponse(responseCode = "200", description = "Updated category",
responseCode = "200", content = @Content(schema = @Schema(implementation = CategoryDto.class)))
description = "Created category", public CategoryDto update(@PathVariable Long id, @RequestBody CategoryRequest req) {
content = @Content(schema = @Schema(implementation = CategoryDto.class)) Category c = categoryService.updateCategory(id, req.getName(), req.getDescription(), req.getIcon(), req.getSmallIcon());
) long count = postService.countPostsByCategory(c.getId());
public CategoryDto create(@RequestBody CategoryRequest req) { return categoryMapper.toDto(c, count);
Category c = categoryService.createCategory( }
req.getName(),
req.getDescription(),
req.getIcon(),
req.getSmallIcon()
);
long count = postService.countPostsByCategory(c.getId());
return categoryMapper.toDto(c, count);
}
@PutMapping("/{id}") @DeleteMapping("/{id}")
@Operation(summary = "Update category", description = "Update an existing category") @Operation(summary = "Delete category", description = "Remove a category by id")
@ApiResponse( @ApiResponse(responseCode = "200", description = "Category deleted")
responseCode = "200", public void delete(@PathVariable Long id) {
description = "Updated category", categoryService.deleteCategory(id);
content = @Content(schema = @Schema(implementation = CategoryDto.class)) }
)
public CategoryDto update(@PathVariable Long id, @RequestBody CategoryRequest req) {
Category c = categoryService.updateCategory(
id,
req.getName(),
req.getDescription(),
req.getIcon(),
req.getSmallIcon()
);
long count = postService.countPostsByCategory(c.getId());
return categoryMapper.toDto(c, count);
}
@DeleteMapping("/{id}") @GetMapping
@Operation(summary = "Delete category", description = "Remove a category by id") @Operation(summary = "List categories", description = "Get all categories")
@ApiResponse(responseCode = "200", description = "Category deleted") @ApiResponse(responseCode = "200", description = "List of categories",
public void delete(@PathVariable Long id) { content = @Content(array = @ArraySchema(schema = @Schema(implementation = CategoryDto.class))))
categoryService.deleteCategory(id); public List<CategoryDto> list() {
} List<Category> all = categoryService.listCategories();
List<Long> ids = all.stream().map(Category::getId).toList();
Map<Long, Long> postsCntByCategoryIds = postService.countPostsByCategoryIds(ids);
return all.stream()
.map(c -> categoryMapper.toDto(c, postsCntByCategoryIds.getOrDefault(c.getId(), 0L)))
.sorted((a, b) -> Long.compare(b.getCount(), a.getCount()))
.collect(Collectors.toList());
}
@GetMapping @GetMapping("/{id}")
@Operation(summary = "List categories", description = "Get all categories") @Operation(summary = "Get category", description = "Get category by id")
@ApiResponse( @ApiResponse(responseCode = "200", description = "Category detail",
responseCode = "200", content = @Content(schema = @Schema(implementation = CategoryDto.class)))
description = "List of categories", public CategoryDto get(@PathVariable Long id) {
content = @Content(array = @ArraySchema(schema = @Schema(implementation = CategoryDto.class))) Category c = categoryService.getCategory(id);
) long count = postService.countPostsByCategory(c.getId());
public List<CategoryDto> list() { return categoryMapper.toDto(c, count);
List<Category> all = categoryService.listCategories(); }
List<Long> ids = all.stream().map(Category::getId).toList();
Map<Long, Long> postsCntByCategoryIds = postService.countPostsByCategoryIds(ids);
return all
.stream()
.map(c -> categoryMapper.toDto(c, postsCntByCategoryIds.getOrDefault(c.getId(), 0L)))
.sorted((a, b) -> Long.compare(b.getCount(), a.getCount()))
.collect(Collectors.toList());
}
@GetMapping("/{id}") @GetMapping("/{id}/posts")
@Operation(summary = "Get category", description = "Get category by id") @Operation(summary = "List posts by category", description = "Get posts under a category")
@ApiResponse( @ApiResponse(responseCode = "200", description = "List of posts",
responseCode = "200", content = @Content(array = @ArraySchema(schema = @Schema(implementation = PostSummaryDto.class))))
description = "Category detail", public List<PostSummaryDto> listPostsByCategory(@PathVariable Long id,
content = @Content(schema = @Schema(implementation = CategoryDto.class)) @RequestParam(value = "page", required = false) Integer page,
) @RequestParam(value = "pageSize", required = false) Integer pageSize) {
public CategoryDto get(@PathVariable Long id) { return postService.listPostsByCategories(java.util.List.of(id), page, pageSize)
Category c = categoryService.getCategory(id); .stream()
long count = postService.countPostsByCategory(c.getId()); .map(postMapper::toSummaryDto)
return categoryMapper.toDto(c, count); .collect(Collectors.toList());
} }
@GetMapping("/{id}/posts")
@Operation(summary = "List posts by category", description = "Get posts under a category")
@ApiResponse(
responseCode = "200",
description = "List of posts",
content = @Content(
array = @ArraySchema(schema = @Schema(implementation = PostSummaryDto.class))
)
)
public List<PostSummaryDto> listPostsByCategory(
@PathVariable Long id,
@RequestParam(value = "page", required = false) Integer page,
@RequestParam(value = "pageSize", required = false) Integer pageSize
) {
return postService
.listPostsByCategories(java.util.List.of(id), page, pageSize)
.stream()
.map(postMapper::toSummaryDto)
.collect(Collectors.toList());
}
} }

View File

@@ -5,66 +5,56 @@ import com.openisle.model.User;
import com.openisle.repository.UserRepository; import com.openisle.repository.UserRepository;
import com.openisle.service.ChannelService; import com.openisle.service.ChannelService;
import com.openisle.service.MessageService; import com.openisle.service.MessageService;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.ArraySchema; import io.swagger.v3.oas.annotations.media.ArraySchema;
import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import java.util.List; import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
@RestController @RestController
@RequestMapping("/api/channels") @RequestMapping("/api/channels")
@RequiredArgsConstructor @RequiredArgsConstructor
public class ChannelController { public class ChannelController {
private final ChannelService channelService;
private final MessageService messageService;
private final UserRepository userRepository;
private final ChannelService channelService; private Long getCurrentUserId(Authentication auth) {
private final MessageService messageService; User user = userRepository.findByUsername(auth.getName())
private final UserRepository userRepository; .orElseThrow(() -> new IllegalArgumentException("User not found"));
return user.getId();
}
private Long getCurrentUserId(Authentication auth) { @GetMapping
User user = userRepository @Operation(summary = "List channels", description = "List channels for the current user")
.findByUsername(auth.getName()) @ApiResponse(responseCode = "200", description = "Channels",
.orElseThrow(() -> new IllegalArgumentException("User not found")); content = @Content(array = @ArraySchema(schema = @Schema(implementation = ChannelDto.class))))
return user.getId(); @SecurityRequirement(name = "JWT")
} public List<ChannelDto> listChannels(Authentication auth) {
return channelService.listChannels(getCurrentUserId(auth));
}
@GetMapping @PostMapping("/{channelId}/join")
@Operation(summary = "List channels", description = "List channels for the current user") @Operation(summary = "Join channel", description = "Join a channel")
@ApiResponse( @ApiResponse(responseCode = "200", description = "Joined channel",
responseCode = "200", content = @Content(schema = @Schema(implementation = ChannelDto.class)))
description = "Channels", @SecurityRequirement(name = "JWT")
content = @Content(array = @ArraySchema(schema = @Schema(implementation = ChannelDto.class))) public ChannelDto joinChannel(@PathVariable Long channelId, Authentication auth) {
) return channelService.joinChannel(channelId, getCurrentUserId(auth));
@SecurityRequirement(name = "JWT") }
public List<ChannelDto> listChannels(Authentication auth) {
return channelService.listChannels(getCurrentUserId(auth));
}
@PostMapping("/{channelId}/join") @GetMapping("/unread-count")
@Operation(summary = "Join channel", description = "Join a channel") @Operation(summary = "Unread count", description = "Get unread channel count")
@ApiResponse( @ApiResponse(responseCode = "200", description = "Unread count",
responseCode = "200", content = @Content(schema = @Schema(implementation = Long.class)))
description = "Joined channel", @SecurityRequirement(name = "JWT")
content = @Content(schema = @Schema(implementation = ChannelDto.class)) public long unreadCount(Authentication auth) {
) return messageService.getUnreadChannelCount(getCurrentUserId(auth));
@SecurityRequirement(name = "JWT") }
public ChannelDto joinChannel(@PathVariable Long channelId, Authentication auth) {
return channelService.joinChannel(channelId, getCurrentUserId(auth));
}
@GetMapping("/unread-count")
@Operation(summary = "Unread count", description = "Get unread channel count")
@ApiResponse(
responseCode = "200",
description = "Unread count",
content = @Content(schema = @Schema(implementation = Long.class))
)
@SecurityRequirement(name = "JWT")
public long unreadCount(Authentication auth) {
return messageService.getUnreadChannelCount(getCurrentUserId(auth));
}
} }

View File

@@ -1,198 +1,161 @@
package com.openisle.controller; package com.openisle.controller;
import com.openisle.dto.CommentDto;
import com.openisle.dto.CommentRequest;
import com.openisle.dto.PostChangeLogDto; import com.openisle.dto.PostChangeLogDto;
import com.openisle.dto.TimelineItemDto; import com.openisle.dto.TimelineItemDto;
import com.openisle.mapper.CommentMapper;
import com.openisle.mapper.PostChangeLogMapper; import com.openisle.mapper.PostChangeLogMapper;
import com.openisle.model.Comment; import com.openisle.model.Comment;
import com.openisle.dto.CommentDto;
import com.openisle.dto.CommentRequest;
import com.openisle.mapper.CommentMapper;
import com.openisle.model.CommentSort; import com.openisle.model.CommentSort;
import com.openisle.service.*; import com.openisle.service.*;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.ArraySchema;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication; import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.ArraySchema;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.stream.Collectors;
@RestController @RestController
@RequestMapping("/api") @RequestMapping("/api")
@RequiredArgsConstructor @RequiredArgsConstructor
@Slf4j @Slf4j
public class CommentController { public class CommentController {
private final CommentService commentService;
private final LevelService levelService;
private final CaptchaService captchaService;
private final CommentMapper commentMapper;
private final PointService pointService;
private final PostChangeLogService changeLogService;
private final PostChangeLogMapper postChangeLogMapper;
private final CommentService commentService; @Value("${app.captcha.enabled:false}")
private final LevelService levelService; private boolean captchaEnabled;
private final CaptchaService captchaService;
private final CommentMapper commentMapper;
private final PointService pointService;
private final PostChangeLogService changeLogService;
private final PostChangeLogMapper postChangeLogMapper;
@Value("${app.captcha.enabled:false}") @Value("${app.captcha.comment-enabled:false}")
private boolean captchaEnabled; private boolean commentCaptchaEnabled;
@Value("${app.captcha.comment-enabled:false}") @PostMapping("/posts/{postId}/comments")
private boolean commentCaptchaEnabled; @Operation(summary = "Create comment", description = "Add a comment to a post")
@ApiResponse(responseCode = "200", description = "Created comment",
@PostMapping("/posts/{postId}/comments") content = @Content(schema = @Schema(implementation = CommentDto.class)))
@Operation(summary = "Create comment", description = "Add a comment to a post") @SecurityRequirement(name = "JWT")
@ApiResponse( public ResponseEntity<CommentDto> createComment(@PathVariable Long postId,
responseCode = "200", @RequestBody CommentRequest req,
description = "Created comment", Authentication auth) {
content = @Content(schema = @Schema(implementation = CommentDto.class)) log.debug("createComment called by user {} for post {}", auth.getName(), postId);
) if (captchaEnabled && commentCaptchaEnabled && !captchaService.verify(req.getCaptcha())) {
@SecurityRequirement(name = "JWT") log.debug("Captcha verification failed for user {} on post {}", auth.getName(), postId);
public ResponseEntity<CommentDto> createComment( return ResponseEntity.badRequest().build();
@PathVariable Long postId, }
@RequestBody CommentRequest req, Comment comment = commentService.addComment(auth.getName(), postId, req.getContent());
Authentication auth CommentDto dto = commentMapper.toDto(comment);
) { dto.setReward(levelService.awardForComment(auth.getName()));
log.debug("createComment called by user {} for post {}", auth.getName(), postId); dto.setPointReward(pointService.awardForComment(auth.getName(), postId, comment.getId()));
if (captchaEnabled && commentCaptchaEnabled && !captchaService.verify(req.getCaptcha())) { log.debug("createComment succeeded for comment {}", comment.getId());
log.debug("Captcha verification failed for user {} on post {}", auth.getName(), postId); return ResponseEntity.ok(dto);
return ResponseEntity.badRequest().build();
} }
Comment comment = commentService.addComment(auth.getName(), postId, req.getContent());
CommentDto dto = commentMapper.toDto(comment);
dto.setReward(levelService.awardForComment(auth.getName()));
dto.setPointReward(pointService.awardForComment(auth.getName(), postId, comment.getId()));
log.debug("createComment succeeded for comment {}", comment.getId());
return ResponseEntity.ok(dto);
}
@PostMapping("/comments/{commentId}/replies") @PostMapping("/comments/{commentId}/replies")
@Operation(summary = "Reply to comment", description = "Reply to an existing comment") @Operation(summary = "Reply to comment", description = "Reply to an existing comment")
@ApiResponse( @ApiResponse(responseCode = "200", description = "Reply created",
responseCode = "200", content = @Content(schema = @Schema(implementation = CommentDto.class)))
description = "Reply created", @SecurityRequirement(name = "JWT")
content = @Content(schema = @Schema(implementation = CommentDto.class)) public ResponseEntity<CommentDto> replyComment(@PathVariable Long commentId,
) @RequestBody CommentRequest req,
@SecurityRequirement(name = "JWT") Authentication auth) {
public ResponseEntity<CommentDto> replyComment( log.debug("replyComment called by user {} for comment {}", auth.getName(), commentId);
@PathVariable Long commentId, if (captchaEnabled && commentCaptchaEnabled && !captchaService.verify(req.getCaptcha())) {
@RequestBody CommentRequest req, log.debug("Captcha verification failed for user {} on comment {}", auth.getName(), commentId);
Authentication auth return ResponseEntity.badRequest().build();
) { }
log.debug("replyComment called by user {} for comment {}", auth.getName(), commentId); Comment comment = commentService.addReply(auth.getName(), commentId, req.getContent());
if (captchaEnabled && commentCaptchaEnabled && !captchaService.verify(req.getCaptcha())) { CommentDto dto = commentMapper.toDto(comment);
log.debug("Captcha verification failed for user {} on comment {}", auth.getName(), commentId); dto.setReward(levelService.awardForComment(auth.getName()));
return ResponseEntity.badRequest().build(); log.debug("replyComment succeeded for comment {}", comment.getId());
return ResponseEntity.ok(dto);
} }
Comment comment = commentService.addReply(auth.getName(), commentId, req.getContent());
CommentDto dto = commentMapper.toDto(comment);
dto.setReward(levelService.awardForComment(auth.getName()));
log.debug("replyComment succeeded for comment {}", comment.getId());
return ResponseEntity.ok(dto);
}
@GetMapping("/posts/{postId}/comments") @GetMapping("/posts/{postId}/comments")
@Operation(summary = "List comments", description = "List comments for a post") @Operation(summary = "List comments", description = "List comments for a post")
@ApiResponse( @ApiResponse(responseCode = "200", description = "Comments",
responseCode = "200", content = @Content(array = @ArraySchema(schema = @Schema(implementation = TimelineItemDto.class))))
description = "Comments", public List<TimelineItemDto<?>> listComments(@PathVariable Long postId,
content = @Content( @RequestParam(value = "sort", required = false, defaultValue = "OLDEST") CommentSort sort) {
array = @ArraySchema(schema = @Schema(implementation = TimelineItemDto.class)) log.debug("listComments called for post {} with sort {}", postId, sort);
) List<CommentDto> commentDtoList = commentService.getCommentsForPost(postId, sort).stream()
) .map(commentMapper::toDtoWithReplies)
public List<TimelineItemDto<?>> listComments( .collect(Collectors.toList());
@PathVariable Long postId, List<PostChangeLogDto> postChangeLogDtoList = changeLogService.listLogs(postId).stream()
@RequestParam(value = "sort", required = false, defaultValue = "OLDEST") CommentSort sort .map(postChangeLogMapper::toDto)
) { .collect(Collectors.toList());
log.debug("listComments called for post {} with sort {}", postId, sort); List<TimelineItemDto<?>> itemDtoList = new ArrayList<>();
List<CommentDto> commentDtoList = commentService
.getCommentsForPost(postId, sort)
.stream()
.map(commentMapper::toDtoWithReplies)
.collect(Collectors.toList());
List<PostChangeLogDto> postChangeLogDtoList = changeLogService
.listLogs(postId)
.stream()
.map(postChangeLogMapper::toDto)
.collect(Collectors.toList());
List<TimelineItemDto<?>> itemDtoList = new ArrayList<>();
itemDtoList.addAll( itemDtoList.addAll(commentDtoList.stream()
commentDtoList .map(c -> new TimelineItemDto<>(
.stream() c.getId(),
.map(c -> "comment",
new TimelineItemDto<>( c.getCreatedAt(),
c.getId(), c // payload 是 CommentDto
"comment", ))
c.getCreatedAt(), .toList());
c // payload 是 CommentDto
)
)
.toList()
);
itemDtoList.addAll( itemDtoList.addAll(postChangeLogDtoList.stream()
postChangeLogDtoList .map(l -> new TimelineItemDto<>(
.stream() l.getId(),
.map(l -> "log",
new TimelineItemDto<>( l.getTime(), // 注意字段名不一样
l.getId(), l // payload 是 PostChangeLogDto
"log", ))
l.getTime(), // 注意字段名不一样 .toList());
l // payload 是 PostChangeLogDto // 排序
) Comparator<TimelineItemDto<?>> comparator = Comparator.comparing(TimelineItemDto::getCreatedAt);
) if (CommentSort.NEWEST.equals(sort)) {
.toList() comparator = comparator.reversed();
); }
// 排序 itemDtoList.sort(comparator);
Comparator<TimelineItemDto<?>> comparator = Comparator.comparing(TimelineItemDto::getCreatedAt); log.debug("listComments returning {} comments", itemDtoList.size());
if (CommentSort.NEWEST.equals(sort)) { return itemDtoList;
comparator = comparator.reversed();
} }
itemDtoList.sort(comparator);
log.debug("listComments returning {} comments", itemDtoList.size());
return itemDtoList;
}
@DeleteMapping("/comments/{id}") @DeleteMapping("/comments/{id}")
@Operation(summary = "Delete comment", description = "Delete a comment") @Operation(summary = "Delete comment", description = "Delete a comment")
@ApiResponse(responseCode = "200", description = "Deleted") @ApiResponse(responseCode = "200", description = "Deleted")
@SecurityRequirement(name = "JWT") @SecurityRequirement(name = "JWT")
public void deleteComment(@PathVariable Long id, Authentication auth) { public void deleteComment(@PathVariable Long id, Authentication auth) {
log.debug("deleteComment called by user {} for comment {}", auth.getName(), id); log.debug("deleteComment called by user {} for comment {}", auth.getName(), id);
commentService.deleteComment(auth.getName(), id); commentService.deleteComment(auth.getName(), id);
log.debug("deleteComment completed for comment {}", id); log.debug("deleteComment completed for comment {}", id);
} }
@PostMapping("/comments/{id}/pin") @PostMapping("/comments/{id}/pin")
@Operation(summary = "Pin comment", description = "Pin a comment") @Operation(summary = "Pin comment", description = "Pin a comment")
@ApiResponse( @ApiResponse(responseCode = "200", description = "Pinned comment",
responseCode = "200", content = @Content(schema = @Schema(implementation = CommentDto.class)))
description = "Pinned comment", @SecurityRequirement(name = "JWT")
content = @Content(schema = @Schema(implementation = CommentDto.class)) public CommentDto pinComment(@PathVariable Long id, Authentication auth) {
) log.debug("pinComment called by user {} for comment {}", auth.getName(), id);
@SecurityRequirement(name = "JWT") return commentMapper.toDto(commentService.pinComment(auth.getName(), id));
public CommentDto pinComment(@PathVariable Long id, Authentication auth) { }
log.debug("pinComment called by user {} for comment {}", auth.getName(), id);
return commentMapper.toDto(commentService.pinComment(auth.getName(), id));
}
@PostMapping("/comments/{id}/unpin") @PostMapping("/comments/{id}/unpin")
@Operation(summary = "Unpin comment", description = "Unpin a comment") @Operation(summary = "Unpin comment", description = "Unpin a comment")
@ApiResponse( @ApiResponse(responseCode = "200", description = "Unpinned comment",
responseCode = "200", content = @Content(schema = @Schema(implementation = CommentDto.class)))
description = "Unpinned comment", @SecurityRequirement(name = "JWT")
content = @Content(schema = @Schema(implementation = CommentDto.class)) public CommentDto unpinComment(@PathVariable Long id, Authentication auth) {
) log.debug("unpinComment called by user {} for comment {}", auth.getName(), id);
@SecurityRequirement(name = "JWT") return commentMapper.toDto(commentService.unpinComment(auth.getName(), id));
public CommentDto unpinComment(@PathVariable Long id, Authentication auth) { }
log.debug("unpinComment called by user {} for comment {}", auth.getName(), id);
return commentMapper.toDto(commentService.unpinComment(auth.getName(), id));
}
} }

View File

@@ -2,56 +2,53 @@ package com.openisle.controller;
import com.openisle.dto.SiteConfigDto; import com.openisle.dto.SiteConfigDto;
import com.openisle.service.RegisterModeService; import com.openisle.service.RegisterModeService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
@RestController @RestController
@RequestMapping("/api") @RequestMapping("/api")
@lombok.RequiredArgsConstructor @lombok.RequiredArgsConstructor
public class ConfigController { public class ConfigController {
@Value("${app.captcha.enabled:false}") @Value("${app.captcha.enabled:false}")
private boolean captchaEnabled; private boolean captchaEnabled;
@Value("${app.captcha.register-enabled:false}") @Value("${app.captcha.register-enabled:false}")
private boolean registerCaptchaEnabled; private boolean registerCaptchaEnabled;
@Value("${app.captcha.login-enabled:false}") @Value("${app.captcha.login-enabled:false}")
private boolean loginCaptchaEnabled; private boolean loginCaptchaEnabled;
@Value("${app.captcha.post-enabled:false}") @Value("${app.captcha.post-enabled:false}")
private boolean postCaptchaEnabled; private boolean postCaptchaEnabled;
@Value("${app.captcha.comment-enabled:false}") @Value("${app.captcha.comment-enabled:false}")
private boolean commentCaptchaEnabled; private boolean commentCaptchaEnabled;
@Value("${app.ai.format-limit:3}") @Value("${app.ai.format-limit:3}")
private int aiFormatLimit; private int aiFormatLimit;
private final RegisterModeService registerModeService; private final RegisterModeService registerModeService;
@GetMapping("/config") @GetMapping("/config")
@Operation(summary = "Site config", description = "Get site configuration") @Operation(summary = "Site config", description = "Get site configuration")
@ApiResponse( @ApiResponse(responseCode = "200", description = "Site configuration",
responseCode = "200", content = @Content(schema = @Schema(implementation = SiteConfigDto.class)))
description = "Site configuration", public SiteConfigDto getConfig() {
content = @Content(schema = @Schema(implementation = SiteConfigDto.class)) SiteConfigDto resp = new SiteConfigDto();
) resp.setCaptchaEnabled(captchaEnabled);
public SiteConfigDto getConfig() { resp.setRegisterCaptchaEnabled(registerCaptchaEnabled);
SiteConfigDto resp = new SiteConfigDto(); resp.setLoginCaptchaEnabled(loginCaptchaEnabled);
resp.setCaptchaEnabled(captchaEnabled); resp.setPostCaptchaEnabled(postCaptchaEnabled);
resp.setRegisterCaptchaEnabled(registerCaptchaEnabled); resp.setCommentCaptchaEnabled(commentCaptchaEnabled);
resp.setLoginCaptchaEnabled(loginCaptchaEnabled); resp.setAiFormatLimit(aiFormatLimit);
resp.setPostCaptchaEnabled(postCaptchaEnabled); resp.setRegisterMode(registerModeService.getRegisterMode());
resp.setCommentCaptchaEnabled(commentCaptchaEnabled); return resp;
resp.setAiFormatLimit(aiFormatLimit); }
resp.setRegisterMode(registerModeService.getRegisterMode());
return resp;
}
} }

View File

@@ -5,64 +5,50 @@ import com.openisle.dto.DraftRequest;
import com.openisle.mapper.DraftMapper; import com.openisle.mapper.DraftMapper;
import com.openisle.model.Draft; import com.openisle.model.Draft;
import com.openisle.service.DraftService; import com.openisle.service.DraftService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
@RestController @RestController
@RequestMapping("/api/drafts") @RequestMapping("/api/drafts")
@RequiredArgsConstructor @RequiredArgsConstructor
public class DraftController { public class DraftController {
private final DraftService draftService;
private final DraftMapper draftMapper;
private final DraftService draftService; @PostMapping
private final DraftMapper draftMapper; @Operation(summary = "Save draft", description = "Save a draft for current user")
@ApiResponse(responseCode = "200", description = "Draft saved",
content = @Content(schema = @Schema(implementation = DraftDto.class)))
@SecurityRequirement(name = "JWT")
public ResponseEntity<DraftDto> saveDraft(@RequestBody DraftRequest req, Authentication auth) {
Draft draft = draftService.saveDraft(auth.getName(), req.getCategoryId(), req.getTitle(), req.getContent(), req.getTagIds());
return ResponseEntity.ok(draftMapper.toDto(draft));
}
@PostMapping @GetMapping("/me")
@Operation(summary = "Save draft", description = "Save a draft for current user") @Operation(summary = "Get my draft", description = "Get current user's draft")
@ApiResponse( @ApiResponse(responseCode = "200", description = "Draft details",
responseCode = "200", content = @Content(schema = @Schema(implementation = DraftDto.class)))
description = "Draft saved", @SecurityRequirement(name = "JWT")
content = @Content(schema = @Schema(implementation = DraftDto.class)) public ResponseEntity<DraftDto> getMyDraft(Authentication auth) {
) return draftService.getDraft(auth.getName())
@SecurityRequirement(name = "JWT") .map(d -> ResponseEntity.ok(draftMapper.toDto(d)))
public ResponseEntity<DraftDto> saveDraft(@RequestBody DraftRequest req, Authentication auth) { .orElseGet(() -> ResponseEntity.noContent().build());
Draft draft = draftService.saveDraft( }
auth.getName(),
req.getCategoryId(),
req.getTitle(),
req.getContent(),
req.getTagIds()
);
return ResponseEntity.ok(draftMapper.toDto(draft));
}
@GetMapping("/me") @DeleteMapping("/me")
@Operation(summary = "Get my draft", description = "Get current user's draft") @Operation(summary = "Delete my draft", description = "Delete current user's draft")
@ApiResponse( @ApiResponse(responseCode = "200", description = "Draft deleted")
responseCode = "200", @SecurityRequirement(name = "JWT")
description = "Draft details", public ResponseEntity<?> deleteMyDraft(Authentication auth) {
content = @Content(schema = @Schema(implementation = DraftDto.class)) draftService.deleteDraft(auth.getName());
) return ResponseEntity.ok().build();
@SecurityRequirement(name = "JWT") }
public ResponseEntity<DraftDto> getMyDraft(Authentication auth) {
return draftService
.getDraft(auth.getName())
.map(d -> ResponseEntity.ok(draftMapper.toDto(d)))
.orElseGet(() -> ResponseEntity.noContent().build());
}
@DeleteMapping("/me")
@Operation(summary = "Delete my draft", description = "Delete current user's draft")
@ApiResponse(responseCode = "200", description = "Draft deleted")
@SecurityRequirement(name = "JWT")
public ResponseEntity<?> deleteMyDraft(Authentication auth) {
draftService.deleteDraft(auth.getName());
return ResponseEntity.ok().build();
}
} }

View File

@@ -1,39 +1,40 @@
package com.openisle.controller; package com.openisle.controller;
import com.openisle.exception.FieldException;
import com.openisle.exception.NotFoundException;
import com.openisle.exception.RateLimitException;
import java.util.Map;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.bind.annotation.RestControllerAdvice;
import com.openisle.exception.FieldException;
import com.openisle.exception.NotFoundException;
import com.openisle.exception.RateLimitException;
import java.util.Map;
@RestControllerAdvice @RestControllerAdvice
public class GlobalExceptionHandler { public class GlobalExceptionHandler {
@ExceptionHandler(FieldException.class) @ExceptionHandler(FieldException.class)
public ResponseEntity<?> handleFieldException(FieldException ex) { public ResponseEntity<?> handleFieldException(FieldException ex) {
return ResponseEntity.badRequest().body( return ResponseEntity.badRequest()
Map.of("error", ex.getMessage(), "field", ex.getField()) .body(Map.of("error", ex.getMessage(), "field", ex.getField()));
); }
}
@ExceptionHandler(NotFoundException.class)
@ExceptionHandler(NotFoundException.class) public ResponseEntity<?> handleNotFoundException(NotFoundException ex) {
public ResponseEntity<?> handleNotFoundException(NotFoundException ex) { return ResponseEntity.status(404).body(Map.of("error", ex.getMessage()));
return ResponseEntity.status(404).body(Map.of("error", ex.getMessage())); }
}
@ExceptionHandler(RateLimitException.class)
@ExceptionHandler(RateLimitException.class) public ResponseEntity<?> handleRateLimitException(RateLimitException ex) {
public ResponseEntity<?> handleRateLimitException(RateLimitException ex) { return ResponseEntity.status(429).body(Map.of("error", ex.getMessage()));
return ResponseEntity.status(429).body(Map.of("error", ex.getMessage())); }
}
@ExceptionHandler(Exception.class)
@ExceptionHandler(Exception.class) public ResponseEntity<?> handleException(Exception ex) {
public ResponseEntity<?> handleException(Exception ex) { String message = ex.getMessage();
String message = ex.getMessage(); if (message == null) {
if (message == null) { message = ex.getClass().getSimpleName();
message = ex.getClass().getSimpleName(); }
return ResponseEntity.badRequest().body(Map.of("error", message));
} }
return ResponseEntity.badRequest().body(Map.of("error", message));
}
} }

View File

@@ -5,22 +5,18 @@ import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import java.util.Map;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
@RestController @RestController
public class HelloController { public class HelloController {
@GetMapping("/api/hello")
@GetMapping("/api/hello") @SecurityRequirement(name = "JWT")
@SecurityRequirement(name = "JWT") @Operation(summary = "Hello endpoint", description = "Returns a greeting for authenticated users")
@Operation(summary = "Hello endpoint", description = "Returns a greeting for authenticated users") @ApiResponse(responseCode = "200", description = "Greeting payload",
@ApiResponse( content = @Content(schema = @Schema(implementation = Map.class)))
responseCode = "200", public Map<String, String> hello() {
description = "Greeting payload", return Map.of("message", "Hello, Authenticated User");
content = @Content(schema = @Schema(implementation = Map.class)) }
)
public Map<String, String> hello() {
return Map.of("message", "Hello, Authenticated User");
}
} }

View File

@@ -1,35 +1,32 @@
package com.openisle.controller; package com.openisle.controller;
import com.openisle.service.InviteService; import com.openisle.service.InviteService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import java.util.Map;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication; import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import java.util.Map;
@RestController @RestController
@RequestMapping("/api/invite") @RequestMapping("/api/invite")
@RequiredArgsConstructor @RequiredArgsConstructor
public class InviteController { public class InviteController {
private final InviteService inviteService;
private final InviteService inviteService; @PostMapping("/generate")
@Operation(summary = "Generate invite", description = "Generate an invite token")
@PostMapping("/generate") @ApiResponse(responseCode = "200", description = "Invite token",
@Operation(summary = "Generate invite", description = "Generate an invite token") content = @Content(schema = @Schema(implementation = Map.class)))
@ApiResponse( @SecurityRequirement(name = "JWT")
responseCode = "200", public Map<String, String> generate(Authentication auth) {
description = "Invite token", String token = inviteService.generate(auth.getName());
content = @Content(schema = @Schema(implementation = Map.class)) return Map.of("token", token);
) }
@SecurityRequirement(name = "JWT")
public Map<String, String> generate(Authentication auth) {
String token = inviteService.generate(auth.getName());
return Map.of("token", token);
}
} }

View File

@@ -3,49 +3,43 @@ package com.openisle.controller;
import com.openisle.dto.MedalDto; import com.openisle.dto.MedalDto;
import com.openisle.dto.MedalSelectRequest; import com.openisle.dto.MedalSelectRequest;
import com.openisle.service.MedalService; import com.openisle.service.MedalService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.ArraySchema; import io.swagger.v3.oas.annotations.media.ArraySchema;
import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import java.util.List; import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
@RestController @RestController
@RequestMapping("/api/medals") @RequestMapping("/api/medals")
@RequiredArgsConstructor @RequiredArgsConstructor
public class MedalController { public class MedalController {
private final MedalService medalService;
private final MedalService medalService; @GetMapping
@Operation(summary = "List medals", description = "List medals for user or globally")
@GetMapping @ApiResponse(responseCode = "200", description = "List of medals",
@Operation(summary = "List medals", description = "List medals for user or globally") content = @Content(array = @ArraySchema(schema = @Schema(implementation = MedalDto.class))))
@ApiResponse( public List<MedalDto> getMedals(@RequestParam(value = "userId", required = false) Long userId) {
responseCode = "200", return medalService.getMedals(userId);
description = "List of medals", }
content = @Content(array = @ArraySchema(schema = @Schema(implementation = MedalDto.class)))
) @PostMapping("/select")
public List<MedalDto> getMedals(@RequestParam(value = "userId", required = false) Long userId) { @Operation(summary = "Select medal", description = "Select a medal for current user")
return medalService.getMedals(userId); @ApiResponse(responseCode = "200", description = "Medal selected")
} @SecurityRequirement(name = "JWT")
public ResponseEntity<Void> selectMedal(@RequestBody MedalSelectRequest req, Authentication auth) {
@PostMapping("/select") try {
@Operation(summary = "Select medal", description = "Select a medal for current user") medalService.selectMedal(auth.getName(), req.getType());
@ApiResponse(responseCode = "200", description = "Medal selected") return ResponseEntity.ok().build();
@SecurityRequirement(name = "JWT") } catch (IllegalArgumentException e) {
public ResponseEntity<Void> selectMedal( return ResponseEntity.badRequest().build();
@RequestBody MedalSelectRequest req, }
Authentication auth
) {
try {
medalService.selectMedal(auth.getName(), req.getType());
return ResponseEntity.ok().build();
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().build();
} }
}
} }

View File

@@ -10,13 +10,6 @@ import com.openisle.model.MessageConversation;
import com.openisle.model.User; import com.openisle.model.User;
import com.openisle.repository.UserRepository; import com.openisle.repository.UserRepository;
import com.openisle.service.MessageService; import com.openisle.service.MessageService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.ArraySchema;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import java.util.List;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.PageRequest;
@@ -25,205 +18,153 @@ import org.springframework.data.domain.Sort;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication; import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.ArraySchema;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import java.util.List;
@RestController @RestController
@RequestMapping("/api/messages") @RequestMapping("/api/messages")
@RequiredArgsConstructor @RequiredArgsConstructor
public class MessageController { public class MessageController {
private final MessageService messageService; private final MessageService messageService;
private final UserRepository userRepository; private final UserRepository userRepository;
// This is a placeholder for getting the current user's ID // This is a placeholder for getting the current user's ID
private Long getCurrentUserId(Authentication auth) { private Long getCurrentUserId(Authentication auth) {
User user = userRepository User user = userRepository.findByUsername(auth.getName()).orElseThrow(() -> new IllegalArgumentException("Sender not found"));
.findByUsername(auth.getName()) // In a real application, you would get this from the Authentication object
.orElseThrow(() -> new IllegalArgumentException("Sender not found")); return user.getId();
// In a real application, you would get this from the Authentication object
return user.getId();
}
@GetMapping("/conversations")
@Operation(summary = "List conversations", description = "Get all conversations of current user")
@ApiResponse(
responseCode = "200",
description = "List of conversations",
content = @Content(
array = @ArraySchema(schema = @Schema(implementation = ConversationDto.class))
)
)
@SecurityRequirement(name = "JWT")
public ResponseEntity<List<ConversationDto>> getConversations(Authentication auth) {
List<ConversationDto> conversations = messageService.getConversations(getCurrentUserId(auth));
return ResponseEntity.ok(conversations);
}
@GetMapping("/conversations/{conversationId}")
@Operation(summary = "Get conversation", description = "Get messages of a conversation")
@ApiResponse(
responseCode = "200",
description = "Conversation detail",
content = @Content(schema = @Schema(implementation = ConversationDetailDto.class))
)
@SecurityRequirement(name = "JWT")
public ResponseEntity<ConversationDetailDto> getMessages(
@PathVariable Long conversationId,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
Authentication auth
) {
Pageable pageable = PageRequest.of(page, size, Sort.by("createdAt").descending());
ConversationDetailDto conversationDetails = messageService.getConversationDetails(
conversationId,
getCurrentUserId(auth),
pageable
);
return ResponseEntity.ok(conversationDetails);
}
@PostMapping
@Operation(summary = "Send message", description = "Send a direct message to a user")
@ApiResponse(
responseCode = "200",
description = "Message sent",
content = @Content(schema = @Schema(implementation = MessageDto.class))
)
@SecurityRequirement(name = "JWT")
public ResponseEntity<MessageDto> sendMessage(
@RequestBody MessageRequest req,
Authentication auth
) {
Message message = messageService.sendMessage(
getCurrentUserId(auth),
req.getRecipientId(),
req.getContent(),
req.getReplyToId()
);
return ResponseEntity.ok(messageService.toDto(message));
}
@PostMapping("/conversations/{conversationId}/messages")
@Operation(summary = "Send message to conversation", description = "Reply within a conversation")
@ApiResponse(
responseCode = "200",
description = "Message sent",
content = @Content(schema = @Schema(implementation = MessageDto.class))
)
@SecurityRequirement(name = "JWT")
public ResponseEntity<MessageDto> sendMessageToConversation(
@PathVariable Long conversationId,
@RequestBody ChannelMessageRequest req,
Authentication auth
) {
Message message = messageService.sendMessageToConversation(
getCurrentUserId(auth),
conversationId,
req.getContent(),
req.getReplyToId()
);
return ResponseEntity.ok(messageService.toDto(message));
}
@PostMapping("/conversations/{conversationId}/read")
@Operation(
summary = "Mark conversation read",
description = "Mark messages in conversation as read"
)
@ApiResponse(responseCode = "200", description = "Marked as read")
@SecurityRequirement(name = "JWT")
public ResponseEntity<Void> markAsRead(@PathVariable Long conversationId, Authentication auth) {
messageService.markConversationAsRead(conversationId, getCurrentUserId(auth));
return ResponseEntity.ok().build();
}
@PostMapping("/conversations")
@Operation(
summary = "Find or create conversation",
description = "Find existing or create new conversation with recipient"
)
@ApiResponse(
responseCode = "200",
description = "Conversation id",
content = @Content(schema = @Schema(implementation = CreateConversationResponse.class))
)
@SecurityRequirement(name = "JWT")
public ResponseEntity<CreateConversationResponse> findOrCreateConversation(
@RequestBody CreateConversationRequest req,
Authentication auth
) {
MessageConversation conversation = messageService.findOrCreateConversation(
getCurrentUserId(auth),
req.getRecipientId()
);
return ResponseEntity.ok(new CreateConversationResponse(conversation.getId()));
}
@GetMapping("/unread-count")
@Operation(
summary = "Unread message count",
description = "Get unread message count for current user"
)
@ApiResponse(
responseCode = "200",
description = "Unread count",
content = @Content(schema = @Schema(implementation = Long.class))
)
@SecurityRequirement(name = "JWT")
public ResponseEntity<Long> getUnreadCount(Authentication auth) {
return ResponseEntity.ok(messageService.getUnreadMessageCount(getCurrentUserId(auth)));
}
// A simple request DTO
static class MessageRequest {
private Long recipientId;
private String content;
private Long replyToId;
public Long getRecipientId() {
return recipientId;
} }
public void setRecipientId(Long recipientId) { @GetMapping("/conversations")
this.recipientId = recipientId; @Operation(summary = "List conversations", description = "Get all conversations of current user")
@ApiResponse(responseCode = "200", description = "List of conversations",
content = @Content(array = @ArraySchema(schema = @Schema(implementation = ConversationDto.class))))
@SecurityRequirement(name = "JWT")
public ResponseEntity<List<ConversationDto>> getConversations(Authentication auth) {
List<ConversationDto> conversations = messageService.getConversations(getCurrentUserId(auth));
return ResponseEntity.ok(conversations);
} }
public String getContent() { @GetMapping("/conversations/{conversationId}")
return content; @Operation(summary = "Get conversation", description = "Get messages of a conversation")
@ApiResponse(responseCode = "200", description = "Conversation detail",
content = @Content(schema = @Schema(implementation = ConversationDetailDto.class)))
@SecurityRequirement(name = "JWT")
public ResponseEntity<ConversationDetailDto> getMessages(@PathVariable Long conversationId,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
Authentication auth) {
Pageable pageable = PageRequest.of(page, size, Sort.by("createdAt").descending());
ConversationDetailDto conversationDetails = messageService.getConversationDetails(conversationId, getCurrentUserId(auth), pageable);
return ResponseEntity.ok(conversationDetails);
} }
public void setContent(String content) { @PostMapping
this.content = content; @Operation(summary = "Send message", description = "Send a direct message to a user")
@ApiResponse(responseCode = "200", description = "Message sent",
content = @Content(schema = @Schema(implementation = MessageDto.class)))
@SecurityRequirement(name = "JWT")
public ResponseEntity<MessageDto> sendMessage(@RequestBody MessageRequest req, Authentication auth) {
Message message = messageService.sendMessage(getCurrentUserId(auth), req.getRecipientId(), req.getContent(), req.getReplyToId());
return ResponseEntity.ok(messageService.toDto(message));
} }
public Long getReplyToId() { @PostMapping("/conversations/{conversationId}/messages")
return replyToId; @Operation(summary = "Send message to conversation", description = "Reply within a conversation")
@ApiResponse(responseCode = "200", description = "Message sent",
content = @Content(schema = @Schema(implementation = MessageDto.class)))
@SecurityRequirement(name = "JWT")
public ResponseEntity<MessageDto> sendMessageToConversation(@PathVariable Long conversationId,
@RequestBody ChannelMessageRequest req,
Authentication auth) {
Message message = messageService.sendMessageToConversation(getCurrentUserId(auth), conversationId, req.getContent(), req.getReplyToId());
return ResponseEntity.ok(messageService.toDto(message));
} }
public void setReplyToId(Long replyToId) { @PostMapping("/conversations/{conversationId}/read")
this.replyToId = replyToId; @Operation(summary = "Mark conversation read", description = "Mark messages in conversation as read")
} @ApiResponse(responseCode = "200", description = "Marked as read")
} @SecurityRequirement(name = "JWT")
public ResponseEntity<Void> markAsRead(@PathVariable Long conversationId, Authentication auth) {
static class ChannelMessageRequest { messageService.markConversationAsRead(conversationId, getCurrentUserId(auth));
return ResponseEntity.ok().build();
private String content;
private Long replyToId;
public String getContent() {
return content;
} }
public void setContent(String content) { @PostMapping("/conversations")
this.content = content; @Operation(summary = "Find or create conversation", description = "Find existing or create new conversation with recipient")
@ApiResponse(responseCode = "200", description = "Conversation id",
content = @Content(schema = @Schema(implementation = CreateConversationResponse.class)))
@SecurityRequirement(name = "JWT")
public ResponseEntity<CreateConversationResponse> findOrCreateConversation(@RequestBody CreateConversationRequest req, Authentication auth) {
MessageConversation conversation = messageService.findOrCreateConversation(getCurrentUserId(auth), req.getRecipientId());
return ResponseEntity.ok(new CreateConversationResponse(conversation.getId()));
} }
public Long getReplyToId() { @GetMapping("/unread-count")
return replyToId; @Operation(summary = "Unread message count", description = "Get unread message count for current user")
@ApiResponse(responseCode = "200", description = "Unread count",
content = @Content(schema = @Schema(implementation = Long.class)))
@SecurityRequirement(name = "JWT")
public ResponseEntity<Long> getUnreadCount(Authentication auth) {
return ResponseEntity.ok(messageService.getUnreadMessageCount(getCurrentUserId(auth)));
} }
public void setReplyToId(Long replyToId) { // A simple request DTO
this.replyToId = replyToId; static class MessageRequest {
private Long recipientId;
private String content;
private Long replyToId;
public Long getRecipientId() {
return recipientId;
}
public void setRecipientId(Long recipientId) {
this.recipientId = recipientId;
}
public String getContent() {
return content;
}
public void setContent(String content) {
this.content = content;
}
public Long getReplyToId() {
return replyToId;
}
public void setReplyToId(Long replyToId) {
this.replyToId = replyToId;
}
} }
}
} static class ChannelMessageRequest {
private String content;
private Long replyToId;
public String getContent() {
return content;
}
public void setContent(String content) {
this.content = content;
}
public Long getReplyToId() {
return replyToId;
}
public void setReplyToId(Long replyToId) {
this.replyToId = replyToId;
}
}
}

View File

@@ -2,158 +2,109 @@ package com.openisle.controller;
import com.openisle.dto.NotificationDto; import com.openisle.dto.NotificationDto;
import com.openisle.dto.NotificationMarkReadRequest; import com.openisle.dto.NotificationMarkReadRequest;
import com.openisle.dto.NotificationUnreadCountDto;
import com.openisle.dto.NotificationPreferenceDto; import com.openisle.dto.NotificationPreferenceDto;
import com.openisle.dto.NotificationPreferenceUpdateRequest; import com.openisle.dto.NotificationPreferenceUpdateRequest;
import com.openisle.dto.NotificationUnreadCountDto;
import com.openisle.mapper.NotificationMapper; import com.openisle.mapper.NotificationMapper;
import com.openisle.service.NotificationService; import com.openisle.service.NotificationService;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.ArraySchema; import io.swagger.v3.oas.annotations.media.ArraySchema;
import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import java.util.List; import java.util.List;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
/** Endpoints for user notifications. */ /** Endpoints for user notifications. */
@RestController @RestController
@RequestMapping("/api/notifications") @RequestMapping("/api/notifications")
@RequiredArgsConstructor @RequiredArgsConstructor
public class NotificationController { public class NotificationController {
private final NotificationService notificationService;
private final NotificationMapper notificationMapper;
private final NotificationService notificationService; @GetMapping
private final NotificationMapper notificationMapper; @Operation(summary = "List notifications", description = "Retrieve notifications for the current user")
@ApiResponse(responseCode = "200", description = "Notifications",
content = @Content(array = @ArraySchema(schema = @Schema(implementation = NotificationDto.class))))
@SecurityRequirement(name = "JWT")
public List<NotificationDto> list(@RequestParam(value = "page", defaultValue = "0") int page,
@RequestParam(value = "size", defaultValue = "30") int size,
Authentication auth) {
return notificationService.listNotifications(auth.getName(), null, page, size).stream()
.map(notificationMapper::toDto)
.collect(Collectors.toList());
}
@GetMapping @GetMapping("/unread")
@Operation( @Operation(summary = "List unread notifications", description = "Retrieve unread notifications for the current user")
summary = "List notifications", @ApiResponse(responseCode = "200", description = "Unread notifications",
description = "Retrieve notifications for the current user" content = @Content(array = @ArraySchema(schema = @Schema(implementation = NotificationDto.class))))
) @SecurityRequirement(name = "JWT")
@ApiResponse( public List<NotificationDto> listUnread(@RequestParam(value = "page", defaultValue = "0") int page,
responseCode = "200", @RequestParam(value = "size", defaultValue = "30") int size,
description = "Notifications", Authentication auth) {
content = @Content( return notificationService.listNotifications(auth.getName(), false, page, size).stream()
array = @ArraySchema(schema = @Schema(implementation = NotificationDto.class)) .map(notificationMapper::toDto)
) .collect(Collectors.toList());
) }
@SecurityRequirement(name = "JWT")
public List<NotificationDto> list(
@RequestParam(value = "page", defaultValue = "0") int page,
@RequestParam(value = "size", defaultValue = "30") int size,
Authentication auth
) {
return notificationService
.listNotifications(auth.getName(), null, page, size)
.stream()
.map(notificationMapper::toDto)
.collect(Collectors.toList());
}
@GetMapping("/unread") @GetMapping("/unread-count")
@Operation( @Operation(summary = "Unread count", description = "Get count of unread notifications")
summary = "List unread notifications", @ApiResponse(responseCode = "200", description = "Unread count",
description = "Retrieve unread notifications for the current user" content = @Content(schema = @Schema(implementation = NotificationUnreadCountDto.class)))
) @SecurityRequirement(name = "JWT")
@ApiResponse( public NotificationUnreadCountDto unreadCount(Authentication auth) {
responseCode = "200", long count = notificationService.countUnread(auth.getName());
description = "Unread notifications", NotificationUnreadCountDto uc = new NotificationUnreadCountDto();
content = @Content( uc.setCount(count);
array = @ArraySchema(schema = @Schema(implementation = NotificationDto.class)) return uc;
) }
)
@SecurityRequirement(name = "JWT")
public List<NotificationDto> listUnread(
@RequestParam(value = "page", defaultValue = "0") int page,
@RequestParam(value = "size", defaultValue = "30") int size,
Authentication auth
) {
return notificationService
.listNotifications(auth.getName(), false, page, size)
.stream()
.map(notificationMapper::toDto)
.collect(Collectors.toList());
}
@GetMapping("/unread-count") @PostMapping("/read")
@Operation(summary = "Unread count", description = "Get count of unread notifications") @Operation(summary = "Mark notifications read", description = "Mark notifications as read")
@ApiResponse( @ApiResponse(responseCode = "200", description = "Marked read")
responseCode = "200", @SecurityRequirement(name = "JWT")
description = "Unread count", public void markRead(@RequestBody NotificationMarkReadRequest req, Authentication auth) {
content = @Content(schema = @Schema(implementation = NotificationUnreadCountDto.class)) notificationService.markRead(auth.getName(), req.getIds());
) }
@SecurityRequirement(name = "JWT")
public NotificationUnreadCountDto unreadCount(Authentication auth) {
long count = notificationService.countUnread(auth.getName());
NotificationUnreadCountDto uc = new NotificationUnreadCountDto();
uc.setCount(count);
return uc;
}
@PostMapping("/read") @GetMapping("/prefs")
@Operation(summary = "Mark notifications read", description = "Mark notifications as read") @Operation(summary = "List preferences", description = "List notification preferences")
@ApiResponse(responseCode = "200", description = "Marked read") @ApiResponse(responseCode = "200", description = "Preferences",
@SecurityRequirement(name = "JWT") content = @Content(array = @ArraySchema(schema = @Schema(implementation = NotificationPreferenceDto.class))))
public void markRead(@RequestBody NotificationMarkReadRequest req, Authentication auth) { @SecurityRequirement(name = "JWT")
notificationService.markRead(auth.getName(), req.getIds()); public List<NotificationPreferenceDto> prefs(Authentication auth) {
} return notificationService.listPreferences(auth.getName());
}
@GetMapping("/prefs") @PostMapping("/prefs")
@Operation(summary = "List preferences", description = "List notification preferences") @Operation(summary = "Update preference", description = "Update notification preference")
@ApiResponse( @ApiResponse(responseCode = "200", description = "Preference updated")
responseCode = "200", @SecurityRequirement(name = "JWT")
description = "Preferences", public void updatePref(@RequestBody NotificationPreferenceUpdateRequest req, Authentication auth) {
content = @Content( notificationService.updatePreference(auth.getName(), req.getType(), req.isEnabled());
array = @ArraySchema(schema = @Schema(implementation = NotificationPreferenceDto.class)) }
)
)
@SecurityRequirement(name = "JWT")
public List<NotificationPreferenceDto> prefs(Authentication auth) {
return notificationService.listPreferences(auth.getName());
}
@PostMapping("/prefs") @GetMapping("/email-prefs")
@Operation(summary = "Update preference", description = "Update notification preference") @Operation(summary = "List email preferences", description = "List email notification preferences")
@ApiResponse(responseCode = "200", description = "Preference updated") @ApiResponse(responseCode = "200", description = "Email preferences",
@SecurityRequirement(name = "JWT") content = @Content(array = @ArraySchema(schema = @Schema(implementation = NotificationPreferenceDto.class))))
public void updatePref( @SecurityRequirement(name = "JWT")
@RequestBody NotificationPreferenceUpdateRequest req, public List<NotificationPreferenceDto> emailPrefs(Authentication auth) {
Authentication auth return notificationService.listEmailPreferences(auth.getName());
) { }
notificationService.updatePreference(auth.getName(), req.getType(), req.isEnabled());
}
@GetMapping("/email-prefs") @PostMapping("/email-prefs")
@Operation( @Operation(summary = "Update email preference", description = "Update email notification preference")
summary = "List email preferences", @ApiResponse(responseCode = "200", description = "Email preference updated")
description = "List email notification preferences" @SecurityRequirement(name = "JWT")
) public void updateEmailPref(@RequestBody NotificationPreferenceUpdateRequest req, Authentication auth) {
@ApiResponse( notificationService.updateEmailPreference(auth.getName(), req.getType(), req.isEnabled());
responseCode = "200", }
description = "Email preferences",
content = @Content(
array = @ArraySchema(schema = @Schema(implementation = NotificationPreferenceDto.class))
)
)
@SecurityRequirement(name = "JWT")
public List<NotificationPreferenceDto> emailPrefs(Authentication auth) {
return notificationService.listEmailPreferences(auth.getName());
}
@PostMapping("/email-prefs")
@Operation(
summary = "Update email preference",
description = "Update email notification preference"
)
@ApiResponse(responseCode = "200", description = "Email preference updated")
@SecurityRequirement(name = "JWT")
public void updateEmailPref(
@RequestBody NotificationPreferenceUpdateRequest req,
Authentication auth
) {
notificationService.updateEmailPreference(auth.getName(), req.getType(), req.isEnabled());
}
} }

View File

@@ -1,15 +1,16 @@
package com.openisle.controller; package com.openisle.controller;
import com.openisle.config.CachingConfig; import com.openisle.config.CachingConfig;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import java.time.Duration;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import java.time.Duration;
/** /**
* @author smallclover * @author smallclover
@@ -21,24 +22,21 @@ import org.springframework.web.bind.annotation.*;
@RequiredArgsConstructor @RequiredArgsConstructor
public class OnlineController { public class OnlineController {
private final RedisTemplate redisTemplate; private final RedisTemplate redisTemplate;
private static final String ONLINE_KEY = CachingConfig.ONLINE_CACHE_NAME + ":"; private static final String ONLINE_KEY = CachingConfig.ONLINE_CACHE_NAME +":";
@PostMapping("/heartbeat") @PostMapping("/heartbeat")
@Operation(summary = "Heartbeat", description = "Record user heartbeat") @Operation(summary = "Heartbeat", description = "Record user heartbeat")
@ApiResponse(responseCode = "200", description = "Heartbeat recorded") @ApiResponse(responseCode = "200", description = "Heartbeat recorded")
public void ping(@RequestParam String userId) { public void ping(@RequestParam String userId){
redisTemplate.opsForValue().set(ONLINE_KEY + userId, "1", Duration.ofSeconds(150)); redisTemplate.opsForValue().set(ONLINE_KEY+userId,"1", Duration.ofSeconds(150));
} }
@GetMapping("/count") @GetMapping("/count")
@Operation(summary = "Online count", description = "Get current online user count") @Operation(summary = "Online count", description = "Get current online user count")
@ApiResponse( @ApiResponse(responseCode = "200", description = "Online count",
responseCode = "200", content = @Content(schema = @Schema(implementation = Long.class)))
description = "Online count", public long count(){
content = @Content(schema = @Schema(implementation = Long.class)) return redisTemplate.keys(ONLINE_KEY+"*").size();
) }
public long count() {
return redisTemplate.keys(ONLINE_KEY + "*").size();
}
} }

View File

@@ -3,60 +3,48 @@ package com.openisle.controller;
import com.openisle.dto.PointHistoryDto; import com.openisle.dto.PointHistoryDto;
import com.openisle.mapper.PointHistoryMapper; import com.openisle.mapper.PointHistoryMapper;
import com.openisle.service.PointService; import com.openisle.service.PointService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.ArraySchema;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication; import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.ArraySchema;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@RestController @RestController
@RequestMapping("/api/point-histories") @RequestMapping("/api/point-histories")
@RequiredArgsConstructor @RequiredArgsConstructor
public class PointHistoryController { public class PointHistoryController {
private final PointService pointService;
private final PointHistoryMapper pointHistoryMapper;
private final PointService pointService; @GetMapping
private final PointHistoryMapper pointHistoryMapper; @Operation(summary = "Point history", description = "List point history for current user")
@ApiResponse(responseCode = "200", description = "List of point histories",
content = @Content(array = @ArraySchema(schema = @Schema(implementation = PointHistoryDto.class))))
@SecurityRequirement(name = "JWT")
public List<PointHistoryDto> list(Authentication auth) {
return pointService.listHistory(auth.getName()).stream()
.map(pointHistoryMapper::toDto)
.collect(Collectors.toList());
}
@GetMapping @GetMapping("/trend")
@Operation(summary = "Point history", description = "List point history for current user") @Operation(summary = "Point trend", description = "Get point trend data for current user")
@ApiResponse( @ApiResponse(responseCode = "200", description = "Trend data",
responseCode = "200", content = @Content(array = @ArraySchema(schema = @Schema(implementation = java.util.Map.class))))
description = "List of point histories", @SecurityRequirement(name = "JWT")
content = @Content( public List<Map<String, Object>> trend(Authentication auth,
array = @ArraySchema(schema = @Schema(implementation = PointHistoryDto.class)) @RequestParam(value = "days", defaultValue = "30") int days) {
) return pointService.trend(auth.getName(), days);
) }
@SecurityRequirement(name = "JWT")
public List<PointHistoryDto> list(Authentication auth) {
return pointService
.listHistory(auth.getName())
.stream()
.map(pointHistoryMapper::toDto)
.collect(Collectors.toList());
}
@GetMapping("/trend")
@Operation(summary = "Point trend", description = "Get point trend data for current user")
@ApiResponse(
responseCode = "200",
description = "Trend data",
content = @Content(array = @ArraySchema(schema = @Schema(implementation = java.util.Map.class)))
)
@SecurityRequirement(name = "JWT")
public List<Map<String, Object>> trend(
Authentication auth,
@RequestParam(value = "days", defaultValue = "30") int days
) {
return pointService.trend(auth.getName(), days);
}
} }

View File

@@ -6,55 +6,47 @@ import com.openisle.mapper.PointGoodMapper;
import com.openisle.model.User; import com.openisle.model.User;
import com.openisle.service.PointMallService; import com.openisle.service.PointMallService;
import com.openisle.service.UserService; import com.openisle.service.UserService;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.ArraySchema; import io.swagger.v3.oas.annotations.media.ArraySchema;
import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
/** REST controller for point mall. */ /** REST controller for point mall. */
@RestController @RestController
@RequestMapping("/api/point-goods") @RequestMapping("/api/point-goods")
@RequiredArgsConstructor @RequiredArgsConstructor
public class PointMallController { public class PointMallController {
private final PointMallService pointMallService;
private final UserService userService;
private final PointGoodMapper pointGoodMapper;
private final PointMallService pointMallService; @GetMapping
private final UserService userService; @Operation(summary = "List goods", description = "List all point goods")
private final PointGoodMapper pointGoodMapper; @ApiResponse(responseCode = "200", description = "List of goods",
content = @Content(array = @ArraySchema(schema = @Schema(implementation = PointGoodDto.class))))
public List<PointGoodDto> list() {
return pointMallService.listGoods().stream()
.map(pointGoodMapper::toDto)
.collect(Collectors.toList());
}
@GetMapping @PostMapping("/redeem")
@Operation(summary = "List goods", description = "List all point goods") @Operation(summary = "Redeem good", description = "Redeem a point good")
@ApiResponse( @ApiResponse(responseCode = "200", description = "Remaining points",
responseCode = "200", content = @Content(schema = @Schema(implementation = java.util.Map.class)))
description = "List of goods", @SecurityRequirement(name = "JWT")
content = @Content(array = @ArraySchema(schema = @Schema(implementation = PointGoodDto.class))) public Map<String, Integer> redeem(@RequestBody PointRedeemRequest req, Authentication auth) {
) User user = userService.findByIdentifier(auth.getName()).orElseThrow();
public List<PointGoodDto> list() { int point = pointMallService.redeem(user, req.getGoodId(), req.getContact());
return pointMallService return Map.of("point", point);
.listGoods() }
.stream()
.map(pointGoodMapper::toDto)
.collect(Collectors.toList());
}
@PostMapping("/redeem")
@Operation(summary = "Redeem good", description = "Redeem a point good")
@ApiResponse(
responseCode = "200",
description = "Remaining points",
content = @Content(schema = @Schema(implementation = java.util.Map.class))
)
@SecurityRequirement(name = "JWT")
public Map<String, Integer> redeem(@RequestBody PointRedeemRequest req, Authentication auth) {
User user = userService.findByIdentifier(auth.getName()).orElseThrow();
int point = pointMallService.redeem(user, req.getGoodId(), req.getContact());
return Map.of("point", point);
}
} }

View File

@@ -3,34 +3,31 @@ package com.openisle.controller;
import com.openisle.dto.PostChangeLogDto; import com.openisle.dto.PostChangeLogDto;
import com.openisle.mapper.PostChangeLogMapper; import com.openisle.mapper.PostChangeLogMapper;
import com.openisle.service.PostChangeLogService; import com.openisle.service.PostChangeLogService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.ArraySchema; import io.swagger.v3.oas.annotations.media.ArraySchema;
import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponse;
import java.util.List; import java.util.List;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
@RestController @RestController
@RequestMapping("/api/posts") @RequestMapping("/api/posts")
@RequiredArgsConstructor @RequiredArgsConstructor
public class PostChangeLogController { public class PostChangeLogController {
private final PostChangeLogService changeLogService;
private final PostChangeLogMapper mapper;
private final PostChangeLogService changeLogService; @GetMapping("/{id}/change-logs")
private final PostChangeLogMapper mapper; @Operation(summary = "Post change logs", description = "List change logs for a post")
@ApiResponse(responseCode = "200", description = "Change logs",
@GetMapping("/{id}/change-logs") content = @Content(array = @ArraySchema(schema = @Schema(implementation = PostChangeLogDto.class))))
@Operation(summary = "Post change logs", description = "List change logs for a post") public List<PostChangeLogDto> listLogs(@PathVariable Long id) {
@ApiResponse( return changeLogService.listLogs(id).stream()
responseCode = "200", .map(mapper::toDto)
description = "Change logs", .collect(Collectors.toList());
content = @Content( }
array = @ArraySchema(schema = @Schema(implementation = PostChangeLogDto.class))
)
)
public List<PostChangeLogDto> listLogs(@PathVariable Long id) {
return changeLogService.listLogs(id).stream().map(mapper::toDto).collect(Collectors.toList());
}
} }

View File

@@ -1,10 +1,9 @@
package com.openisle.controller; package com.openisle.controller;
import com.openisle.config.CachingConfig;
import com.openisle.dto.PollDto;
import com.openisle.dto.PostDetailDto; import com.openisle.dto.PostDetailDto;
import com.openisle.dto.PostRequest; import com.openisle.dto.PostRequest;
import com.openisle.dto.PostSummaryDto; import com.openisle.dto.PostSummaryDto;
import com.openisle.dto.PollDto;
import com.openisle.mapper.PostMapper; import com.openisle.mapper.PostMapper;
import com.openisle.model.Post; import com.openisle.model.Post;
import com.openisle.service.*; import com.openisle.service.*;
@@ -14,305 +13,219 @@ import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import java.util.List;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication; import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.stream.Collectors;
@RestController @RestController
@RequestMapping("/api/posts") @RequestMapping("/api/posts")
@RequiredArgsConstructor @RequiredArgsConstructor
public class PostController { public class PostController {
private final PostService postService;
private final CategoryService categoryService;
private final TagService tagService;
private final LevelService levelService;
private final CaptchaService captchaService;
private final DraftService draftService;
private final UserVisitService userVisitService;
private final PostMapper postMapper;
private final PointService pointService;
private final PostService postService; @Value("${app.captcha.enabled:false}")
private final CategoryService categoryService; private boolean captchaEnabled;
private final TagService tagService;
private final LevelService levelService;
private final CaptchaService captchaService;
private final DraftService draftService;
private final UserVisitService userVisitService;
private final PostMapper postMapper;
private final PointService pointService;
@Value("${app.captcha.enabled:false}") @Value("${app.captcha.post-enabled:false}")
private boolean captchaEnabled; private boolean postCaptchaEnabled;
@Value("${app.captcha.post-enabled:false}") @PostMapping
private boolean postCaptchaEnabled; @SecurityRequirement(name = "JWT")
@Operation(summary = "Create post", description = "Create a new post")
@PostMapping @ApiResponse(responseCode = "200", description = "Created post",
@SecurityRequirement(name = "JWT") content = @Content(schema = @Schema(implementation = PostDetailDto.class)))
@Operation(summary = "Create post", description = "Create a new post") public ResponseEntity<PostDetailDto> createPost(@RequestBody PostRequest req, Authentication auth) {
@ApiResponse( if (captchaEnabled && postCaptchaEnabled && !captchaService.verify(req.getCaptcha())) {
responseCode = "200", return ResponseEntity.badRequest().build();
description = "Created post", }
content = @Content(schema = @Schema(implementation = PostDetailDto.class)) Post post = postService.createPost(auth.getName(), req.getCategoryId(),
) req.getTitle(), req.getContent(), req.getTagIds(),
public ResponseEntity<PostDetailDto> createPost( req.getType(), req.getPrizeDescription(), req.getPrizeIcon(),
@RequestBody PostRequest req, req.getPrizeCount(), req.getPointCost(),
Authentication auth req.getStartTime(), req.getEndTime(),
) { req.getOptions(), req.getMultiple());
if (captchaEnabled && postCaptchaEnabled && !captchaService.verify(req.getCaptcha())) { draftService.deleteDraft(auth.getName());
return ResponseEntity.badRequest().build(); PostDetailDto dto = postMapper.toDetailDto(post, auth.getName());
dto.setReward(levelService.awardForPost(auth.getName()));
dto.setPointReward(pointService.awardForPost(auth.getName(), post.getId()));
return ResponseEntity.ok(dto);
} }
Post post = postService.createPost(
auth.getName(),
req.getCategoryId(),
req.getTitle(),
req.getContent(),
req.getTagIds(),
req.getType(),
req.getPrizeDescription(),
req.getPrizeIcon(),
req.getPrizeCount(),
req.getPointCost(),
req.getStartTime(),
req.getEndTime(),
req.getOptions(),
req.getMultiple()
);
draftService.deleteDraft(auth.getName());
PostDetailDto dto = postMapper.toDetailDto(post, auth.getName());
dto.setReward(levelService.awardForPost(auth.getName()));
dto.setPointReward(pointService.awardForPost(auth.getName(), post.getId()));
return ResponseEntity.ok(dto);
}
@PutMapping("/{id}") @PutMapping("/{id}")
@SecurityRequirement(name = "JWT") @SecurityRequirement(name = "JWT")
@Operation(summary = "Update post", description = "Update an existing post") @Operation(summary = "Update post", description = "Update an existing post")
@ApiResponse( @ApiResponse(responseCode = "200", description = "Updated post",
responseCode = "200", content = @Content(schema = @Schema(implementation = PostDetailDto.class)))
description = "Updated post", public ResponseEntity<PostDetailDto> updatePost(@PathVariable Long id, @RequestBody PostRequest req,
content = @Content(schema = @Schema(implementation = PostDetailDto.class)) Authentication auth) {
) Post post = postService.updatePost(id, auth.getName(), req.getCategoryId(),
public ResponseEntity<PostDetailDto> updatePost( req.getTitle(), req.getContent(), req.getTagIds());
@PathVariable Long id, return ResponseEntity.ok(postMapper.toDetailDto(post, auth.getName()));
@RequestBody PostRequest req, }
Authentication auth
) {
Post post = postService.updatePost(
id,
auth.getName(),
req.getCategoryId(),
req.getTitle(),
req.getContent(),
req.getTagIds()
);
return ResponseEntity.ok(postMapper.toDetailDto(post, auth.getName()));
}
@DeleteMapping("/{id}") @DeleteMapping("/{id}")
@SecurityRequirement(name = "JWT") @SecurityRequirement(name = "JWT")
@Operation(summary = "Delete post", description = "Delete a post") @Operation(summary = "Delete post", description = "Delete a post")
@ApiResponse(responseCode = "200", description = "Post deleted") @ApiResponse(responseCode = "200", description = "Post deleted")
public void deletePost(@PathVariable Long id, Authentication auth) { public void deletePost(@PathVariable Long id, Authentication auth) {
postService.deletePost(id, auth.getName()); postService.deletePost(id, auth.getName());
} }
@PostMapping("/{id}/close") @PostMapping("/{id}/close")
@SecurityRequirement(name = "JWT") @SecurityRequirement(name = "JWT")
@Operation(summary = "Close post", description = "Close a post to prevent further replies") @Operation(summary = "Close post", description = "Close a post to prevent further replies")
@ApiResponse( @ApiResponse(responseCode = "200", description = "Closed post",
responseCode = "200", content = @Content(schema = @Schema(implementation = PostSummaryDto.class)))
description = "Closed post", public PostSummaryDto close(@PathVariable Long id, Authentication auth) {
content = @Content(schema = @Schema(implementation = PostSummaryDto.class)) return postMapper.toSummaryDto(postService.closePost(id, auth.getName()));
) }
public PostSummaryDto close(@PathVariable Long id, Authentication auth) {
return postMapper.toSummaryDto(postService.closePost(id, auth.getName()));
}
@PostMapping("/{id}/reopen") @PostMapping("/{id}/reopen")
@SecurityRequirement(name = "JWT") @SecurityRequirement(name = "JWT")
@Operation(summary = "Reopen post", description = "Reopen a closed post") @Operation(summary = "Reopen post", description = "Reopen a closed post")
@ApiResponse( @ApiResponse(responseCode = "200", description = "Reopened post",
responseCode = "200", content = @Content(schema = @Schema(implementation = PostSummaryDto.class)))
description = "Reopened post", public PostSummaryDto reopen(@PathVariable Long id, Authentication auth) {
content = @Content(schema = @Schema(implementation = PostSummaryDto.class)) return postMapper.toSummaryDto(postService.reopenPost(id, auth.getName()));
) }
public PostSummaryDto reopen(@PathVariable Long id, Authentication auth) {
return postMapper.toSummaryDto(postService.reopenPost(id, auth.getName()));
}
@GetMapping("/{id}") @GetMapping("/{id}")
@Operation(summary = "Get post", description = "Get post details by id") @Operation(summary = "Get post", description = "Get post details by id")
@ApiResponse( @ApiResponse(responseCode = "200", description = "Post detail",
responseCode = "200", content = @Content(schema = @Schema(implementation = PostDetailDto.class)))
description = "Post detail", public ResponseEntity<PostDetailDto> getPost(@PathVariable Long id, Authentication auth) {
content = @Content(schema = @Schema(implementation = PostDetailDto.class)) String viewer = auth != null ? auth.getName() : null;
) Post post = postService.viewPost(id, viewer);
public ResponseEntity<PostDetailDto> getPost(@PathVariable Long id, Authentication auth) { return ResponseEntity.ok(postMapper.toDetailDto(post, viewer));
String viewer = auth != null ? auth.getName() : null; }
Post post = postService.viewPost(id, viewer);
return ResponseEntity.ok(postMapper.toDetailDto(post, viewer));
}
@PostMapping("/{id}/lottery/join") @PostMapping("/{id}/lottery/join")
@SecurityRequirement(name = "JWT") @SecurityRequirement(name = "JWT")
@Operation(summary = "Join lottery", description = "Join a lottery for the post") @Operation(summary = "Join lottery", description = "Join a lottery for the post")
@ApiResponse(responseCode = "200", description = "Joined lottery") @ApiResponse(responseCode = "200", description = "Joined lottery")
public ResponseEntity<Void> joinLottery(@PathVariable Long id, Authentication auth) { public ResponseEntity<Void> joinLottery(@PathVariable Long id, Authentication auth) {
postService.joinLottery(id, auth.getName()); postService.joinLottery(id, auth.getName());
return ResponseEntity.ok().build(); return ResponseEntity.ok().build();
} }
@GetMapping("/{id}/poll/progress") @GetMapping("/{id}/poll/progress")
@Operation(summary = "Poll progress", description = "Get poll progress for a post") @Operation(summary = "Poll progress", description = "Get poll progress for a post")
@ApiResponse( @ApiResponse(responseCode = "200", description = "Poll progress",
responseCode = "200", content = @Content(schema = @Schema(implementation = PollDto.class)))
description = "Poll progress", public ResponseEntity<PollDto> pollProgress(@PathVariable Long id) {
content = @Content(schema = @Schema(implementation = PollDto.class)) return ResponseEntity.ok(postMapper.toSummaryDto(postService.getPoll(id)).getPoll());
) }
public ResponseEntity<PollDto> pollProgress(@PathVariable Long id) {
return ResponseEntity.ok(postMapper.toSummaryDto(postService.getPoll(id)).getPoll());
}
@PostMapping("/{id}/poll/vote") @PostMapping("/{id}/poll/vote")
@SecurityRequirement(name = "JWT") @SecurityRequirement(name = "JWT")
@Operation(summary = "Vote poll", description = "Vote on a poll option") @Operation(summary = "Vote poll", description = "Vote on a poll option")
@ApiResponse(responseCode = "200", description = "Vote recorded") @ApiResponse(responseCode = "200", description = "Vote recorded")
public ResponseEntity<Void> vote( public ResponseEntity<Void> vote(@PathVariable Long id, @RequestParam("option") List<Integer> option, Authentication auth) {
@PathVariable Long id, postService.votePoll(id, auth.getName(), option);
@RequestParam("option") List<Integer> option, return ResponseEntity.ok().build();
Authentication auth }
) {
postService.votePoll(id, auth.getName(), option);
return ResponseEntity.ok().build();
}
@GetMapping @GetMapping
@Operation(summary = "List posts", description = "List posts by various filters") @Operation(summary = "List posts", description = "List posts by various filters")
@ApiResponse( @ApiResponse(responseCode = "200", description = "List of posts",
responseCode = "200", content = @Content(array = @ArraySchema(schema = @Schema(implementation = PostSummaryDto.class))))
description = "List of posts", public List<PostSummaryDto> listPosts(@RequestParam(value = "categoryId", required = false) Long categoryId,
content = @Content( @RequestParam(value = "categoryIds", required = false) List<Long> categoryIds,
array = @ArraySchema(schema = @Schema(implementation = PostSummaryDto.class)) @RequestParam(value = "tagId", required = false) Long tagId,
) @RequestParam(value = "tagIds", required = false) List<Long> tagIds,
) @RequestParam(value = "page", required = false) Integer page,
@Cacheable( @RequestParam(value = "pageSize", required = false) Integer pageSize,
value = CachingConfig.POST_CACHE_NAME, Authentication auth) {
key = "new org.springframework.cache.interceptor.SimpleKey('default', #categoryId, #categoryIds, #tagId, #tagIds, #page, #pageSize)"
)
public List<PostSummaryDto> listPosts(
@RequestParam(value = "categoryId", required = false) Long categoryId,
@RequestParam(value = "categoryIds", required = false) List<Long> categoryIds,
@RequestParam(value = "tagId", required = false) Long tagId,
@RequestParam(value = "tagIds", required = false) List<Long> tagIds,
@RequestParam(value = "page", required = false) Integer page,
@RequestParam(value = "pageSize", required = false) Integer pageSize,
Authentication auth
) {
List<Long> ids = categoryService.getSearchCategoryIds(categoryIds, categoryId);
List<Long> tids = tagService.getSearchTagIds(tagIds, tagId);
// 只需要在请求的一开始统计一次
// if (auth != null) {
// userVisitService.recordVisit(auth.getName());
// }
return postService List<Long> ids = categoryService.getSearchCategoryIds(categoryIds, categoryId);
.defaultListPosts(ids, tids, page, pageSize) List<Long> tids = tagService.getSearchTagIds(tagIds, tagId);
.stream() // 只需要在请求的一开始统计一次
.map(postMapper::toSummaryDto) // if (auth != null) {
.collect(Collectors.toList()); // userVisitService.recordVisit(auth.getName());
} // }
@GetMapping("/ranking") return postService.defaultListPosts(ids,tids,page, pageSize).stream()
@Operation(summary = "Ranking posts", description = "List posts by view rankings") .map(postMapper::toSummaryDto).collect(Collectors.toList());
@ApiResponse( }
responseCode = "200",
description = "Ranked posts",
content = @Content(
array = @ArraySchema(schema = @Schema(implementation = PostSummaryDto.class))
)
)
public List<PostSummaryDto> rankingPosts(
@RequestParam(value = "categoryId", required = false) Long categoryId,
@RequestParam(value = "categoryIds", required = false) List<Long> categoryIds,
@RequestParam(value = "tagId", required = false) Long tagId,
@RequestParam(value = "tagIds", required = false) List<Long> tagIds,
@RequestParam(value = "page", required = false) Integer page,
@RequestParam(value = "pageSize", required = false) Integer pageSize,
Authentication auth
) {
List<Long> ids = categoryService.getSearchCategoryIds(categoryIds, categoryId);
List<Long> tids = tagService.getSearchTagIds(tagIds, tagId);
// 只需要在请求的一开始统计一次
// if (auth != null) {
// userVisitService.recordVisit(auth.getName());
// }
return postService @GetMapping("/ranking")
.listPostsByViews(ids, tids, page, pageSize) @Operation(summary = "Ranking posts", description = "List posts by view rankings")
.stream() @ApiResponse(responseCode = "200", description = "Ranked posts",
.map(postMapper::toSummaryDto) content = @Content(array = @ArraySchema(schema = @Schema(implementation = PostSummaryDto.class))))
.collect(Collectors.toList()); public List<PostSummaryDto> rankingPosts(@RequestParam(value = "categoryId", required = false) Long categoryId,
} @RequestParam(value = "categoryIds", required = false) List<Long> categoryIds,
@RequestParam(value = "tagId", required = false) Long tagId,
@RequestParam(value = "tagIds", required = false) List<Long> tagIds,
@RequestParam(value = "page", required = false) Integer page,
@RequestParam(value = "pageSize", required = false) Integer pageSize,
Authentication auth) {
@GetMapping("/latest-reply") List<Long> ids = categoryService.getSearchCategoryIds(categoryIds, categoryId);
@Operation(summary = "Latest reply posts", description = "List posts by latest replies") List<Long> tids = tagService.getSearchTagIds(tagIds, tagId);
@ApiResponse( // 只需要在请求的一开始统计一次
responseCode = "200", // if (auth != null) {
description = "Posts sorted by latest reply", // userVisitService.recordVisit(auth.getName());
content = @Content( // }
array = @ArraySchema(schema = @Schema(implementation = PostSummaryDto.class))
)
)
@Cacheable(
value = CachingConfig.POST_CACHE_NAME,
key = "new org.springframework.cache.interceptor.SimpleKey('latest_reply', #categoryId, #categoryIds, #tagIds, #page, #pageSize)"
)
public List<PostSummaryDto> latestReplyPosts(
@RequestParam(value = "categoryId", required = false) Long categoryId,
@RequestParam(value = "categoryIds", required = false) List<Long> categoryIds,
@RequestParam(value = "tagId", required = false) Long tagId,
@RequestParam(value = "tagIds", required = false) List<Long> tagIds,
@RequestParam(value = "page", required = false) Integer page,
@RequestParam(value = "pageSize", required = false) Integer pageSize,
Authentication auth
) {
List<Long> ids = categoryService.getSearchCategoryIds(categoryIds, categoryId);
List<Long> tids = tagService.getSearchTagIds(tagIds, tagId);
// 只需要在请求的一开始统计一次
// if (auth != null) {
// userVisitService.recordVisit(auth.getName());
// }
List<Post> posts = postService.listPostsByLatestReply(ids, tids, page, pageSize); return postService.listPostsByViews(ids, tids, page, pageSize)
return posts.stream().map(postMapper::toSummaryDto).collect(Collectors.toList()); .stream().map(postMapper::toSummaryDto).collect(Collectors.toList());
} }
@GetMapping("/featured") @GetMapping("/latest-reply")
@Operation(summary = "Featured posts", description = "List featured posts") @Operation(summary = "Latest reply posts", description = "List posts by latest replies")
@ApiResponse( @ApiResponse(responseCode = "200", description = "Posts sorted by latest reply",
responseCode = "200", content = @Content(array = @ArraySchema(schema = @Schema(implementation = PostSummaryDto.class))))
description = "Featured posts", public List<PostSummaryDto> latestReplyPosts(@RequestParam(value = "categoryId", required = false) Long categoryId,
content = @Content( @RequestParam(value = "categoryIds", required = false) List<Long> categoryIds,
array = @ArraySchema(schema = @Schema(implementation = PostSummaryDto.class)) @RequestParam(value = "tagId", required = false) Long tagId,
) @RequestParam(value = "tagIds", required = false) List<Long> tagIds,
) @RequestParam(value = "page", required = false) Integer page,
public List<PostSummaryDto> featuredPosts( @RequestParam(value = "pageSize", required = false) Integer pageSize,
@RequestParam(value = "categoryId", required = false) Long categoryId, Authentication auth) {
@RequestParam(value = "categoryIds", required = false) List<Long> categoryIds,
@RequestParam(value = "tagId", required = false) Long tagId, List<Long> ids = categoryService.getSearchCategoryIds(categoryIds, categoryId);
@RequestParam(value = "tagIds", required = false) List<Long> tagIds, List<Long> tids = tagService.getSearchTagIds(tagIds, tagId);
@RequestParam(value = "page", required = false) Integer page, // 只需要在请求的一开始统计一次
@RequestParam(value = "pageSize", required = false) Integer pageSize, // if (auth != null) {
Authentication auth // userVisitService.recordVisit(auth.getName());
) { // }
List<Long> ids = categoryService.getSearchCategoryIds(categoryIds, categoryId);
List<Long> tids = tagService.getSearchTagIds(tagIds, tagId); List<Post> posts = postService.listPostsByLatestReply(ids, tids, page, pageSize);
// 只需要在请求的一开始统计一次 return posts.stream().map(postMapper::toSummaryDto).collect(Collectors.toList());
// if (auth != null) { }
// userVisitService.recordVisit(auth.getName());
// } @GetMapping("/featured")
return postService @Operation(summary = "Featured posts", description = "List featured posts")
.listFeaturedPosts(ids, tids, page, pageSize) @ApiResponse(responseCode = "200", description = "Featured posts",
.stream() content = @Content(array = @ArraySchema(schema = @Schema(implementation = PostSummaryDto.class))))
.map(postMapper::toSummaryDto) public List<PostSummaryDto> featuredPosts(@RequestParam(value = "categoryId", required = false) Long categoryId,
.collect(Collectors.toList()); @RequestParam(value = "categoryIds", required = false) List<Long> categoryIds,
} @RequestParam(value = "tagId", required = false) Long tagId,
@RequestParam(value = "tagIds", required = false) List<Long> tagIds,
@RequestParam(value = "page", required = false) Integer page,
@RequestParam(value = "pageSize", required = false) Integer pageSize,
Authentication auth) {
List<Long> ids = categoryService.getSearchCategoryIds(categoryIds, categoryId);
List<Long> tids = tagService.getSearchTagIds(tagIds, tagId);
// 只需要在请求的一开始统计一次
// if (auth != null) {
// userVisitService.recordVisit(auth.getName());
// }
return postService.listFeaturedPosts(ids, tids, page, pageSize)
.stream().map(postMapper::toSummaryDto).collect(Collectors.toList());
}
} }

View File

@@ -3,49 +3,39 @@ package com.openisle.controller;
import com.openisle.dto.PushPublicKeyDto; import com.openisle.dto.PushPublicKeyDto;
import com.openisle.dto.PushSubscriptionRequest; import com.openisle.dto.PushSubscriptionRequest;
import com.openisle.service.PushSubscriptionService; import com.openisle.service.PushSubscriptionService;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
@RestController @RestController
@RequestMapping("/api/push") @RequestMapping("/api/push")
@RequiredArgsConstructor @RequiredArgsConstructor
public class PushSubscriptionController { public class PushSubscriptionController {
private final PushSubscriptionService pushSubscriptionService;
@Value("${app.webpush.public-key}")
private String publicKey;
private final PushSubscriptionService pushSubscriptionService; @GetMapping("/public-key")
@Operation(summary = "Get public key", description = "Retrieve web push public key")
@ApiResponse(responseCode = "200", description = "Public key",
content = @Content(schema = @Schema(implementation = PushPublicKeyDto.class)))
public PushPublicKeyDto getPublicKey() {
PushPublicKeyDto r = new PushPublicKeyDto();
r.setKey(publicKey);
return r;
}
@Value("${app.webpush.public-key}") @PostMapping("/subscribe")
private String publicKey; @Operation(summary = "Subscribe", description = "Subscribe to push notifications")
@ApiResponse(responseCode = "200", description = "Subscribed")
@GetMapping("/public-key") @SecurityRequirement(name = "JWT")
@Operation(summary = "Get public key", description = "Retrieve web push public key") public void subscribe(@RequestBody PushSubscriptionRequest req, Authentication auth) {
@ApiResponse( pushSubscriptionService.saveSubscription(auth.getName(), req.getEndpoint(), req.getP256dh(), req.getAuth());
responseCode = "200", }
description = "Public key",
content = @Content(schema = @Schema(implementation = PushPublicKeyDto.class))
)
public PushPublicKeyDto getPublicKey() {
PushPublicKeyDto r = new PushPublicKeyDto();
r.setKey(publicKey);
return r;
}
@PostMapping("/subscribe")
@Operation(summary = "Subscribe", description = "Subscribe to push notifications")
@ApiResponse(responseCode = "200", description = "Subscribed")
@SecurityRequirement(name = "JWT")
public void subscribe(@RequestBody PushSubscriptionRequest req, Authentication auth) {
pushSubscriptionService.saveSubscription(
auth.getName(),
req.getEndpoint(),
req.getP256dh(),
req.getAuth()
);
}
} }

View File

@@ -8,107 +8,88 @@ import com.openisle.model.ReactionType;
import com.openisle.service.LevelService; import com.openisle.service.LevelService;
import com.openisle.service.PointService; import com.openisle.service.PointService;
import com.openisle.service.ReactionService; import com.openisle.service.ReactionService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
@RestController @RestController
@RequestMapping("/api") @RequestMapping("/api")
@RequiredArgsConstructor @RequiredArgsConstructor
public class ReactionController { public class ReactionController {
private final ReactionService reactionService;
private final LevelService levelService;
private final ReactionMapper reactionMapper;
private final PointService pointService;
private final ReactionService reactionService; /**
private final LevelService levelService; * Get all available reaction types.
private final ReactionMapper reactionMapper; */
private final PointService pointService; @GetMapping("/reaction-types")
@Operation(summary = "List reaction types", description = "Get all available reaction types")
/** @ApiResponse(responseCode = "200", description = "Reaction types",
* Get all available reaction types. content = @Content(schema = @Schema(implementation = ReactionType[].class)))
*/ public ReactionType[] listReactionTypes() {
@GetMapping("/reaction-types") return ReactionType.values();
@Operation(summary = "List reaction types", description = "Get all available reaction types")
@ApiResponse(
responseCode = "200",
description = "Reaction types",
content = @Content(schema = @Schema(implementation = ReactionType[].class))
)
public ReactionType[] listReactionTypes() {
return ReactionType.values();
}
@PostMapping("/posts/{postId}/reactions")
@Operation(summary = "React to post", description = "React to a post")
@ApiResponse(
responseCode = "200",
description = "Reaction result",
content = @Content(schema = @Schema(implementation = ReactionDto.class))
)
@SecurityRequirement(name = "JWT")
public ResponseEntity<ReactionDto> reactToPost(
@PathVariable Long postId,
@RequestBody ReactionRequest req,
Authentication auth
) {
Reaction reaction = reactionService.reactToPost(auth.getName(), postId, req.getType());
if (reaction == null) {
pointService.deductForReactionOfPost(auth.getName(), postId);
return ResponseEntity.noContent().build();
} }
ReactionDto dto = reactionMapper.toDto(reaction);
dto.setReward(levelService.awardForReaction(auth.getName()));
pointService.awardForReactionOfPost(auth.getName(), postId);
return ResponseEntity.ok(dto);
}
@PostMapping("/comments/{commentId}/reactions") @PostMapping("/posts/{postId}/reactions")
@Operation(summary = "React to comment", description = "React to a comment") @Operation(summary = "React to post", description = "React to a post")
@ApiResponse( @ApiResponse(responseCode = "200", description = "Reaction result",
responseCode = "200", content = @Content(schema = @Schema(implementation = ReactionDto.class)))
description = "Reaction result", @SecurityRequirement(name = "JWT")
content = @Content(schema = @Schema(implementation = ReactionDto.class)) public ResponseEntity<ReactionDto> reactToPost(@PathVariable Long postId,
) @RequestBody ReactionRequest req,
@SecurityRequirement(name = "JWT") Authentication auth) {
public ResponseEntity<ReactionDto> reactToComment( Reaction reaction = reactionService.reactToPost(auth.getName(), postId, req.getType());
@PathVariable Long commentId, if (reaction == null) {
@RequestBody ReactionRequest req, pointService.deductForReactionOfPost(auth.getName(), postId);
Authentication auth return ResponseEntity.noContent().build();
) { }
Reaction reaction = reactionService.reactToComment(auth.getName(), commentId, req.getType()); ReactionDto dto = reactionMapper.toDto(reaction);
if (reaction == null) { dto.setReward(levelService.awardForReaction(auth.getName()));
pointService.deductForReactionOfComment(auth.getName(), commentId); pointService.awardForReactionOfPost(auth.getName(), postId);
return ResponseEntity.noContent().build(); return ResponseEntity.ok(dto);
} }
ReactionDto dto = reactionMapper.toDto(reaction);
dto.setReward(levelService.awardForReaction(auth.getName()));
pointService.awardForReactionOfComment(auth.getName(), commentId);
return ResponseEntity.ok(dto);
}
@PostMapping("/messages/{messageId}/reactions") @PostMapping("/comments/{commentId}/reactions")
@Operation(summary = "React to message", description = "React to a message") @Operation(summary = "React to comment", description = "React to a comment")
@ApiResponse( @ApiResponse(responseCode = "200", description = "Reaction result",
responseCode = "200", content = @Content(schema = @Schema(implementation = ReactionDto.class)))
description = "Reaction result", @SecurityRequirement(name = "JWT")
content = @Content(schema = @Schema(implementation = ReactionDto.class)) public ResponseEntity<ReactionDto> reactToComment(@PathVariable Long commentId,
) @RequestBody ReactionRequest req,
@SecurityRequirement(name = "JWT") Authentication auth) {
public ResponseEntity<ReactionDto> reactToMessage( Reaction reaction = reactionService.reactToComment(auth.getName(), commentId, req.getType());
@PathVariable Long messageId, if (reaction == null) {
@RequestBody ReactionRequest req, pointService.deductForReactionOfComment(auth.getName(), commentId);
Authentication auth return ResponseEntity.noContent().build();
) { }
Reaction reaction = reactionService.reactToMessage(auth.getName(), messageId, req.getType()); ReactionDto dto = reactionMapper.toDto(reaction);
if (reaction == null) { dto.setReward(levelService.awardForReaction(auth.getName()));
return ResponseEntity.noContent().build(); pointService.awardForReactionOfComment(auth.getName(), commentId);
return ResponseEntity.ok(dto);
}
@PostMapping("/messages/{messageId}/reactions")
@Operation(summary = "React to message", description = "React to a message")
@ApiResponse(responseCode = "200", description = "Reaction result",
content = @Content(schema = @Schema(implementation = ReactionDto.class)))
@SecurityRequirement(name = "JWT")
public ResponseEntity<ReactionDto> reactToMessage(@PathVariable Long messageId,
@RequestBody ReactionRequest req,
Authentication auth) {
Reaction reaction = reactionService.reactToMessage(auth.getName(), messageId, req.getType());
if (reaction == null) {
return ResponseEntity.noContent().build();
}
ReactionDto dto = reactionMapper.toDto(reaction);
dto.setReward(levelService.awardForReaction(auth.getName()));
return ResponseEntity.ok(dto);
} }
ReactionDto dto = reactionMapper.toDto(reaction);
dto.setReward(levelService.awardForReaction(auth.getName()));
return ResponseEntity.ok(dto);
}
} }

View File

@@ -1,28 +1,10 @@
package com.openisle.controller; package com.openisle.controller;
import com.openisle.model.Post;
import com.openisle.model.Comment; import com.openisle.model.Comment;
import com.openisle.model.CommentSort; import com.openisle.model.CommentSort;
import com.openisle.model.Post;
import com.openisle.service.CommentService;
import com.openisle.service.PostService; import com.openisle.service.PostService;
import com.vladsch.flexmark.ext.autolink.AutolinkExtension; import com.openisle.service.CommentService;
import com.vladsch.flexmark.ext.gfm.strikethrough.StrikethroughExtension;
import com.vladsch.flexmark.ext.gfm.tasklist.TaskListExtension;
import com.vladsch.flexmark.ext.tables.TablesExtension;
import com.vladsch.flexmark.html.HtmlRenderer;
import com.vladsch.flexmark.parser.Parser;
import com.vladsch.flexmark.util.data.MutableDataSet;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import java.net.URI;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.util.*;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.jsoup.Jsoup; import org.jsoup.Jsoup;
import org.jsoup.nodes.Document; import org.jsoup.nodes.Document;
@@ -31,376 +13,346 @@ import org.jsoup.safety.Safelist;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import com.vladsch.flexmark.ext.autolink.AutolinkExtension;
import com.vladsch.flexmark.ext.tables.TablesExtension;
import com.vladsch.flexmark.ext.gfm.strikethrough.StrikethroughExtension;
import com.vladsch.flexmark.ext.gfm.tasklist.TaskListExtension;
import com.vladsch.flexmark.html.HtmlRenderer;
import com.vladsch.flexmark.parser.Parser;
import com.vladsch.flexmark.util.data.MutableDataSet;
import java.net.URI;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.util.*;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@RestController @RestController
@RequiredArgsConstructor @RequiredArgsConstructor
public class RssController { public class RssController {
private final PostService postService;
private final CommentService commentService;
private final PostService postService; @Value("${app.website-url:https://www.open-isle.com}")
private final CommentService commentService; private String websiteUrl;
@Value("${app.website-url:https://www.open-isle.com}") // 兼容 Markdown/HTML 两类图片写法(用于 enclosure
private String websiteUrl; private static final Pattern MD_IMAGE = Pattern.compile("!\\[[^\\]]*\\]\\(([^)]+)\\)");
private static final Pattern HTML_IMAGE = Pattern.compile("<BaseImage[^>]+src=[\"']?([^\"'>]+)[\"']?[^>]*>");
// 兼容 Markdown/HTML 两类图片写法(用于 enclosure private static final DateTimeFormatter RFC1123 = DateTimeFormatter.RFC_1123_DATE_TIME;
private static final Pattern MD_IMAGE = Pattern.compile("!\\[[^\\]]*\\]\\(([^)]+)\\)");
private static final Pattern HTML_IMAGE = Pattern.compile(
"<BaseImage[^>]+src=[\"']?([^\"'>]+)[\"']?[^>]*>"
);
private static final DateTimeFormatter RFC1123 = DateTimeFormatter.RFC_1123_DATE_TIME; // flexmarkMarkdown -> HTML
private static final Parser MD_PARSER;
// flexmarkMarkdown -> HTML private static final HtmlRenderer MD_RENDERER;
private static final Parser MD_PARSER; static {
private static final HtmlRenderer MD_RENDERER; MutableDataSet opts = new MutableDataSet();
opts.set(Parser.EXTENSIONS, Arrays.asList(
static { TablesExtension.create(),
MutableDataSet opts = new MutableDataSet(); AutolinkExtension.create(),
opts.set( StrikethroughExtension.create(),
Parser.EXTENSIONS, TaskListExtension.create()
Arrays.asList( ));
TablesExtension.create(), // 允许内联 HTML下游再做 sanitize
AutolinkExtension.create(), opts.set(Parser.HTML_BLOCK_PARSER, true);
StrikethroughExtension.create(), MD_PARSER = Parser.builder(opts).build();
TaskListExtension.create() MD_RENDERER = HtmlRenderer.builder(opts).escapeHtml(false).build();
)
);
// 允许内联 HTML下游再做 sanitize
opts.set(Parser.HTML_BLOCK_PARSER, true);
MD_PARSER = Parser.builder(opts).build();
MD_RENDERER = HtmlRenderer.builder(opts).escapeHtml(false).build();
}
@GetMapping(value = "/api/rss", produces = "application/rss+xml;charset=UTF-8")
@Operation(summary = "RSS feed", description = "Generate RSS feed for latest posts")
@ApiResponse(
responseCode = "200",
description = "RSS XML",
content = @Content(schema = @Schema(implementation = String.class))
)
public String feed() {
// 建议 20你现在是 10这里保留你的 10
List<Post> posts = postService.listLatestRssPosts(10);
String base = trimTrailingSlash(websiteUrl);
StringBuilder sb = new StringBuilder(4096);
sb.append("<?xml version=\"1.0\" encoding=\"UTF-8\"?>");
sb.append("<rss version=\"2.0\" xmlns:content=\"http://purl.org/rss/1.0/modules/content/\">");
sb.append("<channel>");
elem(sb, "title", cdata("OpenIsle RSS"));
elem(sb, "link", base + "/");
elem(sb, "description", cdata("Latest posts"));
ZonedDateTime updated = posts
.stream()
.map(p -> p.getCreatedAt().atZone(ZoneId.systemDefault()))
.max(Comparator.naturalOrder())
.orElse(ZonedDateTime.now());
// channel lastBuildDateGMT
elem(sb, "lastBuildDate", toRfc1123Gmt(updated));
for (Post p : posts) {
String link = base + "/posts/" + p.getId();
// 1) Markdown -> HTML
String html = renderMarkdown(p.getContent());
// 2) Sanitize白名单增强
String safeHtml = sanitizeHtml(html);
// 3) 绝对化 href/src + 强制 rel/target
String absHtml = absolutifyHtml(safeHtml, base);
// 4) 纯文本摘要(用于 <description>
String plain = textSummary(absHtml, 180);
// 5) enclosure首图已绝对化
String enclosure = firstImage(p.getContent());
if (enclosure == null) {
// 如果 Markdown 没有图,尝试从渲染后的 HTML 再抓一次
enclosure = firstImage(absHtml);
}
if (enclosure != null) {
enclosure = absolutifyUrl(enclosure, base);
}
// 6) 构造优雅的附加区块(原文链接 + 精选评论),编入 <content:encoded>
List<Comment> topComments = commentService.getCommentsForPost(
p.getId(),
CommentSort.MOST_INTERACTIONS
);
topComments = topComments.subList(0, Math.min(10, topComments.size()));
String footerHtml = buildFooterHtml(base, link, topComments);
sb.append("<item>");
elem(sb, "title", cdata(nullSafe(p.getTitle())));
elem(sb, "link", link);
sb.append("<guid isPermaLink=\"true\">").append(link).append("</guid>");
elem(sb, "pubDate", toRfc1123Gmt(p.getCreatedAt().atZone(ZoneId.systemDefault())));
// 摘要
elem(sb, "description", cdata(plain));
// 全文HTML正文 + 优雅的 Markdown 区块(已转 HTML
sb
.append("<content:encoded><![CDATA[")
.append(absHtml)
.append(footerHtml)
.append("]]></content:encoded>");
// 首图 enclosure图片类型
if (enclosure != null) {
sb
.append("<enclosure url=\"")
.append(escapeXml(enclosure))
.append("\" type=\"")
.append(getMimeType(enclosure))
.append("\" />");
}
sb.append("</item>");
} }
sb.append("</channel></rss>"); @GetMapping(value = "/api/rss", produces = "application/rss+xml;charset=UTF-8")
return sb.toString(); @Operation(summary = "RSS feed", description = "Generate RSS feed for latest posts")
} @ApiResponse(responseCode = "200", description = "RSS XML", content = @Content(schema = @Schema(implementation = String.class)))
public String feed() {
// 建议 20你现在是 10这里保留你的 10
List<Post> posts = postService.listLatestRssPosts(10);
String base = trimTrailingSlash(websiteUrl);
/* ===================== Markdown → HTML ===================== */ StringBuilder sb = new StringBuilder(4096);
sb.append("<?xml version=\"1.0\" encoding=\"UTF-8\"?>");
sb.append("<rss version=\"2.0\" xmlns:content=\"http://purl.org/rss/1.0/modules/content/\">");
sb.append("<channel>");
elem(sb, "title", cdata("OpenIsle RSS"));
elem(sb, "link", base + "/");
elem(sb, "description", cdata("Latest posts"));
ZonedDateTime updated = posts.stream()
.map(p -> p.getCreatedAt().atZone(ZoneId.systemDefault()))
.max(Comparator.naturalOrder())
.orElse(ZonedDateTime.now());
// channel lastBuildDateGMT
elem(sb, "lastBuildDate", toRfc1123Gmt(updated));
private static String renderMarkdown(String md) { for (Post p : posts) {
if (md == null || md.isEmpty()) return ""; String link = base + "/posts/" + p.getId();
return MD_RENDERER.render(MD_PARSER.parse(md));
}
/* ===================== Sanitize & 绝对化 ===================== */ // 1) Markdown -> HTML
String html = renderMarkdown(p.getContent());
private static String sanitizeHtml(String html) { // 2) Sanitize白名单增强
if (html == null) return ""; String safeHtml = sanitizeHtml(html);
Safelist wl = Safelist.relaxed()
.addTags(
"pre",
"code",
"figure",
"figcaption",
"picture",
"source",
"table",
"thead",
"tbody",
"tr",
"th",
"td",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"hr",
"blockquote"
)
.addAttributes("a", "href", "title", "target", "rel")
.addAttributes("img", "src", "alt", "title", "width", "height")
.addAttributes("source", "srcset", "type", "media")
.addAttributes("code", "class")
.addAttributes("pre", "class")
.addProtocols("a", "href", "http", "https", "mailto")
.addProtocols("img", "src", "http", "https", "data")
.addProtocols("source", "srcset", "http", "https");
// 清除所有 on* 事件、style避免阅读器环境差异
return Jsoup.clean(html, wl);
}
private static String absolutifyHtml(String html, String baseUrl) { // 3) 绝对化 href/src + 强制 rel/target
if (html == null || html.isEmpty()) return ""; String absHtml = absolutifyHtml(safeHtml, base);
Document doc = Jsoup.parseBodyFragment(html, baseUrl);
// a[href]
for (Element a : doc.select("a[href]")) {
String href = a.attr("href");
String abs = absolutifyUrl(href, baseUrl);
a.attr("href", abs);
// 强制外链安全属性
a.attr("rel", "noopener noreferrer nofollow");
a.attr("target", "_blank");
}
// img[src]
for (Element img : doc.select("img[src]")) {
String src = img.attr("src");
String abs = absolutifyUrl(src, baseUrl);
img.attr("src", abs);
}
// source[srcset] picture/webp
for (Element s : doc.select("source[srcset]")) {
String srcset = s.attr("srcset");
s.attr("srcset", absolutifySrcset(srcset, baseUrl));
}
return doc.body().html();
}
private static String absolutifyUrl(String url, String baseUrl) { // 4) 纯文本摘要(用于 <description>
if (url == null || url.isEmpty()) return url; String plain = textSummary(absHtml, 180);
String u = url.trim();
if (u.startsWith("//")) {
return "https:" + u;
}
if (u.startsWith("#")) {
// 保留页面内锚点:拼接到首页(也可拼接到当前帖子的 link但此处无上下文
return baseUrl + "/" + u;
}
try {
URI base = URI.create(ensureTrailingSlash(baseUrl));
URI abs = base.resolve(u);
return abs.toString();
} catch (Exception e) {
return url;
}
}
private static String absolutifySrcset(String srcset, String baseUrl) { // 5) enclosure首图已绝对化
if (srcset == null || srcset.isEmpty()) return srcset; String enclosure = firstImage(p.getContent());
String[] parts = srcset.split(","); if (enclosure == null) {
List<String> out = new ArrayList<>(parts.length); // 如果 Markdown 没有图,尝试从渲染后的 HTML 再抓一次
for (String part : parts) { enclosure = firstImage(absHtml);
String p = part.trim(); }
if (p.isEmpty()) continue; if (enclosure != null) {
String[] seg = p.split("\\s+"); enclosure = absolutifyUrl(enclosure, base);
String url = seg[0]; }
String size = seg.length > 1 ? seg[1] : "";
out.add(absolutifyUrl(url, baseUrl) + (size.isEmpty() ? "" : " " + size));
}
return String.join(", ", out);
}
/* ===================== 摘要 & enclosure ===================== */ // 6) 构造优雅的附加区块(原文链接 + 精选评论),编入 <content:encoded>
List<Comment> topComments = commentService
.getCommentsForPost(p.getId(), CommentSort.MOST_INTERACTIONS);
topComments = topComments.subList(0, Math.min(10, topComments.size()));
String footerHtml = buildFooterHtml(base, link, topComments);
private static String textSummary(String html, int maxLen) { sb.append("<item>");
if (html == null) return ""; elem(sb, "title", cdata(nullSafe(p.getTitle())));
String text = Jsoup.parse(html).text().replaceAll("\\s+", " ").trim(); elem(sb, "link", link);
if (text.length() <= maxLen) return text; sb.append("<guid isPermaLink=\"true\">").append(link).append("</guid>");
return text.substring(0, maxLen) + ""; elem(sb, "pubDate", toRfc1123Gmt(p.getCreatedAt().atZone(ZoneId.systemDefault())));
} // 摘要
elem(sb, "description", cdata(plain));
// 全文HTML正文 + 优雅的 Markdown 区块(已转 HTML
sb.append("<content:encoded><![CDATA[")
.append(absHtml)
.append(footerHtml)
.append("]]></content:encoded>");
// 首图 enclosure图片类型
if (enclosure != null) {
sb.append("<enclosure url=\"").append(escapeXml(enclosure)).append("\" type=\"")
.append(getMimeType(enclosure)).append("\" />");
}
sb.append("</item>");
}
private String firstImage(String content) { sb.append("</channel></rss>");
if (content == null) return null; return sb.toString();
Matcher m = MD_IMAGE.matcher(content);
if (m.find()) return m.group(1);
m = HTML_IMAGE.matcher(content);
if (m.find()) return m.group(1);
// 再从纯 HTML 里解析一次(如果传入的是渲染后的)
try {
Document doc = Jsoup.parse(content);
Element img = doc.selectFirst("img[src]");
if (img != null) return img.attr("src");
} catch (Exception ignored) {}
return null;
}
private static String getMimeType(String url) {
String lower = url == null ? "" : url.toLowerCase(Locale.ROOT);
if (lower.endsWith(".png")) return "image/png";
if (lower.endsWith(".gif")) return "image/gif";
if (lower.endsWith(".webp")) return "image/webp";
if (lower.endsWith(".svg")) return "image/svg+xml";
if (lower.endsWith(".avif")) return "image/avif";
if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg";
// 默认兜底
return "image/jpeg";
}
/* ===================== 附加区块(原文链接 + 精选评论) ===================== */
/**
* 将“原文链接 + 精选评论(最多 10 条)”以优雅的 Markdown 形式渲染为 HTML
* 并做 sanitize + 绝对化,然后拼入 content:encoded 尾部。
*/
private static String buildFooterHtml(
String baseUrl,
String originalLink,
List<Comment> topComments
) {
StringBuilder md = new StringBuilder(256);
// 分割线
md.append("\n\n---\n\n");
// 原文链接(强调 + 可点击)
md
.append("**原文链接:** ")
.append("[")
.append(originalLink)
.append("](")
.append(originalLink)
.append(")")
.append("\n\n");
// 精选评论(仅当有评论时展示)
if (topComments != null && !topComments.isEmpty()) {
md.append("### 精选评论Top ").append(Math.min(10, topComments.size())).append("\n\n");
for (Comment c : topComments) {
String author = usernameOf(c);
String content = nullSafe(c.getContent()).replace("\r", "");
// 使用引用样式展示,提升可读性
md.append("> @").append(author).append(": ").append(content).append("\n\n");
}
} }
// 渲染为 HTML并保持和正文一致的处理流程 /* ===================== Markdown → HTML ===================== */
String html = renderMarkdown(md.toString());
String safe = sanitizeHtml(html);
return absolutifyHtml(safe, baseUrl);
}
private static String usernameOf(Comment c) { private static String renderMarkdown(String md) {
if (c == null) return "匿名"; if (md == null || md.isEmpty()) return "";
try { return MD_RENDERER.render(MD_PARSER.parse(md));
Object authorObj = c.getAuthor();
if (authorObj == null) return "匿名";
// 反射避免直接依赖实体字段名变化(也可直接强转到具体类型)
String username;
try {
username = (String) authorObj.getClass().getMethod("getUsername").invoke(authorObj);
} catch (Exception e) {
username = null;
}
if (username == null || username.isEmpty()) return "匿名";
return username;
} catch (Exception ignored) {
return "匿名";
} }
}
/* ===================== 时间/字符串/XML ===================== */ /* ===================== Sanitize & 绝对化 ===================== */
private static String toRfc1123Gmt(ZonedDateTime zdt) { private static String sanitizeHtml(String html) {
return zdt.withZoneSameInstant(ZoneId.of("GMT")).format(RFC1123); if (html == null) return "";
} Safelist wl = Safelist.relaxed()
.addTags(
"pre","code","figure","figcaption","picture","source",
"table","thead","tbody","tr","th","td",
"h1","h2","h3","h4","h5","h6",
"hr","blockquote"
)
.addAttributes("a", "href", "title", "target", "rel")
.addAttributes("img", "src", "alt", "title", "width", "height")
.addAttributes("source", "srcset", "type", "media")
.addAttributes("code", "class")
.addAttributes("pre", "class")
.addProtocols("a", "href", "http", "https", "mailto")
.addProtocols("img", "src", "http", "https", "data")
.addProtocols("source", "srcset", "http", "https");
// 清除所有 on* 事件、style避免阅读器环境差异
return Jsoup.clean(html, wl);
}
private static String cdata(String s) { private static String absolutifyHtml(String html, String baseUrl) {
if (s == null) return "<![CDATA[]]>"; if (html == null || html.isEmpty()) return "";
// 防止出现 "]]>" 终止标记破坏 CDATA Document doc = Jsoup.parseBodyFragment(html, baseUrl);
return "<![CDATA[" + s.replace("]]>", "]]]]><![CDATA[>") + "]]>"; // a[href]
} for (Element a : doc.select("a[href]")) {
String href = a.attr("href");
String abs = absolutifyUrl(href, baseUrl);
a.attr("href", abs);
// 强制外链安全属性
a.attr("rel", "noopener noreferrer nofollow");
a.attr("target", "_blank");
}
// img[src]
for (Element img : doc.select("img[src]")) {
String src = img.attr("src");
String abs = absolutifyUrl(src, baseUrl);
img.attr("src", abs);
}
// source[srcset] picture/webp
for (Element s : doc.select("source[srcset]")) {
String srcset = s.attr("srcset");
s.attr("srcset", absolutifySrcset(srcset, baseUrl));
}
return doc.body().html();
}
private static void elem(StringBuilder sb, String name, String value) { private static String absolutifyUrl(String url, String baseUrl) {
sb.append('<').append(name).append('>').append(value).append("</").append(name).append('>'); if (url == null || url.isEmpty()) return url;
} String u = url.trim();
if (u.startsWith("//")) {
return "https:" + u;
}
if (u.startsWith("#")) {
// 保留页面内锚点:拼接到首页(也可拼接到当前帖子的 link但此处无上下文
return baseUrl + "/" + u;
}
try {
URI base = URI.create(ensureTrailingSlash(baseUrl));
URI abs = base.resolve(u);
return abs.toString();
} catch (Exception e) {
return url;
}
}
private static String escapeXml(String s) { private static String absolutifySrcset(String srcset, String baseUrl) {
if (s == null) return ""; if (srcset == null || srcset.isEmpty()) return srcset;
return s String[] parts = srcset.split(",");
.replace("&", "&amp;") List<String> out = new ArrayList<>(parts.length);
.replace("<", "&lt;") for (String part : parts) {
.replace(">", "&gt;") String p = part.trim();
.replace("\"", "&quot;") if (p.isEmpty()) continue;
.replace("'", "&apos;"); String[] seg = p.split("\\s+");
} String url = seg[0];
String size = seg.length > 1 ? seg[1] : "";
out.add(absolutifyUrl(url, baseUrl) + (size.isEmpty() ? "" : " " + size));
}
return String.join(", ", out);
}
private static String trimTrailingSlash(String s) { /* ===================== 摘要 & enclosure ===================== */
if (s == null) return "";
return s.endsWith("/") ? s.substring(0, s.length() - 1) : s;
}
private static String ensureTrailingSlash(String s) { private static String textSummary(String html, int maxLen) {
if (s == null || s.isEmpty()) return "/"; if (html == null) return "";
return s.endsWith("/") ? s : s + "/"; String text = Jsoup.parse(html).text().replaceAll("\\s+", " ").trim();
} if (text.length() <= maxLen) return text;
return text.substring(0, maxLen) + "";
}
private static String nullSafe(String s) { private String firstImage(String content) {
return s == null ? "" : s; if (content == null) return null;
} Matcher m = MD_IMAGE.matcher(content);
if (m.find()) return m.group(1);
m = HTML_IMAGE.matcher(content);
if (m.find()) return m.group(1);
// 再从纯 HTML 里解析一次(如果传入的是渲染后的)
try {
Document doc = Jsoup.parse(content);
Element img = doc.selectFirst("img[src]");
if (img != null) return img.attr("src");
} catch (Exception ignored) {}
return null;
}
private static String getMimeType(String url) {
String lower = url == null ? "" : url.toLowerCase(Locale.ROOT);
if (lower.endsWith(".png")) return "image/png";
if (lower.endsWith(".gif")) return "image/gif";
if (lower.endsWith(".webp")) return "image/webp";
if (lower.endsWith(".svg")) return "image/svg+xml";
if (lower.endsWith(".avif")) return "image/avif";
if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg";
// 默认兜底
return "image/jpeg";
}
/* ===================== 附加区块(原文链接 + 精选评论) ===================== */
/**
* 将“原文链接 + 精选评论(最多 10 条)”以优雅的 Markdown 形式渲染为 HTML
* 并做 sanitize + 绝对化,然后拼入 content:encoded 尾部。
*/
private static String buildFooterHtml(String baseUrl, String originalLink, List<Comment> topComments) {
StringBuilder md = new StringBuilder(256);
// 分割线
md.append("\n\n---\n\n");
// 原文链接(强调 + 可点击)
md.append("**原文链接:** ")
.append("[").append(originalLink).append("](").append(originalLink).append(")")
.append("\n\n");
// 精选评论(仅当有评论时展示)
if (topComments != null && !topComments.isEmpty()) {
md.append("### 精选评论Top ").append(Math.min(10, topComments.size())).append("\n\n");
for (Comment c : topComments) {
String author = usernameOf(c);
String content = nullSafe(c.getContent()).replace("\r", "");
// 使用引用样式展示,提升可读性
md.append("> @").append(author).append(": ").append(content).append("\n\n");
}
}
// 渲染为 HTML并保持和正文一致的处理流程
String html = renderMarkdown(md.toString());
String safe = sanitizeHtml(html);
return absolutifyHtml(safe, baseUrl);
}
private static String usernameOf(Comment c) {
if (c == null) return "匿名";
try {
Object authorObj = c.getAuthor();
if (authorObj == null) return "匿名";
// 反射避免直接依赖实体字段名变化(也可直接强转到具体类型)
String username;
try {
username = (String) authorObj.getClass().getMethod("getUsername").invoke(authorObj);
} catch (Exception e) {
username = null;
}
if (username == null || username.isEmpty()) return "匿名";
return username;
} catch (Exception ignored) {
return "匿名";
}
}
/* ===================== 时间/字符串/XML ===================== */
private static String toRfc1123Gmt(ZonedDateTime zdt) {
return zdt.withZoneSameInstant(ZoneId.of("GMT")).format(RFC1123);
}
private static String cdata(String s) {
if (s == null) return "<![CDATA[]]>";
// 防止出现 "]]>" 终止标记破坏 CDATA
return "<![CDATA[" + s.replace("]]>", "]]]]><![CDATA[>") + "]]>";
}
private static void elem(StringBuilder sb, String name, String value) {
sb.append('<').append(name).append('>').append(value).append("</").append(name).append('>');
}
private static String escapeXml(String s) {
if (s == null) return "";
return s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
.replace("\"", "&quot;").replace("'", "&apos;");
}
private static String trimTrailingSlash(String s) {
if (s == null) return "";
return s.endsWith("/") ? s.substring(0, s.length() - 1) : s;
}
private static String ensureTrailingSlash(String s) {
if (s == null || s.isEmpty()) return "/";
return s.endsWith("/") ? s : s + "/";
}
private static String nullSafe(String s) { return s == null ? "" : s; }
} }

View File

@@ -6,117 +6,84 @@ import com.openisle.dto.UserDto;
import com.openisle.mapper.PostMapper; import com.openisle.mapper.PostMapper;
import com.openisle.mapper.UserMapper; import com.openisle.mapper.UserMapper;
import com.openisle.service.SearchService; import com.openisle.service.SearchService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.ArraySchema;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import java.util.List;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.ArraySchema;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import java.util.List;
import java.util.stream.Collectors;
@RestController @RestController
@RequestMapping("/api/search") @RequestMapping("/api/search")
@RequiredArgsConstructor @RequiredArgsConstructor
public class SearchController { public class SearchController {
private final SearchService searchService;
private final UserMapper userMapper;
private final PostMapper postMapper;
private final SearchService searchService; @GetMapping("/users")
private final UserMapper userMapper; @Operation(summary = "Search users", description = "Search users by keyword")
private final PostMapper postMapper; @ApiResponse(responseCode = "200", description = "List of users",
content = @Content(array = @ArraySchema(schema = @Schema(implementation = UserDto.class))))
public List<UserDto> searchUsers(@RequestParam String keyword) {
return searchService.searchUsers(keyword).stream()
.map(userMapper::toDto)
.collect(Collectors.toList());
}
@GetMapping("/users") @GetMapping("/posts")
@Operation(summary = "Search users", description = "Search users by keyword") @Operation(summary = "Search posts", description = "Search posts by keyword")
@ApiResponse( @ApiResponse(responseCode = "200", description = "List of posts",
responseCode = "200", content = @Content(array = @ArraySchema(schema = @Schema(implementation = PostSummaryDto.class))))
description = "List of users", public List<PostSummaryDto> searchPosts(@RequestParam String keyword) {
content = @Content(array = @ArraySchema(schema = @Schema(implementation = UserDto.class))) return searchService.searchPosts(keyword).stream()
) .map(postMapper::toSummaryDto)
public List<UserDto> searchUsers(@RequestParam String keyword) { .collect(Collectors.toList());
return searchService }
.searchUsers(keyword)
.stream()
.map(userMapper::toDto)
.collect(Collectors.toList());
}
@GetMapping("/posts") @GetMapping("/posts/content")
@Operation(summary = "Search posts", description = "Search posts by keyword") @Operation(summary = "Search posts by content", description = "Search posts by content keyword")
@ApiResponse( @ApiResponse(responseCode = "200", description = "List of posts",
responseCode = "200", content = @Content(array = @ArraySchema(schema = @Schema(implementation = PostSummaryDto.class))))
description = "List of posts", public List<PostSummaryDto> searchPostsByContent(@RequestParam String keyword) {
content = @Content( return searchService.searchPostsByContent(keyword).stream()
array = @ArraySchema(schema = @Schema(implementation = PostSummaryDto.class)) .map(postMapper::toSummaryDto)
) .collect(Collectors.toList());
) }
public List<PostSummaryDto> searchPosts(@RequestParam String keyword) {
return searchService
.searchPosts(keyword)
.stream()
.map(postMapper::toSummaryDto)
.collect(Collectors.toList());
}
@GetMapping("/posts/content") @GetMapping("/posts/title")
@Operation(summary = "Search posts by content", description = "Search posts by content keyword") @Operation(summary = "Search posts by title", description = "Search posts by title keyword")
@ApiResponse( @ApiResponse(responseCode = "200", description = "List of posts",
responseCode = "200", content = @Content(array = @ArraySchema(schema = @Schema(implementation = PostSummaryDto.class))))
description = "List of posts", public List<PostSummaryDto> searchPostsByTitle(@RequestParam String keyword) {
content = @Content( return searchService.searchPostsByTitle(keyword).stream()
array = @ArraySchema(schema = @Schema(implementation = PostSummaryDto.class)) .map(postMapper::toSummaryDto)
) .collect(Collectors.toList());
) }
public List<PostSummaryDto> searchPostsByContent(@RequestParam String keyword) {
return searchService
.searchPostsByContent(keyword)
.stream()
.map(postMapper::toSummaryDto)
.collect(Collectors.toList());
}
@GetMapping("/posts/title") @GetMapping("/global")
@Operation(summary = "Search posts by title", description = "Search posts by title keyword") @Operation(summary = "Global search", description = "Search users and posts globally")
@ApiResponse( @ApiResponse(responseCode = "200", description = "Search results",
responseCode = "200", content = @Content(array = @ArraySchema(schema = @Schema(implementation = SearchResultDto.class))))
description = "List of posts", public List<SearchResultDto> global(@RequestParam String keyword) {
content = @Content( return searchService.globalSearch(keyword).stream()
array = @ArraySchema(schema = @Schema(implementation = PostSummaryDto.class)) .map(r -> {
) SearchResultDto dto = new SearchResultDto();
) dto.setType(r.type());
public List<PostSummaryDto> searchPostsByTitle(@RequestParam String keyword) { dto.setId(r.id());
return searchService dto.setText(r.text());
.searchPostsByTitle(keyword) dto.setSubText(r.subText());
.stream() dto.setExtra(r.extra());
.map(postMapper::toSummaryDto) dto.setPostId(r.postId());
.collect(Collectors.toList()); return dto;
} })
.collect(Collectors.toList());
@GetMapping("/global") }
@Operation(summary = "Global search", description = "Search users and posts globally")
@ApiResponse(
responseCode = "200",
description = "Search results",
content = @Content(
array = @ArraySchema(schema = @Schema(implementation = SearchResultDto.class))
)
)
public List<SearchResultDto> global(@RequestParam String keyword) {
return searchService
.globalSearch(keyword)
.stream()
.map(r -> {
SearchResultDto dto = new SearchResultDto();
dto.setType(r.type());
dto.setId(r.id());
dto.setText(r.text());
dto.setSubText(r.subText());
dto.setExtra(r.extra());
dto.setPostId(r.postId());
return dto;
})
.collect(Collectors.toList());
}
} }

View File

@@ -3,11 +3,6 @@ package com.openisle.controller;
import com.openisle.model.Post; import com.openisle.model.Post;
import com.openisle.model.PostStatus; import com.openisle.model.PostStatus;
import com.openisle.repository.PostRepository; import com.openisle.repository.PostRepository;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import java.util.List;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
@@ -15,6 +10,12 @@ import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import java.util.List;
/** /**
* Controller for dynamic sitemap generation. * Controller for dynamic sitemap generation.
@@ -23,47 +24,53 @@ import org.springframework.web.bind.annotation.RestController;
@RequiredArgsConstructor @RequiredArgsConstructor
@RequestMapping("/api") @RequestMapping("/api")
public class SitemapController { public class SitemapController {
private final PostRepository postRepository;
private final PostRepository postRepository; @Value("${app.website-url}")
private String websiteUrl;
@Value("${app.website-url}") @GetMapping(value = "/sitemap.xml", produces = MediaType.APPLICATION_XML_VALUE)
private String websiteUrl; @Operation(summary = "Sitemap", description = "Generate sitemap xml")
@ApiResponse(responseCode = "200", description = "Sitemap xml",
content = @Content(schema = @Schema(implementation = String.class)))
public ResponseEntity<String> sitemap() {
List<Post> posts = postRepository.findByStatus(PostStatus.PUBLISHED);
@GetMapping(value = "/sitemap.xml", produces = MediaType.APPLICATION_XML_VALUE) StringBuilder body = new StringBuilder();
@Operation(summary = "Sitemap", description = "Generate sitemap xml") body.append("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
@ApiResponse( body.append("<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">\n");
responseCode = "200",
description = "Sitemap xml",
content = @Content(schema = @Schema(implementation = String.class))
)
public ResponseEntity<String> sitemap() {
List<Post> posts = postRepository.findByStatus(PostStatus.PUBLISHED);
StringBuilder body = new StringBuilder(); List<String> staticRoutes = List.of(
body.append("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"); "/",
body.append("<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">\n"); "/about",
"/activities",
"/login",
"/signup"
);
List<String> staticRoutes = List.of("/", "/about", "/activities", "/login", "/signup"); for (String path : staticRoutes) {
body.append(" <url><loc>")
.append(websiteUrl)
.append(path)
.append("</loc></url>\n");
}
for (String path : staticRoutes) { for (Post p : posts) {
body.append(" <url><loc>").append(websiteUrl).append(path).append("</loc></url>\n"); body.append(" <url>\n")
.append(" <loc>")
.append(websiteUrl)
.append("/posts/")
.append(p.getId())
.append("</loc>\n")
.append(" <lastmod>")
.append(p.getCreatedAt().toLocalDate())
.append("</lastmod>\n")
.append(" </url>\n");
}
body.append("</urlset>");
return ResponseEntity.ok()
.contentType(MediaType.APPLICATION_XML)
.body(body.toString());
} }
for (Post p : posts) {
body
.append(" <url>\n")
.append(" <loc>")
.append(websiteUrl)
.append("/posts/")
.append(p.getId())
.append("</loc>\n")
.append(" <lastmod>")
.append(p.getCreatedAt().toLocalDate())
.append("</lastmod>\n")
.append(" </url>\n");
}
body.append("</urlset>");
return ResponseEntity.ok().contentType(MediaType.APPLICATION_XML).body(body.toString());
}
} }

View File

@@ -1,127 +1,105 @@
package com.openisle.controller; package com.openisle.controller;
import com.openisle.service.StatService;
import com.openisle.service.UserVisitService; import com.openisle.service.UserVisitService;
import io.swagger.v3.oas.annotations.Operation; import com.openisle.service.StatService;
import io.swagger.v3.oas.annotations.media.ArraySchema;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import java.time.LocalDate;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.format.annotation.DateTimeFormat; import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.ArraySchema;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import java.time.LocalDate;
import java.util.List;
import java.util.Map;
@RestController @RestController
@RequestMapping("/api/stats") @RequestMapping("/api/stats")
@RequiredArgsConstructor @RequiredArgsConstructor
public class StatController { public class StatController {
private final UserVisitService userVisitService;
private final StatService statService;
private final UserVisitService userVisitService; @GetMapping("/dau")
private final StatService statService; @Operation(summary = "Daily active users", description = "Get daily active user count")
@ApiResponse(responseCode = "200", description = "DAU count",
content = @Content(schema = @Schema(implementation = java.util.Map.class)))
public Map<String, Long> dau(@RequestParam(value = "date", required = false)
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date) {
long count = userVisitService.countDau(date);
return Map.of("dau", count);
}
@GetMapping("/dau") @GetMapping("/dau-range")
@Operation(summary = "Daily active users", description = "Get daily active user count") @Operation(summary = "DAU range", description = "Get daily active users over range of days")
@ApiResponse( @ApiResponse(responseCode = "200", description = "DAU data",
responseCode = "200", content = @Content(array = @ArraySchema(schema = @Schema(implementation = java.util.Map.class))))
description = "DAU count", public List<Map<String, Object>> dauRange(@RequestParam(value = "days", defaultValue = "30") int days) {
content = @Content(schema = @Schema(implementation = java.util.Map.class)) if (days < 1) days = 1;
) LocalDate end = LocalDate.now();
public Map<String, Long> dau( LocalDate start = end.minusDays(days - 1L);
@RequestParam(value = "date", required = false) @DateTimeFormat( var data = userVisitService.countDauRange(start, end);
iso = DateTimeFormat.ISO.DATE return data.entrySet().stream()
) LocalDate date .map(e -> Map.<String,Object>of(
) { "date", e.getKey().toString(),
long count = userVisitService.countDau(date); "value", e.getValue()
return Map.of("dau", count); ))
} .toList();
}
@GetMapping("/dau-range") @GetMapping("/new-users-range")
@Operation(summary = "DAU range", description = "Get daily active users over range of days") @Operation(summary = "New users range", description = "Get new users over range of days")
@ApiResponse( @ApiResponse(responseCode = "200", description = "New user data",
responseCode = "200", content = @Content(array = @ArraySchema(schema = @Schema(implementation = java.util.Map.class))))
description = "DAU data", public List<Map<String, Object>> newUsersRange(@RequestParam(value = "days", defaultValue = "30") int days) {
content = @Content(array = @ArraySchema(schema = @Schema(implementation = java.util.Map.class))) if (days < 1) days = 1;
) LocalDate end = LocalDate.now();
public List<Map<String, Object>> dauRange( LocalDate start = end.minusDays(days - 1L);
@RequestParam(value = "days", defaultValue = "30") int days var data = statService.countNewUsersRange(start, end);
) { return data.entrySet().stream()
if (days < 1) days = 1; .map(e -> Map.<String,Object>of(
LocalDate end = LocalDate.now(); "date", e.getKey().toString(),
LocalDate start = end.minusDays(days - 1L); "value", e.getValue()
var data = userVisitService.countDauRange(start, end); ))
return data .toList();
.entrySet() }
.stream()
.map(e -> Map.<String, Object>of("date", e.getKey().toString(), "value", e.getValue()))
.toList();
}
@GetMapping("/new-users-range") @GetMapping("/posts-range")
@Operation(summary = "New users range", description = "Get new users over range of days") @Operation(summary = "Posts range", description = "Get posts count over range of days")
@ApiResponse( @ApiResponse(responseCode = "200", description = "Post data",
responseCode = "200", content = @Content(array = @ArraySchema(schema = @Schema(implementation = java.util.Map.class))))
description = "New user data", public List<Map<String, Object>> postsRange(@RequestParam(value = "days", defaultValue = "30") int days) {
content = @Content(array = @ArraySchema(schema = @Schema(implementation = java.util.Map.class))) if (days < 1) days = 1;
) LocalDate end = LocalDate.now();
public List<Map<String, Object>> newUsersRange( LocalDate start = end.minusDays(days - 1L);
@RequestParam(value = "days", defaultValue = "30") int days var data = statService.countPostsRange(start, end);
) { return data.entrySet().stream()
if (days < 1) days = 1; .map(e -> Map.<String,Object>of(
LocalDate end = LocalDate.now(); "date", e.getKey().toString(),
LocalDate start = end.minusDays(days - 1L); "value", e.getValue()
var data = statService.countNewUsersRange(start, end); ))
return data .toList();
.entrySet() }
.stream()
.map(e -> Map.<String, Object>of("date", e.getKey().toString(), "value", e.getValue()))
.toList();
}
@GetMapping("/posts-range") @GetMapping("/comments-range")
@Operation(summary = "Posts range", description = "Get posts count over range of days") @Operation(summary = "Comments range", description = "Get comments count over range of days")
@ApiResponse( @ApiResponse(responseCode = "200", description = "Comment data",
responseCode = "200", content = @Content(array = @ArraySchema(schema = @Schema(implementation = java.util.Map.class))))
description = "Post data", public List<Map<String, Object>> commentsRange(@RequestParam(value = "days", defaultValue = "30") int days) {
content = @Content(array = @ArraySchema(schema = @Schema(implementation = java.util.Map.class))) if (days < 1) days = 1;
) LocalDate end = LocalDate.now();
public List<Map<String, Object>> postsRange( LocalDate start = end.minusDays(days - 1L);
@RequestParam(value = "days", defaultValue = "30") int days var data = statService.countCommentsRange(start, end);
) { return data.entrySet().stream()
if (days < 1) days = 1; .map(e -> Map.<String,Object>of(
LocalDate end = LocalDate.now(); "date", e.getKey().toString(),
LocalDate start = end.minusDays(days - 1L); "value", e.getValue()
var data = statService.countPostsRange(start, end); ))
return data .toList();
.entrySet() }
.stream()
.map(e -> Map.<String, Object>of("date", e.getKey().toString(), "value", e.getValue()))
.toList();
}
@GetMapping("/comments-range")
@Operation(summary = "Comments range", description = "Get comments count over range of days")
@ApiResponse(
responseCode = "200",
description = "Comment data",
content = @Content(array = @ArraySchema(schema = @Schema(implementation = java.util.Map.class)))
)
public List<Map<String, Object>> commentsRange(
@RequestParam(value = "days", defaultValue = "30") int days
) {
if (days < 1) days = 1;
LocalDate end = LocalDate.now();
LocalDate start = end.minusDays(days - 1L);
var data = statService.countCommentsRange(start, end);
return data
.entrySet()
.stream()
.map(e -> Map.<String, Object>of("date", e.getKey().toString(), "value", e.getValue()))
.toList();
}
} }

View File

@@ -1,66 +1,65 @@
package com.openisle.controller; package com.openisle.controller;
import com.openisle.service.SubscriptionService; import com.openisle.service.SubscriptionService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication; import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
/** Endpoints for subscribing to posts, comments and users. */ /** Endpoints for subscribing to posts, comments and users. */
@RestController @RestController
@RequestMapping("/api/subscriptions") @RequestMapping("/api/subscriptions")
@RequiredArgsConstructor @RequiredArgsConstructor
public class SubscriptionController { public class SubscriptionController {
private final SubscriptionService subscriptionService;
private final SubscriptionService subscriptionService; @PostMapping("/posts/{postId}")
@Operation(summary = "Subscribe post", description = "Subscribe to a post")
@ApiResponse(responseCode = "200", description = "Subscribed")
@SecurityRequirement(name = "JWT")
public void subscribePost(@PathVariable Long postId, Authentication auth) {
subscriptionService.subscribePost(auth.getName(), postId);
}
@PostMapping("/posts/{postId}") @DeleteMapping("/posts/{postId}")
@Operation(summary = "Subscribe post", description = "Subscribe to a post") @Operation(summary = "Unsubscribe post", description = "Unsubscribe from a post")
@ApiResponse(responseCode = "200", description = "Subscribed") @ApiResponse(responseCode = "200", description = "Unsubscribed")
@SecurityRequirement(name = "JWT") @SecurityRequirement(name = "JWT")
public void subscribePost(@PathVariable Long postId, Authentication auth) { public void unsubscribePost(@PathVariable Long postId, Authentication auth) {
subscriptionService.subscribePost(auth.getName(), postId); subscriptionService.unsubscribePost(auth.getName(), postId);
} }
@DeleteMapping("/posts/{postId}") @PostMapping("/comments/{commentId}")
@Operation(summary = "Unsubscribe post", description = "Unsubscribe from a post") @Operation(summary = "Subscribe comment", description = "Subscribe to a comment")
@ApiResponse(responseCode = "200", description = "Unsubscribed") @ApiResponse(responseCode = "200", description = "Subscribed")
@SecurityRequirement(name = "JWT") @SecurityRequirement(name = "JWT")
public void unsubscribePost(@PathVariable Long postId, Authentication auth) { public void subscribeComment(@PathVariable Long commentId, Authentication auth) {
subscriptionService.unsubscribePost(auth.getName(), postId); subscriptionService.subscribeComment(auth.getName(), commentId);
} }
@PostMapping("/comments/{commentId}") @DeleteMapping("/comments/{commentId}")
@Operation(summary = "Subscribe comment", description = "Subscribe to a comment") @Operation(summary = "Unsubscribe comment", description = "Unsubscribe from a comment")
@ApiResponse(responseCode = "200", description = "Subscribed") @ApiResponse(responseCode = "200", description = "Unsubscribed")
@SecurityRequirement(name = "JWT") @SecurityRequirement(name = "JWT")
public void subscribeComment(@PathVariable Long commentId, Authentication auth) { public void unsubscribeComment(@PathVariable Long commentId, Authentication auth) {
subscriptionService.subscribeComment(auth.getName(), commentId); subscriptionService.unsubscribeComment(auth.getName(), commentId);
} }
@DeleteMapping("/comments/{commentId}") @PostMapping("/users/{username}")
@Operation(summary = "Unsubscribe comment", description = "Unsubscribe from a comment") @Operation(summary = "Subscribe user", description = "Subscribe to a user")
@ApiResponse(responseCode = "200", description = "Unsubscribed") @ApiResponse(responseCode = "200", description = "Subscribed")
@SecurityRequirement(name = "JWT") @SecurityRequirement(name = "JWT")
public void unsubscribeComment(@PathVariable Long commentId, Authentication auth) { public void subscribeUser(@PathVariable String username, Authentication auth) {
subscriptionService.unsubscribeComment(auth.getName(), commentId); subscriptionService.subscribeUser(auth.getName(), username);
} }
@PostMapping("/users/{username}") @DeleteMapping("/users/{username}")
@Operation(summary = "Subscribe user", description = "Subscribe to a user") @Operation(summary = "Unsubscribe user", description = "Unsubscribe from a user")
@ApiResponse(responseCode = "200", description = "Subscribed") @ApiResponse(responseCode = "200", description = "Unsubscribed")
@SecurityRequirement(name = "JWT") @SecurityRequirement(name = "JWT")
public void subscribeUser(@PathVariable String username, Authentication auth) { public void unsubscribeUser(@PathVariable String username, Authentication auth) {
subscriptionService.subscribeUser(auth.getName(), username); subscriptionService.unsubscribeUser(auth.getName(), username);
} }
@DeleteMapping("/users/{username}")
@Operation(summary = "Unsubscribe user", description = "Unsubscribe from a user")
@ApiResponse(responseCode = "200", description = "Unsubscribed")
@SecurityRequirement(name = "JWT")
public void unsubscribeUser(@PathVariable String username, Authentication auth) {
subscriptionService.unsubscribeUser(auth.getName(), username);
}
} }

View File

@@ -11,142 +11,109 @@ import com.openisle.model.Tag;
import com.openisle.repository.UserRepository; import com.openisle.repository.UserRepository;
import com.openisle.service.PostService; import com.openisle.service.PostService;
import com.openisle.service.TagService; import com.openisle.service.TagService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.ArraySchema; import io.swagger.v3.oas.annotations.media.ArraySchema;
import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
@RestController @RestController
@RequestMapping("/api/tags") @RequestMapping("/api/tags")
@RequiredArgsConstructor @RequiredArgsConstructor
public class TagController { public class TagController {
private final TagService tagService;
private final PostService postService;
private final UserRepository userRepository;
private final PostMapper postMapper;
private final TagMapper tagMapper;
private final TagService tagService; @PostMapping
private final PostService postService; @Operation(summary = "Create tag", description = "Create a new tag")
private final UserRepository userRepository; @ApiResponse(responseCode = "200", description = "Created tag",
private final PostMapper postMapper; content = @Content(schema = @Schema(implementation = TagDto.class)))
private final TagMapper tagMapper; @SecurityRequirement(name = "JWT")
public TagDto create(@RequestBody TagRequest req, org.springframework.security.core.Authentication auth) {
@PostMapping boolean approved = true;
@Operation(summary = "Create tag", description = "Create a new tag") if (postService.getPublishMode() == PublishMode.REVIEW && auth != null) {
@ApiResponse( com.openisle.model.User user = userRepository.findByUsername(auth.getName()).orElseThrow();
responseCode = "200", if (user.getRole() != Role.ADMIN) {
description = "Created tag", approved = false;
content = @Content(schema = @Schema(implementation = TagDto.class)) }
) }
@SecurityRequirement(name = "JWT") Tag tag = tagService.createTag(
public TagDto create( req.getName(),
@RequestBody TagRequest req, req.getDescription(),
org.springframework.security.core.Authentication auth req.getIcon(),
) { req.getSmallIcon(),
boolean approved = true; approved,
if (postService.getPublishMode() == PublishMode.REVIEW && auth != null) { auth != null ? auth.getName() : null);
com.openisle.model.User user = userRepository.findByUsername(auth.getName()).orElseThrow(); long count = postService.countPostsByTag(tag.getId());
if (user.getRole() != Role.ADMIN) { return tagMapper.toDto(tag, count);
approved = false;
}
} }
Tag tag = tagService.createTag(
req.getName(),
req.getDescription(),
req.getIcon(),
req.getSmallIcon(),
approved,
auth != null ? auth.getName() : null
);
long count = postService.countPostsByTag(tag.getId());
return tagMapper.toDto(tag, count);
}
@PutMapping("/{id}") @PutMapping("/{id}")
@Operation(summary = "Update tag", description = "Update an existing tag") @Operation(summary = "Update tag", description = "Update an existing tag")
@ApiResponse( @ApiResponse(responseCode = "200", description = "Updated tag",
responseCode = "200", content = @Content(schema = @Schema(implementation = TagDto.class)))
description = "Updated tag", public TagDto update(@PathVariable Long id, @RequestBody TagRequest req) {
content = @Content(schema = @Schema(implementation = TagDto.class)) Tag tag = tagService.updateTag(id, req.getName(), req.getDescription(), req.getIcon(), req.getSmallIcon());
) long count = postService.countPostsByTag(tag.getId());
public TagDto update(@PathVariable Long id, @RequestBody TagRequest req) { return tagMapper.toDto(tag, count);
Tag tag = tagService.updateTag(
id,
req.getName(),
req.getDescription(),
req.getIcon(),
req.getSmallIcon()
);
long count = postService.countPostsByTag(tag.getId());
return tagMapper.toDto(tag, count);
}
@DeleteMapping("/{id}")
@Operation(summary = "Delete tag", description = "Delete a tag by id")
@ApiResponse(responseCode = "200", description = "Tag deleted")
public void delete(@PathVariable Long id) {
tagService.deleteTag(id);
}
@GetMapping
@Operation(summary = "List tags", description = "List tags with optional keyword")
@ApiResponse(
responseCode = "200",
description = "List of tags",
content = @Content(array = @ArraySchema(schema = @Schema(implementation = TagDto.class)))
)
public List<TagDto> list(
@RequestParam(value = "keyword", required = false) String keyword,
@RequestParam(value = "limit", required = false) Integer limit
) {
List<Tag> tags = tagService.searchTags(keyword);
List<Long> tagIds = tags.stream().map(Tag::getId).toList();
Map<Long, Long> postCntByTagIds = postService.countPostsByTagIds(tagIds);
List<TagDto> dtos = tags
.stream()
.map(t -> tagMapper.toDto(t, postCntByTagIds.getOrDefault(t.getId(), 0L)))
.sorted((a, b) -> Long.compare(b.getCount(), a.getCount()))
.collect(Collectors.toList());
if (limit != null && limit > 0 && dtos.size() > limit) {
return dtos.subList(0, limit);
} }
return dtos;
}
@GetMapping("/{id}") @DeleteMapping("/{id}")
@Operation(summary = "Get tag", description = "Get tag by id") @Operation(summary = "Delete tag", description = "Delete a tag by id")
@ApiResponse( @ApiResponse(responseCode = "200", description = "Tag deleted")
responseCode = "200", public void delete(@PathVariable Long id) {
description = "Tag detail", tagService.deleteTag(id);
content = @Content(schema = @Schema(implementation = TagDto.class)) }
)
public TagDto get(@PathVariable Long id) {
Tag tag = tagService.getTag(id);
long count = postService.countPostsByTag(tag.getId());
return tagMapper.toDto(tag, count);
}
@GetMapping("/{id}/posts") @GetMapping
@Operation(summary = "List posts by tag", description = "Get posts with specific tag") @Operation(summary = "List tags", description = "List tags with optional keyword")
@ApiResponse( @ApiResponse(responseCode = "200", description = "List of tags",
responseCode = "200", content = @Content(array = @ArraySchema(schema = @Schema(implementation = TagDto.class))))
description = "List of posts", public List<TagDto> list(@RequestParam(value = "keyword", required = false) String keyword,
content = @Content( @RequestParam(value = "limit", required = false) Integer limit) {
array = @ArraySchema(schema = @Schema(implementation = PostSummaryDto.class)) List<Tag> tags = tagService.searchTags(keyword);
) List<Long> tagIds = tags.stream().map(Tag::getId).toList();
) Map<Long, Long> postCntByTagIds = postService.countPostsByTagIds(tagIds);
public List<PostSummaryDto> listPostsByTag( List<TagDto> dtos = tags.stream()
@PathVariable Long id, .map(t -> tagMapper.toDto(t, postCntByTagIds.getOrDefault(t.getId(), 0L)))
@RequestParam(value = "page", required = false) Integer page, .sorted((a, b) -> Long.compare(b.getCount(), a.getCount()))
@RequestParam(value = "pageSize", required = false) Integer pageSize .collect(Collectors.toList());
) { if (limit != null && limit > 0 && dtos.size() > limit) {
return postService return dtos.subList(0, limit);
.listPostsByTags(java.util.List.of(id), page, pageSize) }
.stream() return dtos;
.map(postMapper::toSummaryDto) }
.collect(Collectors.toList());
} @GetMapping("/{id}")
@Operation(summary = "Get tag", description = "Get tag by id")
@ApiResponse(responseCode = "200", description = "Tag detail",
content = @Content(schema = @Schema(implementation = TagDto.class)))
public TagDto get(@PathVariable Long id) {
Tag tag = tagService.getTag(id);
long count = postService.countPostsByTag(tag.getId());
return tagMapper.toDto(tag, count);
}
@GetMapping("/{id}/posts")
@Operation(summary = "List posts by tag", description = "Get posts with specific tag")
@ApiResponse(responseCode = "200", description = "List of posts",
content = @Content(array = @ArraySchema(schema = @Schema(implementation = PostSummaryDto.class))))
public List<PostSummaryDto> listPostsByTag(@PathVariable Long id,
@RequestParam(value = "page", required = false) Integer page,
@RequestParam(value = "pageSize", required = false) Integer pageSize) {
return postService.listPostsByTags(java.util.List.of(id), page, pageSize)
.stream()
.map(postMapper::toSummaryDto)
.collect(Collectors.toList());
}
} }

View File

@@ -1,99 +1,95 @@
package com.openisle.controller; package com.openisle.controller;
import com.openisle.service.ImageUploader; import com.openisle.service.ImageUploader;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponse;
import java.io.ByteArrayInputStream; import java.io.ByteArrayInputStream;
import java.io.IOException; import java.io.IOException;
import java.net.URI; import java.net.URI;
import java.net.URL; import java.net.URL;
import java.net.URLConnection; import java.net.URLConnection;
import java.util.Map; import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
@RestController @RestController
@RequestMapping("/api/upload") @RequestMapping("/api/upload")
@RequiredArgsConstructor @RequiredArgsConstructor
public class UploadController { public class UploadController {
private final ImageUploader imageUploader;
private final ImageUploader imageUploader; @Value("${app.upload.check-type:true}")
private boolean checkImageType;
@Value("${app.upload.check-type:true}") @Value("${app.upload.max-size:5242880}")
private boolean checkImageType; private long maxUploadSize;
@Value("${app.upload.max-size:5242880}") @PostMapping
private long maxUploadSize; @Operation(summary = "Upload file", description = "Upload image file")
@ApiResponse(responseCode = "200", description = "Upload result",
@PostMapping content = @Content(schema = @Schema(implementation = java.util.Map.class)))
@Operation(summary = "Upload file", description = "Upload image file") public ResponseEntity<?> upload(@RequestParam("file") MultipartFile file) {
@ApiResponse( if (checkImageType && (file.getContentType() == null || !file.getContentType().startsWith("image/"))) {
responseCode = "200", return ResponseEntity.badRequest().body(Map.of("code", 1, "msg", "File is not an image"));
description = "Upload result", }
content = @Content(schema = @Schema(implementation = java.util.Map.class)) if (file.getSize() > maxUploadSize) {
) return ResponseEntity.badRequest().body(Map.of("code", 2, "msg", "File too large"));
public ResponseEntity<?> upload(@RequestParam("file") MultipartFile file) { }
if ( String url;
checkImageType && try {
(file.getContentType() == null || !file.getContentType().startsWith("image/")) url = imageUploader.upload(file.getBytes(), file.getOriginalFilename()).join();
) { } catch (IOException e) {
return ResponseEntity.badRequest().body(Map.of("code", 1, "msg", "File is not an image")); return ResponseEntity.internalServerError().body(Map.of("code", 3, "msg", "Upload failed"));
}
return ResponseEntity.ok(Map.of(
"code", 0,
"msg", "ok",
"data", Map.of("url", url)
));
} }
if (file.getSize() > maxUploadSize) {
return ResponseEntity.badRequest().body(Map.of("code", 2, "msg", "File too large"));
}
String url;
try {
url = imageUploader.upload(file.getBytes(), file.getOriginalFilename()).join();
} catch (IOException e) {
return ResponseEntity.internalServerError().body(Map.of("code", 3, "msg", "Upload failed"));
}
return ResponseEntity.ok(Map.of("code", 0, "msg", "ok", "data", Map.of("url", url)));
}
@PostMapping("/url") @PostMapping("/url")
@Operation(summary = "Upload from URL", description = "Upload image from remote URL") @Operation(summary = "Upload from URL", description = "Upload image from remote URL")
@ApiResponse( @ApiResponse(responseCode = "200", description = "Upload result",
responseCode = "200", content = @Content(schema = @Schema(implementation = java.util.Map.class)))
description = "Upload result", public ResponseEntity<?> uploadUrl(@RequestBody Map<String, String> body) {
content = @Content(schema = @Schema(implementation = java.util.Map.class)) String link = body.get("url");
) if (link == null || link.isBlank()) {
public ResponseEntity<?> uploadUrl(@RequestBody Map<String, String> body) { return ResponseEntity.badRequest().body(Map.of("code", 1, "msg", "Missing url"));
String link = body.get("url"); }
if (link == null || link.isBlank()) { try {
return ResponseEntity.badRequest().body(Map.of("code", 1, "msg", "Missing url")); URL u = URI.create(link).toURL();
byte[] data = u.openStream().readAllBytes();
if (data.length > maxUploadSize) {
return ResponseEntity.badRequest().body(Map.of("code", 2, "msg", "File too large"));
}
String filename = link.substring(link.lastIndexOf('/') + 1);
String contentType = URLConnection.guessContentTypeFromStream(new ByteArrayInputStream(data));
if (checkImageType && (contentType == null || !contentType.startsWith("image/"))) {
return ResponseEntity.badRequest().body(Map.of("code", 1, "msg", "File is not an image"));
}
String url = imageUploader.upload(data, filename).join();
return ResponseEntity.ok(Map.of(
"code", 0,
"msg", "ok",
"data", Map.of("url", url)
));
} catch (Exception e) {
return ResponseEntity.internalServerError().body(Map.of("code", 3, "msg", "Upload failed"));
}
} }
try {
URL u = URI.create(link).toURL();
byte[] data = u.openStream().readAllBytes();
if (data.length > maxUploadSize) {
return ResponseEntity.badRequest().body(Map.of("code", 2, "msg", "File too large"));
}
String filename = link.substring(link.lastIndexOf('/') + 1);
String contentType = URLConnection.guessContentTypeFromStream(new ByteArrayInputStream(data));
if (checkImageType && (contentType == null || !contentType.startsWith("image/"))) {
return ResponseEntity.badRequest().body(Map.of("code", 1, "msg", "File is not an image"));
}
String url = imageUploader.upload(data, filename).join();
return ResponseEntity.ok(Map.of("code", 0, "msg", "ok", "data", Map.of("url", url)));
} catch (Exception e) {
return ResponseEntity.internalServerError().body(Map.of("code", 3, "msg", "Upload failed"));
}
}
@GetMapping("/presign") @GetMapping("/presign")
@Operation(summary = "Presign upload", description = "Get presigned upload URL") @Operation(summary = "Presign upload", description = "Get presigned upload URL")
@ApiResponse( @ApiResponse(responseCode = "200", description = "Presigned URL",
responseCode = "200", content = @Content(schema = @Schema(implementation = java.util.Map.class)))
description = "Presigned URL", public java.util.Map<String, String> presign(@RequestParam("filename") String filename) {
content = @Content(schema = @Schema(implementation = java.util.Map.class)) return imageUploader.presignUpload(filename);
) }
public java.util.Map<String, String> presign(@RequestParam("filename") String filename) {
return imageUploader.presignUpload(filename);
}
} }

View File

@@ -12,8 +12,6 @@ import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import java.io.IOException;
import java.util.Map;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
@@ -21,359 +19,257 @@ import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.util.Map;
@RestController @RestController
@RequestMapping("/api/users") @RequestMapping("/api/users")
@RequiredArgsConstructor @RequiredArgsConstructor
public class UserController { public class UserController {
private final UserService userService;
private final ImageUploader imageUploader;
private final PostService postService;
private final CommentService commentService;
private final ReactionService reactionService;
private final TagService tagService;
private final SubscriptionService subscriptionService;
private final LevelService levelService;
private final JwtService jwtService;
private final UserMapper userMapper;
private final TagMapper tagMapper;
private final UserService userService; @Value("${app.upload.check-type:true}")
private final ImageUploader imageUploader; private boolean checkImageType;
private final PostService postService;
private final CommentService commentService;
private final ReactionService reactionService;
private final TagService tagService;
private final SubscriptionService subscriptionService;
private final LevelService levelService;
private final JwtService jwtService;
private final UserMapper userMapper;
private final TagMapper tagMapper;
@Value("${app.upload.check-type:true}") @Value("${app.upload.max-size:5242880}")
private boolean checkImageType; private long maxUploadSize;
@Value("${app.upload.max-size:5242880}") @Value("${app.user.posts-limit:10}")
private long maxUploadSize; private int defaultPostsLimit;
@Value("${app.user.posts-limit:10}") @Value("${app.user.replies-limit:50}")
private int defaultPostsLimit; private int defaultRepliesLimit;
@Value("${app.user.replies-limit:50}") @Value("${app.user.tags-limit:50}")
private int defaultRepliesLimit; private int defaultTagsLimit;
@Value("${app.user.tags-limit:50}") @GetMapping("/me")
private int defaultTagsLimit; @SecurityRequirement(name = "JWT")
@Operation(summary = "Current user", description = "Get current authenticated user information")
@GetMapping("/me") @ApiResponse(responseCode = "200", description = "User detail",
@SecurityRequirement(name = "JWT") content = @Content(schema = @Schema(implementation = UserDto.class)))
@Operation(summary = "Current user", description = "Get current authenticated user information") public ResponseEntity<UserDto> me(Authentication auth) {
@ApiResponse( User user = userService.findByUsername(auth.getName()).orElseThrow();
responseCode = "200", return ResponseEntity.ok(userMapper.toDto(user, auth));
description = "User detail",
content = @Content(schema = @Schema(implementation = UserDto.class))
)
public ResponseEntity<UserDto> me(Authentication auth) {
User user = userService.findByUsername(auth.getName()).orElseThrow();
return ResponseEntity.ok(userMapper.toDto(user, auth));
}
@PostMapping("/me/avatar")
@SecurityRequirement(name = "JWT")
@Operation(summary = "Upload avatar", description = "Upload avatar for current user")
@ApiResponse(
responseCode = "200",
description = "Upload result",
content = @Content(schema = @Schema(implementation = Map.class))
)
public ResponseEntity<?> uploadAvatar(
@RequestParam("file") MultipartFile file,
Authentication auth
) {
if (
checkImageType &&
(file.getContentType() == null || !file.getContentType().startsWith("image/"))
) {
return ResponseEntity.badRequest().body(Map.of("error", "File is not an image"));
} }
if (file.getSize() > maxUploadSize) {
return ResponseEntity.badRequest().body(Map.of("error", "File too large")); @PostMapping("/me/avatar")
@SecurityRequirement(name = "JWT")
@Operation(summary = "Upload avatar", description = "Upload avatar for current user")
@ApiResponse(responseCode = "200", description = "Upload result",
content = @Content(schema = @Schema(implementation = Map.class)))
public ResponseEntity<?> uploadAvatar(@RequestParam("file") MultipartFile file,
Authentication auth) {
if (checkImageType && (file.getContentType() == null || !file.getContentType().startsWith("image/"))) {
return ResponseEntity.badRequest().body(Map.of("error", "File is not an image"));
}
if (file.getSize() > maxUploadSize) {
return ResponseEntity.badRequest().body(Map.of("error", "File too large"));
}
String url = null;
try {
url = imageUploader.upload(file.getBytes(), file.getOriginalFilename()).join();
} catch (IOException e) {
return ResponseEntity.internalServerError().body(Map.of("url", url));
}
userService.updateAvatar(auth.getName(), url);
return ResponseEntity.ok(Map.of("url", url));
} }
String url = null;
try { @PutMapping("/me")
url = imageUploader.upload(file.getBytes(), file.getOriginalFilename()).join(); @SecurityRequirement(name = "JWT")
} catch (IOException e) { @Operation(summary = "Update profile", description = "Update current user's profile")
return ResponseEntity.internalServerError().body(Map.of("url", url)); @ApiResponse(responseCode = "200", description = "Updated profile",
content = @Content(schema = @Schema(implementation = Map.class)))
public ResponseEntity<?> updateProfile(@RequestBody UpdateProfileDto dto,
Authentication auth) {
User user = userService.updateProfile(auth.getName(), dto.getUsername(), dto.getIntroduction());
return ResponseEntity.ok(Map.of(
"token", jwtService.generateToken(user.getUsername()),
"user", userMapper.toDto(user, auth)
));
} }
userService.updateAvatar(auth.getName(), url);
return ResponseEntity.ok(Map.of("url", url));
}
@PutMapping("/me") // 这个方法似乎没有使用?
@SecurityRequirement(name = "JWT") @PostMapping("/me/signin")
@Operation(summary = "Update profile", description = "Update current user's profile") @SecurityRequirement(name = "JWT")
@ApiResponse( @Operation(summary = "Daily sign in", description = "Sign in to receive rewards")
responseCode = "200", @ApiResponse(responseCode = "200", description = "Sign in reward",
description = "Updated profile", content = @Content(schema = @Schema(implementation = Map.class)))
content = @Content(schema = @Schema(implementation = Map.class)) public Map<String, Integer> signIn(Authentication auth) {
) int reward = levelService.awardForSignin(auth.getName());
public ResponseEntity<?> updateProfile(@RequestBody UpdateProfileDto dto, Authentication auth) { return Map.of("reward", reward);
User user = userService.updateProfile(auth.getName(), dto.getUsername(), dto.getIntroduction()); }
return ResponseEntity.ok(
Map.of(
"token",
jwtService.generateToken(user.getUsername()),
"user",
userMapper.toDto(user, auth)
)
);
}
// 这个方法似乎没有使用? @GetMapping("/{identifier}")
@PostMapping("/me/signin") @Operation(summary = "Get user", description = "Get user by identifier")
@SecurityRequirement(name = "JWT") @ApiResponse(responseCode = "200", description = "User detail",
@Operation(summary = "Daily sign in", description = "Sign in to receive rewards") content = @Content(schema = @Schema(implementation = UserDto.class)))
@ApiResponse( public ResponseEntity<UserDto> getUser(@PathVariable("identifier") String identifier,
responseCode = "200", Authentication auth) {
description = "Sign in reward", User user = userService.findByIdentifier(identifier).orElseThrow(() -> new NotFoundException("User not found"));
content = @Content(schema = @Schema(implementation = Map.class)) return ResponseEntity.ok(userMapper.toDto(user, auth));
) }
public Map<String, Integer> signIn(Authentication auth) {
int reward = levelService.awardForSignin(auth.getName());
return Map.of("reward", reward);
}
@GetMapping("/{identifier}") @GetMapping("/{identifier}/posts")
@Operation(summary = "Get user", description = "Get user by identifier") @Operation(summary = "User posts", description = "Get recent posts by user")
@ApiResponse( @ApiResponse(responseCode = "200", description = "User posts",
responseCode = "200", content = @Content(array = @ArraySchema(schema = @Schema(implementation = PostMetaDto.class))))
description = "User detail", public java.util.List<PostMetaDto> userPosts(@PathVariable("identifier") String identifier,
content = @Content(schema = @Schema(implementation = UserDto.class)) @RequestParam(value = "limit", required = false) Integer limit) {
) int l = limit != null ? limit : defaultPostsLimit;
public ResponseEntity<UserDto> getUser( User user = userService.findByIdentifier(identifier).orElseThrow();
@PathVariable("identifier") String identifier, return postService.getRecentPostsByUser(user.getUsername(), l).stream()
Authentication auth .map(userMapper::toMetaDto)
) { .collect(java.util.stream.Collectors.toList());
User user = userService }
.findByIdentifier(identifier)
.orElseThrow(() -> new NotFoundException("User not found"));
return ResponseEntity.ok(userMapper.toDto(user, auth));
}
@GetMapping("/{identifier}/posts") @GetMapping("/{identifier}/subscribed-posts")
@Operation(summary = "User posts", description = "Get recent posts by user") @Operation(summary = "Subscribed posts", description = "Get posts the user subscribed to")
@ApiResponse( @ApiResponse(responseCode = "200", description = "Subscribed posts",
responseCode = "200", content = @Content(array = @ArraySchema(schema = @Schema(implementation = PostMetaDto.class))))
description = "User posts", public java.util.List<PostMetaDto> subscribedPosts(@PathVariable("identifier") String identifier,
content = @Content(array = @ArraySchema(schema = @Schema(implementation = PostMetaDto.class))) @RequestParam(value = "limit", required = false) Integer limit) {
) int l = limit != null ? limit : defaultPostsLimit;
public java.util.List<PostMetaDto> userPosts( User user = userService.findByIdentifier(identifier).orElseThrow();
@PathVariable("identifier") String identifier, return subscriptionService.getSubscribedPosts(user.getUsername()).stream()
@RequestParam(value = "limit", required = false) Integer limit .limit(l)
) { .map(userMapper::toMetaDto)
int l = limit != null ? limit : defaultPostsLimit; .collect(java.util.stream.Collectors.toList());
User user = userService.findByIdentifier(identifier).orElseThrow(); }
return postService
.getRecentPostsByUser(user.getUsername(), l)
.stream()
.map(userMapper::toMetaDto)
.collect(java.util.stream.Collectors.toList());
}
@GetMapping("/{identifier}/subscribed-posts") @GetMapping("/{identifier}/replies")
@Operation(summary = "Subscribed posts", description = "Get posts the user subscribed to") @Operation(summary = "User replies", description = "Get recent replies by user")
@ApiResponse( @ApiResponse(responseCode = "200", description = "User replies",
responseCode = "200", content = @Content(array = @ArraySchema(schema = @Schema(implementation = CommentInfoDto.class))))
description = "Subscribed posts", public java.util.List<CommentInfoDto> userReplies(@PathVariable("identifier") String identifier,
content = @Content(array = @ArraySchema(schema = @Schema(implementation = PostMetaDto.class))) @RequestParam(value = "limit", required = false) Integer limit) {
) int l = limit != null ? limit : defaultRepliesLimit;
public java.util.List<PostMetaDto> subscribedPosts( User user = userService.findByIdentifier(identifier).orElseThrow();
@PathVariable("identifier") String identifier, return commentService.getRecentCommentsByUser(user.getUsername(), l).stream()
@RequestParam(value = "limit", required = false) Integer limit .map(userMapper::toCommentInfoDto)
) { .collect(java.util.stream.Collectors.toList());
int l = limit != null ? limit : defaultPostsLimit; }
User user = userService.findByIdentifier(identifier).orElseThrow();
return subscriptionService
.getSubscribedPosts(user.getUsername())
.stream()
.limit(l)
.map(userMapper::toMetaDto)
.collect(java.util.stream.Collectors.toList());
}
@GetMapping("/{identifier}/replies") @GetMapping("/{identifier}/hot-posts")
@Operation(summary = "User replies", description = "Get recent replies by user") @Operation(summary = "User hot posts", description = "Get most reacted posts by user")
@ApiResponse( @ApiResponse(responseCode = "200", description = "Hot posts",
responseCode = "200", content = @Content(array = @ArraySchema(schema = @Schema(implementation = PostMetaDto.class))))
description = "User replies", public java.util.List<PostMetaDto> hotPosts(@PathVariable("identifier") String identifier,
content = @Content( @RequestParam(value = "limit", required = false) Integer limit) {
array = @ArraySchema(schema = @Schema(implementation = CommentInfoDto.class)) int l = limit != null ? limit : 10;
) User user = userService.findByIdentifier(identifier).orElseThrow();
) java.util.List<Long> ids = reactionService.topPostIds(user.getUsername(), l);
public java.util.List<CommentInfoDto> userReplies( return postService.getPostsByIds(ids).stream()
@PathVariable("identifier") String identifier, .map(userMapper::toMetaDto)
@RequestParam(value = "limit", required = false) Integer limit .collect(java.util.stream.Collectors.toList());
) { }
int l = limit != null ? limit : defaultRepliesLimit;
User user = userService.findByIdentifier(identifier).orElseThrow();
return commentService
.getRecentCommentsByUser(user.getUsername(), l)
.stream()
.map(userMapper::toCommentInfoDto)
.collect(java.util.stream.Collectors.toList());
}
@GetMapping("/{identifier}/hot-posts") @GetMapping("/{identifier}/hot-replies")
@Operation(summary = "User hot posts", description = "Get most reacted posts by user") @Operation(summary = "User hot replies", description = "Get most reacted replies by user")
@ApiResponse( @ApiResponse(responseCode = "200", description = "Hot replies",
responseCode = "200", content = @Content(array = @ArraySchema(schema = @Schema(implementation = CommentInfoDto.class))))
description = "Hot posts", public java.util.List<CommentInfoDto> hotReplies(@PathVariable("identifier") String identifier,
content = @Content(array = @ArraySchema(schema = @Schema(implementation = PostMetaDto.class))) @RequestParam(value = "limit", required = false) Integer limit) {
) int l = limit != null ? limit : 10;
public java.util.List<PostMetaDto> hotPosts( User user = userService.findByIdentifier(identifier).orElseThrow();
@PathVariable("identifier") String identifier, java.util.List<Long> ids = reactionService.topCommentIds(user.getUsername(), l);
@RequestParam(value = "limit", required = false) Integer limit return commentService.getCommentsByIds(ids).stream()
) { .map(userMapper::toCommentInfoDto)
int l = limit != null ? limit : 10; .collect(java.util.stream.Collectors.toList());
User user = userService.findByIdentifier(identifier).orElseThrow(); }
java.util.List<Long> ids = reactionService.topPostIds(user.getUsername(), l);
return postService
.getPostsByIds(ids)
.stream()
.map(userMapper::toMetaDto)
.collect(java.util.stream.Collectors.toList());
}
@GetMapping("/{identifier}/hot-replies") @GetMapping("/{identifier}/hot-tags")
@Operation(summary = "User hot replies", description = "Get most reacted replies by user") @Operation(summary = "User hot tags", description = "Get tags frequently used by user")
@ApiResponse( @ApiResponse(responseCode = "200", description = "Hot tags",
responseCode = "200", content = @Content(array = @ArraySchema(schema = @Schema(implementation = TagDto.class))))
description = "Hot replies", public java.util.List<TagDto> hotTags(@PathVariable("identifier") String identifier,
content = @Content( @RequestParam(value = "limit", required = false) Integer limit) {
array = @ArraySchema(schema = @Schema(implementation = CommentInfoDto.class)) int l = limit != null ? limit : 10;
) User user = userService.findByIdentifier(identifier).orElseThrow();
) return tagService.getTagsByUser(user.getUsername()).stream()
public java.util.List<CommentInfoDto> hotReplies( .map(t -> tagMapper.toDto(t, postService.countPostsByTag(t.getId())))
@PathVariable("identifier") String identifier, .sorted((a, b) -> Long.compare(b.getCount(), a.getCount()))
@RequestParam(value = "limit", required = false) Integer limit .limit(l)
) { .collect(java.util.stream.Collectors.toList());
int l = limit != null ? limit : 10; }
User user = userService.findByIdentifier(identifier).orElseThrow();
java.util.List<Long> ids = reactionService.topCommentIds(user.getUsername(), l);
return commentService
.getCommentsByIds(ids)
.stream()
.map(userMapper::toCommentInfoDto)
.collect(java.util.stream.Collectors.toList());
}
@GetMapping("/{identifier}/hot-tags") @GetMapping("/{identifier}/tags")
@Operation(summary = "User hot tags", description = "Get tags frequently used by user") @Operation(summary = "User tags", description = "Get recent tags used by user")
@ApiResponse( @ApiResponse(responseCode = "200", description = "User tags",
responseCode = "200", content = @Content(array = @ArraySchema(schema = @Schema(implementation = TagDto.class))))
description = "Hot tags", public java.util.List<TagDto> userTags(@PathVariable("identifier") String identifier,
content = @Content(array = @ArraySchema(schema = @Schema(implementation = TagDto.class))) @RequestParam(value = "limit", required = false) Integer limit) {
) int l = limit != null ? limit : defaultTagsLimit;
public java.util.List<TagDto> hotTags( User user = userService.findByIdentifier(identifier).orElseThrow();
@PathVariable("identifier") String identifier, return tagService.getRecentTagsByUser(user.getUsername(), l).stream()
@RequestParam(value = "limit", required = false) Integer limit .map(t -> tagMapper.toDto(t, postService.countPostsByTag(t.getId())))
) { .collect(java.util.stream.Collectors.toList());
int l = limit != null ? limit : 10; }
User user = userService.findByIdentifier(identifier).orElseThrow();
return tagService
.getTagsByUser(user.getUsername())
.stream()
.map(t -> tagMapper.toDto(t, postService.countPostsByTag(t.getId())))
.sorted((a, b) -> Long.compare(b.getCount(), a.getCount()))
.limit(l)
.collect(java.util.stream.Collectors.toList());
}
@GetMapping("/{identifier}/tags") @GetMapping("/{identifier}/following")
@Operation(summary = "User tags", description = "Get recent tags used by user") @Operation(summary = "Following users", description = "Get users that this user is following")
@ApiResponse( @ApiResponse(responseCode = "200", description = "Following list",
responseCode = "200", content = @Content(array = @ArraySchema(schema = @Schema(implementation = UserDto.class))))
description = "User tags", public java.util.List<UserDto> following(@PathVariable("identifier") String identifier) {
content = @Content(array = @ArraySchema(schema = @Schema(implementation = TagDto.class))) User user = userService.findByIdentifier(identifier).orElseThrow();
) return subscriptionService.getSubscribedUsers(user.getUsername()).stream()
public java.util.List<TagDto> userTags( .map(userMapper::toDto)
@PathVariable("identifier") String identifier, .collect(java.util.stream.Collectors.toList());
@RequestParam(value = "limit", required = false) Integer limit }
) {
int l = limit != null ? limit : defaultTagsLimit;
User user = userService.findByIdentifier(identifier).orElseThrow();
return tagService
.getRecentTagsByUser(user.getUsername(), l)
.stream()
.map(t -> tagMapper.toDto(t, postService.countPostsByTag(t.getId())))
.collect(java.util.stream.Collectors.toList());
}
@GetMapping("/{identifier}/following") @GetMapping("/{identifier}/followers")
@Operation(summary = "Following users", description = "Get users that this user is following") @Operation(summary = "Followers", description = "Get followers of this user")
@ApiResponse( @ApiResponse(responseCode = "200", description = "Followers list",
responseCode = "200", content = @Content(array = @ArraySchema(schema = @Schema(implementation = UserDto.class))))
description = "Following list", public java.util.List<UserDto> followers(@PathVariable("identifier") String identifier) {
content = @Content(array = @ArraySchema(schema = @Schema(implementation = UserDto.class))) User user = userService.findByIdentifier(identifier).orElseThrow();
) return subscriptionService.getSubscribers(user.getUsername()).stream()
public java.util.List<UserDto> following(@PathVariable("identifier") String identifier) { .map(userMapper::toDto)
User user = userService.findByIdentifier(identifier).orElseThrow(); .collect(java.util.stream.Collectors.toList());
return subscriptionService }
.getSubscribedUsers(user.getUsername())
.stream()
.map(userMapper::toDto)
.collect(java.util.stream.Collectors.toList());
}
@GetMapping("/{identifier}/followers") @GetMapping("/admins")
@Operation(summary = "Followers", description = "Get followers of this user") @Operation(summary = "Admin users", description = "List administrator users")
@ApiResponse( @ApiResponse(responseCode = "200", description = "Admin users",
responseCode = "200", content = @Content(array = @ArraySchema(schema = @Schema(implementation = UserDto.class))))
description = "Followers list", public java.util.List<UserDto> admins() {
content = @Content(array = @ArraySchema(schema = @Schema(implementation = UserDto.class))) return userService.getAdmins().stream()
) .map(userMapper::toDto)
public java.util.List<UserDto> followers(@PathVariable("identifier") String identifier) { .collect(java.util.stream.Collectors.toList());
User user = userService.findByIdentifier(identifier).orElseThrow(); }
return subscriptionService
.getSubscribers(user.getUsername())
.stream()
.map(userMapper::toDto)
.collect(java.util.stream.Collectors.toList());
}
@GetMapping("/admins") @GetMapping("/{identifier}/all")
@Operation(summary = "Admin users", description = "List administrator users") @Operation(summary = "User aggregate", description = "Get aggregate information for user")
@ApiResponse( @ApiResponse(responseCode = "200", description = "User aggregate",
responseCode = "200", content = @Content(schema = @Schema(implementation = UserAggregateDto.class)))
description = "Admin users", public ResponseEntity<UserAggregateDto> userAggregate(@PathVariable("identifier") String identifier,
content = @Content(array = @ArraySchema(schema = @Schema(implementation = UserDto.class))) @RequestParam(value = "postsLimit", required = false) Integer postsLimit,
) @RequestParam(value = "repliesLimit", required = false) Integer repliesLimit,
public java.util.List<UserDto> admins() { Authentication auth) {
return userService User user = userService.findByIdentifier(identifier).orElseThrow();
.getAdmins() int pLimit = postsLimit != null ? postsLimit : defaultPostsLimit;
.stream() int rLimit = repliesLimit != null ? repliesLimit : defaultRepliesLimit;
.map(userMapper::toDto) java.util.List<PostMetaDto> posts = postService.getRecentPostsByUser(user.getUsername(), pLimit).stream()
.collect(java.util.stream.Collectors.toList()); .map(userMapper::toMetaDto)
} .collect(java.util.stream.Collectors.toList());
java.util.List<CommentInfoDto> replies = commentService.getRecentCommentsByUser(user.getUsername(), rLimit).stream()
@GetMapping("/{identifier}/all") .map(userMapper::toCommentInfoDto)
@Operation(summary = "User aggregate", description = "Get aggregate information for user") .collect(java.util.stream.Collectors.toList());
@ApiResponse( UserAggregateDto dto = new UserAggregateDto();
responseCode = "200", dto.setUser(userMapper.toDto(user, auth));
description = "User aggregate", dto.setPosts(posts);
content = @Content(schema = @Schema(implementation = UserAggregateDto.class)) dto.setReplies(replies);
) return ResponseEntity.ok(dto);
public ResponseEntity<UserAggregateDto> userAggregate( }
@PathVariable("identifier") String identifier,
@RequestParam(value = "postsLimit", required = false) Integer postsLimit,
@RequestParam(value = "repliesLimit", required = false) Integer repliesLimit,
Authentication auth
) {
User user = userService.findByIdentifier(identifier).orElseThrow();
int pLimit = postsLimit != null ? postsLimit : defaultPostsLimit;
int rLimit = repliesLimit != null ? repliesLimit : defaultRepliesLimit;
java.util.List<PostMetaDto> posts = postService
.getRecentPostsByUser(user.getUsername(), pLimit)
.stream()
.map(userMapper::toMetaDto)
.collect(java.util.stream.Collectors.toList());
java.util.List<CommentInfoDto> replies = commentService
.getRecentCommentsByUser(user.getUsername(), rLimit)
.stream()
.map(userMapper::toCommentInfoDto)
.collect(java.util.stream.Collectors.toList());
UserAggregateDto dto = new UserAggregateDto();
dto.setUser(userMapper.toDto(user, auth));
dto.setPosts(posts);
dto.setReplies(replies);
return ResponseEntity.ok(dto);
}
} }

View File

@@ -1,21 +1,21 @@
package com.openisle.dto; package com.openisle.dto;
import com.openisle.model.ActivityType; import com.openisle.model.ActivityType;
import java.time.LocalDateTime;
import lombok.Data; import lombok.Data;
import java.time.LocalDateTime;
/** /**
* DTO representing an activity without participant details. * DTO representing an activity without participant details.
*/ */
@Data @Data
public class ActivityDto { public class ActivityDto {
private Long id;
private Long id; private String title;
private String title; private String icon;
private String icon; private String content;
private String content; private LocalDateTime startTime;
private LocalDateTime startTime; private LocalDateTime endTime;
private LocalDateTime endTime; private ActivityType type;
private ActivityType type; private boolean ended;
private boolean ended;
} }

View File

@@ -1,16 +1,16 @@
package com.openisle.dto; package com.openisle.dto;
import com.openisle.model.MedalType;
import lombok.Data; import lombok.Data;
import com.openisle.model.MedalType;
/** /**
* DTO representing a post or comment author. * DTO representing a post or comment author.
*/ */
@Data @Data
public class AuthorDto { public class AuthorDto {
private Long id;
private Long id; private String username;
private String username; private String avatar;
private String avatar; private MedalType displayMedal;
private MedalType displayMedal;
} }

View File

@@ -7,11 +7,11 @@ import lombok.Data;
*/ */
@Data @Data
public class CategoryDto { public class CategoryDto {
private Long id;
private Long id; private String name;
private String name; private String description;
private String description; private String icon;
private String icon; private String smallIcon;
private String smallIcon; private Long count;
private Long count;
} }

View File

@@ -5,9 +5,8 @@ import lombok.Data;
/** Request body for creating or updating a category. */ /** Request body for creating or updating a category. */
@Data @Data
public class CategoryRequest { public class CategoryRequest {
private String name;
private String name; private String description;
private String description; private String icon;
private String icon; private String smallIcon;
private String smallIcon;
} }

View File

@@ -6,13 +6,12 @@ import lombok.Setter;
@Getter @Getter
@Setter @Setter
public class ChannelDto { public class ChannelDto {
private Long id;
private Long id; private String name;
private String name; private String description;
private String description; private String avatar;
private String avatar; private MessageDto lastMessage;
private MessageDto lastMessage; private long memberCount;
private long memberCount; private boolean joined;
private boolean joined; private long unreadCount;
private long unreadCount;
} }

View File

@@ -1,22 +1,23 @@
package com.openisle.dto; package com.openisle.dto;
import lombok.Data;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.List; import java.util.List;
import lombok.Data;
/** /**
* DTO representing a comment and its nested replies. * DTO representing a comment and its nested replies.
*/ */
@Data @Data
public class CommentDto { public class CommentDto {
private Long id;
private Long id; private String content;
private String content; private LocalDateTime createdAt;
private LocalDateTime createdAt; private LocalDateTime pinnedAt;
private LocalDateTime pinnedAt; private AuthorDto author;
private AuthorDto author; private List<CommentDto> replies;
private List<CommentDto> replies; private List<ReactionDto> reactions;
private List<ReactionDto> reactions; private int reward;
private int reward; private int pointReward;
private int pointReward;
} }

View File

@@ -1,15 +1,15 @@
package com.openisle.dto; package com.openisle.dto;
import java.time.LocalDateTime;
import lombok.Data; import lombok.Data;
import java.time.LocalDateTime;
/** DTO for comment information in user profiles. */ /** DTO for comment information in user profiles. */
@Data @Data
public class CommentInfoDto { public class CommentInfoDto {
private Long id;
private Long id; private String content;
private String content; private LocalDateTime createdAt;
private LocalDateTime createdAt; private PostMetaDto post;
private PostMetaDto post; private ParentCommentDto parentComment;
private ParentCommentDto parentComment;
} }

View File

@@ -6,7 +6,6 @@ import lombok.EqualsAndHashCode;
@Data @Data
@EqualsAndHashCode(callSuper = true) @EqualsAndHashCode(callSuper = true)
public class CommentMedalDto extends MedalDto { public class CommentMedalDto extends MedalDto {
private long currentCommentCount;
private long currentCommentCount; private long targetCommentCount;
private long targetCommentCount;
} }

View File

@@ -5,7 +5,6 @@ import lombok.Data;
/** Request body for creating or replying to a comment. */ /** Request body for creating or replying to a comment. */
@Data @Data
public class CommentRequest { public class CommentRequest {
private String content;
private String content; private String captcha;
private String captcha;
} }

View File

@@ -8,9 +8,8 @@ import lombok.Data;
/** DTO for site configuration. */ /** DTO for site configuration. */
@Data @Data
public class ConfigDto { public class ConfigDto {
private PublishMode publishMode;
private PublishMode publishMode; private PasswordStrength passwordStrength;
private PasswordStrength passwordStrength; private Integer aiFormatLimit;
private Integer aiFormatLimit; private RegisterMode registerMode;
private RegisterMode registerMode;
} }

View File

@@ -6,7 +6,7 @@ import lombok.EqualsAndHashCode;
@Data @Data
@EqualsAndHashCode(callSuper = true) @EqualsAndHashCode(callSuper = true)
public class ContributorMedalDto extends MedalDto { public class ContributorMedalDto extends MedalDto {
private long currentContributionLines;
private long currentContributionLines; private long targetContributionLines;
private long targetContributionLines;
} }

View File

@@ -1,16 +1,16 @@
package com.openisle.dto; package com.openisle.dto;
import java.util.List;
import lombok.Data; import lombok.Data;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
import java.util.List;
@Data @Data
public class ConversationDetailDto { public class ConversationDetailDto {
private Long id;
private Long id; private String name;
private String name; private boolean channel;
private boolean channel; private String avatar;
private String avatar; private List<UserSummaryDto> participants;
private List<UserSummaryDto> participants; private Page<MessageDto> messages;
private Page<MessageDto> messages; }
}

View File

@@ -1,20 +1,20 @@
package com.openisle.dto; package com.openisle.dto;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.List; import java.util.List;
import lombok.Getter;
import lombok.Setter;
@Getter @Getter
@Setter @Setter
public class ConversationDto { public class ConversationDto {
private Long id;
private Long id; private String name;
private String name; private boolean channel;
private boolean channel; private String avatar;
private String avatar; private MessageDto lastMessage;
private MessageDto lastMessage; private List<UserSummaryDto> participants;
private List<UserSummaryDto> participants; private LocalDateTime createdAt;
private LocalDateTime createdAt; private long unreadCount;
private long unreadCount; }
}

View File

@@ -4,6 +4,5 @@ import lombok.Data;
@Data @Data
public class CreateConversationRequest { public class CreateConversationRequest {
private Long recipientId;
private Long recipientId; }
}

View File

@@ -8,6 +8,5 @@ import lombok.NoArgsConstructor;
@AllArgsConstructor @AllArgsConstructor
@NoArgsConstructor @NoArgsConstructor
public class CreateConversationResponse { public class CreateConversationResponse {
private Long conversationId;
private Long conversationId; }
}

View File

@@ -5,8 +5,7 @@ import lombok.Data;
/** Request for Discord OAuth login. */ /** Request for Discord OAuth login. */
@Data @Data
public class DiscordLoginRequest { public class DiscordLoginRequest {
private String code;
private String code; private String redirectUri;
private String redirectUri; private String inviteToken;
private String inviteToken;
} }

View File

@@ -1,15 +1,15 @@
package com.openisle.dto; package com.openisle.dto;
import java.util.List;
import lombok.Data; import lombok.Data;
import java.util.List;
/** DTO representing a saved draft. */ /** DTO representing a saved draft. */
@Data @Data
public class DraftDto { public class DraftDto {
private Long id;
private Long id; private String title;
private String title; private String content;
private String content; private Long categoryId;
private Long categoryId; private List<Long> tagIds;
private List<Long> tagIds;
} }

View File

@@ -1,14 +1,14 @@
package com.openisle.dto; package com.openisle.dto;
import java.util.List;
import lombok.Data; import lombok.Data;
import java.util.List;
/** Request body for saving a draft. */ /** Request body for saving a draft. */
@Data @Data
public class DraftRequest { public class DraftRequest {
private String title;
private String title; private String content;
private String content; private Long categoryId;
private Long categoryId; private List<Long> tagIds;
private List<Long> tagIds;
} }

View File

@@ -6,7 +6,7 @@ import lombok.EqualsAndHashCode;
@Data @Data
@EqualsAndHashCode(callSuper = true) @EqualsAndHashCode(callSuper = true)
public class FeaturedMedalDto extends MedalDto { public class FeaturedMedalDto extends MedalDto {
private long currentFeaturedCount;
private long currentFeaturedCount; private long targetFeaturedCount;
private long targetFeaturedCount;
} }

View File

@@ -5,6 +5,5 @@ import lombok.Data;
/** Request to trigger a forgot password email. */ /** Request to trigger a forgot password email. */
@Data @Data
public class ForgotPasswordRequest { public class ForgotPasswordRequest {
private String email;
private String email;
} }

View File

@@ -5,8 +5,7 @@ import lombok.Data;
/** Request for GitHub OAuth login. */ /** Request for GitHub OAuth login. */
@Data @Data
public class GithubLoginRequest { public class GithubLoginRequest {
private String code;
private String code; private String redirectUri;
private String redirectUri; private String inviteToken;
private String inviteToken;
} }

View File

@@ -5,7 +5,6 @@ import lombok.Data;
/** Request for Google OAuth login. */ /** Request for Google OAuth login. */
@Data @Data
public class GoogleLoginRequest { public class GoogleLoginRequest {
private String idToken;
private String idToken; private String inviteToken;
private String inviteToken;
} }

View File

@@ -5,8 +5,7 @@ import lombok.Data;
/** Request to login. */ /** Request to login. */
@Data @Data
public class LoginRequest { public class LoginRequest {
private String username;
private String username; private String password;
private String password; private String captcha;
private String captcha;
} }

View File

@@ -1,19 +1,18 @@
package com.openisle.dto; package com.openisle.dto;
import lombok.Data;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.List; import java.util.List;
import lombok.Data;
/** Metadata for lottery posts. */ /** Metadata for lottery posts. */
@Data @Data
public class LotteryDto { public class LotteryDto {
private String prizeDescription;
private String prizeDescription; private String prizeIcon;
private String prizeIcon; private int prizeCount;
private int prizeCount; private int pointCost;
private int pointCost; private LocalDateTime startTime;
private LocalDateTime startTime; private LocalDateTime endTime;
private LocalDateTime endTime; private List<AuthorDto> participants;
private List<AuthorDto> participants; private List<AuthorDto> winners;
private List<AuthorDto> winners;
} }

View File

@@ -5,7 +5,6 @@ import lombok.Data;
/** Request to submit a reason (e.g., for moderation). */ /** Request to submit a reason (e.g., for moderation). */
@Data @Data
public class MakeReasonRequest { public class MakeReasonRequest {
private String token;
private String token; private String reason;
private String reason;
} }

View File

@@ -5,11 +5,10 @@ import lombok.Data;
@Data @Data
public class MedalDto { public class MedalDto {
private String icon;
private String icon; private String title;
private String title; private String description;
private String description; private MedalType type;
private MedalType type; private boolean completed;
private boolean completed; private boolean selected;
private boolean selected;
} }

View File

@@ -5,6 +5,5 @@ import lombok.Data;
@Data @Data
public class MedalSelectRequest { public class MedalSelectRequest {
private MedalType type;
private MedalType type;
} }

View File

@@ -1,17 +1,16 @@
package com.openisle.dto; package com.openisle.dto;
import lombok.Data;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.List; import java.util.List;
import lombok.Data;
@Data @Data
public class MessageDto { public class MessageDto {
private Long id;
private Long id; private String content;
private String content; private UserSummaryDto sender;
private UserSummaryDto sender; private Long conversationId;
private Long conversationId; private LocalDateTime createdAt;
private LocalDateTime createdAt; private MessageDto replyTo;
private MessageDto replyTo; private List<ReactionDto> reactions;
private List<ReactionDto> reactions; }
}

View File

@@ -1,15 +1,15 @@
package com.openisle.dto; package com.openisle.dto;
import java.io.Serializable;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Data; import lombok.Data;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import java.io.Serializable;
@Data @Data
@NoArgsConstructor @NoArgsConstructor
@AllArgsConstructor @AllArgsConstructor
public class MessageNotificationPayload implements Serializable { public class MessageNotificationPayload implements Serializable {
private String targetUsername;
private String targetUsername; private Object payload;
private Object payload; }
}

View File

@@ -5,7 +5,6 @@ import lombok.Data;
/** Info about the milk tea activity. */ /** Info about the milk tea activity. */
@Data @Data
public class MilkTeaInfoDto { public class MilkTeaInfoDto {
private long redeemCount;
private long redeemCount; private boolean ended;
private boolean ended;
} }

View File

@@ -5,6 +5,5 @@ import lombok.Data;
/** Request to redeem the milk tea activity. */ /** Request to redeem the milk tea activity. */
@Data @Data
public class MilkTeaRedeemRequest { public class MilkTeaRedeemRequest {
private String contact;
private String contact;
} }

View File

@@ -2,22 +2,22 @@ package com.openisle.dto;
import com.openisle.model.NotificationType; import com.openisle.model.NotificationType;
import com.openisle.model.ReactionType; import com.openisle.model.ReactionType;
import java.time.LocalDateTime;
import lombok.Data; import lombok.Data;
import java.time.LocalDateTime;
/** DTO representing a user notification. */ /** DTO representing a user notification. */
@Data @Data
public class NotificationDto { public class NotificationDto {
private Long id;
private Long id; private NotificationType type;
private NotificationType type; private PostSummaryDto post;
private PostSummaryDto post; private CommentDto comment;
private CommentDto comment; private CommentDto parentComment;
private CommentDto parentComment; private AuthorDto fromUser;
private AuthorDto fromUser; private ReactionType reactionType;
private ReactionType reactionType; private String content;
private String content; private Boolean approved;
private Boolean approved; private boolean read;
private boolean read; private LocalDateTime createdAt;
private LocalDateTime createdAt;
} }

View File

@@ -1,11 +1,11 @@
package com.openisle.dto; package com.openisle.dto;
import java.util.List;
import lombok.Data; import lombok.Data;
import java.util.List;
/** Request to mark notifications as read. */ /** Request to mark notifications as read. */
@Data @Data
public class NotificationMarkReadRequest { public class NotificationMarkReadRequest {
private List<Long> ids;
private List<Long> ids;
} }

View File

@@ -6,7 +6,6 @@ import lombok.Data;
/** User notification preference DTO. */ /** User notification preference DTO. */
@Data @Data
public class NotificationPreferenceDto { public class NotificationPreferenceDto {
private NotificationType type;
private NotificationType type; private boolean enabled;
private boolean enabled;
} }

View File

@@ -6,7 +6,6 @@ import lombok.Data;
/** Request to update a single notification preference. */ /** Request to update a single notification preference. */
@Data @Data
public class NotificationPreferenceUpdateRequest { public class NotificationPreferenceUpdateRequest {
private NotificationType type;
private NotificationType type; private boolean enabled;
private boolean enabled;
} }

View File

@@ -5,6 +5,5 @@ import lombok.Data;
/** DTO representing unread notification count. */ /** DTO representing unread notification count. */
@Data @Data
public class NotificationUnreadCountDto { public class NotificationUnreadCountDto {
private long count;
private long count;
} }

View File

@@ -5,8 +5,7 @@ import lombok.Data;
/** DTO representing a parent comment. */ /** DTO representing a parent comment. */
@Data @Data
public class ParentCommentDto { public class ParentCommentDto {
private Long id;
private Long id; private String author;
private String author; private String content;
private String content;
} }

View File

@@ -6,6 +6,5 @@ import lombok.EqualsAndHashCode;
@Data @Data
@EqualsAndHashCode(callSuper = true) @EqualsAndHashCode(callSuper = true)
public class PioneerMedalDto extends MedalDto { public class PioneerMedalDto extends MedalDto {
private long rank;
private long rank;
} }

View File

@@ -5,9 +5,8 @@ import lombok.Data;
/** Point mall good info. */ /** Point mall good info. */
@Data @Data
public class PointGoodDto { public class PointGoodDto {
private Long id;
private Long id; private String name;
private String name; private int cost;
private int cost; private String image;
private String image;
} }

View File

@@ -1,23 +1,23 @@
package com.openisle.dto; package com.openisle.dto;
import com.openisle.model.PointHistoryType; import com.openisle.model.PointHistoryType;
import java.time.LocalDateTime;
import lombok.Getter; import lombok.Getter;
import lombok.Setter; import lombok.Setter;
import java.time.LocalDateTime;
@Getter @Getter
@Setter @Setter
public class PointHistoryDto { public class PointHistoryDto {
private Long id;
private Long id; private PointHistoryType type;
private PointHistoryType type; private int amount;
private int amount; private int balance;
private int balance; private Long postId;
private Long postId; private String postTitle;
private String postTitle; private Long commentId;
private Long commentId; private String commentContent;
private String commentContent; private Long fromUserId;
private Long fromUserId; private String fromUserName;
private String fromUserName; private LocalDateTime createdAt;
private LocalDateTime createdAt;
} }

View File

@@ -5,7 +5,6 @@ import lombok.Data;
/** Request to redeem a point mall good. */ /** Request to redeem a point mall good. */
@Data @Data
public class PointRedeemRequest { public class PointRedeemRequest {
private Long goodId;
private Long goodId; private String contact;
private String contact;
} }

View File

@@ -1,17 +1,17 @@
package com.openisle.dto; package com.openisle.dto;
import lombok.Data;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import lombok.Data;
@Data @Data
public class PollDto { public class PollDto {
private List<String> options;
private List<String> options; private Map<Integer, Integer> votes;
private Map<Integer, Integer> votes; private LocalDateTime endTime;
private LocalDateTime endTime; private List<AuthorDto> participants;
private List<AuthorDto> participants; private Map<Integer, List<AuthorDto>> optionParticipants;
private Map<Integer, List<AuthorDto>> optionParticipants; private boolean multiple;
private boolean multiple;
} }

Some files were not shown because too many files have changed in this diff Show More