Compare commits

...

80 Commits

Author SHA1 Message Date
Tim
e5b386cdc2 Merge pull request #1136 from nagisa77/bugfix/1053
fix: 性能优化,首页下拉更新,实测6秒左右,稍慢 #1053
2026-01-16 16:47:46 +08:00
Tim
179699dd66 Merge pull request #1135 from nagisa77/bugfix/1130
fix: 1. 修复dev_local_backend报错问题 2.由于内存占用过高,直接去除opensearch
2026-01-16 16:45:35 +08:00
Tim
ef39b5fedf fix: 性能优化,首页下拉更新,实测6秒左右,稍慢 #1053 2026-01-16 16:45:07 +08:00
Tim
e13ee1ca46 fix: 1. 修复dev_local_backend报错问题 2.由于内存占用过高,直接去除opensearch 2026-01-16 15:22:59 +08:00
Tim
09f1435e33 Merge pull request #1134 from nagisa77/feature/view_history
fix: add view history logic
2026-01-16 15:13:22 +08:00
Tim
7e7cebbbe7 fix: add view history logic 2026-01-16 15:05:06 +08:00
Tim
5c1031c57c Merge pull request #1133 from nagisa77/feature/fix_copy_lick_background
feat: update copylink color
2026-01-16 14:21:02 +08:00
Tim
e6730b2882 feat: update copylink color 2026-01-16 14:17:23 +08:00
Tim
21b1c3317a fix: 解决邮件发送错误,但是前端显示已发送的问题 2026-01-16 11:12:20 +08:00
Tim
72a915af2e fix: 解决icon loading问题 2026-01-15 21:33:55 +08:00
Tim
f000011994 fix: 解决文章loading问题 2026-01-15 21:29:44 +08:00
Tim
d48c9dc27a fix: 新增描述初始用户名和密码 2026-01-15 21:16:58 +08:00
Tim
94f955e50f 更新 daily_news_bot.ts 2025-10-31 11:48:22 +08:00
Tim
bf94707914 Update daily_news_bot.ts 2025-10-31 10:54:36 +08:00
Tim
209f0ef1f8 Update daily_news_bot.ts 2025-10-31 10:50:53 +08:00
Tim
e2d900759a Merge pull request #1126 from nagisa77/codex/set-news-bot-model-to-4o
Allow bots to choose agent model
2025-10-31 10:46:55 +08:00
Tim
40a233a66b Allow bots to override agent model 2025-10-31 10:46:22 +08:00
Tim
b8c0b1c6f8 Update bot_father.ts 2025-10-31 10:29:41 +08:00
Tim
b37df67d31 Update daily_news_bot instructions for content sourcing 2025-10-30 10:09:03 +08:00
Tim
90865b02c9 更新 news-bot.yml 2025-10-30 09:40:32 +08:00
Tim
f8c0335982 fix: prompt 调整 2025-10-29 18:32:04 +08:00
Tim
20b3d89a00 fix: prompt 调整 2025-10-29 18:28:33 +08:00
Tim
ddae56d483 fix: prompt 2025-10-29 18:12:46 +08:00
Tim
265fce4153 feat: add workflow 2025-10-29 18:03:49 +08:00
Tim
cc0880e2c1 Merge pull request #1125 from nagisa77/feature/news_bot
Feature/news bot
2025-10-29 18:02:19 +08:00
Tim
5fe3eec815 Merge pull request #1124 from nagisa77/codex/create-daily-news-bot-with-summaries
Add daily news bot workflow
2025-10-29 18:01:21 +08:00
Tim
f0feb7a45c Add daily news bot workflow 2025-10-29 18:01:08 +08:00
Tim
784057207f feat: add websearch tools 2025-10-29 17:54:06 +08:00
Tim
bed72662b5 Update reply_bot.ts 2025-10-29 14:31:07 +08:00
Tim
895dba495b Update reply_bot.ts 2025-10-29 14:30:14 +08:00
Tim
32dc6bfaf9 Update reply_bot.ts 2025-10-29 14:25:59 +08:00
Tim
4766250577 Update reply_bot.ts 2025-10-29 14:21:27 +08:00
Tim
13baffa9f1 Merge pull request #1123 from nagisa77/codex/rename-coffee-and-reply-bots-to-system
Adjust coffee bot schedule and update bot personas
2025-10-29 14:20:40 +08:00
Tim
d0d7580ac3 Adjust bot identities and coffee bot schedule 2025-10-29 14:20:29 +08:00
Tim
fd4e651a49 Merge pull request #1122 from nagisa77/codex/update-readme.md-with-bot-integration-details
docs: highlight bot integration in README
2025-10-29 13:15:17 +08:00
Tim
58317687d7 docs: update README bot info 2025-10-29 13:15:06 +08:00
tim
006e46f4ef Revert "fix: prompt 修改"
This reverts commit 2c27766544.
2025-10-28 21:56:49 +08:00
Tim
2c27766544 fix: prompt 修改 2025-10-28 20:48:37 +08:00
Tim
c305992223 fix: prompt 修改 2025-10-28 20:25:07 +08:00
Tim
babd2c6549 Merge pull request #1121 from nagisa77/codex/add-opensourcereplybot-with-detailed-responses
feat(bot): add open source reply bot
2025-10-28 19:56:03 +08:00
Tim
d98c3644a6 fix: 添加GitHub action 2025-10-28 19:55:29 +08:00
Tim
dbb63a4039 feat(bot): add open source reply bot 2025-10-28 19:53:07 +08:00
Tim
49aeff3a83 Merge pull request #1120 from nagisa77/codex/add-is_bot-field-to-user-table
Add bot flag to user model and show comment badge
2025-10-28 19:49:44 +08:00
Tim
512e5623e1 Add bot flag to users and surface in comments 2025-10-28 19:49:33 +08:00
Tim
8db928b9a8 Merge pull request #1119 from nagisa77/codex/update-lottery-time-from-23-to-15
Adjust coffee bot draw schedule
2025-10-28 18:58:05 +08:00
Tim
46f6ccb3a8 Adjust coffee draw schedule 2025-10-28 18:57:53 +08:00
Tim
87dcebf052 fix: tools 重写 2025-10-28 18:50:34 +08:00
Tim
0ad4f4feff fix: fix tools 2025-10-28 18:48:32 +08:00
Tim
a227ac77fb Merge pull request #1118 from nagisa77/codex/add-daily-weather-lookup-for-cities
fix: 透传token
2025-10-28 18:45:30 +08:00
Tim
ef53a40ed5 fix: 透传token 2025-10-28 18:44:43 +08:00
Tim
7d8c9b68bd Merge pull request #1117 from nagisa77/codex/add-daily-weather-lookup-for-cities
Integrate weather MCP into Coffee Bot
2025-10-28 18:43:15 +08:00
Tim
dbc3d54fa1 fix: should add weather mcp 2025-10-28 18:42:47 +08:00
Tim
4c0b9e744a feat: weather mcp 2025-10-28 18:39:58 +08:00
Tim
4b4d1a2a86 Merge branch 'main' into codex/add-daily-weather-lookup-for-cities
# Conflicts:
#	bots/instance/coffee_bot.ts
2025-10-28 18:39:44 +08:00
Tim
6990aa93ed Integrate weather MCP into Coffee Bot 2025-10-28 18:32:42 +08:00
Tim
421b8b6b4f fix: prompt 修改 2025-10-28 18:08:19 +08:00
Tim
e55acc6dc4 fix: 解决时区问题 2025-10-28 18:01:03 +08:00
Tim
33ce56aa31 fix: 解决时区问题 2025-10-28 17:58:13 +08:00
Tim
339c39c6ca fix: 时区设置 2025-10-28 17:55:36 +08:00
Tim
389961c922 fix: 修正prompt 2025-10-28 17:47:00 +08:00
Tim
6db53274fb fix: 修正日期 2025-10-28 17:44:14 +08:00
Tim
a413c0be35 fix: 修正语法 2025-10-28 17:35:01 +08:00
Tim
06ecd39c8b Merge branch 'main' of github.com:nagisa77/OpenIsle
# Conflicts:
#	bots/instance/coffee_bot.ts
2025-10-28 17:34:47 +08:00
Tim
f0ba00b7e8 fix: 修正抽奖贴问题 2025-10-28 17:33:23 +08:00
Tim
092c4c36c2 Merge pull request #1116 from nagisa77/codex/create-post-for-coffee-bot-with-categories-and-tags
Update coffee bot post metadata instructions
2025-10-28 17:27:52 +08:00
Tim
db13f8145d Update coffee bot post metadata instructions 2025-10-28 17:27:37 +08:00
Tim
3be396976a Merge pull request #1115 from nagisa77/codex/fix-coffee-bot-mcp-service-error
Validate MCP post creation inputs
2025-10-28 17:23:21 +08:00
Tim
3fbaa332fc Validate required fields for MCP post creation 2025-10-28 17:22:58 +08:00
Tim
4e6cb59753 fix: 修正语法问题 2025-10-28 16:49:44 +08:00
Tim
1c6c17e577 fix: 修正语法问题 2025-10-28 16:47:00 +08:00
Tim
c968efa42a Revert "Fix search client reply argument order"
This reverts commit 7a2cf829c7.
2025-10-28 16:38:53 +08:00
Tim
0cd5ded39b Merge pull request #1114 from nagisa77/codex/fix-syntaxerror-in-search_client.py
Fix reply methods argument order in MCP search client
2025-10-28 16:33:05 +08:00
Tim
7a2cf829c7 Fix search client reply argument order 2025-10-28 16:32:53 +08:00
Tim
12329b43d1 Merge pull request #1113 from nagisa77/codex/pass-bearer-token-to-backend-apis
feat: forward Authorization header for MCP backend requests
2025-10-28 16:22:17 +08:00
Tim
1a45603e0f feat: forward authorization headers to backend 2025-10-28 16:22:04 +08:00
Tim
4a73503399 Merge pull request #1112 from nagisa77/codex/fix-authorization-parameter-error
Fix hosted MCP authorization configuration
2025-10-28 16:00:07 +08:00
Tim
83bf8c1d5e Fix hosted MCP auth header collision 2025-10-28 15:59:57 +08:00
Tim
34e206f05d Merge pull request #1111 from nagisa77/codex/refactor-getbaseinstructions-to-remove-openisletoken
Inject OpenIsle token into MCP requests
2025-10-28 15:57:04 +08:00
Tim
dc349923e9 Inject OpenIsle token into MCP requests 2025-10-28 15:56:51 +08:00
Tim
0d44c9a823 Merge pull request #1110 from nagisa77/codex/update-coffee-bot-prize-image
Update coffee bot prize image instructions
2025-10-28 15:21:23 +08:00
49 changed files with 1154 additions and 304 deletions

