Compare commits

..

192 Commits

Author SHA1 Message Date
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
3fc6929075 Remove unread message after marking read 2025-08-20 20:46:08 +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
Tim
91ffacc335 fix: 已经加载的帖子 重新进入 没有执行评论定位逻辑 #652 2025-08-20 19:43:31 +08:00
Tim
4969a759aa fix: 已关闭的帖子不需要展示订阅按钮 #651 2025-08-20 19:33:31 +08:00
Tim
81e3a80d35 Update README.md 2025-08-20 16:31:49 +08:00
Tim
d717ce03c1 feat: add CONTRIBUTING 2025-08-20 16:29:45 +08:00
Tim
66035447a8 feat: add CONTRIBUTING 2025-08-20 16:28:28 +08:00
Tim
fa1148bc4e Update README.md 2025-08-20 16:25:12 +08:00
Tim
f60f184c84 Update README.md 2025-08-20 16:24:33 +08:00
Tim
06ffb180fe Update README.md 2025-08-20 16:24:05 +08:00
Tim
1b892828f1 Update README.md 2025-08-20 16:23:22 +08:00
Tim
1aa88ab0fe Merge pull request #661 from WoJiaoFuXiaoYun/main 2025-08-20 15:51:52 +08:00
WangHe
86126699d3 fix: 修复超长文本造成ui宽度撑开
Related #602
2025-08-20 15:42:52 +08:00
Tim
a6a07b9bda Merge pull request #658 from zpaeng/main
fix:验证邮箱有歧义,修改为验证并注册
2025-08-20 14:32:31 +08:00
zpaeng
d8b3c68150 fix:验证邮箱有歧义,修改为验证并注册 2025-08-20 13:51:24 +08:00
tim
318b481c4b fix: 判断close 2025-08-19 22:43:22 +08:00
Tim
7338b891db Merge pull request #648 from nagisa77/feature/daily_bugfix_0819
Feature/daily bugfix 0819
2025-08-19 22:37:42 +08:00
tim
eb18dc8e94 feat: 添加关闭 2025-08-19 22:35:28 +08:00
Tim
aec5321f89 Merge pull request #650 from nagisa77/codex/add-option-to-close-posts-s8akgv 2025-08-19 22:20:10 +08:00
Tim
2e658f37a4 Merge pull request #649 from nagisa77/codex/add-option-to-close-posts 2025-08-19 22:19:11 +08:00
Tim
7ccb2a44e3 feat: allow closing posts 2025-08-19 22:19:05 +08:00
Tim
0fa08e2260 feat: allow closing posts 2025-08-19 22:18:53 +08:00
Tim
38a49f7414 fix: 信息展示效率低 #632 2025-08-19 21:58:02 +08:00
Tim
fb89c9fb25 fix: 弹出弹窗逻辑修改 2025-08-19 21:48:48 +08:00
Tim
e9458f5419 fix: hljs 优化导入 2025-08-19 21:24:27 +08:00
Tim
2d87c8f23d fix: 格式化问题修改 2025-08-19 21:17:13 +08:00
Tim
cb281e4030 Merge pull request #638 from nagisa77/feature/message_load_more
支持分页加载
2025-08-19 19:52:34 +08:00
Tim
9b85d77158 Merge pull request #647 from nagisa77/codex/fix-pagination-issue-in-notification-queries
Fix notification pagination after filtering disabled types
2025-08-19 19:49:03 +08:00
Tim
a3b28eafe4 Fix notification pagination after filtering disabled types 2025-08-19 19:48:41 +08:00
tim
805a8df7d3 Reapply "feat: add paginated notification endpoints"
This reverts commit e7a1e1d159.
2025-08-19 19:38:04 +08:00
tim
02be045f55 Revert "feat: add paginated notification APIs and frontend support"
This reverts commit c344b5b4ae.
2025-08-19 19:37:59 +08:00
Tim
ac3c7b7bec Merge pull request #646 from nagisa77/codex/add-pagination-support-for-messages-6ssiwm
feat: add paginated notifications and unread endpoint
2025-08-19 19:35:39 +08:00
Tim
c344b5b4ae feat: add paginated notification APIs and frontend support 2025-08-19 19:34:13 +08:00
tim
e7a1e1d159 Revert "feat: add paginated notification endpoints"
This reverts commit cc525c1c27.
2025-08-19 19:33:13 +08:00
Tim
30b56e54cf Merge pull request #645 from nagisa77/codex/add-pagination-support-for-messages-owucez
feat: add paginated notification endpoints
2025-08-19 19:27:27 +08:00
Tim
cc525c1c27 feat: add paginated notification endpoints 2025-08-19 19:27:07 +08:00
tim
3f2829cd37 Revert "feat: support paginated notifications"
This reverts commit a64fd71bbe.
2025-08-19 19:01:54 +08:00
Tim
3258a42b44 Merge pull request #644 from nagisa77/codex/add-pagination-support-for-messages
feat: paginate and load notifications per page
2025-08-19 18:46:12 +08:00
Tim
a64fd71bbe feat: support paginated notifications 2025-08-19 18:45:56 +08:00
tim
1a12bec7b1 Revert "feat: add paginated notification APIs and frontend"
This reverts commit 7dd1f1b3d0.
2025-08-19 18:26:55 +08:00
Tim
fbca19791a Merge pull request #643 from nagisa77/codex/add-pagination-support-for-message-page-ymy51v
feat: add paginated notification APIs and frontend
2025-08-19 18:25:10 +08:00
tim
10b6fdd1cb Revert "feat: add paginated notifications and unread endpoint"
This reverts commit 73168c1859.
2025-08-19 18:24:49 +08:00
Tim
7dd1f1b3d0 feat: add paginated notification APIs and frontend 2025-08-19 18:24:27 +08:00
Tim
df92ff664c Merge pull request #642 from nagisa77/codex/add-pagination-support-for-message-page-2bmo7x
feat: add paginated notifications and unread endpoint
2025-08-19 18:20:39 +08:00
Tim
73168c1859 feat: add paginated notifications and unread endpoint 2025-08-19 18:20:26 +08:00
tim
77856ff9af fix: make full page 2025-08-19 17:23:50 +08:00
tim
df49b21620 Revert "feat: add paginated notification API and frontend support"
This reverts commit df7ca77652.
2025-08-19 17:23:36 +08:00
Tim
fbe2c66955 Merge pull request #641 from nagisa77/codex/add-pagination-support-for-message-page
feat: paginate notifications and add unread filter
2025-08-19 17:08:05 +08:00
Tim
df7ca77652 feat: add paginated notification API and frontend support 2025-08-19 17:07:27 +08:00
Tim
fe84e3f2fa Merge pull request #639 from CH-122/fix/post-page-update
修复文章详情页面返回后不更新数据 & 优化 /reaction-types 接口重复调用
2025-08-19 17:02:30 +08:00
Tim
c307732696 Merge pull request #637 from zpaeng/main
fix:删帖需要给发帖者提示
2025-08-19 17:01:44 +08:00
tim
35bcd2cdc2 fix: 支持分页加载 2025-08-19 16:52:34 +08:00
CH-122
a29bf7d860 feat: 增加 useReactionTypes,优化 /reaction-types 接口重复调用 2025-08-19 16:52:33 +08:00
CH-122
27393c15f2 fix: 添加 onActivated 钩子以刷新帖子和评论 2025-08-19 16:51:22 +08:00
zpaeng
c91a787f29 Merge branch 'nagisa77:main' into main 2025-08-19 16:49:08 +08:00
zpaeng
6096712291 fix:删帖需要给发帖者提示 2025-08-19 16:45:47 +08:00
Tim
6d20addcde Merge pull request #634 from CH-122/fix/post-list-content
fix: 帖子描述与参与人员重叠
2025-08-19 15:47:21 +08:00
CH-122
d8f9fd670c fix: 帖子描述与参与人员重叠 2025-08-19 15:38:12 +08:00
Tim
5ebe739917 Merge pull request #631 from WoJiaoFuXiaoYun/main
style: 优化行内代码样式
2025-08-19 15:26:29 +08:00
WangHe
022edc866a style: 优化行内代码样式
Related #622
2025-08-19 15:03:08 +08:00
tim
b06815cc59 fix: login with google 2025-08-19 09:41:15 +08:00
Tim
f1b223a3c9 Merge pull request #627 from nagisa77/codex/fix-null-value-assignment-error
Handle nullable rssExcluded flag
2025-08-19 09:17:23 +08:00
Tim
e65273daa6 Use nullable Boolean for rssExcluded 2025-08-19 09:17:10 +08:00
tim
d3a2acb605 fix: 移动端降低gap 2025-08-18 20:21:14 +08:00
tim
bced24e47d feat: rss 动画 2025-08-18 19:59:29 +08:00
tim
425ad03e6f fix: 默认不推荐 2025-08-18 19:51:57 +08:00
Tim
4462d8f711 Merge pull request #626 from nagisa77/codex/adapt-to-rss-2.0-specification
feat: provide RSS feed with admin exclusion
2025-08-18 19:43:59 +08:00
tim
1b31977ec6 feat: rss细化 2025-08-18 19:43:34 +08:00
tim
42693cb1ff feat: add invite 2025-08-18 19:16:05 +08:00
Tim
6b500466fc feat: expose rss feed endpoint 2025-08-18 19:15:12 +08:00
Tim
c84262eb88 Merge pull request #620 from nagisa77/feature/fix_vditor_css
Feature/fix vditor css
2025-08-18 11:28:28 +08:00
Tim
fa2ffaa64a fix: viditor样式失效 #586 2025-08-18 11:27:18 +08:00
Tim
3037c856d0 fix: viditor样式失效 #586 2025-08-18 11:27:13 +08:00
Tim
7b1ce3f070 Merge pull request #619 from nagisa77/feature/remove-router-link
fix: router-link
2025-08-18 11:17:14 +08:00
Tim
f4a15b3448 fix: router-link 2025-08-18 11:14:28 +08:00
Tim
239f1f8c84 Merge pull request #617 from CH-122/fix/mobile-invite-ui
fix: 优化邀请链接组件样式,增加文本换行支持;调整积分商城页面内边距
2025-08-18 10:55:40 +08:00
CH-122
ac303184c4 fix: 优化邀请链接组件样式,增加文本换行支持;调整积分商城页面内边距 2025-08-18 10:32:55 +08:00
Tim
7f16bbdb94 Merge pull request #607 from nagisa77/feature/coin_store
支持积分商城 & 邀请码
2025-08-18 02:20:59 +08:00
tim
f1c83b0f68 fix: 更新提示 2025-08-18 02:19:43 +08:00
tim
22c2b1564d feat: ui 优化+弹窗 2025-08-18 02:18:04 +08:00
tim
628d28c12d feat: 注册流程重构 2025-08-18 02:06:48 +08:00
Tim
2577992ee3 Merge pull request #613 from nagisa77/codex/implement-invitation-link-functionality
feat: add invite link generation and copy
2025-08-18 01:24:05 +08:00
Tim
5b837c9d7f feat: add invite link generation and copy 2025-08-18 01:23:33 +08:00
tim
017ad5bf54 feat: invite ui 2025-08-18 01:15:46 +08:00
Tim
f076b70e9b Merge pull request #612 from nagisa77/codex/add-invitejwt-for-generating-invitation-tokens
feat: add invite token support
2025-08-18 01:11:33 +08:00
Tim
62d12ad2a7 feat: track oauth new-user result 2025-08-18 01:11:16 +08:00
tim
923854bbc6 feat: 适配透传invite_code 2025-08-17 21:56:14 +08:00
tim
9ca5d7b167 feat: 各种登录方式传入invite_token 2025-08-17 12:45:58 +08:00
tim
9c3e1d17f0 Merge remote-tracking branch 'origin/main' into feature/coin_store 2025-08-17 12:09:26 +08:00
tim
7906062945 fix: 添加缺失route 2025-08-17 12:08:18 +08:00
tim
785c36d339 feat: 新增邀请页面ui 2025-08-17 11:51:16 +08:00
Tim
197cbca99c Merge pull request #609 from nagisa77/codex/add-invitation-code-points-event-3vhg3b
Add invite points activity
2025-08-17 11:38:34 +08:00
Tim
b1076d7256 Add invite points activity 2025-08-17 11:38:09 +08:00
140 changed files with 6720 additions and 1203 deletions

View File

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

