mirror of
https://github.com/nagisa77/OpenIsle.git
synced 2026-02-07 15:41:02 +08:00
Compare commits
171 Commits
feature/ui
...
feature/ag
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1978490f1d | ||
|
|
680e44e743 | ||
|
|
657b76bb1e | ||
|
|
f695db62c6 | ||
|
|
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
|
||||
FRONTEND_PORT=3000
|
||||
WEBSOCKET_PORT=8082
|
||||
OPENISLE_MCP_PORT=8085
|
||||
MYSQL_PORT=3306
|
||||
REDIS_PORT=6379
|
||||
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
|
||||
68
AGENTS.md
Normal file
68
AGENTS.md
Normal file
@@ -0,0 +1,68 @@
|
||||
# OpenIsle 协作指引(根目录)
|
||||
|
||||
## 1) 适用范围与优先级
|
||||
|
||||
- 本文件作用于整个仓库。
|
||||
- 若子目录存在 `AGENTS.md`,以“最近目录”的规则为准(就近覆盖)。
|
||||
- 没有子目录规则时,回退到本文件。
|
||||
|
||||
## 2) 仓库地图(高频模块)
|
||||
|
||||
- `backend/`:Spring Boot 主后端(JPA、Redis、RabbitMQ、OpenSearch、OpenAPI)。
|
||||
- `frontend_nuxt/`:Nuxt 3 前端(SSR + WebSocket + OAuth 回调页)。
|
||||
- `websocket_service/`:独立 WebSocket/STOMP 服务(消费 RabbitMQ 通知并推送)。
|
||||
- `mcp/`:Python MCP Server(封装搜索、发帖、回复、通知等工具)。
|
||||
- `docs/`:Fumadocs 文档站(含 OpenAPI 生成)。
|
||||
- `deploy/`:生产/预发部署脚本。
|
||||
- `bots/`:定时 Bot 脚本(由 GitHub Actions 调度)。
|
||||
|
||||
## 3) 全局工作原则
|
||||
|
||||
- 小步修改:仅改当前任务相关文件,不做无关重构。
|
||||
- 先对齐契约,再改实现:优先保证 API、消息结构、环境变量名称稳定。
|
||||
- 变更需可验证:提交前给出最小可执行验证命令与结果。
|
||||
- 安全优先:禁止提交密钥、令牌、生产凭证。
|
||||
|
||||
## 4) 任务路由(先判定影响面)
|
||||
|
||||
- 仅后端业务逻辑:进入 `backend/AGENTS.md`。
|
||||
- 仅前端页面交互:进入 `frontend_nuxt/AGENTS.md`。
|
||||
- 实时消息/在线通知:同时看 `backend/` + `websocket_service/` + `frontend_nuxt/`。
|
||||
- API/DTO/鉴权变更:至少看 `backend/` + `mcp/` + `docs/`。
|
||||
- 部署链路变更:进入 `deploy/AGENTS.md`,并附风险与回滚方案。
|
||||
|
||||
## 5) 跨服务不变量(必须遵守)
|
||||
|
||||
- 环境变量以根目录 `.env.example` 为统一基线;变量改名需同步消费端。
|
||||
- 鉴权链路一致:
|
||||
- 后端 JWT:`backend/src/main/java/com/openisle/config/SecurityConfig.java`
|
||||
- WebSocket JWT:`websocket_service/src/main/java/com/openisle/websocket/config/WebSocketAuthInterceptor.java`
|
||||
- RabbitMQ 分片一致(16 个十六进制分片 + 兼容遗留队列):
|
||||
- `backend/src/main/java/com/openisle/config/RabbitMQConfig.java`
|
||||
- `backend/src/main/java/com/openisle/config/ShardingStrategy.java`
|
||||
- `websocket_service/src/main/java/com/openisle/websocket/listener/NotificationListener.java`
|
||||
- API 契约变更要同步:
|
||||
- MCP schema/tool:`mcp/src/openisle_mcp/schemas.py`、`mcp/src/openisle_mcp/server.py`
|
||||
- 文档生成:`docs/scripts/generate-docs.ts`、`docs/lib/openapi.ts`
|
||||
|
||||
## 6) 最小验证矩阵(按改动类型)
|
||||
|
||||
- 后端改动:`cd backend && mvn test`
|
||||
- 前端改动:`cd frontend_nuxt && npm run build`
|
||||
- WebSocket 服务改动:`cd websocket_service && mvn test`(无测试可退化为 `mvn -DskipTests compile`)
|
||||
- MCP 改动:`cd mcp && python -m pip install -e .`
|
||||
- Docs/OpenAPI 改动:`cd docs && bun run generate && bun run build`
|
||||
- 部署脚本改动:`bash -n deploy/deploy.sh && bash -n deploy/deploy_staging.sh`
|
||||
|
||||
## 7) 交付清单(每次任务输出建议)
|
||||
|
||||
- 改了什么:按文件列出核心变更点。
|
||||
- 为什么改:说明触发原因/缺陷点/一致性要求。
|
||||
- 如何验证:给出实际执行命令与结果摘要。
|
||||
- 风险与后续:列出剩余风险、可选回归点、回滚建议。
|
||||
|
||||
## 8) 禁止事项
|
||||
|
||||
- 不提交 `.env`、密钥、生产 token。
|
||||
- 不在未明确授权下执行破坏性命令(如大范围删除、强制重置)。
|
||||
- 不在无关文件中进行格式化/重排以“顺手优化”。
|
||||
@@ -1,5 +1,6 @@
|
||||
- [前置工作](#前置工作)
|
||||
- [前端极速调试(Docker 全量环境)](#前端极速调试docker-全量环境)
|
||||
- [dev 与 dev_local_backend 巡航指南](#dev-dev_local_backend-guide)
|
||||
- [启动后端服务](#启动后端服务)
|
||||
- [本地 IDEA](#本地-idea)
|
||||
- [配置环境变量](#配置环境变量)
|
||||
@@ -39,13 +40,6 @@ cd OpenIsle
|
||||
```
|
||||
`.env.example` 是模板,可在 `.env` 中按需覆盖如端口、密钥等配置。确保 `NUXT_PUBLIC_API_BASE_URL`、`NUXT_PUBLIC_WEBSOCKET_URL` 等仍指向 `localhost`,方便前端直接访问容器映射端口。
|
||||
2. 启动 Dev Profile:
|
||||
```shell
|
||||
docker compose \
|
||||
-f docker/docker-compose.yaml \
|
||||
--env-file .env \
|
||||
--profile dev build
|
||||
```
|
||||
|
||||
```shell
|
||||
docker compose \
|
||||
-f docker/docker-compose.yaml \
|
||||
@@ -63,6 +57,17 @@ cd OpenIsle
|
||||
--profile dev up -d --force-recreate
|
||||
```
|
||||
|
||||
仅重启后端容器(不重建镜像、不影响前端):
|
||||
```shell
|
||||
docker compose \
|
||||
-f docker/docker-compose.yaml \
|
||||
--env-file .env \
|
||||
--profile dev restart springboot websocket-service
|
||||
```
|
||||
|
||||
数据初始化sql会创建几个帐户供大家测试使用
|
||||
> username:admin/user1/user2 password:123456
|
||||
|
||||
3. 查看服务状态:
|
||||
```shell
|
||||
docker compose -f docker/docker-compose.yaml --env-file .env ps
|
||||
@@ -81,6 +86,41 @@ cd OpenIsle
|
||||
|
||||
如需自定义 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 +150,17 @@ IDEA 打开 `backend/` 文件夹。
|
||||
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 追踪,通常不推荐。
|
||||
|
||||

|
||||
|
||||
@@ -26,7 +26,7 @@ OpenIsle 是一个使用 Spring Boot 和 Vue 3 构建的全栈开源社区平台
|
||||
- 集成 OpenAI 提供的 Markdown 格式化功能
|
||||
- 通过环境变量可调整密码强度、登录方式、保护码等多种配置
|
||||
- 支持图片上传,默认使用腾讯云 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!
|
||||
59
backend/AGENTS.md
Normal file
59
backend/AGENTS.md
Normal file
@@ -0,0 +1,59 @@
|
||||
# Backend 协作指引
|
||||
|
||||
## 1) 适用范围
|
||||
|
||||
- 作用于 `backend/` 目录及其子目录。
|
||||
- 若与根 `AGENTS.md` 冲突,以本文件为准(仅后端范围)。
|
||||
|
||||
## 2) 代码结构心智模型
|
||||
|
||||
- `controller/`:接口层(入参校验、权限边界、响应格式)。
|
||||
- `service/`:业务编排与领域规则(核心逻辑放这里)。
|
||||
- `repository/`:JPA 数据访问(基于实体与查询方法)。
|
||||
- `model/`:实体模型与枚举。
|
||||
- `dto/` + `mapper/`:对外契约和映射转换。
|
||||
- `config/`:安全、缓存、MQ、OpenAPI、初始化器等基础设施配置。
|
||||
- `search/`:OpenSearch 索引与事件驱动同步。
|
||||
|
||||
## 3) 后端修改规则
|
||||
|
||||
- 控制器保持“薄”,复杂逻辑下沉到 `service/`。
|
||||
- DTO 变更优先考虑兼容性,避免无版本的破坏性字段删除/改名。
|
||||
- 新增接口时:
|
||||
- 补齐必要的鉴权规则(`SecurityConfig`)。
|
||||
- 补齐 OpenAPI 注解(`@Operation`、`@ApiResponse` 等)。
|
||||
- 涉及缓存时,确认 `CachingConfig` 中缓存名、TTL 与失效策略一致。
|
||||
|
||||
## 4) 重点一致性检查
|
||||
|
||||
- 鉴权与公开接口:
|
||||
- `src/main/java/com/openisle/config/SecurityConfig.java`
|
||||
- 搜索索引同步(实体字段/文案变更时):
|
||||
- `src/main/java/com/openisle/search/SearchDocumentFactory.java`
|
||||
- `src/main/java/com/openisle/search/SearchIndexEventPublisher.java`
|
||||
- 消息通知链路(评论/通知相关):
|
||||
- `src/main/java/com/openisle/config/RabbitMQConfig.java`
|
||||
- `src/main/java/com/openisle/config/ShardingStrategy.java`
|
||||
- `src/main/java/com/openisle/service/NotificationProducer.java`
|
||||
- 环境变量消费面:
|
||||
- `src/main/resources/application.properties`
|
||||
- 根目录 `.env.example`
|
||||
|
||||
## 5) 数据与事务注意事项
|
||||
|
||||
- 涉及多表写入时,明确事务边界,避免半成功状态。
|
||||
- 避免在 Controller 直接操作 Repository。
|
||||
- JPA 懒加载对象对外返回前应通过 DTO 映射,避免序列化副作用。
|
||||
|
||||
## 6) 测试与验证
|
||||
|
||||
- 首选全量:`mvn test`
|
||||
- 变更集中时可先跑目标测试(示例):
|
||||
- `mvn -Dtest=PostControllerTest test`
|
||||
- `mvn -Dtest=SearchServiceTest test`
|
||||
- 涉及搜索/MQ 配置时,至少完成一次启动级验证或关键集成测试。
|
||||
|
||||
## 7) 输出要求
|
||||
|
||||
- 明确列出“接口/字段/权限/事件”是否发生变化。
|
||||
- 若影响 `mcp/` 或 `docs/`,在结果中显式提示需同步改动。
|
||||
@@ -19,6 +19,7 @@ JWT_EXPIRATION=2592000000
|
||||
# === Redis ===
|
||||
REDIS_HOST=<Redis 地址>
|
||||
REDIS_PORT=<Redis 端口>
|
||||
REDIS_PASS=<Redis 密码>
|
||||
|
||||
# === Resend ===
|
||||
RESEND_API_KEY=<你的resend-api-key>
|
||||
|
||||
@@ -6,10 +6,12 @@ import com.openisle.model.User;
|
||||
import com.openisle.repository.NotificationRepository;
|
||||
import com.openisle.repository.UserRepository;
|
||||
import com.openisle.service.EmailSender;
|
||||
import com.openisle.exception.EmailSendException;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
@@ -17,6 +19,7 @@ import org.springframework.web.bind.annotation.*;
|
||||
@RestController
|
||||
@RequestMapping("/api/admin/users")
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class AdminUserController {
|
||||
|
||||
private final UserRepository userRepository;
|
||||
@@ -35,11 +38,15 @@ public class AdminUserController {
|
||||
user.setApproved(true);
|
||||
userRepository.save(user);
|
||||
markRegisterRequestNotificationsRead(user);
|
||||
emailSender.sendEmail(
|
||||
user.getEmail(),
|
||||
"您的注册已审核通过",
|
||||
"🎉您的注册已经审核通过, 点击以访问网站: " + websiteUrl
|
||||
);
|
||||
try {
|
||||
emailSender.sendEmail(
|
||||
user.getEmail(),
|
||||
"您的注册已审核通过",
|
||||
"🎉您的注册已经审核通过, 点击以访问网站: " + websiteUrl
|
||||
);
|
||||
} catch (EmailSendException e) {
|
||||
log.warn("Failed to send approve email to {}: {}", user.getEmail(), e.getMessage());
|
||||
}
|
||||
return ResponseEntity.ok().build();
|
||||
}
|
||||
|
||||
@@ -52,11 +59,15 @@ public class AdminUserController {
|
||||
user.setApproved(false);
|
||||
userRepository.save(user);
|
||||
markRegisterRequestNotificationsRead(user);
|
||||
emailSender.sendEmail(
|
||||
user.getEmail(),
|
||||
"您的注册已被管理员拒绝",
|
||||
"您的注册被管理员拒绝, 点击链接可以重新填写理由申请: " + websiteUrl
|
||||
);
|
||||
try {
|
||||
emailSender.sendEmail(
|
||||
user.getEmail(),
|
||||
"您的注册已被管理员拒绝",
|
||||
"您的注册被管理员拒绝, 点击链接可以重新填写理由申请: " + websiteUrl
|
||||
);
|
||||
} catch (EmailSendException e) {
|
||||
log.warn("Failed to send reject email to {}: {}", user.getEmail(), e.getMessage());
|
||||
}
|
||||
return ResponseEntity.ok().build();
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.openisle.controller;
|
||||
|
||||
import com.openisle.config.CachingConfig;
|
||||
import com.openisle.dto.*;
|
||||
import com.openisle.exception.EmailSendException;
|
||||
import com.openisle.exception.FieldException;
|
||||
import com.openisle.model.RegisterMode;
|
||||
import com.openisle.model.User;
|
||||
@@ -19,6 +20,7 @@ import java.util.concurrent.TimeUnit;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@@ -83,6 +85,17 @@ public class AuthController {
|
||||
"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) {
|
||||
return ResponseEntity.badRequest().body(
|
||||
Map.of("field", e.getField(), "error", e.getMessage())
|
||||
@@ -97,7 +110,20 @@ public class AuthController {
|
||||
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()) {
|
||||
notificationService.createRegisterRequestNotifications(user, user.getRegisterReason());
|
||||
}
|
||||
@@ -169,14 +195,28 @@ public class AuthController {
|
||||
}
|
||||
User user = userOpt.get();
|
||||
if (!user.isVerified()) {
|
||||
user = userService.register(
|
||||
user.getUsername(),
|
||||
user.getEmail(),
|
||||
user.getPassword(),
|
||||
user.getRegisterReason(),
|
||||
registerModeService.getRegisterMode()
|
||||
);
|
||||
userService.sendVerifyMail(user, VerifyType.REGISTER);
|
||||
user =
|
||||
userService.register(
|
||||
user.getUsername(),
|
||||
user.getEmail(),
|
||||
user.getPassword(),
|
||||
user.getRegisterReason(),
|
||||
registerModeService.getRegisterMode()
|
||||
);
|
||||
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(
|
||||
Map.of(
|
||||
"error",
|
||||
@@ -663,7 +703,20 @@ public class AuthController {
|
||||
if (userOpt.isEmpty()) {
|
||||
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"));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
package com.openisle.controller;
|
||||
|
||||
import com.openisle.dto.CommentContextDto;
|
||||
import com.openisle.dto.CommentDto;
|
||||
import com.openisle.dto.CommentRequest;
|
||||
import com.openisle.dto.PostChangeLogDto;
|
||||
import com.openisle.dto.TimelineItemDto;
|
||||
import com.openisle.mapper.CommentMapper;
|
||||
import com.openisle.mapper.PostChangeLogMapper;
|
||||
import com.openisle.mapper.PostMapper;
|
||||
import com.openisle.model.Comment;
|
||||
import com.openisle.model.CommentSort;
|
||||
import com.openisle.service.*;
|
||||
@@ -40,6 +42,7 @@ public class CommentController {
|
||||
private final PointService pointService;
|
||||
private final PostChangeLogService changeLogService;
|
||||
private final PostChangeLogMapper postChangeLogMapper;
|
||||
private final PostMapper postMapper;
|
||||
|
||||
@Value("${app.captcha.enabled:false}")
|
||||
private boolean captchaEnabled;
|
||||
@@ -109,7 +112,9 @@ public class CommentController {
|
||||
)
|
||||
public List<TimelineItemDto<?>> listComments(
|
||||
@PathVariable Long postId,
|
||||
@RequestParam(value = "sort", required = false, defaultValue = "OLDEST") CommentSort sort
|
||||
@RequestParam(value = "sort", required = false, defaultValue = "OLDEST") CommentSort sort,
|
||||
@RequestParam(value = "page", required = false, defaultValue = "0") int page,
|
||||
@RequestParam(value = "pageSize", required = false, defaultValue = "20") int pageSize
|
||||
) {
|
||||
log.debug("listComments called for post {} with sort {}", postId, sort);
|
||||
List<CommentDto> commentDtoList = commentService
|
||||
@@ -180,8 +185,54 @@ public class CommentController {
|
||||
createdAtComparator = createdAtComparator.reversed();
|
||||
}
|
||||
itemDtoList.sort(comparator.thenComparing(createdAtComparator));
|
||||
log.debug("listComments returning {} comments", itemDtoList.size());
|
||||
return itemDtoList;
|
||||
|
||||
int safePage = Math.max(0, page);
|
||||
int safePageSize = Math.max(1, pageSize);
|
||||
int fromIndex = safePage * safePageSize;
|
||||
int toIndex = Math.min(fromIndex + safePageSize, itemDtoList.size());
|
||||
List<TimelineItemDto<?>> pagedItems =
|
||||
fromIndex >= itemDtoList.size() ? List.of() : itemDtoList.subList(fromIndex, toIndex);
|
||||
|
||||
log.debug(
|
||||
"listComments returning {} items for post {} page {} size {} (total {})",
|
||||
pagedItems.size(),
|
||||
postId,
|
||||
safePage,
|
||||
safePageSize,
|
||||
itemDtoList.size()
|
||||
);
|
||||
return pagedItems;
|
||||
}
|
||||
|
||||
@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}")
|
||||
|
||||
@@ -66,6 +66,7 @@ public class PostController {
|
||||
req.getContent(),
|
||||
req.getTagIds(),
|
||||
req.getType(),
|
||||
req.getPostVisibleScopeType(),
|
||||
req.getPrizeDescription(),
|
||||
req.getPrizeIcon(),
|
||||
req.getPrizeCount(),
|
||||
@@ -73,7 +74,9 @@ public class PostController {
|
||||
req.getStartTime(),
|
||||
req.getEndTime(),
|
||||
req.getOptions(),
|
||||
req.getMultiple()
|
||||
req.getMultiple(),
|
||||
req.getProposedName(),
|
||||
req.getProposalDescription()
|
||||
);
|
||||
draftService.deleteDraft(auth.getName());
|
||||
PostDetailDto dto = postMapper.toDetailDto(post, auth.getName());
|
||||
@@ -101,7 +104,8 @@ public class PostController {
|
||||
req.getCategoryId(),
|
||||
req.getTitle(),
|
||||
req.getContent(),
|
||||
req.getTagIds()
|
||||
req.getTagIds(),
|
||||
req.getPostVisibleScopeType()
|
||||
);
|
||||
return ResponseEntity.ok(postMapper.toDetailDto(post, auth.getName()));
|
||||
}
|
||||
@@ -213,8 +217,24 @@ public class PostController {
|
||||
// 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
|
||||
.defaultListPosts(ids, tids, page, pageSize)
|
||||
.listRecentPosts(minutes)
|
||||
.stream()
|
||||
.map(postMapper::toSummaryDto)
|
||||
.collect(Collectors.toList());
|
||||
@@ -245,11 +265,7 @@ public class PostController {
|
||||
// userVisitService.recordVisit(auth.getName());
|
||||
// }
|
||||
|
||||
return postService
|
||||
.listPostsByViews(ids, tids, page, pageSize)
|
||||
.stream()
|
||||
.map(postMapper::toSummaryDto)
|
||||
.collect(Collectors.toList());
|
||||
return postMapper.toListDtos(postService.listPostsByViews(ids, tids, page, pageSize));
|
||||
}
|
||||
|
||||
@GetMapping("/latest-reply")
|
||||
@@ -281,8 +297,7 @@ public class PostController {
|
||||
// userVisitService.recordVisit(auth.getName());
|
||||
// }
|
||||
|
||||
List<Post> posts = postService.listPostsByLatestReply(ids, tids, page, pageSize);
|
||||
return posts.stream().map(postMapper::toSummaryDto).collect(Collectors.toList());
|
||||
return postMapper.toListDtos(postService.listPostsByLatestReply(ids, tids, page, pageSize));
|
||||
}
|
||||
|
||||
@GetMapping("/featured")
|
||||
@@ -309,10 +324,6 @@ public class PostController {
|
||||
// if (auth != null) {
|
||||
// userVisitService.recordVisit(auth.getName());
|
||||
// }
|
||||
return postService
|
||||
.listFeaturedPosts(ids, tids, page, pageSize)
|
||||
.stream()
|
||||
.map(postMapper::toSummaryDto)
|
||||
.collect(Collectors.toList());
|
||||
return postMapper.toListDtos(postService.listFeaturedPosts(ids, tids, page, pageSize));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ public class UserController {
|
||||
private final TagService tagService;
|
||||
private final SubscriptionService subscriptionService;
|
||||
private final LevelService levelService;
|
||||
private final PostReadService postReadService;
|
||||
private final JwtService jwtService;
|
||||
private final UserMapper userMapper;
|
||||
private final TagMapper tagMapper;
|
||||
@@ -53,6 +54,9 @@ public class UserController {
|
||||
@Value("${app.user.tags-limit:50}")
|
||||
private int defaultTagsLimit;
|
||||
|
||||
@Value("${app.user.read-posts-limit:50}")
|
||||
private int defaultReadPostsLimit;
|
||||
|
||||
@GetMapping("/me")
|
||||
@SecurityRequirement(name = "JWT")
|
||||
@Operation(summary = "Current user", description = "Get current authenticated user information")
|
||||
@@ -211,6 +215,33 @@ public class UserController {
|
||||
.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")
|
||||
@Operation(summary = "User hot posts", description = "Get most reacted posts by user")
|
||||
@ApiResponse(
|
||||
|
||||
@@ -13,4 +13,5 @@ public class AuthorDto {
|
||||
private String username;
|
||||
private String avatar;
|
||||
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;
|
||||
|
||||
import com.openisle.model.PostChangeType;
|
||||
import com.openisle.model.PostVisibleScopeType;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import lombok.Getter;
|
||||
@@ -29,5 +30,7 @@ public class PostChangeLogDto {
|
||||
private LocalDateTime newPinnedAt;
|
||||
private Boolean oldFeatured;
|
||||
private Boolean newFeatured;
|
||||
private PostVisibleScopeType oldVisibleScope;
|
||||
private PostVisibleScopeType newVisibleScope;
|
||||
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 java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
import com.openisle.model.PostVisibleScopeType;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
@@ -19,6 +21,7 @@ public class PostRequest {
|
||||
|
||||
// optional for lottery posts
|
||||
private PostType type;
|
||||
private PostVisibleScopeType postVisibleScopeType;
|
||||
private String prizeDescription;
|
||||
private String prizeIcon;
|
||||
private Integer prizeCount;
|
||||
@@ -28,4 +31,8 @@ public class PostRequest {
|
||||
// fields for poll posts
|
||||
private List<String> options;
|
||||
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 java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
import com.openisle.model.PostVisibleScopeType;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
@@ -34,4 +36,5 @@ public class PostSummaryDto {
|
||||
private PollDto poll;
|
||||
private boolean rssExcluded;
|
||||
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 currentLevel;
|
||||
private int nextLevelExp;
|
||||
private boolean bot;
|
||||
}
|
||||
|
||||
@@ -8,4 +8,5 @@ public class UserSummaryDto {
|
||||
private Long id;
|
||||
private String username;
|
||||
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) {
|
||||
dto.setOldFeatured(f.isOldFeatured());
|
||||
dto.setNewFeatured(f.isNewFeatured());
|
||||
} else if (log instanceof PostVisibleScopeChangeLog v) {
|
||||
dto.setOldVisibleScope(v.getOldVisibleScope());
|
||||
dto.setNewVisibleScope(v.getNewVisibleScope());
|
||||
} else if (log instanceof PostDonateChangeLog d) {
|
||||
dto.setAmount(d.getAmount());
|
||||
}
|
||||
|
||||
@@ -6,7 +6,9 @@ import com.openisle.dto.LotteryDto;
|
||||
import com.openisle.dto.PollDto;
|
||||
import com.openisle.dto.PostDetailDto;
|
||||
import com.openisle.dto.PostSummaryDto;
|
||||
import com.openisle.dto.ProposalDto;
|
||||
import com.openisle.dto.ReactionDto;
|
||||
import com.openisle.model.CategoryProposalPost;
|
||||
import com.openisle.model.CommentSort;
|
||||
import com.openisle.model.LotteryPost;
|
||||
import com.openisle.model.PollPost;
|
||||
@@ -46,6 +48,38 @@ public class PostMapper {
|
||||
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) {
|
||||
PostDetailDto dto = new PostDetailDto();
|
||||
applyCommon(post, dto);
|
||||
@@ -59,6 +93,25 @@ public class PostMapper {
|
||||
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) {
|
||||
dto.setId(post.getId());
|
||||
dto.setTitle(post.getTitle());
|
||||
@@ -73,6 +126,7 @@ public class PostMapper {
|
||||
dto.setPinnedAt(post.getPinnedAt());
|
||||
dto.setRssExcluded(post.getRssExcluded() == null || post.getRssExcluded());
|
||||
dto.setClosed(post.isClosed());
|
||||
dto.setVisibleScope(post.getVisibleScope());
|
||||
|
||||
List<ReactionDto> reactions = reactionService
|
||||
.getReactionsForPost(post.getId())
|
||||
@@ -113,26 +167,40 @@ public class PostMapper {
|
||||
dto.setLottery(l);
|
||||
}
|
||||
|
||||
if (post instanceof PollPost pp) {
|
||||
PollDto p = new PollDto();
|
||||
p.setOptions(pp.getOptions());
|
||||
p.setVotes(pp.getVotes());
|
||||
p.setEndTime(pp.getEndTime());
|
||||
p.setParticipants(
|
||||
pp.getParticipants().stream().map(userMapper::toAuthorDto).collect(Collectors.toList())
|
||||
);
|
||||
Map<Integer, List<AuthorDto>> optionParticipants = pollVoteRepository
|
||||
.findByPostId(pp.getId())
|
||||
.stream()
|
||||
.collect(
|
||||
Collectors.groupingBy(
|
||||
PollVote::getOptionIndex,
|
||||
Collectors.mapping(v -> userMapper.toAuthorDto(v.getUser()), Collectors.toList())
|
||||
)
|
||||
);
|
||||
p.setOptionParticipants(optionParticipants);
|
||||
p.setMultiple(Boolean.TRUE.equals(pp.getMultiple()));
|
||||
dto.setPoll(p);
|
||||
if (post instanceof CategoryProposalPost cp) {
|
||||
ProposalDto proposalDto = (ProposalDto) buildPollDto(cp, new ProposalDto());
|
||||
proposalDto.setProposalStatus(cp.getProposalStatus());
|
||||
proposalDto.setProposedName(cp.getProposedName());
|
||||
proposalDto.setDescription(cp.getDescription());
|
||||
proposalDto.setApproveThreshold(cp.getApproveThreshold());
|
||||
proposalDto.setQuorum(cp.getQuorum());
|
||||
proposalDto.setStartAt(cp.getStartAt());
|
||||
proposalDto.setResultSnapshot(cp.getResultSnapshot());
|
||||
proposalDto.setRejectReason(cp.getRejectReason());
|
||||
dto.setPoll(proposalDto);
|
||||
} else if (post instanceof PollPost pp) {
|
||||
dto.setPoll(buildPollDto(pp, new PollDto()));
|
||||
}
|
||||
}
|
||||
|
||||
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.model.Comment;
|
||||
import com.openisle.model.Post;
|
||||
import com.openisle.model.PostRead;
|
||||
import com.openisle.model.User;
|
||||
import com.openisle.service.*;
|
||||
import java.util.stream.Collectors;
|
||||
@@ -37,6 +38,7 @@ public class UserMapper {
|
||||
dto.setUsername(user.getUsername());
|
||||
dto.setAvatar(user.getAvatar());
|
||||
dto.setDisplayMedal(user.getDisplayMedal());
|
||||
dto.setBot(user.isBot());
|
||||
return dto;
|
||||
}
|
||||
|
||||
@@ -63,6 +65,7 @@ public class UserMapper {
|
||||
dto.setPoint(user.getPoint());
|
||||
dto.setCurrentLevel(levelService.getLevel(user.getExperience()));
|
||||
dto.setNextLevelExp(levelService.nextLevelExp(user.getExperience()));
|
||||
dto.setBot(user.isBot());
|
||||
if (viewer != null) {
|
||||
dto.setSubscribed(subscriptionService.isSubscribed(viewer.getName(), user.getUsername()));
|
||||
} else {
|
||||
@@ -113,4 +116,11 @@ public class UserMapper {
|
||||
}
|
||||
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,
|
||||
/** A poll you participated in has concluded */
|
||||
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 */
|
||||
POST_FEATURED,
|
||||
/** Someone donated to your post */
|
||||
|
||||
@@ -66,6 +66,10 @@ public class Post {
|
||||
@Column(nullable = false)
|
||||
private PostType type = PostType.NORMAL;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false)
|
||||
private PostVisibleScopeType visibleScope = PostVisibleScopeType.ALL;
|
||||
|
||||
@Column(nullable = false)
|
||||
private boolean closed = false;
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ public enum PostChangeType {
|
||||
CLOSED,
|
||||
PINNED,
|
||||
FEATURED,
|
||||
VISIBLE_SCOPE,
|
||||
VOTE_RESULT,
|
||||
LOTTERY_RESULT,
|
||||
DONATE,
|
||||
|
||||
@@ -4,4 +4,5 @@ public enum PostType {
|
||||
NORMAL,
|
||||
LOTTERY,
|
||||
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)
|
||||
private Role role = Role.USER;
|
||||
|
||||
@Column(name = "is_bot", nullable = false)
|
||||
private boolean bot = false;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
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.Post;
|
||||
import com.openisle.model.User;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
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> {
|
||||
List<Comment> findByPostAndParentIsNullOrderByCreatedAtAsc(Post post);
|
||||
List<Comment> findByParentOrderByCreatedAtAsc(Comment parent);
|
||||
List<Comment> findByPostAndCreatedAtLessThanOrderByCreatedAtAsc(
|
||||
Post post,
|
||||
LocalDateTime createdAt
|
||||
);
|
||||
List<Comment> findByAuthorOrderByCreatedAtDesc(User author, Pageable pageable);
|
||||
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.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(
|
||||
"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.PostRead;
|
||||
import com.openisle.model.User;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface PostReadRepository extends JpaRepository<PostRead, Long> {
|
||||
Optional<PostRead> findByUserAndPost(User user, Post post);
|
||||
List<PostRead> findByUserOrderByLastReadAtDesc(User user, Pageable pageable);
|
||||
long countByUser(User user);
|
||||
void deleteByPost(Post post);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,12 @@ public interface PostRepository extends JpaRepository<Post, Long> {
|
||||
List<Post> findByStatusOrderByCreatedAtDesc(PostStatus status, Pageable pageable);
|
||||
List<Post> findByStatusOrderByViewsDesc(PostStatus status);
|
||||
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(
|
||||
User author,
|
||||
PostStatus status,
|
||||
@@ -39,6 +45,16 @@ public interface PostRepository extends JpaRepository<Post, Long> {
|
||||
PostStatus status,
|
||||
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, Pageable pageable);
|
||||
List<Post> findDistinctByTagsInAndStatusOrderByCreatedAtDesc(List<Tag> tags, PostStatus status);
|
||||
@@ -128,6 +144,26 @@ public interface PostRepository extends JpaRepository<Post, Long> {
|
||||
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(
|
||||
"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
|
||||
);
|
||||
|
||||
@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(
|
||||
"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.setUsername(message.getSender().getUsername());
|
||||
userDto.setAvatar(message.getSender().getAvatar());
|
||||
userDto.setBot(message.getSender().isBot());
|
||||
dto.setSender(userDto);
|
||||
|
||||
return dto;
|
||||
|
||||
@@ -21,8 +21,12 @@ import com.openisle.service.NotificationService;
|
||||
import com.openisle.service.PointService;
|
||||
import com.openisle.service.SubscriptionService;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@@ -266,6 +270,27 @@ public class CommentService {
|
||||
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) {
|
||||
log.debug("getRecentCommentsByUser called for user {} with limit {}", username, limit);
|
||||
User user = userRepository
|
||||
@@ -295,6 +320,37 @@ public class CommentService {
|
||||
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) {
|
||||
log.debug("getCommentsByIds called for ids {}", ids);
|
||||
java.util.List<Comment> comments = commentRepository.findAllById(ids);
|
||||
|
||||
@@ -211,6 +211,7 @@ public class MessageService {
|
||||
userSummaryDto.setId(message.getSender().getId());
|
||||
userSummaryDto.setUsername(message.getSender().getUsername());
|
||||
userSummaryDto.setAvatar(message.getSender().getAvatar());
|
||||
userSummaryDto.setBot(message.getSender().isBot());
|
||||
dto.setSender(userSummaryDto);
|
||||
|
||||
if (message.getReplyTo() != null) {
|
||||
@@ -222,6 +223,7 @@ public class MessageService {
|
||||
replySender.setId(reply.getSender().getId());
|
||||
replySender.setUsername(reply.getSender().getUsername());
|
||||
replySender.setAvatar(reply.getSender().getAvatar());
|
||||
replySender.setBot(reply.getSender().isBot());
|
||||
replyDto.setSender(replySender);
|
||||
dto.setReplyTo(replyDto);
|
||||
}
|
||||
@@ -316,6 +318,7 @@ public class MessageService {
|
||||
userDto.setId(p.getUser().getId());
|
||||
userDto.setUsername(p.getUser().getUsername());
|
||||
userDto.setAvatar(p.getUser().getAvatar());
|
||||
userDto.setBot(p.getUser().isBot());
|
||||
return userDto;
|
||||
})
|
||||
.collect(Collectors.toList())
|
||||
@@ -365,6 +368,7 @@ public class MessageService {
|
||||
userDto.setId(p.getUser().getId());
|
||||
userDto.setUsername(p.getUser().getUsername());
|
||||
userDto.setAvatar(p.getUser().getAvatar());
|
||||
userDto.setBot(p.getUser().isBot());
|
||||
return userDto;
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
|
||||
@@ -7,6 +7,7 @@ import com.openisle.repository.NotificationRepository;
|
||||
import com.openisle.repository.ReactionRepository;
|
||||
import com.openisle.repository.UserRepository;
|
||||
import com.openisle.service.EmailSender;
|
||||
import com.openisle.exception.EmailSendException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.EnumSet;
|
||||
import java.util.HashSet;
|
||||
@@ -17,6 +18,7 @@ import java.util.concurrent.Executor;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
@@ -26,6 +28,7 @@ import org.springframework.transaction.support.TransactionSynchronizationManager
|
||||
/** Service for creating and retrieving notifications. */
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class NotificationService {
|
||||
|
||||
private final NotificationRepository notificationRepository;
|
||||
@@ -108,7 +111,11 @@ public class NotificationService {
|
||||
post.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);
|
||||
} else if (type == NotificationType.REACTION && comment != null) {
|
||||
// long count = reactionRepository.countReceived(comment.getAuthor().getUsername());
|
||||
|
||||
@@ -99,6 +99,21 @@ public class PostChangeLogService {
|
||||
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) {
|
||||
PostVoteResultChangeLog log = new PostVoteResultChangeLog();
|
||||
log.setPost(post);
|
||||
|
||||
@@ -7,7 +7,10 @@ import com.openisle.repository.PostReadRepository;
|
||||
import com.openisle.repository.PostRepository;
|
||||
import com.openisle.repository.UserRepository;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.stereotype.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) {
|
||||
User user = userRepository
|
||||
.findByUsername(username)
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
package com.openisle.service;
|
||||
|
||||
import com.openisle.config.CachingConfig;
|
||||
import com.openisle.exception.NotFoundException;
|
||||
import com.openisle.exception.RateLimitException;
|
||||
import com.openisle.mapper.PostMapper;
|
||||
import com.openisle.model.*;
|
||||
import com.openisle.repository.CategoryProposalPostRepository;
|
||||
import com.openisle.repository.CategoryRepository;
|
||||
import com.openisle.repository.CommentRepository;
|
||||
import com.openisle.repository.LotteryPostRepository;
|
||||
@@ -18,10 +19,10 @@ import com.openisle.repository.TagRepository;
|
||||
import com.openisle.repository.UserRepository;
|
||||
import com.openisle.search.SearchIndexEventPublisher;
|
||||
import com.openisle.service.EmailSender;
|
||||
import com.openisle.exception.EmailSendException;
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
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.boot.context.event.ApplicationReadyEvent;
|
||||
import org.springframework.cache.annotation.CacheEvict;
|
||||
import org.springframework.cache.annotation.Cacheable;
|
||||
import org.springframework.context.ApplicationContext;
|
||||
import org.springframework.context.event.EventListener;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
@@ -54,6 +54,7 @@ public class PostService {
|
||||
private final TagRepository tagRepository;
|
||||
private final LotteryPostRepository lotteryPostRepository;
|
||||
private final PollPostRepository pollPostRepository;
|
||||
private final CategoryProposalPostRepository categoryProposalPostRepository;
|
||||
private final PollVoteRepository pollVoteRepository;
|
||||
private PublishMode publishMode;
|
||||
private final NotificationService notificationService;
|
||||
@@ -71,11 +72,17 @@ public class PostService {
|
||||
private final PointService pointService;
|
||||
private final PostChangeLogService postChangeLogService;
|
||||
private final PointHistoryRepository pointHistoryRepository;
|
||||
private final CategoryService categoryService;
|
||||
private final ConcurrentMap<Long, ScheduledFuture<?>> scheduledFinalizations =
|
||||
new ConcurrentHashMap<>();
|
||||
|
||||
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}")
|
||||
private String websiteUrl;
|
||||
|
||||
@@ -89,6 +96,7 @@ public class PostService {
|
||||
TagRepository tagRepository,
|
||||
LotteryPostRepository lotteryPostRepository,
|
||||
PollPostRepository pollPostRepository,
|
||||
CategoryProposalPostRepository categoryProposalPostRepository,
|
||||
PollVoteRepository pollVoteRepository,
|
||||
NotificationService notificationService,
|
||||
SubscriptionService subscriptionService,
|
||||
@@ -107,7 +115,8 @@ public class PostService {
|
||||
PointHistoryRepository pointHistoryRepository,
|
||||
@Value("${app.post.publish-mode:DIRECT}") PublishMode publishMode,
|
||||
RedisTemplate redisTemplate,
|
||||
SearchIndexEventPublisher searchIndexEventPublisher
|
||||
SearchIndexEventPublisher searchIndexEventPublisher,
|
||||
CategoryService categoryService
|
||||
) {
|
||||
this.postRepository = postRepository;
|
||||
this.userRepository = userRepository;
|
||||
@@ -115,6 +124,7 @@ public class PostService {
|
||||
this.tagRepository = tagRepository;
|
||||
this.lotteryPostRepository = lotteryPostRepository;
|
||||
this.pollPostRepository = pollPostRepository;
|
||||
this.categoryProposalPostRepository = categoryProposalPostRepository;
|
||||
this.pollVoteRepository = pollVoteRepository;
|
||||
this.notificationService = notificationService;
|
||||
this.subscriptionService = subscriptionService;
|
||||
@@ -135,6 +145,7 @@ public class PostService {
|
||||
|
||||
this.redisTemplate = redisTemplate;
|
||||
this.searchIndexEventPublisher = searchIndexEventPublisher;
|
||||
this.categoryService = categoryService;
|
||||
}
|
||||
|
||||
@EventListener(ApplicationReadyEvent.class)
|
||||
@@ -160,6 +171,24 @@ public class PostService {
|
||||
for (PollPost pp : pollPostRepository.findByEndTimeBeforeAndResultAnnouncedFalse(now)) {
|
||||
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() {
|
||||
@@ -225,6 +254,7 @@ public class PostService {
|
||||
String content,
|
||||
List<Long> tagIds,
|
||||
PostType type,
|
||||
PostVisibleScopeType postVisibleScopeType,
|
||||
String prizeDescription,
|
||||
String prizeIcon,
|
||||
Integer prizeCount,
|
||||
@@ -232,10 +262,12 @@ public class PostService {
|
||||
LocalDateTime startTime,
|
||||
LocalDateTime endTime,
|
||||
java.util.List<String> options,
|
||||
Boolean multiple
|
||||
Boolean multiple,
|
||||
String proposedName,
|
||||
String proposalDescription
|
||||
) {
|
||||
// 限制访问次数
|
||||
boolean limitResult = postRateLimit(username);
|
||||
boolean limitResult = isPostLimitReached(username);
|
||||
if (!limitResult) {
|
||||
throw new RateLimitException("Too many posts");
|
||||
}
|
||||
@@ -278,6 +310,25 @@ public class PostService {
|
||||
pp.setEndTime(endTime);
|
||||
pp.setMultiple(multiple != null && multiple);
|
||||
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 {
|
||||
post = new Post();
|
||||
}
|
||||
@@ -288,8 +339,19 @@ public class PostService {
|
||||
post.setCategory(category);
|
||||
post.setTags(new HashSet<>(tags));
|
||||
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) {
|
||||
post = lotteryPostRepository.save((LotteryPost) post);
|
||||
} else if (post instanceof CategoryProposalPost categoryProposalPost) {
|
||||
post = categoryProposalPostRepository.save(categoryProposalPost);
|
||||
} else if (post instanceof PollPost) {
|
||||
post = pollPostRepository.save((PollPost) post);
|
||||
} else {
|
||||
@@ -344,6 +406,12 @@ public class PostService {
|
||||
java.util.Date.from(lp.getEndTime().atZone(ZoneId.systemDefault()).toInstant())
|
||||
);
|
||||
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) {
|
||||
ScheduledFuture<?> future = taskScheduler.schedule(
|
||||
() -> applicationContext.getBean(PostService.class).finalizePoll(pp.getId()),
|
||||
@@ -354,24 +422,110 @@ public class PostService {
|
||||
if (post.getStatus() == PostStatus.PUBLISHED) {
|
||||
searchIndexEventPublisher.publishPostSaved(post);
|
||||
}
|
||||
markPostLimit(author.getUsername());
|
||||
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
|
||||
* @return
|
||||
* @return true - 允许发帖,false - 已达限制
|
||||
*/
|
||||
private boolean postRateLimit(String username) {
|
||||
private boolean isPostLimitReached(String username) {
|
||||
String key = CachingConfig.LIMIT_CACHE_NAME + ":posts:" + username;
|
||||
String result = (String) redisTemplate.opsForValue().get(key);
|
||||
//最近没有创建过文章
|
||||
if (StringUtils.isEmpty(result)) {
|
||||
// 限制频率为5分钟
|
||||
redisTemplate.opsForValue().set(key, "1", Duration.ofMinutes(5));
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
return StringUtils.isEmpty(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记用户发帖,触发limit计时
|
||||
* @param username
|
||||
*/
|
||||
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)
|
||||
@@ -450,6 +604,9 @@ public class PostService {
|
||||
pollPostRepository
|
||||
.findById(postId)
|
||||
.ifPresent(pp -> {
|
||||
if (pp instanceof CategoryProposalPost) {
|
||||
return;
|
||||
}
|
||||
if (pp.isResultAnnounced()) {
|
||||
return;
|
||||
}
|
||||
@@ -508,11 +665,15 @@ public class PostService {
|
||||
w.getEmail() != null &&
|
||||
!w.getDisabledEmailNotificationTypes().contains(NotificationType.LOTTERY_WIN)
|
||||
) {
|
||||
emailSender.sendEmail(
|
||||
w.getEmail(),
|
||||
"你中奖了",
|
||||
"恭喜你在抽奖贴 \"" + lp.getTitle() + "\" 中获奖"
|
||||
);
|
||||
try {
|
||||
emailSender.sendEmail(
|
||||
w.getEmail(),
|
||||
"你中奖了",
|
||||
"恭喜你在抽奖贴 \"" + lp.getTitle() + "\" 中获奖"
|
||||
);
|
||||
} catch (EmailSendException e) {
|
||||
log.warn("Failed to send lottery win email to {}: {}", w.getEmail(), e.getMessage());
|
||||
}
|
||||
}
|
||||
notificationService.createNotification(
|
||||
w,
|
||||
@@ -538,11 +699,19 @@ public class PostService {
|
||||
.getDisabledEmailNotificationTypes()
|
||||
.contains(NotificationType.LOTTERY_DRAW)
|
||||
) {
|
||||
emailSender.sendEmail(
|
||||
lp.getAuthor().getEmail(),
|
||||
"抽奖已开奖",
|
||||
"您的抽奖贴 \"" + lp.getTitle() + "\" 已开奖"
|
||||
);
|
||||
try {
|
||||
emailSender.sendEmail(
|
||||
lp.getAuthor().getEmail(),
|
||||
"抽奖已开奖",
|
||||
"您的抽奖贴 \"" + lp.getTitle() + "\" 已开奖"
|
||||
);
|
||||
} catch (EmailSendException e) {
|
||||
log.warn(
|
||||
"Failed to send lottery draw email to {}: {}",
|
||||
lp.getAuthor().getEmail(),
|
||||
e.getMessage()
|
||||
);
|
||||
}
|
||||
}
|
||||
notificationService.createNotification(
|
||||
lp.getAuthor(),
|
||||
@@ -571,7 +740,7 @@ public class PostService {
|
||||
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found"));
|
||||
if (post.getStatus() != PostStatus.PUBLISHED) {
|
||||
if (viewer == null) {
|
||||
throw new com.openisle.exception.NotFoundException("Post not found");
|
||||
throw new com.openisle.exception.NotFoundException("User not found");
|
||||
}
|
||||
User viewerUser = userRepository
|
||||
.findByUsername(viewer)
|
||||
@@ -615,6 +784,18 @@ public class PostService {
|
||||
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) {
|
||||
return listPostsByViews(null, null, page, pageSize);
|
||||
}
|
||||
@@ -629,9 +810,10 @@ public class PostService {
|
||||
boolean hasTags = tagIds != null && !tagIds.isEmpty();
|
||||
|
||||
java.util.List<Post> posts;
|
||||
Pageable pageable = buildPageable(page, pageSize);
|
||||
|
||||
if (!hasCategories && !hasTags) {
|
||||
posts = postRepository.findByStatusOrderByViewsDesc(PostStatus.PUBLISHED);
|
||||
posts = postRepository.findByStatusOrderByPinnedAtDescViewsDesc(PostStatus.PUBLISHED, pageable);
|
||||
} else if (hasCategories) {
|
||||
java.util.List<Category> categories = categoryRepository.findAllById(categoryIds);
|
||||
if (categories.isEmpty()) {
|
||||
@@ -642,16 +824,18 @@ public class PostService {
|
||||
if (tags.isEmpty()) {
|
||||
return java.util.List.of();
|
||||
}
|
||||
posts = postRepository.findByCategoriesAndAllTagsOrderByViewsDesc(
|
||||
posts = postRepository.findByCategoriesAndAllTagsOrderByPinnedAtDescViewsDesc(
|
||||
categories,
|
||||
tags,
|
||||
PostStatus.PUBLISHED,
|
||||
tags.size()
|
||||
tags.size(),
|
||||
pageable
|
||||
);
|
||||
} else {
|
||||
posts = postRepository.findByCategoryInAndStatusOrderByViewsDesc(
|
||||
posts = postRepository.findByCategoryInAndStatusOrderByPinnedAtDescViewsDesc(
|
||||
categories,
|
||||
PostStatus.PUBLISHED
|
||||
PostStatus.PUBLISHED,
|
||||
pageable
|
||||
);
|
||||
}
|
||||
} else {
|
||||
@@ -659,10 +843,15 @@ public class PostService {
|
||||
if (tags.isEmpty()) {
|
||||
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) {
|
||||
@@ -679,9 +868,13 @@ public class PostService {
|
||||
boolean hasTags = tagIds != null && !tagIds.isEmpty();
|
||||
|
||||
java.util.List<Post> posts;
|
||||
Pageable pageable = buildPageable(page, pageSize);
|
||||
|
||||
if (!hasCategories && !hasTags) {
|
||||
posts = postRepository.findByStatusOrderByCreatedAtDesc(PostStatus.PUBLISHED);
|
||||
posts = postRepository.findByStatusOrderByPinnedAtDescLastReplyAtDesc(
|
||||
PostStatus.PUBLISHED,
|
||||
pageable
|
||||
);
|
||||
} else if (hasCategories) {
|
||||
java.util.List<Category> categories = categoryRepository.findAllById(categoryIds);
|
||||
if (categories.isEmpty()) {
|
||||
@@ -692,16 +885,18 @@ public class PostService {
|
||||
if (tags.isEmpty()) {
|
||||
return java.util.List.of();
|
||||
}
|
||||
posts = postRepository.findByCategoriesAndAllTagsOrderByCreatedAtDesc(
|
||||
posts = postRepository.findByCategoriesAndAllTagsOrderByPinnedAtDescLastReplyAtDesc(
|
||||
categories,
|
||||
tags,
|
||||
PostStatus.PUBLISHED,
|
||||
tags.size()
|
||||
tags.size(),
|
||||
pageable
|
||||
);
|
||||
} else {
|
||||
posts = postRepository.findByCategoryInAndStatusOrderByCreatedAtDesc(
|
||||
posts = postRepository.findByCategoryInAndStatusOrderByPinnedAtDescLastReplyAtDesc(
|
||||
categories,
|
||||
PostStatus.PUBLISHED
|
||||
PostStatus.PUBLISHED,
|
||||
pageable
|
||||
);
|
||||
}
|
||||
} else {
|
||||
@@ -709,14 +904,15 @@ public class PostService {
|
||||
if (tags.isEmpty()) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
posts = postRepository.findByAllTagsOrderByCreatedAtDesc(
|
||||
posts = postRepository.findByAllTagsOrderByPinnedAtDescLastReplyAtDesc(
|
||||
tags,
|
||||
PostStatus.PUBLISHED,
|
||||
tags.size()
|
||||
tags.size(),
|
||||
pageable
|
||||
);
|
||||
}
|
||||
|
||||
return paginate(sortByPinnedAndLastReply(posts), page, pageSize);
|
||||
return posts;
|
||||
}
|
||||
|
||||
public List<Post> listPostsByCategories(
|
||||
@@ -1002,7 +1198,8 @@ public class PostService {
|
||||
Long categoryId,
|
||||
String title,
|
||||
String content,
|
||||
java.util.List<Long> tagIds
|
||||
List<Long> tagIds,
|
||||
PostVisibleScopeType postVisibleScopeType
|
||||
) {
|
||||
if (tagIds == null || tagIds.isEmpty()) {
|
||||
throw new IllegalArgumentException("At least one tag required");
|
||||
@@ -1034,6 +1231,8 @@ public class PostService {
|
||||
post.setContent(content);
|
||||
post.setCategory(category);
|
||||
post.setTags(new java.util.HashSet<>(tags));
|
||||
PostVisibleScopeType oldVisibleScope = post.getVisibleScope();
|
||||
post.setVisibleScope(postVisibleScopeType);
|
||||
Post updated = postRepository.save(post);
|
||||
imageUploader.adjustReferences(oldContent, content);
|
||||
notificationService.notifyMentions(content, user, updated, null);
|
||||
@@ -1055,6 +1254,14 @@ public class PostService {
|
||||
if (!oldTags.equals(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) {
|
||||
searchIndexEventPublisher.publishPostSaved(updated);
|
||||
}
|
||||
@@ -1203,6 +1410,13 @@ public class PostService {
|
||||
.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) {
|
||||
if (page == null || pageSize == null) {
|
||||
return posts;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.openisle.service;
|
||||
|
||||
import com.openisle.exception.EmailSendException;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
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.HttpMethod;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.scheduling.annotation.Async;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.client.RestClientException;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
@Service
|
||||
@@ -23,7 +25,6 @@ public class ResendEmailSender extends EmailSender {
|
||||
private final RestTemplate restTemplate = new RestTemplate();
|
||||
|
||||
@Override
|
||||
@Async("notificationExecutor")
|
||||
public void sendEmail(String to, String subject, String text) {
|
||||
String url = "https://api.resend.com/emails"; // hypothetical endpoint
|
||||
|
||||
@@ -38,6 +39,20 @@ public class ResendEmailSender extends EmailSender {
|
||||
body.put("from", "openisle <" + fromEmail + ">");
|
||||
|
||||
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
|
||||
*/
|
||||
public void sendVerifyMail(User user, VerifyType verifyType) {
|
||||
// 缓存验证码
|
||||
String code = genCode();
|
||||
String key;
|
||||
String subject;
|
||||
@@ -133,8 +132,9 @@ public class UserService {
|
||||
subject = "请填写验证码以重置密码(有效期为5分钟)";
|
||||
}
|
||||
|
||||
redisTemplate.opsForValue().set(key, code, 5, TimeUnit.MINUTES); // 五分钟后验证码过期
|
||||
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.port=${REDIS_PORT:6379}
|
||||
spring.data.redis.database=${REDIS_DATABASE:0}
|
||||
spring.data.redis.password=${REDIS_PASS: null}
|
||||
|
||||
# for jwt
|
||||
app.jwt.secret=${JWT_SECRET:jwt_sec}
|
||||
|
||||
@@ -20,6 +20,7 @@ CREATE TABLE IF NOT EXISTS `users` (
|
||||
`username` varchar(50) NOT NULL,
|
||||
`verification_code` varchar(255) DEFAULT NULL,
|
||||
`verified` bit(1) DEFAULT NULL,
|
||||
`is_bot` bit(1) NOT NULL DEFAULT b'0',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `UK_users_email` (`email`),
|
||||
UNIQUE KEY `UK_users_username` (`username`)
|
||||
|
||||
@@ -8,10 +8,28 @@ DELETE FROM `users`;
|
||||
|
||||
-- 插入用户,两个普通用户,一个管理员
|
||||
-- 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
|
||||
(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'),
|
||||
(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'),
|
||||
(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');
|
||||
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`,
|
||||
`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
|
||||
(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
|
||||
private MedalService medalService;
|
||||
|
||||
@MockBean
|
||||
private CategoryService categoryService;
|
||||
|
||||
@MockBean
|
||||
private TagService tagService;
|
||||
|
||||
@MockBean
|
||||
private PointService pointService;
|
||||
|
||||
@MockBean
|
||||
private com.openisle.repository.PollVoteRepository pollVoteRepository;
|
||||
|
||||
@@ -117,6 +126,11 @@ class PostControllerTest {
|
||||
isNull(),
|
||||
isNull(),
|
||||
isNull(),
|
||||
isNull(),
|
||||
isNull(),
|
||||
isNull(),
|
||||
isNull(),
|
||||
isNull(),
|
||||
isNull()
|
||||
)
|
||||
).thenReturn(post);
|
||||
@@ -266,6 +280,11 @@ class PostControllerTest {
|
||||
any(),
|
||||
any(),
|
||||
any(),
|
||||
any(),
|
||||
any(),
|
||||
any(),
|
||||
any(),
|
||||
any(),
|
||||
any()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ class PostServiceTest {
|
||||
TagRepository tagRepo = mock(TagRepository.class);
|
||||
LotteryPostRepository lotteryRepo = mock(LotteryPostRepository.class);
|
||||
PollPostRepository pollPostRepo = mock(PollPostRepository.class);
|
||||
CategoryProposalPostRepository proposalRepo = mock(CategoryProposalPostRepository.class);
|
||||
PollVoteRepository pollVoteRepo = mock(PollVoteRepository.class);
|
||||
NotificationService notifService = mock(NotificationService.class);
|
||||
SubscriptionService subService = mock(SubscriptionService.class);
|
||||
@@ -52,6 +53,7 @@ class PostServiceTest {
|
||||
tagRepo,
|
||||
lotteryRepo,
|
||||
pollPostRepo,
|
||||
proposalRepo,
|
||||
pollVoteRepo,
|
||||
notifService,
|
||||
subService,
|
||||
@@ -104,6 +106,7 @@ class PostServiceTest {
|
||||
TagRepository tagRepo = mock(TagRepository.class);
|
||||
LotteryPostRepository lotteryRepo = mock(LotteryPostRepository.class);
|
||||
PollPostRepository pollPostRepo = mock(PollPostRepository.class);
|
||||
CategoryProposalPostRepository proposalRepo = mock(CategoryProposalPostRepository.class);
|
||||
PollVoteRepository pollVoteRepo = mock(PollVoteRepository.class);
|
||||
NotificationService notifService = mock(NotificationService.class);
|
||||
SubscriptionService subService = mock(SubscriptionService.class);
|
||||
@@ -130,6 +133,7 @@ class PostServiceTest {
|
||||
tagRepo,
|
||||
lotteryRepo,
|
||||
pollPostRepo,
|
||||
proposalRepo,
|
||||
pollVoteRepo,
|
||||
notifService,
|
||||
subService,
|
||||
@@ -195,6 +199,7 @@ class PostServiceTest {
|
||||
TagRepository tagRepo = mock(TagRepository.class);
|
||||
LotteryPostRepository lotteryRepo = mock(LotteryPostRepository.class);
|
||||
PollPostRepository pollPostRepo = mock(PollPostRepository.class);
|
||||
CategoryProposalPostRepository proposalRepo = mock(CategoryProposalPostRepository.class);
|
||||
PollVoteRepository pollVoteRepo = mock(PollVoteRepository.class);
|
||||
NotificationService notifService = mock(NotificationService.class);
|
||||
SubscriptionService subService = mock(SubscriptionService.class);
|
||||
@@ -221,6 +226,7 @@ class PostServiceTest {
|
||||
tagRepo,
|
||||
lotteryRepo,
|
||||
pollPostRepo,
|
||||
proposalRepo,
|
||||
pollVoteRepo,
|
||||
notifService,
|
||||
subService,
|
||||
@@ -260,6 +266,11 @@ class PostServiceTest {
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
)
|
||||
);
|
||||
@@ -273,6 +284,7 @@ class PostServiceTest {
|
||||
TagRepository tagRepo = mock(TagRepository.class);
|
||||
LotteryPostRepository lotteryRepo = mock(LotteryPostRepository.class);
|
||||
PollPostRepository pollPostRepo = mock(PollPostRepository.class);
|
||||
CategoryProposalPostRepository proposalRepo = mock(CategoryProposalPostRepository.class);
|
||||
PollVoteRepository pollVoteRepo = mock(PollVoteRepository.class);
|
||||
NotificationService notifService = mock(NotificationService.class);
|
||||
SubscriptionService subService = mock(SubscriptionService.class);
|
||||
@@ -299,6 +311,7 @@ class PostServiceTest {
|
||||
tagRepo,
|
||||
lotteryRepo,
|
||||
pollPostRepo,
|
||||
proposalRepo,
|
||||
pollVoteRepo,
|
||||
notifService,
|
||||
subService,
|
||||
@@ -367,6 +380,7 @@ class PostServiceTest {
|
||||
TagRepository tagRepo = mock(TagRepository.class);
|
||||
LotteryPostRepository lotteryRepo = mock(LotteryPostRepository.class);
|
||||
PollPostRepository pollPostRepo = mock(PollPostRepository.class);
|
||||
CategoryProposalPostRepository proposalRepo = mock(CategoryProposalPostRepository.class);
|
||||
PollVoteRepository pollVoteRepo = mock(PollVoteRepository.class);
|
||||
NotificationService notifService = mock(NotificationService.class);
|
||||
SubscriptionService subService = mock(SubscriptionService.class);
|
||||
@@ -393,6 +407,7 @@ class PostServiceTest {
|
||||
tagRepo,
|
||||
lotteryRepo,
|
||||
pollPostRepo,
|
||||
proposalRepo,
|
||||
pollVoteRepo,
|
||||
notifService,
|
||||
subService,
|
||||
|
||||
@@ -46,3 +46,4 @@ app.avatar.base-url=${AVATAR_BASE_URL:https://api.dicebear.com/6.x}
|
||||
# Web push configuration
|
||||
app.webpush.public-key=${WEBPUSH_PUBLIC_KEY:}
|
||||
app.webpush.private-key=${WEBPUSH_PRIVATE_KEY:}
|
||||
app.snippet-length=${SNIPPET_LENGTH:200}
|
||||
|
||||
68
bots/AGENTS.md
Normal file
68
bots/AGENTS.md
Normal file
@@ -0,0 +1,68 @@
|
||||
# Bots 协作指引
|
||||
|
||||
## 1) 适用范围
|
||||
|
||||
- 作用于 `bots/` 目录及其子目录。
|
||||
- 本文件用于统一 Bot 脚本开发、调度与发布规范。
|
||||
|
||||
## 2) 模块结构与职责
|
||||
|
||||
- `bot_father.ts`:Bot 基类,统一 Agent 初始化、MCP 工具接入、CLI 运行入口。
|
||||
- `instance/reply_bot.ts`:常规互动回复 Bot(提及/评论自动处理)。
|
||||
- `instance/open_source_reply_bot.ts`:开源问答 Bot(偏代码与贡献流程)。
|
||||
- `instance/daily_news_bot.ts`:每日新闻帖 Bot。
|
||||
- `instance/coffee_bot.ts`:早安抽奖帖 Bot。
|
||||
|
||||
## 3) 开发约定(新增/改造 Bot)
|
||||
|
||||
- 新 Bot 统一继承 `BotFather`,最少实现:
|
||||
- `getAdditionalInstructions()`
|
||||
- `getCliQuery()`
|
||||
- 保持导出约定:`export const runWorkflow = ...`,并保留 `if (require.main === module)` CLI 入口。
|
||||
- 不随意改动 `bot_father.ts` 的 MCP 工具白名单;若必须调整,需同步评估 `mcp/` 契约与线上可用性。
|
||||
|
||||
## 4) 环境变量与密钥规范
|
||||
|
||||
- 必需:`OPENAI_API_KEY`(缺失会直接失败)。
|
||||
- 常用:
|
||||
- `OPENISLE_TOKEN`(用于 OpenIsle MCP 鉴权;GitHub Actions 中可映射不同 secret)
|
||||
- `APIFY_API_TOKEN`(天气 MCP 鉴权)
|
||||
- News Bot 可选参数:
|
||||
- `DAILY_NEWS_CATEGORY_ID`
|
||||
- `DAILY_NEWS_TAG_IDS`
|
||||
- 严禁在代码中硬编码真实 token;仅通过 CI secrets 或本地环境变量注入。
|
||||
|
||||
## 5) 工作流同步规则(与 GitHub Actions 对齐)
|
||||
|
||||
- 相关工作流:
|
||||
- `.github/workflows/reply-bots.yml`
|
||||
- `.github/workflows/open_source_reply_bot.yml`
|
||||
- `.github/workflows/news-bot.yml`
|
||||
- `.github/workflows/coffee-bot.yml`
|
||||
- 若改脚本入口、依赖或 env 键名,必须同步更新对应 workflow。
|
||||
- 若改触发节奏(cron)或 Bot 行为边界,需在变更说明中写明影响(频率、成本、风险)。
|
||||
|
||||
## 6) 行为约束(防重复/防失控)
|
||||
|
||||
- 回复类 Bot 需保持幂等:避免对同一上下文重复回复。
|
||||
- 处理未读后应调用 `mark_notifications_read` 清理通知。
|
||||
- 批量处理建议保持上限(当前提示词约定为最多 10 条)。
|
||||
- 发帖类 Bot(news/coffee)必须控制 `create_post` 调用次数(一次任务最多一次发帖)。
|
||||
- Open Source Reply Bot 保持专业技术风格,避免跑题到非开源问答。
|
||||
|
||||
## 7) 本地验证建议
|
||||
|
||||
- 依赖安装(与 CI 一致):
|
||||
- `npm install --no-save @openai/agents tsx typescript`
|
||||
- 单脚本运行示例:
|
||||
- `npx tsx bots/instance/reply_bot.ts`
|
||||
- `npx tsx bots/instance/open_source_reply_bot.ts`
|
||||
- `npx tsx bots/instance/daily_news_bot.ts`
|
||||
- `npx tsx bots/instance/coffee_bot.ts`
|
||||
- 验证时至少确认:可启动、可调用 MCP、异常时退出码非 0。
|
||||
|
||||
## 8) 输出要求
|
||||
|
||||
- 说明改动影响哪个 Bot、哪个 workflow。
|
||||
- 说明是否改变了工具调用边界(MCP tools / 发帖次数 / 回复策略)。
|
||||
- 说明是否需要同步更新文档或运维配置。
|
||||
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
deploy/AGENTS.md
Normal file
40
deploy/AGENTS.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# Deploy 协作指引
|
||||
|
||||
## 1) 适用范围
|
||||
|
||||
- 作用于 `deploy/` 目录及其脚本。
|
||||
- 该目录为高风险变更区,默认保守修改。
|
||||
|
||||
## 2) 当前部署基线
|
||||
|
||||
- 预发:`main` push 触发(见 `.github/workflows/deploy-staging.yml`)。
|
||||
- 正式:定时任务触发(见 `.github/workflows/deploy.yml`)。
|
||||
- 两者使用同一并发锁 `openisle-server`,避免服务器并发部署冲突。
|
||||
|
||||
## 3) 脚本修改原则
|
||||
|
||||
- 保留 `set -euo pipefail` 等安全执行特性。
|
||||
- 变更服务列表或 `docker compose up` 参数时,必须说明影响范围。
|
||||
- 不随意改动 `git fetch/checkout/reset` 逻辑;若改,必须附回滚方案。
|
||||
- 任何“可能中断服务”的改动,都要在说明中给出停机/风险评估。
|
||||
|
||||
## 4) 环境与参数规则
|
||||
|
||||
- 部署依赖根目录 `.env`(由脚本中 `env_file` 与 `ENV_FILE` 传入)。
|
||||
- `COMPOSE_PROJECT_NAME`、`NUXT_ENV`、服务名列表需保持可追踪且与 compose 一致。
|
||||
- 若新增服务,需同步:
|
||||
- `docker/docker-compose.yaml`
|
||||
- 部署脚本中的 build/up 目标
|
||||
- 必要时更新 workflow 说明
|
||||
|
||||
## 5) 验证建议
|
||||
|
||||
- 语法检查:
|
||||
- `bash -n deploy/deploy.sh`
|
||||
- `bash -n deploy/deploy_staging.sh`
|
||||
- 变更前后做一次 `docker compose config` 思维核对(服务与 profile 是否正确)。
|
||||
|
||||
## 6) 输出要求
|
||||
|
||||
- 明确:影响环境(预发/正式)、影响服务、是否可能重建容器。
|
||||
- 必填:回滚路径(例如切回上一 commit 并重新执行部署脚本)。
|
||||
@@ -40,12 +40,12 @@ echo "👉 Build images ..."
|
||||
docker compose -f "$compose_file" --env-file "$env_file" \
|
||||
build --pull \
|
||||
--build-arg NUXT_ENV=production \
|
||||
frontend_service
|
||||
frontend_service mcp
|
||||
|
||||
echo "👉 Recreate & start all target services (no dev profile)..."
|
||||
docker compose -f "$compose_file" --env-file "$env_file" \
|
||||
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:"
|
||||
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
|
||||
|
||||
echo "👉 Build images (staging)..."
|
||||
# 前端 + OpenSearch 都是自建镜像;--pull 更新其基础镜像
|
||||
docker compose -f "$compose_file" --env-file "$env_file" \
|
||||
build --pull \
|
||||
--build-arg NUXT_ENV=staging \
|
||||
frontend_service
|
||||
frontend_service mcp
|
||||
|
||||
echo "👉 Recreate & start all target services (no dev profile)..."
|
||||
docker compose -f "$compose_file" --env-file "$env_file" \
|
||||
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:"
|
||||
docker compose -f "$compose_file" --env-file "$env_file" ps
|
||||
|
||||
@@ -25,56 +25,67 @@ services:
|
||||
timeout: 3s
|
||||
retries: 30
|
||||
start_period: 20s
|
||||
profiles:
|
||||
- dev
|
||||
- dev_local_backend
|
||||
- prod
|
||||
|
||||
# OpenSearch Service
|
||||
opensearch:
|
||||
user: "1000:1000"
|
||||
build:
|
||||
context: .
|
||||
dockerfile: opensearch.Dockerfile
|
||||
container_name: ${COMPOSE_PROJECT_NAME}-opensearch
|
||||
environment:
|
||||
- cluster.name=os-single
|
||||
- node.name=os-node-1
|
||||
- discovery.type=single-node
|
||||
- bootstrap.memory_lock=true
|
||||
- OPENSEARCH_JAVA_OPTS=-Xms1g -Xmx1g
|
||||
- DISABLE_SECURITY_PLUGIN=true
|
||||
- cluster.blocks.create_index=false
|
||||
ulimits:
|
||||
memlock: { soft: -1, hard: -1 }
|
||||
nofile: { soft: 65536, hard: 65536 }
|
||||
volumes:
|
||||
- opensearch-data:/usr/share/opensearch/data
|
||||
- opensearch-snapshots:/snapshots
|
||||
ports:
|
||||
- "${OPENSEARCH_PORT:-9200}:9200"
|
||||
- "${OPENSEARCH_METRICS_PORT:-9600}:9600"
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD-SHELL
|
||||
- curl -fsS http://127.0.0.1:9200/_cluster/health >/dev/null
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 30
|
||||
start_period: 60s
|
||||
networks:
|
||||
- openisle-network
|
||||
# # OpenSearch Service
|
||||
# opensearch:
|
||||
# user: "1000:1000"
|
||||
# build:
|
||||
# context: .
|
||||
# dockerfile: opensearch.Dockerfile
|
||||
# container_name: ${COMPOSE_PROJECT_NAME}-opensearch
|
||||
# environment:
|
||||
# - cluster.name=os-single
|
||||
# - node.name=os-node-1
|
||||
# - discovery.type=single-node
|
||||
# - bootstrap.memory_lock=true
|
||||
# - OPENSEARCH_JAVA_OPTS=-Xms1g -Xmx1g
|
||||
# - DISABLE_SECURITY_PLUGIN=true
|
||||
# - cluster.blocks.create_index=false
|
||||
# ulimits:
|
||||
# memlock: { soft: -1, hard: -1 }
|
||||
# nofile: { soft: 65536, hard: 65536 }
|
||||
# volumes:
|
||||
# - opensearch-data:/usr/share/opensearch/data
|
||||
# - opensearch-snapshots:/snapshots
|
||||
# ports:
|
||||
# - "${OPENSEARCH_PORT:-9200}:9200"
|
||||
# - "${OPENSEARCH_METRICS_PORT:-9600}:9600"
|
||||
# restart: unless-stopped
|
||||
# healthcheck:
|
||||
# test:
|
||||
# - CMD-SHELL
|
||||
# - curl -fsS http://127.0.0.1:9200/_cluster/health >/dev/null
|
||||
# interval: 10s
|
||||
# timeout: 5s
|
||||
# retries: 30
|
||||
# start_period: 60s
|
||||
# networks:
|
||||
# - openisle-network
|
||||
# profiles:
|
||||
# - dev
|
||||
# - dev_local_backend
|
||||
|
||||
dashboards:
|
||||
image: opensearchproject/opensearch-dashboards:3.0.0
|
||||
container_name: ${COMPOSE_PROJECT_NAME}-os-dashboards
|
||||
environment:
|
||||
OPENSEARCH_HOSTS: '["http://opensearch:9200"]'
|
||||
DISABLE_SECURITY_DASHBOARDS_PLUGIN: "true"
|
||||
ports:
|
||||
- "${OPENSEARCH_DASHBOARDS_PORT:-5601}:5601"
|
||||
depends_on:
|
||||
- opensearch
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- openisle-network
|
||||
# dashboards:
|
||||
# image: opensearchproject/opensearch-dashboards:3.0.0
|
||||
# container_name: ${COMPOSE_PROJECT_NAME}-os-dashboards
|
||||
# environment:
|
||||
# OPENSEARCH_HOSTS: '["http://opensearch:9200"]'
|
||||
# DISABLE_SECURITY_DASHBOARDS_PLUGIN: "true"
|
||||
# ports:
|
||||
# - "${OPENSEARCH_DASHBOARDS_PORT:-5601}:5601"
|
||||
# depends_on:
|
||||
# - opensearch
|
||||
# restart: unless-stopped
|
||||
# networks:
|
||||
# - openisle-network
|
||||
# profiles:
|
||||
# - dev
|
||||
# - dev_local_backend
|
||||
# - prod
|
||||
|
||||
rabbitmq:
|
||||
image: rabbitmq:3.13-management
|
||||
@@ -98,6 +109,10 @@ services:
|
||||
start_period: 30s
|
||||
networks:
|
||||
- openisle-network
|
||||
profiles:
|
||||
- dev
|
||||
- dev_local_backend
|
||||
- prod
|
||||
|
||||
redis:
|
||||
image: redis:7
|
||||
@@ -111,6 +126,10 @@ services:
|
||||
- redis-data:/data
|
||||
networks:
|
||||
- openisle-network
|
||||
profiles:
|
||||
- dev
|
||||
- dev_local_backend
|
||||
- prod
|
||||
|
||||
# Java spring boot service (开发便捷镜像,后续可换成打包镜像)
|
||||
springboot:
|
||||
@@ -142,8 +161,8 @@ services:
|
||||
condition: service_started
|
||||
websocket-service:
|
||||
condition: service_healthy
|
||||
opensearch:
|
||||
condition: service_healthy
|
||||
# opensearch:
|
||||
# condition: service_healthy
|
||||
command: >
|
||||
sh -c "apt-get update && apt-get install -y --no-install-recommends curl &&
|
||||
mvn clean spring-boot:run -Dmaven.test.skip=true"
|
||||
@@ -155,6 +174,34 @@ services:
|
||||
start_period: 60s
|
||||
networks:
|
||||
- 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:
|
||||
image: maven:3.9-eclipse-temurin-17
|
||||
@@ -186,6 +233,10 @@ services:
|
||||
start_period: 60s
|
||||
networks:
|
||||
- openisle-network
|
||||
profiles:
|
||||
- dev
|
||||
- dev_local_backend
|
||||
- prod
|
||||
|
||||
frontend_dev:
|
||||
image: node:20
|
||||
@@ -208,6 +259,28 @@ services:
|
||||
- openisle-network
|
||||
profiles:
|
||||
- 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:
|
||||
build:
|
||||
@@ -226,13 +299,13 @@ services:
|
||||
websocket-service:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
profiles: ["staging", "prod"]
|
||||
|
||||
profiles:
|
||||
- prod
|
||||
|
||||
# 监听“frontend_dev 容器自身的” 127.0.0.1:8080 → 转发到 springboot:8080
|
||||
loopback_8080:
|
||||
image: alpine/socat
|
||||
container_name: ${COMPOSE_PROJECT_NAME}-loopback-8080
|
||||
# 监听“frontend_dev 容器自身的” 127.0.0.1:8080 → 转发到 springboot:8080
|
||||
command:
|
||||
- -d
|
||||
- -d
|
||||
@@ -243,13 +316,37 @@ services:
|
||||
springboot:
|
||||
condition: service_healthy
|
||||
network_mode: "service:frontend_dev"
|
||||
profiles: ["dev"]
|
||||
healthcheck:
|
||||
test: ["CMD", "sh", "-c", "nc -z 127.0.0.1 8080"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 20
|
||||
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:
|
||||
image: alpine/socat
|
||||
@@ -265,13 +362,37 @@ services:
|
||||
websocket-service:
|
||||
condition: service_healthy
|
||||
network_mode: "service:frontend_dev"
|
||||
profiles: ["dev"]
|
||||
healthcheck:
|
||||
test: ["CMD", "sh", "-c", "nc -z 127.0.0.1 8082"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 20
|
||||
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:
|
||||
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"]
|
||||
|
||||
40
docs/AGENTS.md
Normal file
40
docs/AGENTS.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# Docs 协作指引
|
||||
|
||||
## 1) 适用范围
|
||||
|
||||
- 作用于 `docs/` 目录及其子目录。
|
||||
- 文档需服务“开发者真实使用”,优先准确性与可执行性。
|
||||
|
||||
## 2) 文档架构
|
||||
|
||||
- 内容目录:`content/docs/`
|
||||
- 生成脚本:`scripts/generate-docs.ts`
|
||||
- OpenAPI 输入配置:`lib/openapi.ts`
|
||||
- 前端框架:Fumadocs + Next.js(Bun 工具链)
|
||||
|
||||
## 3) 编辑规则
|
||||
|
||||
- 优先修正“与代码不一致”的文档,不复制过时描述。
|
||||
- 涉及技术栈说明时,以当前代码为准(例如后端为 JPA/Repository)。
|
||||
- OpenAPI 自动生成目录(`content/docs/openapi/(generated)`)不要手工细改,改源头配置与脚本。
|
||||
- 结构性改动优先维持导航稳定(`meta.json` 与已有 slug)。
|
||||
|
||||
## 4) OpenAPI 同步规则
|
||||
|
||||
- 后端 API 变更后,应重新生成文档页面:
|
||||
- `bun run generate`
|
||||
- 若接口来源地址或文档聚合策略变化,更新:
|
||||
- `lib/openapi.ts`
|
||||
- `scripts/generate-docs.ts`
|
||||
|
||||
## 5) 验证命令
|
||||
|
||||
- 安装依赖:`bun install`
|
||||
- 生成 API 文档:`bun run generate`
|
||||
- 构建校验:`bun run build`
|
||||
- 本地预览:`bun dev`
|
||||
|
||||
## 6) 输出要求
|
||||
|
||||
- 说明更新了哪些文档入口(backend/frontend/openapi)。
|
||||
- 说明是否需要后端先部署后再刷新文档产物。
|
||||
54
frontend_nuxt/AGENTS.md
Normal file
54
frontend_nuxt/AGENTS.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# Frontend(Nuxt)协作指引
|
||||
|
||||
## 1) 适用范围
|
||||
|
||||
- 作用于 `frontend_nuxt/` 目录及其子目录。
|
||||
- 本文件仅覆盖前端范围;跨服务规则仍遵循根 `AGENTS.md`。
|
||||
|
||||
## 2) 代码组织约定
|
||||
|
||||
- `pages/`:路由页面与页面级数据获取。
|
||||
- `components/`:可复用视图组件。
|
||||
- `composables/`:状态与行为复用(如 WebSocket、倒计时)。
|
||||
- `plugins/`:运行时插件(鉴权 fetch、主题、第三方库注入)。
|
||||
- `utils/`:纯工具函数(时间、鉴权 token、平台适配)。
|
||||
- `assets/`、`public/`:静态资源与样式。
|
||||
|
||||
## 3) 前端修改规则
|
||||
|
||||
- 优先保持现有交互和视觉风格一致,不做无关 UI 重构。
|
||||
- 接口字段变更时,先更新调用点,再统一处理回退逻辑与空值分支。
|
||||
- SSR 与客户端代码分离:
|
||||
- 涉及 `window`、`localStorage`、WebSocket 的逻辑只在 client 侧运行。
|
||||
- 与鉴权相关的 401 处理,保持与 `plugins/auth-fetch.client.ts` 行为一致。
|
||||
|
||||
## 4) 环境变量与运行时配置
|
||||
|
||||
- 统一通过 `nuxt.config.ts` 的 `runtimeConfig.public` 读取。
|
||||
- 关键键位保持一致:
|
||||
- `NUXT_PUBLIC_API_BASE_URL`
|
||||
- `NUXT_PUBLIC_WEBSOCKET_URL`
|
||||
- `NUXT_PUBLIC_WEBSITE_BASE_URL`
|
||||
- 变量改动需同步根目录 `.env.example` 与文档说明。
|
||||
|
||||
## 5) 实时消息链路注意事项
|
||||
|
||||
- WebSocket 入口:
|
||||
- `composables/useWebSocket.js`
|
||||
- 若改订阅目标(`/topic/...`、`/user/...`),必须与后端推送目的地保持一致。
|
||||
- 重连与重订阅逻辑不可被破坏;避免引入重复订阅和泄漏。
|
||||
|
||||
## 6) 构建与验证
|
||||
|
||||
- 标准验证:`npm run build`
|
||||
- 本地联调:`npm run dev`
|
||||
- 涉及 WebSocket/通知的改动,建议至少手工验证:
|
||||
- 登录后连接建立
|
||||
- 收到消息时 UI 状态更新
|
||||
- 断线重连后仍可订阅
|
||||
|
||||
## 7) 输出要求
|
||||
|
||||
- 标注影响页面/组件路径。
|
||||
- 标注是否引入 API 字段兼容处理。
|
||||
- 标注是否需要后端或 WebSocket 服务配合发布。
|
||||
@@ -16,6 +16,7 @@
|
||||
<div class="info-content-header-left">
|
||||
<span class="user-name">{{ comment.userName }}</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" />
|
||||
<NuxtLink
|
||||
v-if="comment.medal"
|
||||
@@ -522,6 +523,21 @@ const handleContentClick = (e) => {
|
||||
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 {
|
||||
font-size: 12px;
|
||||
opacity: 0.6;
|
||||
|
||||
@@ -168,9 +168,19 @@ export default {
|
||||
const mobileMenuRef = ref(null)
|
||||
const isMobile = useIsMobile()
|
||||
|
||||
const openMenu = () => {
|
||||
if (!open.value) {
|
||||
open.value = true
|
||||
}
|
||||
}
|
||||
|
||||
const toggle = () => {
|
||||
open.value = !open.value
|
||||
if (!open.value) emit('close')
|
||||
if (open.value) {
|
||||
open.value = false
|
||||
emit('close')
|
||||
} else {
|
||||
open.value = true
|
||||
}
|
||||
}
|
||||
|
||||
const close = () => {
|
||||
@@ -275,7 +285,7 @@ export default {
|
||||
return /^https?:\/\//.test(icon) || icon.startsWith('/')
|
||||
}
|
||||
|
||||
expose({ toggle, close, reload, scrollToBottom })
|
||||
expose({ toggle, close, reload, scrollToBottom, openMenu })
|
||||
|
||||
return {
|
||||
open,
|
||||
@@ -308,7 +318,6 @@ export default {
|
||||
border: 1px solid var(--normal-border-color);
|
||||
border-radius: 5px;
|
||||
padding: 5px 10px;
|
||||
margin-bottom: 4px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -331,6 +340,7 @@ export default {
|
||||
z-index: 10000;
|
||||
max-height: 300px;
|
||||
min-width: 350px;
|
||||
margin-top: 4px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,11 @@
|
||||
|
||||
<ClientOnly>
|
||||
<div class="header-content-right">
|
||||
<SearchDropdown
|
||||
ref="searchDropdown"
|
||||
v-if="!isMobile || showSearch"
|
||||
@close="closeSearch"
|
||||
/>
|
||||
<!-- 搜索 -->
|
||||
<ToolTip v-if="isMobile" content="搜索" placement="bottom">
|
||||
<div class="header-icon-item" @click="search">
|
||||
@@ -106,7 +111,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</ClientOnly>
|
||||
<SearchDropdown ref="searchDropdown" v-if="isMobile && showSearch" @close="closeSearch" />
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
@@ -539,6 +543,7 @@ onMounted(async () => {
|
||||
.header-label {
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 在线人数的数字文字样式(无背景) */
|
||||
|
||||
@@ -3,15 +3,30 @@
|
||||
<div class="login-overlay-blur"></div>
|
||||
<div class="login-overlay-content">
|
||||
<user-icon class="login-overlay-icon" />
|
||||
<div class="login-overlay-text">请先登录,点击跳转到登录页面</div>
|
||||
<div class="login-overlay-button" @click="goLogin">登录</div>
|
||||
<div class="login-overlay-text">{{ props.text }}</div>
|
||||
<div class="login-overlay-button" @click="goLogin">{{ props.buttonText }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
text: {
|
||||
type: String,
|
||||
default: '请先登录,点击跳转到登录页面',
|
||||
},
|
||||
buttonText: {
|
||||
type: String,
|
||||
default: '登录',
|
||||
},
|
||||
buttonLink: {
|
||||
type: String,
|
||||
default: '/login',
|
||||
},
|
||||
})
|
||||
|
||||
const goLogin = () => {
|
||||
navigateTo('/login', { replace: true })
|
||||
navigateTo(props.buttonLink, { replace: true })
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -4,11 +4,7 @@
|
||||
<span class="poll-row-title">投票选项</span>
|
||||
<div class="poll-option-item" v-for="(opt, idx) in data.options" :key="idx">
|
||||
<BaseInput v-model="data.options[idx]" placeholder="选项内容" />
|
||||
<i
|
||||
v-if="data.options.length > 2"
|
||||
class="fa-solid fa-xmark remove-option-icon"
|
||||
@click="removeOption(idx)"
|
||||
></i>
|
||||
<close-icon class="remove-option-icon" @click="removeOption(idx)" />
|
||||
</div>
|
||||
<div class="add-option" @click="addOption">添加选项</div>
|
||||
</div>
|
||||
|
||||
@@ -36,6 +36,10 @@
|
||||
<template v-if="log.newFeatured">将文章设为精选</template>
|
||||
<template v-else>取消精选文章</template>
|
||||
</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
|
||||
>
|
||||
@@ -69,6 +73,17 @@ const props = defineProps({
|
||||
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(() => {
|
||||
// Track theme changes
|
||||
const isDark = import.meta.client && document.documentElement.dataset.theme === 'dark'
|
||||
|
||||
@@ -2,6 +2,30 @@
|
||||
<div class="post-poll-container" v-if="poll">
|
||||
<div class="poll-top-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-for="(opt, idx) in poll.options" :key="idx" class="poll-option-result">
|
||||
<div class="poll-option-info-container">
|
||||
@@ -29,16 +53,6 @@
|
||||
</div>
|
||||
</div>
|
||||
<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">
|
||||
<div
|
||||
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 class="poll-option-hint">
|
||||
<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>
|
||||
@@ -130,6 +139,9 @@ const emit = defineEmits(['refresh'])
|
||||
const loggedIn = computed(() => authState.loggedIn)
|
||||
const showPollResult = ref(false)
|
||||
|
||||
const isProposal = computed(() =>
|
||||
Object.prototype.hasOwnProperty.call(props.poll || {}, 'proposedName'),
|
||||
)
|
||||
const pollParticipants = computed(() => props.poll?.participants || [])
|
||||
const pollOptionParticipants = computed(() => props.poll?.optionParticipants || {})
|
||||
const pollVotes = computed(() => props.poll?.votes || {})
|
||||
@@ -233,6 +245,34 @@ const submitMultiPoll = async () => {
|
||||
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 {
|
||||
color: var(--text-color);
|
||||
padding: 5px 10px;
|
||||
@@ -385,12 +425,20 @@ const submitMultiPoll = async () => {
|
||||
}
|
||||
|
||||
.poll-title-section {
|
||||
display: flex;
|
||||
gap: 30px;
|
||||
flex-direction: row;
|
||||
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 {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
|
||||
@@ -34,6 +34,7 @@ export default {
|
||||
{ id: 'NORMAL', name: '普通帖子', icon: 'file-text' },
|
||||
{ id: 'LOTTERY', name: '抽奖帖子', icon: 'gift' },
|
||||
{ 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
|
||||
class="text-input"
|
||||
v-model="keyword"
|
||||
placeholder="Search"
|
||||
placeholder="键盘点击「/」以触发搜索"
|
||||
ref="searchInput"
|
||||
@input="setSearch(keyword)"
|
||||
/>
|
||||
</div>
|
||||
@@ -48,7 +49,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import Dropdown from '~/components/Dropdown.vue'
|
||||
import { stripMarkdown } from '~/utils/markdown'
|
||||
import { useIsMobile } from '~/utils/screen'
|
||||
@@ -61,8 +62,48 @@ const keyword = ref('')
|
||||
const selected = ref(null)
|
||||
const results = ref([])
|
||||
const dropdown = ref(null)
|
||||
const searchInput = ref(null)
|
||||
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 = () => {
|
||||
dropdown.value.toggle()
|
||||
}
|
||||
@@ -144,8 +185,7 @@ defineExpose({
|
||||
|
||||
<style scoped>
|
||||
.search-dropdown {
|
||||
margin-top: 20px;
|
||||
width: 500px;
|
||||
width: 300px;
|
||||
}
|
||||
|
||||
.search-mobile-trigger {
|
||||
@@ -154,7 +194,7 @@ defineExpose({
|
||||
}
|
||||
|
||||
.search-input {
|
||||
padding: 10px;
|
||||
padding: 2px 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
@@ -202,7 +242,7 @@ defineExpose({
|
||||
}
|
||||
|
||||
.result-body {
|
||||
line-height: 1;
|
||||
line-height: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
@@ -216,4 +256,14 @@ defineExpose({
|
||||
font-size: 12px;
|
||||
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>
|
||||
|
||||
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>
|
||||
<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-item-container">
|
||||
<div
|
||||
@@ -72,8 +67,10 @@
|
||||
<pin v-if="article.pinned" theme="outline" class="pinned-icon" />
|
||||
<gift v-if="article.type === 'LOTTERY'" class="lottery-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" />
|
||||
{{ article.title }}
|
||||
<lock class="preview-close-icon" v-if="article.isRestricted" />
|
||||
</NuxtLink>
|
||||
<NuxtLink class="article-item-description main-item" :to="`/posts/${article.id}`">
|
||||
<div v-html="stripMarkdownWithTiebaMoji(article.description, 500)"></div>
|
||||
@@ -116,7 +113,7 @@
|
||||
</div>
|
||||
<div v-else class="placeholder-container">分类浏览功能开发中,敬请期待。</div>
|
||||
|
||||
<!-- ✅ 通用“底部加载更多”组件(自管 loading/observer/并发) -->
|
||||
<!-- 通用“底部加载更多”组件(自管 loading/observer/并发) -->
|
||||
<InfiniteLoadMore
|
||||
v-if="articles.length > 0"
|
||||
:key="ioKey"
|
||||
@@ -162,6 +159,12 @@ const selectedTags = ref([])
|
||||
const route = useRoute()
|
||||
const tagOptions = ref([])
|
||||
const categoryOptions = ref([])
|
||||
const clearFilters = () => {
|
||||
selectedCategory.value = ''
|
||||
selectedTags.value = []
|
||||
selectedCategoryGlobal.value = ''
|
||||
selectedTagsGlobal.value = []
|
||||
}
|
||||
|
||||
const topics = ref(['最新回复', '最新', '精选', '排行榜' /*, '热门', '类别'*/])
|
||||
const selectedTopicCookie = useCookie('homeTab')
|
||||
@@ -221,8 +224,18 @@ watch(
|
||||
(query) => {
|
||||
const category = query.category
|
||||
const tags = query.tags
|
||||
category && selectedCategorySet(category)
|
||||
tags && selectedTagsSet(tags)
|
||||
if (category) {
|
||||
selectedCategorySet(category)
|
||||
} else {
|
||||
selectedCategory.value = ''
|
||||
selectedCategoryGlobal.value = ''
|
||||
}
|
||||
if (tags) {
|
||||
selectedTagsSet(tags)
|
||||
} else {
|
||||
selectedTags.value = []
|
||||
selectedTagsGlobal.value = []
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
@@ -299,6 +312,7 @@ const {
|
||||
comments: p.commentCount,
|
||||
views: p.views,
|
||||
rssExcluded: p.rssExcluded || false,
|
||||
isRestricted: p.visibleScope === 'ONLY_ME' || p.visibleScope === 'ONLY_REGISTER',
|
||||
time: TimeManager.format(
|
||||
selectedTopic.value === '最新回复' ? p.lastReplyAt || p.createdAt : p.createdAt,
|
||||
),
|
||||
@@ -340,6 +354,7 @@ const fetchNextPage = async () => {
|
||||
members: (p.participants || []).map((m) => ({ id: m.id, avatar: m.avatar })),
|
||||
comments: p.commentCount,
|
||||
views: p.views,
|
||||
isRestricted: p.visibleScope === 'ONLY_ME' || p.visibleScope === 'ONLY_REGISTER',
|
||||
rssExcluded: p.rssExcluded || false,
|
||||
time: TimeManager.format(
|
||||
selectedTopic.value === '最新回复' ? p.lastReplyAt || p.createdAt : p.createdAt,
|
||||
@@ -368,18 +383,23 @@ watch(selectedTopic, (val) => {
|
||||
if (import.meta.server) {
|
||||
await loadOptions()
|
||||
}
|
||||
|
||||
const handleRefreshHome = () => {
|
||||
clearFilters()
|
||||
refreshFirst()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (categoryOptions.value.length === 0 && tagOptions.value.length === 0) loadOptions()
|
||||
window.addEventListener('refresh-home', refreshFirst)
|
||||
window.addEventListener('refresh-home', handleRefreshHome)
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('refresh-home', refreshFirst)
|
||||
window.removeEventListener('refresh-home', handleRefreshHome)
|
||||
})
|
||||
|
||||
/** 供 InfiniteLoadMore 重建用的 key:筛选/Tab 改变即重建内部状态 */
|
||||
const ioKey = computed(() => asyncKey.value.join('::'))
|
||||
|
||||
|
||||
// 页面选项同步到全局状态
|
||||
watch([selectedCategory, selectedTags], ([newCategory, newTags]) => {
|
||||
selectedCategoryGlobal.value = newCategory
|
||||
@@ -544,14 +564,14 @@ watch([selectedCategory, selectedTags], ([newCategory, newTags]) => {
|
||||
.header-item.views {
|
||||
width: 5%;
|
||||
justify-content: flex-end;
|
||||
text-align: right;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.article-time,
|
||||
.header-item.activity {
|
||||
width: 10%;
|
||||
justify-content: flex-end;
|
||||
text-align: left;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.article-item-title {
|
||||
@@ -573,6 +593,7 @@ watch([selectedCategory, selectedTags], ([newCategory, newTags]) => {
|
||||
.pinned-icon,
|
||||
.lottery-icon,
|
||||
.featured-icon,
|
||||
.proposal-icon,
|
||||
.poll-icon {
|
||||
margin-right: 4px;
|
||||
color: var(--primary-color);
|
||||
|
||||
@@ -58,12 +58,15 @@ const submitLogin = async () => {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
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) {
|
||||
setToken(data.token)
|
||||
toast.success('登录成功')
|
||||
registerPush()
|
||||
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') {
|
||||
toast.info('当前邮箱未验证,已经为您重新发送验证码')
|
||||
await navigateTo(
|
||||
@@ -76,10 +79,12 @@ const submitLogin = async () => {
|
||||
} else if (data.reason_code === 'NOT_APPROVED') {
|
||||
await navigateTo({ path: '/signup-reason', query: { token: data.token } }, { replace: true })
|
||||
} 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) {
|
||||
toast.error('登录失败')
|
||||
toast.error(`登录失败: ${e.message}`)
|
||||
} finally {
|
||||
isWaitingForLogin.value = false
|
||||
}
|
||||
|
||||
@@ -75,7 +75,9 @@
|
||||
@click="markRead(item.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>
|
||||
</span>
|
||||
回复了
|
||||
@@ -85,7 +87,9 @@
|
||||
@click="markRead(item.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>
|
||||
</span>
|
||||
</NotificationContainer>
|
||||
@@ -115,7 +119,9 @@
|
||||
@click="markRead(item.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>
|
||||
</span>
|
||||
</NotificationContainer>
|
||||
@@ -162,7 +168,9 @@
|
||||
@click="markRead(item.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>
|
||||
</span>
|
||||
进行了表态
|
||||
@@ -251,6 +259,38 @@
|
||||
已出结果
|
||||
</NotificationContainer>
|
||||
</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'">
|
||||
<NotificationContainer :item="item" :markRead="markRead">
|
||||
您关注的帖子
|
||||
@@ -287,7 +327,9 @@
|
||||
@click="markRead(item.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
|
||||
@@ -295,7 +337,7 @@
|
||||
@click="markRead(item.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>
|
||||
</NotificationContainer>
|
||||
</template>
|
||||
@@ -775,6 +817,10 @@ const formatType = (t) => {
|
||||
return '发布的投票结果已公布'
|
||||
case 'POLL_RESULT_PARTICIPANT':
|
||||
return '参与的投票结果已公布'
|
||||
case 'CATEGORY_PROPOSAL_RESULT_OWNER':
|
||||
return '分类提案结果已公布'
|
||||
case 'CATEGORY_PROPOSAL_RESULT_PARTICIPANT':
|
||||
return '参与的分类提案结果已公布'
|
||||
default:
|
||||
return t
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
<CategorySelect v-model="selectedCategory" />
|
||||
<TagSelect v-model="selectedTags" creatable />
|
||||
<PostTypeSelect v-model="postType" />
|
||||
<PostVisibleScopeSelect v-model="postVisibleScope"/>
|
||||
</div>
|
||||
<div class="post-options-right">
|
||||
<div class="post-clear" @click="clearPost"><clear-icon /> 清空</div>
|
||||
@@ -37,6 +38,7 @@
|
||||
</div>
|
||||
<LotteryForm v-if="postType === 'LOTTERY'" :data="lottery" />
|
||||
<PollForm v-if="postType === 'POLL'" :data="poll" />
|
||||
<ProposalForm v-if="postType === 'PROPOSAL'" :data="proposal" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -50,8 +52,10 @@ import PostTypeSelect from '~/components/PostTypeSelect.vue'
|
||||
import TagSelect from '~/components/TagSelect.vue'
|
||||
import LotteryForm from '~/components/LotteryForm.vue'
|
||||
import PollForm from '~/components/PollForm.vue'
|
||||
import ProposalForm from '~/components/ProposalForm.vue'
|
||||
import { toast } from '~/main'
|
||||
import { authState, getToken } from '~/utils/auth'
|
||||
import PostVisibleScopeSelect from '~/components/PostVisibleScopeSelect.vue'
|
||||
const config = useRuntimeConfig()
|
||||
const API_BASE_URL = config.public.apiBaseUrl
|
||||
|
||||
@@ -60,6 +64,7 @@ const content = ref('')
|
||||
const selectedCategory = ref('')
|
||||
const selectedTags = ref([])
|
||||
const postType = ref('NORMAL')
|
||||
const postVisibleScope = ref('ALL')
|
||||
const lottery = reactive({
|
||||
prizeIcon: '',
|
||||
prizeIconFile: null,
|
||||
@@ -76,6 +81,10 @@ const poll = reactive({
|
||||
endTime: null,
|
||||
multiple: false,
|
||||
})
|
||||
const proposal = reactive({
|
||||
proposedName: '',
|
||||
proposalDescription: '',
|
||||
})
|
||||
const startTime = ref(null)
|
||||
const isWaitingPosting = ref(false)
|
||||
const isAiLoading = ref(false)
|
||||
@@ -94,6 +103,7 @@ const loadDraft = async () => {
|
||||
content.value = data.content || ''
|
||||
selectedCategory.value = data.categoryId || ''
|
||||
selectedTags.value = data.tagIds || []
|
||||
postVisibleScope.value = data.visiblescope
|
||||
|
||||
toast.success('草稿已加载')
|
||||
}
|
||||
@@ -109,6 +119,7 @@ const clearPost = async () => {
|
||||
content.value = ''
|
||||
selectedCategory.value = ''
|
||||
selectedTags.value = []
|
||||
postVisibleScope.value = 'ALL'
|
||||
postType.value = 'NORMAL'
|
||||
lottery.prizeIcon = ''
|
||||
lottery.prizeIconFile = null
|
||||
@@ -123,6 +134,8 @@ const clearPost = async () => {
|
||||
poll.options = ['', '']
|
||||
poll.endTime = null
|
||||
poll.multiple = false
|
||||
proposal.proposedName = ''
|
||||
proposal.proposalDescription = ''
|
||||
|
||||
// 删除草稿
|
||||
const token = getToken()
|
||||
@@ -160,6 +173,7 @@ const saveDraft = async () => {
|
||||
content: content.value,
|
||||
categoryId: selectedCategory.value || null,
|
||||
tagIds,
|
||||
postVisibleScopeType:postVisibleScope.value
|
||||
}),
|
||||
})
|
||||
if (res.ok) {
|
||||
@@ -283,6 +297,12 @@ const submitPost = async () => {
|
||||
return
|
||||
}
|
||||
}
|
||||
if (postType.value === 'PROPOSAL') {
|
||||
if (!proposal.proposedName.trim()) {
|
||||
toast.error('请填写拟议分类名称')
|
||||
return
|
||||
}
|
||||
}
|
||||
try {
|
||||
const token = getToken()
|
||||
await ensureTags(token)
|
||||
@@ -303,36 +323,46 @@ const submitPost = async () => {
|
||||
}
|
||||
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`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
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,
|
||||
}),
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
|
||||
const data = await res.json()
|
||||
if (res.ok) {
|
||||
if (data.reward && data.reward > 0) {
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
<div class="post-options-left">
|
||||
<CategorySelect v-model="selectedCategory" />
|
||||
<TagSelect v-model="selectedTags" creatable />
|
||||
<PostVisibleScopeSelect v-model="selectedVisibleScope"/>
|
||||
</div>
|
||||
<div class="post-options-right">
|
||||
<div class="post-clear" @click="clearPost"><clear-icon /> 清空</div>
|
||||
@@ -44,6 +45,7 @@ import TagSelect from '~/components/TagSelect.vue'
|
||||
import { toast } from '~/main'
|
||||
import { getToken, authState } from '~/utils/auth'
|
||||
import LoginOverlay from '~/components/LoginOverlay.vue'
|
||||
import PostVisibleScopeSelect from '~/components/PostVisibleScopeSelect.vue'
|
||||
const config = useRuntimeConfig()
|
||||
const API_BASE_URL = config.public.apiBaseUrl
|
||||
|
||||
@@ -51,6 +53,7 @@ const title = ref('')
|
||||
const content = ref('')
|
||||
const selectedCategory = ref('')
|
||||
const selectedTags = ref([])
|
||||
const selectedVisibleScope = ref('ALL')
|
||||
const isWaitingPosting = ref(false)
|
||||
const isAiLoading = ref(false)
|
||||
const isLogin = computed(() => authState.loggedIn)
|
||||
@@ -70,6 +73,7 @@ const loadPost = async () => {
|
||||
content.value = data.content || ''
|
||||
selectedCategory.value = data.category.id || ''
|
||||
selectedTags.value = (data.tags || []).map((t) => t.id)
|
||||
selectedVisibleScope.value = data.visibleScope
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error('加载失败')
|
||||
@@ -180,6 +184,7 @@ const submitPost = async () => {
|
||||
content: content.value,
|
||||
categoryId: selectedCategory.value,
|
||||
tagIds: selectedTags.value,
|
||||
postVisibleScopeType:selectedVisibleScope.value
|
||||
}),
|
||||
})
|
||||
const data = await res.json()
|
||||
|
||||
@@ -1,5 +1,22 @@
|
||||
<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">
|
||||
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
||||
</div>
|
||||
@@ -16,7 +33,9 @@
|
||||
<div v-if="status === 'PENDING'" class="article-pending-button">审核中</div>
|
||||
<div v-if="status === 'REJECTED'" class="article-block-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
|
||||
v-if="!closed && loggedIn && !isAuthor && !subscribed"
|
||||
class="article-subscribe-button"
|
||||
@@ -92,7 +111,7 @@
|
||||
></div>
|
||||
|
||||
<div class="article-footer-container">
|
||||
<div class="option-container">
|
||||
<div class="article-option-container">
|
||||
<ReactionsGroup
|
||||
ref="postReactionsGroupRef"
|
||||
v-model="postReactions"
|
||||
@@ -162,28 +181,15 @@
|
||||
<PostChangeLogItem v-else :log="item" :title="title" />
|
||||
</template>
|
||||
</BaseTimeline>
|
||||
<InfiniteLoadMore
|
||||
v-if="timelineItems.length > 0"
|
||||
:key="commentSort"
|
||||
:on-load="loadMoreTimeline"
|
||||
:pause="isLoadingMoreComments || isFetchingComments"
|
||||
/>
|
||||
</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
|
||||
:visible="lightboxVisible"
|
||||
:index="lightboxIndex"
|
||||
@@ -211,6 +217,7 @@ import CommentEditor from '~/components/CommentEditor.vue'
|
||||
import BaseTimeline from '~/components/BaseTimeline.vue'
|
||||
import BasePlaceholder from '~/components/BasePlaceholder.vue'
|
||||
import PostChangeLogItem from '~/components/PostChangeLogItem.vue'
|
||||
import InfiniteLoadMore from '~/components/InfiniteLoadMore.vue'
|
||||
import ArticleTags from '~/components/ArticleTags.vue'
|
||||
import ArticleCategory from '~/components/ArticleCategory.vue'
|
||||
import ReactionsGroup from '~/components/ReactionsGroup.vue'
|
||||
@@ -228,6 +235,7 @@ import { useIsMobile } from '~/utils/screen'
|
||||
import Dropdown from '~/components/Dropdown.vue'
|
||||
import { ClientOnly } from '#components'
|
||||
import { useConfirm } from '~/composables/useConfirm'
|
||||
import { Lock } from '@icon-park/vue-next'
|
||||
const { confirm } = useConfirm()
|
||||
|
||||
const config = useRuntimeConfig()
|
||||
@@ -241,6 +249,13 @@ const author = ref('')
|
||||
const postContent = ref('')
|
||||
const category = 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 postReactionsGroupRef = ref(null)
|
||||
const postLikeCount = computed(
|
||||
@@ -268,6 +283,10 @@ const currentIndex = ref(1)
|
||||
const subscribed = ref(false)
|
||||
const commentSort = ref('NEWEST')
|
||||
const isFetchingComments = ref(false)
|
||||
const commentPage = ref(0)
|
||||
const commentPageSize = 10
|
||||
const hasMoreComments = ref(true)
|
||||
const isLoadingMoreComments = ref(false)
|
||||
const isMobile = useIsMobile()
|
||||
const timelineItems = ref([])
|
||||
|
||||
@@ -369,6 +388,7 @@ const mapComment = (
|
||||
text: c.content,
|
||||
reactions: c.reactions || [],
|
||||
pinned: Boolean(c.pinned ?? c.pinnedAt ?? c.pinned_at),
|
||||
isBot: Boolean(c.author?.bot),
|
||||
reply: (c.replies || []).map((r) =>
|
||||
mapComment(r, c.author.username, c.author.avatar, c.author.id, level + 1),
|
||||
),
|
||||
@@ -408,6 +428,14 @@ const changeLogIcon = (l) => {
|
||||
} else {
|
||||
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') {
|
||||
return 'check-one'
|
||||
} else if (l.type === 'LOTTERY_RESULT') {
|
||||
@@ -438,6 +466,8 @@ const mapChangeLog = (l) => ({
|
||||
newCategory: l.newCategory,
|
||||
oldTags: l.oldTags,
|
||||
newTags: l.newTags,
|
||||
oldVisibleScope: l.oldVisibleScope,
|
||||
newVisibleScope: l.newVisibleScope,
|
||||
amount: l.amount,
|
||||
icon: changeLogIcon(l),
|
||||
})
|
||||
@@ -497,15 +527,27 @@ const onCommentDeleted = (id) => {
|
||||
fetchTimeline()
|
||||
}
|
||||
|
||||
const tokenHeader = computed(() => {
|
||||
const token = getToken()
|
||||
return token ? { Authorization: `Bearer ${token}` } : {}
|
||||
})
|
||||
const {
|
||||
data: postData,
|
||||
pending: pendingPost,
|
||||
error: postError,
|
||||
refresh: refreshPost,
|
||||
} = await useAsyncData(`post-${postId}`, () => $fetch(`${API_BASE_URL}/api/posts/${postId}`), {
|
||||
server: true,
|
||||
lazy: false,
|
||||
})
|
||||
} = await useAsyncData(
|
||||
`post-${postId}`,
|
||||
async () => {
|
||||
try {
|
||||
return await $fetch(`${API_BASE_URL}/api/posts/${postId}`, { headers: tokenHeader.value })
|
||||
} catch (err) {}
|
||||
},
|
||||
{
|
||||
server: true,
|
||||
lazy: false,
|
||||
},
|
||||
)
|
||||
|
||||
// 用 pendingPost 驱动现有 UI(替代 isWaitingFetchingPost 手控)
|
||||
const isWaitingFetchingPost = computed(() => pendingPost.value)
|
||||
@@ -519,6 +561,7 @@ watchEffect(() => {
|
||||
title.value = data.title
|
||||
category.value = data.category
|
||||
tags.value = data.tags || []
|
||||
visibleScope.value = data.visibleScope || 'ALL'
|
||||
postReactions.value = data.reactions || []
|
||||
subscribed.value = !!data.subscribed
|
||||
status.value = data.status
|
||||
@@ -837,17 +880,33 @@ const fetchCommentSorts = () => {
|
||||
])
|
||||
}
|
||||
|
||||
const fetchCommentsAndChangeLog = async () => {
|
||||
isFetchingComments.value = true
|
||||
console.info('Fetching comments and chang log', { postId, sort: commentSort.value })
|
||||
const fetchCommentsAndChangeLog = async ({ pageNo = 0, append = false } = {}) => {
|
||||
if (isLoadingMoreComments.value) return false
|
||||
if (!append) {
|
||||
hasMoreComments.value = true
|
||||
commentPage.value = 0
|
||||
}
|
||||
if (pageNo === 0) {
|
||||
isFetchingComments.value = true
|
||||
} else {
|
||||
isLoadingMoreComments.value = true
|
||||
}
|
||||
console.info('Fetching comments and chang log', {
|
||||
postId,
|
||||
sort: commentSort.value,
|
||||
page: pageNo,
|
||||
pageSize: commentPageSize,
|
||||
})
|
||||
let done = false
|
||||
try {
|
||||
const token = getToken()
|
||||
const res = await fetch(
|
||||
`${API_BASE_URL}/api/posts/${postId}/comments?sort=${commentSort.value}`,
|
||||
{
|
||||
headers: { Authorization: token ? `Bearer ${token}` : '' },
|
||||
},
|
||||
)
|
||||
const url = new URL(`${API_BASE_URL}/api/posts/${postId}/comments`)
|
||||
url.searchParams.set('sort', commentSort.value)
|
||||
url.searchParams.set('page', String(pageNo))
|
||||
url.searchParams.set('pageSize', String(commentPageSize))
|
||||
const res = await fetch(url.toString(), {
|
||||
headers: { Authorization: token ? `Bearer ${token}` : '' },
|
||||
})
|
||||
console.info('Fetch comments response status', res.status)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
@@ -870,23 +929,48 @@ const fetchCommentsAndChangeLog = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
comments.value = commentList
|
||||
changeLogs.value = changeLogList
|
||||
timelineItems.value = newTimelineItemList
|
||||
if (append) {
|
||||
comments.value.push(...commentList)
|
||||
changeLogs.value.push(...changeLogList)
|
||||
timelineItems.value.push(...newTimelineItemList)
|
||||
commentPage.value = pageNo
|
||||
} else {
|
||||
comments.value = commentList
|
||||
changeLogs.value = changeLogList
|
||||
timelineItems.value = newTimelineItemList
|
||||
commentPage.value = 0
|
||||
}
|
||||
|
||||
isFetchingComments.value = false
|
||||
done = data.length < commentPageSize
|
||||
hasMoreComments.value = !done
|
||||
await nextTick()
|
||||
gatherPostItems()
|
||||
return done
|
||||
}
|
||||
} catch (e) {
|
||||
console.debug('Fetch comments error', e)
|
||||
hasMoreComments.value = false
|
||||
return true
|
||||
} finally {
|
||||
isFetchingComments.value = false
|
||||
isLoadingMoreComments.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const fetchTimeline = async () => {
|
||||
await fetchCommentsAndChangeLog()
|
||||
hasMoreComments.value = true
|
||||
commentPage.value = 0
|
||||
comments.value = []
|
||||
changeLogs.value = []
|
||||
timelineItems.value = []
|
||||
await fetchCommentsAndChangeLog({ pageNo: 0, append: false })
|
||||
}
|
||||
|
||||
const loadMoreTimeline = async () => {
|
||||
if (!hasMoreComments.value || isLoadingMoreComments.value) return true
|
||||
const nextPage = commentPage.value + 1
|
||||
const done = await fetchCommentsAndChangeLog({ pageNo: nextPage, append: true })
|
||||
return done || !hasMoreComments.value
|
||||
}
|
||||
|
||||
watch(commentSort, async () => {
|
||||
@@ -897,8 +981,17 @@ const jumpToHashComment = async () => {
|
||||
const hash = location.hash
|
||||
if (hash.startsWith('#comment-')) {
|
||||
const id = hash.substring('#comment-'.length)
|
||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||
const el = document.getElementById('comment-' + id)
|
||||
await new Promise((resolve) => setTimeout(resolve, 300))
|
||||
let el = document.getElementById('comment-' + id)
|
||||
|
||||
// 若未加载到目标评论,尝试继续分页加载直到找到或无更多
|
||||
while (!el && hasMoreComments.value) {
|
||||
const done = await loadMoreTimeline()
|
||||
await nextTick()
|
||||
el = document.getElementById('comment-' + id)
|
||||
if (done) break
|
||||
}
|
||||
|
||||
if (el) {
|
||||
const top = el.getBoundingClientRect().top + window.scrollY - headerHeight - 20 // 20 for beauty
|
||||
window.scrollTo({ top, behavior: 'smooth' })
|
||||
@@ -935,7 +1028,7 @@ onMounted(async () => {
|
||||
<style>
|
||||
.post-page-container {
|
||||
background-color: var(--background-color);
|
||||
display: flex;
|
||||
display: block;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
@@ -948,9 +1041,10 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
.post-page-main-container {
|
||||
position: relative;
|
||||
scrollbar-width: none;
|
||||
padding: 20px;
|
||||
width: calc(85% - 40px);
|
||||
width: calc(100% - 40px);
|
||||
}
|
||||
|
||||
.info-content-text p code {
|
||||
@@ -1002,6 +1096,35 @@ onMounted(async () => {
|
||||
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 {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
@@ -1106,7 +1229,7 @@ onMounted(async () => {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.article-closed-button,
|
||||
.article-gray-button,
|
||||
.article-subscribe-button-text,
|
||||
.article-featured-button,
|
||||
.article-unsubscribe-button-text {
|
||||
@@ -1159,7 +1282,7 @@ onMounted(async () => {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.article-closed-button {
|
||||
.article-gray-button {
|
||||
background-color: var(--background-color);
|
||||
color: gray;
|
||||
border: 1px solid gray;
|
||||
@@ -1286,7 +1409,7 @@ onMounted(async () => {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.option-container {
|
||||
.article-option-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
@@ -1333,14 +1456,80 @@ onMounted(async () => {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.reaction-action.copy-link:hover {
|
||||
background-color: #e2e2e2;
|
||||
}
|
||||
|
||||
.comment-editor-wrapper {
|
||||
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) {
|
||||
.post-page-main-container {
|
||||
width: calc(100% - 20px);
|
||||
|
||||
@@ -139,8 +139,7 @@ const sendVerification = async () => {
|
||||
inviteToken: inviteToken.value,
|
||||
}),
|
||||
})
|
||||
isWaitingForEmailSent.value = false
|
||||
const data = await res.json()
|
||||
const data = await res.json().catch(() => ({}))
|
||||
if (res.ok) {
|
||||
emailStep.value = 1
|
||||
toast.success('验证码已发送,请查看邮箱')
|
||||
@@ -149,10 +148,14 @@ const sendVerification = async () => {
|
||||
if (data.field === 'email') emailError.value = data.error
|
||||
if (data.field === 'password') passwordError.value = data.error
|
||||
} 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) {
|
||||
toast.error('发送失败')
|
||||
toast.error(`发送失败: ${e.message}`)
|
||||
} finally {
|
||||
isWaitingForEmailSent.value = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -191,14 +191,25 @@
|
||||
>
|
||||
评论和回复
|
||||
</div>
|
||||
<div
|
||||
v-if="isMine"
|
||||
:class="['timeline-tab-item', { selected: timelineFilter === 'reads' }]"
|
||||
@click="timelineFilter = 'reads'"
|
||||
>
|
||||
浏览记录
|
||||
</div>
|
||||
</div>
|
||||
<BasePlaceholder
|
||||
v-if="filteredTimelineItems.length === 0"
|
||||
text="暂无时间线"
|
||||
v-if="
|
||||
timelineFilter === 'reads'
|
||||
? readPosts.length === 0
|
||||
: filteredTimelineItems.length === 0
|
||||
"
|
||||
:text="timelineFilter === 'reads' ? '暂无浏览记录' : '暂无时间线'"
|
||||
icon="inbox"
|
||||
/>
|
||||
<div class="timeline-list">
|
||||
<BaseTimeline :items="filteredTimelineItems">
|
||||
<BaseTimeline v-if="timelineFilter !== 'reads'" :items="filteredTimelineItems">
|
||||
<template #item="{ item }">
|
||||
<template v-if="item.type === 'post'">
|
||||
<TimelinePostItem :item="item" />
|
||||
@@ -214,6 +225,11 @@
|
||||
</template>
|
||||
</template>
|
||||
</BaseTimeline>
|
||||
<BaseTimeline v-else :items="readPosts">
|
||||
<template #item="{ item }">
|
||||
<TimelineReadItem :item="item" />
|
||||
</template>
|
||||
</BaseTimeline>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -276,6 +292,7 @@ import BaseTabs from '~/components/BaseTabs.vue'
|
||||
import LevelProgress from '~/components/LevelProgress.vue'
|
||||
import TimelineCommentGroup from '~/components/TimelineCommentGroup.vue'
|
||||
import TimelinePostItem from '~/components/TimelinePostItem.vue'
|
||||
import TimelineReadItem from '~/components/TimelineReadItem.vue'
|
||||
import TimelineTagItem from '~/components/TimelineTagItem.vue'
|
||||
import BaseUserAvatar from '~/components/BaseUserAvatar.vue'
|
||||
import UserList from '~/components/UserList.vue'
|
||||
@@ -299,12 +316,15 @@ const hotReplies = ref([])
|
||||
const hotTags = ref([])
|
||||
const favoritePosts = ref([])
|
||||
const timelineItems = ref([])
|
||||
const readPosts = ref([])
|
||||
const timelineFilter = ref('all')
|
||||
const filteredTimelineItems = computed(() => {
|
||||
if (timelineFilter.value === 'articles') {
|
||||
return timelineItems.value.filter((item) => item.type === 'post')
|
||||
} else if (timelineFilter.value === 'comments') {
|
||||
return timelineItems.value.filter((item) => item.type === 'comment' || item.type === 'reply')
|
||||
} else if (timelineFilter.value === 'reads') {
|
||||
return []
|
||||
}
|
||||
return timelineItems.value
|
||||
})
|
||||
@@ -477,6 +497,27 @@ const fetchTimeline = async () => {
|
||||
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 [followerRes, followingRes] = await Promise.all([
|
||||
fetch(`${API_BASE_URL}/api/users/${username}/followers`),
|
||||
@@ -508,6 +549,12 @@ const loadTimeline = async () => {
|
||||
tabLoading.value = false
|
||||
}
|
||||
|
||||
const loadReadHistory = async () => {
|
||||
tabLoading.value = true
|
||||
await fetchReadHistory()
|
||||
tabLoading.value = false
|
||||
}
|
||||
|
||||
const loadFollow = async () => {
|
||||
tabLoading.value = true
|
||||
await fetchFollowUsers()
|
||||
@@ -624,8 +671,14 @@ onMounted(init)
|
||||
|
||||
watch(selectedTab, async (val) => {
|
||||
// navigateTo({ query: { ...route.query, tab: val } }, { replace: true })
|
||||
if (val === 'timeline' && timelineItems.value.length === 0) {
|
||||
await loadTimeline()
|
||||
if (val === 'timeline') {
|
||||
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) {
|
||||
await loadFollow()
|
||||
} else if (val === 'favorites' && favoritePosts.value.length === 0) {
|
||||
@@ -634,6 +687,23 @@ watch(selectedTab, async (val) => {
|
||||
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>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -81,6 +81,8 @@ import {
|
||||
CheckOne,
|
||||
Share,
|
||||
Financing,
|
||||
Hands,
|
||||
PreviewCloseOne,
|
||||
} from '@icon-park/vue-next'
|
||||
|
||||
export default defineNuxtPlugin((nuxtApp) => {
|
||||
@@ -165,4 +167,6 @@ export default defineNuxtPlugin((nuxtApp) => {
|
||||
nuxtApp.vueApp.component('CheckOne', CheckOne)
|
||||
nuxtApp.vueApp.component('Share', Share)
|
||||
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){
|
||||
console.error(text)
|
||||
if (!text) return ''
|
||||
|
||||
// Markdown 转成纯文本
|
||||
const plain = stripMarkdown(text)
|
||||
console.error(plain)
|
||||
// 替换 :tieba123: 为 <img>
|
||||
const withEmoji = plain.replace(/:tieba(\d+):/g, (match, num) => {
|
||||
const key = `tieba${num}`
|
||||
const file = tiebaEmoji[key]
|
||||
return file
|
||||
? `<img loading="lazy" class="emoji" src="${file}" alt="${key}">`
|
||||
: match // 没有匹配到图片则保留原样
|
||||
})
|
||||
// Markdown 转成纯文本
|
||||
const plain = stripMarkdown(text)
|
||||
// 替换 :tieba123: 为 <img>
|
||||
const withEmoji = plain.replace(/:tieba(\d+):/g, (match, num) => {
|
||||
const key = `tieba${num}`
|
||||
const file = tiebaEmoji[key]
|
||||
return file
|
||||
? `<img loading="lazy" class="emoji" src="${file}" alt="${key}">`
|
||||
: match // 没有匹配到图片则保留原样
|
||||
})
|
||||
|
||||
// 截断纯文本长度(防止撑太长)
|
||||
const truncated = withEmoji.length > length ? withEmoji.slice(0, length) + '...' : withEmoji
|
||||
return truncated
|
||||
// 截断纯文本长度(防止撑太长)
|
||||
const truncated = withEmoji.length > length ? withEmoji.slice(0, length) + '...' : withEmoji
|
||||
return truncated
|
||||
|
||||
}
|
||||
|
||||
@@ -28,6 +28,8 @@ const iconMap = {
|
||||
POLL_VOTE: 'ChartHistogram',
|
||||
POLL_RESULT_OWNER: 'RankingList',
|
||||
POLL_RESULT_PARTICIPANT: 'ChartLine',
|
||||
CATEGORY_PROPOSAL_RESULT_OWNER: 'TagOne',
|
||||
CATEGORY_PROPOSAL_RESULT_PARTICIPANT: 'TagOne',
|
||||
MENTION: 'HashtagKey',
|
||||
POST_DELETED: 'ClearIcon',
|
||||
POST_FEATURED: 'Star',
|
||||
@@ -254,7 +256,9 @@ function createFetchNotifications() {
|
||||
} else if (
|
||||
n.type === 'POLL_VOTE' ||
|
||||
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({
|
||||
...n,
|
||||
|
||||
51
mcp/AGENTS.md
Normal file
51
mcp/AGENTS.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# MCP 服务协作指引
|
||||
|
||||
## 1) 适用范围
|
||||
|
||||
- 作用于 `mcp/` 目录及其子目录。
|
||||
- 本模块对外提供 MCP tools,接口兼容性要求高。
|
||||
|
||||
## 2) 模块结构
|
||||
|
||||
- `src/openisle_mcp/server.py`:Tool 定义与请求处理入口。
|
||||
- `src/openisle_mcp/search_client.py`:调用 OpenIsle 后端 HTTP API。
|
||||
- `src/openisle_mcp/schemas.py`:Pydantic 数据契约。
|
||||
- `src/openisle_mcp/config.py`:运行配置与环境变量读取。
|
||||
|
||||
## 3) 变更原则
|
||||
|
||||
- Tool 名称默认视为稳定契约,非必要不重命名。
|
||||
- 后端接口字段变化时,优先同步 `schemas.py`,再调整 `server.py` 映射。
|
||||
- 对认证接口保持“显式失败”:
|
||||
- 缺 token、401、403 需给出可理解错误信息。
|
||||
- 避免吞掉异常上下文,保留足够定位信息(HTTP 状态、接口语义)。
|
||||
|
||||
## 4) 与后端契约同步
|
||||
|
||||
- 高风险同步点:
|
||||
- `create_post`
|
||||
- `reply_to_post`
|
||||
- `reply_to_comment`
|
||||
- `list_unread_messages`
|
||||
- `mark_notifications_read`
|
||||
- 若后端响应结构改动,需同步:
|
||||
- `search_client.py` 的解析逻辑
|
||||
- `schemas.py` 的校验模型
|
||||
- `README.md` 的 tool 说明(如有新增/删减)
|
||||
|
||||
## 5) 配置规则
|
||||
|
||||
- 环境变量统一使用 `OPENISLE_MCP_*` 前缀。
|
||||
- 保持默认值可本地运行(如 `http://localhost:8080` 场景)。
|
||||
- 不在代码中硬编码私密 token。
|
||||
|
||||
## 6) 验证建议
|
||||
|
||||
- 安装校验:`python -m pip install -e .`
|
||||
- 启动校验:`openisle-mcp`(或项目内等价启动方式)
|
||||
- 如改动 schema/解析逻辑,至少完成一次真实后端联调请求。
|
||||
|
||||
## 7) 输出要求
|
||||
|
||||
- 说明变更是否影响 tool 输入/输出契约。
|
||||
- 说明是否要求调用方更新(参数名、字段、错误语义)。
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user