From 12d104cc22fe90a2bd804e116e6048a7d6fca175 Mon Sep 17 00:00:00 2001 From: zzlgreat Date: Fri, 21 Nov 2025 13:41:49 +0800 Subject: [PATCH] update pay function --- __pycache__/app_vx.cpython-310.pyc | Bin 0 -> 131677 bytes app_vx - 副本.py | 6352 ++++++++++++++++++++++++++++ app_vx.py | 315 +- 3 files changed, 6362 insertions(+), 305 deletions(-) create mode 100644 __pycache__/app_vx.cpython-310.pyc create mode 100644 app_vx - 副本.py diff --git a/__pycache__/app_vx.cpython-310.pyc b/__pycache__/app_vx.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2c104e8006e1eb44b69a3629c94e31ae39e3447c GIT binary patch literal 131677 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 WEsufj#$wEh1+N8t%}CTDiTa 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 用户 + + 注意:此装饰器需要配合 使用 + """ + 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) + + # 基础账号信息(注册时必填) + username = db.Column(db.String(80), unique=True, nullable=False) # 用户名 + email = db.Column(db.String(120), unique=True, nullable=False) # 邮箱 + password_hash = db.Column(db.String(128), nullable=False) # 密码哈希 + email_confirmed = db.Column(db.Boolean, default=False) # 邮箱是否验证 + wechat_union_id = db.Column(db.String(100), unique=True) # 微信 UnionID + wechat_open_id = db.Column(db.String(100)) # 微信 OpenID + + # 账号状态 + created_at = db.Column(db.DateTime, default=beijing_now) # 注册时间 + last_seen = db.Column(db.DateTime, default=beijing_now) # 最后活跃时间 + status = db.Column(db.String(20), default='active') # 账号状态 active/banned/deleted + + # 个人资料(可选,后续在个人中心完善) + nickname = db.Column(db.String(30)) # 社区昵称 + avatar_url = db.Column(db.String(200)) # 头像URL + banner_url = db.Column(db.String(200)) # 个人主页背景图 + bio = db.Column(db.String(200)) # 个人简介 + gender = db.Column(db.String(10)) # 性别 + birth_date = db.Column(db.Date) # 生日 + location = db.Column(db.String(100)) # 所在地 + + # 联系方式(可选) + phone = db.Column(db.String(20)) # 手机号 + wechat_id = db.Column(db.String(80)) # 微信号 + + # 实名认证信息(可选) + real_name = db.Column(db.String(30)) # 真实姓名 + id_number = db.Column(db.String(18)) # 身份证号(加密存储) + is_verified = db.Column(db.Boolean, default=False) # 是否实名认证 + verify_time = db.Column(db.DateTime) # 实名认证时间 + + # 投资相关信息(可选) + trading_experience = db.Column(db.String(200)) # 炒股年限 + investment_style = db.Column(db.String(50)) # 投资风格 + risk_preference = db.Column(db.String(20)) # 风险偏好 + investment_amount = db.Column(db.String(20)) # 投资规模 + preferred_markets = db.Column(db.String(200), default='[]') # 偏好市场 JSON + + # 社区信息(系统自动更新) + user_level = db.Column(db.Integer, default=1) # 用户等级 + reputation_score = db.Column(db.Integer, default=0) # 信用积分 + contribution_point = db.Column(db.Integer, default=0) # 贡献点数 + post_count = db.Column(db.Integer, default=0) # 发帖数 + comment_count = db.Column(db.Integer, default=0) # 评论数 + follower_count = db.Column(db.Integer, default=0) # 粉丝数 + following_count = db.Column(db.Integer, default=0) # 关注数 + + # 创作者信息(可选) + is_creator = db.Column(db.Boolean, default=False) # 是否创作者 + creator_type = db.Column(db.String(20)) # 创作者类型 + creator_tags = db.Column(db.String(200), default='[]') # 创作者标签 JSON + + # 系统设置 + email_notifications = db.Column(db.Boolean, default=True) # 邮件通知 + sms_notifications = db.Column(db.Boolean, default=False) # 短信通知 + wechat_notifications = db.Column(db.Boolean, default=False) # 微信通知 + notification_preferences = db.Column(db.String(500), default='{}') # 通知偏好 JSON + privacy_level = db.Column(db.String(20), default='public') # 隐私级别 + theme_preference = db.Column(db.String(20), default='light') # 主题偏好 + blocked_keywords = db.Column(db.String(500), default='[]') # 屏蔽关键词 JSON + # 手机号验证 + phone_confirmed = db.Column(db.Boolean, default=False) # 手机是否验证 + phone_confirm_time = db.Column(db.DateTime) # 手机验证时间 + + def __init__(self, username, email=None, password=None, phone=None): + """初始化用户,只需要基本信息""" + self.username = username + if email: + self.email = email + if phone: + self.phone = phone + if password: + self.set_password(password) + self.created_at = beijing_now() + self.last_seen = beijing_now() + + def set_password(self, password): + """设置密码""" + self.password_hash = generate_password_hash(password) + + def check_password(self, password): + """验证密码""" + return check_password_hash(self.password_hash, password) + + def update_last_seen(self): + """更新最后活跃时间""" + self.last_seen = beijing_now() + + # JSON 字段的getter和setter + def get_preferred_markets(self): + """获取偏好市场列表""" + if self.preferred_markets: + try: + return json.loads(self.preferred_markets) + except: + return [] + return [] + + def get_blocked_keywords(self): + """获取屏蔽关键词列表""" + if self.blocked_keywords: + try: + return json.loads(self.blocked_keywords) + except: + return [] + return [] + + def get_notification_preferences(self): + """获取通知偏好设置""" + if self.notification_preferences: + try: + return json.loads(self.notification_preferences) + except: + return {} + return {} + + def get_creator_tags(self): + """获取创作者标签""" + if self.creator_tags: + try: + return json.loads(self.creator_tags) + except: + return [] + return [] + + def set_preferred_markets(self, markets): + """设置偏好市场""" + self.preferred_markets = json.dumps(markets) + + def set_blocked_keywords(self, keywords): + """设置屏蔽关键词""" + self.blocked_keywords = json.dumps(keywords) + + def set_notification_preferences(self, preferences): + """设置通知偏好""" + self.notification_preferences = json.dumps(preferences) + + def set_creator_tags(self, tags): + """设置创作者标签""" + self.creator_tags = json.dumps(tags) + + def to_dict(self): + """返回用户的字典表示""" + return { + 'id': self.id, + 'username': self.username, + 'email': self.email, + 'nickname': self.nickname, + 'avatar_url': get_full_avatar_url(self.avatar_url), # 修改这里 + 'bio': self.bio, + 'is_verified': self.is_verified, + 'user_level': self.user_level, + 'reputation_score': self.reputation_score, + 'is_creator': self.is_creator + } + + def __repr__(self): + return f'' + + +class Notification(db.Model): + """通知模型""" + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('user.id')) + type = db.Column(db.String(50)) # 通知类型 + content = db.Column(db.Text) # 通知内容 + link = db.Column(db.String(200)) # 相关链接 + is_read = db.Column(db.Boolean, default=False) # 是否已读 + created_at = db.Column(db.DateTime, default=beijing_now) + + def __init__(self, user_id, type, content, link=None): + self.user_id = user_id + self.type = type + self.content = content + self.link = link + + +class Event(db.Model): + """事件模型""" + id = db.Column(db.Integer, primary_key=True) + title = db.Column(db.String(200), nullable=False) + description = db.Column(db.Text) + + # 事件类型与状态 + event_type = db.Column(db.String(50)) + status = db.Column(db.String(20), default='active') + + # 时间相关 + start_time = db.Column(db.DateTime, default=beijing_now) + end_time = db.Column(db.DateTime) + created_at = db.Column(db.DateTime, default=beijing_now) + updated_at = db.Column(db.DateTime, default=beijing_now) + + # 热度与统计 + hot_score = db.Column(db.Float, default=0) + view_count = db.Column(db.Integer, default=0) + trending_score = db.Column(db.Float, default=0) + post_count = db.Column(db.Integer, default=0) + follower_count = db.Column(db.Integer, default=0) + + # 关联信息 + related_industries = db.Column(db.JSON) + keywords = db.Column(db.JSON) + files = db.Column(db.JSON) + importance = db.Column(db.String(20)) + related_avg_chg = db.Column(db.Float, default=0) + related_max_chg = db.Column(db.Float, default=0) + related_week_chg = db.Column(db.Float, default=0) + + # 新增字段 + invest_score = db.Column(db.Integer) # 超预期得分 + expectation_surprise_score = db.Column(db.Integer) + # 创建者信息 + creator_id = db.Column(db.Integer, db.ForeignKey('user.id')) + creator = db.relationship('User', backref='created_events') + + # 关系 + posts = db.relationship('Post', backref='event', lazy='dynamic') + followers = db.relationship('EventFollow', backref='event', lazy='dynamic') + related_stocks = db.relationship('RelatedStock', backref='event', lazy='dynamic') + historical_events = db.relationship('HistoricalEvent', backref='event', lazy='dynamic') + 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): + """返回解析后的关键词列表""" + if not self.keywords: + return [] + + if isinstance(self.keywords, list): + return self.keywords + + try: + # 如果是字符串,尝试解析JSON + if isinstance(self.keywords, str): + decoded = json.loads(self.keywords) + # 处理Unicode编码的情况 + if isinstance(decoded, list): + return [ + keyword.encode('utf-8').decode('unicode_escape') + if isinstance(keyword, str) and '\\u' in keyword + else keyword + for keyword in decoded + ] + return [] + + # 如果已经是字典或其他格式,尝试转换为列表 + return list(self.keywords) + except (json.JSONDecodeError, AttributeError, TypeError): + return [] + + def set_keywords(self, keywords): + """设置关键词列表""" + if isinstance(keywords, list): + self.keywords = json.dumps(keywords, ensure_ascii=False) + elif isinstance(keywords, str): + try: + # 尝试解析JSON字符串 + parsed = json.loads(keywords) + if isinstance(parsed, list): + self.keywords = json.dumps(parsed, ensure_ascii=False) + else: + self.keywords = json.dumps([keywords], ensure_ascii=False) + except json.JSONDecodeError: + # 如果不是有效的JSON,将其作为单个关键词 + self.keywords = json.dumps([keywords], ensure_ascii=False) + + +class RelatedStock(db.Model): + """相关标的模型""" + id = db.Column(db.Integer, primary_key=True) + event_id = db.Column(db.Integer, db.ForeignKey('event.id')) + stock_code = db.Column(db.String(20)) # 股票代码 + stock_name = db.Column(db.String(100)) # 股票名称 + sector = db.Column(db.String(100)) # 关联类型 + relation_desc = db.Column(db.String(1024)) # 关联原因描述 + created_at = db.Column(db.DateTime, default=beijing_now) + updated_at = db.Column(db.DateTime, default=beijing_now, onupdate=beijing_now) + correlation = db.Column(db.Float()) + momentum = db.Column(db.String(1024)) # 动量 + # 新增字段 + retrieved_sources = db.Column(db.JSON) # 研报检索源数据 + retrieved_update_time = db.Column(db.DateTime) # 检索数据更新时间 + + +class RelatedData(db.Model): + """关联数据模型""" + id = db.Column(db.Integer, primary_key=True) + event_id = db.Column(db.Integer, db.ForeignKey('event.id')) + title = db.Column(db.String(200)) # 数据标题 + data_type = db.Column(db.String(50)) # 数据类型 + data_content = db.Column(db.JSON) # 数据内容(JSON格式) + description = db.Column(db.Text) # 数据描述 + created_at = db.Column(db.DateTime, default=beijing_now) + + +class RelatedConcepts(db.Model): + """关联数据模型""" + id = db.Column(db.Integer, primary_key=True) + event_id = db.Column(db.Integer, db.ForeignKey('event.id')) + concept_code = db.Column(db.String(20)) # 数据标题 + concept = db.Column(db.String(100)) # 数据类型 + reason = db.Column(db.Text) # 数据描述 + image_paths = db.Column(db.JSON) # 数据内容(JSON格式) + created_at = db.Column(db.DateTime, default=beijing_now) + + @property + def image_paths_list(self): + """返回解析后的图片路径列表""" + if not self.image_paths: + return [] + + try: + # 如果是字符串,先解析成JSON + if isinstance(self.image_paths, str): + paths = json.loads(self.image_paths) + else: + paths = self.image_paths + + # 确保paths是列表 + if not isinstance(paths, list): + paths = [paths] + + # 从每个对象中提取path字段 + return [item['path'] if isinstance(item, dict) and 'path' in item + else item for item in paths] + except Exception as e: + print(f"Error processing image paths: {e}") + return [] + + def get_first_image_path(self): + """获取第一张图片的完整路径""" + paths = self.image_paths_list + if not paths: + return None + + # 获取第一个路径 + first_path = paths[0] + # 返回完整路径 + return first_path + + +class EventHotHistory(db.Model): + """事件热度历史记录""" + id = db.Column(db.Integer, primary_key=True) + event_id = db.Column(db.Integer, db.ForeignKey('event.id')) + score = db.Column(db.Float) # 总分 + interaction_score = db.Column(db.Float) # 互动分数 + follow_score = db.Column(db.Float) # 关注度分数 + view_score = db.Column(db.Float) # 浏览量分数 + recent_activity_score = db.Column(db.Float) # 最近活跃度分数 + time_decay = db.Column(db.Float) # 时间衰减因子 + created_at = db.Column(db.DateTime, default=beijing_now) + + event = db.relationship('Event', backref='hot_history') + + +class Post(db.Model): + """帖子模型""" + id = db.Column(db.Integer, primary_key=True) + event_id = db.Column(db.Integer, db.ForeignKey('event.id'), nullable=False) + user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) + + # 内容 + title = db.Column(db.String(200)) # 标题(可选) + content = db.Column(db.Text, nullable=False) # 内容 + content_type = db.Column(db.String(20), default='text') # 内容类型:text/rich_text/link + + # 时间 + created_at = db.Column(db.DateTime, default=beijing_now) + updated_at = db.Column(db.DateTime, default=beijing_now, onupdate=beijing_now) + + # 统计 + likes_count = db.Column(db.Integer, default=0) + comments_count = db.Column(db.Integer, default=0) + view_count = db.Column(db.Integer, default=0) + + # 状态 + status = db.Column(db.String(20), default='active') # active/hidden/deleted + is_top = db.Column(db.Boolean, default=False) # 是否置顶 + + # 关系 + user = db.relationship('User', backref='posts') + likes = db.relationship('PostLike', backref='post', lazy='dynamic') + comments = db.relationship('Comment', backref='post', lazy='dynamic') + + +# 辅助模型 +class EventFollow(db.Model): + """事件关注""" + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) + event_id = db.Column(db.Integer, db.ForeignKey('event.id'), nullable=False) + created_at = db.Column(db.DateTime, default=beijing_now) + + user = db.relationship('User', backref='event_follows') + + __table_args__ = (db.UniqueConstraint('user_id', 'event_id'),) + + +class PostLike(db.Model): + """帖子点赞""" + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) + post_id = db.Column(db.Integer, db.ForeignKey('post.id'), nullable=False) + created_at = db.Column(db.DateTime, default=beijing_now) + + user = db.relationship('User', backref='post_likes') + + __table_args__ = (db.UniqueConstraint('user_id', 'post_id'),) + + +class Comment(db.Model): + """评论""" + id = db.Column(db.Integer, primary_key=True) + post_id = db.Column(db.Integer, db.ForeignKey('post.id'), nullable=False) + user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) + content = db.Column(db.Text, nullable=False) + parent_id = db.Column(db.Integer, db.ForeignKey('comment.id')) # 父评论ID,用于回复 + created_at = db.Column(db.DateTime, default=beijing_now) + status = db.Column(db.String(20), default='active') + + user = db.relationship('User', backref='comments') + replies = db.relationship('Comment', backref=db.backref('parent', remote_side=[id])) + + +class StockBasicInfo(db.Model): + __tablename__ = 'ea_stocklist' + + SECCODE = db.Column(db.String(10), primary_key=True) + SECNAME = db.Column(db.String(40)) + ORGNAME = db.Column(db.String(100)) + F001V = db.Column(db.String(100)) # Pinyin abbreviation + F003V = db.Column(db.String(50)) # Security category + F005V = db.Column(db.String(50)) # Trading market + F006D = db.Column(db.DateTime) # Listing date + F011V = db.Column(db.String(50)) # Listing status + + +class CompanyInfo(db.Model): + __tablename__ = 'ea_baseinfo' + + SECCODE = db.Column(db.String(10), primary_key=True) + SECNAME = db.Column(db.String(40)) + ORGNAME = db.Column(db.String(100)) + F001V = db.Column(db.String(100)) # English name + F003V = db.Column(db.String(40)) # Legal representative + F015V = db.Column(db.String(500)) # Main business + F016V = db.Column(db.String(4000)) # Business scope + F017V = db.Column(db.String(2000)) # Company introduction + F030V = db.Column(db.String(60)) # CSRC industry first level + F032V = db.Column(db.String(60)) # CSRC industry second level + + +class TradeData(db.Model): + __tablename__ = 'ea_trade' + + SECCODE = db.Column(db.String(10), primary_key=True) + SECNAME = db.Column(db.String(40)) + TRADEDATE = db.Column(db.Date, primary_key=True) + F002N = db.Column(db.Numeric(18, 4)) # Previous close + F003N = db.Column(db.Numeric(18, 4)) # Open price + F004N = db.Column(db.Numeric(18, 4)) # Trading volume + F005N = db.Column(db.Numeric(18, 4)) # High price + F006N = db.Column(db.Numeric(18, 4)) # Low price + F007N = db.Column(db.Numeric(18, 4)) # Close price + F009N = db.Column(db.Numeric(18, 4)) # Change + F010N = db.Column(db.Numeric(18, 4)) # Change percentage + F011N = db.Column(db.Numeric(18, 4)) # Trading amount + + +class SectorInfo(db.Model): + __tablename__ = 'ea_sector' + + SECCODE = db.Column(db.String(10), primary_key=True) + SECNAME = db.Column(db.String(40)) + F001V = db.Column(db.String(50), primary_key=True) # Classification standard code + F002V = db.Column(db.String(50)) # Classification standard + F003V = db.Column(db.String(50)) # Sector code + F004V = db.Column(db.String(50)) # Sector level 1 name + 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') + stored_phone = session.get('sms_verification_phone') + expiration = session.get('sms_verification_expiration') + + if not all([stored_code, stored_phone, expiration]): + return False, "请先获取验证码" + + if stored_phone != phone_number: + return False, "手机号与验证码不匹配" + + if beijing_now().timestamp() > expiration: + return False, "验证码已过期" + + if code != stored_code: + return False, "验证码错误" + + return True, "验证成功" + + +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 +def update_profile(): + """更新个人资料""" + try: + user = request.user + form = request.form + + # 基本信息更新 + user.nickname = form.get('nickname') + user.bio = form.get('bio') + user.gender = form.get('gender') + user.birth_date = datetime.strptime(form.get('birth_date'), '%Y-%m-%d') if form.get('birth_date') else None + user.phone = form.get('phone') + user.location = form.get('location') + user.wechat_id = form.get('wechat_id') + + # 处理头像上传 + if 'avatar' in request.files: + file = request.files['avatar'] + if file and allowed_file(file.filename): + # 生成安全的文件名 + filename = secure_filename(f"{user.id}_{int(datetime.now().timestamp())}_{file.filename}") + filepath = os.path.join(app.config['UPLOAD_FOLDER'], filename) + + # 确保上传目录存在 + os.makedirs(os.path.dirname(filepath), exist_ok=True) + + # 保存并处理图片 + image = Image.open(file) + image.thumbnail((300, 300)) # 调整图片大小 + image.save(filepath) + + # 更新用户头像URL + user.avatar_url = f'{DOMAIN}/static/uploads/avatars/{filename}' + + db.session.commit() + return jsonify({'success': True, 'message': '个人资料已更新'}) + + except Exception as e: + db.session.rollback() + app.logger.error(f"Error updating profile: {str(e)}") + return jsonify({'success': False, 'message': '更新失败,请重试'}) + + +# 投资偏好设置 +@app.route('/settings/investment_preferences', methods=['POST']) +@token_required +def update_investment_preferences(): + """更新投资偏好""" + try: + user = request.user + form = request.form + + user.trading_experience = form.get('trading_experience') + user.investment_style = form.get('investment_style') + user.risk_preference = form.get('risk_preference') + user.investment_amount = form.get('investment_amount') + user.preferred_markets = json.dumps(request.form.getlist('preferred_markets')) + + db.session.commit() + return jsonify({'success': True, 'message': '投资偏好已更新'}) + + except Exception as e: + db.session.rollback() + app.logger.error(f"Error updating investment preferences: {str(e)}") + return jsonify({'success': False, 'message': '更新失败,请重试'}) + + +def get_clickhouse_client(): + return Cclient( + host='222.128.1.157', + port=18000, + user='default', + password='Zzl33818!', + database='stock' + ) + + +@app.route('/api/stock//kline') +def get_stock_kline(stock_code): + """获取股票K线数据 - 仅限 Pro/Max 会员(小程序功能)""" + chart_type = request.args.get('chart_type', 'daily') # 默认改为daily + event_time = request.args.get('event_time') + + try: + event_datetime = datetime.fromisoformat(event_time) if event_time else datetime.now() + except ValueError: + return jsonify({'error': 'Invalid event_time format'}), 400 + + # 获取股票名称 + try: + with engine.connect() as conn: + result = conn.execute(text( + "SELECT SECNAME FROM ea_stocklist WHERE SECCODE = :code" + ), {"code": stock_code.split('.')[0]}).fetchone() + stock_name = result[0] if result else 'Unknown' + except Exception as e: + print(f"Error getting stock name: {e}") + stock_name = 'Unknown' + + if chart_type == 'daily': + return get_daily_kline(stock_code, event_datetime, stock_name) + elif chart_type == 'minute': + return get_minute_kline(stock_code, event_datetime, stock_name) + else: + return jsonify({ + 'error': 'Invalid chart type', + 'message': 'Supported types: daily, minute', + 'code': stock_code, + 'name': stock_name + }), 400 + + +def get_daily_kline(stock_code, event_datetime, stock_name): + """处理日K线数据""" + stock_code = stock_code.split('.')[0] + + print(f"Debug: stock_code={stock_code}, event_datetime={event_datetime}, stock_name={stock_name}") + + try: + 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, + 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 \ + """ + + result = conn.execute(text(kline_sql), { + "stock_code": stock_code, + "trade_date": event_datetime.date() + }).fetchall() + + print(f"Debug: Query result count: {len(result)}") + + if not result: + print("Debug: No data found, trying fallback query...") + # 如果没有数据,尝试获取最近的交易数据 + fallback_sql = """ + 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 \ + """ + + result = conn.execute(text(fallback_sql), { + "stock_code": stock_code, + "trade_date": event_datetime.date() + }).fetchall() + + print(f"Debug: Fallback query result count: {len(result)}") + + if not result: + return jsonify({ + 'error': 'No data available', + 'code': stock_code, + 'name': stock_name, + 'data': [], + 'trade_date': event_datetime.date().strftime('%Y-%m-%d'), + 'type': 'daily' + }) + + kline_data = [] + for row in result: + try: + kline_data.append({ + 'time': row.TRADEDATE.strftime('%Y-%m-%d'), + 'open': float(row.open) if row.open else 0, + 'high': float(row.high) if row.high else 0, + 'low': float(row.low) if row.low else 0, + 'close': float(row.close) if row.close else 0, + 'volume': float(row.volume) if row.volume else 0 + }) + except (ValueError, TypeError) as e: + print(f"Debug: Error processing row: {e}") + continue + + print(f"Debug: Final kline_data count: {len(kline_data)}") + + return jsonify({ + 'code': stock_code, + 'name': stock_name, + 'data': kline_data, + 'trade_date': event_datetime.date().strftime('%Y-%m-%d'), + 'type': 'daily', + 'is_history': True, + 'data_count': len(kline_data) + }) + + except Exception as e: + print(f"Error in get_daily_kline: {e}") + return jsonify({ + 'error': f'Database error: {str(e)}', + 'code': stock_code, + 'name': stock_name, + 'data': [], + 'trade_date': event_datetime.date().strftime('%Y-%m-%d'), + 'type': 'daily' + }), 500 + + +def get_minute_kline(stock_code, event_datetime, stock_name): + """处理分钟K线数据 - 包含零轴(昨日收盘价)""" + client = get_clickhouse_client() + stock_code_short = stock_code.split('.')[0] # 获取不带后缀的股票代码 + + def get_trading_days(): + trading_days = set() + with open('tdays.csv', 'r') as f: + reader = csv.DictReader(f) + for row in reader: + trading_days.add(datetime.strptime(row['DateTime'], '%Y/%m/%d').date()) + return trading_days + + trading_days = get_trading_days() + + def find_next_trading_day(current_date): + """找到下一个交易日""" + while current_date <= max(trading_days): + current_date += timedelta(days=1) + if current_date in trading_days: + return current_date + return None + + def find_prev_trading_day(current_date): + """找到前一个交易日""" + while current_date >= min(trading_days): + current_date -= timedelta(days=1) + if current_date in trading_days: + return current_date + return None + + def get_prev_close(stock_code_short, target_date): + """获取前一交易日的收盘价作为零轴基准""" + prev_date = find_prev_trading_day(target_date) + if not prev_date: + return None + + try: + 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 \ + """ + result = conn.execute(text(sql), { + "stock_code": stock_code_short, + "prev_date": prev_date + }).fetchone() + + if result: + return float(result.close) + 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 \ + """ + result = conn.execute(text(fallback_sql), { + "stock_code": stock_code_short, + "target_date": target_date + }).fetchone() + + if result: + print(f"Using close price from {result.TRADEDATE} as zero axis") + return float(result.close) + + except Exception as e: + print(f"Error getting previous close: {e}") + + return None + + target_date = event_datetime.date() + is_after_market = event_datetime.time() > dt_time(15, 0) + + # 核心逻辑:先判断当前日期是否是交易日,以及是否已收盘 + if target_date in trading_days and is_after_market: + # 如果是交易日且已收盘,查找下一个交易日 + next_trade_date = find_next_trading_day(target_date) + if next_trade_date: + target_date = next_trade_date + elif target_date not in trading_days: + # 如果不是交易日,先尝试找下一个交易日 + next_trade_date = find_next_trading_day(target_date) + if next_trade_date: + target_date = next_trade_date + else: + # 如果找不到下一个交易日,找最近的历史交易日 + target_date = find_prev_trading_day(target_date) + + if not target_date: + return jsonify({ + 'error': 'No data available', + 'code': stock_code, + 'name': stock_name, + 'data': [], + 'trade_date': event_datetime.date().strftime('%Y-%m-%d'), + 'type': 'minute' + }) + + # 获取前一交易日收盘价作为零轴 + zero_axis = get_prev_close(stock_code_short, target_date) + + # 获取目标日期的完整交易时段数据 + 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)) + }) + + kline_data = [] + for row in data: + point = { + 'time': row[0].strftime('%H:%M'), + 'open': float(row[1]), + 'high': float(row[2]), + 'low': float(row[3]), + 'close': float(row[4]), + 'volume': float(row[5]), + 'amount': float(row[6]) + } + + # 如果有零轴数据,计算涨跌幅和涨跌额 + if zero_axis: + point['prev_close'] = zero_axis + point['change'] = point['close'] - zero_axis # 涨跌额 + point['change_pct'] = ((point['close'] - zero_axis) / zero_axis * 100) if zero_axis != 0 else 0 # 涨跌幅百分比 + + kline_data.append(point) + + response_data = { + 'code': stock_code, + 'name': stock_name, + 'data': kline_data, + 'trade_date': target_date.strftime('%Y-%m-%d'), + 'type': 'minute', + 'is_history': target_date < event_datetime.date() + } + + # 添加零轴信息到响应中 + if zero_axis: + response_data['zero_axis'] = zero_axis + response_data['prev_close'] = zero_axis + + # 计算当日整体涨跌幅(如果有数据) + if kline_data: + last_close = kline_data[-1]['close'] + response_data['day_change'] = last_close - zero_axis + response_data['day_change_pct'] = ((last_close - zero_axis) / zero_axis * 100) if zero_axis != 0 else 0 + + return jsonify(response_data) + + +class HistoricalEvent(db.Model): + """历史事件模型""" + id = db.Column(db.Integer, primary_key=True) + event_id = db.Column(db.Integer, db.ForeignKey('event.id')) + title = db.Column(db.String(200)) + content = db.Column(db.Text) + event_date = db.Column(db.DateTime) + relevance = db.Column(db.Integer) # 相关性 + importance = db.Column(db.Integer) # 重要程度 + related_stock = db.Column(db.JSON) # 保留JSON字段 + created_at = db.Column(db.DateTime, default=beijing_now) + + # 新增关系 + stocks = db.relationship('HistoricalEventStock', backref='historical_event', lazy='dynamic', + cascade='all, delete-orphan') + + +class HistoricalEventStock(db.Model): + """历史事件相关股票模型""" + __tablename__ = 'historical_event_stocks' + + id = db.Column(db.Integer, primary_key=True) + historical_event_id = db.Column(db.Integer, db.ForeignKey('historical_event.id'), nullable=False) + stock_code = db.Column(db.String(20), nullable=False) + stock_name = db.Column(db.String(50)) + relation_desc = db.Column(db.Text) + correlation = db.Column(db.Float, default=0.5) + sector = db.Column(db.String(100)) + created_at = db.Column(db.DateTime, default=beijing_now) + + __table_args__ = ( + db.UniqueConstraint('historical_event_id', 'stock_code', name='unique_event_stock'), + ) + + +@app.route('/event/follow/', methods=['POST']) +@token_required +def follow_event(event_id): + """关注/取消关注事件""" + event = Event.query.get_or_404(event_id) + follow = EventFollow.query.filter_by( + user_id=request.user.id, + event_id=event_id + ).first() + + try: + if follow: + db.session.delete(follow) + event.follower_count -= 1 + message = '已取消关注' + else: + follow = EventFollow(user_id=request.user.id, event_id=event_id) + db.session.add(follow) + event.follower_count += 1 + message = '已关注' + + db.session.commit() + return jsonify({'success': True, 'message': message}) + + except Exception as e: + db.session.rollback() + return jsonify({'success': False, 'message': '操作失败,请重试'}) + + +# 帖子相关路由 +@app.route('/post/create/', methods=['GET', 'POST']) +@token_required +def create_post(event_id): + """创建新帖子""" + event = Event.query.get_or_404(event_id) + + if request.method == 'POST': + try: + post = Post( + event_id=event_id, + user_id=request.user.id, + title=request.form.get('title'), + content=request.form['content'], + content_type=request.form.get('content_type', 'text') + ) + + db.session.add(post) + event.post_count += 1 + db.session.commit() + + # 检查是否是 API 请求(通过 Accept header 或 Content-Type 判断) + if request.headers.get('Accept') == 'application/json' or \ + request.headers.get('Content-Type', '').startswith('application/json'): + return jsonify({ + 'success': True, + 'message': '发布成功', + 'data': { + 'post_id': post.id, + 'event_id': event_id, + 'redirect_url': url_for('event_detail', event_id=event_id) + } + }) + else: + # 传统表单提交,添加成功消息并重定向 + flash('发布成功', 'success') + return redirect(url_for('event_detail', event_id=event_id)) + + except Exception as e: + db.session.rollback() + if request.headers.get('Accept') == 'application/json' or \ + request.headers.get('Content-Type', '').startswith('application/json'): + return jsonify({ + 'success': False, + 'message': '发布失败,请重试' + }), 400 + else: + flash('发布失败,请重试', 'error') + app.logger.error(f"Error creating post: {str(e)}") + + return render_template('projects/create_post.html', event=event) + + +# 点赞相关路由 +@app.route('/post/like/', methods=['POST']) +@token_required +def like_post(post_id): + """点赞/取消点赞帖子""" + post = Post.query.get_or_404(post_id) + like = PostLike.query.filter_by( + user_id=request.user.id, + post_id=post_id + ).first() + + try: + if like: + # 取消点赞 + db.session.delete(like) + post.likes_count -= 1 + message = '已取消点赞' + else: + # 添加点赞 + like = PostLike(user_id=request.user.id, post_id=post_id) + db.session.add(like) + post.likes_count += 1 + message = '已点赞' + + db.session.commit() + return jsonify({ + 'success': True, + 'message': message, + 'likes_count': post.likes_count + }) + + except Exception as e: + db.session.rollback() + return jsonify({'success': False, 'message': '操作失败,请重试'}) + + +def update_user_activity(): + """更新用户活跃度""" + with app.app_context(): + try: + # 获取过去7天内的用户活动数据 + seven_days_ago = beijing_now() - timedelta(days=7) + + # 统计用户发帖、评论、点赞等活动 + active_users = db.session.query( + User.id, + db.func.count(Post.id).label('post_count'), + db.func.count(Comment.id).label('comment_count'), + db.func.count(PostLike.id).label('like_count') + ).outerjoin(Post, User.id == Post.user_id) \ + .outerjoin(Comment, User.id == Comment.user_id) \ + .outerjoin(PostLike, User.id == PostLike.user_id) \ + .filter( + db.or_( + Post.created_at >= seven_days_ago, + Comment.created_at >= seven_days_ago, + PostLike.created_at >= seven_days_ago + ) + ).group_by(User.id).all() + + # 更新用户活跃度分数 + for user_id, post_count, comment_count, like_count in active_users: + activity_score = post_count * 2 + comment_count * 1 + like_count * 0.5 + User.query.filter_by(id=user_id).update({ + 'activity_score': activity_score, + 'last_active': beijing_now() + }) + + db.session.commit() + current_app.logger.info("Successfully updated user activity scores") + + except Exception as e: + db.session.rollback() + current_app.logger.error(f"Error updating user activity: {str(e)}") + + +@app.route('/post/comment/', methods=['POST']) +@token_required +def add_comment(post_id): + """添加评论""" + post = Post.query.get_or_404(post_id) + + try: + content = request.form.get('content') + parent_id = request.form.get('parent_id', type=int) + + if not content: + return jsonify({'success': False, 'message': '评论内容不能为空'}) + + comment = Comment( + post_id=post_id, + user_id=request.user.id, + content=content, + parent_id=parent_id + ) + + db.session.add(comment) + post.comments_count += 1 + + db.session.commit() + + return jsonify({ + 'success': True, + 'message': '评论成功', + 'comment': { + 'id': comment.id, + 'content': comment.content, + 'user_name': request.user.username, + 'user_avatar': get_full_avatar_url(request.user.avatar_url), # 修改这里 + 'created_at': comment.created_at.strftime('%Y-%m-%d %H:%M:%S') + } + }) + + except Exception as e: + db.session.rollback() + return jsonify({'success': False, 'message': '评论失败,请重试'}) + + +@app.route('/post/comments/') +def get_comments(post_id): + """获取帖子评论列表""" + page = request.args.get('page', 1, type=int) + + # 获取顶层评论 + comments = Comment.query.filter_by( + post_id=post_id, + parent_id=None, + status='active' + ).order_by( + Comment.created_at.desc() + ).paginate(page=page, per_page=20) + + # 同时获取每个顶层评论的部分回复 + comments_data = [] + for comment in comments.items: + replies = Comment.query.filter_by( + parent_id=comment.id, + status='active' + ).order_by( + Comment.created_at.asc() + ).limit(3).all() + + comments_data.append({ + 'id': comment.id, + 'content': comment.content, + 'user': { + 'id': comment.user.id, + 'username': comment.user.username, + 'avatar_url': get_full_avatar_url(comment.user.avatar_url), # 修改这里 + }, + 'created_at': comment.created_at.strftime('%Y-%m-%d %H:%M:%S'), + 'replies': [{ + 'id': reply.id, + 'content': reply.content, + 'user': { + 'id': reply.user.id, + 'username': reply.user.username, + 'avatar_url': get_full_avatar_url(reply.user.avatar_url), # 修改这里 + }, + 'created_at': reply.created_at.strftime('%Y-%m-%d %H:%M:%S') + } for reply in replies] + }) + + return jsonify({ + 'comments': comments_data, + 'total': comments.total, + 'pages': comments.pages, + 'current_page': comments.page + }) + + +beijing_tz = pytz.timezone('Asia/Shanghai') + + +def update_hot_scores(): + """ + 更新所有事件的热度分数 + 在Flask应用上下文中执行数据库操作 + """ + with app.app_context(): + try: + # 获取所有活跃事件 + events = Event.query.filter_by(status='active').all() + current_time = beijing_now() + + for event in events: + # 确保created_at有时区信息,解决naive和aware datetime比较问题 + created_at = beijing_tz.localize( + event.created_at) if event.created_at.tzinfo is None else event.created_at + + # 使用处理后的created_at计算hours_passed + hours_passed = (current_time - created_at).total_seconds() / 3600 + + # 基础分数 - 帖子数和评论数 + posts = Post.query.filter_by(event_id=event.id).all() + post_count = len(posts) + comment_count = sum(post.comments_count for post in posts) + + # 获取24小时内的新增帖子数 + recent_posts = Post.query.filter( + Post.event_id == event.id, + Post.created_at >= current_time - timedelta(hours=24) + ).count() + + # 获取点赞数 + like_count = db.session.query(func.sum(Post.likes_count)).filter( + Post.event_id == event.id + ).scalar() or 0 + + # 基础互动分数 = 帖子数 * 2 + 评论数 * 1 + 点赞数 * 0.5 + interaction_score = (post_count * 2) + (comment_count * 1) + (like_count * 0.5) + + # 关注度分数 = 关注人数 * 3 + follow_score = event.follower_count * 3 + + # 浏览量分数 = log(浏览量) + if event.view_count > 0: + view_score = math.log(event.view_count) * 2 + else: + view_score = 0 + + # 时间衰减因子 - 使用上面已经计算好的hours_passed + time_decay = math.exp(-hours_passed / 72) # 3天后衰减为原始分数的1/e + + # 最近活跃度权重 + recent_activity_weight = (recent_posts * 5) # 24小时内的新帖权重高 + + # 总分 = (互动分数 + 关注度分数 + 浏览量分数 + 最近活跃度) * 时间衰减 + total_score = (interaction_score + follow_score + view_score + recent_activity_weight) * time_decay + + # 更新热度分数 + event.hot_score = round(total_score, 2) + + # 分数的对数值作为事件的trending_score (用于趋势排序) + if total_score > 0: + event.trending_score = math.log(total_score) * time_decay + else: + event.trending_score = 0 + + # 记录热度历史 + history = EventHotHistory( + event_id=event.id, + score=event.hot_score, + interaction_score=interaction_score, + follow_score=follow_score, + view_score=view_score, + recent_activity_score=recent_activity_weight, + time_decay=time_decay + ) + db.session.add(history) + + db.session.commit() + app.logger.info("Successfully updated event hot scores") + + except Exception as e: + db.session.rollback() + app.logger.error(f"Error updating hot scores: {str(e)}") + raise + + +# 添加热度历史记录模型 + + +def calculate_hot_score(event): + """计算事件热度分数""" + current_time = beijing_now() + time_diff = (current_time - event.created_at).total_seconds() / 3600 # 转换为小时 + + # 基础分数 = 浏览量 * 0.1 + 帖子数 * 0.5 + 关注数 * 1 + base_score = ( + event.view_count * 0.1 + + event.post_count * 0.5 + + event.follower_count * 1 + ) + + # 时间衰减因子,72小时(3天)内的事件获得较高权重 + time_factor = max(1 - (time_diff / 72), 0.1) + + return base_score * time_factor + + +@app.route('/api/sector/hierarchy', methods=['GET']) +def api_sector_hierarchy(): + """行业层级关系接口:展示多个行业分类体系的层级结构""" + try: + # 定义需要返回的行业分类体系 + classification_systems = [ + '申银万国行业分类' + ] + + result = [] # 改为数组 + + for classification in classification_systems: + # 查询特定分类标准的数据 + sectors = SectorInfo.query.filter_by(F002V=classification).all() + + if not sectors: + continue + + # 构建该分类体系的层级结构 + hierarchy = {} + + for sector in sectors: + level1 = sector.F004V # 一级行业 + level2 = sector.F005V # 二级行业 + level3 = sector.F006V # 三级行业 + level4 = sector.F007V # 四级行业 + + # 统计股票数量 + stock_code = sector.SECCODE + + # 初始化一级行业 + if level1 not in hierarchy: + hierarchy[level1] = { + 'level2_sectors': {}, + 'stocks': set(), + 'stocks_count': 0 + } + + # 添加股票到一级行业 + if stock_code: + hierarchy[level1]['stocks'].add(stock_code) + + # 处理二级行业 + if level2: + if level2 not in hierarchy[level1]['level2_sectors']: + hierarchy[level1]['level2_sectors'][level2] = { + 'level3_sectors': {}, + 'stocks': set(), + 'stocks_count': 0 + } + + # 添加股票到二级行业 + if stock_code: + hierarchy[level1]['level2_sectors'][level2]['stocks'].add(stock_code) + + # 处理三级行业 + if level3: + if level3 not in hierarchy[level1]['level2_sectors'][level2]['level3_sectors']: + hierarchy[level1]['level2_sectors'][level2]['level3_sectors'][level3] = { + 'level4_sectors': [], + 'stocks': set(), + 'stocks_count': 0 + } + + # 添加股票到三级行业 + if stock_code: + hierarchy[level1]['level2_sectors'][level2]['level3_sectors'][level3]['stocks'].add( + stock_code) + + # 处理四级行业 + if level4 and level4 not in \ + hierarchy[level1]['level2_sectors'][level2]['level3_sectors'][level3]['level4_sectors']: + hierarchy[level1]['level2_sectors'][level2]['level3_sectors'][level3][ + 'level4_sectors'].append(level4) + + # 计算股票数量并清理set对象 + formatted_hierarchy = [] + for level1, level1_data in hierarchy.items(): + level1_item = { + 'level1_sector': level1, + 'stocks_count': len(level1_data['stocks']), + 'level2_sectors': [] + } + + for level2, level2_data in level1_data['level2_sectors'].items(): + level2_item = { + 'level2_sector': level2, + 'stocks_count': len(level2_data['stocks']), + 'level3_sectors': [] + } + + for level3, level3_data in level2_data['level3_sectors'].items(): + level3_item = { + 'level3_sector': level3, + 'stocks_count': len(level3_data['stocks']), + 'level4_sectors': level3_data['level4_sectors'] + } + level2_item['level3_sectors'].append(level3_item) + + # 按股票数量排序 + level2_item['level3_sectors'].sort(key=lambda x: x['stocks_count'], reverse=True) + level1_item['level2_sectors'].append(level2_item) + + # 按股票数量排序 + level1_item['level2_sectors'].sort(key=lambda x: x['stocks_count'], reverse=True) + formatted_hierarchy.append(level1_item) + + # 按股票数量排序 + formatted_hierarchy.sort(key=lambda x: x['stocks_count'], reverse=True) + + # 将该分类体系添加到结果数组中 + result.append({ + 'classification_name': classification, + 'total_level1_count': len(formatted_hierarchy), + 'total_stocks_count': sum(item['stocks_count'] for item in formatted_hierarchy), + 'hierarchy': formatted_hierarchy + }) + + # 按总股票数量排序 + result.sort(key=lambda x: x['total_stocks_count'], reverse=True) + + return jsonify({ + "code": 200, + "message": "success", + "data": result + }) + + except Exception as e: + return jsonify({ + "code": 500, + "message": str(e), + "data": None + }), 500 + + +@app.route('/api/sector/banner', methods=['GET']) +def api_sector_banner(): + """行业分类 banner 接口:返回一级分类和对应二级行业列表""" + try: + # 原始映射 + sector_map = { + '石油石化': '大周期', '煤炭': '大周期', '有色金属': '大周期', '钢铁': '大周期', '基础化工': '大周期', + '建筑材料': '大周期', '机械设备': '大周期', '电力设备及新能源': '大周期', '国防军工': '大周期', + '电力设备': '大周期', '电网设备': '大周期', '风力发电': '大周期', '太阳能发电': '大周期', + '建筑装饰': '大周期', + + '汽车': '大消费', '家用电器': '大消费', '酒类': '大消费', '食品饮料': '大消费', '医药生物': '大消费', + '纺织服饰': '大消费', '农林牧渔': '大消费', '商贸零售': '大消费', '轻工制造': '大消费', + '消费者服务': '大消费', '美容护理': '大消费', '社会服务': '大消费', + + '银行': '大金融地产', '证券': '大金融地产', '保险': '大金融地产', '多元金融': '大金融地产', + '综合金融': '大金融地产', '房地产': '大金融地产', '非银金融': '大金融地产', + + '计算机': 'TMT板块', '电子': 'TMT板块', '传媒': 'TMT板块', '通信': 'TMT板块', + + '交通运输': '公共产业板块', '电力公用事业': '公共产业板块', '建筑': '公共产业板块', + '环保': '公共产业板块', '综合': '公共产业板块', '公用事业': '公共产业板块' + } + + # 重组结构为 一级 → [二级...] + result_dict = {} + for sub_sector, primary_sector in sector_map.items(): + result_dict.setdefault(primary_sector, []).append(sub_sector) + + # 格式化成列表 + result_list = [ + {"primary_sector": primary, "sub_sectors": subs} + for primary, subs in result_dict.items() + ] + + return jsonify({ + "code": 200, + "message": "success", + "data": result_list + }) + + except Exception as e: + return jsonify({ + "code": 500, + "message": str(e), + "data": None + }), 500 + + +def get_limit_rate(stock_code): + """ + 根据股票代码获取涨跌停限制比例 + + Args: + stock_code: 股票代码 + + Returns: + float: 涨跌停限制比例 + """ + if not stock_code: + return 10.0 + + # 去掉市场后缀 + clean_code = stock_code.replace('.SH', '').replace('.SZ', '').replace('.BJ', '') + + # ST股票 (5%涨跌停) + if 'ST' in stock_code.upper(): + return 5.0 + + # 科创板 (688开头, 20%涨跌停) + if clean_code.startswith('688'): + return 20.0 + + # 创业板注册制 (30开头, 20%涨跌停) + if clean_code.startswith('30'): + return 20.0 + + # 北交所 (43、83、87开头, 30%涨跌停) + if clean_code.startswith(('43', '83', '87')): + return 30.0 + + # 主板、中小板默认 (10%涨跌停) + return 10.0 + + +@app.route('/api/events', methods=['GET']) +def api_get_events(): + """ + 获取事件列表API - 优化版本(保持完全兼容) + 仅限 Pro/Max 会员访问(小程序功能) + + 优化策略: + 1. 使用ind_type字段简化内部逻辑 + 2. 批量获取股票行情,包括周涨跌计算 + 3. 保持原有返回数据结构不变 + """ + try: + # ==================== 参数解析 ==================== + + # 分页参数 + page = max(1, request.args.get('page', 1, type=int)) + per_page = min(100, max(1, request.args.get('per_page', 10, type=int))) + + # 基础筛选参数 + event_type = request.args.get('type', 'all') + event_status = request.args.get('status', 'active') + importance = request.args.get('importance', 'all') + + # 日期筛选参数 + start_date = request.args.get('start_date') + end_date = request.args.get('end_date') + date_range = request.args.get('date_range') + recent_days = request.args.get('recent_days', type=int) + + # 行业筛选参数(重新设计) + ind_type = request.args.get('ind_type', 'all') + stock_sector = request.args.get('stock_sector', 'all') + secondary_sector = request.args.get('secondary_sector', 'all') + + # 新的行业层级筛选参数 + industry_level = request.args.get('industry_level', type=int) # 筛选层级:1-4 + industry_classification = request.args.get('industry_classification') # 行业名称 + + # 如果使用旧参数,映射到ind_type + if ind_type == 'all' and stock_sector != 'all': + ind_type = stock_sector + + # 标签筛选参数 + tag = request.args.get('tag') + tags = request.args.get('tags') + keywords = request.args.get('keywords') + + # 搜索参数 + search_query = request.args.get('q') + search_type = request.args.get('search_type', 'topic') + search_fields = request.args.get('search_fields', 'title,description').split(',') + + # 排序参数 + sort_by = request.args.get('sort', 'new') + return_type = request.args.get('return_type', 'avg') + order = request.args.get('order', 'desc') + + # 收益率筛选参数 + min_avg_return = request.args.get('min_avg_return', type=float) + max_avg_return = request.args.get('max_avg_return', type=float) + min_max_return = request.args.get('min_max_return', type=float) + max_max_return = request.args.get('max_max_return', type=float) + min_week_return = request.args.get('min_week_return', type=float) + max_week_return = request.args.get('max_week_return', type=float) + + # 其他筛选参数 + min_hot_score = request.args.get('min_hot_score', type=float) + max_hot_score = request.args.get('max_hot_score', type=float) + min_view_count = request.args.get('min_view_count', type=int) + creator_id = request.args.get('creator_id', type=int) + + # 返回格式参数 + include_creator = request.args.get('include_creator', 'true').lower() == 'true' + include_stats = request.args.get('include_stats', 'true').lower() == 'true' + include_related_data = request.args.get('include_related_data', 'false').lower() == 'true' + + # ==================== 构建查询 ==================== + + query = Event.query + + # 状态筛选 + if event_status != 'all': + query = query.filter_by(status=event_status) + + # 事件类型筛选 + if event_type != 'all': + query = query.filter_by(event_type=event_type) + + # 重要性筛选 + if importance != 'all': + query = query.filter_by(importance=importance) + + # 行业类型筛选(使用ind_type字段) + if ind_type != 'all': + query = query.filter_by(ind_type=ind_type) + + # 创建者筛选 + if creator_id: + query = query.filter_by(creator_id=creator_id) + + # ==================== 日期筛选 ==================== + + if recent_days: + cutoff_date = datetime.now() - timedelta(days=recent_days) + query = query.filter(Event.created_at >= cutoff_date) + else: + # 处理日期范围字符串 + if date_range and ' 至 ' in date_range: + try: + start_date_str, end_date_str = date_range.split(' 至 ') + start_date = start_date_str.strip() + end_date = end_date_str.strip() + except ValueError: + pass + + # 开始日期 + if start_date: + try: + if len(start_date) == 10: + start_datetime = datetime.strptime(start_date, '%Y-%m-%d') + else: + start_datetime = datetime.strptime(start_date, '%Y-%m-%d %H:%M:%S') + query = query.filter(Event.created_at >= start_datetime) + except ValueError: + pass + + # 结束日期 + if end_date: + try: + if len(end_date) == 10: + end_datetime = datetime.strptime(end_date, '%Y-%m-%d') + end_datetime = end_datetime.replace(hour=23, minute=59, second=59) + else: + end_datetime = datetime.strptime(end_date, '%Y-%m-%d %H:%M:%S') + query = query.filter(Event.created_at <= end_datetime) + except ValueError: + pass + + # ==================== 行业层级筛选(申银万国行业分类) ==================== + + if industry_level and industry_classification: + # 排除行业分类体系名称本身,这些不是具体的行业 + classification_systems = [ + '申银万国行业分类', '中上协行业分类', '巨潮行业分类', + '新财富行业分类', '证监会行业分类', '证监会行业分类(2001)' + ] + + if industry_classification not in classification_systems: + # 使用内存缓存获取行业代码前缀(性能优化:避免每次请求都查询数据库) + # 前端发送的level值: + # level=2 -> 一级行业 + # level=3 -> 二级行业 + # level=4 -> 三级行业 + # level=5 -> 四级行业 + + if industry_level in SYWG_INDUSTRY_CACHE: + # 直接从缓存中获取代码前缀列表 + code_prefixes = SYWG_INDUSTRY_CACHE[industry_level].get(industry_classification, []) + + 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 pattern_conditions: + query = query.filter(or_(*pattern_conditions)) + else: + # 没有找到匹配的行业代码,返回空结果 + query = query.filter(Event.id == -1) + else: + # 无效的层级参数 + app.logger.warning(f"Invalid industry_level: {industry_level}") + else: + # industry_classification 是分类体系名称,不进行筛选 + app.logger.info( + f"Skipping filter: industry_classification '{industry_classification}' is a classification system name") + + # ==================== 细分行业筛选(保留向后兼容) ==================== + + elif secondary_sector != 'all': + # 直接按行业名称查询(最后一级行业 - level5/f007v) + sector_code_query = db.session.query(text("DISTINCT f003v")).select_from( + text("ea_sector") + ).filter( + text("f002v = '申银万国行业分类' AND f007v = :sector_name") + ).params(sector_name=secondary_sector) + + sector_result = sector_code_query.first() + + if sector_result and sector_result[0]: + industry_code_to_search = sector_result[0] + + # 在related_industries JSON中查找包含该代码的事件 + if isinstance(db.engine.dialect, MySQLDialect): + query = query.filter( + text(""" + JSON_SEARCH( + related_industries, + 'one', + :industry_code, + NULL, + '$[*]."申银万国行业分类"' + ) IS NOT NULL + """) + ).params(industry_code=industry_code_to_search) + else: + query = query.filter( + text(""" + related_industries::text LIKE :pattern + """) + ).params(pattern=f'%"申银万国行业分类": "{industry_code_to_search}"%') + else: + # 如果没有找到对应的行业代码,返回空结果 + query = query.filter(Event.id == -1) + + # ==================== 概念/标签筛选 ==================== + + # 单个标签筛选 + if tag: + if isinstance(db.engine.dialect, MySQLDialect): + query = query.filter(text("JSON_CONTAINS(keywords, :tag, '$')")) + query = query.params(tag=json.dumps(tag)) + else: + query = query.filter(Event.keywords.cast(JSONB).contains([tag])) + + # 多个标签筛选 (AND逻辑) + if tags: + tag_list = [t.strip() for t in tags.split(',') if t.strip()] + for single_tag in tag_list: + if isinstance(db.engine.dialect, MySQLDialect): + query = query.filter(text("JSON_CONTAINS(keywords, :tag, '$')")) + query = query.params(tag=json.dumps(single_tag)) + else: + query = query.filter(Event.keywords.cast(JSONB).contains([single_tag])) + + # 关键词筛选 (OR逻辑) + if keywords: + keyword_list = [k.strip() for k in keywords.split(',') if k.strip()] + keyword_filters = [] + for keyword in keyword_list: + if isinstance(db.engine.dialect, MySQLDialect): + keyword_filters.append(text("JSON_CONTAINS(keywords, :keyword, '$')")) + else: + keyword_filters.append(Event.keywords.cast(JSONB).contains([keyword])) + if keyword_filters: + query = query.filter(or_(*keyword_filters)) + + # ==================== 搜索功能 ==================== + + if search_query: + search_terms = search_query.strip().split() + + if search_type == 'stock': + # 股票搜索 + query = query.join(RelatedStock).filter( + or_( + RelatedStock.stock_code.ilike(f'%{search_query}%'), + RelatedStock.stock_name.ilike(f'%{search_query}%') + ) + ).distinct() + elif search_type == 'all': + # 全局搜索 + search_filters = [] + + # 文本字段搜索 + for term in search_terms: + term_filters = [] + if 'title' in search_fields: + term_filters.append(Event.title.ilike(f'%{term}%')) + if 'description' in search_fields: + term_filters.append(Event.description.ilike(f'%{term}%')) + if 'keywords' in search_fields: + if isinstance(db.engine.dialect, MySQLDialect): + term_filters.append(text("JSON_CONTAINS(keywords, :term, '$')")) + else: + term_filters.append(Event.keywords.cast(JSONB).contains([term])) + + if term_filters: + search_filters.append(or_(*term_filters)) + + # 股票搜索 + stock_subquery = db.session.query(RelatedStock.event_id).filter( + or_( + RelatedStock.stock_code.ilike(f'%{search_query}%'), + RelatedStock.stock_name.ilike(f'%{search_query}%') + ) + ).subquery() + + search_filters.append(Event.id.in_(stock_subquery)) + + if search_filters: + query = query.filter(or_(*search_filters)) + else: + # 话题搜索 (默认) + for term in search_terms: + term_filters = [] + if 'title' in search_fields: + term_filters.append(Event.title.ilike(f'%{term}%')) + if 'description' in search_fields: + term_filters.append(Event.description.ilike(f'%{term}%')) + if 'keywords' in search_fields: + if isinstance(db.engine.dialect, MySQLDialect): + term_filters.append(text("JSON_CONTAINS(keywords, :term, '$')")) + else: + term_filters.append(Event.keywords.cast(JSONB).contains([term])) + + if term_filters: + query = query.filter(or_(*term_filters)) + + # ==================== 收益率筛选 ==================== + + if min_avg_return is not None: + query = query.filter(Event.related_avg_chg >= min_avg_return) + if max_avg_return is not None: + query = query.filter(Event.related_avg_chg <= max_avg_return) + + if min_max_return is not None: + query = query.filter(Event.related_max_chg >= min_max_return) + if max_max_return is not None: + query = query.filter(Event.related_max_chg <= max_max_return) + + if min_week_return is not None: + query = query.filter(Event.related_week_chg >= min_week_return) + if max_week_return is not None: + query = query.filter(Event.related_week_chg <= max_week_return) + + # ==================== 其他数值筛选 ==================== + + if min_hot_score is not None: + query = query.filter(Event.hot_score >= min_hot_score) + if max_hot_score is not None: + query = query.filter(Event.hot_score <= max_hot_score) + + if min_view_count is not None: + query = query.filter(Event.view_count >= min_view_count) + + # ==================== 排序逻辑 ==================== + + order_func = desc if order.lower() == 'desc' else asc + + if sort_by == 'hot': + query = query.order_by(order_func(Event.hot_score)) + elif sort_by == 'new': + query = query.order_by(order_func(Event.created_at)) + elif sort_by == 'returns': + if return_type == 'avg': + query = query.order_by(order_func(Event.related_avg_chg)) + elif return_type == 'max': + query = query.order_by(order_func(Event.related_max_chg)) + elif return_type == 'week': + query = query.order_by(order_func(Event.related_week_chg)) + elif sort_by == 'importance': + importance_order = case( + (Event.importance == 'S', 1), + (Event.importance == 'A', 2), + (Event.importance == 'B', 3), + (Event.importance == 'C', 4), + else_=5 + ) + if order.lower() == 'desc': + query = query.order_by(importance_order) + else: + query = query.order_by(desc(importance_order)) + elif sort_by == 'view_count': + query = query.order_by(order_func(Event.view_count)) + elif sort_by == 'follow' and hasattr(request, 'user') and request.user.is_authenticated: + # 关注的事件排序 + query = query.join(EventFollow).filter( + EventFollow.user_id == request.user.id + ).order_by(order_func(Event.created_at)) + + # ==================== 分页查询 ==================== + + paginated = query.paginate(page=page, per_page=per_page, error_out=False) + + # ==================== 批量获取股票行情数据(优化版) ==================== + + # 1. 收集当前页所有事件的ID + event_ids = [event.id for event in paginated.items] + + # 2. 获取所有相关股票 + all_related_stocks = {} + if event_ids: + related_stocks = RelatedStock.query.filter( + RelatedStock.event_id.in_(event_ids) + ).all() + + # 按事件ID分组 + for stock in related_stocks: + if stock.event_id not in all_related_stocks: + all_related_stocks[stock.event_id] = [] + all_related_stocks[stock.event_id].append(stock) + + # 3. 收集所有股票代码 + all_stock_codes = [] + stock_code_mapping = {} # 清理后的代码 -> 原始代码的映射 + + for stocks in all_related_stocks.values(): + for stock in stocks: + clean_code = stock.stock_code.replace('.SH', '').replace('.SZ', '').replace('.BJ', '') + all_stock_codes.append(clean_code) + stock_code_mapping[clean_code] = stock.stock_code + + # 去重 + all_stock_codes = list(set(all_stock_codes)) + + # 4. 批量查询最近7个交易日的数据(用于计算日涨跌和周涨跌) + stock_price_data = {} + + if all_stock_codes: + # 构建SQL查询 - 获取最近7个交易日的数据 + codes_str = "'" + "', '".join(all_stock_codes) + "'" + + # 获取最近7个交易日的数据 + recent_trades_sql = f""" + SELECT + SECCODE, + SECNAME, + F007N as close_price, + F010N as daily_change, + TRADEDATE, + ROW_NUMBER() OVER (PARTITION BY SECCODE ORDER BY TRADEDATE DESC) as rn + FROM ea_trade + WHERE SECCODE IN ({codes_str}) + AND F007N IS NOT NULL + AND TRADEDATE >= DATE_SUB(CURDATE(), INTERVAL 10 DAY) + ORDER BY SECCODE, TRADEDATE DESC + """ + + result = db.session.execute(text(recent_trades_sql)) + + # 整理数据 + for row in result.fetchall(): + sec_code = row[0] + if sec_code not in stock_price_data: + stock_price_data[sec_code] = { + 'stock_name': row[1], + 'prices': [] + } + + stock_price_data[sec_code]['prices'].append({ + 'close_price': float(row[2]) if row[2] else 0, + 'daily_change': float(row[3]) if row[3] else 0, + 'trade_date': row[4], + 'rank': row[5] + }) + + # 5. 计算日涨跌和周涨跌 + stock_changes = {} + + for sec_code, data in stock_price_data.items(): + prices = data['prices'] + + # 最新日涨跌(第1条记录) + daily_change = 0 + if prices and prices[0]['rank'] == 1: + daily_change = prices[0]['daily_change'] + + # 计算周涨跌(最新价 vs 5个交易日前的价格) + week_change = 0 + if len(prices) >= 2: + latest_price = prices[0]['close_price'] + # 找到第5个交易日的数据(如果有) + week_ago_price = None + for price_data in prices: + if price_data['rank'] >= 5: + week_ago_price = price_data['close_price'] + break + + # 如果没有第5天的数据,使用最早的数据 + if week_ago_price is None and len(prices) > 1: + week_ago_price = prices[-1]['close_price'] + + if week_ago_price and week_ago_price > 0: + week_change = (latest_price - week_ago_price) / week_ago_price * 100 + + stock_changes[sec_code] = { + 'stock_name': data['stock_name'], + 'daily_change': daily_change, + 'week_change': week_change + } + + # ==================== 获取整体统计信息(应用所有筛选条件) ==================== + + overall_distribution = { + 'limit_down': 0, + 'down_over_5': 0, + 'down_5_to_1': 0, + 'down_within_1': 0, + 'flat': 0, + 'up_within_1': 0, + 'up_1_to_5': 0, + 'up_over_5': 0, + 'limit_up': 0 + } + + # 使用当前筛选条件的query,但不应用分页限制,获取所有符合条件的事件 + # 这样统计数据会跟随用户的筛选条件变化 + all_filtered_events = query.limit(1000).all() # 限制最多1000个事件,避免查询过慢 + 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() + + # 按事件ID分组 + 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', '') + week_stock_codes.append(clean_code) + week_code_mapping[clean_code] = stock.stock_code + + week_stock_codes = list(set(week_stock_codes)) + + # 批量查询这些股票的最新行情数据 + 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 + elif avg_change >= 10: + overall_distribution['limit_up'] += 1 + elif avg_change > 5: + overall_distribution['up_over_5'] += 1 + elif avg_change > 1: + overall_distribution['up_1_to_5'] += 1 + elif avg_change > 0.1: + overall_distribution['up_within_1'] += 1 + elif avg_change >= -0.1: + overall_distribution['flat'] += 1 + elif avg_change > -1: + overall_distribution['down_within_1'] += 1 + elif avg_change > -5: + overall_distribution['down_5_to_1'] += 1 + else: + overall_distribution['down_over_5'] += 1 + + # ==================== 构建响应数据 ==================== + + events_data = [] + for event in paginated.items: + event_stocks = all_related_stocks.get(event.id, []) + stocks_data = [] + + total_daily_change = 0 + max_daily_change = -999 + total_week_change = 0 + max_week_change = -999 + valid_stocks_count = 0 + + # 处理每个股票的数据 + for stock in event_stocks: + clean_code = stock.stock_code.replace('.SH', '').replace('.SZ', '').replace('.BJ', '') + stock_info = stock_changes.get(clean_code, {}) + + daily_change = stock_info.get('daily_change', 0) + week_change = stock_info.get('week_change', 0) + + if stock_info: + total_daily_change += daily_change + max_daily_change = max(max_daily_change, daily_change) + total_week_change += week_change + max_week_change = max(max_week_change, week_change) + valid_stocks_count += 1 + + stocks_data.append({ + "stock_code": stock.stock_code, + "stock_name": stock.stock_name, + "sector": stock.sector, + "week_change": round(week_change, 2), + "daily_change": round(daily_change, 2) + }) + + avg_daily_change = total_daily_change / valid_stocks_count if valid_stocks_count > 0 else 0 + avg_week_change = total_week_change / valid_stocks_count if valid_stocks_count > 0 else 0 + + if max_daily_change == -999: + max_daily_change = 0 + if max_week_change == -999: + max_week_change = 0 + + # 构建事件数据 + event_dict = { + 'id': event.id, + 'title': event.title, + 'description': event.description, + 'event_type': event.event_type, + 'importance': event.importance, + 'status': event.status, + 'created_at': event.created_at.isoformat() if event.created_at else None, + 'updated_at': event.updated_at.isoformat() if event.updated_at else None, + 'start_time': event.start_time.isoformat() if event.start_time else None, + 'end_time': event.end_time.isoformat() if event.end_time else None, + 'related_stocks': stocks_data, + 'stocks_stats': { + 'stocks_count': len(event_stocks), + 'valid_stocks_count': valid_stocks_count, + # 周涨跌统计 + 'avg_week_change': round(avg_week_change, 2), + 'max_week_change': round(max_week_change, 2), + # 日涨跌统计 + 'avg_daily_change': round(avg_daily_change, 2), + 'max_daily_change': round(max_daily_change, 2) + } + } + + # 统计信息 + if include_stats: + event_dict.update({ + 'hot_score': event.hot_score, + 'view_count': event.view_count, + 'post_count': event.post_count, + 'follower_count': event.follower_count, + 'related_avg_chg': event.related_avg_chg, + 'related_max_chg': event.related_max_chg, + 'related_week_chg': event.related_week_chg, + 'invest_score': event.invest_score, + 'trending_score': event.trending_score, + }) + + # 创建者信息 + if include_creator: + event_dict['creator'] = { + 'id': event.creator.id if event.creator else None, + 'username': event.creator.username if event.creator else 'Anonymous', + 'avatar_url': get_full_avatar_url(event.creator.avatar_url) if event.creator else None, + 'is_creator': event.creator.is_creator if event.creator else False, + 'creator_type': event.creator.creator_type if event.creator else None + } + + # 关联数据 + event_dict['keywords'] = event.keywords if isinstance(event.keywords, list) else [] + event_dict['related_industries'] = event.related_industries + + # 包含统计信息 + if include_stats: + event_dict['stats'] = { + 'related_stocks_count': len(event_stocks), + 'historical_events_count': 0, # 需要额外查询 + 'related_data_count': 0, # 需要额外查询 + 'related_concepts_count': 0 # 需要额外查询 + } + + # 包含关联数据 + if include_related_data: + event_dict['related_stocks'] = [{ + 'id': stock.id, + 'stock_code': stock.stock_code, + 'stock_name': stock.stock_name, + 'sector': stock.sector, + 'correlation': float(stock.correlation) if stock.correlation else 0 + } for stock in event_stocks[:5]] # 限制返回5个 + + events_data.append(event_dict) + + # ==================== 构建筛选信息 ==================== + + applied_filters = {} + + # 记录已应用的筛选条件 + if event_type != 'all': + applied_filters['type'] = event_type + if importance != 'all': + applied_filters['importance'] = importance + if start_date: + applied_filters['start_date'] = start_date + if end_date: + applied_filters['end_date'] = end_date + if industry_level and industry_classification: + applied_filters['industry_level'] = industry_level + applied_filters['industry_classification'] = industry_classification + if tag: + applied_filters['tag'] = tag + if tags: + applied_filters['tags'] = tags + if search_query: + applied_filters['search_query'] = search_query + applied_filters['search_type'] = search_type + + # ==================== 返回结果(保持完全兼容) ==================== + + return jsonify({ + 'success': True, + 'data': { + 'events': events_data, + 'pagination': { + 'page': paginated.page, + 'per_page': paginated.per_page, + 'total': paginated.total, + 'pages': paginated.pages, + 'has_prev': paginated.has_prev, + 'has_next': paginated.has_next + }, + 'filters': { + 'applied_filters': { + 'type': event_type, + 'status': event_status, + 'importance': importance, + 'stock_sector': stock_sector, # 保持兼容 + 'secondary_sector': secondary_sector, # 保持兼容 + 'sort': sort_by, + 'order': order + } + }, + # 整体股票涨跌幅分布统计 + 'overall_stats': { + 'total_stocks': len(all_stocks_for_stats) if 'all_stocks_for_stats' in locals() else 0, + 'change_distribution': overall_distribution, + 'change_distribution_percentages': { + k: v for k, v in overall_distribution.items() + } + } + } + }) + + except Exception as e: + app.logger.error(f"获取事件列表出错: {str(e)}", exc_info=True) + return jsonify({ + 'success': False, + 'error': str(e), + 'error_type': type(e).__name__ + }), 500 + + +def get_filter_counts(base_query): + """ + 获取各个筛选条件的计数信息 + 可用于前端显示筛选选项的可用数量 + """ + try: + counts = {} + + # 重要性计数 + importance_counts = db.session.query( + Event.importance, + func.count(Event.id).label('count') + ).filter( + Event.id.in_(base_query.with_entities(Event.id).subquery()) + ).group_by(Event.importance).all() + + counts['importance'] = {item.importance or 'unknown': item.count for item in importance_counts} + + # 事件类型计数 + type_counts = db.session.query( + Event.event_type, + func.count(Event.id).label('count') + ).filter( + Event.id.in_(base_query.with_entities(Event.id).subquery()) + ).group_by(Event.event_type).all() + + counts['event_type'] = {item.event_type or 'unknown': item.count for item in type_counts} + + return counts + except Exception: + return {} + + +def get_event_class(count): + """根据事件数量返回对应的样式类""" + if count >= 10: + return 'bg-gradient-danger' + elif count >= 7: + return 'bg-gradient-warning' + elif count >= 4: + return 'bg-gradient-info' + else: + return 'bg-gradient-success' + + +@app.route('/api/calendar-event-counts') +def get_calendar_event_counts(): + """获取整月的事件数量统计,仅统计type为event的事件""" + try: + # 获取当前月份的开始和结束日期 + today = datetime.now() + start_date = today.replace(day=1) + if today.month == 12: + end_date = today.replace(year=today.year + 1, month=1, day=1) + else: + end_date = today.replace(month=today.month + 1, day=1) + + # 修改查询以仅统计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) \ + """ + + result = db.session.execute(text(query), { + 'start_date': start_date, + 'end_date': end_date + }) + + # 格式化结果为日历事件格式 + events = [{ + 'title': f'{day.count} 个事件', + 'start': day.date.isoformat() if day.date else None, + 'className': get_event_class(day.count) + } for day in result] + + return jsonify(events) + + except Exception as e: + return jsonify({'error': str(e)}), 500 + + +def get_full_avatar_url(avatar_url): + """ + 统一处理头像URL,确保返回完整的可访问URL + + Args: + avatar_url: 头像URL字符串 + + Returns: + 完整的头像URL,如果没有头像则返回默认头像URL + """ + if not avatar_url: + # 返回默认头像 + return f"{DOMAIN}/static/assets/img/default-avatar.png" + + # 如果已经是完整URL(http或https开头),直接返回 + if avatar_url.startswith(('http://', 'https://')): + return avatar_url + + # 如果是相对路径,拼接域名 + if avatar_url.startswith('/'): + return f"{DOMAIN}{avatar_url}" + else: + return f"{DOMAIN}/{avatar_url}" + + +# 修改User模型的to_dict方法 +def to_dict(self): + """转换为字典格式,方便API返回""" + return { + 'id': self.id, + 'username': self.username, + 'email': self.email, + 'nickname': self.nickname, + 'avatar_url': get_full_avatar_url(self.avatar_url), # 使用统一处理函数 + 'bio': self.bio, + 'location': self.location, + 'is_verified': self.is_verified, + 'user_level': self.user_level, + 'reputation_score': self.reputation_score, + 'post_count': self.post_count, + 'follower_count': self.follower_count, + 'following_count': self.following_count, + 'created_at': self.created_at.isoformat() if self.created_at else None, + '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) + hot_events = Event.query.filter( + Event.status == 'active', + Event.created_at >= seven_days_ago + ).order_by(Event.hot_score.desc()).limit(10).all() + + events_data = [] + for event in hot_events: + related_stocks = RelatedStock.query.filter_by(event_id=event.id).all() + + # 计算相关性统计数据 + correlations = [float(stock.correlation or 0) for stock in related_stocks] + avg_correlation = sum(correlations) / len(correlations) if correlations else 0 + max_correlation = max(correlations) if correlations else 0 + + stocks_data = [] + total_week_change = 0 + max_week_change = 0 + total_daily_change = 0 # 新增:日涨跌幅总和 + max_daily_change = 0 # 新增:最大日涨跌幅 + valid_stocks_count = 0 + + for stock in related_stocks: + stock_code = stock.stock_code.split('.')[0] + + # 获取最新交易日数据 + 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() + + week_change = 0 + daily_change = 0 # 新增:日涨跌幅 + + if latest_trade and latest_trade.F007N: + latest_price = float(latest_trade.F007N or 0) + latest_date = latest_trade.TRADEDATE + daily_change = float(latest_trade.F010N or 0) # F010N是日涨跌幅字段 + + # 更新日涨跌幅统计 + total_daily_change += daily_change + max_daily_change = max(max_daily_change, daily_change) + + # 获取最近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() + + if week_ago_trades and week_ago_trades[-1].F007N: + week_ago_price = float(week_ago_trades[-1].F007N or 0) + if week_ago_price > 0: + week_change = (latest_price - week_ago_price) / week_ago_price * 100 + total_week_change += week_change + max_week_change = max(max_week_change, week_change) + valid_stocks_count += 1 + + stocks_data.append({ + "stock_code": stock.stock_code, + "stock_name": stock.stock_name, + "correlation": float(stock.correlation or 0), + "sector": stock.sector, + "week_change": round(week_change, 2), + "daily_change": round(daily_change, 2), # 新增:个股日涨跌幅 + "latest_trade_date": latest_trade.TRADEDATE.strftime("%Y-%m-%d") if latest_trade else None + }) + + # 计算平均值 + avg_week_change = total_week_change / valid_stocks_count if valid_stocks_count > 0 else 0 + avg_daily_change = total_daily_change / valid_stocks_count if valid_stocks_count > 0 else 0 + + events_data.append({ + "id": event.id, + "title": event.title, + "description": event.description, + 'event_type': event.event_type, + 'importance': event.importance, # 添加重要性 + 'status': event.status, + "created_at": event.created_at.strftime("%Y-%m-%d %H:%M:%S"), + 'updated_at': event.updated_at.isoformat() if event.updated_at else None, + 'start_time': event.start_time.isoformat() if event.start_time else None, + 'end_time': event.end_time.isoformat() if event.end_time else None, + 'view_count': event.view_count, # 添加浏览量 + 'post_count': event.post_count, # 添加帖子数 + 'follower_count': event.follower_count, # 添加关注者数 + "related_stocks": stocks_data, + "stocks_stats": { + "avg_correlation": round(avg_correlation, 2), + "max_correlation": round(max_correlation, 2), + "stocks_count": len(related_stocks), + "valid_stocks_count": valid_stocks_count, + # 周涨跌统计 + "avg_week_change": round(avg_week_change, 2), + "max_week_change": round(max_week_change, 2), + # 日涨跌统计 + "avg_daily_change": round(avg_daily_change, 2), + "max_daily_change": round(max_daily_change, 2) + } + }) + + return jsonify({ + "code": 200, + "message": "success", + "data": { + "events": events_data + } + }) + + except Exception as e: + print(f"Error in api_home: {str(e)}") + return jsonify({ + "code": 500, + "message": str(e), + "data": None + }), 500 + + +@app.route('/api/auth/logout', methods=['POST']) +def logout_with_token(): + """使用token登出""" + # 从请求头获取token + auth_header = request.headers.get('Authorization') + if auth_header and auth_header.startswith('Bearer '): + token = auth_header[7:] + else: + data = request.get_json() + token = data.get('token') if data else None + + if token and token in user_tokens: + del user_tokens[token] + + # 清除session + session.clear() + + return jsonify({'message': '登出成功'}), 200 + + +def send_sms_code(phone, code, template_id): + """发送短信验证码""" + try: + cred = credential.Credential(SMS_SECRET_ID, SMS_SECRET_KEY) + httpProfile = HttpProfile() + httpProfile.endpoint = "sms.tencentcloudapi.com" + + clientProfile = ClientProfile() + clientProfile.httpProfile = httpProfile + client = sms_client.SmsClient(cred, "ap-beijing", clientProfile) + + req = models.SendSmsRequest() + params = { + "PhoneNumberSet": [phone], + "SmsSdkAppId": SMS_SDK_APP_ID, + "TemplateId": template_id, + "SignName": SMS_SIGN_NAME, + "TemplateParamSet": [code] if template_id == SMS_TEMPLATE_REGISTER else [code, "5"] + } + req.from_json_string(json.dumps(params)) + + resp = client.SendSms(req) + return True + except TencentCloudSDKException as err: + 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(): + """发送手机验证码(统一接口,自动判断场景)""" + data = request.get_json() + phone = data.get('phone') + + if not phone: + return jsonify({'error': '手机号不能为空'}), 400 + + # 检查手机号是否已注册 + user_exists = User.query.filter_by(phone=phone).first() is not None + + # 生成验证码 + code = generate_verification_code() + + # 根据用户是否存在自动选择模板 + template_id = SMS_TEMPLATE_LOGIN if user_exists else SMS_TEMPLATE_REGISTER + + # 发送短信 + if send_sms_code(phone, code, template_id): + # 统一存储验证码(5分钟有效) + verification_codes[phone] = { + 'code': code, + 'expires': time.time() + 300 + } + + # 简单返回成功,不暴露用户是否存在的信息 + return jsonify({ + 'message': '验证码已发送', + 'expires_in': 300 # 告诉前端验证码有效期(秒) + }), 200 + else: + return jsonify({'error': '验证码发送失败'}), 500 + + +def generate_token(length=32): + """生成随机token""" + characters = string.ascii_letters + string.digits + return ''.join(secrets.choice(characters) for _ in range(length)) + + +@app.route('/api/auth/login/phone', methods=['POST']) +def login_with_phone(): + """统一的手机号登录/注册接口""" + data = request.get_json() + phone = data.get('phone') + code = data.get('code') + username = data.get('username') # 可选,新用户可以提供 + password = data.get('password') # 可选,新用户可以提供 + + if not all([phone, code]): + return jsonify({ + 'code': 400, + 'message': '手机号和验证码不能为空' + }), 400 + + # 验证验证码 + stored_code = verification_codes.get(phone) + + if not stored_code or stored_code['expires'] < time.time(): + return jsonify({ + 'code': 400, + 'message': '验证码已过期' + }), 400 + + if stored_code['code'] != code: + return jsonify({ + 'code': 400, + 'message': '验证码错误' + }), 400 + + try: + # 查找用户 + user = User.query.filter_by(phone=phone).first() + is_new_user = False + + # 如果用户不存在,自动注册 + if not user: + is_new_user = True + + # 如果提供了用户名,检查是否已存在 + if username: + if User.query.filter_by(username=username).first(): + return jsonify({ + 'code': 400, + 'message': '用户名已被使用,请换一个' + }), 400 + else: + # 自动生成用户名 + base_username = f"user_{phone[-4:]}" + username = base_username + counter = 1 + while User.query.filter_by(username=username).first(): + username = f"{base_username}_{counter}" + counter += 1 + + # 创建新用户 + user = User(username=username, phone=phone) + user.email = f"{username}@valuefrontier.temp" + + # 如果提供了密码就使用,否则生成随机密码 + if password: + user.set_password(password) + else: + random_password = generate_token(16) + user.set_password(random_password) + + user.phone_confirmed = True + + db.session.add(user) + db.session.commit() + + # 生成token + token = generate_token(32) + + # 存储token映射(30天有效期) + user_tokens[token] = { + 'user_id': user.id, + 'expires': datetime.now() + timedelta(days=30) + } + + # 清除验证码 + del verification_codes[phone] + + # 设置session(保持与原有逻辑兼容) + session.permanent = True + session['user_id'] = user.id + session['username'] = user.username + session['logged_in'] = True + + # 返回响应 + response_data = { + 'code': 0, + 'message': '欢迎回来' if not is_new_user else '注册成功,欢迎加入', + 'token': token, + 'is_new_user': is_new_user, # 告诉前端是否是新用户 + 'user': { + 'id': user.id, + 'username': user.username, + 'phone': user.phone, + 'need_complete_profile': is_new_user # 提示新用户完善资料 + } + } + + return jsonify(response_data), 200 + + except Exception as e: + db.session.rollback() + print(f"Login/Register error: {e}") + return jsonify({ + 'code': 500, + 'message': '操作失败,请重试' + }), 500 + + +@app.route('/api/auth/verify-token', methods=['POST']) +def verify_token(): + """验证token有效性(可选接口)""" + data = request.get_json() + token = data.get('token') + + if not token: + return jsonify({'valid': False, 'message': 'Token不能为空'}), 400 + + token_data = user_tokens.get(token) + + if not token_data: + return jsonify({'valid': False, 'message': 'Token无效', 'code': 401}), 401 + + # 检查是否过期 + if token_data['expires'] < datetime.now(): + del user_tokens[token] + return jsonify({'valid': False, 'message': 'Token已过期'}), 401 + + # 获取用户信息 + user = User.query.get(token_data['user_id']) + if not user: + return jsonify({'valid': False, 'message': '用户不存在'}), 404 + + return jsonify({ + 'valid': True, + 'user': { + 'id': user.id, + 'username': user.username, + 'phone': user.phone + } + }), 200 + + +def generate_jwt_token(user_id): + """ + 生成JWT Token - 与原系统保持一致 + + Args: + user_id: 用户ID + + Returns: + str: JWT token字符串 + """ + payload = { + 'user_id': user_id, + 'exp': datetime.utcnow() + timedelta(hours=JWT_EXPIRATION_HOURS), + 'iat': datetime.utcnow() + } + + token = jwt.encode(payload, JWT_SECRET_KEY, algorithm=JWT_ALGORITHM) + return token + + +@app.route('/api/auth/login/wechat', methods=['POST']) +def api_login_wechat(): + try: + # 1. 获取请求数据 + data = request.get_json() + code = data.get('code') if data else None + + if not code: + return jsonify({ + 'code': 400, + 'message': '缺少必要的参数', + 'data': None + }), 400 + + # 2. 验证code格式 + if not isinstance(code, str) or len(code) < 10: + return jsonify({ + 'code': 400, + 'message': 'code格式无效', + 'data': None + }), 400 + + logger.info(f"开始处理微信登录,code长度: {len(code)}") + + # 3. 调用微信接口获取用户信息 + wx_api_url = 'https://api.weixin.qq.com/sns/jscode2session' + params = { + 'appid': WECHAT_APP_ID, + 'secret': WECHAT_APP_SECRET, + 'js_code': code, + 'grant_type': 'authorization_code' + } + + try: + response = requests.get(wx_api_url, params=params, timeout=10) + response.raise_for_status() + wx_data = response.json() + + # 检查微信API返回的错误 + if 'errcode' in wx_data and wx_data['errcode'] != 0: + error_messages = { + -1: '系统繁忙,请稍后重试', + 40029: 'code无效或已过期', + 45011: '频率限制,请稍后再试', + 40013: 'AppID错误', + 40125: 'AppSecret错误', + 40226: '高风险用户,登录被拦截' + } + + error_msg = error_messages.get( + wx_data['errcode'], + f"微信接口错误: {wx_data.get('errmsg', '未知错误')}" + ) + + logger.error(f"WeChat API error {wx_data['errcode']}: {error_msg}") + + return jsonify({ + 'code': 400, + 'message': error_msg, + 'data': None + }), 400 + + # 验证必需字段 + if 'openid' not in wx_data or 'session_key' not in wx_data: + logger.error("响应缺少必需字段") + return jsonify({ + 'code': 500, + 'message': '微信响应格式错误', + 'data': None + }), 500 + + openid = wx_data['openid'] + session_key = wx_data['session_key'] + unionid = wx_data.get('unionid') # 可能为None + + logger.info(f"成功获取微信用户信息 - OpenID: {openid[:8]}...") + if unionid: + logger.info(f"获取到UnionID: {unionid[:8]}...") + + except requests.exceptions.Timeout: + logger.error("请求微信API超时") + return jsonify({ + 'code': 500, + 'message': '请求超时,请重试', + 'data': None + }), 500 + except requests.exceptions.RequestException as e: + logger.error(f"网络请求失败: {str(e)}") + return jsonify({ + 'code': 500, + 'message': '网络错误', + 'data': None + }), 500 + + # 4. 查找或创建用户 - 核心逻辑 + user = None + is_new_user = False + + logger.info(f"开始查找用户 - UnionID: {unionid}, OpenID: {openid[:8]}...") + + if unionid: + # 情况1: 有unionid,优先通过unionid查找 + user = User.query.filter_by(wechat_union_id=unionid).first() + + if user: + logger.info(f"通过UnionID找到用户: {user.username}") + # 更新openid(可能用户从不同小程序登录) + if user.wechat_open_id != openid: + user.wechat_open_id = openid + logger.info(f"更新用户OpenID: {openid[:8]}...") + else: + # unionid没找到,再尝试用openid查找(处理历史数据) + user = User.query.filter_by(wechat_open_id=openid).first() + if user: + logger.info(f"通过OpenID找到用户: {user.username}") + # 补充unionid + user.wechat_union_id = unionid + logger.info(f"为用户补充UnionID: {unionid[:8]}...") + else: + # 情况2: 没有unionid,只能通过openid查找 + logger.warning("未获取到UnionID(小程序可能未绑定开放平台)") + user = User.query.filter_by(wechat_open_id=openid).first() + if user: + logger.info(f"通过OpenID找到用户: {user.username}") + + # 5. 创建新用户 + if not user: + is_new_user = True + + # 生成唯一用户名 + timestamp = int(time.time()) + username = f"wx_{timestamp}_{openid[-6:]}" + + # 确保用户名唯一 + counter = 0 + base_username = username + while User.query.filter_by(username=username).first(): + counter += 1 + username = f"{base_username}_{counter}" + + # 创建用户对象(使用你的User模型) + user = User( + username=username, + email=f"{username}@wechat.local", # 占位邮箱 + password="wechat_login_no_password" # 微信登录不需要密码 + ) + + # 设置微信相关字段 + user.wechat_open_id = openid + user.wechat_union_id = unionid + user.status = 'active' + user.email_confirmed = False + + # 设置默认值 + user.nickname = f"微信用户{openid[-4:]}" + user.bio = "" # 空的个人简介 + user.avatar_url = None # 稍后会处理 + user.is_creator = False + user.is_verified = False + user.user_level = 1 + user.reputation_score = 0 + user.contribution_point = 0 + user.post_count = 0 + user.comment_count = 0 + user.follower_count = 0 + user.following_count = 0 + + # 设置默认偏好 + user.email_notifications = True + user.privacy_level = 'public' + user.theme_preference = 'light' + + db.session.add(user) + logger.info(f"创建新用户: {username}") + else: + # 更新最后登录时间 + user.update_last_seen() + logger.info(f"用户登录: {user.username}") + + # 6. 提交数据库更改 + try: + db.session.commit() + except Exception as e: + db.session.rollback() + logger.error(f"保存用户信息失败: {str(e)}") + return jsonify({ + 'code': 500, + 'message': '保存用户信息失败', + 'data': None + }), 500 + + # 7. 生成JWT token(使用原系统的生成方法) + token = generate_token(32) # 使用相同的随机字符串生成器 + + # 存储token映射(与手机登录保持一致) + user_tokens[token] = { + 'user_id': user.id, + 'expires': datetime.now() + timedelta(days=30) # 30天有效期 + } + + # 设置session(可选,保持与手机登录一致) + session.permanent = True + session['user_id'] = user.id + session['username'] = user.username + session['logged_in'] = True + + # 9. 构造返回数据 - 完全匹配要求的格式 + response_data = { + 'code': 200, + 'data': { + 'token': token, # 现在这个token能被token_required识别了 + 'user': { + 'avatar_url': get_full_avatar_url(user.avatar_url), + 'bio': user.bio or "", + 'email': user.email, + 'id': user.id, + 'is_creator': user.is_creator, + 'is_verified': user.is_verified, + 'nickname': user.nickname or user.username, + 'reputation_score': user.reputation_score, + 'user_level': user.user_level, + 'username': user.username + } + }, + 'message': '登录成功' + } + + # 10. 记录日志 + logger.info( + f"微信登录成功 - 用户ID: {user.id}, " + f"用户名: {user.username}, " + f"新用户: {is_new_user}, " + f"有UnionID: {unionid is not None}" + ) + + return jsonify(response_data), 200 + + except Exception as e: + # 捕获所有未处理的异常 + logger.error(f"微信登录处理异常: {str(e)}", exc_info=True) + db.session.rollback() + + return jsonify({ + 'code': 500, + 'message': '服务器内部错误', + 'data': None + }), 500 + + +@app.route('/api/auth/login/email', methods=['POST']) +def api_login_email(): + """邮箱登录接口""" + try: + data = request.get_json() + email = data.get('email') + password = data.get('password') + + if not email or not password: + return jsonify({ + 'code': 400, + 'message': '邮箱和密码不能为空', + 'data': None + }), 400 + + user = User.query.filter_by(email=email).first() + if not user or not user.check_password(password): + return jsonify({ + 'code': 400, + 'message': '邮箱或密码错误', + 'data': None + }), 400 + token = generate_jwt_token(user.id) + login_user(user) + user.update_last_seen() + db.session.commit() + + return jsonify({ + 'code': 200, + 'message': '登录成功', + 'data': { + 'token': token, + 'user_id': user.id, + 'username': user.username, + 'email': user.email, + 'is_verified': user.is_verified, + 'user_level': user.user_level + } + }) + except Exception as e: + return jsonify({ + 'code': 500, + 'message': str(e), + 'data': None + }), 500 + + +# 5. 事件详情-相关标的接口 +@app.route('/api/event//related-stocks-detail', methods=['GET']) +def api_event_related_stocks(event_id): + """事件相关标的详情接口 - 仅限 Pro/Max 会员""" + try: + from datetime import datetime, timedelta, time as dt_time + from sqlalchemy import text + + event = Event.query.get_or_404(event_id) + related_stocks = event.related_stocks.order_by(RelatedStock.correlation.desc()).all() + + # 获取ClickHouse客户端用于分时数据查询 + 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): + """获取股票分时图数据""" + try: + # 获取当前日期或最新交易日的分时数据 + from datetime import datetime, timedelta, time as dt_time + today = datetime.now().date() + + # 获取最新交易日的分时数据 + 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)) + }) + + # 如果今天没有数据,获取最近的交易日数据 + 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 + }) + data = recent_data + + # 格式化数据 + minute_data = [] + for row in data: + minute_data.append({ + 'time': row[0].strftime('%H:%M'), + 'open': float(row[1]) if row[1] else None, + 'high': float(row[2]) if row[2] else None, + 'low': float(row[3]) if row[3] else None, + 'close': float(row[4]) if row[4] else None, + 'volume': float(row[5]) if row[5] else None, + 'amount': float(row[6]) if row[6] else None + }) + + return minute_data + + except Exception as e: + print(f"Error fetching minute data for {stock_code}: {e}") + 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 = [] + for stock in related_stocks: + print(f"正在组装股票 {stock.stock_code} 的数据...") + + # 从批量查询结果中获取股票基本信息 + 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 + search_codes = [stock.stock_code, stock.stock_code.split('.')[0]] + + for code in search_codes: + latest_trade = TradeData.query.filter_by(SECCODE=code) \ + .order_by(TradeData.TRADEDATE.desc()).first() + if latest_trade: + break + + if latest_trade: + latest_price = float(latest_trade.F007N) if latest_trade.F007N else None + open_price = float(latest_trade.F003N) if latest_trade.F003N else None + 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 + + # 计算涨跌幅 + if latest_trade.F010N: + change_pct = float(latest_trade.F010N) + 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 = minute_chart_map.get(stock.stock_code, []) + + stock_data = { + 'id': stock.id, + 'stock_code': stock.stock_code, + 'stock_name': stock.stock_name, + 'sector': stock.sector, + 'relation_desc': stock.relation_desc, + 'correlation': stock.correlation, + 'momentum': stock.momentum, + 'listing_date': stock_info.F006D.isoformat() if stock_info and stock_info.F006D else None, + 'market': stock_info.F005V if stock_info else None, + + # 交易数据 + 'trade_data': { + 'latest_price': latest_price, + 'first_price': first_price, # 事件发生时的价格 + 'open_price': open_price, + 'high_price': high_price, + 'low_price': low_price, + '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, + 'volume': volume, + 'amount': amount, + 'trade_date': trade_date.isoformat() if trade_date 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, + + # 图表URL + 'charts': { + 'minute_chart_url': f"/api/stock/{stock.stock_code}/minute-chart", + 'daily_chart_url': f"/api/stock/{stock.stock_code}/kline", + } + } + + stocks_data.append(stock_data) + + 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': stocks_data, + 'total_count': len(stocks_data) + } + }) + except Exception as e: + return jsonify({ + 'code': 500, + 'message': str(e), + 'data': None + }), 500 + + +@app.route('/api/stock//minute-chart', methods=['GET']) +def get_minute_chart_data(stock_code): + """获取股票分时图数据 - 仅限 Pro/Max 会员""" + client = get_clickhouse_client() + try: + # 获取当前日期或最新交易日的分时数据 + from datetime import datetime, timedelta, time as dt_time + today = datetime.now().date() + + # 获取最新交易日的分时数据 + 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)) + }) + + # 如果今天没有数据,获取最近的交易日数据 + 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 + }) + data = recent_data + + # 格式化数据 + minute_data = [] + for row in data: + minute_data.append({ + 'time': row[0].strftime('%H:%M'), + 'open': float(row[1]) if row[1] else None, + 'high': float(row[2]) if row[2] else None, + 'low': float(row[3]) if row[3] else None, + 'close': float(row[4]) if row[4] else None, + 'volume': float(row[5]) if row[5] else None, + 'amount': float(row[6]) if row[6] else None + }) + + return minute_data + except Exception as e: + print(f"Error getting minute chart data: {e}") + return [] + + +@app.route('/api/event//stock//detail', methods=['GET']) +def api_stock_detail(event_id, stock_code): + """个股详情接口 - 仅限 Pro/Max 会员""" + try: + # 验证事件是否存在 + event = Event.query.get_or_404(event_id) + + # 获取查询参数 + include_minute_data = request.args.get('include_minute_data', 'true').lower() == 'true' + include_full_sources = request.args.get('include_full_sources', 'false').lower() == 'true' # 是否包含完整研报来源 + + # 获取股票基本信息 + basic_info = None + base_code = stock_code.split('.')[0] # 去掉后缀 + + # 按优先级查找股票信息 + basic_info = StockBasicInfo.query.filter_by(SECCODE=stock_code).first() + if not basic_info: + basic_info = StockBasicInfo.query.filter( + StockBasicInfo.SECCODE.ilike(f"{stock_code}%") + ).first() + if not basic_info: + basic_info = StockBasicInfo.query.filter( + StockBasicInfo.SECCODE.ilike(f"{base_code}%") + ).first() + + company_info = CompanyInfo.query.filter_by(SECCODE=stock_code).first() + if not company_info: + company_info = CompanyInfo.query.filter_by(SECCODE=base_code).first() + + if not basic_info: + return jsonify({ + 'code': 404, + 'stock_code': stock_code, + 'message': '股票不存在', + 'data': None + }), 404 + + # 获取最新交易数据 + latest_trade = TradeData.query.filter_by(SECCODE=stock_code) \ + .order_by(TradeData.TRADEDATE.desc()).first() + if not latest_trade: + latest_trade = TradeData.query.filter_by(SECCODE=base_code) \ + .order_by(TradeData.TRADEDATE.desc()).first() + + # 获取分时数据 + minute_chart_data = [] + if include_minute_data: + minute_chart_data = get_minute_chart_data(stock_code) + + # 获取该事件的相关描述 + related_stock = RelatedStock.query.filter_by( + event_id=event_id + ).filter( + db.or_( + RelatedStock.stock_code == stock_code, + RelatedStock.stock_code == base_code, + RelatedStock.stock_code.like(f"{base_code}.%") + ) + ).first() + + related_desc = None + if related_stock: + # 处理研报来源数据 + retrieved_sources_data = None + sources_summary = None + + if related_stock.retrieved_sources: + try: + # 解析研报来源 + import json + sources = related_stock.retrieved_sources if isinstance(related_stock.retrieved_sources, + list) else json.loads( + related_stock.retrieved_sources) + + # 统计信息 + sources_summary = { + 'total_count': len(sources), + 'has_sources': True, + 'match_scores': {} + } + + # 统计匹配分数分布 + for source in sources: + score = source.get('match_score', '未知') + sources_summary['match_scores'][score] = sources_summary['match_scores'].get(score, 0) + 1 + + # 根据参数决定返回完整数据还是摘要 + if include_full_sources: + # 返回完整的研报来源 + retrieved_sources_data = sources + else: + # 只返回前5条高质量来源作为预览 + # 优先返回匹配度高的 + high_quality_sources = [s for s in sources if s.get('match_score') == '好'][:3] + medium_quality_sources = [s for s in sources if s.get('match_score') == '中'][:2] + + preview_sources = high_quality_sources + medium_quality_sources + if not preview_sources: # 如果没有高中匹配度的,返回前5条 + preview_sources = sources[:5] + + retrieved_sources_data = [] + for source in preview_sources: + retrieved_sources_data.append({ + 'report_title': source.get('report_title', ''), + 'author': source.get('author', ''), + 'sentences': source.get('sentences', '')[:200] + '...' if len( + source.get('sentences', '')) > 200 else source.get('sentences', ''), # 限制长度 + 'match_score': source.get('match_score', ''), + 'declare_date': source.get('declare_date', '') + }) + + except Exception as e: + print(f"Error processing retrieved_sources for stock {stock_code}: {e}") + sources_summary = {'has_sources': False, 'error': str(e)} + else: + sources_summary = {'has_sources': False, 'total_count': 0} + + related_desc = { + 'event_id': related_stock.event_id, + 'relation_desc': related_stock.relation_desc, + 'sector': related_stock.sector, + 'correlation': float(related_stock.correlation) if related_stock.correlation else None, + 'momentum': related_stock.momentum, + + # 新增研报来源相关字段 + 'retrieved_sources': retrieved_sources_data, + 'sources_summary': sources_summary, + 'retrieved_update_time': related_stock.retrieved_update_time.isoformat() if related_stock.retrieved_update_time else None, + + # 添加获取完整来源的URL + 'sources_detail_url': f"/api/event/{event_id}/stock/{stock_code}/sources" if sources_summary.get( + 'has_sources') else None + } + + response_data = { + 'code': 200, + 'message': 'success', + 'data': { + 'event_info': { + 'event_id': event.id, + 'event_title': event.title, + 'event_description': event.description + }, + 'basic_info': { + 'stock_code': basic_info.SECCODE, + 'stock_name': basic_info.SECNAME, + 'org_name': basic_info.ORGNAME, + 'pinyin': basic_info.F001V, + 'category': basic_info.F003V, + 'market': basic_info.F005V, + 'listing_date': basic_info.F006D.isoformat() if basic_info.F006D else None, + 'status': basic_info.F011V + }, + 'company_info': { + 'english_name': company_info.F001V if company_info else None, + 'legal_representative': company_info.F003V if company_info else None, + 'main_business': company_info.F015V if company_info else None, + 'business_scope': company_info.F016V if company_info else None, + 'company_intro': company_info.F017V if company_info else None, + 'csrc_industry_l1': company_info.F030V if company_info else None, + 'csrc_industry_l2': company_info.F032V if company_info else None + }, + 'latest_trade': { + 'trade_date': latest_trade.TRADEDATE.isoformat() if latest_trade else None, + 'close_price': float(latest_trade.F007N) if latest_trade and latest_trade.F007N else None, + 'change': float(latest_trade.F009N) if latest_trade and latest_trade.F009N else None, + 'change_pct': float(latest_trade.F010N) if latest_trade and latest_trade.F010N else None, + 'volume': float(latest_trade.F004N) if latest_trade and latest_trade.F004N else None, + 'amount': float(latest_trade.F011N) if latest_trade and latest_trade.F011N else None + } if latest_trade else None, + 'minute_chart_data': minute_chart_data, + 'related_desc': related_desc + } + } + + response = jsonify(response_data) + response.headers['Content-Type'] = 'application/json; charset=utf-8' + return response + + except Exception as e: + return jsonify({ + 'code': 500, + 'message': str(e), + 'data': None + }), 500 + + +def get_stock_minute_chart_data(stock_code): + """获取股票分时图数据""" + try: + client = get_clickhouse_client() + + # 获取当前日期(使用最新的交易日) + from datetime import datetime, timedelta, time as dt_time + import csv + + def get_trading_days(): + trading_days = set() + with open('tdays.csv', 'r') as f: + reader = csv.DictReader(f) + for row in reader: + trading_days.add(datetime.strptime(row['DateTime'], '%Y/%m/%d').date()) + return trading_days + + trading_days = get_trading_days() + + def find_latest_trading_day(current_date): + """找到最新的交易日""" + while current_date >= min(trading_days): + if current_date in trading_days: + return current_date + current_date -= timedelta(days=1) + return None + + target_date = find_latest_trading_day(datetime.now().date()) + + if not target_date: + return [] + + # 获取分时数据 + 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)) + }) + + minute_data = [] + for row in data: + minute_data.append({ + 'time': row[0].strftime('%H:%M'), + 'open': float(row[1]), + 'high': float(row[2]), + 'low': float(row[3]), + 'close': float(row[4]), + 'volume': float(row[5]), + 'amount': float(row[6]) + }) + + return minute_data + + except Exception as e: + print(f"Error getting minute chart data: {e}") + return [] + + +# 7. 事件详情-相关概念接口 +@app.route('/api/event//related-concepts', methods=['GET']) +def api_event_related_concepts(event_id): + """事件相关概念接口""" + try: + event = Event.query.get_or_404(event_id) + related_concepts = event.related_concepts.all() + base_url = request.host_url + + concepts_data = [] + for concept in related_concepts: + image_paths = concept.image_paths_list + image_urls = [base_url + 'data/concepts/' + p for p in image_paths] + concepts_data.append({ + 'id': concept.id, + 'concept_code': concept.concept_code, + 'concept': concept.concept, + 'reason': concept.reason, + 'image_paths': image_paths, + 'image_urls': image_urls, + 'first_image': image_urls[0] if image_urls else None + }) + + return jsonify({ + 'code': 200, + 'message': 'success', + 'data': { + 'event_id': event_id, + 'event_title': event.title, + 'related_concepts': concepts_data, + 'total_count': len(concepts_data) + } + }) + except Exception as e: + return jsonify({ + 'code': 500, + 'message': str(e), + 'data': None + }), 500 + + +# 8. 事件详情-历史事件接口 +@app.route('/api/event//historical-events', methods=['GET']) +def api_event_historical_events(event_id): + """事件历史事件接口""" + try: + event = Event.query.get_or_404(event_id) + historical_events = event.historical_events.order_by( + HistoricalEvent.importance.desc(), + HistoricalEvent.event_date.desc() + ).all() + + events_data = [] + for hist_event in historical_events: + # 获取相关股票信息 + related_stocks = [] + valid_changes = [] # 用于计算涨跌幅 + + for stock in hist_event.stocks.all(): + base_stock_code = stock.stock_code.split('.')[0] + # 获取股票当日交易数据 + trade_data = TradeData.query.filter( + TradeData.SECCODE.startswith(base_stock_code) + ).order_by(TradeData.TRADEDATE.desc()).first() + + if trade_data and trade_data.F010N is not None: + daily_change = float(trade_data.F010N) + valid_changes.append(daily_change) + else: + daily_change = None + + stock_data = { + 'stock_code': stock.stock_code, + 'stock_name': stock.stock_name, + 'relation_desc': stock.relation_desc, + 'correlation': stock.correlation, + 'sector': stock.sector, + 'daily_change': daily_change, + 'has_trade_data': True if trade_data else False + } + related_stocks.append(stock_data) + + # 计算相关股票的平均涨幅和最大涨幅 + avg_change = None + max_change = None + if valid_changes: + avg_change = sum(valid_changes) / len(valid_changes) + max_change = max(valid_changes) + + events_data.append({ + 'id': hist_event.id, + 'title': hist_event.title, + 'content': hist_event.content, + 'event_date': hist_event.event_date.isoformat() if hist_event.event_date else None, + 'relevance': hist_event.relevance, + 'importance': hist_event.importance, + 'related_stocks': related_stocks, + # 使用计算得到的涨幅数据 + 'related_avg_chg': round(avg_change, 2) if avg_change is not None else None, + 'related_max_chg': round(max_change, 2) if max_change is not None else None + }) + + # 计算当前事件的相关股票涨幅数据 + current_valid_changes = [] + for stock in event.related_stocks: + base_stock_code = stock.stock_code.split('.')[0] + trade_data = TradeData.query.filter( + TradeData.SECCODE.startswith(base_stock_code) + ).order_by(TradeData.TRADEDATE.desc()).first() + + if trade_data and trade_data.F010N is not None: + current_valid_changes.append(float(trade_data.F010N)) + + current_avg_change = None + current_max_change = None + if current_valid_changes: + current_avg_change = sum(current_valid_changes) / len(current_valid_changes) + current_max_change = max(current_valid_changes) + + return jsonify({ + 'code': 200, + 'message': 'success', + 'data': { + 'event_id': event_id, + 'event_title': event.title, + 'invest_score': event.invest_score, + 'related_avg_chg': round(current_avg_change, 2) if current_avg_change is not None else None, + 'related_max_chg': round(current_max_change, 2) if current_max_change is not None else None, + 'historical_events': events_data, + 'total_count': len(events_data) + } + }) + except Exception as e: + print(f"Error in api_event_historical_events: {str(e)}") + return jsonify({ + 'code': 500, + 'message': str(e), + 'data': None + }), 500 + + +@app.route('/api/event//comments', methods=['GET']) +def get_event_comments(event_id): + """获取事件的所有评论和帖子(嵌套格式) + + Query参数: + - page: 页码(默认1) + - per_page: 每页评论数(默认20) + - sort: 排序方式(time_desc/time_asc/hot, 默认time_desc) + - include_posts: 是否包含帖子信息(默认true) + - reply_limit: 每个评论显示的回复数量限制(默认3) + + 返回: + { + "success": true, + "data": { + "event": { + "id": 事件ID, + "title": "事件标题", + "description": "事件描述" + }, + "posts": [帖子信息], + "total": 总评论数, + "current_page": 当前页码, + "total_pages": 总页数, + "comments": [ + { + "comment_id": 评论ID, + "content": "评论内容", + "created_at": "评论时间", + "post_id": 所属帖子ID, + "post_title": "帖子标题", + "user": { + "user_id": 用户ID, + "nickname": "用户昵称", + "avatar_url": "头像URL" + }, + "reply_count": 总回复数量, + "has_more_replies": 是否有更多回复, + "list": [ # 回复列表 + { + "comment_id": 回复ID, + "content": "回复内容", + "created_at": "回复时间", + "user": { + "user_id": 用户ID, + "nickname": "用户昵称", + "avatar_url": "头像URL" + }, + "reply_to": { # 被回复的用户信息 + "user_id": 用户ID, + "nickname": "用户昵称" + } + } + ] + } + ] + } + } + """ + try: + # 获取查询参数 + page = request.args.get('page', 1, type=int) + per_page = request.args.get('per_page', 20, type=int) + sort = request.args.get('sort', 'time_desc') + include_posts = request.args.get('include_posts', 'true').lower() == 'true' + reply_limit = request.args.get('reply_limit', 3, type=int) # 每个评论显示的回复数限制 + + # 参数验证 + if page < 1: + page = 1 + if per_page < 1 or per_page > 100: + per_page = 20 + if reply_limit < 0 or reply_limit > 50: # 限制回复数量 + reply_limit = 3 + + # 获取事件信息 + event = Event.query.get_or_404(event_id) + + # 获取事件下的所有帖子 + posts_query = Post.query.filter_by(event_id=event_id, status='active') \ + .order_by(Post.is_top.desc(), Post.created_at.desc()) + posts = posts_query.all() + + # 格式化帖子数据 + posts_data = [] + if include_posts: + for post in posts: + posts_data.append({ + 'post_id': post.id, + 'title': post.title, + 'content': post.content, + 'content_type': post.content_type, + 'created_at': post.created_at.strftime('%Y-%m-%d %H:%M:%S'), + 'updated_at': post.updated_at.strftime('%Y-%m-%d %H:%M:%S') if post.updated_at else None, + 'likes_count': post.likes_count, + 'comments_count': post.comments_count, + 'view_count': post.view_count, + 'is_top': post.is_top, + 'user': { + 'user_id': post.user.id, + 'username': post.user.username, + 'nickname': post.user.nickname or post.user.username, + 'avatar_url': get_full_avatar_url(post.user.avatar_url), + 'user_level': post.user.user_level, + 'is_verified': post.user.is_verified, + 'is_creator': post.user.is_creator + } + }) + + # 获取帖子ID列表用于查询评论 + post_ids = [p.id for p in posts] + + if not post_ids: + return jsonify({ + 'success': True, + 'data': { + 'event': { + 'id': event.id, + 'title': event.title, + 'description': event.description, + 'event_type': event.event_type, + 'importance': event.importance, + 'status': event.status + }, + 'posts': posts_data, + 'total': 0, + 'current_page': page, + 'total_pages': 0, + 'comments': [] + } + }) + + # 构建基础查询 - 只查询主评论 + base_query = Comment.query.filter( + Comment.post_id.in_(post_ids), + Comment.parent_id == None, # 只查询主评论 + Comment.status == 'active' + ) + + # 排序处理 + if sort == 'time_asc': + base_query = base_query.order_by(Comment.created_at.asc()) + elif sort == 'hot': + # 这里可以根据你的业务逻辑添加热度排序 + base_query = base_query.order_by(Comment.created_at.desc()) + else: # 默认按时间倒序 + base_query = base_query.order_by(Comment.created_at.desc()) + + # 执行分页查询 + pagination = base_query.paginate(page=page, per_page=per_page, error_out=False) + + # 格式化评论数据(嵌套格式) + comments_data = [] + for comment in pagination.items: + # 获取评论的总回复数量 + reply_count = Comment.query.filter_by( + parent_id=comment.id, + status='active' + ).count() + + # 获取指定数量的回复 + replies_query = Comment.query.filter_by( + parent_id=comment.id, + status='active' + ).order_by(Comment.created_at.asc()) # 回复按时间正序排列 + + if reply_limit > 0: + replies = replies_query.limit(reply_limit).all() + else: + replies = [] + + # 格式化回复数据 - 作为list字段 + replies_list = [] + for reply in replies: + # 获取被回复的用户信息(这里是回复主评论,所以reply_to就是主评论的用户) + reply_to_user = { + 'user_id': comment.user.id, + 'nickname': comment.user.nickname or comment.user.username + } + + replies_list.append({ + 'comment_id': reply.id, + 'content': reply.content, + 'created_at': reply.created_at.strftime('%Y-%m-%d %H:%M:%S'), + 'user': { + 'user_id': reply.user.id, + 'username': reply.user.username, + 'nickname': reply.user.nickname or reply.user.username, + 'avatar_url': get_full_avatar_url(reply.user.avatar_url), + 'user_level': reply.user.user_level, + 'is_verified': reply.user.is_verified + }, + 'reply_to': reply_to_user + }) + + # 获取评论所属的帖子信息 + post = comment.post + + # 构建嵌套格式的评论数据 + comments_data.append({ + 'comment_id': comment.id, + 'content': comment.content, + 'created_at': comment.created_at.strftime('%Y-%m-%d %H:%M:%S'), + 'post_id': comment.post_id, + 'post_title': post.title if post else None, + 'post_content_preview': post.content[:100] + '...' if post and len( + post.content) > 100 else post.content if post else None, + 'user': { + 'user_id': comment.user.id, + 'username': comment.user.username, + 'nickname': comment.user.nickname or comment.user.username, + 'avatar_url': get_full_avatar_url(comment.user.avatar_url), + 'user_level': comment.user.user_level, + 'is_verified': comment.user.is_verified + }, + 'reply_count': reply_count, + 'has_more_replies': reply_count > len(replies_list), + 'list': replies_list # 嵌套的回复列表 + }) + + return jsonify({ + 'success': True, + 'data': { + 'event': { + 'id': event.id, + 'title': event.title, + 'description': event.description, + 'event_type': event.event_type, + 'importance': event.importance, + 'status': event.status, + 'created_at': event.created_at.strftime('%Y-%m-%d %H:%M:%S'), + 'hot_score': event.hot_score, + 'view_count': event.view_count, + 'post_count': event.post_count, + 'follower_count': event.follower_count + }, + 'posts': posts_data, + 'posts_count': len(posts_data), + 'total': pagination.total, + 'current_page': pagination.page, + 'total_pages': pagination.pages, + 'comments': comments_data + } + }) + + except Exception as e: + return jsonify({ + 'success': False, + 'message': '获取评论列表失败', + 'error': str(e) + }), 500 + + +@app.route('/api/comment//replies', methods=['GET']) +def get_comment_replies(comment_id): + """获取某条评论的所有回复 + + Query参数: + - page: 页码(默认1) + - per_page: 每页回复数(默认20) + - sort: 排序方式(time_desc/time_asc, 默认time_desc) + + 返回格式: + { + "code": 200, + "message": "success", + "data": { + "comment": { # 原评论信息 + "id": 评论ID, + "content": "评论内容", + "created_at": "评论时间", + "user": { + "id": 用户ID, + "nickname": "用户昵称", + "avatar_url": "头像URL" + } + }, + "replies": { # 回复信息 + "total": 总回复数, + "current_page": 当前页码, + "total_pages": 总页数, + "items": [ + { + "id": 回复ID, + "content": "回复内容", + "created_at": "回复时间", + "user": { + "id": 用户ID, + "nickname": "用户昵称", + "avatar_url": "头像URL" + }, + "reply_to": { # 被回复的用户信息 + "id": 用户ID, + "nickname": "用户昵称" + } + } + ] + } + } + } + """ + try: + # 获取查询参数 + page = request.args.get('page', 1, type=int) + per_page = request.args.get('per_page', 20, type=int) + sort = request.args.get('sort', 'time_desc') + + # 参数验证 + if page < 1: + page = 1 + if per_page < 1 or per_page > 100: + per_page = 20 + + # 获取原评论信息 + comment = Comment.query.get_or_404(comment_id) + if comment.status != 'active': + return jsonify({ + 'code': 404, + 'message': '评论不存在或已被删除', + 'data': None + }), 404 + + # 构建原评论数据 + comment_data = { + 'comment_id': comment.id, + 'content': comment.content, + 'created_at': comment.created_at.strftime('%Y-%m-%d %H:%M:%S'), + 'user': { + 'user_id': comment.user.id, + 'nickname': comment.user.nickname or comment.user.username, + 'avatar_url': get_full_avatar_url(comment.user.avatar_url), # 修改这里 + } + } + + # 构建回复查询 + replies_query = Comment.query.filter_by( + parent_id=comment_id, + status='active' + ) + + # 排序处理 + if sort == 'time_asc': + replies_query = replies_query.order_by(Comment.created_at.asc()) + else: # 默认按时间倒序 + replies_query = replies_query.order_by(Comment.created_at.desc()) + + # 执行分页查询 + pagination = replies_query.paginate(page=page, per_page=per_page, error_out=False) + + # 格式化回复数据 + replies_data = [] + for reply in pagination.items: + # 获取被回复的用户信息 + reply_to_user = None + if reply.parent_id: + parent_comment = Comment.query.get(reply.parent_id) + if parent_comment: + reply_to_user = { + 'id': parent_comment.user.id, + 'nickname': parent_comment.user.nickname or parent_comment.user.username + } + + replies_data.append({ + 'reply_id': reply.id, + 'content': reply.content, + 'created_at': reply.created_at.strftime('%Y-%m-%d %H:%M:%S'), + 'user': { + 'user_id': reply.user.id, + 'nickname': reply.user.nickname or reply.user.username, + 'avatar_url': get_full_avatar_url(reply.user.avatar_url), # 修改这里 + }, + 'reply_to': reply_to_user + }) + + return jsonify({ + 'code': 200, + 'message': 'success', + 'data': { + 'comment': comment_data, + 'replies': { + 'total': pagination.total, + 'current_page': pagination.page, + 'total_pages': pagination.pages, + 'items': replies_data + } + } + }) + + except Exception as e: + return jsonify({ + 'code': 500, + 'message': str(e), + 'data': None + }), 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 符号和多余的换行符 + + 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. 投资日历-事件接口(增强版) +@app.route('/api/calendar/events', methods=['GET']) +def api_calendar_events(): + """投资日历事件接口 - 连接 future_events 表 (修正版)""" + try: + start_date = request.args.get('start') + end_date = request.args.get('end') + importance = request.args.get('importance', 'all') + category = request.args.get('category', 'all') + search_query = request.args.get('q', '').strip() # 新增搜索参数 + page = int(request.args.get('page', 1)) + per_page = int(request.args.get('per_page', 10)) + offset = (page - 1) * per_page + + # 构建基础查询 - 使用 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 \ + """ + + params = {} + + if start_date: + query += " AND calendar_time >= :start_date" + params['start_date'] = datetime.fromisoformat(start_date) + if end_date: + query += " AND calendar_time <= :end_date" + params['end_date'] = datetime.fromisoformat(end_date) + if importance != 'all': + query += " AND star = :importance" + params['importance'] = importance + if category != 'all': + # category参数用于筛选inferred_tag字段(如"大周期"、"大消费"等) + query += " AND inferred_tag = :category" + params['category'] = category + + # 新增搜索条件 + if search_query: + # 使用LIKE进行模糊搜索,同时搜索title和related_stocks字段 + # 对于JSON字段,MySQL会将其作为文本进行搜索 + query += """ AND ( + title LIKE :search_pattern + OR CAST(related_stocks AS CHAR) LIKE :search_pattern + OR CAST(concepts AS CHAR) LIKE :search_pattern + )""" + params['search_pattern'] = f'%{search_query}%' + + query += " ORDER BY calendar_time LIMIT :limit OFFSET :offset" + params['limit'] = per_page + params['offset'] = offset + + result = db.session.execute(text(query), params) + events = result.fetchall() + + # 总数统计(不包含分页) + count_query = """ + SELECT COUNT(*) as count \ + FROM future_events \ + WHERE 1=1 \ + """ + count_params = params.copy() + count_params.pop('limit', None) + count_params.pop('offset', None) + + if start_date: + count_query += " AND calendar_time >= :start_date" + if end_date: + count_query += " AND calendar_time <= :end_date" + if importance != 'all': + count_query += " AND star = :importance" + if category != 'all': + count_query += " AND inferred_tag = :category" + + # 新增搜索条件到计数查询 + if search_query: + count_query += """ AND ( + title LIKE :search_pattern + OR CAST(related_stocks AS CHAR) LIKE :search_pattern + OR CAST(concepts AS CHAR) LIKE :search_pattern + )""" + + total_count_result = db.session.execute(text(count_query), count_params).fetchone() + total_count = total_count_result.count if total_count_result else 0 + + events_data = [] + for event in events: + # 解析相关股票 + related_stocks_list = [] + related_avg_chg = 0 + related_max_chg = 0 + related_week_chg = 0 + + # 处理相关股票数据 + if event.related_stocks: + try: + import json + import ast + + # 使用与detail接口相同的解析逻辑 + if isinstance(event.related_stocks, str): + try: + stock_data = json.loads(event.related_stocks) + except: + stock_data = ast.literal_eval(event.related_stocks) + else: + stock_data = event.related_stocks + + if stock_data: + daily_changes = [] + week_changes = [] + + # 处理正确的数据格式 [股票代码, 股票名称, 描述, 分数] + for stock_info in stock_data: + if isinstance(stock_info, list) and len(stock_info) >= 2: + stock_code = stock_info[0] # 股票代码 + stock_name = stock_info[1] # 股票名称 + description = stock_info[2] if len(stock_info) > 2 else '' + score = stock_info[3] if len(stock_info) > 3 else 0 + else: + continue + + if stock_code: + # 规范化股票代码,移除后缀 + clean_code = stock_code.replace('.SZ', '').replace('.SH', '').replace('.BJ', '') + + # 使用模糊匹配查询真实的交易数据 + 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 \ + """ + trade_result = db.session.execute(text(trade_query), + {'stock_code_pattern': f'{clean_code}%'}) + trade_data = trade_result.fetchall() + + daily_chg = 0 + week_chg = 0 + + if trade_data: + # 日涨跌幅(当日) + daily_chg = float(trade_data[0].change_pct or 0) + + # 周涨跌幅(5个交易日) + if len(trade_data) >= 5: + current_price = float(trade_data[0].close_price or 0) + week_ago_price = float(trade_data[4].close_price or 0) + if week_ago_price > 0: + week_chg = ((current_price - week_ago_price) / week_ago_price) * 100 + + # 收集涨跌幅数据 + daily_changes.append(daily_chg) + week_changes.append(week_chg) + + related_stocks_list.append({ + 'code': stock_code, + 'name': stock_name, + 'description': description, + 'score': score, + 'daily_chg': daily_chg, + 'week_chg': week_chg + }) + + # 计算平均收益率 + if daily_changes: + related_avg_chg = round(sum(daily_changes) / len(daily_changes), 4) + related_max_chg = round(max(daily_changes), 4) + + if week_changes: + related_week_chg = round(sum(week_changes) / len(week_changes), 4) + + except Exception as e: + print(f"Error processing related stocks for event {event.data_id}: {e}") + + # 解析相关概念 + related_concepts = extract_concepts_from_concepts_field(event.concepts) + + # 获取评星等级 + star_rating = event.star + + # 如果有搜索关键词,可以高亮显示匹配的部分(可选功能) + highlight_match = False + if search_query: + # 检查是否在标题中匹配 + if search_query.lower() in (event.title or '').lower(): + highlight_match = 'title' + # 检查是否在股票中匹配 + elif any(search_query.lower() in str(stock).lower() for stock in related_stocks_list): + highlight_match = 'stocks' + # 检查是否在概念中匹配 + elif search_query.lower() in str(related_concepts).lower(): + highlight_match = 'concepts' + + # 将转义的换行符转换为真正的换行符,保留 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, + 'title': event.title, + '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, + 'end_time': None, # future_events 表没有结束时间 + 'category': { + 'event_type': event.type, + 'importance': event.star, + 'star_rating': star_rating, + 'inferred_tag': event.inferred_tag # 添加inferred_tag到返回数据 + }, + 'star_rating': star_rating, + 'inferred_tag': event.inferred_tag, # 直接返回行业标签 + 'related_concepts': related_concepts, + 'related_stocks': related_stocks_list, + 'related_avg_chg': round(related_avg_chg, 2), + 'related_max_chg': round(related_max_chg, 2), + 'related_week_chg': round(related_week_chg, 2), + 'former': cleaned_former, + 'forecast': cleaned_forecast, + 'fact': cleaned_fact + } + + # 可选:添加搜索匹配标记 + if search_query and highlight_match: + event_dict['search_match'] = highlight_match + + events_data.append(event_dict) + + return jsonify({ + 'code': 200, + 'message': 'success', + 'data': { + 'events': events_data, + 'total_count': total_count, + 'page': page, + 'per_page': per_page, + 'total_pages': (total_count + per_page - 1) // per_page, + 'search_query': search_query # 返回搜索关键词 + } + }) + + except Exception as e: + return jsonify({ + 'code': 500, + 'message': str(e), + 'data': None + }), 500 + + +# 11. 投资日历-数据接口 +@app.route('/api/calendar/data', methods=['GET']) +def api_calendar_data(): + """投资日历数据接口""" + try: + start_date = request.args.get('start') + end_date = request.args.get('end') + data_type = request.args.get('type', 'all') + + # 分页参数 + page = int(request.args.get('page', 1)) + page_size = int(request.args.get('page_size', 20)) # 默认每页20条 + + # 验证分页参数 + if page < 1: + page = 1 + if page_size < 1 or page_size > 100: # 限制每页最大100条 + page_size = 20 + + query1 = RelatedData.query + + if start_date: + query1 = query1.filter(RelatedData.created_at >= datetime.fromisoformat(start_date)) + if end_date: + query1 = query1.filter(RelatedData.created_at <= datetime.fromisoformat(end_date)) + if data_type != 'all': + query1 = query1.filter_by(data_type=data_type) + + 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' \ + """ + + # 添加时间筛选条件 + params = {} + if start_date: + query2_sql += " AND calendar_time >= :start_date" + params['start_date'] = start_date + if end_date: + query2_sql += " AND calendar_time <= :end_date" + params['end_date'] = end_date + if data_type != 'all': + query2_sql += " AND type = :data_type" + params['data_type'] = data_type + + query2_sql += " ORDER BY calendar_time DESC" + + result2 = db.session.execute(text(query2_sql), params) + + result_data = [] + + # 处理 RelatedData 的数据 + for data in data_list1: + result_data.append({ + 'id': data.id, + 'title': data.title, + 'data_type': data.data_type, + 'data_content': data.data_content, + 'description': data.description, + 'created_at': data.created_at.isoformat() if data.created_at else None, + 'event_id': data.event_id, + 'source': 'related_data', # 标识数据来源 + 'former': None, + 'forecast': None, + 'fact': None, + 'star': None + }) + + # 处理 future_events 的数据 + for row in result2: + result_data.append({ + 'id': row.id, + 'title': row.title, + 'data_type': row.data_type, + 'data_content': None, + 'description': None, + 'created_at': row.created_at.isoformat() if row.created_at else None, + 'event_id': None, + 'source': 'future_events', # 标识数据来源 + 'former': row.former, + 'forecast': row.forecast, + 'fact': row.fact, + 'star': row.star + }) + + # 按时间排序(最新的在前面) + result_data.sort(key=lambda x: x['created_at'] or '1900-01-01', reverse=True) + + # 计算分页 + total_count = len(result_data) + total_pages = (total_count + page_size - 1) // page_size # 向上取整 + + # 计算起始和结束索引 + start_index = (page - 1) * page_size + end_index = start_index + page_size + + # 获取当前页数据 + current_page_data = result_data[start_index:end_index] + + # 分别统计两个数据源的数量(用于原有逻辑) + related_data_count = len(data_list1) + future_events_count = len(list(result2)) + + return jsonify({ + 'code': 200, + 'message': 'success', + 'data': { + 'data_list': current_page_data, + 'pagination': { + 'current_page': page, + 'page_size': page_size, + 'total_count': total_count, + 'total_pages': total_pages, + 'has_next': page < total_pages, + 'has_prev': page > 1 + }, + # 保留原有字段,便于兼容 + 'total_count': total_count, + 'related_data_count': related_data_count, + 'future_events_count': future_events_count + } + }) + except ValueError as ve: + # 处理分页参数格式错误 + return jsonify({ + 'code': 400, + 'message': f'分页参数格式错误: {str(ve)}', + 'data': None + }), 400 + except Exception as e: + return jsonify({ + 'code': 500, + 'message': str(e), + 'data': None + }), 500 + + +# 12. 投资日历-详情接口 +def extract_concepts_from_concepts_field(concepts_text): + """从concepts字段中提取概念信息""" + if not concepts_text: + return [] + + try: + import json + import ast + + # 解析concepts字段的JSON/字符串数据 + if isinstance(concepts_text, str): + try: + # 先尝试JSON解析 + concepts_data = json.loads(concepts_text) + except: + # 如果JSON解析失败,尝试ast.literal_eval解析 + concepts_data = ast.literal_eval(concepts_text) + else: + concepts_data = concepts_text + + extracted_concepts = [] + for concept_info in concepts_data: + if isinstance(concept_info, list) and len(concept_info) >= 3: + concept_name = concept_info[0] # 概念名称 + reason = concept_info[1] # 原因/描述 + score = concept_info[2] # 分数 + + extracted_concepts.append({ + 'name': concept_name, + 'reason': reason, + 'score': score + }) + + return extracted_concepts + except Exception as e: + print(f"Error extracting concepts: {e}") + return [] + + +@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 \ + """ + + result = db.session.execute(text(query), {'item_id': item_id}) + event = result.fetchone() + + if not event: + return jsonify({ + 'code': 404, + 'message': 'Event not found', + 'data': None + }), 404 + + extracted_concepts = extract_concepts_from_concepts_field(event.concepts) + + # 解析相关股票 + related_stocks_list = [] + sector_stats = { + '全部股票': 0, + '大周期': 0, + '大消费': 0, + 'TMT板块': 0, + '大金融地产': 0, + '公共产业板块': 0, + '其他': 0 + } + + # 申万一级行业到主板块的映射 + sector_map = { + # 大周期 + '石油石化': '大周期', '煤炭': '大周期', '有色金属': '大周期', + '钢铁': '大周期', '基础化工': '大周期', '建筑材料': '大周期', + '机械设备': '大周期', '电力设备及新能源': '大周期', '国防军工': '大周期', + '电力设备': '大周期', '电网设备': '大周期', '风力发电': '大周期', + '太阳能发电': '大周期', '建筑装饰': '大周期', '建筑': '大周期', + '交通运输': '大周期', '采掘': '大周期', '公用事业': '大周期', + + # 大消费 + '汽车': '大消费', '家用电器': '大消费', '酒类': '大消费', + '食品饮料': '大消费', '医药生物': '大消费', '纺织服饰': '大消费', + '农林牧渔': '大消费', '商贸零售': '大消费', '轻工制造': '大消费', + '消费者服务': '大消费', '美容护理': '大消费', '社会服务': '大消费', + '纺织服装': '大消费', '商业贸易': '大消费', '休闲服务': '大消费', + + # 大金融地产 + '银行': '大金融地产', '证券': '大金融地产', '保险': '大金融地产', + '多元金融': '大金融地产', '综合金融': '大金融地产', + '房地产': '大金融地产', '非银金融': '大金融地产', + + # TMT板块 + '计算机': 'TMT板块', '电子': 'TMT板块', '传媒': 'TMT板块', '通信': 'TMT板块', + + # 公共产业 + '环保': '公共产业板块', '综合': '公共产业板块' + } + + # 处理相关股票 + related_avg_chg = 0 + related_max_chg = 0 + related_week_chg = 0 + + if event.related_stocks: + try: + import json + import ast + + # **修正:正确解析related_stocks数据结构** + if isinstance(event.related_stocks, str): + try: + # 先尝试JSON解析 + stock_data = json.loads(event.related_stocks) + except: + # 如果JSON解析失败,尝试ast.literal_eval解析 + stock_data = ast.literal_eval(event.related_stocks) + else: + stock_data = event.related_stocks + + print(f"Parsed stock_data: {stock_data}") # 调试输出 + + if stock_data: + daily_changes = [] + week_changes = [] + + # **修正:处理正确的数据格式 [股票代码, 股票名称, 描述, 分数]** + for stock_info in stock_data: + if isinstance(stock_info, list) and len(stock_info) >= 2: + stock_code = stock_info[0] # 第一个元素是股票代码 + stock_name = stock_info[1] # 第二个元素是股票名称 + description = stock_info[2] if len(stock_info) > 2 else '' + score = stock_info[3] if len(stock_info) > 3 else 0 + else: + continue # 跳过格式不正确的数据 + + if stock_code: + # 规范化股票代码,移除后缀 + clean_code = stock_code.replace('.SZ', '').replace('.SH', '').replace('.BJ', '') + + print(f"Processing stock: {clean_code} - {stock_name}") # 调试输出 + + # 使用模糊匹配LIKE查询申万一级行业F004V + sector_query = """ + 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() + + # 根据申万一级行业(F004V)映射到主板块 + sw_primary_sector = sector_row.sw_primary_sector if sector_row else None + primary_sector = sector_map.get(sw_primary_sector, '其他') if sw_primary_sector else '其他' + + print( + f"Stock: {clean_code}, SW Primary: {sw_primary_sector}, Primary Sector: {primary_sector}") + + # 通过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 \ + """ + trade_result = db.session.execute(text(trade_query), + {'stock_code_pattern': f'{clean_code}%'}) + trade_data = trade_result.fetchall() + + daily_chg = 0 + week_chg = 0 + + if trade_data: + # 日涨跌幅(当日) + daily_chg = float(trade_data[0].change_pct or 0) + + # 周涨跌幅(5个交易日) + if len(trade_data) >= 5: + current_price = float(trade_data[0].close_price or 0) + week_ago_price = float(trade_data[4].close_price or 0) + if week_ago_price > 0: + week_chg = ((current_price - week_ago_price) / week_ago_price) * 100 + + print( + f"Trade data found: {len(trade_data) if trade_data else 0} records, daily_chg: {daily_chg}") + + # 统计各分类数量 + sector_stats['全部股票'] += 1 + sector_stats[primary_sector] += 1 + + # 收集涨跌幅数据 + daily_changes.append(daily_chg) + week_changes.append(week_chg) + + related_stocks_list.append({ + 'code': stock_code, # 原始股票代码 + 'name': stock_name, # 股票名称 + 'description': description, # 关联描述 + 'score': score, # 关联分数 + 'sw_primary_sector': sw_primary_sector, # 申万一级行业(F004V) + 'primary_sector': primary_sector, # 主板块分类 + 'daily_change': daily_chg, # 真实的日涨跌幅 + 'week_change': week_chg # 真实的周涨跌幅 + }) + + # 计算平均收益率 + if daily_changes: + related_avg_chg = sum(daily_changes) / len(daily_changes) + related_max_chg = max(daily_changes) + + if week_changes: + related_week_chg = sum(week_changes) / len(week_changes) + + except Exception as e: + print(f"Error processing related stocks: {e}") + import traceback + traceback.print_exc() + + # 构建返回数据 + detail_data = { + 'id': event.data_id, + 'title': event.title, + 'type': event.type, + 'star': event.star, + 'calendar_time': event.calendar_time.isoformat() if event.calendar_time else None, + 'former': event.former, + 'forecast': event.forecast, + 'fact': event.fact, + 'concepts': event.concepts, + 'extracted_concepts': extracted_concepts, + 'related_stocks': related_stocks_list, + 'sector_stats': sector_stats, + 'related_avg_chg': round(related_avg_chg, 2), + 'related_max_chg': round(related_max_chg, 2), + 'related_week_chg': round(related_week_chg, 2) + } + + return jsonify({ + 'code': 200, + 'message': 'success', + 'data': { + 'type': 'future_event', + 'detail': detail_data + } + }) + + except Exception as e: + return jsonify({ + 'code': 500, + 'message': str(e), + 'data': None + }), 500 + + +# 13-15. 筛选弹窗接口(已有,优化格式) +@app.route('/api/filter/options', methods=['GET']) +def api_filter_options(): + """筛选选项接口""" + try: + # 获取排序选项 + sort_options = [ + {'key': 'new', 'name': '最新', 'desc': '按创建时间排序'}, + {'key': 'hot', 'name': '热门', 'desc': '按热度分数排序'}, + {'key': 'returns', 'name': '收益率', 'desc': '按收益率排序'}, + {'key': 'importance', 'name': '重要性', 'desc': '按重要性等级排序'}, + {'key': 'view_count', 'name': '浏览量', 'desc': '按浏览次数排序'} + ] + + # 获取行业筛选选项 + 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() + + # 获取重要性选项 + importance_options = [ + {'key': 'S', 'name': 'S级', 'desc': '重大事件'}, + {'key': 'A', 'name': 'A级', 'desc': '重要事件'}, + {'key': 'B', 'name': 'B级', 'desc': '普通事件'}, + {'key': 'C', 'name': 'C级', 'desc': '参考事件'} + ] + + return jsonify({ + 'code': 200, + 'message': 'success', + 'data': { + 'sort_options': sort_options, + 'industry_options': [{ + 'name': row.classification_name, + 'count': row.count + } for row in industry_options], + 'importance_options': importance_options + } + }) + except Exception as e: + return jsonify({ + 'code': 500, + 'message': str(e), + 'data': None + }), 500 + + +# 16-17. 会员权益接口 +@app.route('/api/membership/status', methods=['GET']) +@token_required +def api_membership_status(): + """会员状态接口""" + try: + user = request.user + + # TODO: 根据实际业务逻辑判断会员状态 + # 这里假设用户表中有会员相关字段 + is_member = getattr(user, 'is_member', False) + member_expire_date = getattr(user, 'member_expire_date', None) + + return jsonify({ + 'code': 200, + 'message': 'success', + 'data': { + 'user_id': user.id, + 'is_member': is_member, + 'member_expire_date': member_expire_date.isoformat() if member_expire_date else None, + 'user_level': user.user_level, + 'benefits': { + 'unlimited_access': is_member, + 'priority_support': is_member, + 'advanced_analytics': is_member, + 'custom_alerts': is_member + } + } + }) + except Exception as e: + return jsonify({ + 'code': 500, + 'message': str(e), + 'data': None + }), 500 + + +# 18-19. 个人中心接口 +@app.route('/api/user/profile', methods=['GET']) +@token_required +def api_user_profile(): + """个人资料接口""" + try: + user = request.user + + likes_count = PostLike.query.filter_by(user_id=user.id).count() + follows_count = EventFollow.query.filter_by(user_id=user.id).count() + comments_made = Comment.query.filter_by(user_id=user.id).count() + + comments_received = db.session.query(Comment) \ + .join(Post, Comment.post_id == Post.id) \ + .filter(Post.user_id == user.id).count() + + replies_received = Comment.query.filter( + Comment.parent_id.in_( + db.session.query(Comment.id).filter_by(user_id=user.id) + ) + ).count() + + # 总评论数(发出的评论 + 收到的评论和回复) + total_comments = comments_made + comments_received + replies_received + profile_data = { + 'basic_info': { + 'user_id': user.id, + 'username': user.username, + 'email': user.email, + 'phone': user.phone, + 'nickname': user.nickname, + 'avatar_url': get_full_avatar_url(user.avatar_url), # 修改这里 + 'bio': user.bio, + 'gender': user.gender, + 'birth_date': user.birth_date.isoformat() if user.birth_date else None, + 'location': user.location + }, + 'account_status': { + 'email_confirmed': user.email_confirmed, + 'phone_confirmed': user.phone_confirmed, + 'is_verified': user.is_verified, + 'verify_time': user.verify_time.isoformat() if user.verify_time else None, + 'created_at': user.created_at.isoformat() if user.created_at else None, + 'last_seen': user.last_seen.isoformat() if user.last_seen else None + }, + 'statistics': { + 'likes_count': likes_count, # 点赞数 + 'follows_count': follows_count, # 关注数 + 'total_comments': total_comments, # 总评论数 + 'comments_detail': { + 'comments_made': comments_made, # 发出的评论 + 'comments_received': comments_received, # 收到的评论 + 'replies_received': replies_received # 收到的回复 + } + }, + 'investment_preferences': { + 'trading_experience': user.trading_experience, + 'investment_style': user.investment_style, + 'risk_preference': user.risk_preference, + 'investment_amount': user.investment_amount, + 'preferred_markets': user.preferred_markets + }, + 'community_stats': { + 'user_level': user.user_level, + 'reputation_score': user.reputation_score, + 'contribution_point': user.contribution_point, + 'post_count': user.post_count, + 'comment_count': user.comment_count, + 'follower_count': user.follower_count, + 'following_count': user.following_count + }, + 'settings': { + 'email_notifications': user.email_notifications, + 'sms_notifications': user.sms_notifications, + 'privacy_level': user.privacy_level, + 'theme_preference': user.theme_preference + } + } + + return jsonify({ + 'code': 200, + 'message': 'success', + 'data': profile_data + }) + except Exception as e: + return jsonify({ + 'code': 500, + 'message': str(e), + 'data': None + }), 500 + + +# 在文件开头添加缓存变量 +_agreements_cache = {} +_cache_loaded = False + + +def load_agreements_from_docx(): + """从docx文件中加载协议内容,只读取一次""" + global _agreements_cache, _cache_loaded + + if _cache_loaded: + return _agreements_cache + + try: + # 定义文件路径和对应的协议类型 + docx_files = { + 'about_us': 'about_us.docx', # 关于我们 + 'service_terms': 'service_terms.docx', # 服务条款 + 'privacy_policy': 'privacy_policy.docx' # 隐私政策 + } + + # 定义协议标题 + titles = { + 'about_us': '关于我们', + 'service_terms': '服务条款', + 'privacy_policy': '隐私政策' + } + + for agreement_type, filename in docx_files.items(): + file_path = os.path.join(os.path.dirname(__file__), filename) + + if os.path.exists(file_path): + try: + # 读取docx文件 + doc = Document(file_path) + + # 提取文本内容 + content_paragraphs = [] + for paragraph in doc.paragraphs: + if paragraph.text.strip(): # 跳过空段落 + content_paragraphs.append(paragraph.text.strip()) + + # 合并所有段落 + content = '\n\n'.join(content_paragraphs) + + # 获取文件修改时间作为版本标识 + file_stat = os.stat(file_path) + last_modified = file_stat.st_mtime + + # 缓存内容 + _agreements_cache[agreement_type] = { + 'title': titles.get(agreement_type, agreement_type), + 'content': content, + 'last_updated': last_modified, + 'version': '1.0', + 'file_path': filename + } + + print(f"Successfully loaded {agreement_type} from {filename}") + + except Exception as e: + print(f"Error reading {filename}: {str(e)}") + # 如果读取失败,使用默认内容 + _agreements_cache[agreement_type] = { + 'title': titles.get(agreement_type, agreement_type), + 'content': f"协议内容正在加载中,请稍后再试。(文件:{filename})", + 'last_updated': None, + 'version': '1.0', + 'file_path': filename, + 'error': str(e) + } + else: + print(f"File not found: {filename}") + # 如果文件不存在,使用默认内容 + _agreements_cache[agreement_type] = { + 'title': titles.get(agreement_type, agreement_type), + 'content': f"协议文件未找到,请联系管理员。(文件:{filename})", + 'last_updated': None, + 'version': '1.0', + 'file_path': filename, + 'error': 'File not found' + } + + _cache_loaded = True + print(f"Agreements cache loaded successfully. Total: {len(_agreements_cache)} agreements") + + except Exception as e: + print(f"Error loading agreements: {str(e)}") + _cache_loaded = False + + return _agreements_cache + + +@app.route('/api/agreements', methods=['GET']) +def api_agreements(): + """平台协议接口 - 从docx文件读取""" + try: + # 获取查询参数 + agreement_type = request.args.get('type') # about_us, service_terms, privacy_policy + force_reload = request.args.get('reload', 'false').lower() == 'true' # 强制重新加载 + + # 如果需要强制重新加载,清除缓存 + if force_reload: + global _cache_loaded + _cache_loaded = False + _agreements_cache.clear() + + # 加载协议内容 + agreements_data = load_agreements_from_docx() + + if not agreements_data: + return jsonify({ + 'code': 500, + 'message': 'Failed to load agreements', + 'data': None + }), 500 + + # 如果指定了特定协议类型,只返回该协议 + if agreement_type and agreement_type in agreements_data: + return jsonify({ + 'code': 200, + 'message': 'success', + 'data': { + 'agreement_type': agreement_type, + **agreements_data[agreement_type] + } + }) + + # 返回所有协议 + return jsonify({ + 'code': 200, + 'message': 'success', + 'data': { + 'agreements': agreements_data, + 'available_types': list(agreements_data.keys()), + 'cache_loaded': _cache_loaded, + 'total_agreements': len(agreements_data) + } + }) + + except Exception as e: + return jsonify({ + 'code': 500, + 'message': str(e), + 'data': None + }), 500 + + +# 20. 个人中心-我的关注接口 +@app.route('/api/user/activities', methods=['GET']) +@token_required +def api_user_activities(): + """用户活动接口(我的关注、评论、点赞)""" + try: + user = request.user + activity_type = request.args.get('type', 'follows') # follows, comments, likes, commented + page = request.args.get('page', 1, type=int) + per_page = min(50, request.args.get('per_page', 20, type=int)) + + if activity_type == 'follows': + # 我的关注列表 + follows = EventFollow.query.filter_by(user_id=user.id) \ + .order_by(EventFollow.created_at.desc()) \ + .paginate(page=page, per_page=per_page, error_out=False) + + activities = [] + for follow in follows.items: + # 获取相关股票并添加单日涨幅 + related_stocks_data = [] + for stock in follow.event.related_stocks.limit(5): + # 处理股票代码,移除可能的后缀(如 .SZ 或 .SH) + base_stock_code = stock.stock_code.split('.')[0] + + # 获取股票最新交易数据 + trade_data = TradeData.query.filter( + TradeData.SECCODE.startswith(base_stock_code) + ).order_by(TradeData.TRADEDATE.desc()).first() + + # 计算单日涨幅 + daily_change = None + if trade_data and trade_data.F010N is not None: + daily_change = float(trade_data.F010N) + + related_stocks_data.append({ + 'stock_code': stock.stock_code, + 'stock_name': stock.stock_name, + 'correlation': stock.correlation, + 'daily_change': daily_change, # 新增:单日涨幅 + 'daily_change_formatted': f"{daily_change:.2f}%" if daily_change is not None else "暂无数据" + # 格式化显示 + }) + + activities.append({ + 'event_id': follow.event_id, + 'event_title': follow.event.title, + 'event_description': follow.event.description, + 'follow_time': follow.created_at.isoformat() if follow.created_at else None, + 'event_hot_score': follow.event.hot_score, + # 新增字段 + 'importance': follow.event.importance, # 重要性 + 'related_avg_chg': follow.event.related_avg_chg, # 平均涨幅 + 'related_max_chg': follow.event.related_max_chg, # 最大涨幅 + 'related_week_chg': follow.event.related_week_chg, # 周涨幅 + 'related_stocks': related_stocks_data, # 修改:包含单日涨幅的相关股票 + 'created_at': follow.event.created_at.isoformat() if follow.event.created_at else None, # 发布时间 + 'preview': follow.event.description[:200] if follow.event.description else None, # 预览(限制200字) + 'comment_count': follow.event.post_count, # 评论数 + 'view_count': follow.event.view_count, # 评论数 + 'follower_count': follow.event.follower_count # 关注数 + }) + + total = follows.total + pages = follows.pages + + elif activity_type == 'likes': + # 我的点赞列表 + likes = PostLike.query.filter_by(user_id=user.id) \ + .order_by(PostLike.created_at.desc()) \ + .paginate(page=page, per_page=per_page, error_out=False) + + activities = [{ + 'like_id': like.id, + 'post_id': like.post_id, + 'post_content': like.post.content, + 'like_time': like.created_at.isoformat() if like.created_at else None, + # 新增发布人信息 + 'author': { + 'nickname': like.post.user.nickname or like.post.user.username, + 'avatar_url': get_full_avatar_url(like.post.user.avatar_url), # 修改这里 + } + } for like in likes.items] + + total = likes.total + pages = likes.pages + + elif activity_type == 'comments': + # 我的评论列表(增强版 - 添加重要性和事件内容) + comments = Comment.query.filter_by(user_id=user.id) \ + .join(Post, Comment.post_id == Post.id) \ + .join(Event, Post.event_id == Event.id) \ + .order_by(Comment.created_at.desc()) \ + .paginate(page=page, per_page=per_page, error_out=False) + + activities = [] + for comment in comments.items: + # 通过关联路径获取事件信息:comment.post_id -> post.id -> post.event_id -> event.id + post = comment.post + event = post.event if post else None + + activity_data = { + 'comment_id': comment.id, + 'post_id': comment.post_id, + 'content': comment.content, # 评论内容 + 'created_at': comment.created_at.isoformat() if comment.created_at else None, + 'post_title': post.title if post and post.title else None, + 'post_content': post.content if post else None, + + # 新增:评论者信息(当前用户) + 'commenter': { + 'id': comment.user.id, + 'username': comment.user.username, + 'nickname': comment.user.nickname or comment.user.username, + 'avatar_url': get_full_avatar_url(comment.user.avatar_url), + 'user_level': comment.user.user_level, + 'is_verified': comment.user.is_verified + }, + + # 新增字段:事件信息 + 'event': { + 'id': event.id if event else None, + 'title': event.title if event else None, + 'description': event.description if event else None, # 事件内容 + 'importance': event.importance if event else None, # 重要性 + 'event_type': event.event_type if event else None, + 'hot_score': event.hot_score if event else None, + 'view_count': event.view_count if event else None, + 'related_avg_chg': event.related_avg_chg if event else None, + 'created_at': event.created_at.isoformat() if event and event.created_at else None + }, + + # 新增:帖子作者信息 + 'post_author': { + 'id': post.user.id if post else None, + 'username': post.user.username if post else None, + 'nickname': post.user.nickname or post.user.username if post else None, + 'avatar_url': get_full_avatar_url(post.user.avatar_url) if post else None, + } + } + activities.append(activity_data) + + total = comments.total + pages = comments.pages + + elif activity_type == 'commented': + # 评论了我的帖子 + my_posts = Post.query.filter_by(user_id=user.id).subquery() + comments = Comment.query.join(my_posts, Comment.post_id == my_posts.c.id) \ + .filter(Comment.user_id != user.id) \ + .order_by(Comment.created_at.desc()) \ + .paginate(page=page, per_page=per_page, error_out=False) + + activities = [{ + 'comment_id': comment.id, + 'comment_content': comment.content, + 'comment_time': comment.created_at.isoformat() if comment.created_at else None, + 'commenter_nickname': comment.user.nickname or comment.user.username, + 'commenter_avatar': get_full_avatar_url(comment.user.avatar_url), # 修改这里 + 'post_content': comment.post.content, + 'event_title': comment.post.event.title, + 'event_id': comment.post.event_id + } for comment in comments.items] + + total = comments.total + pages = comments.pages + + return jsonify({ + 'code': 200, + 'message': 'success', + 'data': { + 'activities': activities, + 'total': total, + 'pages': pages, + 'current_page': page + } + }) + + except Exception as e: + print(f"Error in api_user_activities: {str(e)}") + return jsonify({ + 'code': 500, + 'message': '服务器内部错误', + 'data': None + }), 500 + + +class UserFeedback(db.Model): + """用户反馈模型""" + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) + type = db.Column(db.String(50), nullable=False) # 反馈类型 + content = db.Column(db.Text, nullable=False) # 反馈内容 + contact_info = db.Column(db.String(100)) # 联系方式 + status = db.Column(db.String(20), default='pending') # 状态:pending/processing/resolved/closed + admin_reply = db.Column(db.Text) # 管理员回复 + created_at = db.Column(db.DateTime, default=beijing_now) + updated_at = db.Column(db.DateTime, default=beijing_now, onupdate=beijing_now) + + # 关联关系 + user = db.relationship('User', backref='feedbacks') + + def __init__(self, user_id, type, content, contact_info=None): + self.user_id = user_id + self.type = type + self.content = content + self.contact_info = contact_info + + def to_dict(self): + return { + 'id': self.id, + 'type': self.type, + 'content': self.content, + 'contact_info': self.contact_info, + 'status': self.status, + 'admin_reply': self.admin_reply, + 'created_at': self.created_at.strftime('%Y-%m-%d %H:%M:%S'), + 'updated_at': self.updated_at.strftime('%Y-%m-%d %H:%M:%S') + } + + +# 通用错误处理 +@app.errorhandler(404) +def api_not_found(error): + if request.path.startswith('/api/'): + return jsonify({ + 'code': 404, + 'message': '接口不存在', + 'data': None + }), 404 + return error + + +@app.errorhandler(405) +def api_method_not_allowed(error): + if request.path.startswith('/api/'): + return jsonify({ + 'code': 405, + 'message': '请求方法不允许', + 'data': None + }), 405 + return error + + +if __name__ == '__main__': + # 初始化申银万国行业分类缓存 + with app.app_context(): + init_sywg_industry_cache() + + app.run( + host='0.0.0.0', + port=5002, + debug=True, + ssl_context=( + '/etc/letsencrypt/live/api.valuefrontier.cn/fullchain.pem', + '/etc/letsencrypt/live/api.valuefrontier.cn/privkey.pem' + ) + ) diff --git a/app_vx.py b/app_vx.py index e7fdc81e..fb4c0a20 100644 --- a/app_vx.py +++ b/app_vx.py @@ -2497,272 +2497,12 @@ def api_get_events(): paginated = query.paginate(page=page, per_page=per_page, error_out=False) - # ==================== 批量获取股票行情数据(优化版) ==================== - - # 1. 收集当前页所有事件的ID - event_ids = [event.id for event in paginated.items] - - # 2. 获取所有相关股票 - all_related_stocks = {} - if event_ids: - related_stocks = RelatedStock.query.filter( - RelatedStock.event_id.in_(event_ids) - ).all() - - # 按事件ID分组 - for stock in related_stocks: - if stock.event_id not in all_related_stocks: - all_related_stocks[stock.event_id] = [] - all_related_stocks[stock.event_id].append(stock) - - # 3. 收集所有股票代码 - all_stock_codes = [] - stock_code_mapping = {} # 清理后的代码 -> 原始代码的映射 - - for stocks in all_related_stocks.values(): - for stock in stocks: - clean_code = stock.stock_code.replace('.SH', '').replace('.SZ', '').replace('.BJ', '') - all_stock_codes.append(clean_code) - stock_code_mapping[clean_code] = stock.stock_code - - # 去重 - all_stock_codes = list(set(all_stock_codes)) - - # 4. 批量查询最近7个交易日的数据(用于计算日涨跌和周涨跌) - stock_price_data = {} - - if all_stock_codes: - # 构建SQL查询 - 获取最近7个交易日的数据 - codes_str = "'" + "', '".join(all_stock_codes) + "'" - - # 获取最近7个交易日的数据 - recent_trades_sql = f""" - SELECT - SECCODE, - SECNAME, - F007N as close_price, - F010N as daily_change, - TRADEDATE, - ROW_NUMBER() OVER (PARTITION BY SECCODE ORDER BY TRADEDATE DESC) as rn - FROM ea_trade - WHERE SECCODE IN ({codes_str}) - AND F007N IS NOT NULL - AND TRADEDATE >= DATE_SUB(CURDATE(), INTERVAL 10 DAY) - ORDER BY SECCODE, TRADEDATE DESC - """ - - result = db.session.execute(text(recent_trades_sql)) - - # 整理数据 - for row in result.fetchall(): - sec_code = row[0] - if sec_code not in stock_price_data: - stock_price_data[sec_code] = { - 'stock_name': row[1], - 'prices': [] - } - - stock_price_data[sec_code]['prices'].append({ - 'close_price': float(row[2]) if row[2] else 0, - 'daily_change': float(row[3]) if row[3] else 0, - 'trade_date': row[4], - 'rank': row[5] - }) - - # 5. 计算日涨跌和周涨跌 - stock_changes = {} - - for sec_code, data in stock_price_data.items(): - prices = data['prices'] - - # 最新日涨跌(第1条记录) - daily_change = 0 - if prices and prices[0]['rank'] == 1: - daily_change = prices[0]['daily_change'] - - # 计算周涨跌(最新价 vs 5个交易日前的价格) - week_change = 0 - if len(prices) >= 2: - latest_price = prices[0]['close_price'] - # 找到第5个交易日的数据(如果有) - week_ago_price = None - for price_data in prices: - if price_data['rank'] >= 5: - week_ago_price = price_data['close_price'] - break - - # 如果没有第5天的数据,使用最早的数据 - if week_ago_price is None and len(prices) > 1: - week_ago_price = prices[-1]['close_price'] - - if week_ago_price and week_ago_price > 0: - week_change = (latest_price - week_ago_price) / week_ago_price * 100 - - stock_changes[sec_code] = { - 'stock_name': data['stock_name'], - 'daily_change': daily_change, - 'week_change': week_change - } - - # ==================== 获取整体统计信息(应用所有筛选条件) ==================== - - overall_distribution = { - 'limit_down': 0, - 'down_over_5': 0, - 'down_5_to_1': 0, - 'down_within_1': 0, - 'flat': 0, - 'up_within_1': 0, - 'up_1_to_5': 0, - 'up_over_5': 0, - 'limit_up': 0 - } - - # 使用当前筛选条件的query,但不应用分页限制,获取所有符合条件的事件 - # 这样统计数据会跟随用户的筛选条件变化 - all_filtered_events = query.limit(1000).all() # 限制最多1000个事件,避免查询过慢 - 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() - - # 按事件ID分组 - 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', '') - week_stock_codes.append(clean_code) - week_code_mapping[clean_code] = stock.stock_code - - week_stock_codes = list(set(week_stock_codes)) - - # 批量查询这些股票的最新行情数据 - 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 - elif avg_change >= 10: - overall_distribution['limit_up'] += 1 - elif avg_change > 5: - overall_distribution['up_over_5'] += 1 - elif avg_change > 1: - overall_distribution['up_1_to_5'] += 1 - elif avg_change > 0.1: - overall_distribution['up_within_1'] += 1 - elif avg_change >= -0.1: - overall_distribution['flat'] += 1 - elif avg_change > -1: - overall_distribution['down_within_1'] += 1 - elif avg_change > -5: - overall_distribution['down_5_to_1'] += 1 - else: - overall_distribution['down_over_5'] += 1 - # ==================== 构建响应数据 ==================== events_data = [] for event in paginated.items: - event_stocks = all_related_stocks.get(event.id, []) - stocks_data = [] - total_daily_change = 0 - max_daily_change = -999 - total_week_change = 0 - max_week_change = -999 - valid_stocks_count = 0 - - # 处理每个股票的数据 - for stock in event_stocks: - clean_code = stock.stock_code.replace('.SH', '').replace('.SZ', '').replace('.BJ', '') - stock_info = stock_changes.get(clean_code, {}) - - daily_change = stock_info.get('daily_change', 0) - week_change = stock_info.get('week_change', 0) - - if stock_info: - total_daily_change += daily_change - max_daily_change = max(max_daily_change, daily_change) - total_week_change += week_change - max_week_change = max(max_week_change, week_change) - valid_stocks_count += 1 - - stocks_data.append({ - "stock_code": stock.stock_code, - "stock_name": stock.stock_name, - "sector": stock.sector, - "week_change": round(week_change, 2), - "daily_change": round(daily_change, 2) - }) - - avg_daily_change = total_daily_change / valid_stocks_count if valid_stocks_count > 0 else 0 - avg_week_change = total_week_change / valid_stocks_count if valid_stocks_count > 0 else 0 - - if max_daily_change == -999: - max_daily_change = 0 - if max_week_change == -999: - max_week_change = 0 - - # 构建事件数据 + # 构建事件数据(简化版,只包含基本信息和涨跌幅) event_dict = { 'id': event.id, 'title': event.title, @@ -2774,34 +2514,26 @@ def api_get_events(): 'updated_at': event.updated_at.isoformat() if event.updated_at else None, 'start_time': event.start_time.isoformat() if event.start_time else None, 'end_time': event.end_time.isoformat() if event.end_time else None, - 'related_stocks': stocks_data, - 'stocks_stats': { - 'stocks_count': len(event_stocks), - 'valid_stocks_count': valid_stocks_count, - # 周涨跌统计 - 'avg_week_change': round(avg_week_change, 2), - 'max_week_change': round(max_week_change, 2), - # 日涨跌统计 - 'avg_daily_change': round(avg_daily_change, 2), - 'max_daily_change': round(max_daily_change, 2) - } + # 涨跌幅数据(从数据库字段直接获取) + 'related_avg_chg': event.related_avg_chg, # 平均涨跌幅 + 'related_max_chg': event.related_max_chg, # 最大涨跌幅 + 'related_week_chg': event.related_week_chg, # 周涨跌幅 + # 关联股票数量(固定值) + 'stocks_count': 10 } - # 统计信息 + # 统计信息(可选) if include_stats: event_dict.update({ 'hot_score': event.hot_score, 'view_count': event.view_count, 'post_count': event.post_count, 'follower_count': event.follower_count, - 'related_avg_chg': event.related_avg_chg, - 'related_max_chg': event.related_max_chg, - 'related_week_chg': event.related_week_chg, 'invest_score': event.invest_score, 'trending_score': event.trending_score, }) - # 创建者信息 + # 创建者信息(可选) if include_creator: event_dict['creator'] = { 'id': event.creator.id if event.creator else None, @@ -2815,25 +2547,6 @@ def api_get_events(): event_dict['keywords'] = event.keywords if isinstance(event.keywords, list) else [] event_dict['related_industries'] = event.related_industries - # 包含统计信息 - if include_stats: - event_dict['stats'] = { - 'related_stocks_count': len(event_stocks), - 'historical_events_count': 0, # 需要额外查询 - 'related_data_count': 0, # 需要额外查询 - 'related_concepts_count': 0 # 需要额外查询 - } - - # 包含关联数据 - if include_related_data: - event_dict['related_stocks'] = [{ - 'id': stock.id, - 'stock_code': stock.stock_code, - 'stock_name': stock.stock_name, - 'sector': stock.sector, - 'correlation': float(stock.correlation) if stock.correlation else 0 - } for stock in event_stocks[:5]] # 限制返回5个 - events_data.append(event_dict) # ==================== 构建筛选信息 ==================== @@ -2860,7 +2573,7 @@ def api_get_events(): applied_filters['search_query'] = search_query applied_filters['search_type'] = search_type - # ==================== 返回结果(保持完全兼容) ==================== + # ==================== 返回结果(简化版) ==================== return jsonify({ 'success': True, @@ -2884,14 +2597,6 @@ def api_get_events(): 'sort': sort_by, 'order': order } - }, - # 整体股票涨跌幅分布统计 - 'overall_stats': { - 'total_stocks': len(all_stocks_for_stats) if 'all_stocks_for_stats' in locals() else 0, - 'change_distribution': overall_distribution, - 'change_distribution_percentages': { - k: v for k, v in overall_distribution.items() - } } } })