diff --git a/app_vx.py b/app_vx.py index f03f89df..e7fdc81e 100644 --- a/app_vx.py +++ b/app_vx.py @@ -14,7 +14,8 @@ from functools import wraps from pathlib import Path import time from sqlalchemy import create_engine, text, func, or_, case, event, desc, asc -from flask import Flask, has_request_context, render_template, request, jsonify, redirect, url_for, flash, session, render_template_string, current_app, send_from_directory +from flask import Flask, has_request_context, render_template, request, jsonify, redirect, url_for, flash, session, \ + render_template_string, current_app, send_from_directory # Flask 3.x 兼容性补丁:flask-sqlalchemy 旧版本需要 _app_ctx_stack try: @@ -24,6 +25,7 @@ except ImportError: from werkzeug.local import LocalStack import threading + # 创建一个兼容的 LocalStack 子类 class CompatLocalStack(LocalStack): @property @@ -36,6 +38,7 @@ except ImportError: except ImportError: return threading.get_ident + flask._app_ctx_stack = CompatLocalStack() from flask_sqlalchemy import SQLAlchemy @@ -105,10 +108,10 @@ SMS_SECRET_KEY = 'pMlBWijlkgT9fz5ziEXdWEnAPTJzRfkf' SMS_SDK_APP_ID = "1400972398" SMS_SIGN_NAME = "价值前沿科技" SMS_TEMPLATE_REGISTER = "2386557" # 注册模板 -SMS_TEMPLATE_LOGIN = "2386540" # 登录模板 +SMS_TEMPLATE_LOGIN = "2386540" # 登录模板 verification_codes = {} -#微信小程序 +# 微信小程序 app.config['WECHAT_APP_ID'] = 'wx0edeaab76d4fa414' app.config['WECHAT_APP_SECRET'] = '0d0c70084f05a8c1411f6b89da7e815d' app.config['BASE_URL'] = 'http://43.143.189.195:5002' @@ -137,6 +140,15 @@ MAX_MEMORY_PERCENT = 75 MEMORY_CHECK_INTERVAL = 300 MAX_CACHE_ITEMS = 50 +# 申银万国行业分类缓存(启动时初始化,避免每次请求都查询数据库) +# 结构: {industry_level: {industry_name: [code_prefix1, code_prefix2, ...]}} +SYWG_INDUSTRY_CACHE = { + 2: {}, # level2: 一级行业 + 3: {}, # level3: 二级行业 + 4: {}, # level4: 三级行业 + 5: {} # level5: 四级行业 +} + # 初始化扩展 db = SQLAlchemy(app) mail = Mail(app) @@ -172,7 +184,7 @@ def token_required(f): token_data = user_tokens.get(token) if not token_data: - return jsonify({'message': 'Token无效','code':401}), 401 + return jsonify({'message': 'Token无效', 'code': 401}), 401 # 检查是否过期 if token_data['expires'] < datetime.now(): @@ -193,8 +205,6 @@ def token_required(f): return decorated_function - - def beijing_now(): # 使用 pytz 处理时区 beijing_tz = pytz.timezone('Asia/Shanghai') @@ -640,6 +650,7 @@ class Event(db.Model): related_data = db.relationship('RelatedData', backref='event', lazy='dynamic') related_concepts = db.relationship('RelatedConcepts', backref='event', lazy='dynamic') ind_type = db.Column(db.String(255)) + @property def keywords_list(self): """返回解析后的关键词列表""" @@ -871,7 +882,6 @@ class CompanyInfo(db.Model): F032V = db.Column(db.String(60)) # CSRC industry second level - class TradeData(db.Model): __tablename__ = 'ea_trade' @@ -901,12 +911,88 @@ class SectorInfo(db.Model): F005V = db.Column(db.String(50)) # Sector level 2 name F006V = db.Column(db.String(50)) # Sector level 3 name F007V = db.Column(db.String(50)) # Sector level 4 name + + +def init_sywg_industry_cache(): + """ + 初始化申银万国行业分类缓存 + 在程序启动时调用,将所有行业分类数据加载到内存中 + """ + global SYWG_INDUSTRY_CACHE + + try: + app.logger.info('开始初始化申银万国行业分类缓存...') + + # 定义层级映射关系 + level_column_map = { + 2: 'f004v', # level2 对应一级行业 + 3: 'f005v', # level3 对应二级行业 + 4: 'f006v', # level4 对应三级行业 + 5: 'f007v' # level5 对应四级行业 + } + + # 定义代码前缀长度映射 + prefix_length_map = { + 2: 3, # S + 2位 + 3: 5, # S + 2位 + 2位 + 4: 7, # S + 2位 + 2位 + 2位 + 5: 9 # 完整代码 + } + + # 遍历所有层级 + for level, column_name in level_column_map.items(): + # 查询该层级的所有行业及其代码 + query_sql = f""" + SELECT DISTINCT {column_name} as industry_name, f003v as code + FROM ea_sector + WHERE f002v = '申银万国行业分类' + AND {column_name} IS NOT NULL + AND {column_name} != '' + """ + + result = db.session.execute(text(query_sql)) + rows = result.fetchall() + + # 构建该层级的缓存 + industry_dict = {} + for row in rows: + industry_name = row[0] + code = row[1] + + if industry_name and code: + # 获取代码前缀 + prefix_length = prefix_length_map[level] + code_prefix = code[:prefix_length] + + # 将前缀添加到对应行业的列表中 + if industry_name not in industry_dict: + industry_dict[industry_name] = set() + industry_dict[industry_name].add(code_prefix) + + # 将set转换为list并存储到缓存中 + for industry_name, prefixes in industry_dict.items(): + SYWG_INDUSTRY_CACHE[level][industry_name] = list(prefixes) + + app.logger.info(f'Level {level} 缓存完成,共 {len(industry_dict)} 个行业') + + # 统计总数 + total_count = sum(len(industries) for industries in SYWG_INDUSTRY_CACHE.values()) + app.logger.info(f'申银万国行业分类缓存初始化完成,共缓存 {total_count} 个行业分类') + + except Exception as e: + app.logger.error(f'初始化申银万国行业分类缓存失败: {str(e)}') + import traceback + app.logger.error(traceback.format_exc()) + + def send_async_email(msg): """异步发送邮件""" try: mail.send(msg) except Exception as e: app.logger.error(f"Error sending async email: {str(e)}") + + def verify_sms_code(phone_number, code): """验证短信验证码""" stored_code = session.get('sms_verification_code') @@ -1039,7 +1125,6 @@ def update_profile(): return jsonify({'success': False, 'message': '更新失败,请重试'}) - # 投资偏好设置 @app.route('/settings/investment_preferences', methods=['POST']) @token_required @@ -1119,26 +1204,25 @@ def get_daily_kline(stock_code, event_datetime, stock_name): with engine.connect() as conn: # 获取事件日期前后的数据 kline_sql = """ - WITH date_range AS ( - SELECT TRADEDATE - FROM ea_trade - WHERE SECCODE = :stock_code - AND TRADEDATE BETWEEN DATE_SUB(:trade_date, INTERVAL 60 DAY) - AND :trade_date - GROUP BY TRADEDATE - ORDER BY TRADEDATE - ) - SELECT t.TRADEDATE, - CAST(t.F003N AS FLOAT) as open, + WITH date_range AS (SELECT TRADEDATE \ + FROM ea_trade \ + WHERE SECCODE = :stock_code \ + AND TRADEDATE BETWEEN DATE_SUB(:trade_date, INTERVAL 60 DAY) \ + AND :trade_date \ + GROUP BY TRADEDATE \ + ORDER BY TRADEDATE) + SELECT t.TRADEDATE, + CAST(t.F003N AS FLOAT) as open, CAST(t.F007N AS FLOAT) as close, CAST(t.F005N AS FLOAT) as high, CAST(t.F006N AS FLOAT) as low, CAST(t.F004N AS FLOAT) as volume - FROM ea_trade t - JOIN date_range d ON t.TRADEDATE = d.TRADEDATE - WHERE t.SECCODE = :stock_code - ORDER BY t.TRADEDATE - """ + FROM ea_trade t + JOIN date_range d \ + ON t.TRADEDATE = d.TRADEDATE + WHERE t.SECCODE = :stock_code + ORDER BY t.TRADEDATE \ + """ result = conn.execute(text(kline_sql), { "stock_code": stock_code, @@ -1151,23 +1235,23 @@ def get_daily_kline(stock_code, event_datetime, stock_name): print("Debug: No data found, trying fallback query...") # 如果没有数据,尝试获取最近的交易数据 fallback_sql = """ - SELECT TRADEDATE, - CAST(F003N AS FLOAT) as open, + SELECT TRADEDATE, + CAST(F003N AS FLOAT) as open, CAST(F007N AS FLOAT) as close, CAST(F005N AS FLOAT) as high, CAST(F006N AS FLOAT) as low, CAST(F004N AS FLOAT) as volume - FROM ea_trade - WHERE SECCODE = :stock_code - AND TRADEDATE <= :trade_date - AND F003N IS NOT NULL - AND F007N IS NOT NULL - AND F005N IS NOT NULL - AND F006N IS NOT NULL - AND F004N IS NOT NULL - ORDER BY TRADEDATE - LIMIT 100 - """ + FROM ea_trade + WHERE SECCODE = :stock_code + AND TRADEDATE <= :trade_date + AND F003N IS NOT NULL + AND F007N IS NOT NULL + AND F005N IS NOT NULL + AND F006N IS NOT NULL + AND F004N IS NOT NULL + ORDER BY TRADEDATE + LIMIT 100 \ + """ result = conn.execute(text(fallback_sql), { "stock_code": stock_code, @@ -1266,13 +1350,13 @@ def get_minute_kline(stock_code, event_datetime, stock_name): with engine.connect() as conn: # 查询前一交易日的收盘价 sql = """ - SELECT CAST(F007N AS FLOAT) as close - FROM ea_trade - WHERE SECCODE = :stock_code - AND TRADEDATE = :prev_date - AND F007N IS NOT NULL - LIMIT 1 - """ + SELECT CAST(F007N AS FLOAT) as close + FROM ea_trade + WHERE SECCODE = :stock_code + AND TRADEDATE = :prev_date + AND F007N IS NOT NULL + LIMIT 1 \ + """ result = conn.execute(text(sql), { "stock_code": stock_code_short, "prev_date": prev_date @@ -1283,14 +1367,15 @@ def get_minute_kline(stock_code, event_datetime, stock_name): else: # 如果指定日期没有数据,尝试获取最近的收盘价 fallback_sql = """ - SELECT CAST(F007N AS FLOAT) as close, TRADEDATE - FROM ea_trade - WHERE SECCODE = :stock_code - AND TRADEDATE < :target_date - AND F007N IS NOT NULL - ORDER BY TRADEDATE DESC - LIMIT 1 - """ + SELECT CAST(F007N AS FLOAT) as close, TRADEDATE + FROM ea_trade + WHERE SECCODE = :stock_code + AND TRADEDATE \ + < :target_date + AND F007N IS NOT NULL + ORDER BY TRADEDATE DESC + LIMIT 1 \ + """ result = conn.execute(text(fallback_sql), { "stock_code": stock_code_short, "target_date": target_date @@ -1338,23 +1423,18 @@ def get_minute_kline(stock_code, event_datetime, stock_name): # 获取目标日期的完整交易时段数据 data = client.execute(""" - SELECT - timestamp, - open, - high, - low, - close, - volume, - amt - FROM stock_minute - WHERE code = %(code)s - AND timestamp BETWEEN %(start)s AND %(end)s - ORDER BY timestamp - """, { - 'code': stock_code, - 'start': datetime.combine(target_date, dt_time(9, 30)), - 'end': datetime.combine(target_date, dt_time(15, 0)) - }) + SELECT + timestamp, open, high, low, close, volume, amt + FROM stock_minute + WHERE code = %(code)s + AND timestamp BETWEEN %(start)s + AND %(end)s + ORDER BY timestamp + """, { + 'code': stock_code, + 'start': datetime.combine(target_date, dt_time(9, 30)), + 'end': datetime.combine(target_date, dt_time(15, 0)) + }) kline_data = [] for row in data: @@ -1398,6 +1478,7 @@ def get_minute_kline(stock_code, event_datetime, stock_name): return jsonify(response_data) + class HistoricalEvent(db.Model): """历史事件模型""" id = db.Column(db.Integer, primary_key=True) @@ -1433,7 +1514,6 @@ class HistoricalEventStock(db.Model): ) - @app.route('/event/follow/', methods=['POST']) @token_required def follow_event(event_id): @@ -1540,7 +1620,6 @@ def like_post(post_id): post.likes_count += 1 message = '已点赞' - db.session.commit() return jsonify({ 'success': True, @@ -1616,7 +1695,6 @@ def add_comment(post_id): db.session.add(comment) post.comments_count += 1 - db.session.commit() return jsonify({ @@ -1637,7 +1715,6 @@ def add_comment(post_id): @app.route('/post/comments/') - def get_comments(post_id): """获取帖子评论列表""" page = request.args.get('page', 1, type=int) @@ -2021,8 +2098,6 @@ def get_limit_rate(stock_code): @app.route('/api/events', methods=['GET']) - - def api_get_events(): """ 获取事件列表API - 优化版本(保持完全兼容) @@ -2170,84 +2245,49 @@ def api_get_events(): ] if industry_classification not in classification_systems: - # 根据层级和名称查询对应的行业代码 - # 前端发送的level值直接对应数据库字段: - # level=2 -> f004v(一级行业) - # level=3 -> f005v(二级行业) - # level=4 -> f006v(三级行业) - # level=5 -> f007v(四级行业) - level_column_map = { - 2: 'f004v', # level2 对应一级行业 - 3: 'f005v', # level3 对应二级行业 - 4: 'f006v', # level4 对应三级行业 - 5: 'f007v' # level5 对应四级行业 - } + # 使用内存缓存获取行业代码前缀(性能优化:避免每次请求都查询数据库) + # 前端发送的level值: + # level=2 -> 一级行业 + # level=3 -> 二级行业 + # level=4 -> 三级行业 + # level=5 -> 四级行业 - if industry_level in level_column_map: - level_column = level_column_map[industry_level] + if industry_level in SYWG_INDUSTRY_CACHE: + # 直接从缓存中获取代码前缀列表 + code_prefixes = SYWG_INDUSTRY_CACHE[industry_level].get(industry_classification, []) - # 查询所有匹配该行业名称的代码 - sector_codes_sql = f""" - SELECT DISTINCT f003v - FROM ea_sector - WHERE f002v = '申银万国行业分类' - AND {level_column} = :industry_name - """ + if code_prefixes: + # 构建查询条件:查找related_industries中包含这些前缀的事件 + if isinstance(db.engine.dialect, MySQLDialect): + # MySQL JSON查询 + conditions = [] + for prefix in code_prefixes: + conditions.append( + text(""" + JSON_SEARCH( + related_industries, + 'one', + CONCAT(:prefix, '%'), + NULL, + '$[*]."申银万国行业分类"' + ) IS NOT NULL + """).params(prefix=prefix) + ) - result = db.session.execute( - text(sector_codes_sql), - {'industry_name': industry_classification} - ) - - matching_codes = [row[0] for row in result.fetchall()] - - if matching_codes: - # 根据层级确定代码前缀长度 - # 申银万国代码规则:S + 2位一级 + 2位二级 + 2位三级 + 2位四级 - prefix_length_map = { - 2: 3, # level2: S + 2位(一级行业) - 3: 5, # level3: S + 2位 + 2位(二级行业) - 4: 7, # level4: S + 2位 + 2位 + 2位(三级行业) - 5: 9 # level5: 完整代码(四级行业) - } - - prefix_length = prefix_length_map.get(industry_level, 9) - - # 获取所有代码的共同前缀(用于模糊匹配) - code_prefixes = list(set([code[:prefix_length] for code in matching_codes if code])) - - if code_prefixes: - # 构建查询条件:查找related_industries中包含这些前缀的事件 - if isinstance(db.engine.dialect, MySQLDialect): - # MySQL JSON查询 - conditions = [] - for prefix in code_prefixes: - conditions.append( - text(""" - JSON_SEARCH( - related_industries, - 'one', - CONCAT(:prefix, '%'), - NULL, - '$[*]."申银万国行业分类"' - ) IS NOT NULL - """).params(prefix=prefix) + if conditions: + query = query.filter(or_(*conditions)) + else: + # 其他数据库 + pattern_conditions = [] + for prefix in code_prefixes: + pattern_conditions.append( + text("related_industries::text LIKE :pattern").params( + pattern=f'%"申银万国行业分类": "{prefix}%' ) + ) - if conditions: - query = query.filter(or_(*conditions)) - else: - # 其他数据库 - pattern_conditions = [] - for prefix in code_prefixes: - pattern_conditions.append( - text("related_industries::text LIKE :pattern").params( - pattern=f'%"申银万国行业分类": "{prefix}%' - ) - ) - - if pattern_conditions: - query = query.filter(or_(*pattern_conditions)) + if pattern_conditions: + query = query.filter(or_(*pattern_conditions)) else: # 没有找到匹配的行业代码,返回空结果 query = query.filter(Event.id == -1) @@ -2908,9 +2948,9 @@ def get_event_class(count): return 'bg-gradient-info' else: return 'bg-gradient-success' + + @app.route('/api/calendar-event-counts') - - def get_calendar_event_counts(): """获取整月的事件数量统计,仅统计type为event的事件""" try: @@ -2924,12 +2964,13 @@ def get_calendar_event_counts(): # 修改查询以仅统计type为event的事件数量 query = """ - SELECT DATE(calendar_time) as date, COUNT(*) as count - FROM future_events - WHERE calendar_time BETWEEN :start_date AND :end_date - AND type = 'event' - GROUP BY DATE(calendar_time) - """ + SELECT DATE (calendar_time) as date, COUNT (*) as count + FROM future_events + WHERE calendar_time BETWEEN :start_date \ + AND :end_date + AND type = 'event' + GROUP BY DATE (calendar_time) \ + """ result = db.session.execute(text(query), { 'start_date': start_date, @@ -2949,7 +2990,6 @@ def get_calendar_event_counts(): return jsonify({'error': str(e)}), 500 - def get_full_avatar_url(avatar_url): """ 统一处理头像URL,确保返回完整的可访问URL @@ -2996,12 +3036,11 @@ def to_dict(self): 'last_seen': self.last_seen.isoformat() if self.last_seen else None } + # ==================== 标准化API接口 ==================== # 1. 首页接口 @app.route('/api/home', methods=['GET']) - - def api_home(): try: seven_days_ago = datetime.now() - timedelta(days=7) @@ -3031,11 +3070,11 @@ def api_home(): # 获取最新交易日数据 latest_trade = db.session.execute(text(""" - SELECT * FROM ea_trade - WHERE SECCODE = :stock_code - ORDER BY TRADEDATE DESC - LIMIT 1 - """), {"stock_code": stock_code}).first() + SELECT * + FROM ea_trade + WHERE SECCODE = :stock_code + ORDER BY TRADEDATE DESC LIMIT 1 + """), {"stock_code": stock_code}).first() week_change = 0 daily_change = 0 # 新增:日涨跌幅 @@ -3051,15 +3090,15 @@ def api_home(): # 获取最近5条交易记录 week_ago_trades = db.session.execute(text(""" - SELECT * FROM ea_trade - WHERE SECCODE = :stock_code - AND TRADEDATE < :latest_date - ORDER BY TRADEDATE DESC - LIMIT 5 - """), { - "stock_code": stock_code, - "latest_date": latest_date - }).fetchall() + SELECT * + FROM ea_trade + WHERE SECCODE = :stock_code + AND TRADEDATE < :latest_date + ORDER BY TRADEDATE DESC LIMIT 5 + """), { + "stock_code": stock_code, + "latest_date": latest_date + }).fetchall() if week_ago_trades and week_ago_trades[-1].F007N: week_ago_price = float(week_ago_trades[-1].F007N or 0) @@ -3128,6 +3167,7 @@ def api_home(): "data": None }), 500 + @app.route('/api/auth/logout', methods=['POST']) def logout_with_token(): """使用token登出""" @@ -3146,6 +3186,8 @@ def logout_with_token(): session.clear() return jsonify({'message': '登出成功'}), 200 + + def send_sms_code(phone, code, template_id): """发送短信验证码""" try: @@ -3173,10 +3215,12 @@ def send_sms_code(phone, code, template_id): print(f"SMS Error: {err}") return False + def generate_verification_code(): """生成6位数字验证码""" return ''.join(random.choices(string.digits, k=6)) + @app.route('/api/auth/send-sms', methods=['POST']) def send_sms_verification(): """发送手机验证码(统一接口,自动判断场景)""" @@ -3218,7 +3262,6 @@ def generate_token(length=32): return ''.join(secrets.choice(characters) for _ in range(length)) - @app.route('/api/auth/login/phone', methods=['POST']) def login_with_phone(): """统一的手机号登录/注册接口""" @@ -3345,7 +3388,7 @@ def verify_token(): token_data = user_tokens.get(token) if not token_data: - return jsonify({'valid': False, 'message': 'Token无效','code':401}), 401 + return jsonify({'valid': False, 'message': 'Token无效', 'code': 401}), 401 # 检查是否过期 if token_data['expires'] < datetime.now(): @@ -3367,8 +3410,6 @@ def verify_token(): }), 200 - - def generate_jwt_token(user_id): """ 生成JWT Token - 与原系统保持一致 @@ -3389,8 +3430,6 @@ def generate_jwt_token(user_id): return token - - @app.route('/api/auth/login/wechat', methods=['POST']) def api_login_wechat(): try: @@ -3489,7 +3528,6 @@ def api_login_wechat(): user = None is_new_user = False - logger.info(f"开始查找用户 - UnionID: {unionid}, OpenID: {openid[:8]}...") if unionid: @@ -3718,10 +3756,10 @@ def api_event_related_stocks(event_id): with engine.connect() as conn: # First check if the event date itself is a trading day is_trading_day = conn.execute(text(""" - SELECT 1 - FROM trading_days - WHERE EXCHANGE_DATE = :date - """), {"date": event_date}).fetchone() is not None + SELECT 1 + FROM trading_days + WHERE EXCHANGE_DATE = :date + """), {"date": event_date}).fetchone() is not None if is_trading_day: # If it's a trading day, determine time period based on event time @@ -3731,11 +3769,11 @@ def api_event_related_stocks(event_id): elif event_time_only > market_close: # After market closes - get next trading day next_trading_day = conn.execute(text(""" - SELECT EXCHANGE_DATE - FROM trading_days - WHERE EXCHANGE_DATE > :date - ORDER BY EXCHANGE_DATE LIMIT 1 - """), {"date": event_date}).fetchone() + SELECT EXCHANGE_DATE + FROM trading_days + WHERE EXCHANGE_DATE > :date + ORDER BY EXCHANGE_DATE LIMIT 1 + """), {"date": event_date}).fetchone() # Convert to date object if we found a next trading day return (next_trading_day[0].date() if next_trading_day else None, market_open, market_close) @@ -3745,11 +3783,11 @@ def api_event_related_stocks(event_id): else: # If not a trading day, get next trading day next_trading_day = conn.execute(text(""" - SELECT EXCHANGE_DATE - FROM trading_days - WHERE EXCHANGE_DATE > :date - ORDER BY EXCHANGE_DATE LIMIT 1 - """), {"date": event_date}).fetchone() + SELECT EXCHANGE_DATE + FROM trading_days + WHERE EXCHANGE_DATE > :date + ORDER BY EXCHANGE_DATE LIMIT 1 + """), {"date": event_date}).fetchone() # Convert to date object if we found a next trading day return (next_trading_day[0].date() if next_trading_day else None, market_open, market_close) @@ -3800,48 +3838,36 @@ def api_event_related_stocks(event_id): # 获取最新交易日的分时数据 data = client.execute(""" - SELECT - timestamp, - open, - high, - low, - close, - volume, - amt - FROM stock_minute - WHERE code = %(code)s - AND timestamp >= %(start)s - AND timestamp <= %(end)s - ORDER BY timestamp - """, { - 'code': stock_code, - 'start': datetime.combine(today, dt_time(9, 30)), - 'end': datetime.combine(today, dt_time(15, 0)) - }) + SELECT + timestamp, open, high, low, close, volume, amt + FROM stock_minute + WHERE code = %(code)s + AND timestamp >= %(start)s + AND timestamp <= %(end)s + ORDER BY timestamp + """, { + 'code': stock_code, + 'start': datetime.combine(today, dt_time(9, 30)), + 'end': datetime.combine(today, dt_time(15, 0)) + }) # 如果今天没有数据,获取最近的交易日数据 if not data: # 获取最近的交易日数据 recent_data = client.execute(""" - SELECT - timestamp, - open, - high, - low, - close, - volume, - amt - FROM stock_minute - WHERE code = %(code)s - AND timestamp >= ( - SELECT MAX(timestamp) - INTERVAL 1 DAY - FROM stock_minute - WHERE code = %(code)s - ) - ORDER BY timestamp - """, { - 'code': stock_code - }) + SELECT + timestamp, open, high, low, close, volume, amt + FROM stock_minute + WHERE code = %(code)s + AND timestamp >= ( + SELECT MAX (timestamp) - INTERVAL 1 DAY + FROM stock_minute + WHERE code = %(code)s + ) + ORDER BY timestamp + """, { + 'code': stock_code + }) data = recent_data # 格式化数据 @@ -3894,45 +3920,36 @@ def api_event_related_stocks(event_id): # 3.1 批量查询价格和涨跌幅数据(使用子查询方式,避免窗口函数与 GROUP BY 冲突) batch_price_query = """ - WITH first_prices AS ( - SELECT - code, - close as first_price, - ROW_NUMBER() OVER (PARTITION BY code ORDER BY timestamp ASC) as rn - FROM stock_minute - WHERE code IN %(codes)s - AND timestamp >= %(start)s - AND timestamp <= %(end)s - ), - last_prices AS ( - SELECT - code, - close as last_price, - open as open_price, - high as high_price, - low as low_price, - volume, - amt as amount, - ROW_NUMBER() OVER (PARTITION BY code ORDER BY timestamp DESC) as rn - FROM stock_minute - WHERE code IN %(codes)s - AND timestamp >= %(start)s - AND timestamp <= %(end)s - ) - SELECT - fp.code, - fp.first_price, - lp.last_price, - (lp.last_price - fp.first_price) / fp.first_price * 100 as change_pct, - lp.open_price, - lp.high_price, - lp.low_price, - lp.volume, - lp.amount - FROM first_prices fp - INNER JOIN last_prices lp ON fp.code = lp.code - WHERE fp.rn = 1 AND lp.rn = 1 - """ + WITH first_prices AS (SELECT code, + close as first_price \ + , ROW_NUMBER() OVER (PARTITION BY code ORDER BY timestamp ASC) as rn + FROM stock_minute + WHERE code IN %(codes)s + AND timestamp >= %(start)s + AND timestamp <= %(end)s + ) \ + , last_prices AS ( + SELECT + code, close as last_price, open as open_price, high as high_price, low as low_price, volume, amt as amount, ROW_NUMBER() OVER (PARTITION BY code ORDER BY timestamp DESC) as rn + FROM stock_minute + WHERE code IN %(codes)s + AND timestamp >= %(start)s + AND timestamp <= %(end)s + ) + SELECT fp.code, \ + fp.first_price, \ + lp.last_price, \ + (lp.last_price - fp.first_price) / fp.first_price * 100 as change_pct, \ + lp.open_price, \ + lp.high_price, \ + lp.low_price, \ + lp.volume, \ + lp.amount + FROM first_prices fp + INNER JOIN last_prices lp ON fp.code = lp.code + WHERE fp.rn = 1 \ + AND lp.rn = 1 \ + """ price_data = client.execute(batch_price_query, { 'codes': stock_codes, @@ -3963,31 +3980,23 @@ def api_event_related_stocks(event_id): 'first_price': first_price, 'change_pct': change_pct, 'change_amount': change_amount, - 'open_price': open_price, - 'high_price': high_price, - 'low_price': low_price, - 'volume': volume, - 'amount': amount, - } + 'open_price': open_price, + 'high_price': high_price, + 'low_price': low_price, + 'volume': volume, + 'amount': amount, + } # 3.2 批量查询分时图数据 print(f"批量查询分时图数据...") minute_chart_query = """ - SELECT - code, - timestamp, - open, - high, - low, - close, - volume, - amt - FROM stock_minute - WHERE code IN %(codes)s - AND timestamp >= %(start)s - AND timestamp <= %(end)s - ORDER BY code, timestamp - """ + SELECT code, timestamp, open, high, low, close, volume, amt + FROM stock_minute + WHERE code IN %(codes)s + AND timestamp >= %(start)s + AND timestamp <= %(end)s + ORDER BY code, timestamp \ + """ minute_data = client.execute(minute_chart_query, { 'codes': stock_codes, @@ -4153,8 +4162,6 @@ def api_event_related_stocks(event_id): @app.route('/api/stock//minute-chart', methods=['GET']) - - def get_minute_chart_data(stock_code): """获取股票分时图数据 - 仅限 Pro/Max 会员""" client = get_clickhouse_client() @@ -4165,48 +4172,36 @@ def get_minute_chart_data(stock_code): # 获取最新交易日的分时数据 data = client.execute(""" - SELECT - timestamp, - open, - high, - low, - close, - volume, - amt - FROM stock_minute - WHERE code = %(code)s - AND timestamp >= %(start)s - AND timestamp <= %(end)s - ORDER BY timestamp - """, { - 'code': stock_code, - 'start': datetime.combine(today, dt_time(9, 30)), - 'end': datetime.combine(today, dt_time(15, 0)) - }) + SELECT + timestamp, open, high, low, close, volume, amt + FROM stock_minute + WHERE code = %(code)s + AND timestamp >= %(start)s + AND timestamp <= %(end)s + ORDER BY timestamp + """, { + 'code': stock_code, + 'start': datetime.combine(today, dt_time(9, 30)), + 'end': datetime.combine(today, dt_time(15, 0)) + }) # 如果今天没有数据,获取最近的交易日数据 if not data: # 获取最近的交易日数据 recent_data = client.execute(""" - SELECT - timestamp, - open, - high, - low, - close, - volume, - amt - FROM stock_minute - WHERE code = %(code)s - AND timestamp >= ( - SELECT MAX(timestamp) - INTERVAL 1 DAY - FROM stock_minute - WHERE code = %(code)s - ) - ORDER BY timestamp - """, { - 'code': stock_code - }) + SELECT + timestamp, open, high, low, close, volume, amt + FROM stock_minute + WHERE code = %(code)s + AND timestamp >= ( + SELECT MAX (timestamp) - INTERVAL 1 DAY + FROM stock_minute + WHERE code = %(code)s + ) + ORDER BY timestamp + """, { + 'code': stock_code + }) data = recent_data # 格式化数据 @@ -4229,8 +4224,6 @@ def get_minute_chart_data(stock_code): @app.route('/api/event//stock//detail', methods=['GET']) - - def api_stock_detail(event_id, stock_code): """个股详情接口 - 仅限 Pro/Max 会员""" try: @@ -4417,6 +4410,7 @@ def api_stock_detail(event_id, stock_code): 'data': None }), 500 + def get_stock_minute_chart_data(stock_code): """获取股票分时图数据""" try: @@ -4451,23 +4445,18 @@ def get_stock_minute_chart_data(stock_code): # 获取分时数据 data = client.execute(""" - SELECT - timestamp, - open, - high, - low, - close, - volume, - amt - FROM stock_minute - WHERE code = %(code)s - AND timestamp BETWEEN %(start)s AND %(end)s - ORDER BY timestamp - """, { - 'code': stock_code, - 'start': datetime.combine(target_date, dt_time(9, 30)), - 'end': datetime.combine(target_date, dt_time(15, 0)) - }) + SELECT + timestamp, open, high, low, close, volume, amt + FROM stock_minute + WHERE code = %(code)s + AND timestamp BETWEEN %(start)s + AND %(end)s + ORDER BY timestamp + """, { + 'code': stock_code, + 'start': datetime.combine(target_date, dt_time(9, 30)), + 'end': datetime.combine(target_date, dt_time(15, 0)) + }) minute_data = [] for row in data: @@ -4490,8 +4479,6 @@ def get_stock_minute_chart_data(stock_code): # 7. 事件详情-相关概念接口 @app.route('/api/event//related-concepts', methods=['GET']) - - def api_event_related_concepts(event_id): """事件相关概念接口""" try: @@ -4533,8 +4520,6 @@ def api_event_related_concepts(event_id): # 8. 事件详情-历史事件接口 @app.route('/api/event//historical-events', methods=['GET']) - - def api_event_historical_events(event_id): """事件历史事件接口""" try: @@ -4634,8 +4619,6 @@ def api_event_historical_events(event_id): @app.route('/api/event//comments', methods=['GET']) - - def get_event_comments(event_id): """获取事件的所有评论和帖子(嵌套格式) @@ -4889,8 +4872,6 @@ def get_event_comments(event_id): @app.route('/api/comment//replies', methods=['GET']) - - def get_comment_replies(comment_id): """获取某条评论的所有回复 @@ -5033,6 +5014,24 @@ def get_comment_replies(comment_id): }), 500 +# 工具函数:处理转义字符,保留 Markdown 格式 +def unescape_markdown_text(text): + """ + 将数据库中存储的转义字符串转换为真正的换行符和特殊字符 + 例如:'\\n\\n#### 标题' -> '\n\n#### 标题' + """ + if not text: + return text + + # 将转义的换行符转换为真正的换行符 + # 注意:这里处理的是字符串字面量 '\\n',不是转义序列 + text = text.replace('\\n', '\n') + text = text.replace('\\r', '\r') + text = text.replace('\\t', '\t') + + return text.strip() + + # 工具函数:清理 Markdown 文本 def clean_markdown_text(text): """清理文本中的 Markdown 符号和多余的换行符 @@ -5105,21 +5104,20 @@ def api_calendar_events(): # 构建基础查询 - 使用 future_events 表 query = """ - SELECT - data_id, - calendar_time, - type, - star, - title, - former, - forecast, - fact, - related_stocks, - concepts, - inferred_tag - FROM future_events - WHERE 1=1 - """ + SELECT data_id, \ + calendar_time, \ + type, \ + star, \ + title, \ + former, \ + forecast, \ + fact, \ + related_stocks, \ + concepts, \ + inferred_tag + FROM future_events + WHERE 1 = 1 \ + """ params = {} @@ -5157,8 +5155,10 @@ def api_calendar_events(): # 总数统计(不包含分页) count_query = """ - SELECT COUNT(*) as count FROM future_events WHERE 1=1 - """ + SELECT COUNT(*) as count \ + FROM future_events \ + WHERE 1=1 \ + """ count_params = params.copy() count_params.pop('limit', None) count_params.pop('offset', None) @@ -5226,12 +5226,11 @@ def api_calendar_events(): # 使用模糊匹配查询真实的交易数据 trade_query = """ - SELECT F007N as close_price, F010N as change_pct, TRADEDATE - FROM ea_trade - WHERE SECCODE LIKE :stock_code_pattern - ORDER BY TRADEDATE DESC - LIMIT 7 - """ + SELECT F007N as close_price, F010N as change_pct, TRADEDATE + FROM ea_trade + WHERE SECCODE LIKE :stock_code_pattern + ORDER BY TRADEDATE DESC LIMIT 7 \ + """ trade_result = db.session.execute(text(trade_query), {'stock_code_pattern': f'{clean_code}%'}) trade_data = trade_result.fetchall() @@ -5293,10 +5292,10 @@ def api_calendar_events(): elif search_query.lower() in str(related_concepts).lower(): highlight_match = 'concepts' - # 清理 Markdown 符号和多余的换行符 - cleaned_former = clean_markdown_text(event.former) - cleaned_forecast = clean_markdown_text(event.forecast) - cleaned_fact = clean_markdown_text(event.fact) + # 将转义的换行符转换为真正的换行符,保留 Markdown 格式 + cleaned_former = unescape_markdown_text(event.former) + cleaned_forecast = unescape_markdown_text(event.forecast) + cleaned_fact = unescape_markdown_text(event.fact) event_dict = { 'id': event.data_id, @@ -5351,8 +5350,6 @@ def api_calendar_events(): # 11. 投资日历-数据接口 @app.route('/api/calendar/data', methods=['GET']) - - def api_calendar_data(): """投资日历数据接口""" try: @@ -5382,18 +5379,17 @@ def api_calendar_data(): data_list1 = query1.order_by(RelatedData.created_at.desc()).all() query2_sql = """ - SELECT - data_id as id, - title, - type as data_type, - former, - forecast, - fact, - star, - calendar_time as created_at - FROM future_events - WHERE type = 'data' - """ + SELECT data_id as id, \ + title, \ + type as data_type, \ + former, \ + forecast, \ + fact, \ + star, \ + calendar_time as created_at + FROM future_events + WHERE type = 'data' \ + """ # 添加时间筛选条件 params = {} @@ -5498,6 +5494,7 @@ def api_calendar_data(): 'data': None }), 500 + # 12. 投资日历-详情接口 def extract_concepts_from_concepts_field(concepts_text): """从concepts字段中提取概念信息""" @@ -5539,27 +5536,24 @@ def extract_concepts_from_concepts_field(concepts_text): @app.route('/api/calendar/detail/', methods=['GET']) - - def api_future_event_detail(item_id): """未来事件详情接口 - 连接 future_events 表 (修正数据解析) - 仅限 Pro/Max 会员""" try: # 从 future_events 表查询事件详情 query = """ - SELECT - data_id, - calendar_time, - type, - star, - title, - former, - forecast, - fact, - related_stocks, - concepts - FROM future_events - WHERE data_id = :item_id - """ + SELECT data_id, \ + calendar_time, \ + type, \ + star, \ + title, \ + former, \ + forecast, \ + fact, \ + related_stocks, \ + concepts + FROM future_events + WHERE data_id = :item_id \ + """ result = db.session.execute(text(query), {'item_id': item_id}) event = result.fetchone() @@ -5659,12 +5653,11 @@ def api_future_event_detail(item_id): # 使用模糊匹配LIKE查询申万一级行业F004V sector_query = """ - SELECT F004V as sw_primary_sector - FROM ea_sector - WHERE SECCODE LIKE :stock_code_pattern - AND F002V = '申银万国行业分类' - LIMIT 1 - """ + SELECT F004V as sw_primary_sector + FROM ea_sector + WHERE SECCODE LIKE :stock_code_pattern + AND F002V = '申银万国行业分类' LIMIT 1 \ + """ sector_result = db.session.execute(text(sector_query), {'stock_code_pattern': f'{clean_code}%'}) sector_row = sector_result.fetchone() @@ -5678,12 +5671,11 @@ def api_future_event_detail(item_id): # 通过SQL查询获取真实的日涨跌幅和周涨跌幅 trade_query = """ - SELECT F007N as close_price, F010N as change_pct, TRADEDATE - FROM ea_trade - WHERE SECCODE LIKE :stock_code_pattern - ORDER BY TRADEDATE DESC - LIMIT 7 - """ + SELECT F007N as close_price, F010N as change_pct, TRADEDATE + FROM ea_trade + WHERE SECCODE LIKE :stock_code_pattern + ORDER BY TRADEDATE DESC LIMIT 7 \ + """ trade_result = db.session.execute(text(trade_query), {'stock_code_pattern': f'{clean_code}%'}) trade_data = trade_result.fetchall() @@ -5775,8 +5767,6 @@ def api_future_event_detail(item_id): # 13-15. 筛选弹窗接口(已有,优化格式) @app.route('/api/filter/options', methods=['GET']) - - def api_filter_options(): """筛选选项接口""" try: @@ -5791,12 +5781,12 @@ def api_filter_options(): # 获取行业筛选选项 industry_options = db.session.execute(text(""" - SELECT DISTINCT f002v as classification_name, COUNT(*) as count - FROM ea_sector - WHERE f002v IS NOT NULL - GROUP BY f002v - ORDER BY f002v - """)).fetchall() + SELECT DISTINCT f002v as classification_name, COUNT(*) as count + FROM ea_sector + WHERE f002v IS NOT NULL + GROUP BY f002v + ORDER BY f002v + """)).fetchall() # 获取重要性选项 importance_options = [ @@ -6323,7 +6313,6 @@ class UserFeedback(db.Model): } - # 通用错误处理 @app.errorhandler(404) def api_not_found(error): @@ -6347,8 +6336,10 @@ def api_method_not_allowed(error): return error - if __name__ == '__main__': + # 初始化申银万国行业分类缓存 + with app.app_context(): + init_sywg_industry_cache() app.run( host='0.0.0.0',