443 lines
11 KiB
JavaScript
443 lines
11 KiB
JavaScript
/**
|
||
* Elasticsearch 服务层
|
||
* 用于价值论坛的帖子、评论存储和搜索
|
||
*/
|
||
|
||
import axios from 'axios';
|
||
|
||
// Elasticsearch 配置
|
||
// 使用 Nginx 代理路径避免 Mixed Content 问题
|
||
const ES_CONFIG = {
|
||
baseURL: process.env.NODE_ENV === 'production'
|
||
? '/es-api' // 生产环境使用 Nginx 代理
|
||
: 'http://222.128.1.157:19200', // 开发环境直连
|
||
timeout: 10000,
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
},
|
||
};
|
||
|
||
// 创建 axios 实例
|
||
const esClient = axios.create(ES_CONFIG);
|
||
|
||
// 索引名称
|
||
const INDICES = {
|
||
POSTS: 'forum_posts',
|
||
COMMENTS: 'forum_comments',
|
||
EVENTS: 'forum_events',
|
||
};
|
||
|
||
/**
|
||
* 初始化索引(创建索引和映射)
|
||
*/
|
||
export const initializeIndices = async () => {
|
||
try {
|
||
// 创建帖子索引
|
||
await esClient.put(`/${INDICES.POSTS}`, {
|
||
mappings: {
|
||
properties: {
|
||
id: { type: 'keyword' },
|
||
author_id: { type: 'keyword' },
|
||
author_name: { type: 'text' },
|
||
author_avatar: { type: 'keyword' },
|
||
title: { type: 'text', analyzer: 'ik_max_word' },
|
||
content: { type: 'text', analyzer: 'ik_max_word' },
|
||
images: { type: 'keyword' },
|
||
tags: { type: 'keyword' },
|
||
category: { type: 'keyword' },
|
||
likes_count: { type: 'integer' },
|
||
comments_count: { type: 'integer' },
|
||
views_count: { type: 'integer' },
|
||
created_at: { type: 'date' },
|
||
updated_at: { type: 'date' },
|
||
is_pinned: { type: 'boolean' },
|
||
status: { type: 'keyword' }, // active, deleted, hidden
|
||
},
|
||
},
|
||
});
|
||
|
||
// 创建评论索引
|
||
await esClient.put(`/${INDICES.COMMENTS}`, {
|
||
mappings: {
|
||
properties: {
|
||
id: { type: 'keyword' },
|
||
post_id: { type: 'keyword' },
|
||
author_id: { type: 'keyword' },
|
||
author_name: { type: 'text' },
|
||
author_avatar: { type: 'keyword' },
|
||
content: { type: 'text', analyzer: 'ik_max_word' },
|
||
parent_id: { type: 'keyword' }, // 用于嵌套评论
|
||
likes_count: { type: 'integer' },
|
||
created_at: { type: 'date' },
|
||
status: { type: 'keyword' },
|
||
},
|
||
},
|
||
});
|
||
|
||
// 创建事件时间轴索引
|
||
await esClient.put(`/${INDICES.EVENTS}`, {
|
||
mappings: {
|
||
properties: {
|
||
id: { type: 'keyword' },
|
||
post_id: { type: 'keyword' },
|
||
event_type: { type: 'keyword' }, // news, price_change, announcement, etc.
|
||
title: { type: 'text' },
|
||
description: { type: 'text', analyzer: 'ik_max_word' },
|
||
source: { type: 'keyword' },
|
||
source_url: { type: 'keyword' },
|
||
related_stocks: { type: 'keyword' },
|
||
occurred_at: { type: 'date' },
|
||
created_at: { type: 'date' },
|
||
importance: { type: 'keyword' }, // high, medium, low
|
||
},
|
||
},
|
||
});
|
||
|
||
console.log('Elasticsearch 索引初始化成功');
|
||
} catch (error) {
|
||
if (error.response?.status === 400 && error.response?.data?.error?.type === 'resource_already_exists_exception') {
|
||
console.log('索引已存在,跳过创建');
|
||
} else {
|
||
console.error('初始化索引失败:', error);
|
||
throw error;
|
||
}
|
||
}
|
||
};
|
||
|
||
// ==================== 帖子相关操作 ====================
|
||
|
||
/**
|
||
* 创建新帖子
|
||
*/
|
||
export const createPost = async (postData) => {
|
||
try {
|
||
const post = {
|
||
id: `post_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
||
...postData,
|
||
likes_count: 0,
|
||
comments_count: 0,
|
||
views_count: 0,
|
||
created_at: new Date().toISOString(),
|
||
updated_at: new Date().toISOString(),
|
||
is_pinned: false,
|
||
status: 'active',
|
||
};
|
||
|
||
const response = await esClient.post(`/${INDICES.POSTS}/_doc/${post.id}`, post);
|
||
return { ...post, _id: response.data._id };
|
||
} catch (error) {
|
||
console.error('创建帖子失败:', error);
|
||
throw error;
|
||
}
|
||
};
|
||
|
||
/**
|
||
* 获取帖子列表(支持分页、排序、筛选)
|
||
*/
|
||
export const getPosts = async ({ page = 1, size = 20, sort = 'created_at', order = 'desc', category = null, tags = [] }) => {
|
||
try {
|
||
const from = (page - 1) * size;
|
||
|
||
const query = {
|
||
bool: {
|
||
must: [{ match: { status: 'active' } }],
|
||
},
|
||
};
|
||
|
||
if (category) {
|
||
query.bool.must.push({ term: { category } });
|
||
}
|
||
|
||
if (tags.length > 0) {
|
||
query.bool.must.push({ terms: { tags } });
|
||
}
|
||
|
||
const response = await esClient.post(`/${INDICES.POSTS}/_search`, {
|
||
from,
|
||
size,
|
||
query,
|
||
sort: [
|
||
{ is_pinned: { order: 'desc' } },
|
||
{ [sort]: { order } },
|
||
],
|
||
});
|
||
|
||
return {
|
||
total: response.data.hits.total.value,
|
||
posts: response.data.hits.hits.map((hit) => ({ ...hit._source, _id: hit._id })),
|
||
page,
|
||
size,
|
||
};
|
||
} catch (error) {
|
||
console.error('获取帖子列表失败:', error);
|
||
throw error;
|
||
}
|
||
};
|
||
|
||
/**
|
||
* 获取单个帖子详情
|
||
*/
|
||
export const getPostById = async (postId) => {
|
||
try {
|
||
const response = await esClient.get(`/${INDICES.POSTS}/_doc/${postId}`);
|
||
|
||
// 增加浏览量
|
||
await esClient.post(`/${INDICES.POSTS}/_update/${postId}`, {
|
||
script: {
|
||
source: 'ctx._source.views_count += 1',
|
||
lang: 'painless',
|
||
},
|
||
});
|
||
|
||
return { ...response.data._source, _id: response.data._id };
|
||
} catch (error) {
|
||
console.error('获取帖子详情失败:', error);
|
||
throw error;
|
||
}
|
||
};
|
||
|
||
/**
|
||
* 更新帖子
|
||
*/
|
||
export const updatePost = async (postId, updateData) => {
|
||
try {
|
||
const response = await esClient.post(`/${INDICES.POSTS}/_update/${postId}`, {
|
||
doc: {
|
||
...updateData,
|
||
updated_at: new Date().toISOString(),
|
||
},
|
||
});
|
||
return response.data;
|
||
} catch (error) {
|
||
console.error('更新帖子失败:', error);
|
||
throw error;
|
||
}
|
||
};
|
||
|
||
/**
|
||
* 删除帖子(软删除)
|
||
*/
|
||
export const deletePost = async (postId) => {
|
||
try {
|
||
await updatePost(postId, { status: 'deleted' });
|
||
} catch (error) {
|
||
console.error('删除帖子失败:', error);
|
||
throw error;
|
||
}
|
||
};
|
||
|
||
/**
|
||
* 点赞帖子
|
||
*/
|
||
export const likePost = async (postId) => {
|
||
try {
|
||
await esClient.post(`/${INDICES.POSTS}/_update/${postId}`, {
|
||
script: {
|
||
source: 'ctx._source.likes_count += 1',
|
||
lang: 'painless',
|
||
},
|
||
});
|
||
} catch (error) {
|
||
console.error('点赞帖子失败:', error);
|
||
throw error;
|
||
}
|
||
};
|
||
|
||
/**
|
||
* 搜索帖子
|
||
*/
|
||
export const searchPosts = async (keyword, { page = 1, size = 20 }) => {
|
||
try {
|
||
const from = (page - 1) * size;
|
||
|
||
const response = await esClient.post(`/${INDICES.POSTS}/_search`, {
|
||
from,
|
||
size,
|
||
query: {
|
||
bool: {
|
||
must: [
|
||
{
|
||
multi_match: {
|
||
query: keyword,
|
||
fields: ['title^3', 'content', 'tags^2'],
|
||
type: 'best_fields',
|
||
},
|
||
},
|
||
{ match: { status: 'active' } },
|
||
],
|
||
},
|
||
},
|
||
highlight: {
|
||
fields: {
|
||
title: {},
|
||
content: {},
|
||
},
|
||
},
|
||
});
|
||
|
||
return {
|
||
total: response.data.hits.total.value,
|
||
posts: response.data.hits.hits.map((hit) => ({
|
||
...hit._source,
|
||
_id: hit._id,
|
||
highlight: hit.highlight,
|
||
})),
|
||
page,
|
||
size,
|
||
};
|
||
} catch (error) {
|
||
console.error('搜索帖子失败:', error);
|
||
throw error;
|
||
}
|
||
};
|
||
|
||
// ==================== 评论相关操作 ====================
|
||
|
||
/**
|
||
* 创建评论
|
||
*/
|
||
export const createComment = async (commentData) => {
|
||
try {
|
||
const comment = {
|
||
id: `comment_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
||
...commentData,
|
||
likes_count: 0,
|
||
created_at: new Date().toISOString(),
|
||
status: 'active',
|
||
};
|
||
|
||
const response = await esClient.post(`/${INDICES.COMMENTS}/_doc/${comment.id}`, comment);
|
||
|
||
// 增加帖子评论数
|
||
await esClient.post(`/${INDICES.POSTS}/_update/${commentData.post_id}`, {
|
||
script: {
|
||
source: 'ctx._source.comments_count += 1',
|
||
lang: 'painless',
|
||
},
|
||
});
|
||
|
||
return { ...comment, _id: response.data._id };
|
||
} catch (error) {
|
||
console.error('创建评论失败:', error);
|
||
throw error;
|
||
}
|
||
};
|
||
|
||
/**
|
||
* 获取帖子的评论列表
|
||
*/
|
||
export const getCommentsByPostId = async (postId, { page = 1, size = 50 }) => {
|
||
try {
|
||
const from = (page - 1) * size;
|
||
|
||
const response = await esClient.post(`/${INDICES.COMMENTS}/_search`, {
|
||
from,
|
||
size,
|
||
query: {
|
||
bool: {
|
||
must: [
|
||
{ term: { post_id: postId } },
|
||
{ match: { status: 'active' } },
|
||
],
|
||
},
|
||
},
|
||
sort: [{ created_at: { order: 'asc' } }],
|
||
});
|
||
|
||
return {
|
||
total: response.data.hits.total.value,
|
||
comments: response.data.hits.hits.map((hit) => ({ ...hit._source, _id: hit._id })),
|
||
};
|
||
} catch (error) {
|
||
// 如果索引不存在(404),返回空结果
|
||
if (error.response?.status === 404) {
|
||
console.warn('评论索引不存在,返回空结果:', INDICES.COMMENTS);
|
||
return { total: 0, comments: [] };
|
||
}
|
||
console.error('获取评论列表失败:', error);
|
||
throw error;
|
||
}
|
||
};
|
||
|
||
/**
|
||
* 点赞评论
|
||
*/
|
||
export const likeComment = async (commentId) => {
|
||
try {
|
||
await esClient.post(`/${INDICES.COMMENTS}/_update/${commentId}`, {
|
||
script: {
|
||
source: 'ctx._source.likes_count += 1',
|
||
lang: 'painless',
|
||
},
|
||
});
|
||
} catch (error) {
|
||
console.error('点赞评论失败:', error);
|
||
throw error;
|
||
}
|
||
};
|
||
|
||
// ==================== 事件时间轴相关操作 ====================
|
||
|
||
/**
|
||
* 创建事件
|
||
*/
|
||
export const createEvent = async (eventData) => {
|
||
try {
|
||
const event = {
|
||
id: `event_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
||
...eventData,
|
||
created_at: new Date().toISOString(),
|
||
};
|
||
|
||
const response = await esClient.post(`/${INDICES.EVENTS}/_doc/${event.id}`, event);
|
||
return { ...event, _id: response.data._id };
|
||
} catch (error) {
|
||
console.error('创建事件失败:', error);
|
||
throw error;
|
||
}
|
||
};
|
||
|
||
/**
|
||
* 获取帖子的事件时间轴
|
||
*/
|
||
export const getEventsByPostId = async (postId) => {
|
||
try {
|
||
const response = await esClient.post(`/${INDICES.EVENTS}/_search`, {
|
||
size: 100,
|
||
query: {
|
||
term: { post_id: postId },
|
||
},
|
||
sort: [{ occurred_at: { order: 'desc' } }],
|
||
});
|
||
|
||
return response.data.hits.hits.map((hit) => ({ ...hit._source, _id: hit._id }));
|
||
} catch (error) {
|
||
// 如果索引不存在(404),返回空数组而不是抛出错误
|
||
if (error.response?.status === 404) {
|
||
console.warn('事件索引不存在,返回空数组:', INDICES.EVENTS);
|
||
return [];
|
||
}
|
||
console.error('获取事件时间轴失败:', error);
|
||
throw error;
|
||
}
|
||
};
|
||
|
||
export default {
|
||
initializeIndices,
|
||
// 帖子操作
|
||
createPost,
|
||
getPosts,
|
||
getPostById,
|
||
updatePost,
|
||
deletePost,
|
||
likePost,
|
||
searchPosts,
|
||
// 评论操作
|
||
createComment,
|
||
getCommentsByPostId,
|
||
likeComment,
|
||
// 事件操作
|
||
createEvent,
|
||
getEventsByPostId,
|
||
};
|