mirror of
https://github.com/nagisa77/OpenIsle.git
synced 2026-02-08 16:11:05 +08:00
Compare commits
141 Commits
codex/fix-
...
feature/ap
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f36bcb74ca | ||
|
|
2263fd97db | ||
|
|
9234d1099e | ||
|
|
373dece19d | ||
|
|
b09828bcc2 | ||
|
|
8751a7707c | ||
|
|
f91b240802 | ||
|
|
062b289f7a | ||
|
|
c1dc77f6db | ||
|
|
cea60175c2 | ||
|
|
2bd3630512 | ||
|
|
a9d8181940 | ||
|
|
4cc108094d | ||
|
|
bfa57cce44 | ||
|
|
8ebdcd94f5 | ||
|
|
9991210db2 | ||
|
|
1c59815afa | ||
|
|
e7593c8ebf | ||
|
|
bc767a6ac9 | ||
|
|
1c1915285d | ||
|
|
b6c2471bc3 | ||
|
|
4cc2800f09 | ||
|
|
396434a82e | ||
|
|
07c6b53f82 | ||
|
|
930a861ba6 | ||
|
|
1f4e1dea75 | ||
|
|
bc617837be | ||
|
|
17e4862eaf | ||
|
|
72b2b82e02 | ||
|
|
70f7442f0c | ||
|
|
2b2deb8f66 | ||
|
|
0a7a433bc6 | ||
|
|
b64f9ef1f6 | ||
|
|
f22ca9cdcd | ||
|
|
d26b96ebd1 | ||
|
|
13cc981421 | ||
|
|
efc8589ca0 | ||
|
|
940690889c | ||
|
|
d46420ef81 | ||
|
|
b36b5b59dc | ||
|
|
cf96806f80 | ||
|
|
3d0d0496b6 | ||
|
|
f67e220894 | ||
|
|
9306e35b84 | ||
|
|
d2268a1944 | ||
|
|
6baa4d4233 | ||
|
|
ef9d90455f | ||
|
|
5d499956d7 | ||
|
|
9101ed336c | ||
|
|
28e3ebb911 | ||
|
|
e93e33fe43 | ||
|
|
0ebeccf21e | ||
|
|
89842b82e9 | ||
|
|
58594229f2 | ||
|
|
b4a811ff4e | ||
|
|
7067630bcc | ||
|
|
b28e8d4bc9 | ||
|
|
063866cc3a | ||
|
|
6f968d16aa | ||
|
|
6db969cc4d | ||
|
|
6ea9b4a33c | ||
|
|
bcfc40d795 | ||
|
|
c5c7066b92 | ||
|
|
51b73fcc93 | ||
|
|
da181b9d6d | ||
|
|
134e3fc866 | ||
|
|
c3758cafe8 | ||
|
|
1a21ba8935 | ||
|
|
a397ebe79b | ||
|
|
abbdb224e0 | ||
|
|
f4fb3b2544 | ||
|
|
ae2412a906 | ||
|
|
d8534fb94d | ||
|
|
6497cb92af | ||
|
|
37bef0b2d7 | ||
|
|
3519a41a2e | ||
|
|
ab04a8b6b1 | ||
|
|
ea079e8b8a | ||
|
|
519656359f | ||
|
|
dc64785279 | ||
|
|
9421d004d4 | ||
|
|
90bd41e740 | ||
|
|
7d5c864f64 | ||
|
|
3f35add587 | ||
|
|
37c4306010 | ||
|
|
1e284e15df | ||
|
|
9d76926b8a | ||
|
|
d2ce203236 | ||
|
|
b2228296af | ||
|
|
7020ae19d0 | ||
|
|
227fb6f6cc | ||
|
|
0e46a67ea6 | ||
|
|
b20b705e46 | ||
|
|
4b3ffbab99 | ||
|
|
74039c89f9 | ||
|
|
10dca73d2f | ||
|
|
e37ed1b70b | ||
|
|
8500a7a914 | ||
|
|
3adf722b3b | ||
|
|
791e5a4daf | ||
|
|
7d25e87fbc | ||
|
|
d02c316a70 | ||
|
|
c189c80c05 | ||
|
|
07db73c9c7 | ||
|
|
c296e25927 | ||
|
|
61fc9d799d | ||
|
|
20c6c73f8c | ||
|
|
81d1f79aae | ||
|
|
4ff76d2586 | ||
|
|
f24bc239cc | ||
|
|
143691206d | ||
|
|
15ad85e6f1 | ||
|
|
843e53143d | ||
|
|
16c94690bd | ||
|
|
5be00e7013 | ||
|
|
1e0f62b421 | ||
|
|
a3201f05fb | ||
|
|
62cccb794d | ||
|
|
afa0c7fb8f | ||
|
|
da311806c1 | ||
|
|
1852f87341 | ||
|
|
7010e8a058 | ||
|
|
38ee37d5be | ||
|
|
e398d8e989 | ||
|
|
85e77c265e | ||
|
|
8abdc73497 | ||
|
|
747d9c07d1 | ||
|
|
09cefbedbf | ||
|
|
d772bc182f | ||
|
|
358c53338d | ||
|
|
2110980797 | ||
|
|
1cd89eaa54 | ||
|
|
1d2e7eb96e | ||
|
|
4428e06f1d | ||
|
|
dddff54556 | ||
|
|
e7f7bbac22 | ||
|
|
37aae4ba5c | ||
|
|
54cfc98336 | ||
|
|
d42d38ff7a | ||
|
|
2b4601bd4b | ||
|
|
5071d9c6d5 |
7
.github/ISSUE_TEMPLATE/新功能建议.md
vendored
7
.github/ISSUE_TEMPLATE/新功能建议.md
vendored
@@ -1,10 +1,9 @@
|
|||||||
---
|
---
|
||||||
name: 新功能建议
|
name: 新功能建议
|
||||||
about: 请为该项目提出一个想法
|
about: 请为该项目提出一个想法
|
||||||
title: ''
|
title: ""
|
||||||
labels: ''
|
labels: ""
|
||||||
assignees: ''
|
assignees: ""
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**你的功能请求是否与某个问题相关?请描述。**
|
**你的功能请求是否与某个问题相关?请描述。**
|
||||||
|
|||||||
21
.github/ISSUE_TEMPLATE/错误-bug报告.md
vendored
21
.github/ISSUE_TEMPLATE/错误-bug报告.md
vendored
@@ -1,10 +1,9 @@
|
|||||||
---
|
---
|
||||||
name: 错误/Bug报告
|
name: 错误/Bug报告
|
||||||
about: 创建报告以帮助我们改进
|
about: 创建报告以帮助我们改进
|
||||||
title: ''
|
title: ""
|
||||||
labels: ''
|
labels: ""
|
||||||
assignees: ''
|
assignees: ""
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**描述 Bug**
|
**描述 Bug**
|
||||||
@@ -26,16 +25,16 @@ assignees: ''
|
|||||||
|
|
||||||
**桌面端(请完成以下信息):**
|
**桌面端(请完成以下信息):**
|
||||||
|
|
||||||
* 操作系统:\[例如 iOS]
|
- 操作系统:\[例如 iOS]
|
||||||
* 浏览器:\[例如 Chrome、Safari]
|
- 浏览器:\[例如 Chrome、Safari]
|
||||||
* 版本:\[例如 22]
|
- 版本:\[例如 22]
|
||||||
|
|
||||||
**移动端(请完成以下信息):**
|
**移动端(请完成以下信息):**
|
||||||
|
|
||||||
* 设备:\[例如 iPhone6]
|
- 设备:\[例如 iPhone6]
|
||||||
* 操作系统:\[例如 iOS8.1]
|
- 操作系统:\[例如 iOS8.1]
|
||||||
* 浏览器:\[例如 系统自带浏览器、Safari]
|
- 浏览器:\[例如 系统自带浏览器、Safari]
|
||||||
* 版本:\[例如 22]
|
- 版本:\[例如 22]
|
||||||
|
|
||||||
**附加上下文**
|
**附加上下文**
|
||||||
在此添加与问题相关的其他上下文信息。
|
在此添加与问题相关的其他上下文信息。
|
||||||
|
|||||||
2
.github/workflows/deploy-staging.yml
vendored
2
.github/workflows/deploy-staging.yml
vendored
@@ -12,6 +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 才执行
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
@@ -31,4 +32,3 @@ jobs:
|
|||||||
secrets: inherit
|
secrets: inherit
|
||||||
with:
|
with:
|
||||||
build-id: ${{ github.run_id }}
|
build-id: ${{ github.run_id }}
|
||||||
|
|
||||||
|
|||||||
2
.github/workflows/deploy.yml
vendored
2
.github/workflows/deploy.yml
vendored
@@ -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:
|
||||||
|
|||||||
@@ -8,21 +8,21 @@ This isn’t an exhaustive list of things that you can’t 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. It’s 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. It’s 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 we’re 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 doesn’t mean that they’re wrong. Don’t forget that it is human to err and blaming each other doesn’t 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 we’re 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 doesn’t mean that they’re wrong. Don’t forget that it is human to err and blaming each other doesn’t 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).
|
||||||
|
|||||||
225
CONTRIBUTING.md
225
CONTRIBUTING.md
@@ -1,16 +1,25 @@
|
|||||||
- [前置工作](#前置工作)
|
- [前置工作](#前置工作)
|
||||||
- [启动后端服务](#启动后端服务)
|
- [启动后端服务](#启动后端服务)
|
||||||
- [本地 IDEA](#本地-idea)
|
- [本地 IDEA](#本地-idea)
|
||||||
- [配置环境变量](#配置环境变量)
|
- [配置环境变量](#配置环境变量)
|
||||||
- [配置 IDEA 参数](#配置-idea-参数)
|
- [配置 IDEA 参数](#配置-idea-参数)
|
||||||
- [配置 MySQL](#配置-mysql)
|
- [配置 MySQL](#配置-mysql)
|
||||||
- [Docker 环境](#docker-环境)
|
- [配置 Redis](#配置-redis)
|
||||||
- [配置环境变量](#配置环境变量-1)
|
- [配置 RabbitMQ](#配置-rabbitmq)
|
||||||
- [构建并启动镜像](#构建并启动镜像)
|
- [Docker 环境](#docker-环境)
|
||||||
|
- [配置环境变量](#配置环境变量-1)
|
||||||
|
- [构建并启动镜像](#构建并启动镜像)
|
||||||
- [启动前端服务](#启动前端服务)
|
- [启动前端服务](#启动前端服务)
|
||||||
- [配置环境变量](#配置环境变量-2)
|
- [配置环境变量](#配置环境变量-2)
|
||||||
- [安装依赖和运行](#安装依赖和运行)
|
- [安装依赖和运行](#安装依赖和运行)
|
||||||
- [其他配置](#其他配置)
|
- [其他配置](#其他配置)
|
||||||
|
- [配置第三方登录以GitHub为例](#配置第三方登录以GitHub为例)
|
||||||
|
- [配置Resend邮箱服务](#配置Resend邮箱服务)
|
||||||
|
- [API文档](#api文档)
|
||||||
|
- [OpenAPI文档](#openapi文档)
|
||||||
|
- [部署时间线以及文档时效性](#部署时间线以及文档时效性)
|
||||||
|
- [OpenAPI文档使用](#OpenAPI文档使用)
|
||||||
|
- [OpenAPI文档应用场景](#OpenAPI文档应用场景)
|
||||||
|
|
||||||
## 前置工作
|
## 前置工作
|
||||||
|
|
||||||
@@ -22,9 +31,9 @@ cd OpenIsle
|
|||||||
```
|
```
|
||||||
|
|
||||||
- 后端开发环境
|
- 后端开发环境
|
||||||
- JDK 17+
|
- JDK 17+
|
||||||
- 前端开发环境
|
- 前端开发环境
|
||||||
- Node.JS 20+
|
- Node.JS 20+
|
||||||
|
|
||||||
## 启动后端服务
|
## 启动后端服务
|
||||||
|
|
||||||
@@ -45,15 +54,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 登录业务,就需要谷歌相关的变量,数据库是一定要的
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
3. 应用环境文件,选择刚刚的 `open-isle.env`
|
3. 应用环境文件,选择刚刚的 `open-isle.env`
|
||||||
|
|
||||||
@@ -72,11 +81,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
|
||||||
```
|
```
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
@@ -88,37 +97,97 @@ SERVER_PORT=8082
|
|||||||
> 如果不知道怎么配置数据库可以参考 [Docker 环境](#docker-环境) 章节
|
> 如果不知道怎么配置数据库可以参考 [Docker 环境](#docker-环境) 章节
|
||||||
|
|
||||||
1. 本机配置 MySQL 服务(网上很多教程,忽略)
|
1. 本机配置 MySQL 服务(网上很多教程,忽略)
|
||||||
|
- 可以用 Laragon,自带 MySQL 包括 Nodejs,版本建议 `6.x`,`7` 以后需要 Lisence
|
||||||
+ 可以用 Laragon,自带 MySQL 包括 Nodejs,版本建议 `6.x`,`7` 以后需要 Lisence
|
- [下载地址](https://github.com/leokhoa/laragon/releases)
|
||||||
+ [下载地址](https://github.com/leokhoa/laragon/releases)
|
|
||||||
|
|
||||||
2. 填写环境变量
|
2. 填写环境变量
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
```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**
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
#### 配置 Redis
|
#### 配置 Redis
|
||||||
|
|
||||||
填写环境变量 `.env` 中的 Redis 相关配置并启动 Redis
|
后端的登录态缓存、访问频控等都依赖 Redis,请确保本地有可用的 Redis 实例。
|
||||||
|
|
||||||
```ini
|
1. **启动 Redis 服务**(已有服务可跳过)
|
||||||
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 配置后,即可继续启动后端服务。
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
@@ -134,9 +203,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 服务的情况,如果只是想快速体验或者启动统一的环境,则推荐使用本方式。
|
||||||
@@ -176,21 +245,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` 值与后端服务端口一致
|
||||||
|
|
||||||
@@ -210,23 +279,23 @@ npm run dev
|
|||||||
|
|
||||||
## 其他配置
|
## 其他配置
|
||||||
|
|
||||||
### 配置第三方登录,这里以 GitHub 为例:
|
### 配置第三方登录以GitHub为例
|
||||||
|
|
||||||
- 修改 `application.properties` 配置
|
- 修改 `application.properties` 配置
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
- 修改 `.env` 配置
|
- 修改 `.env` 配置
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
- 配置第三方登录回调地址
|
- 配置第三方登录回调地址
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
### 配置 Resend 邮箱服务
|
### 配置Resend邮箱服务
|
||||||
|
|
||||||
https://resend.com/emails 创建账号并登录
|
https://resend.com/emails 创建账号并登录
|
||||||
|
|
||||||
@@ -246,3 +315,43 @@ https://resend.com/emails 创建账号并登录
|
|||||||
`RESEND_FROM_EMAIL`: **noreply@域名**
|
`RESEND_FROM_EMAIL`: **noreply@域名**
|
||||||
`RESEND_API_KEY`:**刚刚复制的 Key**
|
`RESEND_API_KEY`:**刚刚复制的 Key**
|
||||||

|

|
||||||
|
|
||||||
|
## API文档
|
||||||
|
|
||||||
|
### OpenAPI文档
|
||||||
|
https://docs.open-isle.com
|
||||||
|
|
||||||
|
### 部署时间线以及文档时效性
|
||||||
|
|
||||||
|
我已经将API Docs的部署融合进本站CI & CD中,目前如下
|
||||||
|
|
||||||
|
- 每次合入main之后,都会构建预发环境 http://staging.open-isle.com/ ,现在文档是紧随其后进行部署,也就是说代码合入main之后,如果是新增后台接口,就可以立即通过OpenAPI文档页面进行查看和调试,但是如果想通过OpenAPI调试需要选择预发环境的
|
||||||
|
- 每日凌晨三点会构建并重新部署正式环境,届时当日合入main的新后台API也可以通过OpenAPI文档页面调试
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
👆如图是合入main之后构建预发+docs的情形,总大约耗时4分钟左右
|
||||||
|
|
||||||
|
### OpenAPI文档使用
|
||||||
|
|
||||||
|
- 预发环境/正式环境切换,可以通过如下位置切换API环境
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
- API分两种,一种是需要鉴权(需登录后的token),另一种是直接访问,可以直接访问的GET请求,直接点击Send即可调试,如下👇,比如本站的推荐流rss: /api/rss: https://docs.open-isle.com/openapi/feed
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
- 需要登陆的API,比如关注,取消关注,发帖等,则需要提供token,目前在“API与调试”可获取自身token,可点击link看看👉 https://www.open-isle.com/about?tab=api
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
copy完token之后,粘贴到Bear之后, 即可发送调试, 如下👇,大家亦可自行尝试:https://docs.open-isle.com/openapi/me
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
#### OpenAPI文档应用场景
|
||||||
|
|
||||||
|
- 方便大部分前端调试的需求,如果有只想做前端/客户端的同学参与本项目,该平台会大大提高效率
|
||||||
|
- 自动化:有自动化发帖/自动化操作的需求,亦可通过该平台实现或调试
|
||||||
|
- API文档: https://docs.open-isle.com/openapi
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
高效的开源社区前后端平台
|
高效的开源社区前后端平台
|
||||||
<br><br><br>
|
<br><br><br>
|
||||||
<img alt="Image" src="https://openisle-1307107697.cos.accelerate.myqcloud.com/dynamic_assert/22752cfac5a04a9c90c41995b9f55fed.png" width="1200">
|
<img alt="Image" src="https://openisle-1307107697.cos.accelerate.myqcloud.com/dynamic_assert/22752cfac5a04a9c90c41995b9f55fed.png" width="1200">
|
||||||
|
<br><br><br>
|
||||||
|
<a href="https://hellogithub.com/repository/nagisa77/OpenIsle" target="_blank"><img src="https://abroad.hellogithub.com/v1/widgets/recommend.svg?rid=8605546658d94cbab45182af2a02e4c8&claim_uid=p5GNFTtZl6HBAYQ" alt="Featured|HelloGitHub" style="width: 250px; height: 54px;" width="250" height="54" /></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
## 💡 简介
|
## 💡 简介
|
||||||
|
|||||||
23
backend/.prettierrc
Normal file
23
backend/.prettierrc
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -7,7 +7,8 @@ import org.springframework.scheduling.annotation.EnableScheduling;
|
|||||||
@SpringBootApplication
|
@SpringBootApplication
|
||||||
@EnableScheduling
|
@EnableScheduling
|
||||||
public class OpenIsleApplication {
|
public class OpenIsleApplication {
|
||||||
public static void main(String[] args) {
|
|
||||||
SpringApplication.run(OpenIsleApplication.class, args);
|
public static void main(String[] args) {
|
||||||
}
|
SpringApplication.run(OpenIsleApplication.class, args);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,37 +3,40 @@ 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;
|
|
||||||
|
|
||||||
@Override
|
private final ActivityRepository activityRepository;
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (activityRepository.findByType(ActivityType.INVITE_POINTS) == null) {
|
@Override
|
||||||
Activity a = new Activity();
|
public void run(String... args) {
|
||||||
a.setTitle("🎁邀请码送积分活动");
|
if (activityRepository.findByType(ActivityType.MILK_TEA) == null) {
|
||||||
a.setType(ActivityType.INVITE_POINTS);
|
Activity a = new Activity();
|
||||||
a.setIcon("https://img.icons8.com/color/96/gift.png");
|
a.setTitle("🎡建站送奶茶活动");
|
||||||
a.setContent("使用邀请码注册或邀请好友即可获得积分奖励,快来参与吧!");
|
a.setType(ActivityType.MILK_TEA);
|
||||||
a.setStartTime(LocalDateTime.now());
|
a.setIcon("https://icons.veryicon.com/png/o/food--drinks/delicious-food-1/coffee-36.png");
|
||||||
a.setEndTime(LocalDate.of(LocalDate.now().getYear(), 10, 1).atStartOfDay());
|
a.setContent(
|
||||||
activityRepository.save(a);
|
"为了有利于建站推广以及激励发布内容,我们推出了建站送奶茶的活动,前50名达到level 1的用户,可以联系站长获取奶茶/咖啡一杯"
|
||||||
}
|
);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
|
||||||
public Executor notificationExecutor() {
|
@Bean(name = "notificationExecutor")
|
||||||
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
|
public Executor notificationExecutor() {
|
||||||
executor.setCorePoolSize(2);
|
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
|
||||||
executor.setMaxPoolSize(10);
|
executor.setCorePoolSize(2);
|
||||||
executor.setQueueCapacity(100);
|
executor.setMaxPoolSize(10);
|
||||||
executor.setThreadNamePrefix("notification-");
|
executor.setQueueCapacity(100);
|
||||||
executor.initialize();
|
executor.setThreadNamePrefix("notification-");
|
||||||
return executor;
|
executor.initialize();
|
||||||
}
|
return executor;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ 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;
|
||||||
@@ -21,10 +24,6 @@ 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
|
||||||
@@ -34,85 +33,107 @@ import java.util.Map;
|
|||||||
@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 VISIT_CACHE_NAME = "openisle_visit";
|
||||||
|
// 文章缓存
|
||||||
|
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(new Hibernate6Module()
|
objectMapper.registerModule(
|
||||||
.disable(Hibernate6Module.Feature.USE_TRANSIENT_ANNOTATION));
|
new Hibernate6Module()
|
||||||
// service的时候带上类型信息
|
.disable(Hibernate6Module.Feature.USE_TRANSIENT_ANNOTATION)
|
||||||
// 启用类型信息,避免 LinkedHashMap 问题
|
// 将 Hibernate 特有的集合类型转换为标准 Java 集合类型
|
||||||
objectMapper.activateDefaultTyping(
|
// 避免序列化时出现 org.hibernate.collection.spi.PersistentSet 这样的类型信息
|
||||||
LaissezFaireSubTypeValidator.instance,
|
.configure(Hibernate6Module.Feature.REPLACE_PERSISTENT_COLLECTIONS, true)
|
||||||
ObjectMapper.DefaultTyping.NON_FINAL,
|
);
|
||||||
JsonTypeInfo.As.PROPERTY
|
// service的时候带上类型信息
|
||||||
);
|
// 启用类型信息,避免 LinkedHashMap 问题
|
||||||
objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
|
objectMapper.activateDefaultTyping(
|
||||||
return new GenericJackson2JsonRedisSerializer(objectMapper);
|
LaissezFaireSubTypeValidator.instance,
|
||||||
}
|
ObjectMapper.DefaultTyping.NON_FINAL,
|
||||||
|
JsonTypeInfo.As.PROPERTY
|
||||||
|
);
|
||||||
|
objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
|
||||||
|
return new GenericJackson2JsonRedisSerializer(objectMapper);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 配置 Spring Cache 使用 RedisCacheManager
|
* 配置 Spring Cache 使用 RedisCacheManager
|
||||||
*/
|
*/
|
||||||
@Bean
|
@Bean
|
||||||
public CacheManager cacheManager(RedisConnectionFactory connectionFactory, RedisSerializer<Object> redisSerializer) {
|
public CacheManager cacheManager(
|
||||||
|
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 值
|
||||||
|
|
||||||
RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
|
// 个别缓存单独设置 TTL 时间
|
||||||
.entryTtl(Duration.ZERO) // 默认缓存不过期
|
Map<String, RedisCacheConfiguration> cacheConfigs = new HashMap<>();
|
||||||
.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
|
RedisCacheConfiguration oneHourConfig = config.entryTtl(Duration.ofHours(1));
|
||||||
.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(redisSerializer))
|
RedisCacheConfiguration tenMinutesConfig = config.entryTtl(Duration.ofMinutes(10));
|
||||||
.disableCachingNullValues(); // 禁止缓存 null 值
|
cacheConfigs.put(TAG_CACHE_NAME, oneHourConfig);
|
||||||
|
cacheConfigs.put(CATEGORY_CACHE_NAME, oneHourConfig);
|
||||||
|
cacheConfigs.put(POST_CACHE_NAME, tenMinutesConfig);
|
||||||
|
|
||||||
// 个别缓存单独设置 TTL 时间
|
return RedisCacheManager.builder(connectionFactory)
|
||||||
Map<String, RedisCacheConfiguration> cacheConfigs = new HashMap<>();
|
.cacheDefaults(config)
|
||||||
RedisCacheConfiguration oneHourConfig = config.entryTtl(Duration.ofHours(1));
|
.withInitialCacheConfigurations(cacheConfigs)
|
||||||
cacheConfigs.put(TAG_CACHE_NAME, oneHourConfig);
|
.build();
|
||||||
cacheConfigs.put(CATEGORY_CACHE_NAME, oneHourConfig);
|
}
|
||||||
|
|
||||||
return RedisCacheManager.builder(connectionFactory)
|
/**
|
||||||
.cacheDefaults(config)
|
* 配置 RedisTemplate,支持直接操作 Redis
|
||||||
.withInitialCacheConfigurations(cacheConfigs)
|
*/
|
||||||
.build();
|
@Bean
|
||||||
}
|
public RedisTemplate<String, Object> redisTemplate(
|
||||||
|
RedisConnectionFactory connectionFactory,
|
||||||
|
RedisSerializer<Object> redisSerializer
|
||||||
|
) {
|
||||||
|
RedisTemplate<String, Object> template = new RedisTemplate<>();
|
||||||
|
template.setConnectionFactory(connectionFactory);
|
||||||
|
|
||||||
/**
|
// key 和 hashKey 使用 String 序列化
|
||||||
* 配置 RedisTemplate,支持直接操作 Redis
|
template.setKeySerializer(new StringRedisSerializer());
|
||||||
*/
|
template.setHashKeySerializer(new StringRedisSerializer());
|
||||||
@Bean
|
|
||||||
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory, RedisSerializer<Object> redisSerializer) {
|
|
||||||
RedisTemplate<String, Object> template = new RedisTemplate<>();
|
|
||||||
template.setConnectionFactory(connectionFactory);
|
|
||||||
|
|
||||||
// key 和 hashKey 使用 String 序列化
|
// value 和 hashValue 使用 JSON 序列化
|
||||||
template.setKeySerializer(new StringRedisSerializer());
|
template.setValueSerializer(redisSerializer);
|
||||||
template.setHashKeySerializer(new StringRedisSerializer());
|
template.setHashValueSerializer(redisSerializer);
|
||||||
|
|
||||||
// value 和 hashValue 使用 JSON 序列化
|
return template;
|
||||||
template.setValueSerializer(redisSerializer);
|
}
|
||||||
template.setHashValueSerializer(redisSerializer);
|
|
||||||
|
|
||||||
return template;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,24 +9,29 @@ import org.springframework.stereotype.Component;
|
|||||||
@Component
|
@Component
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class ChannelInitializer implements CommandLineRunner {
|
public class ChannelInitializer implements CommandLineRunner {
|
||||||
private final MessageConversationRepository conversationRepository;
|
|
||||||
|
|
||||||
@Override
|
private final MessageConversationRepository conversationRepository;
|
||||||
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);
|
|
||||||
|
|
||||||
MessageConversation tech = new MessageConversation();
|
@Override
|
||||||
tech.setChannel(true);
|
public void run(String... args) {
|
||||||
tech.setName("技术讨论群");
|
if (conversationRepository.countByChannelTrue() == 0) {
|
||||||
tech.setDescription("讨论技术相关话题");
|
MessageConversation chat = new MessageConversation();
|
||||||
tech.setAvatar("https://openisle-1307107697.cos.accelerate.myqcloud.com/dynamic_assert/5edde9a5864e471caa32491dbcdaa8b2.png");
|
chat.setChannel(true);
|
||||||
conversationRepository.save(tech);
|
chat.setName("吹水群");
|
||||||
}
|
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);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,23 +3,25 @@ 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
|
|
||||||
public void handle(HttpServletRequest request,
|
@Override
|
||||||
HttpServletResponse response,
|
public void handle(
|
||||||
AccessDeniedException accessDeniedException) throws IOException, ServletException {
|
HttpServletRequest request,
|
||||||
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
|
HttpServletResponse response,
|
||||||
response.setContentType("application/json");
|
AccessDeniedException accessDeniedException
|
||||||
response.getWriter().write("{\"error\": \"Unauthorized\"}");
|
) throws IOException, ServletException {
|
||||||
}
|
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
|
||||||
|
response.setContentType("application/json");
|
||||||
|
response.getWriter().write("{\"error\": \"Unauthorized\"}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,44 +5,54 @@ import io.swagger.v3.oas.models.OpenAPI;
|
|||||||
import io.swagger.v3.oas.models.info.Info;
|
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 java.util.List;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
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;
|
||||||
|
|
||||||
@Configuration
|
@Configuration
|
||||||
|
@RequiredArgsConstructor
|
||||||
public class OpenApiConfig {
|
public class OpenApiConfig {
|
||||||
|
|
||||||
@Value("${springdoc.info.title}")
|
private final SpringDocProperties springDocProperties;
|
||||||
private String title;
|
|
||||||
|
|
||||||
@Value("${springdoc.info.description}")
|
@Value("${springdoc.info.title}")
|
||||||
private String description;
|
private String title;
|
||||||
|
|
||||||
@Value("${springdoc.info.version}")
|
@Value("${springdoc.info.description}")
|
||||||
private String version;
|
private String description;
|
||||||
|
|
||||||
@Value("${springdoc.info.scheme}")
|
@Value("${springdoc.info.version}")
|
||||||
private String scheme;
|
private String version;
|
||||||
|
|
||||||
@Value("${springdoc.info.header}")
|
@Value("${springdoc.info.scheme}")
|
||||||
private String header;
|
private String scheme;
|
||||||
|
|
||||||
@Bean
|
@Value("${springdoc.info.header}")
|
||||||
public OpenAPI openAPI() {
|
private String header;
|
||||||
SecurityScheme securityScheme = new SecurityScheme()
|
|
||||||
.type(SecurityScheme.Type.HTTP)
|
|
||||||
.scheme(scheme.toLowerCase())
|
|
||||||
.bearerFormat("JWT")
|
|
||||||
.in(SecurityScheme.In.HEADER)
|
|
||||||
.name(header);
|
|
||||||
|
|
||||||
return new OpenAPI()
|
@Bean
|
||||||
.info(new Info()
|
public OpenAPI openAPI() {
|
||||||
.title(title)
|
SecurityScheme securityScheme = new SecurityScheme()
|
||||||
.description(description)
|
.type(SecurityScheme.Type.HTTP)
|
||||||
.version(version))
|
.scheme(scheme.toLowerCase())
|
||||||
.components(new Components()
|
.bearerFormat("JWT")
|
||||||
.addSecuritySchemes("JWT", securityScheme))
|
.in(SecurityScheme.In.HEADER)
|
||||||
.addSecurityItem(new SecurityRequirement().addList("JWT"));
|
.name(header);
|
||||||
}
|
|
||||||
|
List<Server> servers = springDocProperties
|
||||||
|
.getServers()
|
||||||
|
.stream()
|
||||||
|
.map(s -> new Server().url(s.getUrl()).description(s.getDescription()))
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
|
||||||
|
return new OpenAPI()
|
||||||
|
.servers(servers)
|
||||||
|
.info(new Info().title(title).description(description).version(version))
|
||||||
|
.components(new Components().addSecuritySchemes("JWT", securityScheme))
|
||||||
|
.addSecurityItem(new SecurityRequirement().addList("JWT"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,22 +10,27 @@ import org.springframework.stereotype.Component;
|
|||||||
@Component
|
@Component
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class PointGoodInitializer implements CommandLineRunner {
|
public class PointGoodInitializer implements CommandLineRunner {
|
||||||
private final PointGoodRepository pointGoodRepository;
|
|
||||||
|
|
||||||
@Override
|
private final PointGoodRepository pointGoodRepository;
|
||||||
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);
|
|
||||||
|
|
||||||
PointGood g2 = new PointGood();
|
@Override
|
||||||
g2.setName("奶茶");
|
public void run(String... args) {
|
||||||
g2.setCost(5000);
|
if (pointGoodRepository.count() == 0) {
|
||||||
g2.setImage("https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/icons/coffee.png");
|
PointGood g1 = new PointGood();
|
||||||
pointGoodRepository.save(g2);
|
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);
|
||||||
|
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,204 +1,219 @@
|
|||||||
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 org.springframework.amqp.core.Binding;
|
import org.springframework.amqp.core.Binding;
|
||||||
import org.springframework.amqp.core.BindingBuilder;
|
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
|
||||||
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;
|
|
||||||
|
|
||||||
@PostConstruct
|
// 硬编码为16以匹配ShardingStrategy中的十六进制分片逻辑
|
||||||
public void init() {
|
private final int queueCount = 16;
|
||||||
System.out.println("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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Bean
|
log.info("分片队列 Bean 创建完成,总数: {}", queues.size());
|
||||||
public TopicExchange exchange() {
|
return queues;
|
||||||
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());
|
||||||
* 创建所有分片队列, 使用十六进制后缀 (0-f)
|
return bindings;
|
||||||
*/
|
}
|
||||||
@Bean
|
|
||||||
public List<Queue> shardedQueues() {
|
|
||||||
System.out.println("开始创建分片队列 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);
|
|
||||||
}
|
|
||||||
|
|
||||||
System.out.println("分片队列 Bean 创建完成,总数: " + queues.size());
|
|
||||||
return queues;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建所有分片绑定, 使用十六进制路由键 (notifications.shard.0 - notifications.shard.f)
|
* 保持向后兼容的单队列配置(可选)
|
||||||
*/
|
*/
|
||||||
@Bean
|
@Bean
|
||||||
public List<Binding> shardedBindings(TopicExchange exchange, @Qualifier("shardedQueues") List<Queue> shardedQueues) {
|
public Queue legacyQueue() {
|
||||||
System.out.println("开始创建分片绑定 Bean...");
|
return new Queue(QUEUE_NAME, queueDurable);
|
||||||
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;
|
@Bean
|
||||||
Binding binding = BindingBuilder.bind(queue).to(exchange).with(routingKey);
|
public Binding legacyBinding(Queue legacyQueue, TopicExchange exchange) {
|
||||||
bindings.add(binding);
|
return BindingBuilder.bind(legacyQueue).to(exchange).with(ROUTING_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
@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(
|
||||||
System.out.println("分片绑定 Bean 创建完成,总数: " + bindings.size());
|
"分片队列处理完成: 成功 {}, 跳过 {}, 总数 {}",
|
||||||
return bindings;
|
successCount,
|
||||||
}
|
skippedCount,
|
||||||
|
shardedQueues.size()
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
// 声明分片绑定
|
||||||
* 保持向后兼容的单队列配置(可选)
|
log.info("开始声明 {} 个分片绑定...", shardedBindings.size());
|
||||||
*/
|
int bindingSuccessCount = 0;
|
||||||
@Bean
|
for (Binding binding : shardedBindings) {
|
||||||
public Queue legacyQueue() {
|
try {
|
||||||
return new Queue(QUEUE_NAME, queueDurable);
|
rabbitAdmin.declareBinding(binding);
|
||||||
}
|
bindingSuccessCount++;
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("绑定声明失败: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.info("分片绑定声明完成: 成功 {}/{}", bindingSuccessCount, shardedBindings.size());
|
||||||
|
|
||||||
/**
|
// 声明遗留队列和绑定 - 检查存在性
|
||||||
* 保持向后兼容的单队列绑定(可选)
|
try {
|
||||||
*/
|
rabbitAdmin.declareQueue(legacyQueue);
|
||||||
@Bean
|
rabbitAdmin.declareBinding(legacyBinding);
|
||||||
public Binding legacyBinding(Queue legacyQueue, TopicExchange exchange) {
|
log.info("遗留队列和绑定就绪: {} (已存在或新创建)", QUEUE_NAME);
|
||||||
return BindingBuilder.bind(legacyQueue).to(exchange).with(ROUTING_KEY);
|
} 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());
|
||||||
|
}
|
||||||
|
|
||||||
@Bean
|
log.info("=== RabbitMQ 组件声明完成 ===");
|
||||||
public Jackson2JsonMessageConverter messageConverter() {
|
log.info("请检查 RabbitMQ 管理界面确认队列已正确创建");
|
||||||
ObjectMapper objectMapper = new ObjectMapper();
|
} catch (Exception e) {
|
||||||
objectMapper.registerModule(new com.fasterxml.jackson.datatype.jsr310.JavaTimeModule());
|
log.error("RabbitMQ 组件声明过程中发生严重错误", e);
|
||||||
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 -> {
|
|
||||||
System.out.println("=== 开始主动声明 RabbitMQ 组件 ===");
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 声明交换
|
|
||||||
rabbitAdmin.declareExchange(exchange);
|
|
||||||
|
|
||||||
// 声明分片队列 - 检查存在性
|
|
||||||
System.out.println("开始检查并声明 " + 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) {
|
|
||||||
System.err.println("队列声明失败: " + queueName + ", 错误: " + e.getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
System.out.println("分片队列处理完成: 成功 " + successCount + ", 跳过 " + skippedCount + ", 总数 " + shardedQueues.size());
|
|
||||||
|
|
||||||
// 声明分片绑定
|
|
||||||
System.out.println("开始声明 " + shardedBindings.size() + " 个分片绑定...");
|
|
||||||
int bindingSuccessCount = 0;
|
|
||||||
for (Binding binding : shardedBindings) {
|
|
||||||
try {
|
|
||||||
rabbitAdmin.declareBinding(binding);
|
|
||||||
bindingSuccessCount++;
|
|
||||||
} catch (Exception e) {
|
|
||||||
System.err.println("绑定声明失败: " + e.getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
System.out.println("分片绑定声明完成: 成功 " + bindingSuccessCount + "/" + shardedBindings.size());
|
|
||||||
|
|
||||||
// 声明遗留队列和绑定 - 检查存在性
|
|
||||||
try {
|
|
||||||
rabbitAdmin.declareQueue(legacyQueue);
|
|
||||||
rabbitAdmin.declareBinding(legacyBinding);
|
|
||||||
System.out.println("遗留队列和绑定就绪: " + QUEUE_NAME + " (已存在或新创建)");
|
|
||||||
} catch (org.springframework.amqp.AmqpIOException e) {
|
|
||||||
if (e.getMessage().contains("PRECONDITION_FAILED") && e.getMessage().contains("durable")) {
|
|
||||||
System.out.println("遗留队列已存在但 durable 设置不匹配: " + QUEUE_NAME + ", 保持现有队列");
|
|
||||||
} else {
|
|
||||||
System.err.println("遗留队列声明失败: " + QUEUE_NAME + ", 错误: " + e.getMessage());
|
|
||||||
}
|
|
||||||
} catch (Exception e) {
|
|
||||||
System.err.println("遗留队列声明失败: " + QUEUE_NAME + ", 错误: " + e.getMessage());
|
|
||||||
}
|
|
||||||
|
|
||||||
System.out.println("=== RabbitMQ 组件声明完成 ===");
|
|
||||||
System.out.println("请检查 RabbitMQ 管理界面确认队列已正确创建");
|
|
||||||
|
|
||||||
} catch (Exception e) {
|
|
||||||
System.err.println("RabbitMQ 组件声明过程中发生严重错误:");
|
|
||||||
e.printStackTrace();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,19 +2,20 @@ 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
|
|
||||||
public TaskScheduler taskScheduler() {
|
@Bean
|
||||||
ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
|
public TaskScheduler taskScheduler() {
|
||||||
scheduler.setPoolSize(2);
|
ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
|
||||||
scheduler.setThreadNamePrefix("lottery-");
|
scheduler.setPoolSize(2);
|
||||||
scheduler.initialize();
|
scheduler.setThreadNamePrefix("lottery-");
|
||||||
return scheduler;
|
scheduler.initialize();
|
||||||
}
|
return scheduler;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,20 @@
|
|||||||
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 com.openisle.repository.UserRepository;
|
import jakarta.servlet.FilterChain;
|
||||||
|
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.http.HttpMethod;
|
import org.springframework.http.HttpMethod;
|
||||||
import org.springframework.security.authentication.AuthenticationManager;
|
import org.springframework.security.authentication.AuthenticationManager;
|
||||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||||
@@ -21,194 +30,268 @@ 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.beans.factory.annotation.Value;
|
import org.springframework.web.filter.OncePerRequestFilter;
|
||||||
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;
|
|
||||||
|
|
||||||
@Bean
|
private final JwtService jwtService;
|
||||||
public PasswordEncoder passwordEncoder() {
|
private final UserRepository userRepository;
|
||||||
return new BCryptPasswordEncoder();
|
private final AccessDeniedHandler customAccessDeniedHandler;
|
||||||
}
|
private final UserVisitService userVisitService;
|
||||||
|
|
||||||
@Bean
|
@Value("${app.website-url}")
|
||||||
public UserDetailsService userDetailsService() {
|
private String websiteUrl;
|
||||||
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
|
private final RedisTemplate redisTemplate;
|
||||||
public AuthenticationManager authenticationManager(HttpSecurity http, PasswordEncoder passwordEncoder, UserDetailsService userDetailsService) throws Exception {
|
|
||||||
return http.getSharedObject(AuthenticationManagerBuilder.class)
|
|
||||||
.userDetailsService(userDetailsService)
|
|
||||||
.passwordEncoder(passwordEncoder)
|
|
||||||
.and()
|
|
||||||
.build();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
public CorsConfigurationSource corsConfigurationSource() {
|
public PasswordEncoder passwordEncoder() {
|
||||||
CorsConfiguration cfg = new CorsConfiguration();
|
return new BCryptPasswordEncoder();
|
||||||
cfg.setAllowedOrigins(List.of(
|
}
|
||||||
"http://127.0.0.1:8080",
|
|
||||||
"http://127.0.0.1:8081",
|
|
||||||
"http://127.0.0.1:8082",
|
|
||||||
"http://127.0.0.1:3000",
|
|
||||||
"http://127.0.0.1:3001",
|
|
||||||
"http://127.0.0.1",
|
|
||||||
"http://localhost:8080",
|
|
||||||
"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",
|
|
||||||
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 SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
public UserDetailsService userDetailsService() {
|
||||||
http.csrf(csrf -> csrf.disable())
|
return username ->
|
||||||
.cors(Customizer.withDefaults())
|
userRepository
|
||||||
.headers(h -> h.frameOptions(f -> f.sameOrigin()))
|
.findByUsername(username)
|
||||||
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
.<UserDetails>map(user ->
|
||||||
.exceptionHandling(eh -> eh.accessDeniedHandler(customAccessDeniedHandler))
|
org.springframework.security.core.userdetails.User.withUsername(user.getUsername())
|
||||||
.authorizeHttpRequests(auth -> auth
|
.password(user.getPassword())
|
||||||
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
|
.authorities(user.getRole().name())
|
||||||
.requestMatchers("/api/ws/**", "/api/sockjs/**").permitAll()
|
.build()
|
||||||
.requestMatchers("/api/v3/api-docs/**").permitAll()
|
)
|
||||||
.requestMatchers(HttpMethod.POST, "/api/auth/**").permitAll()
|
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
|
||||||
.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 OncePerRequestFilter jwtAuthenticationFilter() {
|
public AuthenticationManager authenticationManager(
|
||||||
return new OncePerRequestFilter() {
|
HttpSecurity http,
|
||||||
@Override
|
PasswordEncoder passwordEncoder,
|
||||||
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
|
UserDetailsService userDetailsService
|
||||||
// 让预检请求直接通过
|
) throws Exception {
|
||||||
if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
|
return http
|
||||||
filterChain.doFilter(request, response);
|
.getSharedObject(AuthenticationManagerBuilder.class)
|
||||||
return;
|
.userDetailsService(userDetailsService)
|
||||||
}
|
.passwordEncoder(passwordEncoder)
|
||||||
String authHeader = request.getHeader("Authorization");
|
.and()
|
||||||
String uri = request.getRequestURI();
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
boolean publicGet = "GET".equalsIgnoreCase(request.getMethod()) &&
|
@Bean
|
||||||
(uri.startsWith("/api/posts") || uri.startsWith("/api/comments") ||
|
public CorsConfigurationSource corsConfigurationSource() {
|
||||||
uri.startsWith("/api/categories") || uri.startsWith("/api/tags") ||
|
CorsConfiguration cfg = new CorsConfiguration();
|
||||||
uri.startsWith("/api/search") || uri.startsWith("/api/users") ||
|
cfg.setAllowedOrigins(
|
||||||
uri.startsWith("/api/reaction-types") || uri.startsWith("/api/config") ||
|
List.of(
|
||||||
uri.startsWith("/api/activities") || uri.startsWith("/api/push/public-key") ||
|
"http://127.0.0.1:8080",
|
||||||
uri.startsWith("/api/point-goods") || uri.startsWith("/api/channels") ||
|
"http://127.0.0.1:8081",
|
||||||
uri.startsWith("/api/sitemap.xml") || uri.startsWith("/api/medals") ||
|
"http://127.0.0.1:8082",
|
||||||
uri.startsWith("/api/rss"));
|
"http://127.0.0.1:3000",
|
||||||
|
"http://127.0.0.1:3001",
|
||||||
|
"http://127.0.0.1",
|
||||||
|
"http://localhost:8080",
|
||||||
|
"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;
|
||||||
|
}
|
||||||
|
|
||||||
if (authHeader != null && authHeader.startsWith("Bearer ")) {
|
@Bean
|
||||||
String token = authHeader.substring(7);
|
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
||||||
try {
|
http
|
||||||
String username = jwtService.validateAndGetSubject(token);
|
.csrf(csrf -> csrf.disable())
|
||||||
UserDetails userDetails = userDetailsService().loadUserByUsername(username);
|
.cors(Customizer.withDefaults())
|
||||||
UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
|
.headers(h -> h.frameOptions(f -> f.sameOrigin()))
|
||||||
userDetails, null, userDetails.getAuthorities());
|
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||||
org.springframework.security.core.context.SecurityContextHolder.getContext().setAuthentication(authToken);
|
.exceptionHandling(eh -> eh.accessDeniedHandler(customAccessDeniedHandler))
|
||||||
} catch (Exception e) {
|
.authorizeHttpRequests(auth ->
|
||||||
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
|
auth
|
||||||
response.setContentType("application/json");
|
.requestMatchers(HttpMethod.OPTIONS, "/**")
|
||||||
response.getWriter().write("{\"error\": \"Invalid or expired token\"}");
|
.permitAll()
|
||||||
return;
|
.requestMatchers("/api/ws/**", "/api/sockjs/**")
|
||||||
}
|
.permitAll()
|
||||||
} else if (!uri.startsWith("/api/auth") && !publicGet
|
.requestMatchers("/api/v3/api-docs/**")
|
||||||
&& !uri.startsWith("/api/ws") && !uri.startsWith("/api/sockjs")
|
.permitAll()
|
||||||
&& !uri.startsWith("/api/v3/api-docs")
|
.requestMatchers(HttpMethod.POST, "/api/auth/**")
|
||||||
&& !uri.startsWith("/api/online")) {
|
.permitAll()
|
||||||
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
|
.requestMatchers(HttpMethod.GET, "/api/posts/**")
|
||||||
response.setContentType("application/json");
|
.permitAll()
|
||||||
response.getWriter().write("{\"error\": \"Missing token\"}");
|
.requestMatchers(HttpMethod.GET, "/api/comments/**")
|
||||||
return;
|
.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();
|
||||||
|
}
|
||||||
|
|
||||||
filterChain.doFilter(request, response);
|
@Bean
|
||||||
}
|
public OncePerRequestFilter jwtAuthenticationFilter() {
|
||||||
};
|
return new OncePerRequestFilter() {
|
||||||
}
|
@Override
|
||||||
|
protected void doFilterInternal(
|
||||||
|
HttpServletRequest request,
|
||||||
|
HttpServletResponse response,
|
||||||
|
FilterChain filterChain
|
||||||
|
) throws ServletException, IOException {
|
||||||
|
// 让预检请求直接通过
|
||||||
|
if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
|
||||||
|
filterChain.doFilter(request, response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String authHeader = request.getHeader("Authorization");
|
||||||
|
String uri = request.getRequestURI();
|
||||||
|
|
||||||
@Bean
|
boolean publicGet =
|
||||||
public OncePerRequestFilter userVisitFilter() {
|
"GET".equalsIgnoreCase(request.getMethod()) &&
|
||||||
return new OncePerRequestFilter() {
|
(uri.startsWith("/api/posts") ||
|
||||||
@Override
|
uri.startsWith("/api/comments") ||
|
||||||
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
|
uri.startsWith("/api/categories") ||
|
||||||
var auth = org.springframework.security.core.context.SecurityContextHolder.getContext().getAuthentication();
|
uri.startsWith("/api/tags") ||
|
||||||
if (auth != null && auth.isAuthenticated() && !(auth instanceof org.springframework.security.authentication.AnonymousAuthenticationToken)) {
|
uri.startsWith("/api/search") ||
|
||||||
userVisitService.recordVisit(auth.getName());
|
uri.startsWith("/api/users") ||
|
||||||
}
|
uri.startsWith("/api/reaction-types") ||
|
||||||
filterChain.doFilter(request, response);
|
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 ")) {
|
||||||
|
String token = authHeader.substring(7);
|
||||||
|
try {
|
||||||
|
String username = jwtService.validateAndGetSubject(token);
|
||||||
|
UserDetails userDetails = userDetailsService().loadUserByUsername(username);
|
||||||
|
UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
|
||||||
|
userDetails,
|
||||||
|
null,
|
||||||
|
userDetails.getAuthorities()
|
||||||
|
);
|
||||||
|
org.springframework.security.core.context.SecurityContextHolder.getContext().setAuthentication(
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ import lombok.NoArgsConstructor;
|
|||||||
@AllArgsConstructor
|
@AllArgsConstructor
|
||||||
@NoArgsConstructor
|
@NoArgsConstructor
|
||||||
public class ShardInfo {
|
public class ShardInfo {
|
||||||
private int shardIndex;
|
|
||||||
private String queueName;
|
private int shardIndex;
|
||||||
private String routingKey;
|
private String queueName;
|
||||||
}
|
private String routingKey;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,84 +1,87 @@
|
|||||||
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package com.openisle.config;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import lombok.Data;
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Component
|
||||||
|
@ConfigurationProperties(prefix = "springdoc.api-docs")
|
||||||
|
public class SpringDocProperties {
|
||||||
|
|
||||||
|
private List<ServerConfig> servers = new ArrayList<>();
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public static class ServerConfig {
|
||||||
|
|
||||||
|
private String url;
|
||||||
|
private String description;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,23 +14,27 @@ 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;
|
|
||||||
|
|
||||||
@Override
|
private final UserRepository userRepository;
|
||||||
public void run(String... args) {
|
private final PasswordEncoder passwordEncoder;
|
||||||
userRepository.findByUsername("system").orElseGet(() -> {
|
|
||||||
User system = new User();
|
@Override
|
||||||
system.setUsername("system");
|
public void run(String... args) {
|
||||||
system.setEmail("system@openisle.local");
|
userRepository
|
||||||
// todo(tim): raw password 采用环境变量
|
.findByUsername("system")
|
||||||
system.setPassword(passwordEncoder.encode("system"));
|
.orElseGet(() -> {
|
||||||
system.setRole(Role.USER);
|
User system = new User();
|
||||||
system.setVerified(true);
|
system.setUsername("system");
|
||||||
system.setApproved(true);
|
system.setEmail("system@openisle.local");
|
||||||
system.setAvatar("https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/image.png");
|
// todo(tim): raw password 采用环境变量
|
||||||
return userRepository.save(system);
|
system.setPassword(passwordEncoder.encode("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);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,49 +9,75 @@ 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 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.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.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.stream.Collectors;
|
|
||||||
|
|
||||||
@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;
|
|
||||||
|
|
||||||
@GetMapping
|
private final ActivityService activityService;
|
||||||
public List<ActivityDto> list() {
|
private final UserService userService;
|
||||||
return activityService.list().stream()
|
private final ActivityMapper activityMapper;
|
||||||
.map(activityMapper::toDto)
|
|
||||||
.collect(Collectors.toList());
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/milk-tea")
|
@GetMapping
|
||||||
public MilkTeaInfoDto milkTea() {
|
@Operation(summary = "List activities", description = "Retrieve all activities")
|
||||||
Activity a = activityService.getByType(ActivityType.MILK_TEA);
|
@ApiResponse(
|
||||||
long count = activityService.countParticipants(a);
|
responseCode = "200",
|
||||||
if (!a.isEnded() && count >= 50) {
|
description = "List of activities",
|
||||||
activityService.end(a);
|
content = @Content(array = @ArraySchema(schema = @Schema(implementation = ActivityDto.class)))
|
||||||
}
|
)
|
||||||
MilkTeaInfoDto info = new MilkTeaInfoDto();
|
public List<ActivityDto> list() {
|
||||||
info.setRedeemCount(count);
|
return activityService.list().stream().map(activityMapper::toDto).collect(Collectors.toList());
|
||||||
info.setEnded(a.isEnded());
|
}
|
||||||
return info;
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping("/milk-tea/redeem")
|
@GetMapping("/milk-tea")
|
||||||
public java.util.Map<String, String> redeemMilkTea(@RequestBody MilkTeaRedeemRequest req, Authentication auth) {
|
@Operation(summary = "Milk tea info", description = "Get milk tea activity information")
|
||||||
User user = userService.findByIdentifier(auth.getName()).orElseThrow();
|
@ApiResponse(
|
||||||
Activity a = activityService.getByType(ActivityType.MILK_TEA);
|
responseCode = "200",
|
||||||
boolean first = activityService.redeem(a, user, req.getContact());
|
description = "Milk tea info",
|
||||||
if (first) {
|
content = @Content(schema = @Schema(implementation = MilkTeaInfoDto.class))
|
||||||
return java.util.Map.of("message", "redeemed");
|
)
|
||||||
}
|
public MilkTeaInfoDto milkTea() {
|
||||||
return java.util.Map.of("message", "updated");
|
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")
|
||||||
|
@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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,11 @@ package com.openisle.controller;
|
|||||||
import com.openisle.dto.CommentDto;
|
import com.openisle.dto.CommentDto;
|
||||||
import com.openisle.mapper.CommentMapper;
|
import com.openisle.mapper.CommentMapper;
|
||||||
import com.openisle.service.CommentService;
|
import com.openisle.service.CommentService;
|
||||||
|
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 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.*;
|
||||||
@@ -14,16 +19,31 @@ 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;
|
|
||||||
|
|
||||||
@PostMapping("/{id}/pin")
|
private final CommentService commentService;
|
||||||
public CommentDto pin(@PathVariable Long id, Authentication auth) {
|
private final CommentMapper commentMapper;
|
||||||
return commentMapper.toDto(commentService.pinComment(auth.getName(), id));
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping("/{id}/unpin")
|
@PostMapping("/{id}/pin")
|
||||||
public CommentDto unpin(@PathVariable Long id, Authentication auth) {
|
@SecurityRequirement(name = "JWT")
|
||||||
return commentMapper.toDto(commentService.unpinComment(auth.getName(), id));
|
@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}/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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,11 @@ import com.openisle.service.AiUsageService;
|
|||||||
import com.openisle.service.PasswordValidator;
|
import com.openisle.service.PasswordValidator;
|
||||||
import com.openisle.service.PostService;
|
import com.openisle.service.PostService;
|
||||||
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 io.swagger.v3.oas.annotations.security.SecurityRequirement;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
@@ -12,36 +17,56 @@ 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;
|
|
||||||
|
|
||||||
@GetMapping
|
private final PostService postService;
|
||||||
public ConfigDto getConfig() {
|
private final PasswordValidator passwordValidator;
|
||||||
ConfigDto dto = new ConfigDto();
|
private final AiUsageService aiUsageService;
|
||||||
dto.setPublishMode(postService.getPublishMode());
|
private final RegisterModeService registerModeService;
|
||||||
dto.setPasswordStrength(passwordValidator.getStrength());
|
|
||||||
dto.setAiFormatLimit(aiUsageService.getFormatLimit());
|
@GetMapping
|
||||||
dto.setRegisterMode(registerModeService.getRegisterMode());
|
@SecurityRequirement(name = "JWT")
|
||||||
return dto;
|
@Operation(
|
||||||
|
summary = "Get configuration",
|
||||||
|
description = "Retrieve application configuration settings"
|
||||||
|
)
|
||||||
|
@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;
|
||||||
|
}
|
||||||
|
|
||||||
|
@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) {
|
||||||
@PostMapping
|
passwordValidator.setStrength(dto.getPasswordStrength());
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
|
if (dto.getAiFormatLimit() != null) {
|
||||||
|
aiUsageService.setFormatLimit(dto.getAiFormatLimit());
|
||||||
|
}
|
||||||
|
if (dto.getRegisterMode() != null) {
|
||||||
|
registerModeService.setRegisterMode(dto.getRegisterMode());
|
||||||
|
}
|
||||||
|
return getConfig();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,29 @@
|
|||||||
package com.openisle.controller;
|
package com.openisle.controller;
|
||||||
|
|
||||||
|
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 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")
|
|
||||||
public Map<String, String> adminHello() {
|
@GetMapping("/api/admin/hello")
|
||||||
return Map.of("message", "Hello, Admin User");
|
@SecurityRequirement(name = "JWT")
|
||||||
}
|
@Operation(summary = "Admin greeting", description = "Returns a greeting for admin users")
|
||||||
|
@ApiResponse(
|
||||||
|
responseCode = "200",
|
||||||
|
description = "Greeting payload",
|
||||||
|
content = @Content(schema = @Schema(implementation = Map.class))
|
||||||
|
)
|
||||||
|
public Map<String, String> adminHello() {
|
||||||
|
return Map.of("message", "Hello, Admin User");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,11 +3,16 @@ package com.openisle.controller;
|
|||||||
import com.openisle.dto.PostSummaryDto;
|
import com.openisle.dto.PostSummaryDto;
|
||||||
import com.openisle.mapper.PostMapper;
|
import com.openisle.mapper.PostMapper;
|
||||||
import com.openisle.service.PostService;
|
import com.openisle.service.PostService;
|
||||||
import lombok.RequiredArgsConstructor;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
import org.springframework.web.bind.annotation.*;
|
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.List;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Endpoints for administrators to manage posts.
|
* Endpoints for administrators to manage posts.
|
||||||
@@ -16,43 +21,109 @@ import java.util.stream.Collectors;
|
|||||||
@RequestMapping("/api/admin/posts")
|
@RequestMapping("/api/admin/posts")
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class AdminPostController {
|
public class AdminPostController {
|
||||||
private final PostService postService;
|
|
||||||
private final PostMapper postMapper;
|
|
||||||
|
|
||||||
@GetMapping("/pending")
|
private final PostService postService;
|
||||||
public List<PostSummaryDto> pendingPosts() {
|
private final PostMapper postMapper;
|
||||||
return postService.listPendingPosts().stream()
|
|
||||||
.map(postMapper::toSummaryDto)
|
|
||||||
.collect(Collectors.toList());
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping("/{id}/approve")
|
@GetMapping("/pending")
|
||||||
public PostSummaryDto approve(@PathVariable Long id) {
|
@SecurityRequirement(name = "JWT")
|
||||||
return postMapper.toSummaryDto(postService.approvePost(id));
|
@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());
|
||||||
|
}
|
||||||
|
|
||||||
@PostMapping("/{id}/reject")
|
@PostMapping("/{id}/approve")
|
||||||
public PostSummaryDto reject(@PathVariable Long id) {
|
@SecurityRequirement(name = "JWT")
|
||||||
return postMapper.toSummaryDto(postService.rejectPost(id));
|
@Operation(summary = "Approve post", description = "Approve a pending post")
|
||||||
}
|
@ApiResponse(
|
||||||
|
responseCode = "200",
|
||||||
|
description = "Approved post",
|
||||||
|
content = @Content(schema = @Schema(implementation = PostSummaryDto.class))
|
||||||
|
)
|
||||||
|
public PostSummaryDto approve(@PathVariable Long id) {
|
||||||
|
return postMapper.toSummaryDto(postService.approvePost(id));
|
||||||
|
}
|
||||||
|
|
||||||
@PostMapping("/{id}/pin")
|
@PostMapping("/{id}/reject")
|
||||||
public PostSummaryDto pin(@PathVariable Long id, org.springframework.security.core.Authentication auth) {
|
@SecurityRequirement(name = "JWT")
|
||||||
return postMapper.toSummaryDto(postService.pinPost(id, auth.getName()));
|
@Operation(summary = "Reject post", description = "Reject a pending post")
|
||||||
}
|
@ApiResponse(
|
||||||
|
responseCode = "200",
|
||||||
|
description = "Rejected post",
|
||||||
|
content = @Content(schema = @Schema(implementation = PostSummaryDto.class))
|
||||||
|
)
|
||||||
|
public PostSummaryDto reject(@PathVariable Long id) {
|
||||||
|
return postMapper.toSummaryDto(postService.rejectPost(id));
|
||||||
|
}
|
||||||
|
|
||||||
@PostMapping("/{id}/unpin")
|
@PostMapping("/{id}/pin")
|
||||||
public PostSummaryDto unpin(@PathVariable Long id, org.springframework.security.core.Authentication auth) {
|
@SecurityRequirement(name = "JWT")
|
||||||
return postMapper.toSummaryDto(postService.unpinPost(id, auth.getName()));
|
@Operation(summary = "Pin post", description = "Pin a post to the top")
|
||||||
}
|
@ApiResponse(
|
||||||
|
responseCode = "200",
|
||||||
|
description = "Pinned post",
|
||||||
|
content = @Content(schema = @Schema(implementation = PostSummaryDto.class))
|
||||||
|
)
|
||||||
|
public PostSummaryDto pin(
|
||||||
|
@PathVariable Long id,
|
||||||
|
org.springframework.security.core.Authentication auth
|
||||||
|
) {
|
||||||
|
return postMapper.toSummaryDto(postService.pinPost(id, auth.getName()));
|
||||||
|
}
|
||||||
|
|
||||||
@PostMapping("/{id}/rss-exclude")
|
@PostMapping("/{id}/unpin")
|
||||||
public PostSummaryDto excludeFromRss(@PathVariable Long id, org.springframework.security.core.Authentication auth) {
|
@SecurityRequirement(name = "JWT")
|
||||||
return postMapper.toSummaryDto(postService.excludeFromRss(id, auth.getName()));
|
@Operation(summary = "Unpin post", description = "Remove a post from the top")
|
||||||
}
|
@ApiResponse(
|
||||||
|
responseCode = "200",
|
||||||
|
description = "Unpinned post",
|
||||||
|
content = @Content(schema = @Schema(implementation = PostSummaryDto.class))
|
||||||
|
)
|
||||||
|
public PostSummaryDto unpin(
|
||||||
|
@PathVariable Long id,
|
||||||
|
org.springframework.security.core.Authentication auth
|
||||||
|
) {
|
||||||
|
return postMapper.toSummaryDto(postService.unpinPost(id, auth.getName()));
|
||||||
|
}
|
||||||
|
|
||||||
@PostMapping("/{id}/rss-include")
|
@PostMapping("/{id}/rss-exclude")
|
||||||
public PostSummaryDto includeInRss(@PathVariable Long id, org.springframework.security.core.Authentication auth) {
|
@SecurityRequirement(name = "JWT")
|
||||||
return postMapper.toSummaryDto(postService.includeInRss(id, auth.getName()));
|
@Operation(summary = "Exclude from RSS", description = "Exclude a post from RSS feed")
|
||||||
}
|
@ApiResponse(
|
||||||
|
responseCode = "200",
|
||||||
|
description = "Updated post",
|
||||||
|
content = @Content(schema = @Schema(implementation = PostSummaryDto.class))
|
||||||
|
)
|
||||||
|
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()));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,31 +5,53 @@ import com.openisle.mapper.TagMapper;
|
|||||||
import com.openisle.model.Tag;
|
import com.openisle.model.Tag;
|
||||||
import com.openisle.service.PostService;
|
import com.openisle.service.PostService;
|
||||||
import com.openisle.service.TagService;
|
import com.openisle.service.TagService;
|
||||||
import lombok.RequiredArgsConstructor;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
import org.springframework.web.bind.annotation.*;
|
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.List;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
@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;
|
|
||||||
|
|
||||||
@GetMapping("/pending")
|
private final TagService tagService;
|
||||||
public List<TagDto> pendingTags() {
|
private final PostService postService;
|
||||||
return tagService.listPendingTags().stream()
|
private final TagMapper tagMapper;
|
||||||
.map(t -> tagMapper.toDto(t, postService.countPostsByTag(t.getId())))
|
|
||||||
.collect(Collectors.toList());
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping("/{id}/approve")
|
@GetMapping("/pending")
|
||||||
public TagDto approve(@PathVariable Long id) {
|
@SecurityRequirement(name = "JWT")
|
||||||
Tag tag = tagService.approveTag(id);
|
@Operation(summary = "List pending tags", description = "Retrieve tags awaiting approval")
|
||||||
long count = postService.countPostsByTag(tag.getId());
|
@ApiResponse(
|
||||||
return tagMapper.toDto(tag, count);
|
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());
|
||||||
|
}
|
||||||
|
|
||||||
|
@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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,12 @@ 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.responses.ApiResponse;
|
||||||
|
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
|
||||||
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;
|
||||||
@@ -15,40 +18,56 @@ 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;
|
|
||||||
|
|
||||||
@PostMapping("/{id}/approve")
|
private final UserRepository userRepository;
|
||||||
public ResponseEntity<?> approve(@PathVariable Long id) {
|
private final NotificationRepository notificationRepository;
|
||||||
User user = userRepository.findById(id).orElseThrow();
|
private final EmailSender emailSender;
|
||||||
user.setApproved(true);
|
|
||||||
userRepository.save(user);
|
|
||||||
markRegisterRequestNotificationsRead(user);
|
|
||||||
emailSender.sendEmail(user.getEmail(), "您的注册已审核通过",
|
|
||||||
"🎉您的注册已经审核通过, 点击以访问网站: " + websiteUrl);
|
|
||||||
return ResponseEntity.ok().build();
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping("/{id}/reject")
|
@Value("${app.website-url}")
|
||||||
public ResponseEntity<?> reject(@PathVariable Long id) {
|
private String websiteUrl;
|
||||||
User user = userRepository.findById(id).orElseThrow();
|
|
||||||
user.setApproved(false);
|
|
||||||
userRepository.save(user);
|
|
||||||
markRegisterRequestNotificationsRead(user);
|
|
||||||
emailSender.sendEmail(user.getEmail(), "您的注册已被管理员拒绝",
|
|
||||||
"您的注册被管理员拒绝, 点击链接可以重新填写理由申请: " + websiteUrl);
|
|
||||||
return ResponseEntity.ok().build();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void markRegisterRequestNotificationsRead(User applicant) {
|
@PostMapping("/{id}/approve")
|
||||||
java.util.List<Notification> notifs =
|
@SecurityRequirement(name = "JWT")
|
||||||
notificationRepository.findByTypeAndFromUser(NotificationType.REGISTER_REQUEST, applicant);
|
@Operation(summary = "Approve user", description = "Approve a pending user registration")
|
||||||
for (Notification n : notifs) {
|
@ApiResponse(responseCode = "200", description = "User approved")
|
||||||
n.setRead(true);
|
public ResponseEntity<?> approve(@PathVariable Long id) {
|
||||||
}
|
User user = userRepository.findById(id).orElseThrow();
|
||||||
notificationRepository.saveAll(notifs);
|
user.setApproved(true);
|
||||||
|
userRepository.save(user);
|
||||||
|
markRegisterRequestNotificationsRead(user);
|
||||||
|
emailSender.sendEmail(
|
||||||
|
user.getEmail(),
|
||||||
|
"您的注册已审核通过",
|
||||||
|
"🎉您的注册已经审核通过, 点击以访问网站: " + websiteUrl
|
||||||
|
);
|
||||||
|
return ResponseEntity.ok().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{id}/reject")
|
||||||
|
@SecurityRequirement(name = "JWT")
|
||||||
|
@Operation(summary = "Reject user", description = "Reject a pending user registration")
|
||||||
|
@ApiResponse(responseCode = "200", description = "User rejected")
|
||||||
|
public ResponseEntity<?> reject(@PathVariable Long id) {
|
||||||
|
User user = userRepository.findById(id).orElseThrow();
|
||||||
|
user.setApproved(false);
|
||||||
|
userRepository.save(user);
|
||||||
|
markRegisterRequestNotificationsRead(user);
|
||||||
|
emailSender.sendEmail(
|
||||||
|
user.getEmail(),
|
||||||
|
"您的注册已被管理员拒绝",
|
||||||
|
"您的注册被管理员拒绝, 点击链接可以重新填写理由申请: " + 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
package com.openisle.controller;
|
package com.openisle.controller;
|
||||||
|
|
||||||
import com.openisle.service.OpenAiService;
|
|
||||||
import com.openisle.service.AiUsageService;
|
import com.openisle.service.AiUsageService;
|
||||||
|
import com.openisle.service.OpenAiService;
|
||||||
|
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.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.security.core.Authentication;
|
import org.springframework.security.core.Authentication;
|
||||||
@@ -10,31 +16,39 @@ 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 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")
|
||||||
public ResponseEntity<Map<String, String>> format(@RequestBody Map<String, String> req,
|
@Operation(summary = "Format markdown", description = "Format text via AI")
|
||||||
Authentication auth) {
|
@ApiResponse(
|
||||||
String text = req.get("text");
|
responseCode = "200",
|
||||||
if (text == null) {
|
description = "Formatted content",
|
||||||
return ResponseEntity.badRequest().build();
|
content = @Content(schema = @Schema(implementation = Map.class))
|
||||||
}
|
)
|
||||||
int limit = aiUsageService.getFormatLimit();
|
@SecurityRequirement(name = "JWT")
|
||||||
int used = aiUsageService.getCount(auth.getName());
|
public ResponseEntity<Map<String, String>> format(
|
||||||
if (limit > 0 && used >= limit) {
|
@RequestBody Map<String, String> req,
|
||||||
return ResponseEntity.status(429).build();
|
Authentication auth
|
||||||
}
|
) {
|
||||||
aiUsageService.incrementAndGetCount(auth.getName());
|
String text = req.get("text");
|
||||||
return openAiService.formatMarkdown(text)
|
if (text == null) {
|
||||||
.map(t -> ResponseEntity.ok(Map.of("content", t)))
|
return ResponseEntity.badRequest().build();
|
||||||
.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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -8,66 +8,120 @@ 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 io.swagger.v3.oas.annotations.Operation;
|
||||||
import org.springframework.web.bind.annotation.*;
|
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.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;
|
|
||||||
|
|
||||||
@PostMapping
|
private final CategoryService categoryService;
|
||||||
public CategoryDto create(@RequestBody CategoryRequest req) {
|
private final PostService postService;
|
||||||
Category c = categoryService.createCategory(req.getName(), req.getDescription(), req.getIcon(), req.getSmallIcon());
|
private final PostMapper postMapper;
|
||||||
long count = postService.countPostsByCategory(c.getId());
|
private final CategoryMapper categoryMapper;
|
||||||
return categoryMapper.toDto(c, count);
|
|
||||||
}
|
|
||||||
|
|
||||||
@PutMapping("/{id}")
|
@PostMapping
|
||||||
public CategoryDto update(@PathVariable Long id, @RequestBody CategoryRequest req) {
|
@Operation(summary = "Create category", description = "Create a new category")
|
||||||
Category c = categoryService.updateCategory(id, req.getName(), req.getDescription(), req.getIcon(), req.getSmallIcon());
|
@ApiResponse(
|
||||||
long count = postService.countPostsByCategory(c.getId());
|
responseCode = "200",
|
||||||
return categoryMapper.toDto(c, count);
|
description = "Created category",
|
||||||
}
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
@DeleteMapping("/{id}")
|
@PutMapping("/{id}")
|
||||||
public void delete(@PathVariable Long id) {
|
@Operation(summary = "Update category", description = "Update an existing category")
|
||||||
categoryService.deleteCategory(id);
|
@ApiResponse(
|
||||||
}
|
responseCode = "200",
|
||||||
|
description = "Updated category",
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping
|
@DeleteMapping("/{id}")
|
||||||
public List<CategoryDto> list() {
|
@Operation(summary = "Delete category", description = "Remove a category by id")
|
||||||
List<Category> all = categoryService.listCategories();
|
@ApiResponse(responseCode = "200", description = "Category deleted")
|
||||||
List<Long> ids = all.stream().map(Category::getId).toList();
|
public void delete(@PathVariable Long id) {
|
||||||
Map<Long, Long> postsCntByCategoryIds = postService.countPostsByCategoryIds(ids);
|
categoryService.deleteCategory(id);
|
||||||
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
|
||||||
public CategoryDto get(@PathVariable Long id) {
|
@Operation(summary = "List categories", description = "Get all categories")
|
||||||
Category c = categoryService.getCategory(id);
|
@ApiResponse(
|
||||||
long count = postService.countPostsByCategory(c.getId());
|
responseCode = "200",
|
||||||
return categoryMapper.toDto(c, count);
|
description = "List of categories",
|
||||||
}
|
content = @Content(array = @ArraySchema(schema = @Schema(implementation = CategoryDto.class)))
|
||||||
|
)
|
||||||
|
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("/{id}/posts")
|
@GetMapping("/{id}")
|
||||||
public List<PostSummaryDto> listPostsByCategory(@PathVariable Long id,
|
@Operation(summary = "Get category", description = "Get category by id")
|
||||||
@RequestParam(value = "page", required = false) Integer page,
|
@ApiResponse(
|
||||||
@RequestParam(value = "pageSize", required = false) Integer pageSize) {
|
responseCode = "200",
|
||||||
return postService.listPostsByCategories(java.util.List.of(id), page, pageSize)
|
description = "Category detail",
|
||||||
.stream()
|
content = @Content(schema = @Schema(implementation = CategoryDto.class))
|
||||||
.map(postMapper::toSummaryDto)
|
)
|
||||||
.collect(Collectors.toList());
|
public CategoryDto get(@PathVariable Long id) {
|
||||||
}
|
Category c = categoryService.getCategory(id);
|
||||||
|
long count = postService.countPostsByCategory(c.getId());
|
||||||
|
return categoryMapper.toDto(c, count);
|
||||||
|
}
|
||||||
|
|
||||||
|
@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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,38 +5,66 @@ 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 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.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;
|
|
||||||
|
|
||||||
@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 Long getCurrentUserId(Authentication auth) {
|
private final ChannelService channelService;
|
||||||
User user = userRepository.findByUsername(auth.getName())
|
private final MessageService messageService;
|
||||||
.orElseThrow(() -> new IllegalArgumentException("User not found"));
|
private final UserRepository userRepository;
|
||||||
return user.getId();
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping
|
private Long getCurrentUserId(Authentication auth) {
|
||||||
public List<ChannelDto> listChannels(Authentication auth) {
|
User user = userRepository
|
||||||
return channelService.listChannels(getCurrentUserId(auth));
|
.findByUsername(auth.getName())
|
||||||
}
|
.orElseThrow(() -> new IllegalArgumentException("User not found"));
|
||||||
|
return user.getId();
|
||||||
|
}
|
||||||
|
|
||||||
@PostMapping("/{channelId}/join")
|
@GetMapping
|
||||||
public ChannelDto joinChannel(@PathVariable Long channelId, Authentication auth) {
|
@Operation(summary = "List channels", description = "List channels for the current user")
|
||||||
return channelService.joinChannel(channelId, getCurrentUserId(auth));
|
@ApiResponse(
|
||||||
}
|
responseCode = "200",
|
||||||
|
description = "Channels",
|
||||||
|
content = @Content(array = @ArraySchema(schema = @Schema(implementation = ChannelDto.class)))
|
||||||
|
)
|
||||||
|
@SecurityRequirement(name = "JWT")
|
||||||
|
public List<ChannelDto> listChannels(Authentication auth) {
|
||||||
|
return channelService.listChannels(getCurrentUserId(auth));
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/unread-count")
|
@PostMapping("/{channelId}/join")
|
||||||
public long unreadCount(Authentication auth) {
|
@Operation(summary = "Join channel", description = "Join a channel")
|
||||||
return messageService.getUnreadChannelCount(getCurrentUserId(auth));
|
@ApiResponse(
|
||||||
}
|
responseCode = "200",
|
||||||
|
description = "Joined channel",
|
||||||
|
content = @Content(schema = @Schema(implementation = ChannelDto.class))
|
||||||
|
)
|
||||||
|
@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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,24 @@
|
|||||||
package com.openisle.controller;
|
package com.openisle.controller;
|
||||||
|
|
||||||
import com.openisle.model.Comment;
|
|
||||||
import com.openisle.dto.CommentDto;
|
import com.openisle.dto.CommentDto;
|
||||||
import com.openisle.dto.CommentRequest;
|
import com.openisle.dto.CommentRequest;
|
||||||
|
import com.openisle.dto.PostChangeLogDto;
|
||||||
|
import com.openisle.dto.TimelineItemDto;
|
||||||
import com.openisle.mapper.CommentMapper;
|
import com.openisle.mapper.CommentMapper;
|
||||||
import com.openisle.service.CaptchaService;
|
import com.openisle.mapper.PostChangeLogMapper;
|
||||||
import com.openisle.service.CommentService;
|
import com.openisle.model.Comment;
|
||||||
import com.openisle.service.LevelService;
|
import com.openisle.model.CommentSort;
|
||||||
import com.openisle.service.PointService;
|
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;
|
||||||
@@ -15,86 +26,173 @@ 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")
|
@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;
|
|
||||||
|
|
||||||
@Value("${app.captcha.enabled:false}")
|
private final CommentService commentService;
|
||||||
private boolean captchaEnabled;
|
private final LevelService levelService;
|
||||||
|
private final CaptchaService captchaService;
|
||||||
|
private final CommentMapper commentMapper;
|
||||||
|
private final PointService pointService;
|
||||||
|
private final PostChangeLogService changeLogService;
|
||||||
|
private final PostChangeLogMapper postChangeLogMapper;
|
||||||
|
|
||||||
@Value("${app.captcha.comment-enabled:false}")
|
@Value("${app.captcha.enabled:false}")
|
||||||
private boolean commentCaptchaEnabled;
|
private boolean captchaEnabled;
|
||||||
|
|
||||||
@PostMapping("/posts/{postId}/comments")
|
@Value("${app.captcha.comment-enabled:false}")
|
||||||
public ResponseEntity<CommentDto> createComment(@PathVariable Long postId,
|
private boolean commentCaptchaEnabled;
|
||||||
@RequestBody CommentRequest req,
|
|
||||||
Authentication auth) {
|
@PostMapping("/posts/{postId}/comments")
|
||||||
log.debug("createComment called by user {} for post {}", auth.getName(), postId);
|
@Operation(summary = "Create comment", description = "Add a comment to a post")
|
||||||
if (captchaEnabled && commentCaptchaEnabled && !captchaService.verify(req.getCaptcha())) {
|
@ApiResponse(
|
||||||
log.debug("Captcha verification failed for user {} on post {}", auth.getName(), postId);
|
responseCode = "200",
|
||||||
return ResponseEntity.badRequest().build();
|
description = "Created comment",
|
||||||
}
|
content = @Content(schema = @Schema(implementation = CommentDto.class))
|
||||||
Comment comment = commentService.addComment(auth.getName(), postId, req.getContent());
|
)
|
||||||
CommentDto dto = commentMapper.toDto(comment);
|
@SecurityRequirement(name = "JWT")
|
||||||
dto.setReward(levelService.awardForComment(auth.getName()));
|
public ResponseEntity<CommentDto> createComment(
|
||||||
dto.setPointReward(pointService.awardForComment(auth.getName(), postId, comment.getId()));
|
@PathVariable Long postId,
|
||||||
log.debug("createComment succeeded for comment {}", comment.getId());
|
@RequestBody CommentRequest req,
|
||||||
return ResponseEntity.ok(dto);
|
Authentication auth
|
||||||
|
) {
|
||||||
|
log.debug("createComment called by user {} for post {}", auth.getName(), postId);
|
||||||
|
if (captchaEnabled && commentCaptchaEnabled && !captchaService.verify(req.getCaptcha())) {
|
||||||
|
log.debug("Captcha verification failed for user {} on post {}", auth.getName(), postId);
|
||||||
|
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")
|
||||||
public ResponseEntity<CommentDto> replyComment(@PathVariable Long commentId,
|
@Operation(summary = "Reply to comment", description = "Reply to an existing comment")
|
||||||
@RequestBody CommentRequest req,
|
@ApiResponse(
|
||||||
Authentication auth) {
|
responseCode = "200",
|
||||||
log.debug("replyComment called by user {} for comment {}", auth.getName(), commentId);
|
description = "Reply created",
|
||||||
if (captchaEnabled && commentCaptchaEnabled && !captchaService.verify(req.getCaptcha())) {
|
content = @Content(schema = @Schema(implementation = CommentDto.class))
|
||||||
log.debug("Captcha verification failed for user {} on comment {}", auth.getName(), commentId);
|
)
|
||||||
return ResponseEntity.badRequest().build();
|
@SecurityRequirement(name = "JWT")
|
||||||
}
|
public ResponseEntity<CommentDto> replyComment(
|
||||||
Comment comment = commentService.addReply(auth.getName(), commentId, req.getContent());
|
@PathVariable Long commentId,
|
||||||
CommentDto dto = commentMapper.toDto(comment);
|
@RequestBody CommentRequest req,
|
||||||
dto.setReward(levelService.awardForComment(auth.getName()));
|
Authentication auth
|
||||||
log.debug("replyComment succeeded for comment {}", comment.getId());
|
) {
|
||||||
return ResponseEntity.ok(dto);
|
log.debug("replyComment called by user {} for comment {}", auth.getName(), commentId);
|
||||||
|
if (captchaEnabled && commentCaptchaEnabled && !captchaService.verify(req.getCaptcha())) {
|
||||||
|
log.debug("Captcha verification failed for user {} on comment {}", auth.getName(), commentId);
|
||||||
|
return ResponseEntity.badRequest().build();
|
||||||
}
|
}
|
||||||
|
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")
|
||||||
public List<CommentDto> listComments(@PathVariable Long postId,
|
@Operation(summary = "List comments", description = "List comments for a post")
|
||||||
@RequestParam(value = "sort", required = false, defaultValue = "OLDEST") com.openisle.model.CommentSort sort) {
|
@ApiResponse(
|
||||||
log.debug("listComments called for post {} with sort {}", postId, sort);
|
responseCode = "200",
|
||||||
List<CommentDto> list = commentService.getCommentsForPost(postId, sort).stream()
|
description = "Comments",
|
||||||
.map(commentMapper::toDtoWithReplies)
|
content = @Content(
|
||||||
.collect(Collectors.toList());
|
array = @ArraySchema(schema = @Schema(implementation = TimelineItemDto.class))
|
||||||
log.debug("listComments returning {} comments", list.size());
|
)
|
||||||
return list;
|
)
|
||||||
}
|
public List<TimelineItemDto<?>> listComments(
|
||||||
|
@PathVariable Long postId,
|
||||||
|
@RequestParam(value = "sort", required = false, defaultValue = "OLDEST") CommentSort sort
|
||||||
|
) {
|
||||||
|
log.debug("listComments called for post {} with sort {}", postId, sort);
|
||||||
|
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<>();
|
||||||
|
|
||||||
@DeleteMapping("/comments/{id}")
|
itemDtoList.addAll(
|
||||||
public void deleteComment(@PathVariable Long id, Authentication auth) {
|
commentDtoList
|
||||||
log.debug("deleteComment called by user {} for comment {}", auth.getName(), id);
|
.stream()
|
||||||
commentService.deleteComment(auth.getName(), id);
|
.map(c ->
|
||||||
log.debug("deleteComment completed for comment {}", id);
|
new TimelineItemDto<>(
|
||||||
}
|
c.getId(),
|
||||||
|
"comment",
|
||||||
|
c.getCreatedAt(),
|
||||||
|
c // payload 是 CommentDto
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.toList()
|
||||||
|
);
|
||||||
|
|
||||||
@PostMapping("/comments/{id}/pin")
|
itemDtoList.addAll(
|
||||||
public CommentDto pinComment(@PathVariable Long id, Authentication auth) {
|
postChangeLogDtoList
|
||||||
log.debug("pinComment called by user {} for comment {}", auth.getName(), id);
|
.stream()
|
||||||
return commentMapper.toDto(commentService.pinComment(auth.getName(), id));
|
.map(l ->
|
||||||
|
new TimelineItemDto<>(
|
||||||
|
l.getId(),
|
||||||
|
"log",
|
||||||
|
l.getTime(), // 注意字段名不一样
|
||||||
|
l // payload 是 PostChangeLogDto
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.toList()
|
||||||
|
);
|
||||||
|
// 排序
|
||||||
|
Comparator<TimelineItemDto<?>> comparator = Comparator.comparing(TimelineItemDto::getCreatedAt);
|
||||||
|
if (CommentSort.NEWEST.equals(sort)) {
|
||||||
|
comparator = comparator.reversed();
|
||||||
}
|
}
|
||||||
|
itemDtoList.sort(comparator);
|
||||||
|
log.debug("listComments returning {} comments", itemDtoList.size());
|
||||||
|
return itemDtoList;
|
||||||
|
}
|
||||||
|
|
||||||
@PostMapping("/comments/{id}/unpin")
|
@DeleteMapping("/comments/{id}")
|
||||||
public CommentDto unpinComment(@PathVariable Long id, Authentication auth) {
|
@Operation(summary = "Delete comment", description = "Delete a comment")
|
||||||
log.debug("unpinComment called by user {} for comment {}", auth.getName(), id);
|
@ApiResponse(responseCode = "200", description = "Deleted")
|
||||||
return commentMapper.toDto(commentService.unpinComment(auth.getName(), id));
|
@SecurityRequirement(name = "JWT")
|
||||||
}
|
public void deleteComment(@PathVariable Long id, Authentication auth) {
|
||||||
|
log.debug("deleteComment called by user {} for comment {}", auth.getName(), id);
|
||||||
|
commentService.deleteComment(auth.getName(), id);
|
||||||
|
log.debug("deleteComment completed for comment {}", id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/comments/{id}/pin")
|
||||||
|
@Operation(summary = "Pin comment", description = "Pin a comment")
|
||||||
|
@ApiResponse(
|
||||||
|
responseCode = "200",
|
||||||
|
description = "Pinned comment",
|
||||||
|
content = @Content(schema = @Schema(implementation = CommentDto.class))
|
||||||
|
)
|
||||||
|
@SecurityRequirement(name = "JWT")
|
||||||
|
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")
|
||||||
|
@Operation(summary = "Unpin comment", description = "Unpin a comment")
|
||||||
|
@ApiResponse(
|
||||||
|
responseCode = "200",
|
||||||
|
description = "Unpinned comment",
|
||||||
|
content = @Content(schema = @Schema(implementation = CommentDto.class))
|
||||||
|
)
|
||||||
|
@SecurityRequirement(name = "JWT")
|
||||||
|
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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,10 @@ 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;
|
||||||
@@ -12,36 +16,42 @@ import org.springframework.web.bind.annotation.RestController;
|
|||||||
@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")
|
||||||
public SiteConfigDto getConfig() {
|
@Operation(summary = "Site config", description = "Get site configuration")
|
||||||
SiteConfigDto resp = new SiteConfigDto();
|
@ApiResponse(
|
||||||
resp.setCaptchaEnabled(captchaEnabled);
|
responseCode = "200",
|
||||||
resp.setRegisterCaptchaEnabled(registerCaptchaEnabled);
|
description = "Site configuration",
|
||||||
resp.setLoginCaptchaEnabled(loginCaptchaEnabled);
|
content = @Content(schema = @Schema(implementation = SiteConfigDto.class))
|
||||||
resp.setPostCaptchaEnabled(postCaptchaEnabled);
|
)
|
||||||
resp.setCommentCaptchaEnabled(commentCaptchaEnabled);
|
public SiteConfigDto getConfig() {
|
||||||
resp.setAiFormatLimit(aiFormatLimit);
|
SiteConfigDto resp = new SiteConfigDto();
|
||||||
resp.setRegisterMode(registerModeService.getRegisterMode());
|
resp.setCaptchaEnabled(captchaEnabled);
|
||||||
return resp;
|
resp.setRegisterCaptchaEnabled(registerCaptchaEnabled);
|
||||||
}
|
resp.setLoginCaptchaEnabled(loginCaptchaEnabled);
|
||||||
|
resp.setPostCaptchaEnabled(postCaptchaEnabled);
|
||||||
|
resp.setCommentCaptchaEnabled(commentCaptchaEnabled);
|
||||||
|
resp.setAiFormatLimit(aiFormatLimit);
|
||||||
|
resp.setRegisterMode(registerModeService.getRegisterMode());
|
||||||
|
return resp;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,11 @@ 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 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 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;
|
||||||
@@ -14,25 +19,50 @@ import org.springframework.web.bind.annotation.*;
|
|||||||
@RequestMapping("/api/drafts")
|
@RequestMapping("/api/drafts")
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class DraftController {
|
public class DraftController {
|
||||||
private final DraftService draftService;
|
|
||||||
private final DraftMapper draftMapper;
|
|
||||||
|
|
||||||
@PostMapping
|
private final DraftService draftService;
|
||||||
public ResponseEntity<DraftDto> saveDraft(@RequestBody DraftRequest req, Authentication auth) {
|
private final DraftMapper draftMapper;
|
||||||
Draft draft = draftService.saveDraft(auth.getName(), req.getCategoryId(), req.getTitle(), req.getContent(), req.getTagIds());
|
|
||||||
return ResponseEntity.ok(draftMapper.toDto(draft));
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/me")
|
@PostMapping
|
||||||
public ResponseEntity<DraftDto> getMyDraft(Authentication auth) {
|
@Operation(summary = "Save draft", description = "Save a draft for current user")
|
||||||
return draftService.getDraft(auth.getName())
|
@ApiResponse(
|
||||||
.map(d -> ResponseEntity.ok(draftMapper.toDto(d)))
|
responseCode = "200",
|
||||||
.orElseGet(() -> ResponseEntity.noContent().build());
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
@DeleteMapping("/me")
|
@GetMapping("/me")
|
||||||
public ResponseEntity<?> deleteMyDraft(Authentication auth) {
|
@Operation(summary = "Get my draft", description = "Get current user's draft")
|
||||||
draftService.deleteDraft(auth.getName());
|
@ApiResponse(
|
||||||
return ResponseEntity.ok().build();
|
responseCode = "200",
|
||||||
}
|
description = "Draft details",
|
||||||
|
content = @Content(schema = @Schema(implementation = DraftDto.class))
|
||||||
|
)
|
||||||
|
@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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,40 +1,39 @@
|
|||||||
package com.openisle.controller;
|
package com.openisle.controller;
|
||||||
|
|
||||||
import org.springframework.http.ResponseEntity;
|
|
||||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
|
||||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
|
||||||
import com.openisle.exception.FieldException;
|
import com.openisle.exception.FieldException;
|
||||||
import com.openisle.exception.NotFoundException;
|
import com.openisle.exception.NotFoundException;
|
||||||
import com.openisle.exception.RateLimitException;
|
import com.openisle.exception.RateLimitException;
|
||||||
|
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||||
|
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||||
|
|
||||||
@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()
|
return ResponseEntity.badRequest().body(
|
||||||
.body(Map.of("error", ex.getMessage(), "field", ex.getField()));
|
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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,26 @@
|
|||||||
package com.openisle.controller;
|
package com.openisle.controller;
|
||||||
|
|
||||||
|
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 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")
|
|
||||||
public Map<String, String> hello() {
|
@GetMapping("/api/hello")
|
||||||
return Map.of("message", "Hello, Authenticated User");
|
@SecurityRequirement(name = "JWT")
|
||||||
}
|
@Operation(summary = "Hello endpoint", description = "Returns a greeting for authenticated users")
|
||||||
|
@ApiResponse(
|
||||||
|
responseCode = "200",
|
||||||
|
description = "Greeting payload",
|
||||||
|
content = @Content(schema = @Schema(implementation = Map.class))
|
||||||
|
)
|
||||||
|
public Map<String, String> hello() {
|
||||||
|
return Map.of("message", "Hello, Authenticated User");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,35 @@
|
|||||||
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 java.util.Map;
|
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/invite")
|
@RequestMapping("/api/invite")
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class InviteController {
|
public class InviteController {
|
||||||
private final InviteService inviteService;
|
|
||||||
|
|
||||||
@PostMapping("/generate")
|
private final InviteService inviteService;
|
||||||
public Map<String, String> generate(Authentication auth) {
|
|
||||||
String token = inviteService.generate(auth.getName());
|
@PostMapping("/generate")
|
||||||
return Map.of("token", token);
|
@Operation(summary = "Generate invite", description = "Generate an invite token")
|
||||||
}
|
@ApiResponse(
|
||||||
|
responseCode = "200",
|
||||||
|
description = "Invite token",
|
||||||
|
content = @Content(schema = @Schema(implementation = Map.class))
|
||||||
|
)
|
||||||
|
@SecurityRequirement(name = "JWT")
|
||||||
|
public Map<String, String> generate(Authentication auth) {
|
||||||
|
String token = inviteService.generate(auth.getName());
|
||||||
|
return Map.of("token", token);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,31 +3,49 @@ 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 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.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;
|
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/medals")
|
@RequestMapping("/api/medals")
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class MedalController {
|
public class MedalController {
|
||||||
private final MedalService medalService;
|
|
||||||
|
|
||||||
@GetMapping
|
private final MedalService medalService;
|
||||||
public List<MedalDto> getMedals(@RequestParam(value = "userId", required = false) Long userId) {
|
|
||||||
return medalService.getMedals(userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping("/select")
|
@GetMapping
|
||||||
public ResponseEntity<Void> selectMedal(@RequestBody MedalSelectRequest req, Authentication auth) {
|
@Operation(summary = "List medals", description = "List medals for user or globally")
|
||||||
try {
|
@ApiResponse(
|
||||||
medalService.selectMedal(auth.getName(), req.getType());
|
responseCode = "200",
|
||||||
return ResponseEntity.ok().build();
|
description = "List of medals",
|
||||||
} catch (IllegalArgumentException e) {
|
content = @Content(array = @ArraySchema(schema = @Schema(implementation = MedalDto.class)))
|
||||||
return ResponseEntity.badRequest().build();
|
)
|
||||||
}
|
public List<MedalDto> getMedals(@RequestParam(value = "userId", required = false) Long userId) {
|
||||||
|
return medalService.getMedals(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/select")
|
||||||
|
@Operation(summary = "Select medal", description = "Select a medal for current user")
|
||||||
|
@ApiResponse(responseCode = "200", description = "Medal selected")
|
||||||
|
@SecurityRequirement(name = "JWT")
|
||||||
|
public ResponseEntity<Void> selectMedal(
|
||||||
|
@RequestBody MedalSelectRequest req,
|
||||||
|
Authentication auth
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
medalService.selectMedal(auth.getName(), req.getType());
|
||||||
|
return ResponseEntity.ok().build();
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
return ResponseEntity.badRequest().build();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,13 @@ 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;
|
||||||
@@ -19,119 +26,204 @@ 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;
|
|
||||||
|
|
||||||
@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.findByUsername(auth.getName()).orElseThrow(() -> new IllegalArgumentException("Sender not found"));
|
User user = userRepository
|
||||||
// In a real application, you would get this from the Authentication object
|
.findByUsername(auth.getName())
|
||||||
return user.getId();
|
.orElseThrow(() -> new IllegalArgumentException("Sender not found"));
|
||||||
|
// 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/conversations")
|
public void setRecipientId(Long recipientId) {
|
||||||
public ResponseEntity<List<ConversationDto>> getConversations(Authentication auth) {
|
this.recipientId = recipientId;
|
||||||
List<ConversationDto> conversations = messageService.getConversations(getCurrentUserId(auth));
|
|
||||||
return ResponseEntity.ok(conversations);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/conversations/{conversationId}")
|
public String getContent() {
|
||||||
public ResponseEntity<ConversationDetailDto> getMessages(@PathVariable Long conversationId,
|
return content;
|
||||||
@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
|
public void setContent(String content) {
|
||||||
public ResponseEntity<MessageDto> sendMessage(@RequestBody MessageRequest req, Authentication auth) {
|
this.content = content;
|
||||||
Message message = messageService.sendMessage(getCurrentUserId(auth), req.getRecipientId(), req.getContent(), req.getReplyToId());
|
|
||||||
return ResponseEntity.ok(messageService.toDto(message));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/conversations/{conversationId}/messages")
|
public Long getReplyToId() {
|
||||||
public ResponseEntity<MessageDto> sendMessageToConversation(@PathVariable Long conversationId,
|
return replyToId;
|
||||||
@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")
|
public void setReplyToId(Long replyToId) {
|
||||||
public ResponseEntity<Void> markAsRead(@PathVariable Long conversationId, Authentication auth) {
|
this.replyToId = replyToId;
|
||||||
messageService.markConversationAsRead(conversationId, getCurrentUserId(auth));
|
}
|
||||||
return ResponseEntity.ok().build();
|
}
|
||||||
|
|
||||||
|
static class ChannelMessageRequest {
|
||||||
|
|
||||||
|
private String content;
|
||||||
|
private Long replyToId;
|
||||||
|
|
||||||
|
public String getContent() {
|
||||||
|
return content;
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/conversations")
|
public void setContent(String content) {
|
||||||
public ResponseEntity<CreateConversationResponse> findOrCreateConversation(@RequestBody CreateConversationRequest req, Authentication auth) {
|
this.content = content;
|
||||||
MessageConversation conversation = messageService.findOrCreateConversation(getCurrentUserId(auth), req.getRecipientId());
|
|
||||||
return ResponseEntity.ok(new CreateConversationResponse(conversation.getId()));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/unread-count")
|
public Long getReplyToId() {
|
||||||
public ResponseEntity<Long> getUnreadCount(Authentication auth) {
|
return replyToId;
|
||||||
return ResponseEntity.ok(messageService.getUnreadMessageCount(getCurrentUserId(auth)));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// A simple request DTO
|
public void setReplyToId(Long replyToId) {
|
||||||
static class MessageRequest {
|
this.replyToId = replyToId;
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,74 +2,158 @@ 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 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.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.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.stream.Collectors;
|
|
||||||
|
|
||||||
/** 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;
|
|
||||||
|
|
||||||
@GetMapping
|
private final NotificationService notificationService;
|
||||||
public List<NotificationDto> list(@RequestParam(value = "page", defaultValue = "0") int page,
|
private final NotificationMapper notificationMapper;
|
||||||
@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
|
||||||
public List<NotificationDto> listUnread(@RequestParam(value = "page", defaultValue = "0") int page,
|
@Operation(
|
||||||
@RequestParam(value = "size", defaultValue = "30") int size,
|
summary = "List notifications",
|
||||||
Authentication auth) {
|
description = "Retrieve notifications for the current user"
|
||||||
return notificationService.listNotifications(auth.getName(), false, page, size).stream()
|
)
|
||||||
.map(notificationMapper::toDto)
|
@ApiResponse(
|
||||||
.collect(Collectors.toList());
|
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("/unread-count")
|
@GetMapping("/unread")
|
||||||
public NotificationUnreadCountDto unreadCount(Authentication auth) {
|
@Operation(
|
||||||
long count = notificationService.countUnread(auth.getName());
|
summary = "List unread notifications",
|
||||||
NotificationUnreadCountDto uc = new NotificationUnreadCountDto();
|
description = "Retrieve unread notifications for the current user"
|
||||||
uc.setCount(count);
|
)
|
||||||
return uc;
|
@ApiResponse(
|
||||||
}
|
responseCode = "200",
|
||||||
|
description = "Unread notifications",
|
||||||
|
content = @Content(
|
||||||
|
array = @ArraySchema(schema = @Schema(implementation = NotificationDto.class))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
@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());
|
||||||
|
}
|
||||||
|
|
||||||
@PostMapping("/read")
|
@GetMapping("/unread-count")
|
||||||
public void markRead(@RequestBody NotificationMarkReadRequest req, Authentication auth) {
|
@Operation(summary = "Unread count", description = "Get count of unread notifications")
|
||||||
notificationService.markRead(auth.getName(), req.getIds());
|
@ApiResponse(
|
||||||
}
|
responseCode = "200",
|
||||||
|
description = "Unread count",
|
||||||
|
content = @Content(schema = @Schema(implementation = NotificationUnreadCountDto.class))
|
||||||
|
)
|
||||||
|
@SecurityRequirement(name = "JWT")
|
||||||
|
public NotificationUnreadCountDto unreadCount(Authentication auth) {
|
||||||
|
long count = notificationService.countUnread(auth.getName());
|
||||||
|
NotificationUnreadCountDto uc = new NotificationUnreadCountDto();
|
||||||
|
uc.setCount(count);
|
||||||
|
return uc;
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/prefs")
|
@PostMapping("/read")
|
||||||
public List<NotificationPreferenceDto> prefs(Authentication auth) {
|
@Operation(summary = "Mark notifications read", description = "Mark notifications as read")
|
||||||
return notificationService.listPreferences(auth.getName());
|
@ApiResponse(responseCode = "200", description = "Marked read")
|
||||||
}
|
@SecurityRequirement(name = "JWT")
|
||||||
|
public void markRead(@RequestBody NotificationMarkReadRequest req, Authentication auth) {
|
||||||
|
notificationService.markRead(auth.getName(), req.getIds());
|
||||||
|
}
|
||||||
|
|
||||||
@PostMapping("/prefs")
|
@GetMapping("/prefs")
|
||||||
public void updatePref(@RequestBody NotificationPreferenceUpdateRequest req, Authentication auth) {
|
@Operation(summary = "List preferences", description = "List notification preferences")
|
||||||
notificationService.updatePreference(auth.getName(), req.getType(), req.isEnabled());
|
@ApiResponse(
|
||||||
}
|
responseCode = "200",
|
||||||
|
description = "Preferences",
|
||||||
|
content = @Content(
|
||||||
|
array = @ArraySchema(schema = @Schema(implementation = NotificationPreferenceDto.class))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
@SecurityRequirement(name = "JWT")
|
||||||
|
public List<NotificationPreferenceDto> prefs(Authentication auth) {
|
||||||
|
return notificationService.listPreferences(auth.getName());
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/email-prefs")
|
@PostMapping("/prefs")
|
||||||
public List<NotificationPreferenceDto> emailPrefs(Authentication auth) {
|
@Operation(summary = "Update preference", description = "Update notification preference")
|
||||||
return notificationService.listEmailPreferences(auth.getName());
|
@ApiResponse(responseCode = "200", description = "Preference updated")
|
||||||
}
|
@SecurityRequirement(name = "JWT")
|
||||||
|
public void updatePref(
|
||||||
|
@RequestBody NotificationPreferenceUpdateRequest req,
|
||||||
|
Authentication auth
|
||||||
|
) {
|
||||||
|
notificationService.updatePreference(auth.getName(), req.getType(), req.isEnabled());
|
||||||
|
}
|
||||||
|
|
||||||
@PostMapping("/email-prefs")
|
@GetMapping("/email-prefs")
|
||||||
public void updateEmailPref(@RequestBody NotificationPreferenceUpdateRequest req, Authentication auth) {
|
@Operation(
|
||||||
notificationService.updateEmailPreference(auth.getName(), req.getType(), req.isEnabled());
|
summary = "List email preferences",
|
||||||
}
|
description = "List email notification preferences"
|
||||||
|
)
|
||||||
|
@ApiResponse(
|
||||||
|
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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +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 java.time.Duration;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @author smallclover
|
* @author smallclover
|
||||||
* @since 2025-09-05
|
* @since 2025-09-05
|
||||||
@@ -18,16 +21,24 @@ import java.time.Duration;
|
|||||||
@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")
|
||||||
public void ping(@RequestParam String userId){
|
@Operation(summary = "Heartbeat", description = "Record user heartbeat")
|
||||||
redisTemplate.opsForValue().set(ONLINE_KEY+userId,"1", Duration.ofSeconds(150));
|
@ApiResponse(responseCode = "200", description = "Heartbeat recorded")
|
||||||
}
|
public void ping(@RequestParam String userId) {
|
||||||
|
redisTemplate.opsForValue().set(ONLINE_KEY + userId, "1", Duration.ofSeconds(150));
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/count")
|
@GetMapping("/count")
|
||||||
public long count(){
|
@Operation(summary = "Online count", description = "Get current online user count")
|
||||||
return redisTemplate.keys(ONLINE_KEY+"*").size();
|
@ApiResponse(
|
||||||
}
|
responseCode = "200",
|
||||||
|
description = "Online count",
|
||||||
|
content = @Content(schema = @Schema(implementation = Long.class))
|
||||||
|
)
|
||||||
|
public long count() {
|
||||||
|
return redisTemplate.keys(ONLINE_KEY + "*").size();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,15 @@ 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;
|
||||||
@@ -10,27 +19,44 @@ 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 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;
|
|
||||||
|
|
||||||
@GetMapping
|
private final PointService pointService;
|
||||||
public List<PointHistoryDto> list(Authentication auth) {
|
private final PointHistoryMapper pointHistoryMapper;
|
||||||
return pointService.listHistory(auth.getName()).stream()
|
|
||||||
.map(pointHistoryMapper::toDto)
|
|
||||||
.collect(Collectors.toList());
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/trend")
|
@GetMapping
|
||||||
public List<Map<String, Object>> trend(Authentication auth,
|
@Operation(summary = "Point history", description = "List point history for current user")
|
||||||
@RequestParam(value = "days", defaultValue = "30") int days) {
|
@ApiResponse(
|
||||||
return pointService.trend(auth.getName(), days);
|
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("/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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,34 +6,55 @@ 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 io.swagger.v3.oas.annotations.Operation;
|
||||||
import org.springframework.security.core.Authentication;
|
import io.swagger.v3.oas.annotations.media.ArraySchema;
|
||||||
import org.springframework.web.bind.annotation.*;
|
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.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;
|
|
||||||
|
|
||||||
@GetMapping
|
private final PointMallService pointMallService;
|
||||||
public List<PointGoodDto> list() {
|
private final UserService userService;
|
||||||
return pointMallService.listGoods().stream()
|
private final PointGoodMapper pointGoodMapper;
|
||||||
.map(pointGoodMapper::toDto)
|
|
||||||
.collect(Collectors.toList());
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping("/redeem")
|
@GetMapping
|
||||||
public Map<String, Integer> redeem(@RequestBody PointRedeemRequest req, Authentication auth) {
|
@Operation(summary = "List goods", description = "List all point goods")
|
||||||
User user = userService.findByIdentifier(auth.getName()).orElseThrow();
|
@ApiResponse(
|
||||||
int point = pointMallService.redeem(user, req.getGoodId(), req.getContact());
|
responseCode = "200",
|
||||||
return Map.of("point", point);
|
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());
|
||||||
|
}
|
||||||
|
|
||||||
|
@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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,23 +3,34 @@ 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 io.swagger.v3.oas.annotations.Operation;
|
||||||
import org.springframework.web.bind.annotation.*;
|
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.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;
|
|
||||||
|
|
||||||
@GetMapping("/{id}/change-logs")
|
private final PostChangeLogService changeLogService;
|
||||||
public List<PostChangeLogDto> listLogs(@PathVariable Long id) {
|
private final PostChangeLogMapper mapper;
|
||||||
return changeLogService.listLogs(id).stream()
|
|
||||||
.map(mapper::toDto)
|
@GetMapping("/{id}/change-logs")
|
||||||
.collect(Collectors.toList());
|
@Operation(summary = "Post change logs", description = "List change logs for a post")
|
||||||
}
|
@ApiResponse(
|
||||||
|
responseCode = "200",
|
||||||
|
description = "Change logs",
|
||||||
|
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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,211 +1,318 @@
|
|||||||
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.*;
|
||||||
|
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.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 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}")
|
private final PostService postService;
|
||||||
private boolean captchaEnabled;
|
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;
|
||||||
|
|
||||||
@Value("${app.captcha.post-enabled:false}")
|
@Value("${app.captcha.enabled:false}")
|
||||||
private boolean postCaptchaEnabled;
|
private boolean captchaEnabled;
|
||||||
|
|
||||||
@PostMapping
|
@Value("${app.captcha.post-enabled:false}")
|
||||||
public ResponseEntity<PostDetailDto> createPost(@RequestBody PostRequest req, Authentication auth) {
|
private boolean postCaptchaEnabled;
|
||||||
if (captchaEnabled && postCaptchaEnabled && !captchaService.verify(req.getCaptcha())) {
|
|
||||||
return ResponseEntity.badRequest().build();
|
@PostMapping
|
||||||
}
|
@SecurityRequirement(name = "JWT")
|
||||||
Post post = postService.createPost(auth.getName(), req.getCategoryId(),
|
@Operation(summary = "Create post", description = "Create a new post")
|
||||||
req.getTitle(), req.getContent(), req.getTagIds(),
|
@ApiResponse(
|
||||||
req.getType(), req.getPrizeDescription(), req.getPrizeIcon(),
|
responseCode = "200",
|
||||||
req.getPrizeCount(), req.getPointCost(),
|
description = "Created post",
|
||||||
req.getStartTime(), req.getEndTime(),
|
content = @Content(schema = @Schema(implementation = PostDetailDto.class))
|
||||||
req.getOptions(), req.getMultiple());
|
)
|
||||||
draftService.deleteDraft(auth.getName());
|
public ResponseEntity<PostDetailDto> createPost(
|
||||||
PostDetailDto dto = postMapper.toDetailDto(post, auth.getName());
|
@RequestBody PostRequest req,
|
||||||
dto.setReward(levelService.awardForPost(auth.getName()));
|
Authentication auth
|
||||||
dto.setPointReward(pointService.awardForPost(auth.getName(), post.getId()));
|
) {
|
||||||
return ResponseEntity.ok(dto);
|
if (captchaEnabled && postCaptchaEnabled && !captchaService.verify(req.getCaptcha())) {
|
||||||
|
return ResponseEntity.badRequest().build();
|
||||||
}
|
}
|
||||||
|
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}")
|
||||||
public ResponseEntity<PostDetailDto> updatePost(@PathVariable Long id, @RequestBody PostRequest req,
|
@SecurityRequirement(name = "JWT")
|
||||||
Authentication auth) {
|
@Operation(summary = "Update post", description = "Update an existing post")
|
||||||
Post post = postService.updatePost(id, auth.getName(), req.getCategoryId(),
|
@ApiResponse(
|
||||||
req.getTitle(), req.getContent(), req.getTagIds());
|
responseCode = "200",
|
||||||
return ResponseEntity.ok(postMapper.toDetailDto(post, auth.getName()));
|
description = "Updated post",
|
||||||
}
|
content = @Content(schema = @Schema(implementation = PostDetailDto.class))
|
||||||
|
)
|
||||||
|
public ResponseEntity<PostDetailDto> updatePost(
|
||||||
|
@PathVariable Long id,
|
||||||
|
@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}")
|
||||||
public void deletePost(@PathVariable Long id, Authentication auth) {
|
@SecurityRequirement(name = "JWT")
|
||||||
postService.deletePost(id, auth.getName());
|
@Operation(summary = "Delete post", description = "Delete a post")
|
||||||
}
|
@ApiResponse(responseCode = "200", description = "Post deleted")
|
||||||
|
public void deletePost(@PathVariable Long id, Authentication auth) {
|
||||||
|
postService.deletePost(id, auth.getName());
|
||||||
|
}
|
||||||
|
|
||||||
@PostMapping("/{id}/close")
|
@PostMapping("/{id}/close")
|
||||||
public PostSummaryDto close(@PathVariable Long id, Authentication auth) {
|
@SecurityRequirement(name = "JWT")
|
||||||
return postMapper.toSummaryDto(postService.closePost(id, auth.getName()));
|
@Operation(summary = "Close post", description = "Close a post to prevent further replies")
|
||||||
}
|
@ApiResponse(
|
||||||
|
responseCode = "200",
|
||||||
|
description = "Closed post",
|
||||||
|
content = @Content(schema = @Schema(implementation = PostSummaryDto.class))
|
||||||
|
)
|
||||||
|
public PostSummaryDto close(@PathVariable Long id, Authentication auth) {
|
||||||
|
return postMapper.toSummaryDto(postService.closePost(id, auth.getName()));
|
||||||
|
}
|
||||||
|
|
||||||
@PostMapping("/{id}/reopen")
|
@PostMapping("/{id}/reopen")
|
||||||
public PostSummaryDto reopen(@PathVariable Long id, Authentication auth) {
|
@SecurityRequirement(name = "JWT")
|
||||||
return postMapper.toSummaryDto(postService.reopenPost(id, auth.getName()));
|
@Operation(summary = "Reopen post", description = "Reopen a closed post")
|
||||||
}
|
@ApiResponse(
|
||||||
|
responseCode = "200",
|
||||||
|
description = "Reopened post",
|
||||||
|
content = @Content(schema = @Schema(implementation = PostSummaryDto.class))
|
||||||
|
)
|
||||||
|
public PostSummaryDto reopen(@PathVariable Long id, Authentication auth) {
|
||||||
|
return postMapper.toSummaryDto(postService.reopenPost(id, auth.getName()));
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/{id}")
|
@GetMapping("/{id}")
|
||||||
public ResponseEntity<PostDetailDto> getPost(@PathVariable Long id, Authentication auth) {
|
@Operation(summary = "Get post", description = "Get post details by id")
|
||||||
String viewer = auth != null ? auth.getName() : null;
|
@ApiResponse(
|
||||||
Post post = postService.viewPost(id, viewer);
|
responseCode = "200",
|
||||||
return ResponseEntity.ok(postMapper.toDetailDto(post, viewer));
|
description = "Post detail",
|
||||||
}
|
content = @Content(schema = @Schema(implementation = PostDetailDto.class))
|
||||||
|
)
|
||||||
|
public ResponseEntity<PostDetailDto> getPost(@PathVariable Long id, Authentication auth) {
|
||||||
|
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")
|
||||||
public ResponseEntity<Void> joinLottery(@PathVariable Long id, Authentication auth) {
|
@SecurityRequirement(name = "JWT")
|
||||||
postService.joinLottery(id, auth.getName());
|
@Operation(summary = "Join lottery", description = "Join a lottery for the post")
|
||||||
return ResponseEntity.ok().build();
|
@ApiResponse(responseCode = "200", description = "Joined lottery")
|
||||||
}
|
public ResponseEntity<Void> joinLottery(@PathVariable Long id, Authentication auth) {
|
||||||
|
postService.joinLottery(id, auth.getName());
|
||||||
|
return ResponseEntity.ok().build();
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/{id}/poll/progress")
|
@GetMapping("/{id}/poll/progress")
|
||||||
public ResponseEntity<PollDto> pollProgress(@PathVariable Long id) {
|
@Operation(summary = "Poll progress", description = "Get poll progress for a post")
|
||||||
return ResponseEntity.ok(postMapper.toSummaryDto(postService.getPoll(id)).getPoll());
|
@ApiResponse(
|
||||||
}
|
responseCode = "200",
|
||||||
|
description = "Poll progress",
|
||||||
|
content = @Content(schema = @Schema(implementation = PollDto.class))
|
||||||
|
)
|
||||||
|
public ResponseEntity<PollDto> pollProgress(@PathVariable Long id) {
|
||||||
|
return ResponseEntity.ok(postMapper.toSummaryDto(postService.getPoll(id)).getPoll());
|
||||||
|
}
|
||||||
|
|
||||||
@PostMapping("/{id}/poll/vote")
|
@PostMapping("/{id}/poll/vote")
|
||||||
public ResponseEntity<Void> vote(@PathVariable Long id, @RequestParam("option") List<Integer> option, Authentication auth) {
|
@SecurityRequirement(name = "JWT")
|
||||||
postService.votePoll(id, auth.getName(), option);
|
@Operation(summary = "Vote poll", description = "Vote on a poll option")
|
||||||
return ResponseEntity.ok().build();
|
@ApiResponse(responseCode = "200", description = "Vote recorded")
|
||||||
}
|
public ResponseEntity<Void> vote(
|
||||||
|
@PathVariable Long id,
|
||||||
|
@RequestParam("option") List<Integer> option,
|
||||||
|
Authentication auth
|
||||||
|
) {
|
||||||
|
postService.votePoll(id, auth.getName(), option);
|
||||||
|
return ResponseEntity.ok().build();
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
public List<PostSummaryDto> listPosts(@RequestParam(value = "categoryId", required = false) Long categoryId,
|
@Operation(summary = "List posts", description = "List posts by various filters")
|
||||||
@RequestParam(value = "categoryIds", required = false) List<Long> categoryIds,
|
@ApiResponse(
|
||||||
@RequestParam(value = "tagId", required = false) Long tagId,
|
responseCode = "200",
|
||||||
@RequestParam(value = "tagIds", required = false) List<Long> tagIds,
|
description = "List of posts",
|
||||||
@RequestParam(value = "page", required = false) Integer page,
|
content = @Content(
|
||||||
@RequestParam(value = "pageSize", required = false) Integer pageSize,
|
array = @ArraySchema(schema = @Schema(implementation = PostSummaryDto.class))
|
||||||
Authentication auth) {
|
)
|
||||||
List<Long> ids = categoryIds;
|
)
|
||||||
if (categoryId != null) {
|
@Cacheable(
|
||||||
ids = java.util.List.of(categoryId);
|
value = CachingConfig.POST_CACHE_NAME,
|
||||||
}
|
key = "new org.springframework.cache.interceptor.SimpleKey('default', #categoryId, #categoryIds, #tagId, #tagIds, #page, #pageSize)"
|
||||||
List<Long> tids = tagIds;
|
)
|
||||||
if (tagId != null) {
|
public List<PostSummaryDto> listPosts(
|
||||||
tids = java.util.List.of(tagId);
|
@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());
|
||||||
|
// }
|
||||||
|
|
||||||
if (auth != null) {
|
return postService
|
||||||
userVisitService.recordVisit(auth.getName());
|
.defaultListPosts(ids, tids, page, pageSize)
|
||||||
}
|
.stream()
|
||||||
|
.map(postMapper::toSummaryDto)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
|
||||||
boolean hasCategories = ids != null && !ids.isEmpty();
|
@GetMapping("/ranking")
|
||||||
boolean hasTags = tids != null && !tids.isEmpty();
|
@Operation(summary = "Ranking posts", description = "List posts by view rankings")
|
||||||
|
@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());
|
||||||
|
// }
|
||||||
|
|
||||||
if (hasCategories && hasTags) {
|
return postService
|
||||||
return postService.listPostsByCategoriesAndTags(ids, tids, page, pageSize)
|
.listPostsByViews(ids, tids, page, pageSize)
|
||||||
.stream().map(postMapper::toSummaryDto).collect(Collectors.toList());
|
.stream()
|
||||||
}
|
.map(postMapper::toSummaryDto)
|
||||||
if (hasTags) {
|
.collect(Collectors.toList());
|
||||||
return postService.listPostsByTags(tids, page, pageSize)
|
}
|
||||||
.stream().map(postMapper::toSummaryDto).collect(Collectors.toList());
|
|
||||||
}
|
|
||||||
|
|
||||||
return postService.listPostsByCategories(ids, page, pageSize)
|
@GetMapping("/latest-reply")
|
||||||
.stream().map(postMapper::toSummaryDto).collect(Collectors.toList());
|
@Operation(summary = "Latest reply posts", description = "List posts by latest replies")
|
||||||
}
|
@ApiResponse(
|
||||||
|
responseCode = "200",
|
||||||
|
description = "Posts sorted by latest reply",
|
||||||
|
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());
|
||||||
|
// }
|
||||||
|
|
||||||
@GetMapping("/ranking")
|
List<Post> posts = postService.listPostsByLatestReply(ids, tids, page, pageSize);
|
||||||
public List<PostSummaryDto> rankingPosts(@RequestParam(value = "categoryId", required = false) Long categoryId,
|
return posts.stream().map(postMapper::toSummaryDto).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 = categoryIds;
|
|
||||||
if (categoryId != null) {
|
|
||||||
ids = java.util.List.of(categoryId);
|
|
||||||
}
|
|
||||||
List<Long> tids = tagIds;
|
|
||||||
if (tagId != null) {
|
|
||||||
tids = java.util.List.of(tagId);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (auth != null) {
|
@GetMapping("/featured")
|
||||||
userVisitService.recordVisit(auth.getName());
|
@Operation(summary = "Featured posts", description = "List featured posts")
|
||||||
}
|
@ApiResponse(
|
||||||
|
responseCode = "200",
|
||||||
return postService.listPostsByViews(ids, tids, page, pageSize)
|
description = "Featured posts",
|
||||||
.stream().map(postMapper::toSummaryDto).collect(Collectors.toList());
|
content = @Content(
|
||||||
}
|
array = @ArraySchema(schema = @Schema(implementation = PostSummaryDto.class))
|
||||||
|
)
|
||||||
@GetMapping("/latest-reply")
|
)
|
||||||
public List<PostSummaryDto> latestReplyPosts(@RequestParam(value = "categoryId", required = false) Long categoryId,
|
public List<PostSummaryDto> featuredPosts(
|
||||||
@RequestParam(value = "categoryIds", required = false) List<Long> categoryIds,
|
@RequestParam(value = "categoryId", required = false) Long categoryId,
|
||||||
@RequestParam(value = "tagId", required = false) Long tagId,
|
@RequestParam(value = "categoryIds", required = false) List<Long> categoryIds,
|
||||||
@RequestParam(value = "tagIds", required = false) List<Long> tagIds,
|
@RequestParam(value = "tagId", required = false) Long tagId,
|
||||||
@RequestParam(value = "page", required = false) Integer page,
|
@RequestParam(value = "tagIds", required = false) List<Long> tagIds,
|
||||||
@RequestParam(value = "pageSize", required = false) Integer pageSize,
|
@RequestParam(value = "page", required = false) Integer page,
|
||||||
Authentication auth) {
|
@RequestParam(value = "pageSize", required = false) Integer pageSize,
|
||||||
List<Long> ids = categoryIds;
|
Authentication auth
|
||||||
if (categoryId != null) {
|
) {
|
||||||
ids = java.util.List.of(categoryId);
|
List<Long> ids = categoryService.getSearchCategoryIds(categoryIds, categoryId);
|
||||||
}
|
List<Long> tids = tagService.getSearchTagIds(tagIds, tagId);
|
||||||
List<Long> tids = tagIds;
|
// 只需要在请求的一开始统计一次
|
||||||
if (tagId != null) {
|
// if (auth != null) {
|
||||||
tids = java.util.List.of(tagId);
|
// userVisitService.recordVisit(auth.getName());
|
||||||
}
|
// }
|
||||||
|
return postService
|
||||||
if (auth != null) {
|
.listFeaturedPosts(ids, tids, page, pageSize)
|
||||||
userVisitService.recordVisit(auth.getName());
|
.stream()
|
||||||
}
|
.map(postMapper::toSummaryDto)
|
||||||
|
.collect(Collectors.toList());
|
||||||
return postService.listPostsByLatestReply(ids, tids, page, pageSize)
|
}
|
||||||
.stream().map(postMapper::toSummaryDto).collect(Collectors.toList());
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/featured")
|
|
||||||
public List<PostSummaryDto> featuredPosts(@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 = categoryIds;
|
|
||||||
if (categoryId != null) {
|
|
||||||
ids = java.util.List.of(categoryId);
|
|
||||||
}
|
|
||||||
List<Long> tids = tagIds;
|
|
||||||
if (tagId != null) {
|
|
||||||
tids = java.util.List.of(tagId);
|
|
||||||
}
|
|
||||||
if (auth != null) {
|
|
||||||
userVisitService.recordVisit(auth.getName());
|
|
||||||
}
|
|
||||||
return postService.listFeaturedPosts(ids, tids, page, pageSize)
|
|
||||||
.stream().map(postMapper::toSummaryDto).collect(Collectors.toList());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,11 @@ 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 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 lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.security.core.Authentication;
|
import org.springframework.security.core.Authentication;
|
||||||
@@ -12,19 +17,35 @@ import org.springframework.web.bind.annotation.*;
|
|||||||
@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;
|
|
||||||
|
|
||||||
@GetMapping("/public-key")
|
private final PushSubscriptionService pushSubscriptionService;
|
||||||
public PushPublicKeyDto getPublicKey() {
|
|
||||||
PushPublicKeyDto r = new PushPublicKeyDto();
|
|
||||||
r.setKey(publicKey);
|
|
||||||
return r;
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping("/subscribe")
|
@Value("${app.webpush.public-key}")
|
||||||
public void subscribe(@RequestBody PushSubscriptionRequest req, Authentication auth) {
|
private String publicKey;
|
||||||
pushSubscriptionService.saveSubscription(auth.getName(), req.getEndpoint(), req.getP256dh(), req.getAuth());
|
|
||||||
}
|
@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;
|
||||||
|
}
|
||||||
|
|
||||||
|
@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()
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,11 @@ 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 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 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;
|
||||||
@@ -17,59 +22,93 @@ import org.springframework.web.bind.annotation.*;
|
|||||||
@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;
|
||||||
* Get all available reaction types.
|
private final LevelService levelService;
|
||||||
*/
|
private final ReactionMapper reactionMapper;
|
||||||
@GetMapping("/reaction-types")
|
private final PointService pointService;
|
||||||
public ReactionType[] listReactionTypes() {
|
|
||||||
return ReactionType.values();
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping("/posts/{postId}/reactions")
|
/**
|
||||||
public ResponseEntity<ReactionDto> reactToPost(@PathVariable Long postId,
|
* Get all available reaction types.
|
||||||
@RequestBody ReactionRequest req,
|
*/
|
||||||
Authentication auth) {
|
@GetMapping("/reaction-types")
|
||||||
Reaction reaction = reactionService.reactToPost(auth.getName(), postId, req.getType());
|
@Operation(summary = "List reaction types", description = "Get all available reaction types")
|
||||||
if (reaction == null) {
|
@ApiResponse(
|
||||||
pointService.deductForReactionOfPost(auth.getName(), postId);
|
responseCode = "200",
|
||||||
return ResponseEntity.noContent().build();
|
description = "Reaction types",
|
||||||
}
|
content = @Content(schema = @Schema(implementation = ReactionType[].class))
|
||||||
ReactionDto dto = reactionMapper.toDto(reaction);
|
)
|
||||||
dto.setReward(levelService.awardForReaction(auth.getName()));
|
public ReactionType[] listReactionTypes() {
|
||||||
pointService.awardForReactionOfPost(auth.getName(), postId);
|
return ReactionType.values();
|
||||||
return ResponseEntity.ok(dto);
|
}
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping("/comments/{commentId}/reactions")
|
@PostMapping("/posts/{postId}/reactions")
|
||||||
public ResponseEntity<ReactionDto> reactToComment(@PathVariable Long commentId,
|
@Operation(summary = "React to post", description = "React to a post")
|
||||||
@RequestBody ReactionRequest req,
|
@ApiResponse(
|
||||||
Authentication auth) {
|
responseCode = "200",
|
||||||
Reaction reaction = reactionService.reactToComment(auth.getName(), commentId, req.getType());
|
description = "Reaction result",
|
||||||
if (reaction == null) {
|
content = @Content(schema = @Schema(implementation = ReactionDto.class))
|
||||||
pointService.deductForReactionOfComment(auth.getName(), commentId);
|
)
|
||||||
return ResponseEntity.noContent().build();
|
@SecurityRequirement(name = "JWT")
|
||||||
}
|
public ResponseEntity<ReactionDto> reactToPost(
|
||||||
ReactionDto dto = reactionMapper.toDto(reaction);
|
@PathVariable Long postId,
|
||||||
dto.setReward(levelService.awardForReaction(auth.getName()));
|
@RequestBody ReactionRequest req,
|
||||||
pointService.awardForReactionOfComment(auth.getName(), commentId);
|
Authentication auth
|
||||||
return ResponseEntity.ok(dto);
|
) {
|
||||||
|
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("/messages/{messageId}/reactions")
|
@PostMapping("/comments/{commentId}/reactions")
|
||||||
public ResponseEntity<ReactionDto> reactToMessage(@PathVariable Long messageId,
|
@Operation(summary = "React to comment", description = "React to a comment")
|
||||||
@RequestBody ReactionRequest req,
|
@ApiResponse(
|
||||||
Authentication auth) {
|
responseCode = "200",
|
||||||
Reaction reaction = reactionService.reactToMessage(auth.getName(), messageId, req.getType());
|
description = "Reaction result",
|
||||||
if (reaction == null) {
|
content = @Content(schema = @Schema(implementation = ReactionDto.class))
|
||||||
return ResponseEntity.noContent().build();
|
)
|
||||||
}
|
@SecurityRequirement(name = "JWT")
|
||||||
ReactionDto dto = reactionMapper.toDto(reaction);
|
public ResponseEntity<ReactionDto> reactToComment(
|
||||||
dto.setReward(levelService.awardForReaction(auth.getName()));
|
@PathVariable Long commentId,
|
||||||
return ResponseEntity.ok(dto);
|
@RequestBody ReactionRequest req,
|
||||||
|
Authentication auth
|
||||||
|
) {
|
||||||
|
Reaction reaction = reactionService.reactToComment(auth.getName(), commentId, req.getType());
|
||||||
|
if (reaction == null) {
|
||||||
|
pointService.deductForReactionOfComment(auth.getName(), commentId);
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
}
|
}
|
||||||
|
ReactionDto dto = reactionMapper.toDto(reaction);
|
||||||
|
dto.setReward(levelService.awardForReaction(auth.getName()));
|
||||||
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,28 @@
|
|||||||
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.service.PostService;
|
import com.openisle.model.Post;
|
||||||
import com.openisle.service.CommentService;
|
import com.openisle.service.CommentService;
|
||||||
|
import com.openisle.service.PostService;
|
||||||
|
import com.vladsch.flexmark.ext.autolink.AutolinkExtension;
|
||||||
|
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;
|
||||||
@@ -14,339 +32,375 @@ 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 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;
|
|
||||||
|
|
||||||
@Value("${app.website-url:https://www.open-isle.com}")
|
private final PostService postService;
|
||||||
private String websiteUrl;
|
private final CommentService commentService;
|
||||||
|
|
||||||
// 兼容 Markdown/HTML 两类图片写法(用于 enclosure)
|
@Value("${app.website-url:https://www.open-isle.com}")
|
||||||
private static final Pattern MD_IMAGE = Pattern.compile("!\\[[^\\]]*\\]\\(([^)]+)\\)");
|
private String websiteUrl;
|
||||||
private static final Pattern HTML_IMAGE = Pattern.compile("<BaseImage[^>]+src=[\"']?([^\"'>]+)[\"']?[^>]*>");
|
|
||||||
|
|
||||||
private static final DateTimeFormatter RFC1123 = DateTimeFormatter.RFC_1123_DATE_TIME;
|
// 兼容 Markdown/HTML 两类图片写法(用于 enclosure)
|
||||||
|
private static final Pattern MD_IMAGE = Pattern.compile("!\\[[^\\]]*\\]\\(([^)]+)\\)");
|
||||||
|
private static final Pattern HTML_IMAGE = Pattern.compile(
|
||||||
|
"<BaseImage[^>]+src=[\"']?([^\"'>]+)[\"']?[^>]*>"
|
||||||
|
);
|
||||||
|
|
||||||
// flexmark:Markdown -> HTML
|
private static final DateTimeFormatter RFC1123 = DateTimeFormatter.RFC_1123_DATE_TIME;
|
||||||
private static final Parser MD_PARSER;
|
|
||||||
private static final HtmlRenderer MD_RENDERER;
|
// flexmark:Markdown -> HTML
|
||||||
static {
|
private static final Parser MD_PARSER;
|
||||||
MutableDataSet opts = new MutableDataSet();
|
private static final HtmlRenderer MD_RENDERER;
|
||||||
opts.set(Parser.EXTENSIONS, Arrays.asList(
|
|
||||||
TablesExtension.create(),
|
static {
|
||||||
AutolinkExtension.create(),
|
MutableDataSet opts = new MutableDataSet();
|
||||||
StrikethroughExtension.create(),
|
opts.set(
|
||||||
TaskListExtension.create()
|
Parser.EXTENSIONS,
|
||||||
));
|
Arrays.asList(
|
||||||
// 允许内联 HTML(下游再做 sanitize)
|
TablesExtension.create(),
|
||||||
opts.set(Parser.HTML_BLOCK_PARSER, true);
|
AutolinkExtension.create(),
|
||||||
MD_PARSER = Parser.builder(opts).build();
|
StrikethroughExtension.create(),
|
||||||
MD_RENDERER = HtmlRenderer.builder(opts).escapeHtml(false).build();
|
TaskListExtension.create()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
// 允许内联 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 lastBuildDate(GMT)
|
||||||
|
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>");
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping(value = "/api/rss", produces = "application/rss+xml;charset=UTF-8")
|
sb.append("</channel></rss>");
|
||||||
public String feed() {
|
return sb.toString();
|
||||||
// 建议 20;你现在是 10,这里保留你的 10
|
}
|
||||||
List<Post> posts = postService.listLatestRssPosts(10);
|
|
||||||
String base = trimTrailingSlash(websiteUrl);
|
|
||||||
|
|
||||||
StringBuilder sb = new StringBuilder(4096);
|
/* ===================== Markdown → HTML ===================== */
|
||||||
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 lastBuildDate(GMT)
|
|
||||||
elem(sb, "lastBuildDate", toRfc1123Gmt(updated));
|
|
||||||
|
|
||||||
for (Post p : posts) {
|
private static String renderMarkdown(String md) {
|
||||||
String link = base + "/posts/" + p.getId();
|
if (md == null || md.isEmpty()) return "";
|
||||||
|
return MD_RENDERER.render(MD_PARSER.parse(md));
|
||||||
|
}
|
||||||
|
|
||||||
// 1) Markdown -> HTML
|
/* ===================== Sanitize & 绝对化 ===================== */
|
||||||
String html = renderMarkdown(p.getContent());
|
|
||||||
|
|
||||||
// 2) Sanitize(白名单增强)
|
private static String sanitizeHtml(String html) {
|
||||||
String safeHtml = sanitizeHtml(html);
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
// 3) 绝对化 href/src + 强制 rel/target
|
private static String absolutifyHtml(String html, String baseUrl) {
|
||||||
String absHtml = absolutifyHtml(safeHtml, base);
|
if (html == null || html.isEmpty()) return "";
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
// 4) 纯文本摘要(用于 <description>)
|
private static String absolutifyUrl(String url, String baseUrl) {
|
||||||
String plain = textSummary(absHtml, 180);
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 5) enclosure(首图,已绝对化)
|
private static String absolutifySrcset(String srcset, String baseUrl) {
|
||||||
String enclosure = firstImage(p.getContent());
|
if (srcset == null || srcset.isEmpty()) return srcset;
|
||||||
if (enclosure == null) {
|
String[] parts = srcset.split(",");
|
||||||
// 如果 Markdown 没有图,尝试从渲染后的 HTML 再抓一次
|
List<String> out = new ArrayList<>(parts.length);
|
||||||
enclosure = firstImage(absHtml);
|
for (String part : parts) {
|
||||||
}
|
String p = part.trim();
|
||||||
if (enclosure != null) {
|
if (p.isEmpty()) continue;
|
||||||
enclosure = absolutifyUrl(enclosure, base);
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
// 6) 构造优雅的附加区块(原文链接 + 精选评论),编入 <content:encoded>
|
/* ===================== 摘要 & enclosure ===================== */
|
||||||
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>");
|
private static String textSummary(String html, int maxLen) {
|
||||||
elem(sb, "title", cdata(nullSafe(p.getTitle())));
|
if (html == null) return "";
|
||||||
elem(sb, "link", link);
|
String text = Jsoup.parse(html).text().replaceAll("\\s+", " ").trim();
|
||||||
sb.append("<guid isPermaLink=\"true\">").append(link).append("</guid>");
|
if (text.length() <= maxLen) return text;
|
||||||
elem(sb, "pubDate", toRfc1123Gmt(p.getCreatedAt().atZone(ZoneId.systemDefault())));
|
return text.substring(0, maxLen) + "…";
|
||||||
// 摘要
|
}
|
||||||
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>");
|
private String firstImage(String content) {
|
||||||
return sb.toString();
|
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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===================== Markdown → HTML ===================== */
|
// 渲染为 HTML,并保持和正文一致的处理流程
|
||||||
|
String html = renderMarkdown(md.toString());
|
||||||
|
String safe = sanitizeHtml(html);
|
||||||
|
return absolutifyHtml(safe, baseUrl);
|
||||||
|
}
|
||||||
|
|
||||||
private static String renderMarkdown(String md) {
|
private static String usernameOf(Comment c) {
|
||||||
if (md == null || md.isEmpty()) return "";
|
if (c == null) return "匿名";
|
||||||
return MD_RENDERER.render(MD_PARSER.parse(md));
|
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 "匿名";
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* ===================== Sanitize & 绝对化 ===================== */
|
/* ===================== 时间/字符串/XML ===================== */
|
||||||
|
|
||||||
private static String sanitizeHtml(String html) {
|
private static String toRfc1123Gmt(ZonedDateTime zdt) {
|
||||||
if (html == null) return "";
|
return zdt.withZoneSameInstant(ZoneId.of("GMT")).format(RFC1123);
|
||||||
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) {
|
private static String cdata(String s) {
|
||||||
if (html == null || html.isEmpty()) return "";
|
if (s == null) return "<![CDATA[]]>";
|
||||||
Document doc = Jsoup.parseBodyFragment(html, baseUrl);
|
// 防止出现 "]]>" 终止标记破坏 CDATA
|
||||||
// a[href]
|
return "<![CDATA[" + s.replace("]]>", "]]]]><![CDATA[>") + "]]>";
|
||||||
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) {
|
private static void elem(StringBuilder sb, String name, String value) {
|
||||||
if (url == null || url.isEmpty()) return url;
|
sb.append('<').append(name).append('>').append(value).append("</").append(name).append('>');
|
||||||
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) {
|
private static String escapeXml(String s) {
|
||||||
if (srcset == null || srcset.isEmpty()) return srcset;
|
if (s == null) return "";
|
||||||
String[] parts = srcset.split(",");
|
return s
|
||||||
List<String> out = new ArrayList<>(parts.length);
|
.replace("&", "&")
|
||||||
for (String part : parts) {
|
.replace("<", "<")
|
||||||
String p = part.trim();
|
.replace(">", ">")
|
||||||
if (p.isEmpty()) continue;
|
.replace("\"", """)
|
||||||
String[] seg = p.split("\\s+");
|
.replace("'", "'");
|
||||||
String url = seg[0];
|
}
|
||||||
String size = seg.length > 1 ? seg[1] : "";
|
|
||||||
out.add(absolutifyUrl(url, baseUrl) + (size.isEmpty() ? "" : " " + size));
|
|
||||||
}
|
|
||||||
return String.join(", ", out);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ===================== 摘要 & enclosure ===================== */
|
private static String trimTrailingSlash(String s) {
|
||||||
|
if (s == null) return "";
|
||||||
|
return s.endsWith("/") ? s.substring(0, s.length() - 1) : s;
|
||||||
|
}
|
||||||
|
|
||||||
private static String textSummary(String html, int maxLen) {
|
private static String ensureTrailingSlash(String s) {
|
||||||
if (html == null) return "";
|
if (s == null || s.isEmpty()) return "/";
|
||||||
String text = Jsoup.parse(html).text().replaceAll("\\s+", " ").trim();
|
return s.endsWith("/") ? s : s + "/";
|
||||||
if (text.length() <= maxLen) return text;
|
}
|
||||||
return text.substring(0, maxLen) + "…";
|
|
||||||
}
|
|
||||||
|
|
||||||
private String firstImage(String content) {
|
private static String nullSafe(String s) {
|
||||||
if (content == null) return null;
|
return s == null ? "" : s;
|
||||||
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("&", "&").replace("<", "<").replace(">", ">")
|
|
||||||
.replace("\"", """).replace("'", "'");
|
|
||||||
}
|
|
||||||
|
|
||||||
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; }
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,64 +6,117 @@ 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 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;
|
|
||||||
|
|
||||||
@GetMapping("/users")
|
private final SearchService searchService;
|
||||||
public List<UserDto> searchUsers(@RequestParam String keyword) {
|
private final UserMapper userMapper;
|
||||||
return searchService.searchUsers(keyword).stream()
|
private final PostMapper postMapper;
|
||||||
.map(userMapper::toDto)
|
|
||||||
.collect(Collectors.toList());
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/posts")
|
@GetMapping("/users")
|
||||||
public List<PostSummaryDto> searchPosts(@RequestParam String keyword) {
|
@Operation(summary = "Search users", description = "Search users by keyword")
|
||||||
return searchService.searchPosts(keyword).stream()
|
@ApiResponse(
|
||||||
.map(postMapper::toSummaryDto)
|
responseCode = "200",
|
||||||
.collect(Collectors.toList());
|
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("/posts/content")
|
@GetMapping("/posts")
|
||||||
public List<PostSummaryDto> searchPostsByContent(@RequestParam String keyword) {
|
@Operation(summary = "Search posts", description = "Search posts by keyword")
|
||||||
return searchService.searchPostsByContent(keyword).stream()
|
@ApiResponse(
|
||||||
.map(postMapper::toSummaryDto)
|
responseCode = "200",
|
||||||
.collect(Collectors.toList());
|
description = "List of posts",
|
||||||
}
|
content = @Content(
|
||||||
|
array = @ArraySchema(schema = @Schema(implementation = PostSummaryDto.class))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
public List<PostSummaryDto> searchPosts(@RequestParam String keyword) {
|
||||||
|
return searchService
|
||||||
|
.searchPosts(keyword)
|
||||||
|
.stream()
|
||||||
|
.map(postMapper::toSummaryDto)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/posts/title")
|
@GetMapping("/posts/content")
|
||||||
public List<PostSummaryDto> searchPostsByTitle(@RequestParam String keyword) {
|
@Operation(summary = "Search posts by content", description = "Search posts by content keyword")
|
||||||
return searchService.searchPostsByTitle(keyword).stream()
|
@ApiResponse(
|
||||||
.map(postMapper::toSummaryDto)
|
responseCode = "200",
|
||||||
.collect(Collectors.toList());
|
description = "List of posts",
|
||||||
}
|
content = @Content(
|
||||||
|
array = @ArraySchema(schema = @Schema(implementation = PostSummaryDto.class))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
public List<PostSummaryDto> searchPostsByContent(@RequestParam String keyword) {
|
||||||
|
return searchService
|
||||||
|
.searchPostsByContent(keyword)
|
||||||
|
.stream()
|
||||||
|
.map(postMapper::toSummaryDto)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/global")
|
@GetMapping("/posts/title")
|
||||||
public List<SearchResultDto> global(@RequestParam String keyword) {
|
@Operation(summary = "Search posts by title", description = "Search posts by title keyword")
|
||||||
return searchService.globalSearch(keyword).stream()
|
@ApiResponse(
|
||||||
.map(r -> {
|
responseCode = "200",
|
||||||
SearchResultDto dto = new SearchResultDto();
|
description = "List of posts",
|
||||||
dto.setType(r.type());
|
content = @Content(
|
||||||
dto.setId(r.id());
|
array = @ArraySchema(schema = @Schema(implementation = PostSummaryDto.class))
|
||||||
dto.setText(r.text());
|
)
|
||||||
dto.setSubText(r.subText());
|
)
|
||||||
dto.setExtra(r.extra());
|
public List<PostSummaryDto> searchPostsByTitle(@RequestParam String keyword) {
|
||||||
dto.setPostId(r.postId());
|
return searchService
|
||||||
return dto;
|
.searchPostsByTitle(keyword)
|
||||||
})
|
.stream()
|
||||||
.collect(Collectors.toList());
|
.map(postMapper::toSummaryDto)
|
||||||
}
|
.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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,11 @@ 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;
|
||||||
@@ -11,8 +16,6 @@ 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 java.util.List;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Controller for dynamic sitemap generation.
|
* Controller for dynamic sitemap generation.
|
||||||
*/
|
*/
|
||||||
@@ -20,50 +23,47 @@ import java.util.List;
|
|||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@RequestMapping("/api")
|
@RequestMapping("/api")
|
||||||
public class SitemapController {
|
public class SitemapController {
|
||||||
private final PostRepository postRepository;
|
|
||||||
|
|
||||||
@Value("${app.website-url}")
|
private final PostRepository postRepository;
|
||||||
private String websiteUrl;
|
|
||||||
|
|
||||||
@GetMapping(value = "/sitemap.xml", produces = MediaType.APPLICATION_XML_VALUE)
|
@Value("${app.website-url}")
|
||||||
public ResponseEntity<String> sitemap() {
|
private String websiteUrl;
|
||||||
List<Post> posts = postRepository.findByStatus(PostStatus.PUBLISHED);
|
|
||||||
|
|
||||||
StringBuilder body = new StringBuilder();
|
@GetMapping(value = "/sitemap.xml", produces = MediaType.APPLICATION_XML_VALUE)
|
||||||
body.append("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
|
@Operation(summary = "Sitemap", description = "Generate sitemap xml")
|
||||||
body.append("<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">\n");
|
@ApiResponse(
|
||||||
|
responseCode = "200",
|
||||||
|
description = "Sitemap xml",
|
||||||
|
content = @Content(schema = @Schema(implementation = String.class))
|
||||||
|
)
|
||||||
|
public ResponseEntity<String> sitemap() {
|
||||||
|
List<Post> posts = postRepository.findByStatus(PostStatus.PUBLISHED);
|
||||||
|
|
||||||
List<String> staticRoutes = List.of(
|
StringBuilder body = new StringBuilder();
|
||||||
"/",
|
body.append("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
|
||||||
"/about",
|
body.append("<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">\n");
|
||||||
"/activities",
|
|
||||||
"/login",
|
|
||||||
"/signup"
|
|
||||||
);
|
|
||||||
|
|
||||||
for (String path : staticRoutes) {
|
List<String> staticRoutes = List.of("/", "/about", "/activities", "/login", "/signup");
|
||||||
body.append(" <url><loc>")
|
|
||||||
.append(websiteUrl)
|
|
||||||
.append(path)
|
|
||||||
.append("</loc></url>\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
for (Post p : posts) {
|
for (String path : staticRoutes) {
|
||||||
body.append(" <url>\n")
|
body.append(" <url><loc>").append(websiteUrl).append(path).append("</loc></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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
package com.openisle.controller;
|
package com.openisle.controller;
|
||||||
|
|
||||||
import com.openisle.service.UserVisitService;
|
|
||||||
import com.openisle.service.StatService;
|
import com.openisle.service.StatService;
|
||||||
|
import com.openisle.service.UserVisitService;
|
||||||
|
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;
|
||||||
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;
|
||||||
@@ -9,77 +17,111 @@ 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 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;
|
|
||||||
|
|
||||||
@GetMapping("/dau")
|
private final UserVisitService userVisitService;
|
||||||
public Map<String, Long> dau(@RequestParam(value = "date", required = false)
|
private final StatService statService;
|
||||||
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date) {
|
|
||||||
long count = userVisitService.countDau(date);
|
|
||||||
return Map.of("dau", count);
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/dau-range")
|
@GetMapping("/dau")
|
||||||
public List<Map<String, Object>> dauRange(@RequestParam(value = "days", defaultValue = "30") int days) {
|
@Operation(summary = "Daily active users", description = "Get daily active user count")
|
||||||
if (days < 1) days = 1;
|
@ApiResponse(
|
||||||
LocalDate end = LocalDate.now();
|
responseCode = "200",
|
||||||
LocalDate start = end.minusDays(days - 1L);
|
description = "DAU count",
|
||||||
var data = userVisitService.countDauRange(start, end);
|
content = @Content(schema = @Schema(implementation = java.util.Map.class))
|
||||||
return data.entrySet().stream()
|
)
|
||||||
.map(e -> Map.<String,Object>of(
|
public Map<String, Long> dau(
|
||||||
"date", e.getKey().toString(),
|
@RequestParam(value = "date", required = false) @DateTimeFormat(
|
||||||
"value", e.getValue()
|
iso = DateTimeFormat.ISO.DATE
|
||||||
))
|
) LocalDate date
|
||||||
.toList();
|
) {
|
||||||
}
|
long count = userVisitService.countDau(date);
|
||||||
|
return Map.of("dau", count);
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/new-users-range")
|
@GetMapping("/dau-range")
|
||||||
public List<Map<String, Object>> newUsersRange(@RequestParam(value = "days", defaultValue = "30") int days) {
|
@Operation(summary = "DAU range", description = "Get daily active users over range of days")
|
||||||
if (days < 1) days = 1;
|
@ApiResponse(
|
||||||
LocalDate end = LocalDate.now();
|
responseCode = "200",
|
||||||
LocalDate start = end.minusDays(days - 1L);
|
description = "DAU data",
|
||||||
var data = statService.countNewUsersRange(start, end);
|
content = @Content(array = @ArraySchema(schema = @Schema(implementation = java.util.Map.class)))
|
||||||
return data.entrySet().stream()
|
)
|
||||||
.map(e -> Map.<String,Object>of(
|
public List<Map<String, Object>> dauRange(
|
||||||
"date", e.getKey().toString(),
|
@RequestParam(value = "days", defaultValue = "30") int days
|
||||||
"value", e.getValue()
|
) {
|
||||||
))
|
if (days < 1) days = 1;
|
||||||
.toList();
|
LocalDate end = LocalDate.now();
|
||||||
}
|
LocalDate start = end.minusDays(days - 1L);
|
||||||
|
var data = userVisitService.countDauRange(start, end);
|
||||||
|
return data
|
||||||
|
.entrySet()
|
||||||
|
.stream()
|
||||||
|
.map(e -> Map.<String, Object>of("date", e.getKey().toString(), "value", e.getValue()))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/posts-range")
|
@GetMapping("/new-users-range")
|
||||||
public List<Map<String, Object>> postsRange(@RequestParam(value = "days", defaultValue = "30") int days) {
|
@Operation(summary = "New users range", description = "Get new users over range of days")
|
||||||
if (days < 1) days = 1;
|
@ApiResponse(
|
||||||
LocalDate end = LocalDate.now();
|
responseCode = "200",
|
||||||
LocalDate start = end.minusDays(days - 1L);
|
description = "New user data",
|
||||||
var data = statService.countPostsRange(start, end);
|
content = @Content(array = @ArraySchema(schema = @Schema(implementation = java.util.Map.class)))
|
||||||
return data.entrySet().stream()
|
)
|
||||||
.map(e -> Map.<String,Object>of(
|
public List<Map<String, Object>> newUsersRange(
|
||||||
"date", e.getKey().toString(),
|
@RequestParam(value = "days", defaultValue = "30") int days
|
||||||
"value", e.getValue()
|
) {
|
||||||
))
|
if (days < 1) days = 1;
|
||||||
.toList();
|
LocalDate end = LocalDate.now();
|
||||||
}
|
LocalDate start = end.minusDays(days - 1L);
|
||||||
|
var data = statService.countNewUsersRange(start, end);
|
||||||
|
return data
|
||||||
|
.entrySet()
|
||||||
|
.stream()
|
||||||
|
.map(e -> Map.<String, Object>of("date", e.getKey().toString(), "value", e.getValue()))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/comments-range")
|
@GetMapping("/posts-range")
|
||||||
public List<Map<String, Object>> commentsRange(@RequestParam(value = "days", defaultValue = "30") int days) {
|
@Operation(summary = "Posts range", description = "Get posts count over range of days")
|
||||||
if (days < 1) days = 1;
|
@ApiResponse(
|
||||||
LocalDate end = LocalDate.now();
|
responseCode = "200",
|
||||||
LocalDate start = end.minusDays(days - 1L);
|
description = "Post data",
|
||||||
var data = statService.countCommentsRange(start, end);
|
content = @Content(array = @ArraySchema(schema = @Schema(implementation = java.util.Map.class)))
|
||||||
return data.entrySet().stream()
|
)
|
||||||
.map(e -> Map.<String,Object>of(
|
public List<Map<String, Object>> postsRange(
|
||||||
"date", e.getKey().toString(),
|
@RequestParam(value = "days", defaultValue = "30") int days
|
||||||
"value", e.getValue()
|
) {
|
||||||
))
|
if (days < 1) days = 1;
|
||||||
.toList();
|
LocalDate end = LocalDate.now();
|
||||||
}
|
LocalDate start = end.minusDays(days - 1L);
|
||||||
|
var data = statService.countPostsRange(start, end);
|
||||||
|
return data
|
||||||
|
.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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
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.*;
|
||||||
@@ -10,35 +13,54 @@ import org.springframework.web.bind.annotation.*;
|
|||||||
@RequestMapping("/api/subscriptions")
|
@RequestMapping("/api/subscriptions")
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class SubscriptionController {
|
public class SubscriptionController {
|
||||||
private final SubscriptionService subscriptionService;
|
|
||||||
|
|
||||||
@PostMapping("/posts/{postId}")
|
private final SubscriptionService subscriptionService;
|
||||||
public void subscribePost(@PathVariable Long postId, Authentication auth) {
|
|
||||||
subscriptionService.subscribePost(auth.getName(), postId);
|
|
||||||
}
|
|
||||||
|
|
||||||
@DeleteMapping("/posts/{postId}")
|
@PostMapping("/posts/{postId}")
|
||||||
public void unsubscribePost(@PathVariable Long postId, Authentication auth) {
|
@Operation(summary = "Subscribe post", description = "Subscribe to a post")
|
||||||
subscriptionService.unsubscribePost(auth.getName(), postId);
|
@ApiResponse(responseCode = "200", description = "Subscribed")
|
||||||
}
|
@SecurityRequirement(name = "JWT")
|
||||||
|
public void subscribePost(@PathVariable Long postId, Authentication auth) {
|
||||||
|
subscriptionService.subscribePost(auth.getName(), postId);
|
||||||
|
}
|
||||||
|
|
||||||
@PostMapping("/comments/{commentId}")
|
@DeleteMapping("/posts/{postId}")
|
||||||
public void subscribeComment(@PathVariable Long commentId, Authentication auth) {
|
@Operation(summary = "Unsubscribe post", description = "Unsubscribe from a post")
|
||||||
subscriptionService.subscribeComment(auth.getName(), commentId);
|
@ApiResponse(responseCode = "200", description = "Unsubscribed")
|
||||||
}
|
@SecurityRequirement(name = "JWT")
|
||||||
|
public void unsubscribePost(@PathVariable Long postId, Authentication auth) {
|
||||||
|
subscriptionService.unsubscribePost(auth.getName(), postId);
|
||||||
|
}
|
||||||
|
|
||||||
@DeleteMapping("/comments/{commentId}")
|
@PostMapping("/comments/{commentId}")
|
||||||
public void unsubscribeComment(@PathVariable Long commentId, Authentication auth) {
|
@Operation(summary = "Subscribe comment", description = "Subscribe to a comment")
|
||||||
subscriptionService.unsubscribeComment(auth.getName(), commentId);
|
@ApiResponse(responseCode = "200", description = "Subscribed")
|
||||||
}
|
@SecurityRequirement(name = "JWT")
|
||||||
|
public void subscribeComment(@PathVariable Long commentId, Authentication auth) {
|
||||||
|
subscriptionService.subscribeComment(auth.getName(), commentId);
|
||||||
|
}
|
||||||
|
|
||||||
@PostMapping("/users/{username}")
|
@DeleteMapping("/comments/{commentId}")
|
||||||
public void subscribeUser(@PathVariable String username, Authentication auth) {
|
@Operation(summary = "Unsubscribe comment", description = "Unsubscribe from a comment")
|
||||||
subscriptionService.subscribeUser(auth.getName(), username);
|
@ApiResponse(responseCode = "200", description = "Unsubscribed")
|
||||||
}
|
@SecurityRequirement(name = "JWT")
|
||||||
|
public void unsubscribeComment(@PathVariable Long commentId, Authentication auth) {
|
||||||
|
subscriptionService.unsubscribeComment(auth.getName(), commentId);
|
||||||
|
}
|
||||||
|
|
||||||
@DeleteMapping("/users/{username}")
|
@PostMapping("/users/{username}")
|
||||||
public void unsubscribeUser(@PathVariable String username, Authentication auth) {
|
@Operation(summary = "Subscribe user", description = "Subscribe to a user")
|
||||||
subscriptionService.unsubscribeUser(auth.getName(), username);
|
@ApiResponse(responseCode = "200", description = "Subscribed")
|
||||||
}
|
@SecurityRequirement(name = "JWT")
|
||||||
|
public void subscribeUser(@PathVariable String username, Authentication auth) {
|
||||||
|
subscriptionService.subscribeUser(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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,85 +11,142 @@ 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 io.swagger.v3.oas.annotations.Operation;
|
||||||
import org.springframework.web.bind.annotation.*;
|
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.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;
|
|
||||||
|
|
||||||
@PostMapping
|
private final TagService tagService;
|
||||||
public TagDto create(@RequestBody TagRequest req, org.springframework.security.core.Authentication auth) {
|
private final PostService postService;
|
||||||
boolean approved = true;
|
private final UserRepository userRepository;
|
||||||
if (postService.getPublishMode() == PublishMode.REVIEW && auth != null) {
|
private final PostMapper postMapper;
|
||||||
com.openisle.model.User user = userRepository.findByUsername(auth.getName()).orElseThrow();
|
private final TagMapper tagMapper;
|
||||||
if (user.getRole() != Role.ADMIN) {
|
|
||||||
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}")
|
@PostMapping
|
||||||
public TagDto update(@PathVariable Long id, @RequestBody TagRequest req) {
|
@Operation(summary = "Create tag", description = "Create a new tag")
|
||||||
Tag tag = tagService.updateTag(id, req.getName(), req.getDescription(), req.getIcon(), req.getSmallIcon());
|
@ApiResponse(
|
||||||
long count = postService.countPostsByTag(tag.getId());
|
responseCode = "200",
|
||||||
return tagMapper.toDto(tag, count);
|
description = "Created tag",
|
||||||
|
content = @Content(schema = @Schema(implementation = TagDto.class))
|
||||||
|
)
|
||||||
|
@SecurityRequirement(name = "JWT")
|
||||||
|
public TagDto create(
|
||||||
|
@RequestBody TagRequest req,
|
||||||
|
org.springframework.security.core.Authentication auth
|
||||||
|
) {
|
||||||
|
boolean approved = true;
|
||||||
|
if (postService.getPublishMode() == PublishMode.REVIEW && auth != null) {
|
||||||
|
com.openisle.model.User user = userRepository.findByUsername(auth.getName()).orElseThrow();
|
||||||
|
if (user.getRole() != Role.ADMIN) {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
@DeleteMapping("/{id}")
|
@PutMapping("/{id}")
|
||||||
public void delete(@PathVariable Long id) {
|
@Operation(summary = "Update tag", description = "Update an existing tag")
|
||||||
tagService.deleteTag(id);
|
@ApiResponse(
|
||||||
}
|
responseCode = "200",
|
||||||
|
description = "Updated tag",
|
||||||
|
content = @Content(schema = @Schema(implementation = TagDto.class))
|
||||||
|
)
|
||||||
|
public TagDto update(@PathVariable Long id, @RequestBody TagRequest req) {
|
||||||
|
Tag tag = tagService.updateTag(
|
||||||
|
id,
|
||||||
|
req.getName(),
|
||||||
|
req.getDescription(),
|
||||||
|
req.getIcon(),
|
||||||
|
req.getSmallIcon()
|
||||||
|
);
|
||||||
|
long count = postService.countPostsByTag(tag.getId());
|
||||||
|
return tagMapper.toDto(tag, count);
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping
|
@DeleteMapping("/{id}")
|
||||||
public List<TagDto> list(@RequestParam(value = "keyword", required = false) String keyword,
|
@Operation(summary = "Delete tag", description = "Delete a tag by id")
|
||||||
@RequestParam(value = "limit", required = false) Integer limit) {
|
@ApiResponse(responseCode = "200", description = "Tag deleted")
|
||||||
List<Tag> tags = tagService.searchTags(keyword);
|
public void delete(@PathVariable Long id) {
|
||||||
List<Long> tagIds = tags.stream().map(Tag::getId).toList();
|
tagService.deleteTag(id);
|
||||||
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}")
|
@GetMapping
|
||||||
public TagDto get(@PathVariable Long id) {
|
@Operation(summary = "List tags", description = "List tags with optional keyword")
|
||||||
Tag tag = tagService.getTag(id);
|
@ApiResponse(
|
||||||
long count = postService.countPostsByTag(tag.getId());
|
responseCode = "200",
|
||||||
return tagMapper.toDto(tag, count);
|
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}/posts")
|
@GetMapping("/{id}")
|
||||||
public List<PostSummaryDto> listPostsByTag(@PathVariable Long id,
|
@Operation(summary = "Get tag", description = "Get tag by id")
|
||||||
@RequestParam(value = "page", required = false) Integer page,
|
@ApiResponse(
|
||||||
@RequestParam(value = "pageSize", required = false) Integer pageSize) {
|
responseCode = "200",
|
||||||
return postService.listPostsByTags(java.util.List.of(id), page, pageSize)
|
description = "Tag detail",
|
||||||
.stream()
|
content = @Content(schema = @Schema(implementation = TagDto.class))
|
||||||
.map(postMapper::toSummaryDto)
|
)
|
||||||
.collect(Collectors.toList());
|
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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,82 +1,99 @@
|
|||||||
package com.openisle.controller;
|
package com.openisle.controller;
|
||||||
|
|
||||||
import com.openisle.service.ImageUploader;
|
import com.openisle.service.ImageUploader;
|
||||||
import lombok.RequiredArgsConstructor;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import io.swagger.v3.oas.annotations.media.Content;
|
||||||
import org.springframework.http.ResponseEntity;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||||
import org.springframework.web.multipart.MultipartFile;
|
|
||||||
|
|
||||||
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;
|
|
||||||
|
|
||||||
@Value("${app.upload.check-type:true}")
|
private final ImageUploader imageUploader;
|
||||||
private boolean checkImageType;
|
|
||||||
|
|
||||||
@Value("${app.upload.max-size:5242880}")
|
@Value("${app.upload.check-type:true}")
|
||||||
private long maxUploadSize;
|
private boolean checkImageType;
|
||||||
|
|
||||||
@PostMapping
|
@Value("${app.upload.max-size:5242880}")
|
||||||
public ResponseEntity<?> upload(@RequestParam("file") MultipartFile file) {
|
private long maxUploadSize;
|
||||||
if (checkImageType && (file.getContentType() == null || !file.getContentType().startsWith("image/"))) {
|
|
||||||
return ResponseEntity.badRequest().body(Map.of("code", 1, "msg", "File is not an image"));
|
@PostMapping
|
||||||
}
|
@Operation(summary = "Upload file", description = "Upload image file")
|
||||||
if (file.getSize() > maxUploadSize) {
|
@ApiResponse(
|
||||||
return ResponseEntity.badRequest().body(Map.of("code", 2, "msg", "File too large"));
|
responseCode = "200",
|
||||||
}
|
description = "Upload result",
|
||||||
String url;
|
content = @Content(schema = @Schema(implementation = java.util.Map.class))
|
||||||
try {
|
)
|
||||||
url = imageUploader.upload(file.getBytes(), file.getOriginalFilename()).join();
|
public ResponseEntity<?> upload(@RequestParam("file") MultipartFile file) {
|
||||||
} catch (IOException e) {
|
if (
|
||||||
return ResponseEntity.internalServerError().body(Map.of("code", 3, "msg", "Upload failed"));
|
checkImageType &&
|
||||||
}
|
(file.getContentType() == null || !file.getContentType().startsWith("image/"))
|
||||||
return ResponseEntity.ok(Map.of(
|
) {
|
||||||
"code", 0,
|
return ResponseEntity.badRequest().body(Map.of("code", 1, "msg", "File is not an image"));
|
||||||
"msg", "ok",
|
|
||||||
"data", Map.of("url", url)
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
if (file.getSize() > maxUploadSize) {
|
||||||
@PostMapping("/url")
|
return ResponseEntity.badRequest().body(Map.of("code", 2, "msg", "File too large"));
|
||||||
public ResponseEntity<?> uploadUrl(@RequestBody Map<String, String> body) {
|
|
||||||
String link = body.get("url");
|
|
||||||
if (link == null || link.isBlank()) {
|
|
||||||
return ResponseEntity.badRequest().body(Map.of("code", 1, "msg", "Missing url"));
|
|
||||||
}
|
|
||||||
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"));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
String url;
|
||||||
@GetMapping("/presign")
|
try {
|
||||||
public java.util.Map<String, String> presign(@RequestParam("filename") String filename) {
|
url = imageUploader.upload(file.getBytes(), file.getOriginalFilename()).join();
|
||||||
return imageUploader.presignUpload(filename);
|
} 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")
|
||||||
|
@Operation(summary = "Upload from URL", description = "Upload image from remote URL")
|
||||||
|
@ApiResponse(
|
||||||
|
responseCode = "200",
|
||||||
|
description = "Upload result",
|
||||||
|
content = @Content(schema = @Schema(implementation = java.util.Map.class))
|
||||||
|
)
|
||||||
|
public ResponseEntity<?> uploadUrl(@RequestBody Map<String, String> body) {
|
||||||
|
String link = body.get("url");
|
||||||
|
if (link == null || link.isBlank()) {
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("code", 1, "msg", "Missing url"));
|
||||||
|
}
|
||||||
|
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")
|
||||||
|
@Operation(summary = "Presign upload", description = "Get presigned upload URL")
|
||||||
|
@ApiResponse(
|
||||||
|
responseCode = "200",
|
||||||
|
description = "Presigned URL",
|
||||||
|
content = @Content(schema = @Schema(implementation = java.util.Map.class))
|
||||||
|
)
|
||||||
|
public java.util.Map<String, String> presign(@RequestParam("filename") String filename) {
|
||||||
|
return imageUploader.presignUpload(filename);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,14 @@ import com.openisle.mapper.TagMapper;
|
|||||||
import com.openisle.mapper.UserMapper;
|
import com.openisle.mapper.UserMapper;
|
||||||
import com.openisle.model.User;
|
import com.openisle.model.User;
|
||||||
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.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;
|
||||||
@@ -13,204 +21,359 @@ 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;
|
|
||||||
|
|
||||||
@Value("${app.upload.check-type:true}")
|
private final UserService userService;
|
||||||
private boolean checkImageType;
|
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;
|
||||||
|
|
||||||
@Value("${app.upload.max-size:5242880}")
|
@Value("${app.upload.check-type:true}")
|
||||||
private long maxUploadSize;
|
private boolean checkImageType;
|
||||||
|
|
||||||
@Value("${app.user.posts-limit:10}")
|
@Value("${app.upload.max-size:5242880}")
|
||||||
private int defaultPostsLimit;
|
private long maxUploadSize;
|
||||||
|
|
||||||
@Value("${app.user.replies-limit:50}")
|
@Value("${app.user.posts-limit:10}")
|
||||||
private int defaultRepliesLimit;
|
private int defaultPostsLimit;
|
||||||
|
|
||||||
@Value("${app.user.tags-limit:50}")
|
@Value("${app.user.replies-limit:50}")
|
||||||
private int defaultTagsLimit;
|
private int defaultRepliesLimit;
|
||||||
|
|
||||||
@GetMapping("/me")
|
@Value("${app.user.tags-limit:50}")
|
||||||
public ResponseEntity<UserDto> me(Authentication auth) {
|
private int defaultTagsLimit;
|
||||||
User user = userService.findByUsername(auth.getName()).orElseThrow();
|
|
||||||
return ResponseEntity.ok(userMapper.toDto(user, auth));
|
@GetMapping("/me")
|
||||||
|
@SecurityRequirement(name = "JWT")
|
||||||
|
@Operation(summary = "Current user", description = "Get current authenticated user information")
|
||||||
|
@ApiResponse(
|
||||||
|
responseCode = "200",
|
||||||
|
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) {
|
||||||
@PostMapping("/me/avatar")
|
return ResponseEntity.badRequest().body(Map.of("error", "File too large"));
|
||||||
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;
|
||||||
@PutMapping("/me")
|
try {
|
||||||
public ResponseEntity<?> updateProfile(@RequestBody UpdateProfileDto dto,
|
url = imageUploader.upload(file.getBytes(), file.getOriginalFilename()).join();
|
||||||
Authentication auth) {
|
} catch (IOException e) {
|
||||||
User user = userService.updateProfile(auth.getName(), dto.getUsername(), dto.getIntroduction());
|
return ResponseEntity.internalServerError().body(Map.of("url", url));
|
||||||
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));
|
||||||
|
}
|
||||||
|
|
||||||
@PostMapping("/me/signin")
|
@PutMapping("/me")
|
||||||
public Map<String, Integer> signIn(Authentication auth) {
|
@SecurityRequirement(name = "JWT")
|
||||||
int reward = levelService.awardForSignin(auth.getName());
|
@Operation(summary = "Update profile", description = "Update current user's profile")
|
||||||
return Map.of("reward", reward);
|
@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)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/{identifier}")
|
// 这个方法似乎没有使用?
|
||||||
public ResponseEntity<UserDto> getUser(@PathVariable("identifier") String identifier,
|
@PostMapping("/me/signin")
|
||||||
Authentication auth) {
|
@SecurityRequirement(name = "JWT")
|
||||||
User user = userService.findByIdentifier(identifier).orElseThrow(() -> new NotFoundException("User not found"));
|
@Operation(summary = "Daily sign in", description = "Sign in to receive rewards")
|
||||||
return ResponseEntity.ok(userMapper.toDto(user, auth));
|
@ApiResponse(
|
||||||
}
|
responseCode = "200",
|
||||||
|
description = "Sign in reward",
|
||||||
|
content = @Content(schema = @Schema(implementation = Map.class))
|
||||||
|
)
|
||||||
|
public Map<String, Integer> signIn(Authentication auth) {
|
||||||
|
int reward = levelService.awardForSignin(auth.getName());
|
||||||
|
return Map.of("reward", reward);
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/{identifier}/posts")
|
@GetMapping("/{identifier}")
|
||||||
public java.util.List<PostMetaDto> userPosts(@PathVariable("identifier") String identifier,
|
@Operation(summary = "Get user", description = "Get user by identifier")
|
||||||
@RequestParam(value = "limit", required = false) Integer limit) {
|
@ApiResponse(
|
||||||
int l = limit != null ? limit : defaultPostsLimit;
|
responseCode = "200",
|
||||||
User user = userService.findByIdentifier(identifier).orElseThrow();
|
description = "User detail",
|
||||||
return postService.getRecentPostsByUser(user.getUsername(), l).stream()
|
content = @Content(schema = @Schema(implementation = UserDto.class))
|
||||||
.map(userMapper::toMetaDto)
|
)
|
||||||
.collect(java.util.stream.Collectors.toList());
|
public ResponseEntity<UserDto> getUser(
|
||||||
}
|
@PathVariable("identifier") String identifier,
|
||||||
|
Authentication auth
|
||||||
|
) {
|
||||||
|
User user = userService
|
||||||
|
.findByIdentifier(identifier)
|
||||||
|
.orElseThrow(() -> new NotFoundException("User not found"));
|
||||||
|
return ResponseEntity.ok(userMapper.toDto(user, auth));
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/{identifier}/subscribed-posts")
|
@GetMapping("/{identifier}/posts")
|
||||||
public java.util.List<PostMetaDto> subscribedPosts(@PathVariable("identifier") String identifier,
|
@Operation(summary = "User posts", description = "Get recent posts by user")
|
||||||
@RequestParam(value = "limit", required = false) Integer limit) {
|
@ApiResponse(
|
||||||
int l = limit != null ? limit : defaultPostsLimit;
|
responseCode = "200",
|
||||||
User user = userService.findByIdentifier(identifier).orElseThrow();
|
description = "User posts",
|
||||||
return subscriptionService.getSubscribedPosts(user.getUsername()).stream()
|
content = @Content(array = @ArraySchema(schema = @Schema(implementation = PostMetaDto.class)))
|
||||||
.limit(l)
|
)
|
||||||
.map(userMapper::toMetaDto)
|
public java.util.List<PostMetaDto> userPosts(
|
||||||
.collect(java.util.stream.Collectors.toList());
|
@PathVariable("identifier") String identifier,
|
||||||
}
|
@RequestParam(value = "limit", required = false) Integer limit
|
||||||
|
) {
|
||||||
|
int l = limit != null ? limit : defaultPostsLimit;
|
||||||
|
User user = userService.findByIdentifier(identifier).orElseThrow();
|
||||||
|
return postService
|
||||||
|
.getRecentPostsByUser(user.getUsername(), l)
|
||||||
|
.stream()
|
||||||
|
.map(userMapper::toMetaDto)
|
||||||
|
.collect(java.util.stream.Collectors.toList());
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/{identifier}/replies")
|
@GetMapping("/{identifier}/subscribed-posts")
|
||||||
public java.util.List<CommentInfoDto> userReplies(@PathVariable("identifier") String identifier,
|
@Operation(summary = "Subscribed posts", description = "Get posts the user subscribed to")
|
||||||
@RequestParam(value = "limit", required = false) Integer limit) {
|
@ApiResponse(
|
||||||
int l = limit != null ? limit : defaultRepliesLimit;
|
responseCode = "200",
|
||||||
User user = userService.findByIdentifier(identifier).orElseThrow();
|
description = "Subscribed posts",
|
||||||
return commentService.getRecentCommentsByUser(user.getUsername(), l).stream()
|
content = @Content(array = @ArraySchema(schema = @Schema(implementation = PostMetaDto.class)))
|
||||||
.map(userMapper::toCommentInfoDto)
|
)
|
||||||
.collect(java.util.stream.Collectors.toList());
|
public java.util.List<PostMetaDto> subscribedPosts(
|
||||||
}
|
@PathVariable("identifier") String identifier,
|
||||||
|
@RequestParam(value = "limit", required = false) Integer limit
|
||||||
|
) {
|
||||||
|
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}/hot-posts")
|
@GetMapping("/{identifier}/replies")
|
||||||
public java.util.List<PostMetaDto> hotPosts(@PathVariable("identifier") String identifier,
|
@Operation(summary = "User replies", description = "Get recent replies by user")
|
||||||
@RequestParam(value = "limit", required = false) Integer limit) {
|
@ApiResponse(
|
||||||
int l = limit != null ? limit : 10;
|
responseCode = "200",
|
||||||
User user = userService.findByIdentifier(identifier).orElseThrow();
|
description = "User replies",
|
||||||
java.util.List<Long> ids = reactionService.topPostIds(user.getUsername(), l);
|
content = @Content(
|
||||||
return postService.getPostsByIds(ids).stream()
|
array = @ArraySchema(schema = @Schema(implementation = CommentInfoDto.class))
|
||||||
.map(userMapper::toMetaDto)
|
)
|
||||||
.collect(java.util.stream.Collectors.toList());
|
)
|
||||||
}
|
public java.util.List<CommentInfoDto> userReplies(
|
||||||
|
@PathVariable("identifier") String identifier,
|
||||||
|
@RequestParam(value = "limit", required = false) Integer limit
|
||||||
|
) {
|
||||||
|
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-replies")
|
@GetMapping("/{identifier}/hot-posts")
|
||||||
public java.util.List<CommentInfoDto> hotReplies(@PathVariable("identifier") String identifier,
|
@Operation(summary = "User hot posts", description = "Get most reacted posts by user")
|
||||||
@RequestParam(value = "limit", required = false) Integer limit) {
|
@ApiResponse(
|
||||||
int l = limit != null ? limit : 10;
|
responseCode = "200",
|
||||||
User user = userService.findByIdentifier(identifier).orElseThrow();
|
description = "Hot posts",
|
||||||
java.util.List<Long> ids = reactionService.topCommentIds(user.getUsername(), l);
|
content = @Content(array = @ArraySchema(schema = @Schema(implementation = PostMetaDto.class)))
|
||||||
return commentService.getCommentsByIds(ids).stream()
|
)
|
||||||
.map(userMapper::toCommentInfoDto)
|
public java.util.List<PostMetaDto> hotPosts(
|
||||||
.collect(java.util.stream.Collectors.toList());
|
@PathVariable("identifier") String identifier,
|
||||||
}
|
@RequestParam(value = "limit", required = false) Integer limit
|
||||||
|
) {
|
||||||
|
int l = limit != null ? limit : 10;
|
||||||
|
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-tags")
|
@GetMapping("/{identifier}/hot-replies")
|
||||||
public java.util.List<TagDto> hotTags(@PathVariable("identifier") String identifier,
|
@Operation(summary = "User hot replies", description = "Get most reacted replies by user")
|
||||||
@RequestParam(value = "limit", required = false) Integer limit) {
|
@ApiResponse(
|
||||||
int l = limit != null ? limit : 10;
|
responseCode = "200",
|
||||||
User user = userService.findByIdentifier(identifier).orElseThrow();
|
description = "Hot replies",
|
||||||
return tagService.getTagsByUser(user.getUsername()).stream()
|
content = @Content(
|
||||||
.map(t -> tagMapper.toDto(t, postService.countPostsByTag(t.getId())))
|
array = @ArraySchema(schema = @Schema(implementation = CommentInfoDto.class))
|
||||||
.sorted((a, b) -> Long.compare(b.getCount(), a.getCount()))
|
)
|
||||||
.limit(l)
|
)
|
||||||
.collect(java.util.stream.Collectors.toList());
|
public java.util.List<CommentInfoDto> hotReplies(
|
||||||
}
|
@PathVariable("identifier") String identifier,
|
||||||
|
@RequestParam(value = "limit", required = false) Integer limit
|
||||||
|
) {
|
||||||
|
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}/tags")
|
@GetMapping("/{identifier}/hot-tags")
|
||||||
public java.util.List<TagDto> userTags(@PathVariable("identifier") String identifier,
|
@Operation(summary = "User hot tags", description = "Get tags frequently used by user")
|
||||||
@RequestParam(value = "limit", required = false) Integer limit) {
|
@ApiResponse(
|
||||||
int l = limit != null ? limit : defaultTagsLimit;
|
responseCode = "200",
|
||||||
User user = userService.findByIdentifier(identifier).orElseThrow();
|
description = "Hot tags",
|
||||||
return tagService.getRecentTagsByUser(user.getUsername(), l).stream()
|
content = @Content(array = @ArraySchema(schema = @Schema(implementation = TagDto.class)))
|
||||||
.map(t -> tagMapper.toDto(t, postService.countPostsByTag(t.getId())))
|
)
|
||||||
.collect(java.util.stream.Collectors.toList());
|
public java.util.List<TagDto> hotTags(
|
||||||
}
|
@PathVariable("identifier") String identifier,
|
||||||
|
@RequestParam(value = "limit", required = false) Integer limit
|
||||||
|
) {
|
||||||
|
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}/following")
|
@GetMapping("/{identifier}/tags")
|
||||||
public java.util.List<UserDto> following(@PathVariable("identifier") String identifier) {
|
@Operation(summary = "User tags", description = "Get recent tags used by user")
|
||||||
User user = userService.findByIdentifier(identifier).orElseThrow();
|
@ApiResponse(
|
||||||
return subscriptionService.getSubscribedUsers(user.getUsername()).stream()
|
responseCode = "200",
|
||||||
.map(userMapper::toDto)
|
description = "User tags",
|
||||||
.collect(java.util.stream.Collectors.toList());
|
content = @Content(array = @ArraySchema(schema = @Schema(implementation = TagDto.class)))
|
||||||
}
|
)
|
||||||
|
public java.util.List<TagDto> userTags(
|
||||||
|
@PathVariable("identifier") String identifier,
|
||||||
|
@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}/followers")
|
@GetMapping("/{identifier}/following")
|
||||||
public java.util.List<UserDto> followers(@PathVariable("identifier") String identifier) {
|
@Operation(summary = "Following users", description = "Get users that this user is following")
|
||||||
User user = userService.findByIdentifier(identifier).orElseThrow();
|
@ApiResponse(
|
||||||
return subscriptionService.getSubscribers(user.getUsername()).stream()
|
responseCode = "200",
|
||||||
.map(userMapper::toDto)
|
description = "Following list",
|
||||||
.collect(java.util.stream.Collectors.toList());
|
content = @Content(array = @ArraySchema(schema = @Schema(implementation = UserDto.class)))
|
||||||
}
|
)
|
||||||
|
public java.util.List<UserDto> following(@PathVariable("identifier") String identifier) {
|
||||||
|
User user = userService.findByIdentifier(identifier).orElseThrow();
|
||||||
|
return subscriptionService
|
||||||
|
.getSubscribedUsers(user.getUsername())
|
||||||
|
.stream()
|
||||||
|
.map(userMapper::toDto)
|
||||||
|
.collect(java.util.stream.Collectors.toList());
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/admins")
|
@GetMapping("/{identifier}/followers")
|
||||||
public java.util.List<UserDto> admins() {
|
@Operation(summary = "Followers", description = "Get followers of this user")
|
||||||
return userService.getAdmins().stream()
|
@ApiResponse(
|
||||||
.map(userMapper::toDto)
|
responseCode = "200",
|
||||||
.collect(java.util.stream.Collectors.toList());
|
description = "Followers list",
|
||||||
}
|
content = @Content(array = @ArraySchema(schema = @Schema(implementation = UserDto.class)))
|
||||||
|
)
|
||||||
|
public java.util.List<UserDto> followers(@PathVariable("identifier") String identifier) {
|
||||||
|
User user = userService.findByIdentifier(identifier).orElseThrow();
|
||||||
|
return subscriptionService
|
||||||
|
.getSubscribers(user.getUsername())
|
||||||
|
.stream()
|
||||||
|
.map(userMapper::toDto)
|
||||||
|
.collect(java.util.stream.Collectors.toList());
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/{identifier}/all")
|
@GetMapping("/admins")
|
||||||
public ResponseEntity<UserAggregateDto> userAggregate(@PathVariable("identifier") String identifier,
|
@Operation(summary = "Admin users", description = "List administrator users")
|
||||||
@RequestParam(value = "postsLimit", required = false) Integer postsLimit,
|
@ApiResponse(
|
||||||
@RequestParam(value = "repliesLimit", required = false) Integer repliesLimit,
|
responseCode = "200",
|
||||||
Authentication auth) {
|
description = "Admin users",
|
||||||
User user = userService.findByIdentifier(identifier).orElseThrow();
|
content = @Content(array = @ArraySchema(schema = @Schema(implementation = UserDto.class)))
|
||||||
int pLimit = postsLimit != null ? postsLimit : defaultPostsLimit;
|
)
|
||||||
int rLimit = repliesLimit != null ? repliesLimit : defaultRepliesLimit;
|
public java.util.List<UserDto> admins() {
|
||||||
java.util.List<PostMetaDto> posts = postService.getRecentPostsByUser(user.getUsername(), pLimit).stream()
|
return userService
|
||||||
.map(userMapper::toMetaDto)
|
.getAdmins()
|
||||||
.collect(java.util.stream.Collectors.toList());
|
.stream()
|
||||||
java.util.List<CommentInfoDto> replies = commentService.getRecentCommentsByUser(user.getUsername(), rLimit).stream()
|
.map(userMapper::toDto)
|
||||||
.map(userMapper::toCommentInfoDto)
|
.collect(java.util.stream.Collectors.toList());
|
||||||
.collect(java.util.stream.Collectors.toList());
|
}
|
||||||
UserAggregateDto dto = new UserAggregateDto();
|
|
||||||
dto.setUser(userMapper.toDto(user, auth));
|
@GetMapping("/{identifier}/all")
|
||||||
dto.setPosts(posts);
|
@Operation(summary = "User aggregate", description = "Get aggregate information for user")
|
||||||
dto.setReplies(replies);
|
@ApiResponse(
|
||||||
return ResponseEntity.ok(dto);
|
responseCode = "200",
|
||||||
}
|
description = "User aggregate",
|
||||||
|
content = @Content(schema = @Schema(implementation = UserAggregateDto.class))
|
||||||
|
)
|
||||||
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,21 @@
|
|||||||
package com.openisle.dto;
|
package com.openisle.dto;
|
||||||
|
|
||||||
import com.openisle.model.ActivityType;
|
import com.openisle.model.ActivityType;
|
||||||
import lombok.Data;
|
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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 String title;
|
private Long id;
|
||||||
private String icon;
|
private String title;
|
||||||
private String content;
|
private String icon;
|
||||||
private LocalDateTime startTime;
|
private String content;
|
||||||
private LocalDateTime endTime;
|
private LocalDateTime startTime;
|
||||||
private ActivityType type;
|
private LocalDateTime endTime;
|
||||||
private boolean ended;
|
private ActivityType type;
|
||||||
|
private boolean ended;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
package com.openisle.dto;
|
package com.openisle.dto;
|
||||||
|
|
||||||
import lombok.Data;
|
|
||||||
import com.openisle.model.MedalType;
|
import com.openisle.model.MedalType;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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 String username;
|
|
||||||
private String avatar;
|
|
||||||
private MedalType displayMedal;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
private Long id;
|
||||||
|
private String username;
|
||||||
|
private String avatar;
|
||||||
|
private MedalType displayMedal;
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,11 +7,11 @@ import lombok.Data;
|
|||||||
*/
|
*/
|
||||||
@Data
|
@Data
|
||||||
public class CategoryDto {
|
public class CategoryDto {
|
||||||
private Long id;
|
|
||||||
private String name;
|
|
||||||
private String description;
|
|
||||||
private String icon;
|
|
||||||
private String smallIcon;
|
|
||||||
private Long count;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
private Long id;
|
||||||
|
private String name;
|
||||||
|
private String description;
|
||||||
|
private String icon;
|
||||||
|
private String smallIcon;
|
||||||
|
private Long count;
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,8 +5,9 @@ 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 description;
|
private String name;
|
||||||
private String icon;
|
private String description;
|
||||||
private String smallIcon;
|
private String icon;
|
||||||
|
private String smallIcon;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,12 +6,13 @@ import lombok.Setter;
|
|||||||
@Getter
|
@Getter
|
||||||
@Setter
|
@Setter
|
||||||
public class ChannelDto {
|
public class ChannelDto {
|
||||||
private Long id;
|
|
||||||
private String name;
|
private Long id;
|
||||||
private String description;
|
private String name;
|
||||||
private String avatar;
|
private String description;
|
||||||
private MessageDto lastMessage;
|
private String avatar;
|
||||||
private long memberCount;
|
private MessageDto lastMessage;
|
||||||
private boolean joined;
|
private long memberCount;
|
||||||
private long unreadCount;
|
private boolean joined;
|
||||||
|
private long unreadCount;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,22 @@
|
|||||||
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 String content;
|
|
||||||
private LocalDateTime createdAt;
|
|
||||||
private LocalDateTime pinnedAt;
|
|
||||||
private AuthorDto author;
|
|
||||||
private List<CommentDto> replies;
|
|
||||||
private List<ReactionDto> reactions;
|
|
||||||
private int reward;
|
|
||||||
private int pointReward;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
private Long id;
|
||||||
|
private String content;
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
private LocalDateTime pinnedAt;
|
||||||
|
private AuthorDto author;
|
||||||
|
private List<CommentDto> replies;
|
||||||
|
private List<ReactionDto> reactions;
|
||||||
|
private int reward;
|
||||||
|
private int pointReward;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
package com.openisle.dto;
|
package com.openisle.dto;
|
||||||
|
|
||||||
import lombok.Data;
|
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
/** 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 String content;
|
private Long id;
|
||||||
private LocalDateTime createdAt;
|
private String content;
|
||||||
private PostMetaDto post;
|
private LocalDateTime createdAt;
|
||||||
private ParentCommentDto parentComment;
|
private PostMetaDto post;
|
||||||
|
private ParentCommentDto parentComment;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ 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 targetCommentCount;
|
private long currentCommentCount;
|
||||||
|
private long targetCommentCount;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ 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 captcha;
|
private String content;
|
||||||
|
private String captcha;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,8 +8,9 @@ import lombok.Data;
|
|||||||
/** DTO for site configuration. */
|
/** DTO for site configuration. */
|
||||||
@Data
|
@Data
|
||||||
public class ConfigDto {
|
public class ConfigDto {
|
||||||
private PublishMode publishMode;
|
|
||||||
private PasswordStrength passwordStrength;
|
private PublishMode publishMode;
|
||||||
private Integer aiFormatLimit;
|
private PasswordStrength passwordStrength;
|
||||||
private RegisterMode registerMode;
|
private Integer aiFormatLimit;
|
||||||
|
private RegisterMode registerMode;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 targetContributionLines;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
private long currentContributionLines;
|
||||||
|
private long targetContributionLines;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 String name;
|
private Long id;
|
||||||
private boolean channel;
|
private String name;
|
||||||
private String avatar;
|
private boolean channel;
|
||||||
private List<UserSummaryDto> participants;
|
private String avatar;
|
||||||
private Page<MessageDto> messages;
|
private List<UserSummaryDto> participants;
|
||||||
}
|
private Page<MessageDto> messages;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 String name;
|
private Long id;
|
||||||
private boolean channel;
|
private String name;
|
||||||
private String avatar;
|
private boolean channel;
|
||||||
private MessageDto lastMessage;
|
private String avatar;
|
||||||
private List<UserSummaryDto> participants;
|
private MessageDto lastMessage;
|
||||||
private LocalDateTime createdAt;
|
private List<UserSummaryDto> participants;
|
||||||
private long unreadCount;
|
private LocalDateTime createdAt;
|
||||||
}
|
private long unreadCount;
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,5 +4,6 @@ import lombok.Data;
|
|||||||
|
|
||||||
@Data
|
@Data
|
||||||
public class CreateConversationRequest {
|
public class CreateConversationRequest {
|
||||||
private Long recipientId;
|
|
||||||
}
|
private Long recipientId;
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,5 +8,6 @@ import lombok.NoArgsConstructor;
|
|||||||
@AllArgsConstructor
|
@AllArgsConstructor
|
||||||
@NoArgsConstructor
|
@NoArgsConstructor
|
||||||
public class CreateConversationResponse {
|
public class CreateConversationResponse {
|
||||||
private Long conversationId;
|
|
||||||
}
|
private Long conversationId;
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ 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 redirectUri;
|
private String code;
|
||||||
private String inviteToken;
|
private String redirectUri;
|
||||||
|
private String inviteToken;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
package com.openisle.dto;
|
package com.openisle.dto;
|
||||||
|
|
||||||
import lombok.Data;
|
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
/** DTO representing a saved draft. */
|
/** DTO representing a saved draft. */
|
||||||
@Data
|
@Data
|
||||||
public class DraftDto {
|
public class DraftDto {
|
||||||
private Long id;
|
|
||||||
private String title;
|
private Long id;
|
||||||
private String content;
|
private String title;
|
||||||
private Long categoryId;
|
private String content;
|
||||||
private List<Long> tagIds;
|
private Long categoryId;
|
||||||
|
private List<Long> tagIds;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
package com.openisle.dto;
|
package com.openisle.dto;
|
||||||
|
|
||||||
import lombok.Data;
|
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
/** 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 content;
|
private String title;
|
||||||
private Long categoryId;
|
private String content;
|
||||||
private List<Long> tagIds;
|
private Long categoryId;
|
||||||
|
private List<Long> tagIds;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 targetFeaturedCount;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
private long currentFeaturedCount;
|
||||||
|
private long targetFeaturedCount;
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,5 +5,6 @@ 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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ 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 redirectUri;
|
private String code;
|
||||||
private String inviteToken;
|
private String redirectUri;
|
||||||
|
private String inviteToken;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ 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 inviteToken;
|
private String idToken;
|
||||||
|
private String inviteToken;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import lombok.Data;
|
|||||||
/** Request to login. */
|
/** Request to login. */
|
||||||
@Data
|
@Data
|
||||||
public class LoginRequest {
|
public class LoginRequest {
|
||||||
private String username;
|
|
||||||
private String password;
|
private String username;
|
||||||
private String captcha;
|
private String password;
|
||||||
|
private String captcha;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,19 @@
|
|||||||
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 prizeIcon;
|
private String prizeDescription;
|
||||||
private int prizeCount;
|
private String prizeIcon;
|
||||||
private int pointCost;
|
private int prizeCount;
|
||||||
private LocalDateTime startTime;
|
private int pointCost;
|
||||||
private LocalDateTime endTime;
|
private LocalDateTime startTime;
|
||||||
private List<AuthorDto> participants;
|
private LocalDateTime endTime;
|
||||||
private List<AuthorDto> winners;
|
private List<AuthorDto> participants;
|
||||||
|
private List<AuthorDto> winners;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ 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 reason;
|
private String token;
|
||||||
|
private String reason;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,10 +5,11 @@ import lombok.Data;
|
|||||||
|
|
||||||
@Data
|
@Data
|
||||||
public class MedalDto {
|
public class MedalDto {
|
||||||
private String icon;
|
|
||||||
private String title;
|
private String icon;
|
||||||
private String description;
|
private String title;
|
||||||
private MedalType type;
|
private String description;
|
||||||
private boolean completed;
|
private MedalType type;
|
||||||
private boolean selected;
|
private boolean completed;
|
||||||
|
private boolean selected;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,5 +5,6 @@ import lombok.Data;
|
|||||||
|
|
||||||
@Data
|
@Data
|
||||||
public class MedalSelectRequest {
|
public class MedalSelectRequest {
|
||||||
private MedalType type;
|
|
||||||
|
private MedalType type;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +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 lombok.Data;
|
||||||
|
|
||||||
@Data
|
@Data
|
||||||
public class MessageDto {
|
public class MessageDto {
|
||||||
private Long id;
|
|
||||||
private String content;
|
private Long id;
|
||||||
private UserSummaryDto sender;
|
private String content;
|
||||||
private Long conversationId;
|
private UserSummaryDto sender;
|
||||||
private LocalDateTime createdAt;
|
private Long conversationId;
|
||||||
private MessageDto replyTo;
|
private LocalDateTime createdAt;
|
||||||
private List<ReactionDto> reactions;
|
private MessageDto replyTo;
|
||||||
}
|
private List<ReactionDto> reactions;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 Object payload;
|
private String targetUsername;
|
||||||
}
|
private Object payload;
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ 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 boolean ended;
|
private long redeemCount;
|
||||||
|
private boolean ended;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,5 +5,6 @@ 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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 lombok.Data;
|
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
/** DTO representing a user notification. */
|
/** DTO representing a user notification. */
|
||||||
@Data
|
@Data
|
||||||
public class NotificationDto {
|
public class NotificationDto {
|
||||||
private Long id;
|
|
||||||
private NotificationType type;
|
private Long id;
|
||||||
private PostSummaryDto post;
|
private NotificationType type;
|
||||||
private CommentDto comment;
|
private PostSummaryDto post;
|
||||||
private CommentDto parentComment;
|
private CommentDto comment;
|
||||||
private AuthorDto fromUser;
|
private CommentDto parentComment;
|
||||||
private ReactionType reactionType;
|
private AuthorDto fromUser;
|
||||||
private String content;
|
private ReactionType reactionType;
|
||||||
private Boolean approved;
|
private String content;
|
||||||
private boolean read;
|
private Boolean approved;
|
||||||
private LocalDateTime createdAt;
|
private boolean read;
|
||||||
|
private LocalDateTime createdAt;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
package com.openisle.dto;
|
package com.openisle.dto;
|
||||||
|
|
||||||
import lombok.Data;
|
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
/** 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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import lombok.Data;
|
|||||||
/** User notification preference DTO. */
|
/** User notification preference DTO. */
|
||||||
@Data
|
@Data
|
||||||
public class NotificationPreferenceDto {
|
public class NotificationPreferenceDto {
|
||||||
private NotificationType type;
|
|
||||||
private boolean enabled;
|
private NotificationType type;
|
||||||
|
private boolean enabled;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ 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 boolean enabled;
|
private NotificationType type;
|
||||||
|
private boolean enabled;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,5 +5,6 @@ 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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ 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 String author;
|
private Long id;
|
||||||
private String content;
|
private String author;
|
||||||
|
private String content;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,5 +6,6 @@ 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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,9 @@ import lombok.Data;
|
|||||||
/** Point mall good info. */
|
/** Point mall good info. */
|
||||||
@Data
|
@Data
|
||||||
public class PointGoodDto {
|
public class PointGoodDto {
|
||||||
private Long id;
|
|
||||||
private String name;
|
private Long id;
|
||||||
private int cost;
|
private String name;
|
||||||
private String image;
|
private int cost;
|
||||||
|
private String image;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 PointHistoryType type;
|
private Long id;
|
||||||
private int amount;
|
private PointHistoryType type;
|
||||||
private int balance;
|
private int amount;
|
||||||
private Long postId;
|
private int balance;
|
||||||
private String postTitle;
|
private Long postId;
|
||||||
private Long commentId;
|
private String postTitle;
|
||||||
private String commentContent;
|
private Long commentId;
|
||||||
private Long fromUserId;
|
private String commentContent;
|
||||||
private String fromUserName;
|
private Long fromUserId;
|
||||||
private LocalDateTime createdAt;
|
private String fromUserName;
|
||||||
|
private LocalDateTime createdAt;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ 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 String contact;
|
private Long goodId;
|
||||||
|
private String contact;
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user