mirror of
https://github.com/nagisa77/OpenIsle.git
synced 2026-02-06 23:21:16 +08:00
Compare commits
259 Commits
codex/crea
...
codex/upda
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
f17b644a9b | ||
|
|
61f8fa4bb7 | ||
|
|
43929bcdc5 | ||
|
|
6aecb4f583 | ||
|
|
0d2e6a9505 | ||
|
|
b2d70b9bde | ||
|
|
d914579d64 | ||
|
|
8643446d8b | ||
|
|
2db958f8c9 | ||
|
|
fa29d255c9 | ||
|
|
b3fa5e2bef | ||
|
|
a7ef4380d8 | ||
|
|
39d954d98a | ||
|
|
596d1558a2 | ||
|
|
ce04570efb | ||
|
|
215c7077d5 | ||
|
|
a68c925c68 | ||
|
|
4f248e8a71 | ||
|
|
277883f9d9 | ||
|
|
e9e996f291 | ||
|
|
a8667ce5e9 | ||
|
|
0d316af22a | ||
|
|
f8e13af672 | ||
|
|
92d90c997c | ||
|
|
303ec9b6c1 | ||
|
|
90eafe27fd | ||
|
|
98e2ea7ef8 | ||
|
|
e3290f3431 | ||
|
|
160570574c | ||
|
|
cf7b667f30 | ||
|
|
60fa6051b7 | ||
|
|
1c0e90d32d | ||
|
|
a15065575d | ||
|
|
cb958e162e | ||
|
|
660d8ffe51 | ||
|
|
5509a1eead | ||
|
|
1acd776d3b | ||
|
|
53be8d943a | ||
|
|
9957042746 | ||
|
|
302f98f44e | ||
|
|
790c4db8ea | ||
|
|
bbb0a11d49 | ||
|
|
35340319c6 | ||
|
|
343c4d3793 | ||
|
|
87b214cbc0 | ||
|
|
e7f06787d2 | ||
|
|
d7d2fd5dcb | ||
|
|
76b65a1400 | ||
|
|
fa8ee113a2 | ||
|
|
181237adee | ||
|
|
1b8135acfb | ||
|
|
67bbe832a0 | ||
|
|
9d67f7d8d6 | ||
|
|
da0d26c8b5 | ||
|
|
81d64bfc7b | ||
|
|
3e255c1288 | ||
|
|
224e1a1018 | ||
|
|
4456997573 | ||
|
|
ef0f0d013b | ||
|
|
a83ddc40fe | ||
|
|
f36ed28185 | ||
|
|
1d31284dba | ||
|
|
995d68b50b | ||
|
|
55b680ef83 | ||
|
|
024e52b763 | ||
|
|
536979501e | ||
|
|
85a67a6215 | ||
|
|
57a9a98da6 | ||
|
|
e8976a98d4 | ||
|
|
57e6bcaa0c | ||
|
|
c95b2ebdc2 | ||
|
|
83cf7439c9 | ||
|
|
994f4028fc | ||
|
|
2362458024 | ||
|
|
03c92d4861 | ||
|
|
8df566a9c9 | ||
|
|
870d1e2940 | ||
|
|
0033374481 | ||
|
|
8f36422609 | ||
|
|
b98871bed9 | ||
|
|
2cb8c12f65 | ||
|
|
87a256ba0c | ||
|
|
737157e557 | ||
|
|
6f9570dc95 | ||
|
|
12bc405856 | ||
|
|
a2b0cd1a47 | ||
|
|
25a7f1e138 | ||
|
|
a6dd2bfbc2 | ||
|
|
a0ea63700f | ||
|
|
b49e20d010 | ||
|
|
e44443a605 | ||
|
|
0a3bfb9451 | ||
|
|
adfc05b9b2 | ||
|
|
18a6953ff7 | ||
|
|
181ac7bc8f | ||
|
|
9dc9ca9bd8 | ||
|
|
2457efd11d | ||
|
|
b62b9c691f | ||
|
|
180c45bf2d | ||
|
|
263f2deeb1 | ||
|
|
22b813e40b | ||
|
|
d00dbbbd03 | ||
|
|
3b92bdaf2a | ||
|
|
7ce5de7f7c | ||
|
|
28618c7452 | ||
|
|
f8a2ee6ee9 | ||
|
|
ca26b931da | ||
|
|
24fe90cfc6 | ||
|
|
5971700e8a | ||
|
|
f872a32410 | ||
|
|
fffd335ebb | ||
|
|
287d52df10 | ||
|
|
73790d1992 | ||
|
|
3d5cee6e68 | ||
|
|
2f509cc2d8 | ||
|
|
35c503eb6c | ||
|
|
0cf8113691 | ||
|
|
b2a29913aa | ||
|
|
2b6d7c5ab9 | ||
|
|
e9878487e8 | ||
|
|
201af061e4 | ||
|
|
4080f60f60 | ||
|
|
06d76438e8 | ||
|
|
bb955c98ba | ||
|
|
a12368602d | ||
|
|
208c875868 | ||
|
|
39ae8c02cb | ||
|
|
c9854e1840 | ||
|
|
0119605649 | ||
|
|
0d7dc93a67 | ||
|
|
774611f3a8 | ||
|
|
04616a30f3 | ||
|
|
c0ca615439 | ||
|
|
b0597d34b6 | ||
|
|
e3f680ad0f | ||
|
|
c8a1e6d8c8 | ||
|
|
ffebeb46b7 | ||
|
|
2977d2898f | ||
|
|
8869121bcb | ||
|
|
61f6e7c90a | ||
|
|
892aa6a7c6 | ||
|
|
23cc2d1606 | ||
|
|
44addd2a7b | ||
|
|
0bc65077df | ||
|
|
69869348f6 | ||
|
|
4821b77c17 | ||
|
|
3da5d24488 | ||
|
|
76962d6d1c | ||
|
|
4fc7c861ee | ||
|
|
81dfddf6e1 | ||
|
|
8b93aa95cf | ||
|
|
425fc7d2b1 | ||
|
|
0fff73b682 | ||
|
|
e1171212d7 | ||
|
|
e96db5d0d6 | ||
|
|
1083c4241a | ||
|
|
1eeabab41a | ||
|
|
2b5f6f2208 | ||
|
|
bda377336d | ||
|
|
77507f7b18 | ||
|
|
a39f2f7c00 | ||
|
|
229439aa05 | ||
|
|
612881f1b1 | ||
|
|
05c7bc18d7 | ||
|
|
c68c5985f6 | ||
|
|
7d44791011 | ||
|
|
15b992b949 | ||
|
|
4b8229b0a1 | ||
|
|
6e4fbc3c42 | ||
|
|
779264623c | ||
|
|
76aef40de7 | ||
|
|
a1eccb3b1e | ||
|
|
0f75a95dbe | ||
|
|
dc13b2941f | ||
|
|
13c250d392 |
119
.env.example
Normal file
119
.env.example
Normal file
@@ -0,0 +1,119 @@
|
||||
# === Core Service Ports ===
|
||||
SERVER_PORT=8080
|
||||
FRONTEND_PORT=3000
|
||||
WEBSOCKET_PORT=8082
|
||||
OPENISLE_MCP_PORT=8085
|
||||
MYSQL_PORT=3306
|
||||
REDIS_PORT=6379
|
||||
RABBITMQ_PORT=5672
|
||||
RABBITMQ_MANAGEMENT_PORT=15672
|
||||
|
||||
# === OpenSearch Configuration ===
|
||||
OPENSEARCH_PORT=9200
|
||||
OPENSEARCH_METRICS_PORT=9600
|
||||
OPENSEARCH_DASHBOARDS_PORT=5601
|
||||
OPENSEARCH_ENABLED=true
|
||||
OPENSEARCH_SCHEME=http
|
||||
OPENSEARCH_USERNAME=
|
||||
OPENSEARCH_PASSWORD=
|
||||
OPENSEARCH_HOST=opensearch
|
||||
|
||||
# === Database Configuration ===
|
||||
MYSQL_DATABASE=openisle
|
||||
MYSQL_ROOT_PASSWORD=openisle
|
||||
MYSQL_USER=openisle
|
||||
MYSQL_PASSWORD=openisle
|
||||
MYSQL_HOST=mysql
|
||||
|
||||
# === Redis Configuration ===
|
||||
REDIS_HOST=redis
|
||||
REDIS_DATABASE=0
|
||||
|
||||
# === RabbitMQ Configuration ===
|
||||
RABBITMQ_HOST=rabbitmq
|
||||
RABBITMQ_USERNAME=nagisa
|
||||
RABBITMQ_PASSWORD=nagisa
|
||||
|
||||
# === Backend Application Secrets ===
|
||||
JWT_SECRET=change-me-jwt-secret
|
||||
JWT_REASON_SECRET=change-me-jwt-reason-secret
|
||||
JWT_RESET_SECRET=change-me-jwt-reset-secret
|
||||
JWT_INVITE_SECRET=change-me-jwt-invite-secret
|
||||
JWT_EXPIRATION=2592000000
|
||||
PASSWORD_STRENGTH=LOW
|
||||
POST_PUBLISH_MODE=DIRECT
|
||||
REGISTER_MODE=WHITELIST
|
||||
UPLOAD_CHECK_TYPE=true
|
||||
UPLOAD_MAX_SIZE=5242880
|
||||
AVATAR_STYLE=pixel-art-neutral
|
||||
AVATAR_SIZE=128
|
||||
AVATAR_BASE_URL=https://api.dicebear.com/6.x
|
||||
USER_POSTS_LIMIT=10
|
||||
USER_REPLIES_LIMIT=50
|
||||
SNIPPET_LENGTH=200
|
||||
SEARCH_INDEX_PREFIX=openisle
|
||||
SEARCH_HIGHLIGHT_FRAGMENT_SIZE=200
|
||||
SEARCH_REINDEX_ON_STARTUP=true
|
||||
SEARCH_REINDEX_BATCH_SIZE=500
|
||||
CAPTCHA_ENABLED=false
|
||||
RECAPTCHA_SECRET_KEY=
|
||||
CAPTCHA_REGISTER_ENABLED=false
|
||||
CAPTCHA_LOGIN_ENABLED=false
|
||||
CAPTCHA_POST_ENABLED=false
|
||||
CAPTCHA_COMMENT_ENABLED=false
|
||||
RESEND_API_KEY=
|
||||
RESEND_FROM_EMAIL=
|
||||
COS_BASE_URL=https://<你的cos>.cos.accelerate.myqcloud.com
|
||||
COS_SECRET_ID=
|
||||
COS_SECRET_KEY=
|
||||
COS_REGION=ap-guangzhou
|
||||
COS_BUCKET_NAME=
|
||||
GITHUB_CLIENT_SECRET=
|
||||
DISCORD_CLIENT_SECRET=
|
||||
TWITTER_CLIENT_SECRET=
|
||||
TELEGRAM_BOT_TOKEN=
|
||||
OPENAI_API_KEY=
|
||||
OPENAI_MODEL=gpt-4o
|
||||
AI_FORMAT_LIMIT=3
|
||||
WEBSITE_URL=http://localhost:3000
|
||||
WEBPUSH_PUBLIC_KEY=
|
||||
WEBPUSH_PRIVATE_KEY=
|
||||
LOG_LEVEL=INFO
|
||||
|
||||
# === Frontend (Nuxt) ===
|
||||
# 本地开发
|
||||
NUXT_PUBLIC_API_BASE_URL=http://localhost:8080
|
||||
# 线上环境
|
||||
# NUXT_PUBLIC_API_BASE_URL=https://www.open-isle.com
|
||||
# 测试环境
|
||||
# NUXT_PUBLIC_API_BASE_URL=https://www.staging.open-isle.com
|
||||
|
||||
# 本地开发
|
||||
NUXT_PUBLIC_WEBSOCKET_URL=http://localhost:8082
|
||||
# 线上环境
|
||||
# NUXT_PUBLIC_WEBSOCKET_URL=https://www.open-isle.com/websocket
|
||||
# 测试环境
|
||||
# NUXT_PUBLIC_WEBSOCKET_URL=https://www.staging.open-isle.com/websocket
|
||||
|
||||
# 本地开发
|
||||
NUXT_PUBLIC_WEBSITE_BASE_URL=http://localhost:3000
|
||||
# 线上 & 测试 (www.staging.open-isle.com) & 本地均可使用
|
||||
NUXT_PUBLIC_GOOGLE_CLIENT_ID=777830451304-nt8afkkap18gui4f9entcha99unal744.apps.googleusercontent.com
|
||||
# 线上
|
||||
NUXT_PUBLIC_GITHUB_CLIENT_ID=Ov23liVkO1NPAX5JyWxJ
|
||||
# 测试环境 (www.staging.open-isle.com)
|
||||
# NUXT_PUBLIC_GITHUB_CLIENT_ID=Ov23li6GHPxx4MwipWnM
|
||||
# 本地
|
||||
# NUXT_PUBLIC_GITHUB_CLIENT_ID=Ov23liOlrZnPKRF7s7NN
|
||||
|
||||
# 线上 & 本地均可使用
|
||||
NUXT_PUBLIC_DISCORD_CLIENT_ID=1394985417044000779
|
||||
|
||||
# 线上 & 本地均可使用
|
||||
NUXT_PUBLIC_TWITTER_CLIENT_ID=ZTRTU05KSk9KTTJrTTdrVC1tc1E6MTpjaQ
|
||||
|
||||
# 线上
|
||||
NUXT_PUBLIC_TELEGRAM_BOT_ID=8450237135
|
||||
# 测试环境 (www.staging.open-isle.com)
|
||||
# NUXT_PUBLIC_TELEGRAM_BOT_ID=7832207011
|
||||
|
||||
29
.github/workflows/coffee-bot.yml
vendored
Normal file
29
.github/workflows/coffee-bot.yml
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
name: Coffee Bot
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 1 * * *"
|
||||
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 }}
|
||||
run: npx tsx bots/instance/coffee_bot.ts
|
||||
7
.github/workflows/deploy-docs.yml
vendored
7
.github/workflows/deploy-docs.yml
vendored
@@ -11,12 +11,17 @@ on:
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
# 文档发布自己的排队锁,不影响服务器部署
|
||||
concurrency:
|
||||
group: openisle-docs
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build-docs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
|
||||
13
.github/workflows/deploy-staging.yml
vendored
13
.github/workflows/deploy-staging.yml
vendored
@@ -2,28 +2,33 @@ name: Staging CI & CD
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
branches: [ "main" ]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
# 与生产部署共用同一把锁,确保服务器上始终串行(跨工作流也互斥)
|
||||
concurrency:
|
||||
group: openisle-server
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
runs-on: ubuntu-latest
|
||||
environment: Deploy
|
||||
if: ${{ !github.event.repository.fork }} # 只有非 fork 才执行
|
||||
if: ${{ !github.event.repository.fork }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Deploy to Server
|
||||
- name: Deploy to Server (staging)
|
||||
uses: appleboy/ssh-action@v1.0.3
|
||||
with:
|
||||
host: ${{ secrets.SSH_HOST }}
|
||||
username: root
|
||||
key: ${{ secrets.SSH_KEY }}
|
||||
script: bash /opt/openisle/deploy-staging.sh
|
||||
script: bash /opt/openisle/OpenIsle/deploy/deploy_staging.sh
|
||||
|
||||
deploy-docs:
|
||||
needs: build-and-deploy
|
||||
|
||||
11
.github/workflows/deploy.yml
vendored
11
.github/workflows/deploy.yml
vendored
@@ -3,7 +3,12 @@ name: CI & CD
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: "0 19 * * *" # 每天 UTC 19:00,相当于北京时间凌晨3点
|
||||
- cron: "0 19 * * *" # 每天 UTC 19:00(北京 03:00)
|
||||
|
||||
# 与 Staging 共用同一把锁,避免两边同时在 8G 服务器上跑
|
||||
concurrency:
|
||||
group: openisle-server
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
@@ -13,10 +18,10 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Deploy to Server
|
||||
- name: Deploy to Server (prod)
|
||||
uses: appleboy/ssh-action@v1.0.3
|
||||
with:
|
||||
host: ${{ secrets.SSH_HOST }}
|
||||
username: root
|
||||
key: ${{ secrets.SSH_KEY }}
|
||||
script: bash /opt/openisle/deploy.sh
|
||||
script: bash /opt/openisle/OpenIsle/deploy/deploy.sh
|
||||
|
||||
29
.github/workflows/reply-bots.yml
vendored
Normal file
29
.github/workflows/reply-bots.yml
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
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 }}
|
||||
run: npx tsx bots/instance/reply_bot.ts
|
||||
330
CONTRIBUTING.md
330
CONTRIBUTING.md
@@ -1,25 +1,20 @@
|
||||
- [前置工作](#前置工作)
|
||||
- [前端极速调试(Docker 全量环境)](#前端极速调试docker-全量环境)
|
||||
- [dev 与 dev_local_backend 巡航指南](#dev-dev_local_backend-guide)
|
||||
- [启动后端服务](#启动后端服务)
|
||||
- [本地 IDEA](#本地-idea)
|
||||
- [配置环境变量](#配置环境变量)
|
||||
- [配置 IDEA 参数](#配置-idea-参数)
|
||||
- [配置 MySQL](#配置-mysql)
|
||||
- [配置 Redis](#配置-redis)
|
||||
- [配置 RabbitMQ](#配置-rabbitmq)
|
||||
- [Docker 环境](#docker-环境)
|
||||
- [配置环境变量](#配置环境变量-1)
|
||||
- [构建并启动镜像](#构建并启动镜像)
|
||||
- [启动前端服务](#启动前端服务)
|
||||
- [配置环境变量](#配置环境变量-2)
|
||||
- [安装依赖和运行](#安装依赖和运行)
|
||||
- [连接预发或正式环境](#连接预发或正式环境)
|
||||
- [其他配置](#其他配置)
|
||||
- [配置第三方登录以GitHub为例](#配置第三方登录以GitHub为例)
|
||||
- [配置Resend邮箱服务](#配置Resend邮箱服务)
|
||||
- [配置第三方登录以GitHub为例](#配置第三方登录以github为例)
|
||||
- [配置Resend邮箱服务](#配置resend邮箱服务)
|
||||
- [API文档](#api文档)
|
||||
- [OpenAPI文档](#openapi文档)
|
||||
- [部署时间线以及文档时效性](#部署时间线以及文档时效性)
|
||||
- [OpenAPI文档使用](#OpenAPI文档使用)
|
||||
- [OpenAPI文档应用场景](#OpenAPI文档应用场景)
|
||||
- [OpenAPI文档使用](#openapi文档使用)
|
||||
- [OpenAPI文档应用场景](#openapi文档应用场景)
|
||||
|
||||
## 前置工作
|
||||
|
||||
@@ -35,6 +30,86 @@ cd OpenIsle
|
||||
- 前端开发环境
|
||||
- Node.JS 20+
|
||||
|
||||
## 前端极速调试(Docker 全量环境)
|
||||
|
||||
想要最快速地同时体验前端和后端,可直接使用仓库提供的 Docker Compose。该方案会一次性拉起数据库、消息队列、搜索、后端、WebSocket 以及前端 Dev Server,适合需要全链路联调的场景。
|
||||
|
||||
1. 准备环境变量文件:
|
||||
```shell
|
||||
cp .env.example .env
|
||||
```
|
||||
`.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 up -d
|
||||
```
|
||||
该命令会创建名为 `frontend_dev` 的容器并运行 `npm run dev`,浏览器访问 http://127.0.0.1:3000 即可查看页面。
|
||||
修改前端代码,页面会热更新。
|
||||
如果修改后端代码,可以重启后端容器, 或是环境变量中指向IDEA,采用IDEA编译运行也可以哦。
|
||||
|
||||
```shell
|
||||
docker compose \
|
||||
-f docker/docker-compose.yaml \
|
||||
--env-file .env \
|
||||
--profile dev up -d --force-recreate
|
||||
```
|
||||
|
||||
3. 查看服务状态:
|
||||
```shell
|
||||
docker compose -f docker/docker-compose.yaml --env-file .env ps
|
||||
docker compose -f docker/docker-compose.yaml --env-file .env logs -f frontend_dev
|
||||
```
|
||||
4. 停止所有容器:
|
||||
```shell
|
||||
docker compose -f docker/docker-compose.yaml --env-file .env --profile dev down
|
||||
```
|
||||
|
||||
5. 开发时若需要**重置所有容器及其挂载的数据卷**,可以执行:
|
||||
```shell
|
||||
docker compose -f docker/docker-compose.yaml --env-file .env --profile dev down -v
|
||||
```
|
||||
`-v` 参数会在关闭容器的同时移除通过 `volumes` 声明的挂载卷,适用于希望清理数据库、缓存等持久化数据,确保下一次启动时获得全新环境的场景。
|
||||
|
||||
如需自定义 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` 进行自检。
|
||||
|
||||
## 启动后端服务
|
||||
|
||||
启动后端服务有多种方式,选择一种即可。
|
||||
@@ -52,37 +127,37 @@ IDEA 打开 `backend/` 文件夹。
|
||||
|
||||
#### 配置环境变量
|
||||
|
||||
1. 生成环境变量文件
|
||||
|
||||
1. 生成环境变量文件:
|
||||
```shell
|
||||
cp open-isle.env.example open-isle.env
|
||||
```
|
||||
`open-isle.env` 才是实际被读取的文件。可在其中补充数据库、第三方服务等配置,`open-isle.env` 已被 Git 忽略,放心修改。
|
||||
2. 在 IDEA 中配置「Environment file」:将 `Run/Debug Configuration` 的 `Environment variables` 指向刚刚复制的 `open-isle.env`,即可让 IDE 读取该文件。
|
||||
3. 需要调整端口或功能开关时,优先修改 `open-isle.env`,例如:
|
||||
```ini
|
||||
SERVER_PORT=8081
|
||||
LOG_LEVEL=DEBUG
|
||||
```
|
||||
|
||||
`open-isle.env.example` 是环境变量模板,`open-isle.env` 才是真正读取的内容
|
||||
> [!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 将尝试解析容器网络内的别名而导致连接失败。
|
||||
|
||||
2. 修改环境变量,留下需要的,比如你要开发 Google 登录业务,就需要谷歌相关的变量,数据库是一定要的
|
||||
|
||||

|
||||
|
||||
3. 应用环境文件,选择刚刚的 `open-isle.env`
|
||||
|
||||
可以在 `open-isle.env` 按需填写个性化的配置,该文件不会被 Git 追踪。比如你想把服务跑在 `8082`(默认为 `8080`),那么直接改 `open-isle.env` 即可:
|
||||
|
||||
```ini
|
||||
SERVER_PORT=8082
|
||||
```
|
||||
|
||||
另一种方式是修改 `.properities` 文件(但不建议),位于 `src/main/application.properties`,该配置同样来源于 `open-isle.env`,但修改 `.properties` 文件会被 Git 追踪。
|
||||
也可以修改 `src/main/resources/application.properties`,但该文件会被 Git 追踪,通常不推荐。
|
||||
|
||||

|
||||
|
||||
#### 配置 IDEA 参数
|
||||
|
||||
- 设置 JDK 版本为 java 17
|
||||
|
||||
- 设置 VM Option,最好运行在其他端口,非 `8080`,这里设置 `8081`
|
||||
若上面在环境变量中设置了端口,那这里就不需要再额外设置
|
||||
|
||||
- 设置 JDK 版本为 Java 17。
|
||||
- 设置 VM Option,最好运行在其他端口(例如 `8081`)。若已经在 `open-isle.env` 中调整端口,可省略此步骤。
|
||||
```shell
|
||||
-Dserver.port=8081
|
||||
```
|
||||
@@ -91,191 +166,22 @@ SERVER_PORT=8082
|
||||
|
||||

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

|
||||
|
||||
```ini
|
||||
MYSQL_URL=jdbc:mysql://<数据库地址>:<端口>/<数据库名>?useUnicode=yes&characterEncoding=UTF-8&useInformationSchema=true&useSSL=false&serverTimezone=UTC
|
||||
MYSQL_USER=<数据库用户名>
|
||||
MYSQL_PASSWORD=<数据库密码>
|
||||
```
|
||||
|
||||
3. 执行 [`db/init/init_script.sql`](backend/src/main/resources/db/init/init_script.sql) 脚本,导入基本的数据
|
||||
管理员:**admin/123456**
|
||||
普通用户1:**user1/123456**
|
||||
普通用户2:**user2/123456**
|
||||
|
||||

|
||||
|
||||
#### 配置 Redis
|
||||
|
||||
后端的登录态缓存、访问频控等都依赖 Redis,请确保本地有可用的 Redis 实例。
|
||||
|
||||
1. **启动 Redis 服务**(已有服务可跳过)
|
||||
|
||||
```bash
|
||||
docker run --name openisle-redis -p 6379:6379 -d redis:7-alpine
|
||||
```
|
||||
|
||||
该命令会在本机暴露 `6379` 端口。若你已有其他端口的 Redis,可以根据实际情况调整映射关系。
|
||||
|
||||
2. **在 `backend/open-isle.env` 中填写连接信息**
|
||||
|
||||
```ini
|
||||
REDIS_HOST=127.0.0.1
|
||||
REDIS_PORT=6379
|
||||
# 可选:若需要切换逻辑库,可新增此变量,默认使用 0 号库
|
||||
REDIS_DATABASE=0
|
||||
```
|
||||
|
||||
`application.properties` 中的默认值为 `localhost:6379`、数据库 `0`,如果你的环境恰好一致,也可以不额外填写;显式声明可以避免 IDE/运行时读取到意外配置。
|
||||
|
||||
3. **验证连接**
|
||||
|
||||
```bash
|
||||
redis-cli -h 127.0.0.1 -p 6379 ping
|
||||
```
|
||||
|
||||
启动后端后,日志中会出现 `Redis connection established ...`(来自 `RedisConnectionLogger`),说明已成功连通。
|
||||
|
||||
#### 配置 RabbitMQ
|
||||
|
||||
消息通知和 WebSocket 推送链路依赖 RabbitMQ。后端会自动声明交换机与队列,确保本地 RabbitMQ 可用即可。
|
||||
|
||||
1. **启动 RabbitMQ 服务**(推荐包含管理界面)
|
||||
|
||||
```bash
|
||||
docker run --name openisle-rabbitmq \
|
||||
-e RABBITMQ_DEFAULT_USER=openisle \
|
||||
-e RABBITMQ_DEFAULT_PASS=openisle \
|
||||
-p 5672:5672 -p 15672:15672 \
|
||||
-d rabbitmq:3.13-management
|
||||
```
|
||||
|
||||
管理界面位于 http://127.0.0.1:15672 ,可用于查看队列、交换机等资源。
|
||||
|
||||
2. **同步填写后端与 WebSocket 服务的环境变量**
|
||||
|
||||
```ini
|
||||
# backend/open-isle.env
|
||||
RABBITMQ_HOST=127.0.0.1
|
||||
RABBITMQ_PORT=5672
|
||||
RABBITMQ_USERNAME=openisle
|
||||
RABBITMQ_PASSWORD=openisle
|
||||
|
||||
# 如果需要启动 websocket_service,也需要在 websocket_service.env 中保持一致
|
||||
```
|
||||
|
||||
如果沿用 RabbitMQ 默认的 `guest/guest`,可以不显式设置,Spring Boot 会回退到 `application.properties` 中的默认值 (`localhost:5672`、`guest/guest`、虚拟主机 `/`)。
|
||||
|
||||
3. **确认自动声明的资源**
|
||||
|
||||
- 交换机:`openisle-exchange`
|
||||
- 旧版兼容队列:`notifications-queue`
|
||||
- 分片队列:`notifications-queue-0` ~ `notifications-queue-f`(共 16 个,对应路由键 `notifications.shard.0` ~ `notifications.shard.f`)
|
||||
- 队列持久化默认开启,来自 `rabbitmq.queue.durable=true`,如需仅在本地短暂测试,可在 `application.properties` 中调整该配置。
|
||||
|
||||
启动后端时可在日志中看到 `=== 开始主动声明 RabbitMQ 组件 ===` 与后续的声明结果,也可以在管理界面中查看是否创建成功。
|
||||
|
||||
完成 Redis 与 RabbitMQ 配置后,即可继续启动后端服务。
|
||||
完成环境变量和运行参数设置后,即可启动 Spring Boot 应用。
|
||||
|
||||

|
||||
|
||||
### Docker 环境
|
||||
## 前端连接预发或正式环境
|
||||
|
||||
#### 配置环境变量
|
||||
前端默认读取 `.env` 中的接口地址,可通过修改以下变量快速切换到预发或正式环境:
|
||||
|
||||
```shell
|
||||
cd docker/
|
||||
```
|
||||
1. 按需覆盖关键变量:
|
||||
|
||||
主要配置两个 `.env` 文件
|
||||
```ini
|
||||
NUXT_PUBLIC_API_BASE_URL=https://www.staging.open-isle.com
|
||||
NUXT_PUBLIC_WEBSOCKET_URL=https://www.staging.open-isle.com
|
||||
```
|
||||
将 `staging` 替换为 `www` 即可连接正式环境。其他变量(如 OAuth Client ID、站点地址等)可根据需求调整。
|
||||
|
||||
- `backend/open-isle.env`:后端环境变量,配置同上,见 [配置环境变量](#配置环境变量)。
|
||||
- `docker/.env`:Docker Compose 环境变量,主要配置 MySQL 相关
|
||||
```shell
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
> [!TIP]
|
||||
> 使用单独的 `.env` 文件是为了兼容线上环境或已启用 MySQL 服务的情况,如果只是想快速体验或者启动统一的环境,则推荐使用本方式。
|
||||
|
||||
在指定 `docker/.env` 后,`backend/open-isle.env` 中以下配置会被覆盖,这样就确保使用了同一份配置。
|
||||
|
||||
```ini
|
||||
MYSQL_URL=
|
||||
MYSQL_USER=
|
||||
MYSQL_PASSWORD=
|
||||
```
|
||||
|
||||
#### 构建并启动镜像
|
||||
|
||||
```shell
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
如果想了解启动过程发生了什么可以查看日志
|
||||
|
||||
```shell
|
||||
docker compose logs
|
||||
```
|
||||
|
||||
## 启动前端服务
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **⚠️ 环境要求:Node.js 版本最低 20.0.0(因为 Nuxt 框架要求)**
|
||||
|
||||
```shell
|
||||
cd frontend_nuxt/
|
||||
```
|
||||
|
||||
### 配置环境变量
|
||||
|
||||
前端可以依赖本机部署的后端,也可以直接调用线上的后端接口。
|
||||
|
||||
- 利用预发环境:**(⚠️ 强烈推荐只开发前端的朋友使用该环境)**
|
||||
|
||||
```shell
|
||||
cp .env.staging.example .env
|
||||
```
|
||||
|
||||
- 利用生产环境
|
||||
|
||||
```shell
|
||||
cp .env.production.example .env
|
||||
```
|
||||
|
||||
- 利用本地环境
|
||||
|
||||
```shell
|
||||
cp .env.dev.example .env
|
||||
```
|
||||
|
||||
若依赖本机部署的后端,需要修改 `.env` 中的 `NUXT_PUBLIC_API_BASE_URL` 值与后端服务端口一致
|
||||
|
||||
### 安装依赖和运行
|
||||
|
||||
前端安装依赖并启动服务。
|
||||
|
||||
```shell
|
||||
# 安装依赖
|
||||
npm install --verbose
|
||||
|
||||
# 运行前端服务
|
||||
npm run dev
|
||||
```
|
||||
|
||||
如此一来,浏览器访问 http://127.0.0.1:3000 即可访问前端页面。
|
||||
|
||||
## 其他配置
|
||||
|
||||
@@ -334,7 +240,7 @@ https://docs.open-isle.com
|
||||
|
||||
### OpenAPI文档使用
|
||||
|
||||
- 预发环境/正式环境切换,可以通过如下位置切换API环境
|
||||
- 预发环境/正式环境切换,以通过如下位置切换API环境
|
||||
|
||||

|
||||
|
||||
|
||||
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!
|
||||
@@ -1,3 +1,6 @@
|
||||
# 所有环境变量已集中在仓库根目录的 .env.*.example 文件。
|
||||
# 此文件保留作参考用途,如需在 Docker 之外手动配置,可按需复制。
|
||||
|
||||
# === Spring Boot ===
|
||||
SERVER_PORT=8080
|
||||
|
||||
@@ -16,6 +19,7 @@ JWT_EXPIRATION=2592000000
|
||||
# === Redis ===
|
||||
REDIS_HOST=<Redis 地址>
|
||||
REDIS_PORT=<Redis 端口>
|
||||
REDIS_PASS=<Redis 密码>
|
||||
|
||||
# === Resend ===
|
||||
RESEND_API_KEY=<你的resend-api-key>
|
||||
|
||||
@@ -132,6 +132,23 @@
|
||||
<artifactId>springdoc-openapi-starter-webmvc-api</artifactId>
|
||||
<version>2.2.0</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-actuator</artifactId>
|
||||
</dependency>
|
||||
<!-- 高阶 Java 客户端 -->
|
||||
<dependency>
|
||||
<groupId>org.opensearch.client</groupId>
|
||||
<artifactId>opensearch-java</artifactId>
|
||||
<version>3.2.0</version>
|
||||
</dependency>
|
||||
|
||||
<!-- 低阶 RestClient,提供 org.opensearch.client.RestClient 给你的 RestClientTransport 用 -->
|
||||
<dependency>
|
||||
<groupId>org.opensearch.client</groupId>
|
||||
<artifactId>opensearch-rest-client</artifactId>
|
||||
<version>3.2.0</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
|
||||
@@ -97,6 +97,8 @@ public class SecurityConfig {
|
||||
"http://localhost:8081",
|
||||
"http://localhost:8082",
|
||||
"http://localhost:3000",
|
||||
"http://frontend_dev:3000",
|
||||
"http://frontend_service:3000",
|
||||
"http://localhost:3001",
|
||||
"http://localhost",
|
||||
"http://30.211.97.238:3000",
|
||||
@@ -177,6 +179,8 @@ public class SecurityConfig {
|
||||
.permitAll()
|
||||
.requestMatchers(HttpMethod.POST, "/api/point-goods")
|
||||
.permitAll()
|
||||
.requestMatchers("/actuator/**")
|
||||
.permitAll()
|
||||
.requestMatchers(HttpMethod.POST, "/api/categories/**")
|
||||
.hasAuthority("ADMIN")
|
||||
.requestMatchers(HttpMethod.POST, "/api/tags/**")
|
||||
@@ -230,6 +234,7 @@ public class SecurityConfig {
|
||||
uri.startsWith("/api/channels") ||
|
||||
uri.startsWith("/api/sitemap.xml") ||
|
||||
uri.startsWith("/api/medals") ||
|
||||
uri.startsWith("/actuator") ||
|
||||
uri.startsWith("/api/rss"));
|
||||
|
||||
if (authHeader != null && authHeader.startsWith("Bearer ")) {
|
||||
|
||||
@@ -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.*;
|
||||
@@ -15,6 +17,7 @@ import io.swagger.v3.oas.annotations.media.Content;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
@@ -39,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;
|
||||
@@ -131,6 +135,7 @@ public class CommentController {
|
||||
c.getId(),
|
||||
"comment",
|
||||
c.getCreatedAt(),
|
||||
c.getPinnedAt(),
|
||||
c // payload 是 CommentDto
|
||||
)
|
||||
)
|
||||
@@ -145,21 +150,74 @@ public class CommentController {
|
||||
l.getId(),
|
||||
"log",
|
||||
l.getTime(), // 注意字段名不一样
|
||||
null,
|
||||
l // payload 是 PostChangeLogDto
|
||||
)
|
||||
)
|
||||
.toList()
|
||||
);
|
||||
// 排序
|
||||
Comparator<TimelineItemDto<?>> comparator = Comparator.comparing(TimelineItemDto::getCreatedAt);
|
||||
Comparator<TimelineItemDto<?>> pinnedOrderComparator = (a, b) -> {
|
||||
LocalDateTime aPinned = a.getPinnedAt();
|
||||
LocalDateTime bPinned = b.getPinnedAt();
|
||||
if (aPinned == null && bPinned == null) {
|
||||
return 0;
|
||||
}
|
||||
if (aPinned == null) {
|
||||
return 1;
|
||||
}
|
||||
if (bPinned == null) {
|
||||
return -1;
|
||||
}
|
||||
return bPinned.compareTo(aPinned);
|
||||
};
|
||||
|
||||
Comparator<TimelineItemDto<?>> comparator = Comparator.<TimelineItemDto<?>, Boolean>comparing(
|
||||
item -> item.getPinnedAt() == null
|
||||
).thenComparing(pinnedOrderComparator);
|
||||
|
||||
Comparator<TimelineItemDto<?>> createdAtComparator = Comparator.comparing(
|
||||
TimelineItemDto::getCreatedAt
|
||||
);
|
||||
if (CommentSort.NEWEST.equals(sort)) {
|
||||
comparator = comparator.reversed();
|
||||
createdAtComparator = createdAtComparator.reversed();
|
||||
}
|
||||
itemDtoList.sort(comparator);
|
||||
itemDtoList.sort(comparator.thenComparing(createdAtComparator));
|
||||
log.debug("listComments returning {} comments", itemDtoList.size());
|
||||
return itemDtoList;
|
||||
}
|
||||
|
||||
@GetMapping("/comments/{commentId}/context")
|
||||
@Operation(
|
||||
summary = "Comment context",
|
||||
description = "Get a comment along with its previous comments and related post"
|
||||
)
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "Comment context",
|
||||
content = @Content(schema = @Schema(implementation = CommentContextDto.class))
|
||||
)
|
||||
public ResponseEntity<CommentContextDto> getCommentContext(@PathVariable Long commentId) {
|
||||
log.debug("getCommentContext called for comment {}", commentId);
|
||||
Comment comment = commentService.getComment(commentId);
|
||||
CommentContextDto dto = new CommentContextDto();
|
||||
dto.setPost(postMapper.toSummaryDto(comment.getPost()));
|
||||
dto.setTargetComment(commentMapper.toDtoWithReplies(comment));
|
||||
dto.setPreviousComments(
|
||||
commentService
|
||||
.getCommentsBefore(comment)
|
||||
.stream()
|
||||
.map(commentMapper::toDtoWithReplies)
|
||||
.collect(Collectors.toList())
|
||||
);
|
||||
log.debug(
|
||||
"getCommentContext returning {} previous comments for comment {}",
|
||||
dto.getPreviousComments().size(),
|
||||
commentId
|
||||
);
|
||||
return ResponseEntity.ok(dto);
|
||||
}
|
||||
|
||||
@DeleteMapping("/comments/{id}")
|
||||
@Operation(summary = "Delete comment", description = "Delete a comment")
|
||||
@ApiResponse(responseCode = "200", description = "Deleted")
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
@@ -220,6 +224,26 @@ public class PostController {
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@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
|
||||
.listRecentPosts(minutes)
|
||||
.stream()
|
||||
.map(postMapper::toSummaryDto)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@GetMapping("/ranking")
|
||||
@Operation(summary = "Ranking posts", description = "List posts by view rankings")
|
||||
@ApiResponse(
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.openisle.controller;
|
||||
|
||||
import com.openisle.dto.DonationRequest;
|
||||
import com.openisle.dto.DonationResponse;
|
||||
import com.openisle.service.PointService;
|
||||
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 org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/posts/{postId}/donations")
|
||||
@RequiredArgsConstructor
|
||||
public class PostDonationController {
|
||||
|
||||
private final PointService pointService;
|
||||
|
||||
@GetMapping
|
||||
@Operation(summary = "List donations", description = "Get recent donations for a post")
|
||||
@ApiResponse(responseCode = "200", description = "Donation summary")
|
||||
public DonationResponse list(@PathVariable Long postId) {
|
||||
return pointService.getPostDonations(postId);
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
@SecurityRequirement(name = "JWT")
|
||||
@Operation(summary = "Donate", description = "Donate points to the post author")
|
||||
@ApiResponse(responseCode = "200", description = "Donation result")
|
||||
public DonationResponse donate(
|
||||
@PathVariable Long postId,
|
||||
@RequestBody DonationRequest req,
|
||||
Authentication auth
|
||||
) {
|
||||
return pointService.donateToPost(auth.getName(), postId, req.getAmount());
|
||||
}
|
||||
}
|
||||
@@ -115,6 +115,9 @@ public class SearchController {
|
||||
dto.setSubText(r.subText());
|
||||
dto.setExtra(r.extra());
|
||||
dto.setPostId(r.postId());
|
||||
dto.setHighlightedText(r.highlightedText());
|
||||
dto.setHighlightedSubText(r.highlightedSubText());
|
||||
dto.setHighlightedExtra(r.highlightedExtra());
|
||||
return dto;
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
|
||||
@@ -100,18 +100,32 @@ public class TagController {
|
||||
)
|
||||
public List<TagDto> list(
|
||||
@RequestParam(value = "keyword", required = false) String keyword,
|
||||
@RequestParam(value = "page", required = false) Integer page,
|
||||
@RequestParam(value = "pageSize", required = false) Integer pageSize,
|
||||
@RequestParam(value = "limit", required = false) Integer limit
|
||||
) {
|
||||
List<Tag> tags = tagService.searchTags(keyword);
|
||||
List<Long> tagIds = tags.stream().map(Tag::getId).toList();
|
||||
Map<Long, Long> postCntByTagIds = postService.countPostsByTagIds(tagIds);
|
||||
if (postCntByTagIds == null) {
|
||||
postCntByTagIds = java.util.Collections.emptyMap();
|
||||
}
|
||||
Map<Long, Long> finalPostCntByTagIds = postCntByTagIds;
|
||||
List<TagDto> dtos = tags
|
||||
.stream()
|
||||
.map(t -> tagMapper.toDto(t, postCntByTagIds.getOrDefault(t.getId(), 0L)))
|
||||
.map(t -> tagMapper.toDto(t, finalPostCntByTagIds.getOrDefault(t.getId(), 0L)))
|
||||
.sorted((a, b) -> Long.compare(b.getCount(), a.getCount()))
|
||||
.collect(Collectors.toList());
|
||||
if (page != null && pageSize != null && page >= 0 && pageSize > 0) {
|
||||
int fromIndex = page * pageSize;
|
||||
if (fromIndex >= dtos.size()) {
|
||||
return java.util.Collections.emptyList();
|
||||
}
|
||||
int toIndex = Math.min(fromIndex + pageSize, dtos.size());
|
||||
return new java.util.ArrayList<>(dtos.subList(fromIndex, toIndex));
|
||||
}
|
||||
if (limit != null && limit > 0 && dtos.size() > limit) {
|
||||
return dtos.subList(0, limit);
|
||||
return new java.util.ArrayList<>(dtos.subList(0, limit));
|
||||
}
|
||||
return dtos;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
16
backend/src/main/java/com/openisle/dto/DonationDto.java
Normal file
16
backend/src/main/java/com/openisle/dto/DonationDto.java
Normal file
@@ -0,0 +1,16 @@
|
||||
package com.openisle.dto;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
public class DonationDto {
|
||||
|
||||
private Long userId;
|
||||
private String username;
|
||||
private String avatar;
|
||||
private int amount;
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
11
backend/src/main/java/com/openisle/dto/DonationRequest.java
Normal file
11
backend/src/main/java/com/openisle/dto/DonationRequest.java
Normal file
@@ -0,0 +1,11 @@
|
||||
package com.openisle.dto;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
public class DonationRequest {
|
||||
|
||||
private int amount;
|
||||
}
|
||||
15
backend/src/main/java/com/openisle/dto/DonationResponse.java
Normal file
15
backend/src/main/java/com/openisle/dto/DonationResponse.java
Normal file
@@ -0,0 +1,15 @@
|
||||
package com.openisle.dto;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
public class DonationResponse {
|
||||
|
||||
private int totalAmount;
|
||||
private List<DonationDto> donations = new ArrayList<>();
|
||||
private Integer balance;
|
||||
}
|
||||
@@ -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,4 +30,7 @@ public class PostChangeLogDto {
|
||||
private LocalDateTime newPinnedAt;
|
||||
private Boolean oldFeatured;
|
||||
private Boolean newFeatured;
|
||||
private PostVisibleScopeType oldVisibleScope;
|
||||
private PostVisibleScopeType newVisibleScope;
|
||||
private Integer amount;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -12,4 +12,7 @@ public class SearchResultDto {
|
||||
private String subText;
|
||||
private String extra;
|
||||
private Long postId;
|
||||
private String highlightedText;
|
||||
private String highlightedSubText;
|
||||
private String highlightedExtra;
|
||||
}
|
||||
|
||||
@@ -15,5 +15,6 @@ public class TimelineItemDto<T> {
|
||||
private Long id;
|
||||
private String kind; // "comment" | "log"
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime pinnedAt;
|
||||
private T payload; // 泛型,具体类型由外部决定
|
||||
}
|
||||
|
||||
@@ -52,6 +52,11 @@ 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());
|
||||
}
|
||||
return dto;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -73,6 +75,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 +116,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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,8 +46,14 @@ 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 */
|
||||
DONATION,
|
||||
/** You were mentioned in a post or comment */
|
||||
MENTION,
|
||||
}
|
||||
|
||||
@@ -13,4 +13,6 @@ public enum PointHistoryType {
|
||||
REDEEM,
|
||||
LOTTERY_JOIN,
|
||||
LOTTERY_REWARD,
|
||||
DONATE_SENT,
|
||||
DONATE_RECEIVED,
|
||||
}
|
||||
|
||||
@@ -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,8 @@ public enum PostChangeType {
|
||||
CLOSED,
|
||||
PINNED,
|
||||
FEATURED,
|
||||
VISIBLE_SCOPE,
|
||||
VOTE_RESULT,
|
||||
LOTTERY_RESULT,
|
||||
DONATE,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.openisle.model;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.Table;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@Entity
|
||||
@Table(name = "post_donate_change_logs")
|
||||
public class PostDonateChangeLog extends PostChangeLog {
|
||||
|
||||
@Column(nullable = false)
|
||||
private int amount;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -2,11 +2,14 @@ package com.openisle.repository;
|
||||
|
||||
import com.openisle.model.Comment;
|
||||
import com.openisle.model.PointHistory;
|
||||
import com.openisle.model.PointHistoryType;
|
||||
import com.openisle.model.Post;
|
||||
import com.openisle.model.User;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
public interface PointHistoryRepository extends JpaRepository<PointHistory, Long> {
|
||||
List<PointHistory> findByUserOrderByIdDesc(User user);
|
||||
@@ -21,4 +24,11 @@ public interface PointHistoryRepository extends JpaRepository<PointHistory, Long
|
||||
List<PointHistory> findByComment(Comment comment);
|
||||
|
||||
List<PointHistory> findByPost(Post post);
|
||||
|
||||
List<PointHistory> findTop10ByPostAndTypeOrderByCreatedAtDesc(Post post, PointHistoryType type);
|
||||
|
||||
@Query(
|
||||
"SELECT COALESCE(SUM(ph.amount), 0) FROM PointHistory ph WHERE ph.post = :post AND ph.type = :type"
|
||||
)
|
||||
Long sumAmountByPostAndType(@Param("post") Post post, @Param("type") PointHistoryType type);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,10 @@ 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> findByStatusAndCreatedAtGreaterThanEqualOrderByCreatedAtDesc(
|
||||
PostStatus status,
|
||||
LocalDateTime createdAt
|
||||
);
|
||||
List<Post> findByAuthorAndStatusOrderByCreatedAtDesc(
|
||||
User author,
|
||||
PostStatus status,
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.openisle.search;
|
||||
|
||||
public class NoopSearchIndexer implements SearchIndexer {
|
||||
|
||||
@Override
|
||||
public void indexDocument(String index, SearchDocument document) {
|
||||
// no-op
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deleteDocument(String index, Long id) {
|
||||
// no-op
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package com.openisle.search;
|
||||
|
||||
import org.apache.hc.client5.http.auth.AuthScope;
|
||||
import org.apache.hc.client5.http.auth.UsernamePasswordCredentials;
|
||||
import org.apache.hc.client5.http.impl.auth.BasicCredentialsProvider;
|
||||
import org.apache.hc.core5.http.HttpHost;
|
||||
import org.opensearch.client.RestClient;
|
||||
import org.opensearch.client.RestClientBuilder;
|
||||
import org.opensearch.client.json.jackson.JacksonJsonpMapper;
|
||||
import org.opensearch.client.opensearch.OpenSearchClient;
|
||||
import org.opensearch.client.transport.rest_client.RestClientTransport;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
@Configuration
|
||||
@EnableConfigurationProperties(OpenSearchProperties.class)
|
||||
public class OpenSearchConfig {
|
||||
|
||||
@Bean(destroyMethod = "close")
|
||||
@ConditionalOnProperty(prefix = "app.search", name = "enabled", havingValue = "true")
|
||||
public RestClient openSearchRestClient(OpenSearchProperties properties) {
|
||||
RestClientBuilder builder = RestClient.builder(
|
||||
new HttpHost(properties.getScheme(), properties.getHost(), properties.getPort())
|
||||
);
|
||||
if (StringUtils.hasText(properties.getUsername())) {
|
||||
BasicCredentialsProvider credentialsProvider = new BasicCredentialsProvider();
|
||||
credentialsProvider.setCredentials(
|
||||
new AuthScope(properties.getHost(), properties.getPort()),
|
||||
new UsernamePasswordCredentials(
|
||||
properties.getUsername(),
|
||||
properties.getPassword() != null ? properties.getPassword().toCharArray() : new char[0]
|
||||
)
|
||||
);
|
||||
builder.setHttpClientConfigCallback(httpClientBuilder ->
|
||||
httpClientBuilder.setDefaultCredentialsProvider(credentialsProvider)
|
||||
);
|
||||
}
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
@Bean(destroyMethod = "close")
|
||||
@ConditionalOnBean(RestClient.class)
|
||||
public RestClientTransport openSearchTransport(RestClient restClient) {
|
||||
return new RestClientTransport(restClient, new JacksonJsonpMapper());
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnBean(RestClientTransport.class)
|
||||
public OpenSearchClient openSearchClient(RestClientTransport transport) {
|
||||
return new OpenSearchClient(transport);
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnBean(OpenSearchClient.class)
|
||||
public SearchIndexInitializer searchIndexInitializer(
|
||||
OpenSearchClient client,
|
||||
OpenSearchProperties properties
|
||||
) {
|
||||
return new SearchIndexInitializer(client, properties);
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnBean(OpenSearchClient.class)
|
||||
public SearchIndexer openSearchIndexer(OpenSearchClient client, OpenSearchProperties properties) {
|
||||
return new OpenSearchIndexer(client);
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean(SearchIndexer.class)
|
||||
public SearchIndexer noopSearchIndexer() {
|
||||
return new NoopSearchIndexer();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.openisle.search;
|
||||
|
||||
import java.io.IOException;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.opensearch.client.opensearch.OpenSearchClient;
|
||||
import org.opensearch.client.opensearch.core.DeleteRequest;
|
||||
import org.opensearch.client.opensearch.core.IndexRequest;
|
||||
import org.opensearch.client.opensearch.core.IndexResponse;
|
||||
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class OpenSearchIndexer implements SearchIndexer {
|
||||
|
||||
private final OpenSearchClient client;
|
||||
|
||||
@Override
|
||||
public void indexDocument(String index, SearchDocument document) {
|
||||
if (document == null || document.entityId() == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
IndexRequest<SearchDocument> request = IndexRequest.of(builder ->
|
||||
builder.index(index).id(document.entityId().toString()).document(document)
|
||||
);
|
||||
IndexResponse response = client.index(request);
|
||||
log.info(
|
||||
"Indexed document {} into {} with result {}",
|
||||
document.entityId(),
|
||||
index,
|
||||
response.result()
|
||||
);
|
||||
} catch (IOException e) {
|
||||
log.warn("Failed to index document {} into {}", document.entityId(), index, e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deleteDocument(String index, Long id) {
|
||||
if (id == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
client.delete(DeleteRequest.of(builder -> builder.index(index).id(id.toString())));
|
||||
} catch (IOException e) {
|
||||
log.warn("Failed to delete document {} from {}", id, index, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.openisle.search;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
@ConfigurationProperties(prefix = "app.search")
|
||||
public class OpenSearchProperties {
|
||||
|
||||
private boolean enabled = false;
|
||||
private String host = "localhost";
|
||||
private int port = 9200;
|
||||
private String scheme = "http";
|
||||
private String username;
|
||||
private String password;
|
||||
private String indexPrefix = "openisle";
|
||||
private boolean initialize = true;
|
||||
private int highlightFragmentSize = 200;
|
||||
private boolean reindexOnStartup = false;
|
||||
private int reindexBatchSize = 500;
|
||||
|
||||
private Indices indices = new Indices();
|
||||
|
||||
public String postsIndex() {
|
||||
return indexName(indices.posts);
|
||||
}
|
||||
|
||||
public String commentsIndex() {
|
||||
return indexName(indices.comments);
|
||||
}
|
||||
|
||||
public String usersIndex() {
|
||||
return indexName(indices.users);
|
||||
}
|
||||
|
||||
public String categoriesIndex() {
|
||||
return indexName(indices.categories);
|
||||
}
|
||||
|
||||
public String tagsIndex() {
|
||||
return indexName(indices.tags);
|
||||
}
|
||||
|
||||
private String indexName(String suffix) {
|
||||
if (indexPrefix == null || indexPrefix.isBlank()) {
|
||||
return suffix;
|
||||
}
|
||||
return indexPrefix + "-" + suffix;
|
||||
}
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
public static class Indices {
|
||||
|
||||
private String posts = "posts";
|
||||
private String comments = "comments";
|
||||
private String users = "users";
|
||||
private String categories = "categories";
|
||||
private String tags = "tags";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.openisle.search;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public record SearchDocument(
|
||||
String type,
|
||||
Long entityId,
|
||||
String title,
|
||||
String content,
|
||||
String author,
|
||||
String category,
|
||||
List<String> tags,
|
||||
Long postId,
|
||||
Long createdAt
|
||||
) {}
|
||||
@@ -0,0 +1,127 @@
|
||||
package com.openisle.search;
|
||||
|
||||
import com.openisle.model.Category;
|
||||
import com.openisle.model.Comment;
|
||||
import com.openisle.model.Post;
|
||||
import com.openisle.model.Tag;
|
||||
import com.openisle.model.User;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
public final class SearchDocumentFactory {
|
||||
|
||||
private SearchDocumentFactory() {}
|
||||
|
||||
public static SearchDocument fromPost(Post post) {
|
||||
if (post == null || post.getId() == null) {
|
||||
return null;
|
||||
}
|
||||
List<String> tags = post.getTags() == null
|
||||
? Collections.emptyList()
|
||||
: post
|
||||
.getTags()
|
||||
.stream()
|
||||
.map(Tag::getName)
|
||||
.filter(Objects::nonNull)
|
||||
.collect(Collectors.toList());
|
||||
return new SearchDocument(
|
||||
"post",
|
||||
post.getId(),
|
||||
post.getTitle(),
|
||||
post.getContent(),
|
||||
post.getAuthor() != null ? post.getAuthor().getUsername() : null,
|
||||
post.getCategory() != null ? post.getCategory().getName() : null,
|
||||
tags,
|
||||
post.getId(),
|
||||
toEpochMillis(post.getCreatedAt())
|
||||
);
|
||||
}
|
||||
|
||||
public static SearchDocument fromComment(Comment comment) {
|
||||
if (comment == null || comment.getId() == null) {
|
||||
return null;
|
||||
}
|
||||
Post post = comment.getPost();
|
||||
List<String> tags = post == null || post.getTags() == null
|
||||
? Collections.emptyList()
|
||||
: post
|
||||
.getTags()
|
||||
.stream()
|
||||
.map(Tag::getName)
|
||||
.filter(Objects::nonNull)
|
||||
.collect(Collectors.toList());
|
||||
return new SearchDocument(
|
||||
"comment",
|
||||
comment.getId(),
|
||||
post != null ? post.getTitle() : null,
|
||||
comment.getContent(),
|
||||
comment.getAuthor() != null ? comment.getAuthor().getUsername() : null,
|
||||
post != null && post.getCategory() != null ? post.getCategory().getName() : null,
|
||||
tags,
|
||||
post != null ? post.getId() : null,
|
||||
toEpochMillis(comment.getCreatedAt())
|
||||
);
|
||||
}
|
||||
|
||||
public static SearchDocument fromUser(User user) {
|
||||
if (user == null || user.getId() == null) {
|
||||
return null;
|
||||
}
|
||||
return new SearchDocument(
|
||||
"user",
|
||||
user.getId(),
|
||||
user.getUsername(),
|
||||
user.getIntroduction(),
|
||||
null,
|
||||
null,
|
||||
Collections.emptyList(),
|
||||
null,
|
||||
toEpochMillis(user.getCreatedAt())
|
||||
);
|
||||
}
|
||||
|
||||
public static SearchDocument fromCategory(Category category) {
|
||||
if (category == null || category.getId() == null) {
|
||||
return null;
|
||||
}
|
||||
return new SearchDocument(
|
||||
"category",
|
||||
category.getId(),
|
||||
category.getName(),
|
||||
category.getDescription(),
|
||||
null,
|
||||
null,
|
||||
Collections.emptyList(),
|
||||
null,
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
public static SearchDocument fromTag(Tag tag) {
|
||||
if (tag == null || tag.getId() == null) {
|
||||
return null;
|
||||
}
|
||||
return new SearchDocument(
|
||||
"tag",
|
||||
tag.getId(),
|
||||
tag.getName(),
|
||||
tag.getDescription(),
|
||||
null,
|
||||
null,
|
||||
Collections.emptyList(),
|
||||
null,
|
||||
toEpochMillis(tag.getCreatedAt())
|
||||
);
|
||||
}
|
||||
|
||||
private static Long toEpochMillis(LocalDateTime time) {
|
||||
if (time == null) {
|
||||
return null;
|
||||
}
|
||||
return time.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.openisle.search;
|
||||
|
||||
import com.openisle.search.event.DeleteDocumentEvent;
|
||||
import com.openisle.search.event.IndexDocumentEvent;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.event.TransactionPhase;
|
||||
import org.springframework.transaction.event.TransactionalEventListener;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class SearchIndexEventListener {
|
||||
|
||||
private final SearchIndexer searchIndexer;
|
||||
|
||||
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true)
|
||||
public void handleIndex(IndexDocumentEvent event) {
|
||||
if (event == null || event.document() == null) {
|
||||
return;
|
||||
}
|
||||
searchIndexer.indexDocument(event.index(), event.document());
|
||||
}
|
||||
|
||||
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true)
|
||||
public void handleDelete(DeleteDocumentEvent event) {
|
||||
if (event == null) {
|
||||
return;
|
||||
}
|
||||
searchIndexer.deleteDocument(event.index(), event.id());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package com.openisle.search;
|
||||
|
||||
import com.openisle.model.Category;
|
||||
import com.openisle.model.Comment;
|
||||
import com.openisle.model.Post;
|
||||
import com.openisle.model.PostStatus;
|
||||
import com.openisle.model.Tag;
|
||||
import com.openisle.model.User;
|
||||
import com.openisle.search.event.DeleteDocumentEvent;
|
||||
import com.openisle.search.event.IndexDocumentEvent;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class SearchIndexEventPublisher {
|
||||
|
||||
private final ApplicationEventPublisher publisher;
|
||||
private final OpenSearchProperties properties;
|
||||
|
||||
public void publishPostSaved(Post post) {
|
||||
if (!properties.isEnabled() || post == null || post.getStatus() != PostStatus.PUBLISHED) {
|
||||
return;
|
||||
}
|
||||
SearchDocument document = SearchDocumentFactory.fromPost(post);
|
||||
if (document != null) {
|
||||
publisher.publishEvent(new IndexDocumentEvent(properties.postsIndex(), document));
|
||||
}
|
||||
}
|
||||
|
||||
public void publishPostDeleted(Long postId) {
|
||||
if (!properties.isEnabled() || postId == null) {
|
||||
return;
|
||||
}
|
||||
publisher.publishEvent(new DeleteDocumentEvent(properties.postsIndex(), postId));
|
||||
}
|
||||
|
||||
public void publishCommentSaved(Comment comment) {
|
||||
if (!properties.isEnabled() || comment == null) {
|
||||
return;
|
||||
}
|
||||
SearchDocument document = SearchDocumentFactory.fromComment(comment);
|
||||
if (document != null) {
|
||||
publisher.publishEvent(new IndexDocumentEvent(properties.commentsIndex(), document));
|
||||
}
|
||||
}
|
||||
|
||||
public void publishCommentDeleted(Long commentId) {
|
||||
if (!properties.isEnabled() || commentId == null) {
|
||||
return;
|
||||
}
|
||||
publisher.publishEvent(new DeleteDocumentEvent(properties.commentsIndex(), commentId));
|
||||
}
|
||||
|
||||
public void publishUserSaved(User user) {
|
||||
if (!properties.isEnabled() || user == null) {
|
||||
return;
|
||||
}
|
||||
SearchDocument document = SearchDocumentFactory.fromUser(user);
|
||||
if (document != null) {
|
||||
publisher.publishEvent(new IndexDocumentEvent(properties.usersIndex(), document));
|
||||
}
|
||||
}
|
||||
|
||||
public void publishCategorySaved(Category category) {
|
||||
if (!properties.isEnabled() || category == null) {
|
||||
return;
|
||||
}
|
||||
SearchDocument document = SearchDocumentFactory.fromCategory(category);
|
||||
if (document != null) {
|
||||
publisher.publishEvent(new IndexDocumentEvent(properties.categoriesIndex(), document));
|
||||
}
|
||||
}
|
||||
|
||||
public void publishCategoryDeleted(Long categoryId) {
|
||||
if (!properties.isEnabled() || categoryId == null) {
|
||||
return;
|
||||
}
|
||||
publisher.publishEvent(new DeleteDocumentEvent(properties.categoriesIndex(), categoryId));
|
||||
}
|
||||
|
||||
public void publishTagSaved(Tag tag) {
|
||||
if (!properties.isEnabled() || tag == null || !tag.isApproved()) {
|
||||
return;
|
||||
}
|
||||
SearchDocument document = SearchDocumentFactory.fromTag(tag);
|
||||
if (document != null) {
|
||||
publisher.publishEvent(new IndexDocumentEvent(properties.tagsIndex(), document));
|
||||
}
|
||||
}
|
||||
|
||||
public void publishTagDeleted(Long tagId) {
|
||||
if (!properties.isEnabled() || tagId == null) {
|
||||
return;
|
||||
}
|
||||
publisher.publishEvent(new DeleteDocumentEvent(properties.tagsIndex(), tagId));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
package com.openisle.search;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import java.io.IOException;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.opensearch.client.json.JsonData;
|
||||
import org.opensearch.client.opensearch.OpenSearchClient;
|
||||
import org.opensearch.client.opensearch._types.mapping.Property;
|
||||
import org.opensearch.client.opensearch._types.mapping.TypeMapping;
|
||||
import org.opensearch.client.opensearch.indices.IndexSettings;
|
||||
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class SearchIndexInitializer {
|
||||
|
||||
private final OpenSearchClient client;
|
||||
private final OpenSearchProperties properties;
|
||||
|
||||
@PostConstruct
|
||||
public void initialize() {
|
||||
if (!properties.isEnabled() || !properties.isInitialize()) {
|
||||
return;
|
||||
}
|
||||
ensureIndex(properties.postsIndex(), this::postMapping);
|
||||
ensureIndex(properties.commentsIndex(), this::commentMapping);
|
||||
ensureIndex(properties.usersIndex(), this::userMapping);
|
||||
ensureIndex(properties.categoriesIndex(), this::categoryMapping);
|
||||
ensureIndex(properties.tagsIndex(), this::tagMapping);
|
||||
}
|
||||
|
||||
private void ensureIndex(String index, java.util.function.Supplier<TypeMapping> mappingSupplier) {
|
||||
try {
|
||||
boolean exists = client
|
||||
.indices()
|
||||
.exists(builder -> builder.index(index))
|
||||
.value();
|
||||
if (exists) {
|
||||
return;
|
||||
}
|
||||
client
|
||||
.indices()
|
||||
.create(builder ->
|
||||
builder.index(index).settings(this::applyPinyinAnalysis).mappings(mappingSupplier.get())
|
||||
);
|
||||
log.info("Created OpenSearch index {}", index);
|
||||
} catch (IOException e) {
|
||||
log.warn("Failed to initialize OpenSearch index {}", index, e);
|
||||
}
|
||||
}
|
||||
|
||||
private TypeMapping postMapping() {
|
||||
return TypeMapping.of(builder ->
|
||||
builder
|
||||
.properties("type", Property.of(p -> p.keyword(k -> k)))
|
||||
.properties("title", textWithRawAndPinyin())
|
||||
.properties("content", textWithPinyinOnly()) // content 不做 .raw,避免超长 keyword
|
||||
.properties("author", keywordWithRawAndPinyin())
|
||||
.properties("category", keywordWithRawAndPinyin())
|
||||
.properties("tags", keywordWithRawAndPinyin())
|
||||
.properties("postId", Property.of(p -> p.long_(l -> l)))
|
||||
.properties(
|
||||
"createdAt",
|
||||
Property.of(p -> p.date(d -> d.format("strict_date_optional_time||epoch_millis")))
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private TypeMapping commentMapping() {
|
||||
return TypeMapping.of(builder ->
|
||||
builder
|
||||
.properties("type", Property.of(p -> p.keyword(k -> k)))
|
||||
.properties("title", textWithRawAndPinyin())
|
||||
.properties("content", textWithPinyinOnly())
|
||||
.properties("author", keywordWithRawAndPinyin())
|
||||
.properties("category", keywordWithRawAndPinyin())
|
||||
.properties("tags", keywordWithRawAndPinyin())
|
||||
.properties("postId", Property.of(p -> p.long_(l -> l)))
|
||||
.properties(
|
||||
"createdAt",
|
||||
Property.of(p -> p.date(d -> d.format("strict_date_optional_time||epoch_millis")))
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private TypeMapping userMapping() {
|
||||
return TypeMapping.of(builder ->
|
||||
builder
|
||||
.properties("type", Property.of(p -> p.keyword(k -> k)))
|
||||
.properties("title", textWithRawAndPinyin())
|
||||
.properties("content", textWithPinyinOnly())
|
||||
.properties(
|
||||
"createdAt",
|
||||
Property.of(p -> p.date(d -> d.format("strict_date_optional_time||epoch_millis")))
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private TypeMapping categoryMapping() {
|
||||
return TypeMapping.of(builder ->
|
||||
builder
|
||||
.properties("type", Property.of(p -> p.keyword(k -> k)))
|
||||
.properties("title", textWithRawAndPinyin())
|
||||
.properties("content", textWithPinyinOnly())
|
||||
);
|
||||
}
|
||||
|
||||
private TypeMapping tagMapping() {
|
||||
return TypeMapping.of(builder ->
|
||||
builder
|
||||
.properties("type", Property.of(p -> p.keyword(k -> k)))
|
||||
.properties("title", textWithRawAndPinyin())
|
||||
.properties("content", textWithPinyinOnly())
|
||||
.properties(
|
||||
"createdAt",
|
||||
Property.of(p -> p.date(d -> d.format("strict_date_optional_time||epoch_millis")))
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/** 文本字段:.raw(keyword 精确) + .py(拼音短语精确) + .zh(ICU+2~3gram 召回) */
|
||||
private Property textWithRawAndPinyin() {
|
||||
return Property.of(p ->
|
||||
p.text(t ->
|
||||
t
|
||||
.fields("raw", f -> f.keyword(k -> k.normalizer("lowercase_normalizer")))
|
||||
.fields("py", f -> f.text(sub -> sub.analyzer("py_index").searchAnalyzer("py_search")))
|
||||
.fields("zh", f ->
|
||||
f.text(sub -> sub.analyzer("zh_ngram_index").searchAnalyzer("zh_search"))
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/** 长文本 content:保留拼音 + 新增 zh 子字段(不加 .raw,避免过长 keyword) */
|
||||
private Property textWithPinyinOnly() {
|
||||
return Property.of(p ->
|
||||
p.text(t ->
|
||||
t
|
||||
.fields("py", f -> f.text(sub -> sub.analyzer("py_index").searchAnalyzer("py_search")))
|
||||
.fields("zh", f ->
|
||||
f.text(sub -> sub.analyzer("zh_ngram_index").searchAnalyzer("zh_search"))
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/** 关键词字段(author/category/tags):keyword 等值 + .py + .zh(尽量对齐标题策略) */
|
||||
private Property keywordWithRawAndPinyin() {
|
||||
return Property.of(p ->
|
||||
p.keyword(k ->
|
||||
k
|
||||
.normalizer("lowercase_normalizer")
|
||||
.fields("raw", f -> f.keyword(kk -> kk.normalizer("lowercase_normalizer")))
|
||||
.fields("py", f -> f.text(sub -> sub.analyzer("py_index").searchAnalyzer("py_search")))
|
||||
.fields("zh", f ->
|
||||
f.text(sub -> sub.analyzer("zh_ngram_index").searchAnalyzer("zh_search"))
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/** 新增 zh 分析器(ICU + 2~3gram),并保留你已有的 pinyin/normalizer 设置 */
|
||||
private IndexSettings.Builder applyPinyinAnalysis(IndexSettings.Builder builder) {
|
||||
Map<String, JsonData> settings = new LinkedHashMap<>();
|
||||
|
||||
// --- 已有:keyword normalizer(用于 .raw)
|
||||
settings.put("analysis.normalizer.lowercase_normalizer.type", JsonData.of("custom"));
|
||||
settings.put(
|
||||
"analysis.normalizer.lowercase_normalizer.filter",
|
||||
JsonData.of(List.of("lowercase"))
|
||||
);
|
||||
|
||||
// --- 已有:pinyin filter + analyzers
|
||||
settings.put("analysis.filter.py_filter.type", JsonData.of("pinyin"));
|
||||
settings.put("analysis.filter.py_filter.keep_full_pinyin", JsonData.of(true));
|
||||
settings.put("analysis.filter.py_filter.keep_joined_full_pinyin", JsonData.of(true));
|
||||
settings.put("analysis.filter.py_filter.keep_first_letter", JsonData.of(false));
|
||||
settings.put("analysis.filter.py_filter.remove_duplicated_term", JsonData.of(true));
|
||||
|
||||
settings.put("analysis.analyzer.py_index.type", JsonData.of("custom"));
|
||||
settings.put("analysis.analyzer.py_index.tokenizer", JsonData.of("standard"));
|
||||
settings.put(
|
||||
"analysis.analyzer.py_index.filter",
|
||||
JsonData.of(List.of("lowercase", "py_filter"))
|
||||
);
|
||||
|
||||
settings.put("analysis.analyzer.py_search.type", JsonData.of("custom"));
|
||||
settings.put("analysis.analyzer.py_search.tokenizer", JsonData.of("standard"));
|
||||
settings.put(
|
||||
"analysis.analyzer.py_search.filter",
|
||||
JsonData.of(List.of("lowercase", "py_filter"))
|
||||
);
|
||||
|
||||
settings.put("analysis.filter.zh_ngram_2_3.type", JsonData.of("ngram"));
|
||||
settings.put("analysis.filter.zh_ngram_2_3.min_gram", JsonData.of(2));
|
||||
settings.put("analysis.filter.zh_ngram_2_3.max_gram", JsonData.of(3));
|
||||
|
||||
settings.put("analysis.analyzer.zh_ngram_index.type", JsonData.of("custom"));
|
||||
settings.put("analysis.analyzer.zh_ngram_index.tokenizer", JsonData.of("icu_tokenizer"));
|
||||
settings.put(
|
||||
"analysis.analyzer.zh_ngram_index.filter",
|
||||
JsonData.of(List.of("lowercase", "zh_ngram_2_3"))
|
||||
);
|
||||
|
||||
settings.put("analysis.analyzer.zh_search.type", JsonData.of("custom"));
|
||||
settings.put("analysis.analyzer.zh_search.tokenizer", JsonData.of("icu_tokenizer"));
|
||||
settings.put(
|
||||
"analysis.analyzer.zh_search.filter",
|
||||
JsonData.of(List.of("lowercase", "zh_ngram_2_3"))
|
||||
);
|
||||
|
||||
settings.forEach(builder::customSettings);
|
||||
return builder;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.openisle.search;
|
||||
|
||||
public interface SearchIndexer {
|
||||
void indexDocument(String index, SearchDocument document);
|
||||
void deleteDocument(String index, Long id);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.openisle.search;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.boot.CommandLineRunner;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class SearchReindexInitializer implements CommandLineRunner {
|
||||
|
||||
private final OpenSearchProperties properties;
|
||||
private final SearchReindexService searchReindexService;
|
||||
|
||||
@Override
|
||||
public void run(String... args) {
|
||||
if (!properties.isEnabled()) {
|
||||
log.info("Search indexing disabled, skipping startup reindex.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!properties.isReindexOnStartup()) {
|
||||
log.debug("Startup reindex disabled by configuration.");
|
||||
return;
|
||||
}
|
||||
|
||||
searchReindexService.reindexAll();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package com.openisle.search;
|
||||
|
||||
import com.openisle.model.Post;
|
||||
import com.openisle.model.PostStatus;
|
||||
import com.openisle.model.Tag;
|
||||
import com.openisle.repository.CategoryRepository;
|
||||
import com.openisle.repository.CommentRepository;
|
||||
import com.openisle.repository.PostRepository;
|
||||
import com.openisle.repository.TagRepository;
|
||||
import com.openisle.repository.UserRepository;
|
||||
import java.util.Objects;
|
||||
import java.util.function.Function;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class SearchReindexService {
|
||||
|
||||
private final SearchIndexer searchIndexer;
|
||||
private final OpenSearchProperties properties;
|
||||
private final PostRepository postRepository;
|
||||
private final CommentRepository commentRepository;
|
||||
private final UserRepository userRepository;
|
||||
private final CategoryRepository categoryRepository;
|
||||
private final TagRepository tagRepository;
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public void reindexAll() {
|
||||
if (!properties.isEnabled()) {
|
||||
log.info("Search indexing is disabled, skipping reindex operation.");
|
||||
return;
|
||||
}
|
||||
|
||||
log.info("Starting full search reindex operation.");
|
||||
|
||||
reindex(properties.postsIndex(), postRepository::findAll, (Post post) ->
|
||||
post.getStatus() == PostStatus.PUBLISHED ? SearchDocumentFactory.fromPost(post) : null
|
||||
);
|
||||
|
||||
reindex(
|
||||
properties.commentsIndex(),
|
||||
commentRepository::findAll,
|
||||
SearchDocumentFactory::fromComment
|
||||
);
|
||||
|
||||
reindex(properties.usersIndex(), userRepository::findAll, SearchDocumentFactory::fromUser);
|
||||
|
||||
reindex(
|
||||
properties.categoriesIndex(),
|
||||
categoryRepository::findAll,
|
||||
SearchDocumentFactory::fromCategory
|
||||
);
|
||||
|
||||
reindex(properties.tagsIndex(), tagRepository::findAll, (Tag tag) ->
|
||||
tag.isApproved() ? SearchDocumentFactory.fromTag(tag) : null
|
||||
);
|
||||
|
||||
log.info("Completed full search reindex operation.");
|
||||
}
|
||||
|
||||
private <T> void reindex(
|
||||
String index,
|
||||
Function<Pageable, Page<T>> pageSupplier,
|
||||
Function<T, SearchDocument> mapper
|
||||
) {
|
||||
int batchSize = Math.max(1, properties.getReindexBatchSize());
|
||||
int pageNumber = 0;
|
||||
|
||||
Page<T> page;
|
||||
do {
|
||||
Pageable pageable = PageRequest.of(pageNumber, batchSize);
|
||||
page = pageSupplier.apply(pageable);
|
||||
if (page.isEmpty() && pageNumber == 0) {
|
||||
log.info("No entities found for index {}.", index);
|
||||
}
|
||||
|
||||
log.info("Reindexing {} entities for index {}.", page.getTotalElements(), index);
|
||||
for (T entity : page) {
|
||||
SearchDocument document = mapper.apply(entity);
|
||||
if (Objects.nonNull(document)) {
|
||||
searchIndexer.indexDocument(index, document);
|
||||
}
|
||||
}
|
||||
pageNumber++;
|
||||
} while (page.hasNext());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
package com.openisle.search.event;
|
||||
|
||||
public record DeleteDocumentEvent(String index, Long id) {}
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.openisle.search.event;
|
||||
|
||||
import com.openisle.search.SearchDocument;
|
||||
|
||||
public record IndexDocumentEvent(String index, SearchDocument document) {}
|
||||
@@ -3,6 +3,7 @@ package com.openisle.service;
|
||||
import com.openisle.config.CachingConfig;
|
||||
import com.openisle.model.Category;
|
||||
import com.openisle.repository.CategoryRepository;
|
||||
import com.openisle.search.SearchIndexEventPublisher;
|
||||
import java.util.List;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.cache.annotation.CacheEvict;
|
||||
@@ -14,6 +15,7 @@ import org.springframework.stereotype.Service;
|
||||
public class CategoryService {
|
||||
|
||||
private final CategoryRepository categoryRepository;
|
||||
private final SearchIndexEventPublisher searchIndexEventPublisher;
|
||||
|
||||
@CacheEvict(value = CachingConfig.CATEGORY_CACHE_NAME, allEntries = true)
|
||||
public Category createCategory(String name, String description, String icon, String smallIcon) {
|
||||
@@ -22,7 +24,9 @@ public class CategoryService {
|
||||
category.setDescription(description);
|
||||
category.setIcon(icon);
|
||||
category.setSmallIcon(smallIcon);
|
||||
return categoryRepository.save(category);
|
||||
Category saved = categoryRepository.save(category);
|
||||
searchIndexEventPublisher.publishCategorySaved(saved);
|
||||
return saved;
|
||||
}
|
||||
|
||||
@CacheEvict(value = CachingConfig.CATEGORY_CACHE_NAME, allEntries = true)
|
||||
@@ -48,12 +52,15 @@ public class CategoryService {
|
||||
if (smallIcon != null) {
|
||||
category.setSmallIcon(smallIcon);
|
||||
}
|
||||
return categoryRepository.save(category);
|
||||
Category saved = categoryRepository.save(category);
|
||||
searchIndexEventPublisher.publishCategorySaved(saved);
|
||||
return saved;
|
||||
}
|
||||
|
||||
@CacheEvict(value = CachingConfig.CATEGORY_CACHE_NAME, allEntries = true)
|
||||
public void deleteCategory(Long id) {
|
||||
categoryRepository.deleteById(id);
|
||||
searchIndexEventPublisher.publishCategoryDeleted(id);
|
||||
}
|
||||
|
||||
public Category getCategory(Long id) {
|
||||
|
||||
@@ -16,6 +16,7 @@ import com.openisle.repository.PointHistoryRepository;
|
||||
import com.openisle.repository.PostRepository;
|
||||
import com.openisle.repository.ReactionRepository;
|
||||
import com.openisle.repository.UserRepository;
|
||||
import com.openisle.search.SearchIndexEventPublisher;
|
||||
import com.openisle.service.NotificationService;
|
||||
import com.openisle.service.PointService;
|
||||
import com.openisle.service.SubscriptionService;
|
||||
@@ -49,6 +50,7 @@ public class CommentService {
|
||||
private final PointHistoryRepository pointHistoryRepository;
|
||||
private final PointService pointService;
|
||||
private final ImageUploader imageUploader;
|
||||
private final SearchIndexEventPublisher searchIndexEventPublisher;
|
||||
|
||||
@CacheEvict(value = CachingConfig.POST_CACHE_NAME, allEntries = true)
|
||||
@Transactional
|
||||
@@ -124,6 +126,7 @@ public class CommentService {
|
||||
}
|
||||
notificationService.notifyMentions(content, author, post, comment);
|
||||
log.debug("addComment finished for comment {}", comment.getId());
|
||||
searchIndexEventPublisher.publishCommentSaved(comment);
|
||||
return comment;
|
||||
}
|
||||
|
||||
@@ -221,6 +224,7 @@ public class CommentService {
|
||||
}
|
||||
notificationService.notifyMentions(content, author, parent.getPost(), comment);
|
||||
log.debug("addReply finished for comment {}", comment.getId());
|
||||
searchIndexEventPublisher.publishCommentSaved(comment);
|
||||
return comment;
|
||||
}
|
||||
|
||||
@@ -262,6 +266,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
|
||||
@@ -360,7 +385,9 @@ public class CommentService {
|
||||
|
||||
// 逻辑删除评论
|
||||
Post post = comment.getPost();
|
||||
Long commentId = comment.getId();
|
||||
commentRepository.delete(comment);
|
||||
searchIndexEventPublisher.publishCommentDeleted(commentId);
|
||||
// 删除积分历史
|
||||
pointHistoryRepository.deleteAll(pointHistories);
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.openisle.service;
|
||||
|
||||
import com.openisle.dto.DonationDto;
|
||||
import com.openisle.dto.DonationResponse;
|
||||
import com.openisle.exception.FieldException;
|
||||
import com.openisle.model.*;
|
||||
import com.openisle.repository.*;
|
||||
@@ -8,8 +10,10 @@ import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@@ -20,6 +24,8 @@ public class PointService {
|
||||
private final PostRepository postRepository;
|
||||
private final CommentRepository commentRepository;
|
||||
private final PointHistoryRepository pointHistoryRepository;
|
||||
private final NotificationService notificationService;
|
||||
private final PostChangeLogService postChangeLogService;
|
||||
|
||||
public int awardForPost(String userName, Long postId) {
|
||||
User user = userRepository.findByUsername(userName).orElseThrow();
|
||||
@@ -272,4 +278,95 @@ public class PointService {
|
||||
User user = userRepository.findByUsername(userName).orElseThrow();
|
||||
return recalculateUserPoints(user);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public DonationResponse donateToPost(String donorName, Long postId, int amount) {
|
||||
if (amount <= 0) {
|
||||
throw new FieldException("amount", "打赏积分必须大于0");
|
||||
}
|
||||
User donor = userRepository.findByUsername(donorName).orElseThrow();
|
||||
Post post = postRepository.findById(postId).orElseThrow();
|
||||
User author = post.getAuthor();
|
||||
if (author.getId().equals(donor.getId())) {
|
||||
throw new FieldException("post", "不能给自己打赏");
|
||||
}
|
||||
if (donor.getPoint() < amount) {
|
||||
throw new FieldException("point", "积分不足");
|
||||
}
|
||||
addPoint(donor, -amount, PointHistoryType.DONATE_SENT, post, null, author);
|
||||
addPoint(author, amount, PointHistoryType.DONATE_RECEIVED, post, null, donor);
|
||||
notificationService.createNotification(
|
||||
author,
|
||||
NotificationType.DONATION,
|
||||
post,
|
||||
null,
|
||||
null,
|
||||
donor,
|
||||
null,
|
||||
String.valueOf(amount)
|
||||
);
|
||||
postChangeLogService.recordDonation(post, donor, amount);
|
||||
DonationResponse response = buildDonationResponse(post);
|
||||
response.setBalance(donor.getPoint());
|
||||
return response;
|
||||
}
|
||||
|
||||
public DonationResponse getPostDonations(Long postId) {
|
||||
Post post = postRepository.findById(postId).orElseThrow();
|
||||
return buildDonationResponse(post);
|
||||
}
|
||||
|
||||
private DonationResponse buildDonationResponse(Post post) {
|
||||
List<PointHistory> histories =
|
||||
pointHistoryRepository.findTop10ByPostAndTypeOrderByCreatedAtDesc(
|
||||
post,
|
||||
PointHistoryType.DONATE_RECEIVED
|
||||
);
|
||||
List<DonationDto> donations = histories
|
||||
.stream()
|
||||
.collect(Collectors.collectingAndThen(Collectors.toMap(
|
||||
history -> {
|
||||
User donor = history.getFromUser();
|
||||
if (donor != null && donor.getId() != null) {
|
||||
return "user:" + donor.getId();
|
||||
}
|
||||
return "history:" + history.getId();
|
||||
},
|
||||
history -> {
|
||||
DonationDto dto = new DonationDto();
|
||||
User donor = history.getFromUser();
|
||||
if (donor != null) {
|
||||
dto.setUserId(donor.getId());
|
||||
dto.setUsername(donor.getUsername());
|
||||
dto.setAvatar(donor.getAvatar());
|
||||
}
|
||||
dto.setAmount(history.getAmount());
|
||||
dto.setCreatedAt(history.getCreatedAt());
|
||||
return dto;
|
||||
},
|
||||
(left, right) -> {
|
||||
left.setAmount(left.getAmount() + right.getAmount());
|
||||
if (
|
||||
left.getCreatedAt() == null ||
|
||||
(right.getCreatedAt() != null && right.getCreatedAt().isAfter(left.getCreatedAt()))
|
||||
) {
|
||||
left.setCreatedAt(right.getCreatedAt());
|
||||
}
|
||||
return left;
|
||||
},
|
||||
java.util.LinkedHashMap::new
|
||||
), map -> new java.util.ArrayList<>(map.values())));
|
||||
Long total = pointHistoryRepository.sumAmountByPostAndType(
|
||||
post,
|
||||
PointHistoryType.DONATE_RECEIVED
|
||||
);
|
||||
int safeTotal = 0;
|
||||
if (total != null) {
|
||||
safeTotal = total > Integer.MAX_VALUE ? Integer.MAX_VALUE : total.intValue();
|
||||
}
|
||||
DonationResponse response = new DonationResponse();
|
||||
response.setDonations(donations);
|
||||
response.setTotalAmount(safeTotal);
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
@@ -115,6 +130,15 @@ public class PostChangeLogService {
|
||||
logRepository.save(log);
|
||||
}
|
||||
|
||||
public void recordDonation(Post post, User donor, int amount) {
|
||||
PostDonateChangeLog log = new PostDonateChangeLog();
|
||||
log.setPost(post);
|
||||
log.setUser(donor);
|
||||
log.setType(PostChangeType.DONATE);
|
||||
log.setAmount(amount);
|
||||
logRepository.save(log);
|
||||
}
|
||||
|
||||
public void deleteLogsForPost(Post post) {
|
||||
logRepository.deleteByPost(post);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -16,11 +17,11 @@ import com.openisle.repository.PostSubscriptionRepository;
|
||||
import com.openisle.repository.ReactionRepository;
|
||||
import com.openisle.repository.TagRepository;
|
||||
import com.openisle.repository.UserRepository;
|
||||
import com.openisle.search.SearchIndexEventPublisher;
|
||||
import com.openisle.service.EmailSender;
|
||||
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;
|
||||
@@ -31,7 +32,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;
|
||||
@@ -53,6 +53,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;
|
||||
@@ -70,9 +71,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;
|
||||
|
||||
@@ -86,6 +95,7 @@ public class PostService {
|
||||
TagRepository tagRepository,
|
||||
LotteryPostRepository lotteryPostRepository,
|
||||
PollPostRepository pollPostRepository,
|
||||
CategoryProposalPostRepository categoryProposalPostRepository,
|
||||
PollVoteRepository pollVoteRepository,
|
||||
NotificationService notificationService,
|
||||
SubscriptionService subscriptionService,
|
||||
@@ -103,7 +113,9 @@ public class PostService {
|
||||
PostChangeLogService postChangeLogService,
|
||||
PointHistoryRepository pointHistoryRepository,
|
||||
@Value("${app.post.publish-mode:DIRECT}") PublishMode publishMode,
|
||||
RedisTemplate redisTemplate
|
||||
RedisTemplate redisTemplate,
|
||||
SearchIndexEventPublisher searchIndexEventPublisher,
|
||||
CategoryService categoryService
|
||||
) {
|
||||
this.postRepository = postRepository;
|
||||
this.userRepository = userRepository;
|
||||
@@ -111,6 +123,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;
|
||||
@@ -130,6 +143,8 @@ public class PostService {
|
||||
this.publishMode = publishMode;
|
||||
|
||||
this.redisTemplate = redisTemplate;
|
||||
this.searchIndexEventPublisher = searchIndexEventPublisher;
|
||||
this.categoryService = categoryService;
|
||||
}
|
||||
|
||||
@EventListener(ApplicationReadyEvent.class)
|
||||
@@ -155,6 +170,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() {
|
||||
@@ -220,6 +253,7 @@ public class PostService {
|
||||
String content,
|
||||
List<Long> tagIds,
|
||||
PostType type,
|
||||
PostVisibleScopeType postVisibleScopeType,
|
||||
String prizeDescription,
|
||||
String prizeIcon,
|
||||
Integer prizeCount,
|
||||
@@ -227,10 +261,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");
|
||||
}
|
||||
@@ -273,6 +309,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();
|
||||
}
|
||||
@@ -283,8 +338,18 @@ public class PostService {
|
||||
post.setCategory(category);
|
||||
post.setTags(new HashSet<>(tags));
|
||||
post.setStatus(publishMode == PublishMode.REVIEW ? PostStatus.PENDING : PostStatus.PUBLISHED);
|
||||
|
||||
// 什么都没设置的情况下,默认为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 {
|
||||
@@ -339,6 +404,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()),
|
||||
@@ -346,24 +417,113 @@ public class PostService {
|
||||
);
|
||||
scheduledFinalizations.put(pp.getId(), future);
|
||||
}
|
||||
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)
|
||||
@@ -442,6 +602,9 @@ public class PostService {
|
||||
pollPostRepository
|
||||
.findById(postId)
|
||||
.ifPresent(pp -> {
|
||||
if (pp instanceof CategoryProposalPost) {
|
||||
return;
|
||||
}
|
||||
if (pp.isResultAnnounced()) {
|
||||
return;
|
||||
}
|
||||
@@ -563,7 +726,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)
|
||||
@@ -607,6 +770,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);
|
||||
}
|
||||
@@ -868,10 +1043,12 @@ public class PostService {
|
||||
if (!tag.isApproved()) {
|
||||
tag.setApproved(true);
|
||||
tagRepository.save(tag);
|
||||
searchIndexEventPublisher.publishTagSaved(tag);
|
||||
}
|
||||
}
|
||||
post.setStatus(PostStatus.PUBLISHED);
|
||||
post = postRepository.save(post);
|
||||
searchIndexEventPublisher.publishPostSaved(post);
|
||||
notificationService.createNotification(
|
||||
post.getAuthor(),
|
||||
NotificationType.POST_REVIEWED,
|
||||
@@ -895,13 +1072,16 @@ public class PostService {
|
||||
if (!tag.isApproved()) {
|
||||
long count = postRepository.countDistinctByTags_Id(tag.getId());
|
||||
if (count <= 1) {
|
||||
Long tagId = tag.getId();
|
||||
post.getTags().remove(tag);
|
||||
tagRepository.delete(tag);
|
||||
searchIndexEventPublisher.publishTagDeleted(tagId);
|
||||
}
|
||||
}
|
||||
}
|
||||
post.setStatus(PostStatus.REJECTED);
|
||||
post = postRepository.save(post);
|
||||
searchIndexEventPublisher.publishPostDeleted(post.getId());
|
||||
notificationService.createNotification(
|
||||
post.getAuthor(),
|
||||
NotificationType.POST_REVIEWED,
|
||||
@@ -989,7 +1169,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");
|
||||
@@ -1021,6 +1202,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);
|
||||
@@ -1042,6 +1225,17 @@ 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);
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
@@ -1094,8 +1288,10 @@ public class PostService {
|
||||
}
|
||||
}
|
||||
String title = post.getTitle();
|
||||
Long postId = post.getId();
|
||||
postChangeLogService.deleteLogsForPost(post);
|
||||
postRepository.delete(post);
|
||||
searchIndexEventPublisher.publishPostDeleted(postId);
|
||||
if (adminDeleting) {
|
||||
notificationService.createNotification(
|
||||
author,
|
||||
|
||||
@@ -11,14 +11,30 @@ import com.openisle.repository.CommentRepository;
|
||||
import com.openisle.repository.PostRepository;
|
||||
import com.openisle.repository.TagRepository;
|
||||
import com.openisle.repository.UserRepository;
|
||||
import com.openisle.search.OpenSearchProperties;
|
||||
import com.openisle.search.SearchDocument;
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.opensearch.client.opensearch.OpenSearchClient;
|
||||
import org.opensearch.client.opensearch._types.FieldValue;
|
||||
import org.opensearch.client.opensearch._types.query_dsl.TextQueryType;
|
||||
import org.opensearch.client.opensearch.core.SearchResponse;
|
||||
import org.opensearch.client.opensearch.core.search.Hit;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.util.HtmlUtils;
|
||||
|
||||
@Service
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class SearchService {
|
||||
|
||||
@@ -27,10 +43,14 @@ public class SearchService {
|
||||
private final CommentRepository commentRepository;
|
||||
private final CategoryRepository categoryRepository;
|
||||
private final TagRepository tagRepository;
|
||||
private final Optional<OpenSearchClient> openSearchClient;
|
||||
private final OpenSearchProperties openSearchProperties;
|
||||
|
||||
@org.springframework.beans.factory.annotation.Value("${app.snippet-length}")
|
||||
private int snippetLength;
|
||||
|
||||
private static final int DEFAULT_OPEN_SEARCH_LIMIT = 50;
|
||||
|
||||
public List<User> searchUsers(String keyword) {
|
||||
return userRepository.findByUsernameContainingIgnoreCase(keyword);
|
||||
}
|
||||
@@ -64,49 +84,113 @@ public class SearchService {
|
||||
}
|
||||
|
||||
public List<SearchResult> globalSearch(String keyword) {
|
||||
if (keyword == null || keyword.isBlank()) {
|
||||
return List.of();
|
||||
}
|
||||
if (isOpenSearchEnabled()) {
|
||||
try {
|
||||
List<SearchResult> results = searchWithOpenSearch(keyword);
|
||||
if (!results.isEmpty()) {
|
||||
return results;
|
||||
}
|
||||
} catch (IOException e) {
|
||||
log.warn("OpenSearch global search failed, falling back to database query", e);
|
||||
}
|
||||
}
|
||||
return fallbackGlobalSearch(keyword);
|
||||
}
|
||||
|
||||
private List<SearchResult> fallbackGlobalSearch(String keyword) {
|
||||
final String effectiveKeyword = keyword == null ? "" : keyword.trim();
|
||||
Stream<SearchResult> users = searchUsers(keyword)
|
||||
.stream()
|
||||
.map(u ->
|
||||
new SearchResult("user", u.getId(), u.getUsername(), u.getIntroduction(), null, null)
|
||||
new SearchResult(
|
||||
"user",
|
||||
u.getId(),
|
||||
u.getUsername(),
|
||||
u.getIntroduction(),
|
||||
null,
|
||||
null,
|
||||
highlightHtml(u.getUsername(), effectiveKeyword),
|
||||
highlightHtml(u.getIntroduction(), effectiveKeyword),
|
||||
null
|
||||
)
|
||||
);
|
||||
|
||||
Stream<SearchResult> categories = searchCategories(keyword)
|
||||
.stream()
|
||||
.map(c ->
|
||||
new SearchResult("category", c.getId(), c.getName(), null, c.getDescription(), null)
|
||||
new SearchResult(
|
||||
"category",
|
||||
c.getId(),
|
||||
c.getName(),
|
||||
null,
|
||||
c.getDescription(),
|
||||
null,
|
||||
highlightHtml(c.getName(), effectiveKeyword),
|
||||
null,
|
||||
highlightHtml(c.getDescription(), effectiveKeyword)
|
||||
)
|
||||
);
|
||||
|
||||
Stream<SearchResult> tags = searchTags(keyword)
|
||||
.stream()
|
||||
.map(t -> new SearchResult("tag", t.getId(), t.getName(), null, t.getDescription(), null));
|
||||
.map(t ->
|
||||
new SearchResult(
|
||||
"tag",
|
||||
t.getId(),
|
||||
t.getName(),
|
||||
null,
|
||||
t.getDescription(),
|
||||
null,
|
||||
highlightHtml(t.getName(), effectiveKeyword),
|
||||
null,
|
||||
highlightHtml(t.getDescription(), effectiveKeyword)
|
||||
)
|
||||
);
|
||||
|
||||
// Merge post results while removing duplicates between search by content
|
||||
// and search by title
|
||||
List<SearchResult> mergedPosts = Stream.concat(
|
||||
searchPosts(keyword)
|
||||
.stream()
|
||||
.map(p ->
|
||||
new SearchResult(
|
||||
.map(p -> {
|
||||
String snippet = extractSnippet(p.getContent(), keyword, false);
|
||||
return new SearchResult(
|
||||
"post",
|
||||
p.getId(),
|
||||
p.getTitle(),
|
||||
p.getCategory() != null ? p.getCategory().getName() : null,
|
||||
extractSnippet(p.getContent(), keyword, false),
|
||||
null
|
||||
)
|
||||
),
|
||||
snippet,
|
||||
null,
|
||||
highlightHtml(p.getTitle(), effectiveKeyword),
|
||||
highlightHtml(
|
||||
p.getCategory() != null ? p.getCategory().getName() : null,
|
||||
effectiveKeyword
|
||||
),
|
||||
highlightHtml(snippet, effectiveKeyword)
|
||||
);
|
||||
}),
|
||||
searchPostsByTitle(keyword)
|
||||
.stream()
|
||||
.map(p ->
|
||||
new SearchResult(
|
||||
.map(p -> {
|
||||
String snippet = extractSnippet(p.getContent(), keyword, true);
|
||||
return new SearchResult(
|
||||
"post_title",
|
||||
p.getId(),
|
||||
p.getTitle(),
|
||||
p.getCategory() != null ? p.getCategory().getName() : null,
|
||||
extractSnippet(p.getContent(), keyword, true),
|
||||
null
|
||||
)
|
||||
)
|
||||
snippet,
|
||||
null,
|
||||
highlightHtml(p.getTitle(), effectiveKeyword),
|
||||
highlightHtml(
|
||||
p.getCategory() != null ? p.getCategory().getName() : null,
|
||||
effectiveKeyword
|
||||
),
|
||||
highlightHtml(snippet, effectiveKeyword)
|
||||
);
|
||||
})
|
||||
)
|
||||
.collect(
|
||||
java.util.stream.Collectors.toMap(
|
||||
@@ -122,22 +206,366 @@ public class SearchService {
|
||||
|
||||
Stream<SearchResult> comments = searchComments(keyword)
|
||||
.stream()
|
||||
.map(c ->
|
||||
new SearchResult(
|
||||
.map(c -> {
|
||||
String snippet = extractSnippet(c.getContent(), keyword, false);
|
||||
return new SearchResult(
|
||||
"comment",
|
||||
c.getId(),
|
||||
c.getPost().getTitle(),
|
||||
c.getAuthor().getUsername(),
|
||||
extractSnippet(c.getContent(), keyword, false),
|
||||
c.getPost().getId()
|
||||
)
|
||||
);
|
||||
snippet,
|
||||
c.getPost().getId(),
|
||||
highlightHtml(c.getPost().getTitle(), effectiveKeyword),
|
||||
highlightHtml(c.getAuthor().getUsername(), effectiveKeyword),
|
||||
highlightHtml(snippet, effectiveKeyword)
|
||||
);
|
||||
});
|
||||
|
||||
return Stream.of(users, categories, tags, mergedPosts.stream(), comments)
|
||||
.flatMap(s -> s)
|
||||
.toList();
|
||||
}
|
||||
|
||||
private boolean isOpenSearchEnabled() {
|
||||
return openSearchProperties.isEnabled() && openSearchClient.isPresent();
|
||||
}
|
||||
|
||||
// 在类里加上(字段或静态常量都可)
|
||||
private static final java.util.regex.Pattern HANS_PATTERN = java.util.regex.Pattern.compile(
|
||||
"\\p{IsHan}"
|
||||
);
|
||||
|
||||
private static boolean containsHan(String s) {
|
||||
return s != null && HANS_PATTERN.matcher(s).find();
|
||||
}
|
||||
|
||||
private List<SearchResult> searchWithOpenSearch(String keyword) throws IOException {
|
||||
var client = openSearchClient.orElse(null);
|
||||
if (client == null) return List.of();
|
||||
|
||||
final String qRaw = keyword == null ? "" : keyword.trim();
|
||||
if (qRaw.isEmpty()) return List.of();
|
||||
|
||||
final boolean hasHan = containsHan(qRaw);
|
||||
|
||||
SearchResponse<SearchDocument> resp = client.search(
|
||||
b ->
|
||||
b
|
||||
.index(searchIndices())
|
||||
.trackTotalHits(t -> t.enabled(true))
|
||||
.query(qb ->
|
||||
qb.bool(bool -> {
|
||||
// ---------- 严格层 ----------
|
||||
// 中文/任意短语(轻微符号/空白扰动)
|
||||
bool.should(s ->
|
||||
s.matchPhrase(mp -> mp.field("title").query(qRaw).slop(2).boost(6.0f))
|
||||
);
|
||||
bool.should(s ->
|
||||
s.matchPhrase(mp -> mp.field("content").query(qRaw).slop(2).boost(2.5f))
|
||||
);
|
||||
|
||||
// 结构化等值(.raw)
|
||||
bool.should(s ->
|
||||
s.term(t ->
|
||||
t
|
||||
.field("author.raw")
|
||||
.value(v -> v.stringValue(qRaw))
|
||||
.boost(4.0f)
|
||||
)
|
||||
);
|
||||
bool.should(s ->
|
||||
s.term(t ->
|
||||
t
|
||||
.field("category.raw")
|
||||
.value(v -> v.stringValue(qRaw))
|
||||
.boost(3.0f)
|
||||
)
|
||||
);
|
||||
bool.should(s ->
|
||||
s.term(t ->
|
||||
t
|
||||
.field("tags.raw")
|
||||
.value(v -> v.stringValue(qRaw))
|
||||
.boost(3.0f)
|
||||
)
|
||||
);
|
||||
|
||||
// 拼音短语(严格)
|
||||
bool.should(s ->
|
||||
s.matchPhrase(mp -> mp.field("title.py").query(qRaw).slop(1).boost(4.0f))
|
||||
);
|
||||
bool.should(s ->
|
||||
s.matchPhrase(mp -> mp.field("content.py").query(qRaw).slop(1).boost(1.8f))
|
||||
);
|
||||
bool.should(s ->
|
||||
s.matchPhrase(mp -> mp.field("author.py").query(qRaw).slop(1).boost(2.2f))
|
||||
);
|
||||
bool.should(s ->
|
||||
s.matchPhrase(mp -> mp.field("category.py").query(qRaw).slop(1).boost(2.0f))
|
||||
);
|
||||
bool.should(s ->
|
||||
s.matchPhrase(mp -> mp.field("tags.py").query(qRaw).slop(1).boost(2.0f))
|
||||
);
|
||||
|
||||
// ---------- 放宽层(仅当包含中文时启用) ----------
|
||||
if (hasHan) {
|
||||
// title.zh
|
||||
bool.should(s ->
|
||||
s.match(m ->
|
||||
m
|
||||
.field("title.zh")
|
||||
.query(org.opensearch.client.opensearch._types.FieldValue.of(qRaw))
|
||||
.operator(org.opensearch.client.opensearch._types.query_dsl.Operator.Or)
|
||||
.minimumShouldMatch("2<-1 3<-1 4<-1 5<-2 6<-2 7<-3")
|
||||
.boost(3.0f)
|
||||
)
|
||||
);
|
||||
// content.zh
|
||||
bool.should(s ->
|
||||
s.match(m ->
|
||||
m
|
||||
.field("content.zh")
|
||||
.query(org.opensearch.client.opensearch._types.FieldValue.of(qRaw))
|
||||
.operator(org.opensearch.client.opensearch._types.query_dsl.Operator.Or)
|
||||
.minimumShouldMatch("2<-1 3<-1 4<-1 5<-2 6<-2 7<-3")
|
||||
.boost(1.6f)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return bool.minimumShouldMatch("1");
|
||||
})
|
||||
)
|
||||
// ---------- 高亮:允许跨子字段回填 + 匹配字段组 ----------
|
||||
.highlight(h -> {
|
||||
var hb = h
|
||||
.preTags("<mark>")
|
||||
.postTags("</mark>")
|
||||
.requireFieldMatch(false)
|
||||
.fields("title", f ->
|
||||
f
|
||||
.fragmentSize(highlightFragmentSize())
|
||||
.numberOfFragments(1)
|
||||
.matchedFields(List.of("title", "title.zh", "title.py"))
|
||||
)
|
||||
.fields("content", f ->
|
||||
f
|
||||
.fragmentSize(highlightFragmentSize())
|
||||
.numberOfFragments(1)
|
||||
.matchedFields(List.of("content", "content.zh", "content.py"))
|
||||
)
|
||||
.fields("title.zh", f -> f.fragmentSize(highlightFragmentSize()).numberOfFragments(1))
|
||||
.fields("content.zh", f ->
|
||||
f.fragmentSize(highlightFragmentSize()).numberOfFragments(1)
|
||||
)
|
||||
.fields("title.py", f -> f.fragmentSize(highlightFragmentSize()).numberOfFragments(1))
|
||||
.fields("content.py", f ->
|
||||
f.fragmentSize(highlightFragmentSize()).numberOfFragments(1)
|
||||
)
|
||||
.fields("author", f -> f.numberOfFragments(0))
|
||||
.fields("author.py", f -> f.numberOfFragments(0))
|
||||
.fields("category", f -> f.numberOfFragments(0))
|
||||
.fields("category.py", f -> f.numberOfFragments(0))
|
||||
.fields("tags", f -> f.numberOfFragments(0))
|
||||
.fields("tags.py", f -> f.numberOfFragments(0));
|
||||
return hb;
|
||||
})
|
||||
.size(DEFAULT_OPEN_SEARCH_LIMIT > 0 ? DEFAULT_OPEN_SEARCH_LIMIT : 10),
|
||||
SearchDocument.class
|
||||
);
|
||||
|
||||
return mapHits(resp.hits().hits(), qRaw);
|
||||
}
|
||||
|
||||
/** Lucene query_string 安全转义(保留 * 由我们自己追加) */
|
||||
private static String escapeForQueryString(String s) {
|
||||
if (s == null || s.isEmpty()) return "";
|
||||
StringBuilder sb = new StringBuilder(s.length() * 2);
|
||||
for (char c : s.toCharArray()) {
|
||||
switch (c) {
|
||||
case '+':
|
||||
case '-':
|
||||
case '=':
|
||||
case '&':
|
||||
case '|':
|
||||
case '>':
|
||||
case '<':
|
||||
case '!':
|
||||
case '(':
|
||||
case ')':
|
||||
case '{':
|
||||
case '}':
|
||||
case '[':
|
||||
case ']':
|
||||
case '^':
|
||||
case '"':
|
||||
case '~': /* case '*': */ /* case '?': */
|
||||
case ':':
|
||||
case '\\':
|
||||
case '/':
|
||||
sb.append('\\').append(c);
|
||||
break;
|
||||
default:
|
||||
sb.append(c);
|
||||
}
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private int highlightFragmentSize() {
|
||||
int configured = openSearchProperties.getHighlightFragmentSize();
|
||||
if (configured > 0) {
|
||||
return configured;
|
||||
}
|
||||
if (snippetLength > 0) {
|
||||
return snippetLength;
|
||||
}
|
||||
return 200;
|
||||
}
|
||||
|
||||
private List<String> searchIndices() {
|
||||
return List.of(
|
||||
openSearchProperties.postsIndex(),
|
||||
openSearchProperties.commentsIndex(),
|
||||
openSearchProperties.usersIndex(),
|
||||
openSearchProperties.categoriesIndex(),
|
||||
openSearchProperties.tagsIndex()
|
||||
);
|
||||
}
|
||||
|
||||
private List<SearchResult> mapHits(List<Hit<SearchDocument>> hits, String keyword) {
|
||||
List<SearchResult> results = new ArrayList<>();
|
||||
for (Hit<SearchDocument> hit : hits) {
|
||||
SearchResult result = mapHit(hit, keyword);
|
||||
if (result != null) {
|
||||
results.add(result);
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
private SearchResult mapHit(Hit<SearchDocument> hit, String keyword) {
|
||||
SearchDocument document = hit.source();
|
||||
if (document == null || document.entityId() == null) {
|
||||
return null;
|
||||
}
|
||||
Map<String, List<String>> highlight = hit.highlight();
|
||||
String highlightedContent = firstHighlight(
|
||||
highlight,
|
||||
"content",
|
||||
"content.py",
|
||||
"content.zh",
|
||||
"content.raw"
|
||||
);
|
||||
String highlightedTitle = firstHighlight(
|
||||
highlight,
|
||||
"title",
|
||||
"title.py",
|
||||
"title.zh",
|
||||
"title.raw"
|
||||
);
|
||||
String highlightedAuthor = firstHighlight(highlight, "author", "author.py");
|
||||
String highlightedCategory = firstHighlight(highlight, "category", "category.py");
|
||||
boolean highlightTitle = highlightedTitle != null && !highlightedTitle.isBlank();
|
||||
String documentType = document.type() != null ? document.type() : "";
|
||||
String effectiveType = documentType;
|
||||
if ("post".equals(documentType) && highlightTitle) {
|
||||
effectiveType = "post_title";
|
||||
}
|
||||
String snippetHtml = highlightedContent != null && !highlightedContent.isBlank()
|
||||
? highlightedContent
|
||||
: null;
|
||||
if (snippetHtml == null && highlightTitle) {
|
||||
snippetHtml = highlightedTitle;
|
||||
}
|
||||
|
||||
String snippet = snippetHtml != null && !snippetHtml.isBlank()
|
||||
? cleanHighlight(snippetHtml)
|
||||
: null;
|
||||
boolean fromStart = "post_title".equals(effectiveType);
|
||||
if (snippet == null || snippet.isBlank()) {
|
||||
snippet = fallbackSnippet(document.content(), keyword, fromStart);
|
||||
if (snippetHtml == null) {
|
||||
snippetHtml = highlightHtml(snippet, keyword);
|
||||
}
|
||||
} else if (snippetHtml == null) {
|
||||
snippetHtml = highlightHtml(snippet, keyword);
|
||||
}
|
||||
if (snippet == null) {
|
||||
snippet = "";
|
||||
}
|
||||
String subText = null;
|
||||
Long postId = null;
|
||||
if ("post".equals(documentType) || "post_title".equals(effectiveType)) {
|
||||
subText = document.category();
|
||||
} else if ("comment".equals(documentType)) {
|
||||
subText = document.author();
|
||||
postId = document.postId();
|
||||
}
|
||||
String highlightedText = highlightTitle
|
||||
? highlightedTitle
|
||||
: highlightHtml(document.title(), keyword);
|
||||
String highlightedSubText;
|
||||
if ("comment".equals(documentType)) {
|
||||
highlightedSubText = highlightedAuthor != null && !highlightedAuthor.isBlank()
|
||||
? highlightedAuthor
|
||||
: highlightHtml(subText, keyword);
|
||||
} else if ("post".equals(documentType) || "post_title".equals(effectiveType)) {
|
||||
highlightedSubText = highlightedCategory != null && !highlightedCategory.isBlank()
|
||||
? highlightedCategory
|
||||
: highlightHtml(subText, keyword);
|
||||
} else {
|
||||
highlightedSubText = highlightHtml(subText, keyword);
|
||||
}
|
||||
String highlightedExtra = snippetHtml != null ? snippetHtml : highlightHtml(snippet, keyword);
|
||||
return new SearchResult(
|
||||
effectiveType,
|
||||
document.entityId(),
|
||||
document.title(),
|
||||
subText,
|
||||
snippet,
|
||||
postId,
|
||||
highlightedText,
|
||||
highlightedSubText,
|
||||
highlightedExtra
|
||||
);
|
||||
}
|
||||
|
||||
private String firstHighlight(Map<String, List<String>> highlight, String... fields) {
|
||||
if (highlight == null || fields == null) {
|
||||
return null;
|
||||
}
|
||||
for (String field : fields) {
|
||||
if (field == null) {
|
||||
continue;
|
||||
}
|
||||
List<String> values = highlight.get(field);
|
||||
if (values == null || values.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
for (String value : values) {
|
||||
if (value != null && !value.isBlank()) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String cleanHighlight(String value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
return value.replaceAll("<[^>]+>", "");
|
||||
}
|
||||
|
||||
private String fallbackSnippet(String content, String keyword, boolean fromStart) {
|
||||
if (content == null) {
|
||||
return "";
|
||||
}
|
||||
return extractSnippet(content, keyword, fromStart);
|
||||
}
|
||||
|
||||
private String extractSnippet(String content, String keyword, boolean fromStart) {
|
||||
if (content == null) return "";
|
||||
int limit = snippetLength;
|
||||
@@ -165,12 +593,45 @@ public class SearchService {
|
||||
return snippet;
|
||||
}
|
||||
|
||||
private String highlightHtml(String text, String keyword) {
|
||||
if (text == null) {
|
||||
return null;
|
||||
}
|
||||
String normalizedKeyword = keyword == null ? "" : keyword.trim();
|
||||
if (normalizedKeyword.isEmpty()) {
|
||||
return HtmlUtils.htmlEscape(text);
|
||||
}
|
||||
Pattern pattern = Pattern.compile(
|
||||
Pattern.quote(normalizedKeyword),
|
||||
Pattern.CASE_INSENSITIVE | Pattern.UNICODE_CASE
|
||||
);
|
||||
Matcher matcher = pattern.matcher(text);
|
||||
if (!matcher.find()) {
|
||||
return HtmlUtils.htmlEscape(text);
|
||||
}
|
||||
matcher.reset();
|
||||
StringBuilder sb = new StringBuilder();
|
||||
int lastEnd = 0;
|
||||
while (matcher.find()) {
|
||||
sb.append(HtmlUtils.htmlEscape(text.substring(lastEnd, matcher.start())));
|
||||
sb.append("<mark>");
|
||||
sb.append(HtmlUtils.htmlEscape(matcher.group()));
|
||||
sb.append("</mark>");
|
||||
lastEnd = matcher.end();
|
||||
}
|
||||
sb.append(HtmlUtils.htmlEscape(text.substring(lastEnd)));
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
public record SearchResult(
|
||||
String type,
|
||||
Long id,
|
||||
String text,
|
||||
String subText,
|
||||
String extra,
|
||||
Long postId
|
||||
Long postId,
|
||||
String highlightedText,
|
||||
String highlightedSubText,
|
||||
String highlightedExtra
|
||||
) {}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import com.openisle.model.Tag;
|
||||
import com.openisle.model.User;
|
||||
import com.openisle.repository.TagRepository;
|
||||
import com.openisle.repository.UserRepository;
|
||||
import com.openisle.search.SearchIndexEventPublisher;
|
||||
import java.util.List;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.cache.annotation.CacheEvict;
|
||||
@@ -20,6 +21,7 @@ public class TagService {
|
||||
private final TagRepository tagRepository;
|
||||
private final TagValidator tagValidator;
|
||||
private final UserRepository userRepository;
|
||||
private final SearchIndexEventPublisher searchIndexEventPublisher;
|
||||
|
||||
@CacheEvict(value = CachingConfig.TAG_CACHE_NAME, allEntries = true)
|
||||
public Tag createTag(
|
||||
@@ -43,7 +45,9 @@ public class TagService {
|
||||
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
|
||||
tag.setCreator(creator);
|
||||
}
|
||||
return tagRepository.save(tag);
|
||||
Tag saved = tagRepository.save(tag);
|
||||
searchIndexEventPublisher.publishTagSaved(saved);
|
||||
return saved;
|
||||
}
|
||||
|
||||
public Tag createTag(
|
||||
@@ -78,12 +82,15 @@ public class TagService {
|
||||
if (smallIcon != null) {
|
||||
tag.setSmallIcon(smallIcon);
|
||||
}
|
||||
return tagRepository.save(tag);
|
||||
Tag saved = tagRepository.save(tag);
|
||||
searchIndexEventPublisher.publishTagSaved(saved);
|
||||
return saved;
|
||||
}
|
||||
|
||||
@CacheEvict(value = CachingConfig.TAG_CACHE_NAME, allEntries = true)
|
||||
public void deleteTag(Long id) {
|
||||
tagRepository.deleteById(id);
|
||||
searchIndexEventPublisher.publishTagDeleted(id);
|
||||
}
|
||||
|
||||
public Tag approveTag(Long id) {
|
||||
@@ -91,7 +98,9 @@ public class TagService {
|
||||
.findById(id)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Tag not found"));
|
||||
tag.setApproved(true);
|
||||
return tagRepository.save(tag);
|
||||
Tag saved = tagRepository.save(tag);
|
||||
searchIndexEventPublisher.publishTagSaved(saved);
|
||||
return saved;
|
||||
}
|
||||
|
||||
public List<Tag> listPendingTags() {
|
||||
|
||||
@@ -5,6 +5,7 @@ import com.openisle.exception.FieldException;
|
||||
import com.openisle.model.Role;
|
||||
import com.openisle.model.User;
|
||||
import com.openisle.repository.UserRepository;
|
||||
import com.openisle.search.SearchIndexEventPublisher;
|
||||
import com.openisle.service.AvatarGenerator;
|
||||
import com.openisle.service.PasswordValidator;
|
||||
import com.openisle.service.UsernameValidator;
|
||||
@@ -34,6 +35,7 @@ public class UserService {
|
||||
private final RedisTemplate redisTemplate;
|
||||
|
||||
private final EmailSender emailService;
|
||||
private final SearchIndexEventPublisher searchIndexEventPublisher;
|
||||
|
||||
public User register(
|
||||
String username,
|
||||
@@ -58,7 +60,9 @@ public class UserService {
|
||||
// u.setVerificationCode(genCode());
|
||||
u.setRegisterReason(reason);
|
||||
u.setApproved(mode == com.openisle.model.RegisterMode.DIRECT);
|
||||
return userRepository.save(u);
|
||||
User saved = userRepository.save(u);
|
||||
searchIndexEventPublisher.publishUserSaved(saved);
|
||||
return saved;
|
||||
}
|
||||
|
||||
// ── 再按邮箱查 ───────────────────────────────────────────
|
||||
@@ -75,7 +79,9 @@ public class UserService {
|
||||
// u.setVerificationCode(genCode());
|
||||
u.setRegisterReason(reason);
|
||||
u.setApproved(mode == com.openisle.model.RegisterMode.DIRECT);
|
||||
return userRepository.save(u);
|
||||
User saved = userRepository.save(u);
|
||||
searchIndexEventPublisher.publishUserSaved(saved);
|
||||
return saved;
|
||||
}
|
||||
|
||||
// ── 完全新用户 ───────────────────────────────────────────
|
||||
@@ -89,14 +95,18 @@ public class UserService {
|
||||
user.setAvatar(avatarGenerator.generate(username));
|
||||
user.setRegisterReason(reason);
|
||||
user.setApproved(mode == com.openisle.model.RegisterMode.DIRECT);
|
||||
return userRepository.save(user);
|
||||
User saved = userRepository.save(user);
|
||||
searchIndexEventPublisher.publishUserSaved(saved);
|
||||
return saved;
|
||||
}
|
||||
|
||||
public User registerWithInvite(String username, String email, String password) {
|
||||
User user = register(username, email, password, "", com.openisle.model.RegisterMode.DIRECT);
|
||||
user.setVerified(true);
|
||||
// user.setVerificationCode(genCode());
|
||||
return userRepository.save(user);
|
||||
User saved = userRepository.save(user);
|
||||
searchIndexEventPublisher.publishUserSaved(saved);
|
||||
return saved;
|
||||
}
|
||||
|
||||
private String genCode() {
|
||||
@@ -209,7 +219,9 @@ public class UserService {
|
||||
.findByUsername(username)
|
||||
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
|
||||
user.setRegisterReason(reason);
|
||||
return userRepository.save(user);
|
||||
User saved = userRepository.save(user);
|
||||
searchIndexEventPublisher.publishUserSaved(saved);
|
||||
return saved;
|
||||
}
|
||||
|
||||
public User updateProfile(String currentUsername, String newUsername, String introduction) {
|
||||
|
||||
@@ -4,7 +4,7 @@ server.port=${SERVER_PORT:8080}
|
||||
# for mysql
|
||||
logging.level.root=${LOG_LEVEL:INFO}
|
||||
logging.level.com.openisle.service.CosImageUploader=DEBUG
|
||||
spring.datasource.url=${MYSQL_URL:jdbc:mysql://localhost:3306/openisle}
|
||||
spring.datasource.url=jdbc:mysql://${MYSQL_HOST}:${MYSQL_PORT}/${MYSQL_DATABASE}?useUnicode=yes&characterEncoding=UTF-8&useInformationSchema=true&useSSL=false&serverTimezone=UTC&allowPublicKeyRetrieval=true
|
||||
spring.datasource.username=${MYSQL_USER:root}
|
||||
spring.datasource.password=${MYSQL_PASSWORD:password}
|
||||
spring.jpa.hibernate.ddl-auto=update
|
||||
@@ -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}
|
||||
@@ -45,6 +46,18 @@ app.user.replies-limit=${USER_REPLIES_LIMIT:50}
|
||||
# Length of extracted snippets for posts and search (-1 to disable truncation)
|
||||
app.snippet-length=${SNIPPET_LENGTH:200}
|
||||
|
||||
# OpenSearch integration
|
||||
app.search.enabled=${SEARCH_ENABLED:true}
|
||||
app.search.host=${OPENSEARCH_HOST:opensearch}
|
||||
app.search.port=${OPENSEARCH_PORT:9200}
|
||||
app.search.scheme=${OPENSEARCH_SCHEME:http}
|
||||
app.search.username=${OPENSEARCH_USERNAME:}
|
||||
app.search.password=${OPENSEARCH_PASSWORD:}
|
||||
app.search.index-prefix=${SEARCH_INDEX_PREFIX:openisle}
|
||||
app.search.highlight-fragment-size=${SEARCH_HIGHLIGHT_FRAGMENT_SIZE:${SNIPPET_LENGTH:200}}
|
||||
app.search.reindex-on-startup=${SEARCH_REINDEX_ON_STARTUP:true}
|
||||
app.search.reindex-batch-size=${SEARCH_REINDEX_BATCH_SIZE:500}
|
||||
|
||||
# Captcha configuration
|
||||
app.captcha.enabled=${CAPTCHA_ENABLED:false}
|
||||
recaptcha.secret-key=${RECAPTCHA_SECRET_KEY:}
|
||||
@@ -69,15 +82,15 @@ cos.bucket-name=${COS_BUCKET_NAME:}
|
||||
# your image upload services: ...
|
||||
|
||||
# Google OAuth configuration
|
||||
google.client-id=${GOOGLE_CLIENT_ID:}
|
||||
google.client-id=${NUXT_PUBLIC_GOOGLE_CLIENT_ID:}
|
||||
# GitHub OAuth configuration
|
||||
github.client-id=${GITHUB_CLIENT_ID:}
|
||||
github.client-id=${NUXT_PUBLIC_GITHUB_CLIENT_ID:}
|
||||
github.client-secret=${GITHUB_CLIENT_SECRET:}
|
||||
# Discord OAuth configuration
|
||||
discord.client-id=${DISCORD_CLIENT_ID:}
|
||||
discord.client-id=${NUXT_PUBLIC_DISCORD_CLIENT_ID:}
|
||||
discord.client-secret=${DISCORD_CLIENT_SECRET:}
|
||||
# Twitter OAuth configuration
|
||||
twitter.client-id=${TWITTER_CLIENT_ID:}
|
||||
twitter.client-id=${NUXT_PUBLIC_TWITTER_CLIENT_ID:}
|
||||
twitter.client-secret=${TWITTER_CLIENT_SECRET:}
|
||||
# Telegram login configuration
|
||||
telegram.bot-token=${TELEGRAM_BOT_TOKEN:}
|
||||
@@ -117,3 +130,6 @@ springdoc.info.description=OpenIsle Open API Documentation
|
||||
springdoc.info.version=0.0.1
|
||||
springdoc.info.scheme=Bearer
|
||||
springdoc.info.header=Authorization
|
||||
|
||||
management.endpoints.web.exposure.include=health,info
|
||||
management.endpoint.health.probes.enabled=true
|
||||
13
backend/src/main/resources/db/init/00_init_db_and_user.sql
Normal file
13
backend/src/main/resources/db/init/00_init_db_and_user.sql
Normal file
@@ -0,0 +1,13 @@
|
||||
SET NAMES utf8mb4;
|
||||
SET CHARACTER SET utf8mb4;
|
||||
SET collation_connection = utf8mb4_0900_ai_ci;
|
||||
|
||||
CREATE DATABASE IF NOT EXISTS `openisle`
|
||||
CHARACTER SET utf8mb4
|
||||
COLLATE utf8mb4_0900_ai_ci;
|
||||
|
||||
CREATE USER IF NOT EXISTS 'openisle'@'%' IDENTIFIED BY 'openisle';
|
||||
GRANT ALL PRIVILEGES ON `openisle`.* TO 'openisle'@'%';
|
||||
FLUSH PRIVILEGES;
|
||||
|
||||
USE `openisle`;
|
||||
54
backend/src/main/resources/db/init/01_schema.sql
Normal file
54
backend/src/main/resources/db/init/01_schema.sql
Normal file
@@ -0,0 +1,54 @@
|
||||
USE `openisle`;
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
SET FOREIGN_KEY_CHECKS = 0;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `users` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT,
|
||||
`approved` bit(1) DEFAULT NULL,
|
||||
`avatar` varchar(255) DEFAULT NULL,
|
||||
`created_at` datetime(6) DEFAULT NULL,
|
||||
`display_medal` varchar(255) DEFAULT NULL,
|
||||
`email` varchar(255) NOT NULL,
|
||||
`experience` int DEFAULT NULL,
|
||||
`introduction` text,
|
||||
`password` varchar(255) NOT NULL,
|
||||
`password_reset_code` varchar(255) DEFAULT NULL,
|
||||
`point` int DEFAULT NULL,
|
||||
`register_reason` text,
|
||||
`role` varchar(20) DEFAULT 'USER',
|
||||
`username` varchar(50) NOT NULL,
|
||||
`verification_code` varchar(255) DEFAULT NULL,
|
||||
`verified` bit(1) DEFAULT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `UK_users_email` (`email`),
|
||||
UNIQUE KEY `UK_users_username` (`username`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `categories` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT,
|
||||
`description` text,
|
||||
`icon` varchar(255) DEFAULT NULL,
|
||||
`name` varchar(50) NOT NULL,
|
||||
`small_icon` varchar(255) DEFAULT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `UK_categories_name` (`name`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `tags` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT,
|
||||
`approved` bit(1) DEFAULT NULL,
|
||||
`created_at` datetime(6) DEFAULT NULL,
|
||||
`description` text,
|
||||
`icon` varchar(255) DEFAULT NULL,
|
||||
`name` varchar(50) NOT NULL,
|
||||
`small_icon` varchar(255) DEFAULT NULL,
|
||||
`creator_id` bigint DEFAULT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `UK_tags_name` (`name`),
|
||||
KEY `FK_tags_creator` (`creator_id`),
|
||||
CONSTRAINT `FK_tags_creator` FOREIGN KEY (`creator_id`) REFERENCES `users` (`id`)
|
||||
ON DELETE SET NULL ON UPDATE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
|
||||
|
||||
SET FOREIGN_KEY_CHECKS = 1;
|
||||
26
backend/src/main/resources/db/init/02_seed_data.sql
Normal file
26
backend/src/main/resources/db/init/02_seed_data.sql
Normal file
@@ -0,0 +1,26 @@
|
||||
USE `openisle`;
|
||||
SET NAMES utf8mb4;
|
||||
SET FOREIGN_KEY_CHECKS = 0;
|
||||
|
||||
DELETE FROM `tags`;
|
||||
DELETE FROM `categories`;
|
||||
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 `categories` (`id`,`description`,`icon`,`name`,`small_icon`) VALUES
|
||||
(1,'测试用分类1','star','测试用分类1',NULL),
|
||||
(2,'测试用分类2','star','测试用分类2',NULL),
|
||||
(3,'测试用分类3','star','测试用分类3',NULL);
|
||||
|
||||
INSERT INTO `tags` (`id`,`approved`,`created_at`,`description`,`icon`,`name`,`small_icon`,`creator_id`) VALUES
|
||||
(1,b'1','2025-09-02 10:51:56.000000','测试用标签1',NULL,'测试用标签1',NULL,NULL),
|
||||
(2,b'1','2025-09-02 10:51:56.000000','测试用标签2',NULL,'测试用标签2',NULL,NULL),
|
||||
(3,b'1','2025-09-02 10:51:56.000000','测试用标签3',NULL,'测试用标签3',NULL,NULL);
|
||||
|
||||
SET FOREIGN_KEY_CHECKS = 1;
|
||||
@@ -1,81 +0,0 @@
|
||||
-- 2025-09-02
|
||||
-- 本地化开发,初始化脚本
|
||||
-- 抽奖的时候奖品图片是必须的,把相关代码注释掉即可跳过check
|
||||
|
||||
-- 设置字符集和排序规则
|
||||
SET NAMES utf8;
|
||||
SET CHARACTER SET utf8;
|
||||
SET collation_connection = utf8_general_ci;
|
||||
|
||||
-- 创建 users 表(如果不存在)
|
||||
CREATE TABLE IF NOT EXISTS `users` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT,
|
||||
`approved` bit(1) DEFAULT NULL,
|
||||
`avatar` varchar(255) DEFAULT NULL,
|
||||
`created_at` datetime(6) DEFAULT NULL,
|
||||
`display_medal` varchar(255) DEFAULT NULL,
|
||||
`email` varchar(255) NOT NULL,
|
||||
`experience` int DEFAULT NULL,
|
||||
`introduction` text,
|
||||
`password` varchar(255) NOT NULL,
|
||||
`password_reset_code` varchar(255) DEFAULT NULL,
|
||||
`point` int DEFAULT NULL,
|
||||
`register_reason` text,
|
||||
`role` varchar(20) DEFAULT 'USER',
|
||||
`username` varchar(50) NOT NULL,
|
||||
`verification_code` varchar(255) DEFAULT NULL,
|
||||
`verified` bit(1) DEFAULT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `UK_users_email` (`email`),
|
||||
UNIQUE KEY `UK_users_username` (`username`)
|
||||
);
|
||||
|
||||
-- 清空users表
|
||||
DELETE FROM `users`;
|
||||
-- 插入用户,两个普通用户,一个管理员
|
||||
-- username:admin/user1/user2 password:123321
|
||||
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', '', '2025-09-01 16:08:17.426430', 'PIONEER', 'adminmail@openisle.com', 70, NULL, '$2a$10$dux.NXwW09cCsdZ05BgcnOtxVqqjcmnbj3.8xcxGl/iiIlv06y7Oe', NULL, 110, '测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试', 'ADMIN', 'admin', NULL, b'1'),
|
||||
(2, b'1', '', '2025-09-03 16:08:17.426430', 'PIONEER', 'usermail2@openisle.com', 70, NULL, '$2a$10$dux.NXwW09cCsdZ05BgcnOtxVqqjcmnbj3.8xcxGl/iiIlv06y7Oe', NULL, 110, '测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试', 'USER', 'user1', NULL, b'1'),
|
||||
(3, b'1', '', '2025-09-02 17:21:21.617666', 'PIONEER', 'usermail1@openisle.com', 40, NULL, '$2a$10$dux.NXwW09cCsdZ05BgcnOtxVqqjcmnbj3.8xcxGl/iiIlv06y7Oe', NULL, 40, '测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试', 'USER', 'user2', NULL, b'1');
|
||||
|
||||
-- 创建 tags 表(如果不存在)
|
||||
CREATE TABLE IF NOT EXISTS `tags` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT,
|
||||
`approved` bit(1) DEFAULT NULL,
|
||||
`created_at` datetime(6) DEFAULT NULL,
|
||||
`description` text,
|
||||
`icon` varchar(255) DEFAULT NULL,
|
||||
`name` varchar(50) NOT NULL,
|
||||
`small_icon` varchar(255) DEFAULT NULL,
|
||||
`creator_id` bigint DEFAULT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `UK_tags_name` (`name`),
|
||||
KEY `FK_tags_creator` (`creator_id`),
|
||||
CONSTRAINT `FK_tags_creator` FOREIGN KEY (`creator_id`) REFERENCES `users` (`id`)
|
||||
);
|
||||
-- 清空tags表
|
||||
DELETE FROM `tags`;
|
||||
-- 插入标签,三个测试用标签
|
||||
INSERT INTO `tags` (`id`, `approved`, `created_at`, `description`, `icon`, `name`, `small_icon`, `creator_id`) VALUES
|
||||
(1, b'1', '2025-09-02 10:51:56.000000', '测试用标签1', NULL, '测试用标签1', NULL, NULL),
|
||||
(2, b'1', '2025-09-02 10:51:56.000000', '测试用标签2', NULL, '测试用标签2', NULL, NULL),
|
||||
(3, b'1', '2025-09-02 10:51:56.000000', '测试用标签3', NULL, '测试用标签3', NULL, NULL);
|
||||
|
||||
-- 创建 categories 表(如果不存在)
|
||||
CREATE TABLE IF NOT EXISTS `categories` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT,
|
||||
`description` text,
|
||||
`icon` varchar(255) DEFAULT NULL,
|
||||
`name` varchar(50) NOT NULL,
|
||||
`small_icon` varchar(255) DEFAULT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `UK_categories_name` (`name`)
|
||||
);
|
||||
-- 清空categories表
|
||||
DELETE FROM `categories`;
|
||||
-- 插入分类,三个测试用分类
|
||||
INSERT INTO `categories` (`id`, `description`, `icon`, `name`, `small_icon`) VALUES
|
||||
(1, '测试用分类1', '1', '测试用分类1', NULL),
|
||||
(2, '测试用分类2', '2', '测试用分类2', NULL),
|
||||
(3, '测试用分类3', '3', '测试用分类3', 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);
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -68,9 +68,9 @@ class SearchControllerTest {
|
||||
c.setContent("nice");
|
||||
Mockito.when(searchService.globalSearch("n")).thenReturn(
|
||||
List.of(
|
||||
new SearchService.SearchResult("user", 1L, "bob", null, null, null),
|
||||
new SearchService.SearchResult("post", 2L, "hello", null, null, null),
|
||||
new SearchService.SearchResult("comment", 3L, "nice", null, null, null)
|
||||
new SearchService.SearchResult("user", 1L, "bob", null, null, null, null, null, null),
|
||||
new SearchService.SearchResult("post", 2L, "hello", null, null, null, null, null, null),
|
||||
new SearchService.SearchResult("comment", 3L, "nice", null, null, null, null, null, null)
|
||||
)
|
||||
);
|
||||
|
||||
|
||||
@@ -82,6 +82,7 @@ class TagControllerTest {
|
||||
t.setIcon("i2");
|
||||
t.setSmallIcon("s2");
|
||||
Mockito.when(tagService.searchTags(null)).thenReturn(List.of(t));
|
||||
Mockito.when(postService.countPostsByTagIds(List.of(2L))).thenReturn(java.util.Map.of());
|
||||
|
||||
mockMvc
|
||||
.perform(get("/api/tags"))
|
||||
@@ -93,6 +94,31 @@ class TagControllerTest {
|
||||
.andExpect(jsonPath("$[0].smallIcon").value("s2"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void listTagsWithPagination() throws Exception {
|
||||
Tag t1 = new Tag();
|
||||
t1.setId(1L);
|
||||
t1.setName("java");
|
||||
Tag t2 = new Tag();
|
||||
t2.setId(2L);
|
||||
t2.setName("spring");
|
||||
Mockito.when(tagService.searchTags(null)).thenReturn(List.of(t1, t2));
|
||||
Mockito.when(postService.countPostsByTagIds(List.of(1L, 2L))).thenReturn(
|
||||
java.util.Map.of(1L, 1L, 2L, 5L)
|
||||
);
|
||||
|
||||
mockMvc
|
||||
.perform(get("/api/tags").param("page", "1").param("pageSize", "1"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$", org.hamcrest.Matchers.hasSize(1)))
|
||||
.andExpect(jsonPath("$[0].id").value(1));
|
||||
|
||||
mockMvc
|
||||
.perform(get("/api/tags").param("page", "2").param("pageSize", "1"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$", org.hamcrest.Matchers.hasSize(0)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateTag() throws Exception {
|
||||
Tag t = new Tag();
|
||||
|
||||
@@ -11,6 +11,7 @@ import com.openisle.repository.PointHistoryRepository;
|
||||
import com.openisle.repository.PostRepository;
|
||||
import com.openisle.repository.ReactionRepository;
|
||||
import com.openisle.repository.UserRepository;
|
||||
import com.openisle.search.SearchIndexEventPublisher;
|
||||
import com.openisle.service.PointService;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
@@ -29,6 +30,7 @@ class CommentServiceTest {
|
||||
PointHistoryRepository pointHistoryRepo = mock(PointHistoryRepository.class);
|
||||
PointService pointService = mock(PointService.class);
|
||||
ImageUploader imageUploader = mock(ImageUploader.class);
|
||||
SearchIndexEventPublisher searchIndexEventPublisher = mock(SearchIndexEventPublisher.class);
|
||||
|
||||
CommentService service = new CommentService(
|
||||
commentRepo,
|
||||
@@ -41,7 +43,8 @@ class CommentServiceTest {
|
||||
nRepo,
|
||||
pointHistoryRepo,
|
||||
pointService,
|
||||
imageUploader
|
||||
imageUploader,
|
||||
searchIndexEventPublisher
|
||||
);
|
||||
|
||||
when(commentRepo.countByAuthorAfter(eq("alice"), any())).thenReturn(3L);
|
||||
|
||||
@@ -6,6 +6,7 @@ import static org.mockito.Mockito.*;
|
||||
import com.openisle.exception.RateLimitException;
|
||||
import com.openisle.model.*;
|
||||
import com.openisle.repository.*;
|
||||
import com.openisle.search.SearchIndexEventPublisher;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
@@ -25,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);
|
||||
@@ -42,6 +44,7 @@ class PostServiceTest {
|
||||
PostChangeLogService postChangeLogService = mock(PostChangeLogService.class);
|
||||
PointHistoryRepository pointHistoryRepository = mock(PointHistoryRepository.class);
|
||||
RedisTemplate redisTemplate = mock(RedisTemplate.class);
|
||||
SearchIndexEventPublisher searchIndexEventPublisher = mock(SearchIndexEventPublisher.class);
|
||||
|
||||
PostService service = new PostService(
|
||||
postRepo,
|
||||
@@ -50,6 +53,7 @@ class PostServiceTest {
|
||||
tagRepo,
|
||||
lotteryRepo,
|
||||
pollPostRepo,
|
||||
proposalRepo,
|
||||
pollVoteRepo,
|
||||
notifService,
|
||||
subService,
|
||||
@@ -67,7 +71,8 @@ class PostServiceTest {
|
||||
postChangeLogService,
|
||||
pointHistoryRepository,
|
||||
PublishMode.DIRECT,
|
||||
redisTemplate
|
||||
redisTemplate,
|
||||
searchIndexEventPublisher
|
||||
);
|
||||
when(context.getBean(PostService.class)).thenReturn(service);
|
||||
|
||||
@@ -101,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);
|
||||
@@ -118,6 +124,7 @@ class PostServiceTest {
|
||||
PostChangeLogService postChangeLogService = mock(PostChangeLogService.class);
|
||||
PointHistoryRepository pointHistoryRepository = mock(PointHistoryRepository.class);
|
||||
RedisTemplate redisTemplate = mock(RedisTemplate.class);
|
||||
SearchIndexEventPublisher searchIndexEventPublisher = mock(SearchIndexEventPublisher.class);
|
||||
|
||||
PostService service = new PostService(
|
||||
postRepo,
|
||||
@@ -126,6 +133,7 @@ class PostServiceTest {
|
||||
tagRepo,
|
||||
lotteryRepo,
|
||||
pollPostRepo,
|
||||
proposalRepo,
|
||||
pollVoteRepo,
|
||||
notifService,
|
||||
subService,
|
||||
@@ -143,7 +151,8 @@ class PostServiceTest {
|
||||
postChangeLogService,
|
||||
pointHistoryRepository,
|
||||
PublishMode.DIRECT,
|
||||
redisTemplate
|
||||
redisTemplate,
|
||||
searchIndexEventPublisher
|
||||
);
|
||||
when(context.getBean(PostService.class)).thenReturn(service);
|
||||
|
||||
@@ -190,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);
|
||||
@@ -207,6 +217,7 @@ class PostServiceTest {
|
||||
PostChangeLogService postChangeLogService = mock(PostChangeLogService.class);
|
||||
PointHistoryRepository pointHistoryRepository = mock(PointHistoryRepository.class);
|
||||
RedisTemplate redisTemplate = mock(RedisTemplate.class);
|
||||
SearchIndexEventPublisher searchIndexEventPublisher = mock(SearchIndexEventPublisher.class);
|
||||
|
||||
PostService service = new PostService(
|
||||
postRepo,
|
||||
@@ -215,6 +226,7 @@ class PostServiceTest {
|
||||
tagRepo,
|
||||
lotteryRepo,
|
||||
pollPostRepo,
|
||||
proposalRepo,
|
||||
pollVoteRepo,
|
||||
notifService,
|
||||
subService,
|
||||
@@ -232,7 +244,8 @@ class PostServiceTest {
|
||||
postChangeLogService,
|
||||
pointHistoryRepository,
|
||||
PublishMode.DIRECT,
|
||||
redisTemplate
|
||||
redisTemplate,
|
||||
searchIndexEventPublisher
|
||||
);
|
||||
when(context.getBean(PostService.class)).thenReturn(service);
|
||||
|
||||
@@ -253,6 +266,11 @@ class PostServiceTest {
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
)
|
||||
);
|
||||
@@ -266,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);
|
||||
@@ -283,6 +302,7 @@ class PostServiceTest {
|
||||
PostChangeLogService postChangeLogService = mock(PostChangeLogService.class);
|
||||
PointHistoryRepository pointHistoryRepository = mock(PointHistoryRepository.class);
|
||||
RedisTemplate redisTemplate = mock(RedisTemplate.class);
|
||||
SearchIndexEventPublisher searchIndexEventPublisher = mock(SearchIndexEventPublisher.class);
|
||||
|
||||
PostService service = new PostService(
|
||||
postRepo,
|
||||
@@ -291,6 +311,7 @@ class PostServiceTest {
|
||||
tagRepo,
|
||||
lotteryRepo,
|
||||
pollPostRepo,
|
||||
proposalRepo,
|
||||
pollVoteRepo,
|
||||
notifService,
|
||||
subService,
|
||||
@@ -308,7 +329,8 @@ class PostServiceTest {
|
||||
postChangeLogService,
|
||||
pointHistoryRepository,
|
||||
PublishMode.DIRECT,
|
||||
redisTemplate
|
||||
redisTemplate,
|
||||
searchIndexEventPublisher
|
||||
);
|
||||
when(context.getBean(PostService.class)).thenReturn(service);
|
||||
|
||||
@@ -358,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);
|
||||
@@ -375,6 +398,7 @@ class PostServiceTest {
|
||||
PostChangeLogService postChangeLogService = mock(PostChangeLogService.class);
|
||||
PointHistoryRepository pointHistoryRepository = mock(PointHistoryRepository.class);
|
||||
RedisTemplate redisTemplate = mock(RedisTemplate.class);
|
||||
SearchIndexEventPublisher searchIndexEventPublisher = mock(SearchIndexEventPublisher.class);
|
||||
|
||||
PostService service = new PostService(
|
||||
postRepo,
|
||||
@@ -383,6 +407,7 @@ class PostServiceTest {
|
||||
tagRepo,
|
||||
lotteryRepo,
|
||||
pollPostRepo,
|
||||
proposalRepo,
|
||||
pollVoteRepo,
|
||||
notifService,
|
||||
subService,
|
||||
@@ -400,7 +425,8 @@ class PostServiceTest {
|
||||
postChangeLogService,
|
||||
pointHistoryRepository,
|
||||
PublishMode.DIRECT,
|
||||
redisTemplate
|
||||
redisTemplate,
|
||||
searchIndexEventPublisher
|
||||
);
|
||||
when(context.getBean(PostService.class)).thenReturn(service);
|
||||
|
||||
|
||||
@@ -9,7 +9,9 @@ import com.openisle.repository.CommentRepository;
|
||||
import com.openisle.repository.PostRepository;
|
||||
import com.openisle.repository.TagRepository;
|
||||
import com.openisle.repository.UserRepository;
|
||||
import com.openisle.search.OpenSearchProperties;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.Mockito;
|
||||
|
||||
@@ -27,7 +29,9 @@ class SearchServiceTest {
|
||||
postRepo,
|
||||
commentRepo,
|
||||
categoryRepo,
|
||||
tagRepo
|
||||
tagRepo,
|
||||
Optional.empty(),
|
||||
new OpenSearchProperties()
|
||||
);
|
||||
|
||||
Post post1 = new Post();
|
||||
|
||||
@@ -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}
|
||||
|
||||
139
bots/bot_father.ts
Normal file
139
bots/bot_father.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { Agent, Runner, hostedMcpTool, withTrace } from "@openai/agents";
|
||||
|
||||
export type WorkflowInput = { input_as_text: string };
|
||||
|
||||
export abstract class BotFather {
|
||||
protected readonly allowedMcpTools = [
|
||||
"search",
|
||||
"create_post",
|
||||
"reply_to_post",
|
||||
"reply_to_comment",
|
||||
"recent_posts",
|
||||
"get_post",
|
||||
"list_unread_messages",
|
||||
"mark_notifications_read",
|
||||
"create_post",
|
||||
];
|
||||
|
||||
protected readonly mcp = hostedMcpTool({
|
||||
serverLabel: "openisle_mcp",
|
||||
serverUrl: "https://www.open-isle.com/mcp",
|
||||
allowedTools: this.allowedMcpTools,
|
||||
requireApproval: "never",
|
||||
});
|
||||
|
||||
protected readonly openisleToken = process.env.OPENISLE_TOKEN ?? "";
|
||||
protected readonly agent: Agent;
|
||||
|
||||
constructor(protected readonly name: string) {
|
||||
console.log(`✅ ${this.name} starting...`);
|
||||
console.log(
|
||||
"🛠️ Configured Hosted MCP tools:",
|
||||
this.allowedMcpTools.join(", ")
|
||||
);
|
||||
|
||||
console.log(
|
||||
this.openisleToken
|
||||
? "🔑 OPENISLE_TOKEN detected in environment."
|
||||
: "🔓 OPENISLE_TOKEN not set; agent will request it if required."
|
||||
);
|
||||
|
||||
this.agent = new Agent({
|
||||
name: this.name,
|
||||
instructions: this.buildInstructions(),
|
||||
tools: [this.mcp],
|
||||
model: "gpt-4o",
|
||||
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.",
|
||||
this.openisleToken
|
||||
? `If tools require auth, use this token exactly where the tool schema expects it: ${this.openisleToken}`
|
||||
: "If a tool requires auth, ask me to provide OPENISLE_TOKEN via env.",
|
||||
"After finishing replies, call mark_notifications_read with all processed notification IDs to keep the inbox clean.",
|
||||
];
|
||||
}
|
||||
|
||||
protected getAdditionalInstructions(): string[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
63
bots/instance/coffee_bot.ts
Normal file
63
bots/instance/coffee_bot.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { BotFather, WorkflowInput } from "../bot_father";
|
||||
|
||||
const WEEKDAY_NAMES = ["日", "一", "二", "三", "四", "五", "六"] as const;
|
||||
|
||||
class CoffeeBot extends BotFather {
|
||||
constructor() {
|
||||
super("Coffee Bot");
|
||||
}
|
||||
|
||||
protected override getAdditionalInstructions(): string[] {
|
||||
return [
|
||||
"You are responsible for 发布每日抽奖早安贴。",
|
||||
"创建帖子时,确保标题、奖品信息、开奖时间以及领奖方式完全符合 CLI 查询提供的细节。",
|
||||
"正文需亲切友好,简洁明了,鼓励社区成员互动。",
|
||||
"开奖说明需明确告知中奖者需私聊站长 @nagisa 领取奖励。",
|
||||
"确保只发布一个帖子,避免重复调用 create_post。",
|
||||
];
|
||||
}
|
||||
|
||||
protected override getCliQuery(): string {
|
||||
const now = new Date();
|
||||
const beijingNow = new Date(
|
||||
now.toLocaleString("en-US", { timeZone: "Asia/Shanghai" })
|
||||
);
|
||||
const weekday = WEEKDAY_NAMES[beijingNow.getDay()];
|
||||
|
||||
const drawTime = new Date(beijingNow);
|
||||
drawTime.setHours(15, 0, 0, 0);
|
||||
const drawTimeText = drawTime
|
||||
.toLocaleTimeString("zh-CN", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
timeZone: "Asia/Shanghai",
|
||||
})
|
||||
.replace(/^24:/, "00:");
|
||||
|
||||
return `
|
||||
请立即在 https://www.open-isle.com 使用 create_post 发表一篇全新帖子,遵循以下要求:
|
||||
1. 标题固定为「大家星期${weekday}早安--抽一杯咖啡」。
|
||||
2. 正文包含:
|
||||
- 亲切的早安问候;
|
||||
- 明确奖品写作“Coffee x 1”;
|
||||
- 奖品图片链接:https://openisle-1307107697.cos.accelerate.myqcloud.com/dynamic_assert/0d6a9b33e9ca4fe5a90540187d3f9ecb.png;
|
||||
- 公布开奖时间为今天下午 15:00(北京时间,写成 ${drawTimeText});
|
||||
- 标注“领奖请私聊站长 @nagisa”;
|
||||
- 鼓励大家留言互动。
|
||||
3. 帖子语言使用简体中文,格式可用 Markdown,使关键信息醒目。
|
||||
4. 完成后只输出“已发布咖啡抽奖贴”,不额外生成总结。
|
||||
`.trim();
|
||||
}
|
||||
}
|
||||
|
||||
const coffeeBot = new CoffeeBot();
|
||||
|
||||
export const runWorkflow = async (workflow: WorkflowInput) => {
|
||||
return coffeeBot.runWorkflow(workflow);
|
||||
};
|
||||
|
||||
if (require.main === module) {
|
||||
coffeeBot.runCli();
|
||||
}
|
||||
|
||||
39
bots/instance/reply_bot.ts
Normal file
39
bots/instance/reply_bot.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
// reply_bot.ts
|
||||
import { BotFather, WorkflowInput } from "../bot_father";
|
||||
|
||||
class ReplyBot extends BotFather {
|
||||
constructor() {
|
||||
super("OpenIsle Bot");
|
||||
}
|
||||
|
||||
protected override getAdditionalInstructions(): string[] {
|
||||
return [
|
||||
"You are a helpful and cute assistant for https://www.open-isle.com. Keep the lovable tone with plentiful kawaii kaomoji (颜表情) such as (๑˃ᴗ˂)ﻭ, (•̀ω•́)✧, (。•ᴗ-)_♡, (⁎⁍̴̛ᴗ⁍̴̛⁎), etc., while staying professional and informative.",
|
||||
"OpenIsle 是一个由 Spring Boot + Vue 3 打造的开源社区平台,提供注册登录、OAuth 登录(Google/GitHub/Discord/Twitter)、帖子与评论互动、标签分类、草稿、统计分析、通知消息、全局搜索、Markdown 支持、图片上传(默认腾讯云 COS)、浏览器推送、DiceBear 头像等功能,旨在帮助团队快速搭建属于自己的技术社区。",
|
||||
"回复时请主动结合上述站点背景,为用户提供有洞察力、可执行的建议或答案,并在需要时引用官网 https://www.open-isle.com、GitHub 仓库 https://github.com/nagisa77/OpenIsle 或相关文档链接,避免空泛的安慰或套话。",
|
||||
"When presenting the result, reply in Chinese with a concise yet content-rich summary filled with kaomoji,并清晰列出关键结论、操作步骤、重要 URL 或 ID,确保用户能直接采取行动。",
|
||||
];
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
56
deploy/deploy.sh
Normal file
56
deploy/deploy.sh
Normal file
@@ -0,0 +1,56 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# 可用法:
|
||||
# ./deploy.sh
|
||||
# ./deploy.sh feature/docker
|
||||
deploy_branch="${1:-main}"
|
||||
|
||||
repo_dir="/opt/openisle/OpenIsle"
|
||||
compose_file="${repo_dir}/docker/docker-compose.yaml"
|
||||
env_file="${repo_dir}/.env"
|
||||
project="openisle"
|
||||
|
||||
echo "👉 Enter repo..."
|
||||
cd "$repo_dir"
|
||||
|
||||
echo "👉 Syncing code & switching to branch: $deploy_branch"
|
||||
git fetch --all --prune
|
||||
git checkout -B "$deploy_branch" "origin/$deploy_branch"
|
||||
git reset --hard "origin/$deploy_branch"
|
||||
|
||||
echo "👉 Ensuring env file: $env_file"
|
||||
if [ ! -f "$env_file" ]; then
|
||||
echo "❌ ${env_file} not found. Create it based on .env.example (with domains)."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
export COMPOSE_PROJECT_NAME="$project"
|
||||
# 供 compose 内各 service 的 env_file 使用
|
||||
export ENV_FILE="$env_file"
|
||||
|
||||
echo "👉 Validate compose..."
|
||||
docker compose -f "$compose_file" --env-file "$env_file" config >/dev/null
|
||||
|
||||
echo "👉 Pull base images (for image-based services)..."
|
||||
docker compose -f "$compose_file" --env-file "$env_file" pull --ignore-pull-failures
|
||||
|
||||
echo "👉 Build images ..."
|
||||
# 前端 + OpenSearch 都是自建镜像;--pull 更新其基础镜像
|
||||
docker compose -f "$compose_file" --env-file "$env_file" \
|
||||
build --pull \
|
||||
--build-arg NUXT_ENV=production \
|
||||
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 mcp
|
||||
|
||||
echo "👉 Current status:"
|
||||
docker compose -f "$compose_file" --env-file "$env_file" ps
|
||||
|
||||
echo "👉 Pruning dangling images..."
|
||||
docker image prune -f
|
||||
|
||||
echo "✅ Stack deployed at $(date)"
|
||||
55
deploy/deploy_staging.sh
Normal file
55
deploy/deploy_staging.sh
Normal file
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# 可用法:
|
||||
# ./deploy-staging.sh
|
||||
# ./deploy-staging.sh feature/docker
|
||||
deploy_branch="${1:-main}"
|
||||
|
||||
repo_dir="/opt/openisle/OpenIsle-staging"
|
||||
compose_file="${repo_dir}/docker/docker-compose.yaml"
|
||||
env_file="${repo_dir}/.env"
|
||||
project="openisle_staging"
|
||||
|
||||
echo "👉 Enter repo..."
|
||||
cd "$repo_dir"
|
||||
|
||||
echo "👉 Syncing code & switching to branch: $deploy_branch"
|
||||
git fetch --all --prune
|
||||
git checkout -B "$deploy_branch" "origin/$deploy_branch"
|
||||
git reset --hard "origin/$deploy_branch"
|
||||
|
||||
echo "👉 Ensuring env file: $env_file"
|
||||
if [ ! -f "$env_file" ]; then
|
||||
echo "❌ ${env_file} not found. Create it based on .env.example (with staging domains)."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
export COMPOSE_PROJECT_NAME="$project"
|
||||
# 供 compose 内各 service 的 env_file 使用
|
||||
export ENV_FILE="$env_file"
|
||||
|
||||
echo "👉 Validate compose..."
|
||||
docker compose -f "$compose_file" --env-file "$env_file" config >/dev/null
|
||||
|
||||
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)..."
|
||||
docker compose -f "$compose_file" --env-file "$env_file" \
|
||||
build --pull \
|
||||
--build-arg NUXT_ENV=staging \
|
||||
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 mcp
|
||||
|
||||
echo "👉 Current status:"
|
||||
docker compose -f "$compose_file" --env-file "$env_file" ps
|
||||
|
||||
echo "👉 Pruning dangling images..."
|
||||
docker image prune -f
|
||||
|
||||
echo "✅ Staging stack deployed at $(date)"
|
||||
@@ -1,11 +1,4 @@
|
||||
# 前端访问端口
|
||||
SERVER_PORT=8080
|
||||
|
||||
# MySQL 配置
|
||||
MYSQL_ROOT_PASSWORD=toor
|
||||
|
||||
# 会覆盖 `open-isle.env`
|
||||
MYSQL_PORT=3306
|
||||
MYSQL_DATABASE=openisle
|
||||
MYSQL_USER=<数据库用户名>
|
||||
MYSQL_PASSWORD=<数据库密码>
|
||||
# 已迁移到仓库根目录的 .env.*.example 文件。
|
||||
# 请复制对应环境的示例文件到项目根目录,例如:
|
||||
# cp ../.env.dev.example ../.env
|
||||
# docker-compose 将自动读取 ../.env。
|
||||
|
||||
1
docker/.gitignore
vendored
Normal file
1
docker/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
data
|
||||
@@ -2,44 +2,422 @@ services:
|
||||
# MySQL service
|
||||
mysql:
|
||||
image: mysql:8.0
|
||||
container_name: openisle-mysql
|
||||
container_name: ${COMPOSE_PROJECT_NAME}-openisle-mysql
|
||||
restart: always
|
||||
env_file:
|
||||
- ../backend/open-isle.env
|
||||
- ./.env
|
||||
- ${ENV_FILE:-../.env}
|
||||
command: >
|
||||
--character-set-server=utf8mb4
|
||||
--collation-server=utf8mb4_0900_ai_ci
|
||||
--default-time-zone=+08:00
|
||||
--skip-character-set-client-handshake
|
||||
ports:
|
||||
- "${MYSQL_PORT}:3306"
|
||||
- "${MYSQL_PORT:-3306}:3306"
|
||||
volumes:
|
||||
- mysql-data:/var/lib/mysql
|
||||
- ../backend/src/main/resources/db/init:/docker-entrypoint-initdb.d
|
||||
- ../backend/src/main/resources/db/init:/docker-entrypoint-initdb.d:ro
|
||||
- ./mysql/conf.d:/etc/mysql/conf.d:ro
|
||||
networks:
|
||||
- openisle-network
|
||||
healthcheck:
|
||||
test: ["CMD","mysqladmin","ping","-h","127.0.0.1","-u","root","-p$MYSQL_ROOT_PASSWORD"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 30
|
||||
start_period: 20s
|
||||
profiles:
|
||||
- dev
|
||||
- dev_local_backend
|
||||
- prod
|
||||
|
||||
# Java spring boot service
|
||||
# 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
|
||||
profiles:
|
||||
- dev
|
||||
- dev_local_backend
|
||||
- prod
|
||||
|
||||
rabbitmq:
|
||||
image: rabbitmq:3.13-management
|
||||
container_name: ${COMPOSE_PROJECT_NAME}-openisle-rabbitmq
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
RABBITMQ_DEFAULT_VHOST: "${RABBITMQ_VHOST:-/}"
|
||||
ports:
|
||||
- "${RABBITMQ_PORT:-5672}:5672"
|
||||
- "${RABBITMQ_MANAGEMENT_PORT:-15672}:15672"
|
||||
volumes:
|
||||
- rabbitmq-data:/var/lib/rabbitmq
|
||||
- ./rabbitmq/conf/rabbitmq.conf:/etc/rabbitmq/rabbitmq.conf:ro
|
||||
- ./rabbitmq/conf/enabled_plugins:/etc/rabbitmq/enabled_plugins:ro
|
||||
- ./rabbitmq/definitions.json:/etc/rabbitmq/definitions.json:ro
|
||||
healthcheck:
|
||||
test: ["CMD", "rabbitmq-diagnostics", "-q", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 30
|
||||
start_period: 30s
|
||||
networks:
|
||||
- openisle-network
|
||||
profiles:
|
||||
- dev
|
||||
- dev_local_backend
|
||||
- prod
|
||||
|
||||
redis:
|
||||
image: redis:7
|
||||
container_name: ${COMPOSE_PROJECT_NAME}-openisle-redis
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- ${ENV_FILE:-../.env}
|
||||
ports:
|
||||
- "${REDIS_PORT:-6379}:6379"
|
||||
volumes:
|
||||
- redis-data:/data
|
||||
networks:
|
||||
- openisle-network
|
||||
profiles:
|
||||
- dev
|
||||
- dev_local_backend
|
||||
- prod
|
||||
|
||||
# Java spring boot service (开发便捷镜像,后续可换成打包镜像)
|
||||
springboot:
|
||||
image: maven:3.9-eclipse-temurin-17
|
||||
container_name: openisle-springboot
|
||||
container_name: ${COMPOSE_PROJECT_NAME}-openisle-springboot
|
||||
working_dir: /app
|
||||
env_file:
|
||||
- ../backend/open-isle.env
|
||||
- ./.env
|
||||
- ${ENV_FILE:-../.env}
|
||||
environment:
|
||||
- MYSQL_URL=jdbc:mysql://mysql:${MYSQL_PORT}/${MYSQL_DATABASE}?useUnicode=yes&characterEncoding=UTF-8&useInformationSchema=true&useSSL=false&serverTimezone=UTC&allowPublicKeyRetrieval=true
|
||||
TZ: "Asia/Shanghai"
|
||||
SPRING_HEALTH_PATH: ${SPRING_HEALTH_PATH:-/actuator/health}
|
||||
SERVER_PORT: ${SERVER_PORT:-8080}
|
||||
RABBITMQ_PORT: 5672
|
||||
OPENSEARCH_PORT: 9200
|
||||
MYSQL_PORT: 3306
|
||||
REDIS_PORT: 6379
|
||||
JAVA_OPTS: "-Duser.timezone=Asia/Shanghai"
|
||||
ports:
|
||||
- "${SERVER_PORT}:8080"
|
||||
- "${SERVER_PORT:-8080}:${SERVER_PORT:-8080}"
|
||||
volumes:
|
||||
- ../backend:/app
|
||||
- maven-repo:/root/.m2
|
||||
depends_on:
|
||||
- mysql
|
||||
command: mvn clean spring-boot:run -Dmaven.test.skip=true
|
||||
mysql:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_started
|
||||
rabbitmq:
|
||||
condition: service_started
|
||||
websocket-service:
|
||||
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"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:${SERVER_PORT:-8080}${SPRING_HEALTH_PATH:-/actuator/health} || exit 1"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 30
|
||||
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
|
||||
- dev_local_backend
|
||||
- prod
|
||||
|
||||
|
||||
websocket-service:
|
||||
image: maven:3.9-eclipse-temurin-17
|
||||
container_name: ${COMPOSE_PROJECT_NAME}-openisle-websocket
|
||||
working_dir: /app
|
||||
env_file:
|
||||
- ${ENV_FILE:-../.env}
|
||||
environment:
|
||||
WS_HEALTH_PATH: ${WS_HEALTH_PATH:-/actuator/health}
|
||||
WEBSOCKET_PORT: ${WEBSOCKET_PORT:-8082}
|
||||
SERVER_PORT: ${WEBSOCKET_PORT:-8082}
|
||||
RABBITMQ_PORT: 5672
|
||||
ports:
|
||||
- "${WEBSOCKET_PORT:-8082}:${WEBSOCKET_PORT:-8082}"
|
||||
volumes:
|
||||
- ../websocket_service:/app
|
||||
- websocket-maven-repo:/root/.m2
|
||||
depends_on:
|
||||
rabbitmq:
|
||||
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"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:${WEBSOCKET_PORT:-8082}${WS_HEALTH_PATH:-/actuator/health} || exit 1"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 30
|
||||
start_period: 60s
|
||||
networks:
|
||||
- openisle-network
|
||||
profiles:
|
||||
- dev
|
||||
- dev_local_backend
|
||||
- prod
|
||||
|
||||
frontend_dev:
|
||||
image: node:20
|
||||
container_name: ${COMPOSE_PROJECT_NAME}-openisle-frontend-dev
|
||||
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:
|
||||
springboot:
|
||||
condition: service_healthy
|
||||
websocket-service:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- 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:
|
||||
context: ..
|
||||
dockerfile: docker/frontend-service.Dockerfile
|
||||
args:
|
||||
NUXT_ENV: ${NUXT_ENV:-staging}
|
||||
container_name: ${COMPOSE_PROJECT_NAME}-openisle-frontend
|
||||
env_file:
|
||||
- ${ENV_FILE:-../.env}
|
||||
ports:
|
||||
- "${FRONTEND_PORT:-3000}:3000"
|
||||
depends_on:
|
||||
springboot:
|
||||
condition: service_healthy
|
||||
websocket-service:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
profiles:
|
||||
- prod
|
||||
|
||||
# 监听“frontend_dev 容器自身的” 127.0.0.1:8080 → 转发到 springboot:8080
|
||||
loopback_8080:
|
||||
image: alpine/socat
|
||||
container_name: ${COMPOSE_PROJECT_NAME}-loopback-8080
|
||||
command:
|
||||
- -d
|
||||
- -d
|
||||
- -ly
|
||||
- TCP4-LISTEN:8080,bind=127.0.0.1,reuseaddr,fork
|
||||
- TCP4:springboot:8080
|
||||
depends_on:
|
||||
springboot:
|
||||
condition: service_healthy
|
||||
network_mode: "service:frontend_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
|
||||
container_name: ${COMPOSE_PROJECT_NAME}-loopback-8082
|
||||
# 监听 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"
|
||||
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:
|
||||
name: "${COMPOSE_PROJECT_NAME}_net"
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
mysql-data:
|
||||
name: "${COMPOSE_PROJECT_NAME}_mysql-data"
|
||||
maven-repo:
|
||||
name: "${COMPOSE_PROJECT_NAME}_maven-repo"
|
||||
redis-data:
|
||||
name: "${COMPOSE_PROJECT_NAME}_redis-data"
|
||||
rabbitmq-data:
|
||||
name: "${COMPOSE_PROJECT_NAME}_rabbitmq-data"
|
||||
websocket-maven-repo:
|
||||
name: "${COMPOSE_PROJECT_NAME}_websocket-maven-repo"
|
||||
frontend-node-modules:
|
||||
name: "${COMPOSE_PROJECT_NAME}_frontend-node-modules"
|
||||
frontend-service-node-modules:
|
||||
name: "${COMPOSE_PROJECT_NAME}_frontend-service-node-modules"
|
||||
frontend-static:
|
||||
name: "${COMPOSE_PROJECT_NAME}_frontend-static"
|
||||
opensearch-data:
|
||||
name: "${COMPOSE_PROJECT_NAME}_opensearch-data"
|
||||
opensearch-snapshots:
|
||||
name: "${COMPOSE_PROJECT_NAME}_opensearch-snapshots"
|
||||
|
||||
39
docker/frontend-service.Dockerfile
Normal file
39
docker/frontend-service.Dockerfile
Normal file
@@ -0,0 +1,39 @@
|
||||
# ==== builder ====
|
||||
FROM node:20-bullseye AS builder
|
||||
WORKDIR /app
|
||||
|
||||
# 通过构建参数选择环境:staging / production(默认 staging)
|
||||
ARG NUXT_ENV=staging
|
||||
ENV NODE_ENV=production \
|
||||
NUXT_TELEMETRY_DISABLED=1
|
||||
|
||||
# 复制源代码(假设仓库根目录包含 frontend_nuxt)
|
||||
# 构建上下文由 docker-compose 指向仓库根目录
|
||||
COPY ./frontend_nuxt/package*.json /app/
|
||||
RUN npm ci
|
||||
|
||||
# 拷贝剩余代码
|
||||
COPY ./frontend_nuxt/ /app/
|
||||
|
||||
# 若存在环境样例文件,则在构建期复制为 .env(你也可以用 --build-arg 覆盖)
|
||||
RUN if [ -f ".env.${NUXT_ENV}.example" ]; then cp ".env.${NUXT_ENV}.example" .env; fi
|
||||
|
||||
# 构建 SSR:产物在 .output
|
||||
RUN npm run build
|
||||
|
||||
# ==== runner ====
|
||||
FROM node:20-alpine AS runner
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production \
|
||||
NUXT_TELEMETRY_DISABLED=1 \
|
||||
PORT=3000 \
|
||||
HOST=0.0.0.0
|
||||
|
||||
# 复制构建产物
|
||||
COPY --from=builder /app/.output /app/.output
|
||||
|
||||
# 健康检查(简洁起见,探测首页)
|
||||
HEALTHCHECK --interval=10s --timeout=5s --retries=30 CMD wget -qO- http://127.0.0.1:${PORT}/ >/dev/null 2>&1 || exit 1
|
||||
|
||||
EXPOSE 3000
|
||||
CMD ["node", ".output/server/index.mjs"]
|
||||
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"]
|
||||
|
||||
10
docker/mysql/conf.d/charset.cnf
Normal file
10
docker/mysql/conf.d/charset.cnf
Normal file
@@ -0,0 +1,10 @@
|
||||
[mysqld]
|
||||
character-set-server = utf8mb4
|
||||
collation-server = utf8mb4_0900_ai_ci
|
||||
skip-character-set-client-handshake
|
||||
|
||||
[client]
|
||||
default-character-set = utf8mb4
|
||||
|
||||
[mysql]
|
||||
default-character-set = utf8mb4
|
||||
9
docker/opensearch.Dockerfile
Normal file
9
docker/opensearch.Dockerfile
Normal file
@@ -0,0 +1,9 @@
|
||||
# opensearch
|
||||
FROM opensearchproject/opensearch:3.0.0
|
||||
RUN /usr/share/opensearch/bin/opensearch-plugin install -b analysis-icu
|
||||
RUN /usr/share/opensearch/bin/opensearch-plugin install -b \
|
||||
https://github.com/aparo/opensearch-analysis-pinyin/releases/download/3.0.0/opensearch-analysis-pinyin.zip
|
||||
|
||||
# ...
|
||||
|
||||
|
||||
1
docker/rabbitmq/conf/enabled_plugins
Normal file
1
docker/rabbitmq/conf/enabled_plugins
Normal file
@@ -0,0 +1 @@
|
||||
[rabbitmq_management, rabbitmq_prometheus].
|
||||
6
docker/rabbitmq/conf/rabbitmq.conf
Normal file
6
docker/rabbitmq/conf/rabbitmq.conf
Normal file
@@ -0,0 +1,6 @@
|
||||
# 管理插件加载 definitions(仅空库时生效)
|
||||
management.load_definitions = /etc/rabbitmq/definitions.json
|
||||
|
||||
# (可选)禁用管理老式统计采集,转 Prometheus,避免弃用告警
|
||||
management_agent.disable_metrics_collector = true
|
||||
management.disable_stats = true
|
||||
31
docker/rabbitmq/definitions.json
Normal file
31
docker/rabbitmq/definitions.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"users": [
|
||||
{ "name": "nagisa", "password": "nagisa", "tags": "administrator" }
|
||||
],
|
||||
"vhosts": [{ "name": "/" }],
|
||||
"permissions": [
|
||||
{ "user": "nagisa", "vhost": "/", "configure": ".*", "write": ".*", "read": ".*" }
|
||||
],
|
||||
"queues": [
|
||||
{ "name": "notifications-queue", "vhost": "/", "durable": true, "auto_delete": false, "arguments": {} },
|
||||
{ "name": "notifications-queue-0", "vhost": "/", "durable": true, "auto_delete": false, "arguments": {} },
|
||||
{ "name": "notifications-queue-1", "vhost": "/", "durable": true, "auto_delete": false, "arguments": {} },
|
||||
{ "name": "notifications-queue-2", "vhost": "/", "durable": true, "auto_delete": false, "arguments": {} },
|
||||
{ "name": "notifications-queue-3", "vhost": "/", "durable": true, "auto_delete": false, "arguments": {} },
|
||||
{ "name": "notifications-queue-4", "vhost": "/", "durable": true, "auto_delete": false, "arguments": {} },
|
||||
{ "name": "notifications-queue-5", "vhost": "/", "durable": true, "auto_delete": false, "arguments": {} },
|
||||
{ "name": "notifications-queue-6", "vhost": "/", "durable": true, "auto_delete": false, "arguments": {} },
|
||||
{ "name": "notifications-queue-7", "vhost": "/", "durable": true, "auto_delete": false, "arguments": {} },
|
||||
{ "name": "notifications-queue-8", "vhost": "/", "durable": true, "auto_delete": false, "arguments": {} },
|
||||
{ "name": "notifications-queue-9", "vhost": "/", "durable": true, "auto_delete": false, "arguments": {} },
|
||||
{ "name": "notifications-queue-a", "vhost": "/", "durable": true, "auto_delete": false, "arguments": {} },
|
||||
{ "name": "notifications-queue-b", "vhost": "/", "durable": true, "auto_delete": false, "arguments": {} },
|
||||
{ "name": "notifications-queue-c", "vhost": "/", "durable": true, "auto_delete": false, "arguments": {} },
|
||||
{ "name": "notifications-queue-d", "vhost": "/", "durable": true, "auto_delete": false, "arguments": {} },
|
||||
{ "name": "notifications-queue-e", "vhost": "/", "durable": true, "auto_delete": false, "arguments": {} },
|
||||
{ "name": "notifications-queue-f", "vhost": "/", "durable": true, "auto_delete": false, "arguments": {} }
|
||||
],
|
||||
"exchanges": [],
|
||||
"bindings": []
|
||||
}
|
||||
|
||||
@@ -1,12 +1,3 @@
|
||||
; 本地部署后端
|
||||
NUXT_PUBLIC_API_BASE_URL=http://127.0.0.1:8080
|
||||
NUXT_PUBLIC_WEBSOCKET_URL=https://127.0.0.1:8082
|
||||
NUXT_PUBLIC_WEBSITE_BASE_URL=http://localhost:3000
|
||||
|
||||
NUXT_PUBLIC_GOOGLE_CLIENT_ID=777830451304-nt8afkkap18gui4f9entcha99unal744.apps.googleusercontent.com
|
||||
# NUXT_PUBLIC_GITHUB_CLIENT_ID=Ov23liVkO1NPAX5JyWxJ
|
||||
; 本地
|
||||
NUXT_PUBLIC_GITHUB_CLIENT_ID=Ov23liOlrZnPKRF7s7NN
|
||||
NUXT_PUBLIC_DISCORD_CLIENT_ID=1394985417044000779
|
||||
NUXT_PUBLIC_TWITTER_CLIENT_ID=ZTRTU05KSk9KTTJrTTdrVC1tc1E6MTpjaQ
|
||||
NUXT_PUBLIC_TELEGRAM_BOT_ID=8450237135
|
||||
# 环境变量已统一迁移至仓库根目录的 .env.*.example 文件。
|
||||
# 如需在本地运行 Nuxt,请复制对应的示例文件到项目根目录:
|
||||
# cp ../.env.dev.example ../.env
|
||||
|
||||
@@ -1,19 +1,5 @@
|
||||
; 本地部署后端
|
||||
; NUXT_PUBLIC_API_BASE_URL=https://127.0.0.1:8081
|
||||
; 预发环境后端
|
||||
; NUXT_PUBLIC_API_BASE_URL=https://staging.open-isle.com
|
||||
; 生产环境后端
|
||||
NUXT_PUBLIC_API_BASE_URL=https://open-isle.com
|
||||
|
||||
; 生产环境ws后端
|
||||
NUXT_PUBLIC_WEBSOCKET_URL=https://open-isle.com/websocket
|
||||
|
||||
; 预发环境
|
||||
; NUXT_PUBLIC_WEBSITE_BASE_URL=https://staging.open-isle.com
|
||||
; 正式环境/生产环境
|
||||
NUXT_PUBLIC_WEBSITE_BASE_URL=https://open-isle.com
|
||||
NUXT_PUBLIC_GOOGLE_CLIENT_ID=777830451304-nt8afkkap18gui4f9entcha99unal744.apps.googleusercontent.com
|
||||
NUXT_PUBLIC_GITHUB_CLIENT_ID=Ov23liVkO1NPAX5JyWxJ
|
||||
NUXT_PUBLIC_DISCORD_CLIENT_ID=1394985417044000779
|
||||
NUXT_PUBLIC_TWITTER_CLIENT_ID=ZTRTU05KSk9KTTJrTTdrVC1tc1E6MTpjaQ
|
||||
NUXT_PUBLIC_TELEGRAM_BOT_ID=8450237135
|
||||
# 环境变量已统一迁移至仓库根目录的 .env.*.example 文件。
|
||||
# 根据环境选择对应文件复制至项目根目录:
|
||||
# cp ../.env.dev.example ../.env
|
||||
# cp ../.env.staging.example ../.env
|
||||
# cp ../.env.production.example ../.env
|
||||
|
||||
@@ -1,13 +1,3 @@
|
||||
|
||||
; 生产环境后端
|
||||
NUXT_PUBLIC_API_BASE_URL=https://www.open-isle.com
|
||||
; 正式环境/生产环境
|
||||
NUXT_PUBLIC_WEBSITE_BASE_URL=https://www.open-isle.com
|
||||
; 生产环境ws后端
|
||||
NUXT_PUBLIC_WEBSOCKET_URL=https://www.open-isle.com/websocket
|
||||
|
||||
NUXT_PUBLIC_GOOGLE_CLIENT_ID=777830451304-nt8afkkap18gui4f9entcha99unal744.apps.googleusercontent.com
|
||||
NUXT_PUBLIC_GITHUB_CLIENT_ID=Ov23liVkO1NPAX5JyWxJ
|
||||
NUXT_PUBLIC_DISCORD_CLIENT_ID=1394985417044000779
|
||||
NUXT_PUBLIC_TWITTER_CLIENT_ID=ZTRTU05KSk9KTTJrTTdrVC1tc1E6MTpjaQ
|
||||
NUXT_PUBLIC_TELEGRAM_BOT_ID=8450237135
|
||||
# 环境变量已统一迁移至仓库根目录的 .env.*.example 文件。
|
||||
# 如需配置生产环境,请复制并修改对应示例文件:
|
||||
# cp ../.env.production.example ../.env
|
||||
|
||||
@@ -1,17 +1,3 @@
|
||||
; 本地部署后端
|
||||
; NUXT_PUBLIC_API_BASE_URL=http://127.0.0.1:8080
|
||||
|
||||
; 预发环境后端
|
||||
NUXT_PUBLIC_API_BASE_URL=https://staging.open-isle.com
|
||||
|
||||
; 预发环境ws后端
|
||||
NUXT_PUBLIC_WEBSOCKET_URL=https://staging.open-isle.com/websocket
|
||||
|
||||
; 预发环境
|
||||
NUXT_PUBLIC_WEBSITE_BASE_URL=https://staging.open-isle.com
|
||||
|
||||
NUXT_PUBLIC_GOOGLE_CLIENT_ID=777830451304-nt8afkkap18gui4f9entcha99unal744.apps.googleusercontent.com
|
||||
NUXT_PUBLIC_GITHUB_CLIENT_ID=Ov23liVkO1NPAX5JyWxJ
|
||||
NUXT_PUBLIC_DISCORD_CLIENT_ID=1394985417044000779
|
||||
NUXT_PUBLIC_TWITTER_CLIENT_ID=ZTRTU05KSk9KTTJrTTdrVC1tc1E6MTpjaQ
|
||||
NUXT_PUBLIC_TELEGRAM_BOT_ID=8450237135
|
||||
# 环境变量已统一迁移至仓库根目录的 .env.*.example 文件。
|
||||
# 如需配置预发环境,请复制并修改对应示例文件:
|
||||
# cp ../.env.staging.example ../.env
|
||||
|
||||
@@ -41,10 +41,13 @@ import GlobalPopups from '~/components/GlobalPopups.vue'
|
||||
import ConfirmDialog from '~/components/ConfirmDialog.vue'
|
||||
import MessageFloatWindow from '~/components/MessageFloatWindow.vue'
|
||||
import { useIsMobile } from '~/utils/screen'
|
||||
import { checkToken } from '~/utils/auth'
|
||||
|
||||
const isMobile = useIsMobile()
|
||||
const menuVisible = ref(!isMobile.value)
|
||||
|
||||
await checkToken()
|
||||
|
||||
const showNewPostIcon = computed(() => useRoute().path === '/')
|
||||
|
||||
const hideMenu = computed(() => {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
--primary-color: rgb(10, 110, 120);
|
||||
--primary-color-disabled: rgba(93, 152, 156, 0.5);
|
||||
--secondary-color: rgb(255, 255, 255);
|
||||
--secondary-color-hover: rgba(10, 111, 120, 0.184);
|
||||
--secondary-color-hover: rgba(10, 111, 120, 0.079);
|
||||
--new-post-icon-color: rgba(10, 111, 120, 0.598);
|
||||
--header-height: 60px;
|
||||
--header-background-color: white;
|
||||
@@ -54,6 +54,7 @@
|
||||
--header-border-color: #555;
|
||||
--primary-color: rgb(17, 182, 197);
|
||||
--primary-color-hover: rgb(13, 137, 151);
|
||||
--secondary-color-hover: rgba(17, 182, 197, 0.238);
|
||||
--new-post-icon-color: rgba(10, 111, 120, 0.598);
|
||||
--header-text-color: white;
|
||||
--app-menu-background-color: #333;
|
||||
@@ -108,7 +109,6 @@ body {
|
||||
|
||||
.vditor-toolbar--pin {
|
||||
top: calc(var(--header-height) + 1px) !important;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.vditor-panel {
|
||||
@@ -134,26 +134,6 @@ body {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
/* .vditor {
|
||||
--textarea-background-color: transparent;
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.vditor-reset {
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.vditor-toolbar {
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
} */
|
||||
|
||||
/* .vditor-toolbar {
|
||||
position: relative !important;
|
||||
} */
|
||||
|
||||
/*************************
|
||||
* Markdown 渲染样式
|
||||
*************************/
|
||||
@@ -200,7 +180,9 @@ body {
|
||||
|
||||
.info-content-text pre .line-numbers {
|
||||
counter-reset: line-number 0;
|
||||
width: 2em;
|
||||
white-space: nowrap; /* 禁止数字换行 */
|
||||
font-variant-numeric: tabular-nums; /* 数字等宽 */
|
||||
/* width: 2em; */
|
||||
font-size: 13px;
|
||||
position: sticky;
|
||||
flex-shrink: 0;
|
||||
@@ -333,10 +315,6 @@ body {
|
||||
min-height: 100px;
|
||||
}
|
||||
|
||||
.vditor-toolbar {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.about-content h1,
|
||||
.info-content-text h1 {
|
||||
font-size: 20px;
|
||||
@@ -354,8 +332,8 @@ body {
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.vditor-toolbar--pin {
|
||||
top: 0 !important;
|
||||
.vditor-panel {
|
||||
min-width: 330px;
|
||||
}
|
||||
|
||||
.about-content li,
|
||||
@@ -367,9 +345,14 @@ body {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.vditor-panel {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
/*处理iframe视频标签*/
|
||||
.info-content-text iframe {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
aspect-ratio: 16 / 9; /* 保持 16:9 比例 */
|
||||
border: none;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.d2h-file-name {
|
||||
@@ -387,7 +370,10 @@ body {
|
||||
.d2h-code-line {
|
||||
padding-left: 10px !important;
|
||||
}
|
||||
|
||||
/* 手机端不换行 */
|
||||
.info-content-text code {
|
||||
white-space: pre; /* 禁止自动换行 */
|
||||
}
|
||||
/* .d2h-diff-table {
|
||||
font-size: 6px !important;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user