mirror of
https://github.com/nagisa77/OpenIsle.git
synced 2026-02-07 15:41:02 +08:00
Compare commits
7 Commits
bugfix/113
...
feature/ag
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1978490f1d | ||
|
|
680e44e743 | ||
|
|
657b76bb1e | ||
|
|
f695db62c6 | ||
|
|
e5b386cdc2 | ||
|
|
179699dd66 | ||
|
|
ef39b5fedf |
68
AGENTS.md
Normal file
68
AGENTS.md
Normal file
@@ -0,0 +1,68 @@
|
||||
# OpenIsle 协作指引(根目录)
|
||||
|
||||
## 1) 适用范围与优先级
|
||||
|
||||
- 本文件作用于整个仓库。
|
||||
- 若子目录存在 `AGENTS.md`,以“最近目录”的规则为准(就近覆盖)。
|
||||
- 没有子目录规则时,回退到本文件。
|
||||
|
||||
## 2) 仓库地图(高频模块)
|
||||
|
||||
- `backend/`:Spring Boot 主后端(JPA、Redis、RabbitMQ、OpenSearch、OpenAPI)。
|
||||
- `frontend_nuxt/`:Nuxt 3 前端(SSR + WebSocket + OAuth 回调页)。
|
||||
- `websocket_service/`:独立 WebSocket/STOMP 服务(消费 RabbitMQ 通知并推送)。
|
||||
- `mcp/`:Python MCP Server(封装搜索、发帖、回复、通知等工具)。
|
||||
- `docs/`:Fumadocs 文档站(含 OpenAPI 生成)。
|
||||
- `deploy/`:生产/预发部署脚本。
|
||||
- `bots/`:定时 Bot 脚本(由 GitHub Actions 调度)。
|
||||
|
||||
## 3) 全局工作原则
|
||||
|
||||
- 小步修改:仅改当前任务相关文件,不做无关重构。
|
||||
- 先对齐契约,再改实现:优先保证 API、消息结构、环境变量名称稳定。
|
||||
- 变更需可验证:提交前给出最小可执行验证命令与结果。
|
||||
- 安全优先:禁止提交密钥、令牌、生产凭证。
|
||||
|
||||
## 4) 任务路由(先判定影响面)
|
||||
|
||||
- 仅后端业务逻辑:进入 `backend/AGENTS.md`。
|
||||
- 仅前端页面交互:进入 `frontend_nuxt/AGENTS.md`。
|
||||
- 实时消息/在线通知:同时看 `backend/` + `websocket_service/` + `frontend_nuxt/`。
|
||||
- API/DTO/鉴权变更:至少看 `backend/` + `mcp/` + `docs/`。
|
||||
- 部署链路变更:进入 `deploy/AGENTS.md`,并附风险与回滚方案。
|
||||
|
||||
## 5) 跨服务不变量(必须遵守)
|
||||
|
||||
- 环境变量以根目录 `.env.example` 为统一基线;变量改名需同步消费端。
|
||||
- 鉴权链路一致:
|
||||
- 后端 JWT:`backend/src/main/java/com/openisle/config/SecurityConfig.java`
|
||||
- WebSocket JWT:`websocket_service/src/main/java/com/openisle/websocket/config/WebSocketAuthInterceptor.java`
|
||||
- RabbitMQ 分片一致(16 个十六进制分片 + 兼容遗留队列):
|
||||
- `backend/src/main/java/com/openisle/config/RabbitMQConfig.java`
|
||||
- `backend/src/main/java/com/openisle/config/ShardingStrategy.java`
|
||||
- `websocket_service/src/main/java/com/openisle/websocket/listener/NotificationListener.java`
|
||||
- API 契约变更要同步:
|
||||
- MCP schema/tool:`mcp/src/openisle_mcp/schemas.py`、`mcp/src/openisle_mcp/server.py`
|
||||
- 文档生成:`docs/scripts/generate-docs.ts`、`docs/lib/openapi.ts`
|
||||
|
||||
## 6) 最小验证矩阵(按改动类型)
|
||||
|
||||
- 后端改动:`cd backend && mvn test`
|
||||
- 前端改动:`cd frontend_nuxt && npm run build`
|
||||
- WebSocket 服务改动:`cd websocket_service && mvn test`(无测试可退化为 `mvn -DskipTests compile`)
|
||||
- MCP 改动:`cd mcp && python -m pip install -e .`
|
||||
- Docs/OpenAPI 改动:`cd docs && bun run generate && bun run build`
|
||||
- 部署脚本改动:`bash -n deploy/deploy.sh && bash -n deploy/deploy_staging.sh`
|
||||
|
||||
## 7) 交付清单(每次任务输出建议)
|
||||
|
||||
- 改了什么:按文件列出核心变更点。
|
||||
- 为什么改:说明触发原因/缺陷点/一致性要求。
|
||||
- 如何验证:给出实际执行命令与结果摘要。
|
||||
- 风险与后续:列出剩余风险、可选回归点、回滚建议。
|
||||
|
||||
## 8) 禁止事项
|
||||
|
||||
- 不提交 `.env`、密钥、生产 token。
|
||||
- 不在未明确授权下执行破坏性命令(如大范围删除、强制重置)。
|
||||
- 不在无关文件中进行格式化/重排以“顺手优化”。
|
||||
@@ -57,6 +57,14 @@ cd OpenIsle
|
||||
--profile dev up -d --force-recreate
|
||||
```
|
||||
|
||||
仅重启后端容器(不重建镜像、不影响前端):
|
||||
```shell
|
||||
docker compose \
|
||||
-f docker/docker-compose.yaml \
|
||||
--env-file .env \
|
||||
--profile dev restart springboot websocket-service
|
||||
```
|
||||
|
||||
数据初始化sql会创建几个帐户供大家测试使用
|
||||
> username:admin/user1/user2 password:123456
|
||||
|
||||
|
||||
59
backend/AGENTS.md
Normal file
59
backend/AGENTS.md
Normal file
@@ -0,0 +1,59 @@
|
||||
# Backend 协作指引
|
||||
|
||||
## 1) 适用范围
|
||||
|
||||
- 作用于 `backend/` 目录及其子目录。
|
||||
- 若与根 `AGENTS.md` 冲突,以本文件为准(仅后端范围)。
|
||||
|
||||
## 2) 代码结构心智模型
|
||||
|
||||
- `controller/`:接口层(入参校验、权限边界、响应格式)。
|
||||
- `service/`:业务编排与领域规则(核心逻辑放这里)。
|
||||
- `repository/`:JPA 数据访问(基于实体与查询方法)。
|
||||
- `model/`:实体模型与枚举。
|
||||
- `dto/` + `mapper/`:对外契约和映射转换。
|
||||
- `config/`:安全、缓存、MQ、OpenAPI、初始化器等基础设施配置。
|
||||
- `search/`:OpenSearch 索引与事件驱动同步。
|
||||
|
||||
## 3) 后端修改规则
|
||||
|
||||
- 控制器保持“薄”,复杂逻辑下沉到 `service/`。
|
||||
- DTO 变更优先考虑兼容性,避免无版本的破坏性字段删除/改名。
|
||||
- 新增接口时:
|
||||
- 补齐必要的鉴权规则(`SecurityConfig`)。
|
||||
- 补齐 OpenAPI 注解(`@Operation`、`@ApiResponse` 等)。
|
||||
- 涉及缓存时,确认 `CachingConfig` 中缓存名、TTL 与失效策略一致。
|
||||
|
||||
## 4) 重点一致性检查
|
||||
|
||||
- 鉴权与公开接口:
|
||||
- `src/main/java/com/openisle/config/SecurityConfig.java`
|
||||
- 搜索索引同步(实体字段/文案变更时):
|
||||
- `src/main/java/com/openisle/search/SearchDocumentFactory.java`
|
||||
- `src/main/java/com/openisle/search/SearchIndexEventPublisher.java`
|
||||
- 消息通知链路(评论/通知相关):
|
||||
- `src/main/java/com/openisle/config/RabbitMQConfig.java`
|
||||
- `src/main/java/com/openisle/config/ShardingStrategy.java`
|
||||
- `src/main/java/com/openisle/service/NotificationProducer.java`
|
||||
- 环境变量消费面:
|
||||
- `src/main/resources/application.properties`
|
||||
- 根目录 `.env.example`
|
||||
|
||||
## 5) 数据与事务注意事项
|
||||
|
||||
- 涉及多表写入时,明确事务边界,避免半成功状态。
|
||||
- 避免在 Controller 直接操作 Repository。
|
||||
- JPA 懒加载对象对外返回前应通过 DTO 映射,避免序列化副作用。
|
||||
|
||||
## 6) 测试与验证
|
||||
|
||||
- 首选全量:`mvn test`
|
||||
- 变更集中时可先跑目标测试(示例):
|
||||
- `mvn -Dtest=PostControllerTest test`
|
||||
- `mvn -Dtest=SearchServiceTest test`
|
||||
- 涉及搜索/MQ 配置时,至少完成一次启动级验证或关键集成测试。
|
||||
|
||||
## 7) 输出要求
|
||||
|
||||
- 明确列出“接口/字段/权限/事件”是否发生变化。
|
||||
- 若影响 `mcp/` 或 `docs/`,在结果中显式提示需同步改动。
|
||||
@@ -112,7 +112,9 @@ public class CommentController {
|
||||
)
|
||||
public List<TimelineItemDto<?>> listComments(
|
||||
@PathVariable Long postId,
|
||||
@RequestParam(value = "sort", required = false, defaultValue = "OLDEST") CommentSort sort
|
||||
@RequestParam(value = "sort", required = false, defaultValue = "OLDEST") CommentSort sort,
|
||||
@RequestParam(value = "page", required = false, defaultValue = "0") int page,
|
||||
@RequestParam(value = "pageSize", required = false, defaultValue = "20") int pageSize
|
||||
) {
|
||||
log.debug("listComments called for post {} with sort {}", postId, sort);
|
||||
List<CommentDto> commentDtoList = commentService
|
||||
@@ -183,8 +185,23 @@ public class CommentController {
|
||||
createdAtComparator = createdAtComparator.reversed();
|
||||
}
|
||||
itemDtoList.sort(comparator.thenComparing(createdAtComparator));
|
||||
log.debug("listComments returning {} comments", itemDtoList.size());
|
||||
return itemDtoList;
|
||||
|
||||
int safePage = Math.max(0, page);
|
||||
int safePageSize = Math.max(1, pageSize);
|
||||
int fromIndex = safePage * safePageSize;
|
||||
int toIndex = Math.min(fromIndex + safePageSize, itemDtoList.size());
|
||||
List<TimelineItemDto<?>> pagedItems =
|
||||
fromIndex >= itemDtoList.size() ? List.of() : itemDtoList.subList(fromIndex, toIndex);
|
||||
|
||||
log.debug(
|
||||
"listComments returning {} items for post {} page {} size {} (total {})",
|
||||
pagedItems.size(),
|
||||
postId,
|
||||
safePage,
|
||||
safePageSize,
|
||||
itemDtoList.size()
|
||||
);
|
||||
return pagedItems;
|
||||
}
|
||||
|
||||
@GetMapping("/comments/{commentId}/context")
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -339,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)) {
|
||||
@@ -809,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()) {
|
||||
@@ -822,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 {
|
||||
@@ -839,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) {
|
||||
@@ -859,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()) {
|
||||
@@ -872,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 {
|
||||
@@ -889,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(
|
||||
@@ -1394,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;
|
||||
|
||||
@@ -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;
|
||||
68
bots/AGENTS.md
Normal file
68
bots/AGENTS.md
Normal file
@@ -0,0 +1,68 @@
|
||||
# Bots 协作指引
|
||||
|
||||
## 1) 适用范围
|
||||
|
||||
- 作用于 `bots/` 目录及其子目录。
|
||||
- 本文件用于统一 Bot 脚本开发、调度与发布规范。
|
||||
|
||||
## 2) 模块结构与职责
|
||||
|
||||
- `bot_father.ts`:Bot 基类,统一 Agent 初始化、MCP 工具接入、CLI 运行入口。
|
||||
- `instance/reply_bot.ts`:常规互动回复 Bot(提及/评论自动处理)。
|
||||
- `instance/open_source_reply_bot.ts`:开源问答 Bot(偏代码与贡献流程)。
|
||||
- `instance/daily_news_bot.ts`:每日新闻帖 Bot。
|
||||
- `instance/coffee_bot.ts`:早安抽奖帖 Bot。
|
||||
|
||||
## 3) 开发约定(新增/改造 Bot)
|
||||
|
||||
- 新 Bot 统一继承 `BotFather`,最少实现:
|
||||
- `getAdditionalInstructions()`
|
||||
- `getCliQuery()`
|
||||
- 保持导出约定:`export const runWorkflow = ...`,并保留 `if (require.main === module)` CLI 入口。
|
||||
- 不随意改动 `bot_father.ts` 的 MCP 工具白名单;若必须调整,需同步评估 `mcp/` 契约与线上可用性。
|
||||
|
||||
## 4) 环境变量与密钥规范
|
||||
|
||||
- 必需:`OPENAI_API_KEY`(缺失会直接失败)。
|
||||
- 常用:
|
||||
- `OPENISLE_TOKEN`(用于 OpenIsle MCP 鉴权;GitHub Actions 中可映射不同 secret)
|
||||
- `APIFY_API_TOKEN`(天气 MCP 鉴权)
|
||||
- News Bot 可选参数:
|
||||
- `DAILY_NEWS_CATEGORY_ID`
|
||||
- `DAILY_NEWS_TAG_IDS`
|
||||
- 严禁在代码中硬编码真实 token;仅通过 CI secrets 或本地环境变量注入。
|
||||
|
||||
## 5) 工作流同步规则(与 GitHub Actions 对齐)
|
||||
|
||||
- 相关工作流:
|
||||
- `.github/workflows/reply-bots.yml`
|
||||
- `.github/workflows/open_source_reply_bot.yml`
|
||||
- `.github/workflows/news-bot.yml`
|
||||
- `.github/workflows/coffee-bot.yml`
|
||||
- 若改脚本入口、依赖或 env 键名,必须同步更新对应 workflow。
|
||||
- 若改触发节奏(cron)或 Bot 行为边界,需在变更说明中写明影响(频率、成本、风险)。
|
||||
|
||||
## 6) 行为约束(防重复/防失控)
|
||||
|
||||
- 回复类 Bot 需保持幂等:避免对同一上下文重复回复。
|
||||
- 处理未读后应调用 `mark_notifications_read` 清理通知。
|
||||
- 批量处理建议保持上限(当前提示词约定为最多 10 条)。
|
||||
- 发帖类 Bot(news/coffee)必须控制 `create_post` 调用次数(一次任务最多一次发帖)。
|
||||
- Open Source Reply Bot 保持专业技术风格,避免跑题到非开源问答。
|
||||
|
||||
## 7) 本地验证建议
|
||||
|
||||
- 依赖安装(与 CI 一致):
|
||||
- `npm install --no-save @openai/agents tsx typescript`
|
||||
- 单脚本运行示例:
|
||||
- `npx tsx bots/instance/reply_bot.ts`
|
||||
- `npx tsx bots/instance/open_source_reply_bot.ts`
|
||||
- `npx tsx bots/instance/daily_news_bot.ts`
|
||||
- `npx tsx bots/instance/coffee_bot.ts`
|
||||
- 验证时至少确认:可启动、可调用 MCP、异常时退出码非 0。
|
||||
|
||||
## 8) 输出要求
|
||||
|
||||
- 说明改动影响哪个 Bot、哪个 workflow。
|
||||
- 说明是否改变了工具调用边界(MCP tools / 发帖次数 / 回复策略)。
|
||||
- 说明是否需要同步更新文档或运维配置。
|
||||
40
deploy/AGENTS.md
Normal file
40
deploy/AGENTS.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# Deploy 协作指引
|
||||
|
||||
## 1) 适用范围
|
||||
|
||||
- 作用于 `deploy/` 目录及其脚本。
|
||||
- 该目录为高风险变更区,默认保守修改。
|
||||
|
||||
## 2) 当前部署基线
|
||||
|
||||
- 预发:`main` push 触发(见 `.github/workflows/deploy-staging.yml`)。
|
||||
- 正式:定时任务触发(见 `.github/workflows/deploy.yml`)。
|
||||
- 两者使用同一并发锁 `openisle-server`,避免服务器并发部署冲突。
|
||||
|
||||
## 3) 脚本修改原则
|
||||
|
||||
- 保留 `set -euo pipefail` 等安全执行特性。
|
||||
- 变更服务列表或 `docker compose up` 参数时,必须说明影响范围。
|
||||
- 不随意改动 `git fetch/checkout/reset` 逻辑;若改,必须附回滚方案。
|
||||
- 任何“可能中断服务”的改动,都要在说明中给出停机/风险评估。
|
||||
|
||||
## 4) 环境与参数规则
|
||||
|
||||
- 部署依赖根目录 `.env`(由脚本中 `env_file` 与 `ENV_FILE` 传入)。
|
||||
- `COMPOSE_PROJECT_NAME`、`NUXT_ENV`、服务名列表需保持可追踪且与 compose 一致。
|
||||
- 若新增服务,需同步:
|
||||
- `docker/docker-compose.yaml`
|
||||
- 部署脚本中的 build/up 目标
|
||||
- 必要时更新 workflow 说明
|
||||
|
||||
## 5) 验证建议
|
||||
|
||||
- 语法检查:
|
||||
- `bash -n deploy/deploy.sh`
|
||||
- `bash -n deploy/deploy_staging.sh`
|
||||
- 变更前后做一次 `docker compose config` 思维核对(服务与 profile 是否正确)。
|
||||
|
||||
## 6) 输出要求
|
||||
|
||||
- 明确:影响环境(预发/正式)、影响服务、是否可能重建容器。
|
||||
- 必填:回滚路径(例如切回上一 commit 并重新执行部署脚本)。
|
||||
40
docs/AGENTS.md
Normal file
40
docs/AGENTS.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# Docs 协作指引
|
||||
|
||||
## 1) 适用范围
|
||||
|
||||
- 作用于 `docs/` 目录及其子目录。
|
||||
- 文档需服务“开发者真实使用”,优先准确性与可执行性。
|
||||
|
||||
## 2) 文档架构
|
||||
|
||||
- 内容目录:`content/docs/`
|
||||
- 生成脚本:`scripts/generate-docs.ts`
|
||||
- OpenAPI 输入配置:`lib/openapi.ts`
|
||||
- 前端框架:Fumadocs + Next.js(Bun 工具链)
|
||||
|
||||
## 3) 编辑规则
|
||||
|
||||
- 优先修正“与代码不一致”的文档,不复制过时描述。
|
||||
- 涉及技术栈说明时,以当前代码为准(例如后端为 JPA/Repository)。
|
||||
- OpenAPI 自动生成目录(`content/docs/openapi/(generated)`)不要手工细改,改源头配置与脚本。
|
||||
- 结构性改动优先维持导航稳定(`meta.json` 与已有 slug)。
|
||||
|
||||
## 4) OpenAPI 同步规则
|
||||
|
||||
- 后端 API 变更后,应重新生成文档页面:
|
||||
- `bun run generate`
|
||||
- 若接口来源地址或文档聚合策略变化,更新:
|
||||
- `lib/openapi.ts`
|
||||
- `scripts/generate-docs.ts`
|
||||
|
||||
## 5) 验证命令
|
||||
|
||||
- 安装依赖:`bun install`
|
||||
- 生成 API 文档:`bun run generate`
|
||||
- 构建校验:`bun run build`
|
||||
- 本地预览:`bun dev`
|
||||
|
||||
## 6) 输出要求
|
||||
|
||||
- 说明更新了哪些文档入口(backend/frontend/openapi)。
|
||||
- 说明是否需要后端先部署后再刷新文档产物。
|
||||
54
frontend_nuxt/AGENTS.md
Normal file
54
frontend_nuxt/AGENTS.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# Frontend(Nuxt)协作指引
|
||||
|
||||
## 1) 适用范围
|
||||
|
||||
- 作用于 `frontend_nuxt/` 目录及其子目录。
|
||||
- 本文件仅覆盖前端范围;跨服务规则仍遵循根 `AGENTS.md`。
|
||||
|
||||
## 2) 代码组织约定
|
||||
|
||||
- `pages/`:路由页面与页面级数据获取。
|
||||
- `components/`:可复用视图组件。
|
||||
- `composables/`:状态与行为复用(如 WebSocket、倒计时)。
|
||||
- `plugins/`:运行时插件(鉴权 fetch、主题、第三方库注入)。
|
||||
- `utils/`:纯工具函数(时间、鉴权 token、平台适配)。
|
||||
- `assets/`、`public/`:静态资源与样式。
|
||||
|
||||
## 3) 前端修改规则
|
||||
|
||||
- 优先保持现有交互和视觉风格一致,不做无关 UI 重构。
|
||||
- 接口字段变更时,先更新调用点,再统一处理回退逻辑与空值分支。
|
||||
- SSR 与客户端代码分离:
|
||||
- 涉及 `window`、`localStorage`、WebSocket 的逻辑只在 client 侧运行。
|
||||
- 与鉴权相关的 401 处理,保持与 `plugins/auth-fetch.client.ts` 行为一致。
|
||||
|
||||
## 4) 环境变量与运行时配置
|
||||
|
||||
- 统一通过 `nuxt.config.ts` 的 `runtimeConfig.public` 读取。
|
||||
- 关键键位保持一致:
|
||||
- `NUXT_PUBLIC_API_BASE_URL`
|
||||
- `NUXT_PUBLIC_WEBSOCKET_URL`
|
||||
- `NUXT_PUBLIC_WEBSITE_BASE_URL`
|
||||
- 变量改动需同步根目录 `.env.example` 与文档说明。
|
||||
|
||||
## 5) 实时消息链路注意事项
|
||||
|
||||
- WebSocket 入口:
|
||||
- `composables/useWebSocket.js`
|
||||
- 若改订阅目标(`/topic/...`、`/user/...`),必须与后端推送目的地保持一致。
|
||||
- 重连与重订阅逻辑不可被破坏;避免引入重复订阅和泄漏。
|
||||
|
||||
## 6) 构建与验证
|
||||
|
||||
- 标准验证:`npm run build`
|
||||
- 本地联调:`npm run dev`
|
||||
- 涉及 WebSocket/通知的改动,建议至少手工验证:
|
||||
- 登录后连接建立
|
||||
- 收到消息时 UI 状态更新
|
||||
- 断线重连后仍可订阅
|
||||
|
||||
## 7) 输出要求
|
||||
|
||||
- 标注影响页面/组件路径。
|
||||
- 标注是否引入 API 字段兼容处理。
|
||||
- 标注是否需要后端或 WebSocket 服务配合发布。
|
||||
@@ -159,6 +159,12 @@ const selectedTags = ref([])
|
||||
const route = useRoute()
|
||||
const tagOptions = ref([])
|
||||
const categoryOptions = ref([])
|
||||
const clearFilters = () => {
|
||||
selectedCategory.value = ''
|
||||
selectedTags.value = []
|
||||
selectedCategoryGlobal.value = ''
|
||||
selectedTagsGlobal.value = []
|
||||
}
|
||||
|
||||
const topics = ref(['最新回复', '最新', '精选', '排行榜' /*, '热门', '类别'*/])
|
||||
const selectedTopicCookie = useCookie('homeTab')
|
||||
@@ -218,8 +224,18 @@ watch(
|
||||
(query) => {
|
||||
const category = query.category
|
||||
const tags = query.tags
|
||||
category && selectedCategorySet(category)
|
||||
tags && selectedTagsSet(tags)
|
||||
if (category) {
|
||||
selectedCategorySet(category)
|
||||
} else {
|
||||
selectedCategory.value = ''
|
||||
selectedCategoryGlobal.value = ''
|
||||
}
|
||||
if (tags) {
|
||||
selectedTagsSet(tags)
|
||||
} else {
|
||||
selectedTags.value = []
|
||||
selectedTagsGlobal.value = []
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
@@ -367,12 +383,18 @@ watch(selectedTopic, (val) => {
|
||||
if (import.meta.server) {
|
||||
await loadOptions()
|
||||
}
|
||||
|
||||
const handleRefreshHome = () => {
|
||||
clearFilters()
|
||||
refreshFirst()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (categoryOptions.value.length === 0 && tagOptions.value.length === 0) loadOptions()
|
||||
window.addEventListener('refresh-home', refreshFirst)
|
||||
window.addEventListener('refresh-home', handleRefreshHome)
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('refresh-home', refreshFirst)
|
||||
window.removeEventListener('refresh-home', handleRefreshHome)
|
||||
})
|
||||
|
||||
/** 供 InfiniteLoadMore 重建用的 key:筛选/Tab 改变即重建内部状态 */
|
||||
|
||||
@@ -181,6 +181,12 @@
|
||||
<PostChangeLogItem v-else :log="item" :title="title" />
|
||||
</template>
|
||||
</BaseTimeline>
|
||||
<InfiniteLoadMore
|
||||
v-if="timelineItems.length > 0"
|
||||
:key="commentSort"
|
||||
:on-load="loadMoreTimeline"
|
||||
:pause="isLoadingMoreComments || isFetchingComments"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -211,6 +217,7 @@ import CommentEditor from '~/components/CommentEditor.vue'
|
||||
import BaseTimeline from '~/components/BaseTimeline.vue'
|
||||
import BasePlaceholder from '~/components/BasePlaceholder.vue'
|
||||
import PostChangeLogItem from '~/components/PostChangeLogItem.vue'
|
||||
import InfiniteLoadMore from '~/components/InfiniteLoadMore.vue'
|
||||
import ArticleTags from '~/components/ArticleTags.vue'
|
||||
import ArticleCategory from '~/components/ArticleCategory.vue'
|
||||
import ReactionsGroup from '~/components/ReactionsGroup.vue'
|
||||
@@ -276,6 +283,10 @@ const currentIndex = ref(1)
|
||||
const subscribed = ref(false)
|
||||
const commentSort = ref('NEWEST')
|
||||
const isFetchingComments = ref(false)
|
||||
const commentPage = ref(0)
|
||||
const commentPageSize = 10
|
||||
const hasMoreComments = ref(true)
|
||||
const isLoadingMoreComments = ref(false)
|
||||
const isMobile = useIsMobile()
|
||||
const timelineItems = ref([])
|
||||
|
||||
@@ -869,17 +880,33 @@ const fetchCommentSorts = () => {
|
||||
])
|
||||
}
|
||||
|
||||
const fetchCommentsAndChangeLog = async () => {
|
||||
isFetchingComments.value = true
|
||||
console.info('Fetching comments and chang log', { postId, sort: commentSort.value })
|
||||
const fetchCommentsAndChangeLog = async ({ pageNo = 0, append = false } = {}) => {
|
||||
if (isLoadingMoreComments.value) return false
|
||||
if (!append) {
|
||||
hasMoreComments.value = true
|
||||
commentPage.value = 0
|
||||
}
|
||||
if (pageNo === 0) {
|
||||
isFetchingComments.value = true
|
||||
} else {
|
||||
isLoadingMoreComments.value = true
|
||||
}
|
||||
console.info('Fetching comments and chang log', {
|
||||
postId,
|
||||
sort: commentSort.value,
|
||||
page: pageNo,
|
||||
pageSize: commentPageSize,
|
||||
})
|
||||
let done = false
|
||||
try {
|
||||
const token = getToken()
|
||||
const res = await fetch(
|
||||
`${API_BASE_URL}/api/posts/${postId}/comments?sort=${commentSort.value}`,
|
||||
{
|
||||
headers: { Authorization: token ? `Bearer ${token}` : '' },
|
||||
},
|
||||
)
|
||||
const url = new URL(`${API_BASE_URL}/api/posts/${postId}/comments`)
|
||||
url.searchParams.set('sort', commentSort.value)
|
||||
url.searchParams.set('page', String(pageNo))
|
||||
url.searchParams.set('pageSize', String(commentPageSize))
|
||||
const res = await fetch(url.toString(), {
|
||||
headers: { Authorization: token ? `Bearer ${token}` : '' },
|
||||
})
|
||||
console.info('Fetch comments response status', res.status)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
@@ -902,23 +929,48 @@ const fetchCommentsAndChangeLog = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
comments.value = commentList
|
||||
changeLogs.value = changeLogList
|
||||
timelineItems.value = newTimelineItemList
|
||||
if (append) {
|
||||
comments.value.push(...commentList)
|
||||
changeLogs.value.push(...changeLogList)
|
||||
timelineItems.value.push(...newTimelineItemList)
|
||||
commentPage.value = pageNo
|
||||
} else {
|
||||
comments.value = commentList
|
||||
changeLogs.value = changeLogList
|
||||
timelineItems.value = newTimelineItemList
|
||||
commentPage.value = 0
|
||||
}
|
||||
|
||||
isFetchingComments.value = false
|
||||
done = data.length < commentPageSize
|
||||
hasMoreComments.value = !done
|
||||
await nextTick()
|
||||
gatherPostItems()
|
||||
return done
|
||||
}
|
||||
} catch (e) {
|
||||
console.debug('Fetch comments error', e)
|
||||
hasMoreComments.value = false
|
||||
return true
|
||||
} finally {
|
||||
isFetchingComments.value = false
|
||||
isLoadingMoreComments.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const fetchTimeline = async () => {
|
||||
await fetchCommentsAndChangeLog()
|
||||
hasMoreComments.value = true
|
||||
commentPage.value = 0
|
||||
comments.value = []
|
||||
changeLogs.value = []
|
||||
timelineItems.value = []
|
||||
await fetchCommentsAndChangeLog({ pageNo: 0, append: false })
|
||||
}
|
||||
|
||||
const loadMoreTimeline = async () => {
|
||||
if (!hasMoreComments.value || isLoadingMoreComments.value) return true
|
||||
const nextPage = commentPage.value + 1
|
||||
const done = await fetchCommentsAndChangeLog({ pageNo: nextPage, append: true })
|
||||
return done || !hasMoreComments.value
|
||||
}
|
||||
|
||||
watch(commentSort, async () => {
|
||||
@@ -929,8 +981,17 @@ const jumpToHashComment = async () => {
|
||||
const hash = location.hash
|
||||
if (hash.startsWith('#comment-')) {
|
||||
const id = hash.substring('#comment-'.length)
|
||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||
const el = document.getElementById('comment-' + id)
|
||||
await new Promise((resolve) => setTimeout(resolve, 300))
|
||||
let el = document.getElementById('comment-' + id)
|
||||
|
||||
// 若未加载到目标评论,尝试继续分页加载直到找到或无更多
|
||||
while (!el && hasMoreComments.value) {
|
||||
const done = await loadMoreTimeline()
|
||||
await nextTick()
|
||||
el = document.getElementById('comment-' + id)
|
||||
if (done) break
|
||||
}
|
||||
|
||||
if (el) {
|
||||
const top = el.getBoundingClientRect().top + window.scrollY - headerHeight - 20 // 20 for beauty
|
||||
window.scrollTo({ top, behavior: 'smooth' })
|
||||
|
||||
51
mcp/AGENTS.md
Normal file
51
mcp/AGENTS.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# MCP 服务协作指引
|
||||
|
||||
## 1) 适用范围
|
||||
|
||||
- 作用于 `mcp/` 目录及其子目录。
|
||||
- 本模块对外提供 MCP tools,接口兼容性要求高。
|
||||
|
||||
## 2) 模块结构
|
||||
|
||||
- `src/openisle_mcp/server.py`:Tool 定义与请求处理入口。
|
||||
- `src/openisle_mcp/search_client.py`:调用 OpenIsle 后端 HTTP API。
|
||||
- `src/openisle_mcp/schemas.py`:Pydantic 数据契约。
|
||||
- `src/openisle_mcp/config.py`:运行配置与环境变量读取。
|
||||
|
||||
## 3) 变更原则
|
||||
|
||||
- Tool 名称默认视为稳定契约,非必要不重命名。
|
||||
- 后端接口字段变化时,优先同步 `schemas.py`,再调整 `server.py` 映射。
|
||||
- 对认证接口保持“显式失败”:
|
||||
- 缺 token、401、403 需给出可理解错误信息。
|
||||
- 避免吞掉异常上下文,保留足够定位信息(HTTP 状态、接口语义)。
|
||||
|
||||
## 4) 与后端契约同步
|
||||
|
||||
- 高风险同步点:
|
||||
- `create_post`
|
||||
- `reply_to_post`
|
||||
- `reply_to_comment`
|
||||
- `list_unread_messages`
|
||||
- `mark_notifications_read`
|
||||
- 若后端响应结构改动,需同步:
|
||||
- `search_client.py` 的解析逻辑
|
||||
- `schemas.py` 的校验模型
|
||||
- `README.md` 的 tool 说明(如有新增/删减)
|
||||
|
||||
## 5) 配置规则
|
||||
|
||||
- 环境变量统一使用 `OPENISLE_MCP_*` 前缀。
|
||||
- 保持默认值可本地运行(如 `http://localhost:8080` 场景)。
|
||||
- 不在代码中硬编码私密 token。
|
||||
|
||||
## 6) 验证建议
|
||||
|
||||
- 安装校验:`python -m pip install -e .`
|
||||
- 启动校验:`openisle-mcp`(或项目内等价启动方式)
|
||||
- 如改动 schema/解析逻辑,至少完成一次真实后端联调请求。
|
||||
|
||||
## 7) 输出要求
|
||||
|
||||
- 说明变更是否影响 tool 输入/输出契约。
|
||||
- 说明是否要求调用方更新(参数名、字段、错误语义)。
|
||||
48
websocket_service/AGENTS.md
Normal file
48
websocket_service/AGENTS.md
Normal file
@@ -0,0 +1,48 @@
|
||||
# WebSocket Service 协作指引
|
||||
|
||||
## 1) 适用范围
|
||||
|
||||
- 作用于 `websocket_service/` 目录及其子目录。
|
||||
- 本服务是实时通知链路关键节点,改动需谨慎。
|
||||
|
||||
## 2) 服务职责
|
||||
|
||||
- 通过 STOMP 维护客户端实时连接(`/api/ws`、`/api/sockjs`)。
|
||||
- 从 RabbitMQ 队列消费通知并转发至用户/会话目的地。
|
||||
- 在连接阶段执行 JWT 鉴权。
|
||||
|
||||
## 3) 关键一致性规则
|
||||
|
||||
- JWT 密钥与后端保持一致(同一 `JWT_SECRET` 语义)。
|
||||
- 队列配置与后端分片策略同步:
|
||||
- 后端声明:`backend/.../RabbitMQConfig.java`
|
||||
- 后端分片:`backend/.../ShardingStrategy.java`
|
||||
- 本服务监听:`src/main/java/com/openisle/websocket/listener/NotificationListener.java`
|
||||
- 监听队列当前约定:16 个十六进制分片队列 + 遗留 `notifications-queue`。
|
||||
|
||||
## 4) 修改规则
|
||||
|
||||
- 不随意变更 STOMP 目的地命名(`/topic/...`、`/user/...`)。
|
||||
- 若必须调整目的地,需同步前端 `frontend_nuxt/composables/useWebSocket.js` 与相关消费代码。
|
||||
- `WebSocketAuthInterceptor` 中 CONNECT 鉴权失败策略(拒绝连接)应保持清晰一致。
|
||||
- Allowed origins 改动需考虑本地、预发、正式环境域名。
|
||||
|
||||
## 5) 配置与可观测性
|
||||
|
||||
- 配置入口:`src/main/resources/application.properties`
|
||||
- 健康检查:`/actuator/health`(部署与 compose 依赖该路径)
|
||||
- 日志级别改动需避免在生产产生高噪声。
|
||||
|
||||
## 6) 验证建议
|
||||
|
||||
- 首选:`mvn test`
|
||||
- 若暂无测试覆盖:`mvn -DskipTests compile`
|
||||
- 变更消息推送逻辑时,至少完成一次端到端验证:
|
||||
- 生产者发送消息
|
||||
- RabbitMQ 消费成功
|
||||
- 客户端收到对应目的地消息
|
||||
|
||||
## 7) 输出要求
|
||||
|
||||
- 说明是否影响队列名、路由键、目的地或鉴权逻辑。
|
||||
- 说明是否需要前端/后端同步改动。
|
||||
Reference in New Issue
Block a user