Compare commits

..

258 Commits

Author SHA1 Message Date
Tim
d699e68997 docs: remove docs path prefix 2025-09-09 13:47:57 +08:00
tim
358c53338d Revert "fix: 新增检查"
This reverts commit 1cd89eaa54.
2025-09-09 13:23:30 +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
1c28201cb8 fix: 侧边栏收起打开 有引导 #848 2025-09-07 14:13:48 +08:00
tim
0e26758585 fix: 首页帖子padding 写为15px 2025-09-07 14:10:04 +08:00
tim
786e60e8e5 fix: 帖子元素 -- 上下间距一样 #846 2025-09-07 14:08:37 +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
d94302635a fix: 生产环境新增www 2025-09-07 13:58:22 +08:00
tim
9519f66474 fix: 首页帖子padding可以大一些 比如 20px #845 2025-09-07 13:43:40 +08:00
Tim
14ee5faa1f Merge pull request #913 from nagisa77/feature/sidebar-logic
fix: 更新分类选择
2025-09-07 13:38:56 +08:00
tim
92ba475f3b fix: 更新分类选择 2025-09-07 13:38:09 +08:00
Tim
2eebc1c004 Merge pull request #911 from nagisa77/feature/sidebar-logic
feat: 侧边栏按钮样式逻辑修改
2025-09-07 13:23:05 +08:00
tim
6fffdb0fd6 feat: 侧边栏按钮样式逻辑修改 2025-09-07 13:20:53 +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
tim
db8c896b71 fix: empty commit 2025-09-06 21:49:28 +08:00
Tim
2a090442cc Merge pull request #906 from nagisa77/codex/fix-unsatisfied-dependencies-in-services
Fix Resend email environment variable placeholder
2025-09-06 21:44:49 +08:00
Tim
aa86909598 Fix resend from email env placeholder 2025-09-06 21:44:36 +08:00
Tim
5eb1416c6b Merge pull request #885 from nagisa77/codex/separate-redis-databases-for-environments
feat: allow redis database override
2025-09-06 21:32:02 +08:00
Tim
7320df6d20 Merge pull request #905 from nagisa77/codex/set-redis-tags-sync-interval-to-1-hour
feat: refresh tag and category caches hourly
2025-09-06 21:30:15 +08:00
Tim
9406bf3392 feat: refresh tag and category caches hourly 2025-09-06 21:28:52 +08:00
Tim
ccaada8f4e Merge pull request #889 from nagisa77/feature/icon_park
feature: 图标迁移为字节系IconPark
2025-09-06 21:09:52 +08:00
tim
5738ce75e8 fix: 修改当前在线人数icon 2025-09-06 21:09:13 +08:00
tim
0cf3e8c0f8 Merge remote-tracking branch 'origin/main' into feature/icon_park 2025-09-06 21:05:52 +08:00
Tim
e2d16845f5 Merge pull request #904 from nagisa77/codex/adapt-points.vue-to-iconpark
chore: use iconpark icons in points page
2025-09-06 21:02:31 +08:00
Tim
cb531d1337 chore: use iconpark icons in points page 2025-09-06 21:02:14 +08:00
Tim
b538f99082 Merge pull request #903 from nagisa77/codex/adapt-iconpark-in-users-page
chore: migrate user page icons to IconPark
2025-09-06 21:01:36 +08:00
Tim
ba5f0148af chore: migrate user page icons to IconPark 2025-09-06 21:01:22 +08:00
Tim
7dc9903060 Merge pull request #888 from smallclover/main
功能追加:显示在线人数
2025-09-06 21:00:25 +08:00
Tim
337e7ca43f Merge pull request #891 from palmcivet/docs/fumadocs
docs: 使用 Fumadocs 部署 Open API 文档
2025-09-06 21:00:01 +08:00
Tim
cc333e4bca Merge pull request #902 from mewhz/main
📝 配置文件与部署文档多处修改
2025-09-06 20:59:09 +08:00
mewhz
9e4ad29c7f 🔧 配置文件和部署文档新增 resend 邮箱服务 2025-09-06 15:16:02 +08:00
mewhz
49092780e3 🔧 修改前端配置文件与环境变量 2025-09-06 14:48:17 +08:00
mewhz
6570cfd677 📝 环境变量和部署文档新增 Redis 部分 2025-09-06 14:41:15 +08:00
mewhz
1b3bd27655 📝 新增文档中用户名密码与其他说明 2025-09-06 14:24:38 +08:00
tim
cfe24b5e8e fix: 修改为小写拼写 2025-09-06 13:55:13 +08:00
Tim
52633c8073 Merge pull request #901 from nagisa77/codex/adapt-notification.js-for-iconpark
refactor: switch notification icons to iconpark
2025-09-06 13:50:52 +08:00
Tim
4802c78156 refactor: switch notification icons to iconpark 2025-09-06 13:50:39 +08:00
Tim
cf2299f9bf Merge pull request #900 from nagisa77/codex/adapt-tagselect-for-iconpark
feat: adapt TagSelect to IconPark icons
2025-09-06 11:50:52 +08:00
Tim
f03bf92641 feat: adapt TagSelect to IconPark icons 2025-09-06 11:50:38 +08:00
Tim
8bb9c3e3d9 Merge pull request #899 from nagisa77/codex/adapt-searchdropdown-to-iconpark
refactor: replace font awesome with iconpark in search dropdown
2025-09-06 11:48:40 +08:00
Tim
8c554465f6 refactor: replace font awesome with iconpark in search dropdown 2025-09-06 11:48:22 +08:00
tim
05d56df44e fix 2025-09-06 11:47:18 +08:00
Tim
5b0cbe8ce9 Merge pull request #898 from nagisa77/codex/adapt-basetimeline-for-icon-park
Adapt BaseTimeline for IconPark icons
2025-09-06 11:44:42 +08:00
Tim
140d33d024 feat: support IconPark icons in BaseTimeline 2025-09-06 11:44:13 +08:00
tim
6ad7e951fe feat: add few icons 2025-09-06 11:25:44 +08:00
Tim
da47d37dc5 Merge pull request #897 from nagisa77/codex/adapt-dropdown.vue-to-iconpark-6m72wf
feat: integrate icon park in dropdown
2025-09-06 10:10:06 +08:00
Tim
6293f572d8 feat: integrate icon park in dropdown 2025-09-06 10:09:55 +08:00
tim
94f4792a32 Revert "feat: switch dropdown icons to IconPark"
This reverts commit 7421ec8984.
2025-09-06 10:09:20 +08:00
Tim
069f4bb8c1 Merge pull request #896 from nagisa77/codex/adapt-dropdown.vue-to-iconpark-qtb5fv
feat: switch dropdown icons to IconPark
2025-09-06 10:08:00 +08:00
Tim
7421ec8984 feat: switch dropdown icons to IconPark 2025-09-06 10:07:42 +08:00
tim
90b9d75da2 fix: baseplaceholder修改 2025-09-06 02:05:29 +08:00
Tim
d69b094a7b Merge pull request #894 from nagisa77/codex/migrate-components-to-iconpark
refactor: migrate placeholders to IconPark
2025-09-06 02:02:51 +08:00
Tim
67d80a4edd Merge branch 'feature/icon_park' into codex/migrate-components-to-iconpark 2025-09-06 02:02:39 +08:00
Tim
78498c0ac3 refactor: migrate placeholders to IconPark 2025-09-06 02:02:02 +08:00
tim
47c997ad22 fix: baseinput 适配icon 2025-09-06 02:00:58 +08:00
tim
2cd220e8eb Merge branch 'feature/icon_park' of github.com:nagisa77/OpenIsle into feature/icon_park
# Conflicts:
#	frontend_nuxt/plugins/iconpark.client.ts
2025-09-06 01:57:05 +08:00
tim
8023fa1810 feat: add few icons 2025-09-06 01:56:21 +08:00
Tim
04b1b32b9c Merge pull request #893 from nagisa77/codex/migrate-baseinput-components-to-iconpark
feat(frontend): migrate BaseInput to IconPark
2025-09-06 01:56:05 +08:00
Tim
f5d8f37f96 feat(frontend): migrate BaseInput to IconPark 2025-09-06 01:55:50 +08:00
Tim
4a4c256568 Merge pull request #892 from nagisa77/codex/adapt-basetabs-to-use-iconpark
feat: use iconpark in base tabs
2025-09-06 01:42:43 +08:00
Tim
3bb14ca6a3 feat: use iconpark in base tabs 2025-09-06 01:42:15 +08:00
Palm Civet
080ec97943 fix: conditional overview 2025-09-06 01:25:39 +08:00
Palm Civet
29232afadc docs: 引入 Fumadocs
ci: set up github actions
2025-09-06 01:10:52 +08:00
Tim
4ed679c4f4 Merge pull request #890 from nagisa77/codex/adapt-menucomponent-and-headercomponent-for-iconpark
refactor: support iconpark in menu and header
2025-09-05 22:22:18 +08:00
Tim
50848e0da1 refactor: support iconpark in menu and header 2025-09-05 22:20:46 +08:00
tim
51819913a0 feat: user info page 2025-09-05 22:18:57 +08:00
tim
741bd115d5 feat: add few icons 2025-09-05 22:11:14 +08:00
Tim
d13ee2257f feat: 表情新增 2025-09-05 18:01:38 +08:00
Tim
06dea47bec feat: 引入iconpark并修改部分icon 2025-09-05 17:48:41 +08:00
wangshun
dbd322807d 功能追加:显示在线人数 2025-09-05 16:24:09 +08:00
Tim
f89a17f14d Merge pull request #887 from nagisa77/feature/lottery_ui
fix: 抽奖右上角统一文字icon颜色以及间距 #871
2025-09-05 15:33:51 +08:00
Tim
ac433d6a45 fix: 抽奖右上角统一文字icon颜色以及间距 #871 2025-09-05 15:32:53 +08:00
zhoujia
5534573a19 创建和更新用户名校验增加校验,不允许纯数字用户名 2025-09-05 15:08:22 +08:00
Tim
62e7795e11 Merge pull request #886 from nagisa77/feature/reply_ui
fix: 回复ui重新调整
2025-09-05 14:50:05 +08:00
Tim
722d784691 fix: 回复ui重新调整 2025-09-05 14:48:37 +08:00
Tim
35c6d29b8f feat: allow redis db override 2025-09-05 11:31:44 +08:00
Tim
5dab838482 Merge pull request #882 from smallclover/main
轻量级redis缓存追加
2025-09-05 11:13:50 +08:00
Tim
67636475aa Merge pull request #884 from nagisa77/codex/add-log-for-successful-redis-connection
feat: log redis connection success
2025-09-05 10:56:51 +08:00
Tim
92ae8ae155 feat: log redis connection success 2025-09-05 10:55:13 +08:00
wangshun
c0afe9e2a9 轻量级redis缓存追加
本次主要改动范围:
1.分类列表缓存
2.标签列表缓存

追加的新类库
1.redis
2.jsr310→java8时间类localdatetime无法解析的问题
3.jaskson-hibernate6->hibernate 字段懒加载问题

