mirror of
https://github.com/nagisa77/OpenIsle.git
synced 2026-02-20 22:11:01 +08:00
Compare commits
13 Commits
feat_categ
...
codex/crea
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ab91ec2489 | ||
|
|
a24bd81942 | ||
|
|
8a008a090a | ||
|
|
5dfb69e636 | ||
|
|
499069573e | ||
|
|
636912941a | ||
|
|
bdcc1488b9 | ||
|
|
d33bd233af | ||
|
|
efe4b97d83 | ||
|
|
8a256e167d | ||
|
|
458b125834 | ||
|
|
971a3d36c6 | ||
|
|
e5d66d73cb |
@@ -2,10 +2,15 @@
|
||||
SERVER_PORT=8080
|
||||
FRONTEND_PORT=3000
|
||||
WEBSOCKET_PORT=8082
|
||||
MCP_PORT=9090
|
||||
MYSQL_PORT=3306
|
||||
REDIS_PORT=6379
|
||||
RABBITMQ_PORT=5672
|
||||
RABBITMQ_MANAGEMENT_PORT=15672
|
||||
MCP_HOST=0.0.0.0
|
||||
MCP_BACKEND_BASE_URL=http://springboot:8080
|
||||
MCP_CONNECT_TIMEOUT=5
|
||||
MCP_READ_TIMEOUT=10
|
||||
|
||||
# === OpenSearch Configuration ===
|
||||
OPENSEARCH_PORT=9200
|
||||
|
||||
@@ -66,6 +66,7 @@ public class PostController {
|
||||
req.getContent(),
|
||||
req.getTagIds(),
|
||||
req.getType(),
|
||||
req.getPostVisibleScopeType(),
|
||||
req.getPrizeDescription(),
|
||||
req.getPrizeIcon(),
|
||||
req.getPrizeCount(),
|
||||
@@ -103,7 +104,8 @@ public class PostController {
|
||||
req.getCategoryId(),
|
||||
req.getTitle(),
|
||||
req.getContent(),
|
||||
req.getTagIds()
|
||||
req.getTagIds(),
|
||||
req.getPostVisibleScopeType()
|
||||
);
|
||||
return ResponseEntity.ok(postMapper.toDetailDto(post, auth.getName()));
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.openisle.dto;
|
||||
|
||||
import com.openisle.model.PostChangeType;
|
||||
import com.openisle.model.PostVisibleScopeType;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import lombok.Getter;
|
||||
@@ -29,5 +30,7 @@ public class PostChangeLogDto {
|
||||
private LocalDateTime newPinnedAt;
|
||||
private Boolean oldFeatured;
|
||||
private Boolean newFeatured;
|
||||
private PostVisibleScopeType oldVisibleScope;
|
||||
private PostVisibleScopeType newVisibleScope;
|
||||
private Integer amount;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ package com.openisle.dto;
|
||||
import com.openisle.model.PostType;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
import com.openisle.model.PostVisibleScopeType;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
@@ -19,6 +21,7 @@ public class PostRequest {
|
||||
|
||||
// optional for lottery posts
|
||||
private PostType type;
|
||||
private PostVisibleScopeType postVisibleScopeType;
|
||||
private String prizeDescription;
|
||||
private String prizeIcon;
|
||||
private Integer prizeCount;
|
||||
|
||||
@@ -4,6 +4,8 @@ import com.openisle.model.PostStatus;
|
||||
import com.openisle.model.PostType;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
import com.openisle.model.PostVisibleScopeType;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
@@ -34,4 +36,5 @@ public class PostSummaryDto {
|
||||
private PollDto poll;
|
||||
private boolean rssExcluded;
|
||||
private boolean closed;
|
||||
private PostVisibleScopeType visibleScope;
|
||||
}
|
||||
|
||||
@@ -52,6 +52,9 @@ public class PostChangeLogMapper {
|
||||
} else if (log instanceof PostFeaturedChangeLog f) {
|
||||
dto.setOldFeatured(f.isOldFeatured());
|
||||
dto.setNewFeatured(f.isNewFeatured());
|
||||
} else if (log instanceof PostVisibleScopeChangeLog v) {
|
||||
dto.setOldVisibleScope(v.getOldVisibleScope());
|
||||
dto.setNewVisibleScope(v.getNewVisibleScope());
|
||||
} else if (log instanceof PostDonateChangeLog d) {
|
||||
dto.setAmount(d.getAmount());
|
||||
}
|
||||
|
||||
@@ -75,6 +75,7 @@ public class PostMapper {
|
||||
dto.setPinnedAt(post.getPinnedAt());
|
||||
dto.setRssExcluded(post.getRssExcluded() == null || post.getRssExcluded());
|
||||
dto.setClosed(post.isClosed());
|
||||
dto.setVisibleScope(post.getVisibleScope());
|
||||
|
||||
List<ReactionDto> reactions = reactionService
|
||||
.getReactionsForPost(post.getId())
|
||||
|
||||
@@ -66,6 +66,10 @@ public class Post {
|
||||
@Column(nullable = false)
|
||||
private PostType type = PostType.NORMAL;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false)
|
||||
private PostVisibleScopeType visibleScope = PostVisibleScopeType.ALL;
|
||||
|
||||
@Column(nullable = false)
|
||||
private boolean closed = false;
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ public enum PostChangeType {
|
||||
CLOSED,
|
||||
PINNED,
|
||||
FEATURED,
|
||||
VISIBLE_SCOPE,
|
||||
VOTE_RESULT,
|
||||
LOTTERY_RESULT,
|
||||
DONATE,
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.openisle.model;
|
||||
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.EnumType;
|
||||
import jakarta.persistence.Enumerated;
|
||||
import jakarta.persistence.Table;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@Entity
|
||||
@Table(name = "post_visible_scope_change_logs")
|
||||
public class PostVisibleScopeChangeLog extends PostChangeLog {
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
private PostVisibleScopeType oldVisibleScope;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
private PostVisibleScopeType newVisibleScope;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.openisle.model;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonCreator;
|
||||
import com.fasterxml.jackson.annotation.JsonValue;
|
||||
|
||||
public enum PostVisibleScopeType {
|
||||
ALL,
|
||||
ONLY_ME,
|
||||
ONLY_REGISTER;
|
||||
|
||||
/**
|
||||
* 防止画面传递错误的值
|
||||
* @param value
|
||||
* @return
|
||||
*/
|
||||
@JsonCreator
|
||||
public static PostVisibleScopeType fromString(String value) {
|
||||
if (value == null) return ALL;
|
||||
for (PostVisibleScopeType type : PostVisibleScopeType.values()) {
|
||||
if (type.name().equalsIgnoreCase(value)) {
|
||||
return type;
|
||||
}
|
||||
}
|
||||
// 不匹配时给默认值,而不是抛异常
|
||||
return ALL;
|
||||
}
|
||||
|
||||
@JsonValue
|
||||
public String toValue() {
|
||||
return this.name();
|
||||
}
|
||||
}
|
||||
@@ -99,6 +99,21 @@ public class PostChangeLogService {
|
||||
logRepository.save(log);
|
||||
}
|
||||
|
||||
public void recordVisibleScopeChange(
|
||||
Post post,
|
||||
User user,
|
||||
PostVisibleScopeType oldVisibleScope,
|
||||
PostVisibleScopeType newVisibleScope
|
||||
) {
|
||||
PostVisibleScopeChangeLog log = new PostVisibleScopeChangeLog();
|
||||
log.setPost(post);
|
||||
log.setUser(user);
|
||||
log.setType(PostChangeType.VISIBLE_SCOPE);
|
||||
log.setOldVisibleScope(oldVisibleScope);
|
||||
log.setNewVisibleScope(newVisibleScope);
|
||||
logRepository.save(log);
|
||||
}
|
||||
|
||||
public void recordVoteResult(Post post) {
|
||||
PostVoteResultChangeLog log = new PostVoteResultChangeLog();
|
||||
log.setPost(post);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.openisle.service;
|
||||
|
||||
import com.openisle.config.CachingConfig;
|
||||
import com.openisle.exception.NotFoundException;
|
||||
import com.openisle.exception.RateLimitException;
|
||||
import com.openisle.model.*;
|
||||
import com.openisle.repository.CategoryProposalPostRepository;
|
||||
@@ -252,6 +253,7 @@ public class PostService {
|
||||
String content,
|
||||
List<Long> tagIds,
|
||||
PostType type,
|
||||
PostVisibleScopeType postVisibleScopeType,
|
||||
String prizeDescription,
|
||||
String prizeIcon,
|
||||
Integer prizeCount,
|
||||
@@ -336,6 +338,14 @@ public class PostService {
|
||||
post.setCategory(category);
|
||||
post.setTags(new HashSet<>(tags));
|
||||
post.setStatus(publishMode == PublishMode.REVIEW ? PostStatus.PENDING : PostStatus.PUBLISHED);
|
||||
|
||||
// 什么都没设置的情况下,默认为ALL
|
||||
if (Objects.isNull(postVisibleScopeType)) {
|
||||
post.setVisibleScope(PostVisibleScopeType.ALL);
|
||||
} else {
|
||||
post.setVisibleScope(postVisibleScopeType);
|
||||
}
|
||||
|
||||
if (post instanceof LotteryPost) {
|
||||
post = lotteryPostRepository.save((LotteryPost) post);
|
||||
} else if (post instanceof CategoryProposalPost categoryProposalPost) {
|
||||
@@ -716,7 +726,7 @@ public class PostService {
|
||||
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found"));
|
||||
if (post.getStatus() != PostStatus.PUBLISHED) {
|
||||
if (viewer == null) {
|
||||
throw new com.openisle.exception.NotFoundException("Post not found");
|
||||
throw new com.openisle.exception.NotFoundException("User not found");
|
||||
}
|
||||
User viewerUser = userRepository
|
||||
.findByUsername(viewer)
|
||||
@@ -1147,7 +1157,8 @@ public class PostService {
|
||||
Long categoryId,
|
||||
String title,
|
||||
String content,
|
||||
java.util.List<Long> tagIds
|
||||
List<Long> tagIds,
|
||||
PostVisibleScopeType postVisibleScopeType
|
||||
) {
|
||||
if (tagIds == null || tagIds.isEmpty()) {
|
||||
throw new IllegalArgumentException("At least one tag required");
|
||||
@@ -1179,6 +1190,8 @@ public class PostService {
|
||||
post.setContent(content);
|
||||
post.setCategory(category);
|
||||
post.setTags(new java.util.HashSet<>(tags));
|
||||
PostVisibleScopeType oldVisibleScope = post.getVisibleScope();
|
||||
post.setVisibleScope(postVisibleScopeType);
|
||||
Post updated = postRepository.save(post);
|
||||
imageUploader.adjustReferences(oldContent, content);
|
||||
notificationService.notifyMentions(content, user, updated, null);
|
||||
@@ -1200,6 +1213,14 @@ public class PostService {
|
||||
if (!oldTags.equals(newTags)) {
|
||||
postChangeLogService.recordTagChange(updated, user, oldTags, newTags);
|
||||
}
|
||||
if (!java.util.Objects.equals(oldVisibleScope, postVisibleScopeType)) {
|
||||
postChangeLogService.recordVisibleScopeChange(
|
||||
updated,
|
||||
user,
|
||||
oldVisibleScope,
|
||||
postVisibleScopeType
|
||||
);
|
||||
}
|
||||
if (updated.getStatus() == PostStatus.PUBLISHED) {
|
||||
searchIndexEventPublisher.publishPostSaved(updated);
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE posts ADD COLUMN visible_scope ENUM('ALL', 'ONLY_ME', 'ONLY_REGISTER') NOT NULL DEFAULT 'ALL'
|
||||
@@ -40,12 +40,12 @@ echo "👉 Build images ..."
|
||||
docker compose -f "$compose_file" --env-file "$env_file" \
|
||||
build --pull \
|
||||
--build-arg NUXT_ENV=production \
|
||||
frontend_service
|
||||
frontend_service mcp-service
|
||||
|
||||
echo "👉 Recreate & start all target services (no dev profile)..."
|
||||
docker compose -f "$compose_file" --env-file "$env_file" \
|
||||
up -d --force-recreate --remove-orphans --no-deps \
|
||||
mysql redis rabbitmq websocket-service springboot frontend_service
|
||||
mysql redis rabbitmq websocket-service springboot mcp-service frontend_service
|
||||
|
||||
echo "👉 Current status:"
|
||||
docker compose -f "$compose_file" --env-file "$env_file" ps
|
||||
|
||||
@@ -36,16 +36,15 @@ echo "👉 Pull base images (for image-based services)..."
|
||||
docker compose -f "$compose_file" --env-file "$env_file" pull --ignore-pull-failures
|
||||
|
||||
echo "👉 Build images (staging)..."
|
||||
# 前端 + OpenSearch 都是自建镜像;--pull 更新其基础镜像
|
||||
docker compose -f "$compose_file" --env-file "$env_file" \
|
||||
build --pull \
|
||||
--build-arg NUXT_ENV=staging \
|
||||
frontend_service
|
||||
frontend_service mcp-service
|
||||
|
||||
echo "👉 Recreate & start all target services (no dev profile)..."
|
||||
docker compose -f "$compose_file" --env-file "$env_file" \
|
||||
up -d --force-recreate --remove-orphans --no-deps \
|
||||
mysql redis rabbitmq websocket-service springboot frontend_service
|
||||
mysql redis rabbitmq websocket-service springboot mcp-service frontend_service
|
||||
|
||||
echo "👉 Current status:"
|
||||
docker compose -f "$compose_file" --env-file "$env_file" ps
|
||||
|
||||
@@ -161,8 +161,8 @@ services:
|
||||
condition: service_started
|
||||
websocket-service:
|
||||
condition: service_healthy
|
||||
opensearch:
|
||||
condition: service_healthy
|
||||
# opensearch:
|
||||
# condition: service_healthy
|
||||
command: >
|
||||
sh -c "apt-get update && apt-get install -y --no-install-recommends curl &&
|
||||
mvn clean spring-boot:run -Dmaven.test.skip=true"
|
||||
@@ -178,6 +178,38 @@ services:
|
||||
- dev
|
||||
- prod
|
||||
|
||||
mcp-service:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: mcp/Dockerfile
|
||||
container_name: ${COMPOSE_PROJECT_NAME}-openisle-mcp
|
||||
env_file:
|
||||
- ${ENV_FILE:-../.env}
|
||||
environment:
|
||||
MCP_HOST: ${MCP_HOST:-0.0.0.0}
|
||||
MCP_PORT: ${MCP_PORT:-9090}
|
||||
MCP_BACKEND_BASE_URL: ${MCP_BACKEND_BASE_URL:-http://springboot:8080}
|
||||
MCP_CONNECT_TIMEOUT: ${MCP_CONNECT_TIMEOUT:-5}
|
||||
MCP_READ_TIMEOUT: ${MCP_READ_TIMEOUT:-10}
|
||||
ports:
|
||||
- "${MCP_PORT:-9090}:${MCP_PORT:-9090}"
|
||||
depends_on:
|
||||
springboot:
|
||||
condition: service_healthy
|
||||
command: ["openisle-mcp"]
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:${MCP_PORT:-9090}/healthz || exit 1"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 30
|
||||
start_period: 20s
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- openisle-network
|
||||
profiles:
|
||||
- dev
|
||||
- prod
|
||||
|
||||
websocket-service:
|
||||
image: maven:3.9-eclipse-temurin-17
|
||||
container_name: ${COMPOSE_PROJECT_NAME}-openisle-websocket
|
||||
|
||||
@@ -543,6 +543,7 @@ onMounted(async () => {
|
||||
.header-label {
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 在线人数的数字文字样式(无背景) */
|
||||
|
||||
@@ -3,15 +3,30 @@
|
||||
<div class="login-overlay-blur"></div>
|
||||
<div class="login-overlay-content">
|
||||
<user-icon class="login-overlay-icon" />
|
||||
<div class="login-overlay-text">请先登录,点击跳转到登录页面</div>
|
||||
<div class="login-overlay-button" @click="goLogin">登录</div>
|
||||
<div class="login-overlay-text">{{ props.text }}</div>
|
||||
<div class="login-overlay-button" @click="goLogin">{{ props.buttonText }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
text: {
|
||||
type: String,
|
||||
default: '请先登录,点击跳转到登录页面',
|
||||
},
|
||||
buttonText: {
|
||||
type: String,
|
||||
default: '登录',
|
||||
},
|
||||
buttonLink: {
|
||||
type: String,
|
||||
default: '/login',
|
||||
},
|
||||
})
|
||||
|
||||
const goLogin = () => {
|
||||
navigateTo('/login', { replace: true })
|
||||
navigateTo(props.buttonLink, { replace: true })
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -36,6 +36,10 @@
|
||||
<template v-if="log.newFeatured">将文章设为精选</template>
|
||||
<template v-else>取消精选文章</template>
|
||||
</span>
|
||||
<span v-else-if="log.type === 'VISIBLE_SCOPE'" class="change-log-content">
|
||||
变更了文章可见范围, 从 {{ formatVisibleScope(log.oldVisibleScope) }} 修改为
|
||||
{{ formatVisibleScope(log.newVisibleScope) }}
|
||||
</span>
|
||||
<span v-else-if="log.type === 'VOTE_RESULT'" class="change-log-content"
|
||||
>系统已计算投票结果</span
|
||||
>
|
||||
@@ -69,6 +73,17 @@ const props = defineProps({
|
||||
title: String,
|
||||
})
|
||||
|
||||
const VISIBLE_SCOPE_LABELS = {
|
||||
ALL: '全部可见',
|
||||
ONLY_ME: '仅自己可见',
|
||||
ONLY_REGISTER: '仅注册用户可见',
|
||||
}
|
||||
|
||||
const formatVisibleScope = (scope) => {
|
||||
if (!scope) return VISIBLE_SCOPE_LABELS.ALL
|
||||
return VISIBLE_SCOPE_LABELS[scope] ?? scope
|
||||
}
|
||||
|
||||
const diffHtml = computed(() => {
|
||||
// Track theme changes
|
||||
const isDark = import.meta.client && document.documentElement.dataset.theme === 'dark'
|
||||
|
||||
41
frontend_nuxt/components/PostVisibleScopeSelect.vue
Normal file
41
frontend_nuxt/components/PostVisibleScopeSelect.vue
Normal file
@@ -0,0 +1,41 @@
|
||||
<template>
|
||||
<Dropdown
|
||||
v-model="selected"
|
||||
:fetch-options="fetchTypes"
|
||||
placeholder="选择帖子可见范围"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import Dropdown from '~/components/Dropdown.vue'
|
||||
|
||||
export default {
|
||||
name: 'PostVisibleScopeSelect',
|
||||
components: { Dropdown },
|
||||
props: {
|
||||
modelValue: { type: String, default: 'ALL' },
|
||||
// options: { type: Array, default: () => [] },
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
setup(props, { emit }) {
|
||||
|
||||
const fetchTypes = async () => {
|
||||
return [
|
||||
{ id: 'ALL', name: '全部可见', icon: 'communication' },
|
||||
{ id: 'ONLY_ME', name: '仅自己可见', icon: 'user-icon' },
|
||||
{ id: 'ONLY_REGISTER', name: '仅注册用户可见', icon: 'peoples-two' },
|
||||
]
|
||||
}
|
||||
|
||||
const selected = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (v) => emit('update:modelValue', v),
|
||||
})
|
||||
|
||||
return { fetchTypes, selected }
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -70,6 +70,7 @@
|
||||
<hands v-else-if="article.type === 'PROPOSAL'" class="proposal-icon" />
|
||||
<star v-if="!article.rssExcluded" class="featured-icon" />
|
||||
{{ article.title }}
|
||||
<lock class="preview-close-icon" v-if="article.isRestricted" />
|
||||
</NuxtLink>
|
||||
<NuxtLink class="article-item-description main-item" :to="`/posts/${article.id}`">
|
||||
<div v-html="stripMarkdownWithTiebaMoji(article.description, 500)"></div>
|
||||
@@ -295,6 +296,7 @@ const {
|
||||
comments: p.commentCount,
|
||||
views: p.views,
|
||||
rssExcluded: p.rssExcluded || false,
|
||||
isRestricted: p.visibleScope === 'ONLY_ME' || p.visibleScope === 'ONLY_REGISTER',
|
||||
time: TimeManager.format(
|
||||
selectedTopic.value === '最新回复' ? p.lastReplyAt || p.createdAt : p.createdAt,
|
||||
),
|
||||
@@ -336,6 +338,7 @@ const fetchNextPage = async () => {
|
||||
members: (p.participants || []).map((m) => ({ id: m.id, avatar: m.avatar })),
|
||||
comments: p.commentCount,
|
||||
views: p.views,
|
||||
isRestricted: p.visibleScope === 'ONLY_ME' || p.visibleScope === 'ONLY_REGISTER',
|
||||
rssExcluded: p.rssExcluded || false,
|
||||
time: TimeManager.format(
|
||||
selectedTopic.value === '最新回复' ? p.lastReplyAt || p.createdAt : p.createdAt,
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
<CategorySelect v-model="selectedCategory" />
|
||||
<TagSelect v-model="selectedTags" creatable />
|
||||
<PostTypeSelect v-model="postType" />
|
||||
<PostVisibleScopeSelect v-model="postVisibleScope"/>
|
||||
</div>
|
||||
<div class="post-options-right">
|
||||
<div class="post-clear" @click="clearPost"><clear-icon /> 清空</div>
|
||||
@@ -54,6 +55,7 @@ import PollForm from '~/components/PollForm.vue'
|
||||
import ProposalForm from '~/components/ProposalForm.vue'
|
||||
import { toast } from '~/main'
|
||||
import { authState, getToken } from '~/utils/auth'
|
||||
import PostVisibleScopeSelect from '~/components/PostVisibleScopeSelect.vue'
|
||||
const config = useRuntimeConfig()
|
||||
const API_BASE_URL = config.public.apiBaseUrl
|
||||
|
||||
@@ -62,6 +64,7 @@ const content = ref('')
|
||||
const selectedCategory = ref('')
|
||||
const selectedTags = ref([])
|
||||
const postType = ref('NORMAL')
|
||||
const postVisibleScope = ref('ALL')
|
||||
const lottery = reactive({
|
||||
prizeIcon: '',
|
||||
prizeIconFile: null,
|
||||
@@ -100,6 +103,7 @@ const loadDraft = async () => {
|
||||
content.value = data.content || ''
|
||||
selectedCategory.value = data.categoryId || ''
|
||||
selectedTags.value = data.tagIds || []
|
||||
postVisibleScope.value = data.visiblescope
|
||||
|
||||
toast.success('草稿已加载')
|
||||
}
|
||||
@@ -115,6 +119,7 @@ const clearPost = async () => {
|
||||
content.value = ''
|
||||
selectedCategory.value = ''
|
||||
selectedTags.value = []
|
||||
postVisibleScope.value = 'ALL'
|
||||
postType.value = 'NORMAL'
|
||||
lottery.prizeIcon = ''
|
||||
lottery.prizeIconFile = null
|
||||
@@ -168,6 +173,7 @@ const saveDraft = async () => {
|
||||
content: content.value,
|
||||
categoryId: selectedCategory.value || null,
|
||||
tagIds,
|
||||
postVisibleScopeType:postVisibleScope.value
|
||||
}),
|
||||
})
|
||||
if (res.ok) {
|
||||
@@ -328,6 +334,7 @@ const submitPost = async () => {
|
||||
categoryId: selectedCategory.value,
|
||||
tagIds: selectedTags.value,
|
||||
type: postType.value,
|
||||
postVisibleScopeType: postVisibleScope.value,
|
||||
}
|
||||
|
||||
if (postType.value === 'LOTTERY') {
|
||||
@@ -355,6 +362,7 @@ const submitPost = async () => {
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
|
||||
const data = await res.json()
|
||||
if (res.ok) {
|
||||
if (data.reward && data.reward > 0) {
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
<div class="post-options-left">
|
||||
<CategorySelect v-model="selectedCategory" />
|
||||
<TagSelect v-model="selectedTags" creatable />
|
||||
<PostVisibleScopeSelect v-model="selectedVisibleScope"/>
|
||||
</div>
|
||||
<div class="post-options-right">
|
||||
<div class="post-clear" @click="clearPost"><clear-icon /> 清空</div>
|
||||
@@ -44,6 +45,7 @@ import TagSelect from '~/components/TagSelect.vue'
|
||||
import { toast } from '~/main'
|
||||
import { getToken, authState } from '~/utils/auth'
|
||||
import LoginOverlay from '~/components/LoginOverlay.vue'
|
||||
import PostVisibleScopeSelect from '~/components/PostVisibleScopeSelect.vue'
|
||||
const config = useRuntimeConfig()
|
||||
const API_BASE_URL = config.public.apiBaseUrl
|
||||
|
||||
@@ -51,6 +53,7 @@ const title = ref('')
|
||||
const content = ref('')
|
||||
const selectedCategory = ref('')
|
||||
const selectedTags = ref([])
|
||||
const selectedVisibleScope = ref('ALL')
|
||||
const isWaitingPosting = ref(false)
|
||||
const isAiLoading = ref(false)
|
||||
const isLogin = computed(() => authState.loggedIn)
|
||||
@@ -70,6 +73,7 @@ const loadPost = async () => {
|
||||
content.value = data.content || ''
|
||||
selectedCategory.value = data.category.id || ''
|
||||
selectedTags.value = (data.tags || []).map((t) => t.id)
|
||||
selectedVisibleScope.value = data.visibleScope
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error('加载失败')
|
||||
@@ -180,6 +184,7 @@ const submitPost = async () => {
|
||||
content: content.value,
|
||||
categoryId: selectedCategory.value,
|
||||
tagIds: selectedTags.value,
|
||||
postVisibleScopeType:selectedVisibleScope.value
|
||||
}),
|
||||
})
|
||||
const data = await res.json()
|
||||
|
||||
@@ -1,5 +1,22 @@
|
||||
<template>
|
||||
<div class="post-page-container">
|
||||
<div v-if="isRestricted" class="restricted-content">
|
||||
<template v-if="visibleScope === 'ONLY_ME'">
|
||||
<LoginOverlay
|
||||
text="这是一篇私密文章,仅作者本人及管理员可见"
|
||||
button-text="返回首页"
|
||||
button-link="/"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template v-else-if="visibleScope === 'ONLY_REGISTER'">
|
||||
<LoginOverlay
|
||||
text="这是一篇仅登录用户可见的文章,请先登录"
|
||||
button-text="登录"
|
||||
button-link="/login"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
<div v-else class="post-page-container">
|
||||
<div v-if="isWaitingFetchingPost" class="loading-container">
|
||||
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
||||
</div>
|
||||
@@ -16,7 +33,9 @@
|
||||
<div v-if="status === 'PENDING'" class="article-pending-button">审核中</div>
|
||||
<div v-if="status === 'REJECTED'" class="article-block-button">已拒绝</div>
|
||||
<div v-if="!rssExcluded" class="article-featured-button">精品</div>
|
||||
<div v-if="closed" class="article-closed-button">已关闭</div>
|
||||
<div v-if="closed" class="article-gray-button">已关闭</div>
|
||||
<div v-if="visibleScope === 'ONLY_ME'" class="article-gray-button">仅自己可见</div>
|
||||
<div v-if="visibleScope === 'ONLY_REGISTER'" class="article-gray-button">仅登录可见</div>
|
||||
<div
|
||||
v-if="!closed && loggedIn && !isAuthor && !subscribed"
|
||||
class="article-subscribe-button"
|
||||
@@ -165,25 +184,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="post-page-scroller-container">
|
||||
<div class="scroller">
|
||||
<div v-if="isWaitingFetchingPost" class="scroller-time">loading...</div>
|
||||
<div v-else class="scroller-time">{{ scrollerTopTime }}</div>
|
||||
<div class="scroller-middle">
|
||||
<input
|
||||
type="range"
|
||||
class="scroller-range"
|
||||
:max="totalPosts"
|
||||
:min="1"
|
||||
v-model.number="currentIndex"
|
||||
@input="onSliderInput"
|
||||
/>
|
||||
<div class="scroller-index">{{ currentIndex }}/{{ totalPosts }}</div>
|
||||
</div>
|
||||
<div v-if="isWaitingFetchingPost" class="scroller-time">loading...</div>
|
||||
<div v-else class="scroller-time">{{ lastReplyTime }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<vue-easy-lightbox
|
||||
:visible="lightboxVisible"
|
||||
:index="lightboxIndex"
|
||||
@@ -228,6 +228,7 @@ import { useIsMobile } from '~/utils/screen'
|
||||
import Dropdown from '~/components/Dropdown.vue'
|
||||
import { ClientOnly } from '#components'
|
||||
import { useConfirm } from '~/composables/useConfirm'
|
||||
import { Lock } from '@icon-park/vue-next'
|
||||
const { confirm } = useConfirm()
|
||||
|
||||
const config = useRuntimeConfig()
|
||||
@@ -241,6 +242,13 @@ const author = ref('')
|
||||
const postContent = ref('')
|
||||
const category = ref('')
|
||||
const tags = ref([])
|
||||
const visibleScope = ref('ALL') // 可见范围
|
||||
const isRestricted = computed(() => {
|
||||
return (
|
||||
(visibleScope.value === 'ONLY_ME' && !isAuthor.value && !isAdmin.value) ||
|
||||
(visibleScope.value === 'ONLY_REGISTER' && !loggedIn.value)
|
||||
)
|
||||
})
|
||||
const postReactions = ref([])
|
||||
const postReactionsGroupRef = ref(null)
|
||||
const postLikeCount = computed(
|
||||
@@ -408,6 +416,14 @@ const changeLogIcon = (l) => {
|
||||
} else {
|
||||
return 'dislike'
|
||||
}
|
||||
} else if (l.type === 'VISIBLE_SCOPE') {
|
||||
if (l.newVisibleScope === 'ONLY_ME') {
|
||||
return 'lock-one'
|
||||
} else if (l.newVisibleScope === 'ONLY_REGISTER') {
|
||||
return 'peoples-two'
|
||||
} else {
|
||||
return 'communication'
|
||||
}
|
||||
} else if (l.type === 'VOTE_RESULT') {
|
||||
return 'check-one'
|
||||
} else if (l.type === 'LOTTERY_RESULT') {
|
||||
@@ -438,6 +454,8 @@ const mapChangeLog = (l) => ({
|
||||
newCategory: l.newCategory,
|
||||
oldTags: l.oldTags,
|
||||
newTags: l.newTags,
|
||||
oldVisibleScope: l.oldVisibleScope,
|
||||
newVisibleScope: l.newVisibleScope,
|
||||
amount: l.amount,
|
||||
icon: changeLogIcon(l),
|
||||
})
|
||||
@@ -497,15 +515,27 @@ const onCommentDeleted = (id) => {
|
||||
fetchTimeline()
|
||||
}
|
||||
|
||||
const tokenHeader = computed(() => {
|
||||
const token = getToken()
|
||||
return token ? { Authorization: `Bearer ${token}` } : {}
|
||||
})
|
||||
const {
|
||||
data: postData,
|
||||
pending: pendingPost,
|
||||
error: postError,
|
||||
refresh: refreshPost,
|
||||
} = await useAsyncData(`post-${postId}`, () => $fetch(`${API_BASE_URL}/api/posts/${postId}`), {
|
||||
server: true,
|
||||
lazy: false,
|
||||
})
|
||||
} = await useAsyncData(
|
||||
`post-${postId}`,
|
||||
async () => {
|
||||
try {
|
||||
return await $fetch(`${API_BASE_URL}/api/posts/${postId}`, { headers: tokenHeader.value })
|
||||
} catch (err) {}
|
||||
},
|
||||
{
|
||||
server: false,
|
||||
lazy: false,
|
||||
},
|
||||
)
|
||||
|
||||
// 用 pendingPost 驱动现有 UI(替代 isWaitingFetchingPost 手控)
|
||||
const isWaitingFetchingPost = computed(() => pendingPost.value)
|
||||
@@ -519,6 +549,7 @@ watchEffect(() => {
|
||||
title.value = data.title
|
||||
category.value = data.category
|
||||
tags.value = data.tags || []
|
||||
visibleScope.value = data.visibleScope || 'ALL'
|
||||
postReactions.value = data.reactions || []
|
||||
subscribed.value = !!data.subscribed
|
||||
status.value = data.status
|
||||
@@ -935,7 +966,7 @@ onMounted(async () => {
|
||||
<style>
|
||||
.post-page-container {
|
||||
background-color: var(--background-color);
|
||||
display: flex;
|
||||
display: block;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
@@ -948,9 +979,10 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
.post-page-main-container {
|
||||
position: relative;
|
||||
scrollbar-width: none;
|
||||
padding: 20px;
|
||||
width: calc(85% - 40px);
|
||||
width: calc(100% - 40px);
|
||||
}
|
||||
|
||||
.info-content-text p code {
|
||||
@@ -1002,6 +1034,35 @@ onMounted(async () => {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.skeleton {
|
||||
background-color: #eee;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
height: 20px;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.skeleton::before {
|
||||
content: '';
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
background: linear-gradient(90deg, #eee 0%, #f5f5f7 40%, #e0e0e0 100%);
|
||||
transform: translateX(-100%);
|
||||
animation: skeleton-shimmer 1.5s infinite linear;
|
||||
z-index: 1;
|
||||
border-radius: 8px;
|
||||
}
|
||||
@keyframes skeleton-shimmer {
|
||||
100% {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
|
||||
.user-avatar-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
@@ -1106,7 +1167,7 @@ onMounted(async () => {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.article-closed-button,
|
||||
.article-gray-button,
|
||||
.article-subscribe-button-text,
|
||||
.article-featured-button,
|
||||
.article-unsubscribe-button-text {
|
||||
@@ -1159,7 +1220,7 @@ onMounted(async () => {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.article-closed-button {
|
||||
.article-gray-button {
|
||||
background-color: var(--background-color);
|
||||
color: gray;
|
||||
border: 1px solid gray;
|
||||
@@ -1341,6 +1402,76 @@ onMounted(async () => {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* ======== 权限锁定状态 ======== */
|
||||
.is-blurred {
|
||||
filter: blur(10px);
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
transition: filter 0.3s ease;
|
||||
}
|
||||
|
||||
/* 遮罩层 */
|
||||
.restricted-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 999;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
backdrop-filter: blur(12px);
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
animation: fadeIn 0.3s ease forwards;
|
||||
}
|
||||
|
||||
/* 中央提示框 */
|
||||
.restricted-content {
|
||||
background: #ffff;
|
||||
color: var(--primary-color);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.restricted-icon {
|
||||
font-size: 60px;
|
||||
opacity: 0.8;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.restricted-button {
|
||||
display: inline-block;
|
||||
margin-top: 20px;
|
||||
padding: 10px 18px;
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.restricted-button:hover {
|
||||
background: var(--primary-color-hover);
|
||||
}
|
||||
|
||||
.restricted-actions {
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* 淡入动画 */
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.post-page-main-container {
|
||||
width: calc(100% - 20px);
|
||||
|
||||
@@ -82,6 +82,7 @@ import {
|
||||
Share,
|
||||
Financing,
|
||||
Hands,
|
||||
PreviewCloseOne,
|
||||
} from '@icon-park/vue-next'
|
||||
|
||||
export default defineNuxtPlugin((nuxtApp) => {
|
||||
@@ -167,4 +168,5 @@ export default defineNuxtPlugin((nuxtApp) => {
|
||||
nuxtApp.vueApp.component('Share', Share)
|
||||
nuxtApp.vueApp.component('Financing', Financing)
|
||||
nuxtApp.vueApp.component('Hands', Hands)
|
||||
nuxtApp.vueApp.component('PreviewCloseOne', PreviewCloseOne)
|
||||
})
|
||||
|
||||
@@ -268,23 +268,21 @@ export function stripMarkdownLength(text, length) {
|
||||
|
||||
// 朴素文本带贴吧表情
|
||||
export function stripMarkdownWithTiebaMoji(text, length){
|
||||
console.error(text)
|
||||
if (!text) return ''
|
||||
|
||||
// Markdown 转成纯文本
|
||||
const plain = stripMarkdown(text)
|
||||
console.error(plain)
|
||||
// 替换 :tieba123: 为 <img>
|
||||
const withEmoji = plain.replace(/:tieba(\d+):/g, (match, num) => {
|
||||
const key = `tieba${num}`
|
||||
const file = tiebaEmoji[key]
|
||||
return file
|
||||
? `<img loading="lazy" class="emoji" src="${file}" alt="${key}">`
|
||||
: match // 没有匹配到图片则保留原样
|
||||
})
|
||||
// Markdown 转成纯文本
|
||||
const plain = stripMarkdown(text)
|
||||
// 替换 :tieba123: 为 <img>
|
||||
const withEmoji = plain.replace(/:tieba(\d+):/g, (match, num) => {
|
||||
const key = `tieba${num}`
|
||||
const file = tiebaEmoji[key]
|
||||
return file
|
||||
? `<img loading="lazy" class="emoji" src="${file}" alt="${key}">`
|
||||
: match // 没有匹配到图片则保留原样
|
||||
})
|
||||
|
||||
// 截断纯文本长度(防止撑太长)
|
||||
const truncated = withEmoji.length > length ? withEmoji.slice(0, length) + '...' : withEmoji
|
||||
return truncated
|
||||
// 截断纯文本长度(防止撑太长)
|
||||
const truncated = withEmoji.length > length ? withEmoji.slice(0, length) + '...' : withEmoji
|
||||
return truncated
|
||||
|
||||
}
|
||||
|
||||
21
mcp/Dockerfile
Normal file
21
mcp/Dockerfile
Normal file
@@ -0,0 +1,21 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY mcp/pyproject.toml ./pyproject.toml
|
||||
COPY mcp/README.md ./README.md
|
||||
COPY mcp/src ./src
|
||||
|
||||
RUN pip install --no-cache-dir --upgrade pip \
|
||||
&& pip install --no-cache-dir .
|
||||
|
||||
EXPOSE 9090
|
||||
|
||||
CMD ["openisle-mcp"]
|
||||
34
mcp/README.md
Normal file
34
mcp/README.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# OpenIsle MCP Service
|
||||
|
||||
This package hosts a lightweight Python service that exposes OpenIsle search
|
||||
capabilities through a Model Context Protocol (MCP) compatible HTTP interface.
|
||||
It currently forwards search requests to the main Spring Boot backend and
|
||||
returns the aggregated results. The service is intentionally simple so we can
|
||||
iterate quickly and extend it with additional tools (for example, post
|
||||
creation) in future updates.
|
||||
|
||||
## Local development
|
||||
|
||||
```bash
|
||||
pip install -e ./mcp
|
||||
openisle-mcp
|
||||
```
|
||||
|
||||
By default the server listens on port `9090` and expects the Spring Boot backend
|
||||
at `http://localhost:8080`. Configure the behaviour with the following
|
||||
environment variables:
|
||||
|
||||
- `MCP_PORT` – HTTP port the MCP service should listen on (default: `9090`).
|
||||
- `MCP_HOST` – Bind host for the HTTP server (default: `0.0.0.0`).
|
||||
- `MCP_BACKEND_BASE_URL` – Base URL of the Spring Boot backend that provides the
|
||||
search endpoints (default: `http://springboot:8080`).
|
||||
- `MCP_CONNECT_TIMEOUT` – Connection timeout (seconds) when calling the backend
|
||||
(default: `5`).
|
||||
- `MCP_READ_TIMEOUT` – Read timeout (seconds) when calling the backend (default:
|
||||
`10`).
|
||||
|
||||
## Docker
|
||||
|
||||
The repository contains a Dockerfile that builds a slim Python image running the
|
||||
service with `uvicorn`. The compose configuration wires the container into the
|
||||
existing OpenIsle stack so that deployments automatically start the MCP service.
|
||||
25
mcp/pyproject.toml
Normal file
25
mcp/pyproject.toml
Normal file
@@ -0,0 +1,25 @@
|
||||
[build-system]
|
||||
requires = ["hatchling>=1.21.0"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "openisle-mcp"
|
||||
version = "0.1.0"
|
||||
description = "Model Context Protocol server exposing OpenIsle search capabilities"
|
||||
readme = "README.md"
|
||||
authors = [
|
||||
{ name = "OpenIsle" }
|
||||
]
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"fastapi>=0.111.0,<1.0.0",
|
||||
"uvicorn[standard]>=0.29.0,<0.31.0",
|
||||
"httpx>=0.27.0,<0.28.0",
|
||||
"pydantic>=2.7.0,<3.0.0"
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
openisle-mcp = "openisle_mcp.__main__:main"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/openisle_mcp"]
|
||||
6
mcp/src/openisle_mcp/__init__.py
Normal file
6
mcp/src/openisle_mcp/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""OpenIsle MCP service package."""
|
||||
|
||||
from .config import Settings, get_settings
|
||||
from .server import create_app
|
||||
|
||||
__all__ = ["Settings", "get_settings", "create_app"]
|
||||
24
mcp/src/openisle_mcp/__main__.py
Normal file
24
mcp/src/openisle_mcp/__main__.py
Normal file
@@ -0,0 +1,24 @@
|
||||
"""Entrypoint for running the MCP service with ``python -m``."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
import uvicorn
|
||||
|
||||
from .config import get_settings
|
||||
|
||||
|
||||
def main() -> None:
|
||||
settings = get_settings()
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
uvicorn.run(
|
||||
"openisle_mcp.server:create_app",
|
||||
host=settings.host,
|
||||
port=settings.port,
|
||||
factory=True,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
44
mcp/src/openisle_mcp/client.py
Normal file
44
mcp/src/openisle_mcp/client.py
Normal file
@@ -0,0 +1,44 @@
|
||||
"""HTTP client helpers for talking to the Spring Boot backend."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from .config import Settings
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SearchClient:
|
||||
"""Wrapper around :class:`httpx.AsyncClient` for search operations."""
|
||||
|
||||
def __init__(self, settings: Settings):
|
||||
timeout = httpx.Timeout(
|
||||
connect=settings.connect_timeout,
|
||||
read=settings.read_timeout,
|
||||
write=settings.read_timeout,
|
||||
pool=None,
|
||||
)
|
||||
self._client = httpx.AsyncClient(
|
||||
base_url=settings.normalized_backend_base_url,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
async def close(self) -> None:
|
||||
await self._client.aclose()
|
||||
|
||||
async def global_search(self, keyword: str) -> list[dict[str, Any]]:
|
||||
LOGGER.debug("Performing global search for keyword '%s'", keyword)
|
||||
response = await self._client.get("/api/search/global", params={"keyword": keyword})
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
if isinstance(payload, list):
|
||||
return payload
|
||||
LOGGER.warning("Unexpected payload type from backend: %s", type(payload))
|
||||
return []
|
||||
|
||||
|
||||
__all__ = ["SearchClient"]
|
||||
71
mcp/src/openisle_mcp/config.py
Normal file
71
mcp/src/openisle_mcp/config.py
Normal file
@@ -0,0 +1,71 @@
|
||||
"""Configuration helpers for the MCP service."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from functools import lru_cache
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, ValidationError
|
||||
|
||||
|
||||
class Settings(BaseModel):
|
||||
"""Application settings sourced from environment variables."""
|
||||
|
||||
host: str = Field(default="0.0.0.0", description="Host to bind the HTTP server to")
|
||||
port: int = Field(default=9090, ge=1, le=65535, description="Port exposed by the MCP server")
|
||||
backend_base_url: str = Field(
|
||||
default="http://springboot:8080",
|
||||
description="Base URL of the Spring Boot backend that provides search endpoints",
|
||||
)
|
||||
connect_timeout: float = Field(
|
||||
default=5.0,
|
||||
ge=0.0,
|
||||
description="Connection timeout when communicating with the backend (seconds)",
|
||||
)
|
||||
read_timeout: float = Field(
|
||||
default=10.0,
|
||||
ge=0.0,
|
||||
description="Read timeout when communicating with the backend (seconds)",
|
||||
)
|
||||
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
@property
|
||||
def normalized_backend_base_url(self) -> str:
|
||||
"""Return the backend base URL without a trailing slash."""
|
||||
|
||||
return self.backend_base_url.rstrip("/")
|
||||
|
||||
|
||||
ENV_MAPPING: dict[str, str] = {
|
||||
"host": "MCP_HOST",
|
||||
"port": "MCP_PORT",
|
||||
"backend_base_url": "MCP_BACKEND_BASE_URL",
|
||||
"connect_timeout": "MCP_CONNECT_TIMEOUT",
|
||||
"read_timeout": "MCP_READ_TIMEOUT",
|
||||
}
|
||||
|
||||
|
||||
def _load_environment_values() -> dict[str, Any]:
|
||||
values: dict[str, Any] = {}
|
||||
for field, env_name in ENV_MAPPING.items():
|
||||
value = os.getenv(env_name)
|
||||
if value is None:
|
||||
continue
|
||||
values[field] = value
|
||||
return values
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_settings() -> Settings:
|
||||
"""Load and validate application settings."""
|
||||
|
||||
values = _load_environment_values()
|
||||
try:
|
||||
return Settings(**values)
|
||||
except ValidationError as exc: # pragma: no cover - defensive branch
|
||||
raise RuntimeError("Invalid MCP configuration") from exc
|
||||
|
||||
|
||||
__all__ = ["Settings", "get_settings"]
|
||||
38
mcp/src/openisle_mcp/models.py
Normal file
38
mcp/src/openisle_mcp/models.py
Normal file
@@ -0,0 +1,38 @@
|
||||
"""Pydantic models shared across the MCP service."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class SearchResult(BaseModel):
|
||||
"""Representation of a single search result entry."""
|
||||
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
type: Optional[str] = Field(default=None, description="Type of the result entry")
|
||||
id: Optional[int] = Field(default=None, description="Identifier of the result entry")
|
||||
text: Optional[str] = Field(default=None, description="Primary text of the result entry")
|
||||
subText: Optional[str] = Field(default=None, description="Secondary text associated with the result")
|
||||
extra: Optional[str] = Field(default=None, description="Additional information about the result")
|
||||
postId: Optional[int] = Field(default=None, description="Related post identifier, if applicable")
|
||||
highlightedText: Optional[str] = Field(default=None, description="Highlighted primary text segment")
|
||||
highlightedSubText: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Highlighted secondary text segment",
|
||||
)
|
||||
highlightedExtra: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Highlighted additional information",
|
||||
)
|
||||
|
||||
|
||||
class SearchResponse(BaseModel):
|
||||
"""Response payload returned by the search endpoint."""
|
||||
|
||||
results: list[SearchResult] = Field(default_factory=list)
|
||||
|
||||
|
||||
__all__ = ["SearchResult", "SearchResponse"]
|
||||
66
mcp/src/openisle_mcp/server.py
Normal file
66
mcp/src/openisle_mcp/server.py
Normal file
@@ -0,0 +1,66 @@
|
||||
"""FastAPI application exposing the MCP server endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import Depends, FastAPI, HTTPException, Query, Request
|
||||
import httpx
|
||||
|
||||
from .client import SearchClient
|
||||
from .config import get_settings
|
||||
from .models import SearchResponse, SearchResult
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def _lifespan(app: FastAPI):
|
||||
settings = get_settings()
|
||||
client = SearchClient(settings)
|
||||
app.state.settings = settings
|
||||
app.state.search_client = client
|
||||
LOGGER.info(
|
||||
"Starting MCP server on %s:%s targeting backend %s",
|
||||
settings.host,
|
||||
settings.port,
|
||||
settings.normalized_backend_base_url,
|
||||
)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
LOGGER.info("Shutting down MCP server")
|
||||
await client.close()
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
"""Create and configure the FastAPI application."""
|
||||
|
||||
app = FastAPI(title="OpenIsle MCP Server", lifespan=_lifespan)
|
||||
|
||||
@app.get("/healthz", tags=["health"])
|
||||
async def healthcheck() -> dict[str, str]:
|
||||
return {"status": "ok"}
|
||||
|
||||
async def get_client(request: Request) -> SearchClient:
|
||||
return request.app.state.search_client
|
||||
|
||||
@app.get("/search", response_model=SearchResponse, tags=["search"])
|
||||
async def search(
|
||||
keyword: str = Query(..., min_length=1, description="Keyword to search for"),
|
||||
client: SearchClient = Depends(get_client),
|
||||
) -> SearchResponse:
|
||||
try:
|
||||
raw_results = await client.global_search(keyword)
|
||||
except httpx.HTTPStatusError as exc:
|
||||
LOGGER.warning("Backend responded with error %s", exc.response.status_code)
|
||||
raise HTTPException(status_code=exc.response.status_code, detail="Backend error") from exc
|
||||
except httpx.HTTPError as exc:
|
||||
LOGGER.error("Failed to reach backend: %s", exc)
|
||||
raise HTTPException(status_code=503, detail="Search service unavailable") from exc
|
||||
results = [SearchResult.model_validate(item) for item in raw_results]
|
||||
return SearchResponse(results=results)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
__all__ = ["create_app"]
|
||||
Reference in New Issue
Block a user