Compare commits

...

2 Commits

Author SHA1 Message Date
1c49ddf42c update app_vx 2025-11-13 10:20:03 +08:00
d64349b606 update app_vx 2025-11-13 07:40:46 +08:00
4 changed files with 8658 additions and 177 deletions

View File

@@ -1,49 +0,0 @@
# Bytedesk 客服系统集成文件
以下文件和目录属于客服系统集成功能,未提交到当前分支:
## 1. Dify 机器人控制逻辑
**位置**: public/index.html
**状态**: 已存入 stash
**Stash ID**: stash@{0}
**说明**: 根据路径控制 Dify 机器人显示(已设置为完全不显示,只使用 Bytedesk 客服)
## 2. Bytedesk 集成代码
**位置**: src/bytedesk-integration/
**状态**: 未跟踪文件(需要手动管理)
**内容**:
- .env.bytedesk.example - Bytedesk 环境变量配置示例
- App.jsx.example - 集成 Bytedesk 的示例代码
- components/ - Bytedesk 相关组件
- config/ - Bytedesk 配置文件
- 前端工程师集成手册.md - 详细集成文档
## 恢复方法
### 恢复 public/index.html 的改动:
```bash
git stash apply stash@{0}
```
### 使用 Bytedesk 集成代码:
```bash
# 查看集成手册
cat src/bytedesk-integration/前端工程师集成手册.md
# 复制示例配置
cp src/bytedesk-integration/.env.bytedesk.example .env.bytedesk
cp src/bytedesk-integration/App.jsx.example src/App.jsx
```
## 注意事项
⚠️ **重要提示:**
- `src/bytedesk-integration/` 目录中的文件是未跟踪的untracked
- 如果需要提交客服功能,需要先添加到 git:
```bash
git add src/bytedesk-integration/
git commit -m "feat: 集成 Bytedesk 客服系统"
```
- 当前分支feature_bugfix/251110_event专注于非客服功能
- 建议在单独的分支中开发客服功能

613
app_vx.py
View File