116
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,116 @@
#### **⚠️注意:仅想修改前端的朋友可不用部署后端服务**
## 如何部署
> Step1 先克隆仓库
```shell
git clone https://github.com/nagisa77/OpenIsle.git
cd OpenIsle
```
> Step2 后端部署
```shell
cd backend
```
以IDEA编辑器为例IDEA打开backend文件夹。
- 设置VM Option最好运行在其他端口非8080这里设置8081
```shell
-Dserver.port=8081
```
![CleanShot 2025-08-04 at 11.35.49.png](https://openisle-1307107697.cos.accelerate.myqcloud.com/dynamic_assert/4cf210cfc6ea478a80dfc744c85ccdc4.png)
- 设置jdk版本为java 17
![CleanShot 2025-08-04 at 11.38.03@2x.png](https://openisle-1307107697.cos.accelerate.myqcloud.com/dynamic_assert/392eeec753ae436ca12a78f750dfea2d.png)
- 本机配置MySQL服务网上很多教程忽略
- 设置环境变量.env 文件 或.properties 文件(二选一)
1. 环境变量文件生成
```shell
cp open-isle.env.example open-isle.env
```
修改环境变量留下需要的比如你要开发Google登录业务就需要谷歌相关的变量数据库是一定要的
![CleanShot 2025-08-04 at 11.41.36@2x.png](https://openisle-1307107697.cos.accelerate.myqcloud.com/dynamic_assert/896c8363b6e64ea19d18c12ec4dae2b4.png)
应用环境文件, 选择刚刚的`open-isle.env`
![CleanShot 2025-08-04 at 11.44.41.png](https://openisle-1307107697.cos.accelerate.myqcloud.com/dynamic_assert/f588e37838014a6684c141605639b9fa.png)
2. 直接修改 .properities 文件
位置src/main/application.properties, 数据库需要修改标红处,其他按需修改
![CleanShot 2025-08-04 at 11.47.11@2x.png](https://openisle-1307107697.cos.accelerate.myqcloud.com/dynamic_assert/28c3104448a245419e0b06aee861abb4.png)
处理完环境问题直接跑起来就能通了
![CleanShot 2025-08-04 at 11.49.01@2x.png](https://openisle-1307107697.cos.accelerate.myqcloud.com/dynamic_assert/2c945eae44b1477db09e80fc96b5e02d.png)
> Step3 前端部署
前端可以依赖本机部署的后端,也可以直接调用线上的后端接口
```shell
cd ../frontend_nuxt/
```
copy环境.env文件
```shell
cp .env.staging.example .env
```
1. 依赖本机部署的后端:打开本文件夹,修改.env 修改为瞄准本机后端端口
```yaml
; 本地部署后端
NUXT_PUBLIC_API_BASE_URL=https://127.0.0.1:8081
; 预发环境后端
; NUXT_PUBLIC_API_BASE_URL=https://staging.open-isle.com
; 生产环境后端
; NUXT_PUBLIC_API_BASE_URL=https://www.open-isle.com
```
2. 依赖预发环境后台环境
**(⚠️强烈推荐只部署前端的朋友使用该环境)**
```yaml
; 本地部署后端
; NUXT_PUBLIC_API_BASE_URL=https://127.0.0.1:8081
; 预发环境后端
NUXT_PUBLIC_API_BASE_URL=https://staging.open-isle.com
; 生产环境后端
; NUXT_PUBLIC_API_BASE_URL=https://www.open-isle.com
```
4. 依赖线上后台环境
```yaml
; 本地部署后端
; NUXT_PUBLIC_API_BASE_URL=https://127.0.0.1:8081
; 预发环境后端
; NUXT_PUBLIC_API_BASE_URL=https://staging.open-isle.com
; 生产环境后端
NUXT_PUBLIC_API_BASE_URL=https://www.open-isle.com
```
```shell
# 安装依赖
npm install --verbose
# 运行前端服务
npm run dev
```
如此一来,浏览器访问 http://127.0.0.1:3000 即可访问前端页面

View File

@@ -1,45 +1,18 @@
<p align="center"> <p align="center">
<img alt="OpenIsle" src="https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/image.png" width="200"> <img alt="OpenIsle" src="https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/image.png" width="200">
<br><br> <br>
高效的开源社区前后端平台 高效的开源社区前后端平台
<br><br> <br><br><br>
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg?style=flat-square"></a> <img alt="Image" src="https://openisle-1307107697.cos.accelerate.myqcloud.com/dynamic_assert/22752cfac5a04a9c90c41995b9f55fed.png" width="1200">
</p> </p>
## 💡 简介 ## 💡 简介
OpenIsle 是一个使用 Spring Boot 和 Vue 3 构建的全栈开源社区平台,提供用户注册、登录、贴文发布、评论交互等完整功能,可用于项目社区或直接打造自主社区站点。 OpenIsle 是一个使用 Spring Boot 和 Vue 3 构建的全栈开源社区平台,提供用户注册、登录、贴文发布、评论交互等完整功能,可用于项目社区或直接打造自主社区站点。
## 🚧 开发 ## 🚧 开发 & 部署
### 后端 详细见 [Contributing](https://github.com/nagisa77/OpenIsle?tab=contributing-ov-file)
1. 确保安装 JDK 17 及 Maven
2. 信息配置修改 `src/main/resources/application.properties`,或通过环境变量设置数据库等参数
3. 执行 `mvn clean package` 生成包,之后使用 `java -jar target/openisle-0.0.1-SNAPSHOT.jar`启动,或在开发时直接使用 `mvn spring-boot:run`
### 前端
1. 进入前端目录
```bash
cd frontend_nuxt
```
2. 安装依赖
```bash
npm install
```
3. 启动开发服务
```bash
npm run dev
```
生产版本使用如下命令编译:
```bash
npm run build
```
会在 `.output` 目录生成文件,配合线上网站方式部署
## ✨ 项目特点 ## ✨ 项目特点

View File

@@ -3,6 +3,12 @@ MYSQL_URL=jdbc:mysql://<数据库地址>:<端口>/<数据库名>?useUnicode=yes&
MYSQL_USER=<数据库用户名> MYSQL_USER=<数据库用户名>
MYSQL_PASSWORD=<数据库密码> MYSQL_PASSWORD=<数据库密码>
# === JWT ===
JWT_SECRET=<jwt secret>
JWT_REASON_SECRET=<jwt reason secret>
JWT_RESET_SECRET=<jwt reset secret>
JWT_INVITE_SECRET=<jwt invite secret>
JWT_EXPIRATION=2592000000
# === Resend === # === Resend ===
RESEND_API_KEY=<你的resend-api-key> RESEND_API_KEY=<你的resend-api-key>
@@ -30,4 +36,4 @@ OPENAI_API_KEY=<你的openai-api-key>
WEBPUSH_PUBLIC_KEY=<你的webpush-public-key> WEBPUSH_PUBLIC_KEY=<你的webpush-public-key>
WEBPUSH_PRIVATE_KEY=<你的webpush-private-key> WEBPUSH_PRIVATE_KEY=<你的webpush-private-key>
# LOG_LEVEL=DEBUG # LOG_LEVEL=DEBUG

View File

@@ -26,6 +26,10 @@
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId> <artifactId>spring-boot-starter-web</artifactId>
</dependency> </dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<dependency> <dependency>
<groupId>org.slf4j</groupId> <groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId> <artifactId>slf4j-api</artifactId>
@@ -38,6 +42,16 @@
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId> <artifactId>spring-boot-starter-security</artifactId>
</dependency> </dependency>
<dependency>
<groupId>com.vladsch.flexmark</groupId>
<artifactId>flexmark-all</artifactId>
<version>0.64.8</version>
</dependency>
<dependency>
<groupId>org.jsoup</groupId>
<artifactId>jsoup</artifactId>
<version>1.17.2</version>
</dependency>
<dependency> <dependency>
<groupId>com.mysql</groupId> <groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId> <artifactId>mysql-connector-j</artifactId>

View File

@@ -6,7 +6,7 @@ import com.openisle.repository.ActivityRepository;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.boot.CommandLineRunner; import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import java.time.LocalDate;
import java.time.LocalDateTime; import java.time.LocalDateTime;
@Component @Component
@@ -29,9 +29,10 @@ public class ActivityInitializer implements CommandLineRunner {
Activity a = new Activity(); Activity a = new Activity();
a.setTitle("🎁邀请码送积分活动"); a.setTitle("🎁邀请码送积分活动");
a.setType(ActivityType.INVITE_POINTS); a.setType(ActivityType.INVITE_POINTS);
a.setIcon("https://icons.veryicon.com/png/o/commerce-shopping/two-color-icon-library/gift-30.png"); a.setIcon("https://img.icons8.com/color/96/gift.png");
a.setContent("活动期间,邀请好友注册可获得积分奖励,快来参与吧!"); a.setContent("使用邀请码注册或邀请好友即可获得积分奖励,快来参与吧!");
a.setEndTime(LocalDateTime.of(2025, 10, 1, 0, 0)); a.setStartTime(LocalDateTime.now());
a.setEndTime(LocalDate.of(LocalDate.now().getYear(), 10, 1).atStartOfDay());
activityRepository.save(a); activityRepository.save(a);
} }
} }

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

@@ -45,4 +45,14 @@ public class AdminPostController {
public PostSummaryDto unpin(@PathVariable Long id) { public PostSummaryDto unpin(@PathVariable Long id) {
return postMapper.toSummaryDto(postService.unpinPost(id)); return postMapper.toSummaryDto(postService.unpinPost(id));
} }
@PostMapping("/{id}/rss-exclude")
public PostSummaryDto excludeFromRss(@PathVariable Long id) {
return postMapper.toSummaryDto(postService.excludeFromRss(id));
}
@PostMapping("/{id}/rss-include")
public PostSummaryDto includeInRss(@PathVariable Long id) {
return postMapper.toSummaryDto(postService.includeInRss(id));
}
} }

View File

@@ -29,6 +29,7 @@ public class AuthController {
private final RegisterModeService registerModeService; private final RegisterModeService registerModeService;
private final NotificationService notificationService; private final NotificationService notificationService;
private final UserRepository userRepository; private final UserRepository userRepository;
private final InviteService inviteService;
@Value("${app.captcha.enabled:false}") @Value("${app.captcha.enabled:false}")
@@ -45,6 +46,27 @@ public class AuthController {
if (captchaEnabled && registerCaptchaEnabled && !captchaService.verify(req.getCaptcha())) { if (captchaEnabled && registerCaptchaEnabled && !captchaService.verify(req.getCaptcha())) {
return ResponseEntity.badRequest().body(Map.of("error", "Invalid captcha")); return ResponseEntity.badRequest().body(Map.of("error", "Invalid captcha"));
} }
if (req.getInviteToken() != null && !req.getInviteToken().isEmpty()) {
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(), user.getUsername());
emailService.sendEmail(user.getEmail(), "在网站填写验证码以验证", "您的验证码是 " + user.getVerificationCode());
return ResponseEntity.ok(Map.of(
"token", jwtService.generateToken(user.getUsername()),
"reason_code", "INVITE_APPROVED"
));
} catch (FieldException e) {
return ResponseEntity.badRequest().body(Map.of(
"field", e.getField(),
"error", e.getMessage()
));
}
}
User user = userService.register( User user = userService.register(
req.getUsername(), req.getEmail(), req.getPassword(), "", registerModeService.getRegisterMode()); req.getUsername(), req.getEmail(), req.getPassword(), "", registerModeService.getRegisterMode());
emailService.sendEmail(user.getEmail(), "在网站填写验证码以验证", "您的验证码是 " + user.getVerificationCode()); emailService.sendEmail(user.getEmail(), "在网站填写验证码以验证", "您的验证码是 " + user.getVerificationCode());
@@ -58,10 +80,26 @@ public class AuthController {
public ResponseEntity<?> verify(@RequestBody VerifyRequest req) { public ResponseEntity<?> verify(@RequestBody VerifyRequest req) {
boolean ok = userService.verifyCode(req.getUsername(), req.getCode()); boolean ok = userService.verifyCode(req.getUsername(), req.getCode());
if (ok) { if (ok) {
return ResponseEntity.ok(Map.of( Optional<User> userOpt = userService.findByUsername(req.getUsername());
"message", "Verified", if (userOpt.isEmpty()) {
"token", jwtService.generateReasonToken(req.getUsername()) return ResponseEntity.badRequest().body(Map.of("error", "Invalid credentials"));
)); }
User user = userOpt.get();
if (user.isApproved()) {
return ResponseEntity.ok(Map.of(
"message", "Verified and isApproved",
"reason_code", "VERIFIED_AND_APPROVED",
"token", jwtService.generateToken(req.getUsername())
));
} else {
return ResponseEntity.ok(Map.of(
"message", "Verified",
"reason_code", "VERIFIED",
"token", jwtService.generateReasonToken(req.getUsername())
));
}
} }
return ResponseEntity.badRequest().body(Map.of("error", "Invalid verification code")); return ResponseEntity.badRequest().body(Map.of("error", "Invalid verification code"));
} }
@@ -106,27 +144,43 @@ public class AuthController {
@PostMapping("/google") @PostMapping("/google")
public ResponseEntity<?> loginWithGoogle(@RequestBody GoogleLoginRequest req) { public ResponseEntity<?> loginWithGoogle(@RequestBody GoogleLoginRequest req) {
Optional<User> user = googleAuthService.authenticate(req.getIdToken(), registerModeService.getRegisterMode()); boolean viaInvite = req.getInviteToken() != null && !req.getInviteToken().isEmpty();
if (user.isPresent()) { InviteService.InviteValidateResult inviteValidateResult = inviteService.validate(req.getInviteToken());
if (RegisterMode.DIRECT.equals(registerModeService.getRegisterMode())) { if (viaInvite && !inviteValidateResult.isValidate()) {
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(user.get().getUsername()))); return ResponseEntity.badRequest().body(Map.of("error", "Invalid invite token"));
}
Optional<AuthResult> resultOpt = googleAuthService.authenticate(
req.getIdToken(),
registerModeService.getRegisterMode(),
viaInvite);
if (resultOpt.isPresent()) {
AuthResult result = resultOpt.get();
if (viaInvite && result.isNewUser()) {
inviteService.consume(req.getInviteToken(), inviteValidateResult.getInviteToken().getInviter().getUsername());
return ResponseEntity.ok(Map.of(
"token", jwtService.generateToken(result.getUser().getUsername()),
"reason_code", "INVITE_APPROVED"
));
} }
if (!user.get().isApproved()) { if (RegisterMode.DIRECT.equals(registerModeService.getRegisterMode())) {
if (user.get().getRegisterReason() != null && !user.get().getRegisterReason().isEmpty()) { return ResponseEntity.ok(Map.of("token", jwtService.generateToken(result.getUser().getUsername())));
}
if (!result.getUser().isApproved()) {
if (result.getUser().getRegisterReason() != null && !result.getUser().getRegisterReason().isEmpty()) {
return ResponseEntity.badRequest().body(Map.of( return ResponseEntity.badRequest().body(Map.of(
"error", "Account awaiting approval", "error", "Account awaiting approval",
"reason_code", "IS_APPROVING", "reason_code", "IS_APPROVING",
"token", jwtService.generateReasonToken(user.get().getUsername()) "token", jwtService.generateReasonToken(result.getUser().getUsername())
)); ));
} }
return ResponseEntity.badRequest().body(Map.of( return ResponseEntity.badRequest().body(Map.of(
"error", "Account awaiting approval", "error", "Account awaiting approval",
"reason_code", "NOT_APPROVED", "reason_code", "NOT_APPROVED",
"token", jwtService.generateReasonToken(user.get().getUsername()) "token", jwtService.generateReasonToken(result.getUser().getUsername())
)); ));
} }
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(user.get().getUsername()))); return ResponseEntity.ok(Map.of("token", jwtService.generateToken(result.getUser().getUsername())));
} }
return ResponseEntity.badRequest().body(Map.of( return ResponseEntity.badRequest().body(Map.of(
"error", "Invalid google token", "error", "Invalid google token",
@@ -165,28 +219,45 @@ public class AuthController {
@PostMapping("/github") @PostMapping("/github")
public ResponseEntity<?> loginWithGithub(@RequestBody GithubLoginRequest req) { public ResponseEntity<?> loginWithGithub(@RequestBody GithubLoginRequest req) {
Optional<User> user = githubAuthService.authenticate(req.getCode(), registerModeService.getRegisterMode(), req.getRedirectUri()); boolean viaInvite = req.getInviteToken() != null && !req.getInviteToken().isEmpty();
if (user.isPresent()) { InviteService.InviteValidateResult inviteValidateResult = inviteService.validate(req.getInviteToken());
if (RegisterMode.DIRECT.equals(registerModeService.getRegisterMode())) { if (viaInvite && !inviteValidateResult.isValidate()) {
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(user.get().getUsername()))); return ResponseEntity.badRequest().body(Map.of("error", "Invalid invite token"));
}
Optional<AuthResult> resultOpt = githubAuthService.authenticate(
req.getCode(),
registerModeService.getRegisterMode(),
req.getRedirectUri(),
viaInvite);
if (resultOpt.isPresent()) {
AuthResult result = resultOpt.get();
if (viaInvite && result.isNewUser()) {
inviteService.consume(req.getInviteToken(), inviteValidateResult.getInviteToken().getInviter().getUsername());
return ResponseEntity.ok(Map.of(
"token", jwtService.generateToken(result.getUser().getUsername()),
"reason_code", "INVITE_APPROVED"
));
} }
if (!user.get().isApproved()) { if (RegisterMode.DIRECT.equals(registerModeService.getRegisterMode())) {
if (user.get().getRegisterReason() != null && !user.get().getRegisterReason().isEmpty()) { return ResponseEntity.ok(Map.of("token", jwtService.generateToken(result.getUser().getUsername())));
}
if (!result.getUser().isApproved()) {
if (result.getUser().getRegisterReason() != null && !result.getUser().getRegisterReason().isEmpty()) {
// 已填写注册理由 // 已填写注册理由
return ResponseEntity.badRequest().body(Map.of( return ResponseEntity.badRequest().body(Map.of(
"error", "Account awaiting approval", "error", "Account awaiting approval",
"reason_code", "IS_APPROVING", "reason_code", "IS_APPROVING",
"token", jwtService.generateReasonToken(user.get().getUsername()) "token", jwtService.generateReasonToken(result.getUser().getUsername())
)); ));
} }
return ResponseEntity.badRequest().body(Map.of( return ResponseEntity.badRequest().body(Map.of(
"error", "Account awaiting approval", "error", "Account awaiting approval",
"reason_code", "NOT_APPROVED", "reason_code", "NOT_APPROVED",
"token", jwtService.generateReasonToken(user.get().getUsername()) "token", jwtService.generateReasonToken(result.getUser().getUsername())
)); ));
} }
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(user.get().getUsername()))); return ResponseEntity.ok(Map.of("token", jwtService.generateToken(result.getUser().getUsername())));
} }
return ResponseEntity.badRequest().body(Map.of( return ResponseEntity.badRequest().body(Map.of(
"error", "Invalid github code", "error", "Invalid github code",
@@ -196,27 +267,44 @@ public class AuthController {
@PostMapping("/discord") @PostMapping("/discord")
public ResponseEntity<?> loginWithDiscord(@RequestBody DiscordLoginRequest req) { public ResponseEntity<?> loginWithDiscord(@RequestBody DiscordLoginRequest req) {
Optional<User> user = discordAuthService.authenticate(req.getCode(), registerModeService.getRegisterMode(), req.getRedirectUri()); boolean viaInvite = req.getInviteToken() != null && !req.getInviteToken().isEmpty();
if (user.isPresent()) { InviteService.InviteValidateResult inviteValidateResult = inviteService.validate(req.getInviteToken());
if (RegisterMode.DIRECT.equals(registerModeService.getRegisterMode())) { if (viaInvite && !inviteValidateResult.isValidate()) {
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(user.get().getUsername()))); return ResponseEntity.badRequest().body(Map.of("error", "Invalid invite token"));
}
Optional<AuthResult> resultOpt = discordAuthService.authenticate(
req.getCode(),
registerModeService.getRegisterMode(),
req.getRedirectUri(),
viaInvite);
if (resultOpt.isPresent()) {
AuthResult result = resultOpt.get();
if (viaInvite && result.isNewUser()) {
inviteService.consume(req.getInviteToken(), inviteValidateResult.getInviteToken().getInviter().getUsername());
return ResponseEntity.ok(Map.of(
"token", jwtService.generateToken(result.getUser().getUsername()),
"reason_code", "INVITE_APPROVED"
));
} }
if (!user.get().isApproved()) { if (RegisterMode.DIRECT.equals(registerModeService.getRegisterMode())) {
if (user.get().getRegisterReason() != null && !user.get().getRegisterReason().isEmpty()) { return ResponseEntity.ok(Map.of("token", jwtService.generateToken(result.getUser().getUsername())));
}
if (!result.getUser().isApproved()) {
if (result.getUser().getRegisterReason() != null && !result.getUser().getRegisterReason().isEmpty()) {
return ResponseEntity.badRequest().body(Map.of( return ResponseEntity.badRequest().body(Map.of(
"error", "Account awaiting approval", "error", "Account awaiting approval",
"reason_code", "IS_APPROVING", "reason_code", "IS_APPROVING",
"token", jwtService.generateReasonToken(user.get().getUsername()) "token", jwtService.generateReasonToken(result.getUser().getUsername())
)); ));
} }
return ResponseEntity.badRequest().body(Map.of( return ResponseEntity.badRequest().body(Map.of(
"error", "Account awaiting approval", "error", "Account awaiting approval",
"reason_code", "NOT_APPROVED", "reason_code", "NOT_APPROVED",
"token", jwtService.generateReasonToken(user.get().getUsername()) "token", jwtService.generateReasonToken(result.getUser().getUsername())
)); ));
} }
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(user.get().getUsername()))); return ResponseEntity.ok(Map.of("token", jwtService.generateToken(result.getUser().getUsername())));
} }
return ResponseEntity.badRequest().body(Map.of( return ResponseEntity.badRequest().body(Map.of(
"error", "Invalid discord code", "error", "Invalid discord code",
@@ -226,31 +314,45 @@ public class AuthController {
@PostMapping("/twitter") @PostMapping("/twitter")
public ResponseEntity<?> loginWithTwitter(@RequestBody TwitterLoginRequest req) { public ResponseEntity<?> loginWithTwitter(@RequestBody TwitterLoginRequest req) {
Optional<User> user = twitterAuthService.authenticate( boolean viaInvite = req.getInviteToken() != null && !req.getInviteToken().isEmpty();
InviteService.InviteValidateResult inviteValidateResult = inviteService.validate(req.getInviteToken());
if (viaInvite && !inviteValidateResult.isValidate()) {
return ResponseEntity.badRequest().body(Map.of("error", "Invalid invite token"));
}
Optional<AuthResult> resultOpt = twitterAuthService.authenticate(
req.getCode(), req.getCode(),
req.getCodeVerifier(), req.getCodeVerifier(),
registerModeService.getRegisterMode(), registerModeService.getRegisterMode(),
req.getRedirectUri()); req.getRedirectUri(),
if (user.isPresent()) { viaInvite);
if (RegisterMode.DIRECT.equals(registerModeService.getRegisterMode())) { if (resultOpt.isPresent()) {
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(user.get().getUsername()))); AuthResult result = resultOpt.get();
if (viaInvite && result.isNewUser()) {
inviteService.consume(req.getInviteToken(), inviteValidateResult.getInviteToken().getInviter().getUsername());
return ResponseEntity.ok(Map.of(
"token", jwtService.generateToken(result.getUser().getUsername()),
"reason_code", "INVITE_APPROVED"
));
} }
if (!user.get().isApproved()) { if (RegisterMode.DIRECT.equals(registerModeService.getRegisterMode())) {
if (user.get().getRegisterReason() != null && !user.get().getRegisterReason().isEmpty()) { return ResponseEntity.ok(Map.of("token", jwtService.generateToken(result.getUser().getUsername())));
}
if (!result.getUser().isApproved()) {
if (result.getUser().getRegisterReason() != null && !result.getUser().getRegisterReason().isEmpty()) {
return ResponseEntity.badRequest().body(Map.of( return ResponseEntity.badRequest().body(Map.of(
"error", "Account awaiting approval", "error", "Account awaiting approval",
"reason_code", "IS_APPROVING", "reason_code", "IS_APPROVING",
"token", jwtService.generateReasonToken(user.get().getUsername()) "token", jwtService.generateReasonToken(result.getUser().getUsername())
)); ));
} }
return ResponseEntity.badRequest().body(Map.of( return ResponseEntity.badRequest().body(Map.of(
"error", "Account awaiting approval", "error", "Account awaiting approval",
"reason_code", "NOT_APPROVED", "reason_code", "NOT_APPROVED",
"token", jwtService.generateReasonToken(user.get().getUsername()) "token", jwtService.generateReasonToken(result.getUser().getUsername())
)); ));
} }
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(user.get().getUsername()))); return ResponseEntity.ok(Map.of("token", jwtService.generateToken(result.getUser().getUsername())));
} }
return ResponseEntity.badRequest().body(Map.of( return ResponseEntity.badRequest().body(Map.of(
"error", "Invalid twitter code", "error", "Invalid twitter code",

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()); Comment comment = commentService.addComment(auth.getName(), postId, req.getContent());
CommentDto dto = commentMapper.toDto(comment); CommentDto dto = commentMapper.toDto(comment);
dto.setReward(levelService.awardForComment(auth.getName())); 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()); log.debug("createComment succeeded for comment {}", comment.getId());
return ResponseEntity.ok(dto); return ResponseEntity.ok(dto);
} }

View File

@@ -0,0 +1,23 @@
package com.openisle.controller;
import com.openisle.service.InviteService;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
@RestController
@RequestMapping("/api/invite")
@RequiredArgsConstructor
public class InviteController {
private final InviteService inviteService;
@PostMapping("/generate")
public Map<String, String> generate(Authentication auth) {
String token = inviteService.generate(auth.getName());
return Map.of("token", token);
}
}

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.dto.UserSummaryDto;
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());
return ResponseEntity.ok(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());
return ResponseEntity.ok(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()));
}
private MessageDto toDto(Message message) {
MessageDto dto = new MessageDto();
dto.setId(message.getId());
dto.setContent(message.getContent());
dto.setCreatedAt(message.getCreatedAt());
dto.setConversationId(message.getConversation().getId());
UserSummaryDto senderDto = new UserSummaryDto();
senderDto.setId(message.getSender().getId());
senderDto.setUsername(message.getSender().getUsername());
senderDto.setAvatar(message.getSender().getAvatar());
dto.setSender(senderDto);
return dto;
}
@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;
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;
}
}
static class ChannelMessageRequest {
private String content;
public String getContent() {
return content;
}
public void setContent(String content) {
this.content = content;
}
}
}

View File

@@ -23,9 +23,19 @@ public class NotificationController {
private final NotificationMapper notificationMapper; private final NotificationMapper notificationMapper;
@GetMapping @GetMapping
public List<NotificationDto> list(@RequestParam(value = "read", required = false) Boolean read, public List<NotificationDto> list(@RequestParam(value = "page", defaultValue = "0") int page,
@RequestParam(value = "size", defaultValue = "30") int size,
Authentication auth) { Authentication auth) {
return notificationService.listNotifications(auth.getName(), read).stream() return notificationService.listNotifications(auth.getName(), null, page, size).stream()
.map(notificationMapper::toDto)
.collect(Collectors.toList());
}
@GetMapping("/unread")
public List<NotificationDto> listUnread(@RequestParam(value = "page", defaultValue = "0") int page,
@RequestParam(value = "size", defaultValue = "30") int size,
Authentication auth) {
return notificationService.listNotifications(auth.getName(), false, page, size).stream()
.map(notificationMapper::toDto) .map(notificationMapper::toDto)
.collect(Collectors.toList()); .collect(Collectors.toList());
} }

View File

@@ -0,0 +1,28 @@
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.RestController;
import java.util.List;
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());
}
}

View File

@@ -45,7 +45,7 @@ public class PostController {
draftService.deleteDraft(auth.getName()); draftService.deleteDraft(auth.getName());
PostDetailDto dto = postMapper.toDetailDto(post, auth.getName()); PostDetailDto dto = postMapper.toDetailDto(post, auth.getName());
dto.setReward(levelService.awardForPost(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); return ResponseEntity.ok(dto);
} }
@@ -62,6 +62,16 @@ public class PostController {
postService.deletePost(id, auth.getName()); postService.deletePost(id, auth.getName());
} }
@PostMapping("/{id}/close")
public PostSummaryDto close(@PathVariable Long id, Authentication auth) {
return postMapper.toSummaryDto(postService.closePost(id, auth.getName()));
}
@PostMapping("/{id}/reopen")
public PostSummaryDto reopen(@PathVariable Long id, Authentication auth) {
return postMapper.toSummaryDto(postService.reopenPost(id, auth.getName()));
}
@GetMapping("/{id}") @GetMapping("/{id}")
public ResponseEntity<PostDetailDto> getPost(@PathVariable Long id, Authentication auth) { public ResponseEntity<PostDetailDto> getPost(@PathVariable Long id, Authentication auth) {
String viewer = auth != null ? auth.getName() : null; String viewer = auth != null ? auth.getName() : null;
@@ -161,4 +171,27 @@ public class PostController {
return postService.listPostsByLatestReply(ids, tids, page, pageSize) return postService.listPostsByLatestReply(ids, tids, page, pageSize)
.stream().map(postMapper::toSummaryDto).collect(Collectors.toList()); .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

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

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

@@ -7,4 +7,5 @@ import lombok.Data;
public class DiscordLoginRequest { public class DiscordLoginRequest {
private String code; private String code;
private String redirectUri; private String redirectUri;
private String inviteToken;
} }

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

@@ -7,4 +7,5 @@ import lombok.Data;
public class GithubLoginRequest { public class GithubLoginRequest {
private String code; private String code;
private String redirectUri; private String redirectUri;
private String inviteToken;
} }

View File

@@ -6,4 +6,5 @@ import lombok.Data;
@Data @Data
public class GoogleLoginRequest { public class GoogleLoginRequest {
private String idToken; private String idToken;
private String inviteToken;
} }

View File

@@ -0,0 +1,13 @@
package com.openisle.dto;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class MessageDto {
private Long id;
private String content;
private UserSummaryDto sender;
private Long conversationId;
private LocalDateTime createdAt;
}

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

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

View File

@@ -9,4 +9,5 @@ public class RegisterRequest {
private String email; private String email;
private String password; private String password;
private String captcha; private String captcha;
private String inviteToken;
} }

View File

@@ -8,4 +8,5 @@ public class TwitterLoginRequest {
private String code; private String code;
private String redirectUri; private String redirectUri;
private String codeVerifier; private String codeVerifier;
private String inviteToken;
} }

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

@@ -63,6 +63,8 @@ public class PostMapper {
dto.setCommentCount(commentService.countComments(post.getId())); dto.setCommentCount(commentService.countComments(post.getId()));
dto.setStatus(post.getStatus()); dto.setStatus(post.getStatus());
dto.setPinnedAt(post.getPinnedAt()); dto.setPinnedAt(post.getPinnedAt());
dto.setRssExcluded(post.getRssExcluded() == null || post.getRssExcluded());
dto.setClosed(post.isClosed());
List<ReactionDto> reactions = reactionService.getReactionsForPost(post.getId()) List<ReactionDto> reactions = reactionService.getReactionsForPost(post.getId())
.stream() .stream()

View File

@@ -0,0 +1,23 @@
package com.openisle.model;
import jakarta.persistence.*;
import lombok.Data;
import java.time.LocalDate;
/**
* Invite token entity tracking usage counts.
*/
@Data
@Entity
public class InviteToken {
@Id
private String token;
@ManyToOne
private User inviter;
private LocalDate createdDate;
private int usageCount;
}

View File

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

View File

@@ -0,0 +1,35 @@
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;
@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, POST_REVIEW_REQUEST,
/** Your post under review was approved or rejected */ /** Your post under review was approved or rejected */
POST_REVIEWED, POST_REVIEWED,
/** An administrator deleted your post */
POST_DELETED,
/** A subscribed post received a new comment */ /** A subscribed post received a new comment */
POST_UPDATED, POST_UPDATED,
/** Someone subscribed to your post */ /** Someone subscribed to your post */
@@ -38,6 +40,8 @@ public enum NotificationType {
LOTTERY_WIN, LOTTERY_WIN,
/** Your lottery post was drawn */ /** Your lottery post was drawn */
LOTTERY_DRAW, LOTTERY_DRAW,
/** Your post was featured */
POST_FEATURED,
/** You were mentioned in a post or comment */ /** You were mentioned in a post or comment */
MENTION 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,12 @@
package com.openisle.model;
public enum PointHistoryType {
POST,
COMMENT,
POST_LIKED,
COMMENT_LIKED,
INVITE,
FEATURE,
SYSTEM_ONLINE,
REDEEM
}

View File

@@ -64,7 +64,12 @@ public class Post {
@Column(nullable = false) @Column(nullable = false)
private PostType type = PostType.NORMAL; private PostType type = PostType.NORMAL;
@Column(nullable = false)
private boolean closed = false;
@Column @Column
private LocalDateTime pinnedAt; private LocalDateTime pinnedAt;
@Column(nullable = true)
private Boolean rssExcluded = true;
} }

View File

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

View File

@@ -0,0 +1,12 @@
package com.openisle.repository;
import com.openisle.model.InviteToken;
import com.openisle.model.User;
import org.springframework.data.jpa.repository.JpaRepository;
import java.time.LocalDate;
import java.util.Optional;
public interface InviteTokenRepository extends JpaRepository<InviteToken, String> {
Optional<InviteToken> findByInviterAndCreatedDate(User inviter, LocalDate createdDate);
}

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

@@ -6,6 +6,8 @@ import com.openisle.model.Post;
import com.openisle.model.Comment; import com.openisle.model.Comment;
import com.openisle.model.NotificationType; import com.openisle.model.NotificationType;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import java.util.List; import java.util.List;
@@ -13,7 +15,12 @@ import java.util.List;
public interface NotificationRepository extends JpaRepository<Notification, Long> { public interface NotificationRepository extends JpaRepository<Notification, Long> {
List<Notification> findByUserOrderByCreatedAtDesc(User user); List<Notification> findByUserOrderByCreatedAtDesc(User user);
List<Notification> findByUserAndReadOrderByCreatedAtDesc(User user, boolean read); List<Notification> findByUserAndReadOrderByCreatedAtDesc(User user, boolean read);
Page<Notification> findByUserOrderByCreatedAtDesc(User user, Pageable pageable);
Page<Notification> findByUserAndReadOrderByCreatedAtDesc(User user, boolean read, Pageable pageable);
Page<Notification> findByUserAndTypeNotInOrderByCreatedAtDesc(User user, java.util.Collection<NotificationType> types, Pageable pageable);
Page<Notification> findByUserAndReadAndTypeNotInOrderByCreatedAtDesc(User user, boolean read, java.util.Collection<NotificationType> types, Pageable pageable);
long countByUserAndRead(User user, boolean read); long countByUserAndRead(User user, boolean read);
long countByUserAndReadAndTypeNotIn(User user, boolean read, java.util.Collection<NotificationType> types);
List<Notification> findByPost(Post post); List<Notification> findByPost(Post post);
List<Notification> findByComment(Comment comment); List<Notification> findByComment(Comment comment);

View File

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

View File

@@ -97,6 +97,8 @@ public interface PostRepository extends JpaRepository<Post, Long> {
long countDistinctByTags_Id(Long tagId); 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") @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); List<Object[]> countPostsByTagIds(@Param("tagIds") List<Long> tagIds);
@@ -106,4 +108,6 @@ public interface PostRepository extends JpaRepository<Post, Long> {
"WHERE p.createdAt >= :start AND p.createdAt < :end GROUP BY d ORDER BY d") "WHERE p.createdAt >= :start AND p.createdAt < :end GROUP BY d ORDER BY d")
java.util.List<Object[]> countDailyRange(@Param("start") LocalDateTime start, java.util.List<Object[]> countDailyRange(@Param("start") LocalDateTime start,
@Param("end") LocalDateTime end); @Param("end") LocalDateTime end);
List<Post> findByStatusAndRssExcludedFalseOrderByCreatedAtDesc(PostStatus status, Pageable pageable);
} }

View File

@@ -0,0 +1,12 @@
package com.openisle.service;
import com.openisle.model.User;
import lombok.Value;
/** Result for OAuth authentication indicating whether a new user was created. */
@Value
public class AuthResult {
User user;
boolean newUser;
}

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

@@ -52,6 +52,9 @@ public class CommentService {
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
Post post = postRepository.findById(postId) Post post = postRepository.findById(postId)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found")); .orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found"));
if (post.isClosed()) {
throw new IllegalStateException("Post closed");
}
Comment comment = new Comment(); Comment comment = new Comment();
comment.setAuthor(author); comment.setAuthor(author);
comment.setPost(post); comment.setPost(post);
@@ -94,6 +97,9 @@ public class CommentService {
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
Comment parent = commentRepository.findById(parentId) Comment parent = commentRepository.findById(parentId)
.orElseThrow(() -> new IllegalArgumentException("Comment not found")); .orElseThrow(() -> new IllegalArgumentException("Comment not found"));
if (parent.getPost().isClosed()) {
throw new IllegalStateException("Post closed");
}
Comment comment = new Comment(); Comment comment = new Comment();
comment.setAuthor(author); comment.setAuthor(author);
comment.setPost(parent.getPost()); comment.setPost(parent.getPost());

View File

@@ -26,7 +26,7 @@ public class DiscordAuthService {
@Value("${discord.client-secret:}") @Value("${discord.client-secret:}")
private String clientSecret; private String clientSecret;
public Optional<User> authenticate(String code, com.openisle.model.RegisterMode mode, String redirectUri) { public Optional<AuthResult> authenticate(String code, com.openisle.model.RegisterMode mode, String redirectUri, boolean viaInvite) {
try { try {
String tokenUrl = "https://discord.com/api/oauth2/token"; String tokenUrl = "https://discord.com/api/oauth2/token";
HttpHeaders headers = new HttpHeaders(); HttpHeaders headers = new HttpHeaders();
@@ -67,13 +67,13 @@ public class DiscordAuthService {
if (email == null) { if (email == null) {
email = (username != null ? username : id) + "@users.noreply.discord.com"; email = (username != null ? username : id) + "@users.noreply.discord.com";
} }
return Optional.of(processUser(email, username, avatar, mode)); return Optional.of(processUser(email, username, avatar, mode, viaInvite));
} catch (Exception e) { } catch (Exception e) {
return Optional.empty(); return Optional.empty();
} }
} }
private User processUser(String email, String username, String avatar, com.openisle.model.RegisterMode mode) { private AuthResult processUser(String email, String username, String avatar, com.openisle.model.RegisterMode mode, boolean viaInvite) {
Optional<User> existing = userRepository.findByEmail(email); Optional<User> existing = userRepository.findByEmail(email);
if (existing.isPresent()) { if (existing.isPresent()) {
User user = existing.get(); User user = existing.get();
@@ -82,7 +82,7 @@ public class DiscordAuthService {
user.setVerificationCode(null); user.setVerificationCode(null);
userRepository.save(user); userRepository.save(user);
} }
return user; return new AuthResult(user, false);
} }
String baseUsername = username != null ? username : email.split("@")[0]; String baseUsername = username != null ? username : email.split("@")[0];
String finalUsername = baseUsername; String finalUsername = baseUsername;
@@ -96,12 +96,12 @@ public class DiscordAuthService {
user.setPassword(""); user.setPassword("");
user.setRole(Role.USER); user.setRole(Role.USER);
user.setVerified(true); user.setVerified(true);
user.setApproved(mode == com.openisle.model.RegisterMode.DIRECT); user.setApproved(mode == com.openisle.model.RegisterMode.DIRECT || viaInvite);
if (avatar != null) { if (avatar != null) {
user.setAvatar(avatar); user.setAvatar(avatar);
} else { } else {
user.setAvatar("https://cdn.discordapp.com/embed/avatars/0.png"); user.setAvatar("https://cdn.discordapp.com/embed/avatars/0.png");
} }
return userRepository.save(user); return new AuthResult(userRepository.save(user), true);
} }
} }

View File

@@ -30,7 +30,7 @@ public class GithubAuthService {
@Value("${github.client-secret:}") @Value("${github.client-secret:}")
private String clientSecret; private String clientSecret;
public Optional<User> authenticate(String code, com.openisle.model.RegisterMode mode, String redirectUri) { public Optional<AuthResult> authenticate(String code, com.openisle.model.RegisterMode mode, String redirectUri, boolean viaInvite) {
try { try {
String tokenUrl = "https://github.com/login/oauth/access_token"; String tokenUrl = "https://github.com/login/oauth/access_token";
HttpHeaders headers = new HttpHeaders(); HttpHeaders headers = new HttpHeaders();
@@ -86,13 +86,13 @@ public class GithubAuthService {
if (email == null) { if (email == null) {
email = username + "@users.noreply.github.com"; email = username + "@users.noreply.github.com";
} }
return Optional.of(processUser(email, username, avatarUrl, mode)); return Optional.of(processUser(email, username, avatarUrl, mode, viaInvite));
} catch (Exception e) { } catch (Exception e) {
return Optional.empty(); return Optional.empty();
} }
} }
private User processUser(String email, String username, String avatar, com.openisle.model.RegisterMode mode) { private AuthResult processUser(String email, String username, String avatar, com.openisle.model.RegisterMode mode, boolean viaInvite) {
Optional<User> existing = userRepository.findByEmail(email); Optional<User> existing = userRepository.findByEmail(email);
if (existing.isPresent()) { if (existing.isPresent()) {
User user = existing.get(); User user = existing.get();
@@ -101,7 +101,7 @@ public class GithubAuthService {
user.setVerificationCode(null); user.setVerificationCode(null);
userRepository.save(user); userRepository.save(user);
} }
return user; return new AuthResult(user, false);
} }
String baseUsername = username != null ? username : email.split("@")[0]; String baseUsername = username != null ? username : email.split("@")[0];
String finalUsername = baseUsername; String finalUsername = baseUsername;
@@ -115,12 +115,12 @@ public class GithubAuthService {
user.setPassword(""); user.setPassword("");
user.setRole(Role.USER); user.setRole(Role.USER);
user.setVerified(true); user.setVerified(true);
user.setApproved(mode == com.openisle.model.RegisterMode.DIRECT); user.setApproved(mode == com.openisle.model.RegisterMode.DIRECT || viaInvite);
if (avatar != null) { if (avatar != null) {
user.setAvatar(avatar); user.setAvatar(avatar);
} else { } else {
user.setAvatar(avatarGenerator.generate(finalUsername)); user.setAvatar(avatarGenerator.generate(finalUsername));
} }
return userRepository.save(user); return new AuthResult(userRepository.save(user), true);
} }
} }

View File

@@ -25,7 +25,7 @@ public class GoogleAuthService {
@Value("${google.client-id:}") @Value("${google.client-id:}")
private String clientId; private String clientId;
public Optional<User> authenticate(String idTokenString, com.openisle.model.RegisterMode mode) { public Optional<AuthResult> authenticate(String idTokenString, com.openisle.model.RegisterMode mode, boolean viaInvite) {
GoogleIdTokenVerifier verifier = new GoogleIdTokenVerifier.Builder(new NetHttpTransport(), new JacksonFactory()) GoogleIdTokenVerifier verifier = new GoogleIdTokenVerifier.Builder(new NetHttpTransport(), new JacksonFactory())
.setAudience(Collections.singletonList(clientId)) .setAudience(Collections.singletonList(clientId))
.build(); .build();
@@ -38,13 +38,13 @@ public class GoogleAuthService {
String email = payload.getEmail(); String email = payload.getEmail();
String name = (String) payload.get("name"); String name = (String) payload.get("name");
String picture = (String) payload.get("picture"); String picture = (String) payload.get("picture");
return Optional.of(processUser(email, name, picture, mode)); return Optional.of(processUser(email, name, picture, mode, viaInvite));
} catch (Exception e) { } catch (Exception e) {
return Optional.empty(); return Optional.empty();
} }
} }
private User processUser(String email, String name, String avatar, com.openisle.model.RegisterMode mode) { private AuthResult processUser(String email, String name, String avatar, com.openisle.model.RegisterMode mode, boolean viaInvite) {
Optional<User> existing = userRepository.findByEmail(email); Optional<User> existing = userRepository.findByEmail(email);
if (existing.isPresent()) { if (existing.isPresent()) {
User user = existing.get(); User user = existing.get();
@@ -53,8 +53,7 @@ public class GoogleAuthService {
user.setVerificationCode(null); user.setVerificationCode(null);
userRepository.save(user); userRepository.save(user);
} }
return new AuthResult(user, false);
return user;
} }
User user = new User(); User user = new User();
String baseUsername = email.split("@")[0]; String baseUsername = email.split("@")[0];
@@ -68,12 +67,12 @@ public class GoogleAuthService {
user.setPassword(""); user.setPassword("");
user.setRole(Role.USER); user.setRole(Role.USER);
user.setVerified(true); user.setVerified(true);
user.setApproved(mode == com.openisle.model.RegisterMode.DIRECT); user.setApproved(mode == com.openisle.model.RegisterMode.DIRECT || viaInvite);
if (avatar != null) { if (avatar != null) {
user.setAvatar(avatar); user.setAvatar(avatar);
} else { } else {
user.setAvatar(avatarGenerator.generate(username)); user.setAvatar(avatarGenerator.generate(username));
} }
return userRepository.save(user); return new AuthResult(userRepository.save(user), true);
} }
} }

View File

@@ -0,0 +1,64 @@
package com.openisle.service;
import com.openisle.model.InviteToken;
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;
import java.util.Optional;
@Service
@RequiredArgsConstructor
public class InviteService {
private final InviteTokenRepository inviteTokenRepository;
private final UserRepository userRepository;
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();
Optional<InviteToken> existing = inviteTokenRepository.findByInviterAndCreatedDate(inviter, today);
if (existing.isPresent()) {
return existing.get().getToken();
}
String token = jwtService.generateInviteToken(username);
InviteToken inviteToken = new InviteToken();
inviteToken.setToken(token);
inviteToken.setInviter(inviter);
inviteToken.setCreatedDate(today);
inviteToken.setUsageCount(0);
inviteTokenRepository.save(inviteToken);
return token;
}
public InviteValidateResult validate(String token) {
if (token == null || token.isEmpty()) {
return new InviteValidateResult(null, false);
}
try {
jwtService.validateAndGetSubjectForInvite(token);
} catch (Exception e) {
return new InviteValidateResult(null, false);
}
InviteToken invite = inviteTokenRepository.findById(token).orElse(null);
return new InviteValidateResult(invite, invite != null && invite.getUsageCount() < 3);
}
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(), newUserName);
}
}