其他改动
1.修改了初始化脚本的用户名,追加密码说明
2025-09-04 18:11:18 +08:00
Tim
2c1bef4551 Merge pull request #881 from nagisa77/feature/fix_safari_page_size
fix: 移动端Safari帖子底部被截断 #833
2025-09-04 17:01:29 +08:00
Tim
202c0f7b59 fix: 移动端Safari帖子底部被截断 #833 2025-09-04 17:00:21 +08:00
Tim
fdd6587fff Merge pull request #880 from nagisa77/feature/md_ui
fix: markdown引用ui修改 #837
2025-09-04 16:54:32 +08:00
Tim
77ea208961 fix: markdown 引用修改 2025-09-04 16:53:30 +08:00
Tim
96e1259ad7 fix: 支持swagger访问api 2025-09-04 14:22:58 +08:00
Tim
b77b629d9e fix: 新增api前缀 2025-09-04 14:02:50 +08:00
Tim
2e2813bcbd Merge pull request #838 from zpaeng/main
feat:Websocket服务拆到单独服务,主后台保持单工通信
2025-09-04 13:53:23 +08:00
Tim
ad079e6bfd Merge pull request #878 from nagisa77/codex/fix-duplicate-message-forwarding-issue
Fix duplicate WebSocket broadcasts
2025-09-04 13:50:24 +08:00
Tim
47a72dc9b0 Fix duplicate WebSocket broadcasts 2025-09-04 13:50:05 +08:00
Tim
70a83cbe06 fix: 日志等级可配置 2025-09-04 13:01:57 +08:00
Tim
0ff6f13c86 fix: ws新增 .env 文件 2025-09-04 12:24:30 +08:00
Tim
6f30cf0bc2 Merge pull request #875 from palmcivet/docs/docker-contributing
docs: 完善开发环境的 Docker Compose 配置
2025-09-04 09:54:08 +08:00
Palm Civet
931aee4c3f docs: update CONTRIBUTING.md 2025-09-04 00:36:38 +08:00
Tim
8895405606 fix: 提交一部份修改,以方便预发部署 2025-09-03 18:02:21 +08:00
Tim
12b697d9dd Merge branch 'main' into main 2025-09-03 16:24:56 +08:00
Tim
49a55bcc36 Merge pull request #870 from palmcivet/docs/contributing
docs: 优化 contributing 文档 !869
2025-09-03 14:37:27 +08:00
Palm Civet
690aae3577 docs: 优化 contributing 文档 2025-09-03 14:14:15 +08:00
Tim
93d2c39f6e Merge pull request #867 from palmcivet/docs/openapi-springdoc
docs: backend 引入 springdoc-openapi 生成 OpenAPI 文档
2025-09-03 11:35:18 +08:00
Tim
99b824d852 Merge pull request #868 from smallclover/main
部署教程修改
2025-09-03 11:34:51 +08:00
wangshun
67fae4129f 部署教程修改
1.配图统一改为项目内图片
2.增加laragon配置
3.增加github第三方登录配置
2025-09-03 10:27:57 +08:00
Palm Civet
3739286cca chore: 修改配置 2025-09-03 00:07:53 +08:00
Palm Civet
ec76e70ad0 build: backend 引入 springdoc-openapi 2025-09-02 23:54:23 +08:00
zpaeng
f482d9ff9d fix:【站内信】 2025-09-02 23:16:27 +08:00
zpaeng
5e13b4bdd3 Merge remote-tracking branch 'origin/main' 2025-09-02 23:12:50 +08:00
zpaeng
78a65c6afe feat:Websocket服务拆到单独服务,主后台保持单工通信 2025-09-02 23:10:56 +08:00
zpaeng
84236b0174 feat:Websocket服务拆到单独服务,主后台保持单工通信 2025-09-02 23:10:29 +08:00
tim
c337195b16 fix: ui简要修改 2025-09-02 16:27:05 +08:00
Tim
c506aec506 Merge pull request #835 from smallclover/main
倒计时修改
2025-09-02 16:18:07 +08:00
夢夢の幻想郷
aa4274052e Merge branch 'nagisa77:main' into main 2025-09-02 14:47:29 +08:00
wangshun
e96ba3c26f 1.追加:投票结束查看倒计时时间
2.修改:倒计时样式
3.优化:抽奖和投票倒计时代码统一
2025-09-02 14:46:18 +08:00
tim
36758624c2 Merge branch 'main' of github.com:nagisa77/OpenIsle 2025-09-02 13:01:34 +08:00
tim
4427eff78a fix: 新增Google Search Console网域识别 2025-09-02 13:01:10 +08:00
Tim
ab85e67d69 Merge pull request #830 from nagisa77/feature/ui_fix
reaction 相关修改/timeline相关修改
2025-09-02 12:44:24 +08:00
tim
d7f6bb507d reaction 相关修改/timeline相关修改 2025-09-02 12:43:30 +08:00
Tim
bced7807ae Merge pull request #829 from nagisa77/feature/cdn_change
fix: cdn 修复
2025-09-02 12:29:47 +08:00
Tim
73bb873bfe fix: cdn 修复 2025-09-02 11:45:35 +08:00
Tim
564ebfbc2c fix: 新增map变量 2025-09-01 21:11:07 +08:00
Tim
9a42b8f32a Merge pull request #826 from nagisa77/feature/good_posts
Feature/good posts
2025-09-01 20:59:01 +08:00
Tim
513b1f45a1 Merge pull request #825 from nagisa77/codex/add-conditions-for-featured-posts
feat: show featured marker only for RSS posts
2025-09-01 20:58:39 +08:00
Tim
1b204345a6 feat: show featured icon only for RSS posts 2025-09-01 20:58:21 +08:00
Tim
d146bf2b0d fix: 新增精品icon 2025-09-01 20:53:06 +08:00
Tim
864a760b20 Merge pull request #824 from nagisa77/feature/md_line
fix: markdown渲染的分割线有点深 #767
2025-09-01 19:48:47 +08:00
Tim
2ccdc21568 fix: markdown渲染的分割线有点深 #767 2025-09-01 19:47:24 +08:00
tim
ff63d232a9 Merge branch 'main' of github.com:nagisa77/OpenIsle 2025-09-01 18:50:09 +08:00
tim
32a624e62d fix: 登录样式调整 2025-09-01 18:49:33 +08:00
Tim
5af0c9dee0 Merge pull request #822 from nagisa77/codex/fix-channel-ui-scroll-behavior
fix: scroll to bottom when entering channel
2025-09-01 18:19:17 +08:00
Tim
edaafdd000 fix: scroll channel to bottom on activation 2025-09-01 18:18:58 +08:00
Tim
24838ab714 Merge pull request #819 from sivdead/main
指定Node.js最低版本为20.0.0
2025-09-01 18:16:24 +08:00
Tim
56a80a184b Merge pull request #821 from smallclover/main
修改部署教程
2025-09-01 18:15:49 +08:00
sivdead
ed24ed174b fix: 还原package-lock.json 2025-09-01 17:56:27 +08:00
夢夢の幻想郷
3080acb6e4 Merge branch 'nagisa77:main' into main 2025-09-01 17:52:24 +08:00
wangshun
1856eb191b 修改部署教程
1.本地部署前后端时,如果是时https后端会无法解析请求
2.使用第三方登录时,callback路径需要和注册的路径一致
2025-09-01 17:50:19 +08:00
Tim
0c2a50d620 Merge pull request #820 from CH-122/feat/message-setting
feat: 增加通知设置的权限控制,只有管理员可以显示特定通知类型
2025-09-01 17:40:09 +08:00
CH-122
7562de11a5 feat: 增加通知设置的权限控制,只有管理员可以显示特定通知类型 2025-09-01 17:03:13 +08:00
sivdead
aaacf4efb1 chore(frontend): 指定Node.js最低版本为20.0.0 2025-09-01 15:53:05 +08:00
Tim
1f30cdfe85 Merge pull request #818 from nagisa77/codex/fix-backend-compilation-issue
Fix CommentServiceTest compilation by mocking PointService
2025-09-01 15:35:02 +08:00
Tim
8b37cf5abb test: mock PointService in CommentServiceTest 2025-09-01 15:34:52 +08:00
Tim
4af19a75c9 Merge pull request #815 from sivdead/main
fix: 解决删除评论后积分历史和当前积分不一致的问题
2025-09-01 14:32:01 +08:00
tim
37ea986389 fix: 域名修复 2025-09-01 14:31:05 +08:00
tim
fefd0b3b6c fix: compile problem 2025-09-01 13:18:01 +08:00
tim
a31ed29cfa Reapply "feat: unify third-party auth component"
This reverts commit 800970f078.
2025-09-01 13:16:04 +08:00
tim
2719819ad7 Revert "chore: remove obsolete login styles"
This reverts commit 18fde1052f.
2025-09-01 13:16:00 +08:00
Tim
27ff9a9c9b Merge pull request #814 from nagisa77/codex/create-unified-ui-for-third-party-login-uko0i1
feat: unify third-party auth buttons with customizable styles
2025-09-01 13:15:16 +08:00
Tim
18fde1052f chore: remove obsolete login styles 2025-09-01 13:14:55 +08:00
tim
800970f078 Revert "feat: unify third-party auth component"
This reverts commit 215616d771.
2025-09-01 13:14:13 +08:00
Tim
cbbd1440a1 Merge pull request #813 from nagisa77/codex/create-unified-ui-for-third-party-login
feat: unify third-party auth component
2025-09-01 13:13:36 +08:00
Tim
215616d771 feat: unify third-party auth component 2025-09-01 13:13:16 +08:00
tim
575e90e558 fix: telegram support 2025-09-01 13:02:13 +08:00
Tim
e63d66806d fix: tg 环境变量配置 2025-09-01 11:47:37 +08:00
Tim
1fc0118c5a Merge pull request #812 from nagisa77/codex/support-telegram-registration-and-login
feat: add Telegram authentication
2025-09-01 11:41:34 +08:00
Tim
f3512c1184 feat: add Telegram authentication 2025-09-01 11:39:10 +08:00
sivdead
28842c90b1 feat(service): 在 CommentService 中添加逻辑删除评论时重新计算用户积分的功能,并在 PointService 中实现用户积分的重新计算方法 2025-09-01 11:32:20 +08:00
Tim
d67cc326c4 Merge pull request #811 from nagisa77/codex/update-last-post-time-display
feat: show message when user has no posts
2025-09-01 11:31:09 +08:00
Tim
27c217a630 feat: show message when user has no posts 2025-09-01 11:30:56 +08:00
Tim
4e3e5f147c Merge pull request #810 from nagisa77/codex/fix-channel-ui-scroll-to-bottom
fix(frontend): scroll to bottom on channel entry
2025-09-01 11:30:40 +08:00
Tim
8767aa31d6 fix(frontend): scroll to bottom on channel entry 2025-09-01 11:30:16 +08:00
Tim
a428f472f2 Merge pull request #809 from nagisa77/codex/shorten-invitation-link
feat: shorten invite links
2025-09-01 11:26:25 +08:00
Tim
8544803e62 feat: shorten invite links 2025-09-01 11:25:32 +08:00
Tim
54874cea7a Merge pull request #808 from nagisa77/codex/add-email-notification-settings
feat: add email notification settings
2025-09-01 11:24:19 +08:00
Tim
098d82a6a0 feat: add email notification settings 2025-09-01 11:23:31 +08:00
Tim
90eee03198 Merge pull request #807 from nagisa77/codex/fix-backend-compilation-issues
test: fix PostServiceTest for new PostService deps
2025-09-01 10:54:07 +08:00
Tim
3f152906f2 test: fix PostServiceTest for new PostService deps 2025-09-01 10:53:50 +08:00
Tim
ef71d0b3d4 Merge pull request #798 from nagisa77/feature/vote
feature for vote
2025-09-01 10:28:44 +08:00
Tim
6f80d139ba fix: 投票UI优化 2025-09-01 10:27:02 +08:00
Tim
7454931fa5 Merge pull request #806 from nagisa77/codex/modify-postpoll.vue-for-single-choice-voting
feat: add join button for single polls
2025-09-01 09:54:37 +08:00
Tim
0852664a82 Merge pull request #802 from sivdead/main
feat(model): 为评论和积分历史实体添加逻辑删除功能
2025-09-01 09:54:07 +08:00
Tim
5814fb673a feat: add join button for single polls 2025-09-01 01:06:51 +08:00
Tim
4ee4266e3d Merge pull request #804 from nagisa77/codex/fix-jpasystemexception-for-pollpost
Fix poll multiple property null handling
2025-08-31 14:22:59 +08:00
Tim
6a27fbe1d7 Fix null multiple field for poll posts 2025-08-31 14:22:44 +08:00
Tim
38ff04c358 Merge pull request #803 from nagisa77/codex/add-baseswitch-component-to-voting-post
feat(poll): use BaseSwitch for multiple selection
2025-08-31 14:13:32 +08:00
Tim
fc27200ac1 feat(poll): use BaseSwitch for multiple selection 2025-08-31 14:13:18 +08:00
sivdead
b1998be425 Merge remote-tracking branch 'origin/main' 2025-08-31 14:06:18 +08:00
sivdead
72adc5b232 feat(model): 为 Comment 和 PointHistory 实体添加逻辑删除功能 2025-08-31 14:03:48 +08:00
sivdead
d24e67de5d feat(model): 为 Comment 和 PointHistory 实体添加逻辑删除功能 2025-08-31 14:03:10 +08:00
Tim
eefefac236 Merge pull request #801 from nagisa77/codex/add-multi-select-support-for-voting
feat: support multi-option polls
2025-08-31 12:13:54 +08:00
Tim
2f339fdbdb feat: enable multi-option polls 2025-08-31 12:13:41 +08:00
tim
3808becc8b fix: 多选ui 2025-08-31 11:25:34 +08:00
tim
18db4d7317 fix: toolbar 层级修改 2025-08-31 11:14:48 +08:00
Tim
52cbb71945 Merge pull request #800 from nagisa77/codex/refactor-voting-and-lottery-into-components-zk6hvx
refactor: extract poll and lottery components
2025-08-31 11:10:46 +08:00
Tim
39c34a9048 feat: add PostPoll and PostLottery components 2025-08-31 11:10:20 +08:00
tim
4baabf2224 Revert "refactor: extract poll and lottery sections"
This reverts commit 27efc493b2.
2025-08-31 11:09:22 +08:00
Tim
8023183bc6 Merge pull request #799 from nagisa77/codex/refactor-voting-and-lottery-into-components
refactor: extract poll and lottery sections
2025-08-31 11:08:05 +08:00
Tim
27efc493b2 refactor: extract poll and lottery sections 2025-08-31 11:07:49 +08:00
tim
ca6e45a711 fix: 适配夜间模式 2025-08-31 10:55:40 +08:00
tim
803ca9e103 新的通知类型适配 2025-08-31 02:06:32 +08:00
Tim
9d1e12773a Merge pull request #796 from nagisa77/codex/modify-voting-module-components
Refactor poll module and add poll notifications
2025-08-31 01:49:55 +08:00
Tim
5a09934866 refactor poll and lottery forms, add poll notifications 2025-08-31 01:49:37 +08:00
tim
db1d7981c5 fix: checked修改为false 2025-08-31 01:21:52 +08:00
tim
6e1a7c773c fix: 投票模块采用clientOnly 2025-08-31 01:19:48 +08:00
tim
ac4f1064e7 fix: 结束时只显示结果 2025-08-31 01:05:00 +08:00
Tim
4e98fd6a89 Merge pull request #795 from nagisa77/codex/add-real-data-integration-for-voting-page
feat: render poll results with real data
2025-08-31 00:14:38 +08:00
Tim
1bf92ab1ad feat: render poll results with real data 2025-08-31 00:14:12 +08:00
tim
c6ab431c87 fix: 页面适配 2025-08-31 00:04:35 +08:00
Tim
aaa25d5c2f Merge pull request #794 from nagisa77/codex/add-participant-info-to-vote-response-y233h3
feat: return poll option participants
2025-08-30 12:07:01 +08:00
Tim
569531b462 feat: add poll vote repository 2025-08-30 12:06:11 +08:00
tim
c3ae97f8ba Revert "feat: track poll votes"
This reverts commit 23582934fa.
2025-08-30 12:05:35 +08:00
Tim
a57f3e6406 Merge pull request #793 from nagisa77/codex/add-participant-info-to-vote-response
feat: expose poll option participants
2025-08-30 12:03:34 +08:00
Tim
23582934fa feat: track poll votes 2025-08-30 12:03:17 +08:00
Tim
5adee4db0e Merge pull request #792 from nagisa77/codex/add-voting-feature-to-post
feat: add poll post support
2025-08-29 23:56:41 +08:00
Tim
a2ccc95b4e feat: add poll post support 2025-08-29 23:56:03 +08:00
Tim
dc5eb5a637 Merge pull request #791 from nagisa77/codex/add-voting-post-type
feat: add poll post type
2025-08-29 22:37:12 +08:00
213 changed files with 7639 additions and 1475 deletions

47
.github/workflows/deploy-docs.yml vendored Normal file
View File

@@ -0,0 +1,47 @@
name: Deploy Documentation
on:
workflow_call:
inputs:
build-id:
required: false
type: string
workflow_dispatch:
permissions:
contents: write
jobs:
build-docs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v3
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:
bun-version: latest
- name: Install Bun dependencies
run: bun install
working-directory: ./docs
- name: Generate API MDX
run: bun run generate
working-directory: ./docs
- name: Build documentation
run: bun run build
working-directory: ./docs
- name: Deploy to GitHub Pages
uses: JamesIves/github-pages-deploy-action@v4
with:
branch: gh-pages
folder: ./docs/out

View File

@@ -5,6 +5,9 @@ on:
branches: [main]
workflow_dispatch:
permissions:
contents: write
jobs:
build-and-deploy:
runs-on: ubuntu-latest
@@ -21,3 +24,11 @@ jobs:
key: ${{ secrets.SSH_KEY }}
script: bash /opt/openisle/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 }}

31
.gitignore vendored
View File

@@ -1,7 +1,32 @@
# IDE
.idea
target
openisle.iml
# log
logs
# deps
node_modules
# test & build
coverage
out/
build
dist
open-isle.env
logs
*.tsbuildinfo
# misc
.DS_Store
*.pem
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-lock.yaml
pnpm-workspace.yaml
# env
*.env
.env*.local
# others
openisle.iml

View File

