Compare commits

...

220 Commits

Author SHA1 Message Date
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
Tim
55dd36bd24 feat: add poll post type 2025-08-29 22:36:36 +08:00
Tim
59232f99ca Merge pull request #790 from nagisa77/feature/menu_ui
fix: UI部份美化
2025-08-29 21:03:43 +08:00
tim
f93f58b055 fix: UI部份美化 2025-08-29 21:02:23 +08:00
Tim
8ad35af199 Merge pull request #789 from nagisa77/feature/menu_ui
fix: 首页 & 全局文字优化
2025-08-29 20:39:31 +08:00
tim
d427a41f6d fix: 首页 & 全局文字优化 2025-08-29 20:38:55 +08:00
Tim
ea53bc3c83 Create CODE_OF_CONDUCT.md 2025-08-29 18:15:26 +08:00
Tim
3a39cfdb49 Update issue templates 2025-08-29 18:14:05 +08:00
Tim
3d1b8b8e6e Update issue templates 2025-08-29 18:08:47 +08:00
Tim
f0e58d1efe Create LICENSE 2025-08-29 18:07:41 +08:00
Tim
5c4aca5ab8 Merge pull request #785 from nagisa77/feature/menu_ui
fix: menu ui
2025-08-29 14:52:41 +08:00
tim
fff59e800d fix: menu ui 2025-08-29 14:51:40 +08:00
Tim
b42ed19160 Merge pull request #784 from nagisa77/feature/menu_ui
feat: MENU UI 优化
2025-08-29 14:42:47 +08:00
tim
6fd663d983 feat: MENU UI 优化 2025-08-29 14:41:29 +08:00
Tim
fd6fc11630 Merge pull request #779 from CH-122/fix/comment
更新评论项组件,添加回复用户头像和样式优化
2025-08-29 14:01:17 +08:00
CH-122
d7bfeed259 feat: 更新评论项组件,增加回复用户头像点击事件,取消使用 replace 2025-08-29 13:59:02 +08:00
Tim
c5e4da5e07 Merge pull request #781 from nagisa77/feature/daily_bugfix_0829
fix: 新增ipx依赖,新增node环境说明
2025-08-29 10:21:50 +08:00
Tim
b87932560b fix: 新增ipx依赖,新增node环境说明 2025-08-29 10:20:54 +08:00
CH-122
58ff8b177e fix: 更新评论项组件,添加回复用户头像和样式优化 2025-08-29 08:51:56 +08:00
Tim
4f6b585735 Merge pull request #776 from nagisa77/codex/add-mermaid-rendering-support
feat: add mermaid support to markdown rendering
2025-08-28 16:55:24 +08:00
Tim
ac81bccd20 feat: add mermaid support to markdown 2025-08-28 16:52:07 +08:00
Tim
351447e3d1 Merge pull request #773 from sivdead/main
解决文章和草稿长度不够的问题
2025-08-28 15:59:45 +08:00
sivdead
453d8fa68b refactor(model): 将 Post和Draft 实体的内容字段类型从 TEXT 改为 LONGTEXT 2025-08-28 15:11:14 +08:00
tim
2c5b38ee9e fix: 修复 http://localhost:3000/posts/310 内容超出的问题 2025-08-28 14:40:19 +08:00
Tim
b5fd5a3edc Update README.md 2025-08-28 10:16:12 +08:00
tim
ee717aced2 fix: 处理两处图片加载异常问题 2025-08-28 09:19:06 +08:00
Tim
9a9152593e Merge pull request #764 from nagisa77/feature/daily_bugfix_0827
fix: 简化time规则
2025-08-27 20:58:47 +08:00
Tim
856d3dd513 fix: 简化time规则 2025-08-27 20:57:20 +08:00
Tim
0e42a3335a Merge pull request #763 from nagisa77/feature/daily_bugfix_0827
fix: 新增相对时间
2025-08-27 20:48:57 +08:00
Tim
d96aae59d2 fix: 新增相对时间 2025-08-27 20:47:50 +08:00
Tim
122722d0e9 Merge pull request #762 from nagisa77/codex/fix-overlap-of-mobile-post-button
Adjust mobile post icon position and add close button to message box
2025-08-27 20:40:41 +08:00
Tim
0c2264e509 fix: adjust new post icon position and close message window 2025-08-27 20:39:46 +08:00
Tim
1e503e26f2 Merge pull request #761 from nagisa77/feature/daily_bugfix_0827
fix: 帖子可被刷积分,应新增取消赞消除积分😂 #685
2025-08-27 20:28:49 +08:00
Tim
ec0fd63e30 fix: 帖子可被刷积分,应新增取消赞消除积分😂 #685 2025-08-27 20:27:53 +08:00
Tim
dfd4c70b6e Merge pull request #760 from nagisa77/codex/fix-repeated-reaction-notifications-and-points
fix: resolve repeated reaction issues
2025-08-27 20:23:43 +08:00
Tim
d79dc8877d fix: handle reaction notification and point deduction 2025-08-27 20:23:28 +08:00
Tim
e979350d40 Merge pull request #759 from nagisa77/feature/daily_bugfix_0827
Feature/daily bugfix 0827
2025-08-27 20:21:58 +08:00
Tim
99bf80a47a Merge pull request #758 from nagisa77/codex-mv1xa5
fix: gray out unearned medals
2025-08-27 20:21:23 +08:00
Tim
bfadda1e7d fix: gray out unearned medals 2025-08-27 20:21:05 +08:00
Tim
906998a07f Merge pull request #757 from nagisa77/feature/daily_bugfix_0827
fix: 站内信 scroll问题 #749
2025-08-27 20:19:29 +08:00
Tim
02287c05be fix: 站内信 scroll问题 #749 2025-08-27 20:18:39 +08:00
Tim
56aed4603e Merge pull request #756 from nagisa77/feature/daily_bugfix_0827
fix: 站内信 scroll问题 #749
2025-08-27 20:01:34 +08:00
Tim
a1fa7b2d5b fix: 站内信 scroll问题 #749 2025-08-27 20:00:14 +08:00
Tim
083c7980c6 Merge pull request #755 from nagisa77/feature/daily_bugfix_0827
fix:  回复表情通知为空的问题 #735
2025-08-27 19:45:21 +08:00
Tim
3d51f29be7 fix: 回复表情通知为空的问题 #735 2025-08-27 19:44:27 +08:00
Tim
d243e3a9d6 Merge pull request #752 from nagisa77/feature/daily_bugfix_0827
feature: 积分趋势统计
2025-08-27 15:56:33 +08:00
Tim
2b3c60f9a7 fix: 新增积分趋势统计 2025-08-27 15:55:20 +08:00
Tim
8b948a20cd Merge pull request #751 from nagisa77/codex/add-91zeiv
feat: show 30-day point trend chart
2025-08-27 15:43:33 +08:00
Tim
5053ac213d test(points): cover trend endpoint 2025-08-27 15:42:49 +08:00
Tim
e5ec801785 Merge pull request #750 from nagisa77/feature/daily_bugfix_0827
fix: 修复贴吧表情显示问题
2025-08-27 15:27:16 +08:00
Tim
31e25232d0 fix: 修复贴吧表情显示问题 2025-08-27 15:26:23 +08:00
Tim
cdc92aeebe Merge pull request #744 from nagisa77/feature/daily_bugfix_0825_c
fix: iOS修复blur问题
2025-08-27 13:21:19 +08:00
Tim
d2c2213197 fix: iOS修复blur问题 2025-08-27 13:20:42 +08:00
Tim
c687ffed54 Merge pull request #742 from nagisa77/feature/daily_bugfix_0825_c
fix: svg 采用本地,避免加载不了
2025-08-27 12:48:42 +08:00
Tim
5bc9ff45d7 fix: svg 采用本地,避免加载不了 2025-08-27 12:47:56 +08:00
Tim
78c7681bc8 Merge pull request #734 from nagisa77/feature/daily_bugfix_0825_c
daily bugfix
2025-08-27 12:36:00 +08:00
Tim
5eb206a358 fix: use base tabs 2025-08-27 12:34:28 +08:00
Tim
18179cca22 Merge pull request #741 from nagisa77/codex/create-reusable-multi-tabs-component-kvi40j
feat: add reusable swipeable tabs component
2025-08-27 12:31:11 +08:00
Tim
2b28cb2ac1 feat: add reusable swipeable tabs component 2025-08-27 12:30:56 +08:00
Tim
610a645092 Revert "feat: create BaseTabs component"
This reverts commit 0fc1415a14.
2025-08-27 12:30:08 +08:00
Tim
504ca55cad Merge pull request #740 from nagisa77/codex/create-reusable-multi-tabs-component-d2xsuk
feat: unify tab navigation with reusable swipeable component
2025-08-27 12:26:58 +08:00
Tim
0fc1415a14 feat: create BaseTabs component 2025-08-27 12:26:35 +08:00
Tim
50a84220fe Revert "feat: add reusable multi-tabs component"
This reverts commit e8a162d859.
2025-08-27 12:25:44 +08:00
Tim
af3e049c23 Merge branch 'feature/daily_bugfix_0825_c' of github.com:nagisa77/OpenIsle into feature/daily_bugfix_0825_c 2025-08-27 12:22:55 +08:00
Tim
c33b411659 Merge pull request #739 from nagisa77/codex/create-reusable-multi-tabs-component-j58zes
feat: add reusable multi-tabs component
2025-08-27 12:22:40 +08:00
Tim
e8a162d859 feat: add reusable multi-tabs component 2025-08-27 12:22:22 +08:00
Tim
e819926cf3 fix: 取消chunks分割,避免css覆盖问题 2025-08-27 12:08:50 +08:00
Tim
013d47e8e4 fix: 前端修改:图片loading做一个适配,现在图片没加载出来会出现如下情况, 不丝滑 2025-08-27 12:07:23 +08:00
Tim
6cc76593e4 Merge pull request #737 from nagisa77/codex/abstract-nuxtimg-with-placeholder-3oo626
feat: add Nuxt image component with LQIP placeholder
2025-08-27 11:47:14 +08:00
Tim
a2a08331e2 feat: add BaseImage component with blur placeholder 2025-08-27 11:46:57 +08:00
tim
3eabafadf8 fix: UI修改,采用baseplaceholder #732 2025-08-26 23:18:07 +08:00
tim
62c1983fd5 fix: markdown 支持 video 2025-08-26 13:39:33 +08:00
tim
689b719e18 fix: use keys count 2025-08-26 13:18:20 +08:00
tim
c6eccb01b9 fix: 样式修改 2025-08-26 13:14:03 +08:00
tim
cdf7e61157 Revert "fix: use keys count"
This reverts commit d23511ecb9.
2025-08-26 13:09:20 +08:00
tim
d23511ecb9 fix: use keys count 2025-08-26 13:06:05 +08:00
Tim
c76708d2ff Merge pull request #725 from nagisa77/feature/daily_bugfix_0826
0826 daily bugfix
2025-08-26 11:22:10 +08:00
Tim
d978bd428e fix: 积分icon优化 2025-08-26 11:21:43 +08:00
Tim
e5954cfb62 Merge pull request #730 from nagisa77/codex/add-point-system-for-lottery-participation
feat: integrate points with lottery participation
2025-08-26 11:14:36 +08:00
Tim
cb614b9739 feat: integrate points with lottery participation 2025-08-26 11:14:20 +08:00
Tim
88ce6b682d fix: 抽奖ui 优化 2025-08-26 10:59:54 +08:00
Tim
e02db635c4 fix: 调整收藏样式 2025-08-26 10:52:53 +08:00
Tim
231379181a Merge pull request #729 from nagisa77/codex/add-tab-for-favorite-articles
feat: add favorites tab to user profile
2025-08-26 10:49:01 +08:00
Tim
bd9ce67d4b feat: add favorites tab to user profile 2025-08-26 10:48:38 +08:00
Tim
6527b3790e fix: add link logo, 以及跳转新窗口 2025-08-26 10:47:02 +08:00
Tim
f01e8c942a Merge remote-tracking branch 'origin/main' into feature/daily_bugfix_0826 2025-08-26 10:34:21 +08:00
Tim
1e1ae29d32 fix: reactions面板,超过三种reaction才采用省略样式 而不是三个 #724 2025-08-26 10:33:45 +08:00
Tim
d31a8bfee4 Merge pull request #726 from WoJiaoFuXiaoYun/main
fix: 修复小窗口点击站内链接,会从小窗直接跳,预期主窗口跳转 #723
2025-08-26 10:33:21 +08:00
WangHe
29a96595f7 fix: 修复小窗口点击站内链接,会从小窗直接跳,预期主窗口跳转 #723 2025-08-26 10:14:28 +08:00
Tim
2b242367d7 fix: 站内信:从红点点进去又退出来,没有消退红点,新信息也没适配 #712 2025-08-26 10:12:16 +08:00
Tim
3f0cd2bf0f Merge pull request #720 from nagisa77/feature/daily_bugfix_0825_b
Feature/daily bugfix 0825 b
2025-08-25 20:38:28 +08:00
Tim
a98a631378 Revert "feat: add message float components"
This reverts commit b0eef220a6.
2025-08-25 20:38:10 +08:00
Tim
7701d359dc fix: 允许窗口收起 2025-08-25 20:35:33 +08:00
Tim
ffd9ef8a32 fix: 新增交互 2025-08-25 19:25:06 +08:00
Tim
36cd5ab171 Merge pull request #722 from nagisa77/codex/add-floating-window-support-for-message-box-a7msu4
feat: add floating message box window
2025-08-25 17:20:30 +08:00
Tim
58d86fa065 Merge branch 'feature/daily_bugfix_0825_b' into codex/add-floating-window-support-for-message-box-a7msu4 2025-08-25 17:20:23 +08:00
Tim
df71cf901b feat: add floating message box window 2025-08-25 17:18:34 +08:00
Tim
ac3fc6702a Merge pull request #721 from nagisa77/codex/add-floating-window-support-for-message-box
feat: add floating message window
2025-08-25 17:12:37 +08:00
Tim
b0eef220a6 feat: add message float components 2025-08-25 17:12:21 +08:00
Tim
02d366e2c7 fix: 支持回复/reactions 2025-08-25 17:06:44 +08:00
Tim
6409531a64 Merge pull request #719 from nagisa77/codex/add-reply-and-reaction-support-to-messages
feat: support message replies and reactions
2025-08-25 16:45:49 +08:00
Tim
175ab79b27 feat: support message replies and reactions 2025-08-25 16:42:14 +08:00
Tim
b543953d22 Revert "feat: support floating message box"
This reverts commit cd73747164.