View File

@@ -24,6 +24,9 @@ public class JwtService {
@Value("${app.jwt.reset-secret}") @Value("${app.jwt.reset-secret}")
private String resetSecret; private String resetSecret;
@Value("${app.jwt.invite-secret}")
private String inviteSecret;
@Value("${app.jwt.expiration}") @Value("${app.jwt.expiration}")
private long expiration; private long expiration;
@@ -70,6 +73,17 @@ public class JwtService {
.compact(); .compact();
} }
public String generateInviteToken(String subject) {
Date now = new Date();
Date expiryDate = new Date(now.getTime() + expiration);
return Jwts.builder()
.setSubject(subject)
.setIssuedAt(now)
.setExpiration(expiryDate)
.signWith(getSigningKeyForSecret(inviteSecret))
.compact();
}
public String validateAndGetSubject(String token) { public String validateAndGetSubject(String token) {
Claims claims = Jwts.parserBuilder() Claims claims = Jwts.parserBuilder()
.setSigningKey(getSigningKeyForSecret(secret)) .setSigningKey(getSigningKeyForSecret(secret))
@@ -96,4 +110,13 @@ public class JwtService {
.getBody(); .getBody();
return claims.getSubject(); return claims.getSubject();
} }
public String validateAndGetSubjectForInvite(String token) {
Claims claims = Jwts.parserBuilder()
.setSigningKey(getSigningKeyForSecret(inviteSecret))
.build()
.parseClaimsJws(token)
.getBody();
return claims.getSubject();
}
} }