@@ -1,111 +1,202 @@
#### **⚠️注意:仅想修改前端的朋友可不用部署后端服务**
- [前置工作](#前置工作)
- [启动后端服务](#启动后端服务)
- [本地 IDEA](#本地-idea)
- [配置环境变量](#配置环境变量)
- [配置 IDEA 参数](#配置-idea-参数)
- [配置 MySQL](#配置-mysql)
- [Docker 环境](#docker-环境)
- [配置环境变量](#配置环境变量-1)
- [构建并启动镜像](#构建并启动镜像)
- [启动前端服务](#启动前端服务)
- [配置环境变量](#配置环境变量-2)
- [安装依赖和运行](#安装依赖和运行)
- [其他配置](#其他配置)
## 如何部署
## 前置工作
> Step1 先克隆仓库
先克隆仓库
```shell
git clone https://github.com/nagisa77/OpenIsle.git
cd OpenIsle
```
> Step2 后端部署
- 后端开发环境
- JDK 17+
- 前端开发环境
- Node.JS 20+
## 启动后端服务
启动后端服务有多种方式,选择一种即可。
> [!IMPORTANT]
> 仅想修改前端的朋友可不用部署后端服务。转到 [启动前端服务](#启动前端服务) 章节。
### 本地 IDEA
```shell
cd backend
cd backend/
```
IDEA编辑器为例IDEA打开backend文件夹。
IDEA 打开 `backend/` 文件夹。
- 设置VM Option最好运行在其他端口非8080这里设置8081
#### 配置环境变量
```shell
-Dserver.port=8081
1. 生成环境变量文件
```shell
cp open-isle.env.example open-isle.env
```
`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
```
![CleanShot 2025-08-04 at 11.35.49.png](https://openisle-1307107697.cos.accelerate.myqcloud.com/dynamic_assert/4cf210cfc6ea478a80dfc744c85ccdc4.png)
另一种方式是修改 `.properities` 文件(但不建议),位于 `src/main/application.properties`,该配置同样来源于 `open-isle.env`,但修改 `.properties` 文件会被 Git 追踪。
- 设置jdk版本为java 17
![配置数据库](assets/contributing/backend_img_5.png)
![CleanShot 2025-08-04 at 11.38.03@2x.png](https://openisle-1307107697.cos.accelerate.myqcloud.com/dynamic_assert/392eeec753ae436ca12a78f750dfea2d.png)
#### 配置 IDEA 参数
- 本机配置MySQL服务网上很多教程忽略
- 设置环境变量.env 文件 或.properties 文件(二选一)
- 设置 JDK 版本为 java 17
1. 环境变量文件生成
- 设置 VM Option最好运行在其他端口非 `8080`,这里设置 `8081`
若上面在环境变量中设置了端口,那这里就不需要再额外设置
```shell
cp open-isle.env.example 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 端口>
```
修改环境变量留下需要的比如你要开发Google登录业务就需要谷歌相关的变量数据库是一定要的
![CleanShot 2025-08-04 at 11.41.36@2x.png](https://openisle-1307107697.cos.accelerate.myqcloud.com/dynamic_assert/896c8363b6e64ea19d18c12ec4dae2b4.png)
应用环境文件, 选择刚刚的`open-isle.env`
![CleanShot 2025-08-04 at 11.44.41.png](https://openisle-1307107697.cos.accelerate.myqcloud.com/dynamic_assert/f588e37838014a6684c141605639b9fa.png)
2. 直接修改 .properities 文件
位置src/main/application.properties, 数据库需要修改标红处,其他按需修改
![CleanShot 2025-08-04 at 11.47.11@2x.png](https://openisle-1307107697.cos.accelerate.myqcloud.com/dynamic_assert/28c3104448a245419e0b06aee861abb4.png)
处理完环境问题直接跑起来就能通了
![CleanShot 2025-08-04 at 11.49.01@2x.png](https://openisle-1307107697.cos.accelerate.myqcloud.com/dynamic_assert/2c945eae44b1477db09e80fc96b5e02d.png)
![运行画面](assets/contributing/backend_img_4.png)
> Step3 前端部署
### Docker 环境
**⚠️ 环境要求Node.js 版本最低 20.0.0(因为 Nuxt 框架要求)**
前端可以依赖本机部署的后端,也可以直接调用线上的后端接口
#### 配置环境变量
```shell
cd ../frontend_nuxt/
cd docker/
```
copy环境.env文件
主要配置两个 `.env` 文件
- `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
cp .env.staging.example .env
docker compose up -d
```
1. 依赖本机部署的后端:打开本文件夹,修改.env 修改为瞄准本机后端端口
如果想了解启动过程发生了什么可以查看日志
```yaml
; 本地部署后端
NUXT_PUBLIC_API_BASE_URL=https://127.0.0.1:8081
; 预发环境后端
; NUXT_PUBLIC_API_BASE_URL=https://staging.open-isle.com
; 生产环境后端
; NUXT_PUBLIC_API_BASE_URL=https://www.open-isle.com
```shell
docker compose logs
```
2. 依赖预发环境后台环境
## 启动前端服务
**(⚠️强烈推荐只部署前端的朋友使用该环境)**
> [!IMPORTANT]
> **⚠️ 环境要求Node.js 版本最低 20.0.0(因为 Nuxt 框架要求)**
```yaml
; 本地部署后端
; NUXT_PUBLIC_API_BASE_URL=https://127.0.0.1:8081
; 预发环境后端
NUXT_PUBLIC_API_BASE_URL=https://staging.open-isle.com
; 生产环境后端
; NUXT_PUBLIC_API_BASE_URL=https://www.open-isle.com
```shell
cd frontend_nuxt/
```
4. 依赖线上后台环境
### 配置环境变量
```yaml
; 本地部署后端
; NUXT_PUBLIC_API_BASE_URL=https://127.0.0.1:8081
; 预发环境后端
; NUXT_PUBLIC_API_BASE_URL=https://staging.open-isle.com
; 生产环境后端
NUXT_PUBLIC_API_BASE_URL=https://www.open-isle.com
```
前端可以依赖本机部署的后端,也可以直接调用线上的后端接口。
- 利用预发环境:**(⚠️ 强烈推荐只开发前端的朋友使用该环境)**
```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
# 安装依赖
@@ -115,4 +206,49 @@ npm install --verbose
npm run dev
```
如此一来,浏览器访问 http://127.0.0.1:3000 即可访问前端页面
如此一来,浏览器访问 http://127.0.0.1:3000 即可访问前端页面
## 其他配置
### 配置第三方登录,这里以 GitHub 为例:
- 修改 `application.properties` 配置
![后端配置](assets/contributing/backend_img.png)
- 修改 `.env` 配置
![前端](assets/contributing/fontend_img.png)
- 配置第三方登录回调地址
![github配置](assets/contributing/github_img.png)
![github配置2](assets/contributing/github_img_2.png)
### 配置 Resend 邮箱服务
https://resend.com/emails 创建账号并登录
- `Domains` -> `Add Domain`
![image-20250906150459400](assets/contributing/image-20250906150459400.png)
- 填写域名
![image-20250906150541817](assets/contributing/image-20250906150541817.png)
- 等待一段时间后解析成功,创建 key
`API Keys` -> `Create API Key`,输入名称,设置 `Permission` 为 `Sending access`
**Key 只能查看一次,务必保存下来**
![image-20250906150811572](assets/contributing/image-20250906150811572.png)
![image-20250906150924975](assets/contributing/image-20250906150924975.png)
![image-20250906150944130](assets/contributing/image-20250906150944130.png)
- 修改 `.env` 配置中的 `RESEND_API_KEY` 和 `RESEND_FROM_EMAIL`
`RESEND_FROM_EMAIL` **noreply@域名**
`RESEND_API_KEY`**刚刚复制的 Key**
![image-20250906151218330](assets/contributing/image-20250906151218330.png)
## 开源共建和API文档
- API文档: https://openisle-docs.netlify.app/docs/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>
## 💡 简介

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 242 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 239 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 174 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

View File

@@ -1,5 +1,8 @@
# === Spring Boot ===
SERVER_PORT=8080
# === Database ===
MYSQL_URL=jdbc:mysql://<数据库地址>:<端口>/<数据库名>?useUnicode=yes&characterEncoding=UTF-8&useInformationSchema=true&useSSL=false&serverTimezone=UTC
MYSQL_URL=jdbc:mysql://<数据库地址>:<数据库端口>/<数据库名>?useUnicode=yes&characterEncoding=UTF-8&useInformationSchema=true&useSSL=false&serverTimezone=UTC
MYSQL_USER=<数据库用户名>
MYSQL_PASSWORD=<数据库密码>
@@ -10,8 +13,13 @@ JWT_RESET_SECRET=<jwt reset secret>
JWT_INVITE_SECRET=<jwt invite secret>
JWT_EXPIRATION=2592000000
# === Redis ===
REDIS_HOST=<Redis 地址>
REDIS_PORT=<Redis 端口>
# === Resend ===
RESEND_API_KEY=<你的resend-api-key>
RESEND_FROM_EMAIL=<你的 resend 发送邮箱>
# === COS ===
# COS_BASE_URL=https://<你的cos>.cos.ap-guangzhou.myqcloud.com
@@ -28,6 +36,7 @@ TWITTER_CLIENT_ID=<你的twitter-client-id>
TWITTER_CLIENT_SECRET=<你的-twitter-client-secret>
DISCORD_CLIENT_ID=<你的discord-client-id>
DISCORD_CLIENT_SECRET=<你的discord-client-secret>
TELEGRAM_BOT_TOKEN=<你的telegram-bot-token>
# === OPENAI ===
OPENAI_API_KEY=<你的openai-api-key>
@@ -36,4 +45,10 @@ OPENAI_API_KEY=<你的openai-api-key>
WEBPUSH_PUBLIC_KEY=<你的webpush-public-key>
WEBPUSH_PRIVATE_KEY=<你的webpush-private-key>
# === RabbitMQ ===
RABBITMQ_HOST=<你的rabbitmq_host>
RABBITMQ_PORT=<你的rabbitmq_port>
RABBITMQ_USERNAME=<你的rabbitmq_username>
RABBITMQ_PASSWORD=<你的rabbitmq_password>
# LOG_LEVEL=DEBUG

View File

@@ -26,9 +26,22 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-amqp</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-jsr310</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-hibernate6</artifactId>
<version>2.20.0</version>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
@@ -114,6 +127,11 @@
<artifactId>bcprov-jdk15on</artifactId>
<version>1.70</version>
</dependency>
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-api</artifactId>
<version>2.2.0</version>
</dependency>
</dependencies>
<build>
@@ -141,6 +159,26 @@
</annotationProcessorPaths>
</configuration>
</plugin>
<plugin>
<!-- https://github.com/OpenAPITools/openapi-generator/blob/master/modules/openapi-generator-maven-plugin/README.md -->
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-maven-plugin</artifactId>
<version>1.4</version>
<executions>
<execution>
<goals>
<goal>generate</goal>
</goals>
</execution>
</executions>
<configuration>
<!-- 此处为硬编码,应优化为 env 的配置 -->
<apiDocsUrl>http://localhost:8080/api/v3/api-docs</apiDocsUrl>
<outputFileName>openapi.json</outputFileName>
<outputDir>${project.build.directory}</outputDir>
</configuration>
</plugin>
</plugins>
</build>
</project>

View File

@@ -0,0 +1,118 @@
package com.openisle.config;
import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import com.fasterxml.jackson.annotation.PropertyAccessor;
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 org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.cache.RedisCacheManager;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
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
* @since 2025-09-04
*/
@Configuration
@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 VERIFY_CACHE_NAME="openisle_verify";
/**
* 自定义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);
}
/**
* 配置 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 值
// 个别缓存单独设置 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();
}
/**
* 配置 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());
// value 和 hashValue 使用 JSON 序列化
template.setValueSerializer(redisSerializer);
template.setHashValueSerializer(redisSerializer);
return template;
}
}

View File

@@ -0,0 +1,54 @@
package com.openisle.config;
import io.swagger.v3.oas.models.Components;
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 org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class OpenApiConfig {
@Value("${springdoc.info.title}")
private String title;
@Value("${springdoc.info.description}")
private String description;
@Value("${springdoc.info.version}")
private String version;
@Value("${springdoc.info.scheme}")
private String scheme;
@Value("${springdoc.info.header}")
private String header;
@Value("${springdoc.api-docs.server-url}")
private String serverUrl;
@Bean
public OpenAPI openAPI() {
SecurityScheme securityScheme = new SecurityScheme()
.type(SecurityScheme.Type.HTTP)
.scheme(scheme.toLowerCase())
.bearerFormat("JWT")
.in(SecurityScheme.In.HEADER)
.name(header);
return new OpenAPI()
.servers(List.of(new Server().url(serverUrl)))
.info(new Info()
.title(title)
.description(description)
.version(version))
.components(new Components().addSecuritySchemes("JWT", securityScheme))
.addSecurityItem(new SecurityRequirement().addList("JWT"));
}
}

View File

@@ -0,0 +1,204 @@
package com.openisle.config;
import lombok.RequiredArgsConstructor;
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.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.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
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;
@PostConstruct
public void init() {
System.out.println("RabbitMQ配置初始化: 队列数量=" + queueCount + ", 持久化=" + queueDurable);
}
@Bean
public TopicExchange exchange() {
return new TopicExchange(EXCHANGE_NAME);
}
/**
* 创建所有分片队列, 使用十六进制后缀 (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;
}
/**
* 创建所有分片绑定, 使用十六进制路由键 (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);
}
}
System.out.println("分片绑定 Bean 创建完成,总数: " + bindings.size());
return bindings;
}
/**
* 保持向后兼容的单队列配置(可选)
*/
@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 -> {
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();
}
};
}
}

View File

@@ -0,0 +1,35 @@
package com.openisle.config;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.stereotype.Component;
/**
* Logs a message when a Redis connection is successfully established.
*/
@Component
@Slf4j
public class RedisConnectionLogger implements InitializingBean {
private final RedisConnectionFactory 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);
}
}
}

View File

@@ -74,10 +74,14 @@ public class SecurityConfig {
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",
@@ -85,6 +89,7 @@ public class SecurityConfig {
"http://30.211.97.238",
"http://192.168.7.98",
"http://192.168.7.98:3000",
"https://petstore.swagger.io",
websiteUrl,
websiteUrl.replace("://www.", "://")
));
@@ -106,6 +111,7 @@ public class SecurityConfig {
.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()
@@ -123,6 +129,8 @@ public class SecurityConfig {
.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")
@@ -176,7 +184,9 @@ public class SecurityConfig {
return;
}
} else if (!uri.startsWith("/api/auth") && !publicGet
&& !uri.startsWith("/api/ws") && !uri.startsWith("/api/sockjs")) {
&& !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\"}");

View File

@@ -0,0 +1,14 @@
package com.openisle.config;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@AllArgsConstructor
@NoArgsConstructor
public class ShardInfo {
private int shardIndex;
private String queueName;
private String routingKey;
}

View File

@@ -0,0 +1,84 @@
package com.openisle.config;
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();
}
}

View File

@@ -0,0 +1,36 @@
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

@@ -1,110 +0,0 @@
package com.openisle.config;
import com.openisle.service.JwtService;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.Message;
import org.springframework.messaging.MessageChannel;
import org.springframework.messaging.simp.config.ChannelRegistration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.messaging.simp.stomp.StompCommand;
import org.springframework.messaging.simp.stomp.StompHeaderAccessor;
import org.springframework.messaging.support.ChannelInterceptor;
import org.springframework.messaging.support.MessageHeaderAccessor;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;
@Configuration
@EnableWebSocketMessageBroker
@RequiredArgsConstructor
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
private final JwtService jwtService;
private final UserDetailsService userDetailsService;
@Value("${app.website-url}")
private String websiteUrl;
@Override
public void configureMessageBroker(MessageBrokerRegistry config) {
// Enable a simple memory-based message broker to carry the messages back to the client on destinations prefixed with "/topic" and "/queue"
config.enableSimpleBroker("/topic", "/queue");
// Set user destination prefix for personal messages
config.setUserDestinationPrefix("/user");
// Designates the "/app" prefix for messages that are bound for @MessageMapping-annotated methods.
config.setApplicationDestinationPrefixes("/app");
}
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
// 1) 原生 WebSocket不带 SockJS
registry.addEndpoint("/api/ws")
.setAllowedOriginPatterns(
"https://staging.open-isle.com",
"https://www.staging.open-isle.com",
websiteUrl,
websiteUrl.replace("://www.", "://"),
"http://localhost:*",
"http://127.0.0.1:*",
"http://192.168.7.98:*",
"http://30.211.97.238:*"
);
// 2) SockJS 回退:单独路径
registry.addEndpoint("/api/sockjs")
.setAllowedOriginPatterns(
"https://staging.open-isle.com",
"https://www.staging.open-isle.com",
websiteUrl,
websiteUrl.replace("://www.", "://"),
"http://localhost:*",
"http://127.0.0.1:*",
"http://192.168.7.98:*",
"http://30.211.97.238:*"
)
.withSockJS()
.setWebSocketEnabled(true)
.setSessionCookieNeeded(false);
}
@Override
public void configureClientInboundChannel(ChannelRegistration registration) {
registration.interceptors(new ChannelInterceptor() {
@Override
public Message<?> preSend(Message<?> message, MessageChannel channel) {
StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);
if (StompCommand.CONNECT.equals(accessor.getCommand())) {
System.out.println("WebSocket CONNECT command received");
String authHeader = accessor.getFirstNativeHeader("Authorization");
System.out.println("Authorization header: " + (authHeader != null ? "present" : "missing"));
if (authHeader != null && authHeader.startsWith("Bearer ")) {
String token = authHeader.substring(7);
try {
String username = jwtService.validateAndGetSubject(token);
System.out.println("JWT validated for user: " + username);
var userDetails = userDetailsService.loadUserByUsername(username);
Authentication auth = new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities());
accessor.setUser(auth);
System.out.println("WebSocket user set: " + username);
} catch (Exception e) {
System.err.println("JWT validation failed: " + e.getMessage());
}
}
} else if (StompCommand.SUBSCRIBE.equals(accessor.getCommand())) {
System.out.println("WebSocket SUBSCRIBE to: " + accessor.getDestination());
System.out.println("WebSocket user during subscribe: " + (accessor.getUser() != null ? accessor.getUser().getName() : "null"));
}
return message;
}
});
}
}

View File

@@ -37,22 +37,22 @@ public class AdminPostController {
}
@PostMapping("/{id}/pin")
public PostSummaryDto pin(@PathVariable Long id) {
return postMapper.toSummaryDto(postService.pinPost(id));
public PostSummaryDto pin(@PathVariable Long id, org.springframework.security.core.Authentication auth) {
return postMapper.toSummaryDto(postService.pinPost(id, auth.getName()));
}
@PostMapping("/{id}/unpin")
public PostSummaryDto unpin(@PathVariable Long id) {
return postMapper.toSummaryDto(postService.unpinPost(id));
public PostSummaryDto unpin(@PathVariable Long id, org.springframework.security.core.Authentication auth) {
return postMapper.toSummaryDto(postService.unpinPost(id, auth.getName()));
}
@PostMapping("/{id}/rss-exclude")
public PostSummaryDto excludeFromRss(@PathVariable Long id) {
return postMapper.toSummaryDto(postService.excludeFromRss(id));
public PostSummaryDto excludeFromRss(@PathVariable Long id, org.springframework.security.core.Authentication auth) {
return postMapper.toSummaryDto(postService.excludeFromRss(id, auth.getName()));
}
@PostMapping("/{id}/rss-include")
public PostSummaryDto includeInRss(@PathVariable Long id) {
return postMapper.toSummaryDto(postService.includeInRss(id));
public PostSummaryDto includeInRss(@PathVariable Long id, org.springframework.security.core.Authentication auth) {
return postMapper.toSummaryDto(postService.includeInRss(id, auth.getName()));
}
}

View File

@@ -1,18 +1,22 @@
package com.openisle.controller;
import com.openisle.config.CachingConfig;
import com.openisle.dto.*;
import com.openisle.exception.FieldException;
import com.openisle.model.RegisterMode;
import com.openisle.model.User;
import com.openisle.repository.UserRepository;
import com.openisle.service.*;
import com.openisle.util.VerifyType;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.TimeUnit;
@RestController
@RequestMapping("/api/auth")
@@ -26,6 +30,7 @@ public class AuthController {
private final GithubAuthService githubAuthService;
private final DiscordAuthService discordAuthService;
private final TwitterAuthService twitterAuthService;
private final TelegramAuthService telegramAuthService;
private final RegisterModeService registerModeService;
private final NotificationService notificationService;
private final UserRepository userRepository;
@@ -55,7 +60,8 @@ public class AuthController {
User user = userService.registerWithInvite(
req.getUsername(), req.getEmail(), req.getPassword());
inviteService.consume(req.getInviteToken(), user.getUsername());
emailService.sendEmail(user.getEmail(), "在网站填写验证码以验证", "您的验证码是 " + user.getVerificationCode());
// 发送确认邮件
userService.sendVerifyMail(user, VerifyType.REGISTER);
return ResponseEntity.ok(Map.of(
"token", jwtService.generateToken(user.getUsername()),
"reason_code", "INVITE_APPROVED"
@@ -69,7 +75,8 @@ public class AuthController {
}
User user = userService.register(
req.getUsername(), req.getEmail(), req.getPassword(), "", registerModeService.getRegisterMode());
emailService.sendEmail(user.getEmail(), "在网站填写验证码以验证", "您的验证码是 " + user.getVerificationCode());
// 发送确认邮件
userService.sendVerifyMail(user, VerifyType.REGISTER);
if (!user.isApproved()) {
notificationService.createRegisterRequestNotifications(user, user.getRegisterReason());
}
@@ -78,13 +85,12 @@ public class AuthController {
@PostMapping("/verify")
public ResponseEntity<?> verify(@RequestBody VerifyRequest req) {
boolean ok = userService.verifyCode(req.getUsername(), req.getCode());
Optional<User> userOpt = userService.findByUsername(req.getUsername());
if (userOpt.isEmpty()) {
return ResponseEntity.badRequest().body(Map.of("error", "Invalid credentials"));
}
boolean ok = userService.verifyCode(userOpt.get(), req.getCode(), VerifyType.REGISTER);
if (ok) {
Optional<User> userOpt = userService.findByUsername(req.getUsername());
if (userOpt.isEmpty()) {
return ResponseEntity.badRequest().body(Map.of("error", "Invalid credentials"));
}
User user = userOpt.get();
if (user.isApproved()) {
@@ -121,7 +127,7 @@ public class AuthController {
User user = userOpt.get();
if (!user.isVerified()) {
user = userService.register(user.getUsername(), user.getEmail(), user.getPassword(), user.getRegisterReason(), registerModeService.getRegisterMode());
emailService.sendEmail(user.getEmail(), "在网站填写验证码以验证", "您的验证码是 " + user.getVerificationCode());
userService.sendVerifyMail(user, VerifyType.REGISTER);
return ResponseEntity.badRequest().body(Map.of(
"error", "User not verified",
"reason_code", "NOT_VERIFIED",
@@ -360,6 +366,51 @@ public class AuthController {
));
}
@PostMapping("/telegram")
public ResponseEntity<?> loginWithTelegram(@RequestBody TelegramLoginRequest req) {
boolean viaInvite = req.getInviteToken() != null && !req.getInviteToken().isEmpty();
InviteService.InviteValidateResult inviteValidateResult = inviteService.validate(req.getInviteToken());
if (viaInvite && !inviteValidateResult.isValidate()) {
return ResponseEntity.badRequest().body(Map.of("error", "Invalid invite token"));
}
Optional<AuthResult> resultOpt = telegramAuthService.authenticate(
req,
registerModeService.getRegisterMode(),
viaInvite);
if (resultOpt.isPresent()) {
AuthResult result = resultOpt.get();
if (viaInvite && result.isNewUser()) {
inviteService.consume(req.getInviteToken(), inviteValidateResult.getInviteToken().getInviter().getUsername());
return ResponseEntity.ok(Map.of(
"token", jwtService.generateToken(result.getUser().getUsername()),
"reason_code", "INVITE_APPROVED"
));
}
if (RegisterMode.DIRECT.equals(registerModeService.getRegisterMode())) {
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(result.getUser().getUsername())));
}
if (!result.getUser().isApproved()) {
if (result.getUser().getRegisterReason() != null && !result.getUser().getRegisterReason().isEmpty()) {
return ResponseEntity.badRequest().body(Map.of(
"error", "Account awaiting approval",
"reason_code", "IS_APPROVING",
"token", jwtService.generateReasonToken(result.getUser().getUsername())
));
}
return ResponseEntity.badRequest().body(Map.of(
"error", "Account awaiting approval",
"reason_code", "NOT_APPROVED",
"token", jwtService.generateReasonToken(result.getUser().getUsername())
));
}
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(result.getUser().getUsername())));
}
return ResponseEntity.badRequest().body(Map.of(
"error", "Invalid telegram data",
"reason_code", "INVALID_CREDENTIALS"
));
}
@GetMapping("/check")
public ResponseEntity<?> checkToken() {
return ResponseEntity.ok(Map.of("valid", true));
@@ -371,14 +422,17 @@ public class AuthController {
if (userOpt.isEmpty()) {
return ResponseEntity.badRequest().body(Map.of("error", "User not found"));
}
String code = userService.generatePasswordResetCode(req.getEmail());
emailService.sendEmail(req.getEmail(), "请填写验证码以重置密码", "您的验证码是" + code);
userService.sendVerifyMail(userOpt.get(), VerifyType.RESET_PASSWORD);
return ResponseEntity.ok(Map.of("message", "Verification code sent"));
}
@PostMapping("/forgot/verify")
public ResponseEntity<?> verifyReset(@RequestBody VerifyForgotRequest req) {
boolean ok = userService.verifyPasswordResetCode(req.getEmail(), req.getCode());
Optional<User> userOpt = userService.findByEmail(req.getEmail());
if (userOpt.isEmpty()) {
return ResponseEntity.badRequest().body(Map.of("error", "User not found"));
}
boolean ok = userService.verifyCode(userOpt.get(), req.getCode(), VerifyType.RESET_PASSWORD);
if (ok) {
String username = userService.findByEmail(req.getEmail()).get().getUsername();
return ResponseEntity.ok(Map.of("token", jwtService.generateResetToken(username)));

View File

@@ -62,4 +62,14 @@ public class NotificationController {
public void updatePref(@RequestBody NotificationPreferenceUpdateRequest req, Authentication auth) {
notificationService.updatePreference(auth.getName(), req.getType(), req.isEnabled());
}
@GetMapping("/email-prefs")
public List<NotificationPreferenceDto> emailPrefs(Authentication auth) {
return notificationService.listEmailPreferences(auth.getName());
}
@PostMapping("/email-prefs")
public void updateEmailPref(@RequestBody NotificationPreferenceUpdateRequest req, Authentication auth) {
notificationService.updateEmailPreference(auth.getName(), req.getType(), req.isEnabled());
}
}

View File

@@ -0,0 +1,33 @@
package com.openisle.controller;
import com.openisle.config.CachingConfig;
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
* 统计在线人数
*/
@RestController
@RequestMapping("/api/online")
@RequiredArgsConstructor
public class OnlineController {
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));
}
@GetMapping("/count")
public long count(){
return redisTemplate.keys(ONLINE_KEY+"*").size();
}
}

View File

@@ -0,0 +1,25 @@
package com.openisle.controller;
import com.openisle.dto.PostChangeLogDto;
import com.openisle.mapper.PostChangeLogMapper;
import com.openisle.service.PostChangeLogService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.stream.Collectors;
@RestController
@RequestMapping("/api/posts")
@RequiredArgsConstructor
public class PostChangeLogController {
private final PostChangeLogService changeLogService;
private final PostChangeLogMapper mapper;
@GetMapping("/{id}/change-logs")
public List<PostChangeLogDto> listLogs(@PathVariable Long id) {
return changeLogService.listLogs(id).stream()
.map(mapper::toDto)
.collect(Collectors.toList());
}
}

View File

@@ -44,7 +44,7 @@ public class PostController {
req.getType(), req.getPrizeDescription(), req.getPrizeIcon(),
req.getPrizeCount(), req.getPointCost(),
req.getStartTime(), req.getEndTime(),
req.getQuestion(), req.getOptions());
req.getOptions(), req.getMultiple());
draftService.deleteDraft(auth.getName());
PostDetailDto dto = postMapper.toDetailDto(post, auth.getName());
dto.setReward(levelService.awardForPost(auth.getName()));
@@ -94,7 +94,7 @@ public class PostController {
}
@PostMapping("/{id}/poll/vote")
public ResponseEntity<Void> vote(@PathVariable Long id, @RequestParam("option") int option, Authentication auth) {
public ResponseEntity<Void> vote(@PathVariable Long id, @RequestParam("option") List<Integer> option, Authentication auth) {
postService.votePoll(id, auth.getName(), option);
return ResponseEntity.ok().build();
}

View File

@@ -0,0 +1,15 @@
package com.openisle.dto;
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;
}

View File

@@ -8,9 +8,10 @@ import java.util.Map;
@Data
public class PollDto {
private String question;
private List<String> options;
private Map<Integer, Integer> votes;
private LocalDateTime endTime;
private List<AuthorDto> participants;
private Map<Integer, List<AuthorDto>> optionParticipants;
private boolean multiple;
}

View File

@@ -0,0 +1,32 @@
package com.openisle.dto;
import com.openisle.model.PostChangeType;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
import java.util.List;
@Getter
@Setter
public class PostChangeLogDto {
private Long id;
private String username;
private String userAvatar;
private PostChangeType type;
private LocalDateTime time;
private String oldTitle;
private String newTitle;
private String oldContent;
private String newContent;
private CategoryDto oldCategory;
private CategoryDto newCategory;
private List<TagDto> oldTags;
private List<TagDto> newTags;
private Boolean oldClosed;
private Boolean newClosed;
private LocalDateTime oldPinnedAt;
private LocalDateTime newPinnedAt;
private Boolean oldFeatured;
private Boolean newFeatured;
}

View File

@@ -27,7 +27,7 @@ public class PostRequest {
private LocalDateTime startTime;
private LocalDateTime endTime;
// fields for poll posts
private String question;
private List<String> options;
private Boolean multiple;
}

View File

@@ -0,0 +1,16 @@
package com.openisle.dto;
import lombok.Data;
/** Request for Telegram login. */
@Data
public class TelegramLoginRequest {
private String id;
private String firstName;
private String lastName;
private String username;
private String photoUrl;
private Long authDate;
private String hash;
private String inviteToken;
}

View File

@@ -0,0 +1,92 @@
package com.openisle.mapper;
import com.openisle.dto.CategoryDto;
import com.openisle.dto.PostChangeLogDto;
import com.openisle.dto.TagDto;
import com.openisle.model.*;
import com.openisle.repository.CategoryRepository;
import com.openisle.repository.TagRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;
@Component
@RequiredArgsConstructor
public class PostChangeLogMapper {
private final CategoryRepository categoryRepository;
private final TagRepository tagRepository;
private final CategoryMapper categoryMapper;
private final TagMapper tagMapper;
public PostChangeLogDto toDto(PostChangeLog log) {
PostChangeLogDto dto = new PostChangeLogDto();
dto.setId(log.getId());
if (log.getUser() != null) {
dto.setUsername(log.getUser().getUsername());
dto.setUserAvatar(log.getUser().getAvatar());
}
dto.setType(log.getType());
dto.setTime(log.getCreatedAt());
if (log instanceof PostTitleChangeLog t) {
dto.setOldTitle(t.getOldTitle());
dto.setNewTitle(t.getNewTitle());
} else if (log instanceof PostContentChangeLog c) {
dto.setOldContent(c.getOldContent());
dto.setNewContent(c.getNewContent());
} else if (log instanceof PostCategoryChangeLog cat) {
dto.setOldCategory(mapCategory(cat.getOldCategory()));
dto.setNewCategory(mapCategory(cat.getNewCategory()));
} else if (log instanceof PostTagChangeLog tag) {
dto.setOldTags(mapTags(tag.getOldTags()));
dto.setNewTags(mapTags(tag.getNewTags()));
} else if (log instanceof PostClosedChangeLog cl) {
dto.setOldClosed(cl.isOldClosed());
dto.setNewClosed(cl.isNewClosed());
} else if (log instanceof PostPinnedChangeLog p) {
dto.setOldPinnedAt(p.getOldPinnedAt());
dto.setNewPinnedAt(p.getNewPinnedAt());
} else if (log instanceof PostFeaturedChangeLog f) {
dto.setOldFeatured(f.isOldFeatured());
dto.setNewFeatured(f.isNewFeatured());
}
return dto;
}
private CategoryDto mapCategory(String name) {
if (name == null) {
return null;
}
return categoryRepository.findByName(name)
.map(categoryMapper::toDto)
.orElseGet(() -> {
CategoryDto dto = new CategoryDto();
dto.setName(name);
return dto;
});
}
private List<TagDto> mapTags(String tags) {
if (tags == null || tags.isBlank()) {
return Collections.emptyList();
}
return Arrays.stream(tags.split(","))
.map(String::trim)
.map(this::mapTag)
.collect(Collectors.toList());
}
private TagDto mapTag(String name) {
return tagRepository.findByName(name)
.map(tagMapper::toDto)
.orElseGet(() -> {
TagDto dto = new TagDto();
dto.setName(name);
return dto;
});
}
}

View File

@@ -6,19 +6,23 @@ import com.openisle.dto.PostSummaryDto;
import com.openisle.dto.ReactionDto;
import com.openisle.dto.LotteryDto;
import com.openisle.dto.PollDto;
import com.openisle.dto.AuthorDto;
import com.openisle.model.CommentSort;
import com.openisle.model.Post;
import com.openisle.model.LotteryPost;
import com.openisle.model.PollPost;
import com.openisle.model.User;
import com.openisle.model.PollVote;
import com.openisle.service.CommentService;
import com.openisle.service.ReactionService;
import com.openisle.service.SubscriptionService;
import com.openisle.repository.PollVoteRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/** Mapper responsible for converting posts into DTOs. */
@@ -34,6 +38,7 @@ public class PostMapper {
private final UserMapper userMapper;
private final TagMapper tagMapper;
private final CategoryMapper categoryMapper;
private final PollVoteRepository pollVoteRepository;
public PostSummaryDto toSummaryDto(Post post) {
PostSummaryDto dto = new PostSummaryDto();
@@ -98,11 +103,15 @@ public class PostMapper {
if (post instanceof PollPost pp) {
PollDto p = new PollDto();
p.setQuestion(pp.getQuestion());
p.setOptions(pp.getOptions());
p.setVotes(pp.getVotes());
p.setEndTime(pp.getEndTime());
p.setParticipants(pp.getParticipants().stream().map(userMapper::toAuthorDto).collect(Collectors.toList()));
Map<Integer, List<AuthorDto>> optionParticipants = pollVoteRepository.findByPostId(pp.getId()).stream()
.collect(Collectors.groupingBy(PollVote::getOptionIndex,
Collectors.mapping(v -> userMapper.toAuthorDto(v.getUser()), Collectors.toList())));
p.setOptionParticipants(optionParticipants);
p.setMultiple(Boolean.TRUE.equals(pp.getMultiple()));
dto.setPoll(p);
}
}

View File

@@ -5,6 +5,8 @@ import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.SQLDelete;
import org.hibernate.annotations.Where;
import java.time.LocalDateTime;
@@ -13,6 +15,8 @@ import java.time.LocalDateTime;
@Setter
@NoArgsConstructor
@Table(name = "comments")
@SQLDelete(sql = "UPDATE comments SET deleted_at = CURRENT_TIMESTAMP(6) WHERE id = ?")
@Where(clause = "deleted_at IS NULL")
public class Comment {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@@ -41,4 +45,7 @@ public class Comment {
@Column
private LocalDateTime pinnedAt;
@Column(name = "deleted_at")
private LocalDateTime deletedAt;
}

View File

@@ -14,6 +14,13 @@ public class InviteToken {
@Id
private String token;
/**
* Short token used in invite links. Existing records may have this field null
* and fall back to {@link #token} for backward compatibility.
*/
@Column(unique = true)
private String shortToken;
@ManyToOne
private User inviter;

View File

@@ -1,5 +1,6 @@
package com.openisle.model;
import com.fasterxml.jackson.annotation.JsonBackReference;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
@@ -20,6 +21,7 @@ public class Message {
@ManyToOne(optional = false, fetch = FetchType.LAZY)
@JoinColumn(name = "conversation_id")
@JsonBackReference
private MessageConversation conversation;
@ManyToOne(optional = false, fetch = FetchType.LAZY)

View File

@@ -1,5 +1,7 @@
package com.openisle.model;
import com.fasterxml.jackson.annotation.JsonBackReference;
import com.fasterxml.jackson.annotation.JsonManagedReference;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
@@ -41,8 +43,10 @@ public class MessageConversation {
private Message lastMessage;
@OneToMany(mappedBy = "conversation", cascade = CascadeType.ALL, orphanRemoval = true)
@JsonBackReference
private Set<MessageParticipant> participants = new HashSet<>();
@OneToMany(mappedBy = "conversation", cascade = CascadeType.ALL, orphanRemoval = true)
@JsonBackReference
private Set<Message> messages = new HashSet<>();
}

View File

@@ -1,5 +1,6 @@
package com.openisle.model;
import com.fasterxml.jackson.annotation.JsonBackReference;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
@@ -19,6 +20,7 @@ public class MessageParticipant {
@ManyToOne(optional = false, fetch = FetchType.LAZY)
@JoinColumn(name = "conversation_id")
@JsonBackReference
private MessageConversation conversation;
@ManyToOne(optional = false, fetch = FetchType.LAZY)

View File

@@ -40,6 +40,12 @@ public enum NotificationType {
LOTTERY_WIN,
/** Your lottery post was drawn */
LOTTERY_DRAW,
/** Someone participated in your poll */
POLL_VOTE,
/** Your poll post has concluded */
POLL_RESULT_OWNER,
/** A poll you participated in has concluded */
POLL_RESULT_PARTICIPANT,
/** Your post was featured */
POST_FEATURED,
/** You were mentioned in a post or comment */

View File

@@ -4,6 +4,8 @@ import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.hibernate.annotations.SQLDelete;
import org.hibernate.annotations.Where;
import java.time.LocalDateTime;
@@ -13,6 +15,8 @@ import java.time.LocalDateTime;
@Setter
@NoArgsConstructor
@Table(name = "point_histories")
@SQLDelete(sql = "UPDATE point_histories SET deleted_at = CURRENT_TIMESTAMP(6) WHERE id = ?")
@Where(clause = "deleted_at IS NULL")
public class PointHistory {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@@ -46,4 +50,7 @@ public class PointHistory {
@Column(name = "created_at", nullable = false)
private LocalDateTime createdAt;
@Column(name = "deleted_at")
private LocalDateTime deletedAt;
}

View File

@@ -15,10 +15,6 @@ import java.util.*;
@NoArgsConstructor
@PrimaryKeyJoinColumn(name = "post_id")
public class PollPost extends Post {
@Column(nullable = false)
private String question;
@ElementCollection
@CollectionTable(name = "poll_post_options", joinColumns = @JoinColumn(name = "post_id"))
@Column(name = "option_text")
@@ -36,6 +32,12 @@ public class PollPost extends Post {
inverseJoinColumns = @JoinColumn(name = "user_id"))
private Set<User> participants = new HashSet<>();
@Column
private Boolean multiple = false;
@Column
private LocalDateTime endTime;
@Column
private boolean resultAnnounced = false;
}

View File

@@ -0,0 +1,28 @@
package com.openisle.model;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Entity
@Table(name = "poll_votes", uniqueConstraints = @UniqueConstraint(columnNames = {"post_id", "user_id", "option_index"}))
@Getter
@Setter
@NoArgsConstructor
public class PollVote {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "post_id")
private PollPost post;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "user_id")
private User user;
@Column(name = "option_index", nullable = false)
private int optionIndex;
}

View File

@@ -0,0 +1,17 @@
package com.openisle.model;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Getter
@Setter
@NoArgsConstructor
@Entity
@Table(name = "post_category_change_logs")
public class PostCategoryChangeLog extends PostChangeLog {
private String oldCategory;
private String newCategory;
}

View File

@@ -0,0 +1,37 @@
package com.openisle.model;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.hibernate.annotations.CreationTimestamp;
import java.time.LocalDateTime;
@Getter
@Setter
@NoArgsConstructor
@Entity
@Table(name = "post_change_logs")
@Inheritance(strategy = InheritanceType.JOINED)
public abstract class PostChangeLog {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "post_id")
private Post post;
@ManyToOne(fetch = FetchType.LAZY, optional = true)
@JoinColumn(name = "user_id")
private User user;
@CreationTimestamp
@Column(nullable = false, updatable = false)
private LocalDateTime createdAt;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private PostChangeType type;
}

View File

@@ -0,0 +1,13 @@
package com.openisle.model;
public enum PostChangeType {
CONTENT,
TITLE,
CATEGORY,
TAG,
CLOSED,
PINNED,
FEATURED,
VOTE_RESULT,
LOTTERY_RESULT
}

View File

@@ -0,0 +1,17 @@
package com.openisle.model;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Getter
@Setter
@NoArgsConstructor
@Entity
@Table(name = "post_closed_change_logs")
public class PostClosedChangeLog extends PostChangeLog {
private boolean oldClosed;
private boolean newClosed;
}

View File

@@ -0,0 +1,21 @@
package com.openisle.model;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Getter
@Setter
@NoArgsConstructor
@Entity
@Table(name = "post_content_change_logs")
public class PostContentChangeLog extends PostChangeLog {
@Column(name = "old_content", columnDefinition = "LONGTEXT")
private String oldContent;
@Column(name = "new_content", columnDefinition = "LONGTEXT")
private String newContent;
}

View File

@@ -0,0 +1,17 @@
package com.openisle.model;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Getter
@Setter
@NoArgsConstructor
@Entity
@Table(name = "post_featured_change_logs")
public class PostFeaturedChangeLog extends PostChangeLog {
private boolean oldFeatured;
private boolean newFeatured;
}

View File

@@ -0,0 +1,16 @@
package com.openisle.model;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Getter
@Setter
@NoArgsConstructor
@Entity
@Table(name = "post_lottery_result_change_logs")
public class PostLotteryResultChangeLog extends PostChangeLog {
}

View File

@@ -0,0 +1,19 @@
package com.openisle.model;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.time.LocalDateTime;
@Getter
@Setter
@NoArgsConstructor
@Entity
@Table(name = "post_pinned_change_logs")
public class PostPinnedChangeLog extends PostChangeLog {
private LocalDateTime oldPinnedAt;
private LocalDateTime newPinnedAt;
}

View File

@@ -0,0 +1,21 @@
package com.openisle.model;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Getter
@Setter
@NoArgsConstructor
@Entity
@Table(name = "post_tag_change_logs")
public class PostTagChangeLog extends PostChangeLog {
@Column(name = "old_tags")
private String oldTags;
@Column(name = "new_tags")
private String newTags;
}

View File

@@ -0,0 +1,17 @@
package com.openisle.model;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Getter
@Setter
@NoArgsConstructor
@Entity
@Table(name = "post_title_change_logs")
public class PostTitleChangeLog extends PostChangeLog {
private String oldTitle;
private String newTitle;
}

View File

@@ -0,0 +1,16 @@
package com.openisle.model;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Getter
@Setter
@NoArgsConstructor
@Entity
@Table(name = "post_vote_result_change_logs")
public class PostVoteResultChangeLog extends PostChangeLog {
}

View File

@@ -38,8 +38,8 @@ public class Tag {
@Column(nullable = false, updatable = false,
columnDefinition = "DATETIME(6) DEFAULT CURRENT_TIMESTAMP(6)")
private LocalDateTime createdAt;
@ManyToOne(fetch = FetchType.LAZY)
// 改用redis缓存之后选择立即加载策略
@ManyToOne(fetch = FetchType.EAGER)
@JoinColumn(name = "creator_id")
private User creator;
}

View File

@@ -74,6 +74,12 @@ public class User {
NotificationType.USER_ACTIVITY
);
@ElementCollection(targetClass = NotificationType.class)
@CollectionTable(name = "user_disabled_email_notification_types", joinColumns = @JoinColumn(name = "user_id"))
@Column(name = "notification_type")
@Enumerated(EnumType.STRING)
private Set<NotificationType> disabledEmailNotificationTypes = EnumSet.noneOf(NotificationType.class);
@CreationTimestamp
@Column(nullable = false, updatable = false,
columnDefinition = "DATETIME(6) DEFAULT CURRENT_TIMESTAMP(6)")

View File

@@ -4,7 +4,10 @@ import com.openisle.model.Category;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
public interface CategoryRepository extends JpaRepository<Category, Long> {
List<Category> findByNameContainingIgnoreCase(String keyword);
Optional<Category> findByName(String name);
}

View File

@@ -9,4 +9,8 @@ import java.util.Optional;
public interface InviteTokenRepository extends JpaRepository<InviteToken, String> {
Optional<InviteToken> findByInviterAndCreatedDate(User inviter, LocalDate createdDate);
Optional<InviteToken> findByShortToken(String shortToken);
boolean existsByShortToken(String shortToken);
}

View File

@@ -11,6 +11,9 @@ import java.util.List;
@Repository
public interface MessageConversationRepository extends JpaRepository<MessageConversation, Long> {
@Query("SELECT c FROM MessageConversation c LEFT JOIN FETCH c.participants p LEFT JOIN FETCH p.user WHERE c.id = :id")
java.util.Optional<MessageConversation> findByIdWithParticipantsAndUsers(@Param("id") Long id);
@Query("SELECT c FROM MessageConversation c " +
"WHERE c.channel = false AND size(c.participants) = 2 " +
"AND EXISTS (SELECT 1 FROM c.participants p1 WHERE p1.user = :user1) " +

View File

@@ -2,6 +2,7 @@ package com.openisle.repository;
import com.openisle.model.PointHistory;
import com.openisle.model.User;
import com.openisle.model.Comment;
import org.springframework.data.jpa.repository.JpaRepository;
import java.time.LocalDateTime;
@@ -9,7 +10,10 @@ import java.util.List;
public interface PointHistoryRepository extends JpaRepository<PointHistory, Long> {
List<PointHistory> findByUserOrderByIdDesc(User user);
List<PointHistory> findByUserOrderByIdAsc(User user);
long countByUser(User user);
List<PointHistory> findByUserAndCreatedAtAfterOrderByCreatedAtDesc(User user, LocalDateTime createdAt);
List<PointHistory> findByComment(Comment comment);
}

View File

@@ -3,5 +3,11 @@ package com.openisle.repository;
import com.openisle.model.PollPost;
import org.springframework.data.jpa.repository.JpaRepository;
import java.time.LocalDateTime;
import java.util.List;
public interface PollPostRepository extends JpaRepository<PollPost, Long> {
List<PollPost> findByEndTimeAfterAndResultAnnouncedFalse(LocalDateTime now);
List<PollPost> findByEndTimeBeforeAndResultAnnouncedFalse(LocalDateTime now);
}

View File

@@ -0,0 +1,10 @@
package com.openisle.repository;
import com.openisle.model.PollVote;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface PollVoteRepository extends JpaRepository<PollVote, Long> {
List<PollVote> findByPostId(Long postId);
}

View File

@@ -0,0 +1,11 @@
package com.openisle.repository;
import com.openisle.model.Post;
import com.openisle.model.PostChangeLog;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface PostChangeLogRepository extends JpaRepository<PostChangeLog, Long> {
List<PostChangeLog> findByPostOrderByCreatedAtAsc(Post post);
}

View File

@@ -6,6 +6,7 @@ import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.domain.Pageable;
import java.util.List;
import java.util.Optional;
public interface TagRepository extends JpaRepository<Tag, Long> {
List<Tag> findByNameContainingIgnoreCase(String keyword);
@@ -15,4 +16,6 @@ public interface TagRepository extends JpaRepository<Tag, Long> {
List<Tag> findByCreatorOrderByCreatedAtDesc(User creator, Pageable pageable);
List<Tag> findByCreator(User creator);
Optional<Tag> findByName(String name);
}

View File

@@ -1,8 +1,11 @@
package com.openisle.service;
import com.openisle.config.CachingConfig;
import com.openisle.model.Category;
import com.openisle.repository.CategoryRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
import java.util.List;
@@ -11,7 +14,7 @@ import java.util.List;
@RequiredArgsConstructor
public class CategoryService {
private final CategoryRepository categoryRepository;
@CacheEvict(value = CachingConfig.CATEGORY_CACHE_NAME, allEntries = true)
public Category createCategory(String name, String description, String icon, String smallIcon) {
Category category = new Category();
category.setName(name);
@@ -20,7 +23,7 @@ public class CategoryService {
category.setSmallIcon(smallIcon);
return categoryRepository.save(category);
}
@CacheEvict(value = CachingConfig.CATEGORY_CACHE_NAME, allEntries = true)
public Category updateCategory(Long id, String name, String description, String icon, String smallIcon) {
Category category = categoryRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Category not found"));
@@ -38,7 +41,7 @@ public class CategoryService {
}
return categoryRepository.save(category);
}
@CacheEvict(value = CachingConfig.CATEGORY_CACHE_NAME, allEntries = true)
public void deleteCategory(Long id) {
categoryRepository.deleteById(id);
}
@@ -48,6 +51,14 @@ public class CategoryService {
.orElseThrow(() -> new IllegalArgumentException("Category not found"));
}
/**
* 该方法每次首页加载都会访问,加入缓存
* @return
*/
@Cacheable(
value = CachingConfig.CATEGORY_CACHE_NAME,
key = "'listCategories:'"
)
public List<Category> listCategories() {
return categoryRepository.findAll();
}

View File

@@ -4,6 +4,7 @@ import com.openisle.model.Comment;
import com.openisle.model.Post;
import com.openisle.model.User;
import com.openisle.model.NotificationType;
import com.openisle.model.PointHistory;
import com.openisle.model.CommentSort;
import com.openisle.repository.CommentRepository;
import com.openisle.repository.PostRepository;
@@ -11,8 +12,10 @@ import com.openisle.repository.UserRepository;
import com.openisle.repository.ReactionRepository;
import com.openisle.repository.CommentSubscriptionRepository;
import com.openisle.repository.NotificationRepository;
import com.openisle.repository.PointHistoryRepository;
import com.openisle.service.NotificationService;
import com.openisle.service.SubscriptionService;
import com.openisle.service.PointService;
import com.openisle.model.Role;
import com.openisle.exception.RateLimitException;
import lombok.RequiredArgsConstructor;
@@ -20,6 +23,9 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Set;
import java.util.HashSet;
import java.util.stream.Collectors;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.transaction.annotation.Transactional;
@@ -37,6 +43,8 @@ public class CommentService {
private final ReactionRepository reactionRepository;
private final CommentSubscriptionRepository commentSubscriptionRepository;
private final NotificationRepository notificationRepository;
private final PointHistoryRepository pointHistoryRepository;
private final PointService pointService;
private final ImageUploader imageUploader;
@Transactional
@@ -63,16 +71,19 @@ public class CommentService {
log.debug("Comment {} saved for post {}", comment.getId(), postId);
imageUploader.addReferences(imageUploader.extractUrls(content));
if (!author.getId().equals(post.getAuthor().getId())) {
notificationService.createNotification(post.getAuthor(), NotificationType.COMMENT_REPLY, post, comment, null, null, null, null);
notificationService.createNotification(post.getAuthor(), NotificationType.COMMENT_REPLY, post, comment,
null, null, null, null);
}
for (User u : subscriptionService.getPostSubscribers(postId)) {
if (!u.getId().equals(author.getId())) {
notificationService.createNotification(u, NotificationType.POST_UPDATED, post, comment, null, null, null, null);
notificationService.createNotification(u, NotificationType.POST_UPDATED, post, comment, null, null,
null, null);
}
}
for (User u : subscriptionService.getSubscribers(author.getUsername())) {
if (!u.getId().equals(author.getId())) {
notificationService.createNotification(u, NotificationType.USER_ACTIVITY, post, comment, null, null, null, null);
notificationService.createNotification(u, NotificationType.USER_ACTIVITY, post, comment, null, null,
null, null);
}
}
notificationService.notifyMentions(content, author, post, comment);
@@ -109,21 +120,25 @@ public class CommentService {
log.debug("Reply {} saved for parent {}", comment.getId(), parentId);
imageUploader.addReferences(imageUploader.extractUrls(content));
if (!author.getId().equals(parent.getAuthor().getId())) {
notificationService.createNotification(parent.getAuthor(), NotificationType.COMMENT_REPLY, parent.getPost(), comment, null, null, null, null);
notificationService.createNotification(parent.getAuthor(), NotificationType.COMMENT_REPLY, parent.getPost(),
comment, null, null, null, null);
}
for (User u : subscriptionService.getCommentSubscribers(parentId)) {
if (!u.getId().equals(author.getId())) {
notificationService.createNotification(u, NotificationType.COMMENT_REPLY, parent.getPost(), comment, null, null, null, null);
notificationService.createNotification(u, NotificationType.COMMENT_REPLY, parent.getPost(), comment,
null, null, null, null);
}
}
for (User u : subscriptionService.getPostSubscribers(parent.getPost().getId())) {
if (!u.getId().equals(author.getId())) {
notificationService.createNotification(u, NotificationType.POST_UPDATED, parent.getPost(), comment, null, null, null, null);
notificationService.createNotification(u, NotificationType.POST_UPDATED, parent.getPost(), comment,
null, null, null, null);
}
}
for (User u : subscriptionService.getSubscribers(author.getUsername())) {
if (!u.getId().equals(author.getId())) {
notificationService.createNotification(u, NotificationType.USER_ACTIVITY, parent.getPost(), comment, null, null, null, null);
notificationService.createNotification(u, NotificationType.USER_ACTIVITY, parent.getPost(), comment,
null, null, null, null);
}
}
notificationService.notifyMentions(content, author, parent.getPost(), comment);
@@ -235,11 +250,33 @@ public class CommentService {
for (Comment c : replies) {
deleteCommentCascade(c);
}
// 逻辑删除相关的积分历史记录,并收集受影响的用户
List<PointHistory> pointHistories = pointHistoryRepository.findByComment(comment);
// 收集需要重新计算积分的用户
Set<User> usersToRecalculate = pointHistories.stream().map(PointHistory::getUser).collect(Collectors.toSet());
// 删除其他相关数据
reactionRepository.findByComment(comment).forEach(reactionRepository::delete);
commentSubscriptionRepository.findByComment(comment).forEach(commentSubscriptionRepository::delete);
notificationRepository.deleteAll(notificationRepository.findByComment(comment));
imageUploader.removeReferences(imageUploader.extractUrls(comment.getContent()));
// 逻辑删除评论
commentRepository.delete(comment);
// 删除积分历史
pointHistoryRepository.deleteAll(pointHistories);
// 重新计算受影响用户的积分
if (!usersToRecalculate.isEmpty()) {
for (User user : usersToRecalculate) {
int newPoints = pointService.recalculateUserPoints(user);
user.setPoint(newPoints);
log.debug("Recalculated points for user {}: {}", user.getUsername(), newPoints);
}
userRepository.saveAll(usersToRecalculate);
}
log.debug("deleteCommentCascade removed comment {}", comment.getId());
}

View File

@@ -30,33 +30,53 @@ public class InviteService {
LocalDate today = LocalDate.now();
Optional<InviteToken> existing = inviteTokenRepository.findByInviterAndCreatedDate(inviter, today);
if (existing.isPresent()) {
return existing.get().getToken();
InviteToken inviteToken = existing.get();
return inviteToken.getShortToken() != null ? inviteToken.getShortToken() : inviteToken.getToken();
}
String token = jwtService.generateInviteToken(username);
String shortToken;
do {
shortToken = java.util.UUID.randomUUID().toString().replace("-", "").substring(0, 8);
} while (inviteTokenRepository.existsByShortToken(shortToken));
InviteToken inviteToken = new InviteToken();
inviteToken.setToken(token);
inviteToken.setShortToken(shortToken);
inviteToken.setInviter(inviter);
inviteToken.setCreatedDate(today);
inviteToken.setUsageCount(0);
inviteTokenRepository.save(inviteToken);
return token;
return shortToken;
}
public InviteValidateResult validate(String token) {
if (token == null || token.isEmpty()) {
return new InviteValidateResult(null, false);
}
InviteToken invite = inviteTokenRepository.findById(token).orElse(null);
String realToken = token;
if (invite == null) {
invite = inviteTokenRepository.findByShortToken(token).orElse(null);
if (invite == null) {
return new InviteValidateResult(null, false);
}
realToken = invite.getToken();
}
try {
jwtService.validateAndGetSubjectForInvite(token);
jwtService.validateAndGetSubjectForInvite(realToken);
} catch (Exception e) {
return new InviteValidateResult(null, false);
}
InviteToken invite = inviteTokenRepository.findById(token).orElse(null);
return new InviteValidateResult(invite, invite != null && invite.getUsageCount() < 3);
return new InviteValidateResult(invite, invite.getUsageCount() < 3);
}
public void consume(String token, String newUserName) {
InviteToken invite = inviteTokenRepository.findById(token).orElseThrow();
InviteToken invite = inviteTokenRepository.findById(token)
.orElseGet(() -> inviteTokenRepository.findByShortToken(token).orElseThrow());
invite.setUsageCount(invite.getUsageCount() + 1);
inviteTokenRepository.save(invite);
pointService.awardForInvite(invite.getInviter().getUsername(), newUserName);

View File

@@ -16,16 +16,18 @@ import com.openisle.dto.MessageDto;
import com.openisle.dto.ReactionDto;
import com.openisle.dto.UserSummaryDto;
import com.openisle.mapper.ReactionMapper;
import com.openisle.dto.MessageNotificationPayload;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@Service
@@ -37,7 +39,7 @@ public class MessageService {
private final MessageConversationRepository conversationRepository;
private final MessageParticipantRepository participantRepository;
private final UserRepository userRepository;
private final SimpMessagingTemplate messagingTemplate;
private final NotificationProducer notificationProducer;
private final ReactionRepository reactionRepository;
private final ReactionMapper reactionMapper;
@@ -69,26 +71,41 @@ public class MessageService {
conversationRepository.save(conversation);
log.info("Conversation {} updated with last message ID {}", conversation.getId(), message.getId());
// Broadcast the new message to subscribed clients
MessageDto messageDto = toDto(message);
String conversationDestination = "/topic/conversation/" + conversation.getId();
messagingTemplate.convertAndSend(conversationDestination, messageDto);
log.info("Message {} broadcasted to destination: {}", message.getId(), conversationDestination);
// Also notify the recipient on their personal channel to update the conversation list
String userDestination = "/topic/user/" + recipient.getId() + "/messages";
messagingTemplate.convertAndSend(userDestination, messageDto);
log.info("Message {} notification sent to destination: {}", message.getId(), userDestination);
// Notify recipient of new unread count
long unreadCount = getUnreadMessageCount(recipientId);
log.info("Calculating unread count for user {}: {}", recipientId, unreadCount);
// Send using username instead of user ID for WebSocket routing
String recipientUsername = recipient.getUsername();
messagingTemplate.convertAndSendToUser(recipientUsername, "/queue/unread-count", unreadCount);
log.info("Sent unread count {} to user {} (username: {}) via WebSocket destination: /user/{}/queue/unread-count",
unreadCount, recipientId, recipientUsername, recipientUsername);
try {
MessageDto messageDto = toDto(message);
long unreadCount = getUnreadMessageCount(recipientId);
// 创建包含对话和参与者信息的完整payload
Map<String, Object> conversationInfo = new HashMap<>();
conversationInfo.put("id", conversation.getId());
conversationInfo.put("participants", conversation.getParticipants().stream()
.map(p -> {
Map<String, Object> participantInfo = new HashMap<>();
participantInfo.put("userId", p.getUser().getId());
participantInfo.put("username", p.getUser().getUsername());
return participantInfo;
}).collect(Collectors.toList()));
Map<String, Object> combinedPayload = new HashMap<>();
combinedPayload.put("message", messageDto);
combinedPayload.put("unreadCount", unreadCount);
combinedPayload.put("conversation", conversationInfo);
combinedPayload.put("senderId", senderId);
if (notificationProducer != null) {
log.info("NotificationProducer is available");
} else {
log.info("ERROR: NotificationProducer is NULL!");
return message;
}
log.info("Recipient username: {}", recipient.getUsername());
notificationProducer.sendNotification(new MessageNotificationPayload(recipient.getUsername(), combinedPayload));
log.info("=== Notification call completed ===");
} catch (Exception e) {
log.error("=== Error in notification process ===", e);
}
return message;
}
@@ -97,7 +114,7 @@ public class MessageService {
public Message sendMessageToConversation(Long senderId, Long conversationId, String content, Long replyToId) {
User sender = userRepository.findById(senderId)
.orElseThrow(() -> new IllegalArgumentException("Sender not found"));
MessageConversation conversation = conversationRepository.findById(conversationId)
MessageConversation conversation = conversationRepository.findByIdWithParticipantsAndUsers(conversationId)
.orElseThrow(() -> new IllegalArgumentException("Conversation not found"));
// Join the conversation if not already a participant (useful for channels)
@@ -124,22 +141,30 @@ public class MessageService {
conversationRepository.save(conversation);
MessageDto messageDto = toDto(message);
String conversationDestination = "/topic/conversation/" + conversation.getId();
messagingTemplate.convertAndSend(conversationDestination, messageDto);
// Notify all participants except sender for updates
for (MessageParticipant participant : conversation.getParticipants()) {
if (participant.getUser().getId().equals(senderId)) continue;
String userDestination = "/topic/user/" + participant.getUser().getId() + "/messages";
messagingTemplate.convertAndSend(userDestination, messageDto);
// Build participant payloads once to avoid duplicate broadcasts
java.util.List<Map<String, Object>> participantInfos = conversation.getParticipants().stream()
.filter(p -> !p.getUser().getId().equals(senderId))
.map(p -> {
Map<String, Object> info = new HashMap<>();
info.put("userId", p.getUser().getId());
info.put("username", p.getUser().getUsername());
info.put("unreadCount", getUnreadMessageCount(p.getUser().getId()));
info.put("channelUnread", getUnreadChannelCount(p.getUser().getId()));
return info;
}).collect(Collectors.toList());
long unreadCount = getUnreadMessageCount(participant.getUser().getId());
String username = participant.getUser().getUsername();
messagingTemplate.convertAndSendToUser(username, "/queue/unread-count", unreadCount);
Map<String, Object> conversationInfo = new HashMap<>();
conversationInfo.put("id", conversation.getId());
conversationInfo.put("participants", participantInfos);
long channelUnread = getUnreadChannelCount(participant.getUser().getId());
messagingTemplate.convertAndSendToUser(username, "/queue/channel-unread", channelUnread);
}
Map<String, Object> combinedPayload = new HashMap<>();
combinedPayload.put("message", messageDto);
combinedPayload.put("conversation", conversationInfo);
combinedPayload.put("senderId", senderId);
// Use sender's username for sharding; only one notification is needed
notificationProducer.sendNotification(new MessageNotificationPayload(sender.getUsername(), combinedPayload));
return message;
}

View File

@@ -0,0 +1,63 @@
package com.openisle.service;
import com.openisle.config.RabbitMQConfig;
import com.openisle.config.ShardInfo;
import com.openisle.config.ShardingStrategy;
import com.openisle.dto.MessageNotificationPayload;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
@Slf4j
public class NotificationProducer {
private final RabbitTemplate rabbitTemplate;
private final ShardingStrategy shardingStrategy;
@Value("${rabbitmq.sharding.enabled}")
private boolean shardingEnabled;
public void sendNotification(MessageNotificationPayload payload) {
String targetUsername = payload.getTargetUsername();
try {
if (shardingEnabled) {
// 使用分片策略发送消息
sendShardedNotification(payload, targetUsername);
} else {
// 使用原始单队列方式发送(向后兼容)
sendLegacyNotification(payload);
}
} catch (Exception e) {
log.error("Failed to send message to RabbitMQ for user: {}", targetUsername, e);
throw e;
}
}
/**
* 使用分片策略发送消息
*/
private void sendShardedNotification(MessageNotificationPayload payload, String targetUsername) {
ShardInfo shardInfo = shardingStrategy.getShardInfo(targetUsername);
rabbitTemplate.convertAndSend(
RabbitMQConfig.EXCHANGE_NAME,
shardInfo.getRoutingKey(),
payload
);
}
/**
* 使用原始单队列方式发送消息(向后兼容)
*/
private void sendLegacyNotification(MessageNotificationPayload payload) {
rabbitTemplate.convertAndSend(
RabbitMQConfig.EXCHANGE_NAME,
RabbitMQConfig.ROUTING_KEY,
payload
);
}
}

View File

@@ -19,6 +19,7 @@ import java.util.regex.Pattern;
import java.util.regex.Matcher;
import java.util.Set;
import java.util.HashSet;
import java.util.EnumSet;
import java.util.List;
import java.util.ArrayList;
@@ -40,6 +41,12 @@ public class NotificationService {
private static final Pattern MENTION_PATTERN = Pattern.compile("@\\[([^\\]]+)\\]");
private static final Set<NotificationType> EMAIL_TYPES = EnumSet.of(
NotificationType.COMMENT_REPLY,
NotificationType.LOTTERY_WIN,
NotificationType.LOTTERY_DRAW
);
private String buildPayload(String body, String url) {
// Ensure push notifications contain a link to the related resource so
// that verifications can assert its presence and users can navigate
@@ -75,7 +82,8 @@ public class NotificationService {
n = notificationRepository.save(n);
// Runnable asyncTask = () -> {
if (type == NotificationType.COMMENT_REPLY && user.getEmail() != null && post != null && comment != null) {
if (type == NotificationType.COMMENT_REPLY && user.getEmail() != null && post != null && comment != null
&& !user.getDisabledEmailNotificationTypes().contains(NotificationType.COMMENT_REPLY)) {
String url = String.format("%s/posts/%d#comment-%d", websiteUrl, post.getId(), comment.getId());
emailSender.sendEmail(user.getEmail(), "有人回复了你", url);
sendCustomPush(user, "有人回复了你", url);
@@ -187,6 +195,35 @@ public class NotificationService {
userRepository.save(user);
}
public List<NotificationPreferenceDto> listEmailPreferences(String username) {
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
Set<NotificationType> disabled = user.getDisabledEmailNotificationTypes();
List<NotificationPreferenceDto> prefs = new ArrayList<>();
for (NotificationType nt : EMAIL_TYPES) {
NotificationPreferenceDto dto = new NotificationPreferenceDto();
dto.setType(nt);
dto.setEnabled(!disabled.contains(nt));
prefs.add(dto);
}
return prefs;
}
public void updateEmailPreference(String username, NotificationType type, boolean enabled) {
if (!EMAIL_TYPES.contains(type)) {
return;
}
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
Set<NotificationType> disabled = user.getDisabledEmailNotificationTypes();
if (enabled) {
disabled.remove(type);
} else {
disabled.add(type);
}
userRepository.save(user);
}
public List<Notification> listNotifications(String username, Boolean read, int page, int size) {
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));

View File

@@ -219,4 +219,35 @@ public class PointService {
return result;
}
/**
* 重新计算用户的积分总数
* 通过累加所有积分历史记录来重新计算用户的当前积分
*/
public int recalculateUserPoints(User user) {
// 获取用户所有的积分历史记录(由于@Where注解已删除的记录会被自动过滤
List<PointHistory> histories = pointHistoryRepository.findByUserOrderByIdAsc(user);
int totalPoints = 0;
for (PointHistory history : histories) {
totalPoints += history.getAmount();
// 重新计算每条历史记录的余额
history.setBalance(totalPoints);
}
// 批量更新历史记录及用户积分
pointHistoryRepository.saveAll(histories);
user.setPoint(totalPoints);
userRepository.save(user);
return totalPoints;
}
/**
* 重新计算用户的积分总数(通过用户名)
*/
public int recalculateUserPoints(String userName) {
User user = userRepository.findByUsername(userName).orElseThrow();
return recalculateUserPoints(user);
}
}

View File

@@ -0,0 +1,117 @@
package com.openisle.service;
import com.openisle.model.*;
import com.openisle.repository.PostChangeLogRepository;
import com.openisle.repository.PostRepository;
import com.openisle.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
@Service
@RequiredArgsConstructor
public class PostChangeLogService {
private final PostChangeLogRepository logRepository;
private final PostRepository postRepository;
private final UserRepository userRepository;
private User getSystemUser() {
return userRepository.findByUsername("system")
.orElseThrow(() -> new IllegalStateException("System user not found"));
}
public void recordContentChange(Post post, User user, String oldContent, String newContent) {
PostContentChangeLog log = new PostContentChangeLog();
log.setPost(post);
log.setUser(user);
log.setType(PostChangeType.CONTENT);
log.setOldContent(oldContent);
log.setNewContent(newContent);
logRepository.save(log);
}
public void recordTitleChange(Post post, User user, String oldTitle, String newTitle) {
PostTitleChangeLog log = new PostTitleChangeLog();
log.setPost(post);
log.setUser(user);
log.setType(PostChangeType.TITLE);
log.setOldTitle(oldTitle);
log.setNewTitle(newTitle);
logRepository.save(log);
}
public void recordCategoryChange(Post post, User user, String oldCategory, String newCategory) {
PostCategoryChangeLog log = new PostCategoryChangeLog();
log.setPost(post);
log.setUser(user);
log.setType(PostChangeType.CATEGORY);
log.setOldCategory(oldCategory);
log.setNewCategory(newCategory);
logRepository.save(log);
}
public void recordTagChange(Post post, User user, Set<Tag> oldTags, Set<Tag> newTags) {
PostTagChangeLog log = new PostTagChangeLog();
log.setPost(post);
log.setUser(user);
log.setType(PostChangeType.TAG);
log.setOldTags(oldTags.stream().map(Tag::getName).collect(Collectors.joining(",")));
log.setNewTags(newTags.stream().map(Tag::getName).collect(Collectors.joining(",")));
logRepository.save(log);
}
public void recordClosedChange(Post post, User user, boolean oldClosed, boolean newClosed) {
PostClosedChangeLog log = new PostClosedChangeLog();
log.setPost(post);
log.setUser(user);
log.setType(PostChangeType.CLOSED);
log.setOldClosed(oldClosed);
log.setNewClosed(newClosed);
logRepository.save(log);
}
public void recordPinnedChange(Post post, User user, java.time.LocalDateTime oldPinnedAt, java.time.LocalDateTime newPinnedAt) {
PostPinnedChangeLog log = new PostPinnedChangeLog();
log.setPost(post);
log.setUser(user);
log.setType(PostChangeType.PINNED);
log.setOldPinnedAt(oldPinnedAt);
log.setNewPinnedAt(newPinnedAt);
logRepository.save(log);
}
public void recordFeaturedChange(Post post, User user, boolean oldFeatured, boolean newFeatured) {
PostFeaturedChangeLog log = new PostFeaturedChangeLog();
log.setPost(post);
log.setUser(user);
log.setType(PostChangeType.FEATURED);
log.setOldFeatured(oldFeatured);
log.setNewFeatured(newFeatured);
logRepository.save(log);
}
public void recordVoteResult(Post post) {
PostVoteResultChangeLog log = new PostVoteResultChangeLog();
log.setPost(post);
log.setUser(getSystemUser());
log.setType(PostChangeType.VOTE_RESULT);
logRepository.save(log);
}
public void recordLotteryResult(Post post) {
PostLotteryResultChangeLog log = new PostLotteryResultChangeLog();
log.setPost(post);
log.setUser(getSystemUser());
log.setType(PostChangeType.LOTTERY_RESULT);
logRepository.save(log);
}
public List<PostChangeLog> listLogs(Long postId) {
Post post = postRepository.findById(postId)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found"));
return logRepository.findByPostOrderByCreatedAtAsc(post);
}
}

View File

@@ -10,6 +10,7 @@ import com.openisle.model.Comment;
import com.openisle.model.NotificationType;
import com.openisle.model.LotteryPost;
import com.openisle.model.PollPost;
import com.openisle.model.PollVote;
import com.openisle.repository.PostRepository;
import com.openisle.repository.LotteryPostRepository;
import com.openisle.repository.PollPostRepository;
@@ -18,10 +19,12 @@ import com.openisle.repository.CategoryRepository;
import com.openisle.repository.TagRepository;
import com.openisle.service.SubscriptionService;
import com.openisle.service.CommentService;
import com.openisle.service.PostChangeLogService;
import com.openisle.repository.CommentRepository;
import com.openisle.repository.ReactionRepository;
import com.openisle.repository.PostSubscriptionRepository;
import com.openisle.repository.NotificationRepository;
import com.openisle.repository.PollVoteRepository;
import com.openisle.model.Role;
import com.openisle.exception.RateLimitException;
import lombok.extern.slf4j.Slf4j;
@@ -57,6 +60,7 @@ public class PostService {
private final TagRepository tagRepository;
private final LotteryPostRepository lotteryPostRepository;
private final PollPostRepository pollPostRepository;
private final PollVoteRepository pollVoteRepository;
private PublishMode publishMode;
private final NotificationService notificationService;
private final SubscriptionService subscriptionService;
@@ -71,6 +75,7 @@ public class PostService {
private final EmailSender emailSender;
private final ApplicationContext applicationContext;
private final PointService pointService;
private final PostChangeLogService postChangeLogService;
private final ConcurrentMap<Long, ScheduledFuture<?>> scheduledFinalizations = new ConcurrentHashMap<>();
@Value("${app.website-url:https://www.open-isle.com}")
private String websiteUrl;
@@ -82,6 +87,7 @@ public class PostService {
TagRepository tagRepository,
LotteryPostRepository lotteryPostRepository,
PollPostRepository pollPostRepository,
PollVoteRepository pollVoteRepository,
NotificationService notificationService,
SubscriptionService subscriptionService,
CommentService commentService,
@@ -95,6 +101,7 @@ public class PostService {
EmailSender emailSender,
ApplicationContext applicationContext,
PointService pointService,
PostChangeLogService postChangeLogService,
@Value("${app.post.publish-mode:DIRECT}") PublishMode publishMode) {
this.postRepository = postRepository;
this.userRepository = userRepository;
@@ -102,6 +109,7 @@ public class PostService {
this.tagRepository = tagRepository;
this.lotteryPostRepository = lotteryPostRepository;
this.pollPostRepository = pollPostRepository;
this.pollVoteRepository = pollVoteRepository;
this.notificationService = notificationService;
this.subscriptionService = subscriptionService;
this.commentService = commentService;
@@ -115,6 +123,7 @@ public class PostService {
this.emailSender = emailSender;
this.applicationContext = applicationContext;
this.pointService = pointService;
this.postChangeLogService = postChangeLogService;
this.publishMode = publishMode;
}
@@ -130,6 +139,15 @@ public class PostService {
for (LotteryPost lp : lotteryPostRepository.findByEndTimeBeforeAndWinnersIsEmpty(now)) {
applicationContext.getBean(PostService.class).finalizeLottery(lp.getId());
}
for (PollPost pp : pollPostRepository.findByEndTimeAfterAndResultAnnouncedFalse(now)) {
ScheduledFuture<?> future = taskScheduler.schedule(
() -> applicationContext.getBean(PostService.class).finalizePoll(pp.getId()),
java.util.Date.from(pp.getEndTime().atZone(ZoneId.systemDefault()).toInstant()));
scheduledFinalizations.put(pp.getId(), future);
}
for (PollPost pp : pollPostRepository.findByEndTimeBeforeAndResultAnnouncedFalse(now)) {
applicationContext.getBean(PostService.class).finalizePoll(pp.getId());
}
}
public PublishMode getPublishMode() {
@@ -145,19 +163,28 @@ public class PostService {
return postRepository.findByStatusAndRssExcludedFalseOrderByCreatedAtDesc(PostStatus.PUBLISHED, pageable);
}
public Post excludeFromRss(Long id) {
public Post excludeFromRss(Long id, String username) {
Post post = postRepository.findById(id).orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found"));
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
boolean oldFeatured = !Boolean.TRUE.equals(post.getRssExcluded());
post.setRssExcluded(true);
return postRepository.save(post);
Post saved = postRepository.save(post);
postChangeLogService.recordFeaturedChange(saved, user, oldFeatured, false);
return saved;
}
public Post includeInRss(Long id) {
public Post includeInRss(Long id, String username) {
Post post = postRepository.findById(id).orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found"));
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
boolean oldFeatured = !Boolean.TRUE.equals(post.getRssExcluded());
post.setRssExcluded(false);
post = postRepository.save(post);
notificationService.createNotification(post.getAuthor(), NotificationType.POST_FEATURED, post, null, null, null, null, null);
pointService.awardForFeatured(post.getAuthor().getUsername(), post.getId());
return post;
Post saved = postRepository.save(post);
postChangeLogService.recordFeaturedChange(saved, user, oldFeatured, true);
notificationService.createNotification(saved.getAuthor(), NotificationType.POST_FEATURED, saved, null, null, null, null, null);
pointService.awardForFeatured(saved.getAuthor().getUsername(), saved.getId());
return saved;
}
public Post createPost(String username,
@@ -172,8 +199,8 @@ public class PostService {
Integer pointCost,
LocalDateTime startTime,
LocalDateTime endTime,
String question,
java.util.List<String> options) {
java.util.List<String> options,
Boolean multiple) {
long recent = postRepository.countByAuthorAfter(username,
java.time.LocalDateTime.now().minusMinutes(5));
if (recent >= 1) {
@@ -212,9 +239,9 @@ public class PostService {
throw new IllegalArgumentException("At least two options required");
}
PollPost pp = new PollPost();
pp.setQuestion(question);
pp.setOptions(options);
pp.setEndTime(endTime);
pp.setMultiple(multiple != null && multiple);
post = pp;
} else {
post = new Post();
@@ -264,6 +291,11 @@ public class PostService {
() -> applicationContext.getBean(PostService.class).finalizeLottery(lp.getId()),
java.util.Date.from(lp.getEndTime().atZone(ZoneId.systemDefault()).toInstant()));
scheduledFinalizations.put(lp.getId(), future);
} else if (post instanceof PollPost pp && pp.getEndTime() != null) {
ScheduledFuture<?> future = taskScheduler.schedule(
() -> applicationContext.getBean(PostService.class).finalizePoll(pp.getId()),
java.util.Date.from(pp.getEndTime().atZone(ZoneId.systemDefault()).toInstant()));
scheduledFinalizations.put(pp.getId(), future);
}
return post;
}
@@ -285,7 +317,7 @@ public class PostService {
}
@Transactional
public PollPost votePoll(Long postId, String username, int optionIndex) {
public PollPost votePoll(Long postId, String username, java.util.List<Integer> optionIndices) {
PollPost post = pollPostRepository.findById(postId)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found"));
if (post.getEndTime() != null && post.getEndTime().isBefore(LocalDateTime.now())) {
@@ -296,12 +328,48 @@ public class PostService {
if (post.getParticipants().contains(user)) {
throw new IllegalArgumentException("User already voted");
}
if (optionIndex < 0 || optionIndex >= post.getOptions().size()) {
throw new IllegalArgumentException("Invalid option");
if (optionIndices == null || optionIndices.isEmpty()) {
throw new IllegalArgumentException("No options selected");
}
java.util.Set<Integer> unique = new java.util.HashSet<>(optionIndices);
for (int optionIndex : unique) {
if (optionIndex < 0 || optionIndex >= post.getOptions().size()) {
throw new IllegalArgumentException("Invalid option");
}
}
post.getParticipants().add(user);
post.getVotes().merge(optionIndex, 1, Integer::sum);
return pollPostRepository.save(post);
for (int optionIndex : unique) {
post.getVotes().merge(optionIndex, 1, Integer::sum);
PollVote vote = new PollVote();
vote.setPost(post);
vote.setUser(user);
vote.setOptionIndex(optionIndex);
pollVoteRepository.save(vote);
}
PollPost saved = pollPostRepository.save(post);
if (post.getAuthor() != null && !post.getAuthor().getId().equals(user.getId())) {
notificationService.createNotification(post.getAuthor(), NotificationType.POLL_VOTE, post, null, null, user, null, null);
}
return saved;
}
@Transactional
public void finalizePoll(Long postId) {
scheduledFinalizations.remove(postId);
pollPostRepository.findById(postId).ifPresent(pp -> {
if (pp.isResultAnnounced()) {
return;
}
pp.setResultAnnounced(true);
pollPostRepository.save(pp);
if (pp.getAuthor() != null) {
notificationService.createNotification(pp.getAuthor(), NotificationType.POLL_RESULT_OWNER, pp, null, null, null, null, null);
}
for (User participant : pp.getParticipants()) {
notificationService.createNotification(participant, NotificationType.POLL_RESULT_PARTICIPANT, pp, null, null, null, null, null);
}
postChangeLogService.recordVoteResult(pp);
});
}
@Transactional
@@ -320,19 +388,22 @@ public class PostService {
lp.setWinners(winners);
lotteryPostRepository.save(lp);
for (User w : winners) {
if (w.getEmail() != null) {
if (w.getEmail() != null &&
!w.getDisabledEmailNotificationTypes().contains(NotificationType.LOTTERY_WIN)) {
emailSender.sendEmail(w.getEmail(), "你中奖了", "恭喜你在抽奖贴 \"" + lp.getTitle() + "\" 中获奖");
}
notificationService.createNotification(w, NotificationType.LOTTERY_WIN, lp, null, null, lp.getAuthor(), null, null);
notificationService.sendCustomPush(w, "你中奖了", String.format("%s/posts/%d", websiteUrl, lp.getId()));
}
if (lp.getAuthor() != null) {
if (lp.getAuthor().getEmail() != null) {
if (lp.getAuthor().getEmail() != null &&
!lp.getAuthor().getDisabledEmailNotificationTypes().contains(NotificationType.LOTTERY_DRAW)) {
emailSender.sendEmail(lp.getAuthor().getEmail(), "抽奖已开奖", "您的抽奖贴 \"" + lp.getTitle() + "\" 已开奖");
}
notificationService.createNotification(lp.getAuthor(), NotificationType.LOTTERY_DRAW, lp, null, null, null, null, null);
notificationService.sendCustomPush(lp.getAuthor(), "抽奖已开奖", String.format("%s/posts/%d", websiteUrl, lp.getId()));
}
postChangeLogService.recordLotteryResult(lp);
});
}
@@ -582,18 +653,28 @@ public class PostService {
return post;
}
public Post pinPost(Long id) {
public Post pinPost(Long id, String username) {
Post post = postRepository.findById(id)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found"));
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
java.time.LocalDateTime oldPinned = post.getPinnedAt();
post.setPinnedAt(java.time.LocalDateTime.now());
return postRepository.save(post);
Post saved = postRepository.save(post);
postChangeLogService.recordPinnedChange(saved, user, oldPinned, saved.getPinnedAt());
return saved;
}
public Post unpinPost(Long id) {
public Post unpinPost(Long id, String username) {
Post post = postRepository.findById(id)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found"));
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
java.time.LocalDateTime oldPinned = post.getPinnedAt();
post.setPinnedAt(null);
return postRepository.save(post);
Post saved = postRepository.save(post);
postChangeLogService.recordPinnedChange(saved, user, oldPinned, null);
return saved;
}
public Post closePost(Long id, String username) {
@@ -604,8 +685,11 @@ public class PostService {
if (!user.getId().equals(post.getAuthor().getId()) && user.getRole() != Role.ADMIN) {
throw new IllegalArgumentException("Unauthorized");
}
boolean oldClosed = post.isClosed();
post.setClosed(true);
return postRepository.save(post);
Post saved = postRepository.save(post);
postChangeLogService.recordClosedChange(saved, user, oldClosed, true);
return saved;
}
public Post reopenPost(Long id, String username) {
@@ -616,8 +700,11 @@ public class PostService {
if (!user.getId().equals(post.getAuthor().getId()) && user.getRole() != Role.ADMIN) {
throw new IllegalArgumentException("Unauthorized");
}
boolean oldClosed = post.isClosed();
post.setClosed(false);
return postRepository.save(post);
Post saved = postRepository.save(post);
postChangeLogService.recordClosedChange(saved, user, oldClosed, false);
return saved;
}
@org.springframework.transaction.annotation.Transactional
@@ -646,14 +733,30 @@ public class PostService {
if (tags.isEmpty()) {
throw new IllegalArgumentException("Tag not found");
}
post.setTitle(title);
String oldTitle = post.getTitle();
String oldContent = post.getContent();
Category oldCategory = post.getCategory();
java.util.Set<com.openisle.model.Tag> oldTags = new java.util.HashSet<>(post.getTags());
post.setTitle(title);
post.setContent(content);
post.setCategory(category);
post.setTags(new java.util.HashSet<>(tags));
Post updated = postRepository.save(post);
imageUploader.adjustReferences(oldContent, content);
notificationService.notifyMentions(content, user, updated, null);
if (!java.util.Objects.equals(oldTitle, title)) {
postChangeLogService.recordTitleChange(updated, user, oldTitle, title);
}
if (!java.util.Objects.equals(oldContent, content)) {
postChangeLogService.recordContentChange(updated, user, oldContent, content);
}
if (!java.util.Objects.equals(oldCategory.getId(), category.getId())) {
postChangeLogService.recordCategoryChange(updated, user, oldCategory.getName(), category.getName());
}
java.util.Set<com.openisle.model.Tag> newTags = new java.util.HashSet<>(tags);
if (!oldTags.equals(newTags)) {
postChangeLogService.recordTagChange(updated, user, oldTags, newTags);
}
return updated;
}

View File

@@ -18,6 +18,9 @@ public class ResendEmailSender extends EmailSender {
@Value("${resend.api.key}")
private String apiKey;
@Value("${resend.from.email}")
private String fromEmail;
private final RestTemplate restTemplate = new RestTemplate();
@Override
@@ -33,7 +36,7 @@ public class ResendEmailSender extends EmailSender {
body.put("to", to);
body.put("subject", subject);
body.put("text", text);
body.put("from", "openisle <noreply@chenjiating.com>"); // todo(tim): use config
body.put("from", "openisle <" + fromEmail + ">");
HttpEntity<Map<String, String>> entity = new HttpEntity<>(body, headers);
restTemplate.exchange(url, HttpMethod.POST, entity, String.class);

View File

@@ -1,9 +1,12 @@
package com.openisle.service;
import com.openisle.config.CachingConfig;
import com.openisle.model.Tag;
import com.openisle.model.User;
import com.openisle.repository.TagRepository;
import com.openisle.repository.UserRepository;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import lombok.RequiredArgsConstructor;
@@ -18,6 +21,7 @@ public class TagService {
private final TagValidator tagValidator;
private final UserRepository userRepository;
@CacheEvict(value = CachingConfig.TAG_CACHE_NAME, allEntries = true)
public Tag createTag(String name, String description, String icon, String smallIcon, boolean approved, String creatorUsername) {
tagValidator.validate(name);
Tag tag = new Tag();
@@ -42,6 +46,7 @@ public class TagService {
return createTag(name, description, icon, smallIcon, true, null);
}
@CacheEvict(value = CachingConfig.TAG_CACHE_NAME, allEntries = true)
public Tag updateTag(Long id, String name, String description, String icon, String smallIcon) {
Tag tag = tagRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Tag not found"));
@@ -61,6 +66,7 @@ public class TagService {
return tagRepository.save(tag);
}
@CacheEvict(value = CachingConfig.TAG_CACHE_NAME, allEntries = true)
public void deleteTag(Long id) {
tagRepository.deleteById(id);
}
@@ -85,10 +91,20 @@ public class TagService {
return tagRepository.findByApprovedTrue();
}
/**
* 该方法每次首页加载都会访问,加入缓存
* @param keyword
* @return
*/
@Cacheable(
value = CachingConfig.TAG_CACHE_NAME,
key = "'searchTags:' + (#keyword ?: '')"//keyword为null的场合返回空
)
public List<Tag> searchTags(String keyword) {
if (keyword == null || keyword.isBlank()) {
return tagRepository.findByApprovedTrue();
}
return tagRepository.findByNameContainingIgnoreCaseAndApprovedTrue(keyword);
}

View File

@@ -0,0 +1,102 @@
package com.openisle.service;
import com.openisle.dto.TelegramLoginRequest;
import com.openisle.model.RegisterMode;
import com.openisle.model.Role;
import com.openisle.model.User;
import com.openisle.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.*;
@Service
@RequiredArgsConstructor
public class TelegramAuthService {
private final UserRepository userRepository;
private final AvatarGenerator avatarGenerator;
@Value("${telegram.bot-token:}")
private String botToken;
public Optional<AuthResult> authenticate(TelegramLoginRequest req, RegisterMode mode, boolean viaInvite) {
try {
if (botToken == null || botToken.isEmpty()) {
return Optional.empty();
}
String dataCheckString = buildDataCheckString(req);
MessageDigest md = MessageDigest.getInstance("SHA-256");
byte[] secretKey = md.digest(botToken.getBytes(StandardCharsets.UTF_8));
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secretKey, "HmacSHA256"));
byte[] hash = mac.doFinal(dataCheckString.getBytes(StandardCharsets.UTF_8));
String hex = bytesToHex(hash);
if (!hex.equalsIgnoreCase(req.getHash())) {
return Optional.empty();
}
String username = req.getUsername();
String email = (username != null ? username : req.getId()) + "@telegram.org";
String avatar = req.getPhotoUrl();
return Optional.of(processUser(email, username, avatar, mode, viaInvite));
} catch (Exception e) {
return Optional.empty();
}
}
private String buildDataCheckString(TelegramLoginRequest req) {
List<String> data = new ArrayList<>();
if (req.getAuthDate() != null) data.add("auth_date=" + req.getAuthDate());
if (req.getFirstName() != null) data.add("first_name=" + req.getFirstName());
if (req.getId() != null) data.add("id=" + req.getId());
if (req.getLastName() != null) data.add("last_name=" + req.getLastName());
if (req.getPhotoUrl() != null) data.add("photo_url=" + req.getPhotoUrl());
if (req.getUsername() != null) data.add("username=" + req.getUsername());
Collections.sort(data);
return String.join("\n", data);
}
private String bytesToHex(byte[] bytes) {
StringBuilder sb = new StringBuilder();
for (byte b : bytes) {
sb.append(String.format("%02x", b));
}
return sb.toString();
}
private AuthResult processUser(String email, String username, String avatar, RegisterMode mode, boolean viaInvite) {
Optional<User> existing = userRepository.findByEmail(email);
if (existing.isPresent()) {
User user = existing.get();
if (!user.isVerified()) {
user.setVerified(true);
user.setVerificationCode(null);
userRepository.save(user);
}
return new AuthResult(user, false);
}
String baseUsername = username != null ? username : email.split("@")[0];
String finalUsername = baseUsername;
int suffix = 1;
while (userRepository.findByUsername(finalUsername).isPresent()) {
finalUsername = baseUsername + suffix++;
}
User user = new User();
user.setUsername(finalUsername);
user.setEmail(email);
user.setPassword("");
user.setRole(Role.USER);
user.setVerified(true);
user.setApproved(mode == RegisterMode.DIRECT || viaInvite);
if (avatar != null) {
user.setAvatar(avatar);
} else {
user.setAvatar(avatarGenerator.generate(finalUsername));
}
return new AuthResult(userRepository.save(user), true);
}
}

View File

@@ -1,5 +1,6 @@
package com.openisle.service;
import com.openisle.config.CachingConfig;
import com.openisle.model.User;
import com.openisle.model.Role;
import com.openisle.service.PasswordValidator;
@@ -7,13 +8,18 @@ import com.openisle.service.UsernameValidator;
import com.openisle.service.AvatarGenerator;
import com.openisle.exception.FieldException;
import com.openisle.repository.UserRepository;
import com.openisle.util.VerifyType;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import java.util.Objects;
import java.util.Optional;
import java.util.Random;
import java.util.concurrent.TimeUnit;
@Service
@RequiredArgsConstructor
@@ -25,6 +31,10 @@ public class UserService {
private final ImageUploader imageUploader;
private final AvatarGenerator avatarGenerator;
private final RedisTemplate redisTemplate;
private final EmailSender emailService;
public User register(String username, String email, String password, String reason, com.openisle.model.RegisterMode mode) {
usernameValidator.validate(username);
passwordValidator.validate(password);
@@ -38,7 +48,7 @@ public class UserService {
// 未验证 → 允许“重注册”:覆盖必要字段并重新发验证码
u.setEmail(email); // 若不允许改邮箱可去掉
u.setPassword(passwordEncoder.encode(password));
u.setVerificationCode(genCode());
// u.setVerificationCode(genCode());
u.setRegisterReason(reason);
u.setApproved(mode == com.openisle.model.RegisterMode.DIRECT);
return userRepository.save(u);
@@ -54,7 +64,7 @@ public class UserService {
// 未验证 → 允许“重注册”
u.setUsername(username); // 若不允许改用户名可去掉
u.setPassword(passwordEncoder.encode(password));
u.setVerificationCode(genCode());
// u.setVerificationCode(genCode());
u.setRegisterReason(reason);
u.setApproved(mode == com.openisle.model.RegisterMode.DIRECT);
return userRepository.save(u);
@@ -67,7 +77,7 @@ public class UserService {
user.setPassword(passwordEncoder.encode(password));
user.setRole(Role.USER);
user.setVerified(false);
user.setVerificationCode(genCode());
// user.setVerificationCode(genCode());
user.setAvatar(avatarGenerator.generate(username));
user.setRegisterReason(reason);
user.setApproved(mode == com.openisle.model.RegisterMode.DIRECT);
@@ -77,7 +87,7 @@ public class UserService {
public User registerWithInvite(String username, String email, String password) {
User user = register(username, email, password, "", com.openisle.model.RegisterMode.DIRECT);
user.setVerified(true);
user.setVerificationCode(genCode());
// user.setVerificationCode(genCode());
return userRepository.save(user);
}
@@ -85,16 +95,58 @@ public class UserService {
return String.format("%06d", new Random().nextInt(1000000));
}
public boolean verifyCode(String username, String code) {
Optional<User> userOpt = userRepository.findByUsername(username);
if (userOpt.isPresent() && code.equals(userOpt.get().getVerificationCode())) {
User user = userOpt.get();
user.setVerified(true);
user.setVerificationCode(null);
userRepository.save(user);
return true;
/**
* 将验证码存入缓存,并发送邮件
* @param user
*/
public void sendVerifyMail(User user, VerifyType verifyType){
//缓存验证码
String code = genCode();
String key;
String subject;
String content = "您的验证码是:" + code;
// 注册类型
if(verifyType.equals(VerifyType.REGISTER)){
key = CachingConfig.VERIFY_CACHE_NAME + ":register:code:" + user.getUsername();
subject = "在网站填写验证码以验证(有效期为5分钟)";
}else {
// 重置密码
key = CachingConfig.VERIFY_CACHE_NAME + ":reset_password:code:" + user.getUsername();
subject = "请填写验证码以重置密码(有效期为5分钟)";
}
return false;
redisTemplate.opsForValue().set(key, code, 5, TimeUnit.MINUTES);// 五分钟后验证码过期
emailService.sendEmail(user.getEmail(), subject, content);
}
/**
* 验证code是否正确
* @param user
* @param code
* @param verifyType
* @return
*/
public boolean verifyCode(User user, String code, VerifyType verifyType) {
// 生成key
String key1 = VerifyType.REGISTER.equals(verifyType)?":register:code:":":reset_password:code:";
String key = CachingConfig.VERIFY_CACHE_NAME + key1 + user.getUsername();
// 这里不能使用getAndDelete,需要6.x版本
String cachedCode = (String)redisTemplate.opsForValue().get(key);
// 如果校验code过期或者不存在
// 或者校验code不一致
if(Objects.isNull(cachedCode)
|| !cachedCode.equals(code)){
return false;
}
// 注册模式需要设置已经确认
if(VerifyType.REGISTER.equals(verifyType)){
user.setVerified(true);
userRepository.save(user);
}
// 走到这里说明验证成功删除验证码
redisTemplate.delete(key);
return true;
}
public Optional<User> authenticate(String username, String password) {
@@ -165,26 +217,6 @@ public class UserService {
return userRepository.save(user);
}
public String generatePasswordResetCode(String email) {
User user = userRepository.findByEmail(email)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
String code = genCode();
user.setPasswordResetCode(code);
userRepository.save(user);
return code;
}
public boolean verifyPasswordResetCode(String email, String code) {
Optional<User> userOpt = userRepository.findByEmail(email);
if (userOpt.isPresent() && code.equals(userOpt.get().getPasswordResetCode())) {
User user = userOpt.get();
user.setPasswordResetCode(null);
userRepository.save(user);
return true;
}
return false;
}
public User updatePassword(String username, String newPassword) {
passwordValidator.validate(newPassword);
User user = userRepository.findByUsername(username)

View File

@@ -1,6 +1,7 @@
package com.openisle.service;
import com.openisle.exception.FieldException;
import org.apache.commons.lang3.math.NumberUtils;
import org.springframework.stereotype.Service;
/**
@@ -17,6 +18,11 @@ public class UsernameValidator {
if (username == null || username.isEmpty()) {
throw new FieldException("username", "Username cannot be empty");
}
if (NumberUtils.isDigits(username)) {
throw new FieldException("username", "Username cannot be pure number");
}
}
}

View File

@@ -0,0 +1,20 @@
package com.openisle.util;
/**
* 验证码类型
* @author smallclover
* @since 2025-09-08
*/
public enum VerifyType {
REGISTER(1),
RESET_PASSWORD(2);
private final int code;
VerifyType(int code) {
this.code = code;
}
public int getCode() {
return code;
}
}

View File

@@ -1,3 +1,6 @@
# for spring boot
server.port=${SERVER_PORT:8080}
# for mysql
logging.level.root=${LOG_LEVEL:INFO}
logging.level.com.openisle.service.CosImageUploader=DEBUG
@@ -6,6 +9,11 @@ spring.datasource.username=${MYSQL_USER:root}
spring.datasource.password=${MYSQL_PASSWORD:password}
spring.jpa.hibernate.ddl-auto=update
# for redis
spring.data.redis.host=${REDIS_HOST:localhost}
spring.data.redis.port=${REDIS_PORT:6379}
spring.data.redis.database=${REDIS_DATABASE:0}
# for jwt
app.jwt.secret=${JWT_SECRET:jwt_sec}
app.jwt.reason-secret=${JWT_REASON_SECRET:jwt_reason_sec}
@@ -48,8 +56,10 @@ app.captcha.comment-enabled=${CAPTCHA_COMMENT_ENABLED:false}
# ========= Optional =========
# for resend email send service, you can improve your service by yourself
resend.api.key=${RESEND_API_KEY:}
resend.from.email=${RESEND_FROM_EMAIL:}
# your email services: ...
# for tencent cloud image upload service, you can improve your service by yourself
cos.base-url=${:https://example.com}
cos.secret-id=${COS_SECRET_ID:}
@@ -69,6 +79,8 @@ discord.client-secret=${DISCORD_CLIENT_SECRET:}
# Twitter OAuth configuration
twitter.client-id=${TWITTER_CLIENT_ID:}
twitter.client-secret=${TWITTER_CLIENT_SECRET:}
# Telegram login configuration
telegram.bot-token=${TELEGRAM_BOT_TOKEN:}
# OpenAI configuration
openai.api-key=${OPENAI_API_KEY:}
openai.model=${OPENAI_MODEL:gpt-4o}
@@ -81,3 +93,24 @@ app.website-url=${WEBSITE_URL:https://www.open-isle.com}
# Web push configuration
app.webpush.public-key=${WEBPUSH_PUBLIC_KEY:}
app.webpush.private-key=${WEBPUSH_PRIVATE_KEY:}
# RabbitMQ Configuration
spring.rabbitmq.host=${RABBITMQ_HOST:localhost}
spring.rabbitmq.port=${RABBITMQ_PORT:5672}
spring.rabbitmq.username=${RABBITMQ_USERNAME:guest}
spring.rabbitmq.password=${RABBITMQ_PASSWORD:guest}
# RabbitMQ 队列配置 - 修改为非持久化以匹配现有队列
rabbitmq.queue.durable=true
rabbitmq.sharding.enabled=true
# springdoc-openapi-starter-webmvc-api
# see https://springdoc.org/#springdoc-openapi-core-properties
springdoc.api-docs.path=/api/v3/api-docs
springdoc.api-docs.enabled=true
springdoc.api-docs.server-url=${WEBSITE_URL:https://www.open-isle.com}
springdoc.info.title=OpenIsle
springdoc.info.description=OpenIsle Open API Documentation
springdoc.info.version=0.0.1
springdoc.info.scheme=Bearer
springdoc.info.header=Authorization

View File

@@ -0,0 +1,81 @@
-- 2025-09-02
-- 本地化开发,初始化脚本
-- 抽奖的时候奖品图片是必须的把相关代码注释掉即可跳过check
-- 设置字符集和排序规则
SET NAMES utf8;
SET CHARACTER SET utf8;
SET collation_connection = utf8_general_ci;
-- 创建 users 表(如果不存在)
CREATE TABLE IF NOT EXISTS `users` (
`id` bigint NOT NULL AUTO_INCREMENT,
`approved` bit(1) DEFAULT NULL,
`avatar` varchar(255) DEFAULT NULL,
`created_at` datetime(6) DEFAULT NULL,
`display_medal` varchar(255) DEFAULT NULL,
`email` varchar(255) NOT NULL,
`experience` int DEFAULT NULL,
`introduction` text,
`password` varchar(255) NOT NULL,
`password_reset_code` varchar(255) DEFAULT NULL,
`point` int DEFAULT NULL,
`register_reason` text,
`role` varchar(20) DEFAULT 'USER',
`username` varchar(50) NOT NULL,
`verification_code` varchar(255) DEFAULT NULL,
`verified` bit(1) DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `UK_users_email` (`email`),
UNIQUE KEY `UK_users_username` (`username`)
);
-- 清空users表
DELETE FROM `users`;
-- 插入用户,两个普通用户,一个管理员
-- username:admin/user1/user2 password:123321
INSERT INTO `users` (`id`, `approved`, `avatar`, `created_at`, `display_medal`, `email`, `experience`, `introduction`, `password`, `password_reset_code`, `point`, `register_reason`, `role`, `username`, `verification_code`, `verified`) VALUES
(1, b'1', '', '2025-09-01 16:08:17.426430', 'PIONEER', 'adminmail@openisle.com', 70, NULL, '$2a$10$dux.NXwW09cCsdZ05BgcnOtxVqqjcmnbj3.8xcxGl/iiIlv06y7Oe', NULL, 110, '测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试', 'ADMIN', 'admin', NULL, b'1'),
(2, b'1', '', '2025-09-03 16:08:17.426430', 'PIONEER', 'usermail2@openisle.com', 70, NULL, '$2a$10$dux.NXwW09cCsdZ05BgcnOtxVqqjcmnbj3.8xcxGl/iiIlv06y7Oe', NULL, 110, '测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试', 'USER', 'user1', NULL, b'1'),
(3, b'1', '', '2025-09-02 17:21:21.617666', 'PIONEER', 'usermail1@openisle.com', 40, NULL, '$2a$10$dux.NXwW09cCsdZ05BgcnOtxVqqjcmnbj3.8xcxGl/iiIlv06y7Oe', NULL, 40, '测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试', 'USER', 'user2', NULL, b'1');
-- 创建 tags 表(如果不存在)
CREATE TABLE IF NOT EXISTS `tags` (
`id` bigint NOT NULL AUTO_INCREMENT,
`approved` bit(1) DEFAULT NULL,
`created_at` datetime(6) DEFAULT NULL,
`description` text,
`icon` varchar(255) DEFAULT NULL,
`name` varchar(50) NOT NULL,
`small_icon` varchar(255) DEFAULT NULL,
`creator_id` bigint DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `UK_tags_name` (`name`),
KEY `FK_tags_creator` (`creator_id`),
CONSTRAINT `FK_tags_creator` FOREIGN KEY (`creator_id`) REFERENCES `users` (`id`)
);
-- 清空tags表
DELETE FROM `tags`;
-- 插入标签,三个测试用标签
INSERT INTO `tags` (`id`, `approved`, `created_at`, `description`, `icon`, `name`, `small_icon`, `creator_id`) VALUES
(1, b'1', '2025-09-02 10:51:56.000000', '测试用标签1', NULL, '测试用标签1', NULL, NULL),
(2, b'1', '2025-09-02 10:51:56.000000', '测试用标签2', NULL, '测试用标签2', NULL, NULL),
(3, b'1', '2025-09-02 10:51:56.000000', '测试用标签3', NULL, '测试用标签3', NULL, NULL);
-- 创建 categories 表(如果不存在)
CREATE TABLE IF NOT EXISTS `categories` (
`id` bigint NOT NULL AUTO_INCREMENT,
`description` text,
`icon` varchar(255) DEFAULT NULL,
`name` varchar(50) NOT NULL,
`small_icon` varchar(255) DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `UK_categories_name` (`name`)
);
-- 清空categories表
DELETE FROM `categories`;
-- 插入分类,三个测试用分类
INSERT INTO `categories` (`id`, `description`, `icon`, `name`, `small_icon`) VALUES
(1, '测试用分类1', '1', '测试用分类1', NULL),
(2, '测试用分类2', '2', '测试用分类2', NULL),
(3, '测试用分类3', '3', '测试用分类3', NULL);

View File

@@ -0,0 +1,11 @@
-- Add logical delete support for comments and point_histories tables
-- Add deleted_at column to comments table
ALTER TABLE comments ADD COLUMN deleted_at DATETIME(6) NULL;
-- Add deleted_at column to point_histories table
ALTER TABLE point_histories ADD COLUMN deleted_at DATETIME(6) NULL;
-- Add index for better performance on logical delete queries
CREATE INDEX idx_comments_deleted_at ON comments(deleted_at);
CREATE INDEX idx_point_histories_deleted_at ON point_histories(deleted_at);

View File

@@ -4,6 +4,7 @@ import com.openisle.model.User;
import com.openisle.service.*;
import com.openisle.model.RegisterMode;
import com.openisle.repository.UserRepository;
import com.openisle.util.VerifyType;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.springframework.beans.factory.annotation.Autowired;
@@ -71,7 +72,9 @@ class AuthControllerTest {
@Test
void verifyCodeEndpoint() throws Exception {
Mockito.when(userService.verifyCode("u", "123")).thenReturn(true);
User user = new User();
user.setUsername("u");
Mockito.when(userService.verifyCode(user, "123", VerifyType.REGISTER)).thenReturn(true);
Mockito.when(jwtService.generateReasonToken("u")).thenReturn("reason_token");
mockMvc.perform(post("/api/auth/verify")

View File

@@ -76,7 +76,7 @@ class PostControllerTest {
post.setTags(Set.of(tag));
when(postService.createPost(eq("alice"), eq(1L), eq("t"), eq("c"), eq(List.of(1L)),
isNull(), isNull(), isNull(), isNull(), isNull(), isNull(), isNull())).thenReturn(post);
isNull(), isNull(), isNull(), isNull(), isNull(), isNull(), isNull(), isNull(), isNull())).thenReturn(post);
when(postService.viewPost(eq(1L), any())).thenReturn(post);
when(commentService.getCommentsForPost(eq(1L), any())).thenReturn(List.of());
when(commentService.getParticipants(anyLong(), anyInt())).thenReturn(List.of());
@@ -187,7 +187,7 @@ class PostControllerTest {
.andExpect(status().isBadRequest());
verify(postService, never()).createPost(any(), any(), any(), any(), any(),
any(), any(), any(), any(), any(), any(), any());
any(), any(), any(), any(), any(), any(), any(), any(), any());
}
@Test

View File

@@ -6,6 +6,8 @@ import com.openisle.repository.UserRepository;
import com.openisle.repository.ReactionRepository;
import com.openisle.repository.CommentSubscriptionRepository;
import com.openisle.repository.NotificationRepository;
import com.openisle.repository.PointHistoryRepository;
import com.openisle.service.PointService;
import com.openisle.exception.RateLimitException;
import org.junit.jupiter.api.Test;
@@ -24,10 +26,12 @@ class CommentServiceTest {
ReactionRepository reactionRepo = mock(ReactionRepository.class);
CommentSubscriptionRepository subRepo = mock(CommentSubscriptionRepository.class);
NotificationRepository nRepo = mock(NotificationRepository.class);
PointHistoryRepository pointHistoryRepo = mock(PointHistoryRepository.class);
PointService pointService = mock(PointService.class);
ImageUploader imageUploader = mock(ImageUploader.class);
CommentService service = new CommentService(commentRepo, postRepo, userRepo,
notifService, subService, reactionRepo, subRepo, nRepo, imageUploader);
notifService, subService, reactionRepo, subRepo, nRepo, pointHistoryRepo, pointService, imageUploader);
when(commentRepo.countByAuthorAfter(eq("alice"), any())).thenReturn(3L);

View File

@@ -0,0 +1,67 @@
package com.openisle.service;
import com.openisle.model.PointHistory;
import com.openisle.model.PointHistoryType;
import com.openisle.model.Role;
import com.openisle.model.User;
import com.openisle.repository.PointHistoryRepository;
import com.openisle.repository.UserRepository;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.context.annotation.Import;
import java.time.LocalDateTime;
import static org.junit.jupiter.api.Assertions.assertEquals;
@DataJpaTest
@Import(PointService.class)
class PointServiceRecalculateUserPointsTest {
@Autowired
private PointService pointService;
@Autowired
private UserRepository userRepository;
@Autowired
private PointHistoryRepository pointHistoryRepository;
@Test
void recalculatesBalanceAfterDeletion() {
User user = new User();
user.setUsername("u");
user.setEmail("u@example.com");
user.setPassword("p");
user.setRole(Role.USER);
userRepository.save(user);
PointHistory h1 = new PointHistory();
h1.setUser(user);
h1.setType(PointHistoryType.POST);
h1.setAmount(30);
h1.setBalance(30);
h1.setCreatedAt(LocalDateTime.now().minusMinutes(2));
pointHistoryRepository.save(h1);
PointHistory h2 = new PointHistory();
h2.setUser(user);
h2.setType(PointHistoryType.COMMENT);
h2.setAmount(10);
h2.setBalance(40);
h2.setCreatedAt(LocalDateTime.now().minusMinutes(1));
pointHistoryRepository.save(h2);
user.setPoint(40);
userRepository.save(user);
pointHistoryRepository.delete(h1);
int total = pointService.recalculateUserPoints(user);
assertEquals(10, total);
assertEquals(10, userRepository.findById(user.getId()).orElseThrow().getPoint());
assertEquals(10, pointHistoryRepository.findById(h2.getId()).orElseThrow().getBalance());
}
}

View File

@@ -22,6 +22,8 @@ class PostServiceTest {
CategoryRepository catRepo = mock(CategoryRepository.class);
TagRepository tagRepo = mock(TagRepository.class);
LotteryPostRepository lotteryRepo = mock(LotteryPostRepository.class);
PollPostRepository pollPostRepo = mock(PollPostRepository.class);
PollVoteRepository pollVoteRepo = mock(PollVoteRepository.class);
NotificationService notifService = mock(NotificationService.class);
SubscriptionService subService = mock(SubscriptionService.class);
CommentService commentService = mock(CommentService.class);
@@ -35,11 +37,12 @@ class PostServiceTest {
EmailSender emailSender = mock(EmailSender.class);
ApplicationContext context = mock(ApplicationContext.class);
PointService pointService = mock(PointService.class);
PostChangeLogService postChangeLogService = mock(PostChangeLogService.class);
PostService service = new PostService(postRepo, userRepo, catRepo, tagRepo, lotteryRepo,
notifService, subService, commentService, commentRepo,
pollPostRepo, pollVoteRepo, notifService, subService, commentService, commentRepo,
reactionRepo, subRepo, notificationRepo, postReadService,
imageUploader, taskScheduler, emailSender, context, pointService, PublishMode.DIRECT);
imageUploader, taskScheduler, emailSender, context, pointService, postChangeLogService, PublishMode.DIRECT);
when(context.getBean(PostService.class)).thenReturn(service);
Post post = new Post();
@@ -69,6 +72,8 @@ class PostServiceTest {
CategoryRepository catRepo = mock(CategoryRepository.class);
TagRepository tagRepo = mock(TagRepository.class);
LotteryPostRepository lotteryRepo = mock(LotteryPostRepository.class);
PollPostRepository pollPostRepo = mock(PollPostRepository.class);
PollVoteRepository pollVoteRepo = mock(PollVoteRepository.class);
NotificationService notifService = mock(NotificationService.class);
SubscriptionService subService = mock(SubscriptionService.class);
CommentService commentService = mock(CommentService.class);
@@ -82,11 +87,12 @@ class PostServiceTest {
EmailSender emailSender = mock(EmailSender.class);
ApplicationContext context = mock(ApplicationContext.class);
PointService pointService = mock(PointService.class);
PostChangeLogService postChangeLogService = mock(PostChangeLogService.class);
PostService service = new PostService(postRepo, userRepo, catRepo, tagRepo, lotteryRepo,
notifService, subService, commentService, commentRepo,
pollPostRepo, pollVoteRepo, notifService, subService, commentService, commentRepo,
reactionRepo, subRepo, notificationRepo, postReadService,
imageUploader, taskScheduler, emailSender, context, pointService, PublishMode.DIRECT);
imageUploader, taskScheduler, emailSender, context, pointService, postChangeLogService, PublishMode.DIRECT);
when(context.getBean(PostService.class)).thenReturn(service);
Post post = new Post();
@@ -122,6 +128,8 @@ class PostServiceTest {
CategoryRepository catRepo = mock(CategoryRepository.class);
TagRepository tagRepo = mock(TagRepository.class);
LotteryPostRepository lotteryRepo = mock(LotteryPostRepository.class);
PollPostRepository pollPostRepo = mock(PollPostRepository.class);
PollVoteRepository pollVoteRepo = mock(PollVoteRepository.class);
NotificationService notifService = mock(NotificationService.class);
SubscriptionService subService = mock(SubscriptionService.class);
CommentService commentService = mock(CommentService.class);
@@ -135,18 +143,19 @@ class PostServiceTest {
EmailSender emailSender = mock(EmailSender.class);
ApplicationContext context = mock(ApplicationContext.class);
PointService pointService = mock(PointService.class);
PostChangeLogService postChangeLogService = mock(PostChangeLogService.class);
PostService service = new PostService(postRepo, userRepo, catRepo, tagRepo, lotteryRepo,
notifService, subService, commentService, commentRepo,
pollPostRepo, pollVoteRepo, notifService, subService, commentService, commentRepo,
reactionRepo, subRepo, notificationRepo, postReadService,
imageUploader, taskScheduler, emailSender, context, pointService, PublishMode.DIRECT);
imageUploader, taskScheduler, emailSender, context, pointService, postChangeLogService, PublishMode.DIRECT);
when(context.getBean(PostService.class)).thenReturn(service);
when(postRepo.countByAuthorAfter(eq("alice"), any())).thenReturn(1L);
assertThrows(RateLimitException.class,
() -> service.createPost("alice", 1L, "t", "c", List.of(1L),
null, null, null, null, null, null, null));
null, null, null, null, null, null, null, null, null));
}
@Test
@@ -156,6 +165,8 @@ class PostServiceTest {
CategoryRepository catRepo = mock(CategoryRepository.class);
TagRepository tagRepo = mock(TagRepository.class);
LotteryPostRepository lotteryRepo = mock(LotteryPostRepository.class);
PollPostRepository pollPostRepo = mock(PollPostRepository.class);
PollVoteRepository pollVoteRepo = mock(PollVoteRepository.class);
NotificationService notifService = mock(NotificationService.class);
SubscriptionService subService = mock(SubscriptionService.class);
CommentService commentService = mock(CommentService.class);
@@ -169,11 +180,12 @@ class PostServiceTest {
EmailSender emailSender = mock(EmailSender.class);
ApplicationContext context = mock(ApplicationContext.class);
PointService pointService = mock(PointService.class);
PostChangeLogService postChangeLogService = mock(PostChangeLogService.class);
PostService service = new PostService(postRepo, userRepo, catRepo, tagRepo, lotteryRepo,
notifService, subService, commentService, commentRepo,
pollPostRepo, pollVoteRepo, notifService, subService, commentService, commentRepo,
reactionRepo, subRepo, notificationRepo, postReadService,
imageUploader, taskScheduler, emailSender, context, pointService, PublishMode.DIRECT);
imageUploader, taskScheduler, emailSender, context, pointService, postChangeLogService, PublishMode.DIRECT);
when(context.getBean(PostService.class)).thenReturn(service);
User author = new User();

11
docker/.env.example Normal file
View File

@@ -0,0 +1,11 @@
# 前端访问端口
SERVER_PORT=8080
# MySQL 配置
MYSQL_ROOT_PASSWORD=toor
# 会覆盖 `open-isle.env`
MYSQL_PORT=3306
MYSQL_DATABASE=openisle
MYSQL_USER=<数据库用户名>
MYSQL_PASSWORD=<数据库密码>

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