Compare commits

...

419 Commits

Author SHA1 Message Date
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
e7593c8ebf Enhance user timeline post metadata and grouping 2025-09-19 00:31:52 +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
tim
4cc2800f09 feat: timeline 基础格式更新 2025-09-18 20:48:46 +08:00
Tim
396434a82e Merge pull request #1005 from nagisa77/codex/add-redis/rabbitmq-configuration-details-to-contributing.md
docs: expand Redis and RabbitMQ setup guidance
2025-09-18 17:50:28 +08:00
Tim
07c6b53f82 docs: document redis and rabbitmq setup 2025-09-18 17:48:32 +08:00
Tim
930a861ba6 Merge pull request #1002 from nagisa77/feature/CONTRIBUTING_openAPI
feat: CONTRIBUTING.md 新增 OpenAPI 介绍 #923
2025-09-18 17:36:48 +08:00
Tim
1f4e1dea75 feat: CONTRIBUTING.md 新增 OpenAPI 介绍 #923 2025-09-18 17:36:00 +08:00
Tim
bc617837be Merge pull request #1001 from smallclover/main
menu追加选中状态
2025-09-18 15:52:31 +08:00
wang.shun
17e4862eaf menu追加选中状态 2025-09-18 15:03:50 +08:00
Tim
72b2b82e02 fix: 后端代码格式化 2025-09-18 14:42:25 +08:00
Tim
70f7442f0c fix: test commit 2025-09-18 14:31:22 +08:00
Tim
2b2deb8f66 Merge pull request #998 from nagisa77/codex/add-format-hook-for-backend
chore: add backend formatting to husky
2025-09-18 14:27:18 +08:00
Tim
0a7a433bc6 Merge pull request #1000 from nagisa77/feature/lottery_post_bugfix
fix: 抽奖贴无法看见参与人员 #999
2025-09-18 10:44:39 +08:00
Tim
b64f9ef1f6 fix: 抽奖贴无法看见参与人员 #999 2025-09-18 10:43:55 +08:00
Tim
f22ca9cdcd chore: format backend via husky 2025-09-18 00:07:46 +08:00
Tim
d26b96ebd1 Merge pull request #997 from nagisa77/codex/add-placeholder-for-no-comments
feat: show placeholder when timeline empty
2025-09-17 21:20:59 +08:00
Tim
13cc981421 feat: show placeholder when timeline empty 2025-09-17 21:19:36 +08:00
Tim
efc8589ca0 Merge pull request #996 from nagisa77/codex/implement-reaction-group-gradient-sorting-zszqdc
feat: sort reactions by popularity
2025-09-17 20:58:31 +08:00
Tim
940690889c feat: sort reactions by popularity 2025-09-17 20:36:18 +08:00
Tim
d46420ef81 Merge pull request #993 from nagisa77/codex/fix-compilation-error-in-postservicetest
Fix PostServiceTest constructor parameters
2025-09-17 14:23:44 +08:00
Tim
b36b5b59dc Fix PostServiceTest constructor parameters 2025-09-17 14:23:27 +08:00
Tim
cf96806f80 Merge pull request #979 from sivdead/optimize-post-list-n+1
主页列表接口优化,优化帖子评论统计性能
2025-09-17 14:17:31 +08:00
Tim
3d0d0496b6 fix: comment count 放在last_reply_at后更新,确保数据正确 2025-09-17 14:16:49 +08:00
Tim
f67e220894 fix: 旧帖子的last_reply_at也要及时更新(仅一次) 2025-09-17 14:14:55 +08:00
Tim
9306e35b84 Merge remote-tracking branch 'origin/main' into pr-979 2025-09-17 13:49:34 +08:00
Tim
d2268a1944 Merge pull request #971 from smallclover/main
缓存功能追加
2025-09-17 13:43:40 +08:00
Tim
6baa4d4233 fix: 简单调整按钮格式 2025-09-17 13:37:52 +08:00
Tim
ef9d90455f Merge pull request #991 from nagisa77/codex/fix-foreign-key-constraint-error-on-deletepost-mrgsx4
Delete post change logs before removing posts
2025-09-17 13:31:31 +08:00
Tim
5d499956d7 Delete post change logs before removing posts 2025-09-17 13:30:58 +08:00
Tim
9101ed336c Merge pull request #990 from nagisa77/codex/fix-foreign-key-constraint-error-on-deletepost-1xt4ec
Fix foreign key failures when deleting posts
2025-09-17 12:29:25 +08:00
Tim
28e3ebb911 Handle point history cleanup when deleting posts 2025-09-17 12:29:09 +08:00
Tim
e93e33fe43 Revert "Handle point history cleanup when deleting posts"
This reverts commit b4a811ff4e.
2025-09-17 12:27:07 +08:00
Tim
0ebeccf21e Merge branch 'pr-971' of github.com:nagisa77/OpenIsle into pr-971 2025-09-17 12:23:40 +08:00
Tim
89842b82e9 fix: 文章缓存修改为 10 min 2025-09-17 12:23:20 +08:00
Tim
58594229f2 Merge pull request #989 from nagisa77/codex/fix-foreign-key-constraint-error-on-deletepost
Handle point history cleanup when deleting posts
2025-09-17 12:21:34 +08:00
Tim
b4a811ff4e Handle point history cleanup when deleting posts 2025-09-17 12:21:17 +08:00
Tim
7067630bcc fix: 验证码部分验证完毕,提交小修改 2025-09-17 12:06:02 +08:00
Tim
b28e8d4bc9 Merge pull request #988 from nagisa77/codex/update-post_cache_name-to-handle-pagination
Fix post cache keys to include pagination parameters
2025-09-17 11:53:05 +08:00
Tim
063866cc3a Fix post cache keys to include pagination 2025-09-17 11:52:42 +08:00
Tim
6f968d16aa fix: 处理首屏返回空的问题 2025-09-17 11:41:35 +08:00
夢夢の幻想郷
6db969cc4d Update deploy-staging.yml
只有主仓库的时候才执行
2025-09-15 11:30:37 +08:00
wangshun
6ea9b4a33c 修复问题#927,#860
1.优化评论请求,将两个请求合并为一个
2.修改个人主页按钮的主次
2025-09-15 11:23:31 +08:00
夢夢の幻想郷
bcfc40d795 Merge branch 'nagisa77:main' into main 2025-09-15 09:38:18 +08:00
Tim
c5c7066b92 fix: ci 问题 2025-09-13 11:20:21 +08:00
夢夢の幻想郷
51b73fcc93 Merge branch 'nagisa77:main' into main 2025-09-12 17:07:57 +08:00
Tim
da181b9d6d Merge pull request #980 from nagisa77/feature/tag_height
fix: tags height
2025-09-12 14:27:41 +08:00
tim
134e3fc866 fix: tags height 2025-09-12 14:27:01 +08:00
tim
c3758cafe8 fix: 修复内容绑定问题 2025-09-12 13:42:03 +08:00
sivdead
1a21ba8935 feat(posts): 优化帖子评论统计性能
- 在 Post 模型中添加 commentCount 和 lastReplyAt 字段
- 在 CommentService 中实现更新帖子评论统计的方法
- 在 PostMapper 中使用 Post 模型中的评论统计字段
- 新增数据库迁移脚本,添加评论统计字段和索引
- 更新相关测试用例
2025-09-12 11:08:59 +08:00
Tim
a397ebe79b Merge pull request #978 from nagisa77/codex/fix-image-preview-trigger-in-markdown
fix: restrict image preview to markdown images
2025-09-12 10:50:45 +08:00
Tim
abbdb224e0 fix: restrict image preview to markdown images 2025-09-12 10:50:15 +08:00
Tim
f4fb3b2544 Merge pull request #976 from nagisa77/codex/remove-ffmpeg-dependency-and-functionality
chore: remove ffmpeg video compression
2025-09-12 10:46:39 +08:00
Tim
ae2412a906 Merge pull request #977 from nagisa77/feature/command_load
fix: 评论后--需要刷新帖子内容 #939
2025-09-12 10:46:29 +08:00
Tim
d8534fb94d fix: 评论后--需要刷新帖子内容 #939 2025-09-12 10:43:06 +08:00
Tim
6497cb92af chore: remove ffmpeg video compression 2025-09-12 10:41:48 +08:00
Tim
37bef0b2d7 fix: remove 依赖 2025-09-12 10:15:17 +08:00
Tim
3519a41a2e Merge pull request #975 from nagisa77/feature/ffmpeg_load
Feature/ffmpeg load
2025-09-11 19:12:16 +08:00
tim
ab04a8b6b1 fix: ffmpeg 压缩适配 2025-09-11 19:10:14 +08:00
tim
ea079e8b8a fix: 简化ffmpeg配置 2025-09-11 18:36:47 +08:00
Tim
519656359f Merge pull request #974 from 4twocc/feat/message-box-shortcut
feat(MessageEditor): 添加发送消息的快捷键支持
2025-09-11 17:56:22 +08:00
jiahaosheng
dc64785279 feat: rename is.js to device.js 2025-09-11 17:53:08 +08:00
jiahaosheng
9421d004d4 feat(MessageEditor): 添加发送消息的快捷键支持 2025-09-11 17:27:54 +08:00
tim
90bd41e740 Revert "feat: switch video compression to webcodecs"
This reverts commit 3f35add587.
2025-09-11 17:20:08 +08:00
Tim
7d5c864f64 Merge pull request #973 from nagisa77/codex/switch-video-upload-to-webcodec-and-mp4box.js-bkkx49
feat: replace ffmpeg with WebCodecs and MP4Box.js
2025-09-11 17:02:08 +08:00
Tim
3f35add587 feat: switch video compression to webcodecs 2025-09-11 17:01:54 +08:00
wangshun
37c4306010 缓存功能追加
1.最新回复列表
2.最新列表
2025-09-11 15:29:24 +08:00
Tim
1e284e15df Merge pull request #970 from sivdead/feat/video_upload_and_compress
feat(frontend/vditor): 实现基于 FFmpeg.wasm 的视频压缩功能
2025-09-11 12:54:12 +08:00
sivdead
9d76926b8a feat(frontend/vditor): 实现基于 FFmpeg.wasm 的视频压缩功能
- 添加视频压缩相关配置和工具函数
- 实现 FFmpeg.wasm 初始化和视频压缩功能
- 优化文件上传流程,支持视频文件压缩
2025-09-11 10:05:50 +08:00
tim
d2ce203236 Merge branch 'main' of github.com:nagisa77/OpenIsle 2025-09-10 18:13:16 +08:00
tim
b2228296af fix: hover 新增动画 2025-09-10 18:13:04 +08:00
Tim
7020ae19d0 Merge pull request #968 from smallclover/main
追加快捷键
2025-09-10 18:09:18 +08:00
tim
227fb6f6cc fix: 首页padding修改 2025-09-10 18:07:22 +08:00
wangshun
0e46a67ea6 评论追加快捷键
1.手机时不显示icon,且快捷键不起用
2.电脑端适配win和mac
2025-09-10 18:01:07 +08:00
wangshun
b20b705e46 添加快捷键
+ 不是手机的情况下不启用快捷键
2025-09-10 17:44:53 +08:00
夢夢の幻想郷
4b3ffbab99 Merge branch 'nagisa77:main' into main 2025-09-10 17:43:40 +08:00
tim
74039c89f9 Merge branch 'main' of github.com:nagisa77/OpenIsle 2025-09-10 17:42:32 +08:00
tim
10dca73d2f fix: 新增本地GitHub调试 2025-09-10 17:42:19 +08:00
wangshun
e37ed1b70b 评论追加快捷键
ps:schedule包名拼写错误修正
2025-09-10 15:40:49 +08:00
Tim
8500a7a914 Merge pull request #965 from nagisa77/feature/homepage_ui
fix: 首页banner和帖子之间间距可以大一些 #849
2025-09-10 14:00:19 +08:00
Tim
3adf722b3b fix: 首页banner和帖子之间间距可以大一些 #849 2025-09-10 13:58:11 +08:00
Tim
791e5a4daf Merge pull request #964 from nagisa77/feature/change-log-ui
fix: changelog--文章内容更新 移动端适配 #937
2025-09-10 12:50:11 +08:00
tim
7d25e87fbc fix: changelog--文章内容更新 移动端适配 #937 2025-09-10 12:47:09 +08:00
Tim
d02c316a70 Merge pull request #963 from nagisa77/codex/fix-log-usage-with-slf4j
refactor: replace console prints with slf4j logging
2025-09-10 12:01:43 +08:00
Tim
c189c80c05 refactor: replace console prints with slf4j logging 2025-09-10 12:01:15 +08:00
Tim
07db73c9c7 Merge pull request #959 from nagisa77/codex/fix-chat-markdown-rendering-issues
feat: enhance chat markdown and editor
2025-09-09 21:20:49 +08:00
tim
c296e25927 fix: 聊天UI优化 #957 2025-09-09 21:02:59 +08:00
Tim
61fc9d799d feat(chat): improve markdown and editor 2025-09-09 20:03:22 +08:00
Tim
20c6c73f8c Merge pull request #954 from smallclover/main
用户访问统计使用缓存+定时任务
2025-09-09 19:35:45 +08:00
Tim
81d1f79aae Merge pull request #958 from WoJiaoFuXiaoYun/main
fix: 修复发帖框/修改框边缘不对齐的case
2025-09-09 19:35:18 +08:00
WangHe
4ff76d2586 fix: 修复发帖框/修改框边缘不对齐的case 2025-09-09 17:16:18 +08:00
Tim
f24bc239cc Update CONTRIBUTING.md 2025-09-09 16:49:49 +08:00
Tim
143691206d Merge pull request #955 from nagisa77/codex/add-openapi-annotations-to-controller-methods
doc: add OpenAPI annotations to demo controllers
2025-09-09 16:37:47 +08:00
Tim
15ad85e6f1 doc: add OpenAPI annotations to remaining controllers 2025-09-09 16:37:08 +08:00
wangshun
843e53143d 用户访问统计使用缓存+定时任务
+ 重要:注释的地方如果没用到@nagisa77可以删除
2025-09-09 16:31:59 +08:00
Tim
16c94690bd fix: 未登录UI适配 2025-09-09 15:58:50 +08:00
Tim
5be00e7013 Merge pull request #952 from nagisa77/codex/modify-about-page-with-new-tab
feat: add API debug tab and query param navigation for about page
2025-09-09 15:49:57 +08:00
Tim
1e0f62b421 fix: 正式环境/预发环境切换为英文 2025-09-09 15:40:55 +08:00
Tim
a3201f05fb fix: share icon 2025-09-09 15:39:08 +08:00
Tim
62cccb794d Merge pull request #953 from nagisa77/codex/fix-compilation-errors-in-postservice
Fix PostServiceTest constructor with RedisTemplate mock
2025-09-09 15:32:22 +08:00
Tim
afa0c7fb8f test: update PostServiceTest for redis template 2025-09-09 15:32:03 +08:00
Tim
da311806c1 feat: add API tab to about page 2025-09-09 15:04:49 +08:00
Tim
1852f87341 Merge pull request #951 from nagisa77/codex/update-openapi-servers-configuration
feat: allow configuring multiple OpenAPI servers
2025-09-09 15:03:43 +08:00
Tim
7010e8a058 feat: allow configuring multiple openapi servers 2025-09-09 15:03:25 +08:00
Tim
38ee37d5be Merge pull request #946 from smallclover/main 2025-09-09 14:29:06 +08:00
Tim
e398d8e989 Merge pull request #949 from nagisa77/codex/remove-/docs/-prefix-from-url-uh7skh
feat(docs): remove /docs URL prefix
2025-09-09 14:03:20 +08:00
Tim
85e77c265e feat(docs): remove /docs prefix 2025-09-09 14:03:04 +08:00
tim
8abdc73497 Revert "feat(docs): remove path prefix"
This reverts commit 09cefbedbf.
2025-09-09 14:02:23 +08:00
Tim
747d9c07d1 Merge pull request #948 from nagisa77/codex/remove-/docs/-prefix-from-url-3n0gdr
feat(docs): serve documentation from root
2025-09-09 13:48:51 +08:00
Tim
09cefbedbf feat(docs): remove path prefix 2025-09-09 13:48:26 +08:00
tim
d772bc182f fix: 允许自建OpenAPI地址 2025-09-09 13:46:25 +08:00
tim
358c53338d Revert "fix: 新增检查"
This reverts commit 1cd89eaa54.
2025-09-09 13:23:30 +08:00
wangshun
2110980797 控制用户发帖频率 2025-09-09 13:23:14 +08:00
tim
1cd89eaa54 fix: 新增检查 2025-09-09 13:16:52 +08:00
tim
1d2e7eb96e Revert "Update deploy-docs.yml"
This reverts commit 4428e06f1d.
2025-09-09 13:10:46 +08:00
Tim
4428e06f1d Update deploy-docs.yml 2025-09-09 13:03:08 +08:00
Tim
dddff54556 Update README.md 2025-09-09 12:18:10 +08:00
Tim
e7f7bbac22 Update README.md 2025-09-09 12:17:49 +08:00
Tim
37aae4ba5c Update README.md 2025-09-09 12:17:24 +08:00
Tim
54cfc98336 Merge pull request #945 from nagisa77/codex/fix-server-url-in-api-docs
Add configurable OpenAPI server URL
2025-09-09 12:12:41 +08:00
Tim
d42d38ff7a Add configurable OpenAPI server URL 2025-09-09 12:12:10 +08:00
Tim
2b4601bd4b Update CONTRIBUTING.md 2025-09-09 11:56:15 +08:00
Tim
5071d9c6d5 Merge pull request #944 from nagisa77/codex/fix-api-docs-base-url-to-use-https
docs: use https for OpenAPI base URL
2025-09-09 11:48:53 +08:00
Tim
cfaa4cd094 Update application.properties 2025-09-09 11:48:42 +08:00
Tim
fc414794ff docs: use https for openapi base url 2025-09-09 11:48:07 +08:00
Tim
d8264956c3 Merge pull request #943 from nagisa77/codex/fix-invalid-workflow-permissions-in-deploy-staging.yml
fix: grant write permissions for docs deployment
2025-09-09 11:30:28 +08:00
Tim
effa7f25ca fix: grant write permissions for docs deployment 2025-09-09 11:30:11 +08:00
Tim
9b19fae69a Merge pull request #942 from nagisa77/codex/resolve-conflict-between-deploy-staging-and-deploy-docs
Run docs deployment after staging deploy
2025-09-09 11:06:39 +08:00
Tim
ec04f64ce1 chore: trigger docs deployment after staging 2025-09-09 11:06:16 +08:00
Tim
50bea76c0e Merge pull request #940 from nagisa77/codex/adjust-diff2html-font-for-mobile-ui
style: adjust diff2html fonts on mobile
2025-09-09 00:33:58 +08:00
tim
05522fcdc7 fix: 修改分割线颜色 2025-09-09 00:32:17 +08:00
tim
3820eaa774 fix: changlog--移动端支持换行 #938 2025-09-09 00:23:53 +08:00
Tim
7effaf920a style: adjust diff2html fonts on mobile 2025-09-08 23:48:32 +08:00
Tim
e40a6a3ca9 Merge pull request #935 from smallclover/main
redis功能-注册找回密码
2025-09-08 17:14:04 +08:00
Tim
7c9475cfe2 Merge pull request #936 from nagisa77/codex/fix-compilation-issues-in-postservicetest
test: add PostChangeLogService to PostService tests
2025-09-08 15:42:20 +08:00
Tim
17929dd95d test: add PostChangeLogService to PostService tests 2025-09-08 15:42:08 +08:00
Tim
f478b55538 Merge pull request #924 from nagisa77/codex/add-article-metadata-change-logging
Track post metadata changes and display in timeline
2025-09-08 15:35:44 +08:00
Tim
c58c14f9b7 feat: 设置system的icon+role 2025-09-08 15:35:09 +08:00
Tim
990d7cfbf9 fix: 投票结果UI 2025-09-08 15:32:57 +08:00
wangshun
43fa408f46 redis功能-注册找回密码
+ 注册功能,验证码使用缓存,五分钟过期
+ 重置密码,验证码使用缓存,五分钟过期
2025-09-08 15:23:52 +08:00
Tim
eb860a74af Merge pull request #934 from nagisa77/codex/add-system-user-for-vote-and-lottery-results
Create system user for internal logging
2025-09-08 15:21:30 +08:00
Tim
b3d050b42e Add system user and log attribution 2025-09-08 15:19:17 +08:00
Tim
db678a95c6 Merge pull request #933 from nagisa77/codex/call-recordlotteryresult-and-recordvoteresult
feat: log poll and lottery results
2025-09-08 15:00:30 +08:00
Tim
6d66cb48dc feat: log poll and lottery results 2025-09-08 15:00:15 +08:00
Tim
1fe2994743 fix: 适配分类/tags ui 2025-09-08 14:56:44 +08:00
Tim
126b10ce45 Merge pull request #932 from nagisa77/codex/update-changelog-to-return-dto-format-rnzqgd
Expose category and tag changes as DTOs
2025-09-08 14:46:09 +08:00
Tim
3b1843b6dd Return category and tag change logs as DTOs 2025-09-08 14:45:47 +08:00
Tim
6a5d00f086 Revert "Return structured category and tag data in change logs"
This reverts commit fe167aa0b9.
2025-09-08 14:44:08 +08:00
Tim
06368a6cf1 Merge pull request #931 from nagisa77/codex/add-dark-mode-support-for-diff2html
feat: enable dark mode for diff2html
2025-09-08 14:29:01 +08:00
Tim
c38e4bc44c feat: enable dark mode for diff2html 2025-09-08 14:28:42 +08:00
Tim
e9f25d3b1a Merge pull request #930 from nagisa77/codex/update-changelog-to-return-dto-format
Return structured category and tag data in change logs
2025-09-08 14:27:36 +08:00
Tim
fe167aa0b9 Return structured category and tag data in change logs 2025-09-08 14:27:18 +08:00
Tim
f3421265d2 fix: 修改changelog UI 2025-09-08 14:02:47 +08:00
Tim
f4817cd6d1 Merge pull request #929 from nagisa77/codex/add-user-avatar-return-in-changelog
feat: expand post change log details
2025-09-08 13:54:51 +08:00
Tim
5ae0f9311c feat: add result change log entities 2025-09-08 13:54:35 +08:00
Tim
567452f570 feat: 标题/内容变化的ui 2025-09-08 13:46:22 +08:00
Tim
bb4e866bd0 Merge pull request #928 from nagisa77/codex/add-content-change-details-rendering
feat(frontend): render diff for content changes
2025-09-08 13:22:44 +08:00
Tim
24d0da0864 feat(frontend): render diff for content changes 2025-09-08 13:22:25 +08:00
Tim
9b53479ab6 feat: changelog前端ui优化 2025-09-08 13:04:14 +08:00
Tim
039d482517 Add post change log tracking 2025-09-08 11:27:35 +08:00
Tim
7cc32c36b1 Merge pull request #922 from nagisa77/feature/chat_ui
fix: revert 100vh 修改
2025-09-08 10:44:12 +08:00
tim
2288522372 fix: revert 100vh 修改 2025-09-08 10:43:52 +08:00
Tim
a2b72d7c00 Merge pull request #921 from nagisa77/feature/chat_ui
Chat UI update
2025-09-08 00:17:34 +08:00
Tim
a6d8add5fa Merge pull request #920 from nagisa77/codex/integrate-real-data-for-new-message-container
feat: add floating new message indicator
2025-09-07 23:57:21 +08:00
Tim
ad481cffca feat: add floating new message indicator 2025-09-07 23:57:06 +08:00
Tim
ce213d4c24 Merge pull request #918 from nagisa77/feature/menu_select_state
Some UI fixes~
2025-09-07 23:51:22 +08:00
tim
68a82fa2ec fix: 回复ui 2025-09-07 23:50:11 +08:00
tim
cab8cd06dc fix: 频道聊天,点击写个回复没反应,点击小箭头才行 #916 2025-09-07 22:46:55 +08:00
Tim
b77a96938a Merge pull request #915 from nagisa77/feature/article_ui_fix
Article UI fixes
2025-09-07 14:14:56 +08:00
Tim
df4a707e3a Merge pull request #914 from nagisa77/feature/article_ui_fix
Article UI Fixes
2025-09-07 13:58:42 +08:00
Tim
14ee5faa1f Merge pull request #913 from nagisa77/feature/sidebar-logic
fix: 更新分类选择
2025-09-07 13:38:56 +08:00
Tim
2eebc1c004 Merge pull request #911 from nagisa77/feature/sidebar-logic
feat: 侧边栏按钮样式逻辑修改
2025-09-07 13:23:05 +08:00
Tim
135a6b8c51 Merge pull request #910 from nagisa77/codex/analyze-and-refactor-case
Fix point history balance recalculation
2025-09-07 12:48:04 +08:00
Tim
c43e4b85bc Test recalculation updates balance 2025-09-07 12:47:44 +08:00
Tim
fb3a2839db Merge pull request #909 from Linindoo/main
修复纯数字用户名的用户个人首页 404 问题(注册和修改校验用户名不能为纯数字)
2025-09-07 11:14:40 +08:00
zhoujia
5534573a19 创建和更新用户名校验增加校验,不允许纯数字用户名 2025-09-05 15:08:22 +08:00
469 changed files with 25641 additions and 15228 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

View File

@@ -1,10 +1,9 @@
---
name: 新功能建议
about: 请为该项目提出一个想法
title: ''
labels: ''
assignees: ''
title: ""
labels: ""
assignees: ""
---
**你的功能请求是否与某个问题相关?请描述。**

View File

@@ -1,10 +1,9 @@
---
name: 错误/Bug报告
about: 创建报告以帮助我们改进
title: ''
labels: ''
assignees: ''
title: ""
labels: ""
assignees: ""
---
**描述 Bug**
@@ -26,16 +25,16 @@ assignees: ''
**桌面端(请完成以下信息):**
* 操作系统:\[例如 iOS]
* 浏览器:\[例如 Chrome、Safari]
* 版本:\[例如 22]
- 操作系统:\[例如 iOS]
- 浏览器:\[例如 Chrome、Safari]
- 版本:\[例如 22]
**移动端(请完成以下信息):**
* 设备:\[例如 iPhone6]
* 操作系统:\[例如 iOS8.1]
* 浏览器:\[例如 系统自带浏览器、Safari]
* 版本:\[例如 22]
- 设备:\[例如 iPhone6]
- 操作系统:\[例如 iOS8.1]
- 浏览器:\[例如 系统自带浏览器、Safari]
- 版本:\[例如 22]
**附加上下文**
在此添加与问题相关的其他上下文信息。

View File

@@ -1,21 +1,33 @@
name: Deploy Documentation
on:
push:
workflow_call:
inputs:
build-id:
required: false
type: string
workflow_dispatch:
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
- name: Log build
run: echo "Running documentation deployment from build ${{ inputs.build-id }}"
- name: Setup Bun
uses: oven-sh/setup-bun@v1
with:

View File

@@ -2,22 +2,38 @@ 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 }}
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
if: ${{ success() }}
uses: ./.github/workflows/deploy-docs.yml
secrets: inherit
with:
build-id: ${{ github.run_id }}

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

View File