View File

@@ -6,6 +6,7 @@ import com.openisle.dto.MedalDto;
import com.openisle.dto.PostMedalDto; import com.openisle.dto.PostMedalDto;
import com.openisle.dto.SeedUserMedalDto; import com.openisle.dto.SeedUserMedalDto;
import com.openisle.dto.PioneerMedalDto; import com.openisle.dto.PioneerMedalDto;
import com.openisle.dto.FeaturedMedalDto;
import com.openisle.model.MedalType; import com.openisle.model.MedalType;
import com.openisle.model.User; import com.openisle.model.User;
import com.openisle.repository.CommentRepository; import com.openisle.repository.CommentRepository;
@@ -74,6 +75,23 @@ public class MedalService {
postMedal.setSelected(selected == MedalType.POST); postMedal.setSelected(selected == MedalType.POST);
medals.add(postMedal); 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(); ContributorMedalDto contributorMedal = new ContributorMedalDto();
contributorMedal.setIcon("https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/icons/achi_coder.png"); contributorMedal.setIcon("https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/icons/achi_coder.png");
contributorMedal.setTitle("贡献者"); contributorMedal.setTitle("贡献者");
@@ -141,6 +159,8 @@ public class MedalService {
user.setDisplayMedal(MedalType.COMMENT); user.setDisplayMedal(MedalType.COMMENT);
} else if (postRepository.countByAuthor_Id(user.getId()) >= POST_TARGET) { } else if (postRepository.countByAuthor_Id(user.getId()) >= POST_TARGET) {
user.setDisplayMedal(MedalType.POST); user.setDisplayMedal(MedalType.POST);
} else if (postRepository.countByAuthor_IdAndRssExcludedFalse(user.getId()) >= 1) {
user.setDisplayMedal(MedalType.FEATURED);
} else if (contributorService.getContributionLines(user.getUsername()) >= CONTRIBUTION_TARGET) { } else if (contributorService.getContributionLines(user.getUsername()) >= CONTRIBUTION_TARGET) {
user.setDisplayMedal(MedalType.CONTRIBUTOR); user.setDisplayMedal(MedalType.CONTRIBUTOR);
} else if (userRepository.countByCreatedAtBefore(user.getCreatedAt()) < PIONEER_LIMIT) { } else if (userRepository.countByCreatedAtBefore(user.getCreatedAt()) < PIONEER_LIMIT) {

View File

@@ -0,0 +1,289 @@
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.repository.MessageConversationRepository;
import com.openisle.repository.MessageParticipantRepository;
import com.openisle.repository.MessageRepository;
import com.openisle.repository.UserRepository;
import com.openisle.dto.ConversationDetailDto;
import com.openisle.dto.ConversationDto;
import com.openisle.dto.MessageDto;
import com.openisle.dto.UserSummaryDto;
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;
@Transactional
public Message sendMessage(Long senderId, Long recipientId, String content) {
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);
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) {
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);
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;
}
private 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);
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

@@ -23,7 +23,6 @@ import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.concurrent.Executor; import java.util.concurrent.Executor;
import java.util.stream.Collectors;
/** Service for creating and retrieving notifications. */ /** Service for creating and retrieving notifications. */
@Service @Service
@@ -180,17 +179,26 @@ public class NotificationService {
userRepository.save(user); userRepository.save(user);
} }
public List<Notification> listNotifications(String username, Boolean read) { public List<Notification> listNotifications(String username, Boolean read, int page, int size) {
User user = userRepository.findByUsername(username) User user = userRepository.findByUsername(username)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
Set<NotificationType> disabled = user.getDisabledNotificationTypes(); Set<NotificationType> disabled = user.getDisabledNotificationTypes();
List<Notification> list; org.springframework.data.domain.Pageable pageable = org.springframework.data.domain.PageRequest.of(page, size);
org.springframework.data.domain.Page<Notification> result;
if (read == null) { if (read == null) {
list = notificationRepository.findByUserOrderByCreatedAtDesc(user); if (disabled.isEmpty()) {
result = notificationRepository.findByUserOrderByCreatedAtDesc(user, pageable);
} else {
result = notificationRepository.findByUserAndTypeNotInOrderByCreatedAtDesc(user, disabled, pageable);
}
} else { } else {
list = notificationRepository.findByUserAndReadOrderByCreatedAtDesc(user, read); if (disabled.isEmpty()) {
result = notificationRepository.findByUserAndReadOrderByCreatedAtDesc(user, read, pageable);
} else {
result = notificationRepository.findByUserAndReadAndTypeNotInOrderByCreatedAtDesc(user, read, disabled, pageable);
}
} }
return list.stream().filter(n -> !disabled.contains(n.getType())).collect(Collectors.toList()); return result.getContent();
} }
public void markRead(String username, List<Long> ids) { public void markRead(String username, List<Long> ids) {
@@ -209,8 +217,10 @@ public class NotificationService {
User user = userRepository.findByUsername(username) User user = userRepository.findByUsername(username)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
Set<NotificationType> disabled = user.getDisabledNotificationTypes(); Set<NotificationType> disabled = user.getDisabledNotificationTypes();
return notificationRepository.findByUserAndReadOrderByCreatedAtDesc(user, false).stream() if (disabled.isEmpty()) {
.filter(n -> !disabled.contains(n.getType())).count(); return notificationRepository.countByUserAndRead(user, false);
}
return notificationRepository.countByUserAndReadAndTypeNotIn(user, false, disabled);
} }
public void notifyMentions(String content, User fromUser, Post post, Comment comment) { public void notifyMentions(String content, User fromUser, Post post, Comment comment) {

View File

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

View File

@@ -1,7 +1,6 @@
package com.openisle.service; package com.openisle.service;
import com.openisle.model.PointLog; import com.openisle.model.*;
import com.openisle.model.User;
import com.openisle.repository.*; import com.openisle.repository.*;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -16,14 +15,28 @@ public class PointService {
private final PointLogRepository pointLogRepository; private final PointLogRepository pointLogRepository;
private final PostRepository postRepository; private final PostRepository postRepository;
private final CommentRepository commentRepository; 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(); User user = userRepository.findByUsername(userName).orElseThrow();
PointLog log = getTodayLog(user); PointLog log = getTodayLog(user);
if (log.getPostCount() > 1) return 0; if (log.getPostCount() > 1) return 0;
log.setPostCount(log.getPostCount() + 1); log.setPostCount(log.getPostCount() + 1);
pointLogRepository.save(log); 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, String inviteeName) {
User user = userRepository.findByUsername(userName).orElseThrow();
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);
} }
private PointLog getTodayLog(User user) { private PointLog getTodayLog(User user) {
@@ -40,20 +53,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); user.setPoint(user.getPoint() + amount);
userRepository.save(user); userRepository.save(user);
recordHistory(user, type, amount, post, comment, fromUser);
return amount; 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; boolean isTheRewardCapped = false;
// 根据帖子id找到发帖人 // 根据帖子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(); User commenter = userRepository.findByUsername(commenterName).orElseThrow();
@@ -69,15 +103,15 @@ public class PointService {
} else { } else {
log.setCommentCount(log.getCommentCount() + 1); log.setCommentCount(log.getCommentCount() + 1);
pointLogRepository.save(log); pointLogRepository.save(log);
return addPoint(commenter, 10); return addPoint(commenter, 10, PointHistoryType.COMMENT, post, comment, null);
} }
} else { } else {
addPoint(poster, 10); addPoint(poster, 10, PointHistoryType.COMMENT, post, comment, commenter);
// 如果发帖人与评论者不是同一个,则根据是否达到积分上限来判断评论者加分情况 // 如果发帖人与评论者不是同一个,则根据是否达到积分上限来判断评论者加分情况
if (isTheRewardCapped) { if (isTheRewardCapped) {
return 0; return 0;
} else { } else {
return addPoint(commenter, 10); return addPoint(commenter, 10, PointHistoryType.COMMENT, post, comment, null);
} }
} }
} }
@@ -96,7 +130,8 @@ public class PointService {
} }
// 如果不是同一个,则为发帖人加分 // 如果不是同一个,则为发帖人加分
return addPoint(poster, 10); Post post = postRepository.findById(postId).orElseThrow();
return addPoint(poster, 10, PointHistoryType.POST_LIKED, post, null, reactioner);
} }
// 考虑点赞者和评论者是同一个的情况 // 考虑点赞者和评论者是同一个的情况
@@ -113,7 +148,17 @@ 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 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);
} }
} }

View File