# Conflicts:
#	frontend_nuxt/pages/message-box/index.vue
2025-08-25 15:51:02 +08:00
Tim
b4fef68af5 Merge branch 'feature/daily_bugfix_0825_b' of github.com:nagisa77/OpenIsle into feature/daily_bugfix_0825_b 2025-08-25 15:45:19 +08:00
Tim
6c48a38212 feat: delete router 2025-08-25 15:44:14 +08:00
Tim
8a3e4d8e98 Merge pull request #718 from nagisa77/codex/add-floating-window-support
feat: add floating message window
2025-08-25 15:43:43 +08:00
Tim
cd73747164 feat: support floating message box 2025-08-25 15:42:09 +08:00
Tim
0ee58df868 Merge pull request #716 from 4twocc/fix/safari-copy
fix(frontend): 修复 Safari 浏览器下邀请链接复制问题
2025-08-25 14:01:39 +08:00
Tim
6fed8131f6 Merge pull request #717 from WoJiaoFuXiaoYun/main
feat: 编辑器支持引用站内帖子
2025-08-25 14:00:25 +08:00
Tim
d75c08396a Merge pull request #714 from nagisa77/feature/daily_bugfix_0825
Feature/daily bugfix 0825
2025-08-25 14:00:02 +08:00
Tim
3a742fbb00 fix: tabs ui格式统一 #710 2025-08-25 13:56:42 +08:00
浮小云
9c2b1f6e98 Merge branch 'nagisa77:main' into main 2025-08-25 11:40:01 +08:00
WangHe
28b33d8c44 opt: 优化仅支持文章标题搜索 2025-08-25 11:38:18 +08:00
jiahaosheng
1f99a10322 fix(frontend): 修复 Safari 浏览器下邀请链接复制问题
- 在 Safari 浏览器中,直接使用 navigator.clipboard.writeText 可能导致权限问题
- 通过在 setTimeout 中调用 clipboard API,规避了 Safari 的权限限制
2025-08-25 11:04:32 +08:00
Tim
743c3dbc72 Merge pull request #715 from nagisa77/codex/update-reactionemojimap-to-google-emoji-cdn
feat: use Google emoji CDN
2025-08-25 11:03:21 +08:00
Tim
d46a446f2b feat: use Google emoji CDN 2025-08-25 11:03:06 +08:00
Tim
75a785f612 Merge branch 'feature/daily_bugfix_0825' of github.com:nagisa77/OpenIsle into feature/daily_bugfix_0825 2025-08-25 11:02:22 +08:00
Tim
e79b75f340 fix: tabs ui格式统一 #710 2025-08-25 11:01:52 +08:00
Tim
1f6f470ab5 Merge pull request #713 from nagisa77/codex/limit-base-timeline-hover-to-messages
feat: limit BaseTimeline hover to private messages
2025-08-25 10:29:52 +08:00
Tim
583d4042f5 feat: add optional hover for BaseTimeline 2025-08-25 10:29:35 +08:00
Tim
8437c1c714 Merge pull request #709 from nagisa77/codex/add-globalpopup-for-internal-messages
feat: add message feature popup
2025-08-23 02:58:34 +08:00
Tim
2613fe6cf1 feat: introduce message popup component 2025-08-23 02:58:24 +08:00
Tim
a15d541b72 Merge pull request #699 from nagisa77/feature/daily_bugfix_0823
daily bugfix
2025-08-23 02:49:36 +08:00
Tim
8657a06f52 Merge pull request #708 from nagisa77/codex/restrict-conversation-search-to-direct-messages
Fix findOrCreateConversation to only retrieve private conversations
2025-08-23 02:46:53 +08:00
Tim
09900b34aa Restrict conversation lookup to private chats 2025-08-23 02:46:41 +08:00
Tim
4e1c3f5839 Merge pull request #707 from nagisa77/codex/fix-multiple-results-error-in-findconversationbyusers
Handle multiple conversations between users
2025-08-23 02:39:28 +08:00
Tim
d97cc7df5e Handle multiple conversations between users 2025-08-23 02:38:31 +08:00
Tim
151242f3ba Merge pull request #706 from nagisa77/codex/add-unread-message-indicators-for-channels
feat: separate channel unread notifications
2025-08-23 02:28:43 +08:00
Tim
b2783a0168 chore: remove obsolete channel unread hook 2025-08-23 02:28:06 +08:00
tim
c79bcac217 fix: member count word 2025-08-23 02:15:11 +08:00
Tim
9a06da3bc1 Merge pull request #705 from nagisa77/codex/add-notification-red-dot-for-channels-uk9sj8
feat: show channel message indicator
2025-08-23 02:11:45 +08:00
Tim
98bbc36453 feat: show channel message indicator 2025-08-23 02:11:25 +08:00
tim
4a04f4ec17 Revert "feat: show channel unread indicator"
This reverts commit cf4ca89e19.
2025-08-23 02:10:52 +08:00
Tim
77be2bfebb Merge pull request #704 from nagisa77/codex/add-notification-red-dot-for-channels
feat: show channel unread indicator
2025-08-23 02:06:01 +08:00
Tim
cf4ca89e19 feat: show channel unread indicator 2025-08-23 02:05:39 +08:00
Tim
094fc78d92 Merge pull request #703 from nagisa77/codex/add-lastmessage-support-for-channel
Add last message retrieval and display for channels
2025-08-23 02:03:16 +08:00
Tim
da3d2a6a71 Return and show last channel message 2025-08-23 02:03:00 +08:00
tim
15cba0c96e fix: 支持显示最后一条消息 2025-08-23 01:57:33 +08:00
Tim
98a79acad9 Merge pull request #702 from nagisa77/codex/add-multi-tab-support-to-message-box
feat: add channel support
2025-08-23 01:31:26 +08:00
Tim
4947978f81 feat: add channel support 2025-08-23 01:31:06 +08:00
Tim
24cc479a56 Merge pull request #700 from nagisa77/codex/create-searchpersondropdown-component
feat: add person search dropdown
2025-08-23 01:06:33 +08:00
Tim
8ee1347b17 Merge branch 'feature/daily_bugfix_0823' into codex/create-searchpersondropdown-component 2025-08-23 01:06:11 +08:00
Tim
7e95120341 feat: add person search dropdown 2025-08-23 01:05:28 +08:00
tim
2f261983ac fix: 暂无会话适配 2025-08-23 01:02:35 +08:00
tim
e8e7b9a245 feat: add search drop down 2025-08-23 00:53:32 +08:00
tim
d2bd949ac8 fix: 前端采用sockjs 2025-08-22 23:57:03 +08:00
tim
605654ec99 fix: 把原生 WS 与 SockJS 分路径,避免混淆 2025-08-22 23:52:53 +08:00
tim
88127fcf34 fix: 把原生 WS 与 SockJS 分路径,避免混淆 2025-08-22 23:51:42 +08:00
tim
0a82f0036b fix: 把原生 WS 与 SockJS 分路径,避免混淆 2025-08-22 23:46:58 +08:00
tim
3a979277e4 fix: registerStompEndpoints 里保留一次注册即可,一般写法是一次 addEndpoint("/api/ws") + .withSockJS(),并统一用 setAllowedOriginPatterns(...) 配置白名单,避免同一路径双注册引起歧义。 2025-08-22 23:35:15 +08:00
tim
1c582fbbf1 fix: WebSocketConfig:同时给 SockJS 注册设置允许的 Origin(endpoint 用 patterns,SockJS 用 exact) 2025-08-22 23:18:05 +08:00
tim
92452da19a fix: 改用 patterns,补齐 staging 域名(https) 2025-08-22 23:05:24 +08:00
tim
a2ccaae7aa fix: 改用 patterns,补齐 staging 域名(https) 2025-08-22 23:01:57 +08:00
tim
23371d4433 Revert "fix: 同源内嵌"
This reverts commit e05d65cf49.
2025-08-22 22:09:41 +08:00
tim
e05d65cf49 fix: 同源内嵌 2025-08-22 22:00:08 +08:00
WangHe
809a78fee3 feat: 编辑器支持引用站内帖子
Related #683
2025-08-22 19:27:33 +08:00
Tim
aaf9b35a45 Merge pull request #698 from nagisa77/feature/daily_bugfix_0822_b
fix: api fix
2025-08-22 17:11:55 +08:00
Tim
61c0336a78 fix: api fix 2025-08-22 16:25:22 +08:00
Tim
69c913394f Merge pull request #697 from nagisa77/feature/daily_bugfix_0822_b
fix: 修复nginx /ws拦截问题
2025-08-22 15:27:10 +08:00
Tim
0ed9ad2f2a fix: 修复nginx /ws拦截问题 2025-08-22 15:26:41 +08:00
Tim
67e912381b Merge pull request #693 from nagisa77/feature/daily_bugfix_0822_a
fix: 发送信息,携带头像
2025-08-22 13:26:39 +08:00
Tim
a6a1c72a37 fix: 发送信息,携带头像 2025-08-22 13:26:04 +08:00
Tim
d77baa8a93 Merge pull request #692 from nagisa77/feature/daily_bugfix_0822_a
fix: 移动端 ui适配
2025-08-22 13:24:01 +08:00
Tim
fce4832407 fix: 移动端 ui适配 2025-08-22 13:23:35 +08:00
Tim
91c8cc9607 Merge pull request #689 from nagisa77/feature/daily_bugfix_0822
fix: 消息页面ui重构
2025-08-22 13:12:57 +08:00
Tim
02273e018f fix: 前端ui重构完成 2025-08-22 13:11:12 +08:00
Tim
4af10ecf79 fix: 消息页面ui重构 2025-08-22 12:21:10 +08:00
Tim
d34ed3c058 Merge pull request #687 from zpaeng/main
feat:【站内信】
2025-08-22 11:26:13 +08:00
zpaeng
8372e06949 Merge remote-tracking branch 'origin/main' 2025-08-22 11:16:29 +08:00
zpaeng
a74cb0c272 fix:【站内信】 2025-08-22 11:15:45 +08:00
Tim
5388767a2f Merge pull request #686 from palmcivet/feat/style-optimize
feat: 优化部分样式和文案
2025-08-22 10:36:48 +08:00
Tim
97dda9601e Merge pull request #688 from palmcivet/chore/project-config
chore: 移除未使用的依赖 && 调整 husky 配置
2025-08-22 10:35:37 +08:00
zpaeng
cddbb602bf Merge branch 'nagisa77:main' into main 2025-08-21 23:54:21 +08:00
Palm Civet
f21ed1f062 chore: 移除未使用的依赖 && 调整 husky 配置 2025-08-21 23:50:47 +08:00
Palm Civet
c009616f74 feat: 优化部分文案和 tags 页导航栏间距 2025-08-21 23:44:11 +08:00
zpaeng
84ab87878a feat:【站内信】 2025-08-21 23:42:53 +08:00
Palm Civet
c53f91913c feat: 优化 tags 页导航栏间距 2025-08-21 23:40:31 +08:00
Palm Civet
feed97154a feat: 优化部分样式和文案 2025-08-21 23:30:33 +08:00
Tim
f69562516d Update README.md 2025-08-21 19:05:10 +08:00
Tim
0b8e550097 fix: exclude==null不列入精选 2025-08-21 17:56:27 +08:00
Tim
cf722f5707 fix: 优化首页tabs排序 2025-08-21 16:30:54 +08:00
Tim
67e54c5106 Merge pull request #682 from nagisa77/codex/fix-postservice-constructor-argument-issue
Fix PostService tests for PointService dependency
2025-08-21 16:17:05 +08:00
Tim
d3dcc98122 fix tests for PointService dependency 2025-08-21 16:16:49 +08:00
Tim
c648d4cf39 Merge pull request #681 from nagisa77/codex/add-selected-content
feat: add featured content rewards and badge
2025-08-21 16:11:18 +08:00
Tim
41a5eda311 feat: support featured medals 2025-08-21 16:10:53 +08:00
Tim
c6e0dc6a1d Merge pull request #680 from nagisa77/feature/rss_comment
feat: comment
2025-08-21 15:47:30 +08:00
Tim
92e630df22 feat: comment 2025-08-21 15:26:53 +08:00
Tim
c6b0f32b09 Merge pull request #679 from nagisa77/codex/rss
feat: enrich rss items with comments and source link
2025-08-21 14:28:57 +08:00
Tim
5f5b6f84a8 feat: add markdown comments and link to rss 2025-08-21 14:28:33 +08:00
Tim
cd57d478f2 Merge pull request #677 from nagisa77/feature/website_block
fix: 微信黑名单申诉 #676
2025-08-21 13:41:31 +08:00
Tim
da07313df8 Merge pull request #678 from nagisa77/codex/add-record-to-points-history
Add point redemption history record
2025-08-21 13:41:21 +08:00
Tim
c08ecb5e33 Record point redemption in history 2025-08-21 13:38:53 +08:00
tim
0a722c81c5 fix: 微信黑名单申诉 #676 2025-08-21 13:37:02 +08:00
Tim
15071471b2 Merge pull request #673 from nagisa77/feature/daily_bugfix_0821 2025-08-21 12:37:31 +08:00
Tim
98a9939738 Merge pull request #675 from nagisa77/codex-5yja7z 2025-08-21 12:36:37 +08:00
Tim
9554030054 refactor: add reusable switch component 2025-08-21 12:36:02 +08:00
Tim
72e9a77373 fix: ui 调整 2025-08-21 12:27:50 +08:00
Tim
ed7dcd9414 Merge pull request #674 from nagisa77/codex/add-points-history-system-with-ui
feat: add point history
2025-08-21 11:05:07 +08:00
Tim
79fe8b5997 feat: add point history 2025-08-21 11:04:22 +08:00
Tim
cfce4d7d1d fix: 全局移除process.client、process.server #669 2025-08-21 10:22:33 +08:00
Tim
b7f5d8485c fix:「站点统计」新增loading #664 2025-08-21 10:15:20 +08:00
Tim
d4677a5799 Merge pull request #670 from nagisa77/feature/daily_bugfix_0820
daily bugfix
2025-08-20 20:59:57 +08:00
Tim
99644046fc fix: 本地ui优先已读 2025-08-20 20:55:22 +08:00
Tim
22c9bd7d39 Merge pull request #672 from nagisa77/codex/fix-immediate-deletion-of-unread-message
Remove notification after marking read
2025-08-20 20:46:24 +08:00
Tim
4eed6889d6 Merge pull request #671 from nagisa77/codex/add-notification-type-for-post-deletion
feat: notify authors when admin deletes post
2025-08-20 20:21:48 +08:00
Tim
959b0f6a48 feat: notify authors when admin deletes post 2025-08-20 20:21:31 +08:00
142 changed files with 10326 additions and 2079 deletions

View File

@@ -0,0 +1,20 @@
---
name: 新功能建议
about: 请为该项目提出一个想法
title: ''
labels: ''
assignees: ''
---
**你的功能请求是否与某个问题相关?请描述。**
请清晰、简洁地说明问题。例如:“我经常因为……而感到困扰。”
**你期望的解决方案**
请清晰、简洁地描述你希望发生的事情/功能如何工作。
**你考虑过的替代方案**
请清晰、简洁地说明你已考虑过的其他解决方案或功能。
**其他上下文**
在此添加与功能请求相关的其他信息或截图。

View File

@@ -0,0 +1,41 @@
---
name: 错误/Bug报告
about: 创建报告以帮助我们改进
title: ''
labels: ''
assignees: ''
---
**描述 Bug**
对该 Bug 进行清晰简明的描述。
**复现步骤**
复现该问题的步骤:
1. 进入 '...'
2. 点击 '...'
3. 下拉到 '...'
4. 看到错误
**预期行为**
清晰简明地描述你期望发生的情况。
**截图**
如果适用,请添加截图以帮助解释问题。
**桌面端(请完成以下信息):**
* 操作系统:\[例如 iOS]
* 浏览器:\[例如 Chrome、Safari]
* 版本:\[例如 22]
**移动端(请完成以下信息):**
* 设备:\[例如 iPhone6]
* 操作系统:\[例如 iOS8.1]
* 浏览器:\[例如 系统自带浏览器、Safari]
* 版本:\[例如 22]
**附加上下文**
在此添加与问题相关的其他上下文信息。

View File

@@ -1,4 +1 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
npx lint-staged

32
CODE_OF_CONDUCT.md Normal file
View File

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

View File