@@ -8,21 +8,21 @@ This isnt an exhaustive list of things that you cant do. Rather, take it i
This code of conduct applies to all spaces managed by the OpenIsle project or . This includes IRC, the mailing lists, the issue tracker, DSF events, and any other forums created by the project team which the community uses for communication. In addition, violations of this code outside these spaces may affect a person's ability to participate within them.
If you believe someone is violating the code of conduct, we ask that you report it by emailing [](mailto:). For more details please see our
If you believe someone is violating the code of conduct, we ask that you report it by emailing [](mailto:). For more details please see our
- **Be friendly and patient.**
- **Be welcoming.** We strive to be a community that welcomes and supports people of all backgrounds and identities. This includes, but is not limited to members of any race, ethnicity, culture, national origin, colour, immigration status, social and economic class, educational level, sex, sexual orientation, gender identity and expression, age, size, family status, political belief, religion, and mental and physical ability.
- **Be considerate.** Your work will be used by other people, and you in turn will depend on the work of others. Any decision you take will affect users and colleagues, and you should take those consequences into account when making decisions. Remember that we're a world-wide community, so you might not be communicating in someone else's primary language.
- **Be respectful.** Not all of us will agree all the time, but disagreement is no excuse for poor behavior and poor manners. We might all experience some frustration now and then, but we cannot allow that frustration to turn into a personal attack. Its important to remember that a community where people feel uncomfortable or threatened is not a productive one. Members of the OpenIsle community should be respectful when dealing with other members as well as with people outside the OpenIsle community.
- **Be careful in the words that you choose.** We are a community of professionals, and we conduct ourselves professionally. Be kind to others. Do not insult or put down other participants. Harassment and other exclusionary behavior aren't acceptable. This includes, but is not limited to:
- Violent threats or language directed against another person.
- Discriminatory jokes and language.
- Posting sexually explicit or violent material.
- Posting (or threatening to post) other people's personally identifying information ("doxing").
- Personal insults, especially those using racist or sexist terms.
- Unwelcome sexual attention.
- Advocating for, or encouraging, any of the above behavior.
- Repeated harassment of others. In general, if someone asks you to stop, then stop.
- **Be careful in the words that you choose.** We are a community of professionals, and we conduct ourselves professionally. Be kind to others. Do not insult or put down other participants. Harassment and other exclusionary behavior aren't acceptable. This includes, but is not limited to:
- Violent threats or language directed against another person.
- Discriminatory jokes and language.
- Posting sexually explicit or violent material.
- Posting (or threatening to post) other people's personally identifying information ("doxing").
- Personal insults, especially those using racist or sexist terms.
- Unwelcome sexual attention.
- Advocating for, or encouraging, any of the above behavior.
- Repeated harassment of others. In general, if someone asks you to stop, then stop.
- **When we disagree, try to understand why.** Disagreements, both social and technical, happen all the time and OpenIsle is no exception. It is important that we resolve disagreements and differing views constructively. Remember that were different. The strength of OpenIsle comes from its varied community, people from a wide range of backgrounds. Different people have different perspectives on issues. Being unable to understand why someone holds a viewpoint doesnt mean that theyre wrong. Dont forget that it is human to err and blaming each other doesnt get us anywhere. Instead, focus on helping to resolve issues and learning from mistakes.
Original text courtesy of the [Speak Up! project](http://web.archive.org/web/20141109123859/http://speakup.io/coc.html).

View File

@@ -1,16 +1,20 @@
- [前置工作](#前置工作)
- [前端极速调试Docker 全量环境)](#前端极速调试docker-全量环境)
- [dev 与 dev_local_backend 巡航指南](#dev-dev_local_backend-guide)
- [启动后端服务](#启动后端服务)
- [本地 IDEA](#本地-idea)
- [配置环境变量](#配置环境变量)
- [配置 IDEA 参数](#配置-idea-参数)
- [配置 MySQL](#配置-mysql)
- [Docker 环境](#docker-环境)
- [配置环境变量](#配置环境变量-1)
- [构建并启动镜像](#构建并启动镜像)
- [本地 IDEA](#本地-idea)
- [配置环境变量](#配置环境变量)
- [配置 IDEA 参数](#配置-idea-参数)
- [启动前端服务](#启动前端服务)
- [配置环境变量](#配置环境变量-2)
- [安装依赖和运行](#安装依赖和运行)
- [连接预发或正式环境](#连接预发或正式环境)
- [其他配置](#其他配置)
- [配置第三方登录以GitHub为例](#配置第三方登录以github为例)
- [配置Resend邮箱服务](#配置resend邮箱服务)
- [API文档](#api文档)
- [OpenAPI文档](#openapi文档)
- [部署时间线以及文档时效性](#部署时间线以及文档时效性)
- [OpenAPI文档使用](#openapi文档使用)
- [OpenAPI文档应用场景](#openapi文档应用场景)
## 前置工作
@@ -22,9 +26,89 @@ cd OpenIsle
```
- 后端开发环境
- JDK 17+
- JDK 17+
- 前端开发环境
- Node.JS 20+
- Node.JS 20+
## 前端极速调试Docker 全量环境)
想要最快速地同时体验前端和后端,可直接使用仓库提供的 Docker Compose。该方案会一次性拉起数据库、消息队列、搜索、后端、WebSocket 以及前端 Dev Server适合需要全链路联调的场景。
1. 准备环境变量文件:
```shell
cp .env.example .env
```
`.env.example` 是模板,可在 `.env` 中按需覆盖如端口、密钥等配置。确保 `NUXT_PUBLIC_API_BASE_URL`、`NUXT_PUBLIC_WEBSOCKET_URL` 等仍指向 `localhost`,方便前端直接访问容器映射端口。
2. 启动 Dev Profile
```shell
docker compose \
-f docker/docker-compose.yaml \
--env-file .env \
--profile dev up -d
```
该命令会创建名为 `frontend_dev` 的容器并运行 `npm run dev`,浏览器访问 http://127.0.0.1:3000 即可查看页面。
修改前端代码,页面会热更新。
如果修改后端代码,可以重启后端容器, 或是环境变量中指向IDEA采用IDEA编译运行也可以哦。
```shell
docker compose \
-f docker/docker-compose.yaml \
--env-file .env \
--profile dev up -d --force-recreate
```
3. 查看服务状态:
```shell
docker compose -f docker/docker-compose.yaml --env-file .env ps
docker compose -f docker/docker-compose.yaml --env-file .env logs -f frontend_dev
```
4. 停止所有容器:
```shell
docker compose -f docker/docker-compose.yaml --env-file .env --profile dev down
```
5. 开发时若需要**重置所有容器及其挂载的数据卷**,可以执行:
```shell
docker compose -f docker/docker-compose.yaml --env-file .env --profile dev down -v
```
`-v` 参数会在关闭容器的同时移除通过 `volumes` 声明的挂载卷,适用于希望清理数据库、缓存等持久化数据,确保下一次启动时获得全新环境的场景。
如需自定义 Node 依赖缓存、数据库持久化等,可参考 `docker/docker-compose.yaml` 中各卷的定义进行调整。
<a id="dev-dev_local_backend-guide"></a>
### 🧭 dev 与 dev_local_backend 巡航指南
在需要本地 IDE 启动后端、而容器只提供 MySQL、Redis、RabbitMQ、OpenSearch 等依赖时,可切换到 `dev_local_backend` Profile
```bash
docker compose \
-f docker/docker-compose.yaml \
--env-file .env \
--profile dev_local_backend up -d
```
> [!TIP]
> 该 Profile 不会启动 Docker 内的 Spring Boot 服务,`frontend_dev_local_backend` 会通过 `host.docker.internal` 访问你本机正在运行的后端。非常适合用 IDEA/VS Code 调试 Java 服务的场景!
| 想要的体验 | 推荐 Profile | 会启动的关键容器 | 备注 |
| --- | --- | --- | --- |
| 🚀 一键启动前后端 | `dev` | `springboot`、`frontend_dev`、`mysql`… | 纯容器内跑全链路,省心省力 |
| 🛠️ IDE 启动后端 + 容器托管依赖 | `dev_local_backend` | `frontend_dev_local_backend`、`mysql`、`redis`… | 记得本地后端监听 `8080`/`8082` 等端口 |
切换 Profile 时,请先停掉当前组合再启动另一组,避免端口占用或容器命名冲突:
```bash
docker compose -f docker/docker-compose.yaml --env-file .env --profile dev down
# 或者
docker compose -f docker/docker-compose.yaml --env-file .env --profile dev_local_backend down
```
常见小贴士:
- 🧹 需要彻底清理依赖时,别忘了追加 `-v` 清除持久化数据卷。
- 🪄 仅切换 Profile 时通常无需重新 `build`,除非你更新了镜像依赖。
- 🧪 如需确认前端容器访问的是本机后端,可在 IDE 控制台查看请求日志或执行 `curl http://localhost:8080/actuator/health` 进行自检。
## 启动后端服务
@@ -43,190 +127,81 @@ 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
```
```shell
cp 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 将尝试解析容器网络内的别名而导致连接失败。
`open-isle.env.example` 是环境变量模板,`open-isle.env` 才是真正读取的内容
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`
若上面在环境变量中设置了端口,那这里就不需要再额外设置
```shell
-Dserver.port=8081
```
- 设置 JDK 版本为 Java 17
- 设置 VM Option最好运行在其他端口例如 `8081`)。若已经在 `open-isle.env` 中调整端口,可省略此步骤。
```shell
-Dserver.port=8081
```
![配置1](assets/contributing/backend_img_3.png)
![配置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
填写环境变量 `.env` 中的 Redis 相关配置并启动 Redis
```ini
REDIS_HOST=<Redis 地址>
REDIS_PORT=<Redis 端口>
```
处理完环境问题直接跑起来就能通了
完成环境变量和运行参数设置后,即可启动 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 即可访问前端页面。
## 其他配置
### 配置第三方登录,这里以 GitHub 为例
### 配置第三方登录GitHub为例
- 修改 `application.properties` 配置
![后端配置](assets/contributing/backend_img.png)
![后端配置](assets/contributing/backend_img.png)
- 修改 `.env` 配置
![前端](assets/contributing/fontend_img.png)
![前端](assets/contributing/fontend_img.png)
- 配置第三方登录回调地址
![github配置](assets/contributing/github_img.png)
![github配置](assets/contributing/github_img.png)
![github配置2](assets/contributing/github_img_2.png)
![github配置2](assets/contributing/github_img_2.png)
### 配置 Resend 邮箱服务
### 配置Resend邮箱服务
https://resend.com/emails 创建账号并登录
@@ -246,3 +221,43 @@ https://resend.com/emails 创建账号并登录
`RESEND_FROM_EMAIL` **noreply@域名**
`RESEND_API_KEY`**刚刚复制的 Key**
![image-20250906151218330](assets/contributing/image-20250906151218330.png)
## API文档
### OpenAPI文档
https://docs.open-isle.com
### 部署时间线以及文档时效性
我已经将API Docs的部署融合进本站CI & CD中目前如下
- 每次合入main之后都会构建预发环境 http://staging.open-isle.com/ ,现在文档是紧随其后进行部署也就是说代码合入main之后如果是新增后台接口就可以立即通过OpenAPI文档页面进行查看和调试但是如果想通过OpenAPI调试需要选择预发环境的
- 每日凌晨三点会构建并重新部署正式环境届时当日合入main的新后台API也可以通过OpenAPI文档页面调试
![CleanShot 2025-09-10 at 12.04.48@2x.png](https://openisle-1307107697.cos.accelerate.myqcloud.com/dynamic_assert/168303009f4047ca828344957e911ff1.png)
👆如图是合入main之后构建预发+docs的情形总大约耗时4分钟左右
### OpenAPI文档使用
- 预发环境/正式环境切换以通过如下位置切换API环境
![CleanShot 2025-09-10 at 12.08.00@2x.png](https://openisle-1307107697.cos.accelerate.myqcloud.com/dynamic_assert/f9fb7a0f020d4a0e94159d7820783224.png)
- API分两种一种是需要鉴权需登录后的token另一种是直接访问可以直接访问的GET请求直接点击Send即可调试如下👇比如本站的推荐流rss: /api/rss: https://docs.open-isle.com/openapi/feed
![CleanShot 2025-09-10 at 12.09.48@2x.png](https://openisle-1307107697.cos.accelerate.myqcloud.com/dynamic_assert/2afb42e0c96340559dd42854905ca5fc.png)
- 需要登陆的API比如关注取消关注发帖等则需要提供token目前在“API与调试”可获取自身token可点击link看看👉 https://www.open-isle.com/about?tab=api
![CleanShot 2025-09-10 at 12.11.07@2x.png](https://openisle-1307107697.cos.accelerate.myqcloud.com/dynamic_assert/74033f1b9cc14f2fab3cbe3b7fe306d8.png)
copy完token之后粘贴到Bear之后, 即可发送调试, 如下👇大家亦可自行尝试https://docs.open-isle.com/openapi/me
![CleanShot 2025-09-10 at 12.13.00@2x.png](https://openisle-1307107697.cos.accelerate.myqcloud.com/dynamic_assert/63913fe2e70541a486651e35c723765e.png)
#### OpenAPI文档应用场景
- 方便大部分前端调试的需求,如果有只想做前端/客户端的同学参与本项目,该平台会大大提高效率
- 自动化:有自动化发帖/自动化操作的需求,亦可通过该平台实现或调试
- API文档: https://docs.open-isle.com/openapi

View File

@@ -4,6 +4,8 @@
高效的开源社区前后端平台
<br><br><br>
<img alt="Image" src="https://openisle-1307107697.cos.accelerate.myqcloud.com/dynamic_assert/22752cfac5a04a9c90c41995b9f55fed.png" width="1200">
<br><br><br>
<a href="https://hellogithub.com/repository/nagisa77/OpenIsle" target="_blank"><img src="https://abroad.hellogithub.com/v1/widgets/recommend.svg?rid=8605546658d94cbab45182af2a02e4c8&claim_uid=p5GNFTtZl6HBAYQ" alt="FeaturedHelloGitHub" style="width: 250px; height: 54px;" width="250" height="54" /></a>
</p>
## 💡 简介

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!

23
backend/.prettierrc Normal file
View File

@@ -0,0 +1,23 @@
{
"printWidth": 100,
"tabWidth": 2,
"useTabs": false,
"semi": false,
"singleQuote": true,
"trailingComma": "all",
"endOfLine": "lf",
"proseWrap": "preserve",
"plugins": ["prettier-plugin-java"],
"overrides": [
{
"files": "*.java",
"options": {
"printWidth": 100,
"tabWidth": 2,
"semi": true,
"singleQuote": false,
"trailingComma": "es5"
}
}
]
}

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

@@ -7,7 +7,8 @@ import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication
@EnableScheduling
public class OpenIsleApplication {
public static void main(String[] args) {
SpringApplication.run(OpenIsleApplication.class, args);
}
public static void main(String[] args) {
SpringApplication.run(OpenIsleApplication.class, args);
}
}

View File

@@ -3,37 +3,40 @@ package com.openisle.config;
import com.openisle.model.Activity;
import com.openisle.model.ActivityType;
import com.openisle.repository.ActivityRepository;
import java.time.LocalDate;
import java.time.LocalDateTime;
import lombok.RequiredArgsConstructor;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;
import java.time.LocalDate;
import java.time.LocalDateTime;
@Component
@RequiredArgsConstructor
public class ActivityInitializer implements CommandLineRunner {
private final ActivityRepository activityRepository;
@Override
public void run(String... args) {
if (activityRepository.findByType(ActivityType.MILK_TEA) == null) {
Activity a = new Activity();
a.setTitle("🎡建站送奶茶活动");
a.setType(ActivityType.MILK_TEA);
a.setIcon("https://icons.veryicon.com/png/o/food--drinks/delicious-food-1/coffee-36.png");
a.setContent("为了有利于建站推广以及激励发布内容我们推出了建站送奶茶的活动前50名达到level 1的用户可以联系站长获取奶茶/咖啡一杯");
activityRepository.save(a);
}
private final ActivityRepository activityRepository;
if (activityRepository.findByType(ActivityType.INVITE_POINTS) == null) {
Activity a = new Activity();
a.setTitle("🎁邀请码送积分活动");
a.setType(ActivityType.INVITE_POINTS);
a.setIcon("https://img.icons8.com/color/96/gift.png");
a.setContent("使用邀请码注册或邀请好友即可获得积分奖励,快来参与吧!");
a.setStartTime(LocalDateTime.now());
a.setEndTime(LocalDate.of(LocalDate.now().getYear(), 10, 1).atStartOfDay());
activityRepository.save(a);
}
@Override
public void run(String... args) {
if (activityRepository.findByType(ActivityType.MILK_TEA) == null) {
Activity a = new Activity();
a.setTitle("🎡建站送奶茶活动");
a.setType(ActivityType.MILK_TEA);
a.setIcon("https://icons.veryicon.com/png/o/food--drinks/delicious-food-1/coffee-36.png");
a.setContent(
"为了有利于建站推广以及激励发布内容我们推出了建站送奶茶的活动前50名达到level 1的用户可以联系站长获取奶茶/咖啡一杯"
);
activityRepository.save(a);
}
if (activityRepository.findByType(ActivityType.INVITE_POINTS) == null) {
Activity a = new Activity();
a.setTitle("🎁邀请码送积分活动");
a.setType(ActivityType.INVITE_POINTS);
a.setIcon("https://img.icons8.com/color/96/gift.png");
a.setContent("使用邀请码注册或邀请好友即可获得积分奖励,快来参与吧!");
a.setStartTime(LocalDateTime.now());
a.setEndTime(LocalDate.of(LocalDate.now().getYear(), 10, 1).atStartOfDay());
activityRepository.save(a);
}
}
}

View File

@@ -1,23 +1,23 @@
package com.openisle.config;
import java.util.concurrent.Executor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import java.util.concurrent.Executor;
@Configuration
@EnableAsync
public class AsyncConfig {
@Bean(name = "notificationExecutor")
public Executor notificationExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(2);
executor.setMaxPoolSize(10);
executor.setQueueCapacity(100);
executor.setThreadNamePrefix("notification-");
executor.initialize();
return executor;
}
@Bean(name = "notificationExecutor")
public Executor notificationExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(2);
executor.setMaxPoolSize(10);
executor.setQueueCapacity(100);
executor.setThreadNamePrefix("notification-");
executor.initialize();
return executor;
}
}

View File

@@ -7,6 +7,9 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.jsontype.impl.LaissezFaireSubTypeValidator;
import com.fasterxml.jackson.datatype.hibernate6.Hibernate6Module;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import java.time.Duration;
import java.util.HashMap;
import java.util.Map;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Bean;
@@ -21,10 +24,6 @@ import org.springframework.data.redis.serializer.RedisSerializationContext;
import org.springframework.data.redis.serializer.RedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;
import java.time.Duration;
import java.util.HashMap;
import java.util.Map;
/**
* Redis 缓存配置类
* @author smallclover
@@ -34,83 +33,107 @@ import java.util.Map;
@EnableCaching
public class CachingConfig {
// 标签缓存名
public static final String TAG_CACHE_NAME="openisle_tags";
// 分类缓存名
public static final String CATEGORY_CACHE_NAME="openisle_categories";
// 在线人数缓存名
public static final String ONLINE_CACHE_NAME="openisle_online";
// 标签缓存名
public static final String TAG_CACHE_NAME = "openisle_tags";
// 分类缓存名
public static final String CATEGORY_CACHE_NAME = "openisle_categories";
// 在线人数缓存名
public static final String ONLINE_CACHE_NAME = "openisle_online";
// 注册验证码
public static final String VERIFY_CACHE_NAME = "openisle_verify";
// 发帖频率限制
public static final String LIMIT_CACHE_NAME = "openisle_limit";
// 用户访问统计
public static final String VISIT_CACHE_NAME = "openisle_visit";
// 文章缓存
public static final String POST_CACHE_NAME = "openisle_posts";
/**
* 自定义Redis的序列化器
* @return
*/
@Bean()
@Primary
public RedisSerializer<Object> redisSerializer() {
// 注册 JavaTimeModule 來支持 Java 8 的日期和时间 API,否则回报一下错误同时还要引入jsr310
/**
* 自定义Redis的序列化器
* @return
*/
@Bean
@Primary
public RedisSerializer<Object> redisSerializer() {
// 注册 JavaTimeModule 來支持 Java 8 的日期和时间 API,否则回报一下错误同时还要引入jsr310
// org.springframework.data.redis.serializer.SerializationException: Could not write JSON: Java 8 date/time type `java.time.LocalDateTime` not supported by default:
// add Module "com.fasterxml.jackson.datatype:jackson-datatype-jsr310" to enable handling
// (through reference chain: java.util.ArrayList[0]->com.openisle.dto.TagDto["createdAt"])
// 设置可见性,允许序列化所有元素
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.registerModule(new JavaTimeModule());
// Hibernate6Module 可以自动处理懒加载代理对象。
// Tag对象的creator是FetchType.LAZY
objectMapper.registerModule(new Hibernate6Module()
.disable(Hibernate6Module.Feature.USE_TRANSIENT_ANNOTATION));
// service的时候带上类型信息
// 启用类型信息,避免 LinkedHashMap 问题
objectMapper.activateDefaultTyping(
LaissezFaireSubTypeValidator.instance,
ObjectMapper.DefaultTyping.NON_FINAL,
JsonTypeInfo.As.PROPERTY
);
objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
return new GenericJackson2JsonRedisSerializer(objectMapper);
}
// org.springframework.data.redis.serializer.SerializationException: Could not write JSON: Java 8 date/time type `java.time.LocalDateTime` not supported by default:
// add Module "com.fasterxml.jackson.datatype:jackson-datatype-jsr310" to enable handling
// (through reference chain: java.util.ArrayList[0]->com.openisle.dto.TagDto["createdAt"])
// 设置可见性,允许序列化所有元素
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.registerModule(new JavaTimeModule());
// Hibernate6Module 可以自动处理懒加载代理对象。
// Tag对象的creator是FetchType.LAZY
objectMapper.registerModule(
new Hibernate6Module()
.disable(Hibernate6Module.Feature.USE_TRANSIENT_ANNOTATION)
// 将 Hibernate 特有的集合类型转换为标准 Java 集合类型
// 避免序列化时出现 org.hibernate.collection.spi.PersistentSet 这样的类型信息
.configure(Hibernate6Module.Feature.REPLACE_PERSISTENT_COLLECTIONS, true)
);
// service的时候带上类型信息
// 启用类型信息,避免 LinkedHashMap 问题
objectMapper.activateDefaultTyping(
LaissezFaireSubTypeValidator.instance,
ObjectMapper.DefaultTyping.NON_FINAL,
JsonTypeInfo.As.PROPERTY
);
objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
return new GenericJackson2JsonRedisSerializer(objectMapper);
}
/**
* 配置 Spring Cache 使用 RedisCacheManager
*/
@Bean
public CacheManager cacheManager(RedisConnectionFactory connectionFactory, RedisSerializer<Object> redisSerializer) {
/**
* 配置 Spring Cache 使用 RedisCacheManager
*/
@Bean
public CacheManager cacheManager(
RedisConnectionFactory connectionFactory,
RedisSerializer<Object> redisSerializer
) {
RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ZERO) // 默认缓存不过期
.serializeKeysWith(
RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer())
)
.serializeValuesWith(
RedisSerializationContext.SerializationPair.fromSerializer(redisSerializer)
)
.disableCachingNullValues(); // 禁止缓存 null 值
RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ZERO) // 默认缓存不过期
.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(redisSerializer))
.disableCachingNullValues(); // 禁止缓存 null 值
// 个别缓存单独设置 TTL 时间
Map<String, RedisCacheConfiguration> cacheConfigs = new HashMap<>();
RedisCacheConfiguration oneHourConfig = config.entryTtl(Duration.ofHours(1));
RedisCacheConfiguration tenMinutesConfig = config.entryTtl(Duration.ofMinutes(10));
cacheConfigs.put(TAG_CACHE_NAME, oneHourConfig);
cacheConfigs.put(CATEGORY_CACHE_NAME, oneHourConfig);
cacheConfigs.put(POST_CACHE_NAME, tenMinutesConfig);
// 个别缓存单独设置 TTL 时间
Map<String, RedisCacheConfiguration> cacheConfigs = new HashMap<>();
RedisCacheConfiguration oneHourConfig = config.entryTtl(Duration.ofHours(1));
cacheConfigs.put(TAG_CACHE_NAME, oneHourConfig);
cacheConfigs.put(CATEGORY_CACHE_NAME, oneHourConfig);
return RedisCacheManager.builder(connectionFactory)
.cacheDefaults(config)
.withInitialCacheConfigurations(cacheConfigs)
.build();
}
return RedisCacheManager.builder(connectionFactory)
.cacheDefaults(config)
.withInitialCacheConfigurations(cacheConfigs)
.build();
}
/**
* 配置 RedisTemplate支持直接操作 Redis
*/
@Bean
public RedisTemplate<String, Object> redisTemplate(
RedisConnectionFactory connectionFactory,
RedisSerializer<Object> redisSerializer
) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(connectionFactory);
/**
* 配置 RedisTemplate支持直接操作 Redis
*/
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory, RedisSerializer<Object> redisSerializer) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(connectionFactory);
// key 和 hashKey 使用 String 序列化
template.setKeySerializer(new StringRedisSerializer());
template.setHashKeySerializer(new StringRedisSerializer());
// key 和 hashKey 使用 String 序列化
template.setKeySerializer(new StringRedisSerializer());
template.setHashKeySerializer(new StringRedisSerializer());
// value 和 hashValue 使用 JSON 序列化
template.setValueSerializer(redisSerializer);
template.setHashValueSerializer(redisSerializer);
// value 和 hashValue 使用 JSON 序列化
template.setValueSerializer(redisSerializer);
template.setHashValueSerializer(redisSerializer);
return template;
}
return template;
}
}

View File

@@ -9,24 +9,29 @@ import org.springframework.stereotype.Component;
@Component
@RequiredArgsConstructor
public class ChannelInitializer implements CommandLineRunner {
private final MessageConversationRepository conversationRepository;
@Override
public void run(String... args) {
if (conversationRepository.countByChannelTrue() == 0) {
MessageConversation chat = new MessageConversation();
chat.setChannel(true);
chat.setName("吹水群");
chat.setDescription("吹水聊天");
chat.setAvatar("https://openisle-1307107697.cos.accelerate.myqcloud.com/dynamic_assert/32647273e2334d14adfd4a6ce9db0643.jpeg");
conversationRepository.save(chat);
private final MessageConversationRepository conversationRepository;
MessageConversation tech = new MessageConversation();
tech.setChannel(true);
tech.setName("技术讨论群");
tech.setDescription("讨论技术相关话题");
tech.setAvatar("https://openisle-1307107697.cos.accelerate.myqcloud.com/dynamic_assert/5edde9a5864e471caa32491dbcdaa8b2.png");
conversationRepository.save(tech);
}
@Override
public void run(String... args) {
if (conversationRepository.countByChannelTrue() == 0) {
MessageConversation chat = new MessageConversation();
chat.setChannel(true);
chat.setName("吹水群");
chat.setDescription("吹水聊天");
chat.setAvatar(
"https://openisle-1307107697.cos.accelerate.myqcloud.com/dynamic_assert/32647273e2334d14adfd4a6ce9db0643.jpeg"
);
conversationRepository.save(chat);
MessageConversation tech = new MessageConversation();
tech.setChannel(true);
tech.setName("技术讨论群");
tech.setDescription("讨论技术相关话题");
tech.setAvatar(
"https://openisle-1307107697.cos.accelerate.myqcloud.com/dynamic_assert/5edde9a5864e471caa32491dbcdaa8b2.png"
);
conversationRepository.save(tech);
}
}
}

View File

@@ -3,23 +3,25 @@ package com.openisle.config;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;
import java.io.IOException;
/**
* Returns 401 Unauthorized when an authenticated user lacks required privileges.
*/
@Component
public class CustomAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request,
HttpServletResponse response,
AccessDeniedException accessDeniedException) throws IOException, ServletException {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json");
response.getWriter().write("{\"error\": \"Unauthorized\"}");
}
@Override
public void handle(
HttpServletRequest request,
HttpServletResponse response,
AccessDeniedException accessDeniedException
) throws IOException, ServletException {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json");
response.getWriter().write("{\"error\": \"Unauthorized\"}");
}
}

View File

@@ -5,44 +5,54 @@ import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Info;
import io.swagger.v3.oas.models.security.SecurityRequirement;
import io.swagger.v3.oas.models.security.SecurityScheme;
import io.swagger.v3.oas.models.servers.Server;
import java.util.List;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
@RequiredArgsConstructor
public class OpenApiConfig {
@Value("${springdoc.info.title}")
private String title;
private final SpringDocProperties springDocProperties;
@Value("${springdoc.info.description}")
private String description;
@Value("${springdoc.info.title}")
private String title;
@Value("${springdoc.info.version}")
private String version;
@Value("${springdoc.info.description}")
private String description;
@Value("${springdoc.info.scheme}")
private String scheme;
@Value("${springdoc.info.version}")
private String version;
@Value("${springdoc.info.header}")
private String header;
@Value("${springdoc.info.scheme}")
private String scheme;
@Bean
public OpenAPI openAPI() {
SecurityScheme securityScheme = new SecurityScheme()
.type(SecurityScheme.Type.HTTP)
.scheme(scheme.toLowerCase())
.bearerFormat("JWT")
.in(SecurityScheme.In.HEADER)
.name(header);
@Value("${springdoc.info.header}")
private String header;
return new OpenAPI()
.info(new Info()
.title(title)
.description(description)
.version(version))
.components(new Components()
.addSecuritySchemes("JWT", securityScheme))
.addSecurityItem(new SecurityRequirement().addList("JWT"));
}
@Bean
public OpenAPI openAPI() {
SecurityScheme securityScheme = new SecurityScheme()
.type(SecurityScheme.Type.HTTP)
.scheme(scheme.toLowerCase())
.bearerFormat("JWT")
.in(SecurityScheme.In.HEADER)
.name(header);
List<Server> servers = springDocProperties
.getServers()
.stream()
.map(s -> new Server().url(s.getUrl()).description(s.getDescription()))
.collect(Collectors.toList());
return new OpenAPI()
.servers(servers)
.info(new Info().title(title).description(description).version(version))
.components(new Components().addSecuritySchemes("JWT", securityScheme))
.addSecurityItem(new SecurityRequirement().addList("JWT"));
}
}

View File

@@ -10,22 +10,27 @@ import org.springframework.stereotype.Component;
@Component
@RequiredArgsConstructor
public class PointGoodInitializer implements CommandLineRunner {
private final PointGoodRepository pointGoodRepository;
@Override
public void run(String... args) {
if (pointGoodRepository.count() == 0) {
PointGood g1 = new PointGood();
g1.setName("GPT Plus 1 个月");
g1.setCost(20000);
g1.setImage("https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/icons/chatgpt.png");
pointGoodRepository.save(g1);
private final PointGoodRepository pointGoodRepository;
PointGood g2 = new PointGood();
g2.setName("奶茶");
g2.setCost(5000);
g2.setImage("https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/icons/coffee.png");
pointGoodRepository.save(g2);
}
@Override
public void run(String... args) {
if (pointGoodRepository.count() == 0) {
PointGood g1 = new PointGood();
g1.setName("GPT Plus 1 个月");
g1.setCost(20000);
g1.setImage(
"https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/icons/chatgpt.png"
);
pointGoodRepository.save(g1);
PointGood g2 = new PointGood();
g2.setName("奶茶");
g2.setCost(5000);
g2.setImage(
"https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/icons/coffee.png"
);
pointGoodRepository.save(g2);
}
}
}

View File

@@ -1,204 +1,219 @@
package com.openisle.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.PostConstruct;
import java.util.ArrayList;
import java.util.List;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.core.Binding;
import org.springframework.amqp.core.BindingBuilder;
import org.springframework.amqp.core.Queue;
import org.springframework.amqp.core.TopicExchange;
import org.springframework.amqp.rabbit.connection.ConnectionFactory;
import org.springframework.amqp.rabbit.core.RabbitAdmin;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.CommandLineRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.amqp.rabbit.core.RabbitAdmin;
import org.springframework.boot.CommandLineRunner;
import org.springframework.context.annotation.DependsOn;
import jakarta.annotation.PostConstruct;
import java.util.ArrayList;
import java.util.List;
@Configuration
@RequiredArgsConstructor
@Slf4j
public class RabbitMQConfig {
public static final String EXCHANGE_NAME = "openisle-exchange";
// 保持向后兼容的常量
public static final String QUEUE_NAME = "notifications-queue";
public static final String ROUTING_KEY = "notifications.routingkey";
// 硬编码为16以匹配ShardingStrategy中的十六进制分片逻辑
private final int queueCount = 16;
@Value("${rabbitmq.queue.durable}")
private boolean queueDurable;
public static final String EXCHANGE_NAME = "openisle-exchange";
// 保持向后兼容的常量
public static final String QUEUE_NAME = "notifications-queue";
public static final String ROUTING_KEY = "notifications.routingkey";
@PostConstruct
public void init() {
System.out.println("RabbitMQ配置初始化: 队列数量=" + queueCount + ", 持久化=" + queueDurable);
// 硬编码为16以匹配ShardingStrategy中的十六进制分片逻辑
private final int queueCount = 16;
@Value("${rabbitmq.queue.durable}")
private boolean queueDurable;
@PostConstruct
public void init() {
log.info("RabbitMQ配置初始化: 队列数量={}, 持久化={}", queueCount, queueDurable);
}
@Bean
public TopicExchange exchange() {
return new TopicExchange(EXCHANGE_NAME);
}
/**
* 创建所有分片队列, 使用十六进制后缀 (0-f)
*/
@Bean
public List<Queue> shardedQueues() {
log.info("开始创建分片队列 Bean...");
List<Queue> queues = new ArrayList<>();
for (int i = 0; i < queueCount; i++) {
String shardKey = Integer.toHexString(i);
String queueName = "notifications-queue-" + shardKey;
Queue queue = new Queue(queueName, queueDurable);
queues.add(queue);
}
@Bean
public TopicExchange exchange() {
return new TopicExchange(EXCHANGE_NAME);
log.info("分片队列 Bean 创建完成,总数: {}", queues.size());
return queues;
}
/**
* 创建所有分片绑定, 使用十六进制路由键 (notifications.shard.0 - notifications.shard.f)
*/
@Bean
public List<Binding> shardedBindings(
TopicExchange exchange,
@Qualifier("shardedQueues") List<Queue> shardedQueues
) {
log.info("开始创建分片绑定 Bean...");
List<Binding> bindings = new ArrayList<>();
if (shardedQueues != null) {
for (Queue queue : shardedQueues) {
String queueName = queue.getName();
String shardKey = queueName.substring("notifications-queue-".length());
String routingKey = "notifications.shard." + shardKey;
Binding binding = BindingBuilder.bind(queue).to(exchange).with(routingKey);
bindings.add(binding);
}
}
/**
* 创建所有分片队列, 使用十六进制后缀 (0-f)
*/
@Bean
public List<Queue> shardedQueues() {
System.out.println("开始创建分片队列 Bean...");
List<Queue> queues = new ArrayList<>();
for (int i = 0; i < queueCount; i++) {
String shardKey = Integer.toHexString(i);
String queueName = "notifications-queue-" + shardKey;
Queue queue = new Queue(queueName, queueDurable);
queues.add(queue);
}
System.out.println("分片队列 Bean 创建完成,总数: " + queues.size());
return queues;
}
log.info("分片绑定 Bean 创建完成,总数: {}", bindings.size());
return bindings;
}
/**
* 创建所有分片绑定, 使用十六进制路由键 (notifications.shard.0 - notifications.shard.f)
*/
@Bean
public List<Binding> shardedBindings(TopicExchange exchange, @Qualifier("shardedQueues") List<Queue> shardedQueues) {
System.out.println("开始创建分片绑定 Bean...");
List<Binding> bindings = new ArrayList<>();
if (shardedQueues != null) {
for (Queue queue : shardedQueues) {
String queueName = queue.getName();
String shardKey = queueName.substring("notifications-queue-".length());
String routingKey = "notifications.shard." + shardKey;
Binding binding = BindingBuilder.bind(queue).to(exchange).with(routingKey);
bindings.add(binding);
/**
* 保持向后兼容的单队列配置(可选)
*/
@Bean
public Queue legacyQueue() {
return new Queue(QUEUE_NAME, queueDurable);
}
/**
* 保持向后兼容的单队列绑定(可选)
*/
@Bean
public Binding legacyBinding(Queue legacyQueue, TopicExchange exchange) {
return BindingBuilder.bind(legacyQueue).to(exchange).with(ROUTING_KEY);
}
@Bean
public Jackson2JsonMessageConverter messageConverter() {
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.registerModule(new com.fasterxml.jackson.datatype.jsr310.JavaTimeModule());
objectMapper.disable(
com.fasterxml.jackson.databind.SerializationFeature.WRITE_DATES_AS_TIMESTAMPS
);
return new Jackson2JsonMessageConverter(objectMapper);
}
@Bean
public RabbitAdmin rabbitAdmin(ConnectionFactory connectionFactory) {
return new RabbitAdmin(connectionFactory);
}
@Bean
public RabbitTemplate rabbitTemplate(ConnectionFactory connectionFactory) {
RabbitTemplate template = new RabbitTemplate(connectionFactory);
template.setMessageConverter(messageConverter());
return template;
}
/**
* 使用 CommandLineRunner 确保在应用完全启动后声明队列到 RabbitMQ
* 这样可以确保 RabbitAdmin 和所有 Bean 都已正确初始化
*/
@Bean
@DependsOn({ "rabbitAdmin", "shardedQueues", "exchange" })
public CommandLineRunner queueDeclarationRunner(
RabbitAdmin rabbitAdmin,
@Qualifier("shardedQueues") List<Queue> shardedQueues,
TopicExchange exchange,
Queue legacyQueue,
@Qualifier("shardedBindings") List<Binding> shardedBindings,
Binding legacyBinding
) {
return args -> {
log.info("=== 开始主动声明 RabbitMQ 组件 ===");
try {
// 声明交换
rabbitAdmin.declareExchange(exchange);
// 声明分片队列 - 检查存在性
log.info("开始检查并声明 {} 个分片队列...", shardedQueues.size());
int successCount = 0;
int skippedCount = 0;
for (Queue queue : shardedQueues) {
String queueName = queue.getName();
try {
// 使用 declareQueue 的返回值判断队列是否已存在
// 如果队列已存在且配置匹配declareQueue 会返回现有队列信息
// 如果不匹配或不存在,会创建新队列
rabbitAdmin.declareQueue(queue);
successCount++;
} catch (org.springframework.amqp.AmqpIOException e) {
if (
e.getMessage().contains("PRECONDITION_FAILED") && e.getMessage().contains("durable")
) {
skippedCount++;
}
} catch (Exception e) {
log.error("队列声明失败: {}, 错误: {}", queueName, e.getMessage());
}
}
System.out.println("分片绑定 Bean 创建完成,总数: " + bindings.size());
return bindings;
}
log.info(
"分片队列处理完成: 成功 {}, 跳过 {}, 总数 {}",
successCount,
skippedCount,
shardedQueues.size()
);
/**
* 保持向后兼容的单队列配置(可选)
*/
@Bean
public Queue legacyQueue() {
return new Queue(QUEUE_NAME, queueDurable);
}
// 声明分片绑定
log.info("开始声明 {} 个分片绑定...", shardedBindings.size());
int bindingSuccessCount = 0;
for (Binding binding : shardedBindings) {
try {
rabbitAdmin.declareBinding(binding);
bindingSuccessCount++;
} catch (Exception e) {
log.error("绑定声明失败: {}", e.getMessage());
}
}
log.info("分片绑定声明完成: 成功 {}/{}", bindingSuccessCount, shardedBindings.size());
/**
* 保持向后兼容的单队列绑定(可选)
*/
@Bean
public Binding legacyBinding(Queue legacyQueue, TopicExchange exchange) {
return BindingBuilder.bind(legacyQueue).to(exchange).with(ROUTING_KEY);
}
// 声明遗留队列和绑定 - 检查存在性
try {
rabbitAdmin.declareQueue(legacyQueue);
rabbitAdmin.declareBinding(legacyBinding);
log.info("遗留队列和绑定就绪: {} (已存在或新创建)", QUEUE_NAME);
} catch (org.springframework.amqp.AmqpIOException e) {
if (
e.getMessage().contains("PRECONDITION_FAILED") && e.getMessage().contains("durable")
) {
log.warn("遗留队列已存在但 durable 设置不匹配: {}, 保持现有队列", QUEUE_NAME);
} else {
log.error("遗留队列声明失败: {}, 错误: {}", QUEUE_NAME, e.getMessage());
}
} catch (Exception e) {
log.error("遗留队列声明失败: {}, 错误: {}", QUEUE_NAME, e.getMessage());
}
@Bean
public Jackson2JsonMessageConverter messageConverter() {
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.registerModule(new com.fasterxml.jackson.datatype.jsr310.JavaTimeModule());
objectMapper.disable(com.fasterxml.jackson.databind.SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
return new Jackson2JsonMessageConverter(objectMapper);
}
@Bean
public RabbitAdmin rabbitAdmin(ConnectionFactory connectionFactory) {
return new RabbitAdmin(connectionFactory);
}
@Bean
public RabbitTemplate rabbitTemplate(ConnectionFactory connectionFactory) {
RabbitTemplate template = new RabbitTemplate(connectionFactory);
template.setMessageConverter(messageConverter());
return template;
}
/**
* 使用 CommandLineRunner 确保在应用完全启动后声明队列到 RabbitMQ
* 这样可以确保 RabbitAdmin 和所有 Bean 都已正确初始化
*/
@Bean
@DependsOn({"rabbitAdmin", "shardedQueues", "exchange"})
public CommandLineRunner queueDeclarationRunner(RabbitAdmin rabbitAdmin,
@Qualifier("shardedQueues") List<Queue> shardedQueues,
TopicExchange exchange,
Queue legacyQueue,
@Qualifier("shardedBindings") List<Binding> shardedBindings,
Binding legacyBinding) {
return args -> {
System.out.println("=== 开始主动声明 RabbitMQ 组件 ===");
try {
// 声明交换
rabbitAdmin.declareExchange(exchange);
// 声明分片队列 - 检查存在性
System.out.println("开始检查并声明 " + shardedQueues.size() + " 个分片队列...");
int successCount = 0;
int skippedCount = 0;
for (Queue queue : shardedQueues) {
String queueName = queue.getName();
try {
// 使用 declareQueue 的返回值判断队列是否已存在
// 如果队列已存在且配置匹配declareQueue 会返回现有队列信息
// 如果不匹配或不存在,会创建新队列
rabbitAdmin.declareQueue(queue);
successCount++;
} catch (org.springframework.amqp.AmqpIOException e) {
if (e.getMessage().contains("PRECONDITION_FAILED") && e.getMessage().contains("durable")) {
skippedCount++;
}
} catch (Exception e) {
System.err.println("队列声明失败: " + queueName + ", 错误: " + e.getMessage());
}
}
System.out.println("分片队列处理完成: 成功 " + successCount + ", 跳过 " + skippedCount + ", 总数 " + shardedQueues.size());
// 声明分片绑定
System.out.println("开始声明 " + shardedBindings.size() + " 个分片绑定...");
int bindingSuccessCount = 0;
for (Binding binding : shardedBindings) {
try {
rabbitAdmin.declareBinding(binding);
bindingSuccessCount++;
} catch (Exception e) {
System.err.println("绑定声明失败: " + e.getMessage());
}
}
System.out.println("分片绑定声明完成: 成功 " + bindingSuccessCount + "/" + shardedBindings.size());
// 声明遗留队列和绑定 - 检查存在性
try {
rabbitAdmin.declareQueue(legacyQueue);
rabbitAdmin.declareBinding(legacyBinding);
System.out.println("遗留队列和绑定就绪: " + QUEUE_NAME + " (已存在或新创建)");
} catch (org.springframework.amqp.AmqpIOException e) {
if (e.getMessage().contains("PRECONDITION_FAILED") && e.getMessage().contains("durable")) {
System.out.println("遗留队列已存在但 durable 设置不匹配: " + QUEUE_NAME + ", 保持现有队列");
} else {
System.err.println("遗留队列声明失败: " + QUEUE_NAME + ", 错误: " + e.getMessage());
}
} catch (Exception e) {
System.err.println("遗留队列声明失败: " + QUEUE_NAME + ", 错误: " + e.getMessage());
}
System.out.println("=== RabbitMQ 组件声明完成 ===");
System.out.println("请检查 RabbitMQ 管理界面确认队列已正确创建");
} catch (Exception e) {
System.err.println("RabbitMQ 组件声明过程中发生严重错误:");
e.printStackTrace();
}
};
}
}
log.info("=== RabbitMQ 组件声明完成 ===");
log.info("请检查 RabbitMQ 管理界面确认队列已正确创建");
} catch (Exception e) {
log.error("RabbitMQ 组件声明过程中发生严重错误", e);
}
};
}
}

View File

@@ -13,23 +13,23 @@ import org.springframework.stereotype.Component;
@Slf4j
public class RedisConnectionLogger implements InitializingBean {
private final RedisConnectionFactory connectionFactory;
private final RedisConnectionFactory connectionFactory;
public RedisConnectionLogger(RedisConnectionFactory connectionFactory) {
this.connectionFactory = connectionFactory;
}
public RedisConnectionLogger(RedisConnectionFactory connectionFactory) {
this.connectionFactory = connectionFactory;
}
@Override
public void afterPropertiesSet() {
try (var connection = connectionFactory.getConnection()) {
connection.ping();
if (connectionFactory instanceof LettuceConnectionFactory lettuce) {
log.info("Redis connection established at {}:{}", lettuce.getHostName(), lettuce.getPort());
} else {
log.info("Redis connection established");
}
} catch (Exception e) {
log.error("Failed to connect to Redis", e);
}
@Override
public void afterPropertiesSet() {
try (var connection = connectionFactory.getConnection()) {
connection.ping();
if (connectionFactory instanceof LettuceConnectionFactory lettuce) {
log.info("Redis connection established at {}:{}", lettuce.getHostName(), lettuce.getPort());
} else {
log.info("Redis connection established");
}
} catch (Exception e) {
log.error("Failed to connect to Redis", e);
}
}
}

View File

@@ -2,19 +2,20 @@ package com.openisle.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.TaskScheduler;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;
import org.springframework.scheduling.TaskScheduler;
@Configuration
@EnableScheduling
public class SchedulerConfig {
@Bean
public TaskScheduler taskScheduler() {
ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
scheduler.setPoolSize(2);
scheduler.setThreadNamePrefix("lottery-");
scheduler.initialize();
return scheduler;
}
@Bean
public TaskScheduler taskScheduler() {
ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
scheduler.setPoolSize(2);
scheduler.setThreadNamePrefix("lottery-");
scheduler.initialize();
return scheduler;
}
}

View File

@@ -1,11 +1,20 @@
package com.openisle.config;
import com.openisle.repository.UserRepository;
import com.openisle.service.JwtService;
import com.openisle.service.UserVisitService;
import com.openisle.repository.UserRepository;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.time.LocalDate;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
@@ -21,194 +30,273 @@ import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.filter.OncePerRequestFilter;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.beans.factory.annotation.Value;
import java.util.List;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import org.springframework.web.filter.OncePerRequestFilter;
@Configuration
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtService jwtService;
private final UserRepository userRepository;
private final AccessDeniedHandler customAccessDeniedHandler;
private final UserVisitService userVisitService;
@Value("${app.website-url}")
private String websiteUrl;
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
private final JwtService jwtService;
private final UserRepository userRepository;
private final AccessDeniedHandler customAccessDeniedHandler;
private final UserVisitService userVisitService;
@Bean
public UserDetailsService userDetailsService() {
return username -> userRepository.findByUsername(username)
.<UserDetails>map(user -> org.springframework.security.core.userdetails.User
.withUsername(user.getUsername())
.password(user.getPassword())
.authorities(user.getRole().name())
.build())
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
}
@Value("${app.website-url}")
private String websiteUrl;
@Bean
public AuthenticationManager authenticationManager(HttpSecurity http, PasswordEncoder passwordEncoder, UserDetailsService userDetailsService) throws Exception {
return http.getSharedObject(AuthenticationManagerBuilder.class)
.userDetailsService(userDetailsService)
.passwordEncoder(passwordEncoder)
.and()
.build();
}
private final RedisTemplate redisTemplate;
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration cfg = new CorsConfiguration();
cfg.setAllowedOrigins(List.of(
"http://127.0.0.1:8080",
"http://127.0.0.1:8081",
"http://127.0.0.1:8082",
"http://127.0.0.1:3000",
"http://127.0.0.1:3001",
"http://127.0.0.1",
"http://localhost:8080",
"http://localhost:8081",
"http://localhost:8082",
"http://localhost:3000",
"http://localhost:3001",
"http://localhost",
"http://30.211.97.238:3000",
"http://30.211.97.238",
"http://192.168.7.98",
"http://192.168.7.98:3000",
"https://petstore.swagger.io",
websiteUrl,
websiteUrl.replace("://www.", "://")
));
cfg.setAllowedMethods(List.of("GET","POST","PUT","DELETE","OPTIONS"));
cfg.setAllowedHeaders(List.of("*"));
cfg.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", cfg);
return source;
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.csrf(csrf -> csrf.disable())
.cors(Customizer.withDefaults())
.headers(h -> h.frameOptions(f -> f.sameOrigin()))
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.exceptionHandling(eh -> eh.accessDeniedHandler(customAccessDeniedHandler))
.authorizeHttpRequests(auth -> auth
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
.requestMatchers("/api/ws/**", "/api/sockjs/**").permitAll()
.requestMatchers("/api/v3/api-docs/**").permitAll()
.requestMatchers(HttpMethod.POST, "/api/auth/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/posts/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/comments/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/categories/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/tags/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/config/**").permitAll()
.requestMatchers(HttpMethod.POST,"/api/auth/google").permitAll()
.requestMatchers(HttpMethod.POST,"/api/auth/reason").permitAll()
.requestMatchers(HttpMethod.GET, "/api/search/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/users/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/medals/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/push/public-key").permitAll()
.requestMatchers(HttpMethod.GET, "/api/reaction-types").permitAll()
.requestMatchers(HttpMethod.GET, "/api/activities/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/sitemap.xml").permitAll()
.requestMatchers(HttpMethod.GET, "/api/channels").permitAll()
.requestMatchers(HttpMethod.GET, "/api/rss").permitAll()
.requestMatchers(HttpMethod.GET, "/api/online/**").permitAll()
.requestMatchers(HttpMethod.POST, "/api/online/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/point-goods").permitAll()
.requestMatchers(HttpMethod.POST, "/api/point-goods").permitAll()
.requestMatchers(HttpMethod.POST, "/api/categories/**").hasAuthority("ADMIN")
.requestMatchers(HttpMethod.POST, "/api/tags/**").authenticated()
.requestMatchers(HttpMethod.DELETE, "/api/categories/**").hasAuthority("ADMIN")
.requestMatchers(HttpMethod.DELETE, "/api/tags/**").hasAuthority("ADMIN")
.requestMatchers(HttpMethod.GET, "/api/stats/**").hasAuthority("ADMIN")
.requestMatchers("/api/admin/**").hasAuthority("ADMIN")
.anyRequest().authenticated()
)
.addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class)
.addFilterAfter(userVisitFilter(), UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public UserDetailsService userDetailsService() {
return username ->
userRepository
.findByUsername(username)
.<UserDetails>map(user ->
org.springframework.security.core.userdetails.User.withUsername(user.getUsername())
.password(user.getPassword())
.authorities(user.getRole().name())
.build()
)
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
}
@Bean
public OncePerRequestFilter jwtAuthenticationFilter() {
return new OncePerRequestFilter() {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
// 让预检请求直接通过
if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
filterChain.doFilter(request, response);
return;
}
String authHeader = request.getHeader("Authorization");
String uri = request.getRequestURI();
@Bean
public AuthenticationManager authenticationManager(
HttpSecurity http,
PasswordEncoder passwordEncoder,
UserDetailsService userDetailsService
) throws Exception {
return http
.getSharedObject(AuthenticationManagerBuilder.class)
.userDetailsService(userDetailsService)
.passwordEncoder(passwordEncoder)
.and()
.build();
}
boolean publicGet = "GET".equalsIgnoreCase(request.getMethod()) &&
(uri.startsWith("/api/posts") || uri.startsWith("/api/comments") ||
uri.startsWith("/api/categories") || uri.startsWith("/api/tags") ||
uri.startsWith("/api/search") || uri.startsWith("/api/users") ||
uri.startsWith("/api/reaction-types") || uri.startsWith("/api/config") ||
uri.startsWith("/api/activities") || uri.startsWith("/api/push/public-key") ||
uri.startsWith("/api/point-goods") || uri.startsWith("/api/channels") ||
uri.startsWith("/api/sitemap.xml") || uri.startsWith("/api/medals") ||
uri.startsWith("/api/rss"));
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration cfg = new CorsConfiguration();
cfg.setAllowedOrigins(
List.of(
"http://127.0.0.1:8080",
"http://127.0.0.1:8081",
"http://127.0.0.1:8082",
"http://127.0.0.1:3000",
"http://127.0.0.1:3001",
"http://127.0.0.1",
"http://localhost:8080",
"http://localhost:8081",
"http://localhost:8082",
"http://localhost:3000",
"http://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.90",
"http://192.168.7.90:3000",
"https://petstore.swagger.io",
// 允许自建OpenAPI地址
"https://docs.open-isle.com",
"https://www.docs.open-isle.com",
websiteUrl,
websiteUrl.replace("://www.", "://")
)
);
cfg.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
cfg.setAllowedHeaders(List.of("*"));
cfg.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", cfg);
return source;
}
if (authHeader != null && authHeader.startsWith("Bearer ")) {
String token = authHeader.substring(7);
try {
String username = jwtService.validateAndGetSubject(token);
UserDetails userDetails = userDetailsService().loadUserByUsername(username);
UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities());
org.springframework.security.core.context.SecurityContextHolder.getContext().setAuthentication(authToken);
} catch (Exception e) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json");
response.getWriter().write("{\"error\": \"Invalid or expired token\"}");
return;
}
} else if (!uri.startsWith("/api/auth") && !publicGet
&& !uri.startsWith("/api/ws") && !uri.startsWith("/api/sockjs")
&& !uri.startsWith("/api/v3/api-docs")
&& !uri.startsWith("/api/online")) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json");
response.getWriter().write("{\"error\": \"Missing token\"}");
return;
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.cors(Customizer.withDefaults())
.headers(h -> h.frameOptions(f -> f.sameOrigin()))
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.exceptionHandling(eh -> eh.accessDeniedHandler(customAccessDeniedHandler))
.authorizeHttpRequests(auth ->
auth
.requestMatchers(HttpMethod.OPTIONS, "/**")
.permitAll()
.requestMatchers("/api/ws/**", "/api/sockjs/**")
.permitAll()
.requestMatchers("/api/v3/api-docs/**")
.permitAll()
.requestMatchers(HttpMethod.POST, "/api/auth/**")
.permitAll()
.requestMatchers(HttpMethod.GET, "/api/posts/**")
.permitAll()
.requestMatchers(HttpMethod.GET, "/api/comments/**")
.permitAll()
.requestMatchers(HttpMethod.GET, "/api/categories/**")
.permitAll()
.requestMatchers(HttpMethod.GET, "/api/tags/**")
.permitAll()
.requestMatchers(HttpMethod.GET, "/api/config/**")
.permitAll()
.requestMatchers(HttpMethod.POST, "/api/auth/google")
.permitAll()
.requestMatchers(HttpMethod.POST, "/api/auth/reason")
.permitAll()
.requestMatchers(HttpMethod.GET, "/api/search/**")
.permitAll()
.requestMatchers(HttpMethod.GET, "/api/users/**")
.permitAll()
.requestMatchers(HttpMethod.GET, "/api/medals/**")
.permitAll()
.requestMatchers(HttpMethod.GET, "/api/push/public-key")
.permitAll()
.requestMatchers(HttpMethod.GET, "/api/reaction-types")
.permitAll()
.requestMatchers(HttpMethod.GET, "/api/activities/**")
.permitAll()
.requestMatchers(HttpMethod.GET, "/api/sitemap.xml")
.permitAll()
.requestMatchers(HttpMethod.GET, "/api/channels")
.permitAll()
.requestMatchers(HttpMethod.GET, "/api/rss")
.permitAll()
.requestMatchers(HttpMethod.GET, "/api/online/**")
.permitAll()
.requestMatchers(HttpMethod.POST, "/api/online/**")
.permitAll()
.requestMatchers(HttpMethod.GET, "/api/point-goods")
.permitAll()
.requestMatchers(HttpMethod.POST, "/api/point-goods")
.permitAll()
.requestMatchers("/actuator/**")
.permitAll()
.requestMatchers(HttpMethod.POST, "/api/categories/**")
.hasAuthority("ADMIN")
.requestMatchers(HttpMethod.POST, "/api/tags/**")
.authenticated()
.requestMatchers(HttpMethod.DELETE, "/api/categories/**")
.hasAuthority("ADMIN")
.requestMatchers(HttpMethod.DELETE, "/api/tags/**")
.hasAuthority("ADMIN")
.requestMatchers(HttpMethod.GET, "/api/stats/**")
.hasAuthority("ADMIN")
.requestMatchers("/api/admin/**")
.hasAuthority("ADMIN")
.anyRequest()
.authenticated()
)
.addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class)
.addFilterAfter(userVisitFilter(), UsernamePasswordAuthenticationFilter.class);
return http.build();
}
filterChain.doFilter(request, response);
}
};
}
@Bean
public OncePerRequestFilter jwtAuthenticationFilter() {
return new OncePerRequestFilter() {
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain
) throws ServletException, IOException {
// 让预检请求直接通过
if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
filterChain.doFilter(request, response);
return;
}
String authHeader = request.getHeader("Authorization");
String uri = request.getRequestURI();
@Bean
public OncePerRequestFilter userVisitFilter() {
return new OncePerRequestFilter() {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
var auth = org.springframework.security.core.context.SecurityContextHolder.getContext().getAuthentication();
if (auth != null && auth.isAuthenticated() && !(auth instanceof org.springframework.security.authentication.AnonymousAuthenticationToken)) {
userVisitService.recordVisit(auth.getName());
}
filterChain.doFilter(request, response);
}
};
}
boolean publicGet =
"GET".equalsIgnoreCase(request.getMethod()) &&
(uri.startsWith("/api/posts") ||
uri.startsWith("/api/comments") ||
uri.startsWith("/api/categories") ||
uri.startsWith("/api/tags") ||
uri.startsWith("/api/search") ||
uri.startsWith("/api/users") ||
uri.startsWith("/api/reaction-types") ||
uri.startsWith("/api/config") ||
uri.startsWith("/api/activities") ||
uri.startsWith("/api/push/public-key") ||
uri.startsWith("/api/point-goods") ||
uri.startsWith("/api/channels") ||
uri.startsWith("/api/sitemap.xml") ||
uri.startsWith("/api/medals") ||
uri.startsWith("/actuator") ||
uri.startsWith("/api/rss"));
if (authHeader != null && authHeader.startsWith("Bearer ")) {
String token = authHeader.substring(7);
try {
String username = jwtService.validateAndGetSubject(token);
UserDetails userDetails = userDetailsService().loadUserByUsername(username);
UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
userDetails,
null,
userDetails.getAuthorities()
);
org.springframework.security.core.context.SecurityContextHolder.getContext().setAuthentication(
authToken
);
} catch (Exception e) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json");
response.getWriter().write("{\"error\": \"Invalid or expired token\"}");
return;
}
} else if (
!uri.startsWith("/api/auth") &&
!publicGet &&
!uri.startsWith("/api/ws") &&
!uri.startsWith("/api/sockjs") &&
!uri.startsWith("/api/v3/api-docs") &&
!uri.startsWith("/api/online")
) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json");
response.getWriter().write("{\"error\": \"Missing token\"}");
return;
}
filterChain.doFilter(request, response);
}
};
}
@Bean
public OncePerRequestFilter userVisitFilter() {
return new OncePerRequestFilter() {
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain
) throws ServletException, IOException {
var auth =
org.springframework.security.core.context.SecurityContextHolder.getContext().getAuthentication();
if (
auth != null &&
auth.isAuthenticated() &&
!(auth instanceof
org.springframework.security.authentication.AnonymousAuthenticationToken)
) {
String key = CachingConfig.VISIT_CACHE_NAME + ":" + LocalDate.now();
redisTemplate.opsForSet().add(key, auth.getName());
}
filterChain.doFilter(request, response);
}
};
}
}

View File

@@ -8,7 +8,8 @@ import lombok.NoArgsConstructor;
@AllArgsConstructor
@NoArgsConstructor
public class ShardInfo {
private int shardIndex;
private String queueName;
private String routingKey;
}
private int shardIndex;
private String queueName;
private String routingKey;
}

View File

@@ -1,84 +1,87 @@
package com.openisle.config;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
import java.util.stream.Collectors;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
import java.util.Map;
import java.util.stream.Collectors;
@Component
@Slf4j
public class ShardingStrategy {
// 固定为16以匹配RabbitMQConfig中的十六进制分片逻辑
private static final int QUEUE_COUNT = 16;
// 分片分布统计
private final Map<Integer, AtomicLong> shardCounts = new ConcurrentHashMap<>();
/**
* 根据用户名获取分片信息(基于哈希值首字符)
*/
public ShardInfo getShardInfo(String username) {
if (username == null || username.isEmpty()) {
// 空用户名默认分到第0个分片
return getShardInfoByIndex(0);
}
// 计算用户名的哈希值并转为十六进制字符串
String hash = Integer.toHexString(Math.abs(username.hashCode()));
// 取哈希值的第一个字符 (0-9, a-f)
char firstChar = hash.charAt(0);
// 十六进制字符映射到队列
int shard = getShardFromHexChar(firstChar);
recordShardUsage(shard);
log.debug("Username '{}' -> hash '{}' -> firstChar '{}' -> shard {}",
username, hash, firstChar, shard);
return getShardInfoByIndex(shard);
}
/**
* 将十六进制字符映射到分片索引
*/
private int getShardFromHexChar(char hexChar) {
int charValue;
if (hexChar >= '0' && hexChar <= '9') {
charValue = hexChar - '0'; // 0-9
} else if (hexChar >= 'a' && hexChar <= 'f') {
charValue = hexChar - 'a' + 10; // 10-15
} else {
// 异常情况默认为0
charValue = 0;
}
// 映射到队列数量范围内
return charValue % QUEUE_COUNT;
}
/**
* 根据分片索引获取分片信息
*/
private ShardInfo getShardInfoByIndex(int shard) {
String shardKey = Integer.toHexString(shard);
return new ShardInfo(
shard,
"notifications-queue-" + shardKey,
"notifications.shard." + shardKey
);
}
/**
* 记录分片使用统计
*/
private void recordShardUsage(int shard) {
shardCounts.computeIfAbsent(shard, k -> new AtomicLong(0)).incrementAndGet();
// 固定为16以匹配RabbitMQConfig中的十六进制分片逻辑
private static final int QUEUE_COUNT = 16;
// 分片分布统计
private final Map<Integer, AtomicLong> shardCounts = new ConcurrentHashMap<>();
/**
* 根据用户名获取分片信息(基于哈希值首字符)
*/
public ShardInfo getShardInfo(String username) {
if (username == null || username.isEmpty()) {
// 空用户名默认分到第0个分片
return getShardInfoByIndex(0);
}
}
// 计算用户名的哈希值并转为十六进制字符串
String hash = Integer.toHexString(Math.abs(username.hashCode()));
// 取哈希值的第一个字符 (0-9, a-f)
char firstChar = hash.charAt(0);
// 十六进制字符映射到队列
int shard = getShardFromHexChar(firstChar);
recordShardUsage(shard);
log.debug(
"Username '{}' -> hash '{}' -> firstChar '{}' -> shard {}",
username,
hash,
firstChar,
shard
);
return getShardInfoByIndex(shard);
}
/**
* 将十六进制字符映射到分片索引
*/
private int getShardFromHexChar(char hexChar) {
int charValue;
if (hexChar >= '0' && hexChar <= '9') {
charValue = hexChar - '0'; // 0-9
} else if (hexChar >= 'a' && hexChar <= 'f') {
charValue = hexChar - 'a' + 10; // 10-15
} else {
// 异常情况默认为0
charValue = 0;
}
// 映射到队列数量范围内
return charValue % QUEUE_COUNT;
}
/**
* 根据分片索引获取分片信息
*/
private ShardInfo getShardInfoByIndex(int shard) {
String shardKey = Integer.toHexString(shard);
return new ShardInfo(
shard,
"notifications-queue-" + shardKey,
"notifications.shard." + shardKey
);
}
/**
* 记录分片使用统计
*/
private void recordShardUsage(int shard) {
shardCounts.computeIfAbsent(shard, k -> new AtomicLong(0)).incrementAndGet();
}
}

View File

@@ -0,0 +1,22 @@
package com.openisle.config;
import java.util.ArrayList;
import java.util.List;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
@Data
@Component
@ConfigurationProperties(prefix = "springdoc.api-docs")
public class SpringDocProperties {
private List<ServerConfig> servers = new ArrayList<>();
@Data
public static class ServerConfig {
private String url;
private String description;
}
}

View File

@@ -0,0 +1,40 @@
package com.openisle.config;
import com.openisle.model.Role;
import com.openisle.model.User;
import com.openisle.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.boot.CommandLineRunner;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Component;
/**
* Ensure a dedicated "system" user exists for internal operations.
*/
@Component
@RequiredArgsConstructor
public class SystemUserInitializer implements CommandLineRunner {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
@Override
public void run(String... args) {
userRepository
.findByUsername("system")
.orElseGet(() -> {
User system = new User();
system.setUsername("system");
system.setEmail("system@openisle.local");
// todo(tim): raw password 采用环境变量
system.setPassword(passwordEncoder.encode("system"));
system.setRole(Role.USER);
system.setVerified(true);
system.setApproved(true);
system.setAvatar(
"https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/image.png"
);
return userRepository.save(system);
});
}
}

View File

@@ -9,49 +9,75 @@ import com.openisle.model.ActivityType;
import com.openisle.model.User;
import com.openisle.service.ActivityService;
import com.openisle.service.UserService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.ArraySchema;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import java.util.List;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.stream.Collectors;
@RestController
@RequestMapping("/api/activities")
@RequiredArgsConstructor
public class ActivityController {
private final ActivityService activityService;
private final UserService userService;
private final ActivityMapper activityMapper;
@GetMapping
public List<ActivityDto> list() {
return activityService.list().stream()
.map(activityMapper::toDto)
.collect(Collectors.toList());
}
private final ActivityService activityService;
private final UserService userService;
private final ActivityMapper activityMapper;
@GetMapping("/milk-tea")
public MilkTeaInfoDto milkTea() {
Activity a = activityService.getByType(ActivityType.MILK_TEA);
long count = activityService.countParticipants(a);
if (!a.isEnded() && count >= 50) {
activityService.end(a);
}
MilkTeaInfoDto info = new MilkTeaInfoDto();
info.setRedeemCount(count);
info.setEnded(a.isEnded());
return info;
}
@GetMapping
@Operation(summary = "List activities", description = "Retrieve all activities")
@ApiResponse(
responseCode = "200",
description = "List of activities",
content = @Content(array = @ArraySchema(schema = @Schema(implementation = ActivityDto.class)))
)
public List<ActivityDto> list() {
return activityService.list().stream().map(activityMapper::toDto).collect(Collectors.toList());
}
@PostMapping("/milk-tea/redeem")
public java.util.Map<String, String> redeemMilkTea(@RequestBody MilkTeaRedeemRequest req, Authentication auth) {
User user = userService.findByIdentifier(auth.getName()).orElseThrow();
Activity a = activityService.getByType(ActivityType.MILK_TEA);
boolean first = activityService.redeem(a, user, req.getContact());
if (first) {
return java.util.Map.of("message", "redeemed");
}
return java.util.Map.of("message", "updated");
@GetMapping("/milk-tea")
@Operation(summary = "Milk tea info", description = "Get milk tea activity information")
@ApiResponse(
responseCode = "200",
description = "Milk tea info",
content = @Content(schema = @Schema(implementation = MilkTeaInfoDto.class))
)
public MilkTeaInfoDto milkTea() {
Activity a = activityService.getByType(ActivityType.MILK_TEA);
long count = activityService.countParticipants(a);
if (!a.isEnded() && count >= 50) {
activityService.end(a);
}
MilkTeaInfoDto info = new MilkTeaInfoDto();
info.setRedeemCount(count);
info.setEnded(a.isEnded());
return info;
}
@PostMapping("/milk-tea/redeem")
@Operation(summary = "Redeem milk tea", description = "Redeem milk tea activity reward")
@ApiResponse(
responseCode = "200",
description = "Redeem result",
content = @Content(schema = @Schema(implementation = java.util.Map.class))
)
@SecurityRequirement(name = "JWT")
public java.util.Map<String, String> redeemMilkTea(
@RequestBody MilkTeaRedeemRequest req,
Authentication auth
) {
User user = userService.findByIdentifier(auth.getName()).orElseThrow();
Activity a = activityService.getByType(ActivityType.MILK_TEA);
boolean first = activityService.redeem(a, user, req.getContact());
if (first) {
return java.util.Map.of("message", "redeemed");
}
return java.util.Map.of("message", "updated");
}
}

View File

@@ -3,6 +3,11 @@ package com.openisle.controller;
import com.openisle.dto.CommentDto;
import com.openisle.mapper.CommentMapper;
import com.openisle.service.CommentService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
@@ -14,16 +19,31 @@ import org.springframework.web.bind.annotation.*;
@RequestMapping("/api/admin/comments")
@RequiredArgsConstructor
public class AdminCommentController {
private final CommentService commentService;
private final CommentMapper commentMapper;
@PostMapping("/{id}/pin")
public CommentDto pin(@PathVariable Long id, Authentication auth) {
return commentMapper.toDto(commentService.pinComment(auth.getName(), id));
}
private final CommentService commentService;
private final CommentMapper commentMapper;
@PostMapping("/{id}/unpin")
public CommentDto unpin(@PathVariable Long id, Authentication auth) {
return commentMapper.toDto(commentService.unpinComment(auth.getName(), id));
}
@PostMapping("/{id}/pin")
@SecurityRequirement(name = "JWT")
@Operation(summary = "Pin comment", description = "Pin a comment by its id")
@ApiResponse(
responseCode = "200",
description = "Pinned comment",
content = @Content(schema = @Schema(implementation = CommentDto.class))
)
public CommentDto pin(@PathVariable Long id, Authentication auth) {
return commentMapper.toDto(commentService.pinComment(auth.getName(), id));
}
@PostMapping("/{id}/unpin")
@SecurityRequirement(name = "JWT")
@Operation(summary = "Unpin comment", description = "Remove pin from a comment")
@ApiResponse(
responseCode = "200",
description = "Unpinned comment",
content = @Content(schema = @Schema(implementation = CommentDto.class))
)
public CommentDto unpin(@PathVariable Long id, Authentication auth) {
return commentMapper.toDto(commentService.unpinComment(auth.getName(), id));
}
}

View File

@@ -5,6 +5,11 @@ import com.openisle.service.AiUsageService;
import com.openisle.service.PasswordValidator;
import com.openisle.service.PostService;
import com.openisle.service.RegisterModeService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
@@ -12,36 +17,56 @@ import org.springframework.web.bind.annotation.*;
@RequestMapping("/api/admin/config")
@RequiredArgsConstructor
public class AdminConfigController {
private final PostService postService;
private final PasswordValidator passwordValidator;
private final AiUsageService aiUsageService;
private final RegisterModeService registerModeService;
@GetMapping
public ConfigDto getConfig() {
ConfigDto dto = new ConfigDto();
dto.setPublishMode(postService.getPublishMode());
dto.setPasswordStrength(passwordValidator.getStrength());
dto.setAiFormatLimit(aiUsageService.getFormatLimit());
dto.setRegisterMode(registerModeService.getRegisterMode());
return dto;
private final PostService postService;
private final PasswordValidator passwordValidator;
private final AiUsageService aiUsageService;
private final RegisterModeService registerModeService;
@GetMapping
@SecurityRequirement(name = "JWT")
@Operation(
summary = "Get configuration",
description = "Retrieve application configuration settings"
)
@ApiResponse(
responseCode = "200",
description = "Current configuration",
content = @Content(schema = @Schema(implementation = ConfigDto.class))
)
public ConfigDto getConfig() {
ConfigDto dto = new ConfigDto();
dto.setPublishMode(postService.getPublishMode());
dto.setPasswordStrength(passwordValidator.getStrength());
dto.setAiFormatLimit(aiUsageService.getFormatLimit());
dto.setRegisterMode(registerModeService.getRegisterMode());
return dto;
}
@PostMapping
@SecurityRequirement(name = "JWT")
@Operation(
summary = "Update configuration",
description = "Update application configuration settings"
)
@ApiResponse(
responseCode = "200",
description = "Updated configuration",
content = @Content(schema = @Schema(implementation = ConfigDto.class))
)
public ConfigDto updateConfig(@RequestBody ConfigDto dto) {
if (dto.getPublishMode() != null) {
postService.setPublishMode(dto.getPublishMode());
}
@PostMapping
public ConfigDto updateConfig(@RequestBody ConfigDto dto) {
if (dto.getPublishMode() != null) {
postService.setPublishMode(dto.getPublishMode());
}
if (dto.getPasswordStrength() != null) {
passwordValidator.setStrength(dto.getPasswordStrength());
}
if (dto.getAiFormatLimit() != null) {
aiUsageService.setFormatLimit(dto.getAiFormatLimit());
}
if (dto.getRegisterMode() != null) {
registerModeService.setRegisterMode(dto.getRegisterMode());
}
return getConfig();
if (dto.getPasswordStrength() != null) {
passwordValidator.setStrength(dto.getPasswordStrength());
}
if (dto.getAiFormatLimit() != null) {
aiUsageService.setFormatLimit(dto.getAiFormatLimit());
}
if (dto.getRegisterMode() != null) {
registerModeService.setRegisterMode(dto.getRegisterMode());
}
return getConfig();
}
}

View File

@@ -1,16 +1,29 @@
package com.openisle.controller;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import java.util.Map;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
/**
* Simple admin demo endpoint.
*/
@RestController
public class AdminController {
@GetMapping("/api/admin/hello")
public Map<String, String> adminHello() {
return Map.of("message", "Hello, Admin User");
}
@GetMapping("/api/admin/hello")
@SecurityRequirement(name = "JWT")
@Operation(summary = "Admin greeting", description = "Returns a greeting for admin users")
@ApiResponse(
responseCode = "200",
description = "Greeting payload",
content = @Content(schema = @Schema(implementation = Map.class))
)
public Map<String, String> adminHello() {
return Map.of("message", "Hello, Admin User");
}
}

View File

@@ -3,11 +3,16 @@ package com.openisle.controller;
import com.openisle.dto.PostSummaryDto;
import com.openisle.mapper.PostMapper;
import com.openisle.service.PostService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.ArraySchema;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import java.util.List;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
/**
* Endpoints for administrators to manage posts.
@@ -16,43 +21,109 @@ import java.util.stream.Collectors;
@RequestMapping("/api/admin/posts")
@RequiredArgsConstructor
public class AdminPostController {
private final PostService postService;
private final PostMapper postMapper;
@GetMapping("/pending")
public List<PostSummaryDto> pendingPosts() {
return postService.listPendingPosts().stream()
.map(postMapper::toSummaryDto)
.collect(Collectors.toList());
}
private final PostService postService;
private final PostMapper postMapper;
@PostMapping("/{id}/approve")
public PostSummaryDto approve(@PathVariable Long id) {
return postMapper.toSummaryDto(postService.approvePost(id));
}
@GetMapping("/pending")
@SecurityRequirement(name = "JWT")
@Operation(summary = "List pending posts", description = "Retrieve posts awaiting approval")
@ApiResponse(
responseCode = "200",
description = "Pending posts",
content = @Content(
array = @ArraySchema(schema = @Schema(implementation = PostSummaryDto.class))
)
)
public List<PostSummaryDto> pendingPosts() {
return postService
.listPendingPosts()
.stream()
.map(postMapper::toSummaryDto)
.collect(Collectors.toList());
}
@PostMapping("/{id}/reject")
public PostSummaryDto reject(@PathVariable Long id) {
return postMapper.toSummaryDto(postService.rejectPost(id));
}
@PostMapping("/{id}/approve")
@SecurityRequirement(name = "JWT")
@Operation(summary = "Approve post", description = "Approve a pending post")
@ApiResponse(
responseCode = "200",
description = "Approved post",
content = @Content(schema = @Schema(implementation = PostSummaryDto.class))
)
public PostSummaryDto approve(@PathVariable Long id) {
return postMapper.toSummaryDto(postService.approvePost(id));
}
@PostMapping("/{id}/pin")
public PostSummaryDto pin(@PathVariable Long id) {
return postMapper.toSummaryDto(postService.pinPost(id));
}
@PostMapping("/{id}/reject")
@SecurityRequirement(name = "JWT")
@Operation(summary = "Reject post", description = "Reject a pending post")
@ApiResponse(
responseCode = "200",
description = "Rejected post",
content = @Content(schema = @Schema(implementation = PostSummaryDto.class))
)
public PostSummaryDto reject(@PathVariable Long id) {
return postMapper.toSummaryDto(postService.rejectPost(id));
}
@PostMapping("/{id}/unpin")
public PostSummaryDto unpin(@PathVariable Long id) {
return postMapper.toSummaryDto(postService.unpinPost(id));
}
@PostMapping("/{id}/pin")
@SecurityRequirement(name = "JWT")
@Operation(summary = "Pin post", description = "Pin a post to the top")
@ApiResponse(
responseCode = "200",
description = "Pinned post",
content = @Content(schema = @Schema(implementation = PostSummaryDto.class))
)
public PostSummaryDto pin(
@PathVariable Long id,
org.springframework.security.core.Authentication auth
) {
return postMapper.toSummaryDto(postService.pinPost(id, auth.getName()));
}
@PostMapping("/{id}/rss-exclude")
public PostSummaryDto excludeFromRss(@PathVariable Long id) {
return postMapper.toSummaryDto(postService.excludeFromRss(id));
}
@PostMapping("/{id}/unpin")
@SecurityRequirement(name = "JWT")
@Operation(summary = "Unpin post", description = "Remove a post from the top")
@ApiResponse(
responseCode = "200",
description = "Unpinned post",
content = @Content(schema = @Schema(implementation = PostSummaryDto.class))
)
public PostSummaryDto unpin(
@PathVariable Long id,
org.springframework.security.core.Authentication auth
) {
return postMapper.toSummaryDto(postService.unpinPost(id, auth.getName()));
}
@PostMapping("/{id}/rss-include")
public PostSummaryDto includeInRss(@PathVariable Long id) {
return postMapper.toSummaryDto(postService.includeInRss(id));
}
@PostMapping("/{id}/rss-exclude")
@SecurityRequirement(name = "JWT")
@Operation(summary = "Exclude from RSS", description = "Exclude a post from RSS feed")
@ApiResponse(
responseCode = "200",
description = "Updated post",
content = @Content(schema = @Schema(implementation = PostSummaryDto.class))
)
public PostSummaryDto excludeFromRss(
@PathVariable Long id,
org.springframework.security.core.Authentication auth
) {
return postMapper.toSummaryDto(postService.excludeFromRss(id, auth.getName()));
}
@PostMapping("/{id}/rss-include")
@SecurityRequirement(name = "JWT")
@Operation(summary = "Include in RSS", description = "Include a post in the RSS feed")
@ApiResponse(
responseCode = "200",
description = "Updated post",
content = @Content(schema = @Schema(implementation = PostSummaryDto.class))
)
public PostSummaryDto includeInRss(
@PathVariable Long id,
org.springframework.security.core.Authentication auth
) {
return postMapper.toSummaryDto(postService.includeInRss(id, auth.getName()));
}
}

View File

@@ -5,31 +5,53 @@ import com.openisle.mapper.TagMapper;
import com.openisle.model.Tag;
import com.openisle.service.PostService;
import com.openisle.service.TagService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.ArraySchema;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import java.util.List;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/admin/tags")
@RequiredArgsConstructor
public class AdminTagController {
private final TagService tagService;
private final PostService postService;
private final TagMapper tagMapper;
@GetMapping("/pending")
public List<TagDto> pendingTags() {
return tagService.listPendingTags().stream()
.map(t -> tagMapper.toDto(t, postService.countPostsByTag(t.getId())))
.collect(Collectors.toList());
}
private final TagService tagService;
private final PostService postService;
private final TagMapper tagMapper;
@PostMapping("/{id}/approve")
public TagDto approve(@PathVariable Long id) {
Tag tag = tagService.approveTag(id);
long count = postService.countPostsByTag(tag.getId());
return tagMapper.toDto(tag, count);
}
@GetMapping("/pending")
@SecurityRequirement(name = "JWT")
@Operation(summary = "List pending tags", description = "Retrieve tags awaiting approval")
@ApiResponse(
responseCode = "200",
description = "Pending tags",
content = @Content(array = @ArraySchema(schema = @Schema(implementation = TagDto.class)))
)
public List<TagDto> pendingTags() {
return tagService
.listPendingTags()
.stream()
.map(t -> tagMapper.toDto(t, postService.countPostsByTag(t.getId())))
.collect(Collectors.toList());
}
@PostMapping("/{id}/approve")
@SecurityRequirement(name = "JWT")
@Operation(summary = "Approve tag", description = "Approve a pending tag")
@ApiResponse(
responseCode = "200",
description = "Approved tag",
content = @Content(schema = @Schema(implementation = TagDto.class))
)
public TagDto approve(@PathVariable Long id) {
Tag tag = tagService.approveTag(id);
long count = postService.countPostsByTag(tag.getId());
return tagMapper.toDto(tag, count);
}
}

View File

@@ -3,9 +3,12 @@ package com.openisle.controller;
import com.openisle.model.Notification;
import com.openisle.model.NotificationType;
import com.openisle.model.User;
import com.openisle.service.EmailSender;
import com.openisle.repository.NotificationRepository;
import com.openisle.repository.UserRepository;
import com.openisle.service.EmailSender;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity;
@@ -15,40 +18,56 @@ import org.springframework.web.bind.annotation.*;
@RequestMapping("/api/admin/users")
@RequiredArgsConstructor
public class AdminUserController {
private final UserRepository userRepository;
private final NotificationRepository notificationRepository;
private final EmailSender emailSender;
@Value("${app.website-url}")
private String websiteUrl;
@PostMapping("/{id}/approve")
public ResponseEntity<?> approve(@PathVariable Long id) {
User user = userRepository.findById(id).orElseThrow();
user.setApproved(true);
userRepository.save(user);
markRegisterRequestNotificationsRead(user);
emailSender.sendEmail(user.getEmail(), "您的注册已审核通过",
"🎉您的注册已经审核通过, 点击以访问网站: " + websiteUrl);
return ResponseEntity.ok().build();
}
private final UserRepository userRepository;
private final NotificationRepository notificationRepository;
private final EmailSender emailSender;
@PostMapping("/{id}/reject")
public ResponseEntity<?> reject(@PathVariable Long id) {
User user = userRepository.findById(id).orElseThrow();
user.setApproved(false);
userRepository.save(user);
markRegisterRequestNotificationsRead(user);
emailSender.sendEmail(user.getEmail(), "您的注册已被管理员拒绝",
"您的注册被管理员拒绝, 点击链接可以重新填写理由申请: " + websiteUrl);
return ResponseEntity.ok().build();
}
@Value("${app.website-url}")
private String websiteUrl;
private void markRegisterRequestNotificationsRead(User applicant) {
java.util.List<Notification> notifs =
notificationRepository.findByTypeAndFromUser(NotificationType.REGISTER_REQUEST, applicant);
for (Notification n : notifs) {
n.setRead(true);
}
notificationRepository.saveAll(notifs);
@PostMapping("/{id}/approve")
@SecurityRequirement(name = "JWT")
@Operation(summary = "Approve user", description = "Approve a pending user registration")
@ApiResponse(responseCode = "200", description = "User approved")
public ResponseEntity<?> approve(@PathVariable Long id) {
User user = userRepository.findById(id).orElseThrow();
user.setApproved(true);
userRepository.save(user);
markRegisterRequestNotificationsRead(user);
emailSender.sendEmail(
user.getEmail(),
"您的注册已审核通过",
"🎉您的注册已经审核通过, 点击以访问网站: " + websiteUrl
);
return ResponseEntity.ok().build();
}
@PostMapping("/{id}/reject")
@SecurityRequirement(name = "JWT")
@Operation(summary = "Reject user", description = "Reject a pending user registration")
@ApiResponse(responseCode = "200", description = "User rejected")
public ResponseEntity<?> reject(@PathVariable Long id) {
User user = userRepository.findById(id).orElseThrow();
user.setApproved(false);
userRepository.save(user);
markRegisterRequestNotificationsRead(user);
emailSender.sendEmail(
user.getEmail(),
"您的注册已被管理员拒绝",
"您的注册被管理员拒绝, 点击链接可以重新填写理由申请: " + websiteUrl
);
return ResponseEntity.ok().build();
}
private void markRegisterRequestNotificationsRead(User applicant) {
java.util.List<Notification> notifs = notificationRepository.findByTypeAndFromUser(
NotificationType.REGISTER_REQUEST,
applicant
);
for (Notification n : notifs) {
n.setRead(true);
}
notificationRepository.saveAll(notifs);
}
}

View File

@@ -1,7 +1,13 @@
package com.openisle.controller;
import com.openisle.service.OpenAiService;
import com.openisle.service.AiUsageService;
import com.openisle.service.OpenAiService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
@@ -10,31 +16,39 @@ import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
@RestController
@RequestMapping("/api/ai")
@RequiredArgsConstructor
public class AiController {
private final OpenAiService openAiService;
private final AiUsageService aiUsageService;
private final OpenAiService openAiService;
private final AiUsageService aiUsageService;
@PostMapping("/format")
public ResponseEntity<Map<String, String>> format(@RequestBody Map<String, String> req,
Authentication auth) {
String text = req.get("text");
if (text == null) {
return ResponseEntity.badRequest().build();
}
int limit = aiUsageService.getFormatLimit();
int used = aiUsageService.getCount(auth.getName());
if (limit > 0 && used >= limit) {
return ResponseEntity.status(429).build();
}
aiUsageService.incrementAndGetCount(auth.getName());
return openAiService.formatMarkdown(text)
.map(t -> ResponseEntity.ok(Map.of("content", t)))
.orElse(ResponseEntity.status(500).build());
@PostMapping("/format")
@Operation(summary = "Format markdown", description = "Format text via AI")
@ApiResponse(
responseCode = "200",
description = "Formatted content",
content = @Content(schema = @Schema(implementation = Map.class))
)
@SecurityRequirement(name = "JWT")
public ResponseEntity<Map<String, String>> format(
@RequestBody Map<String, String> req,
Authentication auth
) {
String text = req.get("text");
if (text == null) {
return ResponseEntity.badRequest().build();
}
int limit = aiUsageService.getFormatLimit();
int used = aiUsageService.getCount(auth.getName());
if (limit > 0 && used >= limit) {
return ResponseEntity.status(429).build();
}
aiUsageService.incrementAndGetCount(auth.getName());
return openAiService
.formatMarkdown(text)
.map(t -> ResponseEntity.ok(Map.of("content", t)))
.orElse(ResponseEntity.status(500).build());
}
}

View File

File diff suppressed because it is too large Load Diff

View File

@@ -8,66 +8,120 @@ import com.openisle.mapper.PostMapper;
import com.openisle.model.Category;
import com.openisle.service.CategoryService;
import com.openisle.service.PostService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.ArraySchema;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/categories")
@RequiredArgsConstructor
public class CategoryController {
private final CategoryService categoryService;
private final PostService postService;
private final PostMapper postMapper;
private final CategoryMapper categoryMapper;
@PostMapping
public CategoryDto create(@RequestBody CategoryRequest req) {
Category c = categoryService.createCategory(req.getName(), req.getDescription(), req.getIcon(), req.getSmallIcon());
long count = postService.countPostsByCategory(c.getId());
return categoryMapper.toDto(c, count);
}
private final CategoryService categoryService;
private final PostService postService;
private final PostMapper postMapper;
private final CategoryMapper categoryMapper;
@PutMapping("/{id}")
public CategoryDto update(@PathVariable Long id, @RequestBody CategoryRequest req) {
Category c = categoryService.updateCategory(id, req.getName(), req.getDescription(), req.getIcon(), req.getSmallIcon());
long count = postService.countPostsByCategory(c.getId());
return categoryMapper.toDto(c, count);
}
@PostMapping
@Operation(summary = "Create category", description = "Create a new category")
@ApiResponse(
responseCode = "200",
description = "Created category",
content = @Content(schema = @Schema(implementation = CategoryDto.class))
)
public CategoryDto create(@RequestBody CategoryRequest req) {
Category c = categoryService.createCategory(
req.getName(),
req.getDescription(),
req.getIcon(),
req.getSmallIcon()
);
long count = postService.countPostsByCategory(c.getId());
return categoryMapper.toDto(c, count);
}
@DeleteMapping("/{id}")
public void delete(@PathVariable Long id) {
categoryService.deleteCategory(id);
}
@PutMapping("/{id}")
@Operation(summary = "Update category", description = "Update an existing category")
@ApiResponse(
responseCode = "200",
description = "Updated category",
content = @Content(schema = @Schema(implementation = CategoryDto.class))
)
public CategoryDto update(@PathVariable Long id, @RequestBody CategoryRequest req) {
Category c = categoryService.updateCategory(
id,
req.getName(),
req.getDescription(),
req.getIcon(),
req.getSmallIcon()
);
long count = postService.countPostsByCategory(c.getId());
return categoryMapper.toDto(c, count);
}
@GetMapping
public List<CategoryDto> list() {
List<Category> all = categoryService.listCategories();
List<Long> ids = all.stream().map(Category::getId).toList();
Map<Long, Long> postsCntByCategoryIds = postService.countPostsByCategoryIds(ids);
return all.stream()
.map(c -> categoryMapper.toDto(c, postsCntByCategoryIds.getOrDefault(c.getId(), 0L)))
.sorted((a, b) -> Long.compare(b.getCount(), a.getCount()))
.collect(Collectors.toList());
}
@DeleteMapping("/{id}")
@Operation(summary = "Delete category", description = "Remove a category by id")
@ApiResponse(responseCode = "200", description = "Category deleted")
public void delete(@PathVariable Long id) {
categoryService.deleteCategory(id);
}
@GetMapping("/{id}")
public CategoryDto get(@PathVariable Long id) {
Category c = categoryService.getCategory(id);
long count = postService.countPostsByCategory(c.getId());
return categoryMapper.toDto(c, count);
}
@GetMapping
@Operation(summary = "List categories", description = "Get all categories")
@ApiResponse(
responseCode = "200",
description = "List of categories",
content = @Content(array = @ArraySchema(schema = @Schema(implementation = CategoryDto.class)))
)
public List<CategoryDto> list() {
List<Category> all = categoryService.listCategories();
List<Long> ids = all.stream().map(Category::getId).toList();
Map<Long, Long> postsCntByCategoryIds = postService.countPostsByCategoryIds(ids);
return all
.stream()
.map(c -> categoryMapper.toDto(c, postsCntByCategoryIds.getOrDefault(c.getId(), 0L)))
.sorted((a, b) -> Long.compare(b.getCount(), a.getCount()))
.collect(Collectors.toList());
}
@GetMapping("/{id}/posts")
public List<PostSummaryDto> listPostsByCategory(@PathVariable Long id,
@RequestParam(value = "page", required = false) Integer page,
@RequestParam(value = "pageSize", required = false) Integer pageSize) {
return postService.listPostsByCategories(java.util.List.of(id), page, pageSize)
.stream()
.map(postMapper::toSummaryDto)
.collect(Collectors.toList());
}
@GetMapping("/{id}")
@Operation(summary = "Get category", description = "Get category by id")
@ApiResponse(
responseCode = "200",
description = "Category detail",
content = @Content(schema = @Schema(implementation = CategoryDto.class))
)
public CategoryDto get(@PathVariable Long id) {
Category c = categoryService.getCategory(id);
long count = postService.countPostsByCategory(c.getId());
return categoryMapper.toDto(c, count);
}
@GetMapping("/{id}/posts")
@Operation(summary = "List posts by category", description = "Get posts under a category")
@ApiResponse(
responseCode = "200",
description = "List of posts",
content = @Content(
array = @ArraySchema(schema = @Schema(implementation = PostSummaryDto.class))
)
)
public List<PostSummaryDto> listPostsByCategory(
@PathVariable Long id,
@RequestParam(value = "page", required = false) Integer page,
@RequestParam(value = "pageSize", required = false) Integer pageSize
) {
return postService
.listPostsByCategories(java.util.List.of(id), page, pageSize)
.stream()
.map(postMapper::toSummaryDto)
.collect(Collectors.toList());
}
}

View File

@@ -5,38 +5,66 @@ import com.openisle.model.User;
import com.openisle.repository.UserRepository;
import com.openisle.service.ChannelService;
import com.openisle.service.MessageService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.ArraySchema;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/channels")
@RequiredArgsConstructor
public class ChannelController {
private final ChannelService channelService;
private final MessageService messageService;
private final UserRepository userRepository;
private Long getCurrentUserId(Authentication auth) {
User user = userRepository.findByUsername(auth.getName())
.orElseThrow(() -> new IllegalArgumentException("User not found"));
return user.getId();
}
private final ChannelService channelService;
private final MessageService messageService;
private final UserRepository userRepository;
@GetMapping
public List<ChannelDto> listChannels(Authentication auth) {
return channelService.listChannels(getCurrentUserId(auth));
}
private Long getCurrentUserId(Authentication auth) {
User user = userRepository
.findByUsername(auth.getName())
.orElseThrow(() -> new IllegalArgumentException("User not found"));
return user.getId();
}
@PostMapping("/{channelId}/join")
public ChannelDto joinChannel(@PathVariable Long channelId, Authentication auth) {
return channelService.joinChannel(channelId, getCurrentUserId(auth));
}
@GetMapping
@Operation(summary = "List channels", description = "List channels for the current user")
@ApiResponse(
responseCode = "200",
description = "Channels",
content = @Content(array = @ArraySchema(schema = @Schema(implementation = ChannelDto.class)))
)
@SecurityRequirement(name = "JWT")
public List<ChannelDto> listChannels(Authentication auth) {
return channelService.listChannels(getCurrentUserId(auth));
}
@GetMapping("/unread-count")
public long unreadCount(Authentication auth) {
return messageService.getUnreadChannelCount(getCurrentUserId(auth));
}
@PostMapping("/{channelId}/join")
@Operation(summary = "Join channel", description = "Join a channel")
@ApiResponse(
responseCode = "200",
description = "Joined channel",
content = @Content(schema = @Schema(implementation = ChannelDto.class))
)
@SecurityRequirement(name = "JWT")
public ChannelDto joinChannel(@PathVariable Long channelId, Authentication auth) {
return channelService.joinChannel(channelId, getCurrentUserId(auth));
}
@GetMapping("/unread-count")
@Operation(summary = "Unread count", description = "Get unread channel count")
@ApiResponse(
responseCode = "200",
description = "Unread count",
content = @Content(schema = @Schema(implementation = Long.class))
)
@SecurityRequirement(name = "JWT")
public long unreadCount(Authentication auth) {
return messageService.getUnreadChannelCount(getCurrentUserId(auth));
}
}

View File

@@ -1,13 +1,27 @@
package com.openisle.controller;
import com.openisle.model.Comment;
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.service.CaptchaService;
import com.openisle.service.CommentService;
import com.openisle.service.LevelService;
import com.openisle.service.PointService;
import com.openisle.mapper.PostChangeLogMapper;
import com.openisle.mapper.PostMapper;
import com.openisle.model.Comment;
import com.openisle.model.CommentSort;
import com.openisle.service.*;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.ArraySchema;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
@@ -15,86 +29,228 @@ import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.stream.Collectors;
@RestController
@RequestMapping("/api")
@RequiredArgsConstructor
@Slf4j
public class CommentController {
private final CommentService commentService;
private final LevelService levelService;
private final CaptchaService captchaService;
private final CommentMapper commentMapper;
private final PointService pointService;
@Value("${app.captcha.enabled:false}")
private boolean captchaEnabled;
private final CommentService commentService;
private final LevelService levelService;
private final CaptchaService captchaService;
private final CommentMapper commentMapper;
private final PointService pointService;
private final PostChangeLogService changeLogService;
private final PostChangeLogMapper postChangeLogMapper;
private final PostMapper postMapper;
@Value("${app.captcha.comment-enabled:false}")
private boolean commentCaptchaEnabled;
@Value("${app.captcha.enabled:false}")
private boolean captchaEnabled;
@PostMapping("/posts/{postId}/comments")
public ResponseEntity<CommentDto> createComment(@PathVariable Long postId,
@RequestBody CommentRequest req,
Authentication auth) {
log.debug("createComment called by user {} for post {}", auth.getName(), postId);
if (captchaEnabled && commentCaptchaEnabled && !captchaService.verify(req.getCaptcha())) {
log.debug("Captcha verification failed for user {} on post {}", auth.getName(), postId);
return ResponseEntity.badRequest().build();
}
Comment comment = commentService.addComment(auth.getName(), postId, req.getContent());
CommentDto dto = commentMapper.toDto(comment);
dto.setReward(levelService.awardForComment(auth.getName()));
dto.setPointReward(pointService.awardForComment(auth.getName(), postId, comment.getId()));
log.debug("createComment succeeded for comment {}", comment.getId());
return ResponseEntity.ok(dto);
@Value("${app.captcha.comment-enabled:false}")
private boolean commentCaptchaEnabled;
@PostMapping("/posts/{postId}/comments")
@Operation(summary = "Create comment", description = "Add a comment to a post")
@ApiResponse(
responseCode = "200",
description = "Created comment",
content = @Content(schema = @Schema(implementation = CommentDto.class))
)
@SecurityRequirement(name = "JWT")
public ResponseEntity<CommentDto> createComment(
@PathVariable Long postId,
@RequestBody CommentRequest req,
Authentication auth
) {
log.debug("createComment called by user {} for post {}", auth.getName(), postId);
if (captchaEnabled && commentCaptchaEnabled && !captchaService.verify(req.getCaptcha())) {
log.debug("Captcha verification failed for user {} on post {}", auth.getName(), postId);
return ResponseEntity.badRequest().build();
}
Comment comment = commentService.addComment(auth.getName(), postId, req.getContent());
CommentDto dto = commentMapper.toDto(comment);
dto.setReward(levelService.awardForComment(auth.getName()));
dto.setPointReward(pointService.awardForComment(auth.getName(), postId, comment.getId()));
log.debug("createComment succeeded for comment {}", comment.getId());
return ResponseEntity.ok(dto);
}
@PostMapping("/comments/{commentId}/replies")
public ResponseEntity<CommentDto> replyComment(@PathVariable Long commentId,
@RequestBody CommentRequest req,
Authentication auth) {
log.debug("replyComment called by user {} for comment {}", auth.getName(), commentId);
if (captchaEnabled && commentCaptchaEnabled && !captchaService.verify(req.getCaptcha())) {
log.debug("Captcha verification failed for user {} on comment {}", auth.getName(), commentId);
return ResponseEntity.badRequest().build();
}
Comment comment = commentService.addReply(auth.getName(), commentId, req.getContent());
CommentDto dto = commentMapper.toDto(comment);
dto.setReward(levelService.awardForComment(auth.getName()));
log.debug("replyComment succeeded for comment {}", comment.getId());
return ResponseEntity.ok(dto);
@PostMapping("/comments/{commentId}/replies")
@Operation(summary = "Reply to comment", description = "Reply to an existing comment")
@ApiResponse(
responseCode = "200",
description = "Reply created",
content = @Content(schema = @Schema(implementation = CommentDto.class))
)
@SecurityRequirement(name = "JWT")
public ResponseEntity<CommentDto> replyComment(
@PathVariable Long commentId,
@RequestBody CommentRequest req,
Authentication auth
) {
log.debug("replyComment called by user {} for comment {}", auth.getName(), commentId);
if (captchaEnabled && commentCaptchaEnabled && !captchaService.verify(req.getCaptcha())) {
log.debug("Captcha verification failed for user {} on comment {}", auth.getName(), commentId);
return ResponseEntity.badRequest().build();
}
Comment comment = commentService.addReply(auth.getName(), commentId, req.getContent());
CommentDto dto = commentMapper.toDto(comment);
dto.setReward(levelService.awardForComment(auth.getName()));
log.debug("replyComment succeeded for comment {}", comment.getId());
return ResponseEntity.ok(dto);
}
@GetMapping("/posts/{postId}/comments")
public List<CommentDto> listComments(@PathVariable Long postId,
@RequestParam(value = "sort", required = false, defaultValue = "OLDEST") com.openisle.model.CommentSort sort) {
log.debug("listComments called for post {} with sort {}", postId, sort);
List<CommentDto> list = commentService.getCommentsForPost(postId, sort).stream()
.map(commentMapper::toDtoWithReplies)
.collect(Collectors.toList());
log.debug("listComments returning {} comments", list.size());
return list;
}
@GetMapping("/posts/{postId}/comments")
@Operation(summary = "List comments", description = "List comments for a post")
@ApiResponse(
responseCode = "200",
description = "Comments",
content = @Content(
array = @ArraySchema(schema = @Schema(implementation = TimelineItemDto.class))
)
)
public List<TimelineItemDto<?>> listComments(
@PathVariable Long postId,
@RequestParam(value = "sort", required = false, defaultValue = "OLDEST") CommentSort sort
) {
log.debug("listComments called for post {} with sort {}", postId, sort);
List<CommentDto> commentDtoList = commentService
.getCommentsForPost(postId, sort)
.stream()
.map(commentMapper::toDtoWithReplies)
.collect(Collectors.toList());
List<PostChangeLogDto> postChangeLogDtoList = changeLogService
.listLogs(postId)
.stream()
.map(postChangeLogMapper::toDto)
.collect(Collectors.toList());
List<TimelineItemDto<?>> itemDtoList = new ArrayList<>();
@DeleteMapping("/comments/{id}")
public void deleteComment(@PathVariable Long id, Authentication auth) {
log.debug("deleteComment called by user {} for comment {}", auth.getName(), id);
commentService.deleteComment(auth.getName(), id);
log.debug("deleteComment completed for comment {}", id);
}
itemDtoList.addAll(
commentDtoList
.stream()
.map(c ->
new TimelineItemDto<>(
c.getId(),
"comment",
c.getCreatedAt(),
c.getPinnedAt(),
c // payload 是 CommentDto
)
)
.toList()
);
@PostMapping("/comments/{id}/pin")
public CommentDto pinComment(@PathVariable Long id, Authentication auth) {
log.debug("pinComment called by user {} for comment {}", auth.getName(), id);
return commentMapper.toDto(commentService.pinComment(auth.getName(), id));
}
itemDtoList.addAll(
postChangeLogDtoList
.stream()
.map(l ->
new TimelineItemDto<>(
l.getId(),
"log",
l.getTime(), // 注意字段名不一样
null,
l // payload 是 PostChangeLogDto
)
)
.toList()
);
// 排序
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);
};
@PostMapping("/comments/{id}/unpin")
public CommentDto unpinComment(@PathVariable Long id, Authentication auth) {
log.debug("unpinComment called by user {} for comment {}", auth.getName(), id);
return commentMapper.toDto(commentService.unpinComment(auth.getName(), id));
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)) {
createdAtComparator = createdAtComparator.reversed();
}
itemDtoList.sort(comparator.thenComparing(createdAtComparator));
log.debug("listComments returning {} comments", itemDtoList.size());
return itemDtoList;
}
@GetMapping("/comments/{commentId}/context")
@Operation(
summary = "Comment context",
description = "Get a comment along with its previous comments and related post"
)
@ApiResponse(
responseCode = "200",
description = "Comment context",
content = @Content(schema = @Schema(implementation = CommentContextDto.class))
)
public ResponseEntity<CommentContextDto> getCommentContext(@PathVariable Long commentId) {
log.debug("getCommentContext called for comment {}", commentId);
Comment comment = commentService.getComment(commentId);
CommentContextDto dto = new CommentContextDto();
dto.setPost(postMapper.toSummaryDto(comment.getPost()));
dto.setTargetComment(commentMapper.toDtoWithReplies(comment));
dto.setPreviousComments(
commentService
.getCommentsBefore(comment)
.stream()
.map(commentMapper::toDtoWithReplies)
.collect(Collectors.toList())
);
log.debug(
"getCommentContext returning {} previous comments for comment {}",
dto.getPreviousComments().size(),
commentId
);
return ResponseEntity.ok(dto);
}
@DeleteMapping("/comments/{id}")
@Operation(summary = "Delete comment", description = "Delete a comment")
@ApiResponse(responseCode = "200", description = "Deleted")
@SecurityRequirement(name = "JWT")
public void deleteComment(@PathVariable Long id, Authentication auth) {
log.debug("deleteComment called by user {} for comment {}", auth.getName(), id);
commentService.deleteComment(auth.getName(), id);
log.debug("deleteComment completed for comment {}", id);
}
@PostMapping("/comments/{id}/pin")
@Operation(summary = "Pin comment", description = "Pin a comment")
@ApiResponse(
responseCode = "200",
description = "Pinned comment",
content = @Content(schema = @Schema(implementation = CommentDto.class))
)
@SecurityRequirement(name = "JWT")
public CommentDto pinComment(@PathVariable Long id, Authentication auth) {
log.debug("pinComment called by user {} for comment {}", auth.getName(), id);
return commentMapper.toDto(commentService.pinComment(auth.getName(), id));
}
@PostMapping("/comments/{id}/unpin")
@Operation(summary = "Unpin comment", description = "Unpin a comment")
@ApiResponse(
responseCode = "200",
description = "Unpinned comment",
content = @Content(schema = @Schema(implementation = CommentDto.class))
)
@SecurityRequirement(name = "JWT")
public CommentDto unpinComment(@PathVariable Long id, Authentication auth) {
log.debug("unpinComment called by user {} for comment {}", auth.getName(), id);
return commentMapper.toDto(commentService.unpinComment(auth.getName(), id));
}
}

View File

@@ -2,6 +2,10 @@ package com.openisle.controller;
import com.openisle.dto.SiteConfigDto;
import com.openisle.service.RegisterModeService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
@@ -12,36 +16,42 @@ import org.springframework.web.bind.annotation.RestController;
@lombok.RequiredArgsConstructor
public class ConfigController {
@Value("${app.captcha.enabled:false}")
private boolean captchaEnabled;
@Value("${app.captcha.enabled:false}")
private boolean captchaEnabled;
@Value("${app.captcha.register-enabled:false}")
private boolean registerCaptchaEnabled;
@Value("${app.captcha.register-enabled:false}")
private boolean registerCaptchaEnabled;
@Value("${app.captcha.login-enabled:false}")
private boolean loginCaptchaEnabled;
@Value("${app.captcha.login-enabled:false}")
private boolean loginCaptchaEnabled;
@Value("${app.captcha.post-enabled:false}")
private boolean postCaptchaEnabled;
@Value("${app.captcha.post-enabled:false}")
private boolean postCaptchaEnabled;
@Value("${app.captcha.comment-enabled:false}")
private boolean commentCaptchaEnabled;
@Value("${app.captcha.comment-enabled:false}")
private boolean commentCaptchaEnabled;
@Value("${app.ai.format-limit:3}")
private int aiFormatLimit;
@Value("${app.ai.format-limit:3}")
private int aiFormatLimit;
private final RegisterModeService registerModeService;
private final RegisterModeService registerModeService;
@GetMapping("/config")
public SiteConfigDto getConfig() {
SiteConfigDto resp = new SiteConfigDto();
resp.setCaptchaEnabled(captchaEnabled);
resp.setRegisterCaptchaEnabled(registerCaptchaEnabled);
resp.setLoginCaptchaEnabled(loginCaptchaEnabled);
resp.setPostCaptchaEnabled(postCaptchaEnabled);
resp.setCommentCaptchaEnabled(commentCaptchaEnabled);
resp.setAiFormatLimit(aiFormatLimit);
resp.setRegisterMode(registerModeService.getRegisterMode());
return resp;
}
@GetMapping("/config")
@Operation(summary = "Site config", description = "Get site configuration")
@ApiResponse(
responseCode = "200",
description = "Site configuration",
content = @Content(schema = @Schema(implementation = SiteConfigDto.class))
)
public SiteConfigDto getConfig() {
SiteConfigDto resp = new SiteConfigDto();
resp.setCaptchaEnabled(captchaEnabled);
resp.setRegisterCaptchaEnabled(registerCaptchaEnabled);
resp.setLoginCaptchaEnabled(loginCaptchaEnabled);
resp.setPostCaptchaEnabled(postCaptchaEnabled);
resp.setCommentCaptchaEnabled(commentCaptchaEnabled);
resp.setAiFormatLimit(aiFormatLimit);
resp.setRegisterMode(registerModeService.getRegisterMode());
return resp;
}
}

View File

@@ -5,6 +5,11 @@ import com.openisle.dto.DraftRequest;
import com.openisle.mapper.DraftMapper;
import com.openisle.model.Draft;
import com.openisle.service.DraftService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
@@ -14,25 +19,50 @@ import org.springframework.web.bind.annotation.*;
@RequestMapping("/api/drafts")
@RequiredArgsConstructor
public class DraftController {
private final DraftService draftService;
private final DraftMapper draftMapper;
@PostMapping
public ResponseEntity<DraftDto> saveDraft(@RequestBody DraftRequest req, Authentication auth) {
Draft draft = draftService.saveDraft(auth.getName(), req.getCategoryId(), req.getTitle(), req.getContent(), req.getTagIds());
return ResponseEntity.ok(draftMapper.toDto(draft));
}
private final DraftService draftService;
private final DraftMapper draftMapper;
@GetMapping("/me")
public ResponseEntity<DraftDto> getMyDraft(Authentication auth) {
return draftService.getDraft(auth.getName())
.map(d -> ResponseEntity.ok(draftMapper.toDto(d)))
.orElseGet(() -> ResponseEntity.noContent().build());
}
@PostMapping
@Operation(summary = "Save draft", description = "Save a draft for current user")
@ApiResponse(
responseCode = "200",
description = "Draft saved",
content = @Content(schema = @Schema(implementation = DraftDto.class))
)
@SecurityRequirement(name = "JWT")
public ResponseEntity<DraftDto> saveDraft(@RequestBody DraftRequest req, Authentication auth) {
Draft draft = draftService.saveDraft(
auth.getName(),
req.getCategoryId(),
req.getTitle(),
req.getContent(),
req.getTagIds()
);
return ResponseEntity.ok(draftMapper.toDto(draft));
}
@DeleteMapping("/me")
public ResponseEntity<?> deleteMyDraft(Authentication auth) {
draftService.deleteDraft(auth.getName());
return ResponseEntity.ok().build();
}
@GetMapping("/me")
@Operation(summary = "Get my draft", description = "Get current user's draft")
@ApiResponse(
responseCode = "200",
description = "Draft details",
content = @Content(schema = @Schema(implementation = DraftDto.class))
)
@SecurityRequirement(name = "JWT")
public ResponseEntity<DraftDto> getMyDraft(Authentication auth) {
return draftService
.getDraft(auth.getName())
.map(d -> ResponseEntity.ok(draftMapper.toDto(d)))
.orElseGet(() -> ResponseEntity.noContent().build());
}
@DeleteMapping("/me")
@Operation(summary = "Delete my draft", description = "Delete current user's draft")
@ApiResponse(responseCode = "200", description = "Draft deleted")
@SecurityRequirement(name = "JWT")
public ResponseEntity<?> deleteMyDraft(Authentication auth) {
draftService.deleteDraft(auth.getName());
return ResponseEntity.ok().build();
}
}

View File

@@ -1,40 +1,39 @@
package com.openisle.controller;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import com.openisle.exception.FieldException;
import com.openisle.exception.NotFoundException;
import com.openisle.exception.RateLimitException;
import java.util.Map;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(FieldException.class)
public ResponseEntity<?> handleFieldException(FieldException ex) {
return ResponseEntity.badRequest()
.body(Map.of("error", ex.getMessage(), "field", ex.getField()));
}
@ExceptionHandler(FieldException.class)
public ResponseEntity<?> handleFieldException(FieldException ex) {
return ResponseEntity.badRequest().body(
Map.of("error", ex.getMessage(), "field", ex.getField())
);
}
@ExceptionHandler(NotFoundException.class)
public ResponseEntity<?> handleNotFoundException(NotFoundException ex) {
return ResponseEntity.status(404).body(Map.of("error", ex.getMessage()));
}
@ExceptionHandler(NotFoundException.class)
public ResponseEntity<?> handleNotFoundException(NotFoundException ex) {
return ResponseEntity.status(404).body(Map.of("error", ex.getMessage()));
}
@ExceptionHandler(RateLimitException.class)
public ResponseEntity<?> handleRateLimitException(RateLimitException ex) {
return ResponseEntity.status(429).body(Map.of("error", ex.getMessage()));
}
@ExceptionHandler(RateLimitException.class)
public ResponseEntity<?> handleRateLimitException(RateLimitException ex) {
return ResponseEntity.status(429).body(Map.of("error", ex.getMessage()));
}
@ExceptionHandler(Exception.class)
public ResponseEntity<?> handleException(Exception ex) {
String message = ex.getMessage();
if (message == null) {
message = ex.getClass().getSimpleName();
}
return ResponseEntity.badRequest().body(Map.of("error", message));
@ExceptionHandler(Exception.class)
public ResponseEntity<?> handleException(Exception ex) {
String message = ex.getMessage();
if (message == null) {
message = ex.getClass().getSimpleName();
}
return ResponseEntity.badRequest().body(Map.of("error", message));
}
}

View File

@@ -1,13 +1,26 @@
package com.openisle.controller;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import java.util.Map;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
@RestController
public class HelloController {
@GetMapping("/api/hello")
public Map<String, String> hello() {
return Map.of("message", "Hello, Authenticated User");
}
@GetMapping("/api/hello")
@SecurityRequirement(name = "JWT")
@Operation(summary = "Hello endpoint", description = "Returns a greeting for authenticated users")
@ApiResponse(
responseCode = "200",
description = "Greeting payload",
content = @Content(schema = @Schema(implementation = Map.class))
)
public Map<String, String> hello() {
return Map.of("message", "Hello, Authenticated User");
}
}

View File

@@ -1,23 +1,35 @@
package com.openisle.controller;
import com.openisle.service.InviteService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
@RestController
@RequestMapping("/api/invite")
@RequiredArgsConstructor
public class InviteController {
private final InviteService inviteService;
@PostMapping("/generate")
public Map<String, String> generate(Authentication auth) {
String token = inviteService.generate(auth.getName());
return Map.of("token", token);
}
private final InviteService inviteService;
@PostMapping("/generate")
@Operation(summary = "Generate invite", description = "Generate an invite token")
@ApiResponse(
responseCode = "200",
description = "Invite token",
content = @Content(schema = @Schema(implementation = Map.class))
)
@SecurityRequirement(name = "JWT")
public Map<String, String> generate(Authentication auth) {
String token = inviteService.generate(auth.getName());
return Map.of("token", token);
}
}

View File

@@ -3,31 +3,49 @@ package com.openisle.controller;
import com.openisle.dto.MedalDto;
import com.openisle.dto.MedalSelectRequest;
import com.openisle.service.MedalService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.ArraySchema;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/medals")
@RequiredArgsConstructor
public class MedalController {
private final MedalService medalService;
@GetMapping
public List<MedalDto> getMedals(@RequestParam(value = "userId", required = false) Long userId) {
return medalService.getMedals(userId);
}
private final MedalService medalService;
@PostMapping("/select")
public ResponseEntity<Void> selectMedal(@RequestBody MedalSelectRequest req, Authentication auth) {
try {
medalService.selectMedal(auth.getName(), req.getType());
return ResponseEntity.ok().build();
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().build();
}
@GetMapping
@Operation(summary = "List medals", description = "List medals for user or globally")
@ApiResponse(
responseCode = "200",
description = "List of medals",
content = @Content(array = @ArraySchema(schema = @Schema(implementation = MedalDto.class)))
)
public List<MedalDto> getMedals(@RequestParam(value = "userId", required = false) Long userId) {
return medalService.getMedals(userId);
}
@PostMapping("/select")
@Operation(summary = "Select medal", description = "Select a medal for current user")
@ApiResponse(responseCode = "200", description = "Medal selected")
@SecurityRequirement(name = "JWT")
public ResponseEntity<Void> selectMedal(
@RequestBody MedalSelectRequest req,
Authentication auth
) {
try {
medalService.selectMedal(auth.getName(), req.getType());
return ResponseEntity.ok().build();
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().build();
}
}
}

View File

@@ -10,6 +10,13 @@ import com.openisle.model.MessageConversation;
import com.openisle.model.User;
import com.openisle.repository.UserRepository;
import com.openisle.service.MessageService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.ArraySchema;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
@@ -19,119 +26,204 @@ import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/messages")
@RequiredArgsConstructor
public class MessageController {
private final MessageService messageService;
private final UserRepository userRepository;
private final MessageService messageService;
private final UserRepository userRepository;
// This is a placeholder for getting the current user's ID
private Long getCurrentUserId(Authentication auth) {
User user = userRepository.findByUsername(auth.getName()).orElseThrow(() -> new IllegalArgumentException("Sender not found"));
// In a real application, you would get this from the Authentication object
return user.getId();
// This is a placeholder for getting the current user's ID
private Long getCurrentUserId(Authentication auth) {
User user = userRepository
.findByUsername(auth.getName())
.orElseThrow(() -> new IllegalArgumentException("Sender not found"));
// In a real application, you would get this from the Authentication object
return user.getId();
}
@GetMapping("/conversations")
@Operation(summary = "List conversations", description = "Get all conversations of current user")
@ApiResponse(
responseCode = "200",
description = "List of conversations",
content = @Content(
array = @ArraySchema(schema = @Schema(implementation = ConversationDto.class))
)
)
@SecurityRequirement(name = "JWT")
public ResponseEntity<List<ConversationDto>> getConversations(Authentication auth) {
List<ConversationDto> conversations = messageService.getConversations(getCurrentUserId(auth));
return ResponseEntity.ok(conversations);
}
@GetMapping("/conversations/{conversationId}")
@Operation(summary = "Get conversation", description = "Get messages of a conversation")
@ApiResponse(
responseCode = "200",
description = "Conversation detail",
content = @Content(schema = @Schema(implementation = ConversationDetailDto.class))
)
@SecurityRequirement(name = "JWT")
public ResponseEntity<ConversationDetailDto> getMessages(
@PathVariable Long conversationId,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
Authentication auth
) {
Pageable pageable = PageRequest.of(page, size, Sort.by("createdAt").descending());
ConversationDetailDto conversationDetails = messageService.getConversationDetails(
conversationId,
getCurrentUserId(auth),
pageable
);
return ResponseEntity.ok(conversationDetails);
}
@PostMapping
@Operation(summary = "Send message", description = "Send a direct message to a user")
@ApiResponse(
responseCode = "200",
description = "Message sent",
content = @Content(schema = @Schema(implementation = MessageDto.class))
)
@SecurityRequirement(name = "JWT")
public ResponseEntity<MessageDto> sendMessage(
@RequestBody MessageRequest req,
Authentication auth
) {
Message message = messageService.sendMessage(
getCurrentUserId(auth),
req.getRecipientId(),
req.getContent(),
req.getReplyToId()
);
return ResponseEntity.ok(messageService.toDto(message));
}
@PostMapping("/conversations/{conversationId}/messages")
@Operation(summary = "Send message to conversation", description = "Reply within a conversation")
@ApiResponse(
responseCode = "200",
description = "Message sent",
content = @Content(schema = @Schema(implementation = MessageDto.class))
)
@SecurityRequirement(name = "JWT")
public ResponseEntity<MessageDto> sendMessageToConversation(
@PathVariable Long conversationId,
@RequestBody ChannelMessageRequest req,
Authentication auth
) {
Message message = messageService.sendMessageToConversation(
getCurrentUserId(auth),
conversationId,
req.getContent(),
req.getReplyToId()
);
return ResponseEntity.ok(messageService.toDto(message));
}
@PostMapping("/conversations/{conversationId}/read")
@Operation(
summary = "Mark conversation read",
description = "Mark messages in conversation as read"
)
@ApiResponse(responseCode = "200", description = "Marked as read")
@SecurityRequirement(name = "JWT")
public ResponseEntity<Void> markAsRead(@PathVariable Long conversationId, Authentication auth) {
messageService.markConversationAsRead(conversationId, getCurrentUserId(auth));
return ResponseEntity.ok().build();
}
@PostMapping("/conversations")
@Operation(
summary = "Find or create conversation",
description = "Find existing or create new conversation with recipient"
)
@ApiResponse(
responseCode = "200",
description = "Conversation id",
content = @Content(schema = @Schema(implementation = CreateConversationResponse.class))
)
@SecurityRequirement(name = "JWT")
public ResponseEntity<CreateConversationResponse> findOrCreateConversation(
@RequestBody CreateConversationRequest req,
Authentication auth
) {
MessageConversation conversation = messageService.findOrCreateConversation(
getCurrentUserId(auth),
req.getRecipientId()
);
return ResponseEntity.ok(new CreateConversationResponse(conversation.getId()));
}
@GetMapping("/unread-count")
@Operation(
summary = "Unread message count",
description = "Get unread message count for current user"
)
@ApiResponse(
responseCode = "200",
description = "Unread count",
content = @Content(schema = @Schema(implementation = Long.class))
)
@SecurityRequirement(name = "JWT")
public ResponseEntity<Long> getUnreadCount(Authentication auth) {
return ResponseEntity.ok(messageService.getUnreadMessageCount(getCurrentUserId(auth)));
}
// A simple request DTO
static class MessageRequest {
private Long recipientId;
private String content;
private Long replyToId;
public Long getRecipientId() {
return recipientId;
}
@GetMapping("/conversations")
public ResponseEntity<List<ConversationDto>> getConversations(Authentication auth) {
List<ConversationDto> conversations = messageService.getConversations(getCurrentUserId(auth));
return ResponseEntity.ok(conversations);
public void setRecipientId(Long recipientId) {
this.recipientId = recipientId;
}
@GetMapping("/conversations/{conversationId}")
public ResponseEntity<ConversationDetailDto> getMessages(@PathVariable Long conversationId,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
Authentication auth) {
Pageable pageable = PageRequest.of(page, size, Sort.by("createdAt").descending());
ConversationDetailDto conversationDetails = messageService.getConversationDetails(conversationId, getCurrentUserId(auth), pageable);
return ResponseEntity.ok(conversationDetails);
public String getContent() {
return content;
}
@PostMapping
public ResponseEntity<MessageDto> sendMessage(@RequestBody MessageRequest req, Authentication auth) {
Message message = messageService.sendMessage(getCurrentUserId(auth), req.getRecipientId(), req.getContent(), req.getReplyToId());
return ResponseEntity.ok(messageService.toDto(message));
public void setContent(String content) {
this.content = content;
}
@PostMapping("/conversations/{conversationId}/messages")
public ResponseEntity<MessageDto> sendMessageToConversation(@PathVariable Long conversationId,
@RequestBody ChannelMessageRequest req,
Authentication auth) {
Message message = messageService.sendMessageToConversation(getCurrentUserId(auth), conversationId, req.getContent(), req.getReplyToId());
return ResponseEntity.ok(messageService.toDto(message));
public Long getReplyToId() {
return replyToId;
}
@PostMapping("/conversations/{conversationId}/read")
public ResponseEntity<Void> markAsRead(@PathVariable Long conversationId, Authentication auth) {
messageService.markConversationAsRead(conversationId, getCurrentUserId(auth));
return ResponseEntity.ok().build();
public void setReplyToId(Long replyToId) {
this.replyToId = replyToId;
}
}
static class ChannelMessageRequest {
private String content;
private Long replyToId;
public String getContent() {
return content;
}
@PostMapping("/conversations")
public ResponseEntity<CreateConversationResponse> findOrCreateConversation(@RequestBody CreateConversationRequest req, Authentication auth) {
MessageConversation conversation = messageService.findOrCreateConversation(getCurrentUserId(auth), req.getRecipientId());
return ResponseEntity.ok(new CreateConversationResponse(conversation.getId()));
public void setContent(String content) {
this.content = content;
}
@GetMapping("/unread-count")
public ResponseEntity<Long> getUnreadCount(Authentication auth) {
return ResponseEntity.ok(messageService.getUnreadMessageCount(getCurrentUserId(auth)));
public Long getReplyToId() {
return replyToId;
}
// A simple request DTO
static class MessageRequest {
private Long recipientId;
private String content;
private Long replyToId;
public Long getRecipientId() {
return recipientId;
}
public void setRecipientId(Long recipientId) {
this.recipientId = recipientId;
}
public String getContent() {
return content;
}
public void setContent(String content) {
this.content = content;
}
public Long getReplyToId() {
return replyToId;
}
public void setReplyToId(Long replyToId) {
this.replyToId = replyToId;
}
public void setReplyToId(Long replyToId) {
this.replyToId = replyToId;
}
static class ChannelMessageRequest {
private String content;
private Long replyToId;
public String getContent() {
return content;
}
public void setContent(String content) {
this.content = content;
}
public Long getReplyToId() {
return replyToId;
}
public void setReplyToId(Long replyToId) {
this.replyToId = replyToId;
}
}
}
}
}

View File

@@ -2,74 +2,158 @@ package com.openisle.controller;
import com.openisle.dto.NotificationDto;
import com.openisle.dto.NotificationMarkReadRequest;
import com.openisle.dto.NotificationUnreadCountDto;
import com.openisle.dto.NotificationPreferenceDto;
import com.openisle.dto.NotificationPreferenceUpdateRequest;
import com.openisle.dto.NotificationUnreadCountDto;
import com.openisle.mapper.NotificationMapper;
import com.openisle.service.NotificationService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.ArraySchema;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import java.util.List;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.stream.Collectors;
/** Endpoints for user notifications. */
@RestController
@RequestMapping("/api/notifications")
@RequiredArgsConstructor
public class NotificationController {
private final NotificationService notificationService;
private final NotificationMapper notificationMapper;
@GetMapping
public List<NotificationDto> list(@RequestParam(value = "page", defaultValue = "0") int page,
@RequestParam(value = "size", defaultValue = "30") int size,
Authentication auth) {
return notificationService.listNotifications(auth.getName(), null, page, size).stream()
.map(notificationMapper::toDto)
.collect(Collectors.toList());
}
private final NotificationService notificationService;
private final NotificationMapper notificationMapper;
@GetMapping("/unread")
public List<NotificationDto> listUnread(@RequestParam(value = "page", defaultValue = "0") int page,
@RequestParam(value = "size", defaultValue = "30") int size,
Authentication auth) {
return notificationService.listNotifications(auth.getName(), false, page, size).stream()
.map(notificationMapper::toDto)
.collect(Collectors.toList());
}
@GetMapping
@Operation(
summary = "List notifications",
description = "Retrieve notifications for the current user"
)
@ApiResponse(
responseCode = "200",
description = "Notifications",
content = @Content(
array = @ArraySchema(schema = @Schema(implementation = NotificationDto.class))
)
)
@SecurityRequirement(name = "JWT")
public List<NotificationDto> list(
@RequestParam(value = "page", defaultValue = "0") int page,
@RequestParam(value = "size", defaultValue = "30") int size,
Authentication auth
) {
return notificationService
.listNotifications(auth.getName(), null, page, size)
.stream()
.map(notificationMapper::toDto)
.collect(Collectors.toList());
}
@GetMapping("/unread-count")
public NotificationUnreadCountDto unreadCount(Authentication auth) {
long count = notificationService.countUnread(auth.getName());
NotificationUnreadCountDto uc = new NotificationUnreadCountDto();
uc.setCount(count);
return uc;
}
@GetMapping("/unread")
@Operation(
summary = "List unread notifications",
description = "Retrieve unread notifications for the current user"
)
@ApiResponse(
responseCode = "200",
description = "Unread notifications",
content = @Content(
array = @ArraySchema(schema = @Schema(implementation = NotificationDto.class))
)
)
@SecurityRequirement(name = "JWT")
public List<NotificationDto> listUnread(
@RequestParam(value = "page", defaultValue = "0") int page,
@RequestParam(value = "size", defaultValue = "30") int size,
Authentication auth
) {
return notificationService
.listNotifications(auth.getName(), false, page, size)
.stream()
.map(notificationMapper::toDto)
.collect(Collectors.toList());
}
@PostMapping("/read")
public void markRead(@RequestBody NotificationMarkReadRequest req, Authentication auth) {
notificationService.markRead(auth.getName(), req.getIds());
}
@GetMapping("/unread-count")
@Operation(summary = "Unread count", description = "Get count of unread notifications")
@ApiResponse(
responseCode = "200",
description = "Unread count",
content = @Content(schema = @Schema(implementation = NotificationUnreadCountDto.class))
)
@SecurityRequirement(name = "JWT")
public NotificationUnreadCountDto unreadCount(Authentication auth) {
long count = notificationService.countUnread(auth.getName());
NotificationUnreadCountDto uc = new NotificationUnreadCountDto();
uc.setCount(count);
return uc;
}
@GetMapping("/prefs")
public List<NotificationPreferenceDto> prefs(Authentication auth) {
return notificationService.listPreferences(auth.getName());
}
@PostMapping("/read")
@Operation(summary = "Mark notifications read", description = "Mark notifications as read")
@ApiResponse(responseCode = "200", description = "Marked read")
@SecurityRequirement(name = "JWT")
public void markRead(@RequestBody NotificationMarkReadRequest req, Authentication auth) {
notificationService.markRead(auth.getName(), req.getIds());
}
@PostMapping("/prefs")
public void updatePref(@RequestBody NotificationPreferenceUpdateRequest req, Authentication auth) {
notificationService.updatePreference(auth.getName(), req.getType(), req.isEnabled());
}
@GetMapping("/prefs")
@Operation(summary = "List preferences", description = "List notification preferences")
@ApiResponse(
responseCode = "200",
description = "Preferences",
content = @Content(
array = @ArraySchema(schema = @Schema(implementation = NotificationPreferenceDto.class))
)
)
@SecurityRequirement(name = "JWT")
public List<NotificationPreferenceDto> prefs(Authentication auth) {
return notificationService.listPreferences(auth.getName());
}
@GetMapping("/email-prefs")
public List<NotificationPreferenceDto> emailPrefs(Authentication auth) {
return notificationService.listEmailPreferences(auth.getName());
}
@PostMapping("/prefs")
@Operation(summary = "Update preference", description = "Update notification preference")
@ApiResponse(responseCode = "200", description = "Preference updated")
@SecurityRequirement(name = "JWT")
public void updatePref(
@RequestBody NotificationPreferenceUpdateRequest req,
Authentication auth
) {
notificationService.updatePreference(auth.getName(), req.getType(), req.isEnabled());
}
@PostMapping("/email-prefs")
public void updateEmailPref(@RequestBody NotificationPreferenceUpdateRequest req, Authentication auth) {
notificationService.updateEmailPreference(auth.getName(), req.getType(), req.isEnabled());
}
@GetMapping("/email-prefs")
@Operation(
summary = "List email preferences",
description = "List email notification preferences"
)
@ApiResponse(
responseCode = "200",
description = "Email preferences",
content = @Content(
array = @ArraySchema(schema = @Schema(implementation = NotificationPreferenceDto.class))
)
)
@SecurityRequirement(name = "JWT")
public List<NotificationPreferenceDto> emailPrefs(Authentication auth) {
return notificationService.listEmailPreferences(auth.getName());
}
@PostMapping("/email-prefs")
@Operation(
summary = "Update email preference",
description = "Update email notification preference"
)
@ApiResponse(responseCode = "200", description = "Email preference updated")
@SecurityRequirement(name = "JWT")
public void updateEmailPref(
@RequestBody NotificationPreferenceUpdateRequest req,
Authentication auth
) {
notificationService.updateEmailPreference(auth.getName(), req.getType(), req.isEnabled());
}
}

View File

@@ -1,13 +1,16 @@
package com.openisle.controller;
import com.openisle.config.CachingConfig;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import java.time.Duration;
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.web.bind.annotation.*;
import java.time.Duration;
/**
* @author smallclover
* @since 2025-09-05
@@ -18,16 +21,24 @@ import java.time.Duration;
@RequiredArgsConstructor
public class OnlineController {
private final RedisTemplate redisTemplate;
private static final String ONLINE_KEY = CachingConfig.ONLINE_CACHE_NAME +":";
private final RedisTemplate redisTemplate;
private static final String ONLINE_KEY = CachingConfig.ONLINE_CACHE_NAME + ":";
@PostMapping("/heartbeat")
public void ping(@RequestParam String userId){
redisTemplate.opsForValue().set(ONLINE_KEY+userId,"1", Duration.ofSeconds(150));
}
@PostMapping("/heartbeat")
@Operation(summary = "Heartbeat", description = "Record user heartbeat")
@ApiResponse(responseCode = "200", description = "Heartbeat recorded")
public void ping(@RequestParam String userId) {
redisTemplate.opsForValue().set(ONLINE_KEY + userId, "1", Duration.ofSeconds(150));
}
@GetMapping("/count")
public long count(){
return redisTemplate.keys(ONLINE_KEY+"*").size();
}
@GetMapping("/count")
@Operation(summary = "Online count", description = "Get current online user count")
@ApiResponse(
responseCode = "200",
description = "Online count",
content = @Content(schema = @Schema(implementation = Long.class))
)
public long count() {
return redisTemplate.keys(ONLINE_KEY + "*").size();
}
}

View File

@@ -3,6 +3,15 @@ package com.openisle.controller;
import com.openisle.dto.PointHistoryDto;
import com.openisle.mapper.PointHistoryMapper;
import com.openisle.service.PointService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.ArraySchema;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.GetMapping;
@@ -10,27 +19,44 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@RestController
@RequestMapping("/api/point-histories")
@RequiredArgsConstructor
public class PointHistoryController {
private final PointService pointService;
private final PointHistoryMapper pointHistoryMapper;
@GetMapping
public List<PointHistoryDto> list(Authentication auth) {
return pointService.listHistory(auth.getName()).stream()
.map(pointHistoryMapper::toDto)
.collect(Collectors.toList());
}
private final PointService pointService;
private final PointHistoryMapper pointHistoryMapper;
@GetMapping("/trend")
public List<Map<String, Object>> trend(Authentication auth,
@RequestParam(value = "days", defaultValue = "30") int days) {
return pointService.trend(auth.getName(), days);
}
@GetMapping
@Operation(summary = "Point history", description = "List point history for current user")
@ApiResponse(
responseCode = "200",
description = "List of point histories",
content = @Content(
array = @ArraySchema(schema = @Schema(implementation = PointHistoryDto.class))
)
)
@SecurityRequirement(name = "JWT")
public List<PointHistoryDto> list(Authentication auth) {
return pointService
.listHistory(auth.getName())
.stream()
.map(pointHistoryMapper::toDto)
.collect(Collectors.toList());
}
@GetMapping("/trend")
@Operation(summary = "Point trend", description = "Get point trend data for current user")
@ApiResponse(
responseCode = "200",
description = "Trend data",
content = @Content(array = @ArraySchema(schema = @Schema(implementation = java.util.Map.class)))
)
@SecurityRequirement(name = "JWT")
public List<Map<String, Object>> trend(
Authentication auth,
@RequestParam(value = "days", defaultValue = "30") int days
) {
return pointService.trend(auth.getName(), days);
}
}

View File

@@ -6,34 +6,55 @@ import com.openisle.mapper.PointGoodMapper;
import com.openisle.model.User;
import com.openisle.service.PointMallService;
import com.openisle.service.UserService;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.ArraySchema;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
/** REST controller for point mall. */
@RestController
@RequestMapping("/api/point-goods")
@RequiredArgsConstructor
public class PointMallController {
private final PointMallService pointMallService;
private final UserService userService;
private final PointGoodMapper pointGoodMapper;
@GetMapping
public List<PointGoodDto> list() {
return pointMallService.listGoods().stream()
.map(pointGoodMapper::toDto)
.collect(Collectors.toList());
}
private final PointMallService pointMallService;
private final UserService userService;
private final PointGoodMapper pointGoodMapper;
@PostMapping("/redeem")
public Map<String, Integer> redeem(@RequestBody PointRedeemRequest req, Authentication auth) {
User user = userService.findByIdentifier(auth.getName()).orElseThrow();
int point = pointMallService.redeem(user, req.getGoodId(), req.getContact());
return Map.of("point", point);
}
@GetMapping
@Operation(summary = "List goods", description = "List all point goods")
@ApiResponse(
responseCode = "200",
description = "List of goods",
content = @Content(array = @ArraySchema(schema = @Schema(implementation = PointGoodDto.class)))
)
public List<PointGoodDto> list() {
return pointMallService
.listGoods()
.stream()
.map(pointGoodMapper::toDto)
.collect(Collectors.toList());
}
@PostMapping("/redeem")
@Operation(summary = "Redeem good", description = "Redeem a point good")
@ApiResponse(
responseCode = "200",
description = "Remaining points",
content = @Content(schema = @Schema(implementation = java.util.Map.class))
)
@SecurityRequirement(name = "JWT")
public Map<String, Integer> redeem(@RequestBody PointRedeemRequest req, Authentication auth) {
User user = userService.findByIdentifier(auth.getName()).orElseThrow();
int point = pointMallService.redeem(user, req.getGoodId(), req.getContact());
return Map.of("point", point);
}
}

View File

@@ -0,0 +1,36 @@
package com.openisle.controller;
import com.openisle.dto.PostChangeLogDto;
import com.openisle.mapper.PostChangeLogMapper;
import com.openisle.service.PostChangeLogService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.ArraySchema;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import java.util.List;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/posts")
@RequiredArgsConstructor
public class PostChangeLogController {
private final PostChangeLogService changeLogService;
private final PostChangeLogMapper mapper;
@GetMapping("/{id}/change-logs")
@Operation(summary = "Post change logs", description = "List change logs for a post")
@ApiResponse(
responseCode = "200",
description = "Change logs",
content = @Content(
array = @ArraySchema(schema = @Schema(implementation = PostChangeLogDto.class))
)
)
public List<PostChangeLogDto> listLogs(@PathVariable Long id) {
return changeLogService.listLogs(id).stream().map(mapper::toDto).collect(Collectors.toList());
}
}

View File

@@ -1,211 +1,342 @@
package com.openisle.controller;
import com.openisle.config.CachingConfig;
import com.openisle.dto.PollDto;
import com.openisle.dto.PostDetailDto;
import com.openisle.dto.PostRequest;
import com.openisle.dto.PostSummaryDto;
import com.openisle.dto.PollDto;
import com.openisle.mapper.PostMapper;
import com.openisle.model.Post;
import com.openisle.service.*;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.ArraySchema;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import java.util.List;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.stream.Collectors;
@RestController
@RequestMapping("/api/posts")
@RequiredArgsConstructor
public class PostController {
private final PostService postService;
private final LevelService levelService;
private final CaptchaService captchaService;
private final DraftService draftService;
private final UserVisitService userVisitService;
private final PostMapper postMapper;
private final PointService pointService;
@Value("${app.captcha.enabled:false}")
private boolean captchaEnabled;
private final PostService postService;
private final CategoryService categoryService;
private final TagService tagService;
private final LevelService levelService;
private final CaptchaService captchaService;
private final DraftService draftService;
private final UserVisitService userVisitService;
private final PostMapper postMapper;
private final PointService pointService;
@Value("${app.captcha.post-enabled:false}")
private boolean postCaptchaEnabled;
@Value("${app.captcha.enabled:false}")
private boolean captchaEnabled;
@PostMapping
public ResponseEntity<PostDetailDto> createPost(@RequestBody PostRequest req, Authentication auth) {
if (captchaEnabled && postCaptchaEnabled && !captchaService.verify(req.getCaptcha())) {
return ResponseEntity.badRequest().build();
}
Post post = postService.createPost(auth.getName(), req.getCategoryId(),
req.getTitle(), req.getContent(), req.getTagIds(),
req.getType(), req.getPrizeDescription(), req.getPrizeIcon(),
req.getPrizeCount(), req.getPointCost(),
req.getStartTime(), req.getEndTime(),
req.getOptions(), req.getMultiple());
draftService.deleteDraft(auth.getName());
PostDetailDto dto = postMapper.toDetailDto(post, auth.getName());
dto.setReward(levelService.awardForPost(auth.getName()));
dto.setPointReward(pointService.awardForPost(auth.getName(), post.getId()));
return ResponseEntity.ok(dto);
@Value("${app.captcha.post-enabled:false}")
private boolean postCaptchaEnabled;
@PostMapping
@SecurityRequirement(name = "JWT")
@Operation(summary = "Create post", description = "Create a new post")
@ApiResponse(
responseCode = "200",
description = "Created post",
content = @Content(schema = @Schema(implementation = PostDetailDto.class))
)
public ResponseEntity<PostDetailDto> createPost(
@RequestBody PostRequest req,
Authentication auth
) {
if (captchaEnabled && postCaptchaEnabled && !captchaService.verify(req.getCaptcha())) {
return ResponseEntity.badRequest().build();
}
Post post = postService.createPost(
auth.getName(),
req.getCategoryId(),
req.getTitle(),
req.getContent(),
req.getTagIds(),
req.getType(),
req.getPostVisibleScopeType(),
req.getPrizeDescription(),
req.getPrizeIcon(),
req.getPrizeCount(),
req.getPointCost(),
req.getStartTime(),
req.getEndTime(),
req.getOptions(),
req.getMultiple(),
req.getProposedName(),
req.getProposalDescription()
);
draftService.deleteDraft(auth.getName());
PostDetailDto dto = postMapper.toDetailDto(post, auth.getName());
dto.setReward(levelService.awardForPost(auth.getName()));
dto.setPointReward(pointService.awardForPost(auth.getName(), post.getId()));
return ResponseEntity.ok(dto);
}
@PutMapping("/{id}")
public ResponseEntity<PostDetailDto> updatePost(@PathVariable Long id, @RequestBody PostRequest req,
Authentication auth) {
Post post = postService.updatePost(id, auth.getName(), req.getCategoryId(),
req.getTitle(), req.getContent(), req.getTagIds());
return ResponseEntity.ok(postMapper.toDetailDto(post, auth.getName()));
}
@PutMapping("/{id}")
@SecurityRequirement(name = "JWT")
@Operation(summary = "Update post", description = "Update an existing post")
@ApiResponse(
responseCode = "200",
description = "Updated post",
content = @Content(schema = @Schema(implementation = PostDetailDto.class))
)
public ResponseEntity<PostDetailDto> updatePost(
@PathVariable Long id,
@RequestBody PostRequest req,
Authentication auth
) {
Post post = postService.updatePost(
id,
auth.getName(),
req.getCategoryId(),
req.getTitle(),
req.getContent(),
req.getTagIds(),
req.getPostVisibleScopeType()
);
return ResponseEntity.ok(postMapper.toDetailDto(post, auth.getName()));
}
@DeleteMapping("/{id}")
public void deletePost(@PathVariable Long id, Authentication auth) {
postService.deletePost(id, auth.getName());
}
@DeleteMapping("/{id}")
@SecurityRequirement(name = "JWT")
@Operation(summary = "Delete post", description = "Delete a post")
@ApiResponse(responseCode = "200", description = "Post deleted")
public void deletePost(@PathVariable Long id, Authentication auth) {
postService.deletePost(id, auth.getName());
}
@PostMapping("/{id}/close")
public PostSummaryDto close(@PathVariable Long id, Authentication auth) {
return postMapper.toSummaryDto(postService.closePost(id, auth.getName()));
}
@PostMapping("/{id}/close")
@SecurityRequirement(name = "JWT")
@Operation(summary = "Close post", description = "Close a post to prevent further replies")
@ApiResponse(
responseCode = "200",
description = "Closed post",
content = @Content(schema = @Schema(implementation = PostSummaryDto.class))
)
public PostSummaryDto close(@PathVariable Long id, Authentication auth) {
return postMapper.toSummaryDto(postService.closePost(id, auth.getName()));
}
@PostMapping("/{id}/reopen")
public PostSummaryDto reopen(@PathVariable Long id, Authentication auth) {
return postMapper.toSummaryDto(postService.reopenPost(id, auth.getName()));
}
@PostMapping("/{id}/reopen")
@SecurityRequirement(name = "JWT")
@Operation(summary = "Reopen post", description = "Reopen a closed post")
@ApiResponse(
responseCode = "200",
description = "Reopened post",
content = @Content(schema = @Schema(implementation = PostSummaryDto.class))
)
public PostSummaryDto reopen(@PathVariable Long id, Authentication auth) {
return postMapper.toSummaryDto(postService.reopenPost(id, auth.getName()));
}
@GetMapping("/{id}")
public ResponseEntity<PostDetailDto> getPost(@PathVariable Long id, Authentication auth) {
String viewer = auth != null ? auth.getName() : null;
Post post = postService.viewPost(id, viewer);
return ResponseEntity.ok(postMapper.toDetailDto(post, viewer));
}
@GetMapping("/{id}")
@Operation(summary = "Get post", description = "Get post details by id")
@ApiResponse(
responseCode = "200",
description = "Post detail",
content = @Content(schema = @Schema(implementation = PostDetailDto.class))
)
public ResponseEntity<PostDetailDto> getPost(@PathVariable Long id, Authentication auth) {
String viewer = auth != null ? auth.getName() : null;
Post post = postService.viewPost(id, viewer);
return ResponseEntity.ok(postMapper.toDetailDto(post, viewer));
}
@PostMapping("/{id}/lottery/join")
public ResponseEntity<Void> joinLottery(@PathVariable Long id, Authentication auth) {
postService.joinLottery(id, auth.getName());
return ResponseEntity.ok().build();
}
@PostMapping("/{id}/lottery/join")
@SecurityRequirement(name = "JWT")
@Operation(summary = "Join lottery", description = "Join a lottery for the post")
@ApiResponse(responseCode = "200", description = "Joined lottery")
public ResponseEntity<Void> joinLottery(@PathVariable Long id, Authentication auth) {
postService.joinLottery(id, auth.getName());
return ResponseEntity.ok().build();
}
@GetMapping("/{id}/poll/progress")
public ResponseEntity<PollDto> pollProgress(@PathVariable Long id) {
return ResponseEntity.ok(postMapper.toSummaryDto(postService.getPoll(id)).getPoll());
}
@GetMapping("/{id}/poll/progress")
@Operation(summary = "Poll progress", description = "Get poll progress for a post")
@ApiResponse(
responseCode = "200",
description = "Poll progress",
content = @Content(schema = @Schema(implementation = PollDto.class))
)
public ResponseEntity<PollDto> pollProgress(@PathVariable Long id) {
return ResponseEntity.ok(postMapper.toSummaryDto(postService.getPoll(id)).getPoll());
}
@PostMapping("/{id}/poll/vote")
public ResponseEntity<Void> vote(@PathVariable Long id, @RequestParam("option") List<Integer> option, Authentication auth) {
postService.votePoll(id, auth.getName(), option);
return ResponseEntity.ok().build();
}
@PostMapping("/{id}/poll/vote")
@SecurityRequirement(name = "JWT")
@Operation(summary = "Vote poll", description = "Vote on a poll option")
@ApiResponse(responseCode = "200", description = "Vote recorded")
public ResponseEntity<Void> vote(
@PathVariable Long id,
@RequestParam("option") List<Integer> option,
Authentication auth
) {
postService.votePoll(id, auth.getName(), option);
return ResponseEntity.ok().build();
}
@GetMapping
public List<PostSummaryDto> listPosts(@RequestParam(value = "categoryId", required = false) Long categoryId,
@RequestParam(value = "categoryIds", required = false) List<Long> categoryIds,
@RequestParam(value = "tagId", required = false) Long tagId,
@RequestParam(value = "tagIds", required = false) List<Long> tagIds,
@RequestParam(value = "page", required = false) Integer page,
@RequestParam(value = "pageSize", required = false) Integer pageSize,
Authentication auth) {
List<Long> ids = categoryIds;
if (categoryId != null) {
ids = java.util.List.of(categoryId);
}
List<Long> tids = tagIds;
if (tagId != null) {
tids = java.util.List.of(tagId);
}
@GetMapping
@Operation(summary = "List posts", description = "List posts by various filters")
@ApiResponse(
responseCode = "200",
description = "List of posts",
content = @Content(
array = @ArraySchema(schema = @Schema(implementation = PostSummaryDto.class))
)
)
@Cacheable(
value = CachingConfig.POST_CACHE_NAME,
key = "new org.springframework.cache.interceptor.SimpleKey('default', #categoryId, #categoryIds, #tagId, #tagIds, #page, #pageSize)"
)
public List<PostSummaryDto> listPosts(
@RequestParam(value = "categoryId", required = false) Long categoryId,
@RequestParam(value = "categoryIds", required = false) List<Long> categoryIds,
@RequestParam(value = "tagId", required = false) Long tagId,
@RequestParam(value = "tagIds", required = false) List<Long> tagIds,
@RequestParam(value = "page", required = false) Integer page,
@RequestParam(value = "pageSize", required = false) Integer pageSize,
Authentication auth
) {
List<Long> ids = categoryService.getSearchCategoryIds(categoryIds, categoryId);
List<Long> tids = tagService.getSearchTagIds(tagIds, tagId);
// 只需要在请求的一开始统计一次
// if (auth != null) {
// userVisitService.recordVisit(auth.getName());
// }
if (auth != null) {
userVisitService.recordVisit(auth.getName());
}
return postService
.defaultListPosts(ids, tids, page, pageSize)
.stream()
.map(postMapper::toSummaryDto)
.collect(Collectors.toList());
}
boolean hasCategories = ids != null && !ids.isEmpty();
boolean hasTags = tids != null && !tids.isEmpty();
@GetMapping("/recent")
@Operation(
summary = "Recent posts",
description = "List posts created within the specified number of minutes"
)
@ApiResponse(
responseCode = "200",
description = "Recent posts",
content = @Content(
array = @ArraySchema(schema = @Schema(implementation = PostSummaryDto.class))
)
)
public List<PostSummaryDto> recentPosts(@RequestParam("minutes") int minutes) {
return postService
.listRecentPosts(minutes)
.stream()
.map(postMapper::toSummaryDto)
.collect(Collectors.toList());
}
if (hasCategories && hasTags) {
return postService.listPostsByCategoriesAndTags(ids, tids, page, pageSize)
.stream().map(postMapper::toSummaryDto).collect(Collectors.toList());
}
if (hasTags) {
return postService.listPostsByTags(tids, page, pageSize)
.stream().map(postMapper::toSummaryDto).collect(Collectors.toList());
}
@GetMapping("/ranking")
@Operation(summary = "Ranking posts", description = "List posts by view rankings")
@ApiResponse(
responseCode = "200",
description = "Ranked posts",
content = @Content(
array = @ArraySchema(schema = @Schema(implementation = PostSummaryDto.class))
)
)
public List<PostSummaryDto> rankingPosts(
@RequestParam(value = "categoryId", required = false) Long categoryId,
@RequestParam(value = "categoryIds", required = false) List<Long> categoryIds,
@RequestParam(value = "tagId", required = false) Long tagId,
@RequestParam(value = "tagIds", required = false) List<Long> tagIds,
@RequestParam(value = "page", required = false) Integer page,
@RequestParam(value = "pageSize", required = false) Integer pageSize,
Authentication auth
) {
List<Long> ids = categoryService.getSearchCategoryIds(categoryIds, categoryId);
List<Long> tids = tagService.getSearchTagIds(tagIds, tagId);
// 只需要在请求的一开始统计一次
// if (auth != null) {
// userVisitService.recordVisit(auth.getName());
// }
return postService.listPostsByCategories(ids, page, pageSize)
.stream().map(postMapper::toSummaryDto).collect(Collectors.toList());
}
return postService
.listPostsByViews(ids, tids, page, pageSize)
.stream()
.map(postMapper::toSummaryDto)
.collect(Collectors.toList());
}
@GetMapping("/ranking")
public List<PostSummaryDto> rankingPosts(@RequestParam(value = "categoryId", required = false) Long categoryId,
@RequestParam(value = "categoryIds", required = false) List<Long> categoryIds,
@RequestParam(value = "tagId", required = false) Long tagId,
@RequestParam(value = "tagIds", required = false) List<Long> tagIds,
@RequestParam(value = "page", required = false) Integer page,
@RequestParam(value = "pageSize", required = false) Integer pageSize,
Authentication auth) {
List<Long> ids = categoryIds;
if (categoryId != null) {
ids = java.util.List.of(categoryId);
}
List<Long> tids = tagIds;
if (tagId != null) {
tids = java.util.List.of(tagId);
}
@GetMapping("/latest-reply")
@Operation(summary = "Latest reply posts", description = "List posts by latest replies")
@ApiResponse(
responseCode = "200",
description = "Posts sorted by latest reply",
content = @Content(
array = @ArraySchema(schema = @Schema(implementation = PostSummaryDto.class))
)
)
@Cacheable(
value = CachingConfig.POST_CACHE_NAME,
key = "new org.springframework.cache.interceptor.SimpleKey('latest_reply', #categoryId, #categoryIds, #tagIds, #page, #pageSize)"
)
public List<PostSummaryDto> latestReplyPosts(
@RequestParam(value = "categoryId", required = false) Long categoryId,
@RequestParam(value = "categoryIds", required = false) List<Long> categoryIds,
@RequestParam(value = "tagId", required = false) Long tagId,
@RequestParam(value = "tagIds", required = false) List<Long> tagIds,
@RequestParam(value = "page", required = false) Integer page,
@RequestParam(value = "pageSize", required = false) Integer pageSize,
Authentication auth
) {
List<Long> ids = categoryService.getSearchCategoryIds(categoryIds, categoryId);
List<Long> tids = tagService.getSearchTagIds(tagIds, tagId);
// 只需要在请求的一开始统计一次
// if (auth != null) {
// userVisitService.recordVisit(auth.getName());
// }
if (auth != null) {
userVisitService.recordVisit(auth.getName());
}
List<Post> posts = postService.listPostsByLatestReply(ids, tids, page, pageSize);
return posts.stream().map(postMapper::toSummaryDto).collect(Collectors.toList());
}
return postService.listPostsByViews(ids, tids, page, pageSize)
.stream().map(postMapper::toSummaryDto).collect(Collectors.toList());
}
@GetMapping("/latest-reply")
public List<PostSummaryDto> latestReplyPosts(@RequestParam(value = "categoryId", required = false) Long categoryId,
@RequestParam(value = "categoryIds", required = false) List<Long> categoryIds,
@RequestParam(value = "tagId", required = false) Long tagId,
@RequestParam(value = "tagIds", required = false) List<Long> tagIds,
@RequestParam(value = "page", required = false) Integer page,
@RequestParam(value = "pageSize", required = false) Integer pageSize,
Authentication auth) {
List<Long> ids = categoryIds;
if (categoryId != null) {
ids = java.util.List.of(categoryId);
}
List<Long> tids = tagIds;
if (tagId != null) {
tids = java.util.List.of(tagId);
}
if (auth != null) {
userVisitService.recordVisit(auth.getName());
}
return postService.listPostsByLatestReply(ids, tids, page, pageSize)
.stream().map(postMapper::toSummaryDto).collect(Collectors.toList());
}
@GetMapping("/featured")
public List<PostSummaryDto> featuredPosts(@RequestParam(value = "categoryId", required = false) Long categoryId,
@RequestParam(value = "categoryIds", required = false) List<Long> categoryIds,
@RequestParam(value = "tagId", required = false) Long tagId,
@RequestParam(value = "tagIds", required = false) List<Long> tagIds,
@RequestParam(value = "page", required = false) Integer page,
@RequestParam(value = "pageSize", required = false) Integer pageSize,
Authentication auth) {
List<Long> ids = categoryIds;
if (categoryId != null) {
ids = java.util.List.of(categoryId);
}
List<Long> tids = tagIds;
if (tagId != null) {
tids = java.util.List.of(tagId);
}
if (auth != null) {
userVisitService.recordVisit(auth.getName());
}
return postService.listFeaturedPosts(ids, tids, page, pageSize)
.stream().map(postMapper::toSummaryDto).collect(Collectors.toList());
}
@GetMapping("/featured")
@Operation(summary = "Featured posts", description = "List featured posts")
@ApiResponse(
responseCode = "200",
description = "Featured posts",
content = @Content(
array = @ArraySchema(schema = @Schema(implementation = PostSummaryDto.class))
)
)
public List<PostSummaryDto> featuredPosts(
@RequestParam(value = "categoryId", required = false) Long categoryId,
@RequestParam(value = "categoryIds", required = false) List<Long> categoryIds,
@RequestParam(value = "tagId", required = false) Long tagId,
@RequestParam(value = "tagIds", required = false) List<Long> tagIds,
@RequestParam(value = "page", required = false) Integer page,
@RequestParam(value = "pageSize", required = false) Integer pageSize,
Authentication auth
) {
List<Long> ids = categoryService.getSearchCategoryIds(categoryIds, categoryId);
List<Long> tids = tagService.getSearchTagIds(tagIds, tagId);
// 只需要在请求的一开始统计一次
// if (auth != null) {
// userVisitService.recordVisit(auth.getName());
// }
return postService
.listFeaturedPosts(ids, tids, page, pageSize)
.stream()
.map(postMapper::toSummaryDto)
.collect(Collectors.toList());
}
}

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

@@ -3,6 +3,11 @@ package com.openisle.controller;
import com.openisle.dto.PushPublicKeyDto;
import com.openisle.dto.PushSubscriptionRequest;
import com.openisle.service.PushSubscriptionService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.Authentication;
@@ -12,19 +17,35 @@ import org.springframework.web.bind.annotation.*;
@RequestMapping("/api/push")
@RequiredArgsConstructor
public class PushSubscriptionController {
private final PushSubscriptionService pushSubscriptionService;
@Value("${app.webpush.public-key}")
private String publicKey;
@GetMapping("/public-key")
public PushPublicKeyDto getPublicKey() {
PushPublicKeyDto r = new PushPublicKeyDto();
r.setKey(publicKey);
return r;
}
private final PushSubscriptionService pushSubscriptionService;
@PostMapping("/subscribe")
public void subscribe(@RequestBody PushSubscriptionRequest req, Authentication auth) {
pushSubscriptionService.saveSubscription(auth.getName(), req.getEndpoint(), req.getP256dh(), req.getAuth());
}
@Value("${app.webpush.public-key}")
private String publicKey;
@GetMapping("/public-key")
@Operation(summary = "Get public key", description = "Retrieve web push public key")
@ApiResponse(
responseCode = "200",
description = "Public key",
content = @Content(schema = @Schema(implementation = PushPublicKeyDto.class))
)
public PushPublicKeyDto getPublicKey() {
PushPublicKeyDto r = new PushPublicKeyDto();
r.setKey(publicKey);
return r;
}
@PostMapping("/subscribe")
@Operation(summary = "Subscribe", description = "Subscribe to push notifications")
@ApiResponse(responseCode = "200", description = "Subscribed")
@SecurityRequirement(name = "JWT")
public void subscribe(@RequestBody PushSubscriptionRequest req, Authentication auth) {
pushSubscriptionService.saveSubscription(
auth.getName(),
req.getEndpoint(),
req.getP256dh(),
req.getAuth()
);
}
}

View File

@@ -8,6 +8,11 @@ import com.openisle.model.ReactionType;
import com.openisle.service.LevelService;
import com.openisle.service.PointService;
import com.openisle.service.ReactionService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
@@ -17,59 +22,93 @@ import org.springframework.web.bind.annotation.*;
@RequestMapping("/api")
@RequiredArgsConstructor
public class ReactionController {
private final ReactionService reactionService;
private final LevelService levelService;
private final ReactionMapper reactionMapper;
private final PointService pointService;
/**
* Get all available reaction types.
*/
@GetMapping("/reaction-types")
public ReactionType[] listReactionTypes() {
return ReactionType.values();
}
private final ReactionService reactionService;
private final LevelService levelService;
private final ReactionMapper reactionMapper;
private final PointService pointService;
@PostMapping("/posts/{postId}/reactions")
public ResponseEntity<ReactionDto> reactToPost(@PathVariable Long postId,
@RequestBody ReactionRequest req,
Authentication auth) {
Reaction reaction = reactionService.reactToPost(auth.getName(), postId, req.getType());
if (reaction == null) {
pointService.deductForReactionOfPost(auth.getName(), postId);
return ResponseEntity.noContent().build();
}
ReactionDto dto = reactionMapper.toDto(reaction);
dto.setReward(levelService.awardForReaction(auth.getName()));
pointService.awardForReactionOfPost(auth.getName(), postId);
return ResponseEntity.ok(dto);
}
/**
* Get all available reaction types.
*/
@GetMapping("/reaction-types")
@Operation(summary = "List reaction types", description = "Get all available reaction types")
@ApiResponse(
responseCode = "200",
description = "Reaction types",
content = @Content(schema = @Schema(implementation = ReactionType[].class))
)
public ReactionType[] listReactionTypes() {
return ReactionType.values();
}
@PostMapping("/comments/{commentId}/reactions")
public ResponseEntity<ReactionDto> reactToComment(@PathVariable Long commentId,
@RequestBody ReactionRequest req,
Authentication auth) {
Reaction reaction = reactionService.reactToComment(auth.getName(), commentId, req.getType());
if (reaction == null) {
pointService.deductForReactionOfComment(auth.getName(), commentId);
return ResponseEntity.noContent().build();
}
ReactionDto dto = reactionMapper.toDto(reaction);
dto.setReward(levelService.awardForReaction(auth.getName()));
pointService.awardForReactionOfComment(auth.getName(), commentId);
return ResponseEntity.ok(dto);
@PostMapping("/posts/{postId}/reactions")
@Operation(summary = "React to post", description = "React to a post")
@ApiResponse(
responseCode = "200",
description = "Reaction result",
content = @Content(schema = @Schema(implementation = ReactionDto.class))
)
@SecurityRequirement(name = "JWT")
public ResponseEntity<ReactionDto> reactToPost(
@PathVariable Long postId,
@RequestBody ReactionRequest req,
Authentication auth
) {
Reaction reaction = reactionService.reactToPost(auth.getName(), postId, req.getType());
if (reaction == null) {
pointService.deductForReactionOfPost(auth.getName(), postId);
return ResponseEntity.noContent().build();
}
ReactionDto dto = reactionMapper.toDto(reaction);
dto.setReward(levelService.awardForReaction(auth.getName()));
pointService.awardForReactionOfPost(auth.getName(), postId);
return ResponseEntity.ok(dto);
}
@PostMapping("/messages/{messageId}/reactions")
public ResponseEntity<ReactionDto> reactToMessage(@PathVariable Long messageId,
@RequestBody ReactionRequest req,
Authentication auth) {
Reaction reaction = reactionService.reactToMessage(auth.getName(), messageId, req.getType());
if (reaction == null) {
return ResponseEntity.noContent().build();
}
ReactionDto dto = reactionMapper.toDto(reaction);
dto.setReward(levelService.awardForReaction(auth.getName()));
return ResponseEntity.ok(dto);
@PostMapping("/comments/{commentId}/reactions")
@Operation(summary = "React to comment", description = "React to a comment")
@ApiResponse(
responseCode = "200",
description = "Reaction result",
content = @Content(schema = @Schema(implementation = ReactionDto.class))
)
@SecurityRequirement(name = "JWT")
public ResponseEntity<ReactionDto> reactToComment(
@PathVariable Long commentId,
@RequestBody ReactionRequest req,
Authentication auth
) {
Reaction reaction = reactionService.reactToComment(auth.getName(), commentId, req.getType());
if (reaction == null) {
pointService.deductForReactionOfComment(auth.getName(), commentId);
return ResponseEntity.noContent().build();
}
ReactionDto dto = reactionMapper.toDto(reaction);
dto.setReward(levelService.awardForReaction(auth.getName()));
pointService.awardForReactionOfComment(auth.getName(), commentId);
return ResponseEntity.ok(dto);
}
@PostMapping("/messages/{messageId}/reactions")
@Operation(summary = "React to message", description = "React to a message")
@ApiResponse(
responseCode = "200",
description = "Reaction result",
content = @Content(schema = @Schema(implementation = ReactionDto.class))
)
@SecurityRequirement(name = "JWT")
public ResponseEntity<ReactionDto> reactToMessage(
@PathVariable Long messageId,
@RequestBody ReactionRequest req,
Authentication auth
) {
Reaction reaction = reactionService.reactToMessage(auth.getName(), messageId, req.getType());
if (reaction == null) {
return ResponseEntity.noContent().build();
}
ReactionDto dto = reactionMapper.toDto(reaction);
dto.setReward(levelService.awardForReaction(auth.getName()));
return ResponseEntity.ok(dto);
}
}

View File

@@ -1,10 +1,28 @@
package com.openisle.controller;
import com.openisle.model.Post;
import com.openisle.model.Comment;
import com.openisle.model.CommentSort;
import com.openisle.service.PostService;
import com.openisle.model.Post;
import com.openisle.service.CommentService;
import com.openisle.service.PostService;
import com.vladsch.flexmark.ext.autolink.AutolinkExtension;
import com.vladsch.flexmark.ext.gfm.strikethrough.StrikethroughExtension;
import com.vladsch.flexmark.ext.gfm.tasklist.TaskListExtension;
import com.vladsch.flexmark.ext.tables.TablesExtension;
import com.vladsch.flexmark.html.HtmlRenderer;
import com.vladsch.flexmark.parser.Parser;
import com.vladsch.flexmark.util.data.MutableDataSet;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import java.net.URI;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.util.*;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import lombok.RequiredArgsConstructor;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
@@ -14,339 +32,375 @@ import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import com.vladsch.flexmark.ext.autolink.AutolinkExtension;
import com.vladsch.flexmark.ext.tables.TablesExtension;
import com.vladsch.flexmark.ext.gfm.strikethrough.StrikethroughExtension;
import com.vladsch.flexmark.ext.gfm.tasklist.TaskListExtension;
import com.vladsch.flexmark.html.HtmlRenderer;
import com.vladsch.flexmark.parser.Parser;
import com.vladsch.flexmark.util.data.MutableDataSet;
import java.net.URI;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.util.*;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@RestController
@RequiredArgsConstructor
public class RssController {
private final PostService postService;
private final CommentService commentService;
@Value("${app.website-url:https://www.open-isle.com}")
private String websiteUrl;
private final PostService postService;
private final CommentService commentService;
// 兼容 Markdown/HTML 两类图片写法(用于 enclosure
private static final Pattern MD_IMAGE = Pattern.compile("!\\[[^\\]]*\\]\\(([^)]+)\\)");
private static final Pattern HTML_IMAGE = Pattern.compile("<BaseImage[^>]+src=[\"']?([^\"'>]+)[\"']?[^>]*>");
@Value("${app.website-url:https://www.open-isle.com}")
private String websiteUrl;
private static final DateTimeFormatter RFC1123 = DateTimeFormatter.RFC_1123_DATE_TIME;
// 兼容 Markdown/HTML 两类图片写法(用于 enclosure
private static final Pattern MD_IMAGE = Pattern.compile("!\\[[^\\]]*\\]\\(([^)]+)\\)");
private static final Pattern HTML_IMAGE = Pattern.compile(
"<BaseImage[^>]+src=[\"']?([^\"'>]+)[\"']?[^>]*>"
);
// flexmarkMarkdown -> HTML
private static final Parser MD_PARSER;
private static final HtmlRenderer MD_RENDERER;
static {
MutableDataSet opts = new MutableDataSet();
opts.set(Parser.EXTENSIONS, Arrays.asList(
TablesExtension.create(),
AutolinkExtension.create(),
StrikethroughExtension.create(),
TaskListExtension.create()
));
// 允许内联 HTML下游再做 sanitize
opts.set(Parser.HTML_BLOCK_PARSER, true);
MD_PARSER = Parser.builder(opts).build();
MD_RENDERER = HtmlRenderer.builder(opts).escapeHtml(false).build();
private static final DateTimeFormatter RFC1123 = DateTimeFormatter.RFC_1123_DATE_TIME;
// flexmarkMarkdown -> HTML
private static final Parser MD_PARSER;
private static final HtmlRenderer MD_RENDERER;
static {
MutableDataSet opts = new MutableDataSet();
opts.set(
Parser.EXTENSIONS,
Arrays.asList(
TablesExtension.create(),
AutolinkExtension.create(),
StrikethroughExtension.create(),
TaskListExtension.create()
)
);
// 允许内联 HTML下游再做 sanitize
opts.set(Parser.HTML_BLOCK_PARSER, true);
MD_PARSER = Parser.builder(opts).build();
MD_RENDERER = HtmlRenderer.builder(opts).escapeHtml(false).build();
}
@GetMapping(value = "/api/rss", produces = "application/rss+xml;charset=UTF-8")
@Operation(summary = "RSS feed", description = "Generate RSS feed for latest posts")
@ApiResponse(
responseCode = "200",
description = "RSS XML",
content = @Content(schema = @Schema(implementation = String.class))
)
public String feed() {
// 建议 20你现在是 10这里保留你的 10
List<Post> posts = postService.listLatestRssPosts(10);
String base = trimTrailingSlash(websiteUrl);
StringBuilder sb = new StringBuilder(4096);
sb.append("<?xml version=\"1.0\" encoding=\"UTF-8\"?>");
sb.append("<rss version=\"2.0\" xmlns:content=\"http://purl.org/rss/1.0/modules/content/\">");
sb.append("<channel>");
elem(sb, "title", cdata("OpenIsle RSS"));
elem(sb, "link", base + "/");
elem(sb, "description", cdata("Latest posts"));
ZonedDateTime updated = posts
.stream()
.map(p -> p.getCreatedAt().atZone(ZoneId.systemDefault()))
.max(Comparator.naturalOrder())
.orElse(ZonedDateTime.now());
// channel lastBuildDateGMT
elem(sb, "lastBuildDate", toRfc1123Gmt(updated));
for (Post p : posts) {
String link = base + "/posts/" + p.getId();
// 1) Markdown -> HTML
String html = renderMarkdown(p.getContent());
// 2) Sanitize白名单增强
String safeHtml = sanitizeHtml(html);
// 3) 绝对化 href/src + 强制 rel/target
String absHtml = absolutifyHtml(safeHtml, base);
// 4) 纯文本摘要(用于 <description>
String plain = textSummary(absHtml, 180);
// 5) enclosure首图已绝对化
String enclosure = firstImage(p.getContent());
if (enclosure == null) {
// 如果 Markdown 没有图,尝试从渲染后的 HTML 再抓一次
enclosure = firstImage(absHtml);
}
if (enclosure != null) {
enclosure = absolutifyUrl(enclosure, base);
}
// 6) 构造优雅的附加区块(原文链接 + 精选评论),编入 <content:encoded>
List<Comment> topComments = commentService.getCommentsForPost(
p.getId(),
CommentSort.MOST_INTERACTIONS
);
topComments = topComments.subList(0, Math.min(10, topComments.size()));
String footerHtml = buildFooterHtml(base, link, topComments);
sb.append("<item>");
elem(sb, "title", cdata(nullSafe(p.getTitle())));
elem(sb, "link", link);
sb.append("<guid isPermaLink=\"true\">").append(link).append("</guid>");
elem(sb, "pubDate", toRfc1123Gmt(p.getCreatedAt().atZone(ZoneId.systemDefault())));
// 摘要
elem(sb, "description", cdata(plain));
// 全文HTML正文 + 优雅的 Markdown 区块(已转 HTML
sb
.append("<content:encoded><![CDATA[")
.append(absHtml)
.append(footerHtml)
.append("]]></content:encoded>");
// 首图 enclosure图片类型
if (enclosure != null) {
sb
.append("<enclosure url=\"")
.append(escapeXml(enclosure))
.append("\" type=\"")
.append(getMimeType(enclosure))
.append("\" />");
}
sb.append("</item>");
}
@GetMapping(value = "/api/rss", produces = "application/rss+xml;charset=UTF-8")
public String feed() {
// 建议 20你现在是 10这里保留你的 10
List<Post> posts = postService.listLatestRssPosts(10);
String base = trimTrailingSlash(websiteUrl);
sb.append("</channel></rss>");
return sb.toString();
}
StringBuilder sb = new StringBuilder(4096);
sb.append("<?xml version=\"1.0\" encoding=\"UTF-8\"?>");
sb.append("<rss version=\"2.0\" xmlns:content=\"http://purl.org/rss/1.0/modules/content/\">");
sb.append("<channel>");
elem(sb, "title", cdata("OpenIsle RSS"));
elem(sb, "link", base + "/");
elem(sb, "description", cdata("Latest posts"));
ZonedDateTime updated = posts.stream()
.map(p -> p.getCreatedAt().atZone(ZoneId.systemDefault()))
.max(Comparator.naturalOrder())
.orElse(ZonedDateTime.now());
// channel lastBuildDateGMT
elem(sb, "lastBuildDate", toRfc1123Gmt(updated));
/* ===================== Markdown → HTML ===================== */
for (Post p : posts) {
String link = base + "/posts/" + p.getId();
private static String renderMarkdown(String md) {
if (md == null || md.isEmpty()) return "";
return MD_RENDERER.render(MD_PARSER.parse(md));
}
// 1) Markdown -> HTML
String html = renderMarkdown(p.getContent());
/* ===================== Sanitize & 绝对化 ===================== */
// 2) Sanitize白名单增强
String safeHtml = sanitizeHtml(html);
private static String sanitizeHtml(String html) {
if (html == null) return "";
Safelist wl = Safelist.relaxed()
.addTags(
"pre",
"code",
"figure",
"figcaption",
"picture",
"source",
"table",
"thead",
"tbody",
"tr",
"th",
"td",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"hr",
"blockquote"
)
.addAttributes("a", "href", "title", "target", "rel")
.addAttributes("img", "src", "alt", "title", "width", "height")
.addAttributes("source", "srcset", "type", "media")
.addAttributes("code", "class")
.addAttributes("pre", "class")
.addProtocols("a", "href", "http", "https", "mailto")
.addProtocols("img", "src", "http", "https", "data")
.addProtocols("source", "srcset", "http", "https");
// 清除所有 on* 事件、style避免阅读器环境差异
return Jsoup.clean(html, wl);
}
// 3) 绝对化 href/src + 强制 rel/target
String absHtml = absolutifyHtml(safeHtml, base);
private static String absolutifyHtml(String html, String baseUrl) {
if (html == null || html.isEmpty()) return "";
Document doc = Jsoup.parseBodyFragment(html, baseUrl);
// a[href]
for (Element a : doc.select("a[href]")) {
String href = a.attr("href");
String abs = absolutifyUrl(href, baseUrl);
a.attr("href", abs);
// 强制外链安全属性
a.attr("rel", "noopener noreferrer nofollow");
a.attr("target", "_blank");
}
// img[src]
for (Element img : doc.select("img[src]")) {
String src = img.attr("src");
String abs = absolutifyUrl(src, baseUrl);
img.attr("src", abs);
}
// source[srcset] picture/webp
for (Element s : doc.select("source[srcset]")) {
String srcset = s.attr("srcset");
s.attr("srcset", absolutifySrcset(srcset, baseUrl));
}
return doc.body().html();
}
// 4) 纯文本摘要(用于 <description>
String plain = textSummary(absHtml, 180);
private static String absolutifyUrl(String url, String baseUrl) {
if (url == null || url.isEmpty()) return url;
String u = url.trim();
if (u.startsWith("//")) {
return "https:" + u;
}
if (u.startsWith("#")) {
// 保留页面内锚点:拼接到首页(也可拼接到当前帖子的 link但此处无上下文
return baseUrl + "/" + u;
}
try {
URI base = URI.create(ensureTrailingSlash(baseUrl));
URI abs = base.resolve(u);
return abs.toString();
} catch (Exception e) {
return url;
}
}
// 5) enclosure首图已绝对化
String enclosure = firstImage(p.getContent());
if (enclosure == null) {
// 如果 Markdown 没有图,尝试从渲染后的 HTML 再抓一次
enclosure = firstImage(absHtml);
}
if (enclosure != null) {
enclosure = absolutifyUrl(enclosure, base);
}
private static String absolutifySrcset(String srcset, String baseUrl) {
if (srcset == null || srcset.isEmpty()) return srcset;
String[] parts = srcset.split(",");
List<String> out = new ArrayList<>(parts.length);
for (String part : parts) {
String p = part.trim();
if (p.isEmpty()) continue;
String[] seg = p.split("\\s+");
String url = seg[0];
String size = seg.length > 1 ? seg[1] : "";
out.add(absolutifyUrl(url, baseUrl) + (size.isEmpty() ? "" : " " + size));
}
return String.join(", ", out);
}
// 6) 构造优雅的附加区块(原文链接 + 精选评论),编入 <content:encoded>
List<Comment> topComments = commentService
.getCommentsForPost(p.getId(), CommentSort.MOST_INTERACTIONS);
topComments = topComments.subList(0, Math.min(10, topComments.size()));
String footerHtml = buildFooterHtml(base, link, topComments);
/* ===================== 摘要 & enclosure ===================== */
sb.append("<item>");
elem(sb, "title", cdata(nullSafe(p.getTitle())));
elem(sb, "link", link);
sb.append("<guid isPermaLink=\"true\">").append(link).append("</guid>");
elem(sb, "pubDate", toRfc1123Gmt(p.getCreatedAt().atZone(ZoneId.systemDefault())));
// 摘要
elem(sb, "description", cdata(plain));
// 全文HTML正文 + 优雅的 Markdown 区块(已转 HTML
sb.append("<content:encoded><![CDATA[")
.append(absHtml)
.append(footerHtml)
.append("]]></content:encoded>");
// 首图 enclosure图片类型
if (enclosure != null) {
sb.append("<enclosure url=\"").append(escapeXml(enclosure)).append("\" type=\"")
.append(getMimeType(enclosure)).append("\" />");
}
sb.append("</item>");
}
private static String textSummary(String html, int maxLen) {
if (html == null) return "";
String text = Jsoup.parse(html).text().replaceAll("\\s+", " ").trim();
if (text.length() <= maxLen) return text;
return text.substring(0, maxLen) + "";
}
sb.append("</channel></rss>");
return sb.toString();
private String firstImage(String content) {
if (content == null) return null;
Matcher m = MD_IMAGE.matcher(content);
if (m.find()) return m.group(1);
m = HTML_IMAGE.matcher(content);
if (m.find()) return m.group(1);
// 再从纯 HTML 里解析一次(如果传入的是渲染后的)
try {
Document doc = Jsoup.parse(content);
Element img = doc.selectFirst("img[src]");
if (img != null) return img.attr("src");
} catch (Exception ignored) {}
return null;
}
private static String getMimeType(String url) {
String lower = url == null ? "" : url.toLowerCase(Locale.ROOT);
if (lower.endsWith(".png")) return "image/png";
if (lower.endsWith(".gif")) return "image/gif";
if (lower.endsWith(".webp")) return "image/webp";
if (lower.endsWith(".svg")) return "image/svg+xml";
if (lower.endsWith(".avif")) return "image/avif";
if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg";
// 默认兜底
return "image/jpeg";
}
/* ===================== 附加区块(原文链接 + 精选评论) ===================== */
/**
* 将“原文链接 + 精选评论(最多 10 条)”以优雅的 Markdown 形式渲染为 HTML
* 并做 sanitize + 绝对化,然后拼入 content:encoded 尾部。
*/
private static String buildFooterHtml(
String baseUrl,
String originalLink,
List<Comment> topComments
) {
StringBuilder md = new StringBuilder(256);
// 分割线
md.append("\n\n---\n\n");
// 原文链接(强调 + 可点击)
md
.append("**原文链接:** ")
.append("[")
.append(originalLink)
.append("](")
.append(originalLink)
.append(")")
.append("\n\n");
// 精选评论(仅当有评论时展示)
if (topComments != null && !topComments.isEmpty()) {
md.append("### 精选评论Top ").append(Math.min(10, topComments.size())).append("\n\n");
for (Comment c : topComments) {
String author = usernameOf(c);
String content = nullSafe(c.getContent()).replace("\r", "");
// 使用引用样式展示,提升可读性
md.append("> @").append(author).append(": ").append(content).append("\n\n");
}
}
/* ===================== Markdown → HTML ===================== */
// 渲染为 HTML并保持和正文一致的处理流程
String html = renderMarkdown(md.toString());
String safe = sanitizeHtml(html);
return absolutifyHtml(safe, baseUrl);
}
private static String renderMarkdown(String md) {
if (md == null || md.isEmpty()) return "";
return MD_RENDERER.render(MD_PARSER.parse(md));
private static String usernameOf(Comment c) {
if (c == null) return "匿名";
try {
Object authorObj = c.getAuthor();
if (authorObj == null) return "匿名";
// 反射避免直接依赖实体字段名变化(也可直接强转到具体类型)
String username;
try {
username = (String) authorObj.getClass().getMethod("getUsername").invoke(authorObj);
} catch (Exception e) {
username = null;
}
if (username == null || username.isEmpty()) return "匿名";
return username;
} catch (Exception ignored) {
return "匿名";
}
}
/* ===================== Sanitize & 绝对化 ===================== */
/* ===================== 时间/字符串/XML ===================== */
private static String sanitizeHtml(String html) {
if (html == null) return "";
Safelist wl = Safelist.relaxed()
.addTags(
"pre","code","figure","figcaption","picture","source",
"table","thead","tbody","tr","th","td",
"h1","h2","h3","h4","h5","h6",
"hr","blockquote"
)
.addAttributes("a", "href", "title", "target", "rel")
.addAttributes("img", "src", "alt", "title", "width", "height")
.addAttributes("source", "srcset", "type", "media")
.addAttributes("code", "class")
.addAttributes("pre", "class")
.addProtocols("a", "href", "http", "https", "mailto")
.addProtocols("img", "src", "http", "https", "data")
.addProtocols("source", "srcset", "http", "https");
// 清除所有 on* 事件、style避免阅读器环境差异
return Jsoup.clean(html, wl);
}
private static String toRfc1123Gmt(ZonedDateTime zdt) {
return zdt.withZoneSameInstant(ZoneId.of("GMT")).format(RFC1123);
}
private static String absolutifyHtml(String html, String baseUrl) {
if (html == null || html.isEmpty()) return "";
Document doc = Jsoup.parseBodyFragment(html, baseUrl);
// a[href]
for (Element a : doc.select("a[href]")) {
String href = a.attr("href");
String abs = absolutifyUrl(href, baseUrl);
a.attr("href", abs);
// 强制外链安全属性
a.attr("rel", "noopener noreferrer nofollow");
a.attr("target", "_blank");
}
// img[src]
for (Element img : doc.select("img[src]")) {
String src = img.attr("src");
String abs = absolutifyUrl(src, baseUrl);
img.attr("src", abs);
}
// source[srcset] picture/webp
for (Element s : doc.select("source[srcset]")) {
String srcset = s.attr("srcset");
s.attr("srcset", absolutifySrcset(srcset, baseUrl));
}
return doc.body().html();
}
private static String cdata(String s) {
if (s == null) return "<![CDATA[]]>";
// 防止出现 "]]>" 终止标记破坏 CDATA
return "<![CDATA[" + s.replace("]]>", "]]]]><![CDATA[>") + "]]>";
}
private static String absolutifyUrl(String url, String baseUrl) {
if (url == null || url.isEmpty()) return url;
String u = url.trim();
if (u.startsWith("//")) {
return "https:" + u;
}
if (u.startsWith("#")) {
// 保留页面内锚点:拼接到首页(也可拼接到当前帖子的 link但此处无上下文
return baseUrl + "/" + u;
}
try {
URI base = URI.create(ensureTrailingSlash(baseUrl));
URI abs = base.resolve(u);
return abs.toString();
} catch (Exception e) {
return url;
}
}
private static void elem(StringBuilder sb, String name, String value) {
sb.append('<').append(name).append('>').append(value).append("</").append(name).append('>');
}
private static String absolutifySrcset(String srcset, String baseUrl) {
if (srcset == null || srcset.isEmpty()) return srcset;
String[] parts = srcset.split(",");
List<String> out = new ArrayList<>(parts.length);
for (String part : parts) {
String p = part.trim();
if (p.isEmpty()) continue;
String[] seg = p.split("\\s+");
String url = seg[0];
String size = seg.length > 1 ? seg[1] : "";
out.add(absolutifyUrl(url, baseUrl) + (size.isEmpty() ? "" : " " + size));
}
return String.join(", ", out);
}
private static String escapeXml(String s) {
if (s == null) return "";
return s
.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;")
.replace("'", "&apos;");
}
/* ===================== 摘要 & enclosure ===================== */
private static String trimTrailingSlash(String s) {
if (s == null) return "";
return s.endsWith("/") ? s.substring(0, s.length() - 1) : s;
}
private static String textSummary(String html, int maxLen) {
if (html == null) return "";
String text = Jsoup.parse(html).text().replaceAll("\\s+", " ").trim();
if (text.length() <= maxLen) return text;
return text.substring(0, maxLen) + "";
}
private static String ensureTrailingSlash(String s) {
if (s == null || s.isEmpty()) return "/";
return s.endsWith("/") ? s : s + "/";
}
private String firstImage(String content) {
if (content == null) return null;
Matcher m = MD_IMAGE.matcher(content);
if (m.find()) return m.group(1);
m = HTML_IMAGE.matcher(content);
if (m.find()) return m.group(1);
// 再从纯 HTML 里解析一次(如果传入的是渲染后的)
try {
Document doc = Jsoup.parse(content);
Element img = doc.selectFirst("img[src]");
if (img != null) return img.attr("src");
} catch (Exception ignored) {}
return null;
}
private static String getMimeType(String url) {
String lower = url == null ? "" : url.toLowerCase(Locale.ROOT);
if (lower.endsWith(".png")) return "image/png";
if (lower.endsWith(".gif")) return "image/gif";
if (lower.endsWith(".webp")) return "image/webp";
if (lower.endsWith(".svg")) return "image/svg+xml";
if (lower.endsWith(".avif")) return "image/avif";
if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg";
// 默认兜底
return "image/jpeg";
}
/* ===================== 附加区块(原文链接 + 精选评论) ===================== */
/**
* 将“原文链接 + 精选评论(最多 10 条)”以优雅的 Markdown 形式渲染为 HTML
* 并做 sanitize + 绝对化,然后拼入 content:encoded 尾部。
*/
private static String buildFooterHtml(String baseUrl, String originalLink, List<Comment> topComments) {
StringBuilder md = new StringBuilder(256);
// 分割线
md.append("\n\n---\n\n");
// 原文链接(强调 + 可点击)
md.append("**原文链接:** ")
.append("[").append(originalLink).append("](").append(originalLink).append(")")
.append("\n\n");
// 精选评论(仅当有评论时展示)
if (topComments != null && !topComments.isEmpty()) {
md.append("### 精选评论Top ").append(Math.min(10, topComments.size())).append("\n\n");
for (Comment c : topComments) {
String author = usernameOf(c);
String content = nullSafe(c.getContent()).replace("\r", "");
// 使用引用样式展示,提升可读性
md.append("> @").append(author).append(": ").append(content).append("\n\n");
}
}
// 渲染为 HTML并保持和正文一致的处理流程
String html = renderMarkdown(md.toString());
String safe = sanitizeHtml(html);
return absolutifyHtml(safe, baseUrl);
}
private static String usernameOf(Comment c) {
if (c == null) return "匿名";
try {
Object authorObj = c.getAuthor();
if (authorObj == null) return "匿名";
// 反射避免直接依赖实体字段名变化(也可直接强转到具体类型)
String username;
try {
username = (String) authorObj.getClass().getMethod("getUsername").invoke(authorObj);
} catch (Exception e) {
username = null;
}
if (username == null || username.isEmpty()) return "匿名";
return username;
} catch (Exception ignored) {
return "匿名";
}
}
/* ===================== 时间/字符串/XML ===================== */
private static String toRfc1123Gmt(ZonedDateTime zdt) {
return zdt.withZoneSameInstant(ZoneId.of("GMT")).format(RFC1123);
}
private static String cdata(String s) {
if (s == null) return "<![CDATA[]]>";
// 防止出现 "]]>" 终止标记破坏 CDATA
return "<![CDATA[" + s.replace("]]>", "]]]]><![CDATA[>") + "]]>";
}
private static void elem(StringBuilder sb, String name, String value) {
sb.append('<').append(name).append('>').append(value).append("</").append(name).append('>');
}
private static String escapeXml(String s) {
if (s == null) return "";
return s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
.replace("\"", "&quot;").replace("'", "&apos;");
}
private static String trimTrailingSlash(String s) {
if (s == null) return "";
return s.endsWith("/") ? s.substring(0, s.length() - 1) : s;
}
private static String ensureTrailingSlash(String s) {
if (s == null || s.isEmpty()) return "/";
return s.endsWith("/") ? s : s + "/";
}
private static String nullSafe(String s) { return s == null ? "" : s; }
private static String nullSafe(String s) {
return s == null ? "" : s;
}
}

View File

@@ -6,64 +6,120 @@ import com.openisle.dto.UserDto;
import com.openisle.mapper.PostMapper;
import com.openisle.mapper.UserMapper;
import com.openisle.service.SearchService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.ArraySchema;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import java.util.List;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.stream.Collectors;
@RestController
@RequestMapping("/api/search")
@RequiredArgsConstructor
public class SearchController {
private final SearchService searchService;
private final UserMapper userMapper;
private final PostMapper postMapper;
@GetMapping("/users")
public List<UserDto> searchUsers(@RequestParam String keyword) {
return searchService.searchUsers(keyword).stream()
.map(userMapper::toDto)
.collect(Collectors.toList());
}
private final SearchService searchService;
private final UserMapper userMapper;
private final PostMapper postMapper;
@GetMapping("/posts")
public List<PostSummaryDto> searchPosts(@RequestParam String keyword) {
return searchService.searchPosts(keyword).stream()
.map(postMapper::toSummaryDto)
.collect(Collectors.toList());
}
@GetMapping("/users")
@Operation(summary = "Search users", description = "Search users by keyword")
@ApiResponse(
responseCode = "200",
description = "List of users",
content = @Content(array = @ArraySchema(schema = @Schema(implementation = UserDto.class)))
)
public List<UserDto> searchUsers(@RequestParam String keyword) {
return searchService
.searchUsers(keyword)
.stream()
.map(userMapper::toDto)
.collect(Collectors.toList());
}
@GetMapping("/posts/content")
public List<PostSummaryDto> searchPostsByContent(@RequestParam String keyword) {
return searchService.searchPostsByContent(keyword).stream()
.map(postMapper::toSummaryDto)
.collect(Collectors.toList());
}
@GetMapping("/posts")
@Operation(summary = "Search posts", description = "Search posts by keyword")
@ApiResponse(
responseCode = "200",
description = "List of posts",
content = @Content(
array = @ArraySchema(schema = @Schema(implementation = PostSummaryDto.class))
)
)
public List<PostSummaryDto> searchPosts(@RequestParam String keyword) {
return searchService
.searchPosts(keyword)
.stream()
.map(postMapper::toSummaryDto)
.collect(Collectors.toList());
}
@GetMapping("/posts/title")
public List<PostSummaryDto> searchPostsByTitle(@RequestParam String keyword) {
return searchService.searchPostsByTitle(keyword).stream()
.map(postMapper::toSummaryDto)
.collect(Collectors.toList());
}
@GetMapping("/posts/content")
@Operation(summary = "Search posts by content", description = "Search posts by content keyword")
@ApiResponse(
responseCode = "200",
description = "List of posts",
content = @Content(
array = @ArraySchema(schema = @Schema(implementation = PostSummaryDto.class))
)
)
public List<PostSummaryDto> searchPostsByContent(@RequestParam String keyword) {
return searchService
.searchPostsByContent(keyword)
.stream()
.map(postMapper::toSummaryDto)
.collect(Collectors.toList());
}
@GetMapping("/global")
public List<SearchResultDto> global(@RequestParam String keyword) {
return searchService.globalSearch(keyword).stream()
.map(r -> {
SearchResultDto dto = new SearchResultDto();
dto.setType(r.type());
dto.setId(r.id());
dto.setText(r.text());
dto.setSubText(r.subText());
dto.setExtra(r.extra());
dto.setPostId(r.postId());
return dto;
})
.collect(Collectors.toList());
}
@GetMapping("/posts/title")
@Operation(summary = "Search posts by title", description = "Search posts by title keyword")
@ApiResponse(
responseCode = "200",
description = "List of posts",
content = @Content(
array = @ArraySchema(schema = @Schema(implementation = PostSummaryDto.class))
)
)
public List<PostSummaryDto> searchPostsByTitle(@RequestParam String keyword) {
return searchService
.searchPostsByTitle(keyword)
.stream()
.map(postMapper::toSummaryDto)
.collect(Collectors.toList());
}
@GetMapping("/global")
@Operation(summary = "Global search", description = "Search users and posts globally")
@ApiResponse(
responseCode = "200",
description = "Search results",
content = @Content(
array = @ArraySchema(schema = @Schema(implementation = SearchResultDto.class))
)
)
public List<SearchResultDto> global(@RequestParam String keyword) {
return searchService
.globalSearch(keyword)
.stream()
.map(r -> {
SearchResultDto dto = new SearchResultDto();
dto.setType(r.type());
dto.setId(r.id());
dto.setText(r.text());
dto.setSubText(r.subText());
dto.setExtra(r.extra());
dto.setPostId(r.postId());
dto.setHighlightedText(r.highlightedText());
dto.setHighlightedSubText(r.highlightedSubText());
dto.setHighlightedExtra(r.highlightedExtra());
return dto;
})
.collect(Collectors.toList());
}
}

View File

@@ -3,6 +3,11 @@ package com.openisle.controller;
import com.openisle.model.Post;
import com.openisle.model.PostStatus;
import com.openisle.repository.PostRepository;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.MediaType;
@@ -11,8 +16,6 @@ import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
* Controller for dynamic sitemap generation.
*/
@@ -20,50 +23,47 @@ import java.util.List;
@RequiredArgsConstructor
@RequestMapping("/api")
public class SitemapController {
private final PostRepository postRepository;
@Value("${app.website-url}")
private String websiteUrl;
private final PostRepository postRepository;
@GetMapping(value = "/sitemap.xml", produces = MediaType.APPLICATION_XML_VALUE)
public ResponseEntity<String> sitemap() {
List<Post> posts = postRepository.findByStatus(PostStatus.PUBLISHED);
@Value("${app.website-url}")
private String websiteUrl;
StringBuilder body = new StringBuilder();
body.append("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
body.append("<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">\n");
@GetMapping(value = "/sitemap.xml", produces = MediaType.APPLICATION_XML_VALUE)
@Operation(summary = "Sitemap", description = "Generate sitemap xml")
@ApiResponse(
responseCode = "200",
description = "Sitemap xml",
content = @Content(schema = @Schema(implementation = String.class))
)
public ResponseEntity<String> sitemap() {
List<Post> posts = postRepository.findByStatus(PostStatus.PUBLISHED);
List<String> staticRoutes = List.of(
"/",
"/about",
"/activities",
"/login",
"/signup"
);
StringBuilder body = new StringBuilder();
body.append("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
body.append("<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">\n");
for (String path : staticRoutes) {
body.append(" <url><loc>")
.append(websiteUrl)
.append(path)
.append("</loc></url>\n");
}
List<String> staticRoutes = List.of("/", "/about", "/activities", "/login", "/signup");
for (Post p : posts) {
body.append(" <url>\n")
.append(" <loc>")
.append(websiteUrl)
.append("/posts/")
.append(p.getId())
.append("</loc>\n")
.append(" <lastmod>")
.append(p.getCreatedAt().toLocalDate())
.append("</lastmod>\n")
.append(" </url>\n");
}
body.append("</urlset>");
return ResponseEntity.ok()
.contentType(MediaType.APPLICATION_XML)
.body(body.toString());
for (String path : staticRoutes) {
body.append(" <url><loc>").append(websiteUrl).append(path).append("</loc></url>\n");
}
for (Post p : posts) {
body
.append(" <url>\n")
.append(" <loc>")
.append(websiteUrl)
.append("/posts/")
.append(p.getId())
.append("</loc>\n")
.append(" <lastmod>")
.append(p.getCreatedAt().toLocalDate())
.append("</lastmod>\n")
.append(" </url>\n");
}
body.append("</urlset>");
return ResponseEntity.ok().contentType(MediaType.APPLICATION_XML).body(body.toString());
}
}

View File

@@ -1,7 +1,15 @@
package com.openisle.controller;
import com.openisle.service.UserVisitService;
import com.openisle.service.StatService;
import com.openisle.service.UserVisitService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.ArraySchema;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import java.time.LocalDate;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.web.bind.annotation.GetMapping;
@@ -9,77 +17,111 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.time.LocalDate;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/stats")
@RequiredArgsConstructor
public class StatController {
private final UserVisitService userVisitService;
private final StatService statService;
@GetMapping("/dau")
public Map<String, Long> dau(@RequestParam(value = "date", required = false)
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date) {
long count = userVisitService.countDau(date);
return Map.of("dau", count);
}
private final UserVisitService userVisitService;
private final StatService statService;
@GetMapping("/dau-range")
public List<Map<String, Object>> dauRange(@RequestParam(value = "days", defaultValue = "30") int days) {
if (days < 1) days = 1;
LocalDate end = LocalDate.now();
LocalDate start = end.minusDays(days - 1L);
var data = userVisitService.countDauRange(start, end);
return data.entrySet().stream()
.map(e -> Map.<String,Object>of(
"date", e.getKey().toString(),
"value", e.getValue()
))
.toList();
}
@GetMapping("/dau")
@Operation(summary = "Daily active users", description = "Get daily active user count")
@ApiResponse(
responseCode = "200",
description = "DAU count",
content = @Content(schema = @Schema(implementation = java.util.Map.class))
)
public Map<String, Long> dau(
@RequestParam(value = "date", required = false) @DateTimeFormat(
iso = DateTimeFormat.ISO.DATE
) LocalDate date
) {
long count = userVisitService.countDau(date);
return Map.of("dau", count);
}
@GetMapping("/new-users-range")
public List<Map<String, Object>> newUsersRange(@RequestParam(value = "days", defaultValue = "30") int days) {
if (days < 1) days = 1;
LocalDate end = LocalDate.now();
LocalDate start = end.minusDays(days - 1L);
var data = statService.countNewUsersRange(start, end);
return data.entrySet().stream()
.map(e -> Map.<String,Object>of(
"date", e.getKey().toString(),
"value", e.getValue()
))
.toList();
}
@GetMapping("/dau-range")
@Operation(summary = "DAU range", description = "Get daily active users over range of days")
@ApiResponse(
responseCode = "200",
description = "DAU data",
content = @Content(array = @ArraySchema(schema = @Schema(implementation = java.util.Map.class)))
)
public List<Map<String, Object>> dauRange(
@RequestParam(value = "days", defaultValue = "30") int days
) {
if (days < 1) days = 1;
LocalDate end = LocalDate.now();
LocalDate start = end.minusDays(days - 1L);
var data = userVisitService.countDauRange(start, end);
return data
.entrySet()
.stream()
.map(e -> Map.<String, Object>of("date", e.getKey().toString(), "value", e.getValue()))
.toList();
}
@GetMapping("/posts-range")
public List<Map<String, Object>> postsRange(@RequestParam(value = "days", defaultValue = "30") int days) {
if (days < 1) days = 1;
LocalDate end = LocalDate.now();
LocalDate start = end.minusDays(days - 1L);
var data = statService.countPostsRange(start, end);
return data.entrySet().stream()
.map(e -> Map.<String,Object>of(
"date", e.getKey().toString(),
"value", e.getValue()
))
.toList();
}
@GetMapping("/new-users-range")
@Operation(summary = "New users range", description = "Get new users over range of days")
@ApiResponse(
responseCode = "200",
description = "New user data",
content = @Content(array = @ArraySchema(schema = @Schema(implementation = java.util.Map.class)))
)
public List<Map<String, Object>> newUsersRange(
@RequestParam(value = "days", defaultValue = "30") int days
) {
if (days < 1) days = 1;
LocalDate end = LocalDate.now();
LocalDate start = end.minusDays(days - 1L);
var data = statService.countNewUsersRange(start, end);
return data
.entrySet()
.stream()
.map(e -> Map.<String, Object>of("date", e.getKey().toString(), "value", e.getValue()))
.toList();
}
@GetMapping("/comments-range")
public List<Map<String, Object>> commentsRange(@RequestParam(value = "days", defaultValue = "30") int days) {
if (days < 1) days = 1;
LocalDate end = LocalDate.now();
LocalDate start = end.minusDays(days - 1L);
var data = statService.countCommentsRange(start, end);
return data.entrySet().stream()
.map(e -> Map.<String,Object>of(
"date", e.getKey().toString(),
"value", e.getValue()
))
.toList();
}
@GetMapping("/posts-range")
@Operation(summary = "Posts range", description = "Get posts count over range of days")
@ApiResponse(
responseCode = "200",
description = "Post data",
content = @Content(array = @ArraySchema(schema = @Schema(implementation = java.util.Map.class)))
)
public List<Map<String, Object>> postsRange(
@RequestParam(value = "days", defaultValue = "30") int days
) {
if (days < 1) days = 1;
LocalDate end = LocalDate.now();
LocalDate start = end.minusDays(days - 1L);
var data = statService.countPostsRange(start, end);
return data
.entrySet()
.stream()
.map(e -> Map.<String, Object>of("date", e.getKey().toString(), "value", e.getValue()))
.toList();
}
@GetMapping("/comments-range")
@Operation(summary = "Comments range", description = "Get comments count over range of days")
@ApiResponse(
responseCode = "200",
description = "Comment data",
content = @Content(array = @ArraySchema(schema = @Schema(implementation = java.util.Map.class)))
)
public List<Map<String, Object>> commentsRange(
@RequestParam(value = "days", defaultValue = "30") int days
) {
if (days < 1) days = 1;
LocalDate end = LocalDate.now();
LocalDate start = end.minusDays(days - 1L);
var data = statService.countCommentsRange(start, end);
return data
.entrySet()
.stream()
.map(e -> Map.<String, Object>of("date", e.getKey().toString(), "value", e.getValue()))
.toList();
}
}

View File

@@ -1,6 +1,9 @@
package com.openisle.controller;
import com.openisle.service.SubscriptionService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
@@ -10,35 +13,54 @@ import org.springframework.web.bind.annotation.*;
@RequestMapping("/api/subscriptions")
@RequiredArgsConstructor
public class SubscriptionController {
private final SubscriptionService subscriptionService;
@PostMapping("/posts/{postId}")
public void subscribePost(@PathVariable Long postId, Authentication auth) {
subscriptionService.subscribePost(auth.getName(), postId);
}
private final SubscriptionService subscriptionService;
@DeleteMapping("/posts/{postId}")
public void unsubscribePost(@PathVariable Long postId, Authentication auth) {
subscriptionService.unsubscribePost(auth.getName(), postId);
}
@PostMapping("/posts/{postId}")
@Operation(summary = "Subscribe post", description = "Subscribe to a post")
@ApiResponse(responseCode = "200", description = "Subscribed")
@SecurityRequirement(name = "JWT")
public void subscribePost(@PathVariable Long postId, Authentication auth) {
subscriptionService.subscribePost(auth.getName(), postId);
}
@PostMapping("/comments/{commentId}")
public void subscribeComment(@PathVariable Long commentId, Authentication auth) {
subscriptionService.subscribeComment(auth.getName(), commentId);
}
@DeleteMapping("/posts/{postId}")
@Operation(summary = "Unsubscribe post", description = "Unsubscribe from a post")
@ApiResponse(responseCode = "200", description = "Unsubscribed")
@SecurityRequirement(name = "JWT")
public void unsubscribePost(@PathVariable Long postId, Authentication auth) {
subscriptionService.unsubscribePost(auth.getName(), postId);
}
@DeleteMapping("/comments/{commentId}")
public void unsubscribeComment(@PathVariable Long commentId, Authentication auth) {
subscriptionService.unsubscribeComment(auth.getName(), commentId);
}
@PostMapping("/comments/{commentId}")
@Operation(summary = "Subscribe comment", description = "Subscribe to a comment")
@ApiResponse(responseCode = "200", description = "Subscribed")
@SecurityRequirement(name = "JWT")
public void subscribeComment(@PathVariable Long commentId, Authentication auth) {
subscriptionService.subscribeComment(auth.getName(), commentId);
}
@PostMapping("/users/{username}")
public void subscribeUser(@PathVariable String username, Authentication auth) {
subscriptionService.subscribeUser(auth.getName(), username);
}
@DeleteMapping("/comments/{commentId}")
@Operation(summary = "Unsubscribe comment", description = "Unsubscribe from a comment")
@ApiResponse(responseCode = "200", description = "Unsubscribed")
@SecurityRequirement(name = "JWT")
public void unsubscribeComment(@PathVariable Long commentId, Authentication auth) {
subscriptionService.unsubscribeComment(auth.getName(), commentId);
}
@DeleteMapping("/users/{username}")
public void unsubscribeUser(@PathVariable String username, Authentication auth) {
subscriptionService.unsubscribeUser(auth.getName(), username);
}
@PostMapping("/users/{username}")
@Operation(summary = "Subscribe user", description = "Subscribe to a user")
@ApiResponse(responseCode = "200", description = "Subscribed")
@SecurityRequirement(name = "JWT")
public void subscribeUser(@PathVariable String username, Authentication auth) {
subscriptionService.subscribeUser(auth.getName(), username);
}
@DeleteMapping("/users/{username}")
@Operation(summary = "Unsubscribe user", description = "Unsubscribe from a user")
@ApiResponse(responseCode = "200", description = "Unsubscribed")
@SecurityRequirement(name = "JWT")
public void unsubscribeUser(@PathVariable String username, Authentication auth) {
subscriptionService.unsubscribeUser(auth.getName(), username);
}
}

View File

@@ -11,85 +11,156 @@ import com.openisle.model.Tag;
import com.openisle.repository.UserRepository;
import com.openisle.service.PostService;
import com.openisle.service.TagService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.ArraySchema;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/tags")
@RequiredArgsConstructor
public class TagController {
private final TagService tagService;
private final PostService postService;
private final UserRepository userRepository;
private final PostMapper postMapper;
private final TagMapper tagMapper;
@PostMapping
public TagDto create(@RequestBody TagRequest req, org.springframework.security.core.Authentication auth) {
boolean approved = true;
if (postService.getPublishMode() == PublishMode.REVIEW && auth != null) {
com.openisle.model.User user = userRepository.findByUsername(auth.getName()).orElseThrow();
if (user.getRole() != Role.ADMIN) {
approved = false;
}
}
Tag tag = tagService.createTag(
req.getName(),
req.getDescription(),
req.getIcon(),
req.getSmallIcon(),
approved,
auth != null ? auth.getName() : null);
long count = postService.countPostsByTag(tag.getId());
return tagMapper.toDto(tag, count);
}
private final TagService tagService;
private final PostService postService;
private final UserRepository userRepository;
private final PostMapper postMapper;
private final TagMapper tagMapper;
@PutMapping("/{id}")
public TagDto update(@PathVariable Long id, @RequestBody TagRequest req) {
Tag tag = tagService.updateTag(id, req.getName(), req.getDescription(), req.getIcon(), req.getSmallIcon());
long count = postService.countPostsByTag(tag.getId());
return tagMapper.toDto(tag, count);
@PostMapping
@Operation(summary = "Create tag", description = "Create a new tag")
@ApiResponse(
responseCode = "200",
description = "Created tag",
content = @Content(schema = @Schema(implementation = TagDto.class))
)
@SecurityRequirement(name = "JWT")
public TagDto create(
@RequestBody TagRequest req,
org.springframework.security.core.Authentication auth
) {
boolean approved = true;
if (postService.getPublishMode() == PublishMode.REVIEW && auth != null) {
com.openisle.model.User user = userRepository.findByUsername(auth.getName()).orElseThrow();
if (user.getRole() != Role.ADMIN) {
approved = false;
}
}
Tag tag = tagService.createTag(
req.getName(),
req.getDescription(),
req.getIcon(),
req.getSmallIcon(),
approved,
auth != null ? auth.getName() : null
);
long count = postService.countPostsByTag(tag.getId());
return tagMapper.toDto(tag, count);
}
@DeleteMapping("/{id}")
public void delete(@PathVariable Long id) {
tagService.deleteTag(id);
}
@PutMapping("/{id}")
@Operation(summary = "Update tag", description = "Update an existing tag")
@ApiResponse(
responseCode = "200",
description = "Updated tag",
content = @Content(schema = @Schema(implementation = TagDto.class))
)
public TagDto update(@PathVariable Long id, @RequestBody TagRequest req) {
Tag tag = tagService.updateTag(
id,
req.getName(),
req.getDescription(),
req.getIcon(),
req.getSmallIcon()
);
long count = postService.countPostsByTag(tag.getId());
return tagMapper.toDto(tag, count);
}
@GetMapping
public List<TagDto> list(@RequestParam(value = "keyword", required = false) String keyword,
@RequestParam(value = "limit", required = false) Integer limit) {
List<Tag> tags = tagService.searchTags(keyword);
List<Long> tagIds = tags.stream().map(Tag::getId).toList();
Map<Long, Long> postCntByTagIds = postService.countPostsByTagIds(tagIds);
List<TagDto> dtos = tags.stream()
.map(t -> tagMapper.toDto(t, postCntByTagIds.getOrDefault(t.getId(), 0L)))
.sorted((a, b) -> Long.compare(b.getCount(), a.getCount()))
.collect(Collectors.toList());
if (limit != null && limit > 0 && dtos.size() > limit) {
return dtos.subList(0, limit);
}
return dtos;
}
@DeleteMapping("/{id}")
@Operation(summary = "Delete tag", description = "Delete a tag by id")
@ApiResponse(responseCode = "200", description = "Tag deleted")
public void delete(@PathVariable Long id) {
tagService.deleteTag(id);
}
@GetMapping("/{id}")
public TagDto get(@PathVariable Long id) {
Tag tag = tagService.getTag(id);
long count = postService.countPostsByTag(tag.getId());
return tagMapper.toDto(tag, count);
@GetMapping
@Operation(summary = "List tags", description = "List tags with optional keyword")
@ApiResponse(
responseCode = "200",
description = "List of tags",
content = @Content(array = @ArraySchema(schema = @Schema(implementation = TagDto.class)))
)
public List<TagDto> list(
@RequestParam(value = "keyword", required = false) String keyword,
@RequestParam(value = "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, 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 new java.util.ArrayList<>(dtos.subList(0, limit));
}
return dtos;
}
@GetMapping("/{id}/posts")
public List<PostSummaryDto> listPostsByTag(@PathVariable Long id,
@RequestParam(value = "page", required = false) Integer page,
@RequestParam(value = "pageSize", required = false) Integer pageSize) {
return postService.listPostsByTags(java.util.List.of(id), page, pageSize)
.stream()
.map(postMapper::toSummaryDto)
.collect(Collectors.toList());
}
@GetMapping("/{id}")
@Operation(summary = "Get tag", description = "Get tag by id")
@ApiResponse(
responseCode = "200",
description = "Tag detail",
content = @Content(schema = @Schema(implementation = TagDto.class))
)
public TagDto get(@PathVariable Long id) {
Tag tag = tagService.getTag(id);
long count = postService.countPostsByTag(tag.getId());
return tagMapper.toDto(tag, count);
}
@GetMapping("/{id}/posts")
@Operation(summary = "List posts by tag", description = "Get posts with specific tag")
@ApiResponse(
responseCode = "200",
description = "List of posts",
content = @Content(
array = @ArraySchema(schema = @Schema(implementation = PostSummaryDto.class))
)
)
public List<PostSummaryDto> listPostsByTag(
@PathVariable Long id,
@RequestParam(value = "page", required = false) Integer page,
@RequestParam(value = "pageSize", required = false) Integer pageSize
) {
return postService
.listPostsByTags(java.util.List.of(id), page, pageSize)
.stream()
.map(postMapper::toSummaryDto)
.collect(Collectors.toList());
}
}

View File

@@ -1,82 +1,99 @@
package com.openisle.controller;
import com.openisle.service.ImageUploader;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.net.URI;
import java.net.URL;
import java.net.URLConnection;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
@RestController
@RequestMapping("/api/upload")
@RequiredArgsConstructor
public class UploadController {
private final ImageUploader imageUploader;
@Value("${app.upload.check-type:true}")
private boolean checkImageType;
private final ImageUploader imageUploader;
@Value("${app.upload.max-size:5242880}")
private long maxUploadSize;
@Value("${app.upload.check-type:true}")
private boolean checkImageType;
@PostMapping
public ResponseEntity<?> upload(@RequestParam("file") MultipartFile file) {
if (checkImageType && (file.getContentType() == null || !file.getContentType().startsWith("image/"))) {
return ResponseEntity.badRequest().body(Map.of("code", 1, "msg", "File is not an image"));
}
if (file.getSize() > maxUploadSize) {
return ResponseEntity.badRequest().body(Map.of("code", 2, "msg", "File too large"));
}
String url;
try {
url = imageUploader.upload(file.getBytes(), file.getOriginalFilename()).join();
} catch (IOException e) {
return ResponseEntity.internalServerError().body(Map.of("code", 3, "msg", "Upload failed"));
}
return ResponseEntity.ok(Map.of(
"code", 0,
"msg", "ok",
"data", Map.of("url", url)
));
@Value("${app.upload.max-size:5242880}")
private long maxUploadSize;
@PostMapping
@Operation(summary = "Upload file", description = "Upload image file")
@ApiResponse(
responseCode = "200",
description = "Upload result",
content = @Content(schema = @Schema(implementation = java.util.Map.class))
)
public ResponseEntity<?> upload(@RequestParam("file") MultipartFile file) {
if (
checkImageType &&
(file.getContentType() == null || !file.getContentType().startsWith("image/"))
) {
return ResponseEntity.badRequest().body(Map.of("code", 1, "msg", "File is not an image"));
}
@PostMapping("/url")
public ResponseEntity<?> uploadUrl(@RequestBody Map<String, String> body) {
String link = body.get("url");
if (link == null || link.isBlank()) {
return ResponseEntity.badRequest().body(Map.of("code", 1, "msg", "Missing url"));
}
try {
URL u = URI.create(link).toURL();
byte[] data = u.openStream().readAllBytes();
if (data.length > maxUploadSize) {
return ResponseEntity.badRequest().body(Map.of("code", 2, "msg", "File too large"));
}
String filename = link.substring(link.lastIndexOf('/') + 1);
String contentType = URLConnection.guessContentTypeFromStream(new ByteArrayInputStream(data));
if (checkImageType && (contentType == null || !contentType.startsWith("image/"))) {
return ResponseEntity.badRequest().body(Map.of("code", 1, "msg", "File is not an image"));
}
String url = imageUploader.upload(data, filename).join();
return ResponseEntity.ok(Map.of(
"code", 0,
"msg", "ok",
"data", Map.of("url", url)
));
} catch (Exception e) {
return ResponseEntity.internalServerError().body(Map.of("code", 3, "msg", "Upload failed"));
}
if (file.getSize() > maxUploadSize) {
return ResponseEntity.badRequest().body(Map.of("code", 2, "msg", "File too large"));
}
@GetMapping("/presign")
public java.util.Map<String, String> presign(@RequestParam("filename") String filename) {
return imageUploader.presignUpload(filename);
String url;
try {
url = imageUploader.upload(file.getBytes(), file.getOriginalFilename()).join();
} catch (IOException e) {
return ResponseEntity.internalServerError().body(Map.of("code", 3, "msg", "Upload failed"));
}
return ResponseEntity.ok(Map.of("code", 0, "msg", "ok", "data", Map.of("url", url)));
}
@PostMapping("/url")
@Operation(summary = "Upload from URL", description = "Upload image from remote URL")
@ApiResponse(
responseCode = "200",
description = "Upload result",
content = @Content(schema = @Schema(implementation = java.util.Map.class))
)
public ResponseEntity<?> uploadUrl(@RequestBody Map<String, String> body) {
String link = body.get("url");
if (link == null || link.isBlank()) {
return ResponseEntity.badRequest().body(Map.of("code", 1, "msg", "Missing url"));
}
try {
URL u = URI.create(link).toURL();
byte[] data = u.openStream().readAllBytes();
if (data.length > maxUploadSize) {
return ResponseEntity.badRequest().body(Map.of("code", 2, "msg", "File too large"));
}
String filename = link.substring(link.lastIndexOf('/') + 1);
String contentType = URLConnection.guessContentTypeFromStream(new ByteArrayInputStream(data));
if (checkImageType && (contentType == null || !contentType.startsWith("image/"))) {
return ResponseEntity.badRequest().body(Map.of("code", 1, "msg", "File is not an image"));
}
String url = imageUploader.upload(data, filename).join();
return ResponseEntity.ok(Map.of("code", 0, "msg", "ok", "data", Map.of("url", url)));
} catch (Exception e) {
return ResponseEntity.internalServerError().body(Map.of("code", 3, "msg", "Upload failed"));
}
}
@GetMapping("/presign")
@Operation(summary = "Presign upload", description = "Get presigned upload URL")
@ApiResponse(
responseCode = "200",
description = "Presigned URL",
content = @Content(schema = @Schema(implementation = java.util.Map.class))
)
public java.util.Map<String, String> presign(@RequestParam("filename") String filename) {
return imageUploader.presignUpload(filename);
}
}

View File

@@ -6,6 +6,14 @@ import com.openisle.mapper.TagMapper;
import com.openisle.mapper.UserMapper;
import com.openisle.model.User;
import com.openisle.service.*;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.ArraySchema;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import java.io.IOException;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity;
@@ -13,204 +21,359 @@ import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.util.Map;
@RestController
@RequestMapping("/api/users")
@RequiredArgsConstructor
public class UserController {
private final UserService userService;
private final ImageUploader imageUploader;
private final PostService postService;
private final CommentService commentService;
private final ReactionService reactionService;
private final TagService tagService;
private final SubscriptionService subscriptionService;
private final LevelService levelService;
private final JwtService jwtService;
private final UserMapper userMapper;
private final TagMapper tagMapper;
@Value("${app.upload.check-type:true}")
private boolean checkImageType;
private final UserService userService;
private final ImageUploader imageUploader;
private final PostService postService;
private final CommentService commentService;
private final ReactionService reactionService;
private final TagService tagService;
private final SubscriptionService subscriptionService;
private final LevelService levelService;
private final JwtService jwtService;
private final UserMapper userMapper;
private final TagMapper tagMapper;
@Value("${app.upload.max-size:5242880}")
private long maxUploadSize;
@Value("${app.upload.check-type:true}")
private boolean checkImageType;
@Value("${app.user.posts-limit:10}")
private int defaultPostsLimit;
@Value("${app.upload.max-size:5242880}")
private long maxUploadSize;
@Value("${app.user.replies-limit:50}")
private int defaultRepliesLimit;
@Value("${app.user.posts-limit:10}")
private int defaultPostsLimit;
@Value("${app.user.tags-limit:50}")
private int defaultTagsLimit;
@Value("${app.user.replies-limit:50}")
private int defaultRepliesLimit;
@GetMapping("/me")
public ResponseEntity<UserDto> me(Authentication auth) {
User user = userService.findByUsername(auth.getName()).orElseThrow();
return ResponseEntity.ok(userMapper.toDto(user, auth));
@Value("${app.user.tags-limit:50}")
private int defaultTagsLimit;
@GetMapping("/me")
@SecurityRequirement(name = "JWT")
@Operation(summary = "Current user", description = "Get current authenticated user information")
@ApiResponse(
responseCode = "200",
description = "User detail",
content = @Content(schema = @Schema(implementation = UserDto.class))
)
public ResponseEntity<UserDto> me(Authentication auth) {
User user = userService.findByUsername(auth.getName()).orElseThrow();
return ResponseEntity.ok(userMapper.toDto(user, auth));
}
@PostMapping("/me/avatar")
@SecurityRequirement(name = "JWT")
@Operation(summary = "Upload avatar", description = "Upload avatar for current user")
@ApiResponse(
responseCode = "200",
description = "Upload result",
content = @Content(schema = @Schema(implementation = Map.class))
)
public ResponseEntity<?> uploadAvatar(
@RequestParam("file") MultipartFile file,
Authentication auth
) {
if (
checkImageType &&
(file.getContentType() == null || !file.getContentType().startsWith("image/"))
) {
return ResponseEntity.badRequest().body(Map.of("error", "File is not an image"));
}
@PostMapping("/me/avatar")
public ResponseEntity<?> uploadAvatar(@RequestParam("file") MultipartFile file,
Authentication auth) {
if (checkImageType && (file.getContentType() == null || !file.getContentType().startsWith("image/"))) {
return ResponseEntity.badRequest().body(Map.of("error", "File is not an image"));
}
if (file.getSize() > maxUploadSize) {
return ResponseEntity.badRequest().body(Map.of("error", "File too large"));
}
String url = null;
try {
url = imageUploader.upload(file.getBytes(), file.getOriginalFilename()).join();
} catch (IOException e) {
return ResponseEntity.internalServerError().body(Map.of("url", url));
}
userService.updateAvatar(auth.getName(), url);
return ResponseEntity.ok(Map.of("url", url));
if (file.getSize() > maxUploadSize) {
return ResponseEntity.badRequest().body(Map.of("error", "File too large"));
}
@PutMapping("/me")
public ResponseEntity<?> updateProfile(@RequestBody UpdateProfileDto dto,
Authentication auth) {
User user = userService.updateProfile(auth.getName(), dto.getUsername(), dto.getIntroduction());
return ResponseEntity.ok(Map.of(
"token", jwtService.generateToken(user.getUsername()),
"user", userMapper.toDto(user, auth)
));
String url = null;
try {
url = imageUploader.upload(file.getBytes(), file.getOriginalFilename()).join();
} catch (IOException e) {
return ResponseEntity.internalServerError().body(Map.of("url", url));
}
userService.updateAvatar(auth.getName(), url);
return ResponseEntity.ok(Map.of("url", url));
}
@PostMapping("/me/signin")
public Map<String, Integer> signIn(Authentication auth) {
int reward = levelService.awardForSignin(auth.getName());
return Map.of("reward", reward);
}
@PutMapping("/me")
@SecurityRequirement(name = "JWT")
@Operation(summary = "Update profile", description = "Update current user's profile")
@ApiResponse(
responseCode = "200",
description = "Updated profile",
content = @Content(schema = @Schema(implementation = Map.class))
)
public ResponseEntity<?> updateProfile(@RequestBody UpdateProfileDto dto, Authentication auth) {
User user = userService.updateProfile(auth.getName(), dto.getUsername(), dto.getIntroduction());
return ResponseEntity.ok(
Map.of(
"token",
jwtService.generateToken(user.getUsername()),
"user",
userMapper.toDto(user, auth)
)
);
}
@GetMapping("/{identifier}")
public ResponseEntity<UserDto> getUser(@PathVariable("identifier") String identifier,
Authentication auth) {
User user = userService.findByIdentifier(identifier).orElseThrow(() -> new NotFoundException("User not found"));
return ResponseEntity.ok(userMapper.toDto(user, auth));
}
// 这个方法似乎没有使用?
@PostMapping("/me/signin")
@SecurityRequirement(name = "JWT")
@Operation(summary = "Daily sign in", description = "Sign in to receive rewards")
@ApiResponse(
responseCode = "200",
description = "Sign in reward",
content = @Content(schema = @Schema(implementation = Map.class))
)
public Map<String, Integer> signIn(Authentication auth) {
int reward = levelService.awardForSignin(auth.getName());
return Map.of("reward", reward);
}
@GetMapping("/{identifier}/posts")
public java.util.List<PostMetaDto> userPosts(@PathVariable("identifier") String identifier,
@RequestParam(value = "limit", required = false) Integer limit) {
int l = limit != null ? limit : defaultPostsLimit;
User user = userService.findByIdentifier(identifier).orElseThrow();
return postService.getRecentPostsByUser(user.getUsername(), l).stream()
.map(userMapper::toMetaDto)
.collect(java.util.stream.Collectors.toList());
}
@GetMapping("/{identifier}")
@Operation(summary = "Get user", description = "Get user by identifier")
@ApiResponse(
responseCode = "200",
description = "User detail",
content = @Content(schema = @Schema(implementation = UserDto.class))
)
public ResponseEntity<UserDto> getUser(
@PathVariable("identifier") String identifier,
Authentication auth
) {
User user = userService
.findByIdentifier(identifier)
.orElseThrow(() -> new NotFoundException("User not found"));
return ResponseEntity.ok(userMapper.toDto(user, auth));
}
@GetMapping("/{identifier}/subscribed-posts")
public java.util.List<PostMetaDto> subscribedPosts(@PathVariable("identifier") String identifier,
@RequestParam(value = "limit", required = false) Integer limit) {
int l = limit != null ? limit : defaultPostsLimit;
User user = userService.findByIdentifier(identifier).orElseThrow();
return subscriptionService.getSubscribedPosts(user.getUsername()).stream()
.limit(l)
.map(userMapper::toMetaDto)
.collect(java.util.stream.Collectors.toList());
}
@GetMapping("/{identifier}/posts")
@Operation(summary = "User posts", description = "Get recent posts by user")
@ApiResponse(
responseCode = "200",
description = "User posts",
content = @Content(array = @ArraySchema(schema = @Schema(implementation = PostMetaDto.class)))
)
public java.util.List<PostMetaDto> userPosts(
@PathVariable("identifier") String identifier,
@RequestParam(value = "limit", required = false) Integer limit
) {
int l = limit != null ? limit : defaultPostsLimit;
User user = userService.findByIdentifier(identifier).orElseThrow();
return postService
.getRecentPostsByUser(user.getUsername(), l)
.stream()
.map(userMapper::toMetaDto)
.collect(java.util.stream.Collectors.toList());
}
@GetMapping("/{identifier}/replies")
public java.util.List<CommentInfoDto> userReplies(@PathVariable("identifier") String identifier,
@RequestParam(value = "limit", required = false) Integer limit) {
int l = limit != null ? limit : defaultRepliesLimit;
User user = userService.findByIdentifier(identifier).orElseThrow();
return commentService.getRecentCommentsByUser(user.getUsername(), l).stream()
.map(userMapper::toCommentInfoDto)
.collect(java.util.stream.Collectors.toList());
}
@GetMapping("/{identifier}/subscribed-posts")
@Operation(summary = "Subscribed posts", description = "Get posts the user subscribed to")
@ApiResponse(
responseCode = "200",
description = "Subscribed posts",
content = @Content(array = @ArraySchema(schema = @Schema(implementation = PostMetaDto.class)))
)
public java.util.List<PostMetaDto> subscribedPosts(
@PathVariable("identifier") String identifier,
@RequestParam(value = "limit", required = false) Integer limit
) {
int l = limit != null ? limit : defaultPostsLimit;
User user = userService.findByIdentifier(identifier).orElseThrow();
return subscriptionService
.getSubscribedPosts(user.getUsername())
.stream()
.limit(l)
.map(userMapper::toMetaDto)
.collect(java.util.stream.Collectors.toList());
}
@GetMapping("/{identifier}/hot-posts")
public java.util.List<PostMetaDto> hotPosts(@PathVariable("identifier") String identifier,
@RequestParam(value = "limit", required = false) Integer limit) {
int l = limit != null ? limit : 10;
User user = userService.findByIdentifier(identifier).orElseThrow();
java.util.List<Long> ids = reactionService.topPostIds(user.getUsername(), l);
return postService.getPostsByIds(ids).stream()
.map(userMapper::toMetaDto)
.collect(java.util.stream.Collectors.toList());
}
@GetMapping("/{identifier}/replies")
@Operation(summary = "User replies", description = "Get recent replies by user")
@ApiResponse(
responseCode = "200",
description = "User replies",
content = @Content(
array = @ArraySchema(schema = @Schema(implementation = CommentInfoDto.class))
)
)
public java.util.List<CommentInfoDto> userReplies(
@PathVariable("identifier") String identifier,
@RequestParam(value = "limit", required = false) Integer limit
) {
int l = limit != null ? limit : defaultRepliesLimit;
User user = userService.findByIdentifier(identifier).orElseThrow();
return commentService
.getRecentCommentsByUser(user.getUsername(), l)
.stream()
.map(userMapper::toCommentInfoDto)
.collect(java.util.stream.Collectors.toList());
}
@GetMapping("/{identifier}/hot-replies")
public java.util.List<CommentInfoDto> hotReplies(@PathVariable("identifier") String identifier,
@RequestParam(value = "limit", required = false) Integer limit) {
int l = limit != null ? limit : 10;
User user = userService.findByIdentifier(identifier).orElseThrow();
java.util.List<Long> ids = reactionService.topCommentIds(user.getUsername(), l);
return commentService.getCommentsByIds(ids).stream()
.map(userMapper::toCommentInfoDto)
.collect(java.util.stream.Collectors.toList());
}
@GetMapping("/{identifier}/hot-posts")
@Operation(summary = "User hot posts", description = "Get most reacted posts by user")
@ApiResponse(
responseCode = "200",
description = "Hot posts",
content = @Content(array = @ArraySchema(schema = @Schema(implementation = PostMetaDto.class)))
)
public java.util.List<PostMetaDto> hotPosts(
@PathVariable("identifier") String identifier,
@RequestParam(value = "limit", required = false) Integer limit
) {
int l = limit != null ? limit : 10;
User user = userService.findByIdentifier(identifier).orElseThrow();
java.util.List<Long> ids = reactionService.topPostIds(user.getUsername(), l);
return postService
.getPostsByIds(ids)
.stream()
.map(userMapper::toMetaDto)
.collect(java.util.stream.Collectors.toList());
}
@GetMapping("/{identifier}/hot-tags")
public java.util.List<TagDto> hotTags(@PathVariable("identifier") String identifier,
@RequestParam(value = "limit", required = false) Integer limit) {
int l = limit != null ? limit : 10;
User user = userService.findByIdentifier(identifier).orElseThrow();
return tagService.getTagsByUser(user.getUsername()).stream()
.map(t -> tagMapper.toDto(t, postService.countPostsByTag(t.getId())))
.sorted((a, b) -> Long.compare(b.getCount(), a.getCount()))
.limit(l)
.collect(java.util.stream.Collectors.toList());
}
@GetMapping("/{identifier}/hot-replies")
@Operation(summary = "User hot replies", description = "Get most reacted replies by user")
@ApiResponse(
responseCode = "200",
description = "Hot replies",
content = @Content(
array = @ArraySchema(schema = @Schema(implementation = CommentInfoDto.class))
)
)
public java.util.List<CommentInfoDto> hotReplies(
@PathVariable("identifier") String identifier,
@RequestParam(value = "limit", required = false) Integer limit
) {
int l = limit != null ? limit : 10;
User user = userService.findByIdentifier(identifier).orElseThrow();
java.util.List<Long> ids = reactionService.topCommentIds(user.getUsername(), l);
return commentService
.getCommentsByIds(ids)
.stream()
.map(userMapper::toCommentInfoDto)
.collect(java.util.stream.Collectors.toList());
}
@GetMapping("/{identifier}/tags")
public java.util.List<TagDto> userTags(@PathVariable("identifier") String identifier,
@RequestParam(value = "limit", required = false) Integer limit) {
int l = limit != null ? limit : defaultTagsLimit;
User user = userService.findByIdentifier(identifier).orElseThrow();
return tagService.getRecentTagsByUser(user.getUsername(), l).stream()
.map(t -> tagMapper.toDto(t, postService.countPostsByTag(t.getId())))
.collect(java.util.stream.Collectors.toList());
}
@GetMapping("/{identifier}/hot-tags")
@Operation(summary = "User hot tags", description = "Get tags frequently used by user")
@ApiResponse(
responseCode = "200",
description = "Hot tags",
content = @Content(array = @ArraySchema(schema = @Schema(implementation = TagDto.class)))
)
public java.util.List<TagDto> hotTags(
@PathVariable("identifier") String identifier,
@RequestParam(value = "limit", required = false) Integer limit
) {
int l = limit != null ? limit : 10;
User user = userService.findByIdentifier(identifier).orElseThrow();
return tagService
.getTagsByUser(user.getUsername())
.stream()
.map(t -> tagMapper.toDto(t, postService.countPostsByTag(t.getId())))
.sorted((a, b) -> Long.compare(b.getCount(), a.getCount()))
.limit(l)
.collect(java.util.stream.Collectors.toList());
}
@GetMapping("/{identifier}/following")
public java.util.List<UserDto> following(@PathVariable("identifier") String identifier) {
User user = userService.findByIdentifier(identifier).orElseThrow();
return subscriptionService.getSubscribedUsers(user.getUsername()).stream()
.map(userMapper::toDto)
.collect(java.util.stream.Collectors.toList());
}
@GetMapping("/{identifier}/tags")
@Operation(summary = "User tags", description = "Get recent tags used by user")
@ApiResponse(
responseCode = "200",
description = "User tags",
content = @Content(array = @ArraySchema(schema = @Schema(implementation = TagDto.class)))
)
public java.util.List<TagDto> userTags(
@PathVariable("identifier") String identifier,
@RequestParam(value = "limit", required = false) Integer limit
) {
int l = limit != null ? limit : defaultTagsLimit;
User user = userService.findByIdentifier(identifier).orElseThrow();
return tagService
.getRecentTagsByUser(user.getUsername(), l)
.stream()
.map(t -> tagMapper.toDto(t, postService.countPostsByTag(t.getId())))
.collect(java.util.stream.Collectors.toList());
}
@GetMapping("/{identifier}/followers")
public java.util.List<UserDto> followers(@PathVariable("identifier") String identifier) {
User user = userService.findByIdentifier(identifier).orElseThrow();
return subscriptionService.getSubscribers(user.getUsername()).stream()
.map(userMapper::toDto)
.collect(java.util.stream.Collectors.toList());
}
@GetMapping("/{identifier}/following")
@Operation(summary = "Following users", description = "Get users that this user is following")
@ApiResponse(
responseCode = "200",
description = "Following list",
content = @Content(array = @ArraySchema(schema = @Schema(implementation = UserDto.class)))
)
public java.util.List<UserDto> following(@PathVariable("identifier") String identifier) {
User user = userService.findByIdentifier(identifier).orElseThrow();
return subscriptionService
.getSubscribedUsers(user.getUsername())
.stream()
.map(userMapper::toDto)
.collect(java.util.stream.Collectors.toList());
}
@GetMapping("/admins")
public java.util.List<UserDto> admins() {
return userService.getAdmins().stream()
.map(userMapper::toDto)
.collect(java.util.stream.Collectors.toList());
}
@GetMapping("/{identifier}/followers")
@Operation(summary = "Followers", description = "Get followers of this user")
@ApiResponse(
responseCode = "200",
description = "Followers list",
content = @Content(array = @ArraySchema(schema = @Schema(implementation = UserDto.class)))
)
public java.util.List<UserDto> followers(@PathVariable("identifier") String identifier) {
User user = userService.findByIdentifier(identifier).orElseThrow();
return subscriptionService
.getSubscribers(user.getUsername())
.stream()
.map(userMapper::toDto)
.collect(java.util.stream.Collectors.toList());
}
@GetMapping("/{identifier}/all")
public ResponseEntity<UserAggregateDto> userAggregate(@PathVariable("identifier") String identifier,
@RequestParam(value = "postsLimit", required = false) Integer postsLimit,
@RequestParam(value = "repliesLimit", required = false) Integer repliesLimit,
Authentication auth) {
User user = userService.findByIdentifier(identifier).orElseThrow();
int pLimit = postsLimit != null ? postsLimit : defaultPostsLimit;
int rLimit = repliesLimit != null ? repliesLimit : defaultRepliesLimit;
java.util.List<PostMetaDto> posts = postService.getRecentPostsByUser(user.getUsername(), pLimit).stream()
.map(userMapper::toMetaDto)
.collect(java.util.stream.Collectors.toList());
java.util.List<CommentInfoDto> replies = commentService.getRecentCommentsByUser(user.getUsername(), rLimit).stream()
.map(userMapper::toCommentInfoDto)
.collect(java.util.stream.Collectors.toList());
UserAggregateDto dto = new UserAggregateDto();
dto.setUser(userMapper.toDto(user, auth));
dto.setPosts(posts);
dto.setReplies(replies);
return ResponseEntity.ok(dto);
}
@GetMapping("/admins")
@Operation(summary = "Admin users", description = "List administrator users")
@ApiResponse(
responseCode = "200",
description = "Admin users",
content = @Content(array = @ArraySchema(schema = @Schema(implementation = UserDto.class)))
)
public java.util.List<UserDto> admins() {
return userService
.getAdmins()
.stream()
.map(userMapper::toDto)
.collect(java.util.stream.Collectors.toList());
}
@GetMapping("/{identifier}/all")
@Operation(summary = "User aggregate", description = "Get aggregate information for user")
@ApiResponse(
responseCode = "200",
description = "User aggregate",
content = @Content(schema = @Schema(implementation = UserAggregateDto.class))
)
public ResponseEntity<UserAggregateDto> userAggregate(
@PathVariable("identifier") String identifier,
@RequestParam(value = "postsLimit", required = false) Integer postsLimit,
@RequestParam(value = "repliesLimit", required = false) Integer repliesLimit,
Authentication auth
) {
User user = userService.findByIdentifier(identifier).orElseThrow();
int pLimit = postsLimit != null ? postsLimit : defaultPostsLimit;
int rLimit = repliesLimit != null ? repliesLimit : defaultRepliesLimit;
java.util.List<PostMetaDto> posts = postService
.getRecentPostsByUser(user.getUsername(), pLimit)
.stream()
.map(userMapper::toMetaDto)
.collect(java.util.stream.Collectors.toList());
java.util.List<CommentInfoDto> replies = commentService
.getRecentCommentsByUser(user.getUsername(), rLimit)
.stream()
.map(userMapper::toCommentInfoDto)
.collect(java.util.stream.Collectors.toList());
UserAggregateDto dto = new UserAggregateDto();
dto.setUser(userMapper.toDto(user, auth));
dto.setPosts(posts);
dto.setReplies(replies);
return ResponseEntity.ok(dto);
}
}

View File

@@ -1,21 +1,21 @@
package com.openisle.dto;
import com.openisle.model.ActivityType;
import lombok.Data;
import java.time.LocalDateTime;
import lombok.Data;
/**
* DTO representing an activity without participant details.
*/
@Data
public class ActivityDto {
private Long id;
private String title;
private String icon;
private String content;
private LocalDateTime startTime;
private LocalDateTime endTime;
private ActivityType type;
private boolean ended;
private Long id;
private String title;
private String icon;
private String content;
private LocalDateTime startTime;
private LocalDateTime endTime;
private ActivityType type;
private boolean ended;
}

View File

@@ -1,16 +1,16 @@
package com.openisle.dto;
import lombok.Data;
import com.openisle.model.MedalType;
import lombok.Data;
/**
* DTO representing a post or comment author.
*/
@Data
public class AuthorDto {
private Long id;
private String username;
private String avatar;
private MedalType displayMedal;
}
private Long id;
private String username;
private String avatar;
private MedalType displayMedal;
}

View File

@@ -7,11 +7,11 @@ import lombok.Data;
*/
@Data
public class CategoryDto {
private Long id;
private String name;
private String description;
private String icon;
private String smallIcon;
private Long count;
}
private Long id;
private String name;
private String description;
private String icon;
private String smallIcon;
private Long count;
}

View File

@@ -5,8 +5,9 @@ import lombok.Data;
/** Request body for creating or updating a category. */
@Data
public class CategoryRequest {
private String name;
private String description;
private String icon;
private String smallIcon;
private String name;
private String description;
private String icon;
private String smallIcon;
}

View File

@@ -6,12 +6,13 @@ import lombok.Setter;
@Getter
@Setter
public class ChannelDto {
private Long id;
private String name;
private String description;
private String avatar;
private MessageDto lastMessage;
private long memberCount;
private boolean joined;
private long unreadCount;
private Long id;
private String name;
private String description;
private String avatar;
private MessageDto lastMessage;
private long memberCount;
private boolean joined;
private long unreadCount;
}

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

@@ -1,23 +1,22 @@
package com.openisle.dto;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.List;
import lombok.Data;
/**
* DTO representing a comment and its nested replies.
*/
@Data
public class CommentDto {
private Long id;
private String content;
private LocalDateTime createdAt;
private LocalDateTime pinnedAt;
private AuthorDto author;
private List<CommentDto> replies;
private List<ReactionDto> reactions;
private int reward;
private int pointReward;
}
private Long id;
private String content;
private LocalDateTime createdAt;
private LocalDateTime pinnedAt;
private AuthorDto author;
private List<CommentDto> replies;
private List<ReactionDto> reactions;
private int reward;
private int pointReward;
}

View File

@@ -1,15 +1,15 @@
package com.openisle.dto;
import lombok.Data;
import java.time.LocalDateTime;
import lombok.Data;
/** DTO for comment information in user profiles. */
@Data
public class CommentInfoDto {
private Long id;
private String content;
private LocalDateTime createdAt;
private PostMetaDto post;
private ParentCommentDto parentComment;
private Long id;
private String content;
private LocalDateTime createdAt;
private PostMetaDto post;
private ParentCommentDto parentComment;
}

View File

@@ -6,6 +6,7 @@ import lombok.EqualsAndHashCode;
@Data
@EqualsAndHashCode(callSuper = true)
public class CommentMedalDto extends MedalDto {
private long currentCommentCount;
private long targetCommentCount;
private long currentCommentCount;
private long targetCommentCount;
}

View File

@@ -5,6 +5,7 @@ import lombok.Data;
/** Request body for creating or replying to a comment. */
@Data
public class CommentRequest {
private String content;
private String captcha;
private String content;
private String captcha;
}

View File

@@ -8,8 +8,9 @@ import lombok.Data;
/** DTO for site configuration. */
@Data
public class ConfigDto {
private PublishMode publishMode;
private PasswordStrength passwordStrength;
private Integer aiFormatLimit;
private RegisterMode registerMode;
private PublishMode publishMode;
private PasswordStrength passwordStrength;
private Integer aiFormatLimit;
private RegisterMode registerMode;
}

View File

@@ -6,7 +6,7 @@ import lombok.EqualsAndHashCode;
@Data
@EqualsAndHashCode(callSuper = true)
public class ContributorMedalDto extends MedalDto {
private long currentContributionLines;
private long targetContributionLines;
}
private long currentContributionLines;
private long targetContributionLines;
}

View File

@@ -1,16 +1,16 @@
package com.openisle.dto;
import java.util.List;
import lombok.Data;
import org.springframework.data.domain.Page;
import java.util.List;
@Data
public class ConversationDetailDto {
private Long id;
private String name;
private boolean channel;
private String avatar;
private List<UserSummaryDto> participants;
private Page<MessageDto> messages;
}
private Long id;
private String name;
private boolean channel;
private String avatar;
private List<UserSummaryDto> participants;
private Page<MessageDto> messages;
}

View File

@@ -1,20 +1,20 @@
package com.openisle.dto;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
import java.util.List;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class ConversationDto {
private Long id;
private String name;
private boolean channel;
private String avatar;
private MessageDto lastMessage;
private List<UserSummaryDto> participants;
private LocalDateTime createdAt;
private long unreadCount;
}
private Long id;
private String name;
private boolean channel;
private String avatar;
private MessageDto lastMessage;
private List<UserSummaryDto> participants;
private LocalDateTime createdAt;
private long unreadCount;
}

View File

@@ -4,5 +4,6 @@ import lombok.Data;
@Data
public class CreateConversationRequest {
private Long recipientId;
}
private Long recipientId;
}

View File

@@ -8,5 +8,6 @@ import lombok.NoArgsConstructor;
@AllArgsConstructor
@NoArgsConstructor
public class CreateConversationResponse {
private Long conversationId;
}
private Long conversationId;
}

View File

@@ -5,7 +5,8 @@ import lombok.Data;
/** Request for Discord OAuth login. */
@Data
public class DiscordLoginRequest {
private String code;
private String redirectUri;
private String inviteToken;
private String code;
private String redirectUri;
private String inviteToken;
}

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,15 +1,15 @@
package com.openisle.dto;
import lombok.Data;
import java.util.List;
import lombok.Data;
/** DTO representing a saved draft. */
@Data
public class DraftDto {
private Long id;
private String title;
private String content;
private Long categoryId;
private List<Long> tagIds;
private Long id;
private String title;
private String content;
private Long categoryId;
private List<Long> tagIds;
}

View File

@@ -1,14 +1,14 @@
package com.openisle.dto;
import lombok.Data;
import java.util.List;
import lombok.Data;
/** Request body for saving a draft. */
@Data
public class DraftRequest {
private String title;
private String content;
private Long categoryId;
private List<Long> tagIds;
private String title;
private String content;
private Long categoryId;
private List<Long> tagIds;
}

View File

@@ -6,7 +6,7 @@ import lombok.EqualsAndHashCode;
@Data
@EqualsAndHashCode(callSuper = true)
public class FeaturedMedalDto extends MedalDto {
private long currentFeaturedCount;
private long targetFeaturedCount;
}
private long currentFeaturedCount;
private long targetFeaturedCount;
}

View File

@@ -5,5 +5,6 @@ import lombok.Data;
/** Request to trigger a forgot password email. */
@Data
public class ForgotPasswordRequest {
private String email;
private String email;
}

View File

@@ -5,7 +5,8 @@ import lombok.Data;
/** Request for GitHub OAuth login. */
@Data
public class GithubLoginRequest {
private String code;
private String redirectUri;
private String inviteToken;
private String code;
private String redirectUri;
private String inviteToken;
}

View File

@@ -5,6 +5,7 @@ import lombok.Data;
/** Request for Google OAuth login. */
@Data
public class GoogleLoginRequest {
private String idToken;
private String inviteToken;
private String idToken;
private String inviteToken;
}

View File

@@ -5,7 +5,8 @@ import lombok.Data;
/** Request to login. */
@Data
public class LoginRequest {
private String username;
private String password;
private String captcha;
private String username;
private String password;
private String captcha;
}

View File

@@ -1,18 +1,19 @@
package com.openisle.dto;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.List;
import lombok.Data;
/** Metadata for lottery posts. */
@Data
public class LotteryDto {
private String prizeDescription;
private String prizeIcon;
private int prizeCount;
private int pointCost;
private LocalDateTime startTime;
private LocalDateTime endTime;
private List<AuthorDto> participants;
private List<AuthorDto> winners;
private String prizeDescription;
private String prizeIcon;
private int prizeCount;
private int pointCost;
private LocalDateTime startTime;
private LocalDateTime endTime;
private List<AuthorDto> participants;
private List<AuthorDto> winners;
}

View File

@@ -5,6 +5,7 @@ import lombok.Data;
/** Request to submit a reason (e.g., for moderation). */
@Data
public class MakeReasonRequest {
private String token;
private String reason;
private String token;
private String reason;
}

View File

@@ -5,10 +5,11 @@ import lombok.Data;
@Data
public class MedalDto {
private String icon;
private String title;
private String description;
private MedalType type;
private boolean completed;
private boolean selected;
private String icon;
private String title;
private String description;
private MedalType type;
private boolean completed;
private boolean selected;
}

View File

@@ -5,5 +5,6 @@ import lombok.Data;
@Data
public class MedalSelectRequest {
private MedalType type;
private MedalType type;
}

View File

@@ -1,16 +1,17 @@
package com.openisle.dto;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.List;
import lombok.Data;
@Data
public class MessageDto {
private Long id;
private String content;
private UserSummaryDto sender;
private Long conversationId;
private LocalDateTime createdAt;
private MessageDto replyTo;
private List<ReactionDto> reactions;
}
private Long id;
private String content;
private UserSummaryDto sender;
private Long conversationId;
private LocalDateTime createdAt;
private MessageDto replyTo;
private List<ReactionDto> reactions;
}

View File

@@ -1,15 +1,15 @@
package com.openisle.dto;
import java.io.Serializable;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.io.Serializable;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class MessageNotificationPayload implements Serializable {
private String targetUsername;
private Object payload;
}
private String targetUsername;
private Object payload;
}

View File

@@ -5,6 +5,7 @@ import lombok.Data;
/** Info about the milk tea activity. */
@Data
public class MilkTeaInfoDto {
private long redeemCount;
private boolean ended;
private long redeemCount;
private boolean ended;
}

View File

@@ -5,5 +5,6 @@ import lombok.Data;
/** Request to redeem the milk tea activity. */
@Data
public class MilkTeaRedeemRequest {
private String contact;
private String contact;
}

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