diff --git a/app_vx.py b/app_vx.py index 51bc0fec..be1f176e 100644 --- a/app_vx.py +++ b/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(""" -
-

你的帖子收到了新的点赞

-
-

{{ liker.username }} 点赞了你的帖子

-

帖子内容: {{ post.content[:100] }}...

-
- - 查看详情 - -

- 如果你不想再收到此类通知,可以在个人设置中关闭邮件通知 -

-
- """, **kwargs) - - elif template == 'emails/notification_post_commented.html': - msg.html = render_template_string(""" -
-

你的帖子收到了新的评论

-
-

{{ commenter.username }} 评论了你的帖子:

-

帖子内容: {{ post.content[:100] }}...

-
- - 查看详情 - -

- 如果你不想再收到此类通知,可以在个人设置中关闭邮件通知 -

-
- """, **kwargs) - - elif template == 'emails/notification_comment_replied.html': - msg.html = render_template_string(""" -
-

你的评论收到了新的回复

-
-

{{ replier.username }} 回复了你的评论:

-

你的评论: {{ comment.content[:100] }}...

-
- - 查看详情 - -

- 如果你不想再收到此类通知,可以在个人设置中关闭邮件通知 -

-
- """, **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//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//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//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//stock//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//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//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//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//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/', 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) diff --git a/src/views/Community/components/DynamicNewsDetail/MiniKLineChart.js b/src/views/Community/components/DynamicNewsDetail/MiniKLineChart.js index 1dd23399..81b8b69e 100644 --- a/src/views/Community/components/DynamicNewsDetail/MiniKLineChart.js +++ b/src/views/Community/components/DynamicNewsDetail/MiniKLineChart.js @@ -162,7 +162,8 @@ const MiniKLineChart = React.memo(function MiniKLineChart({ stockCode, eventTime
{/* 单行紧凑布局:名称+涨跌幅 | 分时图 | K线图 | 关联描述 */} - - {/* 左侧:股票代码 + 名称 + 涨跌幅(垂直排列) - 收窄 */} + + {/* 左侧:股票代码 + 名称 + 涨跌幅(垂直排列) */} @@ -190,12 +190,10 @@ const StockListItem = ({ {/* 分时图 - 响应式宽度 */} - 📈 分时 - + - + {/* K线图 - 响应式宽度 */} - 📊 日线 - + - + {/* 关联描述 - 升级和降级处理 */} {stock.relation_desc && ( diff --git a/src/views/Community/components/StockDetailPanel/components/MiniTimelineChart.js b/src/views/Community/components/StockDetailPanel/components/MiniTimelineChart.js index 2913ebb3..b6580af5 100644 --- a/src/views/Community/components/StockDetailPanel/components/MiniTimelineChart.js +++ b/src/views/Community/components/StockDetailPanel/components/MiniTimelineChart.js @@ -166,7 +166,8 @@ const MiniTimelineChart = React.memo(function MiniTimelineChart({ stockCode, eve