diff --git a/app.py b/app.py index 478dda39..c2551de7 100755 --- a/app.py +++ b/app.py @@ -8053,25 +8053,48 @@ def get_stock_quotes(): return jsonify({'success': False, 'error': str(e)}), 500 -# ==================== ClickHouse 连接池(单例模式) ==================== +# ==================== ClickHouse 连接池(带健康检查) ==================== _clickhouse_client = None _clickhouse_client_lock = threading.Lock() +def _create_clickhouse_client(): + """创建新的 ClickHouse 客户端连接""" + return Cclient( + host='127.0.0.1', + port=9000, + user='default', + password='Zzl33818!', + database='stock', + settings={ + 'connect_timeout': 10, + 'send_receive_timeout': 300, + } + ) + def get_clickhouse_client(): - """获取 ClickHouse 客户端(单例模式,避免重复创建连接)""" + """获取 ClickHouse 客户端(带健康检查和自动重连)""" global _clickhouse_client - if _clickhouse_client is None: - with _clickhouse_client_lock: - if _clickhouse_client is None: - _clickhouse_client = Cclient( - host='127.0.0.1', - port=9000, - user='default', - password='Zzl33818!', - database='stock' - ) - print("[ClickHouse] 创建新连接(单例)") - return _clickhouse_client + + with _clickhouse_client_lock: + # 如果客户端不存在,创建新连接 + if _clickhouse_client is None: + _clickhouse_client = _create_clickhouse_client() + print("[ClickHouse] 创建新连接") + return _clickhouse_client + + # 健康检查:尝试执行简单查询 + try: + _clickhouse_client.execute("SELECT 1") + except Exception as e: + print(f"[ClickHouse] 连接失效,正在重连: {e}") + try: + _clickhouse_client.disconnect() + except Exception: + pass + _clickhouse_client = _create_clickhouse_client() + print("[ClickHouse] 重连成功") + + return _clickhouse_client @app.route('/api/account/calendar/events', methods=['GET', 'POST']) diff --git a/community_api.py b/community_api.py index 6f0b43ba..4338fee6 100644 --- a/community_api.py +++ b/community_api.py @@ -1192,6 +1192,84 @@ def create_reply(post_id): return api_error(f'创建回复失败: {str(e)}', 500) +# ============================================================ +# 文件上传接口 +# ============================================================ + +import os +from werkzeug.utils import secure_filename + +# 允许的图片扩展名 +ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'webp'} +# 上传目录(相对于 Flask 应用根目录) +UPLOAD_FOLDER = 'public/uploads/community' +# 最大文件大小 10MB +MAX_FILE_SIZE = 10 * 1024 * 1024 + + +def allowed_file(filename): + """检查文件扩展名是否允许""" + return '.' in filename and \ + filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS + + +@community_bp.route('/upload/image', methods=['POST']) +@login_required +def upload_image(): + """ + 上传图片 + 返回图片 URL,可用于帖子内容中 + """ + try: + if 'file' not in request.files: + return api_error('没有选择文件', 400) + + file = request.files['file'] + + if file.filename == '': + return api_error('没有选择文件', 400) + + if not allowed_file(file.filename): + return api_error('不支持的文件格式,仅支持 PNG、JPG、GIF、WebP', 400) + + # 检查文件大小 + file.seek(0, 2) # 移动到文件末尾 + size = file.tell() + file.seek(0) # 回到开头 + + if size > MAX_FILE_SIZE: + return api_error('文件大小超过限制(最大 10MB)', 400) + + # 生成唯一文件名 + ext = file.filename.rsplit('.', 1)[1].lower() + filename = f"{generate_id()}.{ext}" + + # 创建日期目录 + today = datetime.now().strftime('%Y%m%d') + upload_dir = os.path.join(current_app.root_path, UPLOAD_FOLDER, today) + os.makedirs(upload_dir, exist_ok=True) + + # 保存文件 + filepath = os.path.join(upload_dir, filename) + file.save(filepath) + + # 返回访问 URL + url = f"/uploads/community/{today}/{filename}" + + return api_response({ + 'url': url, + 'filename': filename, + 'size': size, + 'type': f'image/{ext}' + }) + + except Exception as e: + print(f"[Community API] 上传图片失败: {e}") + import traceback + traceback.print_exc() + return api_error(f'上传失败: {str(e)}', 500) + + # ============================================================ # ES 代理接口(前端直接查询 ES) # ============================================================ diff --git a/src/views/StockCommunity/services/communityService.ts b/src/views/StockCommunity/services/communityService.ts index d2a462c5..cd05b024 100644 --- a/src/views/StockCommunity/services/communityService.ts +++ b/src/views/StockCommunity/services/communityService.ts @@ -607,3 +607,77 @@ export const togglePinMessage = async (messageId: string, isPinned: boolean): Pr }); if (!response.ok) throw new Error(isPinned ? '取消置顶失败' : '置顶失败'); }; + +// ============================================================ +// 文件上传相关 +// ============================================================ + +export interface UploadedImage { + url: string; + filename: string; + size: number; + type: string; +} + +/** + * 上传图片 + */ +export const uploadImage = async (file: File): Promise => { + const formData = new FormData(); + formData.append('file', file); + + const response = await fetch(`${API_BASE}/api/community/upload/image`, { + method: 'POST', + credentials: 'include', + body: formData, + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.message || '上传图片失败'); + } + + const data = await response.json(); + return data.data; +}; + +// ============================================================ +// 股票搜索相关 +// ============================================================ + +export interface StockSearchResult { + stock_code: string; + stock_name: string; + full_name?: string; + exchange?: string; + isIndex?: boolean; + security_type?: string; +} + +/** + * 搜索股票 + */ +export const searchStocks = async ( + query: string, + limit: number = 10, + type: 'all' | 'stock' | 'index' = 'stock' +): Promise => { + if (!query.trim()) return []; + + const params = new URLSearchParams({ + q: query, + limit: String(limit), + type, + }); + + const response = await fetch(`${API_BASE}/api/stocks/search?${params}`, { + credentials: 'include', + }); + + if (!response.ok) { + throw new Error('搜索股票失败'); + } + + const data = await response.json(); + return data.results || []; +};