mirror of
https://github.com/nagisa77/OpenIsle.git
synced 2026-03-01 01:21:04 +08:00
Compare commits
167 Commits
feature/ui
...
e5b386cdc2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e5b386cdc2 | ||
|
|
179699dd66 | ||
|
|
ef39b5fedf | ||
|
|
e13ee1ca46 | ||
|
|
09f1435e33 | ||
|
|
7e7cebbbe7 | ||
|
|
5c1031c57c | ||
|
|
e6730b2882 | ||
|
|
21b1c3317a | ||
|
|
72a915af2e | ||
|
|
f000011994 | ||
|
|
d48c9dc27a | ||
|
|
94f955e50f | ||
|
|
bf94707914 | ||
|
|
209f0ef1f8 | ||
|
|
e2d900759a | ||
|
|
40a233a66b | ||
|
|
b8c0b1c6f8 | ||
|
|
b37df67d31 | ||
|
|
90865b02c9 | ||
|
|
f8c0335982 | ||
|
|
20b3d89a00 | ||
|
|
ddae56d483 | ||
|
|
265fce4153 | ||
|
|
cc0880e2c1 | ||
|
|
5fe3eec815 | ||
|
|
f0feb7a45c | ||
|
|
784057207f | ||
|
|
bed72662b5 | ||
|
|
895dba495b | ||
|
|
32dc6bfaf9 | ||
|
|
4766250577 | ||
|
|
13baffa9f1 | ||
|
|
d0d7580ac3 | ||
|
|
fd4e651a49 | ||
|
|
58317687d7 | ||
|
|
006e46f4ef | ||
|
|
2c27766544 | ||
|
|
c305992223 | ||
|
|
babd2c6549 | ||
|
|
d98c3644a6 | ||
|
|
dbb63a4039 | ||
|
|
49aeff3a83 | ||
|
|
512e5623e1 | ||
|
|
8db928b9a8 | ||
|
|
46f6ccb3a8 | ||
|
|
87dcebf052 | ||
|
|
0ad4f4feff | ||
|
|
a227ac77fb | ||
|
|
ef53a40ed5 | ||
|
|
7d8c9b68bd | ||
|
|
dbc3d54fa1 | ||
|
|
4c0b9e744a | ||
|
|
4b4d1a2a86 | ||
|
|
6990aa93ed | ||
|
|
421b8b6b4f | ||
|
|
e55acc6dc4 | ||
|
|
33ce56aa31 | ||
|
|
339c39c6ca | ||
|
|
389961c922 | ||
|
|
6db53274fb | ||
|
|
a413c0be35 | ||
|
|
06ecd39c8b | ||
|
|
f0ba00b7e8 | ||
|
|
092c4c36c2 | ||
|
|
db13f8145d | ||
|
|
3be396976a | ||
|
|
3fbaa332fc | ||
|
|
4e6cb59753 | ||
|
|
1c6c17e577 | ||
|
|
c968efa42a | ||
|
|
0cd5ded39b | ||
|
|
7a2cf829c7 | ||
|
|
12329b43d1 | ||
|
|
1a45603e0f | ||
|
|
4a73503399 | ||
|
|
83bf8c1d5e | ||
|
|
34e206f05d | ||
|
|
dc349923e9 | ||
|
|
0d44c9a823 | ||
|
|
02645af321 | ||
|
|
c3a175f13f | ||
|
|
0821d447f7 | ||
|
|
257794ca00 | ||
|
|
6a527de3eb | ||
|
|
2313f90eb3 | ||
|
|
7fde984e7d | ||
|
|
fc41e605e4 | ||
|
|
042e5fdbe6 | ||
|
|
629442bff6 | ||
|
|
7798910be0 | ||
|
|
6f036eb4fe | ||
|
|
56fc05cb3c | ||
|
|
a55a15659b | ||
|
|
ccf6e0c7ce | ||
|
|
87677f5968 | ||
|
|
fd93a2dc61 | ||
|
|
80f862a226 | ||
|
|
26bb85f4d4 | ||
|
|
398b4b482f | ||
|
|
2cfb302981 | ||
|
|
e75bd76b71 | ||
|
|
99c3ac1837 | ||
|
|
749ab560ff | ||
|
|
541ad4d149 | ||
|
|
03eb027ea4 | ||
|
|
4194b2be91 | ||
|
|
9dadaad5ba | ||
|
|
d4b3400c5f | ||
|
|
e585100625 | ||
|
|
e94471b53e | ||
|
|
997dacdbe6 | ||
|
|
c01349a436 | ||
|
|
4cf48f9157 | ||
|
|
796afbe612 | ||
|
|
dca14390ca | ||
|
|
39875acd35 | ||
|
|
62edc75735 | ||
|
|
26ca9fc916 | ||
|
|
cad70c23b3 | ||
|
|
016276dbc3 | ||
|
|
bd2d6e7485 | ||
|
|
df59a9fd4b | ||
|
|
2e70a3d273 | ||
|
|
3dc6935d19 | ||
|
|
779bb2db78 | ||
|
|
b3b0b194a3 | ||
|
|
e21b2f42d2 | ||
|
|
05a5acee7e | ||
|
|
755982098b | ||
|
|
af24263c0a | ||
|
|
8fd268bd11 | ||
|
|
a24bd81942 | ||
|
|
8a008a090a | ||
|
|
5dfb69e636 | ||
|
|
499069573e | ||
|
|
636912941a | ||
|
|
bdcc1488b9 | ||
|
|
d33bd233af | ||
|
|
efe4b97d83 | ||
|
|
8a256e167d | ||
|
|
9c5a49a47f | ||
|
|
2271bbbd1d | ||
|
|
d6470e04fc | ||
|
|
4db35a4531 | ||
|
|
1906ffd8aa | ||
|
|
426884385f | ||
|
|
8193c92c91 | ||
|
|
90649b422d | ||
|
|
67efb64ccc | ||
|
|
23d8eafc08 | ||
|
|
d1cc16e31e | ||
|
|
0f1c45b155 | ||
|
|
8ed11df99c | ||
|
|
458b125834 | ||
|
|
971a3d36c6 | ||
|
|
e5d66d73cb | ||
|
|
a9608cc706 | ||
|
|
232f40151b | ||
|
|
3b3f99754d | ||
|
|
e14566ee66 | ||
|
|
892312c6d4 | ||
|
|
dfb31771ff | ||
|
|
bf7df629cc | ||
|
|
c9854e1840 | ||
|
|
3da5d24488 | ||
|
|
76962d6d1c |
@@ -2,6 +2,7 @@
|
|||||||
SERVER_PORT=8080
|
SERVER_PORT=8080
|
||||||
FRONTEND_PORT=3000
|
FRONTEND_PORT=3000
|
||||||
WEBSOCKET_PORT=8082
|
WEBSOCKET_PORT=8082
|
||||||
|
OPENISLE_MCP_PORT=8085
|
||||||
MYSQL_PORT=3306
|
MYSQL_PORT=3306
|
||||||
REDIS_PORT=6379
|
REDIS_PORT=6379
|
||||||
RABBITMQ_PORT=5672
|
RABBITMQ_PORT=5672
|
||||||
|
|||||||
30
.github/workflows/coffee-bot.yml
vendored
Normal file
30
.github/workflows/coffee-bot.yml
vendored
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
name: Coffee Bot
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: "0 23 * * 0-4"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
run-coffee-bot:
|
||||||
|
environment: Bots
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "20"
|
||||||
|
cache: "npm"
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm install --no-save @openai/agents tsx typescript
|
||||||
|
|
||||||
|
- name: Run coffee bot
|
||||||
|
env:
|
||||||
|
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||||
|
OPENISLE_TOKEN: ${{ secrets.OPENISLE_TOKEN }}
|
||||||
|
APIFY_API_TOKEN: ${{ secrets.APIFY_API_TOKEN }}
|
||||||
|
run: npx tsx bots/instance/coffee_bot.ts
|
||||||
30
.github/workflows/news-bot.yml
vendored
Normal file
30
.github/workflows/news-bot.yml
vendored
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
name: Daily News Bot
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: "0 22 * * 0-4"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
run-daily-news-bot:
|
||||||
|
environment: Bots
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "20"
|
||||||
|
cache: "npm"
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm install --no-save @openai/agents tsx typescript
|
||||||
|
|
||||||
|
- name: Run daily news bot
|
||||||
|
env:
|
||||||
|
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||||
|
OPENISLE_TOKEN: ${{ secrets.OPENISLE_TOKEN }}
|
||||||
|
APIFY_API_TOKEN: ${{ secrets.APIFY_API_TOKEN }}
|
||||||
|
run: npx tsx bots/instance/daily_news_bot.ts
|
||||||
30
.github/workflows/open_source_reply_bot.yml
vendored
Normal file
30
.github/workflows/open_source_reply_bot.yml
vendored
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
name: Open Source Reply Bot
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: "*/30 * * * *"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
run-open-source-reply-bot:
|
||||||
|
environment: Bots
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "20"
|
||||||
|
cache: "npm"
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm install --no-save @openai/agents tsx typescript
|
||||||
|
|
||||||
|
- name: Run open source reply bot
|
||||||
|
env:
|
||||||
|
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||||
|
OPENISLE_TOKEN: ${{ secrets.OPENISLE_TOKEN_BOT_1 }}
|
||||||
|
APIFY_API_TOKEN: ${{ secrets.APIFY_API_TOKEN }}
|
||||||
|
run: npx tsx bots/instance/open_source_reply_bot.ts
|
||||||
30
.github/workflows/reply-bots.yml
vendored
Normal file
30
.github/workflows/reply-bots.yml
vendored
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
name: Reply Bots
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: "*/30 * * * *"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
run-reply-bot:
|
||||||
|
environment: Bots
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "20"
|
||||||
|
cache: "npm"
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm install --no-save @openai/agents tsx typescript
|
||||||
|
|
||||||
|
- name: Run reply bot
|
||||||
|
env:
|
||||||
|
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||||
|
OPENISLE_TOKEN: ${{ secrets.OPENISLE_TOKEN }}
|
||||||
|
APIFY_API_TOKEN: ${{ secrets.APIFY_API_TOKEN }}
|
||||||
|
run: npx tsx bots/instance/reply_bot.ts
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
- [前置工作](#前置工作)
|
- [前置工作](#前置工作)
|
||||||
- [前端极速调试(Docker 全量环境)](#前端极速调试docker-全量环境)
|
- [前端极速调试(Docker 全量环境)](#前端极速调试docker-全量环境)
|
||||||
|
- [dev 与 dev_local_backend 巡航指南](#dev-dev_local_backend-guide)
|
||||||
- [启动后端服务](#启动后端服务)
|
- [启动后端服务](#启动后端服务)
|
||||||
- [本地 IDEA](#本地-idea)
|
- [本地 IDEA](#本地-idea)
|
||||||
- [配置环境变量](#配置环境变量)
|
- [配置环境变量](#配置环境变量)
|
||||||
@@ -39,13 +40,6 @@ cd OpenIsle
|
|||||||
```
|
```
|
||||||
`.env.example` 是模板,可在 `.env` 中按需覆盖如端口、密钥等配置。确保 `NUXT_PUBLIC_API_BASE_URL`、`NUXT_PUBLIC_WEBSOCKET_URL` 等仍指向 `localhost`,方便前端直接访问容器映射端口。
|
`.env.example` 是模板,可在 `.env` 中按需覆盖如端口、密钥等配置。确保 `NUXT_PUBLIC_API_BASE_URL`、`NUXT_PUBLIC_WEBSOCKET_URL` 等仍指向 `localhost`,方便前端直接访问容器映射端口。
|
||||||
2. 启动 Dev Profile:
|
2. 启动 Dev Profile:
|
||||||
```shell
|
|
||||||
docker compose \
|
|
||||||
-f docker/docker-compose.yaml \
|
|
||||||
--env-file .env \
|
|
||||||
--profile dev build
|
|
||||||
```
|
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
docker compose \
|
docker compose \
|
||||||
-f docker/docker-compose.yaml \
|
-f docker/docker-compose.yaml \
|
||||||
@@ -63,6 +57,9 @@ cd OpenIsle
|
|||||||
--profile dev up -d --force-recreate
|
--profile dev up -d --force-recreate
|
||||||
```
|
```
|
||||||
|
|
||||||
|
数据初始化sql会创建几个帐户供大家测试使用
|
||||||
|
> username:admin/user1/user2 password:123456
|
||||||
|
|
||||||
3. 查看服务状态:
|
3. 查看服务状态:
|
||||||
```shell
|
```shell
|
||||||
docker compose -f docker/docker-compose.yaml --env-file .env ps
|
docker compose -f docker/docker-compose.yaml --env-file .env ps
|
||||||
@@ -81,6 +78,41 @@ cd OpenIsle
|
|||||||
|
|
||||||
如需自定义 Node 依赖缓存、数据库持久化等,可参考 `docker/docker-compose.yaml` 中各卷的定义进行调整。
|
如需自定义 Node 依赖缓存、数据库持久化等,可参考 `docker/docker-compose.yaml` 中各卷的定义进行调整。
|
||||||
|
|
||||||
|
<a id="dev-dev_local_backend-guide"></a>
|
||||||
|
|
||||||
|
### 🧭 dev 与 dev_local_backend 巡航指南
|
||||||
|
|
||||||
|
在需要本地 IDE 启动后端、而容器只提供 MySQL、Redis、RabbitMQ、OpenSearch 等依赖时,可切换到 `dev_local_backend` Profile:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose \
|
||||||
|
-f docker/docker-compose.yaml \
|
||||||
|
--env-file .env \
|
||||||
|
--profile dev_local_backend up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
> [!TIP]
|
||||||
|
> 该 Profile 不会启动 Docker 内的 Spring Boot 服务,`frontend_dev_local_backend` 会通过 `host.docker.internal` 访问你本机正在运行的后端。非常适合用 IDEA/VS Code 调试 Java 服务的场景!
|
||||||
|
|
||||||
|
| 想要的体验 | 推荐 Profile | 会启动的关键容器 | 备注 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 🚀 一键启动前后端 | `dev` | `springboot`、`frontend_dev`、`mysql`… | 纯容器内跑全链路,省心省力 |
|
||||||
|
| 🛠️ IDE 启动后端 + 容器托管依赖 | `dev_local_backend` | `frontend_dev_local_backend`、`mysql`、`redis`… | 记得本地后端监听 `8080`/`8082` 等端口 |
|
||||||
|
|
||||||
|
切换 Profile 时,请先停掉当前组合再启动另一组,避免端口占用或容器命名冲突:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker/docker-compose.yaml --env-file .env --profile dev down
|
||||||
|
# 或者
|
||||||
|
docker compose -f docker/docker-compose.yaml --env-file .env --profile dev_local_backend down
|
||||||
|
```
|
||||||
|
|
||||||
|
常见小贴士:
|
||||||
|
|
||||||
|
- 🧹 需要彻底清理依赖时,别忘了追加 `-v` 清除持久化数据卷。
|
||||||
|
- 🪄 仅切换 Profile 时通常无需重新 `build`,除非你更新了镜像依赖。
|
||||||
|
- 🧪 如需确认前端容器访问的是本机后端,可在 IDE 控制台查看请求日志或执行 `curl http://localhost:8080/actuator/health` 进行自检。
|
||||||
|
|
||||||
## 启动后端服务
|
## 启动后端服务
|
||||||
|
|
||||||
启动后端服务有多种方式,选择一种即可。
|
启动后端服务有多种方式,选择一种即可。
|
||||||
@@ -110,6 +142,17 @@ IDEA 打开 `backend/` 文件夹。
|
|||||||
LOG_LEVEL=DEBUG
|
LOG_LEVEL=DEBUG
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
> 如果你通过 `dev_local_backend` Profile 启动了数据库/缓存等依赖,却让后端由 IDEA 在宿主机运行,请务必将 `open-isle.env`(或 IDEA 的环境变量面板)中的主机名改成 `localhost`:
|
||||||
|
>
|
||||||
|
> ```ini
|
||||||
|
> MYSQL_HOST=localhost
|
||||||
|
> REDIS_HOST=localhost
|
||||||
|
> RABBITMQ_HOST=localhost
|
||||||
|
> ```
|
||||||
|
>
|
||||||
|
> 对应的容器端口均已映射到宿主机,无需额外配置。若仍保留默认的 `mysql`、`redis`、`rabbitmq`,IDEA 将尝试解析容器网络内的别名而导致连接失败。
|
||||||
|
|
||||||
也可以修改 `src/main/resources/application.properties`,但该文件会被 Git 追踪,通常不推荐。
|
也可以修改 `src/main/resources/application.properties`,但该文件会被 Git 追踪,通常不推荐。
|
||||||
|
|
||||||

|

|
||||||
|
|||||||
@@ -26,7 +26,7 @@ OpenIsle 是一个使用 Spring Boot 和 Vue 3 构建的全栈开源社区平台
|
|||||||
- 集成 OpenAI 提供的 Markdown 格式化功能
|
- 集成 OpenAI 提供的 Markdown 格式化功能
|
||||||
- 通过环境变量可调整密码强度、登录方式、保护码等多种配置
|
- 通过环境变量可调整密码强度、登录方式、保护码等多种配置
|
||||||
- 支持图片上传,默认使用腾讯云 COS 扩展
|
- 支持图片上传,默认使用腾讯云 COS 扩展
|
||||||
- 默认头像使用 DiceBear Avatars,可通过 `AVATAR_STYLE` 和 `AVATAR_SIZE` 环境变量自定义主题和大小
|
- Bot 集成,可在平台内快速连接自定义机器人,并通过 Telegram 的 BotFather 创建和管理消息机器人,拓展社区互动渠道
|
||||||
- 浏览器推送通知,离开网站也能及时收到提醒
|
- 浏览器推送通知,离开网站也能及时收到提醒
|
||||||
|
|
||||||
## 🌟 项目优势
|
## 🌟 项目优势
|
||||||
@@ -41,7 +41,7 @@ OpenIsle 是一个使用 Spring Boot 和 Vue 3 构建的全栈开源社区平台
|
|||||||
|
|
||||||
## 🏘️ 社区
|
## 🏘️ 社区
|
||||||
|
|
||||||
欢迎彼此交流和使用 OpenIsle,项目以开源方式提供,想了解更多可访问:<https://github.com/nagisa77/OpenIsle>
|
- 欢迎彼此交流和使用 OpenIsle,项目以开源方式提供;如果遇到问题请到 GitHub 的 Issues 页面反馈,想发起话题讨论也可以前往源站 <https://www.open-isle.com>,这里提供更完整的社区板块与互动体验。
|
||||||
|
|
||||||
## 📋 授权
|
## 📋 授权
|
||||||
|
|
||||||
|
|||||||
176
SECURITY.md
Normal file
176
SECURITY.md
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
# Security Policy
|
||||||
|
|
||||||
|
## Supported Versions
|
||||||
|
|
||||||
|
We take the security of OpenIsle seriously. The following versions are currently being supported with security updates:
|
||||||
|
|
||||||
|
| Version | Supported |
|
||||||
|
| ------- | ------------------ |
|
||||||
|
| 0.0.x | :white_check_mark: |
|
||||||
|
|
||||||
|
## Reporting a Vulnerability
|
||||||
|
|
||||||
|
We appreciate your efforts to responsibly disclose your findings and will make every effort to acknowledge your contributions.
|
||||||
|
|
||||||
|
### How to Report a Security Vulnerability
|
||||||
|
|
||||||
|
**Please do not report security vulnerabilities through public GitHub issues.**
|
||||||
|
|
||||||
|
Instead, please report them via one of the following methods:
|
||||||
|
|
||||||
|
1. **Email**: Send a detailed report to the project maintainer (check the repository for contact information)
|
||||||
|
2. **GitHub Security Advisory**: Use GitHub's private vulnerability reporting feature at https://github.com/nagisa77/OpenIsle/security/advisories/new
|
||||||
|
|
||||||
|
### What to Include in Your Report
|
||||||
|
|
||||||
|
To help us better understand the nature and scope of the issue, please include as much of the following information as possible:
|
||||||
|
|
||||||
|
- Type of issue (e.g., SQL injection, XSS, authentication bypass, etc.)
|
||||||
|
- Full paths of source file(s) related to the manifestation of the issue
|
||||||
|
- The location of the affected source code (tag/branch/commit or direct URL)
|
||||||
|
- Any special configuration required to reproduce the issue
|
||||||
|
- Step-by-step instructions to reproduce the issue
|
||||||
|
- Proof-of-concept or exploit code (if possible)
|
||||||
|
- Impact of the issue, including how an attacker might exploit it
|
||||||
|
|
||||||
|
### Response Timeline
|
||||||
|
|
||||||
|
- **Initial Response**: We will acknowledge your report within 48 hours
|
||||||
|
- **Status Updates**: We will provide status updates at least every 5 business days
|
||||||
|
- **Resolution**: We aim to resolve critical vulnerabilities within 30 days of disclosure
|
||||||
|
|
||||||
|
### What to Expect
|
||||||
|
|
||||||
|
After you submit a report:
|
||||||
|
|
||||||
|
1. We will confirm receipt of your vulnerability report and may ask for additional information
|
||||||
|
2. We will investigate the issue and determine its impact and severity
|
||||||
|
3. We will work on a fix and coordinate disclosure timing with you
|
||||||
|
4. Once the fix is ready, we will release it and publicly acknowledge your contribution (unless you prefer to remain anonymous)
|
||||||
|
|
||||||
|
## Security Considerations for Deployment
|
||||||
|
|
||||||
|
### Authentication & Authorization
|
||||||
|
|
||||||
|
- **JWT Tokens**: Ensure `JWT_SECRET` environment variable is set to a strong, random value (minimum 256 bits)
|
||||||
|
- **OAuth Credentials**: Keep OAuth client secrets secure and never commit them to version control
|
||||||
|
- **Session Management**: Configure appropriate session timeout values
|
||||||
|
|
||||||
|
### Database Security
|
||||||
|
|
||||||
|
- Use strong database passwords
|
||||||
|
- Never expose database ports publicly
|
||||||
|
- Use database connection encryption when available
|
||||||
|
- Regularly backup your database
|
||||||
|
|
||||||
|
### API Security
|
||||||
|
|
||||||
|
- Enable rate limiting to prevent abuse
|
||||||
|
- Validate all user inputs on both client and server side
|
||||||
|
- Use HTTPS in production environments
|
||||||
|
- Configure CORS properly to restrict origins
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
The following sensitive environment variables should be kept secure:
|
||||||
|
|
||||||
|
- `JWT_SECRET` - JWT signing key
|
||||||
|
- `GOOGLE_CLIENT_SECRET` - Google OAuth credentials
|
||||||
|
- `GITHUB_CLIENT_SECRET` - GitHub OAuth credentials
|
||||||
|
- `DISCORD_CLIENT_SECRET` - Discord OAuth credentials
|
||||||
|
- `TWITTER_CLIENT_SECRET` - Twitter OAuth credentials
|
||||||
|
- `WEBPUSH_PRIVATE_KEY` - Web push notification private key
|
||||||
|
- Database connection strings and credentials
|
||||||
|
- Cloud storage credentials (Tencent COS)
|
||||||
|
|
||||||
|
**Never commit these values to version control or expose them in logs.**
|
||||||
|
|
||||||
|
### File Upload Security
|
||||||
|
|
||||||
|
- Validate file types and sizes
|
||||||
|
- Scan uploaded files for malware
|
||||||
|
- Store uploaded files outside the web root
|
||||||
|
- Use cloud storage with proper access controls
|
||||||
|
|
||||||
|
### Password Security
|
||||||
|
|
||||||
|
- Configure password strength requirements via environment variables
|
||||||
|
- Use bcrypt or similar strong hashing algorithms (already implemented in Spring Security)
|
||||||
|
- Implement account lockout after failed login attempts
|
||||||
|
|
||||||
|
### Web Push Notifications
|
||||||
|
|
||||||
|
- Keep `WEBPUSH_PRIVATE_KEY` secret and secure
|
||||||
|
- Only send notifications to users who have explicitly opted in
|
||||||
|
- Validate notification payloads
|
||||||
|
|
||||||
|
### Dependency Management
|
||||||
|
|
||||||
|
- Regularly update dependencies to patch known vulnerabilities
|
||||||
|
- Run `mvn dependency-check:check` to scan for vulnerable dependencies
|
||||||
|
- Monitor GitHub security advisories for this project
|
||||||
|
|
||||||
|
### Production Deployment Checklist
|
||||||
|
|
||||||
|
- [ ] Use HTTPS/TLS for all connections
|
||||||
|
- [ ] Set strong, unique secrets for all environment variables
|
||||||
|
- [ ] Enable CSRF protection
|
||||||
|
- [ ] Configure secure headers (CSP, X-Frame-Options, etc.)
|
||||||
|
- [ ] Disable debug mode and verbose error messages
|
||||||
|
- [ ] Set up proper logging and monitoring
|
||||||
|
- [ ] Implement rate limiting and DDoS protection
|
||||||
|
- [ ] Regular security updates and patches
|
||||||
|
- [ ] Database backups and disaster recovery plan
|
||||||
|
- [ ] Restrict admin access to trusted IPs when possible
|
||||||
|
|
||||||
|
## Known Security Features
|
||||||
|
|
||||||
|
OpenIsle includes the following security features:
|
||||||
|
|
||||||
|
- JWT-based authentication with configurable expiration
|
||||||
|
- OAuth 2.0 integration with major providers
|
||||||
|
- Password strength validation
|
||||||
|
- Protection codes for sensitive operations
|
||||||
|
- Input validation and sanitization
|
||||||
|
- SQL injection prevention through ORM (JPA/Hibernate)
|
||||||
|
- XSS protection in Vue.js templates
|
||||||
|
- CSRF protection (Spring Security)
|
||||||
|
|
||||||
|
## Security Best Practices for Contributors
|
||||||
|
|
||||||
|
- Never commit credentials, API keys, or secrets
|
||||||
|
- Follow secure coding practices (OWASP Top 10)
|
||||||
|
- Validate and sanitize all user inputs
|
||||||
|
- Use parameterized queries for database operations
|
||||||
|
- Implement proper error handling without exposing sensitive information
|
||||||
|
- Write security tests for new features
|
||||||
|
- Review code for security issues before submitting PRs
|
||||||
|
|
||||||
|
## Disclosure Policy
|
||||||
|
|
||||||
|
When we receive a security bug report, we will:
|
||||||
|
|
||||||
|
1. Confirm the problem and determine affected versions
|
||||||
|
2. Audit code to find any similar problems
|
||||||
|
3. Prepare fixes for all supported versions
|
||||||
|
4. Release patches as soon as possible
|
||||||
|
|
||||||
|
We appreciate your help in keeping OpenIsle and its users safe!
|
||||||
|
|
||||||
|
## Attribution
|
||||||
|
|
||||||
|
We believe in recognizing security researchers who help improve OpenIsle's security. With your permission, we will acknowledge your contribution in:
|
||||||
|
|
||||||
|
- Security advisory
|
||||||
|
- Release notes
|
||||||
|
- A security hall of fame (if established)
|
||||||
|
|
||||||
|
If you prefer to remain anonymous, we will respect your wishes.
|
||||||
|
|
||||||
|
## Contact
|
||||||
|
|
||||||
|
For any security-related questions or concerns, please reach out through the channels mentioned above.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Thank you for helping keep OpenIsle secure!
|
||||||
@@ -19,6 +19,7 @@ JWT_EXPIRATION=2592000000
|
|||||||
# === Redis ===
|
# === Redis ===
|
||||||
REDIS_HOST=<Redis 地址>
|
REDIS_HOST=<Redis 地址>
|
||||||
REDIS_PORT=<Redis 端口>
|
REDIS_PORT=<Redis 端口>
|
||||||
|
REDIS_PASS=<Redis 密码>
|
||||||
|
|
||||||
# === Resend ===
|
# === Resend ===
|
||||||
RESEND_API_KEY=<你的resend-api-key>
|
RESEND_API_KEY=<你的resend-api-key>
|
||||||
|
|||||||
@@ -6,10 +6,12 @@ import com.openisle.model.User;
|
|||||||
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 com.openisle.service.EmailSender;
|
||||||
|
import com.openisle.exception.EmailSendException;
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||||
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
|
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
@@ -17,6 +19,7 @@ import org.springframework.web.bind.annotation.*;
|
|||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/admin/users")
|
@RequestMapping("/api/admin/users")
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
|
@Slf4j
|
||||||
public class AdminUserController {
|
public class AdminUserController {
|
||||||
|
|
||||||
private final UserRepository userRepository;
|
private final UserRepository userRepository;
|
||||||
@@ -35,11 +38,15 @@ public class AdminUserController {
|
|||||||
user.setApproved(true);
|
user.setApproved(true);
|
||||||
userRepository.save(user);
|
userRepository.save(user);
|
||||||
markRegisterRequestNotificationsRead(user);
|
markRegisterRequestNotificationsRead(user);
|
||||||
emailSender.sendEmail(
|
try {
|
||||||
user.getEmail(),
|
emailSender.sendEmail(
|
||||||
"您的注册已审核通过",
|
user.getEmail(),
|
||||||
"🎉您的注册已经审核通过, 点击以访问网站: " + websiteUrl
|
"您的注册已审核通过",
|
||||||
);
|
"🎉您的注册已经审核通过, 点击以访问网站: " + websiteUrl
|
||||||
|
);
|
||||||
|
} catch (EmailSendException e) {
|
||||||
|
log.warn("Failed to send approve email to {}: {}", user.getEmail(), e.getMessage());
|
||||||
|
}
|
||||||
return ResponseEntity.ok().build();
|
return ResponseEntity.ok().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,11 +59,15 @@ public class AdminUserController {
|
|||||||
user.setApproved(false);
|
user.setApproved(false);
|
||||||
userRepository.save(user);
|
userRepository.save(user);
|
||||||
markRegisterRequestNotificationsRead(user);
|
markRegisterRequestNotificationsRead(user);
|
||||||
emailSender.sendEmail(
|
try {
|
||||||
user.getEmail(),
|
emailSender.sendEmail(
|
||||||
"您的注册已被管理员拒绝",
|
user.getEmail(),
|
||||||
"您的注册被管理员拒绝, 点击链接可以重新填写理由申请: " + websiteUrl
|
"您的注册已被管理员拒绝",
|
||||||
);
|
"您的注册被管理员拒绝, 点击链接可以重新填写理由申请: " + websiteUrl
|
||||||
|
);
|
||||||
|
} catch (EmailSendException e) {
|
||||||
|
log.warn("Failed to send reject email to {}: {}", user.getEmail(), e.getMessage());
|
||||||
|
}
|
||||||
return ResponseEntity.ok().build();
|
return ResponseEntity.ok().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.openisle.controller;
|
|||||||
|
|
||||||
import com.openisle.config.CachingConfig;
|
import com.openisle.config.CachingConfig;
|
||||||
import com.openisle.dto.*;
|
import com.openisle.dto.*;
|
||||||
|
import com.openisle.exception.EmailSendException;
|
||||||
import com.openisle.exception.FieldException;
|
import com.openisle.exception.FieldException;
|
||||||
import com.openisle.model.RegisterMode;
|
import com.openisle.model.RegisterMode;
|
||||||
import com.openisle.model.User;
|
import com.openisle.model.User;
|
||||||
@@ -19,6 +20,7 @@ import java.util.concurrent.TimeUnit;
|
|||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.data.redis.core.RedisTemplate;
|
import org.springframework.data.redis.core.RedisTemplate;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
@@ -83,6 +85,17 @@ public class AuthController {
|
|||||||
"INVITE_APPROVED"
|
"INVITE_APPROVED"
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
} catch (EmailSendException e) {
|
||||||
|
return ResponseEntity
|
||||||
|
.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||||
|
.body(
|
||||||
|
Map.of(
|
||||||
|
"error",
|
||||||
|
"邮件发送失败: " + e.getMessage(),
|
||||||
|
"reason_code",
|
||||||
|
"EMAIL_SEND_FAILED"
|
||||||
|
)
|
||||||
|
);
|
||||||
} catch (FieldException e) {
|
} catch (FieldException e) {
|
||||||
return ResponseEntity.badRequest().body(
|
return ResponseEntity.badRequest().body(
|
||||||
Map.of("field", e.getField(), "error", e.getMessage())
|
Map.of("field", e.getField(), "error", e.getMessage())
|
||||||
@@ -97,7 +110,20 @@ public class AuthController {
|
|||||||
registerModeService.getRegisterMode()
|
registerModeService.getRegisterMode()
|
||||||
);
|
);
|
||||||
// 发送确认邮件
|
// 发送确认邮件
|
||||||
userService.sendVerifyMail(user, VerifyType.REGISTER);
|
try {
|
||||||
|
userService.sendVerifyMail(user, VerifyType.REGISTER);
|
||||||
|
} catch (EmailSendException e) {
|
||||||
|
return ResponseEntity
|
||||||
|
.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||||
|
.body(
|
||||||
|
Map.of(
|
||||||
|
"error",
|
||||||
|
"邮件发送失败: " + e.getMessage(),
|
||||||
|
"reason_code",
|
||||||
|
"EMAIL_SEND_FAILED"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
if (!user.isApproved()) {
|
if (!user.isApproved()) {
|
||||||
notificationService.createRegisterRequestNotifications(user, user.getRegisterReason());
|
notificationService.createRegisterRequestNotifications(user, user.getRegisterReason());
|
||||||
}
|
}
|
||||||
@@ -169,14 +195,28 @@ public class AuthController {
|
|||||||
}
|
}
|
||||||
User user = userOpt.get();
|
User user = userOpt.get();
|
||||||
if (!user.isVerified()) {
|
if (!user.isVerified()) {
|
||||||
user = userService.register(
|
user =
|
||||||
user.getUsername(),
|
userService.register(
|
||||||
user.getEmail(),
|
user.getUsername(),
|
||||||
user.getPassword(),
|
user.getEmail(),
|
||||||
user.getRegisterReason(),
|
user.getPassword(),
|
||||||
registerModeService.getRegisterMode()
|
user.getRegisterReason(),
|
||||||
);
|
registerModeService.getRegisterMode()
|
||||||
userService.sendVerifyMail(user, VerifyType.REGISTER);
|
);
|
||||||
|
try {
|
||||||
|
userService.sendVerifyMail(user, VerifyType.REGISTER);
|
||||||
|
} catch (EmailSendException e) {
|
||||||
|
return ResponseEntity
|
||||||
|
.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||||
|
.body(
|
||||||
|
Map.of(
|
||||||
|
"error",
|
||||||
|
"Failed to send verification email: " + e.getMessage(),
|
||||||
|
"reason_code",
|
||||||
|
"EMAIL_SEND_FAILED"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
return ResponseEntity.badRequest().body(
|
return ResponseEntity.badRequest().body(
|
||||||
Map.of(
|
Map.of(
|
||||||
"error",
|
"error",
|
||||||
@@ -663,7 +703,20 @@ public class AuthController {
|
|||||||
if (userOpt.isEmpty()) {
|
if (userOpt.isEmpty()) {
|
||||||
return ResponseEntity.badRequest().body(Map.of("error", "User not found"));
|
return ResponseEntity.badRequest().body(Map.of("error", "User not found"));
|
||||||
}
|
}
|
||||||
userService.sendVerifyMail(userOpt.get(), VerifyType.RESET_PASSWORD);
|
try {
|
||||||
|
userService.sendVerifyMail(userOpt.get(), VerifyType.RESET_PASSWORD);
|
||||||
|
} catch (EmailSendException e) {
|
||||||
|
return ResponseEntity
|
||||||
|
.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||||
|
.body(
|
||||||
|
Map.of(
|
||||||
|
"error",
|
||||||
|
"邮件发送失败: " + e.getMessage(),
|
||||||
|
"reason_code",
|
||||||
|
"EMAIL_SEND_FAILED"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
return ResponseEntity.ok(Map.of("message", "Verification code sent"));
|
return ResponseEntity.ok(Map.of("message", "Verification code sent"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
package com.openisle.controller;
|
package com.openisle.controller;
|
||||||
|
|
||||||
|
import com.openisle.dto.CommentContextDto;
|
||||||
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.PostChangeLogDto;
|
||||||
import com.openisle.dto.TimelineItemDto;
|
import com.openisle.dto.TimelineItemDto;
|
||||||
import com.openisle.mapper.CommentMapper;
|
import com.openisle.mapper.CommentMapper;
|
||||||
import com.openisle.mapper.PostChangeLogMapper;
|
import com.openisle.mapper.PostChangeLogMapper;
|
||||||
|
import com.openisle.mapper.PostMapper;
|
||||||
import com.openisle.model.Comment;
|
import com.openisle.model.Comment;
|
||||||
import com.openisle.model.CommentSort;
|
import com.openisle.model.CommentSort;
|
||||||
import com.openisle.service.*;
|
import com.openisle.service.*;
|
||||||
@@ -40,6 +42,7 @@ public class CommentController {
|
|||||||
private final PointService pointService;
|
private final PointService pointService;
|
||||||
private final PostChangeLogService changeLogService;
|
private final PostChangeLogService changeLogService;
|
||||||
private final PostChangeLogMapper postChangeLogMapper;
|
private final PostChangeLogMapper postChangeLogMapper;
|
||||||
|
private final PostMapper postMapper;
|
||||||
|
|
||||||
@Value("${app.captcha.enabled:false}")
|
@Value("${app.captcha.enabled:false}")
|
||||||
private boolean captchaEnabled;
|
private boolean captchaEnabled;
|
||||||
@@ -184,6 +187,37 @@ public class CommentController {
|
|||||||
return itemDtoList;
|
return itemDtoList;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/comments/{commentId}/context")
|
||||||
|
@Operation(
|
||||||
|
summary = "Comment context",
|
||||||
|
description = "Get a comment along with its previous comments and related post"
|
||||||
|
)
|
||||||
|
@ApiResponse(
|
||||||
|
responseCode = "200",
|
||||||
|
description = "Comment context",
|
||||||
|
content = @Content(schema = @Schema(implementation = CommentContextDto.class))
|
||||||
|
)
|
||||||
|
public ResponseEntity<CommentContextDto> getCommentContext(@PathVariable Long commentId) {
|
||||||
|
log.debug("getCommentContext called for comment {}", commentId);
|
||||||
|
Comment comment = commentService.getComment(commentId);
|
||||||
|
CommentContextDto dto = new CommentContextDto();
|
||||||
|
dto.setPost(postMapper.toSummaryDto(comment.getPost()));
|
||||||
|
dto.setTargetComment(commentMapper.toDtoWithReplies(comment));
|
||||||
|
dto.setPreviousComments(
|
||||||
|
commentService
|
||||||
|
.getCommentsBefore(comment)
|
||||||
|
.stream()
|
||||||
|
.map(commentMapper::toDtoWithReplies)
|
||||||
|
.collect(Collectors.toList())
|
||||||
|
);
|
||||||
|
log.debug(
|
||||||
|
"getCommentContext returning {} previous comments for comment {}",
|
||||||
|
dto.getPreviousComments().size(),
|
||||||
|
commentId
|
||||||
|
);
|
||||||
|
return ResponseEntity.ok(dto);
|
||||||
|
}
|
||||||
|
|
||||||
@DeleteMapping("/comments/{id}")
|
@DeleteMapping("/comments/{id}")
|
||||||
@Operation(summary = "Delete comment", description = "Delete a comment")
|
@Operation(summary = "Delete comment", description = "Delete a comment")
|
||||||
@ApiResponse(responseCode = "200", description = "Deleted")
|
@ApiResponse(responseCode = "200", description = "Deleted")
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ public class PostController {
|
|||||||
req.getContent(),
|
req.getContent(),
|
||||||
req.getTagIds(),
|
req.getTagIds(),
|
||||||
req.getType(),
|
req.getType(),
|
||||||
|
req.getPostVisibleScopeType(),
|
||||||
req.getPrizeDescription(),
|
req.getPrizeDescription(),
|
||||||
req.getPrizeIcon(),
|
req.getPrizeIcon(),
|
||||||
req.getPrizeCount(),
|
req.getPrizeCount(),
|
||||||
@@ -73,7 +74,9 @@ public class PostController {
|
|||||||
req.getStartTime(),
|
req.getStartTime(),
|
||||||
req.getEndTime(),
|
req.getEndTime(),
|
||||||
req.getOptions(),
|
req.getOptions(),
|
||||||
req.getMultiple()
|
req.getMultiple(),
|
||||||
|
req.getProposedName(),
|
||||||
|
req.getProposalDescription()
|
||||||
);
|
);
|
||||||
draftService.deleteDraft(auth.getName());
|
draftService.deleteDraft(auth.getName());
|
||||||
PostDetailDto dto = postMapper.toDetailDto(post, auth.getName());
|
PostDetailDto dto = postMapper.toDetailDto(post, auth.getName());
|
||||||
@@ -101,7 +104,8 @@ public class PostController {
|
|||||||
req.getCategoryId(),
|
req.getCategoryId(),
|
||||||
req.getTitle(),
|
req.getTitle(),
|
||||||
req.getContent(),
|
req.getContent(),
|
||||||
req.getTagIds()
|
req.getTagIds(),
|
||||||
|
req.getPostVisibleScopeType()
|
||||||
);
|
);
|
||||||
return ResponseEntity.ok(postMapper.toDetailDto(post, auth.getName()));
|
return ResponseEntity.ok(postMapper.toDetailDto(post, auth.getName()));
|
||||||
}
|
}
|
||||||
@@ -213,8 +217,24 @@ public class PostController {
|
|||||||
// userVisitService.recordVisit(auth.getName());
|
// userVisitService.recordVisit(auth.getName());
|
||||||
// }
|
// }
|
||||||
|
|
||||||
|
return postMapper.toListDtos(postService.defaultListPosts(ids, tids, page, pageSize));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/recent")
|
||||||
|
@Operation(
|
||||||
|
summary = "Recent posts",
|
||||||
|
description = "List posts created within the specified number of minutes"
|
||||||
|
)
|
||||||
|
@ApiResponse(
|
||||||
|
responseCode = "200",
|
||||||
|
description = "Recent posts",
|
||||||
|
content = @Content(
|
||||||
|
array = @ArraySchema(schema = @Schema(implementation = PostSummaryDto.class))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
public List<PostSummaryDto> recentPosts(@RequestParam("minutes") int minutes) {
|
||||||
return postService
|
return postService
|
||||||
.defaultListPosts(ids, tids, page, pageSize)
|
.listRecentPosts(minutes)
|
||||||
.stream()
|
.stream()
|
||||||
.map(postMapper::toSummaryDto)
|
.map(postMapper::toSummaryDto)
|
||||||
.collect(Collectors.toList());
|
.collect(Collectors.toList());
|
||||||
@@ -245,11 +265,7 @@ public class PostController {
|
|||||||
// userVisitService.recordVisit(auth.getName());
|
// userVisitService.recordVisit(auth.getName());
|
||||||
// }
|
// }
|
||||||
|
|
||||||
return postService
|
return postMapper.toListDtos(postService.listPostsByViews(ids, tids, page, pageSize));
|
||||||
.listPostsByViews(ids, tids, page, pageSize)
|
|
||||||
.stream()
|
|
||||||
.map(postMapper::toSummaryDto)
|
|
||||||
.collect(Collectors.toList());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/latest-reply")
|
@GetMapping("/latest-reply")
|
||||||
@@ -281,8 +297,7 @@ public class PostController {
|
|||||||
// userVisitService.recordVisit(auth.getName());
|
// userVisitService.recordVisit(auth.getName());
|
||||||
// }
|
// }
|
||||||
|
|
||||||
List<Post> posts = postService.listPostsByLatestReply(ids, tids, page, pageSize);
|
return postMapper.toListDtos(postService.listPostsByLatestReply(ids, tids, page, pageSize));
|
||||||
return posts.stream().map(postMapper::toSummaryDto).collect(Collectors.toList());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/featured")
|
@GetMapping("/featured")
|
||||||
@@ -309,10 +324,6 @@ public class PostController {
|
|||||||
// if (auth != null) {
|
// if (auth != null) {
|
||||||
// userVisitService.recordVisit(auth.getName());
|
// userVisitService.recordVisit(auth.getName());
|
||||||
// }
|
// }
|
||||||
return postService
|
return postMapper.toListDtos(postService.listFeaturedPosts(ids, tids, page, pageSize));
|
||||||
.listFeaturedPosts(ids, tids, page, pageSize)
|
|
||||||
.stream()
|
|
||||||
.map(postMapper::toSummaryDto)
|
|
||||||
.collect(Collectors.toList());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ public class UserController {
|
|||||||
private final TagService tagService;
|
private final TagService tagService;
|
||||||
private final SubscriptionService subscriptionService;
|
private final SubscriptionService subscriptionService;
|
||||||
private final LevelService levelService;
|
private final LevelService levelService;
|
||||||
|
private final PostReadService postReadService;
|
||||||
private final JwtService jwtService;
|
private final JwtService jwtService;
|
||||||
private final UserMapper userMapper;
|
private final UserMapper userMapper;
|
||||||
private final TagMapper tagMapper;
|
private final TagMapper tagMapper;
|
||||||
@@ -53,6 +54,9 @@ public class UserController {
|
|||||||
@Value("${app.user.tags-limit:50}")
|
@Value("${app.user.tags-limit:50}")
|
||||||
private int defaultTagsLimit;
|
private int defaultTagsLimit;
|
||||||
|
|
||||||
|
@Value("${app.user.read-posts-limit:50}")
|
||||||
|
private int defaultReadPostsLimit;
|
||||||
|
|
||||||
@GetMapping("/me")
|
@GetMapping("/me")
|
||||||
@SecurityRequirement(name = "JWT")
|
@SecurityRequirement(name = "JWT")
|
||||||
@Operation(summary = "Current user", description = "Get current authenticated user information")
|
@Operation(summary = "Current user", description = "Get current authenticated user information")
|
||||||
@@ -211,6 +215,33 @@ public class UserController {
|
|||||||
.collect(java.util.stream.Collectors.toList());
|
.collect(java.util.stream.Collectors.toList());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{identifier}/read-posts")
|
||||||
|
@SecurityRequirement(name = "JWT")
|
||||||
|
@Operation(summary = "User read posts", description = "Get post read history (self only)")
|
||||||
|
@ApiResponse(
|
||||||
|
responseCode = "200",
|
||||||
|
description = "Post read history",
|
||||||
|
content = @Content(array = @ArraySchema(schema = @Schema(implementation = PostReadDto.class)))
|
||||||
|
)
|
||||||
|
public ResponseEntity<java.util.List<PostReadDto>> userReadPosts(
|
||||||
|
@PathVariable("identifier") String identifier,
|
||||||
|
@RequestParam(value = "limit", required = false) Integer limit,
|
||||||
|
Authentication auth
|
||||||
|
) {
|
||||||
|
User user = userService.findByIdentifier(identifier).orElseThrow();
|
||||||
|
if (auth == null || !auth.getName().equals(user.getUsername())) {
|
||||||
|
return ResponseEntity.status(403).body(java.util.List.of());
|
||||||
|
}
|
||||||
|
int l = limit != null ? limit : defaultReadPostsLimit;
|
||||||
|
return ResponseEntity.ok(
|
||||||
|
postReadService
|
||||||
|
.getRecentReadsByUser(user.getUsername(), l)
|
||||||
|
.stream()
|
||||||
|
.map(userMapper::toPostReadDto)
|
||||||
|
.collect(java.util.stream.Collectors.toList())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/{identifier}/hot-posts")
|
@GetMapping("/{identifier}/hot-posts")
|
||||||
@Operation(summary = "User hot posts", description = "Get most reacted posts by user")
|
@Operation(summary = "User hot posts", description = "Get most reacted posts by user")
|
||||||
@ApiResponse(
|
@ApiResponse(
|
||||||
|
|||||||
@@ -13,4 +13,5 @@ public class AuthorDto {
|
|||||||
private String username;
|
private String username;
|
||||||
private String avatar;
|
private String avatar;
|
||||||
private MedalType displayMedal;
|
private MedalType displayMedal;
|
||||||
|
private boolean bot;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package com.openisle.dto;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO representing the context of a comment including its post and previous comments.
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class CommentContextDto {
|
||||||
|
|
||||||
|
private PostSummaryDto post;
|
||||||
|
private CommentDto targetComment;
|
||||||
|
private List<CommentDto> previousComments;
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.openisle.dto;
|
package com.openisle.dto;
|
||||||
|
|
||||||
import com.openisle.model.PostChangeType;
|
import com.openisle.model.PostChangeType;
|
||||||
|
import com.openisle.model.PostVisibleScopeType;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import lombok.Getter;
|
import lombok.Getter;
|
||||||
@@ -29,5 +30,7 @@ public class PostChangeLogDto {
|
|||||||
private LocalDateTime newPinnedAt;
|
private LocalDateTime newPinnedAt;
|
||||||
private Boolean oldFeatured;
|
private Boolean oldFeatured;
|
||||||
private Boolean newFeatured;
|
private Boolean newFeatured;
|
||||||
|
private PostVisibleScopeType oldVisibleScope;
|
||||||
|
private PostVisibleScopeType newVisibleScope;
|
||||||
private Integer amount;
|
private Integer amount;
|
||||||
}
|
}
|
||||||
|
|||||||
12
backend/src/main/java/com/openisle/dto/PostReadDto.java
Normal file
12
backend/src/main/java/com/openisle/dto/PostReadDto.java
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
package com.openisle.dto;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
/** DTO for a user's post read record. */
|
||||||
|
@Data
|
||||||
|
public class PostReadDto {
|
||||||
|
|
||||||
|
private PostMetaDto post;
|
||||||
|
private LocalDateTime lastReadAt;
|
||||||
|
}
|
||||||
@@ -3,6 +3,8 @@ package com.openisle.dto;
|
|||||||
import com.openisle.model.PostType;
|
import com.openisle.model.PostType;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
|
import com.openisle.model.PostVisibleScopeType;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -19,6 +21,7 @@ public class PostRequest {
|
|||||||
|
|
||||||
// optional for lottery posts
|
// optional for lottery posts
|
||||||
private PostType type;
|
private PostType type;
|
||||||
|
private PostVisibleScopeType postVisibleScopeType;
|
||||||
private String prizeDescription;
|
private String prizeDescription;
|
||||||
private String prizeIcon;
|
private String prizeIcon;
|
||||||
private Integer prizeCount;
|
private Integer prizeCount;
|
||||||
@@ -28,4 +31,8 @@ public class PostRequest {
|
|||||||
// fields for poll posts
|
// fields for poll posts
|
||||||
private List<String> options;
|
private List<String> options;
|
||||||
private Boolean multiple;
|
private Boolean multiple;
|
||||||
|
|
||||||
|
// fields for category proposal posts
|
||||||
|
private String proposedName;
|
||||||
|
private String proposalDescription;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import com.openisle.model.PostStatus;
|
|||||||
import com.openisle.model.PostType;
|
import com.openisle.model.PostType;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
|
import com.openisle.model.PostVisibleScopeType;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -34,4 +36,5 @@ public class PostSummaryDto {
|
|||||||
private PollDto poll;
|
private PollDto poll;
|
||||||
private boolean rssExcluded;
|
private boolean rssExcluded;
|
||||||
private boolean closed;
|
private boolean closed;
|
||||||
|
private PostVisibleScopeType visibleScope;
|
||||||
}
|
}
|
||||||
|
|||||||
20
backend/src/main/java/com/openisle/dto/ProposalDto.java
Normal file
20
backend/src/main/java/com/openisle/dto/ProposalDto.java
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
package com.openisle.dto;
|
||||||
|
|
||||||
|
import com.openisle.model.CategoryProposalStatus;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.EqualsAndHashCode;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@EqualsAndHashCode(callSuper = true)
|
||||||
|
public class ProposalDto extends PollDto {
|
||||||
|
|
||||||
|
private CategoryProposalStatus proposalStatus;
|
||||||
|
private String proposedName;
|
||||||
|
private String description;
|
||||||
|
private int approveThreshold;
|
||||||
|
private int quorum;
|
||||||
|
private LocalDateTime startAt;
|
||||||
|
private String resultSnapshot;
|
||||||
|
private String rejectReason;
|
||||||
|
}
|
||||||
@@ -28,4 +28,5 @@ public class UserDto {
|
|||||||
private int point;
|
private int point;
|
||||||
private int currentLevel;
|
private int currentLevel;
|
||||||
private int nextLevelExp;
|
private int nextLevelExp;
|
||||||
|
private boolean bot;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,4 +8,5 @@ public class UserSummaryDto {
|
|||||||
private Long id;
|
private Long id;
|
||||||
private String username;
|
private String username;
|
||||||
private String avatar;
|
private String avatar;
|
||||||
|
private boolean bot;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package com.openisle.exception;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thrown when email sending fails so callers can surface a clear error upstream.
|
||||||
|
*/
|
||||||
|
public class EmailSendException extends RuntimeException {
|
||||||
|
|
||||||
|
public EmailSendException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
public EmailSendException(String message, Throwable cause) {
|
||||||
|
super(message, cause);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -52,6 +52,9 @@ public class PostChangeLogMapper {
|
|||||||
} else if (log instanceof PostFeaturedChangeLog f) {
|
} else if (log instanceof PostFeaturedChangeLog f) {
|
||||||
dto.setOldFeatured(f.isOldFeatured());
|
dto.setOldFeatured(f.isOldFeatured());
|
||||||
dto.setNewFeatured(f.isNewFeatured());
|
dto.setNewFeatured(f.isNewFeatured());
|
||||||
|
} else if (log instanceof PostVisibleScopeChangeLog v) {
|
||||||
|
dto.setOldVisibleScope(v.getOldVisibleScope());
|
||||||
|
dto.setNewVisibleScope(v.getNewVisibleScope());
|
||||||
} else if (log instanceof PostDonateChangeLog d) {
|
} else if (log instanceof PostDonateChangeLog d) {
|
||||||
dto.setAmount(d.getAmount());
|
dto.setAmount(d.getAmount());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ import com.openisle.dto.LotteryDto;
|
|||||||
import com.openisle.dto.PollDto;
|
import com.openisle.dto.PollDto;
|
||||||
import com.openisle.dto.PostDetailDto;
|
import com.openisle.dto.PostDetailDto;
|
||||||
import com.openisle.dto.PostSummaryDto;
|
import com.openisle.dto.PostSummaryDto;
|
||||||
|
import com.openisle.dto.ProposalDto;
|
||||||
import com.openisle.dto.ReactionDto;
|
import com.openisle.dto.ReactionDto;
|
||||||
|
import com.openisle.model.CategoryProposalPost;
|
||||||
import com.openisle.model.CommentSort;
|
import com.openisle.model.CommentSort;
|
||||||
import com.openisle.model.LotteryPost;
|
import com.openisle.model.LotteryPost;
|
||||||
import com.openisle.model.PollPost;
|
import com.openisle.model.PollPost;
|
||||||
@@ -46,6 +48,38 @@ public class PostMapper {
|
|||||||
return dto;
|
return dto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public List<PostSummaryDto> toListDtos(List<Post> posts) {
|
||||||
|
if (posts == null || posts.isEmpty()) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
Map<Long, List<User>> participantsMap = commentService.getParticipantsForPosts(posts, 5);
|
||||||
|
return posts
|
||||||
|
.stream()
|
||||||
|
.map(post -> {
|
||||||
|
PostSummaryDto dto = new PostSummaryDto();
|
||||||
|
applyListFields(post, dto);
|
||||||
|
List<User> participants = participantsMap.get(post.getId());
|
||||||
|
if (participants != null) {
|
||||||
|
dto.setParticipants(
|
||||||
|
participants.stream().map(userMapper::toAuthorDto).collect(Collectors.toList())
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
dto.setParticipants(List.of());
|
||||||
|
}
|
||||||
|
dto.setReactions(List.of());
|
||||||
|
return dto;
|
||||||
|
})
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
public PostSummaryDto toListDto(Post post) {
|
||||||
|
PostSummaryDto dto = new PostSummaryDto();
|
||||||
|
applyListFields(post, dto);
|
||||||
|
dto.setParticipants(List.of());
|
||||||
|
dto.setReactions(List.of());
|
||||||
|
return dto;
|
||||||
|
}
|
||||||
|
|
||||||
public PostDetailDto toDetailDto(Post post, String viewer) {
|
public PostDetailDto toDetailDto(Post post, String viewer) {
|
||||||
PostDetailDto dto = new PostDetailDto();
|
PostDetailDto dto = new PostDetailDto();
|
||||||
applyCommon(post, dto);
|
applyCommon(post, dto);
|
||||||
@@ -59,6 +93,25 @@ public class PostMapper {
|
|||||||
return dto;
|
return dto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void applyListFields(Post post, PostSummaryDto dto) {
|
||||||
|
dto.setId(post.getId());
|
||||||
|
dto.setTitle(post.getTitle());
|
||||||
|
dto.setContent(post.getContent());
|
||||||
|
dto.setCreatedAt(post.getCreatedAt());
|
||||||
|
dto.setAuthor(userMapper.toAuthorDto(post.getAuthor()));
|
||||||
|
dto.setCategory(categoryMapper.toDto(post.getCategory()));
|
||||||
|
dto.setTags(post.getTags().stream().map(tagMapper::toDto).collect(Collectors.toList()));
|
||||||
|
dto.setViews(post.getViews());
|
||||||
|
dto.setCommentCount(post.getCommentCount());
|
||||||
|
dto.setStatus(post.getStatus());
|
||||||
|
dto.setPinnedAt(post.getPinnedAt());
|
||||||
|
dto.setLastReplyAt(post.getLastReplyAt());
|
||||||
|
dto.setRssExcluded(post.getRssExcluded() == null || post.getRssExcluded());
|
||||||
|
dto.setClosed(post.isClosed());
|
||||||
|
dto.setVisibleScope(post.getVisibleScope());
|
||||||
|
dto.setType(post.getType());
|
||||||
|
}
|
||||||
|
|
||||||
private void applyCommon(Post post, PostSummaryDto dto) {
|
private void applyCommon(Post post, PostSummaryDto dto) {
|
||||||
dto.setId(post.getId());
|
dto.setId(post.getId());
|
||||||
dto.setTitle(post.getTitle());
|
dto.setTitle(post.getTitle());
|
||||||
@@ -73,6 +126,7 @@ public class PostMapper {
|
|||||||
dto.setPinnedAt(post.getPinnedAt());
|
dto.setPinnedAt(post.getPinnedAt());
|
||||||
dto.setRssExcluded(post.getRssExcluded() == null || post.getRssExcluded());
|
dto.setRssExcluded(post.getRssExcluded() == null || post.getRssExcluded());
|
||||||
dto.setClosed(post.isClosed());
|
dto.setClosed(post.isClosed());
|
||||||
|
dto.setVisibleScope(post.getVisibleScope());
|
||||||
|
|
||||||
List<ReactionDto> reactions = reactionService
|
List<ReactionDto> reactions = reactionService
|
||||||
.getReactionsForPost(post.getId())
|
.getReactionsForPost(post.getId())
|
||||||
@@ -113,26 +167,40 @@ public class PostMapper {
|
|||||||
dto.setLottery(l);
|
dto.setLottery(l);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (post instanceof PollPost pp) {
|
if (post instanceof CategoryProposalPost cp) {
|
||||||
PollDto p = new PollDto();
|
ProposalDto proposalDto = (ProposalDto) buildPollDto(cp, new ProposalDto());
|
||||||
p.setOptions(pp.getOptions());
|
proposalDto.setProposalStatus(cp.getProposalStatus());
|
||||||
p.setVotes(pp.getVotes());
|
proposalDto.setProposedName(cp.getProposedName());
|
||||||
p.setEndTime(pp.getEndTime());
|
proposalDto.setDescription(cp.getDescription());
|
||||||
p.setParticipants(
|
proposalDto.setApproveThreshold(cp.getApproveThreshold());
|
||||||
pp.getParticipants().stream().map(userMapper::toAuthorDto).collect(Collectors.toList())
|
proposalDto.setQuorum(cp.getQuorum());
|
||||||
);
|
proposalDto.setStartAt(cp.getStartAt());
|
||||||
Map<Integer, List<AuthorDto>> optionParticipants = pollVoteRepository
|
proposalDto.setResultSnapshot(cp.getResultSnapshot());
|
||||||
.findByPostId(pp.getId())
|
proposalDto.setRejectReason(cp.getRejectReason());
|
||||||
.stream()
|
dto.setPoll(proposalDto);
|
||||||
.collect(
|
} else if (post instanceof PollPost pp) {
|
||||||
Collectors.groupingBy(
|
dto.setPoll(buildPollDto(pp, new PollDto()));
|
||||||
PollVote::getOptionIndex,
|
|
||||||
Collectors.mapping(v -> userMapper.toAuthorDto(v.getUser()), Collectors.toList())
|
|
||||||
)
|
|
||||||
);
|
|
||||||
p.setOptionParticipants(optionParticipants);
|
|
||||||
p.setMultiple(Boolean.TRUE.equals(pp.getMultiple()));
|
|
||||||
dto.setPoll(p);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private PollDto buildPollDto(PollPost pollPost, PollDto target) {
|
||||||
|
target.setOptions(pollPost.getOptions());
|
||||||
|
target.setVotes(pollPost.getVotes());
|
||||||
|
target.setEndTime(pollPost.getEndTime());
|
||||||
|
target.setParticipants(
|
||||||
|
pollPost.getParticipants().stream().map(userMapper::toAuthorDto).collect(Collectors.toList())
|
||||||
|
);
|
||||||
|
Map<Integer, List<AuthorDto>> optionParticipants = pollVoteRepository
|
||||||
|
.findByPostId(pollPost.getId())
|
||||||
|
.stream()
|
||||||
|
.collect(
|
||||||
|
Collectors.groupingBy(
|
||||||
|
PollVote::getOptionIndex,
|
||||||
|
Collectors.mapping(v -> userMapper.toAuthorDto(v.getUser()), Collectors.toList())
|
||||||
|
)
|
||||||
|
);
|
||||||
|
target.setOptionParticipants(optionParticipants);
|
||||||
|
target.setMultiple(Boolean.TRUE.equals(pollPost.getMultiple()));
|
||||||
|
return target;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package com.openisle.mapper;
|
|||||||
import com.openisle.dto.*;
|
import com.openisle.dto.*;
|
||||||
import com.openisle.model.Comment;
|
import com.openisle.model.Comment;
|
||||||
import com.openisle.model.Post;
|
import com.openisle.model.Post;
|
||||||
|
import com.openisle.model.PostRead;
|
||||||
import com.openisle.model.User;
|
import com.openisle.model.User;
|
||||||
import com.openisle.service.*;
|
import com.openisle.service.*;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
@@ -37,6 +38,7 @@ public class UserMapper {
|
|||||||
dto.setUsername(user.getUsername());
|
dto.setUsername(user.getUsername());
|
||||||
dto.setAvatar(user.getAvatar());
|
dto.setAvatar(user.getAvatar());
|
||||||
dto.setDisplayMedal(user.getDisplayMedal());
|
dto.setDisplayMedal(user.getDisplayMedal());
|
||||||
|
dto.setBot(user.isBot());
|
||||||
return dto;
|
return dto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,6 +65,7 @@ public class UserMapper {
|
|||||||
dto.setPoint(user.getPoint());
|
dto.setPoint(user.getPoint());
|
||||||
dto.setCurrentLevel(levelService.getLevel(user.getExperience()));
|
dto.setCurrentLevel(levelService.getLevel(user.getExperience()));
|
||||||
dto.setNextLevelExp(levelService.nextLevelExp(user.getExperience()));
|
dto.setNextLevelExp(levelService.nextLevelExp(user.getExperience()));
|
||||||
|
dto.setBot(user.isBot());
|
||||||
if (viewer != null) {
|
if (viewer != null) {
|
||||||
dto.setSubscribed(subscriptionService.isSubscribed(viewer.getName(), user.getUsername()));
|
dto.setSubscribed(subscriptionService.isSubscribed(viewer.getName(), user.getUsername()));
|
||||||
} else {
|
} else {
|
||||||
@@ -113,4 +116,11 @@ public class UserMapper {
|
|||||||
}
|
}
|
||||||
return dto;
|
return dto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public PostReadDto toPostReadDto(PostRead read) {
|
||||||
|
PostReadDto dto = new PostReadDto();
|
||||||
|
dto.setPost(toMetaDto(read.getPost()));
|
||||||
|
dto.setLastReadAt(read.getLastReadAt());
|
||||||
|
return dto;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package com.openisle.model;
|
||||||
|
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.EnumType;
|
||||||
|
import jakarta.persistence.Enumerated;
|
||||||
|
import jakarta.persistence.Index;
|
||||||
|
import jakarta.persistence.PrimaryKeyJoinColumn;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A specialized post type used for proposing new categories.
|
||||||
|
* It reuses poll mechanics (participants, votes, endTime) by extending PollPost.
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(
|
||||||
|
name = "category_proposal_posts",
|
||||||
|
indexes = { @Index(name = "idx_category_proposal_posts_status", columnList = "status") }
|
||||||
|
)
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@NoArgsConstructor
|
||||||
|
@PrimaryKeyJoinColumn(name = "post_id")
|
||||||
|
public class CategoryProposalPost extends PollPost {
|
||||||
|
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
|
@Column(name = "status", nullable = false)
|
||||||
|
private CategoryProposalStatus proposalStatus = CategoryProposalStatus.PENDING;
|
||||||
|
|
||||||
|
@Column(name = "proposed_name", nullable = false, unique = true)
|
||||||
|
private String proposedName;
|
||||||
|
|
||||||
|
@Column(name = "description")
|
||||||
|
private String description;
|
||||||
|
|
||||||
|
// Approval threshold as percentage (0-100), default 60
|
||||||
|
@Column(name = "approve_threshold", nullable = false)
|
||||||
|
private int approveThreshold = 60;
|
||||||
|
|
||||||
|
// Minimum number of participants required to meet quorum
|
||||||
|
@Column(name = "quorum", nullable = false)
|
||||||
|
private int quorum = 10;
|
||||||
|
|
||||||
|
// Optional voting start time (end time inherited from PollPost)
|
||||||
|
@Column(name = "start_at")
|
||||||
|
private LocalDateTime startAt;
|
||||||
|
|
||||||
|
// Snapshot of poll results at finalization (e.g., JSON)
|
||||||
|
@Column(name = "result_snapshot", columnDefinition = "TEXT")
|
||||||
|
private String resultSnapshot;
|
||||||
|
|
||||||
|
// Reason when proposal is rejected
|
||||||
|
@Column(name = "reject_reason")
|
||||||
|
private String rejectReason;
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package com.openisle.model;
|
||||||
|
|
||||||
|
public enum CategoryProposalStatus {
|
||||||
|
PENDING,
|
||||||
|
APPROVED,
|
||||||
|
REJECTED
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -46,6 +46,10 @@ public enum NotificationType {
|
|||||||
POLL_RESULT_OWNER,
|
POLL_RESULT_OWNER,
|
||||||
/** A poll you participated in has concluded */
|
/** A poll you participated in has concluded */
|
||||||
POLL_RESULT_PARTICIPANT,
|
POLL_RESULT_PARTICIPANT,
|
||||||
|
/** Your category proposal has concluded */
|
||||||
|
CATEGORY_PROPOSAL_RESULT_OWNER,
|
||||||
|
/** A category proposal you participated in has concluded */
|
||||||
|
CATEGORY_PROPOSAL_RESULT_PARTICIPANT,
|
||||||
/** Your post was featured */
|
/** Your post was featured */
|
||||||
POST_FEATURED,
|
POST_FEATURED,
|
||||||
/** Someone donated to your post */
|
/** Someone donated to your post */
|
||||||
|
|||||||
@@ -66,6 +66,10 @@ public class Post {
|
|||||||
@Column(nullable = false)
|
@Column(nullable = false)
|
||||||
private PostType type = PostType.NORMAL;
|
private PostType type = PostType.NORMAL;
|
||||||
|
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
|
@Column(nullable = false)
|
||||||
|
private PostVisibleScopeType visibleScope = PostVisibleScopeType.ALL;
|
||||||
|
|
||||||
@Column(nullable = false)
|
@Column(nullable = false)
|
||||||
private boolean closed = false;
|
private boolean closed = false;
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ public enum PostChangeType {
|
|||||||
CLOSED,
|
CLOSED,
|
||||||
PINNED,
|
PINNED,
|
||||||
FEATURED,
|
FEATURED,
|
||||||
|
VISIBLE_SCOPE,
|
||||||
VOTE_RESULT,
|
VOTE_RESULT,
|
||||||
LOTTERY_RESULT,
|
LOTTERY_RESULT,
|
||||||
DONATE,
|
DONATE,
|
||||||
|
|||||||
@@ -4,4 +4,5 @@ public enum PostType {
|
|||||||
NORMAL,
|
NORMAL,
|
||||||
LOTTERY,
|
LOTTERY,
|
||||||
POLL,
|
POLL,
|
||||||
|
PROPOSAL
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package com.openisle.model;
|
||||||
|
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.EnumType;
|
||||||
|
import jakarta.persistence.Enumerated;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@NoArgsConstructor
|
||||||
|
@Entity
|
||||||
|
@Table(name = "post_visible_scope_change_logs")
|
||||||
|
public class PostVisibleScopeChangeLog extends PostChangeLog {
|
||||||
|
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
|
private PostVisibleScopeType oldVisibleScope;
|
||||||
|
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
|
private PostVisibleScopeType newVisibleScope;
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package com.openisle.model;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonCreator;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonValue;
|
||||||
|
|
||||||
|
public enum PostVisibleScopeType {
|
||||||
|
ALL,
|
||||||
|
ONLY_ME,
|
||||||
|
ONLY_REGISTER;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 防止画面传递错误的值
|
||||||
|
* @param value
|
||||||
|
* @return
|
||||||
|
*/
|
||||||
|
@JsonCreator
|
||||||
|
public static PostVisibleScopeType fromString(String value) {
|
||||||
|
if (value == null) return ALL;
|
||||||
|
for (PostVisibleScopeType type : PostVisibleScopeType.values()) {
|
||||||
|
if (type.name().equalsIgnoreCase(value)) {
|
||||||
|
return type;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 不匹配时给默认值,而不是抛异常
|
||||||
|
return ALL;
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonValue
|
||||||
|
public String toValue() {
|
||||||
|
return this.name();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -62,6 +62,9 @@ public class User {
|
|||||||
@Column(nullable = false)
|
@Column(nullable = false)
|
||||||
private Role role = Role.USER;
|
private Role role = Role.USER;
|
||||||
|
|
||||||
|
@Column(name = "is_bot", nullable = false)
|
||||||
|
private boolean bot = false;
|
||||||
|
|
||||||
@Enumerated(EnumType.STRING)
|
@Enumerated(EnumType.STRING)
|
||||||
private MedalType displayMedal;
|
private MedalType displayMedal;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package com.openisle.repository;
|
||||||
|
|
||||||
|
import com.openisle.model.CategoryProposalPost;
|
||||||
|
import com.openisle.model.CategoryProposalStatus;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
public interface CategoryProposalPostRepository extends JpaRepository<CategoryProposalPost, Long> {
|
||||||
|
List<CategoryProposalPost> findByEndTimeAfterAndProposalStatus(
|
||||||
|
LocalDateTime now,
|
||||||
|
CategoryProposalStatus status
|
||||||
|
);
|
||||||
|
List<CategoryProposalPost> findByEndTimeBeforeAndProposalStatus(
|
||||||
|
LocalDateTime now,
|
||||||
|
CategoryProposalStatus status
|
||||||
|
);
|
||||||
|
boolean existsByProposedNameIgnoreCase(String proposedName);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package com.openisle.repository;
|
|||||||
import com.openisle.model.Comment;
|
import com.openisle.model.Comment;
|
||||||
import com.openisle.model.Post;
|
import com.openisle.model.Post;
|
||||||
import com.openisle.model.User;
|
import com.openisle.model.User;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import org.springframework.data.domain.Pageable;
|
import org.springframework.data.domain.Pageable;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
@@ -10,6 +11,10 @@ import org.springframework.data.jpa.repository.JpaRepository;
|
|||||||
public interface CommentRepository extends JpaRepository<Comment, Long> {
|
public interface CommentRepository extends JpaRepository<Comment, Long> {
|
||||||
List<Comment> findByPostAndParentIsNullOrderByCreatedAtAsc(Post post);
|
List<Comment> findByPostAndParentIsNullOrderByCreatedAtAsc(Post post);
|
||||||
List<Comment> findByParentOrderByCreatedAtAsc(Comment parent);
|
List<Comment> findByParentOrderByCreatedAtAsc(Comment parent);
|
||||||
|
List<Comment> findByPostAndCreatedAtLessThanOrderByCreatedAtAsc(
|
||||||
|
Post post,
|
||||||
|
LocalDateTime createdAt
|
||||||
|
);
|
||||||
List<Comment> findByAuthorOrderByCreatedAtDesc(User author, Pageable pageable);
|
List<Comment> findByAuthorOrderByCreatedAtDesc(User author, Pageable pageable);
|
||||||
List<Comment> findByContentContainingIgnoreCase(String keyword);
|
List<Comment> findByContentContainingIgnoreCase(String keyword);
|
||||||
|
|
||||||
@@ -20,6 +25,13 @@ public interface CommentRepository extends JpaRepository<Comment, Long> {
|
|||||||
@org.springframework.data.repository.query.Param("post") Post post
|
@org.springframework.data.repository.query.Param("post") Post post
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@org.springframework.data.jpa.repository.Query(
|
||||||
|
"SELECT DISTINCT c.post.id, c.author FROM Comment c WHERE c.post.id IN :postIds"
|
||||||
|
)
|
||||||
|
java.util.List<Object[]> findDistinctAuthorsByPostIds(
|
||||||
|
@org.springframework.data.repository.query.Param("postIds") java.util.List<Long> postIds
|
||||||
|
);
|
||||||
|
|
||||||
@org.springframework.data.jpa.repository.Query(
|
@org.springframework.data.jpa.repository.Query(
|
||||||
"SELECT MAX(c.createdAt) FROM Comment c WHERE c.post = :post"
|
"SELECT MAX(c.createdAt) FROM Comment c WHERE c.post = :post"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,11 +3,14 @@ package com.openisle.repository;
|
|||||||
import com.openisle.model.Post;
|
import com.openisle.model.Post;
|
||||||
import com.openisle.model.PostRead;
|
import com.openisle.model.PostRead;
|
||||||
import com.openisle.model.User;
|
import com.openisle.model.User;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
public interface PostReadRepository extends JpaRepository<PostRead, Long> {
|
public interface PostReadRepository extends JpaRepository<PostRead, Long> {
|
||||||
Optional<PostRead> findByUserAndPost(User user, Post post);
|
Optional<PostRead> findByUserAndPost(User user, Post post);
|
||||||
|
List<PostRead> findByUserOrderByLastReadAtDesc(User user, Pageable pageable);
|
||||||
long countByUser(User user);
|
long countByUser(User user);
|
||||||
void deleteByPost(Post post);
|
void deleteByPost(Post post);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,12 @@ public interface PostRepository extends JpaRepository<Post, Long> {
|
|||||||
List<Post> findByStatusOrderByCreatedAtDesc(PostStatus status, Pageable pageable);
|
List<Post> findByStatusOrderByCreatedAtDesc(PostStatus status, Pageable pageable);
|
||||||
List<Post> findByStatusOrderByViewsDesc(PostStatus status);
|
List<Post> findByStatusOrderByViewsDesc(PostStatus status);
|
||||||
List<Post> findByStatusOrderByViewsDesc(PostStatus status, Pageable pageable);
|
List<Post> findByStatusOrderByViewsDesc(PostStatus status, Pageable pageable);
|
||||||
|
List<Post> findByStatusOrderByPinnedAtDescViewsDesc(PostStatus status, Pageable pageable);
|
||||||
|
List<Post> findByStatusOrderByPinnedAtDescLastReplyAtDesc(PostStatus status, Pageable pageable);
|
||||||
|
List<Post> findByStatusAndCreatedAtGreaterThanEqualOrderByCreatedAtDesc(
|
||||||
|
PostStatus status,
|
||||||
|
LocalDateTime createdAt
|
||||||
|
);
|
||||||
List<Post> findByAuthorAndStatusOrderByCreatedAtDesc(
|
List<Post> findByAuthorAndStatusOrderByCreatedAtDesc(
|
||||||
User author,
|
User author,
|
||||||
PostStatus status,
|
PostStatus status,
|
||||||
@@ -39,6 +45,16 @@ public interface PostRepository extends JpaRepository<Post, Long> {
|
|||||||
PostStatus status,
|
PostStatus status,
|
||||||
Pageable pageable
|
Pageable pageable
|
||||||
);
|
);
|
||||||
|
List<Post> findByCategoryInAndStatusOrderByPinnedAtDescViewsDesc(
|
||||||
|
List<Category> categories,
|
||||||
|
PostStatus status,
|
||||||
|
Pageable pageable
|
||||||
|
);
|
||||||
|
List<Post> findByCategoryInAndStatusOrderByPinnedAtDescLastReplyAtDesc(
|
||||||
|
List<Category> categories,
|
||||||
|
PostStatus status,
|
||||||
|
Pageable pageable
|
||||||
|
);
|
||||||
List<Post> findDistinctByTagsInAndStatus(List<Tag> tags, PostStatus status);
|
List<Post> findDistinctByTagsInAndStatus(List<Tag> tags, PostStatus status);
|
||||||
List<Post> findDistinctByTagsInAndStatus(List<Tag> tags, PostStatus status, Pageable pageable);
|
List<Post> findDistinctByTagsInAndStatus(List<Tag> tags, PostStatus status, Pageable pageable);
|
||||||
List<Post> findDistinctByTagsInAndStatusOrderByCreatedAtDesc(List<Tag> tags, PostStatus status);
|
List<Post> findDistinctByTagsInAndStatusOrderByCreatedAtDesc(List<Tag> tags, PostStatus status);
|
||||||
@@ -128,6 +144,26 @@ public interface PostRepository extends JpaRepository<Post, Long> {
|
|||||||
Pageable pageable
|
Pageable pageable
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"SELECT p FROM Post p JOIN p.tags t WHERE t IN :tags AND p.status = :status GROUP BY p.id HAVING COUNT(DISTINCT t.id) = :tagCount ORDER BY p.pinnedAt DESC, p.views DESC"
|
||||||
|
)
|
||||||
|
List<Post> findByAllTagsOrderByPinnedAtDescViewsDesc(
|
||||||
|
@Param("tags") List<Tag> tags,
|
||||||
|
@Param("status") PostStatus status,
|
||||||
|
@Param("tagCount") long tagCount,
|
||||||
|
Pageable pageable
|
||||||
|
);
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"SELECT p FROM Post p JOIN p.tags t WHERE t IN :tags AND p.status = :status GROUP BY p.id HAVING COUNT(DISTINCT t.id) = :tagCount ORDER BY p.pinnedAt DESC, p.lastReplyAt DESC"
|
||||||
|
)
|
||||||
|
List<Post> findByAllTagsOrderByPinnedAtDescLastReplyAtDesc(
|
||||||
|
@Param("tags") List<Tag> tags,
|
||||||
|
@Param("status") PostStatus status,
|
||||||
|
@Param("tagCount") long tagCount,
|
||||||
|
Pageable pageable
|
||||||
|
);
|
||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
"SELECT p FROM Post p JOIN p.tags t WHERE p.category IN :categories AND t IN :tags AND p.status = :status GROUP BY p.id HAVING COUNT(DISTINCT t.id) = :tagCount"
|
"SELECT p FROM Post p JOIN p.tags t WHERE p.category IN :categories AND t IN :tags AND p.status = :status GROUP BY p.id HAVING COUNT(DISTINCT t.id) = :tagCount"
|
||||||
)
|
)
|
||||||
@@ -170,6 +206,28 @@ public interface PostRepository extends JpaRepository<Post, Long> {
|
|||||||
Pageable pageable
|
Pageable pageable
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"SELECT p FROM Post p JOIN p.tags t WHERE p.category IN :categories AND t IN :tags AND p.status = :status GROUP BY p.id HAVING COUNT(DISTINCT t.id) = :tagCount ORDER BY p.pinnedAt DESC, p.views DESC"
|
||||||
|
)
|
||||||
|
List<Post> findByCategoriesAndAllTagsOrderByPinnedAtDescViewsDesc(
|
||||||
|
@Param("categories") List<Category> categories,
|
||||||
|
@Param("tags") List<Tag> tags,
|
||||||
|
@Param("status") PostStatus status,
|
||||||
|
@Param("tagCount") long tagCount,
|
||||||
|
Pageable pageable
|
||||||
|
);
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"SELECT p FROM Post p JOIN p.tags t WHERE p.category IN :categories AND t IN :tags AND p.status = :status GROUP BY p.id HAVING COUNT(DISTINCT t.id) = :tagCount ORDER BY p.pinnedAt DESC, p.lastReplyAt DESC"
|
||||||
|
)
|
||||||
|
List<Post> findByCategoriesAndAllTagsOrderByPinnedAtDescLastReplyAtDesc(
|
||||||
|
@Param("categories") List<Category> categories,
|
||||||
|
@Param("tags") List<Tag> tags,
|
||||||
|
@Param("status") PostStatus status,
|
||||||
|
@Param("tagCount") long tagCount,
|
||||||
|
Pageable pageable
|
||||||
|
);
|
||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
"SELECT p FROM Post p JOIN p.tags t WHERE p.category IN :categories AND t IN :tags AND p.status = :status GROUP BY p.id HAVING COUNT(DISTINCT t.id) = :tagCount ORDER BY p.createdAt DESC"
|
"SELECT p FROM Post p JOIN p.tags t WHERE p.category IN :categories AND t IN :tags AND p.status = :status GROUP BY p.id HAVING COUNT(DISTINCT t.id) = :tagCount ORDER BY p.createdAt DESC"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -105,6 +105,7 @@ public class ChannelService {
|
|||||||
userDto.setId(message.getSender().getId());
|
userDto.setId(message.getSender().getId());
|
||||||
userDto.setUsername(message.getSender().getUsername());
|
userDto.setUsername(message.getSender().getUsername());
|
||||||
userDto.setAvatar(message.getSender().getAvatar());
|
userDto.setAvatar(message.getSender().getAvatar());
|
||||||
|
userDto.setBot(message.getSender().isBot());
|
||||||
dto.setSender(userDto);
|
dto.setSender(userDto);
|
||||||
|
|
||||||
return dto;
|
return dto;
|
||||||
|
|||||||
@@ -21,8 +21,12 @@ import com.openisle.service.NotificationService;
|
|||||||
import com.openisle.service.PointService;
|
import com.openisle.service.PointService;
|
||||||
import com.openisle.service.SubscriptionService;
|
import com.openisle.service.SubscriptionService;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashMap;
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
@@ -266,6 +270,27 @@ public class CommentService {
|
|||||||
return replies;
|
return replies;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Comment getComment(Long commentId) {
|
||||||
|
log.debug("getComment called for id {}", commentId);
|
||||||
|
return commentRepository
|
||||||
|
.findById(commentId)
|
||||||
|
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Comment not found"));
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Comment> getCommentsBefore(Comment comment) {
|
||||||
|
log.debug("getCommentsBefore called for comment {}", comment.getId());
|
||||||
|
List<Comment> comments = commentRepository.findByPostAndCreatedAtLessThanOrderByCreatedAtAsc(
|
||||||
|
comment.getPost(),
|
||||||
|
comment.getCreatedAt()
|
||||||
|
);
|
||||||
|
log.debug(
|
||||||
|
"getCommentsBefore returning {} comments for comment {}",
|
||||||
|
comments.size(),
|
||||||
|
comment.getId()
|
||||||
|
);
|
||||||
|
return comments;
|
||||||
|
}
|
||||||
|
|
||||||
public List<Comment> getRecentCommentsByUser(String username, int limit) {
|
public List<Comment> getRecentCommentsByUser(String username, int limit) {
|
||||||
log.debug("getRecentCommentsByUser called for user {} with limit {}", username, limit);
|
log.debug("getRecentCommentsByUser called for user {} with limit {}", username, limit);
|
||||||
User user = userRepository
|
User user = userRepository
|
||||||
@@ -295,6 +320,37 @@ public class CommentService {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Map<Long, List<User>> getParticipantsForPosts(List<Post> posts, int limit) {
|
||||||
|
if (posts == null || posts.isEmpty()) {
|
||||||
|
return Map.of();
|
||||||
|
}
|
||||||
|
Map<Long, LinkedHashSet<User>> map = new HashMap<>();
|
||||||
|
List<Long> postIds = new ArrayList<>(posts.size());
|
||||||
|
for (Post post : posts) {
|
||||||
|
postIds.add(post.getId());
|
||||||
|
LinkedHashSet<User> set = new LinkedHashSet<>();
|
||||||
|
set.add(post.getAuthor());
|
||||||
|
map.put(post.getId(), set);
|
||||||
|
}
|
||||||
|
for (Object[] row : commentRepository.findDistinctAuthorsByPostIds(postIds)) {
|
||||||
|
Long postId = (Long) row[0];
|
||||||
|
User author = (User) row[1];
|
||||||
|
LinkedHashSet<User> set = map.get(postId);
|
||||||
|
if (set != null) {
|
||||||
|
set.add(author);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Map<Long, List<User>> result = new HashMap<>(map.size());
|
||||||
|
for (Map.Entry<Long, LinkedHashSet<User>> entry : map.entrySet()) {
|
||||||
|
List<User> list = new ArrayList<>(entry.getValue());
|
||||||
|
if (list.size() > limit) {
|
||||||
|
list = list.subList(0, limit);
|
||||||
|
}
|
||||||
|
result.put(entry.getKey(), list);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
public java.util.List<Comment> getCommentsByIds(java.util.List<Long> ids) {
|
public java.util.List<Comment> getCommentsByIds(java.util.List<Long> ids) {
|
||||||
log.debug("getCommentsByIds called for ids {}", ids);
|
log.debug("getCommentsByIds called for ids {}", ids);
|
||||||
java.util.List<Comment> comments = commentRepository.findAllById(ids);
|
java.util.List<Comment> comments = commentRepository.findAllById(ids);
|
||||||
|
|||||||
@@ -211,6 +211,7 @@ public class MessageService {
|
|||||||
userSummaryDto.setId(message.getSender().getId());
|
userSummaryDto.setId(message.getSender().getId());
|
||||||
userSummaryDto.setUsername(message.getSender().getUsername());
|
userSummaryDto.setUsername(message.getSender().getUsername());
|
||||||
userSummaryDto.setAvatar(message.getSender().getAvatar());
|
userSummaryDto.setAvatar(message.getSender().getAvatar());
|
||||||
|
userSummaryDto.setBot(message.getSender().isBot());
|
||||||
dto.setSender(userSummaryDto);
|
dto.setSender(userSummaryDto);
|
||||||
|
|
||||||
if (message.getReplyTo() != null) {
|
if (message.getReplyTo() != null) {
|
||||||
@@ -222,6 +223,7 @@ public class MessageService {
|
|||||||
replySender.setId(reply.getSender().getId());
|
replySender.setId(reply.getSender().getId());
|
||||||
replySender.setUsername(reply.getSender().getUsername());
|
replySender.setUsername(reply.getSender().getUsername());
|
||||||
replySender.setAvatar(reply.getSender().getAvatar());
|
replySender.setAvatar(reply.getSender().getAvatar());
|
||||||
|
replySender.setBot(reply.getSender().isBot());
|
||||||
replyDto.setSender(replySender);
|
replyDto.setSender(replySender);
|
||||||
dto.setReplyTo(replyDto);
|
dto.setReplyTo(replyDto);
|
||||||
}
|
}
|
||||||
@@ -316,6 +318,7 @@ public class MessageService {
|
|||||||
userDto.setId(p.getUser().getId());
|
userDto.setId(p.getUser().getId());
|
||||||
userDto.setUsername(p.getUser().getUsername());
|
userDto.setUsername(p.getUser().getUsername());
|
||||||
userDto.setAvatar(p.getUser().getAvatar());
|
userDto.setAvatar(p.getUser().getAvatar());
|
||||||
|
userDto.setBot(p.getUser().isBot());
|
||||||
return userDto;
|
return userDto;
|
||||||
})
|
})
|
||||||
.collect(Collectors.toList())
|
.collect(Collectors.toList())
|
||||||
@@ -365,6 +368,7 @@ public class MessageService {
|
|||||||
userDto.setId(p.getUser().getId());
|
userDto.setId(p.getUser().getId());
|
||||||
userDto.setUsername(p.getUser().getUsername());
|
userDto.setUsername(p.getUser().getUsername());
|
||||||
userDto.setAvatar(p.getUser().getAvatar());
|
userDto.setAvatar(p.getUser().getAvatar());
|
||||||
|
userDto.setBot(p.getUser().isBot());
|
||||||
return userDto;
|
return userDto;
|
||||||
})
|
})
|
||||||
.collect(Collectors.toList());
|
.collect(Collectors.toList());
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import com.openisle.repository.NotificationRepository;
|
|||||||
import com.openisle.repository.ReactionRepository;
|
import com.openisle.repository.ReactionRepository;
|
||||||
import com.openisle.repository.UserRepository;
|
import com.openisle.repository.UserRepository;
|
||||||
import com.openisle.service.EmailSender;
|
import com.openisle.service.EmailSender;
|
||||||
|
import com.openisle.exception.EmailSendException;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.EnumSet;
|
import java.util.EnumSet;
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
@@ -17,6 +18,7 @@ import java.util.concurrent.Executor;
|
|||||||
import java.util.regex.Matcher;
|
import java.util.regex.Matcher;
|
||||||
import java.util.regex.Pattern;
|
import java.util.regex.Pattern;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
@@ -26,6 +28,7 @@ import org.springframework.transaction.support.TransactionSynchronizationManager
|
|||||||
/** Service for creating and retrieving notifications. */
|
/** Service for creating and retrieving notifications. */
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
|
@Slf4j
|
||||||
public class NotificationService {
|
public class NotificationService {
|
||||||
|
|
||||||
private final NotificationRepository notificationRepository;
|
private final NotificationRepository notificationRepository;
|
||||||
@@ -108,7 +111,11 @@ public class NotificationService {
|
|||||||
post.getId(),
|
post.getId(),
|
||||||
comment.getId()
|
comment.getId()
|
||||||
);
|
);
|
||||||
emailSender.sendEmail(user.getEmail(), "有人回复了你", url);
|
try {
|
||||||
|
emailSender.sendEmail(user.getEmail(), "有人回复了你", url);
|
||||||
|
} catch (EmailSendException e) {
|
||||||
|
log.warn("Failed to send notification email to {}: {}", user.getEmail(), e.getMessage());
|
||||||
|
}
|
||||||
sendCustomPush(user, "有人回复了你", url);
|
sendCustomPush(user, "有人回复了你", url);
|
||||||
} else if (type == NotificationType.REACTION && comment != null) {
|
} else if (type == NotificationType.REACTION && comment != null) {
|
||||||
// long count = reactionRepository.countReceived(comment.getAuthor().getUsername());
|
// long count = reactionRepository.countReceived(comment.getAuthor().getUsername());
|
||||||
|
|||||||
@@ -99,6 +99,21 @@ public class PostChangeLogService {
|
|||||||
logRepository.save(log);
|
logRepository.save(log);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void recordVisibleScopeChange(
|
||||||
|
Post post,
|
||||||
|
User user,
|
||||||
|
PostVisibleScopeType oldVisibleScope,
|
||||||
|
PostVisibleScopeType newVisibleScope
|
||||||
|
) {
|
||||||
|
PostVisibleScopeChangeLog log = new PostVisibleScopeChangeLog();
|
||||||
|
log.setPost(post);
|
||||||
|
log.setUser(user);
|
||||||
|
log.setType(PostChangeType.VISIBLE_SCOPE);
|
||||||
|
log.setOldVisibleScope(oldVisibleScope);
|
||||||
|
log.setNewVisibleScope(newVisibleScope);
|
||||||
|
logRepository.save(log);
|
||||||
|
}
|
||||||
|
|
||||||
public void recordVoteResult(Post post) {
|
public void recordVoteResult(Post post) {
|
||||||
PostVoteResultChangeLog log = new PostVoteResultChangeLog();
|
PostVoteResultChangeLog log = new PostVoteResultChangeLog();
|
||||||
log.setPost(post);
|
log.setPost(post);
|
||||||
|
|||||||
@@ -7,7 +7,10 @@ import com.openisle.repository.PostReadRepository;
|
|||||||
import com.openisle.repository.PostRepository;
|
import com.openisle.repository.PostRepository;
|
||||||
import com.openisle.repository.UserRepository;
|
import com.openisle.repository.UserRepository;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.data.domain.PageRequest;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@@ -43,6 +46,14 @@ public class PostReadService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public List<PostRead> getRecentReadsByUser(String username, int limit) {
|
||||||
|
User user = userRepository
|
||||||
|
.findByUsername(username)
|
||||||
|
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
|
||||||
|
Pageable pageable = PageRequest.of(0, limit);
|
||||||
|
return postReadRepository.findByUserOrderByLastReadAtDesc(user, pageable);
|
||||||
|
}
|
||||||
|
|
||||||
public long countReads(String username) {
|
public long countReads(String username) {
|
||||||
User user = userRepository
|
User user = userRepository
|
||||||
.findByUsername(username)
|
.findByUsername(username)
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
package com.openisle.service;
|
package com.openisle.service;
|
||||||
|
|
||||||
import com.openisle.config.CachingConfig;
|
import com.openisle.config.CachingConfig;
|
||||||
|
import com.openisle.exception.NotFoundException;
|
||||||
import com.openisle.exception.RateLimitException;
|
import com.openisle.exception.RateLimitException;
|
||||||
import com.openisle.mapper.PostMapper;
|
|
||||||
import com.openisle.model.*;
|
import com.openisle.model.*;
|
||||||
|
import com.openisle.repository.CategoryProposalPostRepository;
|
||||||
import com.openisle.repository.CategoryRepository;
|
import com.openisle.repository.CategoryRepository;
|
||||||
import com.openisle.repository.CommentRepository;
|
import com.openisle.repository.CommentRepository;
|
||||||
import com.openisle.repository.LotteryPostRepository;
|
import com.openisle.repository.LotteryPostRepository;
|
||||||
@@ -18,10 +19,10 @@ import com.openisle.repository.TagRepository;
|
|||||||
import com.openisle.repository.UserRepository;
|
import com.openisle.repository.UserRepository;
|
||||||
import com.openisle.search.SearchIndexEventPublisher;
|
import com.openisle.search.SearchIndexEventPublisher;
|
||||||
import com.openisle.service.EmailSender;
|
import com.openisle.service.EmailSender;
|
||||||
|
import com.openisle.exception.EmailSendException;
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.time.ZoneId;
|
import java.time.ZoneId;
|
||||||
import java.time.ZoneOffset;
|
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
import java.util.concurrent.ConcurrentMap;
|
import java.util.concurrent.ConcurrentMap;
|
||||||
@@ -32,7 +33,6 @@ import org.apache.commons.lang3.StringUtils;
|
|||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
||||||
import org.springframework.cache.annotation.CacheEvict;
|
import org.springframework.cache.annotation.CacheEvict;
|
||||||
import org.springframework.cache.annotation.Cacheable;
|
|
||||||
import org.springframework.context.ApplicationContext;
|
import org.springframework.context.ApplicationContext;
|
||||||
import org.springframework.context.event.EventListener;
|
import org.springframework.context.event.EventListener;
|
||||||
import org.springframework.data.domain.PageRequest;
|
import org.springframework.data.domain.PageRequest;
|
||||||
@@ -54,6 +54,7 @@ public class PostService {
|
|||||||
private final TagRepository tagRepository;
|
private final TagRepository tagRepository;
|
||||||
private final LotteryPostRepository lotteryPostRepository;
|
private final LotteryPostRepository lotteryPostRepository;
|
||||||
private final PollPostRepository pollPostRepository;
|
private final PollPostRepository pollPostRepository;
|
||||||
|
private final CategoryProposalPostRepository categoryProposalPostRepository;
|
||||||
private final PollVoteRepository pollVoteRepository;
|
private final PollVoteRepository pollVoteRepository;
|
||||||
private PublishMode publishMode;
|
private PublishMode publishMode;
|
||||||
private final NotificationService notificationService;
|
private final NotificationService notificationService;
|
||||||
@@ -71,11 +72,17 @@ public class PostService {
|
|||||||
private final PointService pointService;
|
private final PointService pointService;
|
||||||
private final PostChangeLogService postChangeLogService;
|
private final PostChangeLogService postChangeLogService;
|
||||||
private final PointHistoryRepository pointHistoryRepository;
|
private final PointHistoryRepository pointHistoryRepository;
|
||||||
|
private final CategoryService categoryService;
|
||||||
private final ConcurrentMap<Long, ScheduledFuture<?>> scheduledFinalizations =
|
private final ConcurrentMap<Long, ScheduledFuture<?>> scheduledFinalizations =
|
||||||
new ConcurrentHashMap<>();
|
new ConcurrentHashMap<>();
|
||||||
|
|
||||||
private final SearchIndexEventPublisher searchIndexEventPublisher;
|
private final SearchIndexEventPublisher searchIndexEventPublisher;
|
||||||
|
|
||||||
|
private static final int DEFAULT_PROPOSAL_APPROVE_THRESHOLD = 60;
|
||||||
|
private static final int DEFAULT_PROPOSAL_QUORUM = 10;
|
||||||
|
private static final long DEFAULT_PROPOSAL_DURATION_DAYS = 3;
|
||||||
|
private static final List<String> DEFAULT_PROPOSAL_OPTIONS = List.of("同意", "反对");
|
||||||
|
|
||||||
@Value("${app.website-url:https://www.open-isle.com}")
|
@Value("${app.website-url:https://www.open-isle.com}")
|
||||||
private String websiteUrl;
|
private String websiteUrl;
|
||||||
|
|
||||||
@@ -89,6 +96,7 @@ public class PostService {
|
|||||||
TagRepository tagRepository,
|
TagRepository tagRepository,
|
||||||
LotteryPostRepository lotteryPostRepository,
|
LotteryPostRepository lotteryPostRepository,
|
||||||
PollPostRepository pollPostRepository,
|
PollPostRepository pollPostRepository,
|
||||||
|
CategoryProposalPostRepository categoryProposalPostRepository,
|
||||||
PollVoteRepository pollVoteRepository,
|
PollVoteRepository pollVoteRepository,
|
||||||
NotificationService notificationService,
|
NotificationService notificationService,
|
||||||
SubscriptionService subscriptionService,
|
SubscriptionService subscriptionService,
|
||||||
@@ -107,7 +115,8 @@ public class PostService {
|
|||||||
PointHistoryRepository pointHistoryRepository,
|
PointHistoryRepository pointHistoryRepository,
|
||||||
@Value("${app.post.publish-mode:DIRECT}") PublishMode publishMode,
|
@Value("${app.post.publish-mode:DIRECT}") PublishMode publishMode,
|
||||||
RedisTemplate redisTemplate,
|
RedisTemplate redisTemplate,
|
||||||
SearchIndexEventPublisher searchIndexEventPublisher
|
SearchIndexEventPublisher searchIndexEventPublisher,
|
||||||
|
CategoryService categoryService
|
||||||
) {
|
) {
|
||||||
this.postRepository = postRepository;
|
this.postRepository = postRepository;
|
||||||
this.userRepository = userRepository;
|
this.userRepository = userRepository;
|
||||||
@@ -115,6 +124,7 @@ public class PostService {
|
|||||||
this.tagRepository = tagRepository;
|
this.tagRepository = tagRepository;
|
||||||
this.lotteryPostRepository = lotteryPostRepository;
|
this.lotteryPostRepository = lotteryPostRepository;
|
||||||
this.pollPostRepository = pollPostRepository;
|
this.pollPostRepository = pollPostRepository;
|
||||||
|
this.categoryProposalPostRepository = categoryProposalPostRepository;
|
||||||
this.pollVoteRepository = pollVoteRepository;
|
this.pollVoteRepository = pollVoteRepository;
|
||||||
this.notificationService = notificationService;
|
this.notificationService = notificationService;
|
||||||
this.subscriptionService = subscriptionService;
|
this.subscriptionService = subscriptionService;
|
||||||
@@ -135,6 +145,7 @@ public class PostService {
|
|||||||
|
|
||||||
this.redisTemplate = redisTemplate;
|
this.redisTemplate = redisTemplate;
|
||||||
this.searchIndexEventPublisher = searchIndexEventPublisher;
|
this.searchIndexEventPublisher = searchIndexEventPublisher;
|
||||||
|
this.categoryService = categoryService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@EventListener(ApplicationReadyEvent.class)
|
@EventListener(ApplicationReadyEvent.class)
|
||||||
@@ -160,6 +171,24 @@ public class PostService {
|
|||||||
for (PollPost pp : pollPostRepository.findByEndTimeBeforeAndResultAnnouncedFalse(now)) {
|
for (PollPost pp : pollPostRepository.findByEndTimeBeforeAndResultAnnouncedFalse(now)) {
|
||||||
applicationContext.getBean(PostService.class).finalizePoll(pp.getId());
|
applicationContext.getBean(PostService.class).finalizePoll(pp.getId());
|
||||||
}
|
}
|
||||||
|
for (CategoryProposalPost cp : categoryProposalPostRepository.findByEndTimeAfterAndProposalStatus(
|
||||||
|
now,
|
||||||
|
CategoryProposalStatus.PENDING
|
||||||
|
)) {
|
||||||
|
if (cp.getEndTime() != null) {
|
||||||
|
ScheduledFuture<?> future = taskScheduler.schedule(
|
||||||
|
() -> applicationContext.getBean(PostService.class).finalizeProposal(cp.getId()),
|
||||||
|
java.util.Date.from(cp.getEndTime().atZone(ZoneId.systemDefault()).toInstant())
|
||||||
|
);
|
||||||
|
scheduledFinalizations.put(cp.getId(), future);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (CategoryProposalPost cp : categoryProposalPostRepository.findByEndTimeBeforeAndProposalStatus(
|
||||||
|
now,
|
||||||
|
CategoryProposalStatus.PENDING
|
||||||
|
)) {
|
||||||
|
applicationContext.getBean(PostService.class).finalizeProposal(cp.getId());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public PublishMode getPublishMode() {
|
public PublishMode getPublishMode() {
|
||||||
@@ -225,6 +254,7 @@ public class PostService {
|
|||||||
String content,
|
String content,
|
||||||
List<Long> tagIds,
|
List<Long> tagIds,
|
||||||
PostType type,
|
PostType type,
|
||||||
|
PostVisibleScopeType postVisibleScopeType,
|
||||||
String prizeDescription,
|
String prizeDescription,
|
||||||
String prizeIcon,
|
String prizeIcon,
|
||||||
Integer prizeCount,
|
Integer prizeCount,
|
||||||
@@ -232,10 +262,12 @@ public class PostService {
|
|||||||
LocalDateTime startTime,
|
LocalDateTime startTime,
|
||||||
LocalDateTime endTime,
|
LocalDateTime endTime,
|
||||||
java.util.List<String> options,
|
java.util.List<String> options,
|
||||||
Boolean multiple
|
Boolean multiple,
|
||||||
|
String proposedName,
|
||||||
|
String proposalDescription
|
||||||
) {
|
) {
|
||||||
// 限制访问次数
|
// 限制访问次数
|
||||||
boolean limitResult = postRateLimit(username);
|
boolean limitResult = isPostLimitReached(username);
|
||||||
if (!limitResult) {
|
if (!limitResult) {
|
||||||
throw new RateLimitException("Too many posts");
|
throw new RateLimitException("Too many posts");
|
||||||
}
|
}
|
||||||
@@ -278,6 +310,25 @@ public class PostService {
|
|||||||
pp.setEndTime(endTime);
|
pp.setEndTime(endTime);
|
||||||
pp.setMultiple(multiple != null && multiple);
|
pp.setMultiple(multiple != null && multiple);
|
||||||
post = pp;
|
post = pp;
|
||||||
|
} else if (actualType == PostType.PROPOSAL) {
|
||||||
|
CategoryProposalPost cp = new CategoryProposalPost();
|
||||||
|
if (proposedName == null || proposedName.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("Proposed name required");
|
||||||
|
}
|
||||||
|
String normalizedName = proposedName.trim();
|
||||||
|
if (categoryProposalPostRepository.existsByProposedNameIgnoreCase(normalizedName)) {
|
||||||
|
throw new IllegalArgumentException("Proposed name already exists: " + normalizedName);
|
||||||
|
}
|
||||||
|
cp.setProposedName(normalizedName);
|
||||||
|
cp.setDescription(proposalDescription);
|
||||||
|
cp.setApproveThreshold(DEFAULT_PROPOSAL_APPROVE_THRESHOLD);
|
||||||
|
cp.setQuorum(DEFAULT_PROPOSAL_QUORUM);
|
||||||
|
LocalDateTime now = LocalDateTime.now();
|
||||||
|
cp.setStartAt(now);
|
||||||
|
cp.setEndTime(now.plusDays(DEFAULT_PROPOSAL_DURATION_DAYS));
|
||||||
|
cp.setOptions(new ArrayList<>(DEFAULT_PROPOSAL_OPTIONS));
|
||||||
|
cp.setMultiple(false);
|
||||||
|
post = cp;
|
||||||
} else {
|
} else {
|
||||||
post = new Post();
|
post = new Post();
|
||||||
}
|
}
|
||||||
@@ -288,8 +339,19 @@ public class PostService {
|
|||||||
post.setCategory(category);
|
post.setCategory(category);
|
||||||
post.setTags(new HashSet<>(tags));
|
post.setTags(new HashSet<>(tags));
|
||||||
post.setStatus(publishMode == PublishMode.REVIEW ? PostStatus.PENDING : PostStatus.PUBLISHED);
|
post.setStatus(publishMode == PublishMode.REVIEW ? PostStatus.PENDING : PostStatus.PUBLISHED);
|
||||||
|
post.setLastReplyAt(LocalDateTime.now());
|
||||||
|
|
||||||
|
// 什么都没设置的情况下,默认为ALL
|
||||||
|
if (Objects.isNull(postVisibleScopeType)) {
|
||||||
|
post.setVisibleScope(PostVisibleScopeType.ALL);
|
||||||
|
} else {
|
||||||
|
post.setVisibleScope(postVisibleScopeType);
|
||||||
|
}
|
||||||
|
|
||||||
if (post instanceof LotteryPost) {
|
if (post instanceof LotteryPost) {
|
||||||
post = lotteryPostRepository.save((LotteryPost) post);
|
post = lotteryPostRepository.save((LotteryPost) post);
|
||||||
|
} else if (post instanceof CategoryProposalPost categoryProposalPost) {
|
||||||
|
post = categoryProposalPostRepository.save(categoryProposalPost);
|
||||||
} else if (post instanceof PollPost) {
|
} else if (post instanceof PollPost) {
|
||||||
post = pollPostRepository.save((PollPost) post);
|
post = pollPostRepository.save((PollPost) post);
|
||||||
} else {
|
} else {
|
||||||
@@ -344,6 +406,12 @@ public class PostService {
|
|||||||
java.util.Date.from(lp.getEndTime().atZone(ZoneId.systemDefault()).toInstant())
|
java.util.Date.from(lp.getEndTime().atZone(ZoneId.systemDefault()).toInstant())
|
||||||
);
|
);
|
||||||
scheduledFinalizations.put(lp.getId(), future);
|
scheduledFinalizations.put(lp.getId(), future);
|
||||||
|
} else if (post instanceof CategoryProposalPost cp && cp.getEndTime() != null) {
|
||||||
|
ScheduledFuture<?> future = taskScheduler.schedule(
|
||||||
|
() -> applicationContext.getBean(PostService.class).finalizeProposal(cp.getId()),
|
||||||
|
java.util.Date.from(cp.getEndTime().atZone(ZoneId.systemDefault()).toInstant())
|
||||||
|
);
|
||||||
|
scheduledFinalizations.put(cp.getId(), future);
|
||||||
} else if (post instanceof PollPost pp && pp.getEndTime() != null) {
|
} else if (post instanceof PollPost pp && pp.getEndTime() != null) {
|
||||||
ScheduledFuture<?> future = taskScheduler.schedule(
|
ScheduledFuture<?> future = taskScheduler.schedule(
|
||||||
() -> applicationContext.getBean(PostService.class).finalizePoll(pp.getId()),
|
() -> applicationContext.getBean(PostService.class).finalizePoll(pp.getId()),
|
||||||
@@ -354,24 +422,110 @@ public class PostService {
|
|||||||
if (post.getStatus() == PostStatus.PUBLISHED) {
|
if (post.getStatus() == PostStatus.PUBLISHED) {
|
||||||
searchIndexEventPublisher.publishPostSaved(post);
|
searchIndexEventPublisher.publishPostSaved(post);
|
||||||
}
|
}
|
||||||
|
markPostLimit(author.getUsername());
|
||||||
return post;
|
return post;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@CacheEvict(value = CachingConfig.POST_CACHE_NAME, allEntries = true)
|
||||||
|
@Transactional
|
||||||
|
public void finalizeProposal(Long postId) {
|
||||||
|
scheduledFinalizations.remove(postId);
|
||||||
|
categoryProposalPostRepository
|
||||||
|
.findById(postId)
|
||||||
|
.ifPresent(cp -> {
|
||||||
|
if (cp.getProposalStatus() != CategoryProposalStatus.PENDING) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
int totalParticipants = cp.getParticipants() != null ? cp.getParticipants().size() : 0;
|
||||||
|
int approveVotes = 0;
|
||||||
|
if (cp.getVotes() != null) {
|
||||||
|
approveVotes = cp.getVotes().getOrDefault(0, 0);
|
||||||
|
}
|
||||||
|
boolean quorumMet = totalParticipants >= cp.getQuorum();
|
||||||
|
int approvePercent = totalParticipants > 0 ? (approveVotes * 100) / totalParticipants : 0;
|
||||||
|
boolean thresholdMet = approvePercent >= cp.getApproveThreshold();
|
||||||
|
boolean approved = false;
|
||||||
|
String rejectReason = null;
|
||||||
|
if (quorumMet && thresholdMet) {
|
||||||
|
cp.setProposalStatus(CategoryProposalStatus.APPROVED);
|
||||||
|
approved = true;
|
||||||
|
} else {
|
||||||
|
cp.setProposalStatus(CategoryProposalStatus.REJECTED);
|
||||||
|
String reason;
|
||||||
|
if (!quorumMet && !thresholdMet) {
|
||||||
|
reason = "未达到法定人数且赞成率不足";
|
||||||
|
} else if (!quorumMet) {
|
||||||
|
reason = "未达到法定人数";
|
||||||
|
} else {
|
||||||
|
reason = "赞成率不足";
|
||||||
|
}
|
||||||
|
cp.setRejectReason(reason);
|
||||||
|
rejectReason = reason;
|
||||||
|
}
|
||||||
|
cp.setResultSnapshot(
|
||||||
|
"approveVotes=" +
|
||||||
|
approveVotes +
|
||||||
|
", totalParticipants=" +
|
||||||
|
totalParticipants +
|
||||||
|
", approvePercent=" +
|
||||||
|
approvePercent
|
||||||
|
);
|
||||||
|
categoryProposalPostRepository.save(cp);
|
||||||
|
if (approved) {
|
||||||
|
categoryService.createCategory(cp.getProposedName(), cp.getDescription(), "star", null);
|
||||||
|
}
|
||||||
|
if (cp.getAuthor() != null) {
|
||||||
|
notificationService.createNotification(
|
||||||
|
cp.getAuthor(),
|
||||||
|
NotificationType.CATEGORY_PROPOSAL_RESULT_OWNER,
|
||||||
|
cp,
|
||||||
|
null,
|
||||||
|
approved,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
approved ? null : rejectReason
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (User participant : cp.getParticipants()) {
|
||||||
|
if (
|
||||||
|
cp.getAuthor() != null &&
|
||||||
|
java.util.Objects.equals(participant.getId(), cp.getAuthor().getId())
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
notificationService.createNotification(
|
||||||
|
participant,
|
||||||
|
NotificationType.CATEGORY_PROPOSAL_RESULT_PARTICIPANT,
|
||||||
|
cp,
|
||||||
|
null,
|
||||||
|
approved,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
approved ? null : rejectReason
|
||||||
|
);
|
||||||
|
}
|
||||||
|
postChangeLogService.recordVoteResult(cp);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 限制发帖频率
|
* 检查用户是否达到发帖限制
|
||||||
* @param username
|
* @param username
|
||||||
* @return
|
* @return true - 允许发帖,false - 已达限制
|
||||||
*/
|
*/
|
||||||
private boolean postRateLimit(String username) {
|
private boolean isPostLimitReached(String username) {
|
||||||
String key = CachingConfig.LIMIT_CACHE_NAME + ":posts:" + username;
|
String key = CachingConfig.LIMIT_CACHE_NAME + ":posts:" + username;
|
||||||
String result = (String) redisTemplate.opsForValue().get(key);
|
String result = (String) redisTemplate.opsForValue().get(key);
|
||||||
//最近没有创建过文章
|
return StringUtils.isEmpty(result);
|
||||||
if (StringUtils.isEmpty(result)) {
|
}
|
||||||
// 限制频率为5分钟
|
|
||||||
redisTemplate.opsForValue().set(key, "1", Duration.ofMinutes(5));
|
/**
|
||||||
return true;
|
* 标记用户发帖,触发limit计时
|
||||||
}
|
* @param username
|
||||||
return false;
|
*/
|
||||||
|
private void markPostLimit(String username) {
|
||||||
|
String key = CachingConfig.LIMIT_CACHE_NAME + ":posts:" + username;
|
||||||
|
redisTemplate.opsForValue().set(key, "1", Duration.ofMinutes(5));
|
||||||
}
|
}
|
||||||
|
|
||||||
@CacheEvict(value = CachingConfig.POST_CACHE_NAME, allEntries = true)
|
@CacheEvict(value = CachingConfig.POST_CACHE_NAME, allEntries = true)
|
||||||
@@ -450,6 +604,9 @@ public class PostService {
|
|||||||
pollPostRepository
|
pollPostRepository
|
||||||
.findById(postId)
|
.findById(postId)
|
||||||
.ifPresent(pp -> {
|
.ifPresent(pp -> {
|
||||||
|
if (pp instanceof CategoryProposalPost) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (pp.isResultAnnounced()) {
|
if (pp.isResultAnnounced()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -508,11 +665,15 @@ public class PostService {
|
|||||||
w.getEmail() != null &&
|
w.getEmail() != null &&
|
||||||
!w.getDisabledEmailNotificationTypes().contains(NotificationType.LOTTERY_WIN)
|
!w.getDisabledEmailNotificationTypes().contains(NotificationType.LOTTERY_WIN)
|
||||||
) {
|
) {
|
||||||
emailSender.sendEmail(
|
try {
|
||||||
w.getEmail(),
|
emailSender.sendEmail(
|
||||||
"你中奖了",
|
w.getEmail(),
|
||||||
"恭喜你在抽奖贴 \"" + lp.getTitle() + "\" 中获奖"
|
"你中奖了",
|
||||||
);
|
"恭喜你在抽奖贴 \"" + lp.getTitle() + "\" 中获奖"
|
||||||
|
);
|
||||||
|
} catch (EmailSendException e) {
|
||||||
|
log.warn("Failed to send lottery win email to {}: {}", w.getEmail(), e.getMessage());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
notificationService.createNotification(
|
notificationService.createNotification(
|
||||||
w,
|
w,
|
||||||
@@ -538,11 +699,19 @@ public class PostService {
|
|||||||
.getDisabledEmailNotificationTypes()
|
.getDisabledEmailNotificationTypes()
|
||||||
.contains(NotificationType.LOTTERY_DRAW)
|
.contains(NotificationType.LOTTERY_DRAW)
|
||||||
) {
|
) {
|
||||||
emailSender.sendEmail(
|
try {
|
||||||
lp.getAuthor().getEmail(),
|
emailSender.sendEmail(
|
||||||
"抽奖已开奖",
|
lp.getAuthor().getEmail(),
|
||||||
"您的抽奖贴 \"" + lp.getTitle() + "\" 已开奖"
|
"抽奖已开奖",
|
||||||
);
|
"您的抽奖贴 \"" + lp.getTitle() + "\" 已开奖"
|
||||||
|
);
|
||||||
|
} catch (EmailSendException e) {
|
||||||
|
log.warn(
|
||||||
|
"Failed to send lottery draw email to {}: {}",
|
||||||
|
lp.getAuthor().getEmail(),
|
||||||
|
e.getMessage()
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
notificationService.createNotification(
|
notificationService.createNotification(
|
||||||
lp.getAuthor(),
|
lp.getAuthor(),
|
||||||
@@ -571,7 +740,7 @@ public class PostService {
|
|||||||
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found"));
|
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found"));
|
||||||
if (post.getStatus() != PostStatus.PUBLISHED) {
|
if (post.getStatus() != PostStatus.PUBLISHED) {
|
||||||
if (viewer == null) {
|
if (viewer == null) {
|
||||||
throw new com.openisle.exception.NotFoundException("Post not found");
|
throw new com.openisle.exception.NotFoundException("User not found");
|
||||||
}
|
}
|
||||||
User viewerUser = userRepository
|
User viewerUser = userRepository
|
||||||
.findByUsername(viewer)
|
.findByUsername(viewer)
|
||||||
@@ -615,6 +784,18 @@ public class PostService {
|
|||||||
return listPostsByCategories(null, null, null);
|
return listPostsByCategories(null, null, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public List<Post> listRecentPosts(int minutes) {
|
||||||
|
if (minutes <= 0) {
|
||||||
|
throw new IllegalArgumentException("Minutes must be positive");
|
||||||
|
}
|
||||||
|
LocalDateTime since = LocalDateTime.now().minusMinutes(minutes);
|
||||||
|
List<Post> posts = postRepository.findByStatusAndCreatedAtGreaterThanEqualOrderByCreatedAtDesc(
|
||||||
|
PostStatus.PUBLISHED,
|
||||||
|
since
|
||||||
|
);
|
||||||
|
return sortByPinnedAndCreated(posts);
|
||||||
|
}
|
||||||
|
|
||||||
public List<Post> listPostsByViews(Integer page, Integer pageSize) {
|
public List<Post> listPostsByViews(Integer page, Integer pageSize) {
|
||||||
return listPostsByViews(null, null, page, pageSize);
|
return listPostsByViews(null, null, page, pageSize);
|
||||||
}
|
}
|
||||||
@@ -629,9 +810,10 @@ public class PostService {
|
|||||||
boolean hasTags = tagIds != null && !tagIds.isEmpty();
|
boolean hasTags = tagIds != null && !tagIds.isEmpty();
|
||||||
|
|
||||||
java.util.List<Post> posts;
|
java.util.List<Post> posts;
|
||||||
|
Pageable pageable = buildPageable(page, pageSize);
|
||||||
|
|
||||||
if (!hasCategories && !hasTags) {
|
if (!hasCategories && !hasTags) {
|
||||||
posts = postRepository.findByStatusOrderByViewsDesc(PostStatus.PUBLISHED);
|
posts = postRepository.findByStatusOrderByPinnedAtDescViewsDesc(PostStatus.PUBLISHED, pageable);
|
||||||
} else if (hasCategories) {
|
} else if (hasCategories) {
|
||||||
java.util.List<Category> categories = categoryRepository.findAllById(categoryIds);
|
java.util.List<Category> categories = categoryRepository.findAllById(categoryIds);
|
||||||
if (categories.isEmpty()) {
|
if (categories.isEmpty()) {
|
||||||
@@ -642,16 +824,18 @@ public class PostService {
|
|||||||
if (tags.isEmpty()) {
|
if (tags.isEmpty()) {
|
||||||
return java.util.List.of();
|
return java.util.List.of();
|
||||||
}
|
}
|
||||||
posts = postRepository.findByCategoriesAndAllTagsOrderByViewsDesc(
|
posts = postRepository.findByCategoriesAndAllTagsOrderByPinnedAtDescViewsDesc(
|
||||||
categories,
|
categories,
|
||||||
tags,
|
tags,
|
||||||
PostStatus.PUBLISHED,
|
PostStatus.PUBLISHED,
|
||||||
tags.size()
|
tags.size(),
|
||||||
|
pageable
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
posts = postRepository.findByCategoryInAndStatusOrderByViewsDesc(
|
posts = postRepository.findByCategoryInAndStatusOrderByPinnedAtDescViewsDesc(
|
||||||
categories,
|
categories,
|
||||||
PostStatus.PUBLISHED
|
PostStatus.PUBLISHED,
|
||||||
|
pageable
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -659,10 +843,15 @@ public class PostService {
|
|||||||
if (tags.isEmpty()) {
|
if (tags.isEmpty()) {
|
||||||
return java.util.List.of();
|
return java.util.List.of();
|
||||||
}
|
}
|
||||||
posts = postRepository.findByAllTagsOrderByViewsDesc(tags, PostStatus.PUBLISHED, tags.size());
|
posts = postRepository.findByAllTagsOrderByPinnedAtDescViewsDesc(
|
||||||
|
tags,
|
||||||
|
PostStatus.PUBLISHED,
|
||||||
|
tags.size(),
|
||||||
|
pageable
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return paginate(sortByPinnedAndViews(posts), page, pageSize);
|
return posts;
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<Post> listPostsByLatestReply(Integer page, Integer pageSize) {
|
public List<Post> listPostsByLatestReply(Integer page, Integer pageSize) {
|
||||||
@@ -679,9 +868,13 @@ public class PostService {
|
|||||||
boolean hasTags = tagIds != null && !tagIds.isEmpty();
|
boolean hasTags = tagIds != null && !tagIds.isEmpty();
|
||||||
|
|
||||||
java.util.List<Post> posts;
|
java.util.List<Post> posts;
|
||||||
|
Pageable pageable = buildPageable(page, pageSize);
|
||||||
|
|
||||||
if (!hasCategories && !hasTags) {
|
if (!hasCategories && !hasTags) {
|
||||||
posts = postRepository.findByStatusOrderByCreatedAtDesc(PostStatus.PUBLISHED);
|
posts = postRepository.findByStatusOrderByPinnedAtDescLastReplyAtDesc(
|
||||||
|
PostStatus.PUBLISHED,
|
||||||
|
pageable
|
||||||
|
);
|
||||||
} else if (hasCategories) {
|
} else if (hasCategories) {
|
||||||
java.util.List<Category> categories = categoryRepository.findAllById(categoryIds);
|
java.util.List<Category> categories = categoryRepository.findAllById(categoryIds);
|
||||||
if (categories.isEmpty()) {
|
if (categories.isEmpty()) {
|
||||||
@@ -692,16 +885,18 @@ public class PostService {
|
|||||||
if (tags.isEmpty()) {
|
if (tags.isEmpty()) {
|
||||||
return java.util.List.of();
|
return java.util.List.of();
|
||||||
}
|
}
|
||||||
posts = postRepository.findByCategoriesAndAllTagsOrderByCreatedAtDesc(
|
posts = postRepository.findByCategoriesAndAllTagsOrderByPinnedAtDescLastReplyAtDesc(
|
||||||
categories,
|
categories,
|
||||||
tags,
|
tags,
|
||||||
PostStatus.PUBLISHED,
|
PostStatus.PUBLISHED,
|
||||||
tags.size()
|
tags.size(),
|
||||||
|
pageable
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
posts = postRepository.findByCategoryInAndStatusOrderByCreatedAtDesc(
|
posts = postRepository.findByCategoryInAndStatusOrderByPinnedAtDescLastReplyAtDesc(
|
||||||
categories,
|
categories,
|
||||||
PostStatus.PUBLISHED
|
PostStatus.PUBLISHED,
|
||||||
|
pageable
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -709,14 +904,15 @@ public class PostService {
|
|||||||
if (tags.isEmpty()) {
|
if (tags.isEmpty()) {
|
||||||
return new ArrayList<>();
|
return new ArrayList<>();
|
||||||
}
|
}
|
||||||
posts = postRepository.findByAllTagsOrderByCreatedAtDesc(
|
posts = postRepository.findByAllTagsOrderByPinnedAtDescLastReplyAtDesc(
|
||||||
tags,
|
tags,
|
||||||
PostStatus.PUBLISHED,
|
PostStatus.PUBLISHED,
|
||||||
tags.size()
|
tags.size(),
|
||||||
|
pageable
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return paginate(sortByPinnedAndLastReply(posts), page, pageSize);
|
return posts;
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<Post> listPostsByCategories(
|
public List<Post> listPostsByCategories(
|
||||||
@@ -1002,7 +1198,8 @@ public class PostService {
|
|||||||
Long categoryId,
|
Long categoryId,
|
||||||
String title,
|
String title,
|
||||||
String content,
|
String content,
|
||||||
java.util.List<Long> tagIds
|
List<Long> tagIds,
|
||||||
|
PostVisibleScopeType postVisibleScopeType
|
||||||
) {
|
) {
|
||||||
if (tagIds == null || tagIds.isEmpty()) {
|
if (tagIds == null || tagIds.isEmpty()) {
|
||||||
throw new IllegalArgumentException("At least one tag required");
|
throw new IllegalArgumentException("At least one tag required");
|
||||||
@@ -1034,6 +1231,8 @@ public class PostService {
|
|||||||
post.setContent(content);
|
post.setContent(content);
|
||||||
post.setCategory(category);
|
post.setCategory(category);
|
||||||
post.setTags(new java.util.HashSet<>(tags));
|
post.setTags(new java.util.HashSet<>(tags));
|
||||||
|
PostVisibleScopeType oldVisibleScope = post.getVisibleScope();
|
||||||
|
post.setVisibleScope(postVisibleScopeType);
|
||||||
Post updated = postRepository.save(post);
|
Post updated = postRepository.save(post);
|
||||||
imageUploader.adjustReferences(oldContent, content);
|
imageUploader.adjustReferences(oldContent, content);
|
||||||
notificationService.notifyMentions(content, user, updated, null);
|
notificationService.notifyMentions(content, user, updated, null);
|
||||||
@@ -1055,6 +1254,14 @@ public class PostService {
|
|||||||
if (!oldTags.equals(newTags)) {
|
if (!oldTags.equals(newTags)) {
|
||||||
postChangeLogService.recordTagChange(updated, user, oldTags, newTags);
|
postChangeLogService.recordTagChange(updated, user, oldTags, newTags);
|
||||||
}
|
}
|
||||||
|
if (!java.util.Objects.equals(oldVisibleScope, postVisibleScopeType)) {
|
||||||
|
postChangeLogService.recordVisibleScopeChange(
|
||||||
|
updated,
|
||||||
|
user,
|
||||||
|
oldVisibleScope,
|
||||||
|
postVisibleScopeType
|
||||||
|
);
|
||||||
|
}
|
||||||
if (updated.getStatus() == PostStatus.PUBLISHED) {
|
if (updated.getStatus() == PostStatus.PUBLISHED) {
|
||||||
searchIndexEventPublisher.publishPostSaved(updated);
|
searchIndexEventPublisher.publishPostSaved(updated);
|
||||||
}
|
}
|
||||||
@@ -1203,6 +1410,13 @@ public class PostService {
|
|||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private Pageable buildPageable(Integer page, Integer pageSize) {
|
||||||
|
if (page == null || pageSize == null) {
|
||||||
|
return Pageable.unpaged();
|
||||||
|
}
|
||||||
|
return PageRequest.of(page, pageSize);
|
||||||
|
}
|
||||||
|
|
||||||
private List<Post> paginate(List<Post> posts, Integer page, Integer pageSize) {
|
private List<Post> paginate(List<Post> posts, Integer page, Integer pageSize) {
|
||||||
if (page == null || pageSize == null) {
|
if (page == null || pageSize == null) {
|
||||||
return posts;
|
return posts;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.openisle.service;
|
package com.openisle.service;
|
||||||
|
|
||||||
|
import com.openisle.exception.EmailSendException;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
@@ -7,8 +8,9 @@ import org.springframework.http.HttpEntity;
|
|||||||
import org.springframework.http.HttpHeaders;
|
import org.springframework.http.HttpHeaders;
|
||||||
import org.springframework.http.HttpMethod;
|
import org.springframework.http.HttpMethod;
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
import org.springframework.scheduling.annotation.Async;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.web.client.RestClientException;
|
||||||
import org.springframework.web.client.RestTemplate;
|
import org.springframework.web.client.RestTemplate;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@@ -23,7 +25,6 @@ public class ResendEmailSender extends EmailSender {
|
|||||||
private final RestTemplate restTemplate = new RestTemplate();
|
private final RestTemplate restTemplate = new RestTemplate();
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@Async("notificationExecutor")
|
|
||||||
public void sendEmail(String to, String subject, String text) {
|
public void sendEmail(String to, String subject, String text) {
|
||||||
String url = "https://api.resend.com/emails"; // hypothetical endpoint
|
String url = "https://api.resend.com/emails"; // hypothetical endpoint
|
||||||
|
|
||||||
@@ -38,6 +39,20 @@ public class ResendEmailSender extends EmailSender {
|
|||||||
body.put("from", "openisle <" + fromEmail + ">");
|
body.put("from", "openisle <" + fromEmail + ">");
|
||||||
|
|
||||||
HttpEntity<Map<String, String>> entity = new HttpEntity<>(body, headers);
|
HttpEntity<Map<String, String>> entity = new HttpEntity<>(body, headers);
|
||||||
restTemplate.exchange(url, HttpMethod.POST, entity, String.class);
|
try {
|
||||||
|
ResponseEntity<String> response = restTemplate.exchange(
|
||||||
|
url,
|
||||||
|
HttpMethod.POST,
|
||||||
|
entity,
|
||||||
|
String.class
|
||||||
|
);
|
||||||
|
if (!response.getStatusCode().is2xxSuccessful()) {
|
||||||
|
throw new EmailSendException(
|
||||||
|
"Email service returned status " + response.getStatusCodeValue()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (RestClientException e) {
|
||||||
|
throw new EmailSendException("Failed to send email: " + e.getMessage(), e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -118,7 +118,6 @@ public class UserService {
|
|||||||
* @param user
|
* @param user
|
||||||
*/
|
*/
|
||||||
public void sendVerifyMail(User user, VerifyType verifyType) {
|
public void sendVerifyMail(User user, VerifyType verifyType) {
|
||||||
// 缓存验证码
|
|
||||||
String code = genCode();
|
String code = genCode();
|
||||||
String key;
|
String key;
|
||||||
String subject;
|
String subject;
|
||||||
@@ -133,8 +132,9 @@ public class UserService {
|
|||||||
subject = "请填写验证码以重置密码(有效期为5分钟)";
|
subject = "请填写验证码以重置密码(有效期为5分钟)";
|
||||||
}
|
}
|
||||||
|
|
||||||
redisTemplate.opsForValue().set(key, code, 5, TimeUnit.MINUTES); // 五分钟后验证码过期
|
|
||||||
emailService.sendEmail(user.getEmail(), subject, content);
|
emailService.sendEmail(user.getEmail(), subject, content);
|
||||||
|
// 邮件发送成功后再缓存验证码,避免发送失败时用户收不到但验证被要求
|
||||||
|
redisTemplate.opsForValue().set(key, code, 5, TimeUnit.MINUTES); // 五分钟后验证码过期
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ spring.jpa.hibernate.ddl-auto=update
|
|||||||
spring.data.redis.host=${REDIS_HOST:localhost}
|
spring.data.redis.host=${REDIS_HOST:localhost}
|
||||||
spring.data.redis.port=${REDIS_PORT:6379}
|
spring.data.redis.port=${REDIS_PORT:6379}
|
||||||
spring.data.redis.database=${REDIS_DATABASE:0}
|
spring.data.redis.database=${REDIS_DATABASE:0}
|
||||||
|
spring.data.redis.password=${REDIS_PASS: null}
|
||||||
|
|
||||||
# for jwt
|
# for jwt
|
||||||
app.jwt.secret=${JWT_SECRET:jwt_sec}
|
app.jwt.secret=${JWT_SECRET:jwt_sec}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ CREATE TABLE IF NOT EXISTS `users` (
|
|||||||
`username` varchar(50) NOT NULL,
|
`username` varchar(50) NOT NULL,
|
||||||
`verification_code` varchar(255) DEFAULT NULL,
|
`verification_code` varchar(255) DEFAULT NULL,
|
||||||
`verified` bit(1) DEFAULT NULL,
|
`verified` bit(1) DEFAULT NULL,
|
||||||
|
`is_bot` bit(1) NOT NULL DEFAULT b'0',
|
||||||
PRIMARY KEY (`id`),
|
PRIMARY KEY (`id`),
|
||||||
UNIQUE KEY `UK_users_email` (`email`),
|
UNIQUE KEY `UK_users_email` (`email`),
|
||||||
UNIQUE KEY `UK_users_username` (`username`)
|
UNIQUE KEY `UK_users_username` (`username`)
|
||||||
|
|||||||
@@ -8,10 +8,28 @@ DELETE FROM `users`;
|
|||||||
|
|
||||||
-- 插入用户,两个普通用户,一个管理员
|
-- 插入用户,两个普通用户,一个管理员
|
||||||
-- username:admin/user1/user2 password:123456
|
-- username:admin/user1/user2 password:123456
|
||||||
INSERT INTO `users` (`id`, `approved`, `avatar`, `created_at`, `display_medal`, `email`, `experience`, `introduction`, `password`, `password_reset_code`, `point`, `register_reason`, `role`, `username`, `verification_code`, `verified`) VALUES
|
INSERT INTO `users` (
|
||||||
(1, b'1', 'https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/image.png', '2025-09-01 16:08:17.426430', 'PIONEER', 'adminmail@openisle.com', 70, NULL, '$2a$10$x7HXjUyJTmrvqjnBlBQZH.vmfsC56NzTSWqQ6WqZqRjUO859EhviS', NULL, 110, '测试测试测试……', 'ADMIN', 'admin', NULL, b'1'),
|
`id`,
|
||||||
(2, b'1', 'https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/image.png', '2025-09-03 16:08:17.426430', 'PIONEER', 'usermail2@openisle.com', 70, NULL, '$2a$10$x7HXjUyJTmrvqjnBlBQZH.vmfsC56NzTSWqQ6WqZqRjUO859EhviS', NULL, 110, '测试测试测试……', 'USER', 'user1', NULL, b'1'),
|
`approved`,
|
||||||
(3, b'1', 'https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/image.png', '2025-09-02 17:21:21.617666', 'PIONEER', 'usermail1@openisle.com', 40, NULL, '$2a$10$x7HXjUyJTmrvqjnBlBQZH.vmfsC56NzTSWqQ6WqZqRjUO859EhviS', NULL, 40, '测试测试测试……', 'USER', 'user2', NULL, b'1');
|
`avatar`,
|
||||||
|
`created_at`,
|
||||||
|
`display_medal`,
|
||||||
|
`email`,
|
||||||
|
`experience`,
|
||||||
|
`introduction`,
|
||||||
|
`password`,
|
||||||
|
`password_reset_code`,
|
||||||
|
`point`,
|
||||||
|
`register_reason`,
|
||||||
|
`role`,
|
||||||
|
`username`,
|
||||||
|
`verification_code`,
|
||||||
|
`verified`,
|
||||||
|
`is_bot`
|
||||||
|
) VALUES
|
||||||
|
(1, b'1', 'https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/image.png', '2025-09-01 16:08:17.426430', 'PIONEER', 'adminmail@openisle.com', 70, NULL, '$2a$10$x7HXjUyJTmrvqjnBlBQZH.vmfsC56NzTSWqQ6WqZqRjUO859EhviS', NULL, 110, '测试测试测试……', 'ADMIN', 'admin', NULL, b'1', b'0'),
|
||||||
|
(2, b'1', 'https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/image.png', '2025-09-03 16:08:17.426430', 'PIONEER', 'usermail2@openisle.com', 70, NULL, '$2a$10$x7HXjUyJTmrvqjnBlBQZH.vmfsC56NzTSWqQ6WqZqRjUO859EhviS', NULL, 110, '测试测试测试……', 'USER', 'user1', NULL, b'1', b'0'),
|
||||||
|
(3, b'1', 'https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/image.png', '2025-09-02 17:21:21.617666', 'PIONEER', 'usermail1@openisle.com', 40, NULL, '$2a$10$x7HXjUyJTmrvqjnBlBQZH.vmfsC56NzTSWqQ6WqZqRjUO859EhviS', NULL, 40, '测试测试测试……', 'USER', 'user2', NULL, b'1', b'0');
|
||||||
|
|
||||||
INSERT INTO `categories` (`id`,`description`,`icon`,`name`,`small_icon`) VALUES
|
INSERT INTO `categories` (`id`,`description`,`icon`,`name`,`small_icon`) VALUES
|
||||||
(1,'测试用分类1','star','测试用分类1',NULL),
|
(1,'测试用分类1','star','测试用分类1',NULL),
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-- Backfill last_reply_at for posts without comments to preserve latest-reply ordering
|
||||||
|
UPDATE posts
|
||||||
|
SET last_reply_at = created_at
|
||||||
|
WHERE last_reply_at IS NULL;
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
-- Create table for category proposal posts (subclass of poll_posts)
|
||||||
|
CREATE TABLE IF NOT EXISTS category_proposal_posts (
|
||||||
|
post_id BIGINT NOT NULL,
|
||||||
|
status VARCHAR(50) NOT NULL,
|
||||||
|
proposed_name VARCHAR(255) NOT NULL,
|
||||||
|
proposed_slug VARCHAR(255) NOT NULL,
|
||||||
|
description VARCHAR(255),
|
||||||
|
approve_threshold INT NOT NULL DEFAULT 60,
|
||||||
|
quorum INT NOT NULL DEFAULT 10,
|
||||||
|
start_at DATETIME(6) NULL,
|
||||||
|
result_snapshot LONGTEXT NULL,
|
||||||
|
reject_reason VARCHAR(255),
|
||||||
|
PRIMARY KEY (post_id),
|
||||||
|
CONSTRAINT fk_category_proposal_posts_parent
|
||||||
|
FOREIGN KEY (post_id) REFERENCES poll_posts (post_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_category_proposal_posts_status
|
||||||
|
ON category_proposal_posts (status);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_category_proposal_posts_slug
|
||||||
|
ON category_proposal_posts (proposed_slug);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE posts ADD COLUMN visible_scope ENUM('ALL', 'ONLY_ME', 'ONLY_REGISTER') NOT NULL DEFAULT 'ALL'
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
ALTER TABLE category_proposal_posts
|
||||||
|
DROP INDEX idx_category_proposal_posts_slug;
|
||||||
|
|
||||||
|
ALTER TABLE category_proposal_posts
|
||||||
|
DROP COLUMN proposed_slug;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_category_proposal_posts_name
|
||||||
|
ON category_proposal_posts (proposed_name);
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE users
|
||||||
|
ADD COLUMN is_bot BIT(1) NOT NULL DEFAULT b'0';
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS post_reads (
|
||||||
|
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||||
|
user_id BIGINT NOT NULL,
|
||||||
|
post_id BIGINT NOT NULL,
|
||||||
|
last_read_at DATETIME(6) DEFAULT NULL,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY UK_post_reads_user_post (user_id, post_id),
|
||||||
|
KEY IDX_post_reads_user (user_id),
|
||||||
|
KEY IDX_post_reads_post (post_id),
|
||||||
|
CONSTRAINT FK_post_reads_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT FK_post_reads_post FOREIGN KEY (post_id) REFERENCES posts (id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
@@ -76,6 +76,15 @@ class PostControllerTest {
|
|||||||
@MockBean
|
@MockBean
|
||||||
private MedalService medalService;
|
private MedalService medalService;
|
||||||
|
|
||||||
|
@MockBean
|
||||||
|
private CategoryService categoryService;
|
||||||
|
|
||||||
|
@MockBean
|
||||||
|
private TagService tagService;
|
||||||
|
|
||||||
|
@MockBean
|
||||||
|
private PointService pointService;
|
||||||
|
|
||||||
@MockBean
|
@MockBean
|
||||||
private com.openisle.repository.PollVoteRepository pollVoteRepository;
|
private com.openisle.repository.PollVoteRepository pollVoteRepository;
|
||||||
|
|
||||||
@@ -117,6 +126,11 @@ class PostControllerTest {
|
|||||||
isNull(),
|
isNull(),
|
||||||
isNull(),
|
isNull(),
|
||||||
isNull(),
|
isNull(),
|
||||||
|
isNull(),
|
||||||
|
isNull(),
|
||||||
|
isNull(),
|
||||||
|
isNull(),
|
||||||
|
isNull(),
|
||||||
isNull()
|
isNull()
|
||||||
)
|
)
|
||||||
).thenReturn(post);
|
).thenReturn(post);
|
||||||
@@ -266,6 +280,11 @@ class PostControllerTest {
|
|||||||
any(),
|
any(),
|
||||||
any(),
|
any(),
|
||||||
any(),
|
any(),
|
||||||
|
any(),
|
||||||
|
any(),
|
||||||
|
any(),
|
||||||
|
any(),
|
||||||
|
any(),
|
||||||
any()
|
any()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ class PostServiceTest {
|
|||||||
TagRepository tagRepo = mock(TagRepository.class);
|
TagRepository tagRepo = mock(TagRepository.class);
|
||||||
LotteryPostRepository lotteryRepo = mock(LotteryPostRepository.class);
|
LotteryPostRepository lotteryRepo = mock(LotteryPostRepository.class);
|
||||||
PollPostRepository pollPostRepo = mock(PollPostRepository.class);
|
PollPostRepository pollPostRepo = mock(PollPostRepository.class);
|
||||||
|
CategoryProposalPostRepository proposalRepo = mock(CategoryProposalPostRepository.class);
|
||||||
PollVoteRepository pollVoteRepo = mock(PollVoteRepository.class);
|
PollVoteRepository pollVoteRepo = mock(PollVoteRepository.class);
|
||||||
NotificationService notifService = mock(NotificationService.class);
|
NotificationService notifService = mock(NotificationService.class);
|
||||||
SubscriptionService subService = mock(SubscriptionService.class);
|
SubscriptionService subService = mock(SubscriptionService.class);
|
||||||
@@ -52,6 +53,7 @@ class PostServiceTest {
|
|||||||
tagRepo,
|
tagRepo,
|
||||||
lotteryRepo,
|
lotteryRepo,
|
||||||
pollPostRepo,
|
pollPostRepo,
|
||||||
|
proposalRepo,
|
||||||
pollVoteRepo,
|
pollVoteRepo,
|
||||||
notifService,
|
notifService,
|
||||||
subService,
|
subService,
|
||||||
@@ -104,6 +106,7 @@ class PostServiceTest {
|
|||||||
TagRepository tagRepo = mock(TagRepository.class);
|
TagRepository tagRepo = mock(TagRepository.class);
|
||||||
LotteryPostRepository lotteryRepo = mock(LotteryPostRepository.class);
|
LotteryPostRepository lotteryRepo = mock(LotteryPostRepository.class);
|
||||||
PollPostRepository pollPostRepo = mock(PollPostRepository.class);
|
PollPostRepository pollPostRepo = mock(PollPostRepository.class);
|
||||||
|
CategoryProposalPostRepository proposalRepo = mock(CategoryProposalPostRepository.class);
|
||||||
PollVoteRepository pollVoteRepo = mock(PollVoteRepository.class);
|
PollVoteRepository pollVoteRepo = mock(PollVoteRepository.class);
|
||||||
NotificationService notifService = mock(NotificationService.class);
|
NotificationService notifService = mock(NotificationService.class);
|
||||||
SubscriptionService subService = mock(SubscriptionService.class);
|
SubscriptionService subService = mock(SubscriptionService.class);
|
||||||
@@ -130,6 +133,7 @@ class PostServiceTest {
|
|||||||
tagRepo,
|
tagRepo,
|
||||||
lotteryRepo,
|
lotteryRepo,
|
||||||
pollPostRepo,
|
pollPostRepo,
|
||||||
|
proposalRepo,
|
||||||
pollVoteRepo,
|
pollVoteRepo,
|
||||||
notifService,
|
notifService,
|
||||||
subService,
|
subService,
|
||||||
@@ -195,6 +199,7 @@ class PostServiceTest {
|
|||||||
TagRepository tagRepo = mock(TagRepository.class);
|
TagRepository tagRepo = mock(TagRepository.class);
|
||||||
LotteryPostRepository lotteryRepo = mock(LotteryPostRepository.class);
|
LotteryPostRepository lotteryRepo = mock(LotteryPostRepository.class);
|
||||||
PollPostRepository pollPostRepo = mock(PollPostRepository.class);
|
PollPostRepository pollPostRepo = mock(PollPostRepository.class);
|
||||||
|
CategoryProposalPostRepository proposalRepo = mock(CategoryProposalPostRepository.class);
|
||||||
PollVoteRepository pollVoteRepo = mock(PollVoteRepository.class);
|
PollVoteRepository pollVoteRepo = mock(PollVoteRepository.class);
|
||||||
NotificationService notifService = mock(NotificationService.class);
|
NotificationService notifService = mock(NotificationService.class);
|
||||||
SubscriptionService subService = mock(SubscriptionService.class);
|
SubscriptionService subService = mock(SubscriptionService.class);
|
||||||
@@ -221,6 +226,7 @@ class PostServiceTest {
|
|||||||
tagRepo,
|
tagRepo,
|
||||||
lotteryRepo,
|
lotteryRepo,
|
||||||
pollPostRepo,
|
pollPostRepo,
|
||||||
|
proposalRepo,
|
||||||
pollVoteRepo,
|
pollVoteRepo,
|
||||||
notifService,
|
notifService,
|
||||||
subService,
|
subService,
|
||||||
@@ -260,6 +266,11 @@ class PostServiceTest {
|
|||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
null
|
null
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@@ -273,6 +284,7 @@ class PostServiceTest {
|
|||||||
TagRepository tagRepo = mock(TagRepository.class);
|
TagRepository tagRepo = mock(TagRepository.class);
|
||||||
LotteryPostRepository lotteryRepo = mock(LotteryPostRepository.class);
|
LotteryPostRepository lotteryRepo = mock(LotteryPostRepository.class);
|
||||||
PollPostRepository pollPostRepo = mock(PollPostRepository.class);
|
PollPostRepository pollPostRepo = mock(PollPostRepository.class);
|
||||||
|
CategoryProposalPostRepository proposalRepo = mock(CategoryProposalPostRepository.class);
|
||||||
PollVoteRepository pollVoteRepo = mock(PollVoteRepository.class);
|
PollVoteRepository pollVoteRepo = mock(PollVoteRepository.class);
|
||||||
NotificationService notifService = mock(NotificationService.class);
|
NotificationService notifService = mock(NotificationService.class);
|
||||||
SubscriptionService subService = mock(SubscriptionService.class);
|
SubscriptionService subService = mock(SubscriptionService.class);
|
||||||
@@ -299,6 +311,7 @@ class PostServiceTest {
|
|||||||
tagRepo,
|
tagRepo,
|
||||||
lotteryRepo,
|
lotteryRepo,
|
||||||
pollPostRepo,
|
pollPostRepo,
|
||||||
|
proposalRepo,
|
||||||
pollVoteRepo,
|
pollVoteRepo,
|
||||||
notifService,
|
notifService,
|
||||||
subService,
|
subService,
|
||||||
@@ -367,6 +380,7 @@ class PostServiceTest {
|
|||||||
TagRepository tagRepo = mock(TagRepository.class);
|
TagRepository tagRepo = mock(TagRepository.class);
|
||||||
LotteryPostRepository lotteryRepo = mock(LotteryPostRepository.class);
|
LotteryPostRepository lotteryRepo = mock(LotteryPostRepository.class);
|
||||||
PollPostRepository pollPostRepo = mock(PollPostRepository.class);
|
PollPostRepository pollPostRepo = mock(PollPostRepository.class);
|
||||||
|
CategoryProposalPostRepository proposalRepo = mock(CategoryProposalPostRepository.class);
|
||||||
PollVoteRepository pollVoteRepo = mock(PollVoteRepository.class);
|
PollVoteRepository pollVoteRepo = mock(PollVoteRepository.class);
|
||||||
NotificationService notifService = mock(NotificationService.class);
|
NotificationService notifService = mock(NotificationService.class);
|
||||||
SubscriptionService subService = mock(SubscriptionService.class);
|
SubscriptionService subService = mock(SubscriptionService.class);
|
||||||
@@ -393,6 +407,7 @@ class PostServiceTest {
|
|||||||
tagRepo,
|
tagRepo,
|
||||||
lotteryRepo,
|
lotteryRepo,
|
||||||
pollPostRepo,
|
pollPostRepo,
|
||||||
|
proposalRepo,
|
||||||
pollVoteRepo,
|
pollVoteRepo,
|
||||||
notifService,
|
notifService,
|
||||||
subService,
|
subService,
|
||||||
|
|||||||
@@ -46,3 +46,4 @@ app.avatar.base-url=${AVATAR_BASE_URL:https://api.dicebear.com/6.x}
|
|||||||
# Web push configuration
|
# Web push configuration
|
||||||
app.webpush.public-key=${WEBPUSH_PUBLIC_KEY:}
|
app.webpush.public-key=${WEBPUSH_PUBLIC_KEY:}
|
||||||
app.webpush.private-key=${WEBPUSH_PRIVATE_KEY:}
|
app.webpush.private-key=${WEBPUSH_PRIVATE_KEY:}
|
||||||
|
app.snippet-length=${SNIPPET_LENGTH:200}
|
||||||
|
|||||||
186
bots/bot_father.ts
Normal file
186
bots/bot_father.ts
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
import { Agent, Runner, hostedMcpTool, withTrace, webSearchTool } from "@openai/agents";
|
||||||
|
|
||||||
|
export type WorkflowInput = { input_as_text: string };
|
||||||
|
|
||||||
|
export abstract class BotFather {
|
||||||
|
protected readonly openisleToken = (process.env.OPENISLE_TOKEN ?? "").trim();
|
||||||
|
protected readonly weatherToken = (process.env.APIFY_API_TOKEN ?? "").trim();
|
||||||
|
|
||||||
|
protected readonly openisleMcp = this.createHostedMcpTool();
|
||||||
|
protected readonly weatherMcp = this.createWeatherMcpTool();
|
||||||
|
protected readonly webSearchPreview = this.createWebSearchPreviewTool();
|
||||||
|
protected readonly agent: Agent;
|
||||||
|
|
||||||
|
constructor(protected readonly name: string) {
|
||||||
|
console.log(`✅ ${this.name} starting...`);
|
||||||
|
console.log(
|
||||||
|
this.openisleToken
|
||||||
|
? "🔑 OPENISLE_TOKEN detected in environment; it will be attached to MCP requests."
|
||||||
|
: "🔓 OPENISLE_TOKEN not set; authenticated MCP tools may be unavailable."
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
this.weatherToken
|
||||||
|
? "☁️ APIFY_API_TOKEN detected; weather MCP server will be available."
|
||||||
|
: "🌥️ APIFY_API_TOKEN not set; weather updates will be unavailable."
|
||||||
|
);
|
||||||
|
|
||||||
|
this.agent = new Agent({
|
||||||
|
name: this.name,
|
||||||
|
instructions: this.buildInstructions(),
|
||||||
|
tools: [
|
||||||
|
this.openisleMcp,
|
||||||
|
this.weatherMcp,
|
||||||
|
this.webSearchPreview
|
||||||
|
],
|
||||||
|
model: this.getModel(),
|
||||||
|
modelSettings: {
|
||||||
|
temperature: 0.7,
|
||||||
|
topP: 1,
|
||||||
|
maxTokens: 2048,
|
||||||
|
toolChoice: "auto",
|
||||||
|
store: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected buildInstructions(): string {
|
||||||
|
const instructions = [
|
||||||
|
...this.getBaseInstructions(),
|
||||||
|
...this.getAdditionalInstructions(),
|
||||||
|
].filter(Boolean);
|
||||||
|
return instructions.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
protected getBaseInstructions(): string[] {
|
||||||
|
return [
|
||||||
|
"You are a helpful assistant for https://www.open-isle.com.",
|
||||||
|
"Finish tasks end-to-end before replying. If multiple MCP tools are needed, call them sequentially until the task is truly done.",
|
||||||
|
"When presenting the result, reply in Chinese with a concise summary and include any important URLs or IDs.",
|
||||||
|
"After finishing replies, call mark_notifications_read with all processed notification IDs to keep the inbox clean.",
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private createWebSearchPreviewTool() {
|
||||||
|
return webSearchTool({
|
||||||
|
userLocation: {
|
||||||
|
type: "approximate",
|
||||||
|
country: undefined,
|
||||||
|
region: undefined,
|
||||||
|
city: undefined,
|
||||||
|
timezone: undefined
|
||||||
|
},
|
||||||
|
searchContextSize: "medium"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private createHostedMcpTool() {
|
||||||
|
const token = this.openisleToken;
|
||||||
|
const authConfig = token
|
||||||
|
? {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {};
|
||||||
|
|
||||||
|
return hostedMcpTool({
|
||||||
|
serverLabel: "openisle_mcp",
|
||||||
|
serverUrl: "https://www.open-isle.com/mcp",
|
||||||
|
allowedTools: [
|
||||||
|
"search", // 用于搜索帖子、内容等
|
||||||
|
"create_post", // 创建新帖子
|
||||||
|
"reply_to_post", // 回复帖子
|
||||||
|
"reply_to_comment", // 回复评论
|
||||||
|
"recent_posts", // 获取最新帖子
|
||||||
|
"get_post", // 获取特定帖子的详细信息
|
||||||
|
"list_unread_messages", // 列出未读消息或通知
|
||||||
|
"mark_notifications_read", // 标记通知为已读
|
||||||
|
],
|
||||||
|
requireApproval: "never",
|
||||||
|
...authConfig,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private createWeatherMcpTool(): ReturnType<typeof hostedMcpTool> {
|
||||||
|
return hostedMcpTool({
|
||||||
|
serverLabel: "weather_mcp_server",
|
||||||
|
serverUrl: "https://jiri-spilka--weather-mcp-server.apify.actor/mcp",
|
||||||
|
requireApproval: "never",
|
||||||
|
allowedTools: [
|
||||||
|
"get_current_weather", // 天气 MCP 工具
|
||||||
|
],
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${this.weatherToken || ""}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected getAdditionalInstructions(): string[] {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected getModel(): string {
|
||||||
|
return "gpt-4o-mini";
|
||||||
|
}
|
||||||
|
|
||||||
|
protected createRunner(): Runner {
|
||||||
|
return new Runner({
|
||||||
|
workflowName: this.name,
|
||||||
|
traceMetadata: {
|
||||||
|
__trace_source__: "agent-builder",
|
||||||
|
workflow_id: "wf_69003cbd47e08190928745d3c806c0b50d1a01cfae052be8",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async runWorkflow(workflow: WorkflowInput) {
|
||||||
|
if (!process.env.OPENAI_API_KEY) {
|
||||||
|
throw new Error("Missing OPENAI_API_KEY");
|
||||||
|
}
|
||||||
|
|
||||||
|
const runner = this.createRunner();
|
||||||
|
|
||||||
|
return await withTrace(`${this.name} run`, async () => {
|
||||||
|
const preview = workflow.input_as_text.trim();
|
||||||
|
console.log(
|
||||||
|
"📝 Received workflow input (preview):",
|
||||||
|
preview.length > 200 ? `${preview.slice(0, 200)}…` : preview
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log("🚦 Starting agent run with maxTurns=16...");
|
||||||
|
const result = await runner.run(this.agent, workflow.input_as_text, {
|
||||||
|
maxTurns: 16,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("📬 Agent run completed. Result keys:", Object.keys(result));
|
||||||
|
|
||||||
|
if (!result.finalOutput) {
|
||||||
|
throw new Error("Agent result is undefined (no final output).");
|
||||||
|
}
|
||||||
|
|
||||||
|
const openisleBotResult = { output_text: String(result.finalOutput) };
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
"🤖 Agent result (length=%d):\n%s",
|
||||||
|
openisleBotResult.output_text.length,
|
||||||
|
openisleBotResult.output_text
|
||||||
|
);
|
||||||
|
return openisleBotResult;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected abstract getCliQuery(): string;
|
||||||
|
|
||||||
|
public async runCli(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const query = this.getCliQuery();
|
||||||
|
console.log("🔍 Running workflow...");
|
||||||
|
await this.runWorkflow({ input_as_text: query });
|
||||||
|
process.exit(0);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("❌ Agent failed:", err?.stack || err);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
56
bots/instance/coffee_bot.ts
Normal file
56
bots/instance/coffee_bot.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { BotFather, WorkflowInput } from "../bot_father";
|
||||||
|
|
||||||
|
const WEEKDAY_NAMES = ["日", "一", "二", "三", "四", "五", "六"] as const;
|
||||||
|
|
||||||
|
class CoffeeBot extends BotFather {
|
||||||
|
constructor() {
|
||||||
|
super("Coffee Bot");
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override getAdditionalInstructions(): string[] {
|
||||||
|
return [
|
||||||
|
"记住你的系统代号是 system,有需要自称或签名时都要使用这个名字。",
|
||||||
|
"You are responsible for 发布每日抽奖早安贴。",
|
||||||
|
"创建帖子时,确保标题、奖品信息、开奖时间以及领奖方式完全符合 CLI 查询提供的细节。",
|
||||||
|
"正文需亲切友好,简洁明了,鼓励社区成员互动。",
|
||||||
|
"开奖说明需明确告知中奖者需私聊站长 @nagisa 领取奖励。",
|
||||||
|
"确保只发布一个帖子,避免重复调用 create_post。",
|
||||||
|
"使用标签为 weather_mcp_server 的 MCP 工具获取北京、上海、广州、深圳当天的天气信息,并把结果写入早安问候之后。",
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override getCliQuery(): string {
|
||||||
|
const now = new Date(Date.now() + 8 * 60 * 60 * 1000);
|
||||||
|
const weekday = WEEKDAY_NAMES[now.getDay()];
|
||||||
|
const drawTime = new Date(now);
|
||||||
|
drawTime.setHours(15, 0, 0, 0);
|
||||||
|
|
||||||
|
return `
|
||||||
|
请立即在 https://www.open-isle.com 使用 create_post 发表一篇帖子,遵循以下要求:
|
||||||
|
1. 标题固定为「大家星期${weekday}早安--抽一杯咖啡」。
|
||||||
|
2. 正文包含:
|
||||||
|
- 亲切的早安问候;
|
||||||
|
- 早安问候后立即列出北京、上海、广州、深圳当天的天气信息,每行格式为“城市:天气描述,最低温~最高温”;天气需调用 weather_mcp_server 获取;
|
||||||
|
- 标注“领奖请私聊站长 @[nagisa]”;
|
||||||
|
- 鼓励大家留言互动。
|
||||||
|
3. 奖品信息
|
||||||
|
- 明确奖品写作“Coffee”;
|
||||||
|
- 帖子类型必须为 LOTTERY;
|
||||||
|
- 奖品图片链接:https://openisle-1307107697.cos.accelerate.myqcloud.com/dynamic_assert/0d6a9b33e9ca4fe5a90540187d3f9ecb.png;
|
||||||
|
- 公布开奖时间为 ${drawTime}, 直接传UTC时间给接口,不要考虑时区问题
|
||||||
|
- categoryId 固定为 10,tagIds 设为 [36]。
|
||||||
|
4. 帖子语言使用简体中文。
|
||||||
|
`.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const coffeeBot = new CoffeeBot();
|
||||||
|
|
||||||
|
export const runWorkflow = async (workflow: WorkflowInput) => {
|
||||||
|
return coffeeBot.runWorkflow(workflow);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
coffeeBot.runCli();
|
||||||
|
}
|
||||||
|
|
||||||
69
bots/instance/daily_news_bot.ts
Normal file
69
bots/instance/daily_news_bot.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { BotFather, WorkflowInput } from "../bot_father";
|
||||||
|
|
||||||
|
const WEEKDAY_NAMES = ["日", "一", "二", "三", "四", "五", "六"] as const;
|
||||||
|
|
||||||
|
class DailyNewsBot extends BotFather {
|
||||||
|
constructor() {
|
||||||
|
super("Daily News Bot");
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override getModel(): string {
|
||||||
|
return "gpt-4o";
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override getAdditionalInstructions(): string[] {
|
||||||
|
return [
|
||||||
|
"You are DailyNewsBot,专职在 OpenIsle 发布每日新闻速递。",
|
||||||
|
"始终使用简体中文回复,并以结构化 Markdown 呈现内容。",
|
||||||
|
"发布内容前务必完成资讯核实:分别通过 web_search 调研 CoinDesk 所有要闻、Reuters 重点国际新闻,以及全球 AI 领域的重大进展。",
|
||||||
|
"整合新闻时,将同源资讯合并,突出影响力、涉及主体与潜在影响,保持语句简洁。",
|
||||||
|
"所有新闻要点都要附带来源链接,并在括号中标注来源站点名。",
|
||||||
|
"使用 weather_mcp_server 的 get_current_weather 获取北京、上海、广州、深圳的天气,并在正文中列表展示",
|
||||||
|
"正文结尾补充一个行动建议或提醒,帮助读者快速把握重点。",
|
||||||
|
"严禁发布超过一篇帖子,create_post 只调用一次。",
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override getCliQuery(): string {
|
||||||
|
const now = new Date(Date.now() + 8 * 60 * 60 * 1000);
|
||||||
|
const year = now.getFullYear();
|
||||||
|
const month = String(now.getMonth() + 1).padStart(2, "0");
|
||||||
|
const day = String(now.getDate()).padStart(2, "0");
|
||||||
|
const weekday = WEEKDAY_NAMES[now.getDay()];
|
||||||
|
const dateLabel = `${year}年${month}月${day}日 星期${weekday}`;
|
||||||
|
const isoDate = `${year}-${month}-${day}`;
|
||||||
|
const categoryId = Number(process.env.DAILY_NEWS_CATEGORY_ID ?? "6");
|
||||||
|
const tagIdsEnv = process.env.DAILY_NEWS_TAG_IDS ?? "3,33";
|
||||||
|
const tagIds = tagIdsEnv
|
||||||
|
.split(",")
|
||||||
|
.map((id) => Number(id.trim()))
|
||||||
|
.filter((id) => !Number.isNaN(id));
|
||||||
|
const finalTagIds = tagIds.length > 0 ? tagIds : [1];
|
||||||
|
const tagIdsText = `[${finalTagIds.join(", ")}]`;
|
||||||
|
|
||||||
|
return `
|
||||||
|
请立即在 https://www.open-isle.com 使用 create_post 发布一篇名为「OpenIsle 每日新闻速递|${dateLabel}」的帖子,并遵循以下要求:
|
||||||
|
1. 发布类型为 NORMAL,categoryId = ${categoryId},tagIds = ${tagIdsText}。
|
||||||
|
2. 正文以简洁问候开头, 不用再重复标题
|
||||||
|
3. 使用 web_search 工具按以下顺序收集资讯,并在正文中以 Markdown 小节呈现, 需要调用3次web_search:
|
||||||
|
- 「全球区块链与加密」:汇总 coindesk.com 版面所有重点新闻, 列出至少5条
|
||||||
|
- 「国际新闻速览」:汇总 reuters.com 版面重点头条,关注宏观经济、市场波动或政策变化。列出至少5条
|
||||||
|
- 「AI 行业快讯」:检索今天全球 AI 领域的重要发布或事件(例如 OpenAI、Google、Meta、国内大模型厂商等)。列出至少5条
|
||||||
|
4. 每条新闻采用项目符号,先写结论再给出关键数字或细节,末尾添加来源超链接,格式示例:「**结论** —— 关键细节。(来源:[Reuters](URL))」
|
||||||
|
5. 资讯整理完毕后,调用 weather_mcp_server.get_current_weather,列出北京、上海、广州、深圳今日天气,放置在「城市天气」小节下, 本小节可加emoji。
|
||||||
|
6. 最后一节为「今日提醒」,给出 2-3 条与新闻或天气相关的行动建议。
|
||||||
|
7. 若在资讯搜集过程中发现相互矛盾的信息,须在正文中以「⚠️ 风险提示」说明原因及尚待确认的点。
|
||||||
|
9. 发布完成后,不要再次调用 create_post。
|
||||||
|
`.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const dailyNewsBot = new DailyNewsBot();
|
||||||
|
|
||||||
|
export const runWorkflow = async (workflow: WorkflowInput) => {
|
||||||
|
return dailyNewsBot.runWorkflow(workflow);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
dailyNewsBot.runCli();
|
||||||
|
}
|
||||||
65
bots/instance/open_source_reply_bot.ts
Normal file
65
bots/instance/open_source_reply_bot.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { BotFather, WorkflowInput } from "../bot_father";
|
||||||
|
|
||||||
|
class OpenSourceReplyBot extends BotFather {
|
||||||
|
constructor() {
|
||||||
|
super("OpenSource Reply Bot");
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override getAdditionalInstructions(): string[] {
|
||||||
|
const knowledgeBase = this.loadKnowledgeBase();
|
||||||
|
|
||||||
|
return [
|
||||||
|
"You are OpenSourceReplyBot, a professional helper who focuses on answering open-source development and code-related questions for the OpenIsle community.",
|
||||||
|
"Respond in Chinese using well-structured Markdown sections such as 标题、列表、代码块等,让回复清晰易读。",
|
||||||
|
"保持语气专业、耐心、详尽,绝不使用表情符号或颜文字,也不要卖萌。",
|
||||||
|
"优先解答与项目代码、贡献流程、架构设计或排错相关的问题;",
|
||||||
|
"在需要时引用 README.md 与 CONTRIBUTING.md 中的要点,帮助用户快速定位文档位置。",
|
||||||
|
knowledgeBase,
|
||||||
|
].filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override getCliQuery(): string {
|
||||||
|
return `
|
||||||
|
【AUTO】每30分钟自动巡检未读提及与评论,严格遵守以下流程:
|
||||||
|
1)调用 list_unread_messages 获取待处理的“提及/评论”;
|
||||||
|
2)按时间从新到旧逐条处理(最多10条);如需上下文请调用 get_post;
|
||||||
|
3)仅对与开源项目、代码实现或贡献流程直接相关的问题生成详尽的 Markdown 中文回复,
|
||||||
|
若与主题无关则礼貌说明并跳过;
|
||||||
|
4)回复时引用 README 或 CONTRIBUTING 中的要点(如适用),并优先给出可执行的排查步骤或代码建议;
|
||||||
|
5)回复评论使用 reply_to_comment,回复帖子使用 reply_to_post;
|
||||||
|
6)若某通知最后一条已由本 bot 回复,则跳过避免重复;
|
||||||
|
7)整理已处理通知 ID 调用 mark_notifications_read;
|
||||||
|
8)结束时输出包含处理条目概览(URL或ID)的总结。`.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadKnowledgeBase(): string {
|
||||||
|
const docs = ["../../README.md", "../../CONTRIBUTING.md"];
|
||||||
|
const sections: string[] = [];
|
||||||
|
|
||||||
|
for (const relativePath of docs) {
|
||||||
|
try {
|
||||||
|
const absolutePath = path.resolve(__dirname, relativePath);
|
||||||
|
const content = readFileSync(absolutePath, "utf-8").trim();
|
||||||
|
if (content) {
|
||||||
|
sections.push(`以下是 ${path.basename(absolutePath)} 的内容:\n${content}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
sections.push(`未能加载 ${relativePath},请检查文件路径或权限。`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sections.join("\n\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const openSourceReplyBot = new OpenSourceReplyBot();
|
||||||
|
|
||||||
|
export const runWorkflow = async (workflow: WorkflowInput) => {
|
||||||
|
return openSourceReplyBot.runWorkflow(workflow);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
openSourceReplyBot.runCli();
|
||||||
|
}
|
||||||
38
bots/instance/reply_bot.ts
Normal file
38
bots/instance/reply_bot.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
// reply_bot.ts
|
||||||
|
import { BotFather, WorkflowInput } from "../bot_father";
|
||||||
|
|
||||||
|
class ReplyBot extends BotFather {
|
||||||
|
constructor() {
|
||||||
|
super("OpenIsle Bot");
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override getAdditionalInstructions(): string[] {
|
||||||
|
return [
|
||||||
|
"记住你的系统代号是 system,任何需要自称、署名或解释身份的时候都使用这个名字。",
|
||||||
|
"以阴阳怪气的方式回复各种互动",
|
||||||
|
"你每天会发布咖啡抽奖贴,跟大家互动",
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override getCliQuery(): string {
|
||||||
|
return `
|
||||||
|
【AUTO】无需确认,自动处理所有未读的提及与评论:
|
||||||
|
1)调用 list_unread_messages;
|
||||||
|
2)依次处理每条“提及/评论”:如需上下文则使用 get_post 获取,生成简明中文回复;如有 commentId 则用 reply_to_comment,否则用 reply_to_post;
|
||||||
|
3)跳过关注和系统事件;
|
||||||
|
4)保证幂等性:如该贴最后一条是你自己发的回复,则跳过;
|
||||||
|
5)调用 mark_notifications_read,传入本次已处理的通知 ID 清理已读;
|
||||||
|
6)最多只处理最新10条;结束时仅输出简要摘要(包含URL或ID)。
|
||||||
|
`.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const replyBot = new ReplyBot();
|
||||||
|
|
||||||
|
export const runWorkflow = async (workflow: WorkflowInput) => {
|
||||||
|
return replyBot.runWorkflow(workflow);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
replyBot.runCli();
|
||||||
|
}
|
||||||
@@ -40,12 +40,12 @@ echo "👉 Build images ..."
|
|||||||
docker compose -f "$compose_file" --env-file "$env_file" \
|
docker compose -f "$compose_file" --env-file "$env_file" \
|
||||||
build --pull \
|
build --pull \
|
||||||
--build-arg NUXT_ENV=production \
|
--build-arg NUXT_ENV=production \
|
||||||
frontend_service
|
frontend_service mcp
|
||||||
|
|
||||||
echo "👉 Recreate & start all target services (no dev profile)..."
|
echo "👉 Recreate & start all target services (no dev profile)..."
|
||||||
docker compose -f "$compose_file" --env-file "$env_file" \
|
docker compose -f "$compose_file" --env-file "$env_file" \
|
||||||
up -d --force-recreate --remove-orphans --no-deps \
|
up -d --force-recreate --remove-orphans --no-deps \
|
||||||
mysql redis rabbitmq websocket-service springboot frontend_service
|
mysql redis rabbitmq websocket-service springboot frontend_service mcp
|
||||||
|
|
||||||
echo "👉 Current status:"
|
echo "👉 Current status:"
|
||||||
docker compose -f "$compose_file" --env-file "$env_file" ps
|
docker compose -f "$compose_file" --env-file "$env_file" ps
|
||||||
|
|||||||
@@ -36,16 +36,15 @@ echo "👉 Pull base images (for image-based services)..."
|
|||||||
docker compose -f "$compose_file" --env-file "$env_file" pull --ignore-pull-failures
|
docker compose -f "$compose_file" --env-file "$env_file" pull --ignore-pull-failures
|
||||||
|
|
||||||
echo "👉 Build images (staging)..."
|
echo "👉 Build images (staging)..."
|
||||||
# 前端 + OpenSearch 都是自建镜像;--pull 更新其基础镜像
|
|
||||||
docker compose -f "$compose_file" --env-file "$env_file" \
|
docker compose -f "$compose_file" --env-file "$env_file" \
|
||||||
build --pull \
|
build --pull \
|
||||||
--build-arg NUXT_ENV=staging \
|
--build-arg NUXT_ENV=staging \
|
||||||
frontend_service
|
frontend_service mcp
|
||||||
|
|
||||||
echo "👉 Recreate & start all target services (no dev profile)..."
|
echo "👉 Recreate & start all target services (no dev profile)..."
|
||||||
docker compose -f "$compose_file" --env-file "$env_file" \
|
docker compose -f "$compose_file" --env-file "$env_file" \
|
||||||
up -d --force-recreate --remove-orphans --no-deps \
|
up -d --force-recreate --remove-orphans --no-deps \
|
||||||
mysql redis rabbitmq websocket-service springboot frontend_service
|
mysql redis rabbitmq websocket-service springboot frontend_service mcp
|
||||||
|
|
||||||
echo "👉 Current status:"
|
echo "👉 Current status:"
|
||||||
docker compose -f "$compose_file" --env-file "$env_file" ps
|
docker compose -f "$compose_file" --env-file "$env_file" ps
|
||||||
|
|||||||
@@ -25,56 +25,67 @@ services:
|
|||||||
timeout: 3s
|
timeout: 3s
|
||||||
retries: 30
|
retries: 30
|
||||||
start_period: 20s
|
start_period: 20s
|
||||||
|
profiles:
|
||||||
|
- dev
|
||||||
|
- dev_local_backend
|
||||||
|
- prod
|
||||||
|
|
||||||
# OpenSearch Service
|
# # OpenSearch Service
|
||||||
opensearch:
|
# opensearch:
|
||||||
user: "1000:1000"
|
# user: "1000:1000"
|
||||||
build:
|
# build:
|
||||||
context: .
|
# context: .
|
||||||
dockerfile: opensearch.Dockerfile
|
# dockerfile: opensearch.Dockerfile
|
||||||
container_name: ${COMPOSE_PROJECT_NAME}-opensearch
|
# container_name: ${COMPOSE_PROJECT_NAME}-opensearch
|
||||||
environment:
|
# environment:
|
||||||
- cluster.name=os-single
|
# - cluster.name=os-single
|
||||||
- node.name=os-node-1
|
# - node.name=os-node-1
|
||||||
- discovery.type=single-node
|
# - discovery.type=single-node
|
||||||
- bootstrap.memory_lock=true
|
# - bootstrap.memory_lock=true
|
||||||
- OPENSEARCH_JAVA_OPTS=-Xms1g -Xmx1g
|
# - OPENSEARCH_JAVA_OPTS=-Xms1g -Xmx1g
|
||||||
- DISABLE_SECURITY_PLUGIN=true
|
# - DISABLE_SECURITY_PLUGIN=true
|
||||||
- cluster.blocks.create_index=false
|
# - cluster.blocks.create_index=false
|
||||||
ulimits:
|
# ulimits:
|
||||||
memlock: { soft: -1, hard: -1 }
|
# memlock: { soft: -1, hard: -1 }
|
||||||
nofile: { soft: 65536, hard: 65536 }
|
# nofile: { soft: 65536, hard: 65536 }
|
||||||
volumes:
|
# volumes:
|
||||||
- opensearch-data:/usr/share/opensearch/data
|
# - opensearch-data:/usr/share/opensearch/data
|
||||||
- opensearch-snapshots:/snapshots
|
# - opensearch-snapshots:/snapshots
|
||||||
ports:
|
# ports:
|
||||||
- "${OPENSEARCH_PORT:-9200}:9200"
|
# - "${OPENSEARCH_PORT:-9200}:9200"
|
||||||
- "${OPENSEARCH_METRICS_PORT:-9600}:9600"
|
# - "${OPENSEARCH_METRICS_PORT:-9600}:9600"
|
||||||
restart: unless-stopped
|
# restart: unless-stopped
|
||||||
healthcheck:
|
# healthcheck:
|
||||||
test:
|
# test:
|
||||||
- CMD-SHELL
|
# - CMD-SHELL
|
||||||
- curl -fsS http://127.0.0.1:9200/_cluster/health >/dev/null
|
# - curl -fsS http://127.0.0.1:9200/_cluster/health >/dev/null
|
||||||
interval: 10s
|
# interval: 10s
|
||||||
timeout: 5s
|
# timeout: 5s
|
||||||
retries: 30
|
# retries: 30
|
||||||
start_period: 60s
|
# start_period: 60s
|
||||||
networks:
|
# networks:
|
||||||
- openisle-network
|
# - openisle-network
|
||||||
|
# profiles:
|
||||||
|
# - dev
|
||||||
|
# - dev_local_backend
|
||||||
|
|
||||||
dashboards:
|
# dashboards:
|
||||||
image: opensearchproject/opensearch-dashboards:3.0.0
|
# image: opensearchproject/opensearch-dashboards:3.0.0
|
||||||
container_name: ${COMPOSE_PROJECT_NAME}-os-dashboards
|
# container_name: ${COMPOSE_PROJECT_NAME}-os-dashboards
|
||||||
environment:
|
# environment:
|
||||||
OPENSEARCH_HOSTS: '["http://opensearch:9200"]'
|
# OPENSEARCH_HOSTS: '["http://opensearch:9200"]'
|
||||||
DISABLE_SECURITY_DASHBOARDS_PLUGIN: "true"
|
# DISABLE_SECURITY_DASHBOARDS_PLUGIN: "true"
|
||||||
ports:
|
# ports:
|
||||||
- "${OPENSEARCH_DASHBOARDS_PORT:-5601}:5601"
|
# - "${OPENSEARCH_DASHBOARDS_PORT:-5601}:5601"
|
||||||
depends_on:
|
# depends_on:
|
||||||
- opensearch
|
# - opensearch
|
||||||
restart: unless-stopped
|
# restart: unless-stopped
|
||||||
networks:
|
# networks:
|
||||||
- openisle-network
|
# - openisle-network
|
||||||
|
# profiles:
|
||||||
|
# - dev
|
||||||
|
# - dev_local_backend
|
||||||
|
# - prod
|
||||||
|
|
||||||
rabbitmq:
|
rabbitmq:
|
||||||
image: rabbitmq:3.13-management
|
image: rabbitmq:3.13-management
|
||||||
@@ -98,6 +109,10 @@ services:
|
|||||||
start_period: 30s
|
start_period: 30s
|
||||||
networks:
|
networks:
|
||||||
- openisle-network
|
- openisle-network
|
||||||
|
profiles:
|
||||||
|
- dev
|
||||||
|
- dev_local_backend
|
||||||
|
- prod
|
||||||
|
|
||||||
redis:
|
redis:
|
||||||
image: redis:7
|
image: redis:7
|
||||||
@@ -111,6 +126,10 @@ services:
|
|||||||
- redis-data:/data
|
- redis-data:/data
|
||||||
networks:
|
networks:
|
||||||
- openisle-network
|
- openisle-network
|
||||||
|
profiles:
|
||||||
|
- dev
|
||||||
|
- dev_local_backend
|
||||||
|
- prod
|
||||||
|
|
||||||
# Java spring boot service (开发便捷镜像,后续可换成打包镜像)
|
# Java spring boot service (开发便捷镜像,后续可换成打包镜像)
|
||||||
springboot:
|
springboot:
|
||||||
@@ -142,8 +161,8 @@ services:
|
|||||||
condition: service_started
|
condition: service_started
|
||||||
websocket-service:
|
websocket-service:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
opensearch:
|
# opensearch:
|
||||||
condition: service_healthy
|
# condition: service_healthy
|
||||||
command: >
|
command: >
|
||||||
sh -c "apt-get update && apt-get install -y --no-install-recommends curl &&
|
sh -c "apt-get update && apt-get install -y --no-install-recommends curl &&
|
||||||
mvn clean spring-boot:run -Dmaven.test.skip=true"
|
mvn clean spring-boot:run -Dmaven.test.skip=true"
|
||||||
@@ -155,6 +174,34 @@ services:
|
|||||||
start_period: 60s
|
start_period: 60s
|
||||||
networks:
|
networks:
|
||||||
- openisle-network
|
- openisle-network
|
||||||
|
profiles:
|
||||||
|
- dev
|
||||||
|
- prod
|
||||||
|
|
||||||
|
mcp:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: docker/mcp.Dockerfile
|
||||||
|
container_name: ${COMPOSE_PROJECT_NAME}-openisle-mcp
|
||||||
|
env_file:
|
||||||
|
- ${ENV_FILE:-../.env}
|
||||||
|
environment:
|
||||||
|
OPENISLE_MCP_BACKEND_BASE_URL: http://springboot:${SERVER_PORT:-8080}
|
||||||
|
OPENISLE_MCP_HOST: 0.0.0.0
|
||||||
|
OPENISLE_MCP_PORT: ${OPENISLE_MCP_PORT:-8085}
|
||||||
|
OPENISLE_MCP_TRANSPORT: ${OPENISLE_MCP_TRANSPORT:-streamable-http}
|
||||||
|
OPENISLE_MCP_REQUEST_TIMEOUT: ${OPENISLE_MCP_REQUEST_TIMEOUT:-10.0}
|
||||||
|
ports:
|
||||||
|
- "${OPENISLE_MCP_PORT:-8085}:${OPENISLE_MCP_PORT:-8085}"
|
||||||
|
depends_on:
|
||||||
|
springboot:
|
||||||
|
condition: service_started
|
||||||
|
networks:
|
||||||
|
- openisle-network
|
||||||
|
profiles:
|
||||||
|
- dev
|
||||||
|
- prod
|
||||||
|
|
||||||
|
|
||||||
websocket-service:
|
websocket-service:
|
||||||
image: maven:3.9-eclipse-temurin-17
|
image: maven:3.9-eclipse-temurin-17
|
||||||
@@ -186,6 +233,10 @@ services:
|
|||||||
start_period: 60s
|
start_period: 60s
|
||||||
networks:
|
networks:
|
||||||
- openisle-network
|
- openisle-network
|
||||||
|
profiles:
|
||||||
|
- dev
|
||||||
|
- dev_local_backend
|
||||||
|
- prod
|
||||||
|
|
||||||
frontend_dev:
|
frontend_dev:
|
||||||
image: node:20
|
image: node:20
|
||||||
@@ -208,6 +259,28 @@ services:
|
|||||||
- openisle-network
|
- openisle-network
|
||||||
profiles:
|
profiles:
|
||||||
- dev
|
- dev
|
||||||
|
|
||||||
|
frontend_dev_local_backend:
|
||||||
|
image: node:20
|
||||||
|
container_name: ${COMPOSE_PROJECT_NAME}-openisle-frontend-dev-local-backend
|
||||||
|
working_dir: /app
|
||||||
|
env_file:
|
||||||
|
- ${ENV_FILE:-../.env}
|
||||||
|
command: sh -c "npm install && npm run dev"
|
||||||
|
volumes:
|
||||||
|
- ../frontend_nuxt:/app
|
||||||
|
- frontend-node-modules:/app/node_modules
|
||||||
|
ports:
|
||||||
|
- "${FRONTEND_PORT:-3000}:3000"
|
||||||
|
depends_on:
|
||||||
|
websocket-service:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- openisle-network
|
||||||
|
profiles:
|
||||||
|
- dev_local_backend
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
|
|
||||||
frontend_service:
|
frontend_service:
|
||||||
build:
|
build:
|
||||||
@@ -226,13 +299,13 @@ services:
|
|||||||
websocket-service:
|
websocket-service:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
profiles: ["staging", "prod"]
|
profiles:
|
||||||
|
- prod
|
||||||
|
|
||||||
|
# 监听“frontend_dev 容器自身的” 127.0.0.1:8080 → 转发到 springboot:8080
|
||||||
loopback_8080:
|
loopback_8080:
|
||||||
image: alpine/socat
|
image: alpine/socat
|
||||||
container_name: ${COMPOSE_PROJECT_NAME}-loopback-8080
|
container_name: ${COMPOSE_PROJECT_NAME}-loopback-8080
|
||||||
# 监听“frontend_dev 容器自身的” 127.0.0.1:8080 → 转发到 springboot:8080
|
|
||||||
command:
|
command:
|
||||||
- -d
|
- -d
|
||||||
- -d
|
- -d
|
||||||
@@ -243,13 +316,37 @@ services:
|
|||||||
springboot:
|
springboot:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
network_mode: "service:frontend_dev"
|
network_mode: "service:frontend_dev"
|
||||||
profiles: ["dev"]
|
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "sh", "-c", "nc -z 127.0.0.1 8080"]
|
test: ["CMD", "sh", "-c", "nc -z 127.0.0.1 8080"]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
timeout: 3s
|
timeout: 3s
|
||||||
retries: 20
|
retries: 20
|
||||||
start_period: 10s
|
start_period: 10s
|
||||||
|
profiles:
|
||||||
|
- dev
|
||||||
|
|
||||||
|
# 监听“frontend_dev 容器自身的” 127.0.0.1:8080 → 转发到 启动docker的本机:8080
|
||||||
|
loopback_8080_host:
|
||||||
|
image: alpine/socat
|
||||||
|
container_name: ${COMPOSE_PROJECT_NAME}-loopback-8080-host
|
||||||
|
command:
|
||||||
|
- -d
|
||||||
|
- -d
|
||||||
|
- -ly
|
||||||
|
- TCP4-LISTEN:8080,bind=127.0.0.1,reuseaddr,fork
|
||||||
|
- TCP4:host.docker.internal:8080
|
||||||
|
network_mode: "service:frontend_dev_local_backend"
|
||||||
|
depends_on:
|
||||||
|
frontend_dev_local_backend:
|
||||||
|
condition: service_started
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "sh", "-c", "nc -z 127.0.0.1 8080"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 20
|
||||||
|
start_period: 10s
|
||||||
|
profiles:
|
||||||
|
- dev_local_backend
|
||||||
|
|
||||||
loopback_8082:
|
loopback_8082:
|
||||||
image: alpine/socat
|
image: alpine/socat
|
||||||
@@ -265,13 +362,37 @@ services:
|
|||||||
websocket-service:
|
websocket-service:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
network_mode: "service:frontend_dev"
|
network_mode: "service:frontend_dev"
|
||||||
profiles: ["dev"]
|
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "sh", "-c", "nc -z 127.0.0.1 8082"]
|
test: ["CMD", "sh", "-c", "nc -z 127.0.0.1 8082"]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
timeout: 3s
|
timeout: 3s
|
||||||
retries: 20
|
retries: 20
|
||||||
start_period: 10s
|
start_period: 10s
|
||||||
|
profiles:
|
||||||
|
- dev
|
||||||
|
|
||||||
|
loopback_8082_host:
|
||||||
|
image: alpine/socat
|
||||||
|
container_name: ${COMPOSE_PROJECT_NAME}-loopback-8082-host
|
||||||
|
# 监听 127.0.0.1:8082 → 转发到 websocket-service:8082(WS 纯 TCP 可直接过)
|
||||||
|
command:
|
||||||
|
- -d
|
||||||
|
- -d
|
||||||
|
- -ly
|
||||||
|
- TCP4-LISTEN:8082,bind=127.0.0.1,reuseaddr,fork
|
||||||
|
- TCP4:websocket-service:8082
|
||||||
|
depends_on:
|
||||||
|
websocket-service:
|
||||||
|
condition: service_healthy
|
||||||
|
network_mode: "service:frontend_dev_local_backend"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "sh", "-c", "nc -z 127.0.0.1 8082"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 20
|
||||||
|
start_period: 10s
|
||||||
|
profiles:
|
||||||
|
- dev_local_backend
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
openisle-network:
|
openisle-network:
|
||||||
|
|||||||
21
docker/mcp.Dockerfile
Normal file
21
docker/mcp.Dockerfile
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
FROM python:3.11-slim AS base
|
||||||
|
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY mcp/pyproject.toml mcp/README.md ./
|
||||||
|
COPY mcp/src ./src
|
||||||
|
|
||||||
|
RUN pip install --no-cache-dir --upgrade pip \
|
||||||
|
&& pip install --no-cache-dir .
|
||||||
|
|
||||||
|
ENV OPENISLE_MCP_HOST=0.0.0.0 \
|
||||||
|
OPENISLE_MCP_PORT=8085 \
|
||||||
|
OPENISLE_MCP_TRANSPORT=streamable-http
|
||||||
|
|
||||||
|
EXPOSE 8085
|
||||||
|
|
||||||
|
CMD ["openisle-mcp"]
|
||||||
|
|
||||||
@@ -16,6 +16,7 @@
|
|||||||
<div class="info-content-header-left">
|
<div class="info-content-header-left">
|
||||||
<span class="user-name">{{ comment.userName }}</span>
|
<span class="user-name">{{ comment.userName }}</span>
|
||||||
<span v-if="isCommentFromPostAuthor" class="op-badge" title="楼主">OP</span>
|
<span v-if="isCommentFromPostAuthor" class="op-badge" title="楼主">OP</span>
|
||||||
|
<span v-if="comment.isBot" class="bot-badge" title="Bot">Bot</span>
|
||||||
<medal-one class="medal-icon" />
|
<medal-one class="medal-icon" />
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
v-if="comment.medal"
|
v-if="comment.medal"
|
||||||
@@ -522,6 +523,21 @@ const handleContentClick = (e) => {
|
|||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bot-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-left: 6px;
|
||||||
|
padding: 0 6px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 9px;
|
||||||
|
background-color: rgba(76, 175, 80, 0.16);
|
||||||
|
color: #2e7d32;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.medal-icon {
|
.medal-icon {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
|
|||||||
@@ -168,9 +168,19 @@ export default {
|
|||||||
const mobileMenuRef = ref(null)
|
const mobileMenuRef = ref(null)
|
||||||
const isMobile = useIsMobile()
|
const isMobile = useIsMobile()
|
||||||
|
|
||||||
|
const openMenu = () => {
|
||||||
|
if (!open.value) {
|
||||||
|
open.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const toggle = () => {
|
const toggle = () => {
|
||||||
open.value = !open.value
|
if (open.value) {
|
||||||
if (!open.value) emit('close')
|
open.value = false
|
||||||
|
emit('close')
|
||||||
|
} else {
|
||||||
|
open.value = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const close = () => {
|
const close = () => {
|
||||||
@@ -275,7 +285,7 @@ export default {
|
|||||||
return /^https?:\/\//.test(icon) || icon.startsWith('/')
|
return /^https?:\/\//.test(icon) || icon.startsWith('/')
|
||||||
}
|
}
|
||||||
|
|
||||||
expose({ toggle, close, reload, scrollToBottom })
|
expose({ toggle, close, reload, scrollToBottom, openMenu })
|
||||||
|
|
||||||
return {
|
return {
|
||||||
open,
|
open,
|
||||||
@@ -308,7 +318,6 @@ export default {
|
|||||||
border: 1px solid var(--normal-border-color);
|
border: 1px solid var(--normal-border-color);
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
padding: 5px 10px;
|
padding: 5px 10px;
|
||||||
margin-bottom: 4px;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -331,6 +340,7 @@ export default {
|
|||||||
z-index: 10000;
|
z-index: 10000;
|
||||||
max-height: 300px;
|
max-height: 300px;
|
||||||
min-width: 350px;
|
min-width: 350px;
|
||||||
|
margin-top: 4px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,11 @@
|
|||||||
|
|
||||||
<ClientOnly>
|
<ClientOnly>
|
||||||
<div class="header-content-right">
|
<div class="header-content-right">
|
||||||
|
<SearchDropdown
|
||||||
|
ref="searchDropdown"
|
||||||
|
v-if="!isMobile || showSearch"
|
||||||
|
@close="closeSearch"
|
||||||
|
/>
|
||||||
<!-- 搜索 -->
|
<!-- 搜索 -->
|
||||||
<ToolTip v-if="isMobile" content="搜索" placement="bottom">
|
<ToolTip v-if="isMobile" content="搜索" placement="bottom">
|
||||||
<div class="header-icon-item" @click="search">
|
<div class="header-icon-item" @click="search">
|
||||||
@@ -106,7 +111,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ClientOnly>
|
</ClientOnly>
|
||||||
<SearchDropdown ref="searchDropdown" v-if="isMobile && showSearch" @close="closeSearch" />
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
</template>
|
</template>
|
||||||
@@ -539,6 +543,7 @@ onMounted(async () => {
|
|||||||
.header-label {
|
.header-label {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 在线人数的数字文字样式(无背景) */
|
/* 在线人数的数字文字样式(无背景) */
|
||||||
|
|||||||
@@ -3,15 +3,30 @@
|
|||||||
<div class="login-overlay-blur"></div>
|
<div class="login-overlay-blur"></div>
|
||||||
<div class="login-overlay-content">
|
<div class="login-overlay-content">
|
||||||
<user-icon class="login-overlay-icon" />
|
<user-icon class="login-overlay-icon" />
|
||||||
<div class="login-overlay-text">请先登录,点击跳转到登录页面</div>
|
<div class="login-overlay-text">{{ props.text }}</div>
|
||||||
<div class="login-overlay-button" @click="goLogin">登录</div>
|
<div class="login-overlay-button" @click="goLogin">{{ props.buttonText }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
const props = defineProps({
|
||||||
|
text: {
|
||||||
|
type: String,
|
||||||
|
default: '请先登录,点击跳转到登录页面',
|
||||||
|
},
|
||||||
|
buttonText: {
|
||||||
|
type: String,
|
||||||
|
default: '登录',
|
||||||
|
},
|
||||||
|
buttonLink: {
|
||||||
|
type: String,
|
||||||
|
default: '/login',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
const goLogin = () => {
|
const goLogin = () => {
|
||||||
navigateTo('/login', { replace: true })
|
navigateTo(props.buttonLink, { replace: true })
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -4,11 +4,7 @@
|
|||||||
<span class="poll-row-title">投票选项</span>
|
<span class="poll-row-title">投票选项</span>
|
||||||
<div class="poll-option-item" v-for="(opt, idx) in data.options" :key="idx">
|
<div class="poll-option-item" v-for="(opt, idx) in data.options" :key="idx">
|
||||||
<BaseInput v-model="data.options[idx]" placeholder="选项内容" />
|
<BaseInput v-model="data.options[idx]" placeholder="选项内容" />
|
||||||
<i
|
<close-icon class="remove-option-icon" @click="removeOption(idx)" />
|
||||||
v-if="data.options.length > 2"
|
|
||||||
class="fa-solid fa-xmark remove-option-icon"
|
|
||||||
@click="removeOption(idx)"
|
|
||||||
></i>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="add-option" @click="addOption">添加选项</div>
|
<div class="add-option" @click="addOption">添加选项</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -36,6 +36,10 @@
|
|||||||
<template v-if="log.newFeatured">将文章设为精选</template>
|
<template v-if="log.newFeatured">将文章设为精选</template>
|
||||||
<template v-else>取消精选文章</template>
|
<template v-else>取消精选文章</template>
|
||||||
</span>
|
</span>
|
||||||
|
<span v-else-if="log.type === 'VISIBLE_SCOPE'" class="change-log-content">
|
||||||
|
变更了文章可见范围, 从 {{ formatVisibleScope(log.oldVisibleScope) }} 修改为
|
||||||
|
{{ formatVisibleScope(log.newVisibleScope) }}
|
||||||
|
</span>
|
||||||
<span v-else-if="log.type === 'VOTE_RESULT'" class="change-log-content"
|
<span v-else-if="log.type === 'VOTE_RESULT'" class="change-log-content"
|
||||||
>系统已计算投票结果</span
|
>系统已计算投票结果</span
|
||||||
>
|
>
|
||||||
@@ -69,6 +73,17 @@ const props = defineProps({
|
|||||||
title: String,
|
title: String,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const VISIBLE_SCOPE_LABELS = {
|
||||||
|
ALL: '全部可见',
|
||||||
|
ONLY_ME: '仅自己可见',
|
||||||
|
ONLY_REGISTER: '仅注册用户可见',
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatVisibleScope = (scope) => {
|
||||||
|
if (!scope) return VISIBLE_SCOPE_LABELS.ALL
|
||||||
|
return VISIBLE_SCOPE_LABELS[scope] ?? scope
|
||||||
|
}
|
||||||
|
|
||||||
const diffHtml = computed(() => {
|
const diffHtml = computed(() => {
|
||||||
// Track theme changes
|
// Track theme changes
|
||||||
const isDark = import.meta.client && document.documentElement.dataset.theme === 'dark'
|
const isDark = import.meta.client && document.documentElement.dataset.theme === 'dark'
|
||||||
|
|||||||
@@ -2,6 +2,30 @@
|
|||||||
<div class="post-poll-container" v-if="poll">
|
<div class="post-poll-container" v-if="poll">
|
||||||
<div class="poll-top-container">
|
<div class="poll-top-container">
|
||||||
<div class="poll-options-container">
|
<div class="poll-options-container">
|
||||||
|
<div class="poll-title-section">
|
||||||
|
<div class="poll-title-section-row">
|
||||||
|
<div class="poll-option-title" v-if="poll.multiple">多选</div>
|
||||||
|
<div class="poll-option-title" v-else-if="isProposal">
|
||||||
|
拟议分类:{{ poll.proposedName }}
|
||||||
|
<ToolTip
|
||||||
|
content="🗳️ 提案提交后将开放3天投票,需达到至少60%的赞成率并满10人参与方可通过。"
|
||||||
|
placement="bottom"
|
||||||
|
v-if="isProposal"
|
||||||
|
>
|
||||||
|
<info-icon class="info-icon" />
|
||||||
|
</ToolTip>
|
||||||
|
</div>
|
||||||
|
<div class="poll-option-title" v-else>单选</div>
|
||||||
|
<div class="poll-left-time">
|
||||||
|
<stopwatch class="poll-left-time-icon" />
|
||||||
|
<div class="poll-left-time-title">离结束</div>
|
||||||
|
<div class="poll-left-time-value">{{ countdown }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="poll-title-section-row">
|
||||||
|
<div v-if="poll.description" class="proposal-description">{{ poll.description }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div v-if="showPollResult || pollEnded || hasVoted">
|
<div v-if="showPollResult || pollEnded || hasVoted">
|
||||||
<div v-for="(opt, idx) in poll.options" :key="idx" class="poll-option-result">
|
<div v-for="(opt, idx) in poll.options" :key="idx" class="poll-option-result">
|
||||||
<div class="poll-option-info-container">
|
<div class="poll-option-info-container">
|
||||||
@@ -29,16 +53,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else>
|
<div v-else>
|
||||||
<div class="poll-title-section">
|
|
||||||
<div class="poll-option-title" v-if="poll.multiple">多选</div>
|
|
||||||
<div class="poll-option-title" v-else>单选</div>
|
|
||||||
|
|
||||||
<div class="poll-left-time">
|
|
||||||
<stopwatch class="poll-left-time-icon" />
|
|
||||||
<div class="poll-left-time-title">离结束</div>
|
|
||||||
<div class="poll-left-time-value">{{ countdown }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<template v-if="poll.multiple">
|
<template v-if="poll.multiple">
|
||||||
<div
|
<div
|
||||||
v-for="(opt, idx) in poll.options"
|
v-for="(opt, idx) in poll.options"
|
||||||
@@ -103,11 +117,6 @@
|
|||||||
<div v-else-if="pollEnded" class="poll-option-hint"><stopwatch /> 投票已结束</div>
|
<div v-else-if="pollEnded" class="poll-option-hint"><stopwatch /> 投票已结束</div>
|
||||||
<div v-else class="poll-option-hint">
|
<div v-else class="poll-option-hint">
|
||||||
<div>您已投票,等待结束查看结果</div>
|
<div>您已投票,等待结束查看结果</div>
|
||||||
<div class="poll-left-time">
|
|
||||||
<stopwatch class="poll-left-time-icon" />
|
|
||||||
<div class="poll-left-time-title">离结束</div>
|
|
||||||
<div class="poll-left-time-value">{{ countdown }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -130,6 +139,9 @@ const emit = defineEmits(['refresh'])
|
|||||||
const loggedIn = computed(() => authState.loggedIn)
|
const loggedIn = computed(() => authState.loggedIn)
|
||||||
const showPollResult = ref(false)
|
const showPollResult = ref(false)
|
||||||
|
|
||||||
|
const isProposal = computed(() =>
|
||||||
|
Object.prototype.hasOwnProperty.call(props.poll || {}, 'proposedName'),
|
||||||
|
)
|
||||||
const pollParticipants = computed(() => props.poll?.participants || [])
|
const pollParticipants = computed(() => props.poll?.participants || [])
|
||||||
const pollOptionParticipants = computed(() => props.poll?.optionParticipants || {})
|
const pollOptionParticipants = computed(() => props.poll?.optionParticipants || {})
|
||||||
const pollVotes = computed(() => props.poll?.votes || {})
|
const pollVotes = computed(() => props.poll?.votes || {})
|
||||||
@@ -233,6 +245,34 @@ const submitMultiPoll = async () => {
|
|||||||
padding: 10px;
|
padding: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.proposal-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background-color: var(--background-color);
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.proposal-name {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.proposal-status {
|
||||||
|
font-size: 14px;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.proposal-description {
|
||||||
|
font-size: 16px;
|
||||||
|
margin-top: 10px;
|
||||||
|
line-height: 1.5;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
.poll-option-button {
|
.poll-option-button {
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
padding: 5px 10px;
|
padding: 5px 10px;
|
||||||
@@ -385,12 +425,20 @@ const submitMultiPoll = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.poll-title-section {
|
.poll-title-section {
|
||||||
display: flex;
|
|
||||||
gap: 30px;
|
|
||||||
flex-direction: row;
|
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.poll-title-section-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-icon {
|
||||||
|
margin-right: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
.poll-option-title {
|
.poll-option-title {
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export default {
|
|||||||
{ id: 'NORMAL', name: '普通帖子', icon: 'file-text' },
|
{ id: 'NORMAL', name: '普通帖子', icon: 'file-text' },
|
||||||
{ id: 'LOTTERY', name: '抽奖帖子', icon: 'gift' },
|
{ id: 'LOTTERY', name: '抽奖帖子', icon: 'gift' },
|
||||||
{ id: 'POLL', name: '投票帖子', icon: 'ranking-list' },
|
{ id: 'POLL', name: '投票帖子', icon: 'ranking-list' },
|
||||||
|
{ id: 'PROPOSAL', name: '分类提案', icon: 'tag-one' },
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
41
frontend_nuxt/components/PostVisibleScopeSelect.vue
Normal file
41
frontend_nuxt/components/PostVisibleScopeSelect.vue
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<template>
|
||||||
|
<Dropdown
|
||||||
|
v-model="selected"
|
||||||
|
:fetch-options="fetchTypes"
|
||||||
|
placeholder="选择帖子可见范围"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import Dropdown from '~/components/Dropdown.vue'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'PostVisibleScopeSelect',
|
||||||
|
components: { Dropdown },
|
||||||
|
props: {
|
||||||
|
modelValue: { type: String, default: 'ALL' },
|
||||||
|
// options: { type: Array, default: () => [] },
|
||||||
|
},
|
||||||
|
emits: ['update:modelValue'],
|
||||||
|
setup(props, { emit }) {
|
||||||
|
|
||||||
|
const fetchTypes = async () => {
|
||||||
|
return [
|
||||||
|
{ id: 'ALL', name: '全部可见', icon: 'communication' },
|
||||||
|
{ id: 'ONLY_ME', name: '仅自己可见', icon: 'user-icon' },
|
||||||
|
{ id: 'ONLY_REGISTER', name: '仅注册用户可见', icon: 'peoples-two' },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
const selected = computed({
|
||||||
|
get: () => props.modelValue,
|
||||||
|
set: (v) => emit('update:modelValue', v),
|
||||||
|
})
|
||||||
|
|
||||||
|
return { fetchTypes, selected }
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
77
frontend_nuxt/components/ProposalForm.vue
Normal file
77
frontend_nuxt/components/ProposalForm.vue
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
<template>
|
||||||
|
<div class="proposal-section">
|
||||||
|
<div class="proposal-row">
|
||||||
|
<span class="proposal-row-title rule">
|
||||||
|
<info-icon class="proposal-description-title-icon" />提案规则说明</span
|
||||||
|
>
|
||||||
|
<div class="proposal-description-content">
|
||||||
|
<p>📛 拟议分类名称需保持唯一,请勿与现有分类或正在提案中的名称重复。</p>
|
||||||
|
<p>📝 请在下方详细说明提案目的、预期价值及补充材料,方便大家快速理解。</p>
|
||||||
|
<p>🗳️ 提案提交后将开放 3 天投票,需达到至少 60% 的赞成率并满 10 人参与方可通过。</p>
|
||||||
|
<p>🤝 讨论请遵循社区守则,保持礼貌和善,欢迎附上相关案例或参考链接。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="proposal-row">
|
||||||
|
<span class="proposal-row-title">拟议分类名称</span>
|
||||||
|
<BaseInput v-model="data.proposedName" placeholder="请输入分类名称" />
|
||||||
|
</div>
|
||||||
|
<div class="proposal-row">
|
||||||
|
<span class="proposal-row-title">提案描述</span>
|
||||||
|
<BaseInput v-model="data.proposalDescription" placeholder="简要说明提案目的与理由" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import BaseInput from '~/components/BaseInput.vue'
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
data: { type: Object, required: true },
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.proposal-section {
|
||||||
|
margin-top: 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
margin-bottom: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.proposal-row-title {
|
||||||
|
font-size: 16px;
|
||||||
|
color: var(--text-color);
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.proposal-row-title.rule {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.proposal-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.proposal-activity {
|
||||||
|
margin-top: 20px;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.proposal-description-title-text {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-left: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.proposal-description-title-icon {
|
||||||
|
margin-right: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.proposal-description-content {
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -17,7 +17,8 @@
|
|||||||
<input
|
<input
|
||||||
class="text-input"
|
class="text-input"
|
||||||
v-model="keyword"
|
v-model="keyword"
|
||||||
placeholder="Search"
|
placeholder="键盘点击「/」以触发搜索"
|
||||||
|
ref="searchInput"
|
||||||
@input="setSearch(keyword)"
|
@input="setSearch(keyword)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -48,7 +49,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, watch } from 'vue'
|
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
import Dropdown from '~/components/Dropdown.vue'
|
import Dropdown from '~/components/Dropdown.vue'
|
||||||
import { stripMarkdown } from '~/utils/markdown'
|
import { stripMarkdown } from '~/utils/markdown'
|
||||||
import { useIsMobile } from '~/utils/screen'
|
import { useIsMobile } from '~/utils/screen'
|
||||||
@@ -61,8 +62,48 @@ const keyword = ref('')
|
|||||||
const selected = ref(null)
|
const selected = ref(null)
|
||||||
const results = ref([])
|
const results = ref([])
|
||||||
const dropdown = ref(null)
|
const dropdown = ref(null)
|
||||||
|
const searchInput = ref(null)
|
||||||
const isMobile = useIsMobile()
|
const isMobile = useIsMobile()
|
||||||
|
|
||||||
|
const isEditableElement = (el) => {
|
||||||
|
if (!el) return false
|
||||||
|
if (el.isContentEditable) return true
|
||||||
|
const tagName = el.tagName ? el.tagName.toLowerCase() : ''
|
||||||
|
if (tagName === 'input' || tagName === 'textarea' || tagName === 'select') {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
const role = el.getAttribute ? el.getAttribute('role') : null
|
||||||
|
return role === 'textbox'
|
||||||
|
}
|
||||||
|
|
||||||
|
const focusSearchInput = () => {
|
||||||
|
if (!searchInput.value) return
|
||||||
|
dropdown.value?.openMenu?.()
|
||||||
|
if (typeof searchInput.value.focus === 'function') {
|
||||||
|
try {
|
||||||
|
searchInput.value.focus({ preventScroll: true })
|
||||||
|
} catch (e) {
|
||||||
|
searchInput.value.focus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleGlobalSlash = (event) => {
|
||||||
|
if (event.defaultPrevented) return
|
||||||
|
if (event.key !== '/' || event.ctrlKey || event.metaKey || event.altKey) return
|
||||||
|
if (isEditableElement(document.activeElement)) return
|
||||||
|
event.preventDefault()
|
||||||
|
focusSearchInput()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
window.addEventListener('keydown', handleGlobalSlash)
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
window.removeEventListener('keydown', handleGlobalSlash)
|
||||||
|
})
|
||||||
|
|
||||||
const toggle = () => {
|
const toggle = () => {
|
||||||
dropdown.value.toggle()
|
dropdown.value.toggle()
|
||||||
}
|
}
|
||||||
@@ -144,8 +185,7 @@ defineExpose({
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.search-dropdown {
|
.search-dropdown {
|
||||||
margin-top: 20px;
|
width: 300px;
|
||||||
width: 500px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-mobile-trigger {
|
.search-mobile-trigger {
|
||||||
@@ -154,7 +194,7 @@ defineExpose({
|
|||||||
}
|
}
|
||||||
|
|
||||||
.search-input {
|
.search-input {
|
||||||
padding: 10px;
|
padding: 2px 10px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -202,7 +242,7 @@ defineExpose({
|
|||||||
}
|
}
|
||||||
|
|
||||||
.result-body {
|
.result-body {
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
@@ -216,4 +256,14 @@ defineExpose({
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #666;
|
color: #666;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.search-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background-color: rgba(0, 0, 0, 0.5);
|
||||||
|
z-index: 10000;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
149
frontend_nuxt/components/TimelineReadItem.vue
Normal file
149
frontend_nuxt/components/TimelineReadItem.vue
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
<template>
|
||||||
|
<div class="timeline-container">
|
||||||
|
<div class="timeline-header">
|
||||||
|
<div class="timeline-title">浏览了文章</div>
|
||||||
|
<div class="timeline-date">{{ formattedDate }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="article-container">
|
||||||
|
<NuxtLink :to="postLink" class="timeline-article-link">
|
||||||
|
{{ item.post?.title }}
|
||||||
|
</NuxtLink>
|
||||||
|
<div class="timeline-snippet">
|
||||||
|
{{ strippedSnippet }}
|
||||||
|
</div>
|
||||||
|
<div class="article-meta" v-if="hasMeta">
|
||||||
|
<ArticleCategory v-if="item.post?.category" :category="item.post.category" />
|
||||||
|
<ArticleTags :tags="item.post?.tags" />
|
||||||
|
<div class="article-comment-count" v-if="item.post?.commentCount !== undefined">
|
||||||
|
<comment-one class="article-comment-count-icon" />
|
||||||
|
<span>{{ item.post?.commentCount }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { stripMarkdown } from '~/utils/markdown'
|
||||||
|
import TimeManager from '~/utils/time'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
item: { type: Object, required: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
const postLink = computed(() => {
|
||||||
|
const id = props.item.post?.id
|
||||||
|
return id ? `/posts/${id}` : '#'
|
||||||
|
})
|
||||||
|
|
||||||
|
const formattedDate = computed(() =>
|
||||||
|
TimeManager.format(props.item.lastReadAt ?? props.item.createdAt),
|
||||||
|
)
|
||||||
|
const strippedSnippet = computed(() => stripMarkdown(props.item.post?.snippet ?? ''))
|
||||||
|
const hasMeta = computed(() => {
|
||||||
|
const tags = props.item.post?.tags ?? []
|
||||||
|
const hasTags = Array.isArray(tags) && tags.length > 0
|
||||||
|
const hasCategory = !!props.item.post?.category
|
||||||
|
const hasCommentCount =
|
||||||
|
props.item.post?.commentCount !== undefined && props.item.post?.commentCount !== null
|
||||||
|
return hasTags || hasCategory || hasCommentCount
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.timeline-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding-top: 5px;
|
||||||
|
gap: 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--timeline-card-background, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-date {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--timeline-date-color, #888);
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border: 1px solid var(--normal-border-color);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 10px;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-article-link {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--link-color);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-article-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-snippet {
|
||||||
|
color: var(--timeline-snippet-color, #666);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-tag {
|
||||||
|
background-color: var(--article-info-background-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-comment-count {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-comment-count-icon {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.timeline-article-link {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-snippet {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,10 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="home-page">
|
<div class="home-page">
|
||||||
<div v-if="!isMobile" class="search-container">
|
|
||||||
<div class="search-title">一切可能,从此刻启航,在此遇见灵感与共鸣</div>
|
|
||||||
<SearchDropdown />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="topic-container">
|
<div class="topic-container">
|
||||||
<div class="topic-item-container">
|
<div class="topic-item-container">
|
||||||
<div
|
<div
|
||||||
@@ -72,8 +67,10 @@
|
|||||||
<pin v-if="article.pinned" theme="outline" class="pinned-icon" />
|
<pin v-if="article.pinned" theme="outline" class="pinned-icon" />
|
||||||
<gift v-if="article.type === 'LOTTERY'" class="lottery-icon" />
|
<gift v-if="article.type === 'LOTTERY'" class="lottery-icon" />
|
||||||
<ranking-list v-else-if="article.type === 'POLL'" class="poll-icon" />
|
<ranking-list v-else-if="article.type === 'POLL'" class="poll-icon" />
|
||||||
|
<hands v-else-if="article.type === 'PROPOSAL'" class="proposal-icon" />
|
||||||
<star v-if="!article.rssExcluded" class="featured-icon" />
|
<star v-if="!article.rssExcluded" class="featured-icon" />
|
||||||
{{ article.title }}
|
{{ article.title }}
|
||||||
|
<lock class="preview-close-icon" v-if="article.isRestricted" />
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<NuxtLink class="article-item-description main-item" :to="`/posts/${article.id}`">
|
<NuxtLink class="article-item-description main-item" :to="`/posts/${article.id}`">
|
||||||
<div v-html="stripMarkdownWithTiebaMoji(article.description, 500)"></div>
|
<div v-html="stripMarkdownWithTiebaMoji(article.description, 500)"></div>
|
||||||
@@ -116,7 +113,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div v-else class="placeholder-container">分类浏览功能开发中,敬请期待。</div>
|
<div v-else class="placeholder-container">分类浏览功能开发中,敬请期待。</div>
|
||||||
|
|
||||||
<!-- ✅ 通用“底部加载更多”组件(自管 loading/observer/并发) -->
|
<!-- 通用“底部加载更多”组件(自管 loading/observer/并发) -->
|
||||||
<InfiniteLoadMore
|
<InfiniteLoadMore
|
||||||
v-if="articles.length > 0"
|
v-if="articles.length > 0"
|
||||||
:key="ioKey"
|
:key="ioKey"
|
||||||
@@ -299,6 +296,7 @@ const {
|
|||||||
comments: p.commentCount,
|
comments: p.commentCount,
|
||||||
views: p.views,
|
views: p.views,
|
||||||
rssExcluded: p.rssExcluded || false,
|
rssExcluded: p.rssExcluded || false,
|
||||||
|
isRestricted: p.visibleScope === 'ONLY_ME' || p.visibleScope === 'ONLY_REGISTER',
|
||||||
time: TimeManager.format(
|
time: TimeManager.format(
|
||||||
selectedTopic.value === '最新回复' ? p.lastReplyAt || p.createdAt : p.createdAt,
|
selectedTopic.value === '最新回复' ? p.lastReplyAt || p.createdAt : p.createdAt,
|
||||||
),
|
),
|
||||||
@@ -340,6 +338,7 @@ const fetchNextPage = async () => {
|
|||||||
members: (p.participants || []).map((m) => ({ id: m.id, avatar: m.avatar })),
|
members: (p.participants || []).map((m) => ({ id: m.id, avatar: m.avatar })),
|
||||||
comments: p.commentCount,
|
comments: p.commentCount,
|
||||||
views: p.views,
|
views: p.views,
|
||||||
|
isRestricted: p.visibleScope === 'ONLY_ME' || p.visibleScope === 'ONLY_REGISTER',
|
||||||
rssExcluded: p.rssExcluded || false,
|
rssExcluded: p.rssExcluded || false,
|
||||||
time: TimeManager.format(
|
time: TimeManager.format(
|
||||||
selectedTopic.value === '最新回复' ? p.lastReplyAt || p.createdAt : p.createdAt,
|
selectedTopic.value === '最新回复' ? p.lastReplyAt || p.createdAt : p.createdAt,
|
||||||
@@ -379,7 +378,6 @@ onBeforeUnmount(() => {
|
|||||||
/** 供 InfiniteLoadMore 重建用的 key:筛选/Tab 改变即重建内部状态 */
|
/** 供 InfiniteLoadMore 重建用的 key:筛选/Tab 改变即重建内部状态 */
|
||||||
const ioKey = computed(() => asyncKey.value.join('::'))
|
const ioKey = computed(() => asyncKey.value.join('::'))
|
||||||
|
|
||||||
|
|
||||||
// 页面选项同步到全局状态
|
// 页面选项同步到全局状态
|
||||||
watch([selectedCategory, selectedTags], ([newCategory, newTags]) => {
|
watch([selectedCategory, selectedTags], ([newCategory, newTags]) => {
|
||||||
selectedCategoryGlobal.value = newCategory
|
selectedCategoryGlobal.value = newCategory
|
||||||
@@ -544,14 +542,14 @@ watch([selectedCategory, selectedTags], ([newCategory, newTags]) => {
|
|||||||
.header-item.views {
|
.header-item.views {
|
||||||
width: 5%;
|
width: 5%;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
.article-time,
|
.article-time,
|
||||||
.header-item.activity {
|
.header-item.activity {
|
||||||
width: 10%;
|
width: 10%;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
.article-item-title {
|
.article-item-title {
|
||||||
@@ -573,6 +571,7 @@ watch([selectedCategory, selectedTags], ([newCategory, newTags]) => {
|
|||||||
.pinned-icon,
|
.pinned-icon,
|
||||||
.lottery-icon,
|
.lottery-icon,
|
||||||
.featured-icon,
|
.featured-icon,
|
||||||
|
.proposal-icon,
|
||||||
.poll-icon {
|
.poll-icon {
|
||||||
margin-right: 4px;
|
margin-right: 4px;
|
||||||
color: var(--primary-color);
|
color: var(--primary-color);
|
||||||
|
|||||||
@@ -58,12 +58,15 @@ const submitLogin = async () => {
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ username: username.value, password: password.value }),
|
body: JSON.stringify({ username: username.value, password: password.value }),
|
||||||
})
|
})
|
||||||
const data = await res.json()
|
const data = await res.json().catch(() => ({}))
|
||||||
if (res.ok && data.token) {
|
if (res.ok && data.token) {
|
||||||
setToken(data.token)
|
setToken(data.token)
|
||||||
toast.success('登录成功')
|
toast.success('登录成功')
|
||||||
registerPush()
|
registerPush()
|
||||||
await navigateTo('/', { replace: true })
|
await navigateTo('/', { replace: true })
|
||||||
|
} else if (data.reason_code === 'EMAIL_SEND_FAILED') {
|
||||||
|
const msg = data.error || data.message || res.statusText || '登录失败'
|
||||||
|
toast.error(`${res.status} ${msg} (${data.reason_code})`)
|
||||||
} else if (data.reason_code === 'NOT_VERIFIED') {
|
} else if (data.reason_code === 'NOT_VERIFIED') {
|
||||||
toast.info('当前邮箱未验证,已经为您重新发送验证码')
|
toast.info('当前邮箱未验证,已经为您重新发送验证码')
|
||||||
await navigateTo(
|
await navigateTo(
|
||||||
@@ -76,10 +79,12 @@ const submitLogin = async () => {
|
|||||||
} else if (data.reason_code === 'NOT_APPROVED') {
|
} else if (data.reason_code === 'NOT_APPROVED') {
|
||||||
await navigateTo({ path: '/signup-reason', query: { token: data.token } }, { replace: true })
|
await navigateTo({ path: '/signup-reason', query: { token: data.token } }, { replace: true })
|
||||||
} else {
|
} else {
|
||||||
toast.error(data.error || '登录失败')
|
const msg = data.error || data.message || res.statusText || '登录失败'
|
||||||
|
const reason = data.reason_code ? ` (${data.reason_code})` : ''
|
||||||
|
toast.error(`${res.status} ${msg}${reason}`)
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error('登录失败')
|
toast.error(`登录失败: ${e.message}`)
|
||||||
} finally {
|
} finally {
|
||||||
isWaitingForLogin.value = false
|
isWaitingForLogin.value = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,7 +75,9 @@
|
|||||||
@click="markRead(item.id)"
|
@click="markRead(item.id)"
|
||||||
:to="`/posts/${item.post.id}#comment-${item.parentComment.id}`"
|
:to="`/posts/${item.post.id}#comment-${item.parentComment.id}`"
|
||||||
>
|
>
|
||||||
<span v-html="stripMarkdownWithTiebaMoji(item.parentComment.content, 500)"></span>
|
<span
|
||||||
|
v-html="stripMarkdownWithTiebaMoji(item.parentComment.content, 500)"
|
||||||
|
></span>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</span>
|
</span>
|
||||||
回复了
|
回复了
|
||||||
@@ -85,7 +87,9 @@
|
|||||||
@click="markRead(item.id)"
|
@click="markRead(item.id)"
|
||||||
:to="`/posts/${item.post.id}#comment-${item.comment.id}`"
|
:to="`/posts/${item.post.id}#comment-${item.comment.id}`"
|
||||||
>
|
>
|
||||||
<span v-html="stripMarkdownWithTiebaMoji(item.comment.content, 500)"></span>
|
<span
|
||||||
|
v-html="stripMarkdownWithTiebaMoji(item.comment.content, 500)"
|
||||||
|
></span>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</span>
|
</span>
|
||||||
</NotificationContainer>
|
</NotificationContainer>
|
||||||
@@ -115,7 +119,9 @@
|
|||||||
@click="markRead(item.id)"
|
@click="markRead(item.id)"
|
||||||
:to="`/posts/${item.post.id}#comment-${item.comment.id}`"
|
:to="`/posts/${item.post.id}#comment-${item.comment.id}`"
|
||||||
>
|
>
|
||||||
<span v-html="stripMarkdownWithTiebaMoji(item.comment.content, 500)"></span>
|
<span
|
||||||
|
v-html="stripMarkdownWithTiebaMoji(item.comment.content, 500)"
|
||||||
|
></span>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</span>
|
</span>
|
||||||
</NotificationContainer>
|
</NotificationContainer>
|
||||||
@@ -162,7 +168,9 @@
|
|||||||
@click="markRead(item.id)"
|
@click="markRead(item.id)"
|
||||||
:to="`/posts/${item.post.id}#comment-${item.comment.id}`"
|
:to="`/posts/${item.post.id}#comment-${item.comment.id}`"
|
||||||
>
|
>
|
||||||
<span v-html="stripMarkdownWithTiebaMoji(item.comment.content, 500)"></span>
|
<span
|
||||||
|
v-html="stripMarkdownWithTiebaMoji(item.comment.content, 500)"
|
||||||
|
></span>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</span>
|
</span>
|
||||||
进行了表态
|
进行了表态
|
||||||
@@ -251,6 +259,38 @@
|
|||||||
已出结果
|
已出结果
|
||||||
</NotificationContainer>
|
</NotificationContainer>
|
||||||
</template>
|
</template>
|
||||||
|
<template v-else-if="item.type === 'CATEGORY_PROPOSAL_RESULT_OWNER'">
|
||||||
|
<NotificationContainer :item="item" :markRead="markRead">
|
||||||
|
你的分类提案
|
||||||
|
<NuxtLink
|
||||||
|
class="notif-content-text"
|
||||||
|
@click="markRead(item.id)"
|
||||||
|
:to="`/posts/${item.post.id}`"
|
||||||
|
>
|
||||||
|
{{ stripMarkdownLength(item.post.title, 100) }}
|
||||||
|
</NuxtLink>
|
||||||
|
<span v-if="item.approved">已通过</span>
|
||||||
|
<span v-else>
|
||||||
|
未通过<span v-if="item.content">,原因:{{ item.content }}</span>
|
||||||
|
</span>
|
||||||
|
</NotificationContainer>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="item.type === 'CATEGORY_PROPOSAL_RESULT_PARTICIPANT'">
|
||||||
|
<NotificationContainer :item="item" :markRead="markRead">
|
||||||
|
你参与的分类提案
|
||||||
|
<NuxtLink
|
||||||
|
class="notif-content-text"
|
||||||
|
@click="markRead(item.id)"
|
||||||
|
:to="`/posts/${item.post.id}`"
|
||||||
|
>
|
||||||
|
{{ stripMarkdownLength(item.post.title, 100) }}
|
||||||
|
</NuxtLink>
|
||||||
|
<span v-if="item.approved">已通过</span>
|
||||||
|
<span v-else>
|
||||||
|
未通过<span v-if="item.content">,原因:{{ item.content }}</span>
|
||||||
|
</span>
|
||||||
|
</NotificationContainer>
|
||||||
|
</template>
|
||||||
<template v-else-if="item.type === 'POST_UPDATED'">
|
<template v-else-if="item.type === 'POST_UPDATED'">
|
||||||
<NotificationContainer :item="item" :markRead="markRead">
|
<NotificationContainer :item="item" :markRead="markRead">
|
||||||
您关注的帖子
|
您关注的帖子
|
||||||
@@ -287,7 +327,9 @@
|
|||||||
@click="markRead(item.id)"
|
@click="markRead(item.id)"
|
||||||
:to="`/posts/${item.post.id}#comment-${item.parentComment.id}`"
|
:to="`/posts/${item.post.id}#comment-${item.parentComment.id}`"
|
||||||
>
|
>
|
||||||
<span v-html="stripMarkdownWithTiebaMoji(item.parentComment.content, 500)"></span>
|
<span
|
||||||
|
v-html="stripMarkdownWithTiebaMoji(item.parentComment.content, 500)"
|
||||||
|
></span>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
回复了
|
回复了
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
@@ -295,7 +337,7 @@
|
|||||||
@click="markRead(item.id)"
|
@click="markRead(item.id)"
|
||||||
:to="`/posts/${item.post.id}#comment-${item.comment.id}`"
|
:to="`/posts/${item.post.id}#comment-${item.comment.id}`"
|
||||||
>
|
>
|
||||||
<span v-html="stripMarkdownWithTiebaMoji(item.comment.content, 500)"></span>
|
<span v-html="stripMarkdownWithTiebaMoji(item.comment.content, 500)"></span>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</NotificationContainer>
|
</NotificationContainer>
|
||||||
</template>
|
</template>
|
||||||
@@ -775,6 +817,10 @@ const formatType = (t) => {
|
|||||||
return '发布的投票结果已公布'
|
return '发布的投票结果已公布'
|
||||||
case 'POLL_RESULT_PARTICIPANT':
|
case 'POLL_RESULT_PARTICIPANT':
|
||||||
return '参与的投票结果已公布'
|
return '参与的投票结果已公布'
|
||||||
|
case 'CATEGORY_PROPOSAL_RESULT_OWNER':
|
||||||
|
return '分类提案结果已公布'
|
||||||
|
case 'CATEGORY_PROPOSAL_RESULT_PARTICIPANT':
|
||||||
|
return '参与的分类提案结果已公布'
|
||||||
default:
|
default:
|
||||||
return t
|
return t
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
<CategorySelect v-model="selectedCategory" />
|
<CategorySelect v-model="selectedCategory" />
|
||||||
<TagSelect v-model="selectedTags" creatable />
|
<TagSelect v-model="selectedTags" creatable />
|
||||||
<PostTypeSelect v-model="postType" />
|
<PostTypeSelect v-model="postType" />
|
||||||
|
<PostVisibleScopeSelect v-model="postVisibleScope"/>
|
||||||
</div>
|
</div>
|
||||||
<div class="post-options-right">
|
<div class="post-options-right">
|
||||||
<div class="post-clear" @click="clearPost"><clear-icon /> 清空</div>
|
<div class="post-clear" @click="clearPost"><clear-icon /> 清空</div>
|
||||||
@@ -37,6 +38,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<LotteryForm v-if="postType === 'LOTTERY'" :data="lottery" />
|
<LotteryForm v-if="postType === 'LOTTERY'" :data="lottery" />
|
||||||
<PollForm v-if="postType === 'POLL'" :data="poll" />
|
<PollForm v-if="postType === 'POLL'" :data="poll" />
|
||||||
|
<ProposalForm v-if="postType === 'PROPOSAL'" :data="proposal" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -50,8 +52,10 @@ import PostTypeSelect from '~/components/PostTypeSelect.vue'
|
|||||||
import TagSelect from '~/components/TagSelect.vue'
|
import TagSelect from '~/components/TagSelect.vue'
|
||||||
import LotteryForm from '~/components/LotteryForm.vue'
|
import LotteryForm from '~/components/LotteryForm.vue'
|
||||||
import PollForm from '~/components/PollForm.vue'
|
import PollForm from '~/components/PollForm.vue'
|
||||||
|
import ProposalForm from '~/components/ProposalForm.vue'
|
||||||
import { toast } from '~/main'
|
import { toast } from '~/main'
|
||||||
import { authState, getToken } from '~/utils/auth'
|
import { authState, getToken } from '~/utils/auth'
|
||||||
|
import PostVisibleScopeSelect from '~/components/PostVisibleScopeSelect.vue'
|
||||||
const config = useRuntimeConfig()
|
const config = useRuntimeConfig()
|
||||||
const API_BASE_URL = config.public.apiBaseUrl
|
const API_BASE_URL = config.public.apiBaseUrl
|
||||||
|
|
||||||
@@ -60,6 +64,7 @@ const content = ref('')
|
|||||||
const selectedCategory = ref('')
|
const selectedCategory = ref('')
|
||||||
const selectedTags = ref([])
|
const selectedTags = ref([])
|
||||||
const postType = ref('NORMAL')
|
const postType = ref('NORMAL')
|
||||||
|
const postVisibleScope = ref('ALL')
|
||||||
const lottery = reactive({
|
const lottery = reactive({
|
||||||
prizeIcon: '',
|
prizeIcon: '',
|
||||||
prizeIconFile: null,
|
prizeIconFile: null,
|
||||||
@@ -76,6 +81,10 @@ const poll = reactive({
|
|||||||
endTime: null,
|
endTime: null,
|
||||||
multiple: false,
|
multiple: false,
|
||||||
})
|
})
|
||||||
|
const proposal = reactive({
|
||||||
|
proposedName: '',
|
||||||
|
proposalDescription: '',
|
||||||
|
})
|
||||||
const startTime = ref(null)
|
const startTime = ref(null)
|
||||||
const isWaitingPosting = ref(false)
|
const isWaitingPosting = ref(false)
|
||||||
const isAiLoading = ref(false)
|
const isAiLoading = ref(false)
|
||||||
@@ -94,6 +103,7 @@ const loadDraft = async () => {
|
|||||||
content.value = data.content || ''
|
content.value = data.content || ''
|
||||||
selectedCategory.value = data.categoryId || ''
|
selectedCategory.value = data.categoryId || ''
|
||||||
selectedTags.value = data.tagIds || []
|
selectedTags.value = data.tagIds || []
|
||||||
|
postVisibleScope.value = data.visiblescope
|
||||||
|
|
||||||
toast.success('草稿已加载')
|
toast.success('草稿已加载')
|
||||||
}
|
}
|
||||||
@@ -109,6 +119,7 @@ const clearPost = async () => {
|
|||||||
content.value = ''
|
content.value = ''
|
||||||
selectedCategory.value = ''
|
selectedCategory.value = ''
|
||||||
selectedTags.value = []
|
selectedTags.value = []
|
||||||
|
postVisibleScope.value = 'ALL'
|
||||||
postType.value = 'NORMAL'
|
postType.value = 'NORMAL'
|
||||||
lottery.prizeIcon = ''
|
lottery.prizeIcon = ''
|
||||||
lottery.prizeIconFile = null
|
lottery.prizeIconFile = null
|
||||||
@@ -123,6 +134,8 @@ const clearPost = async () => {
|
|||||||
poll.options = ['', '']
|
poll.options = ['', '']
|
||||||
poll.endTime = null
|
poll.endTime = null
|
||||||
poll.multiple = false
|
poll.multiple = false
|
||||||
|
proposal.proposedName = ''
|
||||||
|
proposal.proposalDescription = ''
|
||||||
|
|
||||||
// 删除草稿
|
// 删除草稿
|
||||||
const token = getToken()
|
const token = getToken()
|
||||||
@@ -160,6 +173,7 @@ const saveDraft = async () => {
|
|||||||
content: content.value,
|
content: content.value,
|
||||||
categoryId: selectedCategory.value || null,
|
categoryId: selectedCategory.value || null,
|
||||||
tagIds,
|
tagIds,
|
||||||
|
postVisibleScopeType:postVisibleScope.value
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
@@ -283,6 +297,12 @@ const submitPost = async () => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (postType.value === 'PROPOSAL') {
|
||||||
|
if (!proposal.proposedName.trim()) {
|
||||||
|
toast.error('请填写拟议分类名称')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const token = getToken()
|
const token = getToken()
|
||||||
await ensureTags(token)
|
await ensureTags(token)
|
||||||
@@ -303,36 +323,46 @@ const submitPost = async () => {
|
|||||||
}
|
}
|
||||||
prizeIconUrl = uploadData.data.url
|
prizeIconUrl = uploadData.data.url
|
||||||
}
|
}
|
||||||
|
const toUtcString = (value) => {
|
||||||
|
if (!value) return undefined
|
||||||
|
return new Date(new Date(value).getTime() + 8.02 * 60 * 60 * 1000).toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
title: title.value,
|
||||||
|
content: content.value,
|
||||||
|
categoryId: selectedCategory.value,
|
||||||
|
tagIds: selectedTags.value,
|
||||||
|
type: postType.value,
|
||||||
|
postVisibleScopeType: postVisibleScope.value,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (postType.value === 'LOTTERY') {
|
||||||
|
payload.prizeIcon = prizeIconUrl
|
||||||
|
payload.prizeName = lottery.prizeName
|
||||||
|
payload.prizeCount = lottery.prizeCount
|
||||||
|
payload.prizeDescription = lottery.prizeDescription
|
||||||
|
payload.pointCost = lottery.pointCost
|
||||||
|
payload.startTime = startTime.value ? new Date(startTime.value).toISOString() : undefined
|
||||||
|
payload.endTime = toUtcString(lottery.endTime)
|
||||||
|
} else if (postType.value === 'POLL') {
|
||||||
|
payload.options = poll.options
|
||||||
|
payload.multiple = poll.multiple
|
||||||
|
payload.endTime = toUtcString(poll.endTime)
|
||||||
|
} else if (postType.value === 'PROPOSAL') {
|
||||||
|
payload.proposedName = proposal.proposedName
|
||||||
|
payload.proposalDescription = proposal.proposalDescription
|
||||||
|
}
|
||||||
|
|
||||||
const res = await fetch(`${API_BASE_URL}/api/posts`, {
|
const res = await fetch(`${API_BASE_URL}/api/posts`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
Authorization: `Bearer ${token}`,
|
Authorization: `Bearer ${token}`,
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify(payload),
|
||||||
title: title.value,
|
|
||||||
content: content.value,
|
|
||||||
categoryId: selectedCategory.value,
|
|
||||||
tagIds: selectedTags.value,
|
|
||||||
type: postType.value,
|
|
||||||
prizeIcon: postType.value === 'LOTTERY' ? prizeIconUrl : undefined,
|
|
||||||
prizeName: postType.value === 'LOTTERY' ? lottery.prizeName : undefined,
|
|
||||||
prizeCount: postType.value === 'LOTTERY' ? lottery.prizeCount : undefined,
|
|
||||||
prizeDescription: postType.value === 'LOTTERY' ? lottery.prizeDescription : undefined,
|
|
||||||
options: postType.value === 'POLL' ? poll.options : undefined,
|
|
||||||
multiple: postType.value === 'POLL' ? poll.multiple : undefined,
|
|
||||||
startTime:
|
|
||||||
postType.value === 'LOTTERY' ? new Date(startTime.value).toISOString() : undefined,
|
|
||||||
pointCost: postType.value === 'LOTTERY' ? lottery.pointCost : undefined,
|
|
||||||
// 将时间转换为 UTC+8.5 时区 todo: 需要优化
|
|
||||||
endTime:
|
|
||||||
postType.value === 'LOTTERY'
|
|
||||||
? new Date(new Date(lottery.endTime).getTime() + 8.02 * 60 * 60 * 1000).toISOString()
|
|
||||||
: postType.value === 'POLL'
|
|
||||||
? new Date(new Date(poll.endTime).getTime() + 8.02 * 60 * 60 * 1000).toISOString()
|
|
||||||
: undefined,
|
|
||||||
}),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
if (data.reward && data.reward > 0) {
|
if (data.reward && data.reward > 0) {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
<div class="post-options-left">
|
<div class="post-options-left">
|
||||||
<CategorySelect v-model="selectedCategory" />
|
<CategorySelect v-model="selectedCategory" />
|
||||||
<TagSelect v-model="selectedTags" creatable />
|
<TagSelect v-model="selectedTags" creatable />
|
||||||
|
<PostVisibleScopeSelect v-model="selectedVisibleScope"/>
|
||||||
</div>
|
</div>
|
||||||
<div class="post-options-right">
|
<div class="post-options-right">
|
||||||
<div class="post-clear" @click="clearPost"><clear-icon /> 清空</div>
|
<div class="post-clear" @click="clearPost"><clear-icon /> 清空</div>
|
||||||
@@ -44,6 +45,7 @@ import TagSelect from '~/components/TagSelect.vue'
|
|||||||
import { toast } from '~/main'
|
import { toast } from '~/main'
|
||||||
import { getToken, authState } from '~/utils/auth'
|
import { getToken, authState } from '~/utils/auth'
|
||||||
import LoginOverlay from '~/components/LoginOverlay.vue'
|
import LoginOverlay from '~/components/LoginOverlay.vue'
|
||||||
|
import PostVisibleScopeSelect from '~/components/PostVisibleScopeSelect.vue'
|
||||||
const config = useRuntimeConfig()
|
const config = useRuntimeConfig()
|
||||||
const API_BASE_URL = config.public.apiBaseUrl
|
const API_BASE_URL = config.public.apiBaseUrl
|
||||||
|
|
||||||
@@ -51,6 +53,7 @@ const title = ref('')
|
|||||||
const content = ref('')
|
const content = ref('')
|
||||||
const selectedCategory = ref('')
|
const selectedCategory = ref('')
|
||||||
const selectedTags = ref([])
|
const selectedTags = ref([])
|
||||||
|
const selectedVisibleScope = ref('ALL')
|
||||||
const isWaitingPosting = ref(false)
|
const isWaitingPosting = ref(false)
|
||||||
const isAiLoading = ref(false)
|
const isAiLoading = ref(false)
|
||||||
const isLogin = computed(() => authState.loggedIn)
|
const isLogin = computed(() => authState.loggedIn)
|
||||||
@@ -70,6 +73,7 @@ const loadPost = async () => {
|
|||||||
content.value = data.content || ''
|
content.value = data.content || ''
|
||||||
selectedCategory.value = data.category.id || ''
|
selectedCategory.value = data.category.id || ''
|
||||||
selectedTags.value = (data.tags || []).map((t) => t.id)
|
selectedTags.value = (data.tags || []).map((t) => t.id)
|
||||||
|
selectedVisibleScope.value = data.visibleScope
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error('加载失败')
|
toast.error('加载失败')
|
||||||
@@ -180,6 +184,7 @@ const submitPost = async () => {
|
|||||||
content: content.value,
|
content: content.value,
|
||||||
categoryId: selectedCategory.value,
|
categoryId: selectedCategory.value,
|
||||||
tagIds: selectedTags.value,
|
tagIds: selectedTags.value,
|
||||||
|
postVisibleScopeType:selectedVisibleScope.value
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
|
|||||||
@@ -1,5 +1,22 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="post-page-container">
|
<div v-if="isRestricted" class="restricted-content">
|
||||||
|
<template v-if="visibleScope === 'ONLY_ME'">
|
||||||
|
<LoginOverlay
|
||||||
|
text="这是一篇私密文章,仅作者本人及管理员可见"
|
||||||
|
button-text="返回首页"
|
||||||
|
button-link="/"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="visibleScope === 'ONLY_REGISTER'">
|
||||||
|
<LoginOverlay
|
||||||
|
text="这是一篇仅登录用户可见的文章,请先登录"
|
||||||
|
button-text="登录"
|
||||||
|
button-link="/login"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<div v-else class="post-page-container">
|
||||||
<div v-if="isWaitingFetchingPost" class="loading-container">
|
<div v-if="isWaitingFetchingPost" class="loading-container">
|
||||||
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
||||||
</div>
|
</div>
|
||||||
@@ -16,7 +33,9 @@
|
|||||||
<div v-if="status === 'PENDING'" class="article-pending-button">审核中</div>
|
<div v-if="status === 'PENDING'" class="article-pending-button">审核中</div>
|
||||||
<div v-if="status === 'REJECTED'" class="article-block-button">已拒绝</div>
|
<div v-if="status === 'REJECTED'" class="article-block-button">已拒绝</div>
|
||||||
<div v-if="!rssExcluded" class="article-featured-button">精品</div>
|
<div v-if="!rssExcluded" class="article-featured-button">精品</div>
|
||||||
<div v-if="closed" class="article-closed-button">已关闭</div>
|
<div v-if="closed" class="article-gray-button">已关闭</div>
|
||||||
|
<div v-if="visibleScope === 'ONLY_ME'" class="article-gray-button">仅自己可见</div>
|
||||||
|
<div v-if="visibleScope === 'ONLY_REGISTER'" class="article-gray-button">仅登录可见</div>
|
||||||
<div
|
<div
|
||||||
v-if="!closed && loggedIn && !isAuthor && !subscribed"
|
v-if="!closed && loggedIn && !isAuthor && !subscribed"
|
||||||
class="article-subscribe-button"
|
class="article-subscribe-button"
|
||||||
@@ -92,7 +111,7 @@
|
|||||||
></div>
|
></div>
|
||||||
|
|
||||||
<div class="article-footer-container">
|
<div class="article-footer-container">
|
||||||
<div class="option-container">
|
<div class="article-option-container">
|
||||||
<ReactionsGroup
|
<ReactionsGroup
|
||||||
ref="postReactionsGroupRef"
|
ref="postReactionsGroupRef"
|
||||||
v-model="postReactions"
|
v-model="postReactions"
|
||||||
@@ -165,25 +184,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="post-page-scroller-container">
|
|
||||||
<div class="scroller">
|
|
||||||
<div v-if="isWaitingFetchingPost" class="scroller-time">loading...</div>
|
|
||||||
<div v-else class="scroller-time">{{ scrollerTopTime }}</div>
|
|
||||||
<div class="scroller-middle">
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
class="scroller-range"
|
|
||||||
:max="totalPosts"
|
|
||||||
:min="1"
|
|
||||||
v-model.number="currentIndex"
|
|
||||||
@input="onSliderInput"
|
|
||||||
/>
|
|
||||||
<div class="scroller-index">{{ currentIndex }}/{{ totalPosts }}</div>
|
|
||||||
</div>
|
|
||||||
<div v-if="isWaitingFetchingPost" class="scroller-time">loading...</div>
|
|
||||||
<div v-else class="scroller-time">{{ lastReplyTime }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<vue-easy-lightbox
|
<vue-easy-lightbox
|
||||||
:visible="lightboxVisible"
|
:visible="lightboxVisible"
|
||||||
:index="lightboxIndex"
|
:index="lightboxIndex"
|
||||||
@@ -228,6 +228,7 @@ import { useIsMobile } from '~/utils/screen'
|
|||||||
import Dropdown from '~/components/Dropdown.vue'
|
import Dropdown from '~/components/Dropdown.vue'
|
||||||
import { ClientOnly } from '#components'
|
import { ClientOnly } from '#components'
|
||||||
import { useConfirm } from '~/composables/useConfirm'
|
import { useConfirm } from '~/composables/useConfirm'
|
||||||
|
import { Lock } from '@icon-park/vue-next'
|
||||||
const { confirm } = useConfirm()
|
const { confirm } = useConfirm()
|
||||||
|
|
||||||
const config = useRuntimeConfig()
|
const config = useRuntimeConfig()
|
||||||
@@ -241,6 +242,13 @@ const author = ref('')
|
|||||||
const postContent = ref('')
|
const postContent = ref('')
|
||||||
const category = ref('')
|
const category = ref('')
|
||||||
const tags = ref([])
|
const tags = ref([])
|
||||||
|
const visibleScope = ref('ALL') // 可见范围
|
||||||
|
const isRestricted = computed(() => {
|
||||||
|
return (
|
||||||
|
(visibleScope.value === 'ONLY_ME' && !isAuthor.value && !isAdmin.value) ||
|
||||||
|
(visibleScope.value === 'ONLY_REGISTER' && !loggedIn.value)
|
||||||
|
)
|
||||||
|
})
|
||||||
const postReactions = ref([])
|
const postReactions = ref([])
|
||||||
const postReactionsGroupRef = ref(null)
|
const postReactionsGroupRef = ref(null)
|
||||||
const postLikeCount = computed(
|
const postLikeCount = computed(
|
||||||
@@ -369,6 +377,7 @@ const mapComment = (
|
|||||||
text: c.content,
|
text: c.content,
|
||||||
reactions: c.reactions || [],
|
reactions: c.reactions || [],
|
||||||
pinned: Boolean(c.pinned ?? c.pinnedAt ?? c.pinned_at),
|
pinned: Boolean(c.pinned ?? c.pinnedAt ?? c.pinned_at),
|
||||||
|
isBot: Boolean(c.author?.bot),
|
||||||
reply: (c.replies || []).map((r) =>
|
reply: (c.replies || []).map((r) =>
|
||||||
mapComment(r, c.author.username, c.author.avatar, c.author.id, level + 1),
|
mapComment(r, c.author.username, c.author.avatar, c.author.id, level + 1),
|
||||||
),
|
),
|
||||||
@@ -408,6 +417,14 @@ const changeLogIcon = (l) => {
|
|||||||
} else {
|
} else {
|
||||||
return 'dislike'
|
return 'dislike'
|
||||||
}
|
}
|
||||||
|
} else if (l.type === 'VISIBLE_SCOPE') {
|
||||||
|
if (l.newVisibleScope === 'ONLY_ME') {
|
||||||
|
return 'lock-one'
|
||||||
|
} else if (l.newVisibleScope === 'ONLY_REGISTER') {
|
||||||
|
return 'peoples-two'
|
||||||
|
} else {
|
||||||
|
return 'communication'
|
||||||
|
}
|
||||||
} else if (l.type === 'VOTE_RESULT') {
|
} else if (l.type === 'VOTE_RESULT') {
|
||||||
return 'check-one'
|
return 'check-one'
|
||||||
} else if (l.type === 'LOTTERY_RESULT') {
|
} else if (l.type === 'LOTTERY_RESULT') {
|
||||||
@@ -438,6 +455,8 @@ const mapChangeLog = (l) => ({
|
|||||||
newCategory: l.newCategory,
|
newCategory: l.newCategory,
|
||||||
oldTags: l.oldTags,
|
oldTags: l.oldTags,
|
||||||
newTags: l.newTags,
|
newTags: l.newTags,
|
||||||
|
oldVisibleScope: l.oldVisibleScope,
|
||||||
|
newVisibleScope: l.newVisibleScope,
|
||||||
amount: l.amount,
|
amount: l.amount,
|
||||||
icon: changeLogIcon(l),
|
icon: changeLogIcon(l),
|
||||||
})
|
})
|
||||||
@@ -497,15 +516,27 @@ const onCommentDeleted = (id) => {
|
|||||||
fetchTimeline()
|
fetchTimeline()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const tokenHeader = computed(() => {
|
||||||
|
const token = getToken()
|
||||||
|
return token ? { Authorization: `Bearer ${token}` } : {}
|
||||||
|
})
|
||||||
const {
|
const {
|
||||||
data: postData,
|
data: postData,
|
||||||
pending: pendingPost,
|
pending: pendingPost,
|
||||||
error: postError,
|
error: postError,
|
||||||
refresh: refreshPost,
|
refresh: refreshPost,
|
||||||
} = await useAsyncData(`post-${postId}`, () => $fetch(`${API_BASE_URL}/api/posts/${postId}`), {
|
} = await useAsyncData(
|
||||||
server: true,
|
`post-${postId}`,
|
||||||
lazy: false,
|
async () => {
|
||||||
})
|
try {
|
||||||
|
return await $fetch(`${API_BASE_URL}/api/posts/${postId}`, { headers: tokenHeader.value })
|
||||||
|
} catch (err) {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
server: true,
|
||||||
|
lazy: false,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
// 用 pendingPost 驱动现有 UI(替代 isWaitingFetchingPost 手控)
|
// 用 pendingPost 驱动现有 UI(替代 isWaitingFetchingPost 手控)
|
||||||
const isWaitingFetchingPost = computed(() => pendingPost.value)
|
const isWaitingFetchingPost = computed(() => pendingPost.value)
|
||||||
@@ -519,6 +550,7 @@ watchEffect(() => {
|
|||||||
title.value = data.title
|
title.value = data.title
|
||||||
category.value = data.category
|
category.value = data.category
|
||||||
tags.value = data.tags || []
|
tags.value = data.tags || []
|
||||||
|
visibleScope.value = data.visibleScope || 'ALL'
|
||||||
postReactions.value = data.reactions || []
|
postReactions.value = data.reactions || []
|
||||||
subscribed.value = !!data.subscribed
|
subscribed.value = !!data.subscribed
|
||||||
status.value = data.status
|
status.value = data.status
|
||||||
@@ -935,7 +967,7 @@ onMounted(async () => {
|
|||||||
<style>
|
<style>
|
||||||
.post-page-container {
|
.post-page-container {
|
||||||
background-color: var(--background-color);
|
background-color: var(--background-color);
|
||||||
display: flex;
|
display: block;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -948,9 +980,10 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.post-page-main-container {
|
.post-page-main-container {
|
||||||
|
position: relative;
|
||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
width: calc(85% - 40px);
|
width: calc(100% - 40px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-content-text p code {
|
.info-content-text p code {
|
||||||
@@ -1002,6 +1035,35 @@ onMounted(async () => {
|
|||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.skeleton {
|
||||||
|
background-color: #eee;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
height: 20px;
|
||||||
|
margin-top: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton::before {
|
||||||
|
content: '';
|
||||||
|
display: block;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
bottom: 0;
|
||||||
|
right: 0;
|
||||||
|
background: linear-gradient(90deg, #eee 0%, #f5f5f7 40%, #e0e0e0 100%);
|
||||||
|
transform: translateX(-100%);
|
||||||
|
animation: skeleton-shimmer 1.5s infinite linear;
|
||||||
|
z-index: 1;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
@keyframes skeleton-shimmer {
|
||||||
|
100% {
|
||||||
|
transform: translateX(100%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.user-avatar-container {
|
.user-avatar-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
@@ -1106,7 +1168,7 @@ onMounted(async () => {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.article-closed-button,
|
.article-gray-button,
|
||||||
.article-subscribe-button-text,
|
.article-subscribe-button-text,
|
||||||
.article-featured-button,
|
.article-featured-button,
|
||||||
.article-unsubscribe-button-text {
|
.article-unsubscribe-button-text {
|
||||||
@@ -1159,7 +1221,7 @@ onMounted(async () => {
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.article-closed-button {
|
.article-gray-button {
|
||||||
background-color: var(--background-color);
|
background-color: var(--background-color);
|
||||||
color: gray;
|
color: gray;
|
||||||
border: 1px solid gray;
|
border: 1px solid gray;
|
||||||
@@ -1286,7 +1348,7 @@ onMounted(async () => {
|
|||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.option-container {
|
.article-option-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -1333,14 +1395,80 @@ onMounted(async () => {
|
|||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
.reaction-action.copy-link:hover {
|
|
||||||
background-color: #e2e2e2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.comment-editor-wrapper {
|
.comment-editor-wrapper {
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ======== 权限锁定状态 ======== */
|
||||||
|
.is-blurred {
|
||||||
|
filter: blur(10px);
|
||||||
|
pointer-events: none;
|
||||||
|
user-select: none;
|
||||||
|
transition: filter 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 遮罩层 */
|
||||||
|
.restricted-overlay {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 999;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
background: rgba(0, 0, 0, 0.45);
|
||||||
|
animation: fadeIn 0.3s ease forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 中央提示框 */
|
||||||
|
.restricted-content {
|
||||||
|
background: #ffff;
|
||||||
|
color: var(--primary-color);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.restricted-icon {
|
||||||
|
font-size: 60px;
|
||||||
|
opacity: 0.8;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.restricted-button {
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: 20px;
|
||||||
|
padding: 10px 18px;
|
||||||
|
background: var(--primary-color);
|
||||||
|
color: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: background 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.restricted-button:hover {
|
||||||
|
background: var(--primary-color-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.restricted-actions {
|
||||||
|
margin-top: 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 淡入动画 */
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.post-page-main-container {
|
.post-page-main-container {
|
||||||
width: calc(100% - 20px);
|
width: calc(100% - 20px);
|
||||||
|
|||||||
@@ -139,8 +139,7 @@ const sendVerification = async () => {
|
|||||||
inviteToken: inviteToken.value,
|
inviteToken: inviteToken.value,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
isWaitingForEmailSent.value = false
|
const data = await res.json().catch(() => ({}))
|
||||||
const data = await res.json()
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
emailStep.value = 1
|
emailStep.value = 1
|
||||||
toast.success('验证码已发送,请查看邮箱')
|
toast.success('验证码已发送,请查看邮箱')
|
||||||
@@ -149,10 +148,14 @@ const sendVerification = async () => {
|
|||||||
if (data.field === 'email') emailError.value = data.error
|
if (data.field === 'email') emailError.value = data.error
|
||||||
if (data.field === 'password') passwordError.value = data.error
|
if (data.field === 'password') passwordError.value = data.error
|
||||||
} else {
|
} else {
|
||||||
toast.error(data.error || '发送失败')
|
const msg = data.error || data.message || res.statusText || '发送失败'
|
||||||
|
const reason = data.reason_code ? ` (${data.reason_code})` : ''
|
||||||
|
toast.error(`${res.status} ${msg}${reason}`)
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error('发送失败')
|
toast.error(`发送失败: ${e.message}`)
|
||||||
|
} finally {
|
||||||
|
isWaitingForEmailSent.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -191,14 +191,25 @@
|
|||||||
>
|
>
|
||||||
评论和回复
|
评论和回复
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="isMine"
|
||||||
|
:class="['timeline-tab-item', { selected: timelineFilter === 'reads' }]"
|
||||||
|
@click="timelineFilter = 'reads'"
|
||||||
|
>
|
||||||
|
浏览记录
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<BasePlaceholder
|
<BasePlaceholder
|
||||||
v-if="filteredTimelineItems.length === 0"
|
v-if="
|
||||||
text="暂无时间线"
|
timelineFilter === 'reads'
|
||||||
|
? readPosts.length === 0
|
||||||
|
: filteredTimelineItems.length === 0
|
||||||
|
"
|
||||||
|
:text="timelineFilter === 'reads' ? '暂无浏览记录' : '暂无时间线'"
|
||||||
icon="inbox"
|
icon="inbox"
|
||||||
/>
|
/>
|
||||||
<div class="timeline-list">
|
<div class="timeline-list">
|
||||||
<BaseTimeline :items="filteredTimelineItems">
|
<BaseTimeline v-if="timelineFilter !== 'reads'" :items="filteredTimelineItems">
|
||||||
<template #item="{ item }">
|
<template #item="{ item }">
|
||||||
<template v-if="item.type === 'post'">
|
<template v-if="item.type === 'post'">
|
||||||
<TimelinePostItem :item="item" />
|
<TimelinePostItem :item="item" />
|
||||||
@@ -214,6 +225,11 @@
|
|||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
</BaseTimeline>
|
</BaseTimeline>
|
||||||
|
<BaseTimeline v-else :items="readPosts">
|
||||||
|
<template #item="{ item }">
|
||||||
|
<TimelineReadItem :item="item" />
|
||||||
|
</template>
|
||||||
|
</BaseTimeline>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -276,6 +292,7 @@ import BaseTabs from '~/components/BaseTabs.vue'
|
|||||||
import LevelProgress from '~/components/LevelProgress.vue'
|
import LevelProgress from '~/components/LevelProgress.vue'
|
||||||
import TimelineCommentGroup from '~/components/TimelineCommentGroup.vue'
|
import TimelineCommentGroup from '~/components/TimelineCommentGroup.vue'
|
||||||
import TimelinePostItem from '~/components/TimelinePostItem.vue'
|
import TimelinePostItem from '~/components/TimelinePostItem.vue'
|
||||||
|
import TimelineReadItem from '~/components/TimelineReadItem.vue'
|
||||||
import TimelineTagItem from '~/components/TimelineTagItem.vue'
|
import TimelineTagItem from '~/components/TimelineTagItem.vue'
|
||||||
import BaseUserAvatar from '~/components/BaseUserAvatar.vue'
|
import BaseUserAvatar from '~/components/BaseUserAvatar.vue'
|
||||||
import UserList from '~/components/UserList.vue'
|
import UserList from '~/components/UserList.vue'
|
||||||
@@ -299,12 +316,15 @@ const hotReplies = ref([])
|
|||||||
const hotTags = ref([])
|
const hotTags = ref([])
|
||||||
const favoritePosts = ref([])
|
const favoritePosts = ref([])
|
||||||
const timelineItems = ref([])
|
const timelineItems = ref([])
|
||||||
|
const readPosts = ref([])
|
||||||
const timelineFilter = ref('all')
|
const timelineFilter = ref('all')
|
||||||
const filteredTimelineItems = computed(() => {
|
const filteredTimelineItems = computed(() => {
|
||||||
if (timelineFilter.value === 'articles') {
|
if (timelineFilter.value === 'articles') {
|
||||||
return timelineItems.value.filter((item) => item.type === 'post')
|
return timelineItems.value.filter((item) => item.type === 'post')
|
||||||
} else if (timelineFilter.value === 'comments') {
|
} else if (timelineFilter.value === 'comments') {
|
||||||
return timelineItems.value.filter((item) => item.type === 'comment' || item.type === 'reply')
|
return timelineItems.value.filter((item) => item.type === 'comment' || item.type === 'reply')
|
||||||
|
} else if (timelineFilter.value === 'reads') {
|
||||||
|
return []
|
||||||
}
|
}
|
||||||
return timelineItems.value
|
return timelineItems.value
|
||||||
})
|
})
|
||||||
@@ -477,6 +497,27 @@ const fetchTimeline = async () => {
|
|||||||
timelineItems.value = combineDiscussionItems(mapped)
|
timelineItems.value = combineDiscussionItems(mapped)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fetchReadHistory = async () => {
|
||||||
|
if (!isMine.value) {
|
||||||
|
readPosts.value = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const token = getToken()
|
||||||
|
if (!token) {
|
||||||
|
readPosts.value = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/users/${username}/read-posts?limit=50`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
readPosts.value = data.map((r) => ({ ...r, icon: 'file-text' }))
|
||||||
|
} else {
|
||||||
|
readPosts.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const fetchFollowUsers = async () => {
|
const fetchFollowUsers = async () => {
|
||||||
const [followerRes, followingRes] = await Promise.all([
|
const [followerRes, followingRes] = await Promise.all([
|
||||||
fetch(`${API_BASE_URL}/api/users/${username}/followers`),
|
fetch(`${API_BASE_URL}/api/users/${username}/followers`),
|
||||||
@@ -508,6 +549,12 @@ const loadTimeline = async () => {
|
|||||||
tabLoading.value = false
|
tabLoading.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const loadReadHistory = async () => {
|
||||||
|
tabLoading.value = true
|
||||||
|
await fetchReadHistory()
|
||||||
|
tabLoading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
const loadFollow = async () => {
|
const loadFollow = async () => {
|
||||||
tabLoading.value = true
|
tabLoading.value = true
|
||||||
await fetchFollowUsers()
|
await fetchFollowUsers()
|
||||||
@@ -624,8 +671,14 @@ onMounted(init)
|
|||||||
|
|
||||||
watch(selectedTab, async (val) => {
|
watch(selectedTab, async (val) => {
|
||||||
// navigateTo({ query: { ...route.query, tab: val } }, { replace: true })
|
// navigateTo({ query: { ...route.query, tab: val } }, { replace: true })
|
||||||
if (val === 'timeline' && timelineItems.value.length === 0) {
|
if (val === 'timeline') {
|
||||||
await loadTimeline()
|
if (timelineFilter.value === 'reads') {
|
||||||
|
if (readPosts.value.length === 0) {
|
||||||
|
await loadReadHistory()
|
||||||
|
}
|
||||||
|
} else if (timelineItems.value.length === 0) {
|
||||||
|
await loadTimeline()
|
||||||
|
}
|
||||||
} else if (val === 'following' && followers.value.length === 0 && followings.value.length === 0) {
|
} else if (val === 'following' && followers.value.length === 0 && followings.value.length === 0) {
|
||||||
await loadFollow()
|
await loadFollow()
|
||||||
} else if (val === 'favorites' && favoritePosts.value.length === 0) {
|
} else if (val === 'favorites' && favoritePosts.value.length === 0) {
|
||||||
@@ -634,6 +687,23 @@ watch(selectedTab, async (val) => {
|
|||||||
await loadAchievements()
|
await loadAchievements()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(timelineFilter, async (val) => {
|
||||||
|
if (selectedTab.value !== 'timeline') return
|
||||||
|
if (val === 'reads') {
|
||||||
|
if (readPosts.value.length === 0) {
|
||||||
|
await loadReadHistory()
|
||||||
|
}
|
||||||
|
} else if (timelineItems.value.length === 0) {
|
||||||
|
await loadTimeline()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(isMine, (val) => {
|
||||||
|
if (!val && timelineFilter.value === 'reads') {
|
||||||
|
timelineFilter.value = 'all'
|
||||||
|
}
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -81,6 +81,8 @@ import {
|
|||||||
CheckOne,
|
CheckOne,
|
||||||
Share,
|
Share,
|
||||||
Financing,
|
Financing,
|
||||||
|
Hands,
|
||||||
|
PreviewCloseOne,
|
||||||
} from '@icon-park/vue-next'
|
} from '@icon-park/vue-next'
|
||||||
|
|
||||||
export default defineNuxtPlugin((nuxtApp) => {
|
export default defineNuxtPlugin((nuxtApp) => {
|
||||||
@@ -165,4 +167,6 @@ export default defineNuxtPlugin((nuxtApp) => {
|
|||||||
nuxtApp.vueApp.component('CheckOne', CheckOne)
|
nuxtApp.vueApp.component('CheckOne', CheckOne)
|
||||||
nuxtApp.vueApp.component('Share', Share)
|
nuxtApp.vueApp.component('Share', Share)
|
||||||
nuxtApp.vueApp.component('Financing', Financing)
|
nuxtApp.vueApp.component('Financing', Financing)
|
||||||
|
nuxtApp.vueApp.component('Hands', Hands)
|
||||||
|
nuxtApp.vueApp.component('PreviewCloseOne', PreviewCloseOne)
|
||||||
})
|
})
|
||||||
@@ -268,23 +268,21 @@ export function stripMarkdownLength(text, length) {
|
|||||||
|
|
||||||
// 朴素文本带贴吧表情
|
// 朴素文本带贴吧表情
|
||||||
export function stripMarkdownWithTiebaMoji(text, length){
|
export function stripMarkdownWithTiebaMoji(text, length){
|
||||||
console.error(text)
|
|
||||||
if (!text) return ''
|
if (!text) return ''
|
||||||
|
|
||||||
// Markdown 转成纯文本
|
// Markdown 转成纯文本
|
||||||
const plain = stripMarkdown(text)
|
const plain = stripMarkdown(text)
|
||||||
console.error(plain)
|
// 替换 :tieba123: 为 <img>
|
||||||
// 替换 :tieba123: 为 <img>
|
const withEmoji = plain.replace(/:tieba(\d+):/g, (match, num) => {
|
||||||
const withEmoji = plain.replace(/:tieba(\d+):/g, (match, num) => {
|
const key = `tieba${num}`
|
||||||
const key = `tieba${num}`
|
const file = tiebaEmoji[key]
|
||||||
const file = tiebaEmoji[key]
|
return file
|
||||||
return file
|
? `<img loading="lazy" class="emoji" src="${file}" alt="${key}">`
|
||||||
? `<img loading="lazy" class="emoji" src="${file}" alt="${key}">`
|
: match // 没有匹配到图片则保留原样
|
||||||
: match // 没有匹配到图片则保留原样
|
})
|
||||||
})
|
|
||||||
|
|
||||||
// 截断纯文本长度(防止撑太长)
|
// 截断纯文本长度(防止撑太长)
|
||||||
const truncated = withEmoji.length > length ? withEmoji.slice(0, length) + '...' : withEmoji
|
const truncated = withEmoji.length > length ? withEmoji.slice(0, length) + '...' : withEmoji
|
||||||
return truncated
|
return truncated
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ const iconMap = {
|
|||||||
POLL_VOTE: 'ChartHistogram',
|
POLL_VOTE: 'ChartHistogram',
|
||||||
POLL_RESULT_OWNER: 'RankingList',
|
POLL_RESULT_OWNER: 'RankingList',
|
||||||
POLL_RESULT_PARTICIPANT: 'ChartLine',
|
POLL_RESULT_PARTICIPANT: 'ChartLine',
|
||||||
|
CATEGORY_PROPOSAL_RESULT_OWNER: 'TagOne',
|
||||||
|
CATEGORY_PROPOSAL_RESULT_PARTICIPANT: 'TagOne',
|
||||||
MENTION: 'HashtagKey',
|
MENTION: 'HashtagKey',
|
||||||
POST_DELETED: 'ClearIcon',
|
POST_DELETED: 'ClearIcon',
|
||||||
POST_FEATURED: 'Star',
|
POST_FEATURED: 'Star',
|
||||||
@@ -254,7 +256,9 @@ function createFetchNotifications() {
|
|||||||
} else if (
|
} else if (
|
||||||
n.type === 'POLL_VOTE' ||
|
n.type === 'POLL_VOTE' ||
|
||||||
n.type === 'POLL_RESULT_OWNER' ||
|
n.type === 'POLL_RESULT_OWNER' ||
|
||||||
n.type === 'POLL_RESULT_PARTICIPANT'
|
n.type === 'POLL_RESULT_PARTICIPANT' ||
|
||||||
|
n.type === 'CATEGORY_PROPOSAL_RESULT_OWNER' ||
|
||||||
|
n.type === 'CATEGORY_PROPOSAL_RESULT_PARTICIPANT'
|
||||||
) {
|
) {
|
||||||
arr.push({
|
arr.push({
|
||||||
...n,
|
...n,
|
||||||
|
|||||||
42
mcp/README.md
Normal file
42
mcp/README.md
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# OpenIsle MCP Server
|
||||||
|
|
||||||
|
This package provides a [Model Context Protocol](https://modelcontextprotocol.io) (MCP) server
|
||||||
|
that exposes OpenIsle's search capabilities as MCP tools. The initial release focuses on the
|
||||||
|
global search endpoint so the agent ecosystem can retrieve relevant posts, users, tags, and
|
||||||
|
other resources.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
The server is configured through environment variables (all prefixed with `OPENISLE_MCP_`):
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `BACKEND_BASE_URL` | `http://springboot:8080` | Base URL of the OpenIsle backend. |
|
||||||
|
| `PORT` | `8085` | TCP port when running with the `streamable-http` transport. |
|
||||||
|
| `HOST` | `0.0.0.0` | Interface to bind when serving HTTP. |
|
||||||
|
| `TRANSPORT` | `streamable-http` | Transport to use (`stdio`, `sse`, or `streamable-http`). |
|
||||||
|
| `REQUEST_TIMEOUT` | `10.0` | Timeout (seconds) for backend HTTP requests. |
|
||||||
|
|
||||||
|
## Running locally
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install .
|
||||||
|
OPENISLE_MCP_BACKEND_BASE_URL="http://localhost:8080" openisle-mcp
|
||||||
|
```
|
||||||
|
|
||||||
|
By default the server listens on port `8085` and serves MCP over Streamable HTTP.
|
||||||
|
|
||||||
|
## Available tools
|
||||||
|
|
||||||
|
| Tool | Description |
|
||||||
|
| --- | --- |
|
||||||
|
| `search` | Perform a global search against the OpenIsle backend. |
|
||||||
|
| `create_post` | Publish a new post using a JWT token. |
|
||||||
|
| `reply_to_post` | Create a new comment on a post using a JWT token. |
|
||||||
|
| `reply_to_comment` | Reply to an existing comment using a JWT token. |
|
||||||
|
| `recent_posts` | Retrieve posts created within the last *N* minutes. |
|
||||||
|
|
||||||
|
The tools return structured data mirroring the backend DTOs, including highlighted snippets for
|
||||||
|
search results, the full comment payload for post replies and comment replies, and detailed
|
||||||
|
metadata for recent posts.
|
||||||
|
|
||||||
27
mcp/pyproject.toml
Normal file
27
mcp/pyproject.toml
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["hatchling>=1.25"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "openisle-mcp"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Model Context Protocol server exposing OpenIsle search capabilities."
|
||||||
|
readme = "README.md"
|
||||||
|
authors = [{ name = "OpenIsle", email = "engineering@openisle.example" }]
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
dependencies = [
|
||||||
|
"mcp>=1.19.0",
|
||||||
|
"httpx>=0.28,<0.29",
|
||||||
|
"pydantic>=2.12,<3",
|
||||||
|
"pydantic-settings>=2.11,<3"
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
openisle-mcp = "openisle_mcp.server:main"
|
||||||
|
|
||||||
|
[tool.hatch.build]
|
||||||
|
packages = ["src/openisle_mcp"]
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
line-length = 100
|
||||||
|
|
||||||
6
mcp/src/openisle_mcp/__init__.py
Normal file
6
mcp/src/openisle_mcp/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
"""OpenIsle MCP server package."""
|
||||||
|
|
||||||
|
from .config import Settings, get_settings
|
||||||
|
|
||||||
|
__all__ = ["Settings", "get_settings"]
|
||||||
|
|
||||||
66
mcp/src/openisle_mcp/config.py
Normal file
66
mcp/src/openisle_mcp/config.py
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
"""Application configuration helpers for the OpenIsle MCP server."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from functools import lru_cache
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from pydantic import Field, SecretStr
|
||||||
|
from pydantic.networks import AnyHttpUrl
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
"""Configuration for the MCP server."""
|
||||||
|
|
||||||
|
backend_base_url: AnyHttpUrl = Field(
|
||||||
|
"http://springboot:8080",
|
||||||
|
description="Base URL for the OpenIsle backend service.",
|
||||||
|
)
|
||||||
|
host: str = Field(
|
||||||
|
"0.0.0.0",
|
||||||
|
description="Host interface to bind when running with HTTP transports.",
|
||||||
|
)
|
||||||
|
port: int = Field(
|
||||||
|
8085,
|
||||||
|
ge=1,
|
||||||
|
le=65535,
|
||||||
|
description="TCP port for HTTP transports.",
|
||||||
|
)
|
||||||
|
transport: Literal["stdio", "sse", "streamable-http"] = Field(
|
||||||
|
"streamable-http",
|
||||||
|
description="MCP transport to use when running the server.",
|
||||||
|
)
|
||||||
|
request_timeout: float = Field(
|
||||||
|
10.0,
|
||||||
|
gt=0,
|
||||||
|
description="Timeout (seconds) for backend search requests.",
|
||||||
|
)
|
||||||
|
access_token: SecretStr | None = Field(
|
||||||
|
default=None,
|
||||||
|
description=(
|
||||||
|
"Optional JWT bearer token used for authenticated backend calls. "
|
||||||
|
"When set, tools that support authentication will use this token "
|
||||||
|
"automatically unless an explicit token override is provided."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
log_level: str = Field(
|
||||||
|
"INFO",
|
||||||
|
description=(
|
||||||
|
"Logging level for the MCP server (e.g. DEBUG, INFO, WARNING)."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
model_config = SettingsConfigDict(
|
||||||
|
env_prefix="OPENISLE_MCP_",
|
||||||
|
env_file=".env",
|
||||||
|
env_file_encoding="utf-8",
|
||||||
|
case_sensitive=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=1)
|
||||||
|
def get_settings() -> Settings:
|
||||||
|
"""Return cached application settings."""
|
||||||
|
|
||||||
|
return Settings()
|
||||||
378
mcp/src/openisle_mcp/schemas.py
Normal file
378
mcp/src/openisle_mcp/schemas.py
Normal file
@@ -0,0 +1,378 @@
|
|||||||
|
"""Pydantic models describing tool inputs and outputs."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field, ConfigDict, field_validator
|
||||||
|
|
||||||
|
|
||||||
|
class SearchResultItem(BaseModel):
|
||||||
|
"""A single search result entry."""
|
||||||
|
|
||||||
|
type: str = Field(description="Entity type for the result (post, user, tag, etc.).")
|
||||||
|
id: Optional[int] = Field(default=None, description="Identifier of the matched entity.")
|
||||||
|
text: Optional[str] = Field(default=None, description="Primary text associated with the result.")
|
||||||
|
sub_text: Optional[str] = Field(
|
||||||
|
default=None,
|
||||||
|
alias="subText",
|
||||||
|
description="Secondary text, e.g. a username or excerpt.",
|
||||||
|
)
|
||||||
|
extra: Optional[str] = Field(default=None, description="Additional contextual information.")
|
||||||
|
post_id: Optional[int] = Field(
|
||||||
|
default=None,
|
||||||
|
alias="postId",
|
||||||
|
description="Associated post identifier when relevant.",
|
||||||
|
)
|
||||||
|
highlighted_text: Optional[str] = Field(
|
||||||
|
default=None,
|
||||||
|
alias="highlightedText",
|
||||||
|
description="Highlighted snippet of the primary text if available.",
|
||||||
|
)
|
||||||
|
highlighted_sub_text: Optional[str] = Field(
|
||||||
|
default=None,
|
||||||
|
alias="highlightedSubText",
|
||||||
|
description="Highlighted snippet of the secondary text if available.",
|
||||||
|
)
|
||||||
|
highlighted_extra: Optional[str] = Field(
|
||||||
|
default=None,
|
||||||
|
alias="highlightedExtra",
|
||||||
|
description="Highlighted snippet of extra information if available.",
|
||||||
|
)
|
||||||
|
|
||||||
|
model_config = ConfigDict(populate_by_name=True)
|
||||||
|
|
||||||
|
|
||||||
|
class SearchResponse(BaseModel):
|
||||||
|
"""Structured response returned by the search tool."""
|
||||||
|
|
||||||
|
keyword: str = Field(description="The keyword that was searched.")
|
||||||
|
total: int = Field(description="Total number of matches returned by the backend.")
|
||||||
|
results: list[SearchResultItem] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
description="Ordered collection of search results.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AuthorInfo(BaseModel):
|
||||||
|
"""Summary of a post or comment author."""
|
||||||
|
|
||||||
|
id: Optional[int] = Field(default=None, description="Author identifier.")
|
||||||
|
username: Optional[str] = Field(default=None, description="Author username.")
|
||||||
|
avatar: Optional[str] = Field(default=None, description="URL of the author's avatar.")
|
||||||
|
display_medal: Optional[str] = Field(
|
||||||
|
default=None,
|
||||||
|
alias="displayMedal",
|
||||||
|
description="Medal displayed next to the author, when available.",
|
||||||
|
)
|
||||||
|
|
||||||
|
model_config = ConfigDict(populate_by_name=True, extra="allow")
|
||||||
|
|
||||||
|
|
||||||
|
class CategoryInfo(BaseModel):
|
||||||
|
"""Basic information about a post category."""
|
||||||
|
|
||||||
|
id: Optional[int] = Field(default=None, description="Category identifier.")
|
||||||
|
name: Optional[str] = Field(default=None, description="Category name.")
|
||||||
|
description: Optional[str] = Field(
|
||||||
|
default=None, description="Human friendly description of the category."
|
||||||
|
)
|
||||||
|
icon: Optional[str] = Field(default=None, description="Icon URL associated with the category.")
|
||||||
|
small_icon: Optional[str] = Field(
|
||||||
|
default=None,
|
||||||
|
alias="smallIcon",
|
||||||
|
description="Compact icon URL for the category.",
|
||||||
|
)
|
||||||
|
count: Optional[int] = Field(default=None, description="Number of posts within the category.")
|
||||||
|
|
||||||
|
model_config = ConfigDict(populate_by_name=True, extra="allow")
|
||||||
|
|
||||||
|
|
||||||
|
class TagInfo(BaseModel):
|
||||||
|
"""Details for a tag assigned to a post."""
|
||||||
|
|
||||||
|
id: Optional[int] = Field(default=None, description="Tag identifier.")
|
||||||
|
name: Optional[str] = Field(default=None, description="Tag name.")
|
||||||
|
description: Optional[str] = Field(default=None, description="Description of the tag.")
|
||||||
|
icon: Optional[str] = Field(default=None, description="Icon URL for the tag.")
|
||||||
|
small_icon: Optional[str] = Field(
|
||||||
|
default=None,
|
||||||
|
alias="smallIcon",
|
||||||
|
description="Compact icon URL for the tag.",
|
||||||
|
)
|
||||||
|
created_at: Optional[datetime] = Field(
|
||||||
|
default=None,
|
||||||
|
alias="createdAt",
|
||||||
|
description="When the tag was created.",
|
||||||
|
)
|
||||||
|
count: Optional[int] = Field(default=None, description="Number of posts using the tag.")
|
||||||
|
|
||||||
|
model_config = ConfigDict(populate_by_name=True, extra="allow")
|
||||||
|
|
||||||
|
|
||||||
|
class ReactionInfo(BaseModel):
|
||||||
|
"""Representation of a reaction on a post or comment."""
|
||||||
|
|
||||||
|
id: Optional[int] = Field(default=None, description="Reaction identifier.")
|
||||||
|
type: Optional[str] = Field(default=None, description="Reaction type (emoji, like, etc.).")
|
||||||
|
user: Optional[str] = Field(default=None, description="Username of the reacting user.")
|
||||||
|
post_id: Optional[int] = Field(
|
||||||
|
default=None,
|
||||||
|
alias="postId",
|
||||||
|
description="Related post identifier when applicable.",
|
||||||
|
)
|
||||||
|
comment_id: Optional[int] = Field(
|
||||||
|
default=None,
|
||||||
|
alias="commentId",
|
||||||
|
description="Related comment identifier when applicable.",
|
||||||
|
)
|
||||||
|
message_id: Optional[int] = Field(
|
||||||
|
default=None,
|
||||||
|
alias="messageId",
|
||||||
|
description="Related message identifier when applicable.",
|
||||||
|
)
|
||||||
|
reward: Optional[int] = Field(default=None, description="Reward granted for the reaction, if any.")
|
||||||
|
|
||||||
|
model_config = ConfigDict(populate_by_name=True, extra="allow")
|
||||||
|
|
||||||
|
|
||||||
|
class CommentData(BaseModel):
|
||||||
|
"""Comment information returned by the backend."""
|
||||||
|
|
||||||
|
id: Optional[int] = Field(default=None, description="Comment identifier.")
|
||||||
|
content: Optional[str] = Field(default=None, description="Markdown content of the comment.")
|
||||||
|
created_at: Optional[datetime] = Field(
|
||||||
|
default=None,
|
||||||
|
alias="createdAt",
|
||||||
|
description="Timestamp when the comment was created.",
|
||||||
|
)
|
||||||
|
pinned_at: Optional[datetime] = Field(
|
||||||
|
default=None,
|
||||||
|
alias="pinnedAt",
|
||||||
|
description="Timestamp when the comment was pinned, if applicable.",
|
||||||
|
)
|
||||||
|
author: Optional[AuthorInfo] = Field(default=None, description="Author of the comment.")
|
||||||
|
replies: list["CommentData"] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
description="Nested replies associated with the comment.",
|
||||||
|
)
|
||||||
|
reactions: list[ReactionInfo] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
description="Reactions applied to the comment.",
|
||||||
|
)
|
||||||
|
reward: Optional[int] = Field(default=None, description="Reward gained by posting the comment.")
|
||||||
|
point_reward: Optional[int] = Field(
|
||||||
|
default=None,
|
||||||
|
alias="pointReward",
|
||||||
|
description="Points rewarded for the comment.",
|
||||||
|
)
|
||||||
|
|
||||||
|
model_config = ConfigDict(populate_by_name=True, extra="allow")
|
||||||
|
|
||||||
|
@field_validator("replies", "reactions", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def _ensure_comment_lists(cls, value: Any) -> list[Any]:
|
||||||
|
"""Convert ``None`` payloads to empty lists for comment collections."""
|
||||||
|
|
||||||
|
if value is None:
|
||||||
|
return []
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class CommentReplyResult(BaseModel):
|
||||||
|
"""Structured response returned when replying to a comment."""
|
||||||
|
|
||||||
|
comment: CommentData = Field(description="Reply comment returned by the backend.")
|
||||||
|
|
||||||
|
|
||||||
|
class CommentCreateResult(BaseModel):
|
||||||
|
"""Structured response returned when creating a comment on a post."""
|
||||||
|
|
||||||
|
comment: CommentData = Field(description="Comment returned by the backend.")
|
||||||
|
|
||||||
|
|
||||||
|
class PostCreateResult(BaseModel):
|
||||||
|
"""Structured response returned when creating a new post."""
|
||||||
|
|
||||||
|
post: PostDetail = Field(description="Detailed post payload returned by the backend.")
|
||||||
|
|
||||||
|
|
||||||
|
class PostSummary(BaseModel):
|
||||||
|
"""Summary information for a post."""
|
||||||
|
|
||||||
|
id: Optional[int] = Field(default=None, description="Post identifier.")
|
||||||
|
title: Optional[str] = Field(default=None, description="Title of the post.")
|
||||||
|
content: Optional[str] = Field(default=None, description="Excerpt or content of the post.")
|
||||||
|
created_at: Optional[datetime] = Field(
|
||||||
|
default=None,
|
||||||
|
alias="createdAt",
|
||||||
|
description="When the post was created.",
|
||||||
|
)
|
||||||
|
author: Optional[AuthorInfo] = Field(default=None, description="Author who created the post.")
|
||||||
|
category: Optional[CategoryInfo] = Field(default=None, description="Category of the post.")
|
||||||
|
tags: list[TagInfo] = Field(default_factory=list, description="Tags assigned to the post.")
|
||||||
|
views: Optional[int] = Field(default=None, description="Total view count for the post.")
|
||||||
|
comment_count: Optional[int] = Field(
|
||||||
|
default=None,
|
||||||
|
alias="commentCount",
|
||||||
|
description="Number of comments on the post.",
|
||||||
|
)
|
||||||
|
status: Optional[str] = Field(default=None, description="Workflow status of the post.")
|
||||||
|
pinned_at: Optional[datetime] = Field(
|
||||||
|
default=None,
|
||||||
|
alias="pinnedAt",
|
||||||
|
description="When the post was pinned, if ever.",
|
||||||
|
)
|
||||||
|
last_reply_at: Optional[datetime] = Field(
|
||||||
|
default=None,
|
||||||
|
alias="lastReplyAt",
|
||||||
|
description="Timestamp of the most recent reply.",
|
||||||
|
)
|
||||||
|
reactions: list[ReactionInfo] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
description="Reactions received by the post.",
|
||||||
|
)
|
||||||
|
participants: list[AuthorInfo] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
description="Users participating in the discussion.",
|
||||||
|
)
|
||||||
|
subscribed: Optional[bool] = Field(
|
||||||
|
default=None,
|
||||||
|
description="Whether the current user is subscribed to the post.",
|
||||||
|
)
|
||||||
|
reward: Optional[int] = Field(default=None, description="Reward granted for the post.")
|
||||||
|
point_reward: Optional[int] = Field(
|
||||||
|
default=None,
|
||||||
|
alias="pointReward",
|
||||||
|
description="Points granted for the post.",
|
||||||
|
)
|
||||||
|
type: Optional[str] = Field(default=None, description="Type of the post.")
|
||||||
|
lottery: Optional[dict[str, Any]] = Field(
|
||||||
|
default=None, description="Lottery information for the post."
|
||||||
|
)
|
||||||
|
poll: Optional[dict[str, Any]] = Field(
|
||||||
|
default=None, description="Poll information for the post."
|
||||||
|
)
|
||||||
|
rss_excluded: Optional[bool] = Field(
|
||||||
|
default=None,
|
||||||
|
alias="rssExcluded",
|
||||||
|
description="Whether the post is excluded from RSS feeds.",
|
||||||
|
)
|
||||||
|
closed: Optional[bool] = Field(default=None, description="Whether the post is closed for replies.")
|
||||||
|
visible_scope: Optional[str] = Field(
|
||||||
|
default=None,
|
||||||
|
alias="visibleScope",
|
||||||
|
description="Visibility scope configuration for the post.",
|
||||||
|
)
|
||||||
|
|
||||||
|
model_config = ConfigDict(populate_by_name=True, extra="allow")
|
||||||
|
|
||||||
|
@field_validator("tags", "reactions", "participants", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def _ensure_post_lists(cls, value: Any) -> list[Any]:
|
||||||
|
"""Normalize ``None`` values returned by the backend to empty lists."""
|
||||||
|
|
||||||
|
if value is None:
|
||||||
|
return []
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class RecentPostsResponse(BaseModel):
|
||||||
|
"""Structured response for the recent posts tool."""
|
||||||
|
|
||||||
|
minutes: int = Field(description="Time window, in minutes, used for the query.")
|
||||||
|
total: int = Field(description="Number of posts returned by the backend.")
|
||||||
|
posts: list[PostSummary] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
description="Posts created within the requested time window.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
CommentData.model_rebuild()
|
||||||
|
|
||||||
|
|
||||||
|
class PostDetail(PostSummary):
|
||||||
|
"""Detailed information for a single post, including comments."""
|
||||||
|
|
||||||
|
comments: list[CommentData] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
description="Comments that belong to the post.",
|
||||||
|
)
|
||||||
|
|
||||||
|
model_config = ConfigDict(populate_by_name=True, extra="allow")
|
||||||
|
|
||||||
|
@field_validator("comments", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def _ensure_comments_list(cls, value: Any) -> list[Any]:
|
||||||
|
"""Treat ``None`` comments payloads as empty lists."""
|
||||||
|
|
||||||
|
if value is None:
|
||||||
|
return []
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationData(BaseModel):
|
||||||
|
"""Unread notification payload returned by the backend."""
|
||||||
|
|
||||||
|
id: Optional[int] = Field(default=None, description="Notification identifier.")
|
||||||
|
type: Optional[str] = Field(default=None, description="Type of the notification.")
|
||||||
|
post: Optional[PostSummary] = Field(
|
||||||
|
default=None, description="Post associated with the notification if applicable."
|
||||||
|
)
|
||||||
|
comment: Optional[CommentData] = Field(
|
||||||
|
default=None, description="Comment referenced by the notification when available."
|
||||||
|
)
|
||||||
|
parent_comment: Optional[CommentData] = Field(
|
||||||
|
default=None,
|
||||||
|
alias="parentComment",
|
||||||
|
description="Parent comment for nested replies, when present.",
|
||||||
|
)
|
||||||
|
from_user: Optional[AuthorInfo] = Field(
|
||||||
|
default=None,
|
||||||
|
alias="fromUser",
|
||||||
|
description="User who triggered the notification.",
|
||||||
|
)
|
||||||
|
reaction_type: Optional[str] = Field(
|
||||||
|
default=None,
|
||||||
|
alias="reactionType",
|
||||||
|
description="Reaction type for reaction-based notifications.",
|
||||||
|
)
|
||||||
|
content: Optional[str] = Field(
|
||||||
|
default=None, description="Additional content or message for the notification."
|
||||||
|
)
|
||||||
|
approved: Optional[bool] = Field(
|
||||||
|
default=None, description="Approval status for moderation notifications."
|
||||||
|
)
|
||||||
|
read: Optional[bool] = Field(default=None, description="Whether the notification is read.")
|
||||||
|
created_at: Optional[datetime] = Field(
|
||||||
|
default=None,
|
||||||
|
alias="createdAt",
|
||||||
|
description="Timestamp when the notification was created.",
|
||||||
|
)
|
||||||
|
|
||||||
|
model_config = ConfigDict(populate_by_name=True, extra="allow")
|
||||||
|
|
||||||
|
|
||||||
|
class UnreadNotificationsResponse(BaseModel):
|
||||||
|
"""Structured response for unread notification queries."""
|
||||||
|
|
||||||
|
page: int = Field(description="Requested page index for the unread notifications.")
|
||||||
|
size: int = Field(description="Requested page size for the unread notifications.")
|
||||||
|
total: int = Field(description="Number of unread notifications returned in this page.")
|
||||||
|
notifications: list[NotificationData] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
description="Unread notifications returned by the backend.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationCleanupResult(BaseModel):
|
||||||
|
"""Structured response returned after marking notifications as read."""
|
||||||
|
|
||||||
|
processed_ids: list[int] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
description="Identifiers that were marked as read in the backend.",
|
||||||
|
)
|
||||||
|
total_marked: int = Field(
|
||||||
|
description="Total number of notifications successfully marked as read.",
|
||||||
|
)
|
||||||
343
mcp/src/openisle_mcp/search_client.py
Normal file
343
mcp/src/openisle_mcp/search_client.py
Normal file
@@ -0,0 +1,343 @@
|
|||||||
|
"""HTTP client helpers for talking to the OpenIsle backend endpoints."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class SearchClient:
|
||||||
|
"""Client for calling the OpenIsle HTTP APIs used by the MCP server."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
base_url: str,
|
||||||
|
*,
|
||||||
|
timeout: float = 10.0,
|
||||||
|
access_token: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
self._base_url = base_url.rstrip("/")
|
||||||
|
self._timeout = timeout
|
||||||
|
self._client: httpx.AsyncClient | None = None
|
||||||
|
self._access_token = self._sanitize_token(access_token)
|
||||||
|
|
||||||
|
def _get_client(self) -> httpx.AsyncClient:
|
||||||
|
if self._client is None:
|
||||||
|
logger.debug(
|
||||||
|
"Creating httpx.AsyncClient for base URL %s with timeout %.2fs",
|
||||||
|
self._base_url,
|
||||||
|
self._timeout,
|
||||||
|
)
|
||||||
|
self._client = httpx.AsyncClient(
|
||||||
|
base_url=self._base_url,
|
||||||
|
timeout=self._timeout,
|
||||||
|
)
|
||||||
|
return self._client
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _sanitize_token(token: str | None) -> str | None:
|
||||||
|
if token is None:
|
||||||
|
return None
|
||||||
|
stripped = token.strip()
|
||||||
|
return stripped or None
|
||||||
|
|
||||||
|
def update_access_token(self, token: str | None) -> None:
|
||||||
|
"""Update the default access token used for authenticated requests."""
|
||||||
|
|
||||||
|
self._access_token = self._sanitize_token(token)
|
||||||
|
if self._access_token:
|
||||||
|
logger.debug("Configured default access token for SearchClient requests.")
|
||||||
|
else:
|
||||||
|
logger.debug("Cleared default access token for SearchClient requests.")
|
||||||
|
|
||||||
|
def _resolve_token(self, token: str | None) -> str | None:
|
||||||
|
candidate = self._sanitize_token(token)
|
||||||
|
if candidate is not None:
|
||||||
|
return candidate
|
||||||
|
return self._access_token
|
||||||
|
|
||||||
|
def _require_token(self, token: str | None) -> str:
|
||||||
|
resolved = self._resolve_token(token)
|
||||||
|
if resolved is None:
|
||||||
|
raise ValueError(
|
||||||
|
"Authenticated request requires an access token. Provide a Bearer token "
|
||||||
|
"via the MCP Authorization header or configure a default token for the server."
|
||||||
|
)
|
||||||
|
return resolved
|
||||||
|
|
||||||
|
def _build_headers(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
token: str,
|
||||||
|
accept: str = "application/json",
|
||||||
|
include_json: bool = False,
|
||||||
|
) -> dict[str, str]:
|
||||||
|
headers: dict[str, str] = {"Accept": accept}
|
||||||
|
resolved = self._resolve_token(token)
|
||||||
|
if resolved:
|
||||||
|
headers["Authorization"] = f"Bearer {resolved}"
|
||||||
|
if include_json:
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
|
return headers
|
||||||
|
|
||||||
|
async def global_search(self, keyword: str) -> list[dict[str, Any]]:
|
||||||
|
"""Call the global search endpoint and return the parsed JSON payload."""
|
||||||
|
|
||||||
|
client = self._get_client()
|
||||||
|
logger.debug("Calling global search with keyword=%s", keyword)
|
||||||
|
response = await client.get(
|
||||||
|
"/api/search/global",
|
||||||
|
params={"keyword": keyword},
|
||||||
|
headers=self._build_headers(),
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
payload = response.json()
|
||||||
|
if not isinstance(payload, list):
|
||||||
|
formatted = json.dumps(payload, ensure_ascii=False)[:200]
|
||||||
|
raise ValueError(f"Unexpected response format from search endpoint: {formatted}")
|
||||||
|
logger.info(
|
||||||
|
"Global search returned %d results for keyword '%s'",
|
||||||
|
len(payload),
|
||||||
|
keyword,
|
||||||
|
)
|
||||||
|
return [self._ensure_dict(entry) for entry in payload]
|
||||||
|
|
||||||
|
async def reply_to_comment(
|
||||||
|
self,
|
||||||
|
comment_id: int,
|
||||||
|
token: str,
|
||||||
|
content: str,
|
||||||
|
captcha: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Reply to an existing comment and return the created reply."""
|
||||||
|
|
||||||
|
client = self._get_client()
|
||||||
|
resolved_token = self._require_token(token)
|
||||||
|
headers = self._build_headers(token=resolved_token, include_json=True)
|
||||||
|
payload: dict[str, Any] = {"content": content}
|
||||||
|
if captcha is not None:
|
||||||
|
stripped_captcha = captcha.strip()
|
||||||
|
if stripped_captcha:
|
||||||
|
payload["captcha"] = stripped_captcha
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
"Posting reply to comment_id=%s (captcha=%s)",
|
||||||
|
comment_id,
|
||||||
|
bool(captcha),
|
||||||
|
)
|
||||||
|
response = await client.post(
|
||||||
|
f"/api/comments/{comment_id}/replies",
|
||||||
|
json=payload,
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
body = self._ensure_dict(response.json())
|
||||||
|
logger.info("Reply to comment_id=%s succeeded with id=%s", comment_id, body.get("id"))
|
||||||
|
return body
|
||||||
|
|
||||||
|
async def reply_to_post(
|
||||||
|
self,
|
||||||
|
post_id: int,
|
||||||
|
token: str,
|
||||||
|
content: str,
|
||||||
|
captcha: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Create a comment on a post and return the backend payload."""
|
||||||
|
|
||||||
|
client = self._get_client()
|
||||||
|
resolved_token = self._require_token(token)
|
||||||
|
headers = self._build_headers(token=resolved_token, include_json=True)
|
||||||
|
payload: dict[str, Any] = {"content": content}
|
||||||
|
if captcha is not None:
|
||||||
|
stripped_captcha = captcha.strip()
|
||||||
|
if stripped_captcha:
|
||||||
|
payload["captcha"] = stripped_captcha
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
"Posting comment to post_id=%s (captcha=%s)",
|
||||||
|
post_id,
|
||||||
|
bool(captcha),
|
||||||
|
)
|
||||||
|
response = await client.post(
|
||||||
|
f"/api/posts/{post_id}/comments",
|
||||||
|
json=payload,
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
body = self._ensure_dict(response.json())
|
||||||
|
logger.info("Reply to post_id=%s succeeded with id=%s", post_id, body.get("id"))
|
||||||
|
return body
|
||||||
|
|
||||||
|
async def create_post(
|
||||||
|
self,
|
||||||
|
payload: dict[str, Any],
|
||||||
|
*,
|
||||||
|
token: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Create a new post and return the detailed backend payload."""
|
||||||
|
|
||||||
|
client = self._get_client()
|
||||||
|
resolved_token = self._require_token(token)
|
||||||
|
headers = self._build_headers(token=resolved_token, include_json=True)
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
"Creating post with category_id=%s and %d tag(s)",
|
||||||
|
payload.get("categoryId"),
|
||||||
|
len(payload.get("tagIds", []) if isinstance(payload.get("tagIds"), list) else []),
|
||||||
|
)
|
||||||
|
response = await client.post(
|
||||||
|
"/api/posts",
|
||||||
|
json=payload,
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
body = self._ensure_dict(response.json())
|
||||||
|
logger.info("Post creation succeeded with id=%s, token=%s", body.get("id"), token)
|
||||||
|
return body
|
||||||
|
|
||||||
|
async def recent_posts(self, minutes: int) -> list[dict[str, Any]]:
|
||||||
|
"""Return posts created within the given timeframe."""
|
||||||
|
|
||||||
|
client = self._get_client()
|
||||||
|
logger.debug(
|
||||||
|
"Fetching recent posts within last %s minutes",
|
||||||
|
minutes,
|
||||||
|
)
|
||||||
|
response = await client.get(
|
||||||
|
"/api/posts/recent",
|
||||||
|
params={"minutes": minutes},
|
||||||
|
headers=self._build_headers(),
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
payload = response.json()
|
||||||
|
if not isinstance(payload, list):
|
||||||
|
formatted = json.dumps(payload, ensure_ascii=False)[:200]
|
||||||
|
raise ValueError(
|
||||||
|
f"Unexpected response format from recent posts endpoint: {formatted}"
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"Fetched %d recent posts for window=%s minutes",
|
||||||
|
len(payload),
|
||||||
|
minutes,
|
||||||
|
)
|
||||||
|
return [self._ensure_dict(entry) for entry in payload]
|
||||||
|
|
||||||
|
async def get_post(self, post_id: int, token: str | None = None) -> dict[str, Any]:
|
||||||
|
"""Retrieve the detailed payload for a single post."""
|
||||||
|
|
||||||
|
client = self._get_client()
|
||||||
|
headers = self._build_headers(token=token)
|
||||||
|
logger.debug("Fetching post details for post_id=%s", post_id)
|
||||||
|
response = await client.get(f"/api/posts/{post_id}", headers=headers)
|
||||||
|
response.raise_for_status()
|
||||||
|
body = self._ensure_dict(response.json())
|
||||||
|
logger.info(
|
||||||
|
"Retrieved post_id=%s successfully with %d top-level comments",
|
||||||
|
post_id,
|
||||||
|
len(body.get("comments", []) if isinstance(body.get("comments"), list) else []),
|
||||||
|
)
|
||||||
|
return body
|
||||||
|
|
||||||
|
async def list_unread_notifications(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
page: int = 0,
|
||||||
|
size: int = 30,
|
||||||
|
token: str,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Return unread notifications for the authenticated user."""
|
||||||
|
|
||||||
|
client = self._get_client()
|
||||||
|
resolved_token = self._require_token(token)
|
||||||
|
logger.debug(
|
||||||
|
"Fetching unread notifications with page=%s, size=%s",
|
||||||
|
page,
|
||||||
|
size,
|
||||||
|
)
|
||||||
|
response = await client.get(
|
||||||
|
"/api/notifications/unread",
|
||||||
|
params={"page": page, "size": size},
|
||||||
|
headers=self._build_headers(token=resolved_token),
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
payload = response.json()
|
||||||
|
if not isinstance(payload, list):
|
||||||
|
formatted = json.dumps(payload, ensure_ascii=False)[:200]
|
||||||
|
raise ValueError(
|
||||||
|
"Unexpected response format from unread notifications endpoint: "
|
||||||
|
f"{formatted}"
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"Fetched %d unread notifications (page=%s, size=%s)",
|
||||||
|
len(payload),
|
||||||
|
page,
|
||||||
|
size,
|
||||||
|
)
|
||||||
|
return [self._ensure_dict(entry) for entry in payload]
|
||||||
|
|
||||||
|
async def mark_notifications_read(
|
||||||
|
self,
|
||||||
|
ids: list[int],
|
||||||
|
*,
|
||||||
|
token: str
|
||||||
|
) -> None:
|
||||||
|
"""Mark the provided notifications as read for the authenticated user."""
|
||||||
|
|
||||||
|
if not ids:
|
||||||
|
raise ValueError(
|
||||||
|
"At least one notification identifier must be provided to mark as read."
|
||||||
|
)
|
||||||
|
|
||||||
|
sanitized_ids: list[int] = []
|
||||||
|
for value in ids:
|
||||||
|
if isinstance(value, bool):
|
||||||
|
raise ValueError("Notification identifiers must be integers, not booleans.")
|
||||||
|
try:
|
||||||
|
converted = int(value)
|
||||||
|
except (TypeError, ValueError) as exc: # pragma: no cover - defensive
|
||||||
|
raise ValueError(
|
||||||
|
"Notification identifiers must be integers."
|
||||||
|
) from exc
|
||||||
|
if converted <= 0:
|
||||||
|
raise ValueError(
|
||||||
|
"Notification identifiers must be positive integers."
|
||||||
|
)
|
||||||
|
sanitized_ids.append(converted)
|
||||||
|
|
||||||
|
client = self._get_client()
|
||||||
|
resolved_token = self._require_token(token)
|
||||||
|
logger.debug(
|
||||||
|
"Marking %d notifications as read: ids=%s",
|
||||||
|
len(sanitized_ids),
|
||||||
|
sanitized_ids,
|
||||||
|
)
|
||||||
|
response = await client.post(
|
||||||
|
"/api/notifications/read",
|
||||||
|
json={"ids": sanitized_ids},
|
||||||
|
headers=self._build_headers(token=resolved_token, include_json=True),
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
logger.info(
|
||||||
|
"Successfully marked %d notifications as read.",
|
||||||
|
len(sanitized_ids),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def aclose(self) -> None:
|
||||||
|
"""Dispose of the underlying HTTP client."""
|
||||||
|
|
||||||
|
if self._client is not None:
|
||||||
|
await self._client.aclose()
|
||||||
|
self._client = None
|
||||||
|
logger.debug("Closed httpx.AsyncClient for SearchClient.")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _ensure_dict(entry: Any) -> dict[str, Any]:
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
raise ValueError(f"Expected JSON object, got: {type(entry)!r}")
|
||||||
|
return entry
|
||||||
963
mcp/src/openisle_mcp/server.py
Normal file
963
mcp/src/openisle_mcp/server.py
Normal file
@@ -0,0 +1,963 @@
|
|||||||
|
"""Entry point for running the OpenIsle MCP server."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from mcp.server.fastmcp import Context, FastMCP
|
||||||
|
from pydantic import ValidationError
|
||||||
|
from pydantic import Field as PydanticField
|
||||||
|
|
||||||
|
from .config import get_settings
|
||||||
|
from .schemas import (
|
||||||
|
CommentCreateResult,
|
||||||
|
CommentData,
|
||||||
|
CommentReplyResult,
|
||||||
|
NotificationData,
|
||||||
|
NotificationCleanupResult,
|
||||||
|
UnreadNotificationsResponse,
|
||||||
|
PostDetail,
|
||||||
|
PostCreateResult,
|
||||||
|
PostSummary,
|
||||||
|
RecentPostsResponse,
|
||||||
|
SearchResponse,
|
||||||
|
SearchResultItem,
|
||||||
|
)
|
||||||
|
from .search_client import SearchClient
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
if not logging.getLogger().handlers:
|
||||||
|
logging.basicConfig(
|
||||||
|
level=getattr(logging, settings.log_level.upper(), logging.INFO),
|
||||||
|
format="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logging.getLogger().setLevel(
|
||||||
|
getattr(logging, settings.log_level.upper(), logging.INFO)
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
search_client = SearchClient(
|
||||||
|
str(settings.backend_base_url),
|
||||||
|
timeout=settings.request_timeout,
|
||||||
|
access_token=(
|
||||||
|
settings.access_token.get_secret_value()
|
||||||
|
if settings.access_token is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_authorization_token(ctx: Context | None) -> str | None:
|
||||||
|
"""Return the Bearer token from the incoming MCP request headers."""
|
||||||
|
|
||||||
|
if ctx is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
request_context = ctx.request_context
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
request = getattr(request_context, "request", None)
|
||||||
|
if request is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
headers = getattr(request, "headers", None)
|
||||||
|
if headers is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
authorization = headers.get("authorization")
|
||||||
|
if not authorization:
|
||||||
|
return None
|
||||||
|
|
||||||
|
scheme, _, token = authorization.partition(" ")
|
||||||
|
if scheme.lower() != "bearer":
|
||||||
|
return None
|
||||||
|
|
||||||
|
stripped = token.strip()
|
||||||
|
return stripped or None
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(_: FastMCP):
|
||||||
|
"""Lifecycle hook that disposes shared resources when the server stops."""
|
||||||
|
|
||||||
|
try:
|
||||||
|
logger.debug("OpenIsle MCP server lifespan started.")
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
logger.debug("Disposing shared SearchClient instance.")
|
||||||
|
await search_client.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
app = FastMCP(
|
||||||
|
name="openisle-mcp",
|
||||||
|
instructions=(
|
||||||
|
"Use this server to search OpenIsle content, create new posts, reply to posts and "
|
||||||
|
"comments using the Authorization header or configured access token, retrieve details "
|
||||||
|
"for a specific post, list posts created within a recent time window, and review "
|
||||||
|
"unread notification messages."
|
||||||
|
),
|
||||||
|
host=settings.host,
|
||||||
|
port=settings.port,
|
||||||
|
lifespan=lifespan,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.tool(
|
||||||
|
name="search",
|
||||||
|
description="Perform a global search across OpenIsle resources.",
|
||||||
|
structured_output=True,
|
||||||
|
)
|
||||||
|
async def search(
|
||||||
|
keyword: Annotated[str, PydanticField(description="Keyword to search for.")],
|
||||||
|
ctx: Context | None = None,
|
||||||
|
) -> SearchResponse:
|
||||||
|
"""Call the OpenIsle global search endpoint and return structured results."""
|
||||||
|
|
||||||
|
sanitized = keyword.strip()
|
||||||
|
if not sanitized:
|
||||||
|
raise ValueError("Keyword must not be empty.")
|
||||||
|
|
||||||
|
try:
|
||||||
|
logger.info("Received search request for keyword='%s'", sanitized)
|
||||||
|
raw_results = await search_client.global_search(sanitized)
|
||||||
|
except httpx.HTTPStatusError as exc: # pragma: no cover - network errors
|
||||||
|
message = (
|
||||||
|
"OpenIsle backend returned HTTP "
|
||||||
|
f"{exc.response.status_code} while searching for '{sanitized}'."
|
||||||
|
)
|
||||||
|
if ctx is not None:
|
||||||
|
await ctx.error(message)
|
||||||
|
raise ValueError(message) from exc
|
||||||
|
except httpx.RequestError as exc: # pragma: no cover - network errors
|
||||||
|
message = f"Unable to reach OpenIsle backend search service: {exc}."
|
||||||
|
if ctx is not None:
|
||||||
|
await ctx.error(message)
|
||||||
|
raise ValueError(message) from exc
|
||||||
|
|
||||||
|
try:
|
||||||
|
results = [SearchResultItem.model_validate(entry) for entry in raw_results]
|
||||||
|
except ValidationError as exc:
|
||||||
|
message = "Received malformed data from the OpenIsle backend search endpoint."
|
||||||
|
if ctx is not None:
|
||||||
|
await ctx.error(message)
|
||||||
|
raise ValueError(message) from exc
|
||||||
|
|
||||||
|
if ctx is not None:
|
||||||
|
await ctx.info(f"Search keyword '{sanitized}' returned {len(results)} results.")
|
||||||
|
logger.debug(
|
||||||
|
"Validated %d search results for keyword='%s'",
|
||||||
|
len(results),
|
||||||
|
sanitized,
|
||||||
|
)
|
||||||
|
|
||||||
|
return SearchResponse(keyword=sanitized, total=len(results), results=results)
|
||||||
|
|
||||||
|
|
||||||
|
@app.tool(
|
||||||
|
name="reply_to_post",
|
||||||
|
description=(
|
||||||
|
"Create a comment on a post using the request Authorization header or the configured "
|
||||||
|
"access token."
|
||||||
|
),
|
||||||
|
structured_output=True,
|
||||||
|
)
|
||||||
|
async def reply_to_post(
|
||||||
|
post_id: Annotated[
|
||||||
|
int,
|
||||||
|
PydanticField(ge=1, description="Identifier of the post being replied to."),
|
||||||
|
],
|
||||||
|
content: Annotated[
|
||||||
|
str,
|
||||||
|
PydanticField(description="Markdown content of the reply."),
|
||||||
|
],
|
||||||
|
captcha: Annotated[
|
||||||
|
str | None,
|
||||||
|
PydanticField(
|
||||||
|
default=None,
|
||||||
|
description="Optional captcha solution if the backend requires it.",
|
||||||
|
),
|
||||||
|
] = None,
|
||||||
|
ctx: Context | None = None,
|
||||||
|
) -> CommentCreateResult:
|
||||||
|
"""Create a comment on a post and return the backend payload."""
|
||||||
|
|
||||||
|
sanitized_content = content.strip()
|
||||||
|
if not sanitized_content:
|
||||||
|
raise ValueError("Reply content must not be empty.")
|
||||||
|
|
||||||
|
sanitized_captcha = captcha.strip() if isinstance(captcha, str) else None
|
||||||
|
|
||||||
|
request_token = _extract_authorization_token(ctx)
|
||||||
|
|
||||||
|
try:
|
||||||
|
logger.info(
|
||||||
|
"Creating reply for post_id=%s (captcha=%s)",
|
||||||
|
post_id,
|
||||||
|
bool(sanitized_captcha),
|
||||||
|
)
|
||||||
|
raw_comment = await search_client.reply_to_post(
|
||||||
|
post_id,
|
||||||
|
token=request_token,
|
||||||
|
content=sanitized_content,
|
||||||
|
captcha=sanitized_captcha,
|
||||||
|
)
|
||||||
|
except httpx.HTTPStatusError as exc: # pragma: no cover - network errors
|
||||||
|
status_code = exc.response.status_code
|
||||||
|
if status_code == 401:
|
||||||
|
message = (
|
||||||
|
"Authentication failed while replying to post "
|
||||||
|
f"{post_id}. Please verify the Authorization header or configured token."
|
||||||
|
)
|
||||||
|
elif status_code == 403:
|
||||||
|
message = (
|
||||||
|
"The provided Authorization token is not authorized to reply to post "
|
||||||
|
f"{post_id}."
|
||||||
|
)
|
||||||
|
elif status_code == 404:
|
||||||
|
message = f"Post {post_id} was not found."
|
||||||
|
else:
|
||||||
|
message = (
|
||||||
|
"OpenIsle backend returned HTTP "
|
||||||
|
f"{status_code} while replying to post {post_id}."
|
||||||
|
)
|
||||||
|
if ctx is not None:
|
||||||
|
await ctx.error(message)
|
||||||
|
raise ValueError(message) from exc
|
||||||
|
except httpx.RequestError as exc: # pragma: no cover - network errors
|
||||||
|
message = (
|
||||||
|
"Unable to reach OpenIsle backend comment service: "
|
||||||
|
f"{exc}."
|
||||||
|
)
|
||||||
|
if ctx is not None:
|
||||||
|
await ctx.error(message)
|
||||||
|
raise ValueError(message) from exc
|
||||||
|
|
||||||
|
try:
|
||||||
|
comment = CommentData.model_validate(raw_comment)
|
||||||
|
except ValidationError as exc:
|
||||||
|
message = "Received malformed data from the post comment endpoint."
|
||||||
|
if ctx is not None:
|
||||||
|
await ctx.error(message)
|
||||||
|
raise ValueError(message) from exc
|
||||||
|
|
||||||
|
if ctx is not None:
|
||||||
|
await ctx.info(
|
||||||
|
"Reply created successfully for post "
|
||||||
|
f"{post_id}."
|
||||||
|
)
|
||||||
|
logger.debug(
|
||||||
|
"Validated reply comment payload for post_id=%s (comment_id=%s)",
|
||||||
|
post_id,
|
||||||
|
comment.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
return CommentCreateResult(comment=comment)
|
||||||
|
|
||||||
|
|
||||||
|
@app.tool(
|
||||||
|
name="reply_to_comment",
|
||||||
|
description=(
|
||||||
|
"Reply to an existing comment using the request Authorization header or the configured "
|
||||||
|
"access token."
|
||||||
|
),
|
||||||
|
structured_output=True,
|
||||||
|
)
|
||||||
|
async def reply_to_comment(
|
||||||
|
comment_id: Annotated[
|
||||||
|
int,
|
||||||
|
PydanticField(ge=1, description="Identifier of the comment being replied to."),
|
||||||
|
],
|
||||||
|
content: Annotated[
|
||||||
|
str,
|
||||||
|
PydanticField(description="Markdown content of the reply."),
|
||||||
|
],
|
||||||
|
captcha: Annotated[
|
||||||
|
str | None,
|
||||||
|
PydanticField(
|
||||||
|
default=None,
|
||||||
|
description="Optional captcha solution if the backend requires it.",
|
||||||
|
),
|
||||||
|
] = None,
|
||||||
|
ctx: Context | None = None,
|
||||||
|
) -> CommentReplyResult:
|
||||||
|
"""Create a reply for a comment and return the backend payload."""
|
||||||
|
|
||||||
|
sanitized_content = content.strip()
|
||||||
|
if not sanitized_content:
|
||||||
|
raise ValueError("Reply content must not be empty.")
|
||||||
|
|
||||||
|
sanitized_captcha = captcha.strip() if isinstance(captcha, str) else None
|
||||||
|
|
||||||
|
request_token = _extract_authorization_token(ctx)
|
||||||
|
|
||||||
|
try:
|
||||||
|
logger.info(
|
||||||
|
"Creating reply for comment_id=%s (captcha=%s)",
|
||||||
|
comment_id,
|
||||||
|
bool(sanitized_captcha),
|
||||||
|
)
|
||||||
|
raw_comment = await search_client.reply_to_comment(
|
||||||
|
comment_id,
|
||||||
|
token=request_token,
|
||||||
|
content=sanitized_content,
|
||||||
|
captcha=sanitized_captcha,
|
||||||
|
)
|
||||||
|
except httpx.HTTPStatusError as exc: # pragma: no cover - network errors
|
||||||
|
status_code = exc.response.status_code
|
||||||
|
if status_code == 401:
|
||||||
|
message = (
|
||||||
|
"Authentication failed while replying to comment "
|
||||||
|
f"{comment_id}. Please verify the Authorization header or configured token."
|
||||||
|
)
|
||||||
|
elif status_code == 403:
|
||||||
|
message = (
|
||||||
|
"The provided Authorization token is not authorized to reply to comment "
|
||||||
|
f"{comment_id}."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
message = (
|
||||||
|
"OpenIsle backend returned HTTP "
|
||||||
|
f"{status_code} while replying to comment {comment_id}."
|
||||||
|
)
|
||||||
|
if ctx is not None:
|
||||||
|
await ctx.error(message)
|
||||||
|
raise ValueError(message) from exc
|
||||||
|
except httpx.RequestError as exc: # pragma: no cover - network errors
|
||||||
|
message = (
|
||||||
|
"Unable to reach OpenIsle backend comment service: "
|
||||||
|
f"{exc}."
|
||||||
|
)
|
||||||
|
if ctx is not None:
|
||||||
|
await ctx.error(message)
|
||||||
|
raise ValueError(message) from exc
|
||||||
|
|
||||||
|
try:
|
||||||
|
comment = CommentData.model_validate(raw_comment)
|
||||||
|
except ValidationError as exc:
|
||||||
|
message = "Received malformed data from the reply comment endpoint."
|
||||||
|
if ctx is not None:
|
||||||
|
await ctx.error(message)
|
||||||
|
raise ValueError(message) from exc
|
||||||
|
|
||||||
|
if ctx is not None:
|
||||||
|
await ctx.info(
|
||||||
|
"Reply created successfully for comment "
|
||||||
|
f"{comment_id}."
|
||||||
|
)
|
||||||
|
logger.debug(
|
||||||
|
"Validated reply payload for comment_id=%s (reply_id=%s)",
|
||||||
|
comment_id,
|
||||||
|
comment.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
return CommentReplyResult(comment=comment)
|
||||||
|
|
||||||
|
|
||||||
|
@app.tool(
|
||||||
|
name="create_post",
|
||||||
|
description=(
|
||||||
|
"Publish a new post using the request Authorization header or the configured access "
|
||||||
|
"token."
|
||||||
|
),
|
||||||
|
structured_output=True,
|
||||||
|
)
|
||||||
|
async def create_post(
|
||||||
|
title: Annotated[
|
||||||
|
str,
|
||||||
|
PydanticField(description="Title of the post to be created."),
|
||||||
|
],
|
||||||
|
content: Annotated[
|
||||||
|
str,
|
||||||
|
PydanticField(description="Markdown content of the post."),
|
||||||
|
],
|
||||||
|
category_id: Annotated[
|
||||||
|
int | None,
|
||||||
|
PydanticField(
|
||||||
|
default=None,
|
||||||
|
ge=1,
|
||||||
|
description="Optional category identifier for the post.",
|
||||||
|
),
|
||||||
|
] = None,
|
||||||
|
tag_ids: Annotated[
|
||||||
|
list[int] | None,
|
||||||
|
PydanticField(
|
||||||
|
default=None,
|
||||||
|
min_length=1,
|
||||||
|
description="Optional list of tag identifiers to assign to the post.",
|
||||||
|
),
|
||||||
|
] = None,
|
||||||
|
post_type: Annotated[
|
||||||
|
str | None,
|
||||||
|
PydanticField(
|
||||||
|
default=None,
|
||||||
|
description="Optional post type value (e.g. LOTTERY, POLL).",
|
||||||
|
),
|
||||||
|
] = None,
|
||||||
|
visible_scope: Annotated[
|
||||||
|
str | None,
|
||||||
|
PydanticField(
|
||||||
|
default=None,
|
||||||
|
description="Optional visibility scope for the post.",
|
||||||
|
),
|
||||||
|
] = None,
|
||||||
|
prize_description: Annotated[
|
||||||
|
str | None,
|
||||||
|
PydanticField(
|
||||||
|
default=None,
|
||||||
|
description="Description of the prize for lottery posts.",
|
||||||
|
),
|
||||||
|
] = None,
|
||||||
|
prize_icon: Annotated[
|
||||||
|
str | None,
|
||||||
|
PydanticField(
|
||||||
|
default=None,
|
||||||
|
description="Icon URL for the lottery prize.",
|
||||||
|
),
|
||||||
|
] = None,
|
||||||
|
prize_count: Annotated[
|
||||||
|
int | None,
|
||||||
|
PydanticField(
|
||||||
|
default=None,
|
||||||
|
ge=1,
|
||||||
|
description="Total number of prizes available for lottery posts.",
|
||||||
|
),
|
||||||
|
] = None,
|
||||||
|
point_cost: Annotated[
|
||||||
|
int | None,
|
||||||
|
PydanticField(
|
||||||
|
default=None,
|
||||||
|
ge=0,
|
||||||
|
description="Point cost required to participate in the post, when applicable.",
|
||||||
|
),
|
||||||
|
] = None,
|
||||||
|
start_time: Annotated[
|
||||||
|
str | None,
|
||||||
|
PydanticField(
|
||||||
|
default=None,
|
||||||
|
description="ISO 8601 start time for lottery or poll posts.",
|
||||||
|
),
|
||||||
|
] = None,
|
||||||
|
end_time: Annotated[
|
||||||
|
str | None,
|
||||||
|
PydanticField(
|
||||||
|
default=None,
|
||||||
|
description="ISO 8601 end time for lottery or poll posts.",
|
||||||
|
),
|
||||||
|
] = None,
|
||||||
|
options: Annotated[
|
||||||
|
list[str] | None,
|
||||||
|
PydanticField(
|
||||||
|
default=None,
|
||||||
|
min_length=1,
|
||||||
|
description="Poll options when creating a poll post.",
|
||||||
|
),
|
||||||
|
] = None,
|
||||||
|
multiple: Annotated[
|
||||||
|
bool | None,
|
||||||
|
PydanticField(
|
||||||
|
default=None,
|
||||||
|
description="Whether the poll allows selecting multiple options.",
|
||||||
|
),
|
||||||
|
] = None,
|
||||||
|
proposed_name: Annotated[
|
||||||
|
str | None,
|
||||||
|
PydanticField(
|
||||||
|
default=None,
|
||||||
|
description="Proposed category name for suggestion posts.",
|
||||||
|
),
|
||||||
|
] = None,
|
||||||
|
proposal_description: Annotated[
|
||||||
|
str | None,
|
||||||
|
PydanticField(
|
||||||
|
default=None,
|
||||||
|
description="Supporting description for the proposed category.",
|
||||||
|
),
|
||||||
|
] = None,
|
||||||
|
captcha: Annotated[
|
||||||
|
str | None,
|
||||||
|
PydanticField(
|
||||||
|
default=None,
|
||||||
|
description="Captcha solution if the backend requires one to create posts.",
|
||||||
|
),
|
||||||
|
] = None,
|
||||||
|
ctx: Context | None = None,
|
||||||
|
) -> PostCreateResult:
|
||||||
|
"""Create a new post in OpenIsle and return the detailed backend payload."""
|
||||||
|
|
||||||
|
sanitized_title = title.strip()
|
||||||
|
if not sanitized_title:
|
||||||
|
raise ValueError("Post title must not be empty.")
|
||||||
|
|
||||||
|
sanitized_content = content.strip()
|
||||||
|
if not sanitized_content:
|
||||||
|
raise ValueError("Post content must not be empty.")
|
||||||
|
|
||||||
|
sanitized_category_id: int | None = None
|
||||||
|
if category_id is not None:
|
||||||
|
if isinstance(category_id, bool):
|
||||||
|
raise ValueError("Category identifier must be an integer, not a boolean.")
|
||||||
|
try:
|
||||||
|
sanitized_category_id = int(category_id)
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise ValueError("Category identifier must be an integer.") from exc
|
||||||
|
if sanitized_category_id <= 0:
|
||||||
|
raise ValueError("Category identifier must be a positive integer.")
|
||||||
|
if sanitized_category_id is None:
|
||||||
|
raise ValueError("A category identifier is required to create a post.")
|
||||||
|
|
||||||
|
sanitized_tag_ids: list[int] | None = None
|
||||||
|
if tag_ids is not None:
|
||||||
|
sanitized_tag_ids = []
|
||||||
|
for value in tag_ids:
|
||||||
|
if isinstance(value, bool):
|
||||||
|
raise ValueError("Tag identifiers must be integers, not booleans.")
|
||||||
|
try:
|
||||||
|
converted = int(value)
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise ValueError("Tag identifiers must be integers.") from exc
|
||||||
|
if converted <= 0:
|
||||||
|
raise ValueError("Tag identifiers must be positive integers.")
|
||||||
|
sanitized_tag_ids.append(converted)
|
||||||
|
if not sanitized_tag_ids:
|
||||||
|
sanitized_tag_ids = None
|
||||||
|
if not sanitized_tag_ids:
|
||||||
|
raise ValueError("At least one tag identifier is required to create a post.")
|
||||||
|
if len(sanitized_tag_ids) > 2:
|
||||||
|
raise ValueError("At most two tag identifiers can be provided for a post.")
|
||||||
|
|
||||||
|
sanitized_post_type = post_type.strip() if isinstance(post_type, str) else None
|
||||||
|
if sanitized_post_type == "":
|
||||||
|
sanitized_post_type = None
|
||||||
|
|
||||||
|
sanitized_visible_scope = (
|
||||||
|
visible_scope.strip() if isinstance(visible_scope, str) else None
|
||||||
|
)
|
||||||
|
if sanitized_visible_scope == "":
|
||||||
|
sanitized_visible_scope = None
|
||||||
|
|
||||||
|
sanitized_prize_description = (
|
||||||
|
prize_description.strip() if isinstance(prize_description, str) else None
|
||||||
|
)
|
||||||
|
if sanitized_prize_description == "":
|
||||||
|
sanitized_prize_description = None
|
||||||
|
|
||||||
|
sanitized_prize_icon = prize_icon.strip() if isinstance(prize_icon, str) else None
|
||||||
|
if sanitized_prize_icon == "":
|
||||||
|
sanitized_prize_icon = None
|
||||||
|
|
||||||
|
sanitized_prize_count: int | None = None
|
||||||
|
if prize_count is not None:
|
||||||
|
if isinstance(prize_count, bool):
|
||||||
|
raise ValueError("Prize count must be an integer, not a boolean.")
|
||||||
|
try:
|
||||||
|
sanitized_prize_count = int(prize_count)
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise ValueError("Prize count must be an integer.") from exc
|
||||||
|
if sanitized_prize_count <= 0:
|
||||||
|
raise ValueError("Prize count must be a positive integer.")
|
||||||
|
|
||||||
|
sanitized_point_cost: int | None = None
|
||||||
|
if point_cost is not None:
|
||||||
|
if isinstance(point_cost, bool):
|
||||||
|
raise ValueError("Point cost must be an integer, not a boolean.")
|
||||||
|
try:
|
||||||
|
sanitized_point_cost = int(point_cost)
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise ValueError("Point cost must be an integer.") from exc
|
||||||
|
if sanitized_point_cost < 0:
|
||||||
|
raise ValueError("Point cost cannot be negative.")
|
||||||
|
|
||||||
|
sanitized_start_time = start_time.strip() if isinstance(start_time, str) else None
|
||||||
|
if sanitized_start_time == "":
|
||||||
|
sanitized_start_time = None
|
||||||
|
|
||||||
|
sanitized_end_time = end_time.strip() if isinstance(end_time, str) else None
|
||||||
|
if sanitized_end_time == "":
|
||||||
|
sanitized_end_time = None
|
||||||
|
|
||||||
|
sanitized_options: list[str] | None = None
|
||||||
|
if options is not None:
|
||||||
|
sanitized_options = []
|
||||||
|
for option in options:
|
||||||
|
if option is None:
|
||||||
|
continue
|
||||||
|
stripped_option = option.strip()
|
||||||
|
if stripped_option:
|
||||||
|
sanitized_options.append(stripped_option)
|
||||||
|
if not sanitized_options:
|
||||||
|
sanitized_options = None
|
||||||
|
|
||||||
|
sanitized_multiple = bool(multiple) if isinstance(multiple, bool) else None
|
||||||
|
|
||||||
|
sanitized_proposed_name = (
|
||||||
|
proposed_name.strip() if isinstance(proposed_name, str) else None
|
||||||
|
)
|
||||||
|
if sanitized_proposed_name == "":
|
||||||
|
sanitized_proposed_name = None
|
||||||
|
|
||||||
|
sanitized_proposal_description = (
|
||||||
|
proposal_description.strip() if isinstance(proposal_description, str) else None
|
||||||
|
)
|
||||||
|
if sanitized_proposal_description == "":
|
||||||
|
sanitized_proposal_description = None
|
||||||
|
|
||||||
|
sanitized_captcha = captcha.strip() if isinstance(captcha, str) else None
|
||||||
|
if sanitized_captcha == "":
|
||||||
|
sanitized_captcha = None
|
||||||
|
|
||||||
|
payload: dict[str, object] = {
|
||||||
|
"title": sanitized_title,
|
||||||
|
"content": sanitized_content,
|
||||||
|
}
|
||||||
|
if sanitized_category_id is not None:
|
||||||
|
payload["categoryId"] = sanitized_category_id
|
||||||
|
if sanitized_tag_ids is not None:
|
||||||
|
payload["tagIds"] = sanitized_tag_ids
|
||||||
|
if sanitized_post_type is not None:
|
||||||
|
payload["type"] = sanitized_post_type
|
||||||
|
if sanitized_visible_scope is not None:
|
||||||
|
payload["postVisibleScopeType"] = sanitized_visible_scope
|
||||||
|
if sanitized_prize_description is not None:
|
||||||
|
payload["prizeDescription"] = sanitized_prize_description
|
||||||
|
if sanitized_prize_icon is not None:
|
||||||
|
payload["prizeIcon"] = sanitized_prize_icon
|
||||||
|
if sanitized_prize_count is not None:
|
||||||
|
payload["prizeCount"] = sanitized_prize_count
|
||||||
|
if sanitized_point_cost is not None:
|
||||||
|
payload["pointCost"] = sanitized_point_cost
|
||||||
|
if sanitized_start_time is not None:
|
||||||
|
payload["startTime"] = sanitized_start_time
|
||||||
|
if sanitized_end_time is not None:
|
||||||
|
payload["endTime"] = sanitized_end_time
|
||||||
|
if sanitized_options is not None:
|
||||||
|
payload["options"] = sanitized_options
|
||||||
|
if sanitized_multiple is not None:
|
||||||
|
payload["multiple"] = sanitized_multiple
|
||||||
|
if sanitized_proposed_name is not None:
|
||||||
|
payload["proposedName"] = sanitized_proposed_name
|
||||||
|
if sanitized_proposal_description is not None:
|
||||||
|
payload["proposalDescription"] = sanitized_proposal_description
|
||||||
|
if sanitized_captcha is not None:
|
||||||
|
payload["captcha"] = sanitized_captcha
|
||||||
|
|
||||||
|
try:
|
||||||
|
logger.info("Creating post with title='%s'", sanitized_title)
|
||||||
|
raw_post = await search_client.create_post(payload, token=_extract_authorization_token(ctx))
|
||||||
|
except httpx.HTTPStatusError as exc: # pragma: no cover - network errors
|
||||||
|
status_code = exc.response.status_code
|
||||||
|
if status_code == 400:
|
||||||
|
message = (
|
||||||
|
"Post creation failed due to invalid input or captcha verification errors."
|
||||||
|
)
|
||||||
|
elif status_code == 401:
|
||||||
|
message = (
|
||||||
|
"Authentication failed while creating the post. Please verify the "
|
||||||
|
"Authorization header or configured token."
|
||||||
|
)
|
||||||
|
elif status_code == 403:
|
||||||
|
message = "The provided Authorization token is not authorized to create posts."
|
||||||
|
else:
|
||||||
|
message = (
|
||||||
|
"OpenIsle backend returned HTTP "
|
||||||
|
f"{status_code} while creating the post."
|
||||||
|
)
|
||||||
|
if ctx is not None:
|
||||||
|
await ctx.error(message)
|
||||||
|
raise ValueError(message) from exc
|
||||||
|
except httpx.RequestError as exc: # pragma: no cover - network errors
|
||||||
|
message = f"Unable to reach OpenIsle backend post service: {exc}."
|
||||||
|
if ctx is not None:
|
||||||
|
await ctx.error(message)
|
||||||
|
raise ValueError(message) from exc
|
||||||
|
|
||||||
|
try:
|
||||||
|
post = PostDetail.model_validate(raw_post)
|
||||||
|
except ValidationError as exc:
|
||||||
|
message = "Received malformed data from the post creation endpoint."
|
||||||
|
if ctx is not None:
|
||||||
|
await ctx.error(message)
|
||||||
|
raise ValueError(message) from exc
|
||||||
|
|
||||||
|
if ctx is not None:
|
||||||
|
await ctx.info(f"Post '{post.title}' created successfully.")
|
||||||
|
logger.debug(
|
||||||
|
"Validated created post payload with id=%s and title='%s'",
|
||||||
|
post.id,
|
||||||
|
post.title,
|
||||||
|
)
|
||||||
|
|
||||||
|
return PostCreateResult(post=post)
|
||||||
|
|
||||||
|
|
||||||
|
@app.tool(
|
||||||
|
name="recent_posts",
|
||||||
|
description="Retrieve posts created in the last N minutes.",
|
||||||
|
structured_output=True,
|
||||||
|
)
|
||||||
|
async def recent_posts(
|
||||||
|
minutes: Annotated[
|
||||||
|
int,
|
||||||
|
PydanticField(gt=0, le=1440, description="Time window in minutes to search for new posts."),
|
||||||
|
],
|
||||||
|
ctx: Context | None = None,
|
||||||
|
) -> RecentPostsResponse:
|
||||||
|
"""Fetch recent posts from the backend and return structured data."""
|
||||||
|
|
||||||
|
try:
|
||||||
|
logger.info("Fetching recent posts for last %s minutes", minutes)
|
||||||
|
raw_posts = await search_client.recent_posts(minutes)
|
||||||
|
except httpx.HTTPStatusError as exc: # pragma: no cover - network errors
|
||||||
|
message = (
|
||||||
|
"OpenIsle backend returned HTTP "
|
||||||
|
f"{exc.response.status_code} while fetching recent posts for the last {minutes} minutes."
|
||||||
|
)
|
||||||
|
if ctx is not None:
|
||||||
|
await ctx.error(message)
|
||||||
|
raise ValueError(message) from exc
|
||||||
|
except httpx.RequestError as exc: # pragma: no cover - network errors
|
||||||
|
message = f"Unable to reach OpenIsle backend recent posts service: {exc}."
|
||||||
|
if ctx is not None:
|
||||||
|
await ctx.error(message)
|
||||||
|
raise ValueError(message) from exc
|
||||||
|
|
||||||
|
try:
|
||||||
|
posts = [PostSummary.model_validate(entry) for entry in raw_posts]
|
||||||
|
except ValidationError as exc:
|
||||||
|
message = "Received malformed data from the recent posts endpoint."
|
||||||
|
if ctx is not None:
|
||||||
|
await ctx.error(message)
|
||||||
|
raise ValueError(message) from exc
|
||||||
|
|
||||||
|
if ctx is not None:
|
||||||
|
await ctx.info(
|
||||||
|
f"Found {len(posts)} posts created within the last {minutes} minutes."
|
||||||
|
)
|
||||||
|
logger.debug(
|
||||||
|
"Validated %d recent posts for window=%s minutes",
|
||||||
|
len(posts),
|
||||||
|
minutes,
|
||||||
|
)
|
||||||
|
|
||||||
|
return RecentPostsResponse(minutes=minutes, total=len(posts), posts=posts)
|
||||||
|
|
||||||
|
|
||||||
|
@app.tool(
|
||||||
|
name="get_post",
|
||||||
|
description="Retrieve detailed information for a single post.",
|
||||||
|
structured_output=True,
|
||||||
|
)
|
||||||
|
async def get_post(
|
||||||
|
post_id: Annotated[
|
||||||
|
int,
|
||||||
|
PydanticField(ge=1, description="Identifier of the post to retrieve."),
|
||||||
|
],
|
||||||
|
ctx: Context | None = None,
|
||||||
|
) -> PostDetail:
|
||||||
|
"""Fetch post details from the backend and validate the response."""
|
||||||
|
|
||||||
|
try:
|
||||||
|
logger.info("Fetching post details for post_id=%s", post_id)
|
||||||
|
raw_post = await search_client.get_post(
|
||||||
|
post_id, _extract_authorization_token(ctx)
|
||||||
|
)
|
||||||
|
except httpx.HTTPStatusError as exc: # pragma: no cover - network errors
|
||||||
|
status_code = exc.response.status_code
|
||||||
|
if status_code == 404:
|
||||||
|
message = f"Post {post_id} was not found."
|
||||||
|
elif status_code == 401:
|
||||||
|
message = "Authentication failed while retrieving the post."
|
||||||
|
elif status_code == 403:
|
||||||
|
message = "The provided Authorization token is not authorized to view this post."
|
||||||
|
else:
|
||||||
|
message = (
|
||||||
|
"OpenIsle backend returned HTTP "
|
||||||
|
f"{status_code} while retrieving post {post_id}."
|
||||||
|
)
|
||||||
|
if ctx is not None:
|
||||||
|
await ctx.error(message)
|
||||||
|
raise ValueError(message) from exc
|
||||||
|
except httpx.RequestError as exc: # pragma: no cover - network errors
|
||||||
|
message = f"Unable to reach OpenIsle backend post service: {exc}."
|
||||||
|
if ctx is not None:
|
||||||
|
await ctx.error(message)
|
||||||
|
raise ValueError(message) from exc
|
||||||
|
|
||||||
|
try:
|
||||||
|
post = PostDetail.model_validate(raw_post)
|
||||||
|
except ValidationError as exc:
|
||||||
|
message = "Received malformed data from the post detail endpoint."
|
||||||
|
if ctx is not None:
|
||||||
|
await ctx.error(message)
|
||||||
|
raise ValueError(message) from exc
|
||||||
|
|
||||||
|
if ctx is not None:
|
||||||
|
await ctx.info(f"Retrieved post {post_id} successfully.")
|
||||||
|
logger.debug(
|
||||||
|
"Validated post payload for post_id=%s with %d comments",
|
||||||
|
post_id,
|
||||||
|
len(post.comments),
|
||||||
|
)
|
||||||
|
|
||||||
|
return post
|
||||||
|
|
||||||
|
|
||||||
|
@app.tool(
|
||||||
|
name="list_unread_messages",
|
||||||
|
description="List unread notification messages for the authenticated user.",
|
||||||
|
structured_output=True,
|
||||||
|
)
|
||||||
|
async def list_unread_messages(
|
||||||
|
page: Annotated[
|
||||||
|
int,
|
||||||
|
PydanticField(
|
||||||
|
default=0,
|
||||||
|
ge=0,
|
||||||
|
description="Page number of unread notifications to retrieve.",
|
||||||
|
),
|
||||||
|
] = 0,
|
||||||
|
size: Annotated[
|
||||||
|
int,
|
||||||
|
PydanticField(
|
||||||
|
default=30,
|
||||||
|
ge=1,
|
||||||
|
le=100,
|
||||||
|
description="Number of unread notifications to include per page.",
|
||||||
|
),
|
||||||
|
] = 30,
|
||||||
|
ctx: Context | None = None,
|
||||||
|
) -> UnreadNotificationsResponse:
|
||||||
|
"""Retrieve unread notifications and return structured data."""
|
||||||
|
|
||||||
|
try:
|
||||||
|
logger.info(
|
||||||
|
"Fetching unread notifications (page=%s, size=%s)",
|
||||||
|
page,
|
||||||
|
size,
|
||||||
|
)
|
||||||
|
raw_notifications = await search_client.list_unread_notifications(
|
||||||
|
page=page,
|
||||||
|
size=size,
|
||||||
|
token=_extract_authorization_token(ctx),
|
||||||
|
)
|
||||||
|
except httpx.HTTPStatusError as exc: # pragma: no cover - network errors
|
||||||
|
message = (
|
||||||
|
"OpenIsle backend returned HTTP "
|
||||||
|
f"{exc.response.status_code} while fetching unread notifications."
|
||||||
|
)
|
||||||
|
if ctx is not None:
|
||||||
|
await ctx.error(message)
|
||||||
|
raise ValueError(message) from exc
|
||||||
|
except httpx.RequestError as exc: # pragma: no cover - network errors
|
||||||
|
message = f"Unable to reach OpenIsle backend notification service: {exc}."
|
||||||
|
if ctx is not None:
|
||||||
|
await ctx.error(message)
|
||||||
|
raise ValueError(message) from exc
|
||||||
|
|
||||||
|
try:
|
||||||
|
notifications = [
|
||||||
|
NotificationData.model_validate(entry) for entry in raw_notifications
|
||||||
|
]
|
||||||
|
except ValidationError as exc:
|
||||||
|
message = "Received malformed data from the unread notifications endpoint."
|
||||||
|
if ctx is not None:
|
||||||
|
await ctx.error(message)
|
||||||
|
raise ValueError(message) from exc
|
||||||
|
|
||||||
|
total = len(notifications)
|
||||||
|
if ctx is not None:
|
||||||
|
await ctx.info(
|
||||||
|
f"Retrieved {total} unread notifications (page {page}, size {size})."
|
||||||
|
)
|
||||||
|
logger.debug(
|
||||||
|
"Validated %d unread notifications for page=%s size=%s",
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
size,
|
||||||
|
)
|
||||||
|
|
||||||
|
return UnreadNotificationsResponse(
|
||||||
|
page=page,
|
||||||
|
size=size,
|
||||||
|
total=total,
|
||||||
|
notifications=notifications,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.tool(
|
||||||
|
name="mark_notifications_read",
|
||||||
|
description="Mark specific notification messages as read to remove them from the unread list.",
|
||||||
|
structured_output=True,
|
||||||
|
)
|
||||||
|
async def mark_notifications_read(
|
||||||
|
ids: Annotated[
|
||||||
|
list[int],
|
||||||
|
PydanticField(
|
||||||
|
min_length=1,
|
||||||
|
description="Notification identifiers that should be marked as read.",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
ctx: Context | None = None,
|
||||||
|
) -> NotificationCleanupResult:
|
||||||
|
"""Mark the supplied notifications as read and report the processed identifiers."""
|
||||||
|
|
||||||
|
try:
|
||||||
|
logger.info(
|
||||||
|
"Marking %d notifications as read", # pragma: no branch - logging
|
||||||
|
len(ids),
|
||||||
|
)
|
||||||
|
await search_client.mark_notifications_read(
|
||||||
|
ids, token=_extract_authorization_token(ctx)
|
||||||
|
)
|
||||||
|
except httpx.HTTPStatusError as exc: # pragma: no cover - network errors
|
||||||
|
message = (
|
||||||
|
"OpenIsle backend returned HTTP "
|
||||||
|
f"{exc.response.status_code} while marking notifications as read."
|
||||||
|
)
|
||||||
|
if ctx is not None:
|
||||||
|
await ctx.error(message)
|
||||||
|
raise ValueError(message) from exc
|
||||||
|
except httpx.RequestError as exc: # pragma: no cover - network errors
|
||||||
|
message = f"Unable to reach OpenIsle backend notification service: {exc}."
|
||||||
|
if ctx is not None:
|
||||||
|
await ctx.error(message)
|
||||||
|
raise ValueError(message) from exc
|
||||||
|
|
||||||
|
processed_ids: list[int] = []
|
||||||
|
for value in ids:
|
||||||
|
if isinstance(value, bool):
|
||||||
|
raise ValueError("Notification identifiers must be integers, not booleans.")
|
||||||
|
converted = int(value)
|
||||||
|
if converted <= 0:
|
||||||
|
raise ValueError("Notification identifiers must be positive integers.")
|
||||||
|
processed_ids.append(converted)
|
||||||
|
if ctx is not None:
|
||||||
|
await ctx.info(
|
||||||
|
f"Marked {len(processed_ids)} notifications as read.",
|
||||||
|
)
|
||||||
|
logger.debug(
|
||||||
|
"Successfully marked notifications as read: ids=%s",
|
||||||
|
processed_ids,
|
||||||
|
)
|
||||||
|
|
||||||
|
return NotificationCleanupResult(
|
||||||
|
processed_ids=processed_ids,
|
||||||
|
total_marked=len(processed_ids),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
"""Run the MCP server using the configured transport."""
|
||||||
|
|
||||||
|
app.run(transport=settings.transport)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__": # pragma: no cover - manual execution
|
||||||
|
main()
|
||||||
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user