事件中心ui
This commit is contained in:
584
app_vx.py
584
app_vx.py
@@ -30,7 +30,6 @@ from werkzeug.security import generate_password_hash, check_password_hash
|
||||
import json
|
||||
from clickhouse_driver import Client as Cclient
|
||||
import jwt
|
||||
import redis
|
||||
from docx import Document
|
||||
from tencentcloud.common import credential
|
||||
from tencentcloud.common.profile.client_profile import ClientProfile
|
||||
@@ -97,13 +96,14 @@ JWT_SECRET_KEY = 'vfllmgreat33818!' # 请修改为安全的密钥
|
||||
JWT_ALGORITHM = 'HS256'
|
||||
JWT_EXPIRATION_HOURS = 24 * 7 # Token有效期7天
|
||||
|
||||
app.config['CACHE_TYPE'] = 'redis'
|
||||
app.config['CACHE_REDIS_HOST'] = '43.143.189.195' # 使用实际的服务器IP
|
||||
app.config['CACHE_REDIS_PORT'] = 6379
|
||||
app.config['CACHE_REDIS_PASSWORD'] = 'Zzl338180'
|
||||
app.config['CACHE_DEFAULT_TIMEOUT'] = 300
|
||||
app.config['SESSION_TYPE'] = 'redis'
|
||||
app.config['SESSION_REDIS'] = redis.Redis(host='43.143.189.195', port=6379, password='Zzl338180')
|
||||
# Session 配置 - 使用文件系统存储(替代 Redis)
|
||||
app.config['SESSION_TYPE'] = 'filesystem'
|
||||
app.config['SESSION_FILE_DIR'] = os.path.join(os.path.dirname(__file__), 'flask_session')
|
||||
app.config['SESSION_PERMANENT'] = True
|
||||
app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(days=7) # Session 有效期 7 天
|
||||
|
||||
# 确保 session 目录存在
|
||||
os.makedirs(app.config['SESSION_FILE_DIR'], exist_ok=True)
|
||||
|
||||
# Cache directory setup
|
||||
CACHE_DIR = Path('cache')
|
||||
@@ -178,6 +178,216 @@ def beijing_now():
|
||||
return datetime.now(beijing_tz)
|
||||
|
||||
|
||||
# ============================================
|
||||
# 订阅功能模块(与 app.py 保持一致)
|
||||
# ============================================
|
||||
class UserSubscription(db.Model):
|
||||
"""用户订阅表 - 独立于现有User表"""
|
||||
__tablename__ = 'user_subscriptions'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
|
||||
user_id = db.Column(db.Integer, nullable=False, unique=True, index=True)
|
||||
subscription_type = db.Column(db.String(10), nullable=False, default='free')
|
||||
subscription_status = db.Column(db.String(20), nullable=False, default='active')
|
||||
start_date = db.Column(db.DateTime, nullable=True)
|
||||
end_date = db.Column(db.DateTime, nullable=True)
|
||||
billing_cycle = db.Column(db.String(10), nullable=True)
|
||||
auto_renewal = db.Column(db.Boolean, nullable=False, default=False)
|
||||
created_at = db.Column(db.DateTime, default=beijing_now)
|
||||
updated_at = db.Column(db.DateTime, default=beijing_now, onupdate=beijing_now)
|
||||
|
||||
def is_active(self):
|
||||
if self.subscription_status != 'active':
|
||||
return False
|
||||
if self.subscription_type == 'free':
|
||||
return True
|
||||
if self.end_date:
|
||||
# 将数据库的 naive datetime 转换为带时区的 aware datetime
|
||||
beijing_tz = pytz.timezone('Asia/Shanghai')
|
||||
end_date_aware = self.end_date if self.end_date.tzinfo else beijing_tz.localize(self.end_date)
|
||||
return beijing_now() <= end_date_aware
|
||||
return True
|
||||
|
||||
def days_left(self):
|
||||
if not self.is_active():
|
||||
return 0
|
||||
if self.subscription_type == 'free':
|
||||
return 999
|
||||
if not self.end_date:
|
||||
return 999
|
||||
try:
|
||||
now = beijing_now()
|
||||
# 将数据库的 naive datetime 转换为带时区的 aware datetime
|
||||
beijing_tz = pytz.timezone('Asia/Shanghai')
|
||||
end_date_aware = self.end_date if self.end_date.tzinfo else beijing_tz.localize(self.end_date)
|
||||
delta = end_date_aware - now
|
||||
return max(0, delta.days)
|
||||
except Exception as e:
|
||||
return 0
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'type': self.subscription_type,
|
||||
'status': self.subscription_status,
|
||||
'is_active': self.is_active(),
|
||||
'start_date': self.start_date.isoformat() if self.start_date else None,
|
||||
'end_date': self.end_date.isoformat() if self.end_date else None,
|
||||
'days_left': self.days_left(),
|
||||
'billing_cycle': self.billing_cycle,
|
||||
'auto_renewal': self.auto_renewal
|
||||
}
|
||||
|
||||
|
||||
# ============================================
|
||||
# 订阅等级工具函数
|
||||
# ============================================
|
||||
def get_user_subscription_safe(user_id):
|
||||
"""
|
||||
安全地获取用户订阅信息
|
||||
:param user_id: 用户ID
|
||||
:return: UserSubscription 对象或默认免费订阅
|
||||
"""
|
||||
try:
|
||||
subscription = UserSubscription.query.filter_by(user_id=user_id).first()
|
||||
if not subscription:
|
||||
# 如果用户没有订阅记录,创建默认免费订阅
|
||||
subscription = UserSubscription(
|
||||
user_id=user_id,
|
||||
subscription_type='free',
|
||||
subscription_status='active'
|
||||
)
|
||||
db.session.add(subscription)
|
||||
db.session.commit()
|
||||
return subscription
|
||||
except Exception as e:
|
||||
print(f"获取用户订阅信息失败: {e}")
|
||||
# 返回一个临时的免费订阅对象(不保存到数据库)
|
||||
temp_sub = UserSubscription(
|
||||
user_id=user_id,
|
||||
subscription_type='free',
|
||||
subscription_status='active'
|
||||
)
|
||||
return temp_sub
|
||||
|
||||
|
||||
def _get_current_subscription_info():
|
||||
"""
|
||||
获取当前登录用户订阅信息的字典形式,未登录或异常时视为免费用户。
|
||||
小程序场景下从 request.current_user_id 获取用户ID
|
||||
"""
|
||||
try:
|
||||
user_id = getattr(request, 'current_user_id', None)
|
||||
if not user_id:
|
||||
return {
|
||||
'type': 'free',
|
||||
'status': 'active',
|
||||
'is_active': True
|
||||
}
|
||||
sub = get_user_subscription_safe(user_id)
|
||||
return {
|
||||
'type': sub.subscription_type,
|
||||
'status': sub.subscription_status,
|
||||
'is_active': sub.is_active(),
|
||||
'start_date': sub.start_date.isoformat() if sub.start_date else None,
|
||||
'end_date': sub.end_date.isoformat() if sub.end_date else None,
|
||||
'days_left': sub.days_left()
|
||||
}
|
||||
except Exception as e:
|
||||
print(f"获取订阅信息异常: {e}")
|
||||
return {
|
||||
'type': 'free',
|
||||
'status': 'active',
|
||||
'is_active': True
|
||||
}
|
||||
|
||||
|
||||
def _subscription_level(sub_type):
|
||||
"""将订阅类型映射到等级数值,free=0, pro=1, max=2。"""
|
||||
mapping = {'free': 0, 'pro': 1, 'max': 2}
|
||||
return mapping.get((sub_type or 'free').lower(), 0)
|
||||
|
||||
|
||||
def _has_required_level(required: str) -> bool:
|
||||
"""判断当前用户是否达到所需订阅级别。"""
|
||||
info = _get_current_subscription_info()
|
||||
if not info.get('is_active', True):
|
||||
return False
|
||||
return _subscription_level(info.get('type')) >= _subscription_level(required)
|
||||
|
||||
|
||||
# ============================================
|
||||
# 权限装饰器
|
||||
# ============================================
|
||||
def subscription_required(level='pro'):
|
||||
"""
|
||||
订阅等级装饰器 - 小程序专用
|
||||
用法:
|
||||
@subscription_required('pro') # 需要 Pro 或 Max 用户
|
||||
@subscription_required('max') # 仅限 Max 用户
|
||||
|
||||
注意:此装饰器需要配合 @token_required 使用
|
||||
"""
|
||||
from functools import wraps
|
||||
|
||||
def decorator(f):
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
if not _has_required_level(level):
|
||||
current_info = _get_current_subscription_info()
|
||||
current_type = current_info.get('type', 'free')
|
||||
is_active = current_info.get('is_active', False)
|
||||
|
||||
if not is_active:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': '您的订阅已过期,请续费后继续使用',
|
||||
'error_code': 'SUBSCRIPTION_EXPIRED',
|
||||
'current_subscription': current_type,
|
||||
'required_subscription': level
|
||||
}), 403
|
||||
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': f'此功能需要 {level.upper()} 或更高等级会员',
|
||||
'error_code': 'SUBSCRIPTION_REQUIRED',
|
||||
'current_subscription': current_type,
|
||||
'required_subscription': level
|
||||
}), 403
|
||||
|
||||
return f(*args, **kwargs)
|
||||
|
||||
return decorated_function
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def pro_or_max_required(f):
|
||||
"""
|
||||
快捷装饰器:要求 Pro 或 Max 用户(小程序专用场景)
|
||||
等同于 @subscription_required('pro')
|
||||
"""
|
||||
from functools import wraps
|
||||
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
if not _has_required_level('pro'):
|
||||
current_info = _get_current_subscription_info()
|
||||
current_type = current_info.get('type', 'free')
|
||||
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': '小程序功能仅对 Pro 和 Max 会员开放',
|
||||
'error_code': 'MINIPROGRAM_PRO_REQUIRED',
|
||||
'current_subscription': current_type,
|
||||
'required_subscription': 'pro',
|
||||
'message': '请升级到 Pro 或 Max 会员以使用小程序完整功能'
|
||||
}), 403
|
||||
|
||||
return f(*args, **kwargs)
|
||||
|
||||
return decorated_function
|
||||
|
||||
|
||||
class User(UserMixin, db.Model):
|
||||
"""用户模型"""
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
@@ -694,90 +904,72 @@ def verify_sms_code(phone_number, code):
|
||||
|
||||
return True, "验证成功"
|
||||
|
||||
def send_notification_email(recipient, subject, template, **kwargs):
|
||||
"""
|
||||
发送通知邮件
|
||||
:param recipient: 收件人邮箱
|
||||
:param subject: 邮件主题
|
||||
:param template: 模板文件名
|
||||
:param kwargs: 传递给模板的参数
|
||||
"""
|
||||
try:
|
||||
# 读取邮件模板内容
|
||||
msg = Message(
|
||||
subject=subject,
|
||||
sender=app.config['MAIL_USERNAME'],
|
||||
recipients=[recipient]
|
||||
)
|
||||
|
||||
# 渲染HTML邮件内容
|
||||
if template == 'emails/notification_post_liked.html':
|
||||
msg.html = render_template_string("""
|
||||
<div style="max-width: 600px; margin: 0 auto; padding: 20px; font-family: Arial, sans-serif;">
|
||||
<h2 style="color: #333;">你的帖子收到了新的点赞</h2>
|
||||
<div style="background: #f7f7f7; padding: 20px; border-radius: 5px; margin: 20px 0;">
|
||||
<p>{{ liker.username }} 点赞了你的帖子</p>
|
||||
<p style="color: #666; font-size: 14px;">帖子内容: {{ post.content[:100] }}...</p>
|
||||
</div>
|
||||
<a href="{{ url_for('event_detail', event_id=post.event_id, _anchor='post-' + post.id|string, _external=True) }}"
|
||||
style="display: inline-block; background: #4a90e2; color: white; padding: 10px 20px; text-decoration: none; border-radius: 5px;">
|
||||
查看详情
|
||||
</a>
|
||||
<p style="color: #666; font-size: 13px; margin-top: 20px;">
|
||||
如果你不想再收到此类通知,可以在个人设置中关闭邮件通知
|
||||
</p>
|
||||
</div>
|
||||
""", **kwargs)
|
||||
|
||||
elif template == 'emails/notification_post_commented.html':
|
||||
msg.html = render_template_string("""
|
||||
<div style="max-width: 600px; margin: 0 auto; padding: 20px; font-family: Arial, sans-serif;">
|
||||
<h2 style="color: #333;">你的帖子收到了新的评论</h2>
|
||||
<div style="background: #f7f7f7; padding: 20px; border-radius: 5px; margin: 20px 0;">
|
||||
<p>{{ commenter.username }} 评论了你的帖子:</p>
|
||||
<p style="color: #666; font-size: 14px;">帖子内容: {{ post.content[:100] }}...</p>
|
||||
</div>
|
||||
<a href="{{ url_for('event_detail', event_id=post.event_id, _anchor='post-' + post.id|string, _external=True) }}"
|
||||
style="display: inline-block; background: #4a90e2; color: white; padding: 10px 20px; text-decoration: none; border-radius: 5px;">
|
||||
查看详情
|
||||
</a>
|
||||
<p style="color: #666; font-size: 13px; margin-top: 20px;">
|
||||
如果你不想再收到此类通知,可以在个人设置中关闭邮件通知
|
||||
</p>
|
||||
</div>
|
||||
""", **kwargs)
|
||||
|
||||
elif template == 'emails/notification_comment_replied.html':
|
||||
msg.html = render_template_string("""
|
||||
<div style="max-width: 600px; margin: 0 auto; padding: 20px; font-family: Arial, sans-serif;">
|
||||
<h2 style="color: #333;">你的评论收到了新的回复</h2>
|
||||
<div style="background: #f7f7f7; padding: 20px; border-radius: 5px; margin: 20px 0;">
|
||||
<p>{{ replier.username }} 回复了你的评论:</p>
|
||||
<p style="color: #666; font-size: 14px;">你的评论: {{ comment.content[:100] }}...</p>
|
||||
</div>
|
||||
<a href="{{ url_for('event_detail', event_id=comment.post.event_id, _anchor='comment-' + comment.id|string, _external=True) }}"
|
||||
style="display: inline-block; background: #4a90e2; color: white; padding: 10px 20px; text-decoration: none; border-radius: 5px;">
|
||||
查看详情
|
||||
</a>
|
||||
<p style="color: #666; font-size: 13px; margin-top: 20px;">
|
||||
如果你不想再收到此类通知,可以在个人设置中关闭邮件通知
|
||||
</p>
|
||||
</div>
|
||||
""", **kwargs)
|
||||
|
||||
# 使用异步任务发送邮件
|
||||
send_async_email(msg)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
app.logger.error(f"Error sending notification email: {str(e)}")
|
||||
return False
|
||||
|
||||
|
||||
def allowed_file(filename):
|
||||
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
|
||||
|
||||
|
||||
# ============================================
|
||||
# 订阅相关 API 接口(小程序专用)
|
||||
# ============================================
|
||||
@app.route('/api/subscription/info', methods=['GET'])
|
||||
@token_required
|
||||
def get_subscription_info():
|
||||
"""
|
||||
获取当前用户的订阅信息 - 小程序专用接口
|
||||
返回用户当前订阅类型、状态、剩余天数等信息
|
||||
"""
|
||||
try:
|
||||
info = _get_current_subscription_info()
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'data': info
|
||||
})
|
||||
except Exception as e:
|
||||
print(f"获取订阅信息错误: {e}")
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'data': {
|
||||
'type': 'free',
|
||||
'status': 'active',
|
||||
'is_active': True,
|
||||
'days_left': 0
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@app.route('/api/subscription/check', methods=['GET'])
|
||||
@token_required
|
||||
def check_subscription_access():
|
||||
"""
|
||||
检查当前用户是否有权限使用小程序功能
|
||||
返回:是否为 Pro/Max 用户
|
||||
"""
|
||||
try:
|
||||
has_access = _has_required_level('pro')
|
||||
info = _get_current_subscription_info()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'data': {
|
||||
'has_access': has_access,
|
||||
'subscription_type': info.get('type', 'free'),
|
||||
'is_active': info.get('is_active', False),
|
||||
'message': '您可以使用小程序功能' if has_access else '小程序功能仅对 Pro 和 Max 会员开放'
|
||||
}
|
||||
})
|
||||
except Exception as e:
|
||||
print(f"检查订阅权限错误: {e}")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}), 500
|
||||
|
||||
|
||||
# ============================================
|
||||
# 现有接口示例(应用权限控制)
|
||||
# ============================================
|
||||
|
||||
# 更新视图函数
|
||||
@app.route('/settings/profile', methods=['POST'])
|
||||
@token_required
|
||||
@@ -860,8 +1052,10 @@ def get_clickhouse_client():
|
||||
|
||||
|
||||
@app.route('/api/stock/<stock_code>/kline')
|
||||
@token_required
|
||||
@pro_or_max_required
|
||||
def get_stock_kline(stock_code):
|
||||
"""获取股票K线数据"""
|
||||
"""获取股票K线数据 - 仅限 Pro/Max 会员(小程序功能)"""
|
||||
chart_type = request.args.get('chart_type', 'daily') # 默认改为daily
|
||||
event_time = request.args.get('event_time')
|
||||
|
||||
@@ -1341,102 +1535,6 @@ def like_post(post_id):
|
||||
return jsonify({'success': False, 'message': '操作失败,请重试'})
|
||||
|
||||
|
||||
# 通知相关函数
|
||||
def notify_user_post_liked(post):
|
||||
"""当用户的帖子被点赞时发送通知"""
|
||||
try:
|
||||
notification = Notification(
|
||||
user_id=post.user_id,
|
||||
type='post_like',
|
||||
content=f'{request.user.username} 点赞了你的帖子',
|
||||
link=url_for('event_detail', event_id=post.event_id, _anchor=f'post-{post.id}'),
|
||||
related_user_id=request.user.id,
|
||||
related_post_id=post.id
|
||||
)
|
||||
db.session.add(notification)
|
||||
|
||||
# 如果用户开启了邮件通知
|
||||
user = User.query.get(post.user_id)
|
||||
if user.email_notifications:
|
||||
send_notification_email(
|
||||
recipient=user.email,
|
||||
subject='你的帖子收到了新的点赞',
|
||||
template='emails/notification_post_liked.html',
|
||||
user=user,
|
||||
post=post,
|
||||
liker=request.user
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
app.logger.error(f"Error creating like notification: {str(e)}")
|
||||
# 通知创建失败不应影响主要功能
|
||||
pass
|
||||
|
||||
|
||||
def notify_user_post_commented(post):
|
||||
"""当用户的帖子收到评论时发送通知"""
|
||||
try:
|
||||
notification = Notification(
|
||||
user_id=post.user_id,
|
||||
type='post_comment',
|
||||
content=f'{request.user.username} 评论了你的帖子',
|
||||
link=url_for('event_detail', event_id=post.event_id, _anchor=f'post-{post.id}'),
|
||||
related_user_id=request.user.id,
|
||||
related_post_id=post.id
|
||||
)
|
||||
db.session.add(notification)
|
||||
|
||||
# 如果用户开启了邮件通知
|
||||
user = User.query.get(post.user_id)
|
||||
if user.email_notifications:
|
||||
send_notification_email(
|
||||
recipient=user.email,
|
||||
subject='你的帖子收到了新的评论',
|
||||
template='emails/notification_post_commented.html',
|
||||
user=user,
|
||||
post=post,
|
||||
commenter=request.user
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
app.logger.error(f"Error creating comment notification: {str(e)}")
|
||||
pass
|
||||
|
||||
|
||||
def notify_user_comment_replied(parent_comment):
|
||||
"""当用户的评论被回复时发送通知"""
|
||||
try:
|
||||
notification = Notification(
|
||||
user_id=parent_comment.user_id,
|
||||
type='comment_reply',
|
||||
content=f'{request.user.username} 回复了你的评论',
|
||||
link=url_for('event_detail',
|
||||
event_id=parent_comment.post.event_id,
|
||||
_anchor=f'comment-{parent_comment.id}'),
|
||||
related_user_id=request.user.id,
|
||||
related_post_id=parent_comment.post_id,
|
||||
related_comment_id=parent_comment.id
|
||||
)
|
||||
db.session.add(notification)
|
||||
|
||||
# 如果用户开启了邮件通知
|
||||
user = User.query.get(parent_comment.user_id)
|
||||
if user.email_notifications:
|
||||
send_notification_email(
|
||||
recipient=user.email,
|
||||
subject='你的评论收到了新的回复',
|
||||
template='emails/notification_comment_replied.html',
|
||||
user=user,
|
||||
comment=parent_comment,
|
||||
replier=request.user
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
app.logger.error(f"Error creating reply notification: {str(e)}")
|
||||
pass
|
||||
|
||||
|
||||
|
||||
def update_user_activity():
|
||||
"""更新用户活跃度"""
|
||||
with app.app_context():
|
||||
@@ -1914,9 +2012,12 @@ def get_limit_rate(stock_code):
|
||||
|
||||
|
||||
@app.route('/api/events', methods=['GET'])
|
||||
@token_required
|
||||
@pro_or_max_required
|
||||
def api_get_events():
|
||||
"""
|
||||
获取事件列表API - 优化版本(保持完全兼容)
|
||||
仅限 Pro/Max 会员访问(小程序功能)
|
||||
|
||||
优化策略:
|
||||
1. 使用ind_type字段简化内部逻辑
|
||||
@@ -2738,6 +2839,8 @@ def get_event_class(count):
|
||||
else:
|
||||
return 'bg-gradient-success'
|
||||
@app.route('/api/calendar-event-counts')
|
||||
@token_required
|
||||
@pro_or_max_required
|
||||
def get_calendar_event_counts():
|
||||
"""获取整月的事件数量统计,仅统计type为event的事件"""
|
||||
try:
|
||||
@@ -2827,6 +2930,8 @@ def to_dict(self):
|
||||
|
||||
# 1. 首页接口
|
||||
@app.route('/api/home', methods=['GET'])
|
||||
@token_required
|
||||
@pro_or_max_required
|
||||
def api_home():
|
||||
try:
|
||||
seven_days_ago = datetime.now() - timedelta(days=7)
|
||||
@@ -3515,8 +3620,10 @@ def api_login_email():
|
||||
|
||||
# 5. 事件详情-相关标的接口
|
||||
@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):
|
||||
"""事件相关标的详情接口"""
|
||||
"""事件相关标的详情接口 - 仅限 Pro/Max 会员"""
|
||||
try:
|
||||
event = Event.query.get_or_404(event_id)
|
||||
related_stocks = event.related_stocks.order_by(RelatedStock.correlation.desc()).all()
|
||||
@@ -3702,9 +3809,11 @@ def api_event_related_stocks(event_id):
|
||||
|
||||
|
||||
@app.route('/api/stock/<stock_code>/minute-chart', methods=['GET'])
|
||||
@token_required
|
||||
@pro_or_max_required
|
||||
def get_minute_chart_data(stock_code):
|
||||
"""获取股票分时图数据 - 仅限 Pro/Max 会员"""
|
||||
client = get_clickhouse_client()
|
||||
"""获取股票分时图数据"""
|
||||
try:
|
||||
# 获取当前日期或最新交易日的分时数据
|
||||
from datetime import datetime, timedelta, time as dt_time
|
||||
@@ -3776,8 +3885,10 @@ def get_minute_chart_data(stock_code):
|
||||
|
||||
|
||||
@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):
|
||||
"""个股详情接口"""
|
||||
"""个股详情接口 - 仅限 Pro/Max 会员"""
|
||||
try:
|
||||
# 验证事件是否存在
|
||||
event = Event.query.get_or_404(event_id)
|
||||
@@ -4035,6 +4146,8 @@ def get_stock_minute_chart_data(stock_code):
|
||||
|
||||
# 7. 事件详情-相关概念接口
|
||||
@app.route('/api/event/<int:event_id>/related-concepts', methods=['GET'])
|
||||
@token_required
|
||||
@pro_or_max_required
|
||||
def api_event_related_concepts(event_id):
|
||||
"""事件相关概念接口"""
|
||||
try:
|
||||
@@ -4076,6 +4189,8 @@ def api_event_related_concepts(event_id):
|
||||
|
||||
# 8. 事件详情-历史事件接口
|
||||
@app.route('/api/event/<int:event_id>/historical-events', methods=['GET'])
|
||||
@token_required
|
||||
@pro_or_max_required
|
||||
def api_event_historical_events(event_id):
|
||||
"""事件历史事件接口"""
|
||||
try:
|
||||
@@ -4176,6 +4291,7 @@ def api_event_historical_events(event_id):
|
||||
|
||||
@app.route('/api/event/<int:event_id>/comments', methods=['GET'])
|
||||
@token_required
|
||||
@pro_or_max_required
|
||||
def get_event_comments(event_id):
|
||||
"""获取事件的所有评论和帖子(嵌套格式)
|
||||
|
||||
@@ -4430,6 +4546,7 @@ def get_event_comments(event_id):
|
||||
|
||||
@app.route('/api/comment/<int:comment_id>/replies', methods=['GET'])
|
||||
@token_required
|
||||
@pro_or_max_required
|
||||
def get_comment_replies(comment_id):
|
||||
"""获取某条评论的所有回复
|
||||
|
||||
@@ -4574,6 +4691,8 @@ def get_comment_replies(comment_id):
|
||||
|
||||
# 10. 投资日历-事件接口(增强版)
|
||||
@app.route('/api/calendar/events', methods=['GET'])
|
||||
@token_required
|
||||
@pro_or_max_required
|
||||
def api_calendar_events():
|
||||
"""投资日历事件接口 - 连接 future_events 表 (修正版)"""
|
||||
try:
|
||||
@@ -4829,6 +4948,8 @@ def api_calendar_events():
|
||||
|
||||
# 11. 投资日历-数据接口
|
||||
@app.route('/api/calendar/data', methods=['GET'])
|
||||
@token_required
|
||||
@pro_or_max_required
|
||||
def api_calendar_data():
|
||||
"""投资日历数据接口"""
|
||||
try:
|
||||
@@ -5015,8 +5136,10 @@ def extract_concepts_from_concepts_field(concepts_text):
|
||||
|
||||
|
||||
@app.route('/api/calendar/detail/<int:item_id>', methods=['GET'])
|
||||
@token_required
|
||||
@pro_or_max_required
|
||||
def api_future_event_detail(item_id):
|
||||
"""未来事件详情接口 - 连接 future_events 表 (修正数据解析)"""
|
||||
"""未来事件详情接口 - 连接 future_events 表 (修正数据解析) - 仅限 Pro/Max 会员"""
|
||||
try:
|
||||
# 从 future_events 表查询事件详情
|
||||
query = """
|
||||
@@ -5249,6 +5372,8 @@ def api_future_event_detail(item_id):
|
||||
|
||||
# 13-15. 筛选弹窗接口(已有,优化格式)
|
||||
@app.route('/api/filter/options', methods=['GET'])
|
||||
@token_required
|
||||
@pro_or_max_required
|
||||
def api_filter_options():
|
||||
"""筛选选项接口"""
|
||||
try:
|
||||
@@ -5795,89 +5920,6 @@ class UserFeedback(db.Model):
|
||||
}
|
||||
|
||||
|
||||
# 21. 个人中心-意见反馈接口
|
||||
@app.route('/api/user/feedback', methods=['POST'])
|
||||
@token_required
|
||||
def api_user_feedback():
|
||||
"""意见反馈接口"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
feedback_type = data.get('type', 'other') # bug, suggestion, complaint, other
|
||||
content = data.get('content')
|
||||
contact_info = data.get('contact_info', '')
|
||||
|
||||
if not content:
|
||||
return jsonify({
|
||||
'code': 400,
|
||||
'message': '反馈类型和内容不能为空',
|
||||
'data': None
|
||||
}), 400
|
||||
|
||||
# 验证反馈类型是否有效
|
||||
valid_types = ['bug', 'feature', 'suggestion', 'other'] # 可以根据需求修改
|
||||
if feedback_type not in valid_types:
|
||||
return jsonify({
|
||||
'code': 400,
|
||||
'message': '无效的反馈类型',
|
||||
'data': None
|
||||
}), 400
|
||||
|
||||
# 创建反馈记录
|
||||
feedback = UserFeedback(
|
||||
user_id=request.user.id,
|
||||
type=feedback_type,
|
||||
content=content,
|
||||
contact_info=contact_info
|
||||
)
|
||||
|
||||
# 保存到数据库
|
||||
db.session.add(feedback)
|
||||
db.session.commit()
|
||||
|
||||
# 可以在这里添加通知管理员的逻辑
|
||||
notify_admin_new_feedback(feedback)
|
||||
|
||||
# 可以保存到数据库或发送邮件通知管理员
|
||||
|
||||
return jsonify({
|
||||
'code': 200,
|
||||
'message': '反馈提交成功,我们会尽快处理',
|
||||
'data': feedback.to_dict()
|
||||
})
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
'code': 500,
|
||||
'message': str(e),
|
||||
'data': None
|
||||
}), 500
|
||||
|
||||
|
||||
def notify_admin_new_feedback(feedback):
|
||||
"""通知管理员新的反馈"""
|
||||
try:
|
||||
# 获取管理员邮箱列表
|
||||
admin_emails = ['admin@example.com'] # 替换为实际的管理员邮箱列表
|
||||
|
||||
# 发送通知邮件
|
||||
subject = f'新的用户反馈 - {feedback.type}'
|
||||
body = f"""
|
||||
收到新的用户反馈:
|
||||
|
||||
用户ID: {feedback.user_id}
|
||||
反馈类型: {feedback.type}
|
||||
反馈内容: {feedback.content}
|
||||
联系方式: {feedback.contact_info or '未提供'}
|
||||
提交时间: {feedback.created_at}
|
||||
"""
|
||||
|
||||
for admin_email in admin_emails:
|
||||
send_notification_email(admin_email, subject, 'emails/admin_notification.html',
|
||||
feedback=feedback)
|
||||
|
||||
except Exception as e:
|
||||
app.logger.error(f"发送管理员通知失败: {str(e)}")
|
||||
|
||||
|
||||
|
||||
# 通用错误处理
|
||||
@app.errorhandler(404)
|
||||
|
||||
@@ -162,7 +162,8 @@ const MiniKLineChart = React.memo(function MiniKLineChart({ stockCode, eventTime
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: 30,
|
||||
height: '100%',
|
||||
minHeight: '40px',
|
||||
cursor: onClick ? 'pointer' : 'default'
|
||||
}}
|
||||
onClick={onClick}
|
||||
|
||||
@@ -125,13 +125,13 @@ const StockListItem = ({
|
||||
transition="all 0.2s"
|
||||
>
|
||||
{/* 单行紧凑布局:名称+涨跌幅 | 分时图 | K线图 | 关联描述 */}
|
||||
<HStack spacing={3} align="stretch" flexWrap="wrap">
|
||||
{/* 左侧:股票代码 + 名称 + 涨跌幅(垂直排列) - 收窄 */}
|
||||
<HStack spacing={3} align="center" flexWrap="wrap">
|
||||
{/* 左侧:股票代码 + 名称 + 涨跌幅(垂直排列) */}
|
||||
<VStack
|
||||
align="stretch"
|
||||
spacing={1}
|
||||
minW="100px"
|
||||
maxW="120px"
|
||||
minW="110px"
|
||||
maxW="130px"
|
||||
justify="center"
|
||||
flexShrink={0}
|
||||
>
|
||||
@@ -190,12 +190,10 @@ const StockListItem = ({
|
||||
</VStack>
|
||||
|
||||
{/* 分时图 - 响应式宽度 */}
|
||||
<Box
|
||||
minW="140px"
|
||||
maxW="160px"
|
||||
<VStack
|
||||
minW="150px"
|
||||
maxW="180px"
|
||||
flex="1"
|
||||
maxH="50px"
|
||||
h="auto"
|
||||
borderWidth="1px"
|
||||
borderColor={useColorModeValue('blue.100', 'blue.700')}
|
||||
borderRadius="md"
|
||||
@@ -207,38 +205,36 @@ const StockListItem = ({
|
||||
}}
|
||||
cursor="pointer"
|
||||
flexShrink={1}
|
||||
alignSelf="center"
|
||||
overflow="hidden"
|
||||
align="stretch"
|
||||
spacing={1}
|
||||
_hover={{
|
||||
borderColor: useColorModeValue('blue.300', 'blue.500'),
|
||||
boxShadow: 'sm'
|
||||
boxShadow: 'md',
|
||||
transform: 'translateY(-1px)'
|
||||
}}
|
||||
transition="all 0.2s"
|
||||
>
|
||||
<Text
|
||||
fontSize="xs"
|
||||
color={useColorModeValue('blue.700', 'blue.200')}
|
||||
mb={1}
|
||||
fontWeight="semibold"
|
||||
whiteSpace="nowrap"
|
||||
>
|
||||
📈 分时
|
||||
</Text>
|
||||
<Box overflow="hidden">
|
||||
<Box h="50px" flex="1">
|
||||
<MiniTimelineChart
|
||||
stockCode={stock.stock_code}
|
||||
eventTime={eventTime}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</VStack>
|
||||
|
||||
{/* K线图 - 响应式宽度 */}
|
||||
<Box
|
||||
minW="140px"
|
||||
maxW="160px"
|
||||
<VStack
|
||||
minW="150px"
|
||||
maxW="180px"
|
||||
flex="1"
|
||||
maxH="50px"
|
||||
h="auto"
|
||||
borderWidth="1px"
|
||||
borderColor={useColorModeValue('purple.100', 'purple.700')}
|
||||
borderRadius="md"
|
||||
@@ -250,30 +246,30 @@ const StockListItem = ({
|
||||
}}
|
||||
cursor="pointer"
|
||||
flexShrink={1}
|
||||
alignSelf="center"
|
||||
overflow="hidden"
|
||||
align="stretch"
|
||||
spacing={1}
|
||||
_hover={{
|
||||
borderColor: useColorModeValue('purple.300', 'purple.500'),
|
||||
boxShadow: 'sm'
|
||||
boxShadow: 'md',
|
||||
transform: 'translateY(-1px)'
|
||||
}}
|
||||
transition="all 0.2s"
|
||||
>
|
||||
<Text
|
||||
fontSize="xs"
|
||||
color={useColorModeValue('purple.700', 'purple.200')}
|
||||
mb={1}
|
||||
fontWeight="semibold"
|
||||
whiteSpace="nowrap"
|
||||
>
|
||||
📊 日线
|
||||
</Text>
|
||||
<Box overflow="hidden">
|
||||
<Box h="50px" flex="1">
|
||||
<MiniKLineChart
|
||||
stockCode={stock.stock_code}
|
||||
eventTime={eventTime}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</VStack>
|
||||
|
||||
{/* 关联描述 - 升级和降级处理 */}
|
||||
{stock.relation_desc && (
|
||||
|
||||
@@ -166,7 +166,8 @@ const MiniTimelineChart = React.memo(function MiniTimelineChart({ stockCode, eve
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: 30,
|
||||
height: '100%',
|
||||
minHeight: '40px',
|
||||
cursor: onClick ? 'pointer' : 'default'
|
||||
}}
|
||||
onClick={onClick}
|
||||
|
||||
Reference in New Issue
Block a user