Compare commits

...

367 Commits

Author SHA1 Message Date
Tim
680e44e743 feat: 给出仅重启后台的vibe coding方式 2026-02-04 20:23:19 +08:00
Tim
657b76bb1e feat: 评论分页 2026-02-04 20:14:46 +08:00
Tim
f695db62c6 Clear filters on logo click 2026-02-04 19:03:58 +08:00
Tim
e5b386cdc2 Merge pull request #1136 from nagisa77/bugfix/1053
fix: 性能优化,首页下拉更新,实测6秒左右,稍慢 #1053
2026-01-16 16:47:46 +08:00
Tim
179699dd66 Merge pull request #1135 from nagisa77/bugfix/1130
fix: 1. 修复dev_local_backend报错问题 2.由于内存占用过高,直接去除opensearch
2026-01-16 16:45:35 +08:00
Tim
ef39b5fedf fix: 性能优化,首页下拉更新,实测6秒左右,稍慢 #1053 2026-01-16 16:45:07 +08:00
Tim
e13ee1ca46 fix: 1. 修复dev_local_backend报错问题 2.由于内存占用过高,直接去除opensearch 2026-01-16 15:22:59 +08:00
Tim
09f1435e33 Merge pull request #1134 from nagisa77/feature/view_history
fix: add view history logic
2026-01-16 15:13:22 +08:00
Tim
7e7cebbbe7 fix: add view history logic 2026-01-16 15:05:06 +08:00
Tim
5c1031c57c Merge pull request #1133 from nagisa77/feature/fix_copy_lick_background
feat: update copylink color
2026-01-16 14:21:02 +08:00
Tim
e6730b2882 feat: update copylink color 2026-01-16 14:17:23 +08:00
Tim
21b1c3317a fix: 解决邮件发送错误,但是前端显示已发送的问题 2026-01-16 11:12:20 +08:00
Tim
72a915af2e fix: 解决icon loading问题 2026-01-15 21:33:55 +08:00
Tim
f000011994 fix: 解决文章loading问题 2026-01-15 21:29:44 +08:00
Tim
d48c9dc27a fix: 新增描述初始用户名和密码 2026-01-15 21:16:58 +08:00
Tim
94f955e50f 更新 daily_news_bot.ts 2025-10-31 11:48:22 +08:00
Tim
bf94707914 Update daily_news_bot.ts 2025-10-31 10:54:36 +08:00
Tim
209f0ef1f8 Update daily_news_bot.ts 2025-10-31 10:50:53 +08:00
Tim
e2d900759a Merge pull request #1126 from nagisa77/codex/set-news-bot-model-to-4o
Allow bots to choose agent model
2025-10-31 10:46:55 +08:00
Tim
40a233a66b Allow bots to override agent model 2025-10-31 10:46:22 +08:00
Tim
b8c0b1c6f8 Update bot_father.ts 2025-10-31 10:29:41 +08:00
Tim
b37df67d31 Update daily_news_bot instructions for content sourcing 2025-10-30 10:09:03 +08:00
Tim
90865b02c9 更新 news-bot.yml 2025-10-30 09:40:32 +08:00
Tim
f8c0335982 fix: prompt 调整 2025-10-29 18:32:04 +08:00
Tim
20b3d89a00 fix: prompt 调整 2025-10-29 18:28:33 +08:00
Tim
ddae56d483 fix: prompt 2025-10-29 18:12:46 +08:00
Tim
265fce4153 feat: add workflow 2025-10-29 18:03:49 +08:00
Tim
cc0880e2c1 Merge pull request #1125 from nagisa77/feature/news_bot
Feature/news bot
2025-10-29 18:02:19 +08:00
Tim
5fe3eec815 Merge pull request #1124 from nagisa77/codex/create-daily-news-bot-with-summaries
Add daily news bot workflow
2025-10-29 18:01:21 +08:00
Tim
f0feb7a45c Add daily news bot workflow 2025-10-29 18:01:08 +08:00
Tim
784057207f feat: add websearch tools 2025-10-29 17:54:06 +08:00
Tim
bed72662b5 Update reply_bot.ts 2025-10-29 14:31:07 +08:00
Tim
895dba495b Update reply_bot.ts 2025-10-29 14:30:14 +08:00
Tim
32dc6bfaf9 Update reply_bot.ts 2025-10-29 14:25:59 +08:00
Tim
4766250577 Update reply_bot.ts 2025-10-29 14:21:27 +08:00
Tim
13baffa9f1 Merge pull request #1123 from nagisa77/codex/rename-coffee-and-reply-bots-to-system
Adjust coffee bot schedule and update bot personas
2025-10-29 14:20:40 +08:00
Tim
d0d7580ac3 Adjust bot identities and coffee bot schedule 2025-10-29 14:20:29 +08:00
Tim
fd4e651a49 Merge pull request #1122 from nagisa77/codex/update-readme.md-with-bot-integration-details
docs: highlight bot integration in README
2025-10-29 13:15:17 +08:00
Tim
58317687d7 docs: update README bot info 2025-10-29 13:15:06 +08:00
tim
006e46f4ef Revert "fix: prompt 修改"
This reverts commit 2c27766544.
2025-10-28 21:56:49 +08:00
Tim
2c27766544 fix: prompt 修改 2025-10-28 20:48:37 +08:00
Tim
c305992223 fix: prompt 修改 2025-10-28 20:25:07 +08:00
Tim
babd2c6549 Merge pull request #1121 from nagisa77/codex/add-opensourcereplybot-with-detailed-responses
feat(bot): add open source reply bot
2025-10-28 19:56:03 +08:00
Tim
d98c3644a6 fix: 添加GitHub action 2025-10-28 19:55:29 +08:00
Tim
dbb63a4039 feat(bot): add open source reply bot 2025-10-28 19:53:07 +08:00
Tim
49aeff3a83 Merge pull request #1120 from nagisa77/codex/add-is_bot-field-to-user-table
Add bot flag to user model and show comment badge
2025-10-28 19:49:44 +08:00
Tim
512e5623e1 Add bot flag to users and surface in comments 2025-10-28 19:49:33 +08:00
Tim
8db928b9a8 Merge pull request #1119 from nagisa77/codex/update-lottery-time-from-23-to-15
Adjust coffee bot draw schedule
2025-10-28 18:58:05 +08:00
Tim
46f6ccb3a8 Adjust coffee draw schedule 2025-10-28 18:57:53 +08:00
Tim
87dcebf052 fix: tools 重写 2025-10-28 18:50:34 +08:00
Tim
0ad4f4feff fix: fix tools 2025-10-28 18:48:32 +08:00
Tim
a227ac77fb Merge pull request #1118 from nagisa77/codex/add-daily-weather-lookup-for-cities
fix: 透传token
2025-10-28 18:45:30 +08:00
Tim
ef53a40ed5 fix: 透传token 2025-10-28 18:44:43 +08:00
Tim
7d8c9b68bd Merge pull request #1117 from nagisa77/codex/add-daily-weather-lookup-for-cities
Integrate weather MCP into Coffee Bot
2025-10-28 18:43:15 +08:00
Tim
dbc3d54fa1 fix: should add weather mcp 2025-10-28 18:42:47 +08:00
Tim
4c0b9e744a feat: weather mcp 2025-10-28 18:39:58 +08:00
Tim
4b4d1a2a86 Merge branch 'main' into codex/add-daily-weather-lookup-for-cities
# Conflicts:
#	bots/instance/coffee_bot.ts
2025-10-28 18:39:44 +08:00
Tim
6990aa93ed Integrate weather MCP into Coffee Bot 2025-10-28 18:32:42 +08:00
Tim
421b8b6b4f fix: prompt 修改 2025-10-28 18:08:19 +08:00
Tim
e55acc6dc4 fix: 解决时区问题 2025-10-28 18:01:03 +08:00
Tim
33ce56aa31 fix: 解决时区问题 2025-10-28 17:58:13 +08:00
Tim
339c39c6ca fix: 时区设置 2025-10-28 17:55:36 +08:00
Tim
389961c922 fix: 修正prompt 2025-10-28 17:47:00 +08:00
Tim
6db53274fb fix: 修正日期 2025-10-28 17:44:14 +08:00
Tim
a413c0be35 fix: 修正语法 2025-10-28 17:35:01 +08:00
Tim
06ecd39c8b Merge branch 'main' of github.com:nagisa77/OpenIsle
# Conflicts:
#	bots/instance/coffee_bot.ts
2025-10-28 17:34:47 +08:00
Tim
f0ba00b7e8 fix: 修正抽奖贴问题 2025-10-28 17:33:23 +08:00
Tim
092c4c36c2 Merge pull request #1116 from nagisa77/codex/create-post-for-coffee-bot-with-categories-and-tags
Update coffee bot post metadata instructions
2025-10-28 17:27:52 +08:00
Tim
db13f8145d Update coffee bot post metadata instructions 2025-10-28 17:27:37 +08:00
Tim
3be396976a Merge pull request #1115 from nagisa77/codex/fix-coffee-bot-mcp-service-error
Validate MCP post creation inputs
2025-10-28 17:23:21 +08:00
Tim
3fbaa332fc Validate required fields for MCP post creation 2025-10-28 17:22:58 +08:00
Tim
4e6cb59753 fix: 修正语法问题 2025-10-28 16:49:44 +08:00
Tim
1c6c17e577 fix: 修正语法问题 2025-10-28 16:47:00 +08:00
Tim
c968efa42a Revert "Fix search client reply argument order"
This reverts commit 7a2cf829c7.
2025-10-28 16:38:53 +08:00
Tim
0cd5ded39b Merge pull request #1114 from nagisa77/codex/fix-syntaxerror-in-search_client.py
Fix reply methods argument order in MCP search client
2025-10-28 16:33:05 +08:00
Tim
7a2cf829c7 Fix search client reply argument order 2025-10-28 16:32:53 +08:00
Tim
12329b43d1 Merge pull request #1113 from nagisa77/codex/pass-bearer-token-to-backend-apis
feat: forward Authorization header for MCP backend requests
2025-10-28 16:22:17 +08:00
Tim
1a45603e0f feat: forward authorization headers to backend 2025-10-28 16:22:04 +08:00
Tim
4a73503399 Merge pull request #1112 from nagisa77/codex/fix-authorization-parameter-error
Fix hosted MCP authorization configuration
2025-10-28 16:00:07 +08:00
Tim
83bf8c1d5e Fix hosted MCP auth header collision 2025-10-28 15:59:57 +08:00
Tim
34e206f05d Merge pull request #1111 from nagisa77/codex/refactor-getbaseinstructions-to-remove-openisletoken
Inject OpenIsle token into MCP requests
2025-10-28 15:57:04 +08:00
Tim
dc349923e9 Inject OpenIsle token into MCP requests 2025-10-28 15:56:51 +08:00
Tim
0d44c9a823 Merge pull request #1110 from nagisa77/codex/update-coffee-bot-prize-image
Update coffee bot prize image instructions
2025-10-28 15:21:23 +08:00
Tim
02645af321 Update coffee bot prize image instructions 2025-10-28 15:21:03 +08:00
Tim
c3a175f13f Merge pull request #1109 from nagisa77/codex/create-coffee-bot-for-lottery-post
Add coffee bot lottery poster and schedule
2025-10-28 15:14:51 +08:00
Tim
0821d447f7 Add coffee bot lottery poster and schedule 2025-10-28 15:14:37 +08:00
Tim
257794ca00 feat: bot father 允许创建帖子 2025-10-28 15:12:53 +08:00
Tim
6a527de3eb Merge pull request #1108 from nagisa77/codex/update-getadditionalinstructions-for-reply_bot
Enhance reply bot persona with site background
2025-10-28 15:07:27 +08:00
Tim
2313f90eb3 Enhance reply bot persona with site background 2025-10-28 15:07:14 +08:00
Tim
7fde984e7d Merge pull request #1107 from nagisa77/codex/add-posting-tool-support-for-mcp-service
Add MCP tool for creating posts
2025-10-28 15:06:09 +08:00
Tim
fc41e605e4 Add MCP tool for creating posts 2025-10-28 15:05:55 +08:00
Tim
042e5fdbe6 fix: make a bot father 2025-10-28 15:01:33 +08:00
Tim
629442bff6 fix: make a bot father 2025-10-28 14:58:38 +08:00
Tim
7798910be0 Merge pull request #1106 from nagisa77/codex/refactor-bots-directory-to-oop
Refactor reply bots to extend BotFather base class
2025-10-28 14:55:02 +08:00
Tim
6f036eb4fe Refactor reply bot with BotFather base class 2025-10-28 14:54:49 +08:00
Tim
56fc05cb3c fix: 新增环境 2025-10-28 14:15:03 +08:00
Tim
a55a15659b fix: 解决脚本失败问题 2025-10-28 14:11:29 +08:00
Tim
ccf6e0c7ce Merge pull request #1104 from nagisa77/feature/bot
Feature/bot
2025-10-28 13:57:23 +08:00
Tim
87677f5968 Merge pull request #1103 from nagisa77/codex/add-git-action-to-run-reply_bots.ts
Add scheduled workflow to run reply bots
2025-10-28 13:56:31 +08:00
Tim
fd93a2dc61 Add scheduled reply bot workflow 2025-10-28 13:56:18 +08:00
Tim
80f862a226 Merge pull request #1102 from nagisa77/codex/add-read-cleanup-interface-for-mcp
Add MCP support for clearing read notifications
2025-10-28 13:50:33 +08:00
Tim
26bb85f4d4 Add MCP support for clearing read notifications 2025-10-28 13:50:16 +08:00
tim
398b4b482f fix: prompt 完善 2025-10-28 13:00:42 +08:00
tim
2cfb302981 fix: add bot 2025-10-28 12:37:17 +08:00
Tim
e75bd76b71 Merge pull request #1101 from nagisa77/codex/fix-unread-notifications-data-format
Normalize null list payloads in notification schemas
2025-10-28 10:27:54 +08:00
Tim
99c3ac1837 Handle null list fields in notification schemas 2025-10-28 10:27:40 +08:00
tim
749ab560ff Revert "Cache MCP session JWT tokens"
This reverts commit 997dacdbe6.
2025-10-28 01:55:46 +08:00
tim
541ad4d149 Revert "Remove token parameters from MCP tools"
This reverts commit e585100625.
2025-10-28 01:55:41 +08:00
tim
03eb027ea4 Revert "Add MCP tool for setting session token"
This reverts commit 9dadaad5ba.
2025-10-28 01:55:36 +08:00
Tim
4194b2be91 Merge pull request #1100 from nagisa77/codex/add-initialization-tool-for-jwt-token
Add MCP tool for initializing session JWT tokens
2025-10-28 01:47:28 +08:00
Tim
9dadaad5ba Add MCP tool for setting session token 2025-10-28 01:47:16 +08:00
Tim
d4b3400c5f Merge pull request #1099 from nagisa77/codex/remove-token-parameters-from-mcp-api
Remove explicit token parameters from MCP tools
2025-10-28 01:32:18 +08:00
Tim
e585100625 Remove token parameters from MCP tools 2025-10-28 01:32:02 +08:00
Tim
e94471b53e Merge pull request #1098 from nagisa77/codex/store-accesstoken-as-jwt-token
Cache MCP session JWT tokens
2025-10-28 01:20:52 +08:00
Tim
997dacdbe6 Cache MCP session JWT tokens 2025-10-28 01:20:32 +08:00
Tim
c01349a436 Merge pull request #1097 from nagisa77/codex/improve-python-mcp-logs
Improve MCP logging and add unread notification tool
2025-10-28 01:01:47 +08:00
Tim
4cf48f9157 Enhance MCP logging and add unread message tool 2025-10-28 01:01:25 +08:00
Tim
796afbe612 Merge pull request #1096 from nagisa77/codex/add-reply-support-for-posts
Add MCP support for replying to posts
2025-10-27 20:24:59 +08:00
Tim
dca14390ca Add MCP tool for replying to posts 2025-10-27 20:24:47 +08:00
Tim
39875acd35 Merge pull request #1095 from nagisa77/codex/add-post-query-interface-in-mcp-iq4fxi
Add MCP tool for retrieving post details
2025-10-27 20:19:29 +08:00
Tim
62edc75735 feat(mcp): add post detail retrieval tool 2025-10-27 20:19:17 +08:00
Tim
26ca9fc916 Merge pull request #1093 from nagisa77/codex/add-reply-and-recent-post-query-apis 2025-10-27 16:13:22 +08:00
Tim
cad70c23b3 feat(mcp): add comment reply and recent posts tools 2025-10-27 16:13:06 +08:00
Tim
016276dbc3 Merge pull request #1092 from nagisa77/codex/add-endpoints-for-post-and-comment-queries 2025-10-27 16:06:18 +08:00
Tim
bd2d6e7485 Add recent post and comment context APIs 2025-10-27 16:05:40 +08:00
Tim
df59a9fd4b Merge pull request #1091 from nagisa77/feature/nginx
fix: 修改配置
2025-10-27 14:28:18 +08:00
tim
2e70a3d273 fix: 修改配置 2025-10-27 14:27:17 +08:00
Tim
3dc6935d19 Merge pull request #1090 from nagisa77/feature/nginx
Feature/nginx
2025-10-26 14:28:05 +08:00
Tim
779bb2db78 Merge pull request #1089 from nagisa77/codex/update-.env.example-for-openisle_mcp_port
Configure MCP proxy routes
2025-10-26 14:26:35 +08:00
Tim
b3b0b194a3 Configure MCP port and nginx proxies 2025-10-26 14:25:09 +08:00
tim
e21b2f42d2 fix: 精简websocket配置 2025-10-26 14:17:38 +08:00
Tim
05a5acee7e Merge pull request #1088 from nagisa77/feature/mcp
Feature/mcp
2025-10-26 14:08:46 +08:00
Tim
755982098b Merge pull request #1087 from nagisa77/codex/add-mcp-service-to-deploy-scripts
Include MCP service in deployment scripts
2025-10-26 14:07:15 +08:00
Tim
af24263c0a Include MCP service in deployment scripts 2025-10-26 14:07:03 +08:00
tim
8fd268bd11 feat: add mcp 2025-10-25 23:33:51 +08:00
Tim
a24bd81942 Merge pull request #1080 from nagisa77/codex/modify-post-visibility-and-update-changelog
Log post visibility scope changes
2025-10-24 11:08:22 +08:00
Tim
8a008a090a Log post visibility changes 2025-10-24 10:41:10 +08:00
Tim
5dfb69e636 Merge pull request #1075 from smallclover/main
markdown 调试信息中的错误日志删除
2025-10-24 10:14:09 +08:00
Tim
499069573e fix: 弹窗修改为overlay 2025-10-24 10:11:56 +08:00
Tim
636912941a fix: commit 2025-10-23 17:56:45 +08:00
Tim
bdcc1488b9 fix: 修改可见范围 2025-10-23 17:54:03 +08:00
Tim
d33bd233af fix: 修改登录遮罩 2025-10-23 17:22:49 +08:00
Tim
efe4b97d83 Merge branch 'main' into main 2025-10-23 17:06:25 +08:00
Tim
8a256e167d Merge pull request #1031 from sivdead/feat/category_proposal
feat: 添加分类提案功能,包括提案表单和相关后端逻辑
2025-10-23 17:03:48 +08:00
Tim
9c5a49a47f fix: 完善提案通知流程,防止重复通知,提案成功自动插入分类 2025-10-23 15:29:55 +08:00
Tim
2271bbbd1d feat: 分类提案首页icon 2025-10-23 14:30:57 +08:00
Tim
d6470e04fc fix: 分类提案投票UI 2025-10-23 14:28:07 +08:00
Tim
4db35a4531 fix: 解决后台报错的问题 2025-10-23 13:43:39 +08:00
Tim
1906ffd8aa Update CONTRIBUTING.md to simplify instructions
Removed redundant build command from contributing guide.
2025-10-23 12:30:37 +08:00
Tim
426884385f fix: 更新巡航指南 2025-10-23 12:27:15 +08:00
Tim
8193c92c91 fix: 新增提案规则,新增自定义后端 2025-10-23 12:11:22 +08:00
Tim
90649b422d feat: 补充提案规则ui 2025-10-22 20:39:54 +08:00
Tim
67efb64ccc fix: 分类提案简化用户输入 2025-10-22 20:33:24 +08:00
Tim
23d8eafc08 fix: 删除替换为icon-park 2025-10-22 20:15:34 +08:00
Tim
d1cc16e31e Merge remote-tracking branch 'origin/main' into feat_category_proposal 2025-10-22 19:54:17 +08:00
Tim
0f1c45b155 Merge pull request #1078 from xuemian168/patch-1
Enhance security policy with detailed guidelines
2025-10-22 10:19:26 +08:00
XueMian (ICT.RUN)
8ed11df99c Enhance security policy with detailed guidelines
Expanded the security policy to include detailed reporting procedures, security considerations, and best practices for contributors.
2025-10-22 10:10:57 +10:00
smallclover
458b125834 1.追加文章可见范围功能
2.删除文章右侧滚动条
2025-10-18 22:32:22 +09:00
smallclover
971a3d36c6 修复:header文字不换行 2025-10-17 23:19:45 +09:00
smallclover
e5d66d73cb markdown 调试信息中的错误日志删除 2025-10-17 22:34:58 +09:00
Tim
a9608cc706 Merge pull request #1074 from nagisa77/feature/search_bar
Feature/search bar
2025-10-17 17:43:04 +08:00
Tim
232f40151b fix: 修改css冲突 2025-10-17 17:42:12 +08:00
Tim
3b3f99754d fix: 搜索focus 2025-10-17 17:33:34 +08:00
Tim
e14566ee66 fix: 搜索提示 2025-10-17 17:01:55 +08:00
Tim
892312c6d4 fix: 搜索框 2025-10-17 16:59:40 +08:00
Tim
dfb31771ff feat: searchbar集成到header 2025-10-17 16:54:03 +08:00
Tim
bf7df629cc Merge pull request #1073 from nagisa77/feature/ui_fix
fix: avatar 以及 auth 重构
2025-10-17 15:11:55 +08:00
Tim
f17b644a9b fix: avatar 以及 auth 重构 2025-10-17 15:10:43 +08:00
Tim
61f8fa4bb7 fix: 新增右间距 2025-10-17 12:19:40 +08:00
Tim
43929bcdc5 Merge pull request #1072 from nagisa77/feature/give_some_money
fix: 移动端ui适配
2025-10-17 12:02:03 +08:00
Tim
6aecb4f583 fix: 移动端ui适配 2025-10-17 12:01:32 +08:00
Tim
0d2e6a9505 Merge pull request #1065 from nagisa77/feature/give_some_money
打赏功能实现
2025-10-17 11:36:34 +08:00
Tim
b2d70b9bde Merge pull request #1069 from nagisa77/codex/add-reinitialize-command-to-contributing.md
docs: document docker compose volume reset workflow
2025-10-17 11:36:15 +08:00
Tim
d914579d64 fix: Donate历史 2025-10-17 11:35:29 +08:00
Tim
8643446d8b feat: 赞赏后台 2025-10-17 11:24:19 +08:00
Tim
2db958f8c9 fix: 赞赏ui 2025-10-17 10:52:05 +08:00
Tim
fa29d255c9 Merge pull request #1067 from smallclover/main
tieba表情函数抽成共通
2025-10-16 22:35:43 +08:00
smallclover
b3fa5e2bef 修复已读 2025-10-16 21:19:13 +09:00
smallclover
a7ef4380d8 问题修复
1.修复网页模式下,markdown代码过长
2.修复网页模实下,按钮文字换行
3.修复网页模式下,消息换行
2025-10-16 21:13:56 +09:00
Tim
39d954d98a fix: 继续做UI工作 2025-10-16 18:11:27 +08:00
Tim
596d1558a2 docs: add compose volume reset instructions 2025-10-16 10:13:07 +08:00
tim
ce04570efb feat: 新增itemGroup 2025-10-16 09:58:57 +08:00
smallclover
215c7077d5 tieba表情函数抽成共通 2025-10-15 22:47:48 +09:00
tim
a68c925c68 fix: 集成一下父亲容器 2025-10-15 19:52:30 +08:00
tim
4f248e8a71 fix: 布局微调 2025-10-15 18:08:16 +08:00
Tim
277883f9d9 Merge pull request #1064 from nagisa77/codex/refactor-reactionsgroup-to-remove-slot-mechanism
Refactor ReactionsGroup layout responsibilities
2025-10-15 17:55:55 +08:00
Tim
e9e996f291 refactor: simplify reactions group usage 2025-10-15 17:47:45 +08:00
Tim
a8667ce5e9 Merge pull request #1062 from smallclover/main
修复markdown手机模式下换行问题
2025-10-14 20:37:52 +08:00
夢夢の幻想郷
0d316af22a Merge branch 'nagisa77:main' into main 2025-10-14 20:25:46 +09:00
smallclover
f8e13af672 修复,markdown在手机模式下换行,导致行号和代码无法一致 2025-10-14 20:24:11 +09:00
Tim
92d90c997c Merge pull request #1061 from smallclover/main
追加
2025-10-13 20:56:40 +08:00
smallclover
303ec9b6c1 追加
首页贴吧表情显示
2025-10-13 19:35:18 +09:00
Tim
90eafe27fd Merge pull request #1060 from smallclover/main
icon对齐
2025-10-13 13:30:10 +08:00
smallclover
98e2ea7ef8 icon对齐
https://github.com/nagisa77/OpenIsle/issues/854
2025-10-13 09:47:58 +09:00
Tim
e3290f3431 Merge pull request #1059 from smallclover/main
header菜单栏调整
2025-10-12 16:32:34 +08:00
smallclover
160570574c 1.header菜单栏格式统一
2.修复未登录的情况下邀请链接状态错误
2025-10-11 10:13:03 +09:00
Tim
cf7b667f30 Merge pull request #1058 from sivdead/fix/issue-857-signup-ui-optimization
fix: 优化申请注册页面UI (#857)
2025-10-10 16:12:53 +08:00
sivdead
60fa6051b7 fix: 优化申请注册页面UI (#857)
- 将字符计数移至输入框内部右下角
- 错误提示距离输入框8px
- 优化布局结构,使用input-wrapper包裹输入区域
2025-10-10 15:20:39 +08:00
Tim
1c0e90d32d Merge pull request #1056 from smallclover/main
主页对齐方式修复
2025-10-10 09:56:28 +08:00
smallclover
a15065575d 修复问题
https://github.com/nagisa77/OpenIsle/issues/1057
问题原因:行号的css限制宽度,导致行数超过99错位
2025-10-09 21:40:28 +09:00
夢夢の幻想郷
cb958e162e Merge branch 'nagisa77:main' into main 2025-10-08 21:32:11 +09:00
smallclover
660d8ffe51 https://github.com/nagisa77/OpenIsle/issues/843
对齐方式修复
2025-10-08 21:31:36 +09:00
tim
5509a1eead Merge branch 'main' of github.com:nagisa77/OpenIsle 2025-10-08 19:18:10 +08:00
tim
1acd776d3b fix: 启动排队机制 2025-10-08 19:17:45 +08:00
Tim
53be8d943a Merge pull request #1055 from immortal521/fix/theme-toggle-flicker
fix: theme-toggle-flicker
2025-10-08 15:48:23 +08:00
immortal521
9957042746 fix: theme-toggle-flicker
- remove unnecessary await nextTick in view transition

- Simplify transition callback

- Add fill: 'both' to transition style
2025-10-08 01:19:38 +08:00
tim
302f98f44e Revert "feat: 先把每日定时构件给注释掉"
This reverts commit 0119605649.
2025-10-07 18:02:06 +08:00
Tim
790c4db8ea Merge pull request #1054 from nagisa77/codex/update-dropdown.vue-for-empty-state-rendering
Add empty dropdown state message when search yields no results
2025-10-07 18:00:59 +08:00
tim
bbb0a11d49 fix: searchdropdown新增空state、 2025-10-07 18:00:37 +08:00
tim
35340319c6 fix: 限定profile 2025-10-07 16:38:04 +08:00
Tim
343c4d3793 Add empty state to dropdown when no search results 2025-10-07 16:29:53 +08:00
Tim
87b214cbc0 Merge pull request #1052 from smallclover/main
修改主页各section间距
2025-10-07 16:20:48 +08:00
tim
e7f06787d2 fix: 仅支配websocket打头域名 2025-10-07 15:59:23 +08:00
tim
d7d2fd5dcb fix: 仅支配websocket打头域名 2025-10-07 15:58:18 +08:00
smallclover
76b65a1400 修改主页各section间距 2025-10-05 21:08:05 +09:00
tim
fa8ee113a2 fix: 设置telegram测试环境 2025-10-05 18:05:45 +08:00
tim
181237adee fix: 更新GitHub预发环境登录 2025-10-05 17:45:40 +08:00
tim
1b8135acfb Merge branch 'main' of github.com:nagisa77/OpenIsle 2025-10-05 17:44:25 +08:00
tim
67bbe832a0 fix: 更新GitHub预发环境登录 2025-10-05 17:43:45 +08:00
Tim
9d67f7d8d6 Merge pull request #1051 from nagisa77/codex/fix-comment-pinning-order-in-article
Ensure pinned comments stay at top of post timeline
2025-10-05 17:22:16 +08:00
Tim
da0d26c8b5 Ensure pinned comments stay at top of post timeline 2025-10-05 17:21:45 +08:00
Tim
81d64bfc7b Merge pull request #1048 from smallclover/main
修复手机网页iframe的视频标签超出容器的问题
2025-10-04 17:44:23 +08:00
smallclover
3e255c1288 修复手机网页iframe的视频标签超出容器的问题 2025-10-04 17:13:00 +09:00
tim
224e1a1018 Merge branch 'main' of github.com:nagisa77/OpenIsle 2025-10-04 14:52:53 +08:00
tim
4456997573 fix: 解决GitHub登录问题 2025-10-04 14:52:42 +08:00
Tim
ef0f0d013b Merge pull request #1046 from smallclover/main
修复文章变动图标问题
2025-10-04 14:46:45 +08:00
Tim
a83ddc40fe Update CONTRIBUTING.md 2025-10-04 12:46:19 +08:00
tim
f36ed28185 Revert "fix: 修改为资源图片"
This reverts commit 536979501e.

# Conflicts:
#	CONTRIBUTING.md
2025-10-04 12:26:38 +08:00
smallclover
1d31284dba 1.修复置顶图标不显示
2.修复取消置顶图标不显示
2025-10-04 09:13:34 +09:00
Tim
995d68b50b Merge pull request #1045 from nagisa77/codex/update-contributing.md-instructions
docs: update docker compose dev instructions
2025-10-04 02:02:53 +08:00
Tim
55b680ef83 Update CONTRIBUTING.md 2025-10-04 02:02:43 +08:00
Tim
024e52b763 docs: update docker compose dev instructions 2025-10-04 02:01:32 +08:00
tim
536979501e fix: 修改为资源图片 2025-10-04 01:53:19 +08:00
tim
85a67a6215 fix: 本站自部署方法 2025-10-04 01:44:56 +08:00
tim
57a9a98da6 fix: 修改deploy地址 2025-10-03 16:52:00 +08:00
tim
e8976a98d4 fix: 新增nginx配置,修改deploy地址 2025-10-03 16:43:38 +08:00
tim
57e6bcaa0c Revert "feat: add admin point grants and history UI"
This reverts commit adfc05b9b2.
2025-10-03 00:58:24 +08:00
tim
c95b2ebdc2 fix: 修改staging部署 2025-10-03 00:48:00 +08:00
tim
83cf7439c9 fix: 删除dashboard 2025-10-02 22:35:19 +08:00
tim
994f4028fc fix: 取消opensearch 2025-10-02 22:29:35 +08:00
tim
2362458024 fix: volumes 修改 2025-10-02 22:25:15 +08:00
tim
03c92d4861 Merge branch 'main' of github.com:nagisa77/OpenIsle 2025-10-02 22:00:20 +08:00
tim
8df566a9c9 fix: 修改为main 2025-10-02 22:00:08 +08:00
Tim
870d1e2940 Merge pull request #1042 from nagisa77/codex/add-iframe-support-for-markdown-rendering
Allow iframe embeds in markdown sanitizer
2025-10-02 21:43:04 +08:00
Tim
0033374481 Allow iframe embeds in markdown sanitizer 2025-10-02 21:42:52 +08:00
tim
8f36422609 fix: 解决传参 2025-10-02 15:28:25 +08:00
tim
b98871bed9 fix: mysql 时区 2025-10-01 16:40:23 +08:00
tim
2cb8c12f65 fix: 修改main 2025-10-01 16:33:14 +08:00
Tim
87a256ba0c Merge pull request #1037 from nagisa77/feature/docker
所有业务适配Docker
2025-10-01 16:19:10 +08:00
tim
737157e557 fix: add timezone 2025-10-01 16:08:53 +08:00
tim
6f9570dc95 fix: 时区限制 2025-10-01 15:38:12 +08:00
tim
12bc405856 fix: 时区限制 2025-10-01 15:36:18 +08:00
tim
a2b0cd1a47 fix: 新增deploy 2025-10-01 11:36:55 +08:00
tim
25a7f1e138 fix: add deploy 2025-10-01 11:35:02 +08:00
tim
a6dd2bfbc2 Revert "fix: 修改文件名"
This reverts commit a0ea63700f.
2025-09-30 21:40:14 +08:00
tim
a0ea63700f fix: 修改文件名 2025-09-30 21:34:30 +08:00
tim
b49e20d010 fix: 添加环境名、变量名 2025-09-30 21:33:14 +08:00
tim
e44443a605 Merge remote-tracking branch 'origin/main' into feature/docker 2025-09-30 20:12:43 +08:00
Tim
0a3bfb9451 Merge pull request #1041 from nagisa77/codex/add-points-module-and-history-tracking
feat: add admin point grants and history UI
2025-09-30 20:12:31 +08:00
Tim
adfc05b9b2 feat: add admin point grants and history UI 2025-09-30 20:11:45 +08:00
tim
18a6953ff7 fix: 解决opensearch问题 2025-09-30 15:15:11 +08:00
tim
181ac7bc8f fix: 容器port修改 2025-09-30 15:09:59 +08:00
tim
9dc9ca9bd8 Revert "fix: 端口适配"
This reverts commit 180c45bf2d.
2025-09-30 15:02:40 +08:00
tim
2457efd11d Revert "fix: healthy check 修改"
This reverts commit b62b9c691f.
2025-09-30 15:02:36 +08:00
tim
b62b9c691f fix: healthy check 修改 2025-09-30 14:54:57 +08:00
tim
180c45bf2d fix: 端口适配 2025-09-30 14:52:27 +08:00
tim
263f2deeb1 fix: 修改yaml 2025-09-30 14:32:30 +08:00
tim
22b813e40b fix: 修改yaml 2025-09-30 14:16:10 +08:00
tim
d00dbbbd03 fix: 修改前端生产构建方案 2025-09-30 13:51:43 +08:00
Tim
3b92bdaf2a Merge pull request #1038 from smallclover/main
修改按钮样式
2025-09-30 10:46:07 +08:00
tim
7ce5de7f7c fix: 自部署基本完善 2025-09-30 10:45:31 +08:00
tim
28618c7452 fix: springboot healthy检测完成 2025-09-30 10:22:39 +08:00
tim
f8a2ee6ee9 fix: use server port 2025-09-30 01:45:47 +08:00
tim
ca26b931da fix: use server port 2025-09-30 01:19:13 +08:00
tim
24fe90cfc6 fix: change port 2025-09-30 00:47:18 +08:00
tim
5971700e8a fix: 新增依赖 2025-09-30 00:33:05 +08:00
smallclover
f872a32410 修改按钮样式
1. 文字变为白色
2. 按钮样式和其他按钮统一
2025-09-29 21:47:12 +09:00
Tim
fffd335ebb fix: 两个springboot新增探活机制 2025-09-29 19:54:37 +08:00
Tim
287d52df10 feat: healthy.检测 2025-09-29 19:47:55 +08:00
Tim
73790d1992 feat: healthy.检测 2025-09-29 19:42:54 +08:00
Tim
3d5cee6e68 feat: mysql 乱码处理 2025-09-29 19:41:04 +08:00
Tim
2f509cc2d8 feat: mysql 自定义初始化 2025-09-29 19:27:24 +08:00
Tim
35c503eb6c feat: mysql 自定义初始化 2025-09-29 19:26:02 +08:00
Tim
0cf8113691 Revert "feat: 移动文件位置"
This reverts commit b2a29913aa.
2025-09-29 19:14:48 +08:00
Tim
b2a29913aa feat: 移动文件位置 2025-09-29 19:11:18 +08:00
Tim
2b6d7c5ab9 fix: 新增多种url供开发者选择 2025-09-29 18:12:55 +08:00
Tim
e9878487e8 fix: 容器内流量转发 2025-09-29 18:08:35 +08:00
Tim
201af061e4 fix: 简单修改 2025-09-29 17:55:19 +08:00
Tim
4080f60f60 fix: rabbitmq 初始化 2025-09-29 16:46:25 +08:00
Tim
06d76438e8 fix: 前端初步调通 2025-09-29 16:04:14 +08:00
Tim
bb955c98ba fix: 后台实现链接各个服务 2025-09-29 15:16:32 +08:00
Tim
a12368602d fix: 尝试docker部署 2025-09-29 10:52:59 +08:00
Tim
208c875868 fix: 去除compose中重复声明 2025-09-29 10:42:17 +08:00
Tim
39ae8c02cb fix: 修改.env.example 2025-09-29 10:29:37 +08:00
LuoQianhong
c9854e1840 Merge branch 'main' into feat/category_proposal 2025-09-29 09:26:22 +08:00
tim
0119605649 feat: 先把每日定时构件给注释掉 2025-09-29 01:14:50 +08:00
Tim
0d7dc93a67 fix: 初步转移为docker 2025-09-28 21:06:52 +08:00
Tim
774611f3a8 Merge pull request #1033 from nagisa77/feature/open_search
Feature: Open Search
2025-09-28 19:19:21 +08:00
Tim
04616a30f3 fix: 新增端口指定 2025-09-28 19:13:17 +08:00
Tim
c0ca615439 feat: 新增docker部署相关信息 2025-09-28 18:05:49 +08:00
Tim
b0597d34b6 fix: 去除无用代码 2025-09-28 17:58:58 +08:00
Tim
e3f680ad0f fix: 索引/查询规则微调 2025-09-28 17:58:10 +08:00
Tim
c8a1e6d8c8 fix: 禁用首字母匹配 2025-09-28 15:21:02 +08:00
Tim
ffebeb46b7 fix: 新增拼音 2025-09-28 15:08:20 +08:00
Tim
2977d2898f fix: 后端highlight 2025-09-28 14:55:56 +08:00
Tim
8869121bcb fix: add pinyin 2025-09-28 14:28:45 +08:00
Tim
61f6e7c90a Merge pull request #1034 from smallclover/main
UI调整
2025-09-28 10:06:28 +08:00
smallclover
892aa6a7c6 UI调整
https://github.com/nagisa77/OpenIsle/issues/855
2025-09-27 08:59:11 +09:00
tim
23cc2d1606 feat: 新增贴文reindex 2025-09-26 18:19:29 +08:00
tim
44addd2a7b fix: 搜索main路径跑通 2025-09-26 18:03:25 +08:00
tim
0bc65077df feat: opensearch init 2025-09-26 16:37:13 +08:00
tim
69869348f6 Revert "feat: add open search support"
This reverts commit 4821b77c17.
2025-09-26 15:36:31 +08:00
Tim
4821b77c17 feat: add open search support 2025-09-26 15:34:06 +08:00
sivdead
3da5d24488 fix: 修复代码合并问题 2025-09-25 18:17:26 +08:00
sivdead
76962d6d1c feat: 添加分类提案功能,包括提案表单和相关后端逻辑 2025-09-25 17:47:46 +08:00
Tim
4fc7c861ee Merge pull request #1030 from nagisa77/codex/add-op/-identifier-in-posts
feat: add OP badge to post comments
2025-09-25 13:37:28 +08:00
Tim
81dfddf6e1 feat: highlight post author in comments 2025-09-25 13:34:25 +08:00
Tim
8b93aa95cf Merge pull request #1027 from nagisa77/feature/avatar_count
fix: 移动端头像显示问题 #1023
2025-09-24 16:58:29 +08:00
tim
425fc7d2b1 fix: 移动端头像显示问题 #1023 2025-09-24 16:57:42 +08:00
Tim
0fff73b682 Merge pull request #1025 from nagisa77/codex/add-pagination-support-for-tags-qfn36n
feat: paginate tags across backend and ui
2025-09-24 16:18:09 +08:00
Tim
e1171212d7 Merge pull request #1026 from nagisa77/codex/fix-dropdown-to-scroll-after-loading-more
fix: keep dropdown at bottom after loading more
2025-09-24 16:15:14 +08:00
Tim
e96db5d0d6 fix: keep dropdown at bottom after loading more 2025-09-24 16:14:45 +08:00
tim
1083c4241a fix: 修复语法问题 2025-09-24 16:06:17 +08:00
Tim
1eeabab41a feat: paginate tags across backend and ui 2025-09-24 15:58:24 +08:00
Tim
2b5f6f2208 Merge pull request #1022 from nagisa77/feature/user_list_and_avatar
Feature/user list and avatar
2025-09-24 01:51:51 +08:00
tim
bda377336d fix: 优化一些头像属性 2025-09-24 01:51:02 +08:00
tim
77507f7b18 Revert "style: enhance BaseUserAvatar presentation"
This reverts commit 229439aa05.
2025-09-24 01:38:41 +08:00
Tim
a39f2f7c00 Merge pull request #1021 from nagisa77/codex/improve-baseuseravatar-styling-ok22do
style: enhance BaseUserAvatar presentation
2025-09-24 01:31:46 +08:00
Tim
229439aa05 style: enhance BaseUserAvatar presentation 2025-09-24 01:31:31 +08:00
tim
612881f1b1 Revert "refine BaseUserAvatar styling"
This reverts commit c68c5985f6.
2025-09-24 01:31:05 +08:00
Tim
05c7bc18d7 Merge pull request #1020 from nagisa77/codex/improve-baseuseravatar-styling-z7f617
Enhance BaseUserAvatar aesthetics
2025-09-24 01:23:25 +08:00
Tim
c68c5985f6 refine BaseUserAvatar styling 2025-09-24 01:23:12 +08:00
tim
7d44791011 Revert "feat: refresh base user avatar styling"
This reverts commit 4b8229b0a1.
2025-09-24 01:22:50 +08:00
Tim
15b992b949 Merge pull request #1019 from nagisa77/codex/improve-baseuseravatar-styling
feat: refresh base user avatar styling
2025-09-24 01:21:29 +08:00
Tim
4b8229b0a1 feat: refresh base user avatar styling 2025-09-24 01:21:12 +08:00
tim
6e4fbc3c42 fix: base avatar 重构 2025-09-24 00:43:57 +08:00
Tim
779264623c Merge pull request #1018 from nagisa77/codex/create-baseuseravatar-component-zv8hyo
feat: add base user avatar component
2025-09-24 00:31:11 +08:00
Tim
76aef40de7 feat: add base user avatar component 2025-09-24 00:30:54 +08:00
tim
a1eccb3b1e Revert "feat: add BaseUserAvatar and unify avatar usage"
This reverts commit efbb83924b.
2025-09-24 00:30:23 +08:00
Tim
0f75a95dbe Merge pull request #1017 from nagisa77/codex/create-baseuseravatar-component
feat: unify avatar rendering with BaseUserAvatar
2025-09-24 00:27:10 +08:00
Tim
efbb83924b feat: add BaseUserAvatar and unify avatar usage 2025-09-24 00:26:51 +08:00
tim
26d1db79f4 fix: user list 结构调整 2025-09-23 23:59:42 +08:00
Tim
dc13b2941f Merge pull request #1016 from nagisa77/feature/vditor_layout
fix: 移动端--频道--表情无法显示完全 #994
2025-09-23 23:48:59 +08:00
tim
13c250d392 fix: 移动端--频道--表情无法显示完全 #994 2025-09-23 23:48:31 +08:00
tim
f5b40feaa2 Merge branch 'main' of github.com:nagisa77/OpenIsle 2025-09-23 23:32:07 +08:00
tim
c47c318e6f fix: 简单更新本地调试端口 2025-09-23 23:31:53 +08:00
Tim
c02d993e90 Merge pull request #1015 from nagisa77/feature/api_click
fix: 修复api playgrond 跳转问题
2025-09-23 23:27:33 +08:00
tim
f36bcb74ca fix: 修复api playgrond 跳转问题 2025-09-23 23:27:01 +08:00
Tim
2263fd97db Merge pull request #1014 from nagisa77/feature/loading_spin
fix: 修复整个按钮都在转的问题
2025-09-23 23:18:08 +08:00
tim
9234d1099e fix: 修复整个按钮都在转的问题 2025-09-23 23:14:32 +08:00
Tim
373dece19d Merge pull request #1012 from nagisa77/feature/loading_icon
fix: loading icon
2025-09-19 23:21:48 +08:00
tim
b09828bcc2 fix: loading icon 2025-09-19 23:20:50 +08:00
Tim
8751a7707c Merge pull request #1010 from nagisa77/codex/update-overview-page-to-display-api-paths
feat(docs): show API routes on overview
2025-09-19 18:03:18 +08:00
Tim
f91b240802 feat(docs): show API routes on overview 2025-09-19 17:57:44 +08:00
Tim
062b289f7a Revert "fix: 新增openAPI配置选项"
This reverts commit c1dc77f6db.
2025-09-19 17:47:41 +08:00
Tim
c1dc77f6db fix: 新增openAPI配置选项 2025-09-19 16:46:28 +08:00
Tim
cea60175c2 fix: basetimeline 去除hover属性 2025-09-19 16:39:10 +08:00
Tim
2bd3630512 Merge pull request #1008 from nagisa77/feature/user_page_timeline
user page timeline
2025-09-19 16:22:20 +08:00
tim
a9d8181940 fix: timeline ui 重构 2025-09-19 16:21:19 +08:00
Tim
4cc108094d Merge pull request #1009 from nagisa77/codex/integrate-timelinetagitem-and-refactor-components
feat: extract timeline tag item component
2025-09-19 13:50:15 +08:00
Tim
bfa57cce44 feat: extract timeline tag item component 2025-09-19 13:44:37 +08:00
tim
8ebdcd94f5 fix: timeline 继承标签介绍 2025-09-19 11:30:58 +08:00
tim
9991210db2 fix: 部分ui修改 2025-09-19 11:21:27 +08:00
Tim
1c59815afa Merge pull request #1007 from nagisa77/codex/refactor-user-posts-display-components-aopsvr
Enhance user timeline post metadata and grouping
2025-09-19 00:32:17 +08:00
tim
bc767a6ac9 Revert "Enhance user timeline grouping and post metadata"
This reverts commit b6c2471bc3.
2025-09-19 00:31:24 +08:00
Tim
1c1915285d Merge pull request #1006 from nagisa77/codex/refactor-user-posts-display-components
Enhance user timeline grouping and post metadata
2025-09-19 00:22:58 +08:00
Tim
b6c2471bc3 Enhance user timeline grouping and post metadata 2025-09-19 00:22:34 +08:00
198 changed files with 9804 additions and 1215 deletions

119
.env.example Normal file
View 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

30
.github/workflows/coffee-bot.yml vendored Normal file
View File

@@ -0,0 +1,30 @@
name: Coffee Bot
on:
schedule:
- cron: "0 23 * * 0-4"
workflow_dispatch:
jobs:
run-coffee-bot:
environment: Bots
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
- name: Install dependencies
run: npm install --no-save @openai/agents tsx typescript
- name: Run coffee bot
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENISLE_TOKEN: ${{ secrets.OPENISLE_TOKEN }}
APIFY_API_TOKEN: ${{ secrets.APIFY_API_TOKEN }}
run: npx tsx bots/instance/coffee_bot.ts

View File

@@ -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

View File

@@ -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

View File

@@ -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

30
.github/workflows/news-bot.yml vendored Normal file
View File

@@ -0,0 +1,30 @@
name: Daily News Bot
on:
schedule:
- cron: "0 22 * * 0-4"
workflow_dispatch:
jobs:
run-daily-news-bot:
environment: Bots
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
- name: Install dependencies
run: npm install --no-save @openai/agents tsx typescript
- name: Run daily news bot
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENISLE_TOKEN: ${{ secrets.OPENISLE_TOKEN }}
APIFY_API_TOKEN: ${{ secrets.APIFY_API_TOKEN }}
run: npx tsx bots/instance/daily_news_bot.ts

View File

@@ -0,0 +1,30 @@
name: Open Source Reply Bot
on:
schedule:
- cron: "*/30 * * * *"
workflow_dispatch:
jobs:
run-open-source-reply-bot:
environment: Bots
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
- name: Install dependencies
run: npm install --no-save @openai/agents tsx typescript
- name: Run open source reply bot
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENISLE_TOKEN: ${{ secrets.OPENISLE_TOKEN_BOT_1 }}
APIFY_API_TOKEN: ${{ secrets.APIFY_API_TOKEN }}
run: npx tsx bots/instance/open_source_reply_bot.ts

30
.github/workflows/reply-bots.yml vendored Normal file
View File

@@ -0,0 +1,30 @@
name: Reply Bots
on:
schedule:
- cron: "*/30 * * * *"
workflow_dispatch:
jobs:
run-reply-bot:
environment: Bots
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
- name: Install dependencies
run: npm install --no-save @openai/agents tsx typescript
- name: Run reply bot
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENISLE_TOKEN: ${{ secrets.OPENISLE_TOKEN }}
APIFY_API_TOKEN: ${{ secrets.APIFY_API_TOKEN }}
run: npx tsx bots/instance/reply_bot.ts

View File

@@ -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,97 @@ 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
```
仅重启后端容器(不重建镜像、不影响前端):
```shell
docker compose \
-f docker/docker-compose.yaml \
--env-file .env \
--profile dev restart springboot websocket-service
```
数据初始化sql会创建几个帐户供大家测试使用
> username:admin/user1/user2 password:123456
3. 查看服务状态:
```shell
docker compose -f docker/docker-compose.yaml --env-file .env ps
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 +138,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 登录业务,就需要谷歌相关的变量,数据库是一定要的
![环境变量](assets/contributing/backend_img_7.png)
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 追踪,通常不推荐。
![配置数据库](assets/contributing/backend_img_5.png)
#### 配置 IDEA 参数
- 设置 JDK 版本为 java 17
- 设置 VM Option最好运行在其他端口非 `8080`,这里设置 `8081`
若上面在环境变量中设置了端口,那这里就不需要再额外设置
- 设置 JDK 版本为 Java 17
- 设置 VM Option最好运行在其他端口例如 `8081`)。若已经在 `open-isle.env` 中调整端口,可省略此步骤。
```shell
-Dserver.port=8081
```
@@ -91,191 +177,22 @@ SERVER_PORT=8082
![配置2](assets/contributing/backend_img_2.png)
#### 配置 MySQL
> [!TIP]
> 如果不知道怎么配置数据库可以参考 [Docker 环境](#docker-环境) 章节
1. 本机配置 MySQL 服务(网上很多教程,忽略)
- 可以用 Laragon自带 MySQL 包括 Nodejs版本建议 `6.x``7` 以后需要 Lisence
- [下载地址](https://github.com/leokhoa/laragon/releases)
2. 填写环境变量
![环境变量](assets/contributing/backend_img_6.png)
```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**
![初始化脚本](assets/contributing/resources_img.png)
#### 配置 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 应用。
![运行画面](assets/contributing/backend_img_4.png)
### 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 +251,7 @@ https://docs.open-isle.com
### OpenAPI文档使用
- 预发环境/正式环境切换,以通过如下位置切换API环境
- 预发环境/正式环境切换以通过如下位置切换API环境
![CleanShot 2025-09-10 at 12.08.00@2x.png](https://openisle-1307107697.cos.accelerate.myqcloud.com/dynamic_assert/f9fb7a0f020d4a0e94159d7820783224.png)

View File

@@ -26,7 +26,7 @@ OpenIsle 是一个使用 Spring Boot 和 Vue 3 构建的全栈开源社区平台
- 集成 OpenAI 提供的 Markdown 格式化功能
- 通过环境变量可调整密码强度、登录方式、保护码等多种配置
- 支持图片上传,默认使用腾讯云 COS 扩展
- 默认头像使用 DiceBear Avatars可通过 `AVATAR_STYLE``AVATAR_SIZE` 环境变量自定义主题和大小
- Bot 集成,可在平台内快速连接自定义机器人,并通过 Telegram 的 BotFather 创建和管理消息机器人,拓展社区互动渠道
- 浏览器推送通知,离开网站也能及时收到提醒
## 🌟 项目优势
@@ -41,7 +41,7 @@ OpenIsle 是一个使用 Spring Boot 和 Vue 3 构建的全栈开源社区平台
## 🏘️ 社区
欢迎彼此交流和使用 OpenIsle项目以开源方式提供,想了解更多可访问:<https://github.com/nagisa77/OpenIsle>
- 欢迎彼此交流和使用 OpenIsle项目以开源方式提供;如果遇到问题请到 GitHub 的 Issues 页面反馈,想发起话题讨论也可以前往源站 <https://www.open-isle.com>,这里提供更完整的社区板块与互动体验。
## 📋 授权

176
SECURITY.md Normal file
View 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!

View File

@@ -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>

View File

@@ -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>

View File

@@ -97,12 +97,14 @@ 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",
"http://30.211.97.238",
"http://192.168.7.98",
"http://192.168.7.98:3000",
"http://192.168.7.90",
"http://192.168.7.90:3000",
"https://petstore.swagger.io",
// 允许自建OpenAPI地址
"https://docs.open-isle.com",
@@ -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 ")) {

View File

@@ -6,10 +6,12 @@ import com.openisle.model.User;
import com.openisle.repository.NotificationRepository;
import com.openisle.repository.UserRepository;
import com.openisle.service.EmailSender;
import com.openisle.exception.EmailSendException;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@@ -17,6 +19,7 @@ import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/admin/users")
@RequiredArgsConstructor
@Slf4j
public class AdminUserController {
private final UserRepository userRepository;
@@ -35,11 +38,15 @@ public class AdminUserController {
user.setApproved(true);
userRepository.save(user);
markRegisterRequestNotificationsRead(user);
emailSender.sendEmail(
user.getEmail(),
"您的注册已审核通过",
"🎉您的注册已审核通过, 点击以访问网站: " + websiteUrl
);
try {
emailSender.sendEmail(
user.getEmail(),
"您的注册已审核通过",
"🎉您的注册已经审核通过, 点击以访问网站: " + websiteUrl
);
} catch (EmailSendException e) {
log.warn("Failed to send approve email to {}: {}", user.getEmail(), e.getMessage());
}
return ResponseEntity.ok().build();
}
@@ -52,11 +59,15 @@ public class AdminUserController {
user.setApproved(false);
userRepository.save(user);
markRegisterRequestNotificationsRead(user);
emailSender.sendEmail(
user.getEmail(),
"您的注册已被管理员拒绝",
"您的注册被管理员拒绝, 点击链接可以重新填写理由申请: " + websiteUrl
);
try {
emailSender.sendEmail(
user.getEmail(),
"您的注册被管理员拒绝",
"您的注册被管理员拒绝, 点击链接可以重新填写理由申请: " + websiteUrl
);
} catch (EmailSendException e) {
log.warn("Failed to send reject email to {}: {}", user.getEmail(), e.getMessage());
}
return ResponseEntity.ok().build();
}

View File

@@ -2,6 +2,7 @@ package com.openisle.controller;
import com.openisle.config.CachingConfig;
import com.openisle.dto.*;
import com.openisle.exception.EmailSendException;
import com.openisle.exception.FieldException;
import com.openisle.model.RegisterMode;
import com.openisle.model.User;
@@ -19,6 +20,7 @@ import java.util.concurrent.TimeUnit;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@@ -83,6 +85,17 @@ public class AuthController {
"INVITE_APPROVED"
)
);
} catch (EmailSendException e) {
return ResponseEntity
.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(
Map.of(
"error",
"邮件发送失败: " + e.getMessage(),
"reason_code",
"EMAIL_SEND_FAILED"
)
);
} catch (FieldException e) {
return ResponseEntity.badRequest().body(
Map.of("field", e.getField(), "error", e.getMessage())
@@ -97,7 +110,20 @@ public class AuthController {
registerModeService.getRegisterMode()
);
// 发送确认邮件
userService.sendVerifyMail(user, VerifyType.REGISTER);
try {
userService.sendVerifyMail(user, VerifyType.REGISTER);
} catch (EmailSendException e) {
return ResponseEntity
.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(
Map.of(
"error",
"邮件发送失败: " + e.getMessage(),
"reason_code",
"EMAIL_SEND_FAILED"
)
);
}
if (!user.isApproved()) {
notificationService.createRegisterRequestNotifications(user, user.getRegisterReason());
}
@@ -169,14 +195,28 @@ public class AuthController {
}
User user = userOpt.get();
if (!user.isVerified()) {
user = userService.register(
user.getUsername(),
user.getEmail(),
user.getPassword(),
user.getRegisterReason(),
registerModeService.getRegisterMode()
);
userService.sendVerifyMail(user, VerifyType.REGISTER);
user =
userService.register(
user.getUsername(),
user.getEmail(),
user.getPassword(),
user.getRegisterReason(),
registerModeService.getRegisterMode()
);
try {
userService.sendVerifyMail(user, VerifyType.REGISTER);
} catch (EmailSendException e) {
return ResponseEntity
.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(
Map.of(
"error",
"Failed to send verification email: " + e.getMessage(),
"reason_code",
"EMAIL_SEND_FAILED"
)
);
}
return ResponseEntity.badRequest().body(
Map.of(
"error",
@@ -663,7 +703,20 @@ public class AuthController {
if (userOpt.isEmpty()) {
return ResponseEntity.badRequest().body(Map.of("error", "User not found"));
}
userService.sendVerifyMail(userOpt.get(), VerifyType.RESET_PASSWORD);
try {
userService.sendVerifyMail(userOpt.get(), VerifyType.RESET_PASSWORD);
} catch (EmailSendException e) {
return ResponseEntity
.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(
Map.of(
"error",
"邮件发送失败: " + e.getMessage(),
"reason_code",
"EMAIL_SEND_FAILED"
)
);
}
return ResponseEntity.ok(Map.of("message", "Verification code sent"));
}

View File

@@ -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;
@@ -108,7 +112,9 @@ public class CommentController {
)
public List<TimelineItemDto<?>> listComments(
@PathVariable Long postId,
@RequestParam(value = "sort", required = false, defaultValue = "OLDEST") CommentSort sort
@RequestParam(value = "sort", required = false, defaultValue = "OLDEST") CommentSort sort,
@RequestParam(value = "page", required = false, defaultValue = "0") int page,
@RequestParam(value = "pageSize", required = false, defaultValue = "20") int pageSize
) {
log.debug("listComments called for post {} with sort {}", postId, sort);
List<CommentDto> commentDtoList = commentService
@@ -131,6 +137,7 @@ public class CommentController {
c.getId(),
"comment",
c.getCreatedAt(),
c.getPinnedAt(),
c // payload 是 CommentDto
)
)
@@ -145,19 +152,87 @@ 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);
log.debug("listComments returning {} comments", itemDtoList.size());
return itemDtoList;
itemDtoList.sort(comparator.thenComparing(createdAtComparator));
int safePage = Math.max(0, page);
int safePageSize = Math.max(1, pageSize);
int fromIndex = safePage * safePageSize;
int toIndex = Math.min(fromIndex + safePageSize, itemDtoList.size());
List<TimelineItemDto<?>> pagedItems =
fromIndex >= itemDtoList.size() ? List.of() : itemDtoList.subList(fromIndex, toIndex);
log.debug(
"listComments returning {} items for post {} page {} size {} (total {})",
pagedItems.size(),
postId,
safePage,
safePageSize,
itemDtoList.size()
);
return pagedItems;
}
@GetMapping("/comments/{commentId}/context")
@Operation(
summary = "Comment context",
description = "Get a comment along with its previous comments and related post"
)
@ApiResponse(
responseCode = "200",
description = "Comment context",
content = @Content(schema = @Schema(implementation = CommentContextDto.class))
)
public ResponseEntity<CommentContextDto> getCommentContext(@PathVariable Long commentId) {
log.debug("getCommentContext called for comment {}", commentId);
Comment comment = commentService.getComment(commentId);
CommentContextDto dto = new CommentContextDto();
dto.setPost(postMapper.toSummaryDto(comment.getPost()));
dto.setTargetComment(commentMapper.toDtoWithReplies(comment));
dto.setPreviousComments(
commentService
.getCommentsBefore(comment)
.stream()
.map(commentMapper::toDtoWithReplies)
.collect(Collectors.toList())
);
log.debug(
"getCommentContext returning {} previous comments for comment {}",
dto.getPreviousComments().size(),
commentId
);
return ResponseEntity.ok(dto);
}
@DeleteMapping("/comments/{id}")

View File

@@ -66,6 +66,7 @@ public class PostController {
req.getContent(),
req.getTagIds(),
req.getType(),
req.getPostVisibleScopeType(),
req.getPrizeDescription(),
req.getPrizeIcon(),
req.getPrizeCount(),
@@ -73,7 +74,9 @@ public class PostController {
req.getStartTime(),
req.getEndTime(),
req.getOptions(),
req.getMultiple()
req.getMultiple(),
req.getProposedName(),
req.getProposalDescription()
);
draftService.deleteDraft(auth.getName());
PostDetailDto dto = postMapper.toDetailDto(post, auth.getName());
@@ -101,7 +104,8 @@ public class PostController {
req.getCategoryId(),
req.getTitle(),
req.getContent(),
req.getTagIds()
req.getTagIds(),
req.getPostVisibleScopeType()
);
return ResponseEntity.ok(postMapper.toDetailDto(post, auth.getName()));
}
@@ -213,8 +217,24 @@ public class PostController {
// userVisitService.recordVisit(auth.getName());
// }
return postMapper.toListDtos(postService.defaultListPosts(ids, tids, page, pageSize));
}
@GetMapping("/recent")
@Operation(
summary = "Recent posts",
description = "List posts created within the specified number of minutes"
)
@ApiResponse(
responseCode = "200",
description = "Recent posts",
content = @Content(
array = @ArraySchema(schema = @Schema(implementation = PostSummaryDto.class))
)
)
public List<PostSummaryDto> recentPosts(@RequestParam("minutes") int minutes) {
return postService
.defaultListPosts(ids, tids, page, pageSize)
.listRecentPosts(minutes)
.stream()
.map(postMapper::toSummaryDto)
.collect(Collectors.toList());
@@ -245,11 +265,7 @@ public class PostController {
// userVisitService.recordVisit(auth.getName());
// }
return postService
.listPostsByViews(ids, tids, page, pageSize)
.stream()
.map(postMapper::toSummaryDto)
.collect(Collectors.toList());
return postMapper.toListDtos(postService.listPostsByViews(ids, tids, page, pageSize));
}
@GetMapping("/latest-reply")
@@ -281,8 +297,7 @@ public class PostController {
// userVisitService.recordVisit(auth.getName());
// }
List<Post> posts = postService.listPostsByLatestReply(ids, tids, page, pageSize);
return posts.stream().map(postMapper::toSummaryDto).collect(Collectors.toList());
return postMapper.toListDtos(postService.listPostsByLatestReply(ids, tids, page, pageSize));
}
@GetMapping("/featured")
@@ -309,10 +324,6 @@ public class PostController {
// if (auth != null) {
// userVisitService.recordVisit(auth.getName());
// }
return postService
.listFeaturedPosts(ids, tids, page, pageSize)
.stream()
.map(postMapper::toSummaryDto)
.collect(Collectors.toList());
return postMapper.toListDtos(postService.listFeaturedPosts(ids, tids, page, pageSize));
}
}

View File

@@ -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());
}
}

View File

@@ -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());

View File

@@ -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;
}

View File

@@ -34,6 +34,7 @@ public class UserController {
private final TagService tagService;
private final SubscriptionService subscriptionService;
private final LevelService levelService;
private final PostReadService postReadService;
private final JwtService jwtService;
private final UserMapper userMapper;
private final TagMapper tagMapper;
@@ -53,6 +54,9 @@ public class UserController {
@Value("${app.user.tags-limit:50}")
private int defaultTagsLimit;
@Value("${app.user.read-posts-limit:50}")
private int defaultReadPostsLimit;
@GetMapping("/me")
@SecurityRequirement(name = "JWT")
@Operation(summary = "Current user", description = "Get current authenticated user information")
@@ -211,6 +215,33 @@ public class UserController {
.collect(java.util.stream.Collectors.toList());
}
@GetMapping("/{identifier}/read-posts")
@SecurityRequirement(name = "JWT")
@Operation(summary = "User read posts", description = "Get post read history (self only)")
@ApiResponse(
responseCode = "200",
description = "Post read history",
content = @Content(array = @ArraySchema(schema = @Schema(implementation = PostReadDto.class)))
)
public ResponseEntity<java.util.List<PostReadDto>> userReadPosts(
@PathVariable("identifier") String identifier,
@RequestParam(value = "limit", required = false) Integer limit,
Authentication auth
) {
User user = userService.findByIdentifier(identifier).orElseThrow();
if (auth == null || !auth.getName().equals(user.getUsername())) {
return ResponseEntity.status(403).body(java.util.List.of());
}
int l = limit != null ? limit : defaultReadPostsLimit;
return ResponseEntity.ok(
postReadService
.getRecentReadsByUser(user.getUsername(), l)
.stream()
.map(userMapper::toPostReadDto)
.collect(java.util.stream.Collectors.toList())
);
}
@GetMapping("/{identifier}/hot-posts")
@Operation(summary = "User hot posts", description = "Get most reacted posts by user")
@ApiResponse(

View File

@@ -13,4 +13,5 @@ public class AuthorDto {
private String username;
private String avatar;
private MedalType displayMedal;
private boolean bot;
}

View File

@@ -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;
}

View 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;
}

View File

@@ -0,0 +1,11 @@
package com.openisle.dto;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class DonationRequest {
private int amount;
}

View 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;
}

View File

@@ -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;
}

View File

@@ -0,0 +1,12 @@
package com.openisle.dto;
import java.time.LocalDateTime;
import lombok.Data;
/** DTO for a user's post read record. */
@Data
public class PostReadDto {
private PostMetaDto post;
private LocalDateTime lastReadAt;
}

View File

@@ -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;
}

View File

@@ -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;
}

View 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;
}

View File

@@ -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;
}

View File

@@ -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; // 泛型,具体类型由外部决定
}

View File

@@ -28,4 +28,5 @@ public class UserDto {
private int point;
private int currentLevel;
private int nextLevelExp;
private boolean bot;
}

View File

@@ -8,4 +8,5 @@ public class UserSummaryDto {
private Long id;
private String username;
private String avatar;
private boolean bot;
}

View File

@@ -0,0 +1,15 @@
package com.openisle.exception;
/**
* Thrown when email sending fails so callers can surface a clear error upstream.
*/
public class EmailSendException extends RuntimeException {
public EmailSendException(String message) {
super(message);
}
public EmailSendException(String message, Throwable cause) {
super(message, cause);
}
}

View File

@@ -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;
}

View File

@@ -6,7 +6,9 @@ import com.openisle.dto.LotteryDto;
import com.openisle.dto.PollDto;
import com.openisle.dto.PostDetailDto;
import com.openisle.dto.PostSummaryDto;
import com.openisle.dto.ProposalDto;
import com.openisle.dto.ReactionDto;
import com.openisle.model.CategoryProposalPost;
import com.openisle.model.CommentSort;
import com.openisle.model.LotteryPost;
import com.openisle.model.PollPost;
@@ -46,6 +48,38 @@ public class PostMapper {
return dto;
}
public List<PostSummaryDto> toListDtos(List<Post> posts) {
if (posts == null || posts.isEmpty()) {
return List.of();
}
Map<Long, List<User>> participantsMap = commentService.getParticipantsForPosts(posts, 5);
return posts
.stream()
.map(post -> {
PostSummaryDto dto = new PostSummaryDto();
applyListFields(post, dto);
List<User> participants = participantsMap.get(post.getId());
if (participants != null) {
dto.setParticipants(
participants.stream().map(userMapper::toAuthorDto).collect(Collectors.toList())
);
} else {
dto.setParticipants(List.of());
}
dto.setReactions(List.of());
return dto;
})
.collect(Collectors.toList());
}
public PostSummaryDto toListDto(Post post) {
PostSummaryDto dto = new PostSummaryDto();
applyListFields(post, dto);
dto.setParticipants(List.of());
dto.setReactions(List.of());
return dto;
}
public PostDetailDto toDetailDto(Post post, String viewer) {
PostDetailDto dto = new PostDetailDto();
applyCommon(post, dto);
@@ -59,6 +93,25 @@ public class PostMapper {
return dto;
}
private void applyListFields(Post post, PostSummaryDto dto) {
dto.setId(post.getId());
dto.setTitle(post.getTitle());
dto.setContent(post.getContent());
dto.setCreatedAt(post.getCreatedAt());
dto.setAuthor(userMapper.toAuthorDto(post.getAuthor()));
dto.setCategory(categoryMapper.toDto(post.getCategory()));
dto.setTags(post.getTags().stream().map(tagMapper::toDto).collect(Collectors.toList()));
dto.setViews(post.getViews());
dto.setCommentCount(post.getCommentCount());
dto.setStatus(post.getStatus());
dto.setPinnedAt(post.getPinnedAt());
dto.setLastReplyAt(post.getLastReplyAt());
dto.setRssExcluded(post.getRssExcluded() == null || post.getRssExcluded());
dto.setClosed(post.isClosed());
dto.setVisibleScope(post.getVisibleScope());
dto.setType(post.getType());
}
private void applyCommon(Post post, PostSummaryDto dto) {
dto.setId(post.getId());
dto.setTitle(post.getTitle());
@@ -73,6 +126,7 @@ public class PostMapper {
dto.setPinnedAt(post.getPinnedAt());
dto.setRssExcluded(post.getRssExcluded() == null || post.getRssExcluded());
dto.setClosed(post.isClosed());
dto.setVisibleScope(post.getVisibleScope());
List<ReactionDto> reactions = reactionService
.getReactionsForPost(post.getId())
@@ -113,26 +167,40 @@ public class PostMapper {
dto.setLottery(l);
}
if (post instanceof PollPost pp) {
PollDto p = new PollDto();
p.setOptions(pp.getOptions());
p.setVotes(pp.getVotes());
p.setEndTime(pp.getEndTime());
p.setParticipants(
pp.getParticipants().stream().map(userMapper::toAuthorDto).collect(Collectors.toList())
);
Map<Integer, List<AuthorDto>> optionParticipants = pollVoteRepository
.findByPostId(pp.getId())
.stream()
.collect(
Collectors.groupingBy(
PollVote::getOptionIndex,
Collectors.mapping(v -> userMapper.toAuthorDto(v.getUser()), Collectors.toList())
)
);
p.setOptionParticipants(optionParticipants);
p.setMultiple(Boolean.TRUE.equals(pp.getMultiple()));
dto.setPoll(p);
if (post instanceof CategoryProposalPost cp) {
ProposalDto proposalDto = (ProposalDto) buildPollDto(cp, new ProposalDto());
proposalDto.setProposalStatus(cp.getProposalStatus());
proposalDto.setProposedName(cp.getProposedName());
proposalDto.setDescription(cp.getDescription());
proposalDto.setApproveThreshold(cp.getApproveThreshold());
proposalDto.setQuorum(cp.getQuorum());
proposalDto.setStartAt(cp.getStartAt());
proposalDto.setResultSnapshot(cp.getResultSnapshot());
proposalDto.setRejectReason(cp.getRejectReason());
dto.setPoll(proposalDto);
} else if (post instanceof PollPost pp) {
dto.setPoll(buildPollDto(pp, new PollDto()));
}
}
private PollDto buildPollDto(PollPost pollPost, PollDto target) {
target.setOptions(pollPost.getOptions());
target.setVotes(pollPost.getVotes());
target.setEndTime(pollPost.getEndTime());
target.setParticipants(
pollPost.getParticipants().stream().map(userMapper::toAuthorDto).collect(Collectors.toList())
);
Map<Integer, List<AuthorDto>> optionParticipants = pollVoteRepository
.findByPostId(pollPost.getId())
.stream()
.collect(
Collectors.groupingBy(
PollVote::getOptionIndex,
Collectors.mapping(v -> userMapper.toAuthorDto(v.getUser()), Collectors.toList())
)
);
target.setOptionParticipants(optionParticipants);
target.setMultiple(Boolean.TRUE.equals(pollPost.getMultiple()));
return target;
}
}

View File

@@ -3,6 +3,7 @@ package com.openisle.mapper;
import com.openisle.dto.*;
import com.openisle.model.Comment;
import com.openisle.model.Post;
import com.openisle.model.PostRead;
import com.openisle.model.User;
import com.openisle.service.*;
import java.util.stream.Collectors;
@@ -37,6 +38,7 @@ public class UserMapper {
dto.setUsername(user.getUsername());
dto.setAvatar(user.getAvatar());
dto.setDisplayMedal(user.getDisplayMedal());
dto.setBot(user.isBot());
return dto;
}
@@ -63,6 +65,7 @@ public class UserMapper {
dto.setPoint(user.getPoint());
dto.setCurrentLevel(levelService.getLevel(user.getExperience()));
dto.setNextLevelExp(levelService.nextLevelExp(user.getExperience()));
dto.setBot(user.isBot());
if (viewer != null) {
dto.setSubscribed(subscriptionService.isSubscribed(viewer.getName(), user.getUsername()));
} else {
@@ -113,4 +116,11 @@ public class UserMapper {
}
return dto;
}
public PostReadDto toPostReadDto(PostRead read) {
PostReadDto dto = new PostReadDto();
dto.setPost(toMetaDto(read.getPost()));
dto.setLastReadAt(read.getLastReadAt());
return dto;
}
}

View File

@@ -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;
}

View File

@@ -0,0 +1,10 @@
package com.openisle.model;
public enum CategoryProposalStatus {
PENDING,
APPROVED,
REJECTED
}

View File

@@ -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,
}

View File

@@ -13,4 +13,6 @@ public enum PointHistoryType {
REDEEM,
LOTTERY_JOIN,
LOTTERY_REWARD,
DONATE_SENT,
DONATE_RECEIVED,
}

View File

@@ -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;

View File

@@ -8,6 +8,8 @@ public enum PostChangeType {
CLOSED,
PINNED,
FEATURED,
VISIBLE_SCOPE,
VOTE_RESULT,
LOTTERY_RESULT,
DONATE,
}

View File

@@ -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;
}

View File

@@ -4,4 +4,5 @@ public enum PostType {
NORMAL,
LOTTERY,
POLL,
PROPOSAL
}

View File

@@ -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;
}

View File

@@ -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();
}
}

View File

@@ -62,6 +62,9 @@ public class User {
@Column(nullable = false)
private Role role = Role.USER;
@Column(name = "is_bot", nullable = false)
private boolean bot = false;
@Enumerated(EnumType.STRING)
private MedalType displayMedal;

View File

@@ -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);
}

View File

@@ -3,6 +3,7 @@ package com.openisle.repository;
import com.openisle.model.Comment;
import com.openisle.model.Post;
import com.openisle.model.User;
import java.time.LocalDateTime;
import java.util.List;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
@@ -10,6 +11,10 @@ import org.springframework.data.jpa.repository.JpaRepository;
public interface CommentRepository extends JpaRepository<Comment, Long> {
List<Comment> findByPostAndParentIsNullOrderByCreatedAtAsc(Post post);
List<Comment> findByParentOrderByCreatedAtAsc(Comment parent);
List<Comment> findByPostAndCreatedAtLessThanOrderByCreatedAtAsc(
Post post,
LocalDateTime createdAt
);
List<Comment> findByAuthorOrderByCreatedAtDesc(User author, Pageable pageable);
List<Comment> findByContentContainingIgnoreCase(String keyword);
@@ -20,6 +25,13 @@ public interface CommentRepository extends JpaRepository<Comment, Long> {
@org.springframework.data.repository.query.Param("post") Post post
);
@org.springframework.data.jpa.repository.Query(
"SELECT DISTINCT c.post.id, c.author FROM Comment c WHERE c.post.id IN :postIds"
)
java.util.List<Object[]> findDistinctAuthorsByPostIds(
@org.springframework.data.repository.query.Param("postIds") java.util.List<Long> postIds
);
@org.springframework.data.jpa.repository.Query(
"SELECT MAX(c.createdAt) FROM Comment c WHERE c.post = :post"
)

View File

@@ -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);
}

View File

@@ -3,11 +3,14 @@ package com.openisle.repository;
import com.openisle.model.Post;
import com.openisle.model.PostRead;
import com.openisle.model.User;
import java.util.List;
import java.util.Optional;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
public interface PostReadRepository extends JpaRepository<PostRead, Long> {
Optional<PostRead> findByUserAndPost(User user, Post post);
List<PostRead> findByUserOrderByLastReadAtDesc(User user, Pageable pageable);
long countByUser(User user);
void deleteByPost(Post post);
}

View File

@@ -19,6 +19,12 @@ public interface PostRepository extends JpaRepository<Post, Long> {
List<Post> findByStatusOrderByCreatedAtDesc(PostStatus status, Pageable pageable);
List<Post> findByStatusOrderByViewsDesc(PostStatus status);
List<Post> findByStatusOrderByViewsDesc(PostStatus status, Pageable pageable);
List<Post> findByStatusOrderByPinnedAtDescViewsDesc(PostStatus status, Pageable pageable);
List<Post> findByStatusOrderByPinnedAtDescLastReplyAtDesc(PostStatus status, Pageable pageable);
List<Post> findByStatusAndCreatedAtGreaterThanEqualOrderByCreatedAtDesc(
PostStatus status,
LocalDateTime createdAt
);
List<Post> findByAuthorAndStatusOrderByCreatedAtDesc(
User author,
PostStatus status,
@@ -39,6 +45,16 @@ public interface PostRepository extends JpaRepository<Post, Long> {
PostStatus status,
Pageable pageable
);
List<Post> findByCategoryInAndStatusOrderByPinnedAtDescViewsDesc(
List<Category> categories,
PostStatus status,
Pageable pageable
);
List<Post> findByCategoryInAndStatusOrderByPinnedAtDescLastReplyAtDesc(
List<Category> categories,
PostStatus status,
Pageable pageable
);
List<Post> findDistinctByTagsInAndStatus(List<Tag> tags, PostStatus status);
List<Post> findDistinctByTagsInAndStatus(List<Tag> tags, PostStatus status, Pageable pageable);
List<Post> findDistinctByTagsInAndStatusOrderByCreatedAtDesc(List<Tag> tags, PostStatus status);
@@ -128,6 +144,26 @@ public interface PostRepository extends JpaRepository<Post, Long> {
Pageable pageable
);
@Query(
"SELECT p FROM Post p JOIN p.tags t WHERE t IN :tags AND p.status = :status GROUP BY p.id HAVING COUNT(DISTINCT t.id) = :tagCount ORDER BY p.pinnedAt DESC, p.views DESC"
)
List<Post> findByAllTagsOrderByPinnedAtDescViewsDesc(
@Param("tags") List<Tag> tags,
@Param("status") PostStatus status,
@Param("tagCount") long tagCount,
Pageable pageable
);
@Query(
"SELECT p FROM Post p JOIN p.tags t WHERE t IN :tags AND p.status = :status GROUP BY p.id HAVING COUNT(DISTINCT t.id) = :tagCount ORDER BY p.pinnedAt DESC, p.lastReplyAt DESC"
)
List<Post> findByAllTagsOrderByPinnedAtDescLastReplyAtDesc(
@Param("tags") List<Tag> tags,
@Param("status") PostStatus status,
@Param("tagCount") long tagCount,
Pageable pageable
);
@Query(
"SELECT p FROM Post p JOIN p.tags t WHERE p.category IN :categories AND t IN :tags AND p.status = :status GROUP BY p.id HAVING COUNT(DISTINCT t.id) = :tagCount"
)
@@ -170,6 +206,28 @@ public interface PostRepository extends JpaRepository<Post, Long> {
Pageable pageable
);
@Query(
"SELECT p FROM Post p JOIN p.tags t WHERE p.category IN :categories AND t IN :tags AND p.status = :status GROUP BY p.id HAVING COUNT(DISTINCT t.id) = :tagCount ORDER BY p.pinnedAt DESC, p.views DESC"
)
List<Post> findByCategoriesAndAllTagsOrderByPinnedAtDescViewsDesc(
@Param("categories") List<Category> categories,
@Param("tags") List<Tag> tags,
@Param("status") PostStatus status,
@Param("tagCount") long tagCount,
Pageable pageable
);
@Query(
"SELECT p FROM Post p JOIN p.tags t WHERE p.category IN :categories AND t IN :tags AND p.status = :status GROUP BY p.id HAVING COUNT(DISTINCT t.id) = :tagCount ORDER BY p.pinnedAt DESC, p.lastReplyAt DESC"
)
List<Post> findByCategoriesAndAllTagsOrderByPinnedAtDescLastReplyAtDesc(
@Param("categories") List<Category> categories,
@Param("tags") List<Tag> tags,
@Param("status") PostStatus status,
@Param("tagCount") long tagCount,
Pageable pageable
);
@Query(
"SELECT p FROM Post p JOIN p.tags t WHERE p.category IN :categories AND t IN :tags AND p.status = :status GROUP BY p.id HAVING COUNT(DISTINCT t.id) = :tagCount ORDER BY p.createdAt DESC"
)

View File

@@ -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
}
}

View File

@@ -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();
}
}

View File

@@ -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);
}
}
}

View File

@@ -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";
}
}

View File

@@ -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
) {}

View File

@@ -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();
}
}

View File

@@ -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());
}
}

View File

@@ -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));
}
}

View File

@@ -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")))
)
);
}
/** 文本字段:.rawkeyword 精确) + .py拼音短语精确 + .zhICU+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/tagskeyword 等值 + .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;
}
}

View File

@@ -0,0 +1,6 @@
package com.openisle.search;
public interface SearchIndexer {
void indexDocument(String index, SearchDocument document);
void deleteDocument(String index, Long id);
}

View File

@@ -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();
}
}

View File

@@ -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());
}
}

View File

@@ -0,0 +1,3 @@
package com.openisle.search.event;
public record DeleteDocumentEvent(String index, Long id) {}

View File

@@ -0,0 +1,5 @@
package com.openisle.search.event;
import com.openisle.search.SearchDocument;
public record IndexDocumentEvent(String index, SearchDocument document) {}

View File

@@ -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) {

View File

@@ -105,6 +105,7 @@ public class ChannelService {
userDto.setId(message.getSender().getId());
userDto.setUsername(message.getSender().getUsername());
userDto.setAvatar(message.getSender().getAvatar());
userDto.setBot(message.getSender().isBot());
dto.setSender(userDto);
return dto;

View File

@@ -16,12 +16,17 @@ 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;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
@@ -49,6 +54,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 +130,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 +228,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 +270,27 @@ public class CommentService {
return replies;
}
public Comment getComment(Long commentId) {
log.debug("getComment called for id {}", commentId);
return commentRepository
.findById(commentId)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Comment not found"));
}
public List<Comment> getCommentsBefore(Comment comment) {
log.debug("getCommentsBefore called for comment {}", comment.getId());
List<Comment> comments = commentRepository.findByPostAndCreatedAtLessThanOrderByCreatedAtAsc(
comment.getPost(),
comment.getCreatedAt()
);
log.debug(
"getCommentsBefore returning {} comments for comment {}",
comments.size(),
comment.getId()
);
return comments;
}
public List<Comment> getRecentCommentsByUser(String username, int limit) {
log.debug("getRecentCommentsByUser called for user {} with limit {}", username, limit);
User user = userRepository
@@ -291,6 +320,37 @@ public class CommentService {
return result;
}
public Map<Long, List<User>> getParticipantsForPosts(List<Post> posts, int limit) {
if (posts == null || posts.isEmpty()) {
return Map.of();
}
Map<Long, LinkedHashSet<User>> map = new HashMap<>();
List<Long> postIds = new ArrayList<>(posts.size());
for (Post post : posts) {
postIds.add(post.getId());
LinkedHashSet<User> set = new LinkedHashSet<>();
set.add(post.getAuthor());
map.put(post.getId(), set);
}
for (Object[] row : commentRepository.findDistinctAuthorsByPostIds(postIds)) {
Long postId = (Long) row[0];
User author = (User) row[1];
LinkedHashSet<User> set = map.get(postId);
if (set != null) {
set.add(author);
}
}
Map<Long, List<User>> result = new HashMap<>(map.size());
for (Map.Entry<Long, LinkedHashSet<User>> entry : map.entrySet()) {
List<User> list = new ArrayList<>(entry.getValue());
if (list.size() > limit) {
list = list.subList(0, limit);
}
result.put(entry.getKey(), list);
}
return result;
}
public java.util.List<Comment> getCommentsByIds(java.util.List<Long> ids) {
log.debug("getCommentsByIds called for ids {}", ids);
java.util.List<Comment> comments = commentRepository.findAllById(ids);
@@ -360,7 +420,9 @@ public class CommentService {
// 逻辑删除评论
Post post = comment.getPost();
Long commentId = comment.getId();
commentRepository.delete(comment);
searchIndexEventPublisher.publishCommentDeleted(commentId);
// 删除积分历史
pointHistoryRepository.deleteAll(pointHistories);

View File

@@ -211,6 +211,7 @@ public class MessageService {
userSummaryDto.setId(message.getSender().getId());
userSummaryDto.setUsername(message.getSender().getUsername());
userSummaryDto.setAvatar(message.getSender().getAvatar());
userSummaryDto.setBot(message.getSender().isBot());
dto.setSender(userSummaryDto);
if (message.getReplyTo() != null) {
@@ -222,6 +223,7 @@ public class MessageService {
replySender.setId(reply.getSender().getId());
replySender.setUsername(reply.getSender().getUsername());
replySender.setAvatar(reply.getSender().getAvatar());
replySender.setBot(reply.getSender().isBot());
replyDto.setSender(replySender);
dto.setReplyTo(replyDto);
}
@@ -316,6 +318,7 @@ public class MessageService {
userDto.setId(p.getUser().getId());
userDto.setUsername(p.getUser().getUsername());
userDto.setAvatar(p.getUser().getAvatar());
userDto.setBot(p.getUser().isBot());
return userDto;
})
.collect(Collectors.toList())
@@ -365,6 +368,7 @@ public class MessageService {
userDto.setId(p.getUser().getId());
userDto.setUsername(p.getUser().getUsername());
userDto.setAvatar(p.getUser().getAvatar());
userDto.setBot(p.getUser().isBot());
return userDto;
})
.collect(Collectors.toList());

View File

@@ -7,6 +7,7 @@ import com.openisle.repository.NotificationRepository;
import com.openisle.repository.ReactionRepository;
import com.openisle.repository.UserRepository;
import com.openisle.service.EmailSender;
import com.openisle.exception.EmailSendException;
import java.util.ArrayList;
import java.util.EnumSet;
import java.util.HashSet;
@@ -17,6 +18,7 @@ import java.util.concurrent.Executor;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -26,6 +28,7 @@ import org.springframework.transaction.support.TransactionSynchronizationManager
/** Service for creating and retrieving notifications. */
@Service
@RequiredArgsConstructor
@Slf4j
public class NotificationService {
private final NotificationRepository notificationRepository;
@@ -108,7 +111,11 @@ public class NotificationService {
post.getId(),
comment.getId()
);
emailSender.sendEmail(user.getEmail(), "有人回复了你", url);
try {
emailSender.sendEmail(user.getEmail(), "有人回复了你", url);
} catch (EmailSendException e) {
log.warn("Failed to send notification email to {}: {}", user.getEmail(), e.getMessage());
}
sendCustomPush(user, "有人回复了你", url);
} else if (type == NotificationType.REACTION && comment != null) {
// long count = reactionRepository.countReceived(comment.getAuthor().getUsername());

View File

@@ -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;
}
}

View File

@@ -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);
}

View File

@@ -7,7 +7,10 @@ import com.openisle.repository.PostReadRepository;
import com.openisle.repository.PostRepository;
import com.openisle.repository.UserRepository;
import java.time.LocalDateTime;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
@Service
@@ -43,6 +46,14 @@ public class PostReadService {
);
}
public List<PostRead> getRecentReadsByUser(String username, int limit) {
User user = userRepository
.findByUsername(username)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
Pageable pageable = PageRequest.of(0, limit);
return postReadRepository.findByUserOrderByLastReadAtDesc(user, pageable);
}
public long countReads(String username) {
User user = userRepository
.findByUsername(username)

View File

@@ -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,12 @@ 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 com.openisle.exception.EmailSendException;
import java.time.Duration;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
@@ -31,7 +33,6 @@ import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.context.ApplicationContext;
import org.springframework.context.event.EventListener;
import org.springframework.data.domain.PageRequest;
@@ -53,6 +54,7 @@ public class PostService {
private final TagRepository tagRepository;
private final LotteryPostRepository lotteryPostRepository;
private final PollPostRepository pollPostRepository;
private final CategoryProposalPostRepository categoryProposalPostRepository;
private final PollVoteRepository pollVoteRepository;
private PublishMode publishMode;
private final NotificationService notificationService;
@@ -70,9 +72,17 @@ public class PostService {
private final PointService pointService;
private final PostChangeLogService postChangeLogService;
private final PointHistoryRepository pointHistoryRepository;
private final CategoryService categoryService;
private final ConcurrentMap<Long, ScheduledFuture<?>> scheduledFinalizations =
new ConcurrentHashMap<>();
private final SearchIndexEventPublisher searchIndexEventPublisher;
private static final int DEFAULT_PROPOSAL_APPROVE_THRESHOLD = 60;
private static final int DEFAULT_PROPOSAL_QUORUM = 10;
private static final long DEFAULT_PROPOSAL_DURATION_DAYS = 3;
private static final List<String> DEFAULT_PROPOSAL_OPTIONS = List.of("同意", "反对");
@Value("${app.website-url:https://www.open-isle.com}")
private String websiteUrl;
@@ -86,6 +96,7 @@ public class PostService {
TagRepository tagRepository,
LotteryPostRepository lotteryPostRepository,
PollPostRepository pollPostRepository,
CategoryProposalPostRepository categoryProposalPostRepository,
PollVoteRepository pollVoteRepository,
NotificationService notificationService,
SubscriptionService subscriptionService,
@@ -103,7 +114,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 +124,7 @@ public class PostService {
this.tagRepository = tagRepository;
this.lotteryPostRepository = lotteryPostRepository;
this.pollPostRepository = pollPostRepository;
this.categoryProposalPostRepository = categoryProposalPostRepository;
this.pollVoteRepository = pollVoteRepository;
this.notificationService = notificationService;
this.subscriptionService = subscriptionService;
@@ -130,6 +144,8 @@ public class PostService {
this.publishMode = publishMode;
this.redisTemplate = redisTemplate;
this.searchIndexEventPublisher = searchIndexEventPublisher;
this.categoryService = categoryService;
}
@EventListener(ApplicationReadyEvent.class)
@@ -155,6 +171,24 @@ public class PostService {
for (PollPost pp : pollPostRepository.findByEndTimeBeforeAndResultAnnouncedFalse(now)) {
applicationContext.getBean(PostService.class).finalizePoll(pp.getId());
}
for (CategoryProposalPost cp : categoryProposalPostRepository.findByEndTimeAfterAndProposalStatus(
now,
CategoryProposalStatus.PENDING
)) {
if (cp.getEndTime() != null) {
ScheduledFuture<?> future = taskScheduler.schedule(
() -> applicationContext.getBean(PostService.class).finalizeProposal(cp.getId()),
java.util.Date.from(cp.getEndTime().atZone(ZoneId.systemDefault()).toInstant())
);
scheduledFinalizations.put(cp.getId(), future);
}
}
for (CategoryProposalPost cp : categoryProposalPostRepository.findByEndTimeBeforeAndProposalStatus(
now,
CategoryProposalStatus.PENDING
)) {
applicationContext.getBean(PostService.class).finalizeProposal(cp.getId());
}
}
public PublishMode getPublishMode() {
@@ -220,6 +254,7 @@ public class PostService {
String content,
List<Long> tagIds,
PostType type,
PostVisibleScopeType postVisibleScopeType,
String prizeDescription,
String prizeIcon,
Integer prizeCount,
@@ -227,10 +262,12 @@ public class PostService {
LocalDateTime startTime,
LocalDateTime endTime,
java.util.List<String> options,
Boolean multiple
Boolean multiple,
String proposedName,
String proposalDescription
) {
// 限制访问次数
boolean limitResult = postRateLimit(username);
boolean limitResult = isPostLimitReached(username);
if (!limitResult) {
throw new RateLimitException("Too many posts");
}
@@ -273,6 +310,25 @@ public class PostService {
pp.setEndTime(endTime);
pp.setMultiple(multiple != null && multiple);
post = pp;
} else if (actualType == PostType.PROPOSAL) {
CategoryProposalPost cp = new CategoryProposalPost();
if (proposedName == null || proposedName.isBlank()) {
throw new IllegalArgumentException("Proposed name required");
}
String normalizedName = proposedName.trim();
if (categoryProposalPostRepository.existsByProposedNameIgnoreCase(normalizedName)) {
throw new IllegalArgumentException("Proposed name already exists: " + normalizedName);
}
cp.setProposedName(normalizedName);
cp.setDescription(proposalDescription);
cp.setApproveThreshold(DEFAULT_PROPOSAL_APPROVE_THRESHOLD);
cp.setQuorum(DEFAULT_PROPOSAL_QUORUM);
LocalDateTime now = LocalDateTime.now();
cp.setStartAt(now);
cp.setEndTime(now.plusDays(DEFAULT_PROPOSAL_DURATION_DAYS));
cp.setOptions(new ArrayList<>(DEFAULT_PROPOSAL_OPTIONS));
cp.setMultiple(false);
post = cp;
} else {
post = new Post();
}
@@ -283,8 +339,19 @@ public class PostService {
post.setCategory(category);
post.setTags(new HashSet<>(tags));
post.setStatus(publishMode == PublishMode.REVIEW ? PostStatus.PENDING : PostStatus.PUBLISHED);
post.setLastReplyAt(LocalDateTime.now());
// 什么都没设置的情况下默认为ALL
if (Objects.isNull(postVisibleScopeType)) {
post.setVisibleScope(PostVisibleScopeType.ALL);
} else {
post.setVisibleScope(postVisibleScopeType);
}
if (post instanceof LotteryPost) {
post = lotteryPostRepository.save((LotteryPost) post);
} else if (post instanceof CategoryProposalPost categoryProposalPost) {
post = categoryProposalPostRepository.save(categoryProposalPost);
} else if (post instanceof PollPost) {
post = pollPostRepository.save((PollPost) post);
} else {
@@ -339,6 +406,12 @@ public class PostService {
java.util.Date.from(lp.getEndTime().atZone(ZoneId.systemDefault()).toInstant())
);
scheduledFinalizations.put(lp.getId(), future);
} else if (post instanceof CategoryProposalPost cp && cp.getEndTime() != null) {
ScheduledFuture<?> future = taskScheduler.schedule(
() -> applicationContext.getBean(PostService.class).finalizeProposal(cp.getId()),
java.util.Date.from(cp.getEndTime().atZone(ZoneId.systemDefault()).toInstant())
);
scheduledFinalizations.put(cp.getId(), future);
} else if (post instanceof PollPost pp && pp.getEndTime() != null) {
ScheduledFuture<?> future = taskScheduler.schedule(
() -> applicationContext.getBean(PostService.class).finalizePoll(pp.getId()),
@@ -346,24 +419,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 +604,9 @@ public class PostService {
pollPostRepository
.findById(postId)
.ifPresent(pp -> {
if (pp instanceof CategoryProposalPost) {
return;
}
if (pp.isResultAnnounced()) {
return;
}
@@ -500,11 +665,15 @@ public class PostService {
w.getEmail() != null &&
!w.getDisabledEmailNotificationTypes().contains(NotificationType.LOTTERY_WIN)
) {
emailSender.sendEmail(
w.getEmail(),
"你中奖了",
"恭喜你在抽奖贴 \"" + lp.getTitle() + "\" 中获奖"
);
try {
emailSender.sendEmail(
w.getEmail(),
"你中奖了",
"恭喜你在抽奖贴 \"" + lp.getTitle() + "\" 中获奖"
);
} catch (EmailSendException e) {
log.warn("Failed to send lottery win email to {}: {}", w.getEmail(), e.getMessage());
}
}
notificationService.createNotification(
w,
@@ -530,11 +699,19 @@ public class PostService {
.getDisabledEmailNotificationTypes()
.contains(NotificationType.LOTTERY_DRAW)
) {
emailSender.sendEmail(
lp.getAuthor().getEmail(),
"抽奖已开奖",
"您的抽奖贴 \"" + lp.getTitle() + "\" 已开奖"
);
try {
emailSender.sendEmail(
lp.getAuthor().getEmail(),
"抽奖已开奖",
"您的抽奖贴 \"" + lp.getTitle() + "\" 已开奖"
);
} catch (EmailSendException e) {
log.warn(
"Failed to send lottery draw email to {}: {}",
lp.getAuthor().getEmail(),
e.getMessage()
);
}
}
notificationService.createNotification(
lp.getAuthor(),
@@ -563,7 +740,7 @@ public class PostService {
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found"));
if (post.getStatus() != PostStatus.PUBLISHED) {
if (viewer == null) {
throw new com.openisle.exception.NotFoundException("Post not found");
throw new com.openisle.exception.NotFoundException("User not found");
}
User viewerUser = userRepository
.findByUsername(viewer)
@@ -607,6 +784,18 @@ public class PostService {
return listPostsByCategories(null, null, null);
}
public List<Post> listRecentPosts(int minutes) {
if (minutes <= 0) {
throw new IllegalArgumentException("Minutes must be positive");
}
LocalDateTime since = LocalDateTime.now().minusMinutes(minutes);
List<Post> posts = postRepository.findByStatusAndCreatedAtGreaterThanEqualOrderByCreatedAtDesc(
PostStatus.PUBLISHED,
since
);
return sortByPinnedAndCreated(posts);
}
public List<Post> listPostsByViews(Integer page, Integer pageSize) {
return listPostsByViews(null, null, page, pageSize);
}
@@ -621,9 +810,10 @@ public class PostService {
boolean hasTags = tagIds != null && !tagIds.isEmpty();
java.util.List<Post> posts;
Pageable pageable = buildPageable(page, pageSize);
if (!hasCategories && !hasTags) {
posts = postRepository.findByStatusOrderByViewsDesc(PostStatus.PUBLISHED);
posts = postRepository.findByStatusOrderByPinnedAtDescViewsDesc(PostStatus.PUBLISHED, pageable);
} else if (hasCategories) {
java.util.List<Category> categories = categoryRepository.findAllById(categoryIds);
if (categories.isEmpty()) {
@@ -634,16 +824,18 @@ public class PostService {
if (tags.isEmpty()) {
return java.util.List.of();
}
posts = postRepository.findByCategoriesAndAllTagsOrderByViewsDesc(
posts = postRepository.findByCategoriesAndAllTagsOrderByPinnedAtDescViewsDesc(
categories,
tags,
PostStatus.PUBLISHED,
tags.size()
tags.size(),
pageable
);
} else {
posts = postRepository.findByCategoryInAndStatusOrderByViewsDesc(
posts = postRepository.findByCategoryInAndStatusOrderByPinnedAtDescViewsDesc(
categories,
PostStatus.PUBLISHED
PostStatus.PUBLISHED,
pageable
);
}
} else {
@@ -651,10 +843,15 @@ public class PostService {
if (tags.isEmpty()) {
return java.util.List.of();
}
posts = postRepository.findByAllTagsOrderByViewsDesc(tags, PostStatus.PUBLISHED, tags.size());
posts = postRepository.findByAllTagsOrderByPinnedAtDescViewsDesc(
tags,
PostStatus.PUBLISHED,
tags.size(),
pageable
);
}
return paginate(sortByPinnedAndViews(posts), page, pageSize);
return posts;
}
public List<Post> listPostsByLatestReply(Integer page, Integer pageSize) {
@@ -671,9 +868,13 @@ public class PostService {
boolean hasTags = tagIds != null && !tagIds.isEmpty();
java.util.List<Post> posts;
Pageable pageable = buildPageable(page, pageSize);
if (!hasCategories && !hasTags) {
posts = postRepository.findByStatusOrderByCreatedAtDesc(PostStatus.PUBLISHED);
posts = postRepository.findByStatusOrderByPinnedAtDescLastReplyAtDesc(
PostStatus.PUBLISHED,
pageable
);
} else if (hasCategories) {
java.util.List<Category> categories = categoryRepository.findAllById(categoryIds);
if (categories.isEmpty()) {
@@ -684,16 +885,18 @@ public class PostService {
if (tags.isEmpty()) {
return java.util.List.of();
}
posts = postRepository.findByCategoriesAndAllTagsOrderByCreatedAtDesc(
posts = postRepository.findByCategoriesAndAllTagsOrderByPinnedAtDescLastReplyAtDesc(
categories,
tags,
PostStatus.PUBLISHED,
tags.size()
tags.size(),
pageable
);
} else {
posts = postRepository.findByCategoryInAndStatusOrderByCreatedAtDesc(
posts = postRepository.findByCategoryInAndStatusOrderByPinnedAtDescLastReplyAtDesc(
categories,
PostStatus.PUBLISHED
PostStatus.PUBLISHED,
pageable
);
}
} else {
@@ -701,14 +904,15 @@ public class PostService {
if (tags.isEmpty()) {
return new ArrayList<>();
}
posts = postRepository.findByAllTagsOrderByCreatedAtDesc(
posts = postRepository.findByAllTagsOrderByPinnedAtDescLastReplyAtDesc(
tags,
PostStatus.PUBLISHED,
tags.size()
tags.size(),
pageable
);
}
return paginate(sortByPinnedAndLastReply(posts), page, pageSize);
return posts;
}
public List<Post> listPostsByCategories(
@@ -868,10 +1072,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 +1101,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 +1198,8 @@ public class PostService {
Long categoryId,
String title,
String content,
java.util.List<Long> tagIds
List<Long> tagIds,
PostVisibleScopeType postVisibleScopeType
) {
if (tagIds == null || tagIds.isEmpty()) {
throw new IllegalArgumentException("At least one tag required");
@@ -1021,6 +1231,8 @@ public class PostService {
post.setContent(content);
post.setCategory(category);
post.setTags(new java.util.HashSet<>(tags));
PostVisibleScopeType oldVisibleScope = post.getVisibleScope();
post.setVisibleScope(postVisibleScopeType);
Post updated = postRepository.save(post);
imageUploader.adjustReferences(oldContent, content);
notificationService.notifyMentions(content, user, updated, null);
@@ -1042,6 +1254,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 +1317,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,
@@ -1185,6 +1410,13 @@ public class PostService {
.toList();
}
private Pageable buildPageable(Integer page, Integer pageSize) {
if (page == null || pageSize == null) {
return Pageable.unpaged();
}
return PageRequest.of(page, pageSize);
}
private List<Post> paginate(List<Post> posts, Integer page, Integer pageSize) {
if (page == null || pageSize == null) {
return posts;

View File

@@ -1,5 +1,6 @@
package com.openisle.service;
import com.openisle.exception.EmailSendException;
import java.util.HashMap;
import java.util.Map;
import org.springframework.beans.factory.annotation.Value;
@@ -7,8 +8,9 @@ import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.scheduling.annotation.Async;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClientException;
import org.springframework.web.client.RestTemplate;
@Service
@@ -23,7 +25,6 @@ public class ResendEmailSender extends EmailSender {
private final RestTemplate restTemplate = new RestTemplate();
@Override
@Async("notificationExecutor")
public void sendEmail(String to, String subject, String text) {
String url = "https://api.resend.com/emails"; // hypothetical endpoint
@@ -38,6 +39,20 @@ public class ResendEmailSender extends EmailSender {
body.put("from", "openisle <" + fromEmail + ">");
HttpEntity<Map<String, String>> entity = new HttpEntity<>(body, headers);
restTemplate.exchange(url, HttpMethod.POST, entity, String.class);
try {
ResponseEntity<String> response = restTemplate.exchange(
url,
HttpMethod.POST,
entity,
String.class
);
if (!response.getStatusCode().is2xxSuccessful()) {
throw new EmailSendException(
"Email service returned status " + response.getStatusCodeValue()
);
}
} catch (RestClientException e) {
throw new EmailSendException("Failed to send email: " + e.getMessage(), e);
}
}
}

View File

@@ -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
) {}
}

View File

@@ -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() {

View File

@@ -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() {
@@ -108,7 +118,6 @@ public class UserService {
* @param user
*/
public void sendVerifyMail(User user, VerifyType verifyType) {
// 缓存验证码
String code = genCode();
String key;
String subject;
@@ -123,8 +132,9 @@ public class UserService {
subject = "请填写验证码以重置密码(有效期为5分钟)";
}
redisTemplate.opsForValue().set(key, code, 5, TimeUnit.MINUTES); // 五分钟后验证码过期
emailService.sendEmail(user.getEmail(), subject, content);
// 邮件发送成功后再缓存验证码,避免发送失败时用户收不到但验证被要求
redisTemplate.opsForValue().set(key, code, 5, TimeUnit.MINUTES); // 五分钟后验证码过期
}
/**
@@ -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) {

View File

@@ -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

View 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`;

View File

@@ -0,0 +1,55 @@
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,
`is_bot` bit(1) NOT NULL DEFAULT b'0',
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;

View File

@@ -0,0 +1,44 @@
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`,
`is_bot`
) VALUES
(1, b'1', 'https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/image.png', '2025-09-01 16:08:17.426430', 'PIONEER', 'adminmail@openisle.com', 70, NULL, '$2a$10$x7HXjUyJTmrvqjnBlBQZH.vmfsC56NzTSWqQ6WqZqRjUO859EhviS', NULL, 110, '测试测试测试……', 'ADMIN', 'admin', NULL, b'1', b'0'),
(2, b'1', 'https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/image.png', '2025-09-03 16:08:17.426430', 'PIONEER', 'usermail2@openisle.com', 70, NULL, '$2a$10$x7HXjUyJTmrvqjnBlBQZH.vmfsC56NzTSWqQ6WqZqRjUO859EhviS', NULL, 110, '测试测试测试……', 'USER', 'user1', NULL, b'1', b'0'),
(3, b'1', 'https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/image.png', '2025-09-02 17:21:21.617666', 'PIONEER', 'usermail1@openisle.com', 40, NULL, '$2a$10$x7HXjUyJTmrvqjnBlBQZH.vmfsC56NzTSWqQ6WqZqRjUO859EhviS', NULL, 40, '测试测试测试……', 'USER', 'user2', NULL, b'1', b'0');
INSERT INTO `categories` (`id`,`description`,`icon`,`name`,`small_icon`) VALUES
(1,'测试用分类1','star','测试用分类1',NULL),
(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;

View File

@@ -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);

View File

@@ -0,0 +1,4 @@
-- Backfill last_reply_at for posts without comments to preserve latest-reply ordering
UPDATE posts
SET last_reply_at = created_at
WHERE last_reply_at IS NULL;

View File

@@ -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);

View File

@@ -0,0 +1 @@
ALTER TABLE posts ADD COLUMN visible_scope ENUM('ALL', 'ONLY_ME', 'ONLY_REGISTER') NOT NULL DEFAULT 'ALL'

View File

@@ -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);

View File

@@ -0,0 +1,2 @@
ALTER TABLE users
ADD COLUMN is_bot BIT(1) NOT NULL DEFAULT b'0';

View File

@@ -0,0 +1,12 @@
CREATE TABLE IF NOT EXISTS post_reads (
id BIGINT NOT NULL AUTO_INCREMENT,
user_id BIGINT NOT NULL,
post_id BIGINT NOT NULL,
last_read_at DATETIME(6) DEFAULT NULL,
PRIMARY KEY (id),
UNIQUE KEY UK_post_reads_user_post (user_id, post_id),
KEY IDX_post_reads_user (user_id),
KEY IDX_post_reads_post (post_id),
CONSTRAINT FK_post_reads_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
CONSTRAINT FK_post_reads_post FOREIGN KEY (post_id) REFERENCES posts (id) ON DELETE CASCADE
);

View File

@@ -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()
);
}

View File

@@ -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)
)
);

View File

@@ -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();

View File

@@ -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);

View File

@@ -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);

View File

@@ -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();

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