@@ -67,6 +67,7 @@ public class PostService {
private final TaskScheduler taskScheduler; private final TaskScheduler taskScheduler;
private final EmailSender emailSender; private final EmailSender emailSender;
private final ApplicationContext applicationContext; private final ApplicationContext applicationContext;
private final PointService pointService;
private final ConcurrentMap<Long, ScheduledFuture<?>> scheduledFinalizations = new ConcurrentHashMap<>(); private final ConcurrentMap<Long, ScheduledFuture<?>> scheduledFinalizations = new ConcurrentHashMap<>();
@Value("${app.website-url:https://www.open-isle.com}") @Value("${app.website-url:https://www.open-isle.com}")
private String websiteUrl; private String websiteUrl;
@@ -89,6 +90,7 @@ public class PostService {
TaskScheduler taskScheduler, TaskScheduler taskScheduler,
EmailSender emailSender, EmailSender emailSender,
ApplicationContext applicationContext, ApplicationContext applicationContext,
PointService pointService,
@Value("${app.post.publish-mode:DIRECT}") PublishMode publishMode) { @Value("${app.post.publish-mode:DIRECT}") PublishMode publishMode) {
this.postRepository = postRepository; this.postRepository = postRepository;
this.userRepository = userRepository; this.userRepository = userRepository;
@@ -107,6 +109,7 @@ public class PostService {
this.taskScheduler = taskScheduler; this.taskScheduler = taskScheduler;
this.emailSender = emailSender; this.emailSender = emailSender;
this.applicationContext = applicationContext; this.applicationContext = applicationContext;
this.pointService = pointService;
this.publishMode = publishMode; this.publishMode = publishMode;
} }
@@ -132,6 +135,26 @@ public class PostService {
this.publishMode = publishMode; this.publishMode = publishMode;
} }
public List<Post> listLatestRssPosts(int limit) {
Pageable pageable = PageRequest.of(0, limit, Sort.by(Sort.Direction.DESC, "createdAt"));
return postRepository.findByStatusAndRssExcludedFalseOrderByCreatedAtDesc(PostStatus.PUBLISHED, pageable);
}
public Post excludeFromRss(Long id) {
Post post = postRepository.findById(id).orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found"));
post.setRssExcluded(true);
return postRepository.save(post);
}
public Post includeInRss(Long id) {
Post post = postRepository.findById(id).orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found"));
post.setRssExcluded(false);
post = postRepository.save(post);
notificationService.createNotification(post.getAuthor(), NotificationType.POST_FEATURED, post, null, null, null, null, null);
pointService.awardForFeatured(post.getAuthor().getUsername(), post.getId());
return post;
}
public Post createPost(String username, public Post createPost(String username,
Long categoryId, Long categoryId,
String title, String title,
@@ -441,6 +464,34 @@ public class PostService {
return paginate(sortByPinnedAndCreated(posts), page, pageSize); 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() { public List<Post> listPendingPosts() {
return postRepository.findByStatus(PostStatus.PENDING); return postRepository.findByStatus(PostStatus.PENDING);
} }
@@ -495,6 +546,30 @@ public class PostService {
return postRepository.save(post); return postRepository.save(post);
} }
public Post closePost(Long id, String username) {
Post post = postRepository.findById(id)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found"));
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
if (!user.getId().equals(post.getAuthor().getId()) && user.getRole() != Role.ADMIN) {
throw new IllegalArgumentException("Unauthorized");
}
post.setClosed(true);
return postRepository.save(post);
}
public Post reopenPost(Long id, String username) {
Post post = postRepository.findById(id)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found"));
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
if (!user.getId().equals(post.getAuthor().getId()) && user.getRole() != Role.ADMIN) {
throw new IllegalArgumentException("Unauthorized");
}
post.setClosed(false);
return postRepository.save(post);
}
@org.springframework.transaction.annotation.Transactional @org.springframework.transaction.annotation.Transactional
public Post updatePost(Long id, public Post updatePost(Long id,
String username, String username,
@@ -538,7 +613,9 @@ public class PostService {
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found")); .orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found"));
User user = userRepository.findByUsername(username) User user = userRepository.findByUsername(username)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); .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"); throw new IllegalArgumentException("Unauthorized");
} }
for (Comment c : commentRepository.findByPostAndParentIsNullOrderByCreatedAtAsc(post)) { for (Comment c : commentRepository.findByPostAndParentIsNullOrderByCreatedAtAsc(post)) {
@@ -555,7 +632,12 @@ public class PostService {
future.cancel(false); future.cancel(false);
} }
} }
String title = post.getTitle();
postRepository.delete(post); 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) { public java.util.List<Post> getPostsByIds(java.util.List<Long> ids) {

View File

@@ -33,11 +33,12 @@ public class TwitterAuthService {
@Value("${twitter.client-secret:}") @Value("${twitter.client-secret:}")
private String clientSecret; private String clientSecret;
public Optional<User> authenticate( public Optional<AuthResult> authenticate(
String code, String code,
String codeVerifier, String codeVerifier,
RegisterMode mode, RegisterMode mode,
String redirectUri) { String redirectUri,
boolean viaInvite) {
logger.debug("Starting authentication with code {} and verifier {}", code, codeVerifier); logger.debug("Starting authentication with code {} and verifier {}", code, codeVerifier);
@@ -106,10 +107,10 @@ public class TwitterAuthService {
// Twitter v2 默认拿不到 email如果你申请到 email.scope可改用 /2/users/:id?user.fields=email // Twitter v2 默认拿不到 email如果你申请到 email.scope可改用 /2/users/:id?user.fields=email
String email = username + "@twitter.com"; String email = username + "@twitter.com";
logger.debug("Processing user {} with email {}", username, email); logger.debug("Processing user {} with email {}", username, email);
return Optional.of(processUser(email, username, avatar, mode)); return Optional.of(processUser(email, username, avatar, mode, viaInvite));
} }
private User processUser(String email, String username, String avatar, com.openisle.model.RegisterMode mode) { private AuthResult processUser(String email, String username, String avatar, com.openisle.model.RegisterMode mode, boolean viaInvite) {
Optional<User> existing = userRepository.findByEmail(email); Optional<User> existing = userRepository.findByEmail(email);
if (existing.isPresent()) { if (existing.isPresent()) {
User user = existing.get(); User user = existing.get();
@@ -119,7 +120,7 @@ public class TwitterAuthService {
userRepository.save(user); userRepository.save(user);
} }
logger.debug("Existing user {} authenticated", user.getUsername()); logger.debug("Existing user {} authenticated", user.getUsername());
return user; return new AuthResult(user, false);
} }
String baseUsername = username != null ? username : email.split("@")[0]; String baseUsername = username != null ? username : email.split("@")[0];
String finalUsername = baseUsername; String finalUsername = baseUsername;
@@ -133,13 +134,13 @@ public class TwitterAuthService {
user.setPassword(""); user.setPassword("");
user.setRole(Role.USER); user.setRole(Role.USER);
user.setVerified(true); user.setVerified(true);
user.setApproved(mode == com.openisle.model.RegisterMode.DIRECT); user.setApproved(mode == com.openisle.model.RegisterMode.DIRECT || viaInvite);
if (avatar != null) { if (avatar != null) {
user.setAvatar(avatar); user.setAvatar(avatar);
} else { } else {
user.setAvatar("https://twitter.com/" + finalUsername + "/profile_image"); user.setAvatar("https://twitter.com/" + finalUsername + "/profile_image");
} }
logger.debug("Creating new user {}", finalUsername); logger.debug("Creating new user {}", finalUsername);
return userRepository.save(user); return new AuthResult(userRepository.save(user), true);
} }
} }

View File

@@ -74,6 +74,13 @@ public class UserService {
return userRepository.save(user); return userRepository.save(user);
} }
public User registerWithInvite(String username, String email, String password) {
User user = register(username, email, password, "", com.openisle.model.RegisterMode.DIRECT);
user.setVerified(true);
user.setVerificationCode(genCode());
return userRepository.save(user);
}
private String genCode() { private String genCode() {
return String.format("%06d", new Random().nextInt(1000000)); return String.format("%06d", new Random().nextInt(1000000));
} }

View File

@@ -10,6 +10,7 @@ spring.jpa.hibernate.ddl-auto=update
app.jwt.secret=${JWT_SECRET:jwt_sec} app.jwt.secret=${JWT_SECRET:jwt_sec}
app.jwt.reason-secret=${JWT_REASON_SECRET:jwt_reason_sec} app.jwt.reason-secret=${JWT_REASON_SECRET:jwt_reason_sec}
app.jwt.reset-secret=${JWT_RESET_SECRET:jwt_reset_sec} app.jwt.reset-secret=${JWT_RESET_SECRET:jwt_reset_sec}
app.jwt.invite-secret=${JWT_INVITE_SECRET:jwt_invite_sec}
# 30 days # 30 days
app.jwt.expiration=${JWT_EXPIRATION:2592000000} app.jwt.expiration=${JWT_EXPIRATION:2592000000}
# Password strength: LOW, MEDIUM or HIGH # Password strength: LOW, MEDIUM or HIGH

View File

@@ -0,0 +1 @@
ALTER TABLE posts ADD COLUMN rss_excluded BOOLEAN NOT NULL DEFAULT 0;

View File

@@ -45,7 +45,7 @@ class NotificationControllerTest {
p.setId(2L); p.setId(2L);
n.setPost(p); n.setPost(p);
n.setCreatedAt(LocalDateTime.now()); n.setCreatedAt(LocalDateTime.now());
when(notificationService.listNotifications("alice", null)) when(notificationService.listNotifications("alice", null, 0, 30))
.thenReturn(List.of(n)); .thenReturn(List.of(n));
NotificationDto dto = new NotificationDto(); NotificationDto dto = new NotificationDto();
@@ -62,6 +62,24 @@ class NotificationControllerTest {
.andExpect(jsonPath("$[0].post.id").value(2)); .andExpect(jsonPath("$[0].post.id").value(2));
} }
@Test
void listUnreadNotifications() throws Exception {
Notification n = new Notification();
n.setId(5L);
n.setType(NotificationType.POST_VIEWED);
when(notificationService.listNotifications("alice", false, 0, 30))
.thenReturn(List.of(n));
NotificationDto dto = new NotificationDto();
dto.setId(5L);
when(notificationMapper.toDto(n)).thenReturn(dto);
mockMvc.perform(get("/api/notifications/unread")
.principal(new UsernamePasswordAuthenticationToken("alice","p")))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].id").value(5));
}
@Test @Test
void markReadEndpoint() throws Exception { void markReadEndpoint() throws Exception {
mockMvc.perform(post("/api/notifications/read") mockMvc.perform(post("/api/notifications/read")

View File

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

View File

@@ -11,6 +11,9 @@ import org.mockito.Mockito;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.HashSet;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.Pageable;
import static org.junit.jupiter.api.Assertions.*; import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*; import static org.mockito.Mockito.*;
@@ -62,15 +65,17 @@ class NotificationServiceTest {
User user = new User(); User user = new User();
user.setId(2L); user.setId(2L);
user.setUsername("bob"); user.setUsername("bob");
user.setDisabledNotificationTypes(new HashSet<>());
when(uRepo.findByUsername("bob")).thenReturn(Optional.of(user)); when(uRepo.findByUsername("bob")).thenReturn(Optional.of(user));
Notification n = new Notification(); Notification n = new Notification();
when(nRepo.findByUserOrderByCreatedAtDesc(user)).thenReturn(List.of(n)); when(nRepo.findByUserOrderByCreatedAtDesc(eq(user), any(Pageable.class)))
.thenReturn(new PageImpl<>(List.of(n)));
List<Notification> list = service.listNotifications("bob", null); List<Notification> list = service.listNotifications("bob", null, 0, 10);
assertEquals(1, list.size()); assertEquals(1, list.size());
verify(nRepo).findByUserOrderByCreatedAtDesc(user); verify(nRepo).findByUserOrderByCreatedAtDesc(eq(user), any(Pageable.class));
} }
@Test @Test
@@ -87,6 +92,7 @@ class NotificationServiceTest {
User user = new User(); User user = new User();
user.setId(3L); user.setId(3L);
user.setUsername("carl"); user.setUsername("carl");
user.setDisabledNotificationTypes(new HashSet<>());
when(uRepo.findByUsername("carl")).thenReturn(Optional.of(user)); when(uRepo.findByUsername("carl")).thenReturn(Optional.of(user));
when(nRepo.countByUserAndRead(user, false)).thenReturn(5L); when(nRepo.countByUserAndRead(user, false)).thenReturn(5L);
@@ -96,6 +102,56 @@ class NotificationServiceTest {
verify(nRepo).countByUserAndRead(user, false); verify(nRepo).countByUserAndRead(user, false);
} }
@Test
void listNotificationsFiltersDisabledTypes() {
NotificationRepository nRepo = mock(NotificationRepository.class);
UserRepository uRepo = mock(UserRepository.class);
ReactionRepository rRepo = mock(ReactionRepository.class);
EmailSender email = mock(EmailSender.class);
PushNotificationService push = mock(PushNotificationService.class);
Executor executor = Runnable::run;
NotificationService service = new NotificationService(nRepo, uRepo, email, push, rRepo, executor);
org.springframework.test.util.ReflectionTestUtils.setField(service, "websiteUrl", "https://ex.com");
User user = new User();
user.setId(4L);
user.setUsername("dana");
when(uRepo.findByUsername("dana")).thenReturn(Optional.of(user));
Notification n = new Notification();
when(nRepo.findByUserAndTypeNotInOrderByCreatedAtDesc(eq(user), eq(user.getDisabledNotificationTypes()), any(Pageable.class)))
.thenReturn(new PageImpl<>(List.of(n)));
List<Notification> list = service.listNotifications("dana", null, 0, 10);
assertEquals(1, list.size());
verify(nRepo).findByUserAndTypeNotInOrderByCreatedAtDesc(eq(user), eq(user.getDisabledNotificationTypes()), any(Pageable.class));
}
@Test
void countUnreadFiltersDisabledTypes() {
NotificationRepository nRepo = mock(NotificationRepository.class);
UserRepository uRepo = mock(UserRepository.class);
ReactionRepository rRepo = mock(ReactionRepository.class);
EmailSender email = mock(EmailSender.class);
PushNotificationService push = mock(PushNotificationService.class);
Executor executor = Runnable::run;
NotificationService service = new NotificationService(nRepo, uRepo, email, push, rRepo, executor);
org.springframework.test.util.ReflectionTestUtils.setField(service, "websiteUrl", "https://ex.com");
User user = new User();
user.setId(5L);
user.setUsername("erin");
when(uRepo.findByUsername("erin")).thenReturn(Optional.of(user));
when(nRepo.countByUserAndReadAndTypeNotIn(eq(user), eq(false), eq(user.getDisabledNotificationTypes())))
.thenReturn(2L);
long count = service.countUnread("erin");
assertEquals(2L, count);
verify(nRepo).countByUserAndReadAndTypeNotIn(eq(user), eq(false), eq(user.getDisabledNotificationTypes()));
}
@Test @Test
void createRegisterRequestNotificationsDeletesOldOnes() { void createRegisterRequestNotificationsDeletesOldOnes() {
NotificationRepository nRepo = mock(NotificationRepository.class); NotificationRepository nRepo = mock(NotificationRepository.class);

View File

@@ -34,11 +34,12 @@ class PostServiceTest {
TaskScheduler taskScheduler = mock(TaskScheduler.class); TaskScheduler taskScheduler = mock(TaskScheduler.class);
EmailSender emailSender = mock(EmailSender.class); EmailSender emailSender = mock(EmailSender.class);
ApplicationContext context = mock(ApplicationContext.class); ApplicationContext context = mock(ApplicationContext.class);
PointService pointService = mock(PointService.class);
PostService service = new PostService(postRepo, userRepo, catRepo, tagRepo, lotteryRepo, PostService service = new PostService(postRepo, userRepo, catRepo, tagRepo, lotteryRepo,
notifService, subService, commentService, commentRepo, notifService, subService, commentService, commentRepo,
reactionRepo, subRepo, notificationRepo, postReadService, 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(context.getBean(PostService.class)).thenReturn(service);
Post post = new Post(); Post post = new Post();
@@ -61,6 +62,59 @@ class PostServiceTest {
verify(postRepo).delete(post); 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 @Test
void createPostRespectsRateLimit() { void createPostRespectsRateLimit() {
PostRepository postRepo = mock(PostRepository.class); PostRepository postRepo = mock(PostRepository.class);
@@ -80,11 +134,12 @@ class PostServiceTest {
TaskScheduler taskScheduler = mock(TaskScheduler.class); TaskScheduler taskScheduler = mock(TaskScheduler.class);
EmailSender emailSender = mock(EmailSender.class); EmailSender emailSender = mock(EmailSender.class);
ApplicationContext context = mock(ApplicationContext.class); ApplicationContext context = mock(ApplicationContext.class);
PointService pointService = mock(PointService.class);
PostService service = new PostService(postRepo, userRepo, catRepo, tagRepo, lotteryRepo, PostService service = new PostService(postRepo, userRepo, catRepo, tagRepo, lotteryRepo,
notifService, subService, commentService, commentRepo, notifService, subService, commentService, commentRepo,
reactionRepo, subRepo, notificationRepo, postReadService, 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(context.getBean(PostService.class)).thenReturn(service);
when(postRepo.countByAuthorAfter(eq("alice"), any())).thenReturn(1L); when(postRepo.countByAuthorAfter(eq("alice"), any())).thenReturn(1L);
@@ -113,11 +168,12 @@ class PostServiceTest {
TaskScheduler taskScheduler = mock(TaskScheduler.class); TaskScheduler taskScheduler = mock(TaskScheduler.class);
EmailSender emailSender = mock(EmailSender.class); EmailSender emailSender = mock(EmailSender.class);
ApplicationContext context = mock(ApplicationContext.class); ApplicationContext context = mock(ApplicationContext.class);
PointService pointService = mock(PointService.class);
PostService service = new PostService(postRepo, userRepo, catRepo, tagRepo, lotteryRepo, PostService service = new PostService(postRepo, userRepo, catRepo, tagRepo, lotteryRepo,
notifService, subService, commentService, commentRepo, notifService, subService, commentService, commentRepo,
reactionRepo, subRepo, notificationRepo, postReadService, 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(context.getBean(PostService.class)).thenReturn(service);
User author = new User(); User author = new User();

View File

@@ -21,6 +21,8 @@
</div> </div>
</div> </div>
<GlobalPopups /> <GlobalPopups />
<ConfirmDialog />
<ChatFloating />
</div> </div>
</template> </template>
@@ -28,6 +30,8 @@
import HeaderComponent from '~/components/HeaderComponent.vue' import HeaderComponent from '~/components/HeaderComponent.vue'
import MenuComponent from '~/components/MenuComponent.vue' import MenuComponent from '~/components/MenuComponent.vue'
import GlobalPopups from '~/components/GlobalPopups.vue' import GlobalPopups from '~/components/GlobalPopups.vue'
import ConfirmDialog from '~/components/ConfirmDialog.vue'
import ChatFloating from '~/components/ChatFloating.vue'
import { useIsMobile } from '~/utils/screen' import { useIsMobile } from '~/utils/screen'
const isMobile = useIsMobile() const isMobile = useIsMobile()

View File

@@ -18,7 +18,7 @@
--background-color-blur: rgba(255, 255, 255, 0.57); --background-color-blur: rgba(255, 255, 255, 0.57);
--menu-border-color: lightgray; --menu-border-color: lightgray;
--normal-border-color: lightgray; --normal-border-color: lightgray;
--menu-selected-background-color: rgba(208, 250, 255, 0.659); --menu-selected-background-color: rgba(228, 228, 228, 0.884);
--menu-text-color: black; --menu-text-color: black;
--scroller-background-color: rgba(130, 175, 180, 0.5); --scroller-background-color: rgba(130, 175, 180, 0.5);
/* --normal-background-color: rgb(241, 241, 241); */ /* --normal-background-color: rgb(241, 241, 241); */
@@ -90,7 +90,8 @@ body {
} }
.vditor-toolbar--pin { .vditor-toolbar--pin {
top: var(--header-height) !important; top: calc(var(--header-height) + 1px) !important;
z-index: 2000;
} }
.vditor-panel { .vditor-panel {
@@ -183,7 +184,7 @@ body {
font-family: 'Maple Mono', monospace; font-family: 'Maple Mono', monospace;
font-size: 13px; font-size: 13px;
border-radius: 4px; border-radius: 4px;
white-space: no-wrap; white-space: break-spaces;
background-color: var(--code-highlight-background-color); background-color: var(--code-highlight-background-color);
color: var(--text-color); color: var(--text-color);
} }

View File

@@ -26,6 +26,9 @@
<template v-else-if="medal.type === 'POST'"> <template v-else-if="medal.type === 'POST'">
{{ medal.currentPostCount }}/{{ medal.targetPostCount }} {{ medal.currentPostCount }}/{{ medal.targetPostCount }}
</template> </template>
<template v-else-if="medal.type === 'FEATURED'">
{{ medal.currentFeaturedCount }}/{{ medal.targetFeaturedCount }}
</template>
<template v-else-if="medal.type === 'CONTRIBUTOR'"> <template v-else-if="medal.type === 'CONTRIBUTOR'">
{{ medal.currentContributionLines }}/{{ medal.targetContributionLines }} {{ medal.currentContributionLines }}/{{ medal.targetContributionLines }}
</template> </template>

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

@@ -1,5 +1,5 @@
<template> <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-item" v-for="(item, idx) in items" :key="idx">
<div <div
class="timeline-icon" class="timeline-icon"
@@ -8,7 +8,7 @@
> >
<img v-if="item.src" :src="item.src" class="timeline-img" alt="timeline item" /> <img v-if="item.src" :src="item.src" class="timeline-img" alt="timeline item" />
<i v-else-if="item.icon" :class="item.icon"></i> <i v-else-if="item.icon" :class="item.icon"></i>
<span v-else-if="item.emoji" class="timeline-emoji">{{ item.emoji }}</span> <img v-else-if="item.emoji" :src="item.emoji" class="timeline-emoji" alt="emoji" />
</div> </div>
<div class="timeline-content"> <div class="timeline-content">
<slot name="item" :item="item">{{ item.content }}</slot> <slot name="item" :item="item">{{ item.content }}</slot>
@@ -22,6 +22,7 @@ export default {
name: 'BaseTimeline', name: 'BaseTimeline',
props: { props: {
items: { type: Array, default: () => [] }, items: { type: Array, default: () => [] },
hover: { type: Boolean, default: false },
}, },
} }
</script> </script>
@@ -41,6 +42,12 @@ export default {
margin-top: 10px; 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 { .timeline-icon {
position: sticky; position: sticky;
top: 0; top: 0;
@@ -67,8 +74,9 @@ export default {
} }
.timeline-emoji { .timeline-emoji {
font-size: 20px; width: 20px;
line-height: 1; height: 20px;
object-fit: contain;
} }
.timeline-item::before { .timeline-item::before {

View File

@@ -0,0 +1,56 @@
<template>
<div v-if="chatFloating" class="chat-floating">
<iframe :src="iframeSrc" class="chat-frame"></iframe>
</div>
</template>
<script setup>
import { computed } from 'vue'
const chatFloating = useState('chatFloating', () => false)
const chatPath = useState('chatPath', () => '/message-box')
const iframeSrc = computed(() =>
chatPath.value.includes('?') ? `${chatPath.value}&float=1` : `${chatPath.value}?float=1`,
)
if (process.client) {
window.addEventListener('message', (event) => {
if (event.data?.type === 'maximize-chat') {
chatFloating.value = false
navigateTo(event.data.path || chatPath.value)
}
})
}
</script>
<style scoped>
.chat-floating {
position: fixed;
bottom: 20px;
right: 20px;
width: 400px;
height: 70vh;
max-height: 600px;
background: var(--background-color);
border: 1px solid var(--normal-border-color);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
z-index: 2000;
display: flex;
flex-direction: column;
}
.chat-frame {
width: 100%;
height: 100%;
border: none;
}
@media (max-width: 500px) {
.chat-floating {
right: 0;
width: 100%;
height: 60vh;
}
}
</style>

View File

@@ -22,6 +22,7 @@ import {
getEditorTheme as getEditorThemeUtil, getEditorTheme as getEditorThemeUtil,
getPreviewTheme as getPreviewThemeUtil, getPreviewTheme as getPreviewThemeUtil,
} from '~/utils/vditor' } from '~/utils/vditor'
import '~/assets/global.css'
import LoginOverlay from '~/components/LoginOverlay.vue' import LoginOverlay from '~/components/LoginOverlay.vue'
export default { export default {

View File

@@ -16,11 +16,11 @@
<div class="info-content-header-left"> <div class="info-content-header-left">
<span class="user-name">{{ comment.userName }}</span> <span class="user-name">{{ comment.userName }}</span>
<i class="fas fa-medal medal-icon"></i> <i class="fas fa-medal medal-icon"></i>
<router-link <NuxtLink
v-if="comment.medal" v-if="comment.medal"
class="medal-name" class="medal-name"
:to="`/users/${comment.userId}?tab=achievements`" :to="`/users/${comment.userId}?tab=achievements`"
>{{ getMedalTitle(comment.medal) }}</router-link >{{ getMedalTitle(comment.medal) }}</NuxtLink
> >
<i v-if="comment.pinned" class="fas fa-thumbtack pin-icon"></i> <i v-if="comment.pinned" class="fas fa-thumbtack pin-icon"></i>
<span v-if="level >= 2"> <span v-if="level >= 2">
@@ -57,7 +57,7 @@
v-if="showEditor" v-if="showEditor"
@submit="submitReply" @submit="submitReply"
:loading="isWaitingForReply" :loading="isWaitingForReply"
:disabled="!loggedIn" :disabled="!loggedIn || postClosed"
:show-login-overlay="!loggedIn" :show-login-overlay="!loggedIn"
:parent-user-name="comment.userName" :parent-user-name="comment.userName"
/> />
@@ -76,6 +76,7 @@
:level="level + 1" :level="level + 1"
:default-show-replies="item.openReplies" :default-show-replies="item.openReplies"
:post-author-id="postAuthorId" :post-author-id="postAuthorId"
:post-closed="postClosed"
/> />
</template> </template>
</BaseTimeline> </BaseTimeline>
@@ -122,6 +123,10 @@ const props = defineProps({
type: [Number, String], type: [Number, String],
required: true, required: true,
}, },
postClosed: {
type: Boolean,
default: false,
},
}) })
const emit = defineEmits(['deleted']) const emit = defineEmits(['deleted'])
@@ -148,6 +153,7 @@ const toggleReplies = () => {
} }
const toggleEditor = () => { const toggleEditor = () => {
if (props.postClosed) return
showEditor.value = !showEditor.value showEditor.value = !showEditor.value
if (showEditor.value) { if (showEditor.value) {
setTimeout(() => { setTimeout(() => {
@@ -213,6 +219,10 @@ const deleteComment = async () => {
} }
const submitReply = async (parentUserName, text, clear) => { const submitReply = async (parentUserName, text, clear) => {
if (!text.trim()) return if (!text.trim()) return
if (props.postClosed) {
toast.error('帖子已关闭')
return
}
isWaitingForReply.value = true isWaitingForReply.value = true
const token = getToken() const token = getToken()
if (!token) { if (!token) {

View File

@@ -0,0 +1,71 @@
<template>
<BasePopup :visible="visible" @close="onCancel">
<div class="confirm-dialog" role="dialog" aria-modal="true">
<h3 class="confirm-title">{{ title }}</h3>
<p class="confirm-message">{{ message }}</p>
<div class="confirm-actions">
<div class="cancel-button" @click="onCancel">取消</div>
<div class="confirm-button" @click="onConfirm">确认</div>
</div>
</div>
</BasePopup>
</template>
<script setup lang="ts">
import BasePopup from '~/components/BasePopup.vue'
import { useConfirm } from '~/composables/useConfirm'
const { visible, title, message, onConfirm, onCancel } = useConfirm()
</script>
<style scoped>
.confirm-dialog {
padding: 20px;
text-align: center;
}
.confirm-title {
margin-top: 0;
font-size: 18px;
font-weight: 600;
}
.confirm-message {
margin: 16px 0 20px;
line-height: 1.6;
color: var(--text-secondary, #666);
}
.confirm-actions {
display: flex;
justify-content: center;
gap: 12px;
}
.confirm-button,
.cancel-button {
min-width: 88px;
height: 36px;
padding: 0 14px;
border-radius: 8px;
cursor: pointer;
border: 1px solid transparent;
}
.confirm-button {
background: var(--primary-color);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
}
.confirm-button:hover {
background: var(--primary-color-hover);
}
.cancel-button {
background: transparent;
color: var(--primary-color);
border-color: currentColor;
display: flex;
align-items: center;
justify-content: center;
}
.cancel-button:hover {
opacity: 0.85;
}
</style>

View File

@@ -1,11 +1,5 @@
<template> <template>
<div> <div>
<ActivityPopup
:visible="showInvitePointsPopup"
:icon="invitePointsIcon"
text="邀请码送积分活动火热进行中,快来邀请好友吧!"
@close="closeInvitePointsPopup"
/>
<ActivityPopup <ActivityPopup
:visible="showMilkTeaPopup" :visible="showMilkTeaPopup"
:icon="milkTeaIcon" :icon="milkTeaIcon"
@@ -13,7 +7,15 @@
@close="closeMilkTeaPopup" @close="closeMilkTeaPopup"
/> />
<NotificationSettingPopup :visible="showNotificationPopup" @close="closeNotificationPopup" /> <NotificationSettingPopup :visible="showNotificationPopup" @close="closeNotificationPopup" />
<MessagePopup :visible="showMessagePopup" @close="closeMessagePopup" />
<MedalPopup :visible="showMedalPopup" :medals="newMedals" @close="closeMedalPopup" /> <MedalPopup :visible="showMedalPopup" :medals="newMedals" @close="closeMedalPopup" />
<ActivityPopup
:visible="showInviteCodePopup"
:icon="inviteCodeIcon"
text="邀请码活动开始了,速来参与大伙们🔥🔥🔥"
@close="closeInviteCodePopup"
/>
</div> </div>
</template> </template>
@@ -21,26 +23,32 @@
import ActivityPopup from '~/components/ActivityPopup.vue' import ActivityPopup from '~/components/ActivityPopup.vue'
import MedalPopup from '~/components/MedalPopup.vue' import MedalPopup from '~/components/MedalPopup.vue'
import NotificationSettingPopup from '~/components/NotificationSettingPopup.vue' import NotificationSettingPopup from '~/components/NotificationSettingPopup.vue'
import MessagePopup from '~/components/MessagePopup.vue'
import { authState } from '~/utils/auth' import { authState } from '~/utils/auth'
const config = useRuntimeConfig() const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl const API_BASE_URL = config.public.apiBaseUrl
const showInvitePointsPopup = ref(false)
const invitePointsIcon = ref('')
const showMilkTeaPopup = ref(false) const showMilkTeaPopup = ref(false)
const showInviteCodePopup = ref(false)
const milkTeaIcon = ref('') const milkTeaIcon = ref('')
const inviteCodeIcon = ref('')
const showNotificationPopup = ref(false) const showNotificationPopup = ref(false)
const showMessagePopup = ref(false)
const showMedalPopup = ref(false) const showMedalPopup = ref(false)
const newMedals = ref([]) const newMedals = ref([])
onMounted(async () => { onMounted(async () => {
await checkInvitePointsActivity()
if (showInvitePointsPopup.value) return
await checkMilkTeaActivity() await checkMilkTeaActivity()
if (showMilkTeaPopup.value) return if (showMilkTeaPopup.value) return
await checkInviteCodeActivity()
if (showInviteCodePopup.value) return
await checkMessageFeature()
if (showMessagePopup.value) return
await checkNotificationSetting() await checkNotificationSetting()
if (showNotificationPopup.value) return if (showNotificationPopup.value) return
@@ -48,7 +56,7 @@ onMounted(async () => {
}) })
const checkMilkTeaActivity = async () => { const checkMilkTeaActivity = async () => {
if (!process.client) return if (!import.meta.client) return
if (localStorage.getItem('milkTeaActivityPopupShown')) return if (localStorage.getItem('milkTeaActivityPopupShown')) return
try { try {
const res = await fetch(`${API_BASE_URL}/api/activities`) const res = await fetch(`${API_BASE_URL}/api/activities`)
@@ -64,49 +72,62 @@ const checkMilkTeaActivity = async () => {
// ignore network errors // ignore network errors
} }
} }
const closeMilkTeaPopup = () => {
if (!process.client) return const checkInviteCodeActivity = async () => {
localStorage.setItem('milkTeaActivityPopupShown', 'true') if (!import.meta.client) return
showMilkTeaPopup.value = false if (localStorage.getItem('inviteCodeActivityPopupShown')) return
checkNotificationSetting()
}
const checkInvitePointsActivity = async () => {
if (!process.client) return
if (localStorage.getItem('invitePointsActivityPopupShown')) return
try { try {
const res = await fetch(`${API_BASE_URL}/api/activities`) const res = await fetch(`${API_BASE_URL}/api/activities`)
if (res.ok) { if (res.ok) {
const list = await res.json() const list = await res.json()
const a = list.find((i) => i.type === 'INVITE_POINTS' && !i.ended) const a = list.find((i) => i.type === 'INVITE_POINTS' && !i.ended)
if (a) { if (a) {
invitePointsIcon.value = a.icon inviteCodeIcon.value = a.icon
showInvitePointsPopup.value = true showInviteCodePopup.value = true
} }
} }
} catch (e) { } catch (e) {
// ignore network errors // ignore network errors
} }
} }
const closeInvitePointsPopup = () => {
if (!process.client) return const closeInviteCodePopup = () => {
localStorage.setItem('invitePointsActivityPopupShown', 'true') if (!import.meta.client) return
showInvitePointsPopup.value = false localStorage.setItem('inviteCodeActivityPopupShown', 'true')
checkMilkTeaActivity() showInviteCodePopup.value = false
} }
const closeMilkTeaPopup = () => {
if (!import.meta.client) return
localStorage.setItem('milkTeaActivityPopupShown', 'true')
showMilkTeaPopup.value = false
}
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 () => { const checkNotificationSetting = async () => {
if (!process.client) return if (!import.meta.client) return
if (!authState.loggedIn) return if (!authState.loggedIn) return
if (localStorage.getItem('notificationSettingPopupShown')) return if (localStorage.getItem('notificationSettingPopupShown')) return
showNotificationPopup.value = true showNotificationPopup.value = true
} }
const closeNotificationPopup = () => { const closeNotificationPopup = () => {
if (!process.client) return if (!import.meta.client) return
localStorage.setItem('notificationSettingPopupShown', 'true') localStorage.setItem('notificationSettingPopupShown', 'true')
showNotificationPopup.value = false showNotificationPopup.value = false
checkNewMedals()
} }
const checkNewMedals = async () => { const checkNewMedals = async () => {
if (!process.client) return if (!import.meta.client) return
if (!authState.loggedIn || !authState.userId) return if (!authState.loggedIn || !authState.userId) return
try { try {
const res = await fetch(`${API_BASE_URL}/api/medals?userId=${authState.userId}`) const res = await fetch(`${API_BASE_URL}/api/medals?userId=${authState.userId}`)
@@ -124,7 +145,7 @@ const checkNewMedals = async () => {
} }
} }
const closeMedalPopup = () => { const closeMedalPopup = () => {
if (!process.client) return if (!import.meta.client) return
const seen = new Set(JSON.parse(localStorage.getItem('seenMedals') || '[]')) const seen = new Set(JSON.parse(localStorage.getItem('seenMedals') || '[]'))
newMedals.value.forEach((m) => seen.add(m.type)) newMedals.value.forEach((m) => seen.add(m.type))
localStorage.setItem('seenMedals', JSON.stringify([...seen])) localStorage.setItem('seenMedals', JSON.stringify([...seen]))

View File

@@ -6,7 +6,10 @@
<button class="menu-btn" ref="menuBtn" @click="$emit('toggle-menu')"> <button class="menu-btn" ref="menuBtn" @click="$emit('toggle-menu')">
<i class="fas fa-bars"></i> <i class="fas fa-bars"></i>
</button> </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> </div>
<NuxtLink class="logo-container" :to="`/`" @click="refrechData"> <NuxtLink class="logo-container" :to="`/`" @click="refrechData">
<img <img
@@ -29,12 +32,34 @@
<i :class="iconClass"></i> <i :class="iconClass"></i>
</div> </div>
<div v-if="!isMobile" class="invite_text" @click="copyInviteLink">
<i class="fas fa-copy"></i>
邀请
<i v-if="isCopying" class="fas fa-spinner fa-spin"></i>
</div>
<ToolTip content="复制RSS链接" placement="bottom">
<div class="rss-icon" @click="copyRssLink">
<i class="fas fa-rss"></i>
</div>
</ToolTip>
<ToolTip v-if="!isMobile && isLogin" content="发帖" placement="bottom"> <ToolTip v-if="!isMobile && isLogin" content="发帖" placement="bottom">
<div class="new-post-icon" @click="goToNewPost"> <div class="new-post-icon" @click="goToNewPost">
<i class="fas fa-edit"></i> <i class="fas fa-edit"></i>
</div> </div>
</ToolTip> </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"> <DropdownMenu v-if="isLogin" ref="userMenu" :items="headerMenuItems">
<template #trigger> <template #trigger>
<div class="avatar-container"> <div class="avatar-container">
@@ -63,9 +88,15 @@ import DropdownMenu from '~/components/DropdownMenu.vue'
import ToolTip from '~/components/ToolTip.vue' import ToolTip from '~/components/ToolTip.vue'
import SearchDropdown from '~/components/SearchDropdown.vue' import SearchDropdown from '~/components/SearchDropdown.vue'
import { authState, clearToken, loadCurrentUser } from '~/utils/auth' 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 { useIsMobile } from '~/utils/screen'
import { themeState, cycleTheme, ThemeMode } from '~/utils/theme' import { themeState, cycleTheme, ThemeMode } from '~/utils/theme'
import { toast } from '~/main'
import { getToken } from '~/utils/auth'
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
const WEBSITE_BASE_URL = config.public.websiteBaseUrl
const props = defineProps({ const props = defineProps({
showMenuBtn: { showMenuBtn: {
@@ -76,12 +107,14 @@ const props = defineProps({
const isLogin = computed(() => authState.loggedIn) const isLogin = computed(() => authState.loggedIn)
const isMobile = useIsMobile() const isMobile = useIsMobile()
const unreadCount = computed(() => notificationState.unreadCount) const { count: unreadMessageCount, fetchUnreadCount } = useUnreadCount()
const { hasUnread: hasChannelUnread, fetchChannelUnread } = useChannelsUnreadCount()
const avatar = ref('') const avatar = ref('')
const showSearch = ref(false) const showSearch = ref(false)
const searchDropdown = ref(null) const searchDropdown = ref(null)
const userMenu = ref(null) const userMenu = ref(null)
const menuBtn = ref(null) const menuBtn = ref(null)
const isCopying = ref(false)
const search = () => { const search = () => {
showSearch.value = true showSearch.value = true
@@ -100,6 +133,53 @@ const goToLogin = () => {
const goToSettings = () => { const goToSettings = () => {
navigateTo('/settings', { replace: true }) navigateTo('/settings', { replace: true })
} }
const copyInviteLink = async () => {
isCopying.value = true
const token = getToken()
if (!token) {
toast.error('请先登录')
return
}
try {
const res = await fetch(`${API_BASE_URL}/api/invite/generate`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
})
if (res.ok) {
const data = await res.json()
const inviteLink = data.token ? `${WEBSITE_BASE_URL}/signup?invite_token=${data.token}` : ''
/**
* 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 || '生成邀请链接失败')
}
} catch (e) {
toast.error('生成邀请链接失败')
} finally {
isCopying.value = false
}
}
const copyRssLink = async () => {
const rssLink = `${API_BASE_URL}/api/rss`
await navigator.clipboard.writeText(rssLink)
toast.success('RSS链接已复制')
}
const goToProfile = async () => { const goToProfile = async () => {
if (!authState.loggedIn) { if (!authState.loggedIn) {
navigateTo('/login', { replace: true }) navigateTo('/login', { replace: true })
@@ -129,10 +209,13 @@ const goToNewPost = () => {
} }
const refrechData = async () => { const refrechData = async () => {
await fetchUnreadCount()
window.dispatchEvent(new Event('refresh-home')) window.dispatchEvent(new Event('refresh-home'))
} }
const goToMessages = () => {
navigateTo('/message-box')
}
const headerMenuItems = computed(() => [ const headerMenuItems = computed(() => [
{ text: '设置', onClick: goToSettings }, { text: '设置', onClick: goToSettings },
{ text: '个人主页', onClick: goToProfile }, { text: '个人主页', onClick: goToProfile },
@@ -162,9 +245,10 @@ onMounted(async () => {
} }
const updateUnread = async () => { const updateUnread = async () => {
if (authState.loggedIn) { if (authState.loggedIn) {
await fetchUnreadCount() fetchUnreadCount()
fetchChannelUnread()
} else { } else {
notificationState.unreadCount = 0 fetchChannelUnread()
} }
} }
@@ -173,7 +257,7 @@ onMounted(async () => {
watch( watch(
() => authState.loggedIn, () => authState.loggedIn,
async () => { async (isLoggedIn) => {
await updateAvatar() await updateAvatar()
await updateUnread() await updateUnread()
}, },
@@ -224,7 +308,7 @@ onMounted(async () => {
margin-left: auto; margin-left: auto;
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
gap: 20px; gap: 30px;
} }
.auth-btns { .auth-btns {
@@ -315,9 +399,67 @@ onMounted(async () => {
cursor: pointer; cursor: pointer;
} }
.new-post-icon { .invite_text {
font-size: 12px;
cursor: pointer;
color: var(--primary-color);
}
.invite_text:hover {
text-decoration: underline;
}
.rss-icon,
.new-post-icon,
.messages-icon {
font-size: 18px; font-size: 18px;
cursor: pointer; 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 {
animation: rss-glow 2s 3;
}
@keyframes rss-glow {
0% {
text-shadow: 0 0 0px var(--primary-color);
opacity: 1;
}
50% {
text-shadow: 0 0 12px var(--primary-color);
opacity: 0.8;
}
100% {
text-shadow: 0 0 0px var(--primary-color);
opacity: 1;
}
} }
@media (max-width: 1200px) { @media (max-width: 1200px) {
@@ -336,5 +478,9 @@ onMounted(async () => {
.logo-text { .logo-text {
display: none; display: none;
} }
.header-content-right {
gap: 15px;
}
} }
</style> </style>

View File

@@ -0,0 +1,102 @@
<template>
<!-- done 后整个容器自动隐藏不再占位 -->
<div v-show="!done" class="infinite-loadmore">
<div v-show="isLoading" class="loading-container bottom-loading" aria-live="polite">
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
</div>
<!-- 永久存在的底部触发器由组件内部持有与观察 -->
<div ref="sentinel" class="load-more-trigger" aria-hidden="true"></div>
</div>
</template>
<script setup>
import { ref, onMounted, onBeforeUnmount, watch, nextTick } from 'vue'
const props = defineProps({
/** 父组件提供:执行“加载下一页”的函数
* 返回:
* - booleantrue 表示“已经没有更多数据done
* - { done: boolean }:同上
*/
onLoad: { type: Function, required: true },
/** pause=true 时暂停观察(例如首屏/筛选加载过程) */
pause: { type: Boolean, default: false },
/** 预取范围,默认 200px */
rootMargin: { type: String, default: '200px 0px' },
/** 触发阈值 */
threshold: { type: Number, default: 0 },
})
const isLoading = ref(false)
const done = ref(false)
const sentinel = ref(null)
let io = null
const stopObserver = () => {
if (io) {
io.disconnect()
io = null
}
}
const startObserver = () => {
if (!import.meta.client || props.pause || done.value) return
stopObserver()
io = new IntersectionObserver(
async (entries) => {
const e = entries[0]
if (!e?.isIntersecting || isLoading.value || done.value) return
isLoading.value = true
try {
const res = await props.onLoad()
const finished = typeof res === 'boolean' ? res : !!(res && res.done)
if (finished) {
done.value = true
stopObserver()
}
} finally {
isLoading.value = false
}
},
{ root: null, rootMargin: props.rootMargin, threshold: props.threshold },
)
if (sentinel.value) io.observe(sentinel.value)
}
onMounted(() => {
nextTick(startObserver)
})
onBeforeUnmount(stopObserver)
watch(
() => props.pause,
(p) => {
if (p) stopObserver()
else nextTick(startObserver)
},
)
/** 父组件可选择性调用,用于外部强制重置(一般直接用 :key 重建更简单) */
const reset = () => {
done.value = false
nextTick(startObserver)
}
defineExpose({ reset })
</script>
<style scoped>
.infinite-loadmore {
}
.loading-container {
display: flex;
justify-content: center;
align-items: center;
height: 100px; /* 与原样式匹配 */
}
.load-more-trigger {
width: 100%;
height: 1px;
}
</style>

View File

@@ -0,0 +1,190 @@
<template>
<div class="invite-code-activity">
<div class="invite-code-description">
<div class="invite-code-description-title">
<i class="fas fa-info-circle"></i>
<span class="invite-code-description-title-text">邀请规则说明</span>
</div>
<div class="invite-code-description-content">
<p>邀请好友注册并登录每次可以获得500积分🎉🎉🎉</p>
<p>邀请链接的有效期为1个月</p>
<p>每一个邀请链接的邀请人数上限为3人</p>
<p>通过邀请链接注册无需注册审核</p>
<p>每人每天仅能生产1个邀请链接</p>
</div>
</div>
<div v-if="inviteLink" class="invite-code-link-content">
<p class="invite-code-link-content-text">
邀请链接{{ inviteLink }}
<span @click="copyLink"><i class="fas fa-copy copy-icon"></i></span>
</p>
</div>
<div :class="['generate-button', { disabled: !user || loadingInvite }]" @click="generateInvite">
生成邀请链接
</div>
</div>
</template>
<script setup>
import { toast } from '~/main'
import { fetchCurrentUser, getToken } from '~/utils/auth'
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
const WEBSITE_BASE_URL = config.public.websiteBaseUrl
const user = ref(null)
const isLoadingUser = ref(true)
const inviteCode = ref('')
const loadingInvite = ref(false)
const inviteLink = computed(() =>
inviteCode.value ? `${WEBSITE_BASE_URL}/signup?invite_token=${inviteCode.value}` : '',
)
onMounted(async () => {
isLoadingUser.value = true
user.value = await fetchCurrentUser()
isLoadingUser.value = false
// if (user.value) {
// await fetchInvite(false)
// }
})
const fetchInvite = async (showToast = true) => {
loadingInvite.value = true
const token = getToken()
if (!token) {
toast.error('请先登录')
loadingInvite.value = false
return
}
try {
const res = await fetch(`${API_BASE_URL}/api/invite/generate`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
})
if (res.ok) {
const data = await res.json()
inviteCode.value = data.token
if (showToast) toast.success('邀请链接已生成')
} else {
const data = await res.json().catch(() => ({}))
toast.error(data.error || '生成邀请链接失败')
}
} catch (e) {
toast.error('生成邀请链接失败')
} finally {
loadingInvite.value = false
}
}
const generateInvite = () => fetchInvite(true)
const copyLink = async () => {
if (!inviteLink.value) return
try {
await navigator.clipboard.writeText(inviteLink.value)
toast.success('已复制')
} catch (e) {
toast.error('复制失败')
}
}
</script>
<style scoped>
.invite-code-description-title-text {
font-size: 14px;
font-weight: bold;
margin-left: 5px;
}
.invite-code-description-content {
font-size: 12px;
opacity: 0.8;
}
.status-title {
font-weight: bold;
}
.status-text {
font-size: 12px;
opacity: 0.8;
}
.invite-code-activity {
margin-top: 20px;
padding: 20px;
}
.generate-button {
margin-top: 20px;
background-color: var(--primary-color);
color: #fff;
padding: 8px 16px;
border-radius: 10px;
width: fit-content;
cursor: pointer;
}
.generate-button:hover {
background-color: var(--primary-color-hover);
}
.generate-button.disabled {
background-color: var(--primary-color-disabled);
cursor: not-allowed;
}
.generate-button.disabled:hover {
background-color: var(--primary-color-disabled);
}
.invite-code-status-container {
display: flex;
flex-direction: row;
align-items: center;
gap: 30px;
margin-top: 20px;
}
.invite-code-status {
display: flex;
flex-direction: column;
gap: 4px;
margin-top: 10px;
font-size: 14px;
}
.user-level-text {
opacity: 0.8;
font-size: 12px;
color: var(--primary-color);
}
.invite-code-link-content {
margin-top: 20px;
font-size: 12px;
opacity: 0.8;
}
.invite-code-link-content-text {
word-break: break-all;
}
.copy-icon {
cursor: pointer;
margin-left: 5px;
}
@media screen and (max-width: 768px) {
.invite-code-status-container {
flex-direction: column;
align-items: flex-start;
gap: 10px;
}
}
</style>

View File

@@ -106,7 +106,7 @@
<div class="menu-section"> <div class="menu-section">
<div class="section-header" @click="tagOpen = !tagOpen"> <div class="section-header" @click="tagOpen = !tagOpen">
<span>tag</span> <span>标签</span>
<i :class="tagOpen ? 'fas fa-chevron-up' : 'fas fa-chevron-down'"></i> <i :class="tagOpen ? 'fas fa-chevron-up' : 'fas fa-chevron-down'"></i>
</div> </div>
<div v-if="tagOpen" class="section-items"> <div v-if="tagOpen" class="section-items">
@@ -262,7 +262,7 @@ const gotoTag = (t) => {
top: var(--header-height); top: var(--header-height);
width: 220px; width: 220px;
background-color: var(--app-menu-background-color); 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); border-right: 1px solid var(--menu-border-color);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -348,6 +348,7 @@ const gotoTag = (t) => {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
cursor: pointer;
} }
.menu-section { .menu-section {

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,74 @@
<template>
<BasePopup :visible="visible" @close="close">
<div class="message-popup">
<div class="message-popup-title">📨 站内信上线啦</div>
<div class="message-popup-text">现在可以在右上角使用站内信功能</div>
<div class="message-popup-actions">
<div class="message-popup-close" @click="close">知道了</div>
<div class="message-popup-button" @click="gotoMessage">去看看</div>
</div>
</div>
</BasePopup>
</template>
<script setup>
import BasePopup from '~/components/BasePopup.vue'
defineProps({
visible: { type: Boolean, default: false },
})
const emit = defineEmits(['close'])
const gotoMessage = () => {
emit('close')
navigateTo('/message-box', { replace: true })
}
const close = () => emit('close')
</script>
<style scoped>
.message-popup {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: 10px;
min-width: 200px;
}
.message-popup-title {
font-size: 18px;
font-weight: bold;
margin-bottom: 10px;
}
.message-popup-actions {
margin-top: 10px;
display: flex;
flex-direction: row;
gap: 20px;
}
.message-popup-button {
background-color: var(--primary-color);
color: #fff;
padding: 8px 16px;
border-radius: 8px;
cursor: pointer;
}
.message-popup-button:hover {
background-color: var(--primary-color-hover);
}
.message-popup-close {
cursor: pointer;
color: var(--primary-color);
display: flex;
align-items: center;
}
.message-popup-close:hover {
text-decoration: underline;
}
</style>

View File

@@ -16,6 +16,7 @@ import {
getEditorTheme as getEditorThemeUtil, getEditorTheme as getEditorThemeUtil,
getPreviewTheme as getPreviewThemeUtil, getPreviewTheme as getPreviewThemeUtil,
} from '~/utils/vditor' } from '~/utils/vditor'
import '~/assets/global.css'
export default { export default {
name: 'PostEditor', name: 'PostEditor',

View File

@@ -3,20 +3,37 @@
<div class="reactions-viewer"> <div class="reactions-viewer">
<div <div
class="reactions-viewer-item-container" class="reactions-viewer-item-container"
@click="openPanel"
@mouseenter="cancelHide" @mouseenter="cancelHide"
@mouseleave="scheduleHide" @mouseleave="scheduleHide"
> >
<template v-if="displayedReactions.length"> <template v-if="reactions.length < 4">
<div v-for="r in displayedReactions" :key="r.type" class="reactions-viewer-item"> <div
{{ reactionEmojiMap[r.type] }} v-for="r in displayedReactions"
:key="r.type"
class="reactions-viewer-single-item"
:class="{ selected: userReacted(r.type) }"
@click="toggleReaction(r.type)"
>
<img :src="reactionEmojiMap[r.type]" class="emoji" alt="emoji" />
<div>{{ counts[r.type] }}</div>
</div>
<div class="reactions-viewer-item placeholder" @click="openPanel">
<i class="far fa-smile"></i>
<!-- <span class="reactions-viewer-item-placeholder-text">点击以表态</span> -->
</div>
</template>
<template v-else-if="displayedReactions.length">
<div
v-for="r in displayedReactions"
:key="r.type"
class="reactions-viewer-item"
@click="openPanel"
>
<img :src="reactionEmojiMap[r.type]" class="emoji" alt="emoji" />
</div> </div>
<div class="reactions-count">{{ totalCount }}</div> <div class="reactions-count">{{ totalCount }}</div>
</template> </template>
<div v-else class="reactions-viewer-item placeholder">
<i class="far fa-smile"></i>
<span class="reactions-viewer-item-placeholder-text">点击以表态</span>
</div>
</div> </div>
</div> </div>
<div class="make-reaction-container"> <div class="make-reaction-container">
@@ -40,7 +57,9 @@
@click="toggleReaction(t)" @click="toggleReaction(t)"
:class="{ selected: userReacted(t) }" :class="{ selected: userReacted(t) }"
> >
{{ reactionEmojiMap[t] }}<span v-if="counts[t]">{{ counts[t] }}</span> <img :src="reactionEmojiMap[t]" class="emoji" alt="emoji" /><span v-if="counts[t]">{{
counts[t]
}}</span>
</div> </div>
</div> </div>
</div> </div>
@@ -51,6 +70,10 @@ import { computed, onMounted, ref, watch } from 'vue'
import { toast } from '~/main' import { toast } from '~/main'
import { authState, getToken } from '~/utils/auth' import { authState, getToken } from '~/utils/auth'
import { reactionEmojiMap } from '~/utils/reactions' import { reactionEmojiMap } from '~/utils/reactions'
import { useReactionTypes } from '~/composables/useReactionTypes'
const { reactionTypes, initialize } = useReactionTypes()
const config = useRuntimeConfig() const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl const API_BASE_URL = config.public.apiBaseUrl
const emit = defineEmits(['update:modelValue']) const emit = defineEmits(['update:modelValue'])
@@ -66,30 +89,6 @@ watch(
) )
const reactions = ref(props.modelValue) const reactions = ref(props.modelValue)
const reactionTypes = ref([])
let cachedTypes = null
const fetchTypes = async () => {
if (cachedTypes) return cachedTypes
try {
const token = getToken()
const res = await fetch(`${API_BASE_URL}/api/reaction-types`, {
headers: { Authorization: token ? `Bearer ${token}` : '' },
})
if (res.ok) {
cachedTypes = await res.json()
} else {
cachedTypes = []
}
} catch {
cachedTypes = []
}
return cachedTypes
}
onMounted(async () => {
reactionTypes.value = await fetchTypes()
})
const counts = computed(() => { const counts = computed(() => {
const c = {} const c = {}
@@ -200,6 +199,10 @@ const toggleReaction = async (type) => {
toast.error('操作失败') toast.error('操作失败')
} }
} }
onMounted(async () => {
await initialize()
})
</script> </script>
<style> <style>
@@ -233,13 +236,6 @@ const toggleReaction = async (type) => {
font-size: 16px; font-size: 16px;
} }
.reactions-viewer-item.placeholder {
opacity: 0.5;
display: flex;
flex-direction: row;
align-items: center;
}
.reactions-viewer-item-placeholder-text { .reactions-viewer-item-placeholder-text {
font-size: 14px; font-size: 14px;
padding-left: 5px; padding-left: 5px;
@@ -253,7 +249,7 @@ const toggleReaction = async (type) => {
.make-reaction-item { .make-reaction-item {
cursor: pointer; cursor: pointer;
padding: 10px; padding: 4px;
opacity: 0.5; opacity: 0.5;
border-radius: 8px; border-radius: 8px;
font-size: 20px; font-size: 20px;
@@ -278,18 +274,16 @@ const toggleReaction = async (type) => {
.reactions-panel { .reactions-panel {
position: absolute; position: absolute;
bottom: 40px; bottom: 50px;
left: -20px;
background-color: var(--background-color); background-color: var(--background-color);
border: 1px solid var(--normal-border-color); border: 1px solid var(--normal-border-color);
border-radius: 5px; border-radius: 20px;
padding: 5px; padding: 5px 10px;
max-width: 240px;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
flex-wrap: wrap; flex-wrap: wrap;
z-index: 10; z-index: 10;
gap: 2px; gap: 5px;
box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.1); box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.1);
} }
@@ -303,6 +297,27 @@ const toggleReaction = async (type) => {
gap: 2px; gap: 2px;
} }
.reactions-viewer-item.placeholder,
.reactions-viewer-single-item {
display: flex;
cursor: pointer;
flex-direction: row;
padding: 2px 10px;
gap: 5px;
border: 1px solid var(--normal-border-color);
border-radius: 10px;
margin-right: 5px;
margin-bottom: 5px;
font-size: 14px;
color: var(--text-color);
align-items: center;
}
.reactions-viewer-item.placeholder,
.reactions-viewer-single-item.selected {
background-color: var(--menu-selected-background-color);
}
.reaction-option.selected { .reaction-option.selected {
background-color: var(--menu-selected-background-color); background-color: var(--menu-selected-background-color);
} }

View File

@@ -0,0 +1,198 @@
<template>
<div class="search-dropdown">
<Dropdown
ref="dropdown"
v-model="selected"
:fetch-options="fetchResults"
remote
menu-class="search-menu"
option-class="search-option"
:show-search="isMobile"
@update:search="keyword = $event"
@close="onClose"
>
<template #display="{ setSearch }">
<div class="search-input">
<i class="search-input-icon fas fa-search"></i>
<input
class="text-input"
v-model="keyword"
placeholder="Search users"
@input="setSearch(keyword)"
/>
</div>
</template>
<template #option="{ option }">
<div class="search-option-item">
<img
:src="option.avatar || '/default-avatar.svg'"
class="avatar"
@error="handleAvatarError"
/>
<div class="result-body">
<div class="result-main" v-html="highlight(option.username)"></div>
<div
v-if="option.introduction"
class="result-sub"
v-html="highlight(option.introduction)"
></div>
</div>
</div>
</template>
</Dropdown>
</div>
</template>
<script setup>
import { ref, watch } from 'vue'
import Dropdown from '~/components/Dropdown.vue'
import { stripMarkdown } from '~/utils/markdown'
import { useIsMobile } from '~/utils/screen'
import { getToken } from '~/utils/auth'
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
const emit = defineEmits(['close'])
const keyword = ref('')
const selected = ref(null)
const results = ref([])
const dropdown = ref(null)
const isMobile = useIsMobile()
const toggle = () => {
dropdown.value.toggle()
}
const onClose = () => emit('close')
const fetchResults = async (kw) => {
if (!kw) return []
const res = await fetch(`${API_BASE_URL}/api/search/users?keyword=${encodeURIComponent(kw)}`)
if (!res.ok) return []
const data = await res.json()
results.value = data.map((u) => ({
id: u.id,
username: u.username,
avatar: u.avatar,
introduction: u.introduction,
}))
return results.value
}
const highlight = (text) => {
text = stripMarkdown(text || '')
if (!keyword.value) return text
const reg = new RegExp(keyword.value, 'gi')
return text.replace(reg, (m) => `<span class="highlight">${m}</span>`)
}
const handleAvatarError = (e) => {
e.target.src = '/default-avatar.svg'
}
watch(selected, async (val) => {
if (!val) return
const user = results.value.find((u) => u.id === val)
if (!user) return
const token = getToken()
if (!token) {
navigateTo('/login', { replace: true })
} else {
try {
const res = await fetch(`${API_BASE_URL}/api/messages/conversations`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ recipientId: user.id }),
})
if (res.ok) {
const data = await res.json()
navigateTo(`/message-box/${data.conversationId}`, { replace: true })
}
} catch (e) {
// ignore
}
}
selected.value = null
keyword.value = ''
})
defineExpose({
toggle,
})
</script>
<style scoped>
.search-dropdown {
margin-top: 20px;
width: 500px;
}
.search-input {
padding: 10px;
display: flex;
align-items: center;
width: 100%;
}
.text-input {
background-color: var(--app-menu-background-color);
color: var(--text-color);
border: none;
outline: none;
width: 100%;
margin-left: 10px;
font-size: 16px;
}
.search-menu {
width: 100%;
max-width: 600px;
}
@media (max-width: 768px) {
.search-dropdown {
width: 100%;
}
}
.search-option-item {
display: flex;
gap: 10px;
}
.search-option {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 20px;
}
:deep(.highlight) {
color: var(--primary-color);
}
.avatar {
width: 32px;
height: 32px;
border-radius: 50%;
object-fit: cover;
}
.result-body {
display: flex;
flex-direction: column;
}
.result-main {
font-weight: bold;
}
.result-sub {
font-size: 12px;
color: #666;
}
</style>

View File

@@ -63,7 +63,7 @@ const isImageIcon = (icon) => {
} }
const buildTagsUrl = (kw = '') => { const buildTagsUrl = (kw = '') => {
const base = API_BASE_URL || (process.client ? window.location.origin : '') const base = API_BASE_URL || (import.meta.client ? window.location.origin : '')
const url = new URL('/api/tags', base) const url = new URL('/api/tags', base)
if (kw) url.searchParams.set('keyword', kw) if (kw) url.searchParams.set('keyword', kw)

View File

@@ -0,0 +1,92 @@
import { ref, computed, watch } from 'vue'
import { useWebSocket } from './useWebSocket'
import { getToken } from '~/utils/auth'
const count = ref(0)
let isInitialized = false
let wsSubscription = null
export function useChannelsUnreadCount() {
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
const { subscribe, isConnected, connect } = useWebSocket()
const fetchChannelUnread = async () => {
const token = getToken()
if (!token) {
count.value = 0
return
}
try {
const response = await fetch(`${API_BASE_URL}/api/channels/unread-count`, {
headers: { Authorization: `Bearer ${token}` },
})
if (response.ok) {
const data = await response.json()
count.value = data
}
} catch (e) {
console.error('Failed to fetch channel unread count:', e)
}
}
const initialize = () => {
const token = getToken()
if (!token) {
count.value = 0
return
}
fetchChannelUnread()
if (!isConnected.value) {
connect(token)
}
setupWebSocketListener()
}
const setupWebSocketListener = () => {
if (!wsSubscription) {
watch(
isConnected,
(newValue) => {
if (newValue && !wsSubscription) {
wsSubscription = subscribe('/user/queue/channel-unread', (message) => {
const unread = parseInt(message.body, 10)
if (!isNaN(unread)) {
count.value = unread
}
})
}
},
{ immediate: true },
)
}
}
const setFromList = (channels) => {
count.value = Array.isArray(channels) ? channels.filter((c) => c.unreadCount > 0).length : 0
}
const hasUnread = computed(() => count.value > 0)
const token = getToken()
if (token) {
if (!isInitialized) {
isInitialized = true
initialize()
} else {
fetchChannelUnread()
if (!isConnected.value) {
connect(token)
}
setupWebSocketListener()
}
}
return {
count,
hasUnread,
fetchChannelUnread,
initialize,
setFromList,
}
}

View File

@@ -0,0 +1,52 @@
// composables/useConfirm.ts
import { ref } from 'vue'
// 全局单例SPA 下即可Nuxt/SSR 下见文末“SSR 提醒”)
const visible = ref(false)
const title = ref('')
const message = ref('')
let resolver: ((ok: boolean) => void) | null = null
function reset() {
visible.value = false
title.value = ''
message.value = ''
resolver = null
}
export function useConfirm() {
/**
* 打开确认框,返回 Promise<boolean>
* - 确认 => resolve(true)
* - 取消/关闭 => resolve(false)
* 若并发调用,以最后一次为准(更简单直观)
*/
const confirm = (t: string, m: string) => {
title.value = t
message.value = m
visible.value = true
return new Promise<boolean>((resolve) => {
resolver = resolve
})
}
const onConfirm = () => {
resolver?.(true)
reset()
}
const onCancel = () => {
resolver?.(false)
reset()
}
return {
visible,
title,
message,
confirm,
onConfirm,
onCancel,
}
}

View File

@@ -0,0 +1,52 @@
import { ref } from 'vue'
const reactionTypes = ref([])
let isLoading = false
let isInitialized = false
export function useReactionTypes() {
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
const fetchReactionTypes = async () => {
if (isInitialized || isLoading) {
reactionTypes.value = [...(window.reactionTypes || [])]
return reactionTypes.value
}
isLoading = true
try {
const token = getToken()
const res = await fetch(`${API_BASE_URL}/api/reaction-types`, {
headers: { Authorization: token ? `Bearer ${token}` : '' },
})
if (res.ok) {
reactionTypes.value = await res.json()
window.reactionTypes = [...reactionTypes.value]
isInitialized = true
} else {
reactionTypes.value = []
}
} catch (error) {
console.error('Failed to fetch reaction types:', error)
reactionTypes.value = []
} finally {
isLoading = false
}
return reactionTypes.value
}
const initialize = async () => {
if (!isInitialized) {
await fetchReactionTypes()
}
return reactionTypes.value
}
return {
reactionTypes: readonly(reactionTypes),
fetchReactionTypes,
initialize,
isInitialized: readonly(isInitialized)
}
}

View File

@@ -1,7 +1,7 @@
// 导出一个便捷的 toast 对象 // 导出一个便捷的 toast 对象
export const toast = { export const toast = {
success: async (message) => { success: async (message) => {
if (process.client) { if (import.meta.client) {
try { try {
const { useToast } = await import('vue-toastification') const { useToast } = await import('vue-toastification')
const toastInstance = useToast() const toastInstance = useToast()
@@ -12,7 +12,7 @@ export const toast = {
} }
}, },
error: async (message) => { error: async (message) => {
if (process.client) { if (import.meta.client) {
try { try {
const { useToast } = await import('vue-toastification') const { useToast } = await import('vue-toastification')
const toastInstance = useToast() const toastInstance = useToast()
@@ -23,7 +23,7 @@ export const toast = {
} }
}, },
warning: async (message) => { warning: async (message) => {
if (process.client) { if (import.meta.client) {
try { try {
const { useToast } = await import('vue-toastification') const { useToast } = await import('vue-toastification')
const toastInstance = useToast() const toastInstance = useToast()
@@ -34,7 +34,7 @@ export const toast = {
} }
}, },
info: async (message) => { info: async (message) => {
if (process.client) { if (import.meta.client) {
try { try {
const { useToast } = await import('vue-toastification') const { useToast } = await import('vue-toastification')
const toastInstance = useToast() const toastInstance = useToast()
@@ -48,7 +48,7 @@ export const toast = {
// 导出 useToast composable // 导出 useToast composable
export const useToast = () => { export const useToast = () => {
if (process.client) { if (import.meta.client) {
return new Promise(async (resolve) => { return new Promise(async (resolve) => {
try { try {
const { useToast: useVueToast } = await import('vue-toastification') const { useToast: useVueToast } = await import('vue-toastification')

View File

@@ -0,0 +1,93 @@
import { ref, watch, onMounted } from 'vue';
import { useWebSocket } from './useWebSocket';
import { getToken } from '~/utils/auth';
const count = ref(0);
let isInitialized = false;
let wsSubscription = null;
export function useUnreadCount() {
const config = useRuntimeConfig();
const API_BASE_URL = config.public.apiBaseUrl;
const { subscribe, isConnected, connect } = useWebSocket();
const fetchUnreadCount = async () => {
const token = getToken();
if (!token) {
count.value = 0;
return;
}
try {
const response = await fetch(`${API_BASE_URL}/api/messages/unread-count`, {
headers: { Authorization: `Bearer ${token}` },
});
if (response.ok) {
const data = await response.json();
count.value = data;
}
} catch (error) {
console.error('Failed to fetch unread count:', error);
}
};
const initialize = async () => {
const token = getToken();
if (!token) {
count.value = 0;
return;
}
// 总是获取最新的未读数量
fetchUnreadCount();
// 确保WebSocket连接
if (!isConnected.value) {
connect(token);
}
// 设置WebSocket监听
await setupWebSocketListener();
};
const setupWebSocketListener = async () => {
// 只有在还没有订阅的情况下才设置监听
if (!wsSubscription) {
watch(isConnected, (newValue) => {
if (newValue && !wsSubscription) {
const destination = `/user/queue/unread-count`;
wsSubscription = subscribe(destination, (message) => {
const unreadCount = parseInt(message.body, 10);
if (!isNaN(unreadCount)) {
count.value = unreadCount;
}
});
}
}, { immediate: true });
}
};
// 自动初始化逻辑 - 确保每次调用都能获取到未读数量并设置监听
const token = getToken();
if (token) {
if (!isInitialized) {
isInitialized = true;
initialize(); // 完整初始化包括WebSocket监听
} else {
// 即使已经初始化也要确保获取最新的未读数量并确保WebSocket监听存在
fetchUnreadCount();
// 确保WebSocket连接和监听都存在
if (!isConnected.value) {
connect(token);
}
setupWebSocketListener();
}
}
return {
count,
fetchUnreadCount,
initialize,
};
}

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