@@ -15,6 +15,29 @@ from pathlib import Path
import time import time
from sqlalchemy import create_engine, text, func, or_, case, event, desc, asc 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:
from flask import _app_ctx_stack
except ImportError:
import flask
from werkzeug.local import LocalStack
import threading
# 创建一个兼容的 LocalStack 子类
class CompatLocalStack(LocalStack):
@property
def __ident_func__(self):
# 返回当前线程的标识函数
# 优先使用 greenlet协程否则使用 threading
try:
from greenlet import getcurrent
return getcurrent
except ImportError:
return threading.get_ident
flask._app_ctx_stack = CompatLocalStack()
from flask_sqlalchemy import SQLAlchemy from flask_sqlalchemy import SQLAlchemy
from flask_login import LoginManager, UserMixin, login_user, logout_user, login_required, current_user from flask_login import LoginManager, UserMixin, login_user, logout_user, login_required, current_user
from flask_mail import Mail, Message from flask_mail import Mail, Message
@@ -325,7 +348,7 @@ def subscription_required(level='pro'):
@subscription_required('pro') # 需要 Pro 或 Max 用户 @subscription_required('pro') # 需要 Pro 或 Max 用户
@subscription_required('max') # 仅限 Max 用户 @subscription_required('max') # 仅限 Max 用户
注意:此装饰器需要配合 @token_required 使用 注意:此装饰器需要配合 使用
""" """
from functools import wraps from functools import wraps
@@ -1052,8 +1075,6 @@ def get_clickhouse_client():
@app.route('/api/stock/<stock_code>/kline') @app.route('/api/stock/<stock_code>/kline')
@token_required
@pro_or_max_required
def get_stock_kline(stock_code): def get_stock_kline(stock_code):
"""获取股票K线数据 - 仅限 Pro/Max 会员(小程序功能)""" """获取股票K线数据 - 仅限 Pro/Max 会员(小程序功能)"""
chart_type = request.args.get('chart_type', 'daily') # 默认改为daily chart_type = request.args.get('chart_type', 'daily') # 默认改为daily
@@ -1519,9 +1540,6 @@ def like_post(post_id):
post.likes_count += 1 post.likes_count += 1
message = '已点赞' message = '已点赞'
# 可以在这里添加点赞通知
if post.user_id != request.user.id:
notify_user_post_liked(post)
db.session.commit() db.session.commit()
return jsonify({ return jsonify({
@@ -1598,15 +1616,6 @@ def add_comment(post_id):
db.session.add(comment) db.session.add(comment)
post.comments_count += 1 post.comments_count += 1
# 如果是回复评论,可以添加通知
if parent_id:
parent_comment = Comment.query.get(parent_id)
if parent_comment and parent_comment.user_id != request.user.id:
notify_user_comment_replied(parent_comment)
# 如果是评论帖子,通知帖子作者
elif post.user_id != request.user.id:
notify_user_post_commented(post)
db.session.commit() db.session.commit()
@@ -1628,7 +1637,7 @@ def add_comment(post_id):
@app.route('/post/comments/<int:post_id>') @app.route('/post/comments/<int:post_id>')
@token_required
def get_comments(post_id): def get_comments(post_id):
"""获取帖子评论列表""" """获取帖子评论列表"""
page = request.args.get('page', 1, type=int) page = request.args.get('page', 1, type=int)
@@ -2012,8 +2021,8 @@ def get_limit_rate(stock_code):
@app.route('/api/events', methods=['GET']) @app.route('/api/events', methods=['GET'])
@token_required
@pro_or_max_required
def api_get_events(): def api_get_events():
""" """
获取事件列表API - 优化版本(保持完全兼容) 获取事件列表API - 优化版本(保持完全兼容)
@@ -2555,11 +2564,7 @@ def api_get_events():
'week_change': week_change 'week_change': week_change
} }
# ==================== 获取整体统计信息 ==================== # ==================== 获取整体统计信息(应用所有筛选条件) ====================
# 获取所有筛选条件下的事件和股票(用于统计)
all_filtered_events = query.limit(500).all()
all_event_ids = [e.id for e in all_filtered_events]
overall_distribution = { overall_distribution = {
'limit_down': 0, 'limit_down': 0,
@@ -2573,37 +2578,102 @@ def api_get_events():
'limit_up': 0 'limit_up': 0
} }
if all_event_ids: # 使用当前筛选条件的query但不应用分页限制获取所有符合条件的事件
# 获取所有相关股票 # 这样统计数据会跟随用户的筛选条件变化
all_stocks_for_stats = RelatedStock.query.filter( all_filtered_events = query.limit(1000).all() # 限制最多1000个事件避免查询过慢
RelatedStock.event_id.in_(all_event_ids) week_event_ids = [e.id for e in all_filtered_events]
if week_event_ids:
# 获取这些事件的所有关联股票
week_related_stocks = RelatedStock.query.filter(
RelatedStock.event_id.in_(week_event_ids)
).all() ).all()
# 统计涨跌分布 # 按事件ID分组
for stock in all_stocks_for_stats: week_stocks_by_event = {}
for stock in week_related_stocks:
if stock.event_id not in week_stocks_by_event:
week_stocks_by_event[stock.event_id] = []
week_stocks_by_event[stock.event_id].append(stock)
# 收集所有股票代码(用于批量查询行情)
week_stock_codes = []
week_code_mapping = {}
for stocks in week_stocks_by_event.values():
for stock in stocks:
clean_code = stock.stock_code.replace('.SH', '').replace('.SZ', '').replace('.BJ', '') clean_code = stock.stock_code.replace('.SH', '').replace('.SZ', '').replace('.BJ', '')
if clean_code in stock_changes: week_stock_codes.append(clean_code)
daily_change = stock_changes[clean_code]['daily_change'] week_code_mapping[clean_code] = stock.stock_code
# 计算涨跌停限制 week_stock_codes = list(set(week_stock_codes))
limit_rate = get_limit_rate(stock.stock_code)
# 分类统计 # 批量查询这些股票的最新行情数据
if daily_change <= -limit_rate + 0.01: week_stock_changes = {}
if week_stock_codes:
codes_str = "'" + "', '".join(week_stock_codes) + "'"
recent_trades_sql = f"""
SELECT
SECCODE,
SECNAME,
F010N as daily_change,
ROW_NUMBER() OVER (PARTITION BY SECCODE ORDER BY TRADEDATE DESC) as rn
FROM ea_trade
WHERE SECCODE IN ({codes_str})
AND F010N IS NOT NULL
AND TRADEDATE >= DATE_SUB(CURDATE(), INTERVAL 3 DAY)
ORDER BY SECCODE, TRADEDATE DESC
"""
result = db.session.execute(text(recent_trades_sql))
for row in result.fetchall():
sec_code = row[0]
if row[3] == 1: # 只取最新的数据rn=1
week_stock_changes[sec_code] = {
'stock_name': row[1],
'daily_change': float(row[2]) if row[2] else 0
}
# 按事件统计平均涨跌幅分布
event_avg_changes = {}
for event in all_filtered_events:
event_stocks = week_stocks_by_event.get(event.id, [])
if not event_stocks:
continue
total_change = 0
valid_count = 0
for stock in event_stocks:
clean_code = stock.stock_code.replace('.SH', '').replace('.SZ', '').replace('.BJ', '')
if clean_code in week_stock_changes:
daily_change = week_stock_changes[clean_code]['daily_change']
total_change += daily_change
valid_count += 1
if valid_count > 0:
avg_change = total_change / valid_count
event_avg_changes[event.id] = avg_change
# 统计事件平均涨跌幅的分布
for event_id, avg_change in event_avg_changes.items():
# 对于事件平均涨幅,不使用涨跌停分类,使用通用分类
if avg_change <= -10:
overall_distribution['limit_down'] += 1 overall_distribution['limit_down'] += 1
elif daily_change >= limit_rate - 0.01: elif avg_change >= 10:
overall_distribution['limit_up'] += 1 overall_distribution['limit_up'] += 1
elif daily_change > 5: elif avg_change > 5:
overall_distribution['up_over_5'] += 1 overall_distribution['up_over_5'] += 1
elif daily_change > 1: elif avg_change > 1:
overall_distribution['up_1_to_5'] += 1 overall_distribution['up_1_to_5'] += 1
elif daily_change > 0.1: elif avg_change > 0.1:
overall_distribution['up_within_1'] += 1 overall_distribution['up_within_1'] += 1
elif daily_change >= -0.1: elif avg_change >= -0.1:
overall_distribution['flat'] += 1 overall_distribution['flat'] += 1
elif daily_change > -1: elif avg_change > -1:
overall_distribution['down_within_1'] += 1 overall_distribution['down_within_1'] += 1
elif daily_change > -5: elif avg_change > -5:
overall_distribution['down_5_to_1'] += 1 overall_distribution['down_5_to_1'] += 1
else: else:
overall_distribution['down_over_5'] += 1 overall_distribution['down_over_5'] += 1
@@ -2839,8 +2909,8 @@ def get_event_class(count):
else: else:
return 'bg-gradient-success' return 'bg-gradient-success'
@app.route('/api/calendar-event-counts') @app.route('/api/calendar-event-counts')
@token_required
@pro_or_max_required
def get_calendar_event_counts(): def get_calendar_event_counts():
"""获取整月的事件数量统计仅统计type为event的事件""" """获取整月的事件数量统计仅统计type为event的事件"""
try: try:
@@ -2930,8 +3000,8 @@ def to_dict(self):
# 1. 首页接口 # 1. 首页接口
@app.route('/api/home', methods=['GET']) @app.route('/api/home', methods=['GET'])
@token_required
@pro_or_max_required
def api_home(): def api_home():
try: try:
seven_days_ago = datetime.now() - timedelta(days=7) seven_days_ago = datetime.now() - timedelta(days=7)
@@ -3620,17 +3690,107 @@ def api_login_email():
# 5. 事件详情-相关标的接口 # 5. 事件详情-相关标的接口
@app.route('/api/event/<int:event_id>/related-stocks-detail', methods=['GET']) @app.route('/api/event/<int:event_id>/related-stocks-detail', methods=['GET'])
@token_required
@pro_or_max_required
def api_event_related_stocks(event_id): def api_event_related_stocks(event_id):
"""事件相关标的详情接口 - 仅限 Pro/Max 会员""" """事件相关标的详情接口 - 仅限 Pro/Max 会员"""
try: try:
from datetime import datetime, timedelta, time as dt_time
from sqlalchemy import text
event = Event.query.get_or_404(event_id) event = Event.query.get_or_404(event_id)
related_stocks = event.related_stocks.order_by(RelatedStock.correlation.desc()).all() related_stocks = event.related_stocks.order_by(RelatedStock.correlation.desc()).all()
# 获取ClickHouse客户端用于分时数据查询 # 获取ClickHouse客户端用于分时数据查询
client = get_clickhouse_client() client = get_clickhouse_client()
# 获取事件时间(如果事件有开始时间,使用开始时间;否则使用创建时间)
event_time = event.start_time if event.start_time else event.created_at
current_time = datetime.now()
# 定义交易日和时间范围计算函数(与 app.py 中的逻辑完全一致)
def get_trading_day_and_times(event_datetime):
event_date = event_datetime.date()
event_time_only = event_datetime.time()
# Trading hours
market_open = dt_time(9, 30)
market_close = dt_time(15, 0)
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
if is_trading_day:
# If it's a trading day, determine time period based on event time
if event_time_only < market_open:
# Before market opens - use full trading day
return event_date, market_open, market_close
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()
# 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)
else:
# During trading hours
return event_date, event_time_only, market_close
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()
# 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)
trading_day, start_time, end_time = get_trading_day_and_times(event_time)
if not trading_day:
# 如果没有交易日,返回空数据
return jsonify({
'code': 200,
'message': 'success',
'data': {
'event_id': event_id,
'event_title': event.title,
'event_desc': event.description,
'event_type': event.event_type,
'event_importance': event.importance,
'event_status': event.status,
'event_created_at': event.created_at.strftime("%Y-%m-%d %H:%M:%S"),
'event_start_time': event.start_time.isoformat() if event.start_time else None,
'event_end_time': event.end_time.isoformat() if event.end_time else None,
'keywords': event.keywords,
'view_count': event.view_count,
'post_count': event.post_count,
'follower_count': event.follower_count,
'related_stocks': [],
'total_count': 0
}
})
# For historical dates, ensure we're using actual data
start_datetime = datetime.combine(trading_day, start_time)
end_datetime = datetime.combine(trading_day, end_time)
# If the trading day is in the future relative to current time, return only names without data
if trading_day > current_time.date():
start_datetime = datetime.combine(trading_day, start_time)
end_datetime = datetime.combine(trading_day, end_time)
print(f"事件时间: {event_time}, 交易日: {trading_day}, 时间范围: {start_datetime} - {end_datetime}")
def get_minute_chart_data(stock_code): def get_minute_chart_data(stock_code):
"""获取股票分时图数据""" """获取股票分时图数据"""
try: try:
@@ -3703,15 +3863,197 @@ def api_event_related_stocks(event_id):
print(f"Error fetching minute data for {stock_code}: {e}") print(f"Error fetching minute data for {stock_code}: {e}")
return [] return []
# ==================== 性能优化:批量查询所有股票数据 ====================
# 1. 收集所有股票代码
stock_codes = [stock.stock_code for stock in related_stocks]
# 2. 批量查询股票基本信息
stock_info_map = {}
if stock_codes:
stock_infos = StockBasicInfo.query.filter(StockBasicInfo.SECCODE.in_(stock_codes)).all()
for info in stock_infos:
stock_info_map[info.SECCODE] = info
# 处理不带后缀的股票代码
base_codes = [code.split('.')[0] for code in stock_codes if '.' in code and code not in stock_info_map]
if base_codes:
base_infos = StockBasicInfo.query.filter(StockBasicInfo.SECCODE.in_(base_codes)).all()
for info in base_infos:
# 将不带后缀的信息映射到带后缀的代码
for code in stock_codes:
if code.split('.')[0] == info.SECCODE and code not in stock_info_map:
stock_info_map[code] = info
# 3. 批量查询 ClickHouse 数据(价格、涨跌幅、分时图数据)
price_data_map = {} # 存储价格和涨跌幅数据
minute_chart_map = {} # 存储分时图数据
try:
if stock_codes:
print(f"批量查询 {len(stock_codes)} 只股票的价格数据...")
# 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
"""
price_data = client.execute(batch_price_query, {
'codes': stock_codes,
'start': start_datetime,
'end': end_datetime
})
print(f"批量查询返回 {len(price_data)} 条价格数据")
# 解析批量查询结果
for row in price_data:
code = row[0]
first_price = float(row[1]) if row[1] is not None else None
last_price = float(row[2]) if row[2] is not None else None
change_pct = float(row[3]) if row[3] is not None else None
open_price = float(row[4]) if row[4] is not None else None
high_price = float(row[5]) if row[5] is not None else None
low_price = float(row[6]) if row[6] is not None else None
volume = int(row[7]) if row[7] is not None else None
amount = float(row[8]) if row[8] is not None else None
change_amount = None
if last_price is not None and first_price is not None:
change_amount = last_price - first_price
price_data_map[code] = {
'latest_price': last_price,
'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,
}
# 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
"""
minute_data = client.execute(minute_chart_query, {
'codes': stock_codes,
'start': start_datetime,
'end': end_datetime
})
print(f"批量查询返回 {len(minute_data)} 条分时数据")
# 按股票代码分组分时数据
for row in minute_data:
code = row[0]
if code not in minute_chart_map:
minute_chart_map[code] = []
minute_chart_map[code].append({
'time': row[1].strftime('%H:%M'),
'open': float(row[2]) if row[2] else None,
'high': float(row[3]) if row[3] else None,
'low': float(row[4]) if row[4] else None,
'close': float(row[5]) if row[5] else None,
'volume': float(row[6]) if row[6] else None,
'amount': float(row[7]) if row[7] else None
})
except Exception as e:
print(f"批量查询 ClickHouse 失败: {e}")
# 如果批量查询失败price_data_map 和 minute_chart_map 为空,后续会使用降级方案
# 4. 组装每个股票的数据(从批量查询结果中获取)
stocks_data = [] stocks_data = []
for stock in related_stocks: for stock in related_stocks:
# 获取股票基本信息 - 也使用灵活匹配 print(f"正在组装股票 {stock.stock_code} 的数据...")
stock_info = StockBasicInfo.query.filter_by(SECCODE=stock.stock_code).first()
if not stock_info:
base_code = stock.stock_code.split('.')[0]
stock_info = StockBasicInfo.query.filter_by(SECCODE=base_code).first()
# 获取最新交易数据 - 使用灵活匹配 # 从批量查询结果中获取股票基本信息
stock_info = stock_info_map.get(stock.stock_code)
# 从批量查询结果中获取价格数据
price_info = price_data_map.get(stock.stock_code)
latest_price = None
first_price = None
change_pct = None
change_amount = None
open_price = None
high_price = None
low_price = None
volume = None
amount = None
trade_date = trading_day
if price_info:
# 使用批量查询的结果
latest_price = price_info['latest_price']
first_price = price_info['first_price']
change_pct = price_info['change_pct']
change_amount = price_info['change_amount']
open_price = price_info['open_price']
high_price = price_info['high_price']
low_price = price_info['low_price']
volume = price_info['volume']
amount = price_info['amount']
else:
# 如果批量查询没有返回数据使用降级方案TradeData
print(f"股票 {stock.stock_code} 批量查询无数据,使用降级方案...")
try:
latest_trade = None latest_trade = None
search_codes = [stock.stock_code, stock.stock_code.split('.')[0]] search_codes = [stock.stock_code, stock.stock_code.split('.')[0]]
@@ -3721,26 +4063,26 @@ def api_event_related_stocks(event_id):
if latest_trade: if latest_trade:
break break
# 获取前一交易日数据
prev_trade = None
if latest_trade: if latest_trade:
prev_trade = TradeData.query.filter_by(SECCODE=latest_trade.SECCODE) \ latest_price = float(latest_trade.F007N) if latest_trade.F007N else None
.filter(TradeData.TRADEDATE < latest_trade.TRADEDATE) \ open_price = float(latest_trade.F003N) if latest_trade.F003N else None
.order_by(TradeData.TRADEDATE.desc()).first() high_price = float(latest_trade.F005N) if latest_trade.F005N else None
low_price = float(latest_trade.F006N) if latest_trade.F006N else None
first_price = float(latest_trade.F002N) if latest_trade.F002N else None
volume = float(latest_trade.F004N) if latest_trade.F004N else None
amount = float(latest_trade.F011N) if latest_trade.F011N else None
trade_date = latest_trade.TRADEDATE
# 计算涨跌幅 # 计算涨跌幅
change_pct = None if latest_trade.F010N:
change_amount = None
if latest_trade and prev_trade:
if prev_trade.F007N and prev_trade.F007N != 0:
change_amount = float(latest_trade.F007N) - float(prev_trade.F007N)
change_pct = (change_amount / float(prev_trade.F007N)) * 100
elif latest_trade and latest_trade.F010N:
change_pct = float(latest_trade.F010N) change_pct = float(latest_trade.F010N)
change_amount = float(latest_trade.F009N) if latest_trade.F009N else None if latest_trade.F009N:
change_amount = float(latest_trade.F009N)
except Exception as fallback_error:
print(f"降级查询也失败 {stock.stock_code}: {fallback_error}")
# 获取分时图数据 # 从批量查询结果中获取分时图数据
minute_chart_data = get_minute_chart_data(stock.stock_code) minute_chart_data = minute_chart_map.get(stock.stock_code, [])
stock_data = { stock_data = {
'id': stock.id, 'id': stock.id,
@@ -3755,17 +4097,19 @@ def api_event_related_stocks(event_id):
# 交易数据 # 交易数据
'trade_data': { 'trade_data': {
'latest_price': float(latest_trade.F007N) if latest_trade and latest_trade.F007N else None, 'latest_price': latest_price,
'open_price': float(latest_trade.F003N) if latest_trade and latest_trade.F003N else None, 'first_price': first_price, # 事件发生时的价格
'high_price': float(latest_trade.F005N) if latest_trade and latest_trade.F005N else None, 'open_price': open_price,
'low_price': float(latest_trade.F006N) if latest_trade and latest_trade.F006N else None, 'high_price': high_price,
'prev_close': float(latest_trade.F002N) if latest_trade and latest_trade.F002N else None, 'low_price': low_price,
'change_amount': change_amount, 'change_amount': round(change_amount, 2) if change_amount is not None else None,
'change_pct': round(change_pct, 2) if change_pct is not None else None, 'change_pct': round(change_pct, 2) if change_pct is not None else None,
'volume': float(latest_trade.F004N) if latest_trade and latest_trade.F004N else None, 'volume': volume,
'amount': float(latest_trade.F011N) if latest_trade and latest_trade.F011N else None, 'amount': amount,
'trade_date': latest_trade.TRADEDATE.isoformat() if latest_trade else None, 'trade_date': trade_date.isoformat() if trade_date else None,
} if latest_trade else None, 'event_start_time': start_datetime.isoformat() if start_datetime else None, # 事件开始时间
'event_end_time': end_datetime.isoformat() if end_datetime else None, # 查询结束时间
} if latest_price is not None else None,
# 分时图数据 # 分时图数据
'minute_chart_data': minute_chart_data, 'minute_chart_data': minute_chart_data,
@@ -3809,8 +4153,8 @@ def api_event_related_stocks(event_id):
@app.route('/api/stock/<stock_code>/minute-chart', methods=['GET']) @app.route('/api/stock/<stock_code>/minute-chart', methods=['GET'])
@token_required
@pro_or_max_required
def get_minute_chart_data(stock_code): def get_minute_chart_data(stock_code):
"""获取股票分时图数据 - 仅限 Pro/Max 会员""" """获取股票分时图数据 - 仅限 Pro/Max 会员"""
client = get_clickhouse_client() client = get_clickhouse_client()
@@ -3885,8 +4229,8 @@ def get_minute_chart_data(stock_code):
@app.route('/api/event/<int:event_id>/stock/<stock_code>/detail', methods=['GET']) @app.route('/api/event/<int:event_id>/stock/<stock_code>/detail', methods=['GET'])
@token_required
@pro_or_max_required
def api_stock_detail(event_id, stock_code): def api_stock_detail(event_id, stock_code):
"""个股详情接口 - 仅限 Pro/Max 会员""" """个股详情接口 - 仅限 Pro/Max 会员"""
try: try:
@@ -4146,8 +4490,8 @@ def get_stock_minute_chart_data(stock_code):
# 7. 事件详情-相关概念接口 # 7. 事件详情-相关概念接口
@app.route('/api/event/<int:event_id>/related-concepts', methods=['GET']) @app.route('/api/event/<int:event_id>/related-concepts', methods=['GET'])
@token_required
@pro_or_max_required
def api_event_related_concepts(event_id): def api_event_related_concepts(event_id):
"""事件相关概念接口""" """事件相关概念接口"""
try: try:
@@ -4189,8 +4533,8 @@ def api_event_related_concepts(event_id):
# 8. 事件详情-历史事件接口 # 8. 事件详情-历史事件接口
@app.route('/api/event/<int:event_id>/historical-events', methods=['GET']) @app.route('/api/event/<int:event_id>/historical-events', methods=['GET'])
@token_required
@pro_or_max_required
def api_event_historical_events(event_id): def api_event_historical_events(event_id):
"""事件历史事件接口""" """事件历史事件接口"""
try: try:
@@ -4290,8 +4634,8 @@ def api_event_historical_events(event_id):
@app.route('/api/event/<int:event_id>/comments', methods=['GET']) @app.route('/api/event/<int:event_id>/comments', methods=['GET'])
@token_required
@pro_or_max_required
def get_event_comments(event_id): def get_event_comments(event_id):
"""获取事件的所有评论和帖子(嵌套格式) """获取事件的所有评论和帖子(嵌套格式)
@@ -4545,8 +4889,8 @@ def get_event_comments(event_id):
@app.route('/api/comment/<int:comment_id>/replies', methods=['GET']) @app.route('/api/comment/<int:comment_id>/replies', methods=['GET'])
@token_required
@pro_or_max_required
def get_comment_replies(comment_id): def get_comment_replies(comment_id):
"""获取某条评论的所有回复 """获取某条评论的所有回复
@@ -4689,10 +5033,64 @@ def get_comment_replies(comment_id):
}), 500 }), 500
# 工具函数:清理 Markdown 文本
def clean_markdown_text(text):
"""清理文本中的 Markdown 符号和多余的换行符
Args:
text: 原始文本(可能包含 Markdown 符号)
Returns:
清理后的纯文本
"""
if not text:
return text
import re
# 1. 移除 Markdown 标题符号 (### , ## , # )
text = re.sub(r'^#{1,6}\s+', '', text, flags=re.MULTILINE)
# 2. 移除 Markdown 加粗符号 (**text** 或 __text__)
text = re.sub(r'\*\*(.+?)\*\*', r'\1', text)
text = re.sub(r'__(.+?)__', r'\1', text)
# 3. 移除 Markdown 斜体符号 (*text* 或 _text_)
text = re.sub(r'\*(.+?)\*', r'\1', text)
text = re.sub(r'_(.+?)_', r'\1', text)
# 4. 移除 Markdown 列表符号 (- , * , + , 1. )
text = re.sub(r'^[\s]*[-*+]\s+', '', text, flags=re.MULTILINE)
text = re.sub(r'^[\s]*\d+\.\s+', '', text, flags=re.MULTILINE)
# 5. 移除 Markdown 引用符号 (> )
text = re.sub(r'^>\s+', '', text, flags=re.MULTILINE)
# 6. 移除 Markdown 代码块符号 (``` 或 `)
text = re.sub(r'```[\s\S]*?```', '', text)
text = re.sub(r'`(.+?)`', r'\1', text)
# 7. 移除 Markdown 链接 ([text](url) -> text)
text = re.sub(r'\[(.+?)\]\(.+?\)', r'\1', text)
# 8. 清理多余的换行符
# 将多个连续的换行符(\n\n\n...)替换为单个换行符
text = re.sub(r'\n{3,}', '\n\n', text)
# 9. 清理行首行尾的空白字符
text = re.sub(r'^\s+|\s+$', '', text, flags=re.MULTILINE)
# 10. 移除多余的空格(连续多个空格替换为单个空格)
text = re.sub(r' {2,}', ' ', text)
# 11. 清理首尾空白
text = text.strip()
return text
# 10. 投资日历-事件接口(增强版) # 10. 投资日历-事件接口(增强版)
@app.route('/api/calendar/events', methods=['GET']) @app.route('/api/calendar/events', methods=['GET'])
@token_required
@pro_or_max_required
def api_calendar_events(): def api_calendar_events():
"""投资日历事件接口 - 连接 future_events 表 (修正版)""" """投资日历事件接口 - 连接 future_events 表 (修正版)"""
try: try:
@@ -4895,10 +5293,15 @@ def api_calendar_events():
elif search_query.lower() in str(related_concepts).lower(): elif search_query.lower() in str(related_concepts).lower():
highlight_match = 'concepts' 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)
event_dict = { event_dict = {
'id': event.data_id, 'id': event.data_id,
'title': event.title, 'title': event.title,
'description': f"前值: {event.former}, 预测: {event.forecast}, 实际: {event.fact}" if event.former or event.forecast or event.fact else "", 'description': f"前值: {cleaned_former}, 预测: {cleaned_forecast}, 实际: {cleaned_fact}" if cleaned_former or cleaned_forecast or cleaned_fact else "",
'start_time': event.calendar_time.isoformat() if event.calendar_time else None, 'start_time': event.calendar_time.isoformat() if event.calendar_time else None,
'end_time': None, # future_events 表没有结束时间 'end_time': None, # future_events 表没有结束时间
'category': { 'category': {
@@ -4914,9 +5317,9 @@ def api_calendar_events():
'related_avg_chg': round(related_avg_chg, 2), 'related_avg_chg': round(related_avg_chg, 2),
'related_max_chg': round(related_max_chg, 2), 'related_max_chg': round(related_max_chg, 2),
'related_week_chg': round(related_week_chg, 2), 'related_week_chg': round(related_week_chg, 2),
'former': event.former, 'former': cleaned_former,
'forecast': event.forecast, 'forecast': cleaned_forecast,
'fact': event.fact 'fact': cleaned_fact
} }
# 可选:添加搜索匹配标记 # 可选:添加搜索匹配标记
@@ -4948,8 +5351,8 @@ def api_calendar_events():
# 11. 投资日历-数据接口 # 11. 投资日历-数据接口
@app.route('/api/calendar/data', methods=['GET']) @app.route('/api/calendar/data', methods=['GET'])
@token_required
@pro_or_max_required
def api_calendar_data(): def api_calendar_data():
"""投资日历数据接口""" """投资日历数据接口"""
try: try:
@@ -5136,8 +5539,8 @@ def extract_concepts_from_concepts_field(concepts_text):
@app.route('/api/calendar/detail/<int:item_id>', methods=['GET']) @app.route('/api/calendar/detail/<int:item_id>', methods=['GET'])
@token_required
@pro_or_max_required
def api_future_event_detail(item_id): def api_future_event_detail(item_id):
"""未来事件详情接口 - 连接 future_events 表 (修正数据解析) - 仅限 Pro/Max 会员""" """未来事件详情接口 - 连接 future_events 表 (修正数据解析) - 仅限 Pro/Max 会员"""
try: try:
@@ -5372,8 +5775,8 @@ def api_future_event_detail(item_id):
# 13-15. 筛选弹窗接口(已有,优化格式) # 13-15. 筛选弹窗接口(已有,优化格式)
@app.route('/api/filter/options', methods=['GET']) @app.route('/api/filter/options', methods=['GET'])
@token_required
@pro_or_max_required
def api_filter_options(): def api_filter_options():
"""筛选选项接口""" """筛选选项接口"""
try: try:
@@ -5952,7 +6355,7 @@ if __name__ == '__main__':
port=5002, port=5002,
debug=True, debug=True,
ssl_context=( ssl_context=(
'/home/ubuntu/dify/docker/nginx/ssl/fullchain.pem', '/etc/letsencrypt/live/api.valuefrontier.cn/fullchain.pem',
'/home/ubuntu/dify/docker/nginx/ssl/privkey.pem' '/etc/letsencrypt/live/api.valuefrontier.cn/privkey.pem'
) )
) )

8126
app_vx_raw.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +1,12 @@
Flask==2.3.3 Flask>=3.0.0
Flask-CORS==4.0.0 Flask-CORS==4.0.0
Flask-SQLAlchemy==3.0.5 Flask-SQLAlchemy>=3.1.0
Flask-Login==0.6.3 Flask-Login>=0.6.3
Flask-Compress==1.14 Flask-Compress==1.14
Flask-SocketIO==5.3.6 Flask-SocketIO==5.3.6
Flask-Mail==0.9.1 Flask-Mail==0.9.1
Flask-Migrate==4.0.5 Flask-Migrate==4.0.5
Flask-Session>=0.5.0
pandas==2.0.3 pandas==2.0.3
numpy==1.24.3 numpy==1.24.3
requests==2.31.0 requests==2.31.0