View File

@@ -2,7 +2,7 @@ name: Coffee Bot
on:
schedule:
- cron: "0 1 * * *"
- cron: "0 23 * * 0-4"
workflow_dispatch:
jobs:
@@ -26,4 +26,5 @@ jobs:
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENISLE_TOKEN: ${{ secrets.OPENISLE_TOKEN }}
APIFY_API_TOKEN: ${{ secrets.APIFY_API_TOKEN }}
run: npx tsx bots/instance/coffee_bot.ts

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

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

View File

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

View File

@@ -26,4 +26,5 @@ jobs:
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENISLE_TOKEN: ${{ secrets.OPENISLE_TOKEN }}
APIFY_API_TOKEN: ${{ secrets.APIFY_API_TOKEN }}
run: npx tsx bots/instance/reply_bot.ts

View File

@@ -57,6 +57,9 @@ cd OpenIsle
--profile dev up -d --force-recreate
```
数据初始化sql会创建几个帐户供大家测试使用
> username:admin/user1/user2 password:123456
3. 查看服务状态:
```shell
docker compose -f docker/docker-compose.yaml --env-file .env ps

View File

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

View File

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

View File

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

View File

@@ -217,11 +217,7 @@ public class PostController {
// userVisitService.recordVisit(auth.getName());
// }
return postService
.defaultListPosts(ids, tids, page, pageSize)
.stream()
.map(postMapper::toSummaryDto)
.collect(Collectors.toList());
return postMapper.toListDtos(postService.defaultListPosts(ids, tids, page, pageSize));
}
@GetMapping("/recent")
@@ -269,11 +265,7 @@ public class PostController {
// userVisitService.recordVisit(auth.getName());
// }
return postService
.listPostsByViews(ids, tids, page, pageSize)
.stream()
.map(postMapper::toSummaryDto)
.collect(Collectors.toList());
return postMapper.toListDtos(postService.listPostsByViews(ids, tids, page, pageSize));
}
@GetMapping("/latest-reply")
@@ -305,8 +297,7 @@ public class PostController {
// userVisitService.recordVisit(auth.getName());
// }
List<Post> posts = postService.listPostsByLatestReply(ids, tids, page, pageSize);
return posts.stream().map(postMapper::toSummaryDto).collect(Collectors.toList());
return postMapper.toListDtos(postService.listPostsByLatestReply(ids, tids, page, pageSize));
}
@GetMapping("/featured")
@@ -333,10 +324,6 @@ public class PostController {
// if (auth != null) {
// userVisitService.recordVisit(auth.getName());
// }
return postService
.listFeaturedPosts(ids, tids, page, pageSize)
.stream()
.map(postMapper::toSummaryDto)
.collect(Collectors.toList());
return postMapper.toListDtos(postService.listFeaturedPosts(ids, tids, page, pageSize));
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -48,6 +48,38 @@ public class PostMapper {
return dto;
}
public List<PostSummaryDto> toListDtos(List<Post> posts) {
if (posts == null || posts.isEmpty()) {
return List.of();
}
Map<Long, List<User>> participantsMap = commentService.getParticipantsForPosts(posts, 5);
return posts
.stream()
.map(post -> {
PostSummaryDto dto = new PostSummaryDto();
applyListFields(post, dto);
List<User> participants = participantsMap.get(post.getId());
if (participants != null) {
dto.setParticipants(
participants.stream().map(userMapper::toAuthorDto).collect(Collectors.toList())
);
} else {
dto.setParticipants(List.of());
}
dto.setReactions(List.of());
return dto;
})
.collect(Collectors.toList());
}
public PostSummaryDto toListDto(Post post) {
PostSummaryDto dto = new PostSummaryDto();
applyListFields(post, dto);
dto.setParticipants(List.of());
dto.setReactions(List.of());
return dto;
}
public PostDetailDto toDetailDto(Post post, String viewer) {
PostDetailDto dto = new PostDetailDto();
applyCommon(post, dto);
@@ -61,6 +93,25 @@ public class PostMapper {
return dto;
}
private void applyListFields(Post post, PostSummaryDto dto) {
dto.setId(post.getId());
dto.setTitle(post.getTitle());
dto.setContent(post.getContent());
dto.setCreatedAt(post.getCreatedAt());
dto.setAuthor(userMapper.toAuthorDto(post.getAuthor()));
dto.setCategory(categoryMapper.toDto(post.getCategory()));
dto.setTags(post.getTags().stream().map(tagMapper::toDto).collect(Collectors.toList()));
dto.setViews(post.getViews());
dto.setCommentCount(post.getCommentCount());
dto.setStatus(post.getStatus());
dto.setPinnedAt(post.getPinnedAt());
dto.setLastReplyAt(post.getLastReplyAt());
dto.setRssExcluded(post.getRssExcluded() == null || post.getRssExcluded());
dto.setClosed(post.isClosed());
dto.setVisibleScope(post.getVisibleScope());
dto.setType(post.getType());
}
private void applyCommon(Post post, PostSummaryDto dto) {
dto.setId(post.getId());
dto.setTitle(post.getTitle());

View File

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

View File

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

View File

@@ -25,6 +25,13 @@ public interface CommentRepository extends JpaRepository<Comment, Long> {
@org.springframework.data.repository.query.Param("post") Post post
);
@org.springframework.data.jpa.repository.Query(
"SELECT DISTINCT c.post.id, c.author FROM Comment c WHERE c.post.id IN :postIds"
)
java.util.List<Object[]> findDistinctAuthorsByPostIds(
@org.springframework.data.repository.query.Param("postIds") java.util.List<Long> postIds
);
@org.springframework.data.jpa.repository.Query(
"SELECT MAX(c.createdAt) FROM Comment c WHERE c.post = :post"
)

View File

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

View File

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

View File

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

View File

@@ -21,8 +21,12 @@ import com.openisle.service.NotificationService;
import com.openisle.service.PointService;
import com.openisle.service.SubscriptionService;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
@@ -316,6 +320,37 @@ public class CommentService {
return result;
}
public Map<Long, List<User>> getParticipantsForPosts(List<Post> posts, int limit) {
if (posts == null || posts.isEmpty()) {
return Map.of();
}
Map<Long, LinkedHashSet<User>> map = new HashMap<>();
List<Long> postIds = new ArrayList<>(posts.size());
for (Post post : posts) {
postIds.add(post.getId());
LinkedHashSet<User> set = new LinkedHashSet<>();
set.add(post.getAuthor());
map.put(post.getId(), set);
}
for (Object[] row : commentRepository.findDistinctAuthorsByPostIds(postIds)) {
Long postId = (Long) row[0];
User author = (User) row[1];
LinkedHashSet<User> set = map.get(postId);
if (set != null) {
set.add(author);
}
}
Map<Long, List<User>> result = new HashMap<>(map.size());
for (Map.Entry<Long, LinkedHashSet<User>> entry : map.entrySet()) {
List<User> list = new ArrayList<>(entry.getValue());
if (list.size() > limit) {
list = list.subList(0, limit);
}
result.put(entry.getKey(), list);
}
return result;
}
public java.util.List<Comment> getCommentsByIds(java.util.List<Long> ids) {
log.debug("getCommentsByIds called for ids {}", ids);
java.util.List<Comment> comments = commentRepository.findAllById(ids);

View File

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

View File

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

View File

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

View File

@@ -19,6 +19,7 @@ import com.openisle.repository.TagRepository;
import com.openisle.repository.UserRepository;
import com.openisle.search.SearchIndexEventPublisher;
import com.openisle.service.EmailSender;
import com.openisle.exception.EmailSendException;
import java.time.Duration;
import java.time.LocalDateTime;
import java.time.ZoneId;
@@ -338,6 +339,7 @@ public class PostService {
post.setCategory(category);
post.setTags(new HashSet<>(tags));
post.setStatus(publishMode == PublishMode.REVIEW ? PostStatus.PENDING : PostStatus.PUBLISHED);
post.setLastReplyAt(LocalDateTime.now());
// 什么都没设置的情况下默认为ALL
if (Objects.isNull(postVisibleScopeType)) {
@@ -663,11 +665,15 @@ public class PostService {
w.getEmail() != null &&
!w.getDisabledEmailNotificationTypes().contains(NotificationType.LOTTERY_WIN)
) {
emailSender.sendEmail(
w.getEmail(),
"你中奖了",
"恭喜你在抽奖贴 \"" + lp.getTitle() + "\" 中获奖"
);
try {
emailSender.sendEmail(
w.getEmail(),
"你中奖了",
"恭喜你在抽奖贴 \"" + lp.getTitle() + "\" 中获奖"
);
} catch (EmailSendException e) {
log.warn("Failed to send lottery win email to {}: {}", w.getEmail(), e.getMessage());
}
}
notificationService.createNotification(
w,
@@ -693,11 +699,19 @@ public class PostService {
.getDisabledEmailNotificationTypes()
.contains(NotificationType.LOTTERY_DRAW)
) {
emailSender.sendEmail(
lp.getAuthor().getEmail(),
"抽奖已开奖",
"您的抽奖贴 \"" + lp.getTitle() + "\" 已开奖"
);
try {
emailSender.sendEmail(
lp.getAuthor().getEmail(),
"抽奖已开奖",
"您的抽奖贴 \"" + lp.getTitle() + "\" 已开奖"
);
} catch (EmailSendException e) {
log.warn(
"Failed to send lottery draw email to {}: {}",
lp.getAuthor().getEmail(),
e.getMessage()
);
}
}
notificationService.createNotification(
lp.getAuthor(),
@@ -796,9 +810,10 @@ public class PostService {
boolean hasTags = tagIds != null && !tagIds.isEmpty();
java.util.List<Post> posts;
Pageable pageable = buildPageable(page, pageSize);
if (!hasCategories && !hasTags) {
posts = postRepository.findByStatusOrderByViewsDesc(PostStatus.PUBLISHED);
posts = postRepository.findByStatusOrderByPinnedAtDescViewsDesc(PostStatus.PUBLISHED, pageable);
} else if (hasCategories) {
java.util.List<Category> categories = categoryRepository.findAllById(categoryIds);
if (categories.isEmpty()) {
@@ -809,16 +824,18 @@ public class PostService {
if (tags.isEmpty()) {
return java.util.List.of();
}
posts = postRepository.findByCategoriesAndAllTagsOrderByViewsDesc(
posts = postRepository.findByCategoriesAndAllTagsOrderByPinnedAtDescViewsDesc(
categories,
tags,
PostStatus.PUBLISHED,
tags.size()
tags.size(),
pageable
);
} else {
posts = postRepository.findByCategoryInAndStatusOrderByViewsDesc(
posts = postRepository.findByCategoryInAndStatusOrderByPinnedAtDescViewsDesc(
categories,
PostStatus.PUBLISHED
PostStatus.PUBLISHED,
pageable
);
}
} else {
@@ -826,10 +843,15 @@ public class PostService {
if (tags.isEmpty()) {
return java.util.List.of();
}
posts = postRepository.findByAllTagsOrderByViewsDesc(tags, PostStatus.PUBLISHED, tags.size());
posts = postRepository.findByAllTagsOrderByPinnedAtDescViewsDesc(
tags,
PostStatus.PUBLISHED,
tags.size(),
pageable
);
}
return paginate(sortByPinnedAndViews(posts), page, pageSize);
return posts;
}
public List<Post> listPostsByLatestReply(Integer page, Integer pageSize) {
@@ -846,9 +868,13 @@ public class PostService {
boolean hasTags = tagIds != null && !tagIds.isEmpty();
java.util.List<Post> posts;
Pageable pageable = buildPageable(page, pageSize);
if (!hasCategories && !hasTags) {
posts = postRepository.findByStatusOrderByCreatedAtDesc(PostStatus.PUBLISHED);
posts = postRepository.findByStatusOrderByPinnedAtDescLastReplyAtDesc(
PostStatus.PUBLISHED,
pageable
);
} else if (hasCategories) {
java.util.List<Category> categories = categoryRepository.findAllById(categoryIds);
if (categories.isEmpty()) {
@@ -859,16 +885,18 @@ public class PostService {
if (tags.isEmpty()) {
return java.util.List.of();
}
posts = postRepository.findByCategoriesAndAllTagsOrderByCreatedAtDesc(
posts = postRepository.findByCategoriesAndAllTagsOrderByPinnedAtDescLastReplyAtDesc(
categories,
tags,
PostStatus.PUBLISHED,
tags.size()
tags.size(),
pageable
);
} else {
posts = postRepository.findByCategoryInAndStatusOrderByCreatedAtDesc(
posts = postRepository.findByCategoryInAndStatusOrderByPinnedAtDescLastReplyAtDesc(
categories,
PostStatus.PUBLISHED
PostStatus.PUBLISHED,
pageable
);
}
} else {
@@ -876,14 +904,15 @@ public class PostService {
if (tags.isEmpty()) {
return new ArrayList<>();
}
posts = postRepository.findByAllTagsOrderByCreatedAtDesc(
posts = postRepository.findByAllTagsOrderByPinnedAtDescLastReplyAtDesc(
tags,
PostStatus.PUBLISHED,
tags.size()
tags.size(),
pageable
);
}
return paginate(sortByPinnedAndLastReply(posts), page, pageSize);
return posts;
}
public List<Post> listPostsByCategories(
@@ -1381,6 +1410,13 @@ public class PostService {
.toList();
}
private Pageable buildPageable(Integer page, Integer pageSize) {
if (page == null || pageSize == null) {
return Pageable.unpaged();
}
return PageRequest.of(page, pageSize);
}
private List<Post> paginate(List<Post> posts, Integer page, Integer pageSize) {
if (page == null || pageSize == null) {
return posts;

View File

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

View File

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

View File

@@ -20,6 +20,7 @@ CREATE TABLE IF NOT EXISTS `users` (
`username` varchar(50) NOT NULL,
`verification_code` varchar(255) DEFAULT NULL,
`verified` bit(1) DEFAULT NULL,
`is_bot` bit(1) NOT NULL DEFAULT b'0',
PRIMARY KEY (`id`),
UNIQUE KEY `UK_users_email` (`email`),
UNIQUE KEY `UK_users_username` (`username`)

View File

@@ -8,10 +8,28 @@ DELETE FROM `users`;
-- 插入用户,两个普通用户,一个管理员
-- username:admin/user1/user2 password:123456
INSERT INTO `users` (`id`, `approved`, `avatar`, `created_at`, `display_medal`, `email`, `experience`, `introduction`, `password`, `password_reset_code`, `point`, `register_reason`, `role`, `username`, `verification_code`, `verified`) VALUES
(1, b'1', 'https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/image.png', '2025-09-01 16:08:17.426430', 'PIONEER', 'adminmail@openisle.com', 70, NULL, '$2a$10$x7HXjUyJTmrvqjnBlBQZH.vmfsC56NzTSWqQ6WqZqRjUO859EhviS', NULL, 110, '测试测试测试……', 'ADMIN', 'admin', NULL, b'1'),
(2, b'1', 'https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/image.png', '2025-09-03 16:08:17.426430', 'PIONEER', 'usermail2@openisle.com', 70, NULL, '$2a$10$x7HXjUyJTmrvqjnBlBQZH.vmfsC56NzTSWqQ6WqZqRjUO859EhviS', NULL, 110, '测试测试测试……', 'USER', 'user1', NULL, b'1'),
(3, b'1', 'https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/image.png', '2025-09-02 17:21:21.617666', 'PIONEER', 'usermail1@openisle.com', 40, NULL, '$2a$10$x7HXjUyJTmrvqjnBlBQZH.vmfsC56NzTSWqQ6WqZqRjUO859EhviS', NULL, 40, '测试测试测试……', 'USER', 'user2', NULL, b'1');
INSERT INTO `users` (
`id`,
`approved`,
`avatar`,
`created_at`,
`display_medal`,
`email`,
`experience`,
`introduction`,
`password`,
`password_reset_code`,
`point`,
`register_reason`,
`role`,
`username`,
`verification_code`,
`verified`,
`is_bot`
) VALUES
(1, b'1', 'https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/image.png', '2025-09-01 16:08:17.426430', 'PIONEER', 'adminmail@openisle.com', 70, NULL, '$2a$10$x7HXjUyJTmrvqjnBlBQZH.vmfsC56NzTSWqQ6WqZqRjUO859EhviS', NULL, 110, '测试测试测试……', 'ADMIN', 'admin', NULL, b'1', b'0'),
(2, b'1', 'https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/image.png', '2025-09-03 16:08:17.426430', 'PIONEER', 'usermail2@openisle.com', 70, NULL, '$2a$10$x7HXjUyJTmrvqjnBlBQZH.vmfsC56NzTSWqQ6WqZqRjUO859EhviS', NULL, 110, '测试测试测试……', 'USER', 'user1', NULL, b'1', b'0'),
(3, b'1', 'https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/image.png', '2025-09-02 17:21:21.617666', 'PIONEER', 'usermail1@openisle.com', 40, NULL, '$2a$10$x7HXjUyJTmrvqjnBlBQZH.vmfsC56NzTSWqQ6WqZqRjUO859EhviS', NULL, 40, '测试测试测试……', 'USER', 'user2', NULL, b'1', b'0');
INSERT INTO `categories` (`id`,`description`,`icon`,`name`,`small_icon`) VALUES
(1,'测试用分类1','star','测试用分类1',NULL),

View File

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

View File

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

View File

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

View File

@@ -1,48 +1,39 @@
import { Agent, Runner, hostedMcpTool, withTrace } from "@openai/agents";
import { Agent, Runner, hostedMcpTool, withTrace, webSearchTool } from "@openai/agents";
export type WorkflowInput = { input_as_text: string };
export abstract class BotFather {
protected readonly allowedMcpTools = [
"search",
"create_post",
"reply_to_post",
"reply_to_comment",
"recent_posts",
"get_post",
"list_unread_messages",
"mark_notifications_read",
"create_post",
];
protected readonly openisleToken = (process.env.OPENISLE_TOKEN ?? "").trim();
protected readonly weatherToken = (process.env.APIFY_API_TOKEN ?? "").trim();
protected readonly mcp = hostedMcpTool({
serverLabel: "openisle_mcp",
serverUrl: "https://www.open-isle.com/mcp",
allowedTools: this.allowedMcpTools,
requireApproval: "never",
});
protected readonly openisleToken = process.env.OPENISLE_TOKEN ?? "";
protected readonly openisleMcp = this.createHostedMcpTool();
protected readonly weatherMcp = this.createWeatherMcpTool();
protected readonly webSearchPreview = this.createWebSearchPreviewTool();
protected readonly agent: Agent;
constructor(protected readonly name: string) {
console.log(`${this.name} starting...`);
console.log(
"🛠️ Configured Hosted MCP tools:",
this.allowedMcpTools.join(", ")
this.openisleToken
? "🔑 OPENISLE_TOKEN detected in environment; it will be attached to MCP requests."
: "🔓 OPENISLE_TOKEN not set; authenticated MCP tools may be unavailable."
);
console.log(
this.openisleToken
? "🔑 OPENISLE_TOKEN detected in environment."
: "🔓 OPENISLE_TOKEN not set; agent will request it if required."
this.weatherToken
? "☁️ APIFY_API_TOKEN detected; weather MCP server will be available."
: "🌥️ APIFY_API_TOKEN not set; weather updates will be unavailable."
);
this.agent = new Agent({
name: this.name,
instructions: this.buildInstructions(),
tools: [this.mcp],
model: "gpt-4o",
tools: [
this.openisleMcp,
this.weatherMcp,
this.webSearchPreview
],
model: this.getModel(),
modelSettings: {
temperature: 0.7,
topP: 1,
@@ -66,17 +57,73 @@ export abstract class BotFather {
"You are a helpful assistant for https://www.open-isle.com.",
"Finish tasks end-to-end before replying. If multiple MCP tools are needed, call them sequentially until the task is truly done.",
"When presenting the result, reply in Chinese with a concise summary and include any important URLs or IDs.",
this.openisleToken
? `If tools require auth, use this token exactly where the tool schema expects it: ${this.openisleToken}`
: "If a tool requires auth, ask me to provide OPENISLE_TOKEN via env.",
"After finishing replies, call mark_notifications_read with all processed notification IDs to keep the inbox clean.",
];
}
private createWebSearchPreviewTool() {
return webSearchTool({
userLocation: {
type: "approximate",
country: undefined,
region: undefined,
city: undefined,
timezone: undefined
},
searchContextSize: "medium"
})
}
private createHostedMcpTool() {
const token = this.openisleToken;
const authConfig = token
? {
headers: {
Authorization: `Bearer ${token}`,
},
}
: {};
return hostedMcpTool({
serverLabel: "openisle_mcp",
serverUrl: "https://www.open-isle.com/mcp",
allowedTools: [
"search", // 用于搜索帖子、内容等
"create_post", // 创建新帖子
"reply_to_post", // 回复帖子
"reply_to_comment", // 回复评论
"recent_posts", // 获取最新帖子
"get_post", // 获取特定帖子的详细信息
"list_unread_messages", // 列出未读消息或通知
"mark_notifications_read", // 标记通知为已读
],
requireApproval: "never",
...authConfig,
});
}
private createWeatherMcpTool(): ReturnType<typeof hostedMcpTool> {
return hostedMcpTool({
serverLabel: "weather_mcp_server",
serverUrl: "https://jiri-spilka--weather-mcp-server.apify.actor/mcp",
requireApproval: "never",
allowedTools: [
"get_current_weather", // 天气 MCP 工具
],
headers: {
Authorization: `Bearer ${this.weatherToken || ""}`,
},
});
}
protected getAdditionalInstructions(): string[] {
return [];
}
protected getModel(): string {
return "gpt-4o-mini";
}
protected createRunner(): Runner {
return new Runner({
workflowName: this.name,

View File

@@ -9,44 +9,37 @@ class CoffeeBot extends BotFather {
protected override getAdditionalInstructions(): string[] {
return [
"记住你的系统代号是 system有需要自称或签名时都要使用这个名字。",
"You are responsible for 发布每日抽奖早安贴。",
"创建帖子时,确保标题、奖品信息、开奖时间以及领奖方式完全符合 CLI 查询提供的细节。",
"正文需亲切友好,简洁明了,鼓励社区成员互动。",
"开奖说明需明确告知中奖者需私聊站长 @nagisa 领取奖励。",
"确保只发布一个帖子,避免重复调用 create_post。",
"使用标签为 weather_mcp_server 的 MCP 工具获取北京、上海、广州、深圳当天的天气信息,并把结果写入早安问候之后。",
];
}
protected override getCliQuery(): string {
const now = new Date();
const beijingNow = new Date(
now.toLocaleString("en-US", { timeZone: "Asia/Shanghai" })
);
const weekday = WEEKDAY_NAMES[beijingNow.getDay()];
const drawTime = new Date(beijingNow);
const now = new Date(Date.now() + 8 * 60 * 60 * 1000);
const weekday = WEEKDAY_NAMES[now.getDay()];
const drawTime = new Date(now);
drawTime.setHours(15, 0, 0, 0);
const drawTimeText = drawTime
.toLocaleTimeString("zh-CN", {
hour: "2-digit",
minute: "2-digit",
hour12: false,
timeZone: "Asia/Shanghai",
})
.replace(/^24:/, "00:");
return `
请立即在 https://www.open-isle.com 使用 create_post 发表一篇全新帖子,遵循以下要求:
请立即在 https://www.open-isle.com 使用 create_post 发表一篇帖子,遵循以下要求:
1. 标题固定为「大家星期${weekday}早安--抽一杯咖啡」。
2. 正文包含:
- 亲切的早安问候;
- 明确奖品写作“Coffee x 1”
- 奖品图片链接https://openisle-1307107697.cos.accelerate.myqcloud.com/dynamic_assert/0d6a9b33e9ca4fe5a90540187d3f9ecb.png
- 公布开奖时间为今天下午 15:00北京时间写成 ${drawTimeText}
- 标注“领奖请私聊站长 @nagisa”
- 早安问候后立即列出北京、上海、广州、深圳当天的天气信息,每行格式为“城市:天气描述,最低温~最高温”;天气需调用 weather_mcp_server 获取
- 标注“领奖请私聊站长 @[nagisa]”
- 鼓励大家留言互动。
3. 帖子语言使用简体中文,格式可用 Markdown使关键信息醒目。
4. 完成后只输出“已发布咖啡抽奖贴”,不额外生成总结。
3. 奖品信息
- 明确奖品写作“Coffee”
- 帖子类型必须为 LOTTERY
- 奖品图片链接https://openisle-1307107697.cos.accelerate.myqcloud.com/dynamic_assert/0d6a9b33e9ca4fe5a90540187d3f9ecb.png
- 公布开奖时间为 ${drawTime}, 直接传UTC时间给接口不要考虑时区问题
- categoryId 固定为 10tagIds 设为 [36]。
4. 帖子语言使用简体中文。
`.trim();
}
}

View File

@@ -0,0 +1,69 @@
import { BotFather, WorkflowInput } from "../bot_father";
const WEEKDAY_NAMES = ["日", "一", "二", "三", "四", "五", "六"] as const;
class DailyNewsBot extends BotFather {
constructor() {
super("Daily News Bot");
}
protected override getModel(): string {
return "gpt-4o";
}
protected override getAdditionalInstructions(): string[] {
return [
"You are DailyNewsBot专职在 OpenIsle 发布每日新闻速递。",
"始终使用简体中文回复,并以结构化 Markdown 呈现内容。",
"发布内容前务必完成资讯核实:分别通过 web_search 调研 CoinDesk 所有要闻、Reuters 重点国际新闻,以及全球 AI 领域的重大进展。",
"整合新闻时,将同源资讯合并,突出影响力、涉及主体与潜在影响,保持语句简洁。",
"所有新闻要点都要附带来源链接,并在括号中标注来源站点名。",
"使用 weather_mcp_server 的 get_current_weather 获取北京、上海、广州、深圳的天气,并在正文中列表展示",
"正文结尾补充一个行动建议或提醒,帮助读者快速把握重点。",
"严禁发布超过一篇帖子create_post 只调用一次。",
];
}
protected override getCliQuery(): string {
const now = new Date(Date.now() + 8 * 60 * 60 * 1000);
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, "0");
const day = String(now.getDate()).padStart(2, "0");
const weekday = WEEKDAY_NAMES[now.getDay()];
const dateLabel = `${year}${month}${day}日 星期${weekday}`;
const isoDate = `${year}-${month}-${day}`;
const categoryId = Number(process.env.DAILY_NEWS_CATEGORY_ID ?? "6");
const tagIdsEnv = process.env.DAILY_NEWS_TAG_IDS ?? "3,33";
const tagIds = tagIdsEnv
.split(",")
.map((id) => Number(id.trim()))
.filter((id) => !Number.isNaN(id));
const finalTagIds = tagIds.length > 0 ? tagIds : [1];
const tagIdsText = `[${finalTagIds.join(", ")}]`;
return `
请立即在 https://www.open-isle.com 使用 create_post 发布一篇名为「OpenIsle 每日新闻速递|${dateLabel}」的帖子,并遵循以下要求:
1. 发布类型为 NORMALcategoryId = ${categoryId}tagIds = ${tagIdsText}
2. 正文以简洁问候开头, 不用再重复标题
3. 使用 web_search 工具按以下顺序收集资讯,并在正文中以 Markdown 小节呈现, 需要调用3次web_search
- 「全球区块链与加密」:汇总 coindesk.com 版面所有重点新闻, 列出至少5条
- 「国际新闻速览」:汇总 reuters.com 版面重点头条关注宏观经济、市场波动或政策变化。列出至少5条
- 「AI 行业快讯」:检索今天全球 AI 领域的重要发布或事件(例如 OpenAI、Google、Meta、国内大模型厂商等。列出至少5条
4. 每条新闻采用项目符号,先写结论再给出关键数字或细节,末尾添加来源超链接,格式示例:「**结论** —— 关键细节。(来源:[Reuters](URL))」
5. 资讯整理完毕后,调用 weather_mcp_server.get_current_weather列出北京、上海、广州、深圳今日天气放置在「城市天气」小节下, 本小节可加emoji。
6. 最后一节为「今日提醒」,给出 2-3 条与新闻或天气相关的行动建议。
7. 若在资讯搜集过程中发现相互矛盾的信息,须在正文中以「⚠️ 风险提示」说明原因及尚待确认的点。
9. 发布完成后,不要再次调用 create_post。
`.trim();
}
}
const dailyNewsBot = new DailyNewsBot();
export const runWorkflow = async (workflow: WorkflowInput) => {
return dailyNewsBot.runWorkflow(workflow);
};
if (require.main === module) {
dailyNewsBot.runCli();
}

View File

@@ -0,0 +1,65 @@
import { readFileSync } from "node:fs";
import path from "node:path";
import { BotFather, WorkflowInput } from "../bot_father";
class OpenSourceReplyBot extends BotFather {
constructor() {
super("OpenSource Reply Bot");
}
protected override getAdditionalInstructions(): string[] {
const knowledgeBase = this.loadKnowledgeBase();
return [
"You are OpenSourceReplyBot, a professional helper who focuses on answering open-source development and code-related questions for the OpenIsle community.",
"Respond in Chinese using well-structured Markdown sections such as 标题、列表、代码块等,让回复清晰易读。",
"保持语气专业、耐心、详尽,绝不使用表情符号或颜文字,也不要卖萌。",
"优先解答与项目代码、贡献流程、架构设计或排错相关的问题;",
"在需要时引用 README.md 与 CONTRIBUTING.md 中的要点,帮助用户快速定位文档位置。",
knowledgeBase,
].filter(Boolean);
}
protected override getCliQuery(): string {
return `
【AUTO】每30分钟自动巡检未读提及与评论严格遵守以下流程
1调用 list_unread_messages 获取待处理的“提及/评论”;
2按时间从新到旧逐条处理最多10条如需上下文请调用 get_post
3仅对与开源项目、代码实现或贡献流程直接相关的问题生成详尽的 Markdown 中文回复,
若与主题无关则礼貌说明并跳过;
4回复时引用 README 或 CONTRIBUTING 中的要点(如适用),并优先给出可执行的排查步骤或代码建议;
5回复评论使用 reply_to_comment回复帖子使用 reply_to_post
6若某通知最后一条已由本 bot 回复,则跳过避免重复;
7整理已处理通知 ID 调用 mark_notifications_read
8结束时输出包含处理条目概览URL或ID的总结。`.trim();
}
private loadKnowledgeBase(): string {
const docs = ["../../README.md", "../../CONTRIBUTING.md"];
const sections: string[] = [];
for (const relativePath of docs) {
try {
const absolutePath = path.resolve(__dirname, relativePath);
const content = readFileSync(absolutePath, "utf-8").trim();
if (content) {
sections.push(`以下是 ${path.basename(absolutePath)} 的内容:\n${content}`);
}
} catch (error) {
sections.push(`未能加载 ${relativePath},请检查文件路径或权限。`);
}
}
return sections.join("\n\n");
}
}
const openSourceReplyBot = new OpenSourceReplyBot();
export const runWorkflow = async (workflow: WorkflowInput) => {
return openSourceReplyBot.runWorkflow(workflow);
};
if (require.main === module) {
openSourceReplyBot.runCli();
}

View File

@@ -8,10 +8,9 @@ class ReplyBot extends BotFather {
protected override getAdditionalInstructions(): string[] {
return [
"You are a helpful and cute assistant for https://www.open-isle.com. Keep the lovable tone with plentiful kawaii kaomoji (颜表情) such as (๑˃ᴗ˂)ﻭ, (•̀ω•́)✧, (。•ᴗ-)_♡, (⁎⁍̴̛ᴗ⁍̴̛⁎), etc., while staying professional and informative.",
"OpenIsle 是一个由 Spring Boot + Vue 3 打造的开源社区平台提供注册登录、OAuth 登录Google/GitHub/Discord/Twitter、帖子与评论互动、标签分类、草稿、统计分析、通知消息、全局搜索、Markdown 支持、图片上传(默认腾讯云 COS、浏览器推送、DiceBear 头像等功能,旨在帮助团队快速搭建属于自己的技术社区。",
"回复时请主动结合上述站点背景,为用户提供有洞察力、可执行的建议或答案,并在需要时引用官网 https://www.open-isle.com、GitHub 仓库 https://github.com/nagisa77/OpenIsle 或相关文档链接,避免空泛的安慰或套话。",
"When presenting the result, reply in Chinese with a concise yet content-rich summary filled with kaomoji,并清晰列出关键结论、操作步骤、重要 URL 或 ID确保用户能直接采取行动。",
"记住你的系统代号是 system任何需要自称、署名或解释身份的时候都使用这个名字。",
"以阴阳怪气的方式回复各种互动",
"你每天会发布咖啡抽奖贴,跟大家互动",
];
}

View File

@@ -30,62 +30,62 @@ services:
- dev_local_backend
- prod
# OpenSearch Service
opensearch:
user: "1000:1000"
build:
context: .
dockerfile: opensearch.Dockerfile
container_name: ${COMPOSE_PROJECT_NAME}-opensearch
environment:
- cluster.name=os-single
- node.name=os-node-1
- discovery.type=single-node
- bootstrap.memory_lock=true
- OPENSEARCH_JAVA_OPTS=-Xms1g -Xmx1g
- DISABLE_SECURITY_PLUGIN=true
- cluster.blocks.create_index=false
ulimits:
memlock: { soft: -1, hard: -1 }
nofile: { soft: 65536, hard: 65536 }
volumes:
- opensearch-data:/usr/share/opensearch/data
- opensearch-snapshots:/snapshots
ports:
- "${OPENSEARCH_PORT:-9200}:9200"
- "${OPENSEARCH_METRICS_PORT:-9600}:9600"
restart: unless-stopped
healthcheck:
test:
- CMD-SHELL
- curl -fsS http://127.0.0.1:9200/_cluster/health >/dev/null
interval: 10s
timeout: 5s
retries: 30
start_period: 60s
networks:
- openisle-network
profiles:
- dev
- dev_local_backend
# # OpenSearch Service
# opensearch:
# user: "1000:1000"
# build:
# context: .
# dockerfile: opensearch.Dockerfile
# container_name: ${COMPOSE_PROJECT_NAME}-opensearch
# environment:
# - cluster.name=os-single
# - node.name=os-node-1
# - discovery.type=single-node
# - bootstrap.memory_lock=true
# - OPENSEARCH_JAVA_OPTS=-Xms1g -Xmx1g
# - DISABLE_SECURITY_PLUGIN=true
# - cluster.blocks.create_index=false
# ulimits:
# memlock: { soft: -1, hard: -1 }
# nofile: { soft: 65536, hard: 65536 }
# volumes:
# - opensearch-data:/usr/share/opensearch/data
# - opensearch-snapshots:/snapshots
# ports:
# - "${OPENSEARCH_PORT:-9200}:9200"
# - "${OPENSEARCH_METRICS_PORT:-9600}:9600"
# restart: unless-stopped
# healthcheck:
# test:
# - CMD-SHELL
# - curl -fsS http://127.0.0.1:9200/_cluster/health >/dev/null
# interval: 10s
# timeout: 5s
# retries: 30
# start_period: 60s
# networks:
# - openisle-network
# profiles:
# - dev
# - dev_local_backend
dashboards:
image: opensearchproject/opensearch-dashboards:3.0.0
container_name: ${COMPOSE_PROJECT_NAME}-os-dashboards
environment:
OPENSEARCH_HOSTS: '["http://opensearch:9200"]'
DISABLE_SECURITY_DASHBOARDS_PLUGIN: "true"
ports:
- "${OPENSEARCH_DASHBOARDS_PORT:-5601}:5601"
depends_on:
- opensearch
restart: unless-stopped
networks:
- openisle-network
profiles:
- dev
- dev_local_backend
- prod
# dashboards:
# image: opensearchproject/opensearch-dashboards:3.0.0
# container_name: ${COMPOSE_PROJECT_NAME}-os-dashboards
# environment:
# OPENSEARCH_HOSTS: '["http://opensearch:9200"]'
# DISABLE_SECURITY_DASHBOARDS_PLUGIN: "true"
# ports:
# - "${OPENSEARCH_DASHBOARDS_PORT:-5601}:5601"
# depends_on:
# - opensearch
# restart: unless-stopped
# networks:
# - openisle-network
# profiles:
# - dev
# - dev_local_backend
# - prod
rabbitmq:
image: rabbitmq:3.13-management
@@ -200,7 +200,6 @@ services:
- openisle-network
profiles:
- dev
- dev_local_backend
- prod

View File

@@ -16,6 +16,7 @@
<div class="info-content-header-left">
<span class="user-name">{{ comment.userName }}</span>
<span v-if="isCommentFromPostAuthor" class="op-badge" title="楼主">OP</span>
<span v-if="comment.isBot" class="bot-badge" title="Bot">Bot</span>
<medal-one class="medal-icon" />
<NuxtLink
v-if="comment.medal"
@@ -522,6 +523,21 @@ const handleContentClick = (e) => {
line-height: 1;
}
.bot-badge {
display: inline-flex;
align-items: center;
justify-content: center;
margin-left: 6px;
padding: 0 6px;
height: 18px;
border-radius: 9px;
background-color: rgba(76, 175, 80, 0.16);
color: #2e7d32;
font-size: 12px;
font-weight: 600;
line-height: 1;
}
.medal-icon {
font-size: 12px;
opacity: 0.6;

View File

@@ -0,0 +1,149 @@
<template>
<div class="timeline-container">
<div class="timeline-header">
<div class="timeline-title">浏览了文章</div>
<div class="timeline-date">{{ formattedDate }}</div>
</div>
<div class="article-container">
<NuxtLink :to="postLink" class="timeline-article-link">
{{ item.post?.title }}
</NuxtLink>
<div class="timeline-snippet">
{{ strippedSnippet }}
</div>
<div class="article-meta" v-if="hasMeta">
<ArticleCategory v-if="item.post?.category" :category="item.post.category" />
<ArticleTags :tags="item.post?.tags" />
<div class="article-comment-count" v-if="item.post?.commentCount !== undefined">
<comment-one class="article-comment-count-icon" />
<span>{{ item.post?.commentCount }}</span>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { stripMarkdown } from '~/utils/markdown'
import TimeManager from '~/utils/time'
const props = defineProps({
item: { type: Object, required: true },
})
const postLink = computed(() => {
const id = props.item.post?.id
return id ? `/posts/${id}` : '#'
})
const formattedDate = computed(() =>
TimeManager.format(props.item.lastReadAt ?? props.item.createdAt),
)
const strippedSnippet = computed(() => stripMarkdown(props.item.post?.snippet ?? ''))
const hasMeta = computed(() => {
const tags = props.item.post?.tags ?? []
const hasTags = Array.isArray(tags) && tags.length > 0
const hasCategory = !!props.item.post?.category
const hasCommentCount =
props.item.post?.commentCount !== undefined && props.item.post?.commentCount !== null
return hasTags || hasCategory || hasCommentCount
})
</script>
<style scoped>
.timeline-container {
display: flex;
flex-direction: column;
padding-top: 5px;
gap: 12px;
border-radius: 10px;
background: var(--timeline-card-background, transparent);
}
.timeline-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.timeline-title {
font-size: 16px;
font-weight: 600;
}
.timeline-date {
font-size: 12px;
color: var(--timeline-date-color, #888);
}
.article-container {
display: flex;
flex-direction: column;
border: 1px solid var(--normal-border-color);
border-radius: 10px;
padding: 10px;
gap: 6px;
}
.timeline-article-link {
font-size: 18px;
font-weight: 600;
color: var(--link-color);
text-decoration: none;
}
.timeline-article-link:hover {
text-decoration: underline;
}
.timeline-snippet {
color: var(--timeline-snippet-color, #666);
font-size: 14px;
line-height: 1.6;
}
.article-meta {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.article-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.article-tag {
background-color: var(--article-info-background-color);
border-radius: 6px;
padding: 2px 6px;
font-size: 12px;
color: var(--text-color);
}
.article-comment-count {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: var(--text-color);
}
.article-comment-count-icon {
width: 16px;
height: 16px;
}
@media (max-width: 768px) {
.timeline-article-link {
font-size: 16px;
}
.timeline-snippet {
font-size: 13px;
}
}
</style>

View File

@@ -58,12 +58,15 @@ const submitLogin = async () => {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: username.value, password: password.value }),
})
const data = await res.json()
const data = await res.json().catch(() => ({}))
if (res.ok && data.token) {
setToken(data.token)
toast.success('登录成功')
registerPush()
await navigateTo('/', { replace: true })
} else if (data.reason_code === 'EMAIL_SEND_FAILED') {
const msg = data.error || data.message || res.statusText || '登录失败'
toast.error(`${res.status} ${msg} (${data.reason_code})`)
} else if (data.reason_code === 'NOT_VERIFIED') {
toast.info('当前邮箱未验证,已经为您重新发送验证码')
await navigateTo(
@@ -76,10 +79,12 @@ const submitLogin = async () => {
} else if (data.reason_code === 'NOT_APPROVED') {
await navigateTo({ path: '/signup-reason', query: { token: data.token } }, { replace: true })
} else {
toast.error(data.error || '登录失败')
const msg = data.error || data.message || res.statusText || '登录失败'
const reason = data.reason_code ? ` (${data.reason_code})` : ''
toast.error(`${res.status} ${msg}${reason}`)
}
} catch (e) {
toast.error('登录失败')
toast.error(`登录失败: ${e.message}`)
} finally {
isWaitingForLogin.value = false
}

View File

@@ -377,6 +377,7 @@ const mapComment = (
text: c.content,
reactions: c.reactions || [],
pinned: Boolean(c.pinned ?? c.pinnedAt ?? c.pinned_at),
isBot: Boolean(c.author?.bot),
reply: (c.replies || []).map((r) =>
mapComment(r, c.author.username, c.author.avatar, c.author.id, level + 1),
),
@@ -532,7 +533,7 @@ const {
} catch (err) {}
},
{
server: false,
server: true,
lazy: false,
},
)
@@ -1394,10 +1395,6 @@ onMounted(async () => {
font-weight: bold;
}
.reaction-action.copy-link:hover {
background-color: #e2e2e2;
}
.comment-editor-wrapper {
position: relative;
}

View File

@@ -139,8 +139,7 @@ const sendVerification = async () => {
inviteToken: inviteToken.value,
}),
})
isWaitingForEmailSent.value = false
const data = await res.json()
const data = await res.json().catch(() => ({}))
if (res.ok) {
emailStep.value = 1
toast.success('验证码已发送,请查看邮箱')
@@ -149,10 +148,14 @@ const sendVerification = async () => {
if (data.field === 'email') emailError.value = data.error
if (data.field === 'password') passwordError.value = data.error
} else {
toast.error(data.error || '发送失败')
const msg = data.error || data.message || res.statusText || '发送失败'
const reason = data.reason_code ? ` (${data.reason_code})` : ''
toast.error(`${res.status} ${msg}${reason}`)
}
} catch (e) {
toast.error('发送失败')
toast.error(`发送失败: ${e.message}`)
} finally {
isWaitingForEmailSent.value = false
}
}

View File

@@ -191,14 +191,25 @@
>
评论和回复
</div>
<div
v-if="isMine"
:class="['timeline-tab-item', { selected: timelineFilter === 'reads' }]"
@click="timelineFilter = 'reads'"
>
浏览记录
</div>
</div>
<BasePlaceholder
v-if="filteredTimelineItems.length === 0"
text="暂无时间线"
v-if="
timelineFilter === 'reads'
? readPosts.length === 0
: filteredTimelineItems.length === 0
"
:text="timelineFilter === 'reads' ? '暂无浏览记录' : '暂无时间线'"
icon="inbox"
/>
<div class="timeline-list">
<BaseTimeline :items="filteredTimelineItems">
<BaseTimeline v-if="timelineFilter !== 'reads'" :items="filteredTimelineItems">
<template #item="{ item }">
<template v-if="item.type === 'post'">
<TimelinePostItem :item="item" />
@@ -214,6 +225,11 @@
</template>
</template>
</BaseTimeline>
<BaseTimeline v-else :items="readPosts">
<template #item="{ item }">
<TimelineReadItem :item="item" />
</template>
</BaseTimeline>
</div>
</div>
@@ -276,6 +292,7 @@ import BaseTabs from '~/components/BaseTabs.vue'
import LevelProgress from '~/components/LevelProgress.vue'
import TimelineCommentGroup from '~/components/TimelineCommentGroup.vue'
import TimelinePostItem from '~/components/TimelinePostItem.vue'
import TimelineReadItem from '~/components/TimelineReadItem.vue'
import TimelineTagItem from '~/components/TimelineTagItem.vue'
import BaseUserAvatar from '~/components/BaseUserAvatar.vue'
import UserList from '~/components/UserList.vue'
@@ -299,12 +316,15 @@ const hotReplies = ref([])
const hotTags = ref([])
const favoritePosts = ref([])
const timelineItems = ref([])
const readPosts = ref([])
const timelineFilter = ref('all')
const filteredTimelineItems = computed(() => {
if (timelineFilter.value === 'articles') {
return timelineItems.value.filter((item) => item.type === 'post')
} else if (timelineFilter.value === 'comments') {
return timelineItems.value.filter((item) => item.type === 'comment' || item.type === 'reply')
} else if (timelineFilter.value === 'reads') {
return []
}
return timelineItems.value
})
@@ -477,6 +497,27 @@ const fetchTimeline = async () => {
timelineItems.value = combineDiscussionItems(mapped)
}
const fetchReadHistory = async () => {
if (!isMine.value) {
readPosts.value = []
return
}
const token = getToken()
if (!token) {
readPosts.value = []
return
}
const res = await fetch(`${API_BASE_URL}/api/users/${username}/read-posts?limit=50`, {
headers: { Authorization: `Bearer ${token}` },
})
if (res.ok) {
const data = await res.json()
readPosts.value = data.map((r) => ({ ...r, icon: 'file-text' }))
} else {
readPosts.value = []
}
}
const fetchFollowUsers = async () => {
const [followerRes, followingRes] = await Promise.all([
fetch(`${API_BASE_URL}/api/users/${username}/followers`),
@@ -508,6 +549,12 @@ const loadTimeline = async () => {
tabLoading.value = false
}
const loadReadHistory = async () => {
tabLoading.value = true
await fetchReadHistory()
tabLoading.value = false
}
const loadFollow = async () => {
tabLoading.value = true
await fetchFollowUsers()
@@ -624,8 +671,14 @@ onMounted(init)
watch(selectedTab, async (val) => {
// navigateTo({ query: { ...route.query, tab: val } }, { replace: true })
if (val === 'timeline' && timelineItems.value.length === 0) {
await loadTimeline()
if (val === 'timeline') {
if (timelineFilter.value === 'reads') {
if (readPosts.value.length === 0) {
await loadReadHistory()
}
} else if (timelineItems.value.length === 0) {
await loadTimeline()
}
} else if (val === 'following' && followers.value.length === 0 && followings.value.length === 0) {
await loadFollow()
} else if (val === 'favorites' && favoritePosts.value.length === 0) {
@@ -634,6 +687,23 @@ watch(selectedTab, async (val) => {
await loadAchievements()
}
})
watch(timelineFilter, async (val) => {
if (selectedTab.value !== 'timeline') return
if (val === 'reads') {
if (readPosts.value.length === 0) {
await loadReadHistory()
}
} else if (timelineItems.value.length === 0) {
await loadTimeline()
}
})
watch(isMine, (val) => {
if (!val && timelineFilter.value === 'reads') {
timelineFilter.value = 'all'
}
})
</script>
<style scoped>

View File

@@ -66,14 +66,15 @@ class SearchClient:
resolved = self._resolve_token(token)
if resolved is None:
raise ValueError(
"Authenticated request requires an access token but none was provided."
"Authenticated request requires an access token. Provide a Bearer token "
"via the MCP Authorization header or configure a default token for the server."
)
return resolved
def _build_headers(
self,
*,
token: str | None = None,
token: str,
accept: str = "application/json",
include_json: bool = False,
) -> dict[str, str]:
@@ -177,7 +178,7 @@ class SearchClient:
self,
payload: dict[str, Any],
*,
token: str | None = None,
token: str,
) -> dict[str, Any]:
"""Create a new post and return the detailed backend payload."""
@@ -197,7 +198,7 @@ class SearchClient:
)
response.raise_for_status()
body = self._ensure_dict(response.json())
logger.info("Post creation succeeded with id=%s", body.get("id"))
logger.info("Post creation succeeded with id=%s, token=%s", body.get("id"), token)
return body
async def recent_posts(self, minutes: int) -> list[dict[str, Any]]:
@@ -248,7 +249,7 @@ class SearchClient:
*,
page: int = 0,
size: int = 30,
token: str | None = None,
token: str,
) -> list[dict[str, Any]]:
"""Return unread notifications for the authenticated user."""
@@ -284,7 +285,7 @@ class SearchClient:
self,
ids: list[int],
*,
token: str | None = None,
token: str
) -> None:
"""Mark the provided notifications as read for the authenticated user."""

View File

@@ -52,6 +52,37 @@ search_client = SearchClient(
)
def _extract_authorization_token(ctx: Context | None) -> str | None:
"""Return the Bearer token from the incoming MCP request headers."""
if ctx is None:
return None
try:
request_context = ctx.request_context
except ValueError:
return None
request = getattr(request_context, "request", None)
if request is None:
return None
headers = getattr(request, "headers", None)
if headers is None:
return None
authorization = headers.get("authorization")
if not authorization:
return None
scheme, _, token = authorization.partition(" ")
if scheme.lower() != "bearer":
return None
stripped = token.strip()
return stripped or None
@asynccontextmanager
async def lifespan(_: FastMCP):
"""Lifecycle hook that disposes shared resources when the server stops."""
@@ -68,8 +99,9 @@ app = FastMCP(
name="openisle-mcp",
instructions=(
"Use this server to search OpenIsle content, create new posts, reply to posts and "
"comments with an authentication token, retrieve details for a specific post, list "
"posts created within a recent time window, and review unread notification messages."
"comments using the Authorization header or configured access token, retrieve details "
"for a specific post, list posts created within a recent time window, and review "
"unread notification messages."
),
host=settings.host,
port=settings.port,
@@ -130,7 +162,10 @@ async def search(
@app.tool(
name="reply_to_post",
description="Create a comment on a post using an authentication token.",
description=(
"Create a comment on a post using the request Authorization header or the configured "
"access token."
),
structured_output=True,
)
async def reply_to_post(
@@ -149,15 +184,6 @@ async def reply_to_post(
description="Optional captcha solution if the backend requires it.",
),
] = None,
token: Annotated[
str | None,
PydanticField(
default=None,
description=(
"Optional JWT bearer token. When omitted the configured access token is used."
),
),
] = None,
ctx: Context | None = None,
) -> CommentCreateResult:
"""Create a comment on a post and return the backend payload."""
@@ -166,12 +192,10 @@ async def reply_to_post(
if not sanitized_content:
raise ValueError("Reply content must not be empty.")
sanitized_token = token.strip() if isinstance(token, str) else None
if sanitized_token == "":
sanitized_token = None
sanitized_captcha = captcha.strip() if isinstance(captcha, str) else None
request_token = _extract_authorization_token(ctx)
try:
logger.info(
"Creating reply for post_id=%s (captcha=%s)",
@@ -180,20 +204,20 @@ async def reply_to_post(
)
raw_comment = await search_client.reply_to_post(
post_id,
sanitized_token,
sanitized_content,
sanitized_captcha,
token=request_token,
content=sanitized_content,
captcha=sanitized_captcha,
)
except httpx.HTTPStatusError as exc: # pragma: no cover - network errors
status_code = exc.response.status_code
if status_code == 401:
message = (
"Authentication failed while replying to post "
f"{post_id}. Please verify the token."
f"{post_id}. Please verify the Authorization header or configured token."
)
elif status_code == 403:
message = (
"The provided token is not authorized to reply to post "
"The provided Authorization token is not authorized to reply to post "
f"{post_id}."
)
elif status_code == 404:
@@ -239,7 +263,10 @@ async def reply_to_post(
@app.tool(
name="reply_to_comment",
description="Reply to an existing comment using an authentication token.",
description=(
"Reply to an existing comment using the request Authorization header or the configured "
"access token."
),
structured_output=True,
)
async def reply_to_comment(
@@ -258,15 +285,6 @@ async def reply_to_comment(
description="Optional captcha solution if the backend requires it.",
),
] = None,
token: Annotated[
str | None,
PydanticField(
default=None,
description=(
"Optional JWT bearer token. When omitted the configured access token is used."
),
),
] = None,
ctx: Context | None = None,
) -> CommentReplyResult:
"""Create a reply for a comment and return the backend payload."""
@@ -275,10 +293,10 @@ async def reply_to_comment(
if not sanitized_content:
raise ValueError("Reply content must not be empty.")
sanitized_token = token.strip() if isinstance(token, str) else None
sanitized_captcha = captcha.strip() if isinstance(captcha, str) else None
request_token = _extract_authorization_token(ctx)
try:
logger.info(
"Creating reply for comment_id=%s (captcha=%s)",
@@ -287,20 +305,20 @@ async def reply_to_comment(
)
raw_comment = await search_client.reply_to_comment(
comment_id,
sanitized_token,
sanitized_content,
sanitized_captcha,
token=request_token,
content=sanitized_content,
captcha=sanitized_captcha,
)
except httpx.HTTPStatusError as exc: # pragma: no cover - network errors
status_code = exc.response.status_code
if status_code == 401:
message = (
"Authentication failed while replying to comment "
f"{comment_id}. Please verify the token."
f"{comment_id}. Please verify the Authorization header or configured token."
)
elif status_code == 403:
message = (
"The provided token is not authorized to reply to comment "
"The provided Authorization token is not authorized to reply to comment "
f"{comment_id}."
)
else:
@@ -344,7 +362,10 @@ async def reply_to_comment(
@app.tool(
name="create_post",
description="Publish a new post using an authentication token.",
description=(
"Publish a new post using the request Authorization header or the configured access "
"token."
),
structured_output=True,
)
async def create_post(
@@ -466,15 +487,6 @@ async def create_post(
description="Captcha solution if the backend requires one to create posts.",
),
] = None,
token: Annotated[
str | None,
PydanticField(
default=None,
description=(
"Optional JWT bearer token. When omitted the configured access token is used."
),
),
] = None,
ctx: Context | None = None,
) -> PostCreateResult:
"""Create a new post in OpenIsle and return the detailed backend payload."""
@@ -487,10 +499,6 @@ async def create_post(
if not sanitized_content:
raise ValueError("Post content must not be empty.")
sanitized_token = token.strip() if isinstance(token, str) else None
if sanitized_token == "":
sanitized_token = None
sanitized_category_id: int | None = None
if category_id is not None:
if isinstance(category_id, bool):
@@ -501,6 +509,8 @@ async def create_post(
raise ValueError("Category identifier must be an integer.") from exc
if sanitized_category_id <= 0:
raise ValueError("Category identifier must be a positive integer.")
if sanitized_category_id is None:
raise ValueError("A category identifier is required to create a post.")
sanitized_tag_ids: list[int] | None = None
if tag_ids is not None:
@@ -517,6 +527,10 @@ async def create_post(
sanitized_tag_ids.append(converted)
if not sanitized_tag_ids:
sanitized_tag_ids = None
if not sanitized_tag_ids:
raise ValueError("At least one tag identifier is required to create a post.")
if len(sanitized_tag_ids) > 2:
raise ValueError("At most two tag identifiers can be provided for a post.")
sanitized_post_type = post_type.strip() if isinstance(post_type, str) else None
if sanitized_post_type == "":
@@ -635,7 +649,7 @@ async def create_post(
try:
logger.info("Creating post with title='%s'", sanitized_title)
raw_post = await search_client.create_post(payload, token=sanitized_token)
raw_post = await search_client.create_post(payload, token=_extract_authorization_token(ctx))
except httpx.HTTPStatusError as exc: # pragma: no cover - network errors
status_code = exc.response.status_code
if status_code == 400:
@@ -643,9 +657,12 @@ async def create_post(
"Post creation failed due to invalid input or captcha verification errors."
)
elif status_code == 401:
message = "Authentication failed while creating the post. Please verify the token."
message = (
"Authentication failed while creating the post. Please verify the "
"Authorization header or configured token."
)
elif status_code == 403:
message = "The provided token is not authorized to create posts."
message = "The provided Authorization token is not authorized to create posts."
else:
message = (
"OpenIsle backend returned HTTP "
@@ -741,24 +758,15 @@ async def get_post(
int,
PydanticField(ge=1, description="Identifier of the post to retrieve."),
],
token: Annotated[
str | None,
PydanticField(
default=None,
description="Optional JWT bearer token to view the post as an authenticated user.",
),
] = None,
ctx: Context | None = None,
) -> PostDetail:
"""Fetch post details from the backend and validate the response."""
sanitized_token = token.strip() if isinstance(token, str) else None
if sanitized_token == "":
sanitized_token = None
try:
logger.info("Fetching post details for post_id=%s", post_id)
raw_post = await search_client.get_post(post_id, sanitized_token)
raw_post = await search_client.get_post(
post_id, _extract_authorization_token(ctx)
)
except httpx.HTTPStatusError as exc: # pragma: no cover - network errors
status_code = exc.response.status_code
if status_code == 404:
@@ -766,7 +774,7 @@ async def get_post(
elif status_code == 401:
message = "Authentication failed while retrieving the post."
elif status_code == 403:
message = "The provided token is not authorized to view this post."
message = "The provided Authorization token is not authorized to view this post."
else:
message = (
"OpenIsle backend returned HTTP "
@@ -823,21 +831,10 @@ async def list_unread_messages(
description="Number of unread notifications to include per page.",
),
] = 30,
token: Annotated[
str | None,
PydanticField(
default=None,
description=(
"Optional JWT bearer token. When omitted the configured access token is used."
),
),
] = None,
ctx: Context | None = None,
) -> UnreadNotificationsResponse:
"""Retrieve unread notifications and return structured data."""
sanitized_token = token.strip() if isinstance(token, str) else None
try:
logger.info(
"Fetching unread notifications (page=%s, size=%s)",
@@ -847,7 +844,7 @@ async def list_unread_messages(
raw_notifications = await search_client.list_unread_notifications(
page=page,
size=size,
token=sanitized_token,
token=_extract_authorization_token(ctx),
)
except httpx.HTTPStatusError as exc: # pragma: no cover - network errors
message = (
@@ -906,29 +903,18 @@ async def mark_notifications_read(
description="Notification identifiers that should be marked as read.",
),
],
token: Annotated[
str | None,
PydanticField(
default=None,
description=(
"Optional JWT bearer token. When omitted the configured access token is used."
),
),
] = None,
ctx: Context | None = None,
) -> NotificationCleanupResult:
"""Mark the supplied notifications as read and report the processed identifiers."""
sanitized_token = token.strip() if isinstance(token, str) else None
if sanitized_token == "":
sanitized_token = None
try:
logger.info(
"Marking %d notifications as read", # pragma: no branch - logging
len(ids),
)
await search_client.mark_notifications_read(ids, token=sanitized_token)
await search_client.mark_notifications_read(
ids, token=_extract_authorization_token(ctx)
)
except httpx.HTTPStatusError as exc: # pragma: no cover - network errors
message = (
"OpenIsle backend returned HTTP "