@@ -58,6 +58,8 @@ cp open-isle.env.example open-isle.env
> Step3 前端部署
**⚠️ 环境要求Node.js 版本最低 20.0.0(因为 Nuxt 框架要求)**
前端可以依赖本机部署的后端,也可以直接调用线上的后端接口
```shell

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Tim
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -1,7 +1,7 @@
<p align="center">
<img alt="OpenIsle" src="https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/image.png" width="200">
<br>
高效的开源社区前后端平台
高效的开源社区前后端平台
<br><br><br>
<img alt="Image" src="https://openisle-1307107697.cos.accelerate.myqcloud.com/dynamic_assert/22752cfac5a04a9c90c41995b9f55fed.png" width="1200">
</p>

View File

@@ -26,6 +26,10 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>

View File

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

View File

@@ -99,11 +99,13 @@ public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.csrf(csrf -> csrf.disable())
.cors(Customizer.withDefaults()) // 让 Spring 自带 CorsFilter 处理预检
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.cors(Customizer.withDefaults())
.headers(h -> h.frameOptions(f -> f.sameOrigin()))
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.exceptionHandling(eh -> eh.accessDeniedHandler(customAccessDeniedHandler))
.authorizeHttpRequests(auth -> auth
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
.requestMatchers("/api/ws/**", "/api/sockjs/**").permitAll()
.requestMatchers(HttpMethod.POST, "/api/auth/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/posts/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/comments/**").permitAll()
@@ -119,6 +121,7 @@ public class SecurityConfig {
.requestMatchers(HttpMethod.GET, "/api/reaction-types").permitAll()
.requestMatchers(HttpMethod.GET, "/api/activities/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/sitemap.xml").permitAll()
.requestMatchers(HttpMethod.GET, "/api/channels").permitAll()
.requestMatchers(HttpMethod.GET, "/api/rss").permitAll()
.requestMatchers(HttpMethod.GET, "/api/point-goods").permitAll()
.requestMatchers(HttpMethod.POST, "/api/point-goods").permitAll()
@@ -154,7 +157,7 @@ public class SecurityConfig {
uri.startsWith("/api/search") || uri.startsWith("/api/users") ||
uri.startsWith("/api/reaction-types") || uri.startsWith("/api/config") ||
uri.startsWith("/api/activities") || uri.startsWith("/api/push/public-key") ||
uri.startsWith("/api/point-goods") ||
uri.startsWith("/api/point-goods") || uri.startsWith("/api/channels") ||
uri.startsWith("/api/sitemap.xml") || uri.startsWith("/api/medals") ||
uri.startsWith("/api/rss"));
@@ -172,7 +175,8 @@ public class SecurityConfig {
response.getWriter().write("{\"error\": \"Invalid or expired token\"}");
return;
}
} else if (!uri.startsWith("/api/auth") && !publicGet) {
} else if (!uri.startsWith("/api/auth") && !publicGet
&& !uri.startsWith("/api/ws") && !uri.startsWith("/api/sockjs")) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json");
response.getWriter().write("{\"error\": \"Missing token\"}");

View File

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

@@ -47,13 +47,14 @@ public class AuthController {
return ResponseEntity.badRequest().body(Map.of("error", "Invalid captcha"));
}
if (req.getInviteToken() != null && !req.getInviteToken().isEmpty()) {
if (!inviteService.validate(req.getInviteToken())) {
InviteService.InviteValidateResult result = inviteService.validate(req.getInviteToken());
if (!result.isValidate()) {
return ResponseEntity.badRequest().body(Map.of("error", "邀请码使用次数过多"));
}
try {
User user = userService.registerWithInvite(
req.getUsername(), req.getEmail(), req.getPassword());
inviteService.consume(req.getInviteToken());
inviteService.consume(req.getInviteToken(), user.getUsername());
emailService.sendEmail(user.getEmail(), "在网站填写验证码以验证", "您的验证码是 " + user.getVerificationCode());
return ResponseEntity.ok(Map.of(
"token", jwtService.generateToken(user.getUsername()),
@@ -144,7 +145,8 @@ public class AuthController {
@PostMapping("/google")
public ResponseEntity<?> loginWithGoogle(@RequestBody GoogleLoginRequest req) {
boolean viaInvite = req.getInviteToken() != null && !req.getInviteToken().isEmpty();
if (viaInvite && !inviteService.validate(req.getInviteToken())) {
InviteService.InviteValidateResult inviteValidateResult = inviteService.validate(req.getInviteToken());
if (viaInvite && !inviteValidateResult.isValidate()) {
return ResponseEntity.badRequest().body(Map.of("error", "Invalid invite token"));
}
Optional<AuthResult> resultOpt = googleAuthService.authenticate(
@@ -154,7 +156,7 @@ public class AuthController {
if (resultOpt.isPresent()) {
AuthResult result = resultOpt.get();
if (viaInvite && result.isNewUser()) {
inviteService.consume(req.getInviteToken());
inviteService.consume(req.getInviteToken(), inviteValidateResult.getInviteToken().getInviter().getUsername());
return ResponseEntity.ok(Map.of(
"token", jwtService.generateToken(result.getUser().getUsername()),
"reason_code", "INVITE_APPROVED"
@@ -218,7 +220,8 @@ public class AuthController {
@PostMapping("/github")
public ResponseEntity<?> loginWithGithub(@RequestBody GithubLoginRequest req) {
boolean viaInvite = req.getInviteToken() != null && !req.getInviteToken().isEmpty();
if (viaInvite && !inviteService.validate(req.getInviteToken())) {
InviteService.InviteValidateResult inviteValidateResult = inviteService.validate(req.getInviteToken());
if (viaInvite && !inviteValidateResult.isValidate()) {
return ResponseEntity.badRequest().body(Map.of("error", "Invalid invite token"));
}
Optional<AuthResult> resultOpt = githubAuthService.authenticate(
@@ -229,7 +232,7 @@ public class AuthController {
if (resultOpt.isPresent()) {
AuthResult result = resultOpt.get();
if (viaInvite && result.isNewUser()) {
inviteService.consume(req.getInviteToken());
inviteService.consume(req.getInviteToken(), inviteValidateResult.getInviteToken().getInviter().getUsername());
return ResponseEntity.ok(Map.of(
"token", jwtService.generateToken(result.getUser().getUsername()),
"reason_code", "INVITE_APPROVED"
@@ -265,7 +268,8 @@ public class AuthController {
@PostMapping("/discord")
public ResponseEntity<?> loginWithDiscord(@RequestBody DiscordLoginRequest req) {
boolean viaInvite = req.getInviteToken() != null && !req.getInviteToken().isEmpty();
if (viaInvite && !inviteService.validate(req.getInviteToken())) {
InviteService.InviteValidateResult inviteValidateResult = inviteService.validate(req.getInviteToken());
if (viaInvite && !inviteValidateResult.isValidate()) {
return ResponseEntity.badRequest().body(Map.of("error", "Invalid invite token"));
}
Optional<AuthResult> resultOpt = discordAuthService.authenticate(
@@ -276,7 +280,7 @@ public class AuthController {
if (resultOpt.isPresent()) {
AuthResult result = resultOpt.get();
if (viaInvite && result.isNewUser()) {
inviteService.consume(req.getInviteToken());
inviteService.consume(req.getInviteToken(), inviteValidateResult.getInviteToken().getInviter().getUsername());
return ResponseEntity.ok(Map.of(
"token", jwtService.generateToken(result.getUser().getUsername()),
"reason_code", "INVITE_APPROVED"
@@ -311,7 +315,8 @@ public class AuthController {
@PostMapping("/twitter")
public ResponseEntity<?> loginWithTwitter(@RequestBody TwitterLoginRequest req) {
boolean viaInvite = req.getInviteToken() != null && !req.getInviteToken().isEmpty();
if (viaInvite && !inviteService.validate(req.getInviteToken())) {
InviteService.InviteValidateResult inviteValidateResult = inviteService.validate(req.getInviteToken());
if (viaInvite && !inviteValidateResult.isValidate()) {
return ResponseEntity.badRequest().body(Map.of("error", "Invalid invite token"));
}
Optional<AuthResult> resultOpt = twitterAuthService.authenticate(
@@ -323,7 +328,7 @@ public class AuthController {
if (resultOpt.isPresent()) {
AuthResult result = resultOpt.get();
if (viaInvite && result.isNewUser()) {
inviteService.consume(req.getInviteToken());
inviteService.consume(req.getInviteToken(), inviteValidateResult.getInviteToken().getInviter().getUsername());
return ResponseEntity.ok(Map.of(
"token", jwtService.generateToken(result.getUser().getUsername()),
"reason_code", "INVITE_APPROVED"

View File

@@ -0,0 +1,42 @@
package com.openisle.controller;
import com.openisle.dto.ChannelDto;
import com.openisle.model.User;
import com.openisle.repository.UserRepository;
import com.openisle.service.ChannelService;
import com.openisle.service.MessageService;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/channels")
@RequiredArgsConstructor
public class ChannelController {
private final ChannelService channelService;
private final MessageService messageService;
private final UserRepository userRepository;
private Long getCurrentUserId(Authentication auth) {
User user = userRepository.findByUsername(auth.getName())
.orElseThrow(() -> new IllegalArgumentException("User not found"));
return user.getId();
}
@GetMapping
public List<ChannelDto> listChannels(Authentication auth) {
return channelService.listChannels(getCurrentUserId(auth));
}
@PostMapping("/{channelId}/join")
public ChannelDto joinChannel(@PathVariable Long channelId, Authentication auth) {
return channelService.joinChannel(channelId, getCurrentUserId(auth));
}
@GetMapping("/unread-count")
public long unreadCount(Authentication auth) {
return messageService.getUnreadChannelCount(getCurrentUserId(auth));
}
}

View File

@@ -47,7 +47,7 @@ public class CommentController {
Comment comment = commentService.addComment(auth.getName(), postId, req.getContent());
CommentDto dto = commentMapper.toDto(comment);
dto.setReward(levelService.awardForComment(auth.getName()));
dto.setPointReward(pointService.awardForComment(auth.getName(),postId));
dto.setPointReward(pointService.awardForComment(auth.getName(), postId, comment.getId()));
log.debug("createComment succeeded for comment {}", comment.getId());
return ResponseEntity.ok(dto);
}

View File

@@ -0,0 +1,137 @@
package com.openisle.controller;
import com.openisle.dto.ConversationDetailDto;
import com.openisle.dto.ConversationDto;
import com.openisle.dto.CreateConversationRequest;
import com.openisle.dto.CreateConversationResponse;
import com.openisle.dto.MessageDto;
import com.openisle.model.Message;
import com.openisle.model.MessageConversation;
import com.openisle.model.User;
import com.openisle.repository.UserRepository;
import com.openisle.service.MessageService;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/messages")
@RequiredArgsConstructor
public class MessageController {
private final MessageService messageService;
private final UserRepository userRepository;
// This is a placeholder for getting the current user's ID
private Long getCurrentUserId(Authentication auth) {
User user = userRepository.findByUsername(auth.getName()).orElseThrow(() -> new IllegalArgumentException("Sender not found"));
// In a real application, you would get this from the Authentication object
return user.getId();
}
@GetMapping("/conversations")
public ResponseEntity<List<ConversationDto>> getConversations(Authentication auth) {
List<ConversationDto> conversations = messageService.getConversations(getCurrentUserId(auth));
return ResponseEntity.ok(conversations);
}
@GetMapping("/conversations/{conversationId}")
public ResponseEntity<ConversationDetailDto> getMessages(@PathVariable Long conversationId,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
Authentication auth) {
Pageable pageable = PageRequest.of(page, size, Sort.by("createdAt").descending());
ConversationDetailDto conversationDetails = messageService.getConversationDetails(conversationId, getCurrentUserId(auth), pageable);
return ResponseEntity.ok(conversationDetails);
}
@PostMapping
public ResponseEntity<MessageDto> sendMessage(@RequestBody MessageRequest req, Authentication auth) {
Message message = messageService.sendMessage(getCurrentUserId(auth), req.getRecipientId(), req.getContent(), req.getReplyToId());
return ResponseEntity.ok(messageService.toDto(message));
}
@PostMapping("/conversations/{conversationId}/messages")
public ResponseEntity<MessageDto> sendMessageToConversation(@PathVariable Long conversationId,
@RequestBody ChannelMessageRequest req,
Authentication auth) {
Message message = messageService.sendMessageToConversation(getCurrentUserId(auth), conversationId, req.getContent(), req.getReplyToId());
return ResponseEntity.ok(messageService.toDto(message));
}
@PostMapping("/conversations/{conversationId}/read")
public ResponseEntity<Void> markAsRead(@PathVariable Long conversationId, Authentication auth) {
messageService.markConversationAsRead(conversationId, getCurrentUserId(auth));
return ResponseEntity.ok().build();
}
@PostMapping("/conversations")
public ResponseEntity<CreateConversationResponse> findOrCreateConversation(@RequestBody CreateConversationRequest req, Authentication auth) {
MessageConversation conversation = messageService.findOrCreateConversation(getCurrentUserId(auth), req.getRecipientId());
return ResponseEntity.ok(new CreateConversationResponse(conversation.getId()));
}
@GetMapping("/unread-count")
public ResponseEntity<Long> getUnreadCount(Authentication auth) {
return ResponseEntity.ok(messageService.getUnreadMessageCount(getCurrentUserId(auth)));
}
// A simple request DTO
static class MessageRequest {
private Long recipientId;
private String content;
private Long replyToId;
public Long getRecipientId() {
return recipientId;
}
public void setRecipientId(Long recipientId) {
this.recipientId = recipientId;
}
public String getContent() {
return content;
}
public void setContent(String content) {
this.content = content;
}
public Long getReplyToId() {
return replyToId;
}
public void setReplyToId(Long replyToId) {
this.replyToId = replyToId;
}
}
static class ChannelMessageRequest {
private String content;
private Long replyToId;
public String getContent() {
return content;
}
public void setContent(String content) {
this.content = content;
}
public Long getReplyToId() {
return replyToId;
}
public void setReplyToId(Long replyToId) {
this.replyToId = replyToId;
}
}
}

View File

@@ -0,0 +1,36 @@
package com.openisle.controller;
import com.openisle.dto.PointHistoryDto;
import com.openisle.mapper.PointHistoryMapper;
import com.openisle.service.PointService;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@RestController
@RequestMapping("/api/point-histories")
@RequiredArgsConstructor
public class PointHistoryController {
private final PointService pointService;
private final PointHistoryMapper pointHistoryMapper;
@GetMapping
public List<PointHistoryDto> list(Authentication auth) {
return pointService.listHistory(auth.getName()).stream()
.map(pointHistoryMapper::toDto)
.collect(Collectors.toList());
}
@GetMapping("/trend")
public List<Map<String, Object>> trend(Authentication auth,
@RequestParam(value = "days", defaultValue = "30") int days) {
return pointService.trend(auth.getName(), days);
}
}

View File

@@ -3,6 +3,7 @@ package com.openisle.controller;
import com.openisle.dto.PostDetailDto;
import com.openisle.dto.PostRequest;
import com.openisle.dto.PostSummaryDto;
import com.openisle.dto.PollDto;
import com.openisle.mapper.PostMapper;
import com.openisle.model.Post;
import com.openisle.service.*;
@@ -41,11 +42,13 @@ public class PostController {
Post post = postService.createPost(auth.getName(), req.getCategoryId(),
req.getTitle(), req.getContent(), req.getTagIds(),
req.getType(), req.getPrizeDescription(), req.getPrizeIcon(),
req.getPrizeCount(), req.getStartTime(), req.getEndTime());
req.getPrizeCount(), req.getPointCost(),
req.getStartTime(), req.getEndTime(),
req.getOptions());
draftService.deleteDraft(auth.getName());
PostDetailDto dto = postMapper.toDetailDto(post, auth.getName());
dto.setReward(levelService.awardForPost(auth.getName()));
dto.setPointReward(pointService.awardForPost(auth.getName()));
dto.setPointReward(pointService.awardForPost(auth.getName(), post.getId()));
return ResponseEntity.ok(dto);
}
@@ -85,6 +88,17 @@ public class PostController {
return ResponseEntity.ok().build();
}
@GetMapping("/{id}/poll/progress")
public ResponseEntity<PollDto> pollProgress(@PathVariable Long id) {
return ResponseEntity.ok(postMapper.toSummaryDto(postService.getPoll(id)).getPoll());
}
@PostMapping("/{id}/poll/vote")
public ResponseEntity<Void> vote(@PathVariable Long id, @RequestParam("option") int option, Authentication auth) {
postService.votePoll(id, auth.getName(), option);
return ResponseEntity.ok().build();
}
@GetMapping
public List<PostSummaryDto> listPosts(@RequestParam(value = "categoryId", required = false) Long categoryId,
@RequestParam(value = "categoryIds", required = false) List<Long> categoryIds,
@@ -171,4 +185,27 @@ public class PostController {
return postService.listPostsByLatestReply(ids, tids, page, pageSize)
.stream().map(postMapper::toSummaryDto).collect(Collectors.toList());
}
@GetMapping("/featured")
public List<PostSummaryDto> featuredPosts(@RequestParam(value = "categoryId", required = false) Long categoryId,
@RequestParam(value = "categoryIds", required = false) List<Long> categoryIds,
@RequestParam(value = "tagId", required = false) Long tagId,
@RequestParam(value = "tagIds", required = false) List<Long> tagIds,
@RequestParam(value = "page", required = false) Integer page,
@RequestParam(value = "pageSize", required = false) Integer pageSize,
Authentication auth) {
List<Long> ids = categoryIds;
if (categoryId != null) {
ids = java.util.List.of(categoryId);
}
List<Long> tids = tagIds;
if (tagId != null) {
tids = java.util.List.of(tagId);
}
if (auth != null) {
userVisitService.recordVisit(auth.getName());
}
return postService.listFeaturedPosts(ids, tids, page, pageSize)
.stream().map(postMapper::toSummaryDto).collect(Collectors.toList());
}
}

View File

@@ -36,6 +36,7 @@ public class ReactionController {
Authentication auth) {
Reaction reaction = reactionService.reactToPost(auth.getName(), postId, req.getType());
if (reaction == null) {
pointService.deductForReactionOfPost(auth.getName(), postId);
return ResponseEntity.noContent().build();
}
ReactionDto dto = reactionMapper.toDto(reaction);
@@ -50,6 +51,7 @@ public class ReactionController {
Authentication auth) {
Reaction reaction = reactionService.reactToComment(auth.getName(), commentId, req.getType());
if (reaction == null) {
pointService.deductForReactionOfComment(auth.getName(), commentId);
return ResponseEntity.noContent().build();
}
ReactionDto dto = reactionMapper.toDto(reaction);
@@ -57,4 +59,17 @@ public class ReactionController {
pointService.awardForReactionOfComment(auth.getName(), commentId);
return ResponseEntity.ok(dto);
}
@PostMapping("/messages/{messageId}/reactions")
public ResponseEntity<ReactionDto> reactToMessage(@PathVariable Long messageId,
@RequestBody ReactionRequest req,
Authentication auth) {
Reaction reaction = reactionService.reactToMessage(auth.getName(), messageId, req.getType());
if (reaction == null) {
return ResponseEntity.noContent().build();
}
ReactionDto dto = reactionMapper.toDto(reaction);
dto.setReward(levelService.awardForReaction(auth.getName()));
return ResponseEntity.ok(dto);
}
}

View File

@@ -1,7 +1,10 @@
package com.openisle.controller;
import com.openisle.model.Post;
import com.openisle.model.Comment;
import com.openisle.model.CommentSort;
import com.openisle.service.PostService;
import com.openisle.service.CommentService;
import lombok.RequiredArgsConstructor;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
@@ -31,13 +34,14 @@ import java.util.regex.Pattern;
@RequiredArgsConstructor
public class RssController {
private final PostService postService;
private final CommentService commentService;
@Value("${app.website-url:https://www.open-isle.com}")
private String websiteUrl;
// 兼容 Markdown/HTML 两类图片写法(用于 enclosure
private static final Pattern MD_IMAGE = Pattern.compile("!\\[[^\\]]*\\]\\(([^)]+)\\)");
private static final Pattern HTML_IMAGE = Pattern.compile("<img[^>]+src=[\"']?([^\"'>]+)[\"']?[^>]*>");
private static final Pattern HTML_IMAGE = Pattern.compile("<BaseImage[^>]+src=[\"']?([^\"'>]+)[\"']?[^>]*>");
private static final DateTimeFormatter RFC1123 = DateTimeFormatter.RFC_1123_DATE_TIME;
@@ -103,6 +107,12 @@ public class RssController {
enclosure = absolutifyUrl(enclosure, base);
}
// 6) 构造优雅的附加区块(原文链接 + 精选评论),编入 <content:encoded>
List<Comment> topComments = commentService
.getCommentsForPost(p.getId(), CommentSort.MOST_INTERACTIONS);
topComments = topComments.subList(0, Math.min(10, topComments.size()));
String footerHtml = buildFooterHtml(base, link, topComments);
sb.append("<item>");
elem(sb, "title", cdata(nullSafe(p.getTitle())));
elem(sb, "link", link);
@@ -110,8 +120,11 @@ public class RssController {
elem(sb, "pubDate", toRfc1123Gmt(p.getCreatedAt().atZone(ZoneId.systemDefault())));
// 摘要
elem(sb, "description", cdata(plain));
// 全文HTML
sb.append("<content:encoded><![CDATA[").append(absHtml).append("]]></content:encoded>");
// 全文HTML:正文 + 优雅的 Markdown 区块(已转 HTML
sb.append("<content:encoded><![CDATA[")
.append(absHtml)
.append(footerHtml)
.append("]]></content:encoded>");
// 首图 enclosure图片类型
if (enclosure != null) {
sb.append("<enclosure url=\"").append(escapeXml(enclosure)).append("\" type=\"")
@@ -136,8 +149,12 @@ public class RssController {
private static String sanitizeHtml(String html) {
if (html == null) return "";
Safelist wl = Safelist.relaxed()
.addTags("pre", "code", "figure", "figcaption", "picture", "source",
"table","thead","tbody","tr","th","td","h1","h2","h3","h4","h5","h6")
.addTags(
"pre","code","figure","figcaption","picture","source",
"table","thead","tbody","tr","th","td",
"h1","h2","h3","h4","h5","h6",
"hr","blockquote"
)
.addAttributes("a", "href", "title", "target", "rel")
.addAttributes("img", "src", "alt", "title", "width", "height")
.addAttributes("source", "srcset", "type", "media")
@@ -246,6 +263,59 @@ public class RssController {
return "image/jpeg";
}
/* ===================== 附加区块(原文链接 + 精选评论) ===================== */
/**
* 将“原文链接 + 精选评论(最多 10 条)”以优雅的 Markdown 形式渲染为 HTML
* 并做 sanitize + 绝对化,然后拼入 content:encoded 尾部。
*/
private static String buildFooterHtml(String baseUrl, String originalLink, List<Comment> topComments) {
StringBuilder md = new StringBuilder(256);
// 分割线
md.append("\n\n---\n\n");
// 原文链接(强调 + 可点击)
md.append("**原文链接:** ")
.append("[").append(originalLink).append("](").append(originalLink).append(")")
.append("\n\n");
// 精选评论(仅当有评论时展示)
if (topComments != null && !topComments.isEmpty()) {
md.append("### 精选评论Top ").append(Math.min(10, topComments.size())).append("\n\n");
for (Comment c : topComments) {
String author = usernameOf(c);
String content = nullSafe(c.getContent()).replace("\r", "");
// 使用引用样式展示,提升可读性
md.append("> @").append(author).append(": ").append(content).append("\n\n");
}
}
// 渲染为 HTML并保持和正文一致的处理流程
String html = renderMarkdown(md.toString());
String safe = sanitizeHtml(html);
return absolutifyHtml(safe, baseUrl);
}
private static String usernameOf(Comment c) {
if (c == null) return "匿名";
try {
Object authorObj = c.getAuthor();
if (authorObj == null) return "匿名";
// 反射避免直接依赖实体字段名变化(也可直接强转到具体类型)
String username;
try {
username = (String) authorObj.getClass().getMethod("getUsername").invoke(authorObj);
} catch (Exception e) {
username = null;
}
if (username == null || username.isEmpty()) return "匿名";
return username;
} catch (Exception ignored) {
return "匿名";
}
}
/* ===================== 时间/字符串/XML ===================== */
private static String toRfc1123Gmt(ZonedDateTime zdt) {

View File

@@ -105,6 +105,17 @@ public class UserController {
.collect(java.util.stream.Collectors.toList());
}
@GetMapping("/{identifier}/subscribed-posts")
public java.util.List<PostMetaDto> subscribedPosts(@PathVariable("identifier") String identifier,
@RequestParam(value = "limit", required = false) Integer limit) {
int l = limit != null ? limit : defaultPostsLimit;
User user = userService.findByIdentifier(identifier).orElseThrow();
return subscriptionService.getSubscribedPosts(user.getUsername()).stream()
.limit(l)
.map(userMapper::toMetaDto)
.collect(java.util.stream.Collectors.toList());
}
@GetMapping("/{identifier}/replies")
public java.util.List<CommentInfoDto> userReplies(@PathVariable("identifier") String identifier,
@RequestParam(value = "limit", required = false) Integer limit) {

View File

@@ -0,0 +1,17 @@
package com.openisle.dto;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class ChannelDto {
private Long id;
private String name;
private String description;
private String avatar;
private MessageDto lastMessage;
private long memberCount;
private boolean joined;
private long unreadCount;
}

View File

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

View File

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

View File

@@ -0,0 +1,8 @@
package com.openisle.dto;
import lombok.Data;
@Data
public class CreateConversationRequest {
private Long recipientId;
}

View File

@@ -0,0 +1,12 @@
package com.openisle.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@AllArgsConstructor
@NoArgsConstructor
public class CreateConversationResponse {
private Long conversationId;
}

View File

@@ -0,0 +1,12 @@
package com.openisle.dto;
import lombok.Data;
import lombok.EqualsAndHashCode;
@Data
@EqualsAndHashCode(callSuper = true)
public class FeaturedMedalDto extends MedalDto {
private long currentFeaturedCount;
private long targetFeaturedCount;
}

View File

@@ -10,6 +10,7 @@ public class LotteryDto {
private String prizeDescription;
private String prizeIcon;
private int prizeCount;
private int pointCost;
private LocalDateTime startTime;
private LocalDateTime endTime;
private List<AuthorDto> participants;

View File

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

View File

@@ -0,0 +1,23 @@
package com.openisle.dto;
import com.openisle.model.PointHistoryType;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
@Getter
@Setter
public class PointHistoryDto {
private Long id;
private PointHistoryType type;
private int amount;
private int balance;
private Long postId;
private String postTitle;
private Long commentId;
private String commentContent;
private Long fromUserId;
private String fromUserName;
private LocalDateTime createdAt;
}

View File

@@ -0,0 +1,16 @@
package com.openisle.dto;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
@Data
public class PollDto {
private List<String> options;
private Map<Integer, Integer> votes;
private LocalDateTime endTime;
private List<AuthorDto> participants;
private Map<Integer, List<AuthorDto>> optionParticipants;
}

View File

@@ -23,7 +23,10 @@ public class PostRequest {
private String prizeDescription;
private String prizeIcon;
private Integer prizeCount;
private Integer pointCost;
private LocalDateTime startTime;
private LocalDateTime endTime;
// fields for poll posts
private List<String> options;
}

View File

@@ -31,6 +31,7 @@ public class PostSummaryDto {
private int pointReward;
private PostType type;
private LotteryDto lottery;
private PollDto poll;
private boolean rssExcluded;
private boolean closed;
}

View File

@@ -4,7 +4,7 @@ import com.openisle.model.ReactionType;
import lombok.Data;
/**
* DTO representing a reaction on a post or comment.
* DTO representing a reaction on a post, comment or message.
*/
@Data
public class ReactionDto {
@@ -13,6 +13,7 @@ public class ReactionDto {
private String user;
private Long postId;
private Long commentId;
private Long messageId;
private int reward;
}

View File

@@ -0,0 +1,10 @@
package com.openisle.dto;
import lombok.Data;
@Data
public class UserSummaryDto {
private Long id;
private String username;
private String avatar;
}

View File

@@ -0,0 +1,34 @@
package com.openisle.mapper;
import com.openisle.dto.PointHistoryDto;
import com.openisle.model.PointHistory;
import org.springframework.stereotype.Component;
@Component
public class PointHistoryMapper {
public PointHistoryDto toDto(PointHistory history) {
PointHistoryDto dto = new PointHistoryDto();
dto.setId(history.getId());
dto.setType(history.getType());
dto.setAmount(history.getAmount());
dto.setBalance(history.getBalance());
dto.setCreatedAt(history.getCreatedAt());
if (history.getPost() != null) {
dto.setPostId(history.getPost().getId());
dto.setPostTitle(history.getPost().getTitle());
}
if (history.getComment() != null) {
dto.setCommentId(history.getComment().getId());
dto.setCommentContent(history.getComment().getContent());
if (history.getComment().getPost() != null && dto.getPostId() == null) {
dto.setPostId(history.getComment().getPost().getId());
dto.setPostTitle(history.getComment().getPost().getTitle());
}
}
if (history.getFromUser() != null) {
dto.setFromUserId(history.getFromUser().getId());
dto.setFromUserName(history.getFromUser().getUsername());
}
return dto;
}
}

View File

@@ -5,18 +5,24 @@ import com.openisle.dto.PostDetailDto;
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. */
@@ -32,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();
@@ -86,11 +93,25 @@ public class PostMapper {
l.setPrizeDescription(lp.getPrizeDescription());
l.setPrizeIcon(lp.getPrizeIcon());
l.setPrizeCount(lp.getPrizeCount());
l.setPointCost(lp.getPointCost());
l.setStartTime(lp.getStartTime());
l.setEndTime(lp.getEndTime());
l.setParticipants(lp.getParticipants().stream().map(userMapper::toAuthorDto).collect(Collectors.toList()));
l.setWinners(lp.getWinners().stream().map(userMapper::toAuthorDto).collect(Collectors.toList()));
dto.setLottery(l);
}
if (post instanceof PollPost pp) {
PollDto p = new PollDto();
p.setOptions(pp.getOptions());
p.setVotes(pp.getVotes());
p.setEndTime(pp.getEndTime());
p.setParticipants(pp.getParticipants().stream().map(userMapper::toAuthorDto).collect(Collectors.toList()));
Map<Integer, List<AuthorDto>> optionParticipants = pollVoteRepository.findByPostId(pp.getId()).stream()
.collect(Collectors.groupingBy(PollVote::getOptionIndex,
Collectors.mapping(v -> userMapper.toAuthorDto(v.getUser()), Collectors.toList())));
p.setOptionParticipants(optionParticipants);
dto.setPoll(p);
}
}
}

View File

@@ -19,6 +19,9 @@ public class ReactionMapper {
if (reaction.getComment() != null) {
dto.setCommentId(reaction.getComment().getId());
}
if (reaction.getMessage() != null) {
dto.setMessageId(reaction.getMessage().getId());
}
dto.setReward(0);
return dto;
}

View File

@@ -22,7 +22,7 @@ public class Draft {
private String title;
@Column(columnDefinition = "TEXT")
@Column(columnDefinition = "LONGTEXT")
private String content;
@ManyToOne(fetch = FetchType.LAZY, optional = false)

View File

@@ -26,6 +26,9 @@ public class LotteryPost extends Post {
@Column(nullable = false)
private int prizeCount;
@Column(nullable = false)
private int pointCost;
@Column
private LocalDateTime startTime;

View File

@@ -3,6 +3,7 @@ package com.openisle.model;
public enum MedalType {
COMMENT,
POST,
FEATURED,
CONTRIBUTOR,
SEED,
PIONEER

View File

@@ -0,0 +1,39 @@
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;
@Entity
@Getter
@Setter
@NoArgsConstructor
@Table(name = "messages")
public class Message {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(optional = false, fetch = FetchType.LAZY)
@JoinColumn(name = "conversation_id")
private MessageConversation conversation;
@ManyToOne(optional = false, fetch = FetchType.LAZY)
@JoinColumn(name = "sender_id")
private User sender;
@Column(nullable = false, columnDefinition = "TEXT")
private String content;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "reply_to_id")
private Message replyTo;
@CreationTimestamp
@Column(nullable = false, updatable = false)
private LocalDateTime createdAt;
}

View File

@@ -0,0 +1,48 @@
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;
import java.util.HashSet;
import java.util.Set;
@Entity
@Getter
@Setter
@NoArgsConstructor
@Table(name = "message_conversations")
public class MessageConversation {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// Indicates whether this conversation represents a public channel
@Column(nullable = false)
private boolean channel = false;
// Channel metadata
private String name;
@Column(columnDefinition = "TEXT")
private String description;
private String avatar;
@CreationTimestamp
@Column(nullable = false, updatable = false)
private LocalDateTime createdAt;
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "last_message_id")
private Message lastMessage;
@OneToMany(mappedBy = "conversation", cascade = CascadeType.ALL, orphanRemoval = true)
private Set<MessageParticipant> participants = new HashSet<>();
@OneToMany(mappedBy = "conversation", cascade = CascadeType.ALL, orphanRemoval = true)
private Set<Message> messages = new HashSet<>();
}

View File

@@ -0,0 +1,30 @@
package com.openisle.model;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.time.LocalDateTime;
@Entity
@Getter
@Setter
@NoArgsConstructor
@Table(name = "message_participants")
public class MessageParticipant {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(optional = false, fetch = FetchType.LAZY)
@JoinColumn(name = "conversation_id")
private MessageConversation conversation;
@ManyToOne(optional = false, fetch = FetchType.LAZY)
@JoinColumn(name = "user_id")
private User user;
@Column
private LocalDateTime lastReadAt;
}

View File

@@ -14,6 +14,8 @@ public enum NotificationType {
POST_REVIEW_REQUEST,
/** Your post under review was approved or rejected */
POST_REVIEWED,
/** An administrator deleted your post */
POST_DELETED,
/** A subscribed post received a new comment */
POST_UPDATED,
/** Someone subscribed to your post */
@@ -38,6 +40,14 @@ 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 */
MENTION
}

View File

@@ -0,0 +1,49 @@
package com.openisle.model;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.time.LocalDateTime;
/** Point change history for a user. */
@Entity
@Getter
@Setter
@NoArgsConstructor
@Table(name = "point_histories")
public class PointHistory {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "user_id")
private User user;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private PointHistoryType type;
@Column(nullable = false)
private int amount;
@Column(nullable = false)
private int balance;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "post_id")
private Post post;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "comment_id")
private Comment comment;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "from_user_id")
private User fromUser;
@Column(name = "created_at", nullable = false)
private LocalDateTime createdAt;
}

View File

@@ -0,0 +1,16 @@
package com.openisle.model;
public enum PointHistoryType {
POST,
COMMENT,
POST_LIKED,
COMMENT_LIKED,
POST_LIKE_CANCELLED,
COMMENT_LIKE_CANCELLED,
INVITE,
FEATURE,
SYSTEM_ONLINE,
REDEEM,
LOTTERY_JOIN,
LOTTERY_REWARD
}

View File

@@ -0,0 +1,40 @@
package com.openisle.model;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.time.LocalDateTime;
import java.util.*;
@Entity
@Table(name = "poll_posts")
@Getter
@Setter
@NoArgsConstructor
@PrimaryKeyJoinColumn(name = "post_id")
public class PollPost extends Post {
@ElementCollection
@CollectionTable(name = "poll_post_options", joinColumns = @JoinColumn(name = "post_id"))
@Column(name = "option_text")
private List<String> options = new ArrayList<>();
@ElementCollection
@CollectionTable(name = "poll_post_votes", joinColumns = @JoinColumn(name = "post_id"))
@MapKeyColumn(name = "option_index")
@Column(name = "vote_count")
private Map<Integer, Integer> votes = new HashMap<>();
@ManyToMany
@JoinTable(name = "poll_participants",
joinColumns = @JoinColumn(name = "post_id"),
inverseJoinColumns = @JoinColumn(name = "user_id"))
private Set<User> participants = new HashSet<>();
@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"}))
@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

@@ -31,7 +31,7 @@ public class Post {
@Column(nullable = false)
private String title;
@Column(nullable = false, columnDefinition = "TEXT")
@Column(nullable = false, columnDefinition = "LONGTEXT")
private String content;
@CreationTimestamp

View File

@@ -2,5 +2,6 @@ package com.openisle.model;
public enum PostType {
NORMAL,
LOTTERY
LOTTERY,
POLL
}

View File

@@ -7,7 +7,7 @@ import lombok.Setter;
import org.hibernate.annotations.CreationTimestamp;
/**
* Reaction entity representing a user's reaction to a post or comment.
* Reaction entity representing a user's reaction to a post, comment or message.
*/
@Entity
@Getter
@@ -16,7 +16,8 @@ import org.hibernate.annotations.CreationTimestamp;
@Table(name = "reactions",
uniqueConstraints = {
@UniqueConstraint(columnNames = {"user_id", "post_id", "type"}),
@UniqueConstraint(columnNames = {"user_id", "comment_id", "type"})
@UniqueConstraint(columnNames = {"user_id", "comment_id", "type"}),
@UniqueConstraint(columnNames = {"user_id", "message_id", "type"})
})
public class Reaction {
@Id
@@ -39,6 +40,10 @@ public class Reaction {
@JoinColumn(name = "comment_id")
private Comment comment;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "message_id")
private Message message;
@CreationTimestamp
@Column(nullable = false, updatable = false,
columnDefinition = "DATETIME(6) DEFAULT CURRENT_TIMESTAMP(6)")

View File

@@ -6,7 +6,9 @@ package com.openisle.model;
public enum ReactionType {
LIKE,
DISLIKE,
SMILE,
RECOMMEND,
CONGRATULATIONS,
ANGRY,
FLUSHED,
STAR_STRUCK,
@@ -26,5 +28,5 @@ public enum ReactionType {
CHINA,
USA,
JAPAN,
KOREA
KOREA,
}

View File

@@ -0,0 +1,34 @@
package com.openisle.repository;
import com.openisle.model.MessageConversation;
import com.openisle.model.User;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface MessageConversationRepository extends JpaRepository<MessageConversation, Long> {
@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) " +
"AND EXISTS (SELECT 1 FROM c.participants p2 WHERE p2.user = :user2) " +
"ORDER BY c.createdAt DESC")
List<MessageConversation> findConversationsByUsers(@Param("user1") User user1, @Param("user2") User user2);
@Query("SELECT DISTINCT c FROM MessageConversation c " +
"JOIN c.participants p " +
"LEFT JOIN FETCH c.lastMessage lm " +
"LEFT JOIN FETCH lm.sender " +
"LEFT JOIN FETCH c.participants cp " +
"LEFT JOIN FETCH cp.user " +
"WHERE p.user.id = :userId " +
"ORDER BY COALESCE(lm.createdAt, c.createdAt) DESC")
List<MessageConversation> findConversationsByUserIdOrderByLastMessageDesc(@Param("userId") Long userId);
List<MessageConversation> findByChannelTrue();
long countByChannelTrue();
}

View File

@@ -0,0 +1,14 @@
package com.openisle.repository;
import com.openisle.model.MessageParticipant;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
@Repository
public interface MessageParticipantRepository extends JpaRepository<MessageParticipant, Long> {
Optional<MessageParticipant> findByConversationIdAndUserId(Long conversationId, Long userId);
List<MessageParticipant> findByUserId(Long userId);
}

View File

@@ -0,0 +1,21 @@
package com.openisle.repository;
import com.openisle.model.Message;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface MessageRepository extends JpaRepository<Message, Long> {
List<Message> findByConversationIdOrderByCreatedAtAsc(Long conversationId);
Page<Message> findByConversationId(Long conversationId, Pageable pageable);
long countByConversationIdAndCreatedAtAfter(Long conversationId, java.time.LocalDateTime createdAt);
// 只计算不是指定用户发送的消息(即别人发给当前用户的消息)
long countByConversationIdAndCreatedAtAfterAndSenderIdNot(Long conversationId, java.time.LocalDateTime createdAt, Long senderId);
}

View File

@@ -5,6 +5,7 @@ import com.openisle.model.User;
import com.openisle.model.Post;
import com.openisle.model.Comment;
import com.openisle.model.NotificationType;
import com.openisle.model.ReactionType;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
@@ -29,4 +30,8 @@ public interface NotificationRepository extends JpaRepository<Notification, Long
List<Notification> findByTypeAndFromUser(NotificationType type, User fromUser);
void deleteByTypeAndFromUserAndPost(NotificationType type, User fromUser, Post post);
void deleteByTypeAndFromUserAndPostAndReactionType(NotificationType type, User fromUser, Post post, ReactionType reactionType);
void deleteByTypeAndFromUserAndCommentAndReactionType(NotificationType type, User fromUser, Comment comment, ReactionType reactionType);
}

View File

@@ -0,0 +1,15 @@
package com.openisle.repository;
import com.openisle.model.PointHistory;
import com.openisle.model.User;
import org.springframework.data.jpa.repository.JpaRepository;
import java.time.LocalDateTime;
import java.util.List;
public interface PointHistoryRepository extends JpaRepository<PointHistory, Long> {
List<PointHistory> findByUserOrderByIdDesc(User user);
long countByUser(User user);
List<PointHistory> findByUserAndCreatedAtAfterOrderByCreatedAtDesc(User user, LocalDateTime createdAt);
}

View File

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

@@ -97,6 +97,8 @@ public interface PostRepository extends JpaRepository<Post, Long> {
long countDistinctByTags_Id(Long tagId);
long countByAuthor_IdAndRssExcludedFalse(Long userId);
@Query("SELECT t.id, COUNT(DISTINCT p) FROM Post p JOIN p.tags t WHERE t.id IN :tagIds GROUP BY t.id")
List<Object[]> countPostsByTagIds(@Param("tagIds") List<Long> tagIds);

View File

@@ -1,6 +1,7 @@
package com.openisle.repository;
import com.openisle.model.Comment;
import com.openisle.model.Message;
import com.openisle.model.Post;
import com.openisle.model.Reaction;
import com.openisle.model.User;
@@ -15,8 +16,10 @@ import java.util.Optional;
public interface ReactionRepository extends JpaRepository<Reaction, Long> {
Optional<Reaction> findByUserAndPostAndType(User user, Post post, com.openisle.model.ReactionType type);
Optional<Reaction> findByUserAndCommentAndType(User user, Comment comment, com.openisle.model.ReactionType type);
Optional<Reaction> findByUserAndMessageAndType(User user, Message message, com.openisle.model.ReactionType type);
List<Reaction> findByPost(Post post);
List<Reaction> findByComment(Comment comment);
List<Reaction> findByMessage(Message message);
@Query("SELECT r.post.id FROM Reaction r WHERE r.post IS NOT NULL AND r.post.author.username = :username AND r.type = com.openisle.model.ReactionType.LIKE GROUP BY r.post.id ORDER BY COUNT(r.id) DESC")
List<Long> findTopPostIds(@Param("username") String username, Pageable pageable);

View File

@@ -0,0 +1,98 @@
package com.openisle.service;
import com.openisle.dto.ChannelDto;
import com.openisle.dto.MessageDto;
import com.openisle.dto.UserSummaryDto;
import com.openisle.model.Message;
import com.openisle.model.MessageConversation;
import com.openisle.model.MessageParticipant;
import com.openisle.model.User;
import com.openisle.repository.MessageConversationRepository;
import com.openisle.repository.MessageParticipantRepository;
import com.openisle.repository.MessageRepository;
import com.openisle.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.List;
import java.util.stream.Collectors;
@Service
@RequiredArgsConstructor
public class ChannelService {
private final MessageConversationRepository conversationRepository;
private final MessageParticipantRepository participantRepository;
private final MessageRepository messageRepository;
private final UserRepository userRepository;
@Transactional(readOnly = true)
public List<ChannelDto> listChannels(Long userId) {
List<MessageConversation> channels = conversationRepository.findByChannelTrue();
return channels.stream().map(c -> toDto(c, userId)).collect(Collectors.toList());
}
@Transactional
public ChannelDto joinChannel(Long channelId, Long userId) {
MessageConversation channel = conversationRepository.findById(channelId)
.orElseThrow(() -> new IllegalArgumentException("Channel not found"));
User user = userRepository.findById(userId)
.orElseThrow(() -> new IllegalArgumentException("User not found"));
participantRepository.findByConversationIdAndUserId(channelId, userId)
.orElseGet(() -> {
MessageParticipant p = new MessageParticipant();
p.setConversation(channel);
p.setUser(user);
MessageParticipant saved = participantRepository.save(p);
channel.getParticipants().add(saved);
return saved;
});
return toDto(channel, userId);
}
private ChannelDto toDto(MessageConversation channel, Long userId) {
ChannelDto dto = new ChannelDto();
dto.setId(channel.getId());
dto.setName(channel.getName());
dto.setDescription(channel.getDescription());
dto.setAvatar(channel.getAvatar());
if (channel.getLastMessage() != null) {
dto.setLastMessage(toMessageDto(channel.getLastMessage()));
}
dto.setMemberCount(channel.getParticipants().size());
boolean joined = channel.getParticipants().stream()
.anyMatch(p -> p.getUser().getId().equals(userId));
dto.setJoined(joined);
if (joined) {
MessageParticipant participant = channel.getParticipants().stream()
.filter(p -> p.getUser().getId().equals(userId))
.findFirst().orElse(null);
LocalDateTime lastRead = participant.getLastReadAt() == null
? LocalDateTime.of(1970, 1, 1, 0, 0)
: participant.getLastReadAt();
long unread = messageRepository
.countByConversationIdAndCreatedAtAfterAndSenderIdNot(channel.getId(), lastRead, userId);
dto.setUnreadCount(unread);
} else {
dto.setUnreadCount(0);
}
return dto;
}
private MessageDto toMessageDto(Message message) {
MessageDto dto = new MessageDto();
dto.setId(message.getId());
dto.setContent(message.getContent());
dto.setConversationId(message.getConversation().getId());
dto.setCreatedAt(message.getCreatedAt());
UserSummaryDto userDto = new UserSummaryDto();
userDto.setId(message.getSender().getId());
userDto.setUsername(message.getSender().getUsername());
userDto.setAvatar(message.getSender().getAvatar());
dto.setSender(userDto);
return dto;
}
}

View File

@@ -5,6 +5,7 @@ import com.openisle.model.User;
import com.openisle.repository.InviteTokenRepository;
import com.openisle.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import lombok.Value;
import org.springframework.stereotype.Service;
import java.time.LocalDate;
@@ -18,6 +19,12 @@ public class InviteService {
private final JwtService jwtService;
private final PointService pointService;
@Value
public class InviteValidateResult {
InviteToken inviteToken;
boolean validate;
}
public String generate(String username) {
User inviter = userRepository.findByUsername(username).orElseThrow();
LocalDate today = LocalDate.now();
@@ -35,20 +42,23 @@ public class InviteService {
return token;
}
public boolean validate(String token) {
public InviteValidateResult validate(String token) {
if (token == null || token.isEmpty()) {
return new InviteValidateResult(null, false);
}
try {
jwtService.validateAndGetSubjectForInvite(token);
} catch (Exception e) {
return false;
return new InviteValidateResult(null, false);
}
InviteToken invite = inviteTokenRepository.findById(token).orElse(null);
return invite != null && invite.getUsageCount() < 3;
return new InviteValidateResult(invite, invite != null && invite.getUsageCount() < 3);
}
public void consume(String token) {
public void consume(String token, String newUserName) {
InviteToken invite = inviteTokenRepository.findById(token).orElseThrow();
invite.setUsageCount(invite.getUsageCount() + 1);
inviteTokenRepository.save(invite);
pointService.awardForInvite(invite.getInviter().getUsername());
pointService.awardForInvite(invite.getInviter().getUsername(), newUserName);
}
}

View File

@@ -6,6 +6,7 @@ import com.openisle.dto.MedalDto;
import com.openisle.dto.PostMedalDto;
import com.openisle.dto.SeedUserMedalDto;
import com.openisle.dto.PioneerMedalDto;
import com.openisle.dto.FeaturedMedalDto;
import com.openisle.model.MedalType;
import com.openisle.model.User;
import com.openisle.repository.CommentRepository;
@@ -74,6 +75,23 @@ public class MedalService {
postMedal.setSelected(selected == MedalType.POST);
medals.add(postMedal);
FeaturedMedalDto featuredMedal = new FeaturedMedalDto();
featuredMedal.setIcon("https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/icons/achi_rss.png");
featuredMedal.setTitle("精选作者");
featuredMedal.setDescription("至少有1篇文章被收录为精选");
featuredMedal.setType(MedalType.FEATURED);
featuredMedal.setTargetFeaturedCount(1);
if (user != null) {
long count = postRepository.countByAuthor_IdAndRssExcludedFalse(user.getId());
featuredMedal.setCurrentFeaturedCount(count);
featuredMedal.setCompleted(count >= 1);
} else {
featuredMedal.setCurrentFeaturedCount(0);
featuredMedal.setCompleted(false);
}
featuredMedal.setSelected(selected == MedalType.FEATURED);
medals.add(featuredMedal);
ContributorMedalDto contributorMedal = new ContributorMedalDto();
contributorMedal.setIcon("https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/icons/achi_coder.png");
contributorMedal.setTitle("贡献者");
@@ -141,6 +159,8 @@ public class MedalService {
user.setDisplayMedal(MedalType.COMMENT);
} else if (postRepository.countByAuthor_Id(user.getId()) >= POST_TARGET) {
user.setDisplayMedal(MedalType.POST);
} else if (postRepository.countByAuthor_IdAndRssExcludedFalse(user.getId()) >= 1) {
user.setDisplayMedal(MedalType.FEATURED);
} else if (contributorService.getContributionLines(user.getUsername()) >= CONTRIBUTION_TARGET) {
user.setDisplayMedal(MedalType.CONTRIBUTOR);
} else if (userRepository.countByCreatedAtBefore(user.getCreatedAt()) < PIONEER_LIMIT) {

View File

@@ -0,0 +1,324 @@
package com.openisle.service;
import com.openisle.model.Message;
import com.openisle.model.MessageConversation;
import com.openisle.model.MessageParticipant;
import com.openisle.model.User;
import com.openisle.model.Reaction;
import com.openisle.repository.MessageConversationRepository;
import com.openisle.repository.MessageParticipantRepository;
import com.openisle.repository.MessageRepository;
import com.openisle.repository.UserRepository;
import com.openisle.repository.ReactionRepository;
import com.openisle.dto.ConversationDetailDto;
import com.openisle.dto.ConversationDto;
import com.openisle.dto.MessageDto;
import com.openisle.dto.ReactionDto;
import com.openisle.dto.UserSummaryDto;
import com.openisle.mapper.ReactionMapper;
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.List;
import java.util.stream.Collectors;
@Service
@RequiredArgsConstructor
@Slf4j
public class MessageService {
private final MessageRepository messageRepository;
private final MessageConversationRepository conversationRepository;
private final MessageParticipantRepository participantRepository;
private final UserRepository userRepository;
private final SimpMessagingTemplate messagingTemplate;
private final ReactionRepository reactionRepository;
private final ReactionMapper reactionMapper;
@Transactional
public Message sendMessage(Long senderId, Long recipientId, String content, Long replyToId) {
log.info("Attempting to send message from user {} to user {}", senderId, recipientId);
User sender = userRepository.findById(senderId)
.orElseThrow(() -> new IllegalArgumentException("Sender not found"));
User recipient = userRepository.findById(recipientId)
.orElseThrow(() -> new IllegalArgumentException("Recipient not found"));
log.info("Finding or creating conversation for users {} and {}", sender.getUsername(), recipient.getUsername());
MessageConversation conversation = findOrCreateConversation(sender, recipient);
log.info("Conversation found or created with ID: {}", conversation.getId());
Message message = new Message();
message.setConversation(conversation);
message.setSender(sender);
message.setContent(content);
if (replyToId != null) {
Message replyTo = messageRepository.findById(replyToId)
.orElseThrow(() -> new IllegalArgumentException("Message not found"));
message.setReplyTo(replyTo);
}
message = messageRepository.save(message);
log.info("Message saved with ID: {}", message.getId());
conversation.setLastMessage(message);
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);
return message;
}
@Transactional
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)
.orElseThrow(() -> new IllegalArgumentException("Conversation not found"));
// Join the conversation if not already a participant (useful for channels)
participantRepository.findByConversationIdAndUserId(conversationId, senderId)
.orElseGet(() -> {
MessageParticipant p = new MessageParticipant();
p.setConversation(conversation);
p.setUser(sender);
return participantRepository.save(p);
});
Message message = new Message();
message.setConversation(conversation);
message.setSender(sender);
message.setContent(content);
if (replyToId != null) {
Message replyTo = messageRepository.findById(replyToId)
.orElseThrow(() -> new IllegalArgumentException("Message not found"));
message.setReplyTo(replyTo);
}
message = messageRepository.save(message);
conversation.setLastMessage(message);
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);
long unreadCount = getUnreadMessageCount(participant.getUser().getId());
String username = participant.getUser().getUsername();
messagingTemplate.convertAndSendToUser(username, "/queue/unread-count", unreadCount);
long channelUnread = getUnreadChannelCount(participant.getUser().getId());
messagingTemplate.convertAndSendToUser(username, "/queue/channel-unread", channelUnread);
}
return message;
}
public MessageDto toDto(Message message) {
MessageDto dto = new MessageDto();
dto.setId(message.getId());
dto.setContent(message.getContent());
dto.setConversationId(message.getConversation().getId());
dto.setCreatedAt(message.getCreatedAt());
UserSummaryDto userSummaryDto = new UserSummaryDto();
userSummaryDto.setId(message.getSender().getId());
userSummaryDto.setUsername(message.getSender().getUsername());
userSummaryDto.setAvatar(message.getSender().getAvatar());
dto.setSender(userSummaryDto);
if (message.getReplyTo() != null) {
Message reply = message.getReplyTo();
MessageDto replyDto = new MessageDto();
replyDto.setId(reply.getId());
replyDto.setContent(reply.getContent());
UserSummaryDto replySender = new UserSummaryDto();
replySender.setId(reply.getSender().getId());
replySender.setUsername(reply.getSender().getUsername());
replySender.setAvatar(reply.getSender().getAvatar());
replyDto.setSender(replySender);
dto.setReplyTo(replyDto);
}
java.util.List<Reaction> reactions = reactionRepository.findByMessage(message);
java.util.List<ReactionDto> reactionDtos = reactions.stream()
.map(reactionMapper::toDto)
.collect(Collectors.toList());
dto.setReactions(reactionDtos);
return dto;
}
public MessageConversation findOrCreateConversation(Long user1Id, Long user2Id) {
User user1 = userRepository.findById(user1Id)
.orElseThrow(() -> new IllegalArgumentException("User1 not found"));
User user2 = userRepository.findById(user2Id)
.orElseThrow(() -> new IllegalArgumentException("User2 not found"));
return findOrCreateConversation(user1, user2);
}
private MessageConversation findOrCreateConversation(User user1, User user2) {
log.info("Searching for existing conversation between {} and {}", user1.getUsername(), user2.getUsername());
return conversationRepository.findConversationsByUsers(user1, user2).stream()
.findFirst()
.orElseGet(() -> {
log.info("No existing conversation found. Creating a new one.");
MessageConversation conversation = new MessageConversation();
conversation = conversationRepository.save(conversation);
log.info("New conversation created with ID: {}", conversation.getId());
MessageParticipant participant1 = new MessageParticipant();
participant1.setConversation(conversation);
participant1.setUser(user1);
participantRepository.save(participant1);
log.info("Participant {} added to conversation {}", user1.getUsername(), conversation.getId());
MessageParticipant participant2 = new MessageParticipant();
participant2.setConversation(conversation);
participant2.setUser(user2);
participantRepository.save(participant2);
log.info("Participant {} added to conversation {}", user2.getUsername(), conversation.getId());
return conversation;
});
}
@Transactional(readOnly = true)
public List<ConversationDto> getConversations(Long userId) {
List<MessageConversation> conversations = conversationRepository.findConversationsByUserIdOrderByLastMessageDesc(userId);
return conversations.stream()
.filter(c -> !c.isChannel())
.map(c -> toDto(c, userId))
.collect(Collectors.toList());
}
private ConversationDto toDto(MessageConversation conversation, Long userId) {
ConversationDto dto = new ConversationDto();
dto.setId(conversation.getId());
dto.setChannel(conversation.isChannel());
dto.setName(conversation.getName());
dto.setAvatar(conversation.getAvatar());
dto.setCreatedAt(conversation.getCreatedAt());
if (conversation.getLastMessage() != null) {
dto.setLastMessage(toDto(conversation.getLastMessage()));
}
dto.setParticipants(conversation.getParticipants().stream()
.map(p -> {
UserSummaryDto userDto = new UserSummaryDto();
userDto.setId(p.getUser().getId());
userDto.setUsername(p.getUser().getUsername());
userDto.setAvatar(p.getUser().getAvatar());
return userDto;
})
.collect(Collectors.toList()));
MessageParticipant self = conversation.getParticipants().stream()
.filter(p -> p.getUser().getId().equals(userId))
.findFirst()
.orElseThrow(() -> new IllegalStateException("Participant not found in conversation"));
LocalDateTime lastRead = self.getLastReadAt() == null ? LocalDateTime.of(1970, 1, 1, 0, 0) : self.getLastReadAt();
// 只计算别人发送给当前用户的未读消息
long unreadCount = messageRepository.countByConversationIdAndCreatedAtAfterAndSenderIdNot(conversation.getId(), lastRead, userId);
dto.setUnreadCount(unreadCount);
return dto;
}
@Transactional
public ConversationDetailDto getConversationDetails(Long conversationId, Long userId, Pageable pageable) {
markConversationAsRead(conversationId, userId);
MessageConversation conversation = conversationRepository.findById(conversationId)
.orElseThrow(() -> new IllegalArgumentException("Conversation not found"));
Page<Message> messagesPage = messageRepository.findByConversationId(conversationId, pageable);
Page<MessageDto> messageDtoPage = messagesPage.map(this::toDto);
List<UserSummaryDto> participants = conversation.getParticipants().stream()
.map(p -> {
UserSummaryDto userDto = new UserSummaryDto();
userDto.setId(p.getUser().getId());
userDto.setUsername(p.getUser().getUsername());
userDto.setAvatar(p.getUser().getAvatar());
return userDto;
})
.collect(Collectors.toList());
ConversationDetailDto detailDto = new ConversationDetailDto();
detailDto.setId(conversation.getId());
detailDto.setName(conversation.getName());
detailDto.setChannel(conversation.isChannel());
detailDto.setAvatar(conversation.getAvatar());
detailDto.setParticipants(participants);
detailDto.setMessages(messageDtoPage);
return detailDto;
}
@Transactional
public void markConversationAsRead(Long conversationId, Long userId) {
MessageParticipant participant = participantRepository.findByConversationIdAndUserId(conversationId, userId)
.orElseThrow(() -> new IllegalArgumentException("Participant not found"));
participant.setLastReadAt(LocalDateTime.now());
participantRepository.save(participant);
}
@Transactional(readOnly = true)
public long getUnreadMessageCount(Long userId) {
List<MessageParticipant> participations = participantRepository.findByUserId(userId);
long totalUnreadCount = 0;
for (MessageParticipant p : participations) {
if (p.getConversation().isChannel()) continue;
LocalDateTime lastRead = p.getLastReadAt() == null ? LocalDateTime.of(1970, 1, 1, 0, 0) : p.getLastReadAt();
// 只计算别人发送给当前用户的未读消息
totalUnreadCount += messageRepository.countByConversationIdAndCreatedAtAfterAndSenderIdNot(p.getConversation().getId(), lastRead, userId);
}
return totalUnreadCount;
}
@Transactional(readOnly = true)
public long getUnreadChannelCount(Long userId) {
List<MessageParticipant> participations = participantRepository.findByUserId(userId);
long unreadChannelCount = 0;
for (MessageParticipant p : participations) {
if (!p.getConversation().isChannel()) continue;
LocalDateTime lastRead = p.getLastReadAt() == null ? LocalDateTime.of(1970, 1, 1, 0, 0) : p.getLastReadAt();
long unread = messageRepository.countByConversationIdAndCreatedAtAfterAndSenderIdNot(p.getConversation().getId(), lastRead, userId);
if (unread > 0) {
unreadChannelCount++;
}
}
return unreadChannelCount;
}
}

View File

@@ -114,6 +114,14 @@ public class NotificationService {
return n;
}
public void deleteReactionNotification(User fromUser, Post post, Comment comment, ReactionType reactionType) {
if (post != null) {
notificationRepository.deleteByTypeAndFromUserAndPostAndReactionType(NotificationType.REACTION, fromUser, post, reactionType);
} else if (comment != null) {
notificationRepository.deleteByTypeAndFromUserAndCommentAndReactionType(NotificationType.REACTION, fromUser, comment, reactionType);
}
}
/**
* Create notifications for all admins when a user submits a register request.
* Old register request notifications from the same applicant are removed first.

View File

@@ -3,8 +3,11 @@ package com.openisle.service;
import com.openisle.exception.FieldException;
import com.openisle.exception.NotFoundException;
import com.openisle.model.PointGood;
import com.openisle.model.PointHistory;
import com.openisle.model.PointHistoryType;
import com.openisle.model.User;
import com.openisle.repository.PointGoodRepository;
import com.openisle.repository.PointHistoryRepository;
import com.openisle.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
@@ -18,6 +21,7 @@ public class PointMallService {
private final PointGoodRepository pointGoodRepository;
private final UserRepository userRepository;
private final NotificationService notificationService;
private final PointHistoryRepository pointHistoryRepository;
public List<PointGood> listGoods() {
return pointGoodRepository.findAll();
@@ -32,6 +36,13 @@ public class PointMallService {
user.setPoint(user.getPoint() - good.getCost());
userRepository.save(user);
notificationService.createPointRedeemNotifications(user, good.getName() + ": " + contact);
PointHistory history = new PointHistory();
history.setUser(user);
history.setType(PointHistoryType.REDEEM);
history.setAmount(-good.getCost());
history.setBalance(user.getPoint());
history.setCreatedAt(java.time.LocalDateTime.now());
pointHistoryRepository.save(history);
return user.getPoint();
}
}

View File

@@ -1,12 +1,16 @@
package com.openisle.service;
import com.openisle.model.PointLog;
import com.openisle.model.User;
import com.openisle.model.*;
import com.openisle.repository.*;
import com.openisle.exception.FieldException;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
@Service
@RequiredArgsConstructor
@@ -16,19 +20,39 @@ public class PointService {
private final PointLogRepository pointLogRepository;
private final PostRepository postRepository;
private final CommentRepository commentRepository;
private final PointHistoryRepository pointHistoryRepository;
public int awardForPost(String userName) {
public int awardForPost(String userName, Long postId) {
User user = userRepository.findByUsername(userName).orElseThrow();
PointLog log = getTodayLog(user);
if (log.getPostCount() > 1) return 0;
log.setPostCount(log.getPostCount() + 1);
pointLogRepository.save(log);
return addPoint(user, 30);
Post post = postRepository.findById(postId).orElseThrow();
return addPoint(user, 30, PointHistoryType.POST, post, null, null);
}
public int awardForInvite(String userName) {
public int awardForInvite(String userName, String inviteeName) {
User user = userRepository.findByUsername(userName).orElseThrow();
return addPoint(user, 500);
User invitee = userRepository.findByUsername(inviteeName).orElseThrow();
return addPoint(user, 500, PointHistoryType.INVITE, null, null, invitee);
}
public int awardForFeatured(String userName, Long postId) {
User user = userRepository.findByUsername(userName).orElseThrow();
Post post = postRepository.findById(postId).orElseThrow();
return addPoint(user, 500, PointHistoryType.FEATURE, post, null, null);
}
public void processLotteryJoin(User participant, LotteryPost post) {
int cost = post.getPointCost();
if (cost > 0) {
if (participant.getPoint() < cost) {
throw new FieldException("point", "积分不足");
}
addPoint(participant, -cost, PointHistoryType.LOTTERY_JOIN, post, null, post.getAuthor());
addPoint(post.getAuthor(), cost, PointHistoryType.LOTTERY_REWARD, post, null, participant);
}
}
private PointLog getTodayLog(User user) {
@@ -45,20 +69,41 @@ public class PointService {
});
}
private int addPoint(User user, int amount) {
private int addPoint(User user, int amount, PointHistoryType type,
Post post, Comment comment, User fromUser) {
if (pointHistoryRepository.countByUser(user) == 0) {
recordHistory(user, PointHistoryType.SYSTEM_ONLINE, 0, null, null, null);
}
user.setPoint(user.getPoint() + amount);
userRepository.save(user);
recordHistory(user, type, amount, post, comment, fromUser);
return amount;
}
private void recordHistory(User user, PointHistoryType type, int amount,
Post post, Comment comment, User fromUser) {
PointHistory history = new PointHistory();
history.setUser(user);
history.setType(type);
history.setAmount(amount);
history.setBalance(user.getPoint());
history.setPost(post);
history.setComment(comment);
history.setFromUser(fromUser);
history.setCreatedAt(java.time.LocalDateTime.now());
pointHistoryRepository.save(history);
}
// 同时为评论者和发帖人增加积分,返回值为评论者增加的积分数
// 注意需要考虑发帖和回复是同一人的场景
public int awardForComment(String commenterName, Long postId) {
public int awardForComment(String commenterName, Long postId, Long commentId) {
// 标记评论者是否已达到积分奖励上限
boolean isTheRewardCapped = false;
// 根据帖子id找到发帖人
User poster = postRepository.findById(postId).orElseThrow().getAuthor();
Post post = postRepository.findById(postId).orElseThrow();
User poster = post.getAuthor();
Comment comment = commentRepository.findById(commentId).orElseThrow();
// 获取评论者的加分日志
User commenter = userRepository.findByUsername(commenterName).orElseThrow();
@@ -74,15 +119,15 @@ public class PointService {
} else {
log.setCommentCount(log.getCommentCount() + 1);
pointLogRepository.save(log);
return addPoint(commenter, 10);
return addPoint(commenter, 10, PointHistoryType.COMMENT, post, comment, null);
}
} else {
addPoint(poster, 10);
addPoint(poster, 10, PointHistoryType.COMMENT, post, comment, commenter);
// 如果发帖人与评论者不是同一个,则根据是否达到积分上限来判断评论者加分情况
if (isTheRewardCapped) {
return 0;
} else {
return addPoint(commenter, 10);
return addPoint(commenter, 10, PointHistoryType.COMMENT, post, comment, null);
}
}
}
@@ -101,7 +146,18 @@ public class PointService {
}
// 如果不是同一个,则为发帖人加分
return addPoint(poster, 10);
Post post = postRepository.findById(postId).orElseThrow();
return addPoint(poster, 10, PointHistoryType.POST_LIKED, post, null, reactioner);
}
public int deductForReactionOfPost(String reactionerName, Long postId) {
User poster = postRepository.findById(postId).orElseThrow().getAuthor();
User reactioner = userRepository.findByUsername(reactionerName).orElseThrow();
if (poster.getId().equals(reactioner.getId())) {
return 0;
}
Post post = postRepository.findById(postId).orElseThrow();
return addPoint(poster, -10, PointHistoryType.POST_LIKE_CANCELLED, post, null, reactioner);
}
// 考虑点赞者和评论者是同一个的情况
@@ -118,7 +174,49 @@ public class PointService {
}
// 如果不是同一个,则为发帖人加分
return addPoint(commenter, 10);
Comment comment = commentRepository.findById(commentId).orElseThrow();
Post post = comment.getPost();
return addPoint(commenter, 10, PointHistoryType.COMMENT_LIKED, post, comment, reactioner);
}
public int deductForReactionOfComment(String reactionerName, Long commentId) {
User commenter = commentRepository.findById(commentId).orElseThrow().getAuthor();
User reactioner = userRepository.findByUsername(reactionerName).orElseThrow();
if (commenter.getId().equals(reactioner.getId())) {
return 0;
}
Comment comment = commentRepository.findById(commentId).orElseThrow();
Post post = comment.getPost();
return addPoint(commenter, -10, PointHistoryType.COMMENT_LIKE_CANCELLED, post, comment, reactioner);
}
public java.util.List<PointHistory> listHistory(String userName) {
User user = userRepository.findByUsername(userName).orElseThrow();
if (pointHistoryRepository.countByUser(user) == 0) {
recordHistory(user, PointHistoryType.SYSTEM_ONLINE, 0, null, null, null);
}
return pointHistoryRepository.findByUserOrderByIdDesc(user);
}
public List<Map<String, Object>> trend(String userName, int days) {
if (days < 1) days = 1;
User user = userRepository.findByUsername(userName).orElseThrow();
LocalDate end = LocalDate.now();
LocalDate start = end.minusDays(days - 1L);
var histories = pointHistoryRepository.findByUserAndCreatedAtAfterOrderByCreatedAtDesc(
user, start.atStartOfDay());
int idx = 0;
int balance = user.getPoint();
List<Map<String, Object>> result = new ArrayList<>();
for (LocalDate day = end; !day.isBefore(start); day = day.minusDays(1)) {
result.add(Map.of("date", day.toString(), "value", balance));
while (idx < histories.size() && histories.get(idx).getCreatedAt().toLocalDate().isEqual(day)) {
balance -= histories.get(idx).getAmount();
idx++;
}
}
Collections.reverse(result);
return result;
}
}

View File

@@ -9,8 +9,11 @@ import com.openisle.model.Category;
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;
import com.openisle.repository.UserRepository;
import com.openisle.repository.CategoryRepository;
import com.openisle.repository.TagRepository;
@@ -20,6 +23,7 @@ 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;
@@ -54,6 +58,8 @@ public class PostService {
private final CategoryRepository categoryRepository;
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;
@@ -67,6 +73,7 @@ public class PostService {
private final TaskScheduler taskScheduler;
private final EmailSender emailSender;
private final ApplicationContext applicationContext;
private final PointService pointService;
private final ConcurrentMap<Long, ScheduledFuture<?>> scheduledFinalizations = new ConcurrentHashMap<>();
@Value("${app.website-url:https://www.open-isle.com}")
private String websiteUrl;
@@ -77,6 +84,8 @@ public class PostService {
CategoryRepository categoryRepository,
TagRepository tagRepository,
LotteryPostRepository lotteryPostRepository,
PollPostRepository pollPostRepository,
PollVoteRepository pollVoteRepository,
NotificationService notificationService,
SubscriptionService subscriptionService,
CommentService commentService,
@@ -89,12 +98,15 @@ public class PostService {
TaskScheduler taskScheduler,
EmailSender emailSender,
ApplicationContext applicationContext,
PointService pointService,
@Value("${app.post.publish-mode:DIRECT}") PublishMode publishMode) {
this.postRepository = postRepository;
this.userRepository = userRepository;
this.categoryRepository = categoryRepository;
this.tagRepository = tagRepository;
this.lotteryPostRepository = lotteryPostRepository;
this.pollPostRepository = pollPostRepository;
this.pollVoteRepository = pollVoteRepository;
this.notificationService = notificationService;
this.subscriptionService = subscriptionService;
this.commentService = commentService;
@@ -107,6 +119,7 @@ public class PostService {
this.taskScheduler = taskScheduler;
this.emailSender = emailSender;
this.applicationContext = applicationContext;
this.pointService = pointService;
this.publishMode = publishMode;
}
@@ -122,6 +135,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() {
@@ -146,7 +168,10 @@ public class PostService {
public Post includeInRss(Long id) {
Post post = postRepository.findById(id).orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found"));
post.setRssExcluded(false);
return postRepository.save(post);
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;
}
public Post createPost(String username,
@@ -158,8 +183,10 @@ public class PostService {
String prizeDescription,
String prizeIcon,
Integer prizeCount,
Integer pointCost,
LocalDateTime startTime,
LocalDateTime endTime) {
LocalDateTime endTime,
java.util.List<String> options) {
long recent = postRepository.countByAuthorAfter(username,
java.time.LocalDateTime.now().minusMinutes(5));
if (recent >= 1) {
@@ -182,13 +209,25 @@ public class PostService {
PostType actualType = type != null ? type : PostType.NORMAL;
Post post;
if (actualType == PostType.LOTTERY) {
if (pointCost != null && (pointCost < 0 || pointCost > 100)) {
throw new IllegalArgumentException("pointCost must be between 0 and 100");
}
LotteryPost lp = new LotteryPost();
lp.setPrizeDescription(prizeDescription);
lp.setPrizeIcon(prizeIcon);
lp.setPrizeCount(prizeCount != null ? prizeCount : 0);
lp.setPointCost(pointCost != null ? pointCost : 0);
lp.setStartTime(startTime);
lp.setEndTime(endTime);
post = lp;
} else if (actualType == PostType.POLL) {
if (options == null || options.size() < 2) {
throw new IllegalArgumentException("At least two options required");
}
PollPost pp = new PollPost();
pp.setOptions(options);
pp.setEndTime(endTime);
post = pp;
} else {
post = new Post();
}
@@ -201,6 +240,8 @@ public class PostService {
post.setStatus(publishMode == PublishMode.REVIEW ? PostStatus.PENDING : PostStatus.PUBLISHED);
if (post instanceof LotteryPost) {
post = lotteryPostRepository.save((LotteryPost) post);
} else if (post instanceof PollPost) {
post = pollPostRepository.save((PollPost) post);
} else {
post = postRepository.save(post);
}
@@ -235,6 +276,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;
}
@@ -244,8 +290,62 @@ public class PostService {
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found"));
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
if (post.getParticipants().add(user)) {
pointService.processLotteryJoin(user, post);
lotteryPostRepository.save(post);
}
}
public PollPost getPoll(Long postId) {
return pollPostRepository.findById(postId)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found"));
}
@Transactional
public PollPost votePoll(Long postId, String username, int optionIndex) {
PollPost post = pollPostRepository.findById(postId)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found"));
if (post.getEndTime() != null && post.getEndTime().isBefore(LocalDateTime.now())) {
throw new IllegalStateException("Poll has ended");
}
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
if (post.getParticipants().contains(user)) {
throw new IllegalArgumentException("User already voted");
}
if (optionIndex < 0 || optionIndex >= post.getOptions().size()) {
throw new IllegalArgumentException("Invalid option");
}
post.getParticipants().add(user);
lotteryPostRepository.save(post);
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);
}
});
}
@Transactional
@@ -458,6 +558,34 @@ public class PostService {
return paginate(sortByPinnedAndCreated(posts), page, pageSize);
}
public List<Post> listFeaturedPosts(List<Long> categoryIds,
List<Long> tagIds,
Integer page,
Integer pageSize) {
List<Post> posts;
boolean hasCategories = categoryIds != null && !categoryIds.isEmpty();
boolean hasTags = tagIds != null && !tagIds.isEmpty();
if (hasCategories && hasTags) {
posts = listPostsByCategoriesAndTags(categoryIds, tagIds, null, null);
} else if (hasCategories) {
posts = listPostsByCategories(categoryIds, null, null);
} else if (hasTags) {
posts = listPostsByTags(tagIds, null, null);
} else {
posts = listPosts();
}
// 仅保留 getRssExcluded 为 0 且不为空
// 若字段类型是 Boolean包装类型0 等价于 false
posts = posts.stream()
.filter(p -> p.getRssExcluded() != null && !p.getRssExcluded())
.toList();
return paginate(sortByPinnedAndCreated(posts), page, pageSize);
}
public List<Post> listPendingPosts() {
return postRepository.findByStatus(PostStatus.PENDING);
}
@@ -579,7 +707,9 @@ public class PostService {
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found"));
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
if (!user.getId().equals(post.getAuthor().getId()) && user.getRole() != Role.ADMIN) {
User author = post.getAuthor();
boolean adminDeleting = !user.getId().equals(author.getId()) && user.getRole() == Role.ADMIN;
if (!user.getId().equals(author.getId()) && user.getRole() != Role.ADMIN) {
throw new IllegalArgumentException("Unauthorized");
}
for (Comment c : commentRepository.findByPostAndParentIsNullOrderByCreatedAtAsc(post)) {
@@ -596,7 +726,12 @@ public class PostService {
future.cancel(false);
}
}
String title = post.getTitle();
postRepository.delete(post);
if (adminDeleting) {
notificationService.createNotification(author, NotificationType.POST_DELETED,
null, null, null, user, null, title);
}
}
public java.util.List<Post> getPostsByIds(java.util.List<Long> ids) {

View File

@@ -6,10 +6,12 @@ import com.openisle.model.Reaction;
import com.openisle.model.ReactionType;
import com.openisle.model.User;
import com.openisle.model.NotificationType;
import com.openisle.model.Message;
import com.openisle.repository.CommentRepository;
import com.openisle.repository.PostRepository;
import com.openisle.repository.ReactionRepository;
import com.openisle.repository.UserRepository;
import com.openisle.repository.MessageRepository;
import com.openisle.service.NotificationService;
import com.openisle.service.EmailSender;
import lombok.RequiredArgsConstructor;
@@ -24,6 +26,7 @@ public class ReactionService {
private final UserRepository userRepository;
private final PostRepository postRepository;
private final CommentRepository commentRepository;
private final MessageRepository messageRepository;
private final NotificationService notificationService;
private final EmailSender emailSender;
@@ -39,6 +42,7 @@ public class ReactionService {
java.util.Optional<Reaction> existing =
reactionRepository.findByUserAndPostAndType(user, post, type);
if (existing.isPresent()) {
notificationService.deleteReactionNotification(user, post, null, type);
reactionRepository.delete(existing.get());
return null;
}
@@ -62,6 +66,7 @@ public class ReactionService {
java.util.Optional<Reaction> existing =
reactionRepository.findByUserAndCommentAndType(user, comment, type);
if (existing.isPresent()) {
notificationService.deleteReactionNotification(user, null, comment, type);
reactionRepository.delete(existing.get());
return null;
}
@@ -77,6 +82,26 @@ public class ReactionService {
return reaction;
}
@Transactional
public Reaction reactToMessage(String username, Long messageId, ReactionType type) {
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
Message message = messageRepository.findById(messageId)
.orElseThrow(() -> new IllegalArgumentException("Message not found"));
java.util.Optional<Reaction> existing =
reactionRepository.findByUserAndMessageAndType(user, message, type);
if (existing.isPresent()) {
reactionRepository.delete(existing.get());
return null;
}
Reaction reaction = new Reaction();
reaction.setUser(user);
reaction.setMessage(message);
reaction.setType(type);
reaction = reactionRepository.save(reaction);
return reaction;
}
public java.util.List<Reaction> getReactionsForPost(Long postId) {
Post post = postRepository.findById(postId)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found"));

View File

@@ -107,6 +107,11 @@ public class SubscriptionService {
return commentSubRepo.findByComment(c).stream().map(CommentSubscription::getUser).toList();
}
public List<Post> getSubscribedPosts(String username) {
User user = userRepo.findByUsername(username).orElseThrow();
return postSubRepo.findByUser(user).stream().map(PostSubscription::getPost).toList();
}
public long countSubscribers(String username) {
User user = userRepo.findByUsername(username).orElseThrow();

View File

@@ -0,0 +1 @@
ALTER TABLE lottery_posts ADD COLUMN point_cost INT NOT NULL DEFAULT 0;

View File

@@ -0,0 +1,65 @@
package com.openisle.controller;
import com.openisle.config.CustomAccessDeniedHandler;
import com.openisle.config.SecurityConfig;
import com.openisle.service.PointService;
import com.openisle.mapper.PointHistoryMapper;
import com.openisle.service.JwtService;
import com.openisle.repository.UserRepository;
import com.openisle.model.User;
import com.openisle.model.Role;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.context.annotation.Import;
import org.springframework.test.web.servlet.MockMvc;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@WebMvcTest(PointHistoryController.class)
@AutoConfigureMockMvc
@Import({SecurityConfig.class, CustomAccessDeniedHandler.class})
class PointHistoryControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private JwtService jwtService;
@MockBean
private UserRepository userRepository;
@MockBean
private PointService pointService;
@MockBean
private PointHistoryMapper pointHistoryMapper;
@Test
void trendReturnsSeries() throws Exception {
Mockito.when(jwtService.validateAndGetSubject("token")).thenReturn("user");
User user = new User();
user.setUsername("user");
user.setPassword("p");
user.setEmail("u@example.com");
user.setRole(Role.USER);
Mockito.when(userRepository.findByUsername("user")).thenReturn(Optional.of(user));
List<Map<String, Object>> data = List.of(
Map.of("date", java.time.LocalDate.now().minusDays(1).toString(), "value", 100),
Map.of("date", java.time.LocalDate.now().toString(), "value", 110)
);
Mockito.when(pointService.trend(Mockito.eq("user"), Mockito.anyInt())).thenReturn(data);
mockMvc.perform(get("/api/point-histories/trend").param("days", "2")
.header("Authorization", "Bearer token"))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].value").value(100))
.andExpect(jsonPath("$[1].value").value(110));
}
}

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())).thenReturn(post);
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());
}
@Test

View File

@@ -5,6 +5,7 @@ import com.openisle.model.Post;
import com.openisle.model.Reaction;
import com.openisle.model.ReactionType;
import com.openisle.model.User;
import com.openisle.model.Message;
import com.openisle.service.ReactionService;
import com.openisle.service.LevelService;
import com.openisle.mapper.ReactionMapper;
@@ -78,6 +79,27 @@ class ReactionControllerTest {
.andExpect(jsonPath("$.commentId").value(2));
}
@Test
void reactToMessage() throws Exception {
User user = new User();
user.setUsername("u3");
Message message = new Message();
message.setId(3L);
Reaction reaction = new Reaction();
reaction.setId(3L);
reaction.setUser(user);
reaction.setMessage(message);
reaction.setType(ReactionType.LIKE);
Mockito.when(reactionService.reactToMessage(eq("u3"), eq(3L), eq(ReactionType.LIKE))).thenReturn(reaction);
mockMvc.perform(post("/api/messages/3/reactions")
.contentType("application/json")
.content("{\"type\":\"LIKE\"}")
.principal(new UsernamePasswordAuthenticationToken("u3", "p")))
.andExpect(status().isOk())
.andExpect(jsonPath("$.messageId").value(3));
}
@Test
void listReactionTypes() throws Exception {
mockMvc.perform(get("/api/reaction-types"))

View File

@@ -136,6 +136,30 @@ class UserControllerTest {
.andExpect(jsonPath("$[0].title").value("hello"));
}
@Test
void listSubscribedPosts() throws Exception {
User user = new User();
user.setUsername("bob");
com.openisle.model.Category cat = new com.openisle.model.Category();
cat.setName("tech");
com.openisle.model.Post post = new com.openisle.model.Post();
post.setId(6L);
post.setTitle("fav");
post.setCreatedAt(java.time.LocalDateTime.now());
post.setCategory(cat);
post.setAuthor(user);
Mockito.when(userService.findByIdentifier("bob")).thenReturn(Optional.of(user));
Mockito.when(subscriptionService.getSubscribedPosts("bob")).thenReturn(java.util.List.of(post));
PostMetaDto meta = new PostMetaDto();
meta.setId(6L);
meta.setTitle("fav");
Mockito.when(userMapper.toMetaDto(post)).thenReturn(meta);
mockMvc.perform(get("/api/users/bob/subscribed-posts"))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].title").value("fav"));
}
@Test
void listUserReplies() throws Exception {
User user = new User();

View File

@@ -27,7 +27,7 @@ class MedalServiceTest {
List<MedalDto> medals = service.getMedals(null);
medals.forEach(m -> assertFalse(m.isCompleted()));
assertEquals(5, medals.size());
assertEquals(6, medals.size());
}
@Test

View File

@@ -34,11 +34,12 @@ class PostServiceTest {
TaskScheduler taskScheduler = mock(TaskScheduler.class);
EmailSender emailSender = mock(EmailSender.class);
ApplicationContext context = mock(ApplicationContext.class);
PointService pointService = mock(PointService.class);
PostService service = new PostService(postRepo, userRepo, catRepo, tagRepo, lotteryRepo,
notifService, subService, commentService, commentRepo,
reactionRepo, subRepo, notificationRepo, postReadService,
imageUploader, taskScheduler, emailSender, context, PublishMode.DIRECT);
imageUploader, taskScheduler, emailSender, context, pointService, PublishMode.DIRECT);
when(context.getBean(PostService.class)).thenReturn(service);
Post post = new Post();
@@ -61,6 +62,59 @@ class PostServiceTest {
verify(postRepo).delete(post);
}
@Test
void deletePostByAdminNotifiesAuthor() {
PostRepository postRepo = mock(PostRepository.class);
UserRepository userRepo = mock(UserRepository.class);
CategoryRepository catRepo = mock(CategoryRepository.class);
TagRepository tagRepo = mock(TagRepository.class);
LotteryPostRepository lotteryRepo = mock(LotteryPostRepository.class);
NotificationService notifService = mock(NotificationService.class);
SubscriptionService subService = mock(SubscriptionService.class);
CommentService commentService = mock(CommentService.class);
CommentRepository commentRepo = mock(CommentRepository.class);
ReactionRepository reactionRepo = mock(ReactionRepository.class);
PostSubscriptionRepository subRepo = mock(PostSubscriptionRepository.class);
NotificationRepository notificationRepo = mock(NotificationRepository.class);
PostReadService postReadService = mock(PostReadService.class);
ImageUploader imageUploader = mock(ImageUploader.class);
TaskScheduler taskScheduler = mock(TaskScheduler.class);
EmailSender emailSender = mock(EmailSender.class);
ApplicationContext context = mock(ApplicationContext.class);
PointService pointService = mock(PointService.class);
PostService service = new PostService(postRepo, userRepo, catRepo, tagRepo, lotteryRepo,
notifService, subService, commentService, commentRepo,
reactionRepo, subRepo, notificationRepo, postReadService,
imageUploader, taskScheduler, emailSender, context, pointService, PublishMode.DIRECT);
when(context.getBean(PostService.class)).thenReturn(service);
Post post = new Post();
post.setId(1L);
post.setTitle("T");
post.setContent("");
User author = new User();
author.setId(2L);
author.setRole(Role.USER);
post.setAuthor(author);
User admin = new User();
admin.setId(1L);
admin.setRole(Role.ADMIN);
when(postRepo.findById(1L)).thenReturn(Optional.of(post));
when(userRepo.findByUsername("admin")).thenReturn(Optional.of(admin));
when(commentRepo.findByPostAndParentIsNullOrderByCreatedAtAsc(post)).thenReturn(List.of());
when(reactionRepo.findByPost(post)).thenReturn(List.of());
when(subRepo.findByPost(post)).thenReturn(List.of());
when(notificationRepo.findByPost(post)).thenReturn(List.of());
service.deletePost(1L, "admin");
verify(notifService).createNotification(eq(author), eq(NotificationType.POST_DELETED), isNull(),
isNull(), isNull(), eq(admin), isNull(), eq("T"));
}
@Test
void createPostRespectsRateLimit() {
PostRepository postRepo = mock(PostRepository.class);
@@ -80,18 +134,19 @@ class PostServiceTest {
TaskScheduler taskScheduler = mock(TaskScheduler.class);
EmailSender emailSender = mock(EmailSender.class);
ApplicationContext context = mock(ApplicationContext.class);
PointService pointService = mock(PointService.class);
PostService service = new PostService(postRepo, userRepo, catRepo, tagRepo, lotteryRepo,
notifService, subService, commentService, commentRepo,
reactionRepo, subRepo, notificationRepo, postReadService,
imageUploader, taskScheduler, emailSender, context, PublishMode.DIRECT);
imageUploader, taskScheduler, emailSender, context, pointService, 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));
}
@Test
@@ -113,11 +168,12 @@ class PostServiceTest {
TaskScheduler taskScheduler = mock(TaskScheduler.class);
EmailSender emailSender = mock(EmailSender.class);
ApplicationContext context = mock(ApplicationContext.class);
PointService pointService = mock(PointService.class);
PostService service = new PostService(postRepo, userRepo, catRepo, tagRepo, lotteryRepo,
notifService, subService, commentService, commentRepo,
reactionRepo, subRepo, notificationRepo, postReadService,
imageUploader, taskScheduler, emailSender, context, PublishMode.DIRECT);
imageUploader, taskScheduler, emailSender, context, pointService, PublishMode.DIRECT);
when(context.getBean(PostService.class)).thenReturn(service);
User author = new User();

View File

@@ -15,9 +15,10 @@ class ReactionServiceTest {
UserRepository userRepo = mock(UserRepository.class);
PostRepository postRepo = mock(PostRepository.class);
CommentRepository commentRepo = mock(CommentRepository.class);
MessageRepository messageRepo = mock(MessageRepository.class);
NotificationService notif = mock(NotificationService.class);
EmailSender email = mock(EmailSender.class);
ReactionService service = new ReactionService(reactionRepo, userRepo, postRepo, commentRepo, notif, email);
ReactionService service = new ReactionService(reactionRepo, userRepo, postRepo, commentRepo, messageRepo, notif, email);
org.springframework.test.util.ReflectionTestUtils.setField(service, "websiteUrl", "https://ex.com");
User user = new User();

View File

@@ -1,6 +1,6 @@
<template>
<div id="app">
<div class="header-container">
<div v-if="!isFloatMode" class="header-container">
<HeaderComponent
ref="header"
@toggle-menu="menuVisible = !menuVisible"
@@ -9,19 +9,28 @@
</div>
<div class="main-container">
<div class="menu-container" v-click-outside="handleMenuOutside">
<div v-if="!isFloatMode" class="menu-container" v-click-outside="handleMenuOutside">
<MenuComponent :visible="!hideMenu && menuVisible" @item-click="menuVisible = false" />
</div>
<div class="content" :class="{ 'menu-open': menuVisible && !hideMenu }">
<div
class="content"
:class="{ 'menu-open': menuVisible && !hideMenu && !isFloatMode }"
:style="isFloatMode ? { paddingTop: '0px', minHeight: '100vh' } : {}"
>
<NuxtPage keepalive />
</div>
<div v-if="showNewPostIcon && isMobile" class="app-new-post-icon" @click="goToNewPost">
<div
v-if="showNewPostIcon && isMobile && !isFloatMode"
class="app-new-post-icon"
@click="goToNewPost"
>
<i class="fas fa-edit"></i>
</div>
</div>
<GlobalPopups />
<ConfirmDialog />
<MessageFloatWindow v-if="!isFloatMode" />
</div>
</template>
@@ -30,6 +39,7 @@ import HeaderComponent from '~/components/HeaderComponent.vue'
import MenuComponent from '~/components/MenuComponent.vue'
import GlobalPopups from '~/components/GlobalPopups.vue'
import ConfirmDialog from '~/components/ConfirmDialog.vue'
import MessageFloatWindow from '~/components/MessageFloatWindow.vue'
import { useIsMobile } from '~/utils/screen'
const isMobile = useIsMobile()
@@ -52,6 +62,7 @@ const hideMenu = computed(() => {
})
const header = useTemplateRef('header')
const isFloatMode = computed(() => useRoute().query.float !== undefined)
onMounted(() => {
if (typeof window !== 'undefined') {
@@ -127,7 +138,7 @@ const goToNewPost = () => {
height: 60px;
border-radius: 50%;
position: fixed;
bottom: 40px;
bottom: 70px;
right: 20px;
font-size: 20px;
cursor: pointer;

View File

@@ -18,8 +18,8 @@
--background-color-blur: rgba(255, 255, 255, 0.57);
--menu-border-color: lightgray;
--normal-border-color: lightgray;
--menu-selected-background-color: rgba(208, 250, 255, 0.659);
--menu-text-color: black;
--menu-selected-background-color: rgba(242, 242, 242, 0.884);
--menu-text-color: rgb(99, 99, 99);
--scroller-background-color: rgba(130, 175, 180, 0.5);
/* --normal-background-color: rgb(241, 241, 241); */
--normal-background-color: white;
@@ -27,7 +27,7 @@
--code-highlight-background-color: rgb(241, 241, 241);
--login-background-color: rgb(248, 248, 248);
--login-background-color-hover: #e0e0e0;
--text-color: black;
--text-color: rgb(70, 70, 70);
--blockquote-text-color: #6a737d;
--menu-width: 200px;
--page-max-width: 1400px;
@@ -50,7 +50,7 @@
--menu-border-color: #555;
--normal-border-color: #555;
--menu-selected-background-color: rgba(255, 255, 255, 0.1);
--menu-text-color: white;
--menu-text-color: rgb(173, 173, 173);
/* --normal-background-color: #000000; */
--normal-background-color: #333;
--lottery-background-color: #4e4e4e;
@@ -75,7 +75,7 @@
body {
margin: 0;
padding: 0;
font-family: 'Roboto', sans-serif;
font-family: 'WenQuanYi Micro Hei', 'Helvetica Neue', Arial, sans-serif;
background-color: var(--normal-background-color);
color: var(--text-color);
/* 禁止滚动 */
@@ -139,6 +139,10 @@ body {
margin-bottom: 0.8em;
}
.info-content-text video {
max-width: 100%;
}
.info-content-text {
word-break: break-word;
max-width: 100%;
@@ -158,6 +162,7 @@ body {
border-radius: 4px;
line-height: 1.5;
position: relative;
white-space: break-spaces;
}
.info-content-text pre .line-numbers {
@@ -184,7 +189,6 @@ body {
font-family: 'Maple Mono', monospace;
font-size: 13px;
border-radius: 4px;
white-space: break-spaces;
background-color: var(--code-highlight-background-color);
color: var(--text-color);
}

View File

@@ -9,7 +9,7 @@
]"
@click="selectMedal(medal)"
>
<img
<BaseImage
:src="medal.icon"
:alt="medal.title"
:class="['achievements-list-item-icon', { not_completed: !medal.completed }]"
@@ -26,6 +26,9 @@
<template v-else-if="medal.type === 'POST'">
{{ medal.currentPostCount }}/{{ medal.targetPostCount }}
</template>
<template v-else-if="medal.type === 'FEATURED'">
{{ medal.currentFeaturedCount }}/{{ medal.targetFeaturedCount }}
</template>
<template v-else-if="medal.type === 'CONTRIBUTOR'">
{{ medal.currentContributionLines }}/{{ medal.targetContributionLines }}
</template>

View File

@@ -1,7 +1,7 @@
<template>
<BasePopup :visible="visible" @close="close">
<div class="activity-popup">
<img v-if="icon" :src="icon" class="activity-popup-icon" alt="activity icon" />
<BaseImage v-if="icon" :src="icon" class="activity-popup-icon" alt="activity icon" />
<div class="activity-popup-text">{{ text }}</div>
<div class="activity-popup-actions">
<div class="activity-popup-button" @click="gotoActivity">立即前往</div>

View File

@@ -1,7 +1,7 @@
<template>
<div class="article-category-container" v-if="category">
<div class="article-info-item" @click="gotoCategory">
<img
<BaseImage
v-if="category.smallIcon"
class="article-info-item-img"
:src="category.smallIcon"

View File

@@ -6,7 +6,7 @@
:key="tag.id || tag.name"
@click="gotoTag(tag)"
>
<img
<BaseImage
v-if="tag.smallIcon"
class="article-info-item-img"
:src="tag.smallIcon"

View File

@@ -0,0 +1,66 @@
<template>
<NuxtImg
v-bind="passAttrs"
:src="src"
:alt="alt"
loading="lazy"
:placeholder="placeholder"
placeholder-class="base-image-ph"
@load="onLoad"
@error="onError"
:class="['base-image', passAttrs.class, { 'is-loaded': loaded }]"
/>
</template>
<script setup>
import { computed, ref } from 'vue'
import { useAttrs } from 'vue'
const props = defineProps({
src: { type: String, required: true },
alt: { type: String, default: '' },
})
const attrs = useAttrs()
const passAttrs = computed(() => {
const { placeholder, ...rest } = attrs
return rest
})
const loaded = ref(false)
const img = useImage()
const placeholder = computed(() => {
if (!props.src) return undefined
return img(props.src, { w: 16, h: 16, f: 'webp', q: 20, blur: 1 })
})
function onLoad() {
loaded.value = true
}
function onError() {
loaded.value = true
}
</script>
<style scoped>
.base-image {
transition:
filter 0.35s ease,
transform 0.35s ease,
opacity 0.35s ease;
opacity: 0.92;
}
.base-image-ph {
filter: blur(20px);
transform: scale(0.5);
}
.base-image.is-loaded {
/* Allow filters from parent classes (e.g. grayscale for unfinished medals) */
transform: none;
opacity: 1;
}
</style>

View File

@@ -0,0 +1,65 @@
<template>
<label class="switch">
<input
type="checkbox"
:checked="modelValue"
@change="$emit('update:modelValue', $event.target.checked)"
/>
<span class="slider"></span>
</label>
</template>
<script setup>
defineProps({
modelValue: { type: Boolean, default: false },
})
defineEmits(['update:modelValue'])
</script>
<style scoped>
.switch {
position: relative;
display: inline-block;
width: 40px;
height: 20px;
}
.switch input {
opacity: 0;
width: 0;
height: 0;
}
.slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #ccc;
transition: 0.2s;
border-radius: 20px;
}
.slider:before {
position: absolute;
content: '';
height: 16px;
width: 16px;
left: 2px;
bottom: 2px;
background-color: white;
transition: 0.2s;
border-radius: 50%;
}
input:checked + .slider {
background-color: var(--primary-color);
}
input:checked + .slider:before {
transform: translateX(20px);
}
</style>

View File

@@ -0,0 +1,92 @@
<template>
<div class="base-tabs">
<div class="base-tabs-header">
<div class="base-tabs-items">
<div
v-for="tab in tabs"
:key="tab.key"
:class="['base-tabs-item', { selected: modelValue === tab.key }]"
@click="$emit('update:modelValue', tab.key)"
>
<i v-if="tab.icon" :class="tab.icon"></i>
<div class="base-tabs-item-label">{{ tab.label }}</div>
</div>
</div>
<div class="base-tabs-header-right">
<slot name="right"></slot>
</div>
</div>
<div class="base-tabs-content" @touchstart="onTouchStart" @touchend="onTouchEnd">
<slot></slot>
</div>
</div>
</template>
<script setup>
const props = defineProps({
modelValue: { type: String, required: true },
tabs: { type: Array, default: () => [] },
})
const emit = defineEmits(['update:modelValue'])
let touchStartX = 0
function onTouchStart(e) {
touchStartX = e.touches[0].clientX
}
function onTouchEnd(e) {
const diffX = e.changedTouches[0].clientX - touchStartX
if (Math.abs(diffX) > 50) {
const index = props.tabs.findIndex((t) => t.key === props.modelValue)
if (diffX < 0 && index < props.tabs.length - 1) {
emit('update:modelValue', props.tabs[index + 1].key)
} else if (diffX > 0 && index > 0) {
emit('update:modelValue', props.tabs[index - 1].key)
}
}
}
</script>
<style scoped>
.base-tabs-header {
display: flex;
border-bottom: 1px solid var(--normal-border-color);
align-items: center;
flex-direction: row;
}
.base-tabs-items {
display: flex;
overflow-x: auto;
scrollbar-width: none;
flex: 1;
}
.base-tabs-item {
padding: 10px 20px;
cursor: pointer;
white-space: nowrap;
display: flex;
align-items: center;
}
.base-tabs-item i {
margin-right: 6px;
}
.base-tabs-item.selected {
color: var(--primary-color);
border-bottom: 2px solid var(--primary-color);
}
.base-tabs-header-right {
display: flex;
flex-shrink: 0;
}
.base-tabs-content {
width: 100%;
}
</style>

View File

@@ -1,14 +1,14 @@
<template>
<div class="timeline">
<div class="timeline" :class="{ 'hover-enabled': hover }">
<div class="timeline-item" v-for="(item, idx) in items" :key="idx">
<div
class="timeline-icon"
:class="{ clickable: !!item.iconClick }"
@click="item.iconClick && item.iconClick()"
>
<img v-if="item.src" :src="item.src" class="timeline-img" alt="timeline item" />
<BaseImage v-if="item.src" :src="item.src" class="timeline-img" alt="timeline item" />
<i v-else-if="item.icon" :class="item.icon"></i>
<span v-else-if="item.emoji" class="timeline-emoji">{{ item.emoji }}</span>
<BaseImage v-else-if="item.emoji" :src="item.emoji" class="timeline-emoji" alt="emoji" />
</div>
<div class="timeline-content">
<slot name="item" :item="item">{{ item.content }}</slot>
@@ -22,6 +22,7 @@ export default {
name: 'BaseTimeline',
props: {
items: { type: Array, default: () => [] },
hover: { type: Boolean, default: false },
},
}
</script>
@@ -41,6 +42,12 @@ export default {
margin-top: 10px;
}
.hover-enabled .timeline-item:hover {
background-color: var(--menu-selected-background-color);
transition: background-color 0.2s;
border-radius: 10px;
}
.timeline-icon {
position: sticky;
top: 0;
@@ -67,8 +74,9 @@ export default {
}
.timeline-emoji {
font-size: 20px;
line-height: 1;
width: 20px;
height: 20px;
object-fit: contain;
}
.timeline-item::before {

View File

@@ -9,7 +9,7 @@
<div class="option-container">
<div class="option-main">
<template v-if="option.icon">
<img
<BaseImage
v-if="isImageIcon(option.icon)"
:src="option.icon"
class="option-icon"

View File

@@ -8,7 +8,7 @@
>
<!-- <div class="user-avatar-container">
<div class="user-avatar-item">
<img class="user-avatar-item-img" :src="comment.avatar" alt="avatar" />
<BaseImage class="user-avatar-item-img" :src="comment.avatar" alt="avatar" />
</div>
</div> -->
<div class="info-content">
@@ -23,9 +23,13 @@
>{{ getMedalTitle(comment.medal) }}</NuxtLink
>
<i v-if="comment.pinned" class="fas fa-thumbtack pin-icon"></i>
<span v-if="level >= 2">
<span v-if="level >= 2" class="reply-item">
<i class="fas fa-reply reply-icon"></i>
<span class="user-name reply-user-name">{{ comment.parentUserName }}</span>
<span class="reply-info">
<BaseImage class="reply-avatar" :src="comment.parentUserAvatar || '/default-avatar.svg'" alt="avatar"
@click="comment.parentUserClick && comment.parentUserClick()" />
<span class="reply-user-name">{{ comment.parentUserName }}</span>
</span>
</span>
<div class="post-time">{{ comment.time }}</div>
</div>
@@ -250,6 +254,7 @@ const submitReply = async (parentUserName, text, clear) => {
medal: data.author.displayMedal,
text: data.content,
parentUserName: parentUserName,
parentUserAvatar: props.comment.avatar,
reactions: [],
reply: (data.replies || []).map((r) => ({
id: r.id,
@@ -376,7 +381,22 @@ const handleContentClick = (e) => {
justify-content: space-between;
}
.reply-item, .reply-info {
display: inline-flex;
flex-direction: row;
align-items: center;
}
.reply-avatar {
width: 20px;
height: 20px;
border-radius: 50%;
margin-right: 5px;
cursor: pointer;
}
.reply-icon {
color: var(--primary-color);
margin-right: 10px;
margin-left: 10px;
opacity: 0.5;

View File

@@ -13,7 +13,7 @@
<template v-for="(label, idx) in selectedLabels" :key="label.id">
<div class="selected-label">
<template v-if="label.icon">
<img
<BaseImage
v-if="isImageIcon(label.icon)"
:src="label.icon"
class="option-icon"
@@ -32,7 +32,7 @@
<span v-if="selectedLabels.length">
<div class="selected-label">
<template v-if="selectedLabels[0].icon">
<img
<BaseImage
v-if="isImageIcon(selectedLabels[0].icon)"
:src="selectedLabels[0].icon"
class="option-icon"
@@ -69,7 +69,12 @@
>
<slot name="option" :option="o" :isSelected="isSelected(o.id)">
<template v-if="o.icon">
<img v-if="isImageIcon(o.icon)" :src="o.icon" class="option-icon" :alt="o.name" />
<BaseImage
v-if="isImageIcon(o.icon)"
:src="o.icon"
class="option-icon"
:alt="o.name"
/>
<i v-else :class="['option-icon', o.icon]"></i>
</template>
<span>{{ o.name }}</span>
@@ -100,7 +105,12 @@
>
<slot name="option" :option="o" :isSelected="isSelected(o.id)">
<template v-if="o.icon">
<img v-if="isImageIcon(o.icon)" :src="o.icon" class="option-icon" :alt="o.name" />
<BaseImage
v-if="isImageIcon(o.icon)"
:src="o.icon"
class="option-icon"
:alt="o.name"
/>
<i v-else :class="['option-icon', o.icon]"></i>
</template>
<span>{{ o.name }}</span>

View File

@@ -7,6 +7,7 @@
@close="closeMilkTeaPopup"
/>
<NotificationSettingPopup :visible="showNotificationPopup" @close="closeNotificationPopup" />
<MessagePopup :visible="showMessagePopup" @close="closeMessagePopup" />
<MedalPopup :visible="showMedalPopup" :medals="newMedals" @close="closeMedalPopup" />
<ActivityPopup
@@ -22,6 +23,7 @@
import ActivityPopup from '~/components/ActivityPopup.vue'
import MedalPopup from '~/components/MedalPopup.vue'
import NotificationSettingPopup from '~/components/NotificationSettingPopup.vue'
import MessagePopup from '~/components/MessagePopup.vue'
import { authState } from '~/utils/auth'
const config = useRuntimeConfig()
@@ -33,6 +35,7 @@ const milkTeaIcon = ref('')
const inviteCodeIcon = ref('')
const showNotificationPopup = ref(false)
const showMessagePopup = ref(false)
const showMedalPopup = ref(false)
const newMedals = ref([])
@@ -43,6 +46,9 @@ onMounted(async () => {
await checkInviteCodeActivity()
if (showInviteCodePopup.value) return
await checkMessageFeature()
if (showMessagePopup.value) return
await checkNotificationSetting()
if (showNotificationPopup.value) return
@@ -50,7 +56,7 @@ onMounted(async () => {
})
const checkMilkTeaActivity = async () => {
if (!process.client) return
if (!import.meta.client) return
if (localStorage.getItem('milkTeaActivityPopupShown')) return
try {
const res = await fetch(`${API_BASE_URL}/api/activities`)
@@ -68,7 +74,7 @@ const checkMilkTeaActivity = async () => {
}
const checkInviteCodeActivity = async () => {
if (!process.client) return
if (!import.meta.client) return
if (localStorage.getItem('inviteCodeActivityPopupShown')) return
try {
const res = await fetch(`${API_BASE_URL}/api/activities`)
@@ -86,32 +92,42 @@ const checkInviteCodeActivity = async () => {
}
const closeInviteCodePopup = () => {
if (!process.client) return
if (!import.meta.client) return
localStorage.setItem('inviteCodeActivityPopupShown', 'true')
showInviteCodePopup.value = false
}
const closeMilkTeaPopup = () => {
if (!process.client) return
if (!import.meta.client) return
localStorage.setItem('milkTeaActivityPopupShown', 'true')
showMilkTeaPopup.value = false
checkNotificationSetting()
}
const checkMessageFeature = async () => {
if (!import.meta.client) return
if (!authState.loggedIn) return
if (localStorage.getItem('messageFeaturePopupShown')) return
showMessagePopup.value = true
}
const closeMessagePopup = () => {
if (!import.meta.client) return
localStorage.setItem('messageFeaturePopupShown', 'true')
showMessagePopup.value = false
}
const checkNotificationSetting = async () => {
if (!process.client) return
if (!import.meta.client) return
if (!authState.loggedIn) return
if (localStorage.getItem('notificationSettingPopupShown')) return
showNotificationPopup.value = true
}
const closeNotificationPopup = () => {
if (!process.client) return
if (!import.meta.client) return
localStorage.setItem('notificationSettingPopupShown', 'true')
showNotificationPopup.value = false
checkNewMedals()
}
const checkNewMedals = async () => {
if (!process.client) return
if (!import.meta.client) return
if (!authState.loggedIn || !authState.userId) return
try {
const res = await fetch(`${API_BASE_URL}/api/medals?userId=${authState.userId}`)
@@ -129,7 +145,7 @@ const checkNewMedals = async () => {
}
}
const closeMedalPopup = () => {
if (!process.client) return
if (!import.meta.client) return
const seen = new Set(JSON.parse(localStorage.getItem('seenMedals') || '[]'))
newMedals.value.forEach((m) => seen.add(m.type))
localStorage.setItem('seenMedals', JSON.stringify([...seen]))

View File

@@ -4,12 +4,15 @@
<div class="header-content-left">
<div v-if="showMenuBtn" class="menu-btn-wrapper">
<button class="menu-btn" ref="menuBtn" @click="$emit('toggle-menu')">
<i class="fas fa-bars"></i>
<i class="fas fa-bars micon"></i>
</button>
<span v-if="isMobile && unreadCount > 0" class="menu-unread-dot"></span>
<span
v-if="isMobile && (unreadMessageCount > 0 || hasChannelUnread)"
class="menu-unread-dot"
></span>
</div>
<NuxtLink class="logo-container" :to="`/`" @click="refrechData">
<img
<BaseImage
alt="OpenIsle"
src="https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/image.png"
width="60"
@@ -47,6 +50,16 @@
</div>
</ToolTip>
<ToolTip v-if="isLogin" content="站内信和频道" placement="bottom">
<div class="messages-icon" @click="goToMessages">
<i class="fas fa-comments"></i>
<span v-if="unreadMessageCount > 0" class="unread-badge">{{
unreadMessageCount
}}</span>
<span v-else-if="hasChannelUnread" class="unread-dot"></span>
</div>
</ToolTip>
<DropdownMenu v-if="isLogin" ref="userMenu" :items="headerMenuItems">
<template #trigger>
<div class="avatar-container">
@@ -62,7 +75,6 @@
</div>
</div>
</ClientOnly>
<SearchDropdown ref="searchDropdown" v-if="isMobile && showSearch" @close="closeSearch" />
</div>
</header>
@@ -75,7 +87,8 @@ import DropdownMenu from '~/components/DropdownMenu.vue'
import ToolTip from '~/components/ToolTip.vue'
import SearchDropdown from '~/components/SearchDropdown.vue'
import { authState, clearToken, loadCurrentUser } from '~/utils/auth'
import { fetchUnreadCount, notificationState } from '~/utils/notification'
import { useUnreadCount } from '~/composables/useUnreadCount'
import { useChannelsUnreadCount } from '~/composables/useChannelsUnreadCount'
import { useIsMobile } from '~/utils/screen'
import { themeState, cycleTheme, ThemeMode } from '~/utils/theme'
import { toast } from '~/main'
@@ -93,7 +106,8 @@ const props = defineProps({
const isLogin = computed(() => authState.loggedIn)
const isMobile = useIsMobile()
const unreadCount = computed(() => notificationState.unreadCount)
const { count: unreadMessageCount, fetchUnreadCount } = useUnreadCount()
const { hasUnread: hasChannelUnread, fetchChannelUnread } = useChannelsUnreadCount()
const avatar = ref('')
const showSearch = ref(false)
const searchDropdown = ref(null)
@@ -134,8 +148,21 @@ const copyInviteLink = async () => {
if (res.ok) {
const data = await res.json()
const inviteLink = data.token ? `${WEBSITE_BASE_URL}/signup?invite_token=${data.token}` : ''
await navigator.clipboard.writeText(inviteLink)
toast.success('邀请链接已复制')
/**
* navigator.clipboard在webkit中有点奇怪的行为
* https://stackoverflow.com/questions/62327358/javascript-clipboard-api-safari-ios-notallowederror-message
* https://webkit.org/blog/10247/new-webkit-features-in-safari-13-1/
*/
setTimeout(() => {
navigator.clipboard
.writeText(inviteLink)
.then(() => {
toast.success('邀请链接已复制')
})
.catch(() => {
toast.error('邀请链接复制失败')
})
}, 0)
} else {
const data = await res.json().catch(() => ({}))
toast.error(data.error || '生成邀请链接失败')
@@ -182,10 +209,13 @@ const goToNewPost = () => {
}
const refrechData = async () => {
await fetchUnreadCount()
window.dispatchEvent(new Event('refresh-home'))
}
const goToMessages = () => {
navigateTo('/message-box')
}
const headerMenuItems = computed(() => [
{ text: '设置', onClick: goToSettings },
{ text: '个人主页', onClick: goToProfile },
@@ -215,9 +245,10 @@ onMounted(async () => {
}
const updateUnread = async () => {
if (authState.loggedIn) {
await fetchUnreadCount()
fetchUnreadCount()
fetchChannelUnread()
} else {
notificationState.unreadCount = 0
fetchChannelUnread()
}
}
@@ -226,7 +257,7 @@ onMounted(async () => {
watch(
() => authState.loggedIn,
async () => {
async (isLoggedIn) => {
await updateAvatar()
await updateUnread()
},
@@ -287,6 +318,10 @@ onMounted(async () => {
gap: 20px;
}
.micon {
margin-left: 10px;
}
.menu-btn {
font-size: 24px;
background: none;
@@ -339,6 +374,7 @@ onMounted(async () => {
display: flex;
align-items: center;
cursor: pointer;
margin-right: 10px;
}
.avatar-img {
@@ -379,9 +415,37 @@ onMounted(async () => {
}
.rss-icon,
.new-post-icon {
.new-post-icon,
.messages-icon {
font-size: 18px;
cursor: pointer;
position: relative;
}
.unread-badge {
position: absolute;
top: -5px;
right: -10px;
background-color: #ff4d4f;
color: white;
border-radius: 50%;
padding: 2px 5px;
font-size: 10px;
font-weight: bold;
line-height: 1;
min-width: 16px;
text-align: center;
box-sizing: border-box;
}
.unread-dot {
position: absolute;
top: -2px;
right: -4px;
width: 8px;
height: 8px;
border-radius: 50%;
background-color: #ff4d4f;
}
.rss-icon {

View File

@@ -40,7 +40,7 @@ const stopObserver = () => {
}
const startObserver = () => {
if (!process.client || props.pause || done.value) return
if (!import.meta.client || props.pause || done.value) return
stopObserver()
io = new IntersectionObserver(
async (entries) => {

View File

@@ -0,0 +1,189 @@
<template>
<div class="lottery-section">
<AvatarCropper
:src="data.tempPrizeIcon"
:show="data.showPrizeCropper"
@close="data.showPrizeCropper = false"
@crop="onPrizeCropped"
/>
<div class="prize-row">
<span class="prize-row-title">奖品图片</span>
<label class="prize-container">
<BaseImage v-if="data.prizeIcon" :src="data.prizeIcon" class="prize-preview" alt="prize" />
<i v-else class="fa-solid fa-image default-prize-icon"></i>
<div class="prize-overlay">上传奖品图片</div>
<input type="file" class="prize-input" accept="image/*" @change="onPrizeIconChange" />
</label>
</div>
<div class="prize-name-row">
<span class="prize-row-title">奖品描述</span>
<BaseInput v-model="data.prizeDescription" placeholder="奖品描述" />
</div>
<div class="prize-count-row">
<span class="prize-row-title">奖品数量</span>
<div class="prize-count-input">
<input
class="prize-count-input-field"
type="number"
v-model.number="data.prizeCount"
min="1"
/>
</div>
</div>
<div class="prize-point-row">
<span class="prize-row-title">参与所需积分</span>
<div class="prize-count-input">
<input
class="prize-count-input-field"
type="number"
v-model.number="data.pointCost"
min="0"
max="100"
/>
</div>
</div>
<div class="prize-time-row">
<span class="prize-row-title">抽奖结束时间</span>
<client-only>
<flat-pickr v-model="data.endTime" :config="dateConfig" class="time-picker" />
</client-only>
</div>
</div>
</template>
<script setup>
import 'flatpickr/dist/flatpickr.css'
import FlatPickr from 'vue-flatpickr-component'
import AvatarCropper from '~/components/AvatarCropper.vue'
import BaseImage from '~/components/BaseImage.vue'
import BaseInput from '~/components/BaseInput.vue'
import { watch } from 'vue'
const props = defineProps({
data: {
type: Object,
required: true,
},
})
const dateConfig = { enableTime: true, time_24hr: true, dateFormat: 'Y-m-d H:i' }
const onPrizeIconChange = (e) => {
const file = e.target.files[0]
if (file) {
const reader = new FileReader()
reader.onload = () => {
props.data.tempPrizeIcon = reader.result
props.data.showPrizeCropper = true
}
reader.readAsDataURL(file)
}
}
const onPrizeCropped = ({ file, url }) => {
props.data.prizeIconFile = file
props.data.prizeIcon = url
}
watch(
() => props.data.prizeCount,
(val) => {
if (!val || val < 1) props.data.prizeCount = 1
},
)
watch(
() => props.data.pointCost,
(val) => {
if (val === undefined || val === null || val < 0) props.data.pointCost = 0
if (val > 100) props.data.pointCost = 100
},
)
</script>
<style scoped>
.lottery-section {
margin-top: 20px;
display: flex;
flex-direction: column;
gap: 20px;
margin-bottom: 200px;
}
.prize-row-title {
font-size: 16px;
color: var(--text-color);
font-weight: bold;
margin-bottom: 10px;
}
.prize-row,
.prize-name-row,
.prize-count-row,
.prize-point-row,
.prize-time-row {
display: flex;
flex-direction: column;
}
.prize-container {
position: relative;
width: 100px;
height: 100px;
border-radius: 10px;
overflow: hidden;
cursor: pointer;
background-color: var(--lottery-background-color);
display: flex;
align-items: center;
justify-content: center;
}
.default-prize-icon {
font-size: 30px;
opacity: 0.1;
color: var(--text-color);
}
.prize-preview {
width: 100%;
height: 100%;
object-fit: cover;
}
.prize-input {
display: none;
}
.prize-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.4);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.2s;
}
.prize-container:hover .prize-overlay {
opacity: 1;
}
.prize-count-input {
display: flex;
align-items: center;
}
.prize-count-input-field {
width: 50px;
height: 30px;
border-radius: 5px;
border: 1px solid var(--border-color);
padding: 0 10px;
font-size: 16px;
color: var(--text-color);
background-color: var(--lottery-background-color);
}
.time-picker {
max-width: 200px;
height: 30px;
background-color: var(--lottery-background-color);
border-radius: 5px;
border: 1px solid var(--border-color);
}
</style>

View File

@@ -4,7 +4,7 @@
<div class="medal-popup-title">恭喜你获得以下勋章</div>
<div class="medal-popup-list">
<div v-for="medal in medals" :key="medal.type" class="medal-popup-item">
<img :src="medal.icon" :alt="medal.title" class="medal-popup-item-icon" />
<BaseImage :src="medal.icon" :alt="medal.title" class="medal-popup-item-icon" />
<div class="medal-popup-item-title">{{ medal.title }}</div>
</div>
</div>

View File

@@ -88,7 +88,7 @@
@click="gotoCategory(c)"
>
<template v-if="c.smallIcon || c.icon">
<img
<BaseImage
v-if="isImageIcon(c.smallIcon || c.icon)"
:src="c.smallIcon || c.icon"
class="section-item-icon"
@@ -106,7 +106,7 @@
<div class="menu-section">
<div class="section-header" @click="tagOpen = !tagOpen">
<span>tag</span>
<span>标签</span>
<i :class="tagOpen ? 'fas fa-chevron-up' : 'fas fa-chevron-down'"></i>
</div>
<div v-if="tagOpen" class="section-items">
@@ -114,7 +114,7 @@
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
</div>
<div v-else v-for="t in tagData" :key="t.id" class="section-item" @click="gotoTag(t)">
<img
<BaseImage
v-if="isImageIcon(t.smallIcon || t.icon)"
:src="t.smallIcon || t.icon"
class="section-item-icon"
@@ -262,7 +262,7 @@ const gotoTag = (t) => {
top: var(--header-height);
width: 220px;
background-color: var(--app-menu-background-color);
height: calc(100vh - 20px - var(--header-height));
height: calc(100vh - var(--header-height));
border-right: 1px solid var(--menu-border-color);
display: flex;
flex-direction: column;
@@ -279,12 +279,29 @@ const gotoTag = (t) => {
padding: 10px 10px 0 10px;
}
.menu-content::-webkit-scrollbar {
display: none;
}
.menu-content {
-ms-overflow-style: none;
scrollbar-width: none;
}
.menu-item-container {
border-bottom: 1px solid var(--menu-border-color);
}
.menu-item:last-child {
margin-bottom: 5px;
}
/* .menu-item-container { */
/**/
/* } */
.menu-item {
padding: 4px 10px;
padding: 6px 12px;
text-decoration: none;
color: var(--menu-text-color);
border-radius: 10px;
@@ -298,7 +315,7 @@ const gotoTag = (t) => {
}
.menu-item-text {
font-size: 16px;
font-size: 14px;
text-decoration: none;
color: var(--menu-text-color);
}
@@ -348,19 +365,21 @@ const gotoTag = (t) => {
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.menu-section {
margin-top: 10px;
border-bottom: 1px solid var(--menu-border-color);
padding-bottom: 5px;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
font-weight: bold;
opacity: 0.5;
padding: 4px 10px;
font-size: 14px;
padding: 6px 12px 0 12px;
color: var(--menu-text-color);
cursor: pointer;
}
@@ -372,7 +391,7 @@ const gotoTag = (t) => {
}
.section-item {
padding: 4px 10px;
padding: 6px 12px;
display: flex;
align-items: center;
gap: 5px;
@@ -392,6 +411,8 @@ const gotoTag = (t) => {
}
.section-item-text {
font-size: 14px;
text-decoration: none;
color: var(--menu-text-color);
}

View File

@@ -0,0 +1,182 @@
<template>
<div class="message-editor-container">
<div class="message-editor-wrapper">
<div :id="editorId" ref="vditorElement"></div>
</div>
<div class="message-bottom-container">
<div class="message-submit" :class="{ disabled: isDisabled }" @click="submit">
<template v-if="!loading"> 发送 </template>
<template v-else> <i class="fa-solid fa-spinner fa-spin"></i> 发送中... </template>
</div>
</div>
</div>
</template>
<script>
import { computed, onMounted, onUnmounted, ref, useId, watch } from 'vue'
import { clearVditorStorage } from '~/utils/clearVditorStorage'
import { themeState } from '~/utils/theme'
import {
createVditor,
getEditorTheme as getEditorThemeUtil,
getPreviewTheme as getPreviewThemeUtil,
} from '~/utils/vditor'
import '~/assets/global.css'
export default {
name: 'MessageEditor',
emits: ['submit'],
props: {
editorId: {
type: String,
default: '',
},
loading: {
type: Boolean,
default: false,
},
disabled: {
type: Boolean,
default: false,
},
},
setup(props, { emit }) {
const vditorInstance = ref(null)
const text = ref('')
const editorId = ref(props.editorId)
if (!editorId.value) {
editorId.value = 'editor-' + useId()
}
const getEditorTheme = getEditorThemeUtil
const getPreviewTheme = getPreviewThemeUtil
const applyTheme = () => {
if (vditorInstance.value) {
vditorInstance.value.setTheme(getEditorTheme(), getPreviewTheme())
}
}
const isDisabled = computed(() => props.loading || props.disabled || !text.value.trim())
const submit = () => {
if (!vditorInstance.value || isDisabled.value) return
const value = vditorInstance.value.getValue()
emit('submit', value, () => {
if (!vditorInstance.value) return
vditorInstance.value.setValue('')
text.value = ''
})
}
onMounted(() => {
vditorInstance.value = createVditor(editorId.value, {
placeholder: '输入消息...',
height: 150,
toolbar: [
'emoji',
'bold',
'italic',
'strike',
'link',
'|',
'list',
'|',
'line',
'quote',
'code',
'inline-code',
'|',
'upload',
],
preview: {
actions: [],
markdown: { toc: false },
},
input(value) {
text.value = value
},
after() {
if (props.loading || props.disabled) {
vditorInstance.value.disabled()
}
applyTheme()
},
})
})
onUnmounted(() => {
clearVditorStorage()
})
watch(
() => props.loading,
(val) => {
if (!vditorInstance.value) return
if (val) {
vditorInstance.value.disabled()
} else if (!props.disabled) {
vditorInstance.value.enable()
}
},
)
watch(
() => props.disabled,
(val) => {
if (!vditorInstance.value) return
if (val) {
vditorInstance.value.disabled()
} else if (!props.loading) {
vditorInstance.value.enable()
}
},
)
watch(
() => themeState.mode,
() => {
applyTheme()
},
)
return { submit, isDisabled, editorId }
},
}
</script>
<style scoped>
.message-editor-container {
border: 1px solid var(--border-color);
border-radius: 8px;
}
.message-bottom-container {
display: flex;
flex-direction: row;
justify-content: flex-end;
padding: 10px;
background-color: var(--bg-color-soft);
border-top: 1px solid var(--border-color);
border-bottom-left-radius: 8px;
border-bottom-right-radius: 8px;
}
.message-submit {
background-color: var(--primary-color);
color: #fff;
padding: 8px 16px;
border-radius: 6px;
font-size: 14px;
cursor: pointer;
transition: background-color 0.2s ease;
}
.message-submit.disabled {
background-color: var(--primary-color-disabled);
opacity: 0.6;
cursor: not-allowed;
}
.message-submit:not(.disabled):hover {
background-color: var(--primary-color-hover);
}
</style>

View File

@@ -0,0 +1,115 @@
<template>
<div v-if="floatRoute" class="message-float-window" :style="{ height: floatHeight }">
<iframe :src="iframeSrc" frameborder="0" ref="iframeRef" @load="injectBaseTag"></iframe>
<div class="float-actions">
<i
class="fas fa-chevron-down"
v-if="floatHeight !== MINI_HEIGHT"
title="收起至 100px"
@click="collapseToMini"
></i>
<i
class="fas fa-chevron-up"
v-if="floatHeight !== DEFAULT_HEIGHT"
title="回弹至 60vh"
@click="reboundToDefault"
></i>
<i class="fas fa-expand" title="在页面中打开" @click="expand"></i>
<i class="fas fa-times" title="关闭" @click="close"></i>
</div>
</div>
</template>
<script setup>
const floatRoute = useState('messageFloatRoute')
const DEFAULT_HEIGHT = '60vh'
const MINI_HEIGHT = '45px'
const floatHeight = ref(DEFAULT_HEIGHT)
const iframeRef = ref(null)
const iframeSrc = computed(() => {
if (!floatRoute.value) return ''
return floatRoute.value + (floatRoute.value.includes('?') ? '&' : '?') + 'float=1'
})
function collapseToMini() {
floatHeight.value = MINI_HEIGHT
}
function reboundToDefault() {
floatHeight.value = DEFAULT_HEIGHT
}
function expand() {
if (!floatRoute.value) return
const target = floatRoute.value
floatRoute.value = null
navigateTo(target)
}
function close() {
floatRoute.value = null
}
function injectBaseTag() {
if (!iframeRef.value) return
const iframeDoc = iframeRef.value.contentDocument || iframeRef.value.contentWindow.document
if (iframeDoc && !iframeDoc.querySelector('base')) {
const base = iframeDoc.createElement('base')
base.target = '_top'
iframeDoc.head.appendChild(base)
}
}
watch(
() => floatRoute.value,
(v) => {
if (v) floatHeight.value = DEFAULT_HEIGHT
},
)
</script>
<style scoped>
.message-float-window {
position: fixed;
bottom: 0;
right: 0;
width: 400px;
max-height: 90vh;
background-color: var(--background-color);
border: 1px solid var(--normal-border-color);
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
z-index: 2000;
display: flex;
flex-direction: column;
transition: height 0.25s ease;
/* 平滑过渡 */
}
.message-float-window iframe {
width: 100%;
flex: 1;
}
.float-actions {
position: absolute;
top: 4px;
right: 8px;
padding: 12px;
display: flex;
gap: 10px;
}
.float-actions i {
cursor: pointer;
font-size: 14px;
opacity: 0.9;
}
.float-actions i:hover {
opacity: 1;
}
</style>

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