From f72b52000cb06a096fbb95ee8f059fe2cae72581 Mon Sep 17 00:00:00 2001 From: zzlgreat Date: Fri, 14 Nov 2025 15:08:32 +0800 Subject: [PATCH 1/5] update ui --- app.py | 97 +- app.py.backup | 12556 ++++++++++++++++++++++++++++++++ app.py.backup_20251114_145340 | 12556 ++++++++++++++++++++++++++++++++ 3 files changed, 25176 insertions(+), 33 deletions(-) create mode 100644 app.py.backup create mode 100644 app.py.backup_20251114_145340 diff --git a/app.py b/app.py index b5922c1a..bd9d5431 100755 --- a/app.py +++ b/app.py @@ -8644,7 +8644,8 @@ def get_stock_info(seccode): ORDER BY a.ENDDATE DESC LIMIT 1 """) - result = engine.execute(query, seccode=seccode).fetchone() + with engine.connect() as conn: + result = conn.execute(query, seccode=seccode).fetchone() if not result: return jsonify({ @@ -8667,7 +8668,8 @@ def get_stock_info(seccode): ORDER BY F001D DESC LIMIT 1 """) - forecast_result = engine.execute(forecast_query, seccode=seccode).fetchone() + with engine.connect() as conn: + forecast_result = conn.execute(forecast_query, seccode=seccode).fetchone() data = { 'stock_code': result.SECCODE, @@ -8828,7 +8830,8 @@ def get_balance_sheet(seccode): ORDER BY ENDDATE DESC LIMIT :limit """) - result = engine.execute(query, seccode=seccode, limit=limit) + with engine.connect() as conn: + result = conn.execute(query, seccode=seccode, limit=limit) data = [] for row in result: @@ -9018,7 +9021,8 @@ def get_income_statement(seccode): ORDER BY ENDDATE DESC LIMIT :limit """) - result = engine.execute(query, seccode=seccode, limit=limit) + with engine.connect() as conn: + result = conn.execute(query, seccode=seccode, limit=limit) data = [] for row in result: @@ -9227,7 +9231,8 @@ def get_cashflow(seccode): ORDER BY ENDDATE DESC LIMIT :limit """) - result = engine.execute(query, seccode=seccode, limit=limit) + with engine.connect() as conn: + result = conn.execute(query, seccode=seccode, limit=limit) data = [] for row in result: @@ -9462,7 +9467,8 @@ def get_financial_metrics(seccode): ORDER BY ENDDATE DESC LIMIT :limit """) - result = engine.execute(query, seccode=seccode, limit=limit) + with engine.connect() as conn: + result = conn.execute(query, seccode=seccode, limit=limit) data = [] for row in result: @@ -9602,7 +9608,8 @@ def get_main_business(seccode): ORDER BY ENDDATE DESC LIMIT :limit """) - periods = engine.execute(period_query, seccode=seccode, limit=limit).fetchall() + with engine.connect() as conn: + periods = conn.execute(period_query, seccode=seccode, limit=limit).fetchall() # 产品分类数据 product_data = [] @@ -9620,7 +9627,8 @@ def get_main_business(seccode): ORDER BY F005N DESC """) - result = engine.execute(query, seccode=seccode, enddate=period[0]) + with engine.connect() as conn: + result = conn.execute(query, seccode=seccode, enddate=period[0]) # Convert result to list to allow multiple iterations rows = list(result) @@ -9669,7 +9677,8 @@ def get_main_business(seccode): ORDER BY F007N DESC """) - result = engine.execute(query, seccode=seccode, enddate=period[0]) + with engine.connect() as conn: + result = conn.execute(query, seccode=seccode, enddate=period[0]) # Convert result to list to allow multiple iterations rows = list(result) @@ -9730,7 +9739,8 @@ def get_forecast(seccode): ORDER BY F001D DESC, UPDATE_DATE DESC LIMIT 10 """) - forecast_result = engine.execute(forecast_query, seccode=seccode) + with engine.connect() as conn: + forecast_result = conn.execute(forecast_query, seccode=seccode) forecast_data = [] for row in forecast_result: @@ -9771,7 +9781,8 @@ def get_forecast(seccode): ORDER BY F001D DESC LIMIT 8 """) - pretime_result = engine.execute(pretime_query, seccode=seccode) + with engine.connect() as conn: + pretime_result = conn.execute(pretime_query, seccode=seccode) pretime_data = [] for row in pretime_result: @@ -9870,7 +9881,8 @@ def get_industry_rank(seccode): """) # 获取多个报告期的数据 - result = engine.execute(query, seccode=seccode, limit_total=limit * 4) + with engine.connect() as conn: + result = conn.execute(query, seccode=seccode, limit_total=limit * 4) # 按报告期和行业级别组织数据 data_by_period = {} @@ -9990,7 +10002,8 @@ def get_period_comparison(seccode): ORDER BY fi.ENDDATE DESC LIMIT :periods """) - result = engine.execute(query, seccode=seccode, periods=periods) + with engine.connect() as conn: + result = conn.execute(query, seccode=seccode, periods=periods) data = [] for row in result: @@ -10114,7 +10127,8 @@ def get_trade_data(seccode): LIMIT :days """) - result = engine.execute(query, seccode=seccode, end_date=end_date, days=days) + with engine.connect() as conn: + result = conn.execute(query, seccode=seccode, end_date=end_date, days=days) data = [] for row in result: @@ -10190,7 +10204,8 @@ def get_funding_data(seccode): ORDER BY TRADEDATE DESC LIMIT :days """) - result = engine.execute(query, seccode=seccode, days=days) + with engine.connect() as conn: + result = conn.execute(query, seccode=seccode, days=days) data = [] for row in result: @@ -10248,7 +10263,8 @@ def get_bigdeal_data(seccode): ORDER BY TRADEDATE DESC, F007N LIMIT :days """) - result = engine.execute(query, seccode=seccode, days=days) + with engine.connect() as conn: + result = conn.execute(query, seccode=seccode, days=days) data = [] for row in result: @@ -10322,7 +10338,8 @@ def get_unusual_data(seccode): ORDER BY TRADEDATE DESC, F004N LIMIT 100 """) - result = engine.execute(query, seccode=seccode) + with engine.connect() as conn: + result = conn.execute(query, seccode=seccode) data = [] for row in result: @@ -10400,7 +10417,8 @@ def get_pledge_data(seccode): ORDER BY ENDDATE DESC LIMIT 12 """) - result = engine.execute(query, seccode=seccode) + with engine.connect() as conn: + result = conn.execute(query, seccode=seccode) data = [] for row in result: @@ -10457,9 +10475,12 @@ def get_market_summary(seccode): ORDER BY ENDDATE DESC LIMIT 1 """) - trade_result = engine.execute(trade_query, seccode=seccode).fetchone() - funding_result = engine.execute(funding_query, seccode=seccode).fetchone() - pledge_result = engine.execute(pledge_query, seccode=seccode).fetchone() + with engine.connect() as conn: + trade_result = conn.execute(trade_query, seccode=seccode).fetchone() + with engine.connect() as conn: + funding_result = conn.execute(funding_query, seccode=seccode).fetchone() + with engine.connect() as conn: + pledge_result = conn.execute(pledge_query, seccode=seccode).fetchone() summary = { 'stock_code': seccode, @@ -10954,7 +10975,8 @@ def get_rise_analysis(seccode): ORDER BY trade_date DESC LIMIT 100 """) - result = engine.execute(query, **params).fetchall() + with engine.connect() as conn: + result = conn.execute(query, **params).fetchall() # 格式化数据 rise_analysis_data = [] @@ -11016,7 +11038,8 @@ def get_comprehensive_analysis(company_code): WHERE company_code = :company_code """) - qualitative_result = engine.execute(qualitative_query, company_code=company_code).fetchone() + with engine.connect() as conn: + qualitative_result = conn.execute(qualitative_query, company_code=company_code).fetchone() # 获取业务板块分析 segments_query = text(""" @@ -11033,7 +11056,8 @@ def get_comprehensive_analysis(company_code): ORDER BY created_at DESC """) - segments_result = engine.execute(segments_query, company_code=company_code).fetchall() + with engine.connect() as conn: + segments_result = conn.execute(segments_query, company_code=company_code).fetchall() # 获取竞争地位数据 - 最新一期 competitive_query = text(""" @@ -11058,7 +11082,8 @@ def get_comprehensive_analysis(company_code): ORDER BY report_period DESC LIMIT 1 """) - competitive_result = engine.execute(competitive_query, company_code=company_code).fetchone() + with engine.connect() as conn: + competitive_result = conn.execute(competitive_query, company_code=company_code).fetchone() # 获取业务结构数据 - 最新一期 business_structure_query = text(""" @@ -11085,7 +11110,8 @@ def get_comprehensive_analysis(company_code): ORDER BY revenue_ratio DESC """) - business_structure_result = engine.execute(business_structure_query, company_code=company_code).fetchall() + with engine.connect() as conn: + business_structure_result = conn.execute(business_structure_query, company_code=company_code).fetchall() # 构建返回数据 response_data = { @@ -11222,7 +11248,8 @@ def get_value_chain_analysis(company_code): ORDER BY node_level ASC, importance_score DESC """) - nodes_result = engine.execute(nodes_query, company_code=company_code).fetchall() + with engine.connect() as conn: + nodes_result = conn.execute(nodes_query, company_code=company_code).fetchall() # 获取产业链流向数据 flows_query = text(""" @@ -11242,7 +11269,8 @@ def get_value_chain_analysis(company_code): ORDER BY flow_ratio DESC """) - flows_result = engine.execute(flows_query, company_code=company_code).fetchall() + with engine.connect() as conn: + flows_result = conn.execute(flows_query, company_code=company_code).fetchall() # 构建节点数据结构 nodes_by_level = {} @@ -11352,7 +11380,8 @@ def get_key_factors_timeline(company_code): ORDER BY display_order ASC, created_at ASC """) - categories_result = engine.execute(categories_query, company_code=company_code).fetchall() + with engine.connect() as conn: + categories_result = conn.execute(categories_query, company_code=company_code).fetchall() # 获取关键因素详情 factors_query = text(""" @@ -11417,7 +11446,8 @@ def get_key_factors_timeline(company_code): ORDER BY kf.report_period DESC, kf.impact_weight DESC, kf.updated_at DESC """) - factors_result = engine.execute(factors_query, **params).fetchall() + with engine.connect() as conn: + factors_result = conn.execute(factors_query, **params).fetchall() # 获取发展时间线事件 timeline_query = text(""" @@ -11436,9 +11466,10 @@ def get_key_factors_timeline(company_code): ORDER BY event_date DESC LIMIT :limit """) - timeline_result = engine.execute(timeline_query, - company_code=company_code, - limit=event_limit).fetchall() + with engine.connect() as conn: + timeline_result = conn.execute(timeline_query, + company_code=company_code, + limit=event_limit).fetchall() # 构建关键因素数据结构 key_factors_data = {} diff --git a/app.py.backup b/app.py.backup new file mode 100644 index 00000000..b5922c1a --- /dev/null +++ b/app.py.backup @@ -0,0 +1,12556 @@ +import base64 +import csv +import io +import os +import time +import urllib +import uuid +from functools import wraps +import qrcode +from flask_mail import Mail, Message +from flask_socketio import SocketIO, emit, join_room, leave_room +import pytz +import requests +from celery import Celery +from flask_compress import Compress +from pathlib import Path +import json +from sqlalchemy import Column, Integer, String, Boolean, DateTime, create_engine, text, func, or_ +from flask import Flask, render_template, request, jsonify, redirect, url_for, flash, session, render_template_string, \ + current_app, make_response +from flask_sqlalchemy import SQLAlchemy +from flask_login import LoginManager, UserMixin, login_user, logout_user, login_required, current_user +import random +from werkzeug.security import generate_password_hash, check_password_hash +import re +import string +from datetime import datetime, timedelta, time as dt_time, date +from clickhouse_driver import Client as Cclient +from flask_cors import CORS + +from collections import defaultdict +from functools import lru_cache +import jieba +import jieba.analyse +from flask_cors import cross_origin +from tencentcloud.common import credential +from tencentcloud.common.profile.client_profile import ClientProfile +from tencentcloud.common.profile.http_profile import HttpProfile +from tencentcloud.sms.v20210111 import sms_client, models +from tencentcloud.common.exception.tencent_cloud_sdk_exception import TencentCloudSDKException +from sqlalchemy import text, desc, and_ +import pandas as pd +from decimal import Decimal +from apscheduler.schedulers.background import BackgroundScheduler + +# 交易日数据缓存 +trading_days = [] +trading_days_set = set() + + +def load_trading_days(): + """加载交易日数据""" + global trading_days, trading_days_set + try: + with open('tdays.csv', 'r') as f: + reader = csv.DictReader(f) + for row in reader: + date_str = row['DateTime'] + # 解析日期 (格式: 2010/1/4) + date = datetime.strptime(date_str, '%Y/%m/%d').date() + trading_days.append(date) + trading_days_set.add(date) + + # 排序交易日 + trading_days.sort() + print(f"成功加载 {len(trading_days)} 个交易日数据") + except Exception as e: + print(f"加载交易日数据失败: {e}") + + +def get_trading_day_near_date(target_date): + """ + 获取距离目标日期最近的交易日 + 如果目标日期是交易日,返回该日期 + 如果不是,返回下一个交易日 + """ + if not trading_days: + load_trading_days() + + if not trading_days: + return None + + # 如果目标日期是datetime,转换为date + if isinstance(target_date, datetime): + target_date = target_date.date() + + # 检查目标日期是否是交易日 + if target_date in trading_days_set: + return target_date + + # 查找下一个交易日 + for trading_day in trading_days: + if trading_day >= target_date: + return trading_day + + # 如果没有找到,返回最后一个交易日 + return trading_days[-1] if trading_days else None + + +# 应用启动时加载交易日数据 +load_trading_days() + +engine = create_engine( + "mysql+pymysql://root:Zzl5588161!@222.128.1.157:33060/stock?charset=utf8mb4", + echo=False, + pool_size=10, + pool_recycle=3600, + pool_pre_ping=True, + pool_timeout=30, + max_overflow=20 +) +engine_med = create_engine( + "mysql+pymysql://root:Zzl5588161!@222.128.1.157:33060/med?charset=utf8mb4", + echo=False, + pool_size=5, + pool_recycle=3600, + pool_pre_ping=True, + pool_timeout=30, + max_overflow=10 +) +engine_2 = create_engine( + "mysql+pymysql://root:Zzl5588161!@222.128.1.157:33060/valuefrontier?charset=utf8mb4", + echo=False, + pool_size=5, + pool_recycle=3600, + pool_pre_ping=True, + pool_timeout=30, + max_overflow=10 +) +app = Flask(__name__) +# 存储验证码的临时字典(生产环境应使用Redis) +verification_codes = {} +wechat_qr_sessions = {} +# 腾讯云短信配置 +SMS_SECRET_ID = 'AKID2we9TacdTAhCjCSYTErHVimeJo9Yr00s' +SMS_SECRET_KEY = 'pMlBWijlkgT9fz5ziEXdWEnAPTJzRfkf' +SMS_SDK_APP_ID = "1400972398" +SMS_SIGN_NAME = "价值前沿科技" +SMS_TEMPLATE_REGISTER = "2386557" # 注册模板 +SMS_TEMPLATE_LOGIN = "2386540" # 登录模板 + +# 微信开放平台配置 +WECHAT_APPID = 'wxa8d74c47041b5f87' +WECHAT_APPSECRET = 'eedef95b11787fd7ca7f1acc6c9061bc' +WECHAT_REDIRECT_URI = 'http://valuefrontier.cn/api/auth/wechat/callback' + +# 邮件服务配置(QQ企业邮箱) +MAIL_SERVER = 'smtp.exmail.qq.com' +MAIL_PORT = 465 +MAIL_USE_SSL = True +MAIL_USE_TLS = False +MAIL_USERNAME = 'admin@valuefrontier.cn' +MAIL_PASSWORD = 'QYncRu6WUdASvTg4' +MAIL_DEFAULT_SENDER = 'admin@valuefrontier.cn' + +# Session和安全配置 +app.config['SECRET_KEY'] = ''.join(random.choices(string.ascii_letters + string.digits, k=32)) +app.config['SESSION_COOKIE_SECURE'] = False # 如果生产环境使用HTTPS,应设为True +app.config['SESSION_COOKIE_HTTPONLY'] = True # 生产环境应设为True,防止XSS攻击 +app.config['SESSION_COOKIE_SAMESITE'] = 'Lax' # 使用'Lax'以平衡安全性和功能性 +app.config['SESSION_COOKIE_DOMAIN'] = None # 不限制域名 +app.config['SESSION_COOKIE_PATH'] = '/' # 设置cookie路径 +app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(days=7) # session持续7天 +app.config['REMEMBER_COOKIE_DURATION'] = timedelta(days=30) # 记住登录30天 +app.config['REMEMBER_COOKIE_SECURE'] = False # 记住登录cookie不要求HTTPS +app.config['REMEMBER_COOKIE_HTTPONLY'] = False # 允许JavaScript访问 + +# 配置邮件 +app.config['MAIL_SERVER'] = MAIL_SERVER +app.config['MAIL_PORT'] = MAIL_PORT +app.config['MAIL_USE_SSL'] = MAIL_USE_SSL +app.config['MAIL_USE_TLS'] = MAIL_USE_TLS +app.config['MAIL_USERNAME'] = MAIL_USERNAME +app.config['MAIL_PASSWORD'] = MAIL_PASSWORD +app.config['MAIL_DEFAULT_SENDER'] = MAIL_DEFAULT_SENDER + +# 允许前端跨域访问 - 修复CORS配置 +try: + CORS(app, + origins=["http://localhost:3000", "http://127.0.0.1:3000", "http://localhost:5173", "https://valuefrontier.cn", + "http://valuefrontier.cn"], # 明确指定允许的源 + methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"], + allow_headers=["Content-Type", "Authorization", "X-Requested-With"], + supports_credentials=True, # 允许携带凭据 + expose_headers=["Content-Type", "Authorization"]) +except ImportError: + pass # 如果未安装flask_cors则跳过 + +# 初始化 Flask-Login +login_manager = LoginManager() +login_manager.init_app(app) +login_manager.login_view = 'login' +login_manager.login_message = '请先登录访问此页面' +login_manager.remember_cookie_duration = timedelta(days=30) # 记住登录持续时间 +Compress(app) +MAX_CONTENT_LENGTH = 16 * 1024 * 1024 # 16MB max file size +# Configure Flask-Compress +app.config['COMPRESS_ALGORITHM'] = ['gzip', 'br'] +app.config['COMPRESS_MIMETYPES'] = [ + 'text/html', + 'text/css', + 'text/xml', + 'application/json', + 'application/javascript', + 'application/x-javascript' +] +app.config['SQLALCHEMY_DATABASE_URI'] = 'mysql+pymysql://root:Zzl5588161!@222.128.1.157:33060/stock?charset=utf8mb4' +app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False +app.config['SQLALCHEMY_ENGINE_OPTIONS'] = { + 'pool_size': 10, + 'pool_recycle': 3600, + 'pool_pre_ping': True, + 'pool_timeout': 30, + 'max_overflow': 20 +} +# Cache directory setup +CACHE_DIR = Path('cache') +CACHE_DIR.mkdir(exist_ok=True) + + +def beijing_now(): + # 使用 pytz 处理时区,但返回 naive datetime(适合数据库存储) + beijing_tz = pytz.timezone('Asia/Shanghai') + return datetime.now(beijing_tz).replace(tzinfo=None) + + +# 检查用户是否登录的装饰器 +def login_required(f): + @wraps(f) + def decorated_function(*args, **kwargs): + if 'user_id' not in session: + return jsonify({'success': False, 'error': '未登录'}), 401 + return f(*args, **kwargs) + + return decorated_function + + +# Memory management constants +MAX_MEMORY_PERCENT = 75 +MEMORY_CHECK_INTERVAL = 300 +MAX_CACHE_ITEMS = 50 +db = SQLAlchemy(app) + +# 初始化邮件服务 +mail = Mail(app) + +# 初始化 Flask-SocketIO(用于实时事件推送) +socketio = SocketIO( + app, + cors_allowed_origins=["http://localhost:3000", "http://127.0.0.1:3000", "http://localhost:5173", + "https://valuefrontier.cn", "http://valuefrontier.cn"], + async_mode='gevent', + logger=True, + engineio_logger=False, + ping_timeout=120, # 心跳超时时间(秒),客户端120秒内无响应才断开 + ping_interval=25 # 心跳检测间隔(秒),每25秒发送一次ping +) + + +@login_manager.user_loader +def load_user(user_id): + """Flask-Login 用户加载回调""" + try: + return User.query.get(int(user_id)) + except Exception as e: + app.logger.error(f"用户加载错误: {e}") + return None + + +# 全局错误处理器 - 确保API接口始终返回JSON +@app.errorhandler(404) +def not_found_error(error): + """404错误处理""" + if request.path.startswith('/api/'): + return jsonify({'success': False, 'error': '接口不存在'}), 404 + return error + + +@app.errorhandler(500) +def internal_error(error): + """500错误处理""" + db.session.rollback() + if request.path.startswith('/api/'): + return jsonify({'success': False, 'error': '服务器内部错误'}), 500 + return error + + +@app.errorhandler(405) +def method_not_allowed_error(error): + """405错误处理""" + if request.path.startswith('/api/'): + return jsonify({'success': False, 'error': '请求方法不被允许'}), 405 + return error + + +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 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')) + + # 时间 + 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) + + # 状态 + status = db.Column(db.String(20), default='active') # active/hidden/deleted + + # 关系 + user = db.relationship('User', backref='comments') + replies = db.relationship('Comment', backref=db.backref('parent', remote_side=[id])) + + +class User(UserMixin, db.Model): + """用户模型 - 完全匹配现有数据库表结构""" + __tablename__ = 'user' + + # 主键 + id = db.Column(db.Integer, primary_key=True, autoincrement=True) + + # 基础账号信息 + username = db.Column(db.String(80), unique=True, nullable=False) + email = db.Column(db.String(120), unique=True, nullable=True) + password_hash = db.Column(db.String(255), nullable=True) + email_confirmed = db.Column(db.Boolean, nullable=True, default=True) + + # 时间字段 + created_at = db.Column(db.DateTime, nullable=True, default=beijing_now) + last_seen = db.Column(db.DateTime, nullable=True, default=beijing_now) + + # 账号状态 + status = db.Column(db.String(20), nullable=True, default='active') + + # 个人资料信息 + nickname = db.Column(db.String(30), nullable=True) + avatar_url = db.Column(db.String(200), nullable=True) + banner_url = db.Column(db.String(200), nullable=True) + bio = db.Column(db.String(200), nullable=True) + gender = db.Column(db.String(10), nullable=True) + birth_date = db.Column(db.Date, nullable=True) + location = db.Column(db.String(100), nullable=True) + + # 联系方式 + phone = db.Column(db.String(20), nullable=True) + wechat_id = db.Column(db.String(80), nullable=True) # 微信号 + + # 实名认证 + real_name = db.Column(db.String(30), nullable=True) + id_number = db.Column(db.String(18), nullable=True) + is_verified = db.Column(db.Boolean, nullable=True, default=False) + verify_time = db.Column(db.DateTime, nullable=True) + + # 投资偏好 + trading_experience = db.Column(db.String(200), nullable=True) + investment_style = db.Column(db.String(50), nullable=True) + risk_preference = db.Column(db.String(20), nullable=True) + investment_amount = db.Column(db.String(20), nullable=True) + preferred_markets = db.Column(db.String(200), nullable=True) + + # 社区数据 + user_level = db.Column(db.Integer, nullable=True, default=1) + reputation_score = db.Column(db.Integer, nullable=True, default=0) + contribution_point = db.Column(db.Integer, nullable=True, default=0) + post_count = db.Column(db.Integer, nullable=True, default=0) + comment_count = db.Column(db.Integer, nullable=True, default=0) + follower_count = db.Column(db.Integer, nullable=True, default=0) + following_count = db.Column(db.Integer, nullable=True, default=0) + + # 创作者相关 + is_creator = db.Column(db.Boolean, nullable=True, default=False) + creator_type = db.Column(db.String(20), nullable=True) + creator_tags = db.Column(db.String(200), nullable=True) + + # 通知设置 + email_notifications = db.Column(db.Boolean, nullable=True, default=True) + sms_notifications = db.Column(db.Boolean, nullable=True, default=False) + wechat_notifications = db.Column(db.Boolean, nullable=True, default=False) + notification_preferences = db.Column(db.String(500), nullable=True) + + # 隐私和界面设置 + privacy_level = db.Column(db.String(20), nullable=True, default='public') + theme_preference = db.Column(db.String(20), nullable=True, default='light') + blocked_keywords = db.Column(db.String(500), nullable=True) + + # 手机验证相关 + phone_confirmed = db.Column(db.Boolean, nullable=True, default=False) # 注意:原表中是blob,这里改为Boolean更合理 + phone_confirm_time = db.Column(db.DateTime, nullable=True) + + # 微信登录相关字段 + wechat_union_id = db.Column(db.String(100), nullable=True) # 微信UnionID + wechat_open_id = db.Column(db.String(100), nullable=True) # 微信OpenID + + 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.nickname = username # 默认昵称为用户名 + self.created_at = beijing_now() + self.last_seen = beijing_now() + + def set_password(self, password): + """设置密码""" + if password: + self.password_hash = generate_password_hash(password) + + def check_password(self, password): + """验证密码""" + if not password or not self.password_hash: + return False + return check_password_hash(self.password_hash, password) + + def update_last_seen(self): + """更新最后活跃时间""" + self.last_seen = beijing_now() + db.session.commit() + + def confirm_email(self): + """确认邮箱""" + self.email_confirmed = True + db.session.commit() + + def confirm_phone(self): + """确认手机号""" + self.phone_confirmed = True + self.phone_confirm_time = beijing_now() + db.session.commit() + + def bind_wechat(self, open_id, union_id=None, wechat_info=None): + """绑定微信账号""" + self.wechat_open_id = open_id + if union_id: + self.wechat_union_id = union_id + + # 如果提供了微信用户信息,更新头像和昵称 + if wechat_info: + if not self.avatar_url and wechat_info.get('headimgurl'): + self.avatar_url = wechat_info['headimgurl'] + if not self.nickname and wechat_info.get('nickname'): + # 确保昵称编码正确且长度合理 + nickname = self._sanitize_nickname(wechat_info['nickname']) + self.nickname = nickname + + db.session.commit() + + def _sanitize_nickname(self, nickname): + """清理和验证昵称""" + if not nickname: + return '微信用户' + + try: + # 确保是正确的UTF-8字符串 + sanitized = str(nickname).strip() + + # 移除可能的控制字符 + import re + sanitized = re.sub(r'[\x00-\x1f\x7f-\x9f]', '', sanitized) + + # 限制长度(避免过长的昵称) + if len(sanitized) > 50: + sanitized = sanitized[:47] + '...' + + # 如果清理后为空,使用默认值 + if not sanitized: + sanitized = '微信用户' + + return sanitized + except Exception as e: + return '微信用户' + + def unbind_wechat(self): + """解绑微信账号""" + self.wechat_open_id = None + self.wechat_union_id = None + db.session.commit() + + def increment_post_count(self): + """增加发帖数""" + self.post_count = (self.post_count or 0) + 1 + db.session.commit() + + def increment_comment_count(self): + """增加评论数""" + self.comment_count = (self.comment_count or 0) + 1 + db.session.commit() + + def add_reputation(self, points): + """增加声誉分数""" + self.reputation_score = (self.reputation_score or 0) + points + db.session.commit() + + def to_dict(self, include_sensitive=False): + """转换为字典""" + data = { + 'id': self.id, + 'username': self.username, + 'nickname': self.nickname or self.username, + 'avatar_url': self.avatar_url, + 'banner_url': self.banner_url, + 'bio': self.bio, + 'gender': self.gender, + 'location': self.location, + 'user_level': self.user_level or 1, + 'reputation_score': self.reputation_score or 0, + 'contribution_point': self.contribution_point or 0, + 'post_count': self.post_count or 0, + 'comment_count': self.comment_count or 0, + 'follower_count': self.follower_count or 0, + 'following_count': self.following_count or 0, + 'is_creator': self.is_creator or False, + 'creator_type': self.creator_type, + 'creator_tags': self.creator_tags, + 'is_verified': self.is_verified or False, + 'created_at': self.created_at.isoformat() if self.created_at else None, + 'last_seen': self.last_seen.isoformat() if self.last_seen else None, + 'status': self.status, + 'has_wechat': bool(self.wechat_open_id), + 'is_authenticated': True + } + + # 敏感信息只在需要时包含 + if include_sensitive: + data.update({ + 'email': self.email, + 'phone': self.phone, + 'email_confirmed': self.email_confirmed, + 'phone_confirmed': self.phone_confirmed, + 'real_name': self.real_name, + 'birth_date': self.birth_date.isoformat() if self.birth_date else None, + 'trading_experience': self.trading_experience, + 'investment_style': self.investment_style, + 'risk_preference': self.risk_preference, + 'investment_amount': self.investment_amount, + 'preferred_markets': self.preferred_markets, + 'email_notifications': self.email_notifications, + 'sms_notifications': self.sms_notifications, + 'wechat_notifications': self.wechat_notifications, + 'privacy_level': self.privacy_level, + 'theme_preference': self.theme_preference + }) + + return data + + def to_public_dict(self): + """公开信息字典(用于显示给其他用户)""" + return { + 'id': self.id, + 'username': self.username, + 'nickname': self.nickname or self.username, + 'avatar_url': self.avatar_url, + 'bio': self.bio, + 'user_level': self.user_level or 1, + 'reputation_score': self.reputation_score or 0, + 'post_count': self.post_count or 0, + 'follower_count': self.follower_count or 0, + 'is_creator': self.is_creator or False, + 'creator_type': self.creator_type, + 'is_verified': self.is_verified or False, + 'created_at': self.created_at.isoformat() if self.created_at else None + } + + @staticmethod + def find_by_login_info(login_info): + """根据登录信息查找用户(支持用户名、邮箱、手机号)""" + return User.query.filter( + db.or_( + User.username == login_info, + User.email == login_info, + User.phone == login_info + ) + ).first() + + @staticmethod + def find_by_wechat_openid(open_id): + """根据微信OpenID查找用户""" + return User.query.filter_by(wechat_open_id=open_id).first() + + @staticmethod + def find_by_wechat_unionid(union_id): + """根据微信UnionID查找用户""" + return User.query.filter_by(wechat_union_id=union_id).first() + + @staticmethod + def is_username_taken(username): + """检查用户名是否已被使用""" + return User.query.filter_by(username=username).first() is not None + + @staticmethod + def is_email_taken(email): + """检查邮箱是否已被使用""" + return User.query.filter_by(email=email).first() is not None + + @staticmethod + def is_phone_taken(phone): + """检查手机号是否已被使用""" + return User.query.filter_by(phone=phone).first() is not None + + def __repr__(self): + return f'' + + +# ============================================ +# 订阅功能模块(安全版本 - 独立表) +# ============================================ +class UserSubscription(db.Model): + """用户订阅表 - 独立于现有User表""" + __tablename__ = 'user_subscriptions' + + id = db.Column(db.Integer, primary_key=True, autoincrement=True) + user_id = db.Column(db.Integer, nullable=False, unique=True, index=True) + subscription_type = db.Column(db.String(10), nullable=False, default='free') + subscription_status = db.Column(db.String(20), nullable=False, default='active') + start_date = db.Column(db.DateTime, nullable=True) + end_date = db.Column(db.DateTime, nullable=True) + billing_cycle = db.Column(db.String(10), nullable=True) + auto_renewal = db.Column(db.Boolean, nullable=False, default=False) + created_at = db.Column(db.DateTime, default=beijing_now) + updated_at = db.Column(db.DateTime, default=beijing_now, onupdate=beijing_now) + + def is_active(self): + if self.subscription_status != 'active': + return False + if self.subscription_type == 'free': + return True + if self.end_date: + try: + now = beijing_now() + if self.end_date < now: + return False + except Exception as e: + return False + return True + + def days_left(self): + if self.subscription_type == 'free' or not self.end_date: + return 999 + try: + now = beijing_now() + delta = self.end_date - now + return max(0, delta.days) + except Exception as e: + return 0 + + def to_dict(self): + return { + 'type': self.subscription_type, + 'status': self.subscription_status, + 'is_active': self.is_active(), + 'days_left': self.days_left(), + 'start_date': self.start_date.isoformat() if self.start_date else None, + 'end_date': self.end_date.isoformat() if self.end_date else None, + 'billing_cycle': self.billing_cycle, + 'auto_renewal': self.auto_renewal + } + + +class SubscriptionPlan(db.Model): + """订阅套餐表""" + __tablename__ = 'subscription_plans' + + id = db.Column(db.Integer, primary_key=True, autoincrement=True) + name = db.Column(db.String(50), nullable=False, unique=True) + display_name = db.Column(db.String(100), nullable=False) + description = db.Column(db.Text, nullable=True) + monthly_price = db.Column(db.Numeric(10, 2), nullable=False) + yearly_price = db.Column(db.Numeric(10, 2), nullable=False) + features = db.Column(db.Text, nullable=True) + pricing_options = db.Column(db.Text, nullable=True) # JSON格式:[{"months": 1, "price": 99}, {"months": 12, "price": 999}] + is_active = db.Column(db.Boolean, default=True) + sort_order = db.Column(db.Integer, default=0) + created_at = db.Column(db.DateTime, default=beijing_now) + + def to_dict(self): + # 解析pricing_options(如果存在) + pricing_opts = None + if self.pricing_options: + try: + pricing_opts = json.loads(self.pricing_options) + except: + pricing_opts = None + + # 如果没有pricing_options,则从monthly_price和yearly_price生成默认选项 + if not pricing_opts: + pricing_opts = [ + { + 'months': 1, + 'price': float(self.monthly_price) if self.monthly_price else 0, + 'label': '月付', + 'cycle_key': 'monthly' + }, + { + 'months': 12, + 'price': float(self.yearly_price) if self.yearly_price else 0, + 'label': '年付', + 'cycle_key': 'yearly', + 'discount_percent': 20 # 年付默认20%折扣 + } + ] + + return { + 'id': self.id, + 'name': self.name, + 'display_name': self.display_name, + 'description': self.description, + 'monthly_price': float(self.monthly_price) if self.monthly_price else 0, + 'yearly_price': float(self.yearly_price) if self.yearly_price else 0, + 'pricing_options': pricing_opts, # 新增:灵活计费周期选项 + 'features': json.loads(self.features) if self.features else [], + 'is_active': self.is_active, + 'sort_order': self.sort_order + } + + +class PaymentOrder(db.Model): + """支付订单表""" + __tablename__ = 'payment_orders' + + id = db.Column(db.Integer, primary_key=True, autoincrement=True) + order_no = db.Column(db.String(32), unique=True, nullable=False) + user_id = db.Column(db.Integer, nullable=False) + plan_name = db.Column(db.String(20), nullable=False) + billing_cycle = db.Column(db.String(10), nullable=False) + amount = db.Column(db.Numeric(10, 2), nullable=False) + wechat_order_id = db.Column(db.String(64), nullable=True) + prepay_id = db.Column(db.String(64), nullable=True) + qr_code_url = db.Column(db.String(200), nullable=True) + status = db.Column(db.String(20), default='pending') + created_at = db.Column(db.DateTime, default=beijing_now) + paid_at = db.Column(db.DateTime, nullable=True) + expired_at = db.Column(db.DateTime, nullable=True) + remark = db.Column(db.String(200), nullable=True) + + def __init__(self, user_id, plan_name, billing_cycle, amount): + self.user_id = user_id + self.plan_name = plan_name + self.billing_cycle = billing_cycle + self.amount = amount + import random + timestamp = int(beijing_now().timestamp() * 1000000) + random_suffix = random.randint(1000, 9999) + self.order_no = f"{timestamp}{user_id:04d}{random_suffix}" + self.expired_at = beijing_now() + timedelta(minutes=30) + + def is_expired(self): + if not self.expired_at: + return False + try: + now = beijing_now() + return now > self.expired_at + except Exception as e: + return False + + def mark_as_paid(self, wechat_order_id, transaction_id=None): + self.status = 'paid' + self.paid_at = beijing_now() + self.wechat_order_id = wechat_order_id + + def to_dict(self): + return { + 'id': self.id, + 'order_no': self.order_no, + 'user_id': self.user_id, + 'plan_name': self.plan_name, + 'billing_cycle': self.billing_cycle, + 'amount': float(self.amount) if self.amount else 0, + 'original_amount': float(self.original_amount) if hasattr(self, 'original_amount') and self.original_amount else None, + 'discount_amount': float(self.discount_amount) if hasattr(self, 'discount_amount') and self.discount_amount else 0, + 'promo_code': self.promo_code.code if hasattr(self, 'promo_code') and self.promo_code else None, + 'is_upgrade': self.is_upgrade if hasattr(self, 'is_upgrade') else False, + 'qr_code_url': self.qr_code_url, + 'status': self.status, + 'is_expired': self.is_expired(), + 'created_at': self.created_at.isoformat() if self.created_at else None, + 'paid_at': self.paid_at.isoformat() if self.paid_at else None, + 'expired_at': self.expired_at.isoformat() if self.expired_at else None, + 'remark': self.remark + } + + +class PromoCode(db.Model): + """优惠码表""" + __tablename__ = 'promo_codes' + + id = db.Column(db.Integer, primary_key=True, autoincrement=True) + code = db.Column(db.String(50), unique=True, nullable=False, index=True) + description = db.Column(db.String(200), nullable=True) + + # 折扣类型和值 + discount_type = db.Column(db.String(20), nullable=False) # 'percentage' 或 'fixed_amount' + discount_value = db.Column(db.Numeric(10, 2), nullable=False) + + # 适用范围 + applicable_plans = db.Column(db.String(200), nullable=True) # JSON格式 + applicable_cycles = db.Column(db.String(50), nullable=True) # JSON格式 + min_amount = db.Column(db.Numeric(10, 2), nullable=True) + + # 使用限制 + max_uses = db.Column(db.Integer, nullable=True) + max_uses_per_user = db.Column(db.Integer, default=1) + current_uses = db.Column(db.Integer, default=0) + + # 有效期 + valid_from = db.Column(db.DateTime, nullable=False) + valid_until = db.Column(db.DateTime, nullable=False) + + # 状态 + is_active = db.Column(db.Boolean, default=True) + created_by = db.Column(db.Integer, nullable=True) + created_at = db.Column(db.DateTime, default=beijing_now) + updated_at = db.Column(db.DateTime, default=beijing_now, onupdate=beijing_now) + + def to_dict(self): + return { + 'id': self.id, + 'code': self.code, + 'description': self.description, + 'discount_type': self.discount_type, + 'discount_value': float(self.discount_value) if self.discount_value else 0, + 'applicable_plans': json.loads(self.applicable_plans) if self.applicable_plans else None, + 'applicable_cycles': json.loads(self.applicable_cycles) if self.applicable_cycles else None, + 'min_amount': float(self.min_amount) if self.min_amount else None, + 'max_uses': self.max_uses, + 'max_uses_per_user': self.max_uses_per_user, + 'current_uses': self.current_uses, + 'valid_from': self.valid_from.isoformat() if self.valid_from else None, + 'valid_until': self.valid_until.isoformat() if self.valid_until else None, + 'is_active': self.is_active + } + + +class PromoCodeUsage(db.Model): + """优惠码使用记录表""" + __tablename__ = 'promo_code_usage' + + id = db.Column(db.Integer, primary_key=True, autoincrement=True) + promo_code_id = db.Column(db.Integer, db.ForeignKey('promo_codes.id'), nullable=False) + user_id = db.Column(db.Integer, nullable=False, index=True) + order_id = db.Column(db.Integer, db.ForeignKey('payment_orders.id'), nullable=False) + + original_amount = db.Column(db.Numeric(10, 2), nullable=False) + discount_amount = db.Column(db.Numeric(10, 2), nullable=False) + final_amount = db.Column(db.Numeric(10, 2), nullable=False) + + used_at = db.Column(db.DateTime, default=beijing_now) + + # 关系 + promo_code = db.relationship('PromoCode', backref='usages') + order = db.relationship('PaymentOrder', backref='promo_usage') + + +class SubscriptionUpgrade(db.Model): + """订阅升级/降级记录表""" + __tablename__ = 'subscription_upgrades' + + id = db.Column(db.Integer, primary_key=True, autoincrement=True) + user_id = db.Column(db.Integer, nullable=False, index=True) + order_id = db.Column(db.Integer, db.ForeignKey('payment_orders.id'), nullable=False) + + # 原订阅信息 + from_plan = db.Column(db.String(20), nullable=False) + from_cycle = db.Column(db.String(10), nullable=False) + from_end_date = db.Column(db.DateTime, nullable=True) + + # 新订阅信息 + to_plan = db.Column(db.String(20), nullable=False) + to_cycle = db.Column(db.String(10), nullable=False) + to_end_date = db.Column(db.DateTime, nullable=False) + + # 价格计算 + remaining_value = db.Column(db.Numeric(10, 2), nullable=False) + upgrade_amount = db.Column(db.Numeric(10, 2), nullable=False) + actual_amount = db.Column(db.Numeric(10, 2), nullable=False) + + upgrade_type = db.Column(db.String(20), nullable=False) # 'plan_upgrade', 'cycle_change', 'both' + created_at = db.Column(db.DateTime, default=beijing_now) + + # 关系 + order = db.relationship('PaymentOrder', backref='upgrade_record') + + +# ============================================ +# 模拟盘相关模型 +# ============================================ +class SimulationAccount(db.Model): + """模拟账户""" + __tablename__ = 'simulation_accounts' + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False, unique=True) + account_name = db.Column(db.String(100), default='我的模拟账户') + initial_capital = db.Column(db.Numeric(15, 2), default=1000000.00) # 初始资金 + available_cash = db.Column(db.Numeric(15, 2), default=1000000.00) # 可用资金 + frozen_cash = db.Column(db.Numeric(15, 2), default=0.00) # 冻结资金 + position_value = db.Column(db.Numeric(15, 2), default=0.00) # 持仓市值 + total_assets = db.Column(db.Numeric(15, 2), default=1000000.00) # 总资产 + total_profit = db.Column(db.Numeric(15, 2), default=0.00) # 总盈亏 + total_profit_rate = db.Column(db.Numeric(10, 4), default=0.00) # 总收益率 + daily_profit = db.Column(db.Numeric(15, 2), default=0.00) # 日盈亏 + daily_profit_rate = db.Column(db.Numeric(10, 4), default=0.00) # 日收益率 + created_at = db.Column(db.DateTime, default=beijing_now) + updated_at = db.Column(db.DateTime, default=beijing_now, onupdate=beijing_now) + last_settlement_date = db.Column(db.Date) # 最后结算日期 + + # 关系 + user = db.relationship('User', backref='simulation_account') + positions = db.relationship('SimulationPosition', backref='account', lazy='dynamic') + orders = db.relationship('SimulationOrder', backref='account', lazy='dynamic') + transactions = db.relationship('SimulationTransaction', backref='account', lazy='dynamic') + + def calculate_total_assets(self): + """计算总资产""" + self.total_assets = self.available_cash + self.frozen_cash + self.position_value + self.total_profit = self.total_assets - self.initial_capital + self.total_profit_rate = (self.total_profit / self.initial_capital) * 100 if self.initial_capital > 0 else 0 + return self.total_assets + + +class SimulationPosition(db.Model): + """模拟持仓""" + __tablename__ = 'simulation_positions' + + id = db.Column(db.Integer, primary_key=True) + account_id = db.Column(db.Integer, db.ForeignKey('simulation_accounts.id'), nullable=False) + stock_code = db.Column(db.String(20), nullable=False) + stock_name = db.Column(db.String(100)) + position_qty = db.Column(db.Integer, default=0) # 持仓数量 + available_qty = db.Column(db.Integer, default=0) # 可用数量(T+1) + frozen_qty = db.Column(db.Integer, default=0) # 冻结数量 + avg_cost = db.Column(db.Numeric(10, 3), default=0.00) # 平均成本 + current_price = db.Column(db.Numeric(10, 3), default=0.00) # 当前价格 + market_value = db.Column(db.Numeric(15, 2), default=0.00) # 市值 + profit = db.Column(db.Numeric(15, 2), default=0.00) # 盈亏 + profit_rate = db.Column(db.Numeric(10, 4), default=0.00) # 盈亏比例 + today_profit = db.Column(db.Numeric(15, 2), default=0.00) # 今日盈亏 + today_profit_rate = db.Column(db.Numeric(10, 4), default=0.00) # 今日盈亏比例 + created_at = db.Column(db.DateTime, default=beijing_now) + updated_at = db.Column(db.DateTime, default=beijing_now, onupdate=beijing_now) + + __table_args__ = ( + db.UniqueConstraint('account_id', 'stock_code', name='unique_account_stock'), + ) + + def update_market_value(self, current_price): + """更新市值和盈亏""" + self.current_price = current_price + self.market_value = self.position_qty * current_price + total_cost = self.position_qty * self.avg_cost + self.profit = self.market_value - total_cost + self.profit_rate = (self.profit / total_cost * 100) if total_cost > 0 else 0 + return self.market_value + + +class SimulationOrder(db.Model): + """模拟订单""" + __tablename__ = 'simulation_orders' + + id = db.Column(db.Integer, primary_key=True) + account_id = db.Column(db.Integer, db.ForeignKey('simulation_accounts.id'), nullable=False) + order_no = db.Column(db.String(32), unique=True, nullable=False) + stock_code = db.Column(db.String(20), nullable=False) + stock_name = db.Column(db.String(100)) + order_type = db.Column(db.String(10), nullable=False) # BUY/SELL + price_type = db.Column(db.String(10), default='MARKET') # MARKET/LIMIT + order_price = db.Column(db.Numeric(10, 3)) # 委托价格 + order_qty = db.Column(db.Integer, nullable=False) # 委托数量 + filled_qty = db.Column(db.Integer, default=0) # 成交数量 + filled_price = db.Column(db.Numeric(10, 3)) # 成交价格 + filled_amount = db.Column(db.Numeric(15, 2)) # 成交金额 + commission = db.Column(db.Numeric(10, 2), default=0.00) # 手续费 + stamp_tax = db.Column(db.Numeric(10, 2), default=0.00) # 印花税 + transfer_fee = db.Column(db.Numeric(10, 2), default=0.00) # 过户费 + total_fee = db.Column(db.Numeric(10, 2), default=0.00) # 总费用 + status = db.Column(db.String(20), default='PENDING') # PENDING/PARTIAL/FILLED/CANCELLED/REJECTED + reject_reason = db.Column(db.String(200)) + order_time = db.Column(db.DateTime, default=beijing_now) + filled_time = db.Column(db.DateTime) + cancel_time = db.Column(db.DateTime) + + def calculate_fees(self): + """计算交易费用""" + if not self.filled_amount: + return 0 + + # 佣金(万分之2.5,最低5元) + self.commission = max(float(self.filled_amount) * 0.00025, 5.0) + + # 印花税(卖出时收取千分之1) + if self.order_type == 'SELL': + self.stamp_tax = float(self.filled_amount) * 0.001 + else: + self.stamp_tax = 0 + + # 过户费(双向收取,万分之0.2) + self.transfer_fee = float(self.filled_amount) * 0.00002 + + # 总费用 + self.total_fee = self.commission + self.stamp_tax + self.transfer_fee + + return self.total_fee + + +class SimulationTransaction(db.Model): + """模拟成交记录""" + __tablename__ = 'simulation_transactions' + + id = db.Column(db.Integer, primary_key=True) + account_id = db.Column(db.Integer, db.ForeignKey('simulation_accounts.id'), nullable=False) + order_id = db.Column(db.Integer, db.ForeignKey('simulation_orders.id'), nullable=False) + transaction_no = db.Column(db.String(32), unique=True, nullable=False) + stock_code = db.Column(db.String(20), nullable=False) + stock_name = db.Column(db.String(100)) + transaction_type = db.Column(db.String(10), nullable=False) # BUY/SELL + transaction_price = db.Column(db.Numeric(10, 3), nullable=False) + transaction_qty = db.Column(db.Integer, nullable=False) + transaction_amount = db.Column(db.Numeric(15, 2), nullable=False) + commission = db.Column(db.Numeric(10, 2), default=0.00) + stamp_tax = db.Column(db.Numeric(10, 2), default=0.00) + transfer_fee = db.Column(db.Numeric(10, 2), default=0.00) + total_fee = db.Column(db.Numeric(10, 2), default=0.00) + transaction_time = db.Column(db.DateTime, default=beijing_now) + settlement_date = db.Column(db.Date) # T+1结算日期 + + # 关系 + order = db.relationship('SimulationOrder', backref='transactions') + + +class SimulationDailyStats(db.Model): + """模拟账户日统计""" + __tablename__ = 'simulation_daily_stats' + + id = db.Column(db.Integer, primary_key=True) + account_id = db.Column(db.Integer, db.ForeignKey('simulation_accounts.id'), nullable=False) + stat_date = db.Column(db.Date, nullable=False) + opening_assets = db.Column(db.Numeric(15, 2)) # 期初资产 + closing_assets = db.Column(db.Numeric(15, 2)) # 期末资产 + daily_profit = db.Column(db.Numeric(15, 2)) # 日盈亏 + daily_profit_rate = db.Column(db.Numeric(10, 4)) # 日收益率 + total_profit = db.Column(db.Numeric(15, 2)) # 累计盈亏 + total_profit_rate = db.Column(db.Numeric(10, 4)) # 累计收益率 + trade_count = db.Column(db.Integer, default=0) # 交易次数 + win_count = db.Column(db.Integer, default=0) # 盈利次数 + loss_count = db.Column(db.Integer, default=0) # 亏损次数 + max_profit = db.Column(db.Numeric(15, 2)) # 最大盈利 + max_loss = db.Column(db.Numeric(15, 2)) # 最大亏损 + created_at = db.Column(db.DateTime, default=beijing_now) + + __table_args__ = ( + db.UniqueConstraint('account_id', 'stat_date', name='unique_account_date'), + ) + + +def get_user_subscription_safe(user_id): + """安全地获取用户订阅信息""" + try: + subscription = UserSubscription.query.filter_by(user_id=user_id).first() + if not subscription: + subscription = UserSubscription(user_id=user_id) + db.session.add(subscription) + db.session.commit() + return subscription + except Exception as e: + # 返回默认免费版本对象 + class DefaultSub: + def to_dict(self): + return { + 'type': 'free', + 'status': 'active', + 'is_active': True, + 'days_left': 999, + 'billing_cycle': None, + 'auto_renewal': False + } + + return DefaultSub() + + +def activate_user_subscription(user_id, plan_type, billing_cycle, extend_from_now=False): + """激活用户订阅 + + Args: + user_id: 用户ID + plan_type: 套餐类型 + billing_cycle: 计费周期 + extend_from_now: 是否从当前时间开始延长(用于升级场景) + """ + try: + subscription = UserSubscription.query.filter_by(user_id=user_id).first() + if not subscription: + subscription = UserSubscription(user_id=user_id) + db.session.add(subscription) + + subscription.subscription_type = plan_type + subscription.subscription_status = 'active' + subscription.billing_cycle = billing_cycle + + if not extend_from_now or not subscription.start_date: + subscription.start_date = beijing_now() + + if billing_cycle == 'monthly': + subscription.end_date = beijing_now() + timedelta(days=30) + else: # yearly + subscription.end_date = beijing_now() + timedelta(days=365) + + subscription.updated_at = beijing_now() + db.session.commit() + return subscription + except Exception as e: + return None + + +def validate_promo_code(code, plan_name, billing_cycle, amount, user_id): + """验证优惠码 + + Returns: + tuple: (promo_code_obj, error_message) + """ + try: + promo = PromoCode.query.filter_by(code=code.upper(), is_active=True).first() + + if not promo: + return None, "优惠码不存在或已失效" + + # 检查有效期 + now = beijing_now() + if now < promo.valid_from: + return None, "优惠码尚未生效" + if now > promo.valid_until: + return None, "优惠码已过期" + + # 检查使用次数 + if promo.max_uses and promo.current_uses >= promo.max_uses: + return None, "优惠码已被使用完" + + # 检查每用户使用次数 + if promo.max_uses_per_user: + user_usage_count = PromoCodeUsage.query.filter_by( + promo_code_id=promo.id, + user_id=user_id + ).count() + if user_usage_count >= promo.max_uses_per_user: + return None, f"您已使用过此优惠码(限用{promo.max_uses_per_user}次)" + + # 检查适用套餐 + if promo.applicable_plans: + try: + applicable = json.loads(promo.applicable_plans) + if plan_name not in applicable: + return None, "该优惠码不适用于此套餐" + except: + pass + + # 检查适用周期 + if promo.applicable_cycles: + try: + applicable = json.loads(promo.applicable_cycles) + if billing_cycle not in applicable: + return None, "该优惠码不适用于此计费周期" + except: + pass + + # 检查最低消费 + if promo.min_amount and amount < float(promo.min_amount): + return None, f"需满{float(promo.min_amount):.2f}元才可使用此优惠码" + + return promo, None + except Exception as e: + return None, f"验证优惠码时出错: {str(e)}" + + +def calculate_discount(promo_code, amount): + """计算优惠金额""" + try: + if promo_code.discount_type == 'percentage': + discount = amount * (float(promo_code.discount_value) / 100) + else: # fixed_amount + discount = float(promo_code.discount_value) + + # 确保折扣不超过总金额 + return min(discount, amount) + except: + return 0 + + +def calculate_remaining_value(subscription, current_plan): + """计算当前订阅的剩余价值""" + try: + if not subscription or not subscription.end_date: + return 0 + + now = beijing_now() + if subscription.end_date <= now: + return 0 + + days_left = (subscription.end_date - now).days + + if subscription.billing_cycle == 'monthly': + daily_value = float(current_plan.monthly_price) / 30 + else: # yearly + daily_value = float(current_plan.yearly_price) / 365 + + return daily_value * days_left + except: + return 0 + + +def calculate_upgrade_price(user_id, to_plan_name, to_cycle, promo_code=None): + """计算升级所需价格 + + Returns: + dict: 包含价格计算结果的字典 + """ + try: + # 1. 获取当前订阅 + current_sub = UserSubscription.query.filter_by(user_id=user_id).first() + + # 2. 获取目标套餐 + to_plan = SubscriptionPlan.query.filter_by(name=to_plan_name, is_active=True).first() + if not to_plan: + return {'error': '目标套餐不存在'} + + # 3. 计算目标套餐价格 + new_price = float(to_plan.yearly_price if to_cycle == 'yearly' else to_plan.monthly_price) + + # 4. 如果是新订阅(非升级) + if not current_sub or current_sub.subscription_type == 'free': + result = { + 'is_upgrade': False, + 'new_plan_price': new_price, + 'remaining_value': 0, + 'upgrade_amount': new_price, + 'original_amount': new_price, + 'discount_amount': 0, + 'final_amount': new_price, + 'promo_code': None + } + + # 应用优惠码 + if promo_code: + promo, error = validate_promo_code(promo_code, to_plan_name, to_cycle, new_price, user_id) + if promo: + discount = calculate_discount(promo, new_price) + result['discount_amount'] = discount + result['final_amount'] = new_price - discount + result['promo_code'] = promo.code + elif error: + result['promo_error'] = error + + return result + + # 5. 升级场景:计算剩余价值 + current_plan = SubscriptionPlan.query.filter_by(name=current_sub.subscription_type, is_active=True).first() + if not current_plan: + return {'error': '当前套餐信息不存在'} + + remaining_value = calculate_remaining_value(current_sub, current_plan) + + # 6. 计算升级差价 + upgrade_amount = max(0, new_price - remaining_value) + + # 7. 判断升级类型 + upgrade_type = 'new' + if current_sub.subscription_type != to_plan_name and current_sub.billing_cycle != to_cycle: + upgrade_type = 'both' + elif current_sub.subscription_type != to_plan_name: + upgrade_type = 'plan_upgrade' + elif current_sub.billing_cycle != to_cycle: + upgrade_type = 'cycle_change' + + result = { + 'is_upgrade': True, + 'upgrade_type': upgrade_type, + 'current_plan': current_sub.subscription_type, + 'current_cycle': current_sub.billing_cycle, + 'current_end_date': current_sub.end_date.isoformat() if current_sub.end_date else None, + 'new_plan_price': new_price, + 'remaining_value': remaining_value, + 'upgrade_amount': upgrade_amount, + 'original_amount': upgrade_amount, + 'discount_amount': 0, + 'final_amount': upgrade_amount, + 'promo_code': None + } + + # 8. 应用优惠码 + if promo_code and upgrade_amount > 0: + promo, error = validate_promo_code(promo_code, to_plan_name, to_cycle, upgrade_amount, user_id) + if promo: + discount = calculate_discount(promo, upgrade_amount) + result['discount_amount'] = discount + result['final_amount'] = upgrade_amount - discount + result['promo_code'] = promo.code + elif error: + result['promo_error'] = error + + return result + except Exception as e: + return {'error': str(e)} + + +def initialize_subscription_plans_safe(): + """安全地初始化订阅套餐""" + try: + if SubscriptionPlan.query.first(): + return + + pro_plan = SubscriptionPlan( + name='pro', + display_name='Pro版本', + description='适合个人投资者的基础功能套餐', + monthly_price=0.01, + yearly_price=0.08, + features=json.dumps([ + "基础股票分析工具", + "历史数据查询", + "基础财务报表", + "简单投资计划记录", + "标准客服支持" + ]), + sort_order=1 + ) + + max_plan = SubscriptionPlan( + name='max', + display_name='Max版本', + description='适合专业投资者的全功能套餐', + monthly_price=0.1, + yearly_price=0.8, + features=json.dumps([ + "全部Pro版本功能", + "高级分析工具", + "实时数据推送", + "专业财务分析报告", + "AI投资建议", + "无限投资计划存储", + "优先客服支持", + "独家研报访问" + ]), + sort_order=2 + ) + + db.session.add(pro_plan) + db.session.add(max_plan) + db.session.commit() + except Exception as e: + pass + + +# -------------------------------------------- +# 订阅等级工具函数 +# -------------------------------------------- +def _get_current_subscription_info(): + """获取当前登录用户订阅信息的字典形式,未登录或异常时视为免费用户。""" + try: + user_id = session.get('user_id') + if not user_id: + return { + 'type': 'free', + 'status': 'active', + 'is_active': True + } + sub = get_user_subscription_safe(user_id) + data = sub.to_dict() + # 标准化字段名 + return { + 'type': data.get('type') or data.get('subscription_type') or 'free', + 'status': data.get('status') or data.get('subscription_status') or 'active', + 'is_active': data.get('is_active', True) + } + except Exception: + return { + 'type': 'free', + 'status': 'active', + 'is_active': True + } + + +def _subscription_level(sub_type): + """将订阅类型映射到等级数值,free=0, pro=1, max=2。""" + mapping = {'free': 0, 'pro': 1, 'max': 2} + return mapping.get((sub_type or 'free').lower(), 0) + + +def _has_required_level(required: str) -> bool: + """判断当前用户是否达到所需订阅级别。""" + info = _get_current_subscription_info() + if not info.get('is_active', True): + return False + return _subscription_level(info.get('type')) >= _subscription_level(required) + + +# ============================================ +# 订阅相关API接口 +# ============================================ + +@app.route('/api/subscription/plans', methods=['GET']) +def get_subscription_plans(): + """获取订阅套餐列表""" + try: + plans = SubscriptionPlan.query.filter_by(is_active=True).order_by(SubscriptionPlan.sort_order).all() + return jsonify({ + 'success': True, + 'data': [plan.to_dict() for plan in plans] + }) + except Exception as e: + # 返回默认套餐(包含pricing_options以兼容新前端) + default_plans = [ + { + 'id': 1, + 'name': 'pro', + 'display_name': 'Pro版本', + 'description': '适合个人投资者的基础功能套餐', + 'monthly_price': 198, + 'yearly_price': 2000, + 'pricing_options': [ + {'months': 1, 'price': 198, 'label': '月付', 'cycle_key': 'monthly'}, + {'months': 3, 'price': 534, 'label': '3个月', 'cycle_key': '3months', 'discount_percent': 10}, + {'months': 6, 'price': 950, 'label': '半年', 'cycle_key': '6months', 'discount_percent': 20}, + {'months': 12, 'price': 2000, 'label': '1年', 'cycle_key': 'yearly', 'discount_percent': 16}, + {'months': 24, 'price': 3600, 'label': '2年', 'cycle_key': '2years', 'discount_percent': 24}, + {'months': 36, 'price': 5040, 'label': '3年', 'cycle_key': '3years', 'discount_percent': 29} + ], + 'features': ['基础股票分析工具', '历史数据查询', '基础财务报表', '简单投资计划记录', '标准客服支持'], + 'is_active': True, + 'sort_order': 1 + }, + { + 'id': 2, + 'name': 'max', + 'display_name': 'Max版本', + 'description': '适合专业投资者的全功能套餐', + 'monthly_price': 998, + 'yearly_price': 10000, + 'pricing_options': [ + {'months': 1, 'price': 998, 'label': '月付', 'cycle_key': 'monthly'}, + {'months': 3, 'price': 2695, 'label': '3个月', 'cycle_key': '3months', 'discount_percent': 10}, + {'months': 6, 'price': 4790, 'label': '半年', 'cycle_key': '6months', 'discount_percent': 20}, + {'months': 12, 'price': 10000, 'label': '1年', 'cycle_key': 'yearly', 'discount_percent': 17}, + {'months': 24, 'price': 18000, 'label': '2年', 'cycle_key': '2years', 'discount_percent': 25}, + {'months': 36, 'price': 25200, 'label': '3年', 'cycle_key': '3years', 'discount_percent': 30} + ], + 'features': ['全部Pro版本功能', '高级分析工具', '实时数据推送', 'API访问', '优先客服支持'], + 'is_active': True, + 'sort_order': 2 + } + ] + return jsonify({ + 'success': True, + 'data': default_plans + }) + + +@app.route('/api/subscription/current', methods=['GET']) +def get_current_subscription(): + """获取当前用户的订阅信息""" + try: + if 'user_id' not in session: + return jsonify({'success': False, 'error': '未登录'}), 401 + + subscription = get_user_subscription_safe(session['user_id']) + return jsonify({ + 'success': True, + 'data': subscription.to_dict() + }) + except Exception as e: + return jsonify({ + 'success': True, + 'data': { + 'type': 'free', + 'status': 'active', + 'is_active': True, + 'days_left': 999 + } + }) + + +@app.route('/api/subscription/info', methods=['GET']) +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': 999 + } + }) + + +@app.route('/api/promo-code/validate', methods=['POST']) +def validate_promo_code_api(): + """验证优惠码""" + try: + if 'user_id' not in session: + return jsonify({'success': False, 'error': '未登录'}), 401 + + data = request.get_json() + code = data.get('code', '').strip() + plan_name = data.get('plan_name') + billing_cycle = data.get('billing_cycle') + amount = data.get('amount', 0) + + if not code or not plan_name or not billing_cycle: + return jsonify({'success': False, 'error': '参数不完整'}), 400 + + # 验证优惠码 + promo, error = validate_promo_code(code, plan_name, billing_cycle, amount, session['user_id']) + + if error: + return jsonify({ + 'success': False, + 'valid': False, + 'error': error + }) + + # 计算折扣 + discount_amount = calculate_discount(promo, amount) + final_amount = amount - discount_amount + + return jsonify({ + 'success': True, + 'valid': True, + 'promo_code': promo.to_dict(), + 'discount_amount': discount_amount, + 'final_amount': final_amount + }) + + except Exception as e: + return jsonify({ + 'success': False, + 'error': f'验证失败: {str(e)}' + }), 500 + + +@app.route('/api/subscription/calculate-price', methods=['POST']) +def calculate_subscription_price(): + """计算订阅价格(支持升级和优惠码)""" + try: + if 'user_id' not in session: + return jsonify({'success': False, 'error': '未登录'}), 401 + + data = request.get_json() + to_plan = data.get('to_plan') + to_cycle = data.get('to_cycle') + promo_code = data.get('promo_code', '').strip() or None + + if not to_plan or not to_cycle: + return jsonify({'success': False, 'error': '参数不完整'}), 400 + + # 计算价格 + result = calculate_upgrade_price(session['user_id'], to_plan, to_cycle, promo_code) + + if 'error' in result: + return jsonify({ + 'success': False, + 'error': result['error'] + }), 400 + + return jsonify({ + 'success': True, + 'data': result + }) + + except Exception as e: + return jsonify({ + 'success': False, + 'error': f'计算失败: {str(e)}' + }), 500 + + +@app.route('/api/payment/create-order', methods=['POST']) +def create_payment_order(): + """创建支付订单(支持升级和优惠码)""" + try: + if 'user_id' not in session: + return jsonify({'success': False, 'error': '未登录'}), 401 + + data = request.get_json() + plan_name = data.get('plan_name') + billing_cycle = data.get('billing_cycle') + promo_code = data.get('promo_code', '').strip() or None + + if not plan_name or not billing_cycle: + return jsonify({'success': False, 'error': '参数不完整'}), 400 + + # 计算价格(包括升级和优惠码) + price_result = calculate_upgrade_price(session['user_id'], plan_name, billing_cycle, promo_code) + + if 'error' in price_result: + return jsonify({'success': False, 'error': price_result['error']}), 400 + + amount = price_result['final_amount'] + original_amount = price_result['original_amount'] + discount_amount = price_result['discount_amount'] + is_upgrade = price_result.get('is_upgrade', False) + + # 创建订单 + try: + order = PaymentOrder( + user_id=session['user_id'], + plan_name=plan_name, + billing_cycle=billing_cycle, + amount=amount + ) + + # 添加扩展字段(使用动态属性) + if hasattr(order, 'original_amount') or True: # 兼容性检查 + order.original_amount = original_amount + order.discount_amount = discount_amount + order.is_upgrade = is_upgrade + + # 如果使用了优惠码,关联优惠码 + if promo_code and price_result.get('promo_code'): + promo_obj = PromoCode.query.filter_by(code=promo_code.upper()).first() + if promo_obj: + order.promo_code_id = promo_obj.id + + # 如果是升级,记录原套餐信息 + if is_upgrade: + order.upgrade_from_plan = price_result.get('current_plan') + + db.session.add(order) + db.session.commit() + + # 如果是升级订单,创建升级记录 + if is_upgrade and price_result.get('upgrade_type'): + try: + upgrade_record = SubscriptionUpgrade( + user_id=session['user_id'], + order_id=order.id, + from_plan=price_result['current_plan'], + from_cycle=price_result['current_cycle'], + from_end_date=datetime.fromisoformat(price_result['current_end_date']) if price_result.get('current_end_date') else None, + to_plan=plan_name, + to_cycle=billing_cycle, + to_end_date=beijing_now() + timedelta(days=365 if billing_cycle == 'yearly' else 30), + remaining_value=price_result['remaining_value'], + upgrade_amount=price_result['upgrade_amount'], + actual_amount=amount, + upgrade_type=price_result['upgrade_type'] + ) + db.session.add(upgrade_record) + db.session.commit() + except Exception as e: + print(f"创建升级记录失败: {e}") + # 不影响主流程 + + except Exception as e: + db.session.rollback() + return jsonify({'success': False, 'error': f'订单创建失败: {str(e)}'}), 500 + + # 尝试调用真实的微信支付API + try: + from wechat_pay import create_wechat_pay_instance, check_wechat_pay_ready + + # 检查微信支付是否就绪 + is_ready, ready_msg = check_wechat_pay_ready() + if not is_ready: + # 使用模拟二维码 + order.qr_code_url = f"https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=wxpay://order/{order.order_no}" + order.remark = f"演示模式 - {ready_msg}" + else: + wechat_pay = create_wechat_pay_instance() + + # 创建微信支付订单 + plan_display_name = f"{plan_name.upper()}版本-{billing_cycle}" + wechat_result = wechat_pay.create_native_order( + order_no=order.order_no, + total_fee=float(amount), + body=f"VFr-{plan_display_name}", + product_id=f"{plan_name}_{billing_cycle}" + ) + + if wechat_result['success']: + + # 获取微信返回的原始code_url + wechat_code_url = wechat_result['code_url'] + + # 将微信协议URL转换为二维码图片URL + import urllib.parse + encoded_url = urllib.parse.quote(wechat_code_url, safe='') + qr_image_url = f"https://api.qrserver.com/v1/create-qr-code/?size=200x200&data={encoded_url}" + + order.qr_code_url = qr_image_url + order.prepay_id = wechat_result.get('prepay_id') + order.remark = f"微信支付 - {wechat_code_url}" + + else: + order.qr_code_url = f"https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=wxpay://order/{order.order_no}" + order.remark = f"微信支付失败: {wechat_result.get('error')}" + + except ImportError as e: + order.qr_code_url = f"https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=wxpay://order/{order.order_no}" + order.remark = "微信支付模块未配置" + except Exception as e: + order.qr_code_url = f"https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=wxpay://order/{order.order_no}" + order.remark = f"支付异常: {str(e)}" + + db.session.commit() + + return jsonify({ + 'success': True, + 'data': order.to_dict(), + 'message': '订单创建成功' + }) + + except Exception as e: + db.session.rollback() + return jsonify({'success': False, 'error': '创建订单失败'}), 500 + + +@app.route('/api/payment/order//status', methods=['GET']) +def check_order_status(order_id): + """查询订单支付状态""" + try: + if 'user_id' not in session: + return jsonify({'success': False, 'error': '未登录'}), 401 + + # 查找订单 + order = PaymentOrder.query.filter_by( + id=order_id, + user_id=session['user_id'] + ).first() + + if not order: + return jsonify({'success': False, 'error': '订单不存在'}), 404 + + # 如果订单已经是已支付状态,直接返回 + if order.status == 'paid': + return jsonify({ + 'success': True, + 'data': order.to_dict(), + 'message': '订单已支付', + 'payment_success': True + }) + + # 如果订单过期,标记为过期 + if order.is_expired(): + order.status = 'expired' + db.session.commit() + return jsonify({ + 'success': True, + 'data': order.to_dict(), + 'message': '订单已过期' + }) + + # 调用微信支付API查询真实状态 + try: + from wechat_pay import create_wechat_pay_instance + wechat_pay = create_wechat_pay_instance() + + query_result = wechat_pay.query_order(order_no=order.order_no) + + if query_result['success']: + trade_state = query_result.get('trade_state') + transaction_id = query_result.get('transaction_id') + + if trade_state == 'SUCCESS': + # 支付成功,更新订单状态 + order.mark_as_paid(transaction_id) + + # 激活用户订阅 + activate_user_subscription(order.user_id, order.plan_name, order.billing_cycle) + + return jsonify({ + 'success': True, + 'data': order.to_dict(), + 'message': '支付成功!订阅已激活', + 'payment_success': True + }) + elif trade_state in ['NOTPAY', 'USERPAYING']: + # 未支付或支付中 + return jsonify({ + 'success': True, + 'data': order.to_dict(), + 'message': '等待支付...', + 'payment_success': False + }) + else: + # 支付失败或取消 + order.status = 'cancelled' + db.session.commit() + return jsonify({ + 'success': True, + 'data': order.to_dict(), + 'message': '支付已取消', + 'payment_success': False + }) + else: + # 微信查询失败,返回当前状态 + return jsonify({ + 'success': True, + 'data': order.to_dict(), + 'message': f"查询失败: {query_result.get('error')}", + 'payment_success': False + }) + + except Exception as e: + # 查询失败,返回当前订单状态 + return jsonify({ + 'success': True, + 'data': order.to_dict(), + 'message': '无法查询支付状态,请稍后重试', + 'payment_success': False + }) + + except Exception as e: + return jsonify({'success': False, 'error': '查询失败'}), 500 + + +@app.route('/api/payment/order//force-update', methods=['POST']) +def force_update_order_status(order_id): + """强制更新订单支付状态(调试用)""" + try: + if 'user_id' not in session: + return jsonify({'success': False, 'error': '未登录'}), 401 + + # 查找订单 + order = PaymentOrder.query.filter_by( + id=order_id, + user_id=session['user_id'] + ).first() + + if not order: + return jsonify({'success': False, 'error': '订单不存在'}), 404 + + # 检查微信支付状态 + try: + from wechat_pay import create_wechat_pay_instance + wechat_pay = create_wechat_pay_instance() + + query_result = wechat_pay.query_order(order_no=order.order_no) + + if query_result['success'] and query_result.get('trade_state') == 'SUCCESS': + # 强制更新为已支付 + old_status = order.status + order.mark_as_paid(query_result.get('transaction_id')) + + # 激活用户订阅 + activate_user_subscription(order.user_id, order.plan_name, order.billing_cycle) + + # 记录优惠码使用(如果使用了优惠码) + if hasattr(order, 'promo_code_id') and order.promo_code_id: + try: + promo_usage = PromoCodeUsage( + promo_code_id=order.promo_code_id, + user_id=order.user_id, + order_id=order.id, + original_amount=order.original_amount if hasattr(order, 'original_amount') else order.amount, + discount_amount=order.discount_amount if hasattr(order, 'discount_amount') else 0, + final_amount=order.amount + ) + db.session.add(promo_usage) + + # 更新优惠码使用次数 + promo = PromoCode.query.get(order.promo_code_id) + if promo: + promo.current_uses = (promo.current_uses or 0) + 1 + except Exception as e: + print(f"记录优惠码使用失败: {e}") + + db.session.commit() + + print(f"✅ 订单状态强制更新成功: {old_status} -> paid") + + return jsonify({ + 'success': True, + 'message': f'订单状态已从 {old_status} 更新为 paid', + 'data': order.to_dict(), + 'payment_success': True + }) + else: + return jsonify({ + 'success': False, + 'error': '微信支付状态不是成功状态,无法强制更新' + }) + + except Exception as e: + print(f"❌ 强制更新失败: {e}") + return jsonify({ + 'success': False, + 'error': f'强制更新失败: {str(e)}' + }) + + except Exception as e: + print(f"强制更新订单状态失败: {str(e)}") + return jsonify({'success': False, 'error': '操作失败'}), 500 + + +@app.route('/api/payment/wechat/callback', methods=['POST']) +def wechat_payment_callback(): + """微信支付回调处理""" + try: + # 获取原始XML数据 + raw_data = request.get_data() + print(f"📥 收到微信支付回调: {raw_data}") + + # 验证回调数据 + try: + from wechat_pay import create_wechat_pay_instance + wechat_pay = create_wechat_pay_instance() + verify_result = wechat_pay.verify_callback(raw_data.decode('utf-8')) + + if not verify_result['success']: + print(f"❌ 微信支付回调验证失败: {verify_result['error']}") + return '' + + callback_data = verify_result['data'] + + except Exception as e: + print(f"❌ 微信支付回调处理异常: {e}") + # 简单解析XML(fallback) + callback_data = _parse_xml_callback(raw_data.decode('utf-8')) + if not callback_data: + return '' + + # 获取关键字段 + return_code = callback_data.get('return_code') + result_code = callback_data.get('result_code') + order_no = callback_data.get('out_trade_no') + transaction_id = callback_data.get('transaction_id') + + print(f"📦 回调数据解析:") + print(f" 返回码: {return_code}") + print(f" 结果码: {result_code}") + print(f" 订单号: {order_no}") + print(f" 交易号: {transaction_id}") + + if not order_no: + return '' + + # 查找订单 + order = PaymentOrder.query.filter_by(order_no=order_no).first() + if not order: + print(f"❌ 订单不存在: {order_no}") + return '' + + # 处理支付成功 + if return_code == 'SUCCESS' and result_code == 'SUCCESS': + print(f"🎉 支付回调成功: 订单 {order_no}") + + # 检查订单是否已经处理过 + if order.status == 'paid': + print(f"ℹ️ 订单已处理过: {order_no}") + db.session.commit() + return '' + + # 更新订单状态(无论之前是什么状态) + old_status = order.status + order.mark_as_paid(transaction_id) + print(f"📝 订单状态已更新: {old_status} -> paid") + + # 激活用户订阅 + subscription = activate_user_subscription(order.user_id, order.plan_name, order.billing_cycle) + + if subscription: + print(f"✅ 用户订阅已激活: 用户{order.user_id}, 套餐{order.plan_name}") + else: + print(f"⚠️ 订阅激活失败,但订单已标记为已支付") + + db.session.commit() + + # 返回成功响应给微信 + return '' + + except Exception as e: + db.session.rollback() + print(f"❌ 微信支付回调处理失败: {e}") + import traceback + app.logger.error(f"回调处理错误: {e}", exc_info=True) + return '' + + +def _parse_xml_callback(xml_data): + """简单的XML回调数据解析""" + try: + import xml.etree.ElementTree as ET + root = ET.fromstring(xml_data) + result = {} + for child in root: + result[child.tag] = child.text + return result + except Exception as e: + print(f"XML解析失败: {e}") + return None + + +@app.route('/api/auth/session', methods=['GET']) +def get_session_info(): + """获取当前登录用户信息""" + if 'user_id' in session: + user = User.query.get(session['user_id']) + if user: + # 获取用户订阅信息 + subscription_info = get_user_subscription_safe(user.id).to_dict() + + return jsonify({ + 'success': True, + 'isAuthenticated': True, + 'user': { + 'id': user.id, + 'username': user.username, + 'nickname': user.nickname or user.username, + 'email': user.email, + 'phone': user.phone, + 'phone_confirmed': bool(user.phone_confirmed), + 'email_confirmed': bool(user.email_confirmed) if hasattr(user, 'email_confirmed') else None, + 'avatar_url': user.avatar_url, + 'has_wechat': bool(user.wechat_open_id), + 'created_at': user.created_at.isoformat() if user.created_at else None, + 'last_seen': user.last_seen.isoformat() if user.last_seen else None, + # 将订阅字段映射到前端期望的字段名 + 'subscription_type': subscription_info['type'], + 'subscription_status': subscription_info['status'], + 'subscription_end_date': subscription_info['end_date'], + 'is_subscription_active': subscription_info['is_active'], + 'subscription_days_left': subscription_info['days_left'] + } + }) + + return jsonify({ + 'success': True, + 'isAuthenticated': False, + 'user': None + }) + + +def generate_verification_code(): + """生成6位数字验证码""" + return ''.join(random.choices(string.digits, k=6)) + + +@app.route('/api/auth/login', methods=['POST']) +def login(): + """传统登录 - 使用Session""" + try: + + username = request.form.get('username') + email = request.form.get('email') + phone = request.form.get('phone') + password = request.form.get('password') + + # 验证必要参数 + if not password: + return jsonify({'success': False, 'error': '密码不能为空'}), 400 + + # 根据提供的信息查找用户 + user = None + if username: + # 检查username是否为手机号格式 + if re.match(r'^1[3-9]\d{9}$', username): + # 如果username是手机号格式,先按手机号查找 + user = User.query.filter_by(phone=username).first() + if not user: + # 如果没找到,再按用户名查找 + user = User.find_by_login_info(username) + else: + # 不是手机号格式,按用户名查找 + user = User.find_by_login_info(username) + elif email: + user = User.query.filter_by(email=email).first() + elif phone: + user = User.query.filter_by(phone=phone).first() + else: + return jsonify({'success': False, 'error': '请提供用户名、邮箱或手机号'}), 400 + + if not user: + return jsonify({'success': False, 'error': '用户不存在'}), 404 + + # 尝试密码验证 + password_valid = user.check_password(password) + + if not password_valid: + # 还可以尝试直接验证 + if user.password_hash: + from werkzeug.security import check_password_hash + direct_check = check_password_hash(user.password_hash, password) + return jsonify({'success': False, 'error': '密码错误'}), 401 + + # 设置session + session.permanent = True # 使用永久session + session['user_id'] = user.id + session['username'] = user.username + session['logged_in'] = True + + # Flask-Login 登录 + login_user(user, remember=True) + + # 更新最后登录时间 + user.update_last_seen() + + return jsonify({ + 'success': True, + 'message': '登录成功', + 'user': { + 'id': user.id, + 'username': user.username, + 'nickname': user.nickname or user.username, + 'email': user.email, + 'phone': user.phone, + 'avatar_url': user.avatar_url, + 'has_wechat': bool(user.wechat_open_id) + } + }) + + except Exception as e: + import traceback + app.logger.error(f"回调处理错误: {e}", exc_info=True) + return jsonify({'success': False, 'error': '登录处理失败,请重试'}), 500 + + +# 添加OPTIONS请求处理 +@app.before_request +def handle_preflight(): + if request.method == "OPTIONS": + response = make_response() + response.headers.add("Access-Control-Allow-Origin", "*") + response.headers.add('Access-Control-Allow-Headers', "*") + response.headers.add('Access-Control-Allow-Methods', "*") + return response + + +# 修改密码API +@app.route('/api/account/change-password', methods=['POST']) +@login_required +def change_password(): + """修改当前用户密码""" + try: + data = request.get_json() or request.form + current_password = data.get('currentPassword') or data.get('current_password') + new_password = data.get('newPassword') or data.get('new_password') + is_first_set = data.get('isFirstSet', False) # 是否为首次设置密码 + + if not new_password: + return jsonify({'success': False, 'error': '新密码不能为空'}), 400 + + if len(new_password) < 6: + return jsonify({'success': False, 'error': '新密码至少需要6个字符'}), 400 + + # 获取当前用户 + user = current_user + if not user: + return jsonify({'success': False, 'error': '用户未登录'}), 401 + + # 检查是否为微信用户且首次设置密码 + is_wechat_user = bool(user.wechat_open_id) + + # 如果是微信用户首次设置密码,或者明确标记为首次设置,则跳过当前密码验证 + if is_first_set or (is_wechat_user and not current_password): + pass # 跳过当前密码验证 + else: + # 普通用户或非首次设置,需要验证当前密码 + if not current_password: + return jsonify({'success': False, 'error': '请输入当前密码'}), 400 + + if not user.check_password(current_password): + return jsonify({'success': False, 'error': '当前密码错误'}), 400 + + # 设置新密码 + user.set_password(new_password) + db.session.commit() + + return jsonify({ + 'success': True, + 'message': '密码设置成功' if (is_first_set or is_wechat_user) else '密码修改成功' + }) + + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + + +# 检查用户密码状态API +@app.route('/api/account/password-status', methods=['GET']) +@login_required +def get_password_status(): + """获取当前用户的密码状态信息""" + try: + user = current_user + if not user: + return jsonify({'success': False, 'error': '用户未登录'}), 401 + + is_wechat_user = bool(user.wechat_open_id) + + return jsonify({ + 'success': True, + 'data': { + 'isWechatUser': is_wechat_user, + 'hasPassword': bool(user.password_hash), + 'needsFirstTimeSetup': is_wechat_user # 微信用户需要首次设置 + } + }) + + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + + +# 检查用户信息完整性API +@app.route('/api/account/profile-completeness', methods=['GET']) +@login_required +def get_profile_completeness(): + try: + user = current_user + if not user: + return jsonify({'success': False, 'error': '用户未登录'}), 401 + + is_wechat_user = bool(user.wechat_open_id) + + # 检查各项信息 + completeness = { + 'hasPassword': bool(user.password_hash), + 'hasPhone': bool(user.phone), + 'hasEmail': bool(user.email and '@' in user.email and not user.email.endswith('@valuefrontier.temp')), + 'isWechatUser': is_wechat_user + } + + # 计算完整度 + total_items = 3 + completed_items = sum([completeness['hasPassword'], completeness['hasPhone'], completeness['hasEmail']]) + completeness_percentage = int((completed_items / total_items) * 100) + + # 智能判断是否需要提醒 + needs_attention = False + missing_items = [] + + # 只在用户首次登录或最近登录时提醒 + if is_wechat_user: + # 检查用户是否是新用户(注册7天内) + is_new_user = (datetime.now() - user.created_at).days < 7 + + # 检查是否最近没有提醒过(使用session记录) + last_reminder = session.get('last_completeness_reminder') + should_remind = False + + if not last_reminder: + should_remind = True + else: + # 每7天最多提醒一次 + days_since_reminder = (datetime.now() - datetime.fromisoformat(last_reminder)).days + should_remind = days_since_reminder >= 7 + + # 只对新用户或长时间未完善的用户提醒 + if (is_new_user or completeness_percentage < 50) and should_remind: + needs_attention = True + if not completeness['hasPassword']: + missing_items.append('登录密码') + if not completeness['hasPhone']: + missing_items.append('手机号') + if not completeness['hasEmail']: + missing_items.append('邮箱') + + # 记录本次提醒时间 + session['last_completeness_reminder'] = datetime.now().isoformat() + + return jsonify({ + 'success': True, + 'data': { + 'completeness': completeness, + 'completenessPercentage': completeness_percentage, + 'needsAttention': needs_attention, + 'missingItems': missing_items, + 'isComplete': completed_items == total_items, + 'showReminder': needs_attention # 前端使用这个字段决定是否显示提醒 + } + }) + + except Exception as e: + print(f"获取资料完整性错误: {e}") + return jsonify({'success': False, 'error': str(e)}), 500 + + +@app.route('/api/auth/logout', methods=['POST']) +def logout(): + """登出 - 清除Session""" + logout_user() # Flask-Login 登出 + session.clear() + return jsonify({'success': True, 'message': '已登出'}) + + +@app.route('/api/auth/send-verification-code', methods=['POST']) +def send_verification_code(): + """发送验证码(支持手机号和邮箱)""" + try: + data = request.get_json() + credential = data.get('credential') # 手机号或邮箱 + code_type = data.get('type') # 'phone' 或 'email' + purpose = data.get('purpose', 'login') # 'login' 或 'register' + + if not credential or not code_type: + return jsonify({'success': False, 'error': '缺少必要参数'}), 400 + + # 清理格式字符(空格、横线、括号等) + if code_type == 'phone': + # 移除手机号中的空格、横线、括号、加号等格式字符 + credential = re.sub(r'[\s\-\(\)\+]', '', credential) + print(f"📱 清理后的手机号: {credential}") + elif code_type == 'email': + # 邮箱只移除空格 + credential = credential.strip() + + # 生成验证码 + verification_code = generate_verification_code() + + # 存储验证码到session(实际生产环境建议使用Redis) + session_key = f'verification_code_{code_type}_{credential}_{purpose}' + session[session_key] = { + 'code': verification_code, + 'timestamp': time.time(), + 'attempts': 0 + } + + if code_type == 'phone': + # 手机号验证码发送 + if not re.match(r'^1[3-9]\d{9}$', credential): + return jsonify({'success': False, 'error': '手机号格式不正确'}), 400 + + # 发送真实短信验证码 + if send_sms_code(credential, verification_code, SMS_TEMPLATE_LOGIN): + print(f"[短信已发送] 验证码到 {credential}: {verification_code}") + else: + return jsonify({'success': False, 'error': '短信发送失败,请稍后重试'}), 500 + + elif code_type == 'email': + # 邮箱验证码发送 + if not re.match(r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$', credential): + return jsonify({'success': False, 'error': '邮箱格式不正确'}), 400 + + # 发送真实邮件验证码 + if send_email_code(credential, verification_code): + print(f"[邮件已发送] 验证码到 {credential}: {verification_code}") + else: + return jsonify({'success': False, 'error': '邮件发送失败,请稍后重试'}), 500 + + else: + return jsonify({'success': False, 'error': '不支持的验证码类型'}), 400 + + return jsonify({ + 'success': True, + 'message': f'验证码已发送到您的{code_type}' + }) + + except Exception as e: + print(f"发送验证码错误: {e}") + return jsonify({'success': False, 'error': '发送验证码失败'}), 500 + + +@app.route('/api/auth/login-with-code', methods=['POST']) +def login_with_verification_code(): + """使用验证码登录/注册(自动注册)""" + try: + data = request.get_json() + credential = data.get('credential') # 手机号或邮箱 + verification_code = data.get('verification_code') + login_type = data.get('login_type') # 'phone' 或 'email' + + if not credential or not verification_code or not login_type: + return jsonify({'success': False, 'error': '缺少必要参数'}), 400 + + # 清理格式字符(空格、横线、括号等) + if login_type == 'phone': + # 移除手机号中的空格、横线、括号、加号等格式字符 + original_credential = credential + credential = re.sub(r'[\s\-\(\)\+]', '', credential) + if original_credential != credential: + print(f"📱 登录时清理手机号: {original_credential} -> {credential}") + elif login_type == 'email': + # 邮箱只移除前后空格 + credential = credential.strip() + + # 检查验证码 + session_key = f'verification_code_{login_type}_{credential}_login' + stored_code_info = session.get(session_key) + + if not stored_code_info: + return jsonify({'success': False, 'error': '验证码已过期或不存在'}), 400 + + # 检查验证码是否过期(5分钟) + if time.time() - stored_code_info['timestamp'] > 300: + session.pop(session_key, None) + return jsonify({'success': False, 'error': '验证码已过期'}), 400 + + # 检查尝试次数 + if stored_code_info['attempts'] >= 3: + session.pop(session_key, None) + return jsonify({'success': False, 'error': '验证码错误次数过多'}), 400 + + # 验证码错误 + if stored_code_info['code'] != verification_code: + stored_code_info['attempts'] += 1 + session[session_key] = stored_code_info + return jsonify({'success': False, 'error': '验证码错误'}), 400 + + # 验证码正确,查找用户 + user = None + is_new_user = False + + if login_type == 'phone': + user = User.query.filter_by(phone=credential).first() + if not user: + # 自动注册新用户 + is_new_user = True + # 生成唯一用户名 + base_username = f"user_{credential}" + 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=credential) + user.phone_confirmed = True + user.email = f"{username}@valuefrontier.temp" # 临时邮箱 + db.session.add(user) + db.session.commit() + + elif login_type == 'email': + user = User.query.filter_by(email=credential).first() + if not user: + # 自动注册新用户 + is_new_user = True + # 从邮箱生成用户名 + email_prefix = credential.split('@')[0] + base_username = f"user_{email_prefix}" + username = base_username + counter = 1 + while User.query.filter_by(username=username).first(): + username = f"{base_username}_{counter}" + counter += 1 + + # 如果用户不存在,自动创建新用户 + if not user: + try: + # 生成用户名 + if login_type == 'phone': + # 使用手机号生成用户名 + base_username = f"用户{credential[-4:]}" + elif login_type == 'email': + # 使用邮箱前缀生成用户名 + base_username = credential.split('@')[0] + else: + base_username = "新用户" + + # 确保用户名唯一 + username = base_username + counter = 1 + while User.is_username_taken(username): + username = f"{base_username}_{counter}" + counter += 1 + + # 创建新用户 + user = User(username=username) + + # 设置手机号或邮箱 + if login_type == 'phone': + user.phone = credential + elif login_type == 'email': + user.email = credential + + # 设置默认密码(使用随机密码,用户后续可以修改) + user.set_password(uuid.uuid4().hex) + user.status = 'active' + user.nickname = username + + db.session.add(user) + db.session.commit() + + is_new_user = True + print(f"✅ 自动创建新用户: {username}, {login_type}: {credential}") + + except Exception as e: + print(f"❌ 创建用户失败: {e}") + db.session.rollback() + return jsonify({'success': False, 'error': '创建用户失败'}), 500 + + # 清除验证码 + session.pop(session_key, None) + + # 设置session + session.permanent = True + session['user_id'] = user.id + session['username'] = user.username + session['logged_in'] = True + + # Flask-Login 登录 + login_user(user, remember=True) + + # 更新最后登录时间 + user.update_last_seen() + + # 根据是否为新用户返回不同的消息 + message = '注册成功,欢迎加入!' if is_new_user else '登录成功' + + return jsonify({ + 'success': True, + 'message': message, + 'is_new_user': is_new_user, + 'user': { + 'id': user.id, + 'username': user.username, + 'nickname': user.nickname or user.username, + 'email': user.email, + 'phone': user.phone, + 'avatar_url': user.avatar_url, + 'has_wechat': bool(user.wechat_open_id) + } + }) + + except Exception as e: + print(f"验证码登录错误: {e}") + db.session.rollback() + return jsonify({'success': False, 'error': '登录失败'}), 500 + + +@app.route('/api/auth/register', methods=['POST']) +def register(): + """用户注册 - 使用Session""" + username = request.form.get('username') + email = request.form.get('email') + password = request.form.get('password') + + # 验证输入 + if not all([username, email, password]): + return jsonify({'success': False, 'error': '所有字段都是必填的'}), 400 + + # 检查用户名和邮箱是否已存在 + if User.is_username_taken(username): + return jsonify({'success': False, 'error': '用户名已存在'}), 400 + + if User.is_email_taken(email): + return jsonify({'success': False, 'error': '邮箱已被使用'}), 400 + + try: + # 创建新用户 + user = User(username=username, email=email) + user.set_password(password) + user.email_confirmed = True # 暂时默认已确认 + + db.session.add(user) + db.session.commit() + + # 自动登录 + session.permanent = True + session['user_id'] = user.id + session['username'] = user.username + session['logged_in'] = True + + # Flask-Login 登录 + login_user(user, remember=True) + + return jsonify({ + 'success': True, + 'message': '注册成功', + 'user': { + 'id': user.id, + 'username': user.username, + 'nickname': user.nickname or user.username, + 'email': user.email + } + }), 201 + + except Exception as e: + db.session.rollback() + print(f"验证码登录/注册错误: {e}") + return jsonify({'success': False, 'error': '登录失败'}), 500 + + +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, "5"] if template_id == SMS_TEMPLATE_LOGIN else [code] + } + 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 send_email_code(email, code): + """发送邮件验证码""" + try: + print(f"[邮件发送] 准备发送验证码到: {email}") + print(f"[邮件配置] 服务器: {MAIL_SERVER}, 端口: {MAIL_PORT}, SSL: {MAIL_USE_SSL}") + + msg = Message( + subject='价值前沿 - 验证码', + recipients=[email], + body=f'您的验证码是:{code},有效期5分钟。如非本人操作,请忽略此邮件。' + ) + mail.send(msg) + print(f"[邮件发送] 验证码邮件发送成功到: {email}") + return True + except Exception as e: + print(f"[邮件发送错误] 发送到 {email} 失败: {str(e)}") + print(f"[邮件发送错误] 错误类型: {type(e).__name__}") + return False + + +@app.route('/api/auth/send-sms-code', methods=['POST']) +def send_sms_verification(): + """发送手机验证码""" + data = request.get_json() + phone = data.get('phone') + + if not phone: + return jsonify({'error': '手机号不能为空'}), 400 + + # 注册时验证是否已注册;若用于绑定手机,需要另外接口 + # 这里保留原逻辑,新增绑定接口处理不同规则 + if User.query.filter_by(phone=phone).first(): + return jsonify({'error': '该手机号已注册'}), 400 + + # 生成验证码 + code = generate_verification_code() + + # 发送短信 + if send_sms_code(phone, code, SMS_TEMPLATE_REGISTER): + # 存储验证码(5分钟有效) + verification_codes[f'phone_{phone}'] = { + 'code': code, + 'expires': time.time() + 300 + } + return jsonify({'message': '验证码已发送'}), 200 + else: + return jsonify({'error': '验证码发送失败'}), 500 + + +@app.route('/api/auth/send-email-code', methods=['POST']) +def send_email_verification(): + """发送邮箱验证码""" + data = request.get_json() + email = data.get('email') + + if not email: + return jsonify({'error': '邮箱不能为空'}), 400 + + if User.query.filter_by(email=email).first(): + return jsonify({'error': '该邮箱已注册'}), 400 + + # 生成验证码 + code = generate_verification_code() + + # 发送邮件 + if send_email_code(email, code): + # 存储验证码(5分钟有效) + verification_codes[f'email_{email}'] = { + 'code': code, + 'expires': time.time() + 300 + } + return jsonify({'message': '验证码已发送'}), 200 + else: + return jsonify({'error': '验证码发送失败'}), 500 + + +@app.route('/api/auth/register/phone', methods=['POST']) +def register_with_phone(): + """手机号注册 - 使用Session""" + data = request.get_json() + phone = data.get('phone') + code = data.get('code') + password = data.get('password') + username = data.get('username') + + if not all([phone, code, password, username]): + return jsonify({'success': False, 'error': '所有字段都是必填的'}), 400 + + # 验证验证码 + stored_code = verification_codes.get(f'phone_{phone}') + if not stored_code or stored_code['expires'] < time.time(): + return jsonify({'success': False, 'error': '验证码已过期'}), 400 + + if stored_code['code'] != code: + return jsonify({'success': False, 'error': '验证码错误'}), 400 + + if User.query.filter_by(username=username).first(): + return jsonify({'success': False, 'error': '用户名已存在'}), 400 + + try: + # 创建用户 + user = User(username=username, phone=phone) + user.email = f"{username}@valuefrontier.temp" + user.set_password(password) + user.phone_confirmed = True + + db.session.add(user) + db.session.commit() + + # 清除验证码 + del verification_codes[f'phone_{phone}'] + + # 自动登录 + session.permanent = True + session['user_id'] = user.id + session['username'] = user.username + session['logged_in'] = True + + # Flask-Login 登录 + login_user(user, remember=True) + + return jsonify({ + 'success': True, + 'message': '注册成功', + 'user': { + 'id': user.id, + 'username': user.username, + 'phone': user.phone + } + }), 201 + + except Exception as e: + db.session.rollback() + return jsonify({'success': False, 'error': '注册失败,请重试'}), 500 + + +@app.route('/api/account/phone/send-code', methods=['POST']) +def send_sms_bind_code(): + """发送绑定手机验证码(需已登录)""" + if not session.get('logged_in'): + return jsonify({'error': '未登录'}), 401 + + data = request.get_json() + phone = data.get('phone') + if not phone: + return jsonify({'error': '手机号不能为空'}), 400 + + # 绑定时要求手机号未被占用 + if User.query.filter_by(phone=phone).first(): + return jsonify({'error': '该手机号已被其他账号使用'}), 400 + + code = generate_verification_code() + if send_sms_code(phone, code, SMS_TEMPLATE_REGISTER): + verification_codes[f'bind_{phone}'] = { + 'code': code, + 'expires': time.time() + 300 + } + return jsonify({'message': '验证码已发送'}), 200 + else: + return jsonify({'error': '验证码发送失败'}), 500 + + +@app.route('/api/account/phone/bind', methods=['POST']) +def bind_phone(): + """当前登录用户绑定手机号""" + if not session.get('logged_in'): + return jsonify({'error': '未登录'}), 401 + + data = request.get_json() + phone = data.get('phone') + code = data.get('code') + + if not phone or not code: + return jsonify({'error': '手机号和验证码不能为空'}), 400 + + stored = verification_codes.get(f'bind_{phone}') + if not stored or stored['expires'] < time.time(): + return jsonify({'error': '验证码已过期'}), 400 + if stored['code'] != code: + return jsonify({'error': '验证码错误'}), 400 + + if User.query.filter_by(phone=phone).first(): + return jsonify({'error': '该手机号已被其他账号使用'}), 400 + + try: + user = User.query.get(session.get('user_id')) + if not user: + return jsonify({'error': '用户不存在'}), 404 + + user.phone = phone + user.confirm_phone() + # 清除验证码 + del verification_codes[f'bind_{phone}'] + + return jsonify({'message': '绑定成功', 'success': True}), 200 + except Exception as e: + print(f"Bind phone error: {e}") + db.session.rollback() + return jsonify({'error': '绑定失败,请重试'}), 500 + + +@app.route('/api/account/phone/unbind', methods=['POST']) +def unbind_phone(): + """解绑手机号(需已登录)""" + if not session.get('logged_in'): + return jsonify({'error': '未登录'}), 401 + + try: + user = User.query.get(session.get('user_id')) + if not user: + return jsonify({'error': '用户不存在'}), 404 + + user.phone = None + user.phone_confirmed = False + user.phone_confirm_time = None + db.session.commit() + return jsonify({'message': '解绑成功', 'success': True}), 200 + except Exception as e: + print(f"Unbind phone error: {e}") + db.session.rollback() + return jsonify({'error': '解绑失败,请重试'}), 500 + + +@app.route('/api/account/email/send-bind-code', methods=['POST']) +def send_email_bind_code(): + """发送绑定邮箱验证码(需已登录)""" + if not session.get('logged_in'): + return jsonify({'error': '未登录'}), 401 + + data = request.get_json() + email = data.get('email') + + if not email: + return jsonify({'error': '邮箱不能为空'}), 400 + + # 邮箱格式验证 + if not re.match(r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$', email): + return jsonify({'error': '邮箱格式不正确'}), 400 + + # 检查邮箱是否已被其他账号使用 + if User.query.filter_by(email=email).first(): + return jsonify({'error': '该邮箱已被其他账号使用'}), 400 + + # 生成验证码 + code = ''.join(random.choices(string.digits, k=6)) + + if send_email_code(email, code): + # 存储验证码(5分钟有效) + verification_codes[f'bind_{email}'] = { + 'code': code, + 'expires': time.time() + 300 + } + return jsonify({'message': '验证码已发送'}), 200 + else: + return jsonify({'error': '验证码发送失败'}), 500 + + +@app.route('/api/account/email/bind', methods=['POST']) +def bind_email(): + """当前登录用户绑定邮箱""" + if not session.get('logged_in'): + return jsonify({'error': '未登录'}), 401 + + data = request.get_json() + email = data.get('email') + code = data.get('code') + + if not email or not code: + return jsonify({'error': '邮箱和验证码不能为空'}), 400 + + stored = verification_codes.get(f'bind_{email}') + if not stored or stored['expires'] < time.time(): + return jsonify({'error': '验证码已过期'}), 400 + if stored['code'] != code: + return jsonify({'error': '验证码错误'}), 400 + + if User.query.filter_by(email=email).first(): + return jsonify({'error': '该邮箱已被其他账号使用'}), 400 + + try: + user = User.query.get(session.get('user_id')) + if not user: + return jsonify({'error': '用户不存在'}), 404 + + user.email = email + user.confirm_email() + db.session.commit() + + # 清除验证码 + del verification_codes[f'bind_{email}'] + + return jsonify({ + 'message': '邮箱绑定成功', + 'success': True, + 'user': { + 'email': user.email, + 'email_confirmed': user.email_confirmed + } + }), 200 + except Exception as e: + print(f"Bind email error: {e}") + db.session.rollback() + return jsonify({'error': '绑定失败,请重试'}), 500 + + +@app.route('/api/account/email/unbind', methods=['POST']) +def unbind_email(): + """解绑邮箱(需已登录)""" + if not session.get('logged_in'): + return jsonify({'error': '未登录'}), 401 + + try: + user = User.query.get(session.get('user_id')) + if not user: + return jsonify({'error': '用户不存在'}), 404 + + user.email = None + user.email_confirmed = False + db.session.commit() + return jsonify({'message': '解绑成功', 'success': True}), 200 + except Exception as e: + print(f"Unbind email error: {e}") + db.session.rollback() + return jsonify({'error': '解绑失败,请重试'}), 500 + + +@app.route('/api/auth/register/email', methods=['POST']) +def register_with_email(): + """邮箱注册 - 使用Session""" + data = request.get_json() + email = data.get('email') + code = data.get('code') + password = data.get('password') + username = data.get('username') + + if not all([email, code, password, username]): + return jsonify({'success': False, 'error': '所有字段都是必填的'}), 400 + + # 验证验证码 + stored_code = verification_codes.get(f'email_{email}') + if not stored_code or stored_code['expires'] < time.time(): + return jsonify({'success': False, 'error': '验证码已过期'}), 400 + + if stored_code['code'] != code: + return jsonify({'success': False, 'error': '验证码错误'}), 400 + + if User.query.filter_by(username=username).first(): + return jsonify({'success': False, 'error': '用户名已存在'}), 400 + + try: + # 创建用户 + user = User(username=username, email=email) + user.set_password(password) + user.email_confirmed = True + + db.session.add(user) + db.session.commit() + + # 清除验证码 + del verification_codes[f'email_{email}'] + + # 自动登录 + session.permanent = True + session['user_id'] = user.id + session['username'] = user.username + session['logged_in'] = True + + # Flask-Login 登录 + login_user(user, remember=True) + + return jsonify({ + 'success': True, + 'message': '注册成功', + 'user': { + 'id': user.id, + 'username': user.username, + 'email': user.email + } + }), 201 + + except Exception as e: + db.session.rollback() + return jsonify({'success': False, 'error': '注册失败,请重试'}), 500 + + +def get_wechat_access_token(code): + """通过code获取微信access_token""" + url = "https://api.weixin.qq.com/sns/oauth2/access_token" + params = { + 'appid': WECHAT_APPID, + 'secret': WECHAT_APPSECRET, + 'code': code, + 'grant_type': 'authorization_code' + } + + try: + response = requests.get(url, params=params, timeout=10) + data = response.json() + + if 'errcode' in data: + print(f"WeChat access token error: {data}") + return None + + return data + except Exception as e: + print(f"WeChat access token request error: {e}") + return None + + +def get_wechat_userinfo(access_token, openid): + """获取微信用户信息(包含UnionID)""" + url = "https://api.weixin.qq.com/sns/userinfo" + params = { + 'access_token': access_token, + 'openid': openid, + 'lang': 'zh_CN' + } + + try: + response = requests.get(url, params=params, timeout=10) + response.encoding = 'utf-8' # 明确设置编码为UTF-8 + data = response.json() + + if 'errcode' in data: + print(f"WeChat userinfo error: {data}") + return None + + # 确保nickname字段的编码正确 + if 'nickname' in data and data['nickname']: + # 确保昵称是正确的UTF-8编码 + try: + # 检查是否已经是正确的UTF-8字符串 + data['nickname'] = data['nickname'].encode('utf-8').decode('utf-8') + except (UnicodeEncodeError, UnicodeDecodeError) as e: + print(f"Nickname encoding error: {e}, using default") + data['nickname'] = '微信用户' + + return data + except Exception as e: + print(f"WeChat userinfo request error: {e}") + return None + + +@app.route('/api/auth/wechat/qrcode', methods=['GET']) +def get_wechat_qrcode(): + """返回微信授权URL,前端使用iframe展示""" + # 生成唯一state参数 + state = uuid.uuid4().hex + + # URL编码回调地址 + redirect_uri = urllib.parse.quote_plus(WECHAT_REDIRECT_URI) + + # 构建微信授权URL + wechat_auth_url = ( + f"https://open.weixin.qq.com/connect/qrconnect?" + f"appid={WECHAT_APPID}&redirect_uri={redirect_uri}" + f"&response_type=code&scope=snsapi_login&state={state}" + "#wechat_redirect" + ) + + # 存储session信息 + wechat_qr_sessions[state] = { + 'status': 'waiting', + 'expires': time.time() + 300, # 5分钟过期 + 'user_info': None, + 'wechat_openid': None, + 'wechat_unionid': None + } + + return jsonify({"code":0, + "data": + { + 'auth_url': wechat_auth_url, + 'session_id': state, + 'expires_in': 300 + }}), 200 + + +@app.route('/api/account/wechat/qrcode', methods=['GET']) +def get_wechat_bind_qrcode(): + """发起微信绑定二维码,会话标记为绑定模式""" + if not session.get('logged_in'): + return jsonify({'error': '未登录'}), 401 + + # 生成唯一state参数 + state = uuid.uuid4().hex + + # URL编码回调地址 + redirect_uri = urllib.parse.quote_plus(WECHAT_REDIRECT_URI) + + # 构建微信授权URL + wechat_auth_url = ( + f"https://open.weixin.qq.com/connect/qrconnect?" + f"appid={WECHAT_APPID}&redirect_uri={redirect_uri}" + f"&response_type=code&scope=snsapi_login&state={state}" + "#wechat_redirect" + ) + + # 存储session信息,标记为绑定模式并记录目标用户 + wechat_qr_sessions[state] = { + 'status': 'waiting', + 'expires': time.time() + 300, + 'mode': 'bind', + 'bind_user_id': session.get('user_id'), + 'user_info': None, + 'wechat_openid': None, + 'wechat_unionid': None + } + + return jsonify({ + 'auth_url': wechat_auth_url, + 'session_id': state, + 'expires_in': 300 + }), 200 + + +@app.route('/api/auth/wechat/check', methods=['POST']) +def check_wechat_scan(): + """检查微信扫码状态""" + data = request.get_json() + session_id = data.get('session_id') + + if not session_id or session_id not in wechat_qr_sessions: + return jsonify({'status': 'invalid', 'error': '无效的session'}), 400 + + session = wechat_qr_sessions[session_id] + + # 检查是否过期 + if time.time() > session['expires']: + del wechat_qr_sessions[session_id] + return jsonify({'status': 'expired'}), 200 + + return jsonify({ + 'status': session['status'], + 'user_info': session.get('user_info'), + 'expires_in': int(session['expires'] - time.time()) + }), 200 + + +@app.route('/api/account/wechat/check', methods=['POST']) +def check_wechat_bind_scan(): + """检查微信扫码绑定状态""" + data = request.get_json() + session_id = data.get('session_id') + + if not session_id or session_id not in wechat_qr_sessions: + return jsonify({'status': 'invalid', 'error': '无效的session'}), 400 + + sess = wechat_qr_sessions[session_id] + + # 绑定模式限制 + if sess.get('mode') != 'bind': + return jsonify({'status': 'invalid', 'error': '会话模式错误'}), 400 + + # 过期处理 + if time.time() > sess['expires']: + del wechat_qr_sessions[session_id] + return jsonify({'status': 'expired'}), 200 + + return jsonify({ + 'status': sess['status'], + 'user_info': sess.get('user_info'), + 'expires_in': int(sess['expires'] - time.time()) + }), 200 + + +@app.route('/api/auth/wechat/callback', methods=['GET']) +def wechat_callback(): + """微信授权回调处理 - 使用Session""" + code = request.args.get('code') + state = request.args.get('state') + error = request.args.get('error') + + # 错误处理:用户拒绝授权 + if error: + if state in wechat_qr_sessions: + wechat_qr_sessions[state]['status'] = 'auth_denied' + wechat_qr_sessions[state]['error'] = '用户拒绝授权' + print(f"❌ 用户拒绝授权: state={state}") + return redirect('/auth/signin?error=wechat_auth_denied') + + # 参数验证 + if not code or not state: + if state in wechat_qr_sessions: + wechat_qr_sessions[state]['status'] = 'auth_failed' + wechat_qr_sessions[state]['error'] = '授权参数缺失' + return redirect('/auth/signin?error=wechat_auth_failed') + + # 验证state + if state not in wechat_qr_sessions: + return redirect('/auth/signin?error=session_expired') + + session_data = wechat_qr_sessions[state] + + # 检查过期 + if time.time() > session_data['expires']: + del wechat_qr_sessions[state] + return redirect('/auth/signin?error=session_expired') + + try: + # 步骤1: 用户已扫码并授权(微信回调过来说明用户已完成扫码+授权) + session_data['status'] = 'scanned' + print(f"✅ 微信扫码回调: state={state}, code={code[:10]}...") + + # 步骤2: 获取access_token + token_data = get_wechat_access_token(code) + if not token_data: + session_data['status'] = 'auth_failed' + session_data['error'] = '获取访问令牌失败' + print(f"❌ 获取微信access_token失败: state={state}") + return redirect('/auth/signin?error=token_failed') + + # 步骤3: Token获取成功,标记为已授权 + session_data['status'] = 'authorized' + print(f"✅ 微信授权成功: openid={token_data['openid']}") + + # 步骤4: 获取用户信息 + user_info = get_wechat_userinfo(token_data['access_token'], token_data['openid']) + if not user_info: + session_data['status'] = 'auth_failed' + session_data['error'] = '获取用户信息失败' + print(f"❌ 获取微信用户信息失败: openid={token_data['openid']}") + return redirect('/auth/signin?error=userinfo_failed') + + # 查找或创建用户 / 或处理绑定 + openid = token_data['openid'] + unionid = user_info.get('unionid') or token_data.get('unionid') + + # 如果是绑定流程 + session_item = wechat_qr_sessions.get(state) + if session_item and session_item.get('mode') == 'bind': + try: + target_user_id = session.get('user_id') or session_item.get('bind_user_id') + if not target_user_id: + return redirect('/auth/signin?error=bind_no_user') + + target_user = User.query.get(target_user_id) + if not target_user: + return redirect('/auth/signin?error=bind_user_missing') + + # 检查该微信是否已被其他账户绑定 + existing = None + if unionid: + existing = User.query.filter_by(wechat_union_id=unionid).first() + if not existing: + existing = User.query.filter_by(wechat_open_id=openid).first() + + if existing and existing.id != target_user.id: + session_item['status'] = 'bind_conflict' + return redirect('/home?bind=conflict') + + # 执行绑定 + target_user.bind_wechat(openid, unionid, wechat_info=user_info) + + # 标记绑定完成,供前端轮询 + session_item['status'] = 'bind_ready' + session_item['user_info'] = {'user_id': target_user.id} + + return redirect('/home?bind=success') + except Exception as e: + print(f"❌ 微信绑定失败: {e}") + db.session.rollback() + session_item['status'] = 'bind_failed' + return redirect('/home?bind=failed') + + user = None + is_new_user = False + + if unionid: + user = User.query.filter_by(wechat_union_id=unionid).first() + if not user: + user = User.query.filter_by(wechat_open_id=openid).first() + + if not user: + # 创建新用户 + # 先清理微信昵称 + raw_nickname = user_info.get('nickname', '微信用户') + # 创建临时用户实例以使用清理方法 + temp_user = User.__new__(User) + sanitized_nickname = temp_user._sanitize_nickname(raw_nickname) + + username = sanitized_nickname + counter = 1 + while User.is_username_taken(username): + username = f"{sanitized_nickname}_{counter}" + counter += 1 + + user = User(username=username) + user.nickname = sanitized_nickname + user.avatar_url = user_info.get('headimgurl') + user.wechat_open_id = openid + user.wechat_union_id = unionid + user.set_password(uuid.uuid4().hex) + user.status = 'active' + + db.session.add(user) + db.session.commit() + + is_new_user = True + print(f"✅ 微信扫码自动创建新用户: {username}, openid: {openid}") + + # 更新最后登录时间 + user.update_last_seen() + + # 设置session + session.permanent = True + session['user_id'] = user.id + session['username'] = user.username + session['logged_in'] = True + session['wechat_login'] = True # 标记是微信登录 + + # Flask-Login 登录 + login_user(user, remember=True) + + # 更新微信session状态,供前端轮询检测 + if state in wechat_qr_sessions: + session_item = wechat_qr_sessions[state] + # 仅处理登录/注册流程,不处理绑定流程 + if not session_item.get('mode'): + # 更新状态和用户信息 + session_item['status'] = 'register_ready' if is_new_user else 'login_ready' + session_item['user_info'] = {'user_id': user.id} + print(f"✅ 微信扫码状态已更新: {session_item['status']}, user_id: {user.id}") + + # 直接跳转到首页 + return redirect('/home') + + except Exception as e: + print(f"❌ 微信登录失败: {e}") + import traceback + traceback.print_exc() + db.session.rollback() + + # 更新session状态为失败 + if state in wechat_qr_sessions: + wechat_qr_sessions[state]['status'] = 'auth_failed' + wechat_qr_sessions[state]['error'] = str(e) + + return redirect('/auth/signin?error=login_failed') + + +@app.route('/api/auth/login/wechat', methods=['POST']) +def login_with_wechat(): + """微信登录 - 修复版本""" + data = request.get_json() + session_id = data.get('session_id') + + if not session_id: + return jsonify({'success': False, 'error': 'session_id不能为空'}), 400 + + # 验证session + session = wechat_qr_sessions.get(session_id) + if not session: + return jsonify({'success': False, 'error': '会话不存在或已过期'}), 400 + + # 检查session状态 + if session['status'] not in ['login_ready', 'register_ready']: + return jsonify({'success': False, 'error': '会话状态无效'}), 400 + + # 检查是否有用户信息 + user_info = session.get('user_info') + if not user_info or not user_info.get('user_id'): + return jsonify({'success': False, 'error': '用户信息不完整'}), 400 + + try: + user = User.query.get(user_info['user_id']) + if not user: + return jsonify({'success': False, 'error': '用户不存在'}), 404 + + # 更新最后登录时间 + user.update_last_seen() + + # 清除session + del wechat_qr_sessions[session_id] + + # 生成登录响应 + response_data = { + 'success': True, + 'message': '登录成功' if session['status'] == 'login_ready' else '注册并登录成功', + 'user': { + 'id': user.id, + 'username': user.username, + 'nickname': user.nickname or user.username, + 'email': user.email, + 'avatar_url': user.avatar_url, + 'has_wechat': True, + 'wechat_open_id': user.wechat_open_id, + 'wechat_union_id': user.wechat_union_id, + 'created_at': user.created_at.isoformat() if user.created_at else None, + 'last_seen': user.last_seen.isoformat() if user.last_seen else None + } + } + + # 如果需要token认证,可以在这里生成 + # response_data['token'] = generate_token(user.id) + + return jsonify(response_data), 200 + + except Exception as e: + print(f"❌ 微信登录错误: {e}") + import traceback + app.logger.error(f"回调处理错误: {e}", exc_info=True) + return jsonify({ + 'success': False, + 'error': '登录失败,请重试' + }), 500 + + +@app.route('/api/account/wechat/unbind', methods=['POST']) +def unbind_wechat_account(): + """解绑当前登录用户的微信""" + if not session.get('logged_in'): + return jsonify({'error': '未登录'}), 401 + + try: + user = User.query.get(session.get('user_id')) + if not user: + return jsonify({'error': '用户不存在'}), 404 + + user.unbind_wechat() + return jsonify({'message': '解绑成功', 'success': True}), 200 + except Exception as e: + print(f"Unbind wechat error: {e}") + db.session.rollback() + return jsonify({'error': '解绑失败,请重试'}), 500 + + +# 评论模型 +class EventComment(db.Model): + """事件评论""" + __tablename__ = 'event_comment' + + id = db.Column(db.Integer, primary_key=True) + event_id = db.Column(db.Integer, nullable=False) + user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=True) + author = db.Column(db.String(50), default='匿名用户') + content = db.Column(db.Text, nullable=False) + parent_id = db.Column(db.Integer, db.ForeignKey('event_comment.id')) + likes = db.Column(db.Integer, default=0) + created_at = db.Column(db.DateTime, default=beijing_now) + status = db.Column(db.String(20), default='active') + + user = db.relationship('User', backref='event_comments') + replies = db.relationship('EventComment', backref=db.backref('parent', remote_side=[id])) + + def to_dict(self, user_session_id=None, current_user_id=None): + # 检查当前用户是否已点赞 + user_liked = False + if user_session_id: + like_record = CommentLike.query.filter_by( + comment_id=self.id, + session_id=user_session_id + ).first() + user_liked = like_record is not None + + # 检查当前用户是否可以删除此评论 + can_delete = current_user_id is not None and self.user_id == current_user_id + + return { + 'id': self.id, + 'event_id': self.event_id, + 'author': self.author, + 'content': self.content, + 'parent_id': self.parent_id, + 'likes': self.likes, + 'created_at': self.created_at.isoformat() if self.created_at else None, + 'user_liked': user_liked, + 'can_delete': can_delete, + 'user_id': self.user_id, + 'replies': [reply.to_dict(user_session_id, current_user_id) for reply in self.replies if + reply.status == 'active'] + } + + +class CommentLike(db.Model): + """评论点赞记录""" + __tablename__ = 'comment_like' + + id = db.Column(db.Integer, primary_key=True) + comment_id = db.Column(db.Integer, db.ForeignKey('event_comment.id'), nullable=False) + session_id = db.Column(db.String(100), nullable=False) + created_at = db.Column(db.DateTime, default=beijing_now) + + __table_args__ = (db.UniqueConstraint('comment_id', 'session_id'),) + + +@app.after_request +def after_request(response): + """处理所有响应,添加CORS头部和安全头部""" + origin = request.headers.get('Origin') + allowed_origins = ['http://localhost:3000', 'http://127.0.0.1:3000', 'http://localhost:5173', + 'https://valuefrontier.cn', 'http://valuefrontier.cn'] + + if origin in allowed_origins: + response.headers['Access-Control-Allow-Origin'] = origin + response.headers['Access-Control-Allow-Credentials'] = 'true' + response.headers['Access-Control-Allow-Headers'] = 'Content-Type,Authorization,X-Requested-With' + response.headers['Access-Control-Allow-Methods'] = 'GET,PUT,POST,DELETE,OPTIONS' + response.headers['Access-Control-Expose-Headers'] = 'Content-Type,Authorization' + + # 处理预检请求 + if request.method == 'OPTIONS': + response.status_code = 200 + + return response + + +def add_cors_headers(response): + """添加CORS头(保留原有函数以兼容)""" + origin = request.headers.get('Origin') + allowed_origins = ['http://localhost:3000', 'http://127.0.0.1:3000', 'http://localhost:5173', + 'https://valuefrontier.cn', 'http://valuefrontier.cn'] + + if origin in allowed_origins: + response.headers['Access-Control-Allow-Origin'] = origin + else: + response.headers['Access-Control-Allow-Origin'] = 'http://localhost:3000' + + response.headers['Access-Control-Allow-Headers'] = 'Content-Type,Authorization,X-Requested-With' + response.headers['Access-Control-Allow-Methods'] = 'GET,PUT,POST,DELETE,OPTIONS' + response.headers['Access-Control-Allow-Credentials'] = 'true' + return response + + +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 FutureEventFollow(db.Model): + """未来事件关注""" + __tablename__ = 'future_event_follow' + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) + future_event_id = db.Column(db.Integer, nullable=False) # future_events表的id + created_at = db.Column(db.DateTime, default=beijing_now) + + user = db.relationship('User', backref='future_event_follows') + + __table_args__ = (db.UniqueConstraint('user_id', 'future_event_id'),) + + +# —— 自选股输入统一化与名称补全工具 —— +def _normalize_stock_input(raw_input: str): + """解析用户输入为标准6位股票代码与可选名称。 + + 支持: + - 6位代码: "600519",或带后缀 "600519.SH"/"600519.SZ" + - 名称(代码): "贵州茅台(600519)" 或 "贵州茅台(600519)" + 返回 (code6, name_or_none) + """ + if not raw_input: + return None, None + s = str(raw_input).strip() + + # 名称(600519) 或 名称(600519) + m = re.match(r"^(.+?)[\((]\s*(\d{6})\s*[\))]\s*$", s) + if m: + name = m.group(1).strip() + code = m.group(2) + return code, (name if name else None) + + # 600519 或 600519.SH / 600519.SZ + m2 = re.match(r"^(\d{6})(?:\.(?:SH|SZ))?$", s, re.IGNORECASE) + if m2: + return m2.group(1), None + + # SH600519 / SZ000001 + m3 = re.match(r"^(SH|SZ)(\d{6})$", s, re.IGNORECASE) + if m3: + return m3.group(2), None + + return None, None + + +def _query_stock_name_by_code(code6: str): + """根据6位代码查询股票名称,查不到返回None。""" + try: + with engine.connect() as conn: + q = text(""" + SELECT SECNAME + FROM ea_baseinfo + WHERE SECCODE = :c LIMIT 1 + """) + row = conn.execute(q, {'c': code6}).fetchone() + if row: + return row[0] + except Exception: + pass + return None + + +class Watchlist(db.Model): + """用户自选股""" + __tablename__ = 'watchlist' + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) + stock_code = db.Column(db.String(20), nullable=False) + stock_name = db.Column(db.String(100), nullable=True) + created_at = db.Column(db.DateTime, default=beijing_now) + + user = db.relationship('User', backref='watchlist') + + __table_args__ = (db.UniqueConstraint('user_id', 'stock_code'),) + + +@app.route('/api/account/watchlist', methods=['GET']) +def get_my_watchlist(): + """获取当前用户的自选股列表""" + try: + if 'user_id' not in session: + return jsonify({'success': False, 'error': '未登录'}), 401 + + items = Watchlist.query.filter_by(user_id=session['user_id']).order_by(Watchlist.created_at.desc()).all() + + # 懒更新:统一代码为6位、补全缺失的名称,并去重(同一代码保留一个记录) + from collections import defaultdict + groups = defaultdict(list) + for i in items: + code6, _ = _normalize_stock_input(i.stock_code) + normalized_code = code6 or (i.stock_code.strip().upper() if isinstance(i.stock_code, str) else i.stock_code) + groups[normalized_code].append(i) + + dirty = False + to_delete = [] + for code6, group in groups.items(): + # 选择保留记录:优先有名称的,其次创建时间早的 + def sort_key(x): + return (x.stock_name is None, x.created_at or datetime.min) + + group_sorted = sorted(group, key=sort_key) + keep = group_sorted[0] + # 规范保留项 + if keep.stock_code != code6: + keep.stock_code = code6 + dirty = True + if not keep.stock_name and code6: + nm = _query_stock_name_by_code(code6) + if nm: + keep.stock_name = nm + dirty = True + # 其余删除 + for g in group_sorted[1:]: + to_delete.append(g) + + if to_delete: + for g in to_delete: + db.session.delete(g) + dirty = True + + if dirty: + db.session.commit() + + return jsonify({'success': True, 'data': [ + { + 'id': i.id, + 'stock_code': i.stock_code, + 'stock_name': i.stock_name, + 'created_at': i.created_at.isoformat() if i.created_at else None + } for i in items + ]}) + except Exception as e: + print(f"Error in get_my_watchlist: {str(e)}") + return jsonify({'success': False, 'error': str(e)}), 500 + + +@app.route('/api/account/watchlist', methods=['POST']) +def add_to_watchlist(): + """添加到自选股""" + if 'user_id' not in session: + return jsonify({'success': False, 'error': '未登录'}), 401 + + data = request.get_json() or {} + raw_code = data.get('stock_code') + raw_name = data.get('stock_name') + + code6, name_from_input = _normalize_stock_input(raw_code) + if not code6: + return jsonify({'success': False, 'error': '无效的股票标识'}), 400 + + # 优先使用传入名称,其次从输入解析中获得,最后查库补全 + final_name = raw_name or name_from_input or _query_stock_name_by_code(code6) + + # 查找已存在记录,兼容历史:6位/带后缀 + candidates = [code6, f"{code6}.SH", f"{code6}.SZ"] + existing = Watchlist.query.filter( + Watchlist.user_id == session['user_id'], + Watchlist.stock_code.in_(candidates) + ).first() + if existing: + # 统一为6位,补全名称 + updated = False + if existing.stock_code != code6: + existing.stock_code = code6 + updated = True + if (not existing.stock_name) and final_name: + existing.stock_name = final_name + updated = True + if updated: + db.session.commit() + return jsonify({'success': True, 'data': {'id': existing.id}}) + + item = Watchlist(user_id=session['user_id'], stock_code=code6, stock_name=final_name) + db.session.add(item) + db.session.commit() + return jsonify({'success': True, 'data': {'id': item.id}}) + + +@app.route('/api/account/watchlist/', methods=['DELETE']) +def remove_from_watchlist(stock_code): + """从自选股移除""" + if 'user_id' not in session: + return jsonify({'success': False, 'error': '未登录'}), 401 + + code6, _ = _normalize_stock_input(stock_code) + candidates = [] + if code6: + candidates = [code6, f"{code6}.SH", f"{code6}.SZ"] + # 包含原始传入(以兼容历史) + if stock_code not in candidates: + candidates.append(stock_code) + + item = Watchlist.query.filter( + Watchlist.user_id == session['user_id'], + Watchlist.stock_code.in_(candidates) + ).first() + if not item: + return jsonify({'success': False, 'error': '未找到自选项'}), 404 + db.session.delete(item) + db.session.commit() + return jsonify({'success': True}) + + +@app.route('/api/account/watchlist/realtime', methods=['GET']) +def get_watchlist_realtime(): + """获取自选股实时行情数据(基于分钟线)""" + try: + if 'user_id' not in session: + return jsonify({'success': False, 'error': '未登录'}), 401 + + # 获取用户自选股列表 + watchlist = Watchlist.query.filter_by(user_id=session['user_id']).all() + if not watchlist: + return jsonify({'success': True, 'data': []}) + + # 获取股票代码列表 + stock_codes = [] + for item in watchlist: + code6, _ = _normalize_stock_input(item.stock_code) + # 统一内部查询代码 + normalized = code6 or str(item.stock_code).strip().upper() + stock_codes.append(normalized) + + # 使用现有的分钟线接口获取最新行情 + client = get_clickhouse_client() + quotes_data = {} + + # 获取最新交易日 + today = datetime.now().date() + + # 获取每只股票的最新价格 + for code in stock_codes: + raw_code = str(code).strip().upper() + if '.' in raw_code: + stock_code_full = raw_code + else: + stock_code_full = f"{raw_code}.SH" if raw_code.startswith('6') else f"{raw_code}.SZ" + + # 获取最新分钟线数据(先查近7天,若无数据再兜底倒序取最近一条) + query = """ + SELECT + close, timestamp, high, low, volume, amt + FROM stock_minute + WHERE code = %(code)s + AND timestamp >= %(start)s + ORDER BY timestamp DESC + LIMIT 1 \ + """ + + # 获取最近7天的分钟数据 + start_date = today - timedelta(days=7) + + result = client.execute(query, { + 'code': stock_code_full, + 'start': datetime.combine(start_date, dt_time(9, 30)) + }) + + # 若近7天无数据,兜底直接取最近一条 + if not result: + fallback_query = """ + SELECT + close, timestamp, high, low, volume, amt + FROM stock_minute + WHERE code = %(code)s + ORDER BY timestamp DESC + LIMIT 1 \ + """ + result = client.execute(fallback_query, {'code': stock_code_full}) + + if result: + latest_data = result[0] + latest_ts = latest_data[1] + + # 获取该bar所属交易日前一个交易日的收盘价 + prev_close_query = """ + SELECT close + FROM stock_minute + WHERE code = %(code)s + AND timestamp \ + < %(start)s + ORDER BY timestamp DESC + LIMIT 1 \ + """ + + prev_result = client.execute(prev_close_query, { + 'code': stock_code_full, + 'start': datetime.combine(latest_ts.date(), dt_time(9, 30)) + }) + + prev_close = float(prev_result[0][0]) if prev_result else float(latest_data[0]) + + # 计算涨跌幅 + change = float(latest_data[0]) - prev_close + change_percent = (change / prev_close * 100) if prev_close > 0 else 0.0 + + quotes_data[code] = { + 'price': float(latest_data[0]), + 'prev_close': float(prev_close), + 'change': float(change), + 'change_percent': float(change_percent), + 'high': float(latest_data[2]), + 'low': float(latest_data[3]), + 'volume': int(latest_data[4]), + 'amount': float(latest_data[5]), + 'update_time': latest_ts.strftime('%H:%M:%S') + } + + # 构建响应数据 + response_data = [] + for item in watchlist: + code6, _ = _normalize_stock_input(item.stock_code) + quote = quotes_data.get(code6 or item.stock_code, {}) + response_data.append({ + 'stock_code': code6 or item.stock_code, + 'stock_name': item.stock_name or (code6 and _query_stock_name_by_code(code6)) or None, + 'current_price': quote.get('price', 0), + 'prev_close': quote.get('prev_close', 0), + 'change': quote.get('change', 0), + 'change_percent': quote.get('change_percent', 0), + 'high': quote.get('high', 0), + 'low': quote.get('low', 0), + 'volume': quote.get('volume', 0), + 'amount': quote.get('amount', 0), + 'update_time': quote.get('update_time', ''), + # industry 字段在 Watchlist 模型中不存在,先不返回该字段 + }) + + return jsonify({ + 'success': True, + 'data': response_data + }) + + except Exception as e: + print(f"获取实时行情失败: {str(e)}") + return jsonify({'success': False, 'error': '获取实时行情失败'}), 500 + + +# 投资计划和复盘相关的模型 +class InvestmentPlan(db.Model): + __tablename__ = 'investment_plans' + id = db.Column(db.Integer, primary_key=True, autoincrement=True) + user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) + date = db.Column(db.Date, nullable=False) + title = db.Column(db.String(200), nullable=False) + content = db.Column(db.Text) + type = db.Column(db.String(20)) # 'plan' or 'review' + stocks = db.Column(db.Text) # JSON array of stock codes + tags = db.Column(db.String(500)) # JSON array of tags + status = db.Column(db.String(20), default='active') # active, completed, cancelled + created_at = db.Column(db.DateTime, default=datetime.utcnow) + updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + def to_dict(self): + return { + 'id': self.id, + 'date': self.date.isoformat() if self.date else None, + 'title': self.title, + 'content': self.content, + 'type': self.type, + 'stocks': json.loads(self.stocks) if self.stocks else [], + 'tags': json.loads(self.tags) if self.tags else [], + 'status': self.status, + 'created_at': self.created_at.isoformat() if self.created_at else None, + 'updated_at': self.updated_at.isoformat() if self.updated_at else None + } + + +@app.route('/api/account/investment-plans', methods=['GET']) +def get_investment_plans(): + """获取投资计划和复盘记录""" + try: + if 'user_id' not in session: + return jsonify({'success': False, 'error': '未登录'}), 401 + + plan_type = request.args.get('type') # 'plan', 'review', or None for all + start_date = request.args.get('start_date') + end_date = request.args.get('end_date') + + query = InvestmentPlan.query.filter_by(user_id=session['user_id']) + + if plan_type: + query = query.filter_by(type=plan_type) + + if start_date: + query = query.filter(InvestmentPlan.date >= datetime.fromisoformat(start_date).date()) + + if end_date: + query = query.filter(InvestmentPlan.date <= datetime.fromisoformat(end_date).date()) + + plans = query.order_by(InvestmentPlan.date.desc()).all() + + return jsonify({ + 'success': True, + 'data': [plan.to_dict() for plan in plans] + }) + + except Exception as e: + print(f"获取投资计划失败: {str(e)}") + return jsonify({'success': False, 'error': '获取数据失败'}), 500 + + +@app.route('/api/account/investment-plans', methods=['POST']) +def create_investment_plan(): + """创建投资计划或复盘记录""" + try: + if 'user_id' not in session: + return jsonify({'success': False, 'error': '未登录'}), 401 + + data = request.get_json() + + # 验证必要字段 + if not data.get('date') or not data.get('title') or not data.get('type'): + return jsonify({'success': False, 'error': '缺少必要字段'}), 400 + + plan = InvestmentPlan( + user_id=session['user_id'], + date=datetime.fromisoformat(data['date']).date(), + title=data['title'], + content=data.get('content', ''), + type=data['type'], + stocks=json.dumps(data.get('stocks', [])), + tags=json.dumps(data.get('tags', [])), + status=data.get('status', 'active') + ) + + db.session.add(plan) + db.session.commit() + + return jsonify({ + 'success': True, + 'data': plan.to_dict() + }) + + except Exception as e: + db.session.rollback() + print(f"创建投资计划失败: {str(e)}") + return jsonify({'success': False, 'error': '创建失败'}), 500 + + +@app.route('/api/account/investment-plans/', methods=['PUT']) +def update_investment_plan(plan_id): + """更新投资计划或复盘记录""" + try: + if 'user_id' not in session: + return jsonify({'success': False, 'error': '未登录'}), 401 + + plan = InvestmentPlan.query.filter_by(id=plan_id, user_id=session['user_id']).first() + if not plan: + return jsonify({'success': False, 'error': '未找到该记录'}), 404 + + data = request.get_json() + + if 'date' in data: + plan.date = datetime.fromisoformat(data['date']).date() + if 'title' in data: + plan.title = data['title'] + if 'content' in data: + plan.content = data['content'] + if 'stocks' in data: + plan.stocks = json.dumps(data['stocks']) + if 'tags' in data: + plan.tags = json.dumps(data['tags']) + if 'status' in data: + plan.status = data['status'] + + plan.updated_at = datetime.utcnow() + db.session.commit() + + return jsonify({ + 'success': True, + 'data': plan.to_dict() + }) + + except Exception as e: + db.session.rollback() + print(f"更新投资计划失败: {str(e)}") + return jsonify({'success': False, 'error': '更新失败'}), 500 + + +@app.route('/api/account/investment-plans/', methods=['DELETE']) +def delete_investment_plan(plan_id): + """删除投资计划或复盘记录""" + try: + if 'user_id' not in session: + return jsonify({'success': False, 'error': '未登录'}), 401 + + plan = InvestmentPlan.query.filter_by(id=plan_id, user_id=session['user_id']).first() + if not plan: + return jsonify({'success': False, 'error': '未找到该记录'}), 404 + + db.session.delete(plan) + db.session.commit() + + return jsonify({'success': True}) + + except Exception as e: + db.session.rollback() + print(f"删除投资计划失败: {str(e)}") + return jsonify({'success': False, 'error': '删除失败'}), 500 + + +@app.route('/api/account/events/following', methods=['GET']) +def get_my_following_events(): + """获取我关注的事件列表""" + if 'user_id' not in session: + return jsonify({'success': False, 'error': '未登录'}), 401 + + follows = EventFollow.query.filter_by(user_id=session['user_id']).order_by(EventFollow.created_at.desc()).all() + event_ids = [f.event_id for f in follows] + if not event_ids: + return jsonify({'success': True, 'data': []}) + + events = Event.query.filter(Event.id.in_(event_ids)).all() + data = [] + for ev in events: + data.append({ + 'id': ev.id, + 'title': ev.title, + 'event_type': ev.event_type, + 'start_time': ev.start_time.isoformat() if ev.start_time else None, + 'hot_score': ev.hot_score, + 'follower_count': ev.follower_count, + }) + return jsonify({'success': True, 'data': data}) + + +@app.route('/api/account/events/comments', methods=['GET']) +def get_my_event_comments(): + """获取我在事件上的评论(EventComment)""" + if 'user_id' not in session: + return jsonify({'success': False, 'error': '未登录'}), 401 + + comments = EventComment.query.filter_by(user_id=session['user_id']).order_by(EventComment.created_at.desc()).limit( + 100).all() + return jsonify({'success': True, 'data': [c.to_dict() for c in comments]}) + + +@app.route('/api/account/future-events/following', methods=['GET']) +def get_my_following_future_events(): + """获取当前用户关注的未来事件""" + if 'user_id' not in session: + return jsonify({'success': False, 'error': '未登录'}), 401 + + try: + # 获取用户关注的未来事件ID列表 + follows = FutureEventFollow.query.filter_by(user_id=session['user_id']).all() + future_event_ids = [f.future_event_id for f in follows] + + if not future_event_ids: + return jsonify({'success': True, 'data': []}) + + # 查询未来事件详情 + sql = """ + SELECT * + FROM future_events + WHERE data_id IN :event_ids + ORDER BY calendar_time \ + """ + + result = db.session.execute( + text(sql), + {'event_ids': tuple(future_event_ids)} + ) + + events = [] + for row in result: + event_data = { + 'id': row.data_id, + 'title': row.title, + 'type': row.type, + 'calendar_time': row.calendar_time.isoformat(), + 'star': row.star, + 'former': row.former, + 'forecast': row.forecast, + 'fact': row.fact, + 'is_following': True, # 这些都是已关注的 + 'related_stocks': parse_json_field(row.related_stocks), + 'concepts': parse_json_field(row.concepts) + } + events.append(event_data) + + return jsonify({'success': True, 'data': events}) + + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + + +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 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') + + @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) # 动量 + + +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 EventTransmissionNode(db.Model): + """事件传导节点模型""" + __tablename__ = 'event_transmission_nodes' + + id = db.Column(db.Integer, primary_key=True) + event_id = db.Column(db.Integer, db.ForeignKey('event.id'), nullable=False) + node_type = db.Column(db.Enum('company', 'industry', 'policy', 'technology', + 'market', 'event', 'other'), nullable=False) + node_name = db.Column(db.String(200), nullable=False) + node_description = db.Column(db.Text) + importance_score = db.Column(db.Integer, default=50) + stock_code = db.Column(db.String(20)) + is_main_event = db.Column(db.Boolean, default=False) + + created_at = db.Column(db.DateTime, default=beijing_now) + updated_at = db.Column(db.DateTime, default=beijing_now, onupdate=beijing_now) + + # Relationships + event = db.relationship('Event', backref='transmission_nodes') + outgoing_edges = db.relationship('EventTransmissionEdge', + foreign_keys='EventTransmissionEdge.from_node_id', + backref='from_node', cascade='all, delete-orphan') + incoming_edges = db.relationship('EventTransmissionEdge', + foreign_keys='EventTransmissionEdge.to_node_id', + backref='to_node', cascade='all, delete-orphan') + + __table_args__ = ( + db.Index('idx_event_id', 'event_id'), + db.Index('idx_node_type', 'node_type'), + db.Index('idx_main_event', 'is_main_event'), + ) + + +class EventTransmissionEdge(db.Model): + """事件传导边模型""" + __tablename__ = 'event_transmission_edges' + + id = db.Column(db.Integer, primary_key=True) + event_id = db.Column(db.Integer, db.ForeignKey('event.id'), nullable=False) + from_node_id = db.Column(db.Integer, db.ForeignKey('event_transmission_nodes.id'), nullable=False) + to_node_id = db.Column(db.Integer, db.ForeignKey('event_transmission_nodes.id'), nullable=False) + + transmission_type = db.Column(db.Enum('supply_chain', 'competition', 'policy', + 'technology', 'capital_flow', 'expectation', + 'cyclic_effect', 'other'), nullable=False) + transmission_mechanism = db.Column(db.Text) + direction = db.Column(db.Enum('positive', 'negative', 'neutral', 'mixed'), default='neutral') + strength = db.Column(db.Integer, default=50) + impact = db.Column(db.Text) + is_circular = db.Column(db.Boolean, default=False) + + created_at = db.Column(db.DateTime, default=beijing_now) + updated_at = db.Column(db.DateTime, default=beijing_now, onupdate=beijing_now) + + # Relationship + event = db.relationship('Event', backref='transmission_edges') + + __table_args__ = ( + db.Index('idx_event_id', 'event_id'), + db.Index('idx_strength', 'strength'), + db.Index('idx_from_to', 'from_node_id', 'to_node_id'), + db.Index('idx_circular', 'is_circular'), + ) + + +# 在 paste-2.txt 的模型定义部分添加 +class EventSankeyFlow(db.Model): + """事件桑基流模型""" + __tablename__ = 'event_sankey_flows' + + id = db.Column(db.Integer, primary_key=True) + event_id = db.Column(db.Integer, db.ForeignKey('event.id'), nullable=False) + + # 流的基本信息 + source_node = db.Column(db.String(200), nullable=False) + source_type = db.Column(db.Enum('event', 'policy', 'technology', 'industry', + 'company', 'product'), nullable=False) + source_level = db.Column(db.Integer, nullable=False, default=0) + + target_node = db.Column(db.String(200), nullable=False) + target_type = db.Column(db.Enum('policy', 'technology', 'industry', + 'company', 'product'), nullable=False) + target_level = db.Column(db.Integer, nullable=False, default=1) + + # 流量信息 + flow_value = db.Column(db.Numeric(10, 2), nullable=False) + flow_ratio = db.Column(db.Numeric(5, 4), nullable=False) + + # 传导机制 + transmission_path = db.Column(db.String(500)) + impact_description = db.Column(db.Text) + evidence_strength = db.Column(db.Integer, default=50) + + # 时间戳 + created_at = db.Column(db.DateTime, default=beijing_now) + updated_at = db.Column(db.DateTime, default=beijing_now, onupdate=beijing_now) + + # 关系 + event = db.relationship('Event', backref='sankey_flows') + + __table_args__ = ( + db.Index('idx_event_id', 'event_id'), + db.Index('idx_source_target', 'source_node', 'target_node'), + db.Index('idx_levels', 'source_level', 'target_level'), + db.Index('idx_flow_value', 'flow_value'), + ) + + +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'), + ) + + +# === 股票盈利预测(自有表) === +class StockForecastData(db.Model): + """股票盈利预测数据 + + 源于本地表 stock_forecast_data,由独立离线程序写入。 + 字段与表结构保持一致,仅用于读取聚合后输出前端报表所需的结构。 + """ + __tablename__ = 'stock_forecast_data' + + id = db.Column(db.Integer, primary_key=True) + stock_code = db.Column(db.String(6), nullable=False) + indicator_name = db.Column(db.String(50), nullable=False) + year_2022a = db.Column(db.Numeric(15, 2)) + year_2023a = db.Column(db.Numeric(15, 2)) + year_2024a = db.Column(db.Numeric(15, 2)) + year_2025e = db.Column(db.Numeric(15, 2)) + year_2026e = db.Column(db.Numeric(15, 2)) + year_2027e = db.Column(db.Numeric(15, 2)) + process_time = db.Column(db.DateTime, nullable=False) + + __table_args__ = ( + db.UniqueConstraint('stock_code', 'indicator_name', name='unique_stock_indicator'), + ) + + def values_by_year(self): + years = ['2022A', '2023A', '2024A', '2025E', '2026E', '2027E'] + vals = [self.year_2022a, self.year_2023a, self.year_2024a, self.year_2025e, self.year_2026e, self.year_2027e] + + def _to_float(x): + try: + return float(x) if x is not None else None + except Exception: + return None + + return years, [_to_float(v) for v in vals] + + +@app.route('/api/events/', methods=['GET']) +def get_event_detail(event_id): + """获取事件详情""" + try: + event = Event.query.get_or_404(event_id) + + # 增加浏览计数 + event.view_count += 1 + db.session.commit() + + return jsonify({ + 'success': True, + 'data': { + 'id': event.id, + 'title': event.title, + 'description': event.description, + 'event_type': event.event_type, + 'status': event.status, + 'start_time': event.start_time.isoformat() if event.start_time else None, + 'end_time': event.end_time.isoformat() if event.end_time else None, + 'created_at': event.created_at.isoformat() if event.created_at else None, + 'hot_score': event.hot_score, + 'view_count': event.view_count, + 'trending_score': event.trending_score, + 'post_count': event.post_count, + 'follower_count': event.follower_count, + 'related_industries': event.related_industries, + 'keywords': event.keywords_list, + 'importance': event.importance, + '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, + 'expectation_surprise_score': event.expectation_surprise_score, + 'creator_id': event.creator_id, + 'has_chain_analysis': ( + EventTransmissionNode.query.filter_by(event_id=event_id).first() is not None or + EventSankeyFlow.query.filter_by(event_id=event_id).first() is not None + ), + 'is_following': False, # 需要根据当前用户状态判断 + } + }) + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + + +@app.route('/api/events//stocks', methods=['GET']) +def get_related_stocks(event_id): + """获取相关股票列表""" + try: + # 订阅控制:相关标的需要 Pro 及以上 + if not _has_required_level('pro'): + return jsonify({'success': False, 'error': '需要Pro订阅', 'required_level': 'pro'}), 403 + event = Event.query.get_or_404(event_id) + stocks = event.related_stocks.order_by(RelatedStock.correlation.desc()).all() + + stocks_data = [] + for stock in stocks: + if stock.retrieved_sources is not None: + stocks_data.append({ + 'id': stock.id, + 'stock_code': stock.stock_code, + 'stock_name': stock.stock_name, + 'sector': stock.sector, + 'relation_desc': {"data":stock.retrieved_sources}, + 'retrieved_sources': stock.retrieved_sources, + 'correlation': stock.correlation, + 'momentum': stock.momentum, + 'created_at': stock.created_at.isoformat() if stock.created_at else None, + 'updated_at': stock.updated_at.isoformat() if stock.updated_at else None + }) + else: + stocks_data.append({ + '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, + 'created_at': stock.created_at.isoformat() if stock.created_at else None, + 'updated_at': stock.updated_at.isoformat() if stock.updated_at else None + }) + + return jsonify({ + 'success': True, + 'data': stocks_data + }) + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + + +@app.route('/api/events//stocks', methods=['POST']) +def add_related_stock(event_id): + """添加相关股票""" + try: + event = Event.query.get_or_404(event_id) + data = request.get_json() + + # 验证必要字段 + if not data.get('stock_code') or not data.get('relation_desc'): + return jsonify({'success': False, 'error': '缺少必要字段'}), 400 + + # 检查是否已存在 + existing = RelatedStock.query.filter_by( + event_id=event_id, + stock_code=data['stock_code'] + ).first() + + if existing: + return jsonify({'success': False, 'error': '该股票已存在'}), 400 + + # 创建新的相关股票记录 + new_stock = RelatedStock( + event_id=event_id, + stock_code=data['stock_code'], + stock_name=data.get('stock_name', ''), + sector=data.get('sector', ''), + relation_desc=data['relation_desc'], + correlation=data.get('correlation', 0.5), + momentum=data.get('momentum', '') + ) + + db.session.add(new_stock) + db.session.commit() + + return jsonify({ + 'success': True, + 'data': { + 'id': new_stock.id, + 'stock_code': new_stock.stock_code, + 'relation_desc': new_stock.relation_desc + } + }) + except Exception as e: + db.session.rollback() + return jsonify({'success': False, 'error': str(e)}), 500 + + +@app.route('/api/stocks/', methods=['DELETE']) +def delete_related_stock(stock_id): + """删除相关股票""" + try: + stock = RelatedStock.query.get_or_404(stock_id) + db.session.delete(stock) + db.session.commit() + + return jsonify({'success': True, 'message': '删除成功'}) + except Exception as e: + db.session.rollback() + return jsonify({'success': False, 'error': str(e)}), 500 + + +@app.route('/api/events//concepts', methods=['GET']) +def get_related_concepts(event_id): + """获取相关概念列表""" + try: + # 订阅控制:相关概念需要 Pro 及以上 + if not _has_required_level('pro'): + return jsonify({'success': False, 'error': '需要Pro订阅', 'required_level': 'pro'}), 403 + event = Event.query.get_or_404(event_id) + concepts = event.related_concepts.all() + + concepts_data = [] + for concept in concepts: + concepts_data.append({ + 'id': concept.id, + 'concept_code': concept.concept_code, + 'concept': concept.concept, + 'reason': concept.reason, + 'image_paths': concept.image_paths_list, + 'first_image_path': concept.get_first_image_path(), + 'created_at': concept.created_at.isoformat() if concept.created_at else None + }) + + return jsonify({ + 'success': True, + 'data': concepts_data + }) + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + + +@app.route('/api/events//historical', methods=['GET']) +def get_historical_events(event_id): + """获取历史事件对比""" + try: + event = Event.query.get_or_404(event_id) + historical_events = event.historical_events.order_by(HistoricalEvent.event_date.desc()).all() + + events_data = [] + for hist_event in historical_events: + 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, + 'importance': hist_event.importance, + 'relevance': hist_event.relevance, + 'created_at': hist_event.created_at.isoformat() if hist_event.created_at else None + }) + + # 订阅控制:免费用户仅返回前2条;Pro/Max返回全部 + info = _get_current_subscription_info() + sub_type = (info.get('type') or 'free').lower() + if sub_type == 'free': + return jsonify({ + 'success': True, + 'data': events_data[:2], + 'truncated': len(events_data) > 2, + 'required_level': 'pro' + }) + return jsonify({'success': True, 'data': events_data}) + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + + +@app.route('/api/historical-events//stocks', methods=['GET']) +def get_historical_event_stocks(event_id): + """获取历史事件相关股票列表""" + try: + # 直接查询历史事件,不需要通过主事件 + hist_event = HistoricalEvent.query.get_or_404(event_id) + stocks = hist_event.stocks.order_by(HistoricalEventStock.correlation.desc()).all() + + # 获取事件对应的交易日 + event_trading_date = None + if hist_event.event_date: + event_trading_date = get_trading_day_near_date(hist_event.event_date) + + stocks_data = [] + for stock in stocks: + 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, + 'created_at': stock.created_at.isoformat() if stock.created_at else None + } + + # 添加涨幅数据 + if event_trading_date: + try: + # 查询股票在事件对应交易日的数据 + with engine.connect() as conn: + query = text(""" + SELECT close_price, change_pct + FROM ea_dailyline + WHERE seccode = :stock_code + AND date = :trading_date + ORDER BY date DESC + LIMIT 1 + """) + + result = conn.execute(query, { + 'stock_code': stock.stock_code, + 'trading_date': event_trading_date + }).fetchone() + + if result: + stock_data['event_day_close'] = float(result[0]) if result[0] else None + stock_data['event_day_change_pct'] = float(result[1]) if result[1] else None + else: + stock_data['event_day_close'] = None + stock_data['event_day_change_pct'] = None + except Exception as e: + print(f"查询股票{stock.stock_code}在{event_trading_date}的数据失败: {e}") + stock_data['event_day_close'] = None + stock_data['event_day_change_pct'] = None + else: + stock_data['event_day_close'] = None + stock_data['event_day_change_pct'] = None + + stocks_data.append(stock_data) + + return jsonify({ + 'success': True, + 'data': stocks_data, + 'event_trading_date': event_trading_date.isoformat() if event_trading_date else None + }) + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + + +@app.route('/api/events//expectation-score', methods=['GET']) +def get_expectation_score(event_id): + """获取超预期得分""" + try: + event = Event.query.get_or_404(event_id) + + # 如果事件有超预期得分,直接返回 + if event.expectation_surprise_score is not None: + score = event.expectation_surprise_score + else: + # 如果没有,根据历史事件计算一个模拟得分 + historical_events = event.historical_events.all() + if historical_events: + # 基于历史事件数量和重要性计算得分 + total_importance = sum(ev.importance or 0 for ev in historical_events) + avg_importance = total_importance / len(historical_events) if historical_events else 0 + score = min(100, max(0, int(avg_importance * 20 + len(historical_events) * 5))) + else: + # 默认得分 + score = 65 + + return jsonify({ + 'success': True, + 'data': { + 'score': score, + 'description': '基于历史事件判断当前事件的超预期情况,满分100分' + } + }) + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + + +@app.route('/api/events//follow', methods=['POST']) +def toggle_event_follow(event_id): + """切换事件关注状态(需登录)""" + if 'user_id' not in session: + return jsonify({'success': False, 'error': '未登录'}), 401 + + try: + event = Event.query.get_or_404(event_id) + user_id = session['user_id'] + + existing = EventFollow.query.filter_by(user_id=user_id, event_id=event_id).first() + if existing: + # 取消关注 + db.session.delete(existing) + event.follower_count = max(0, (event.follower_count or 0) - 1) + db.session.commit() + return jsonify({'success': True, 'data': {'is_following': False, 'follower_count': event.follower_count}}) + else: + # 关注 + follow = EventFollow(user_id=user_id, event_id=event_id) + db.session.add(follow) + event.follower_count = (event.follower_count or 0) + 1 + db.session.commit() + return jsonify({'success': True, 'data': {'is_following': True, 'follower_count': event.follower_count}}) + except Exception as e: + db.session.rollback() + return jsonify({'success': False, 'error': str(e)}), 500 + + +@app.route('/api/events//transmission', methods=['GET']) +def get_transmission_chain(event_id): + try: + # 订阅控制:传导链分析需要 Max 及以上 + if not _has_required_level('max'): + return jsonify({'success': False, 'error': '需要Max订阅', 'required_level': 'max'}), 403 + # 确保数据库连接是活跃的 + db.session.execute(text('SELECT 1')) + + event = Event.query.get_or_404(event_id) + nodes = EventTransmissionNode.query.filter_by(event_id=event_id).all() + edges = EventTransmissionEdge.query.filter_by(event_id=event_id).all() + + # 过滤孤立节点 + connected_node_ids = set() + for edge in edges: + connected_node_ids.add(edge.from_node_id) + connected_node_ids.add(edge.to_node_id) + + # 只保留有连接的节点 + connected_nodes = [node for node in nodes if node.id in connected_node_ids] + + # 如果没有主事件节点,也保留主事件节点 + main_event_node = next((node for node in nodes if node.is_main_event), None) + if main_event_node and main_event_node not in connected_nodes: + connected_nodes.append(main_event_node) + + if not connected_nodes: + return jsonify({'success': False, 'message': '暂无传导链分析数据'}) + + # 节点类型到中文类别的映射 + categories = { + 'event': "事件", 'industry': "行业", 'company': "公司", + 'policy': "政策", 'technology': "技术", 'market': "市场", 'other': "其他" + } + + nodes_data = [] + for node in connected_nodes: + node_category = categories.get(node.node_type, "其他") + nodes_data.append({ + 'id': str(node.id), # 转换为字符串以保持一致性 + 'name': node.node_name, + 'category': node_category, + 'value': node.importance_score or 20, + 'extra': { + 'node_type': node.node_type, + 'description': node.node_description, + 'importance_score': node.importance_score, + 'stock_code': node.stock_code, + 'is_main_event': node.is_main_event + } + }) + + edges_data = [] + for edge in edges: + # 确保边的两端节点都在连接节点列表中 + if edge.from_node_id in connected_node_ids and edge.to_node_id in connected_node_ids: + edges_data.append({ + 'source': str(edge.from_node_id), # 转换为字符串以保持一致性 + 'target': str(edge.to_node_id), # 转换为字符串以保持一致性 + 'value': edge.strength or 50, + 'extra': { + 'transmission_type': edge.transmission_type, + 'transmission_mechanism': edge.transmission_mechanism, + 'direction': edge.direction, + 'strength': edge.strength, + 'impact': edge.impact, + 'is_circular': edge.is_circular, + } + }) + + return jsonify({ + 'success': True, + 'data': { + 'nodes': nodes_data, + 'edges': edges_data + } + }) + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + + +# 修复股票报价API - 支持GET和POST方法 +@app.route('/api/stock/quotes', methods=['GET', 'POST']) +def get_stock_quotes(): + try: + if request.method == 'GET': + # GET 请求从查询参数获取数据 + codes_str = request.args.get('codes', '') + codes = [code.strip() for code in codes_str.split(',') if code.strip()] + event_time_str = request.args.get('event_time') + else: + # POST 请求从 JSON 获取数据 + codes = request.json.get('codes', []) + event_time_str = request.json.get('event_time') + + if not codes: + return jsonify({'success': False, 'error': '请提供股票代码'}), 400 + + # 处理事件时间 + if event_time_str: + try: + event_time = datetime.fromisoformat(event_time_str.replace('Z', '+00:00')) + except: + event_time = datetime.now() + else: + event_time = datetime.now() + + current_time = datetime.now() + client = get_clickhouse_client() + + # Get stock names from MySQL + stock_names = {} + with engine.connect() as conn: + for code in codes: + codez = code.split('.')[0] + result = conn.execute(text( + "SELECT SECNAME FROM ea_stocklist WHERE SECCODE = :code" + ), {"code": codez}).fetchone() + if result: + stock_names[code] = result[0] + else: + stock_names[code] = f"股票{codez}" + + def get_trading_day_and_times(event_datetime): + event_date = event_datetime.date() + event_time = 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 < market_open: + # Before market opens - use full trading day + return event_date, market_open, market_close + elif event_time > 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, 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({ + 'success': True, + 'data': {code: {'name': name, 'price': None, 'change': None} + for code, name in stock_names.items()} + }) + + # 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(): + return jsonify({ + 'success': True, + 'data': {code: {'name': name, 'price': None, 'change': None} + for code, name in stock_names.items()} + }) + + results = {} + print(f"处理股票代码: {codes}, 交易日: {trading_day}, 时间范围: {start_datetime} - {end_datetime}") + + for code in codes: + try: + print(f"正在查询股票 {code} 的价格数据...") + # Get the first price and last price for the trading period + data = client.execute(""" + WITH first_price AS (SELECT close + FROM stock_minute + WHERE code = %(code)s + AND timestamp >= %(start)s + AND timestamp <= %(end)s + ORDER BY timestamp + LIMIT 1 + ), + last_price AS ( + SELECT close + FROM stock_minute + WHERE code = %(code)s + AND timestamp >= %(start)s + AND timestamp <= %(end)s + ORDER BY timestamp DESC + LIMIT 1 + ) + SELECT last_price.close as last_price, + (last_price.close - first_price.close) / first_price.close * 100 as change + FROM last_price + CROSS JOIN first_price + WHERE EXISTS (SELECT 1 FROM first_price) + AND EXISTS (SELECT 1 FROM last_price) + """, { + 'code': code, + 'start': start_datetime, + 'end': end_datetime + }) + + print(f"股票 {code} 查询结果: {data}") + if data and data[0] and data[0][0] is not None: + price = float(data[0][0]) if data[0][0] is not None else None + change = float(data[0][1]) if data[0][1] is not None else None + + results[code] = { + 'price': price, + 'change': change, + 'name': stock_names.get(code, f'股票{code.split(".")[0]}') + } + else: + results[code] = { + 'price': None, + 'change': None, + 'name': stock_names.get(code, f'股票{code.split(".")[0]}') + } + except Exception as e: + print(f"Error processing stock {code}: {e}") + results[code] = { + 'price': None, + 'change': None, + 'name': stock_names.get(code, f'股票{code.split(".")[0]}') + } + + # 返回标准格式 + return jsonify({'success': True, 'data': results}) + + except Exception as e: + print(f"Stock quotes API error: {e}") + return jsonify({'success': False, 'error': str(e)}), 500 + + +def get_clickhouse_client(): + return Cclient( + host='222.128.1.157', + port=18000, + user='default', + password='Zzl33818!', + database='stock' + ) + + +@app.route('/api/account/calendar/events', methods=['GET', 'POST']) +def account_calendar_events(): + """返回当前用户的投资计划与关注的未来事件(合并)。 + GET: 可按日期范围/月份过滤;POST: 新增投资计划(写入 InvestmentPlan)。 + """ + try: + if 'user_id' not in session: + return jsonify({'success': False, 'error': '未登录'}), 401 + + if request.method == 'POST': + data = request.get_json() or {} + title = data.get('title') + event_date_str = data.get('event_date') or data.get('date') + plan_type = data.get('type') or 'plan' + description = data.get('description') or data.get('content') or '' + stocks = data.get('stocks') or [] + + if not title or not event_date_str: + return jsonify({'success': False, 'error': '缺少必填字段'}), 400 + + try: + event_date = datetime.fromisoformat(event_date_str).date() + except Exception: + return jsonify({'success': False, 'error': '日期格式错误'}), 400 + + plan = InvestmentPlan( + user_id=session['user_id'], + date=event_date, + title=title, + content=description, + type=plan_type, + stocks=json.dumps(stocks), + tags=json.dumps(data.get('tags', [])), + status=data.get('status', 'active') + ) + db.session.add(plan) + db.session.commit() + + return jsonify({'success': True, 'data': { + 'id': plan.id, + 'title': plan.title, + 'event_date': plan.date.isoformat(), + 'type': plan.type, + 'description': plan.content, + 'stocks': json.loads(plan.stocks) if plan.stocks else [], + 'source': 'plan' + }}) + + # GET + # 解析过滤参数:date 或 (year, month) 或 (start_date, end_date) + date_str = request.args.get('date') + year = request.args.get('year', type=int) + month = request.args.get('month', type=int) + start_date_str = request.args.get('start_date') + end_date_str = request.args.get('end_date') + + start_date = None + end_date = None + if date_str: + try: + d = datetime.fromisoformat(date_str).date() + start_date = d + end_date = d + except Exception: + pass + elif year and month: + # 月份范围 + start_date = datetime(year, month, 1).date() + if month == 12: + end_date = datetime(year + 1, 1, 1).date() - timedelta(days=1) + else: + end_date = datetime(year, month + 1, 1).date() - timedelta(days=1) + elif start_date_str and end_date_str: + try: + start_date = datetime.fromisoformat(start_date_str).date() + end_date = datetime.fromisoformat(end_date_str).date() + except Exception: + start_date = None + end_date = None + + # 查询投资计划 + plans_query = InvestmentPlan.query.filter_by(user_id=session['user_id']) + if start_date and end_date: + plans_query = plans_query.filter(InvestmentPlan.date >= start_date, InvestmentPlan.date <= end_date) + elif start_date: + plans_query = plans_query.filter(InvestmentPlan.date == start_date) + plans = plans_query.order_by(InvestmentPlan.date.asc()).all() + + plan_events = [{ + 'id': p.id, + 'title': p.title, + 'event_date': p.date.isoformat(), + 'type': p.type or 'plan', + 'description': p.content, + 'importance': 3, + 'stocks': json.loads(p.stocks) if p.stocks else [], + 'source': 'plan' + } for p in plans] + + # 查询关注的未来事件 + follows = FutureEventFollow.query.filter_by(user_id=session['user_id']).all() + future_event_ids = [f.future_event_id for f in follows] + + future_events = [] + if future_event_ids: + base_sql = """ + SELECT data_id, \ + title, \ + type, \ + calendar_time, \ + star, \ + former, \ + forecast, \ + fact, \ + related_stocks, \ + concepts + FROM future_events + WHERE data_id IN :event_ids \ + """ + + params = {'event_ids': tuple(future_event_ids)} + # 日期过滤(按 calendar_time 的日期) + if start_date and end_date: + base_sql += " AND DATE(calendar_time) BETWEEN :start_date AND :end_date" + params.update({'start_date': start_date, 'end_date': end_date}) + elif start_date: + base_sql += " AND DATE(calendar_time) = :start_date" + params.update({'start_date': start_date}) + + base_sql += " ORDER BY calendar_time" + + result = db.session.execute(text(base_sql), params) + for row in result: + # related_stocks 形如 [[code,name,reason,score], ...] + rs = parse_json_field(row.related_stocks) + stock_tags = [] + try: + for it in rs: + if isinstance(it, (list, tuple)) and len(it) >= 2: + stock_tags.append(f"{it[0]} {it[1]}") + elif isinstance(it, str): + stock_tags.append(it) + except Exception: + pass + + future_events.append({ + 'id': row.data_id, + 'title': row.title, + 'event_date': (row.calendar_time.date().isoformat() if row.calendar_time else None), + 'type': 'future_event', + 'importance': int(row.star) if getattr(row, 'star', None) is not None else 3, + 'description': row.former or '', + 'stocks': stock_tags, + 'is_following': True, + 'source': 'future' + }) + + return jsonify({'success': True, 'data': plan_events + future_events}) + + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + + +@app.route('/api/account/calendar/events/', methods=['DELETE']) +def delete_account_calendar_event(event_id): + """删除用户创建的投资计划事件(不影响关注的未来事件)。""" + try: + if 'user_id' not in session: + return jsonify({'success': False, 'error': '未登录'}), 401 + plan = InvestmentPlan.query.filter_by(id=event_id, user_id=session['user_id']).first() + if not plan: + return jsonify({'success': False, 'error': '未找到该记录'}), 404 + db.session.delete(plan) + db.session.commit() + return jsonify({'success': True}) + except Exception as e: + db.session.rollback() + return jsonify({'success': False, 'error': str(e)}), 500 + + +@app.route('/api/stock//kline') +def get_stock_kline(stock_code): + chart_type = request.args.get('type', 'minute') + 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 + + # 获取股票名称 + 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' + + 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) + elif chart_type == 'timeline': + return get_timeline_data(stock_code, event_datetime, stock_name) + else: + # 对于未知的类型,返回错误 + return jsonify({'error': f'Unsupported chart type: {chart_type}'}), 400 + + +@app.route('/api/stock//latest-minute', methods=['GET']) +def get_latest_minute_data(stock_code): + """获取最新交易日的分钟频数据""" + client = get_clickhouse_client() + + # 确保股票代码包含后缀 + if '.' not in stock_code: + stock_code = f"{stock_code}.SH" if stock_code.startswith('6') else f"{stock_code}.SZ" + + # 获取股票名称 + 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' + + # 查找最近30天内有数据的最新交易日 + target_date = None + current_date = datetime.now().date() + + for i in range(30): + check_date = current_date - timedelta(days=i) + trading_day = get_trading_day_near_date(check_date) + + if trading_day and trading_day <= current_date: + # 检查这个交易日是否有分钟数据 + test_data = client.execute(""" + SELECT COUNT(*) + FROM stock_minute + WHERE code = %(code)s + AND timestamp BETWEEN %(start)s AND %(end)s + LIMIT 1 + """, { + 'code': stock_code, + 'start': datetime.combine(trading_day, dt_time(9, 30)), + 'end': datetime.combine(trading_day, dt_time(15, 0)) + }) + + if test_data and test_data[0][0] > 0: + target_date = trading_day + break + + if not target_date: + return jsonify({ + 'error': 'No data available', + 'code': stock_code, + 'name': stock_name, + 'data': [], + 'trade_date': current_date.strftime('%Y-%m-%d'), + 'type': 'minute' + }) + + # 获取目标日期的完整交易时段数据 + 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 = [{ + '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]) + } for row in data] + + return jsonify({ + 'code': stock_code, + 'name': stock_name, + 'data': kline_data, + 'trade_date': target_date.strftime('%Y-%m-%d'), + 'type': 'minute', + 'is_latest': True + }) + + +@app.route('/api/stock//forecast-report', methods=['GET']) +def get_stock_forecast_report(stock_code): + """基于 stock_forecast_data 输出报表所需数据结构 + + 返回: + - income_profit_trend: 营业收入/归母净利润趋势 + - growth_bars: 增长率柱状图数据(基于营业收入同比) + - eps_trend: EPS 折线 + - pe_peg_axes: PE/PEG 双轴 + - detail_table: 详细数据表格(与附件结构一致) + """ + try: + # 读取该股票所有指标 + rows = StockForecastData.query.filter_by(stock_code=stock_code).all() + if not rows: + return jsonify({'success': False, 'error': 'no_data'}), 404 + + # 将指标映射为字典 + indicators = {} + for r in rows: + years, vals = r.values_by_year() + indicators[r.indicator_name] = dict(zip(years, vals)) + + def safe(x): + return x if x is not None else None + + years = ['2022A', '2023A', '2024A', '2025E', '2026E', '2027E'] + + # 营业收入与净利润趋势 + income = indicators.get('营业总收入(百万元)', {}) + profit = indicators.get('归母净利润(百万元)', {}) + income_profit_trend = { + 'years': years, + 'income': [safe(income.get(y)) for y in years], + 'profit': [safe(profit.get(y)) for y in years] + } + + # 增长率柱状(若表内已有"增长率(%)",直接使用;否则按营业收入同比计算) + growth = indicators.get('增长率(%)') + if growth is None: + # 计算同比: (curr - prev)/prev*100 + growth_vals = [] + prev = None + for y in years: + curr = income.get(y) + if prev is not None and prev not in (None, 0) and curr is not None: + growth_vals.append(round((float(curr) - float(prev)) / float(prev) * 100, 2)) + else: + growth_vals.append(None) + prev = curr + else: + growth_vals = [safe(growth.get(y)) for y in years] + growth_bars = { + 'years': years, + 'revenue_growth_pct': growth_vals, + 'net_profit_growth_pct': None # 如后续需要可扩展 + } + + # EPS 趋势 + eps = indicators.get('EPS(稀释)') or indicators.get('EPS(元/股)') or {} + eps_trend = { + 'years': years, + 'eps': [safe(eps.get(y)) for y in years] + } + + # PE / PEG 双轴 + pe = indicators.get('PE') or {} + peg = indicators.get('PEG') or {} + pe_peg_axes = { + 'years': years, + 'pe': [safe(pe.get(y)) for y in years], + 'peg': [safe(peg.get(y)) for y in years] + } + + # 详细数据表格(列顺序固定) + def fmt(val): + try: + return None if val is None else round(float(val), 2) + except Exception: + return None + + detail_rows = [ + { + '指标': '营业总收入(百万元)', + **{y: fmt(income.get(y)) for y in years}, + }, + { + '指标': '增长率(%)', + **{y: fmt(v) for y, v in zip(years, growth_vals)}, + }, + { + '指标': '归母净利润(百万元)', + **{y: fmt(profit.get(y)) for y in years}, + }, + { + '指标': 'EPS(稀释)', + **{y: fmt(eps.get(y)) for y in years}, + }, + { + '指标': 'PE', + **{y: fmt(pe.get(y)) for y in years}, + }, + { + '指标': 'PEG', + **{y: fmt(peg.get(y)) for y in years}, + }, + ] + + return jsonify({ + 'success': True, + 'data': { + 'income_profit_trend': income_profit_trend, + 'growth_bars': growth_bars, + 'eps_trend': eps_trend, + 'pe_peg_axes': pe_peg_axes, + 'detail_table': { + 'years': years, + 'rows': detail_rows + } + } + }) + except Exception as e: + app.logger.error(f"forecast report error: {e}", exc_info=True) + return jsonify({'success': False, 'error': str(e)}), 500 + + +@app.route('/api/stock//basic-info', methods=['GET']) +def get_stock_basic_info(stock_code): + """获取股票基本信息(来自ea_baseinfo表)""" + try: + with engine.connect() as conn: + query = text(""" + SELECT SECCODE, + SECNAME, + ORGNAME, + F001V as en_name, + F002V as en_short_name, + F003V as legal_representative, + F004V as reg_address, + F005V as office_address, + F006V as post_code, + F007N as reg_capital, + F009V as currency, + F010D as establish_date, + F011V as website, + F012V as email, + F013V as tel, + F014V as fax, + F015V as main_business, + F016V as business_scope, + F017V as company_intro, + F018V as secretary, + F019V as secretary_tel, + F020V as secretary_fax, + F021V as secretary_email, + F024V as listing_status, + F026V as province, + F028V as city, + F030V as industry_l1, + F032V as industry_l2, + F034V as sw_industry_l1, + F036V as sw_industry_l2, + F038V as sw_industry_l3, + F039V as accounting_firm, + F040V as law_firm, + F041V as chairman, + F042V as general_manager, + F043V as independent_directors, + F050V as credit_code, + F054V as company_size, + UPDATE_DATE + FROM ea_baseinfo + WHERE SECCODE = :stock_code LIMIT 1 + """) + + result = conn.execute(query, {'stock_code': stock_code}).fetchone() + + if not result: + return jsonify({ + 'success': False, + 'error': f'未找到股票代码 {stock_code} 的基本信息' + }), 404 + + # 转换为字典 + basic_info = {} + for key, value in zip(result.keys(), result): + if isinstance(value, datetime): + basic_info[key] = value.strftime('%Y-%m-%d') + elif isinstance(value, Decimal): + basic_info[key] = float(value) + else: + basic_info[key] = value + + return jsonify({ + 'success': True, + 'data': basic_info + }) + + except Exception as e: + app.logger.error(f"Error getting stock basic info: {e}", exc_info=True) + return jsonify({'success': False, 'error': str(e)}), 500 + + +@app.route('/api/stock//announcements', methods=['GET']) +def get_stock_announcements(stock_code): + """获取股票公告列表""" + try: + limit = request.args.get('limit', 50, type=int) + + with engine.connect() as conn: + query = text(""" + SELECT F001D as announce_date, + F002V as title, + F003V as url, + F004V as format, + F005N as file_size, + F006V as info_type, + UPDATE_DATE + FROM ea_baseinfolist + WHERE SECCODE = :stock_code + ORDER BY F001D DESC LIMIT :limit + """) + + result = conn.execute(query, {'stock_code': stock_code, 'limit': limit}).fetchall() + + announcements = [] + for row in result: + announcement = {} + for key, value in zip(row.keys(), row): + if value is None: + announcement[key] = None + elif isinstance(value, datetime): + announcement[key] = value.strftime('%Y-%m-%d %H:%M:%S') + elif isinstance(value, date): + announcement[key] = value.strftime('%Y-%m-%d') + elif isinstance(value, Decimal): + announcement[key] = float(value) + else: + announcement[key] = value + announcements.append(announcement) + + return jsonify({ + 'success': True, + 'data': announcements, + 'total': len(announcements) + }) + + except Exception as e: + app.logger.error(f"Error getting stock announcements: {e}", exc_info=True) + return jsonify({'success': False, 'error': str(e)}), 500 + + +@app.route('/api/stock//disclosure-schedule', methods=['GET']) +def get_stock_disclosure_schedule(stock_code): + """获取股票财报预披露时间表""" + try: + with engine.connect() as conn: + query = text(""" + SELECT distinct F001D as report_period, + F002D as scheduled_date, + F003D as change_date1, + F004D as change_date2, + F005D as change_date3, + F006D as actual_date, + F007D as change_date4, + F008D as change_date5, + MODTIME as mod_time + FROM ea_pretime + WHERE SECCODE = :stock_code + ORDER BY F001D DESC LIMIT 20 + """) + + result = conn.execute(query, {'stock_code': stock_code}).fetchall() + + schedules = [] + for row in result: + schedule = {} + for key, value in zip(row.keys(), row): + if value is None: + schedule[key] = None + elif isinstance(value, datetime): + schedule[key] = value.strftime('%Y-%m-%d %H:%M:%S') + elif isinstance(value, date): + schedule[key] = value.strftime('%Y-%m-%d') + elif isinstance(value, Decimal): + schedule[key] = float(value) + else: + schedule[key] = value + + # 计算最新的预约日期 + latest_scheduled = schedule.get('scheduled_date') + for change_field in ['change_date5', 'change_date4', 'change_date3', 'change_date2', 'change_date1']: + if schedule.get(change_field): + latest_scheduled = schedule[change_field] + break + + schedule['latest_scheduled_date'] = latest_scheduled + schedule['is_disclosed'] = bool(schedule.get('actual_date')) + + # 格式化报告期名称 + if schedule.get('report_period'): + period_date = schedule['report_period'] + if period_date.endswith('-03-31'): + schedule['report_name'] = f"{period_date[:4]}年一季报" + elif period_date.endswith('-06-30'): + schedule['report_name'] = f"{period_date[:4]}年中报" + elif period_date.endswith('-09-30'): + schedule['report_name'] = f"{period_date[:4]}年三季报" + elif period_date.endswith('-12-31'): + schedule['report_name'] = f"{period_date[:4]}年年报" + else: + schedule['report_name'] = period_date + + schedules.append(schedule) + + return jsonify({ + 'success': True, + 'data': schedules, + 'total': len(schedules) + }) + + except Exception as e: + app.logger.error(f"Error getting disclosure schedule: {e}", exc_info=True) + return jsonify({'success': False, 'error': str(e)}), 500 + + +@app.route('/api/stock//actual-control', methods=['GET']) +def get_stock_actual_control(stock_code): + """获取股票实际控制人信息""" + try: + with engine.connect() as conn: + query = text(""" + SELECT DECLAREDATE as declare_date, + ENDDATE as end_date, + F001V as direct_holder_id, + F002V as direct_holder_name, + F003V as actual_controller_id, + F004V as actual_controller_name, + F005N as holding_shares, + F006N as holding_ratio, + F007V as control_type_code, + F008V as control_type, + F012V as direct_controller_id, + F013V as direct_controller_name, + F014V as controller_type, + ORGNAME as org_name, + SECCODE as sec_code, + SECNAME as sec_name + FROM ea_actualcon + WHERE SECCODE = :stock_code + ORDER BY ENDDATE DESC, DECLAREDATE DESC LIMIT 20 + """) + + result = conn.execute(query, {'stock_code': stock_code}).fetchall() + + control_info = [] + for row in result: + control_record = {} + for key, value in zip(row.keys(), row): + if value is None: + control_record[key] = None + elif isinstance(value, datetime): + control_record[key] = value.strftime('%Y-%m-%d %H:%M:%S') + elif isinstance(value, date): + control_record[key] = value.strftime('%Y-%m-%d') + elif isinstance(value, Decimal): + control_record[key] = float(value) + else: + control_record[key] = value + + control_info.append(control_record) + + return jsonify({ + 'success': True, + 'data': control_info, + 'total': len(control_info) + }) + + except Exception as e: + app.logger.error(f"Error getting actual control info: {e}", exc_info=True) + return jsonify({'success': False, 'error': str(e)}), 500 + + +@app.route('/api/stock//concentration', methods=['GET']) +def get_stock_concentration(stock_code): + """获取股票股权集中度信息""" + try: + with engine.connect() as conn: + query = text(""" + SELECT ENDDATE as end_date, + F001V as stat_item, + F002N as holding_shares, + F003N as holding_ratio, + F004N as ratio_change, + ORGNAME as org_name, + SECCODE as sec_code, + SECNAME as sec_name + FROM ea_concentration + WHERE SECCODE = :stock_code + ORDER BY ENDDATE DESC LIMIT 20 + """) + + result = conn.execute(query, {'stock_code': stock_code}).fetchall() + + concentration_info = [] + for row in result: + concentration_record = {} + for key, value in zip(row.keys(), row): + if value is None: + concentration_record[key] = None + elif isinstance(value, datetime): + concentration_record[key] = value.strftime('%Y-%m-%d %H:%M:%S') + elif isinstance(value, date): + concentration_record[key] = value.strftime('%Y-%m-%d') + elif isinstance(value, Decimal): + concentration_record[key] = float(value) + else: + concentration_record[key] = value + + concentration_info.append(concentration_record) + + return jsonify({ + 'success': True, + 'data': concentration_info, + 'total': len(concentration_info) + }) + + except Exception as e: + app.logger.error(f"Error getting concentration info: {e}", exc_info=True) + return jsonify({'success': False, 'error': str(e)}), 500 + + +@app.route('/api/stock//management', methods=['GET']) +def get_stock_management(stock_code): + """获取股票管理层信息""" + try: + # 获取是否只显示在职人员参数 + active_only = request.args.get('active_only', 'true').lower() == 'true' + + with engine.connect() as conn: + base_query = """ + SELECT DECLAREDATE as declare_date, \ + F001V as person_id, \ + F002V as name, \ + F007D as start_date, \ + F008D as end_date, \ + F009V as position_name, \ + F010V as gender, \ + F011V as education, \ + F012V as birth_year, \ + F013V as nationality, \ + F014V as position_category_code, \ + F015V as position_category, \ + F016V as position_code, \ + F017V as highest_degree, \ + F019V as resume, \ + F020C as is_active, \ + ORGNAME as org_name, \ + SECCODE as sec_code, \ + SECNAME as sec_name + FROM ea_management + WHERE SECCODE = :stock_code \ + """ + + if active_only: + base_query += " AND F020C = '1'" + + base_query += " ORDER BY DECLAREDATE DESC, F007D DESC" + + query = text(base_query) + + result = conn.execute(query, {'stock_code': stock_code}).fetchall() + + management_info = [] + for row in result: + management_record = {} + for key, value in zip(row.keys(), row): + if value is None: + management_record[key] = None + elif isinstance(value, datetime): + management_record[key] = value.strftime('%Y-%m-%d %H:%M:%S') + elif isinstance(value, date): + management_record[key] = value.strftime('%Y-%m-%d') + elif isinstance(value, Decimal): + management_record[key] = float(value) + else: + management_record[key] = value + + management_info.append(management_record) + + return jsonify({ + 'success': True, + 'data': management_info, + 'total': len(management_info) + }) + + except Exception as e: + app.logger.error(f"Error getting management info: {e}", exc_info=True) + return jsonify({'success': False, 'error': str(e)}), 500 + + +@app.route('/api/stock//top-circulation-shareholders', methods=['GET']) +def get_stock_top_circulation_shareholders(stock_code): + """获取股票十大流通股东信息""" + try: + limit = request.args.get('limit', 10, type=int) + + with engine.connect() as conn: + query = text(""" + SELECT DECLAREDATE as declare_date, + ENDDATE as end_date, + F001N as shareholder_rank, + F002V as shareholder_id, + F003V as shareholder_name, + F004V as shareholder_type, + F005N as holding_shares, + F006N as total_share_ratio, + F007N as circulation_share_ratio, + F011V as share_nature, + F012N as b_shares, + F013N as h_shares, + F014N as other_shares, + ORGNAME as org_name, + SECCODE as sec_code, + SECNAME as sec_name + FROM ea_tencirculation + WHERE SECCODE = :stock_code + ORDER BY ENDDATE DESC, F001N ASC LIMIT :limit + """) + + result = conn.execute(query, {'stock_code': stock_code, 'limit': limit}).fetchall() + + shareholders_info = [] + for row in result: + shareholder_record = {} + for key, value in zip(row.keys(), row): + if value is None: + shareholder_record[key] = None + elif isinstance(value, datetime): + shareholder_record[key] = value.strftime('%Y-%m-%d %H:%M:%S') + elif isinstance(value, date): + shareholder_record[key] = value.strftime('%Y-%m-%d') + elif isinstance(value, Decimal): + shareholder_record[key] = float(value) + else: + shareholder_record[key] = value + + shareholders_info.append(shareholder_record) + + return jsonify({ + 'success': True, + 'data': shareholders_info, + 'total': len(shareholders_info) + }) + + except Exception as e: + app.logger.error(f"Error getting top circulation shareholders: {e}", exc_info=True) + return jsonify({'success': False, 'error': str(e)}), 500 + + +@app.route('/api/stock//top-shareholders', methods=['GET']) +def get_stock_top_shareholders(stock_code): + """获取股票十大股东信息""" + try: + limit = request.args.get('limit', 10, type=int) + + with engine.connect() as conn: + query = text(""" + SELECT DECLAREDATE as declare_date, + ENDDATE as end_date, + F001N as shareholder_rank, + F002V as shareholder_name, + F003V as shareholder_id, + F004V as shareholder_type, + F005N as holding_shares, + F006N as total_share_ratio, + F007N as circulation_share_ratio, + F011V as share_nature, + F016N as restricted_shares, + F017V as concert_party_group, + F018N as circulation_shares, + ORGNAME as org_name, + SECCODE as sec_code, + SECNAME as sec_name + FROM ea_tenshareholder + WHERE SECCODE = :stock_code + ORDER BY ENDDATE DESC, F001N ASC LIMIT :limit + """) + + result = conn.execute(query, {'stock_code': stock_code, 'limit': limit}).fetchall() + + shareholders_info = [] + for row in result: + shareholder_record = {} + for key, value in zip(row.keys(), row): + if value is None: + shareholder_record[key] = None + elif isinstance(value, datetime): + shareholder_record[key] = value.strftime('%Y-%m-%d %H:%M:%S') + elif isinstance(value, date): + shareholder_record[key] = value.strftime('%Y-%m-%d') + elif isinstance(value, Decimal): + shareholder_record[key] = float(value) + else: + shareholder_record[key] = value + + shareholders_info.append(shareholder_record) + + return jsonify({ + 'success': True, + 'data': shareholders_info, + 'total': len(shareholders_info) + }) + + except Exception as e: + app.logger.error(f"Error getting top shareholders: {e}", exc_info=True) + return jsonify({'success': False, 'error': str(e)}), 500 + + +@app.route('/api/stock//branches', methods=['GET']) +def get_stock_branches(stock_code): + """获取股票分支机构信息""" + try: + with engine.connect() as conn: + query = text(""" + SELECT CRECODE as cre_code, + F001V as branch_name, + F002V as register_capital, + F003V as business_status, + F004D as register_date, + F005N as related_company_count, + F006V as legal_person, + ORGNAME as org_name, + SECCODE as sec_code, + SECNAME as sec_name + FROM ea_branch + WHERE SECCODE = :stock_code + ORDER BY F004D DESC + """) + + result = conn.execute(query, {'stock_code': stock_code}).fetchall() + + branches_info = [] + for row in result: + branch_record = {} + for key, value in zip(row.keys(), row): + if value is None: + branch_record[key] = None + elif isinstance(value, datetime): + branch_record[key] = value.strftime('%Y-%m-%d %H:%M:%S') + elif isinstance(value, date): + branch_record[key] = value.strftime('%Y-%m-%d') + elif isinstance(value, Decimal): + branch_record[key] = float(value) + else: + branch_record[key] = value + + branches_info.append(branch_record) + + return jsonify({ + 'success': True, + 'data': branches_info, + 'total': len(branches_info) + }) + + except Exception as e: + app.logger.error(f"Error getting branches info: {e}", exc_info=True) + return jsonify({'success': False, 'error': str(e)}), 500 + + +@app.route('/api/stock//patents', methods=['GET']) +def get_stock_patents(stock_code): + """获取股票专利信息""" + try: + limit = request.args.get('limit', 50, type=int) + patent_type = request.args.get('type', None) # 专利类型筛选 + + with engine.connect() as conn: + base_query = """ + SELECT CRECODE as cre_code, \ + F001V as patent_name, \ + F002V as application_number, \ + F003V as publication_number, \ + F004V as classification_number, \ + F005D as publication_date, \ + F006D as application_date, \ + F007V as patent_type, \ + F008V as applicant, \ + F009V as inventor, \ + ID as id, \ + ORGNAME as org_name, \ + SECCODE as sec_code, \ + SECNAME as sec_name + FROM ea_patent + WHERE SECCODE = :stock_code \ + """ + + params = {'stock_code': stock_code, 'limit': limit} + + if patent_type: + base_query += " AND F007V = :patent_type" + params['patent_type'] = patent_type + + base_query += " ORDER BY F006D DESC, F005D DESC LIMIT :limit" + + query = text(base_query) + + result = conn.execute(query, params).fetchall() + + patents_info = [] + for row in result: + patent_record = {} + for key, value in zip(row.keys(), row): + if value is None: + patent_record[key] = None + elif isinstance(value, datetime): + patent_record[key] = value.strftime('%Y-%m-%d %H:%M:%S') + elif isinstance(value, date): + patent_record[key] = value.strftime('%Y-%m-%d') + elif isinstance(value, Decimal): + patent_record[key] = float(value) + else: + patent_record[key] = value + + patents_info.append(patent_record) + + return jsonify({ + 'success': True, + 'data': patents_info, + 'total': len(patents_info) + }) + + except Exception as e: + app.logger.error(f"Error getting patents info: {e}", exc_info=True) + return jsonify({'success': False, 'error': str(e)}), 500 + + +def get_daily_kline(stock_code, event_datetime, stock_name): + """处理日K线数据""" + stock_code = stock_code.split('.')[0] + + 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 DATE_ADD(:trade_date, INTERVAL 30 DAY) \ + 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() + + 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 = [{ + 'time': row.TRADEDATE.strftime('%Y-%m-%d'), + 'open': float(row.open), + 'high': float(row.high), + 'low': float(row.low), + 'close': float(row.close), + 'volume': float(row.volume) + } for row in result] + + 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 + }) + + +def get_minute_kline(stock_code, event_datetime, stock_name): + """处理分钟K线数据""" + client = get_clickhouse_client() + + target_date = get_trading_day_near_date(event_datetime.date()) + is_after_market = event_datetime.time() > dt_time(15, 0) + + # 核心逻辑改动:先判断当前日期是否是交易日,以及是否已收盘 + if target_date and is_after_market: + # 如果是交易日且已收盘,查找下一个交易日 + next_trade_date = get_trading_day_near_date(target_date + timedelta(days=1)) + if next_trade_date: + target_date = next_trade_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' + }) + + # 获取目标日期的完整交易时段数据 + 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 = [{ + '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]) + } for row in data] + + return jsonify({ + '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() + }) + + +def get_timeline_data(stock_code, event_datetime, stock_name): + """处理分时均价线数据(timeline)。 + 规则: + - 若事件时间在交易日的15:00之后,则展示下一个交易日的分时数据; + - 若事件日非交易日,优先展示下一个交易日;如无,则回退到最近一个交易日; + - 数据区间固定为 09:30-15:00。 + """ + client = get_clickhouse_client() + + target_date = get_trading_day_near_date(event_datetime.date()) + is_after_market = event_datetime.time() > dt_time(15, 0) + + # 与分钟K逻辑保持一致的日期选择规则 + if target_date and is_after_market: + next_trade_date = get_trading_day_near_date(target_date + timedelta(days=1)) + if next_trade_date: + target_date = next_trade_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': 'timeline' + }) + + # 获取昨收盘价 + prev_close_query = """ + SELECT close + FROM stock_minute + WHERE code = %(code)s + AND timestamp \ + < %(start)s + ORDER BY timestamp DESC + LIMIT 1 \ + """ + + prev_close_result = client.execute(prev_close_query, { + 'code': stock_code, + 'start': datetime.combine(target_date, dt_time(9, 30)) + }) + + prev_close = float(prev_close_result[0][0]) if prev_close_result else None + + data = client.execute( + """ + SELECT + timestamp, close, volume + 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)), + } + ) + + timeline_data = [] + total_amount = 0 + total_volume = 0 + for row in data: + price = float(row[1]) + volume = float(row[2]) + total_amount += price * volume + total_volume += volume + avg_price = total_amount / total_volume if total_volume > 0 else price + + # 计算涨跌幅 + change_percent = ((price - prev_close) / prev_close * 100) if prev_close else 0 + + timeline_data.append({ + 'time': row[0].strftime('%H:%M'), + 'price': price, + 'avg_price': avg_price, + 'volume': volume, + 'change_percent': change_percent, + }) + + return jsonify({ + 'code': stock_code, + 'name': stock_name, + 'data': timeline_data, + 'trade_date': target_date.strftime('%Y-%m-%d'), + 'type': 'timeline', + 'is_history': target_date < event_datetime.date(), + 'prev_close': prev_close, + }) + + +# ==================== 指数行情API(与股票逻辑一致,数据表为 index_minute) ==================== +@app.route('/api/index//kline') +def get_index_kline(index_code): + chart_type = request.args.get('type', 'minute') + 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 + + # 指数名称(暂无索引表,先返回代码本身) + index_name = index_code + + if chart_type == 'minute': + return get_index_minute_kline(index_code, event_datetime, index_name) + elif chart_type == 'timeline': + return get_index_timeline_data(index_code, event_datetime, index_name) + elif chart_type == 'daily': + return get_index_daily_kline(index_code, event_datetime, index_name) + else: + return jsonify({'error': f'Unsupported chart type: {chart_type}'}), 400 + + +def get_index_minute_kline(index_code, event_datetime, index_name): + client = get_clickhouse_client() + target_date = get_trading_day_near_date(event_datetime.date()) + + if not target_date: + return jsonify({ + 'error': 'No data available', + 'code': index_code, + 'name': index_name, + 'data': [], + 'trade_date': event_datetime.date().strftime('%Y-%m-%d'), + 'type': 'minute' + }) + + data = client.execute( + """ + SELECT timestamp, open, high, low, close, volume, amt + FROM index_minute + WHERE code = %(code)s + AND timestamp BETWEEN %(start)s + AND %(end)s + ORDER BY timestamp + """, + { + 'code': index_code, + 'start': datetime.combine(target_date, dt_time(9, 30)), + 'end': datetime.combine(target_date, dt_time(15, 0)), + } + ) + + kline_data = [{ + '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]), + } for row in data] + + return jsonify({ + 'code': index_code, + 'name': index_name, + 'data': kline_data, + 'trade_date': target_date.strftime('%Y-%m-%d'), + 'type': 'minute', + 'is_history': target_date < event_datetime.date(), + }) + + +def get_index_timeline_data(index_code, event_datetime, index_name): + client = get_clickhouse_client() + target_date = get_trading_day_near_date(event_datetime.date()) + + if not target_date: + return jsonify({ + 'error': 'No data available', + 'code': index_code, + 'name': index_name, + 'data': [], + 'trade_date': event_datetime.date().strftime('%Y-%m-%d'), + 'type': 'timeline' + }) + + data = client.execute( + """ + SELECT timestamp, close, volume + FROM index_minute + WHERE code = %(code)s + AND timestamp BETWEEN %(start)s + AND %(end)s + ORDER BY timestamp + """, + { + 'code': index_code, + 'start': datetime.combine(target_date, dt_time(9, 30)), + 'end': datetime.combine(target_date, dt_time(15, 0)), + } + ) + + timeline = [] + total_amount = 0 + total_volume = 0 + for row in data: + price = float(row[1]) + volume = float(row[2]) + total_amount += price * volume + total_volume += volume + avg_price = total_amount / total_volume if total_volume > 0 else price + timeline.append({ + 'time': row[0].strftime('%H:%M'), + 'price': price, + 'avg_price': avg_price, + 'volume': volume, + }) + + return jsonify({ + 'code': index_code, + 'name': index_name, + 'data': timeline, + 'trade_date': target_date.strftime('%Y-%m-%d'), + 'type': 'timeline', + 'is_history': target_date < event_datetime.date(), + }) + + +def get_index_daily_kline(index_code, event_datetime, index_name): + """从 MySQL 的 stock.ea_exchangetrade 获取指数日线 + 注意:表中 INDEXCODE 无后缀,例如 000001.SH -> 000001 + 字段: + F003N 开市指数 -> open + F004N 最高指数 -> high + F005N 最低指数 -> low + F006N 最近指数 -> close(作为当日收盘或最近价使用) + F007N 昨日收市指数 -> prev_close + """ + # 去掉后缀 + code_no_suffix = index_code.split('.')[0] + + # 选择展示的最后交易日 + target_date = get_trading_day_near_date(event_datetime.date()) + if not target_date: + return jsonify({ + 'error': 'No data available', + 'code': index_code, + 'name': index_name, + 'data': [], + 'trade_date': event_datetime.date().strftime('%Y-%m-%d'), + 'type': 'daily' + }) + + # 取最近一段时间的日线(倒序再反转为升序) + with engine.connect() as conn: + rows = conn.execute(text( + """ + SELECT TRADEDATE, F003N, F004N, F005N, F006N, F007N + FROM ea_exchangetrade + WHERE INDEXCODE = :code + AND TRADEDATE <= :end_dt + ORDER BY TRADEDATE DESC LIMIT 180 + """ + ), { + 'code': code_no_suffix, + 'end_dt': datetime.combine(target_date, dt_time(23, 59, 59)) + }).fetchall() + + # 反转为时间升序 + rows = list(reversed(rows)) + + daily = [] + for i, r in enumerate(rows): + trade_dt = r[0] + open_v = r[1] + high_v = r[2] + low_v = r[3] + last_v = r[4] + prev_close_v = r[5] + + # 正确的前收盘价逻辑:使用前一个交易日的F006N(收盘价) + calculated_prev_close = None + if i > 0 and rows[i - 1][4] is not None: + # 使用前一个交易日的收盘价作为前收盘价 + calculated_prev_close = float(rows[i - 1][4]) + else: + # 第一条记录,尝试使用F007N字段作为备选 + if prev_close_v is not None and prev_close_v > 0: + calculated_prev_close = float(prev_close_v) + + daily.append({ + 'time': trade_dt.strftime('%Y-%m-%d') if hasattr(trade_dt, 'strftime') else str(trade_dt), + 'open': float(open_v) if open_v is not None else None, + 'high': float(high_v) if high_v is not None else None, + 'low': float(low_v) if low_v is not None else None, + 'close': float(last_v) if last_v is not None else None, + 'prev_close': calculated_prev_close, + }) + + return jsonify({ + 'code': index_code, + 'name': index_name, + 'data': daily, + 'trade_date': target_date.strftime('%Y-%m-%d'), + 'type': 'daily', + 'is_history': target_date < event_datetime.date(), + }) + + +# ==================== 日历API ==================== +@app.route('/api/v1/calendar/event-counts', methods=['GET']) +def get_event_counts(): + """获取日历事件数量统计""" + try: + # 获取月份参数 + year = request.args.get('year', datetime.now().year, type=int) + month = request.args.get('month', datetime.now().month, type=int) + + # 计算月份的开始和结束日期 + start_date = datetime(year, month, 1) + if month == 12: + end_date = datetime(year + 1, 1, 1) + else: + end_date = datetime(year, month + 1, 1) + + # 查询事件数量 + 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 = [] + for day in result: + events.append({ + 'date': day.date.isoformat(), + 'count': day.count, + 'className': get_event_class(day.count) + }) + + return jsonify({ + 'success': True, + 'data': events + }) + + except Exception as e: + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + + +@app.route('/api/v1/calendar/events', methods=['GET']) +def get_calendar_events(): + """获取指定日期的事件列表""" + date_str = request.args.get('date') + event_type = request.args.get('type', 'all') + + if not date_str: + return jsonify({ + 'success': False, + 'error': 'Date parameter required' + }), 400 + + try: + date = datetime.strptime(date_str, '%Y-%m-%d') + except ValueError: + return jsonify({ + 'success': False, + 'error': 'Invalid date format' + }), 400 + + # 修复SQL语法:去掉函数名后的空格,去掉参数前的空格 + query = """ + SELECT * + FROM future_events + WHERE DATE(calendar_time) = :date + """ + + params = {'date': date} + + if event_type != 'all': + query += " AND type = :type" + params['type'] = event_type + + query += " ORDER BY calendar_time" + + result = db.session.execute(text(query), params) + + events = [] + user_following_ids = set() + if 'user_id' in session: + follows = FutureEventFollow.query.filter_by(user_id=session['user_id']).all() + user_following_ids = {f.future_event_id for f in follows} + + for row in result: + event_data = { + 'id': row.data_id, + 'title': row.title, + 'type': row.type, + 'calendar_time': row.calendar_time.isoformat(), + 'star': row.star, + 'former': row.former, + 'forecast': row.forecast, + 'fact': row.fact, + 'is_following': row.data_id in user_following_ids + } + + # 解析相关股票和概念 + if row.related_stocks: + try: + if isinstance(row.related_stocks, str): + if row.related_stocks.startswith('['): + event_data['related_stocks'] = json.loads(row.related_stocks) + else: + event_data['related_stocks'] = row.related_stocks.split(',') + else: + event_data['related_stocks'] = row.related_stocks + except: + event_data['related_stocks'] = [] + else: + event_data['related_stocks'] = [] + + if row.concepts: + try: + if isinstance(row.concepts, str): + if row.concepts.startswith('['): + event_data['concepts'] = json.loads(row.concepts) + else: + event_data['concepts'] = row.concepts.split(',') + else: + event_data['concepts'] = row.concepts + except: + event_data['concepts'] = [] + else: + event_data['concepts'] = [] + + events.append(event_data) + + return jsonify({ + 'success': True, + 'data': events + }) + +@app.route('/api/v1/calendar/events/', methods=['GET']) +def get_calendar_event_detail(event_id): + """获取日历事件详情""" + try: + sql = """ + SELECT * + FROM future_events + WHERE data_id = :event_id \ + """ + + result = db.session.execute(text(sql), {'event_id': event_id}).first() + + if not result: + return jsonify({ + 'success': False, + 'error': 'Event not found' + }), 404 + + event_data = { + 'id': result.data_id, + 'title': result.title, + 'type': result.type, + 'calendar_time': result.calendar_time.isoformat(), + 'star': result.star, + 'former': result.former, + 'forecast': result.forecast, + 'fact': result.fact, + 'related_stocks': parse_json_field(result.related_stocks), + 'concepts': parse_json_field(result.concepts) + } + + # 检查当前用户是否关注了该未来事件 + if 'user_id' in session: + is_following = FutureEventFollow.query.filter_by( + user_id=session['user_id'], + future_event_id=event_id + ).first() is not None + event_data['is_following'] = is_following + else: + event_data['is_following'] = False + + return jsonify({ + 'success': True, + 'data': event_data + }) + + except Exception as e: + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + + +@app.route('/api/v1/calendar/events//follow', methods=['POST']) +def toggle_future_event_follow(event_id): + """切换未来事件关注状态(需登录)""" + if 'user_id' not in session: + return jsonify({'success': False, 'error': '未登录'}), 401 + + try: + # 检查未来事件是否存在 + sql = """ + SELECT data_id \ + FROM future_events \ + WHERE data_id = :event_id \ + """ + result = db.session.execute(text(sql), {'event_id': event_id}).first() + + if not result: + return jsonify({'success': False, 'error': '未来事件不存在'}), 404 + + user_id = session['user_id'] + + # 检查是否已关注 + existing = FutureEventFollow.query.filter_by( + user_id=user_id, + future_event_id=event_id + ).first() + + if existing: + # 取消关注 + db.session.delete(existing) + db.session.commit() + return jsonify({ + 'success': True, + 'data': {'is_following': False} + }) + else: + # 关注 + follow = FutureEventFollow( + user_id=user_id, + future_event_id=event_id + ) + db.session.add(follow) + db.session.commit() + return jsonify({ + 'success': True, + 'data': {'is_following': True} + }) + except Exception as e: + db.session.rollback() + return jsonify({'success': False, 'error': str(e)}), 500 + + +def get_event_class(count): + """根据事件数量返回CSS类名""" + if count >= 10: + return 'event-high' + elif count >= 5: + return 'event-medium' + elif count > 0: + return 'event-low' + return '' + + +def parse_json_field(field_value): + """解析JSON字段""" + if not field_value: + return [] + try: + if isinstance(field_value, str): + if field_value.startswith('['): + return json.loads(field_value) + else: + return field_value.split(',') + else: + return field_value + except: + return [] + + +# ==================== 行业API ==================== +@app.route('/api/classifications', methods=['GET']) +def get_classifications(): + """获取申银万国行业分类树形结构""" + try: + # 查询申银万国行业分类的所有数据 + sql = """ + SELECT f003v as code, f004v as level1, f005v as level2, f006v as level3,f007v as level4 + FROM ea_sector + WHERE f002v = '申银万国行业分类' + AND f003v IS NOT NULL + AND f004v IS NOT NULL + ORDER BY f003v + """ + + result = db.session.execute(text(sql)).all() + + # 构建树形结构 + tree_dict = {} + + for row in result: + code = row.code + level1 = row.level1 + level2 = row.level2 + level3 = row.level3 + + # 跳过空数据 + if not level1: + continue + + # 第一层 + if level1 not in tree_dict: + # 获取第一层的code(取前3位或前缀) + level1_code = code[:3] if len(code) >= 3 else code + tree_dict[level1] = { + 'value': level1_code, + 'label': level1, + 'children_dict': {} + } + + # 第二层 + if level2: + if level2 not in tree_dict[level1]['children_dict']: + # 获取第二层的code(取前6位) + level2_code = code[:6] if len(code) >= 6 else code + tree_dict[level1]['children_dict'][level2] = { + 'value': level2_code, + 'label': level2, + 'children_dict': {} + } + + # 第三层 + if level3: + if level3 not in tree_dict[level1]['children_dict'][level2]['children_dict']: + tree_dict[level1]['children_dict'][level2]['children_dict'][level3] = { + 'value': code, + 'label': level3 + } + + # 转换为最终格式 + result_list = [] + for level1_name, level1_data in tree_dict.items(): + level1_node = { + 'value': level1_data['value'], + 'label': level1_data['label'] + } + + # 处理第二层 + if level1_data['children_dict']: + level1_children = [] + for level2_name, level2_data in level1_data['children_dict'].items(): + level2_node = { + 'value': level2_data['value'], + 'label': level2_data['label'] + } + + # 处理第三层 + if level2_data['children_dict']: + level2_children = [] + for level3_name, level3_data in level2_data['children_dict'].items(): + level2_children.append({ + 'value': level3_data['value'], + 'label': level3_data['label'] + }) + if level2_children: + level2_node['children'] = level2_children + + level1_children.append(level2_node) + + if level1_children: + level1_node['children'] = level1_children + + result_list.append(level1_node) + + return jsonify({ + 'success': True, + 'data': result_list + }) + + except Exception as e: + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + + +@app.route('/api/stocklist', methods=['GET']) +def get_stock_list(): + """获取股票列表""" + try: + sql = """ + SELECT DISTINCT SECCODE as code, SECNAME as name + FROM ea_stocklist + ORDER BY SECCODE + """ + + result = db.session.execute(text(sql)).all() + + stocks = [{'code': row.code, 'name': row.name} for row in result] + + return jsonify(stocks) + + except Exception as e: + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + + +@app.route('/api/events', methods=['GET'], strict_slashes=False) +def api_get_events(): + """ + 获取事件列表API - 支持筛选、排序、分页,兼容前端调用 + """ + 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) + + # 行业筛选参数(只支持申银万国行业分类) + industry_code = request.args.get('industry_code') # 申万行业代码,如 "S370502" + + # 概念/标签筛选参数 + 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) + # 支持多个重要性级别筛选,用逗号分隔(如 importance=S,A) + if importance != 'all': + if ',' in importance: + # 多个重要性级别 + importance_list = [imp.strip() for imp in importance.split(',') if imp.strip()] + query = query.filter(Event.importance.in_(importance_list)) + else: + # 单个重要性级别 + query = query.filter_by(importance=importance) + if creator_id: + query = query.filter_by(creator_id=creator_id) + # 新增:行业代码过滤(申银万国行业分类) + if industry_code: + # related_industries 格式: [{"申银万国行业分类": "S370502"}, ...] + # 支持多个行业代码,用逗号分隔 + json_path = '$[*]."申银万国行业分类"' + + # 如果包含逗号,说明是多个行业代码 + if ',' in industry_code: + codes = [code.strip() for code in industry_code.split(',') if code.strip()] + # 使用 OR 条件匹配任意一个行业代码 + conditions = [] + for code in codes: + conditions.append( + text("JSON_CONTAINS(JSON_EXTRACT(related_industries, :json_path), :code)") + .bindparams(json_path=json_path, code=json.dumps(code)) + ) + query = query.filter(db.or_(*conditions)) + else: + # 单个行业代码 + query = query.filter( + text("JSON_CONTAINS(JSON_EXTRACT(related_industries, :json_path), :industry_code)") + ).params(json_path=json_path, industry_code=json.dumps(industry_code)) + # 新增:关键词/全文搜索过滤(MySQL JSON) + if search_query: + like_pattern = f"%{search_query}%" + query = query.filter( + db.or_( + Event.title.ilike(like_pattern), + Event.description.ilike(like_pattern), + text(f"JSON_SEARCH(keywords, 'one', '%{search_query}%') IS NOT NULL") + ) + ) + if recent_days: + from datetime import datetime, timedelta + 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: + from datetime import datetime + 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: + from datetime import datetime + 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 min_view_count is not None: + query = query.filter(Event.view_count >= min_view_count) + # 排序 + from sqlalchemy import desc, asc, case + 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)) + # 分页 + paginated = query.paginate(page=page, per_page=per_page, error_out=False) + events_data = [] + for event in paginated.items: + 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, + } + 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' + } + event_dict['keywords'] = event.keywords_list if hasattr(event, 'keywords_list') else event.keywords + event_dict['related_industries'] = event.related_industries + if include_related_data: + pass + 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_code: + applied_filters['industry_code'] = industry_code + 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': applied_filters, + 'total_count': paginated.total + } + } + }) + 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 + + +@app.route('/api/events/hot', methods=['GET']) +def get_hot_events(): + """获取热点事件""" + try: + from datetime import datetime, timedelta + days = request.args.get('days', 3, type=int) + limit = request.args.get('limit', 4, type=int) + since_date = datetime.now() - timedelta(days=days) + hot_events = Event.query.filter( + Event.status == 'active', + Event.created_at >= since_date, + Event.related_avg_chg != None, + Event.related_avg_chg > 0 + ).order_by(Event.related_avg_chg.desc()).limit(limit).all() + if len(hot_events) < limit: + additional_events = Event.query.filter( + Event.status == 'active', + Event.created_at >= since_date, + ~Event.id.in_([event.id for event in hot_events]) + ).order_by(Event.hot_score.desc()).limit(limit - len(hot_events)).all() + hot_events.extend(additional_events) + events_data = [] + for event in hot_events: + events_data.append({ + 'id': event.id, + 'title': event.title, + 'description': event.description, + 'importance': event.importance, + 'created_at': event.created_at.isoformat() if event.created_at else None, + 'related_avg_chg': event.related_avg_chg, + 'creator': { + 'username': event.creator.username if event.creator else 'Anonymous' + } + }) + return jsonify({'success': True, 'data': events_data}) + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + + +@app.route('/api/events/keywords/popular', methods=['GET']) +def get_popular_keywords(): + """获取热门关键词""" + try: + limit = request.args.get('limit', 20, type=int) + sql = ''' + WITH RECURSIVE \ + numbers AS (SELECT 0 as n \ + UNION ALL \ + SELECT n + 1 \ + FROM numbers \ + WHERE n < 100), \ + json_array AS (SELECT JSON_UNQUOTE(JSON_EXTRACT(e.keywords, CONCAT('$[', n.n, ']'))) as keyword, \ + COUNT(*) as count + FROM event e + CROSS JOIN numbers n + WHERE + e.status = 'active' + AND JSON_EXTRACT(e.keywords \ + , CONCAT('$[' \ + , n.n \ + , ']')) IS NOT NULL + GROUP BY JSON_UNQUOTE(JSON_EXTRACT(e.keywords, CONCAT('$[', n.n, ']'))) + HAVING keyword IS NOT NULL + ) + SELECT keyword, count + FROM json_array + ORDER BY count DESC, keyword LIMIT :limit \ + ''' + result = db.session.execute(text(sql), {'limit': limit}).all() + keywords_data = [{'keyword': row.keyword, 'count': row.count} for row in result] + return jsonify({'success': True, 'data': keywords_data}) + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + + +@app.route('/api/events//sankey-data') +def get_event_sankey_data(event_id): + """ + 获取事件桑基图数据 (最终优化版) + - 处理重名节点 + - 检测并打破循环依赖 + """ + flows = EventSankeyFlow.query.filter_by(event_id=event_id).order_by( + EventSankeyFlow.source_level, EventSankeyFlow.target_level + ).all() + + if not flows: + return jsonify({'success': False, 'message': '暂无桑基图数据'}) + + nodes_map = {} + links = [] + type_colors = { + 'event': '#ff4757', 'policy': '#10ac84', 'technology': '#ee5a6f', + 'industry': '#00d2d3', 'company': '#54a0ff', 'product': '#ffd93d' + } + + # --- 1. 识别并处理重名节点 (与上一版相同) --- + all_node_keys = set() + name_counts = {} + for flow in flows: + source_key = f"{flow.source_node}|{flow.source_level}" + target_key = f"{flow.target_node}|{flow.target_level}" + all_node_keys.add(source_key) + all_node_keys.add(target_key) + name_counts.setdefault(flow.source_node, set()).add(flow.source_level) + name_counts.setdefault(flow.target_node, set()).add(flow.target_level) + + duplicate_names = {name for name, levels in name_counts.items() if len(levels) > 1} + + for flow in flows: + source_key = f"{flow.source_node}|{flow.source_level}" + if source_key not in nodes_map: + display_name = f"{flow.source_node} (L{flow.source_level})" if flow.source_node in duplicate_names else flow.source_node + nodes_map[source_key] = {'name': display_name, 'type': flow.source_type, 'level': flow.source_level, + 'color': type_colors.get(flow.source_type)} + + target_key = f"{flow.target_node}|{flow.target_level}" + if target_key not in nodes_map: + display_name = f"{flow.target_node} (L{flow.target_level})" if flow.target_node in duplicate_names else flow.target_node + nodes_map[target_key] = {'name': display_name, 'type': flow.target_type, 'level': flow.target_level, + 'color': type_colors.get(flow.target_type)} + + links.append({ + 'source_key': source_key, 'target_key': target_key, 'value': float(flow.flow_value), + 'ratio': float(flow.flow_ratio), 'transmission_path': flow.transmission_path, + 'impact_description': flow.impact_description, 'evidence_strength': flow.evidence_strength + }) + + # --- 2. 循环检测与处理 --- + # 构建邻接表 + adj = defaultdict(list) + for link in links: + adj[link['source_key']].append(link['target_key']) + + # 深度优先搜索(DFS)来检测循环 + path = set() # 记录当前递归路径上的节点 + visited = set() # 记录所有访问过的节点 + back_edges = set() # 记录导致循环的"回流边" + + def detect_cycle_util(node): + path.add(node) + visited.add(node) + for neighbour in adj.get(node, []): + if neighbour in path: + # 发现了循环,记录这条回流边 (target, source) + back_edges.add((neighbour, node)) + elif neighbour not in visited: + detect_cycle_util(neighbour) + path.remove(node) + + # 从所有节点开始检测 + for node_key in list(adj.keys()): + if node_key not in visited: + detect_cycle_util(node_key) + + # 过滤掉导致循环的边 + if back_edges: + print(f"检测到并移除了 {len(back_edges)} 条循环边: {back_edges}") + + valid_links_no_cycle = [] + for link in links: + if (link['source_key'], link['target_key']) not in back_edges and \ + (link['target_key'], link['source_key']) not in back_edges: # 移除非严格意义上的双向边 + valid_links_no_cycle.append(link) + + # --- 3. 构建最终的 JSON 响应 (与上一版相似) --- + node_list = [] + node_index_map = {} + sorted_node_keys = sorted(nodes_map.keys(), key=lambda k: (nodes_map[k]['level'], nodes_map[k]['name'])) + + for i, key in enumerate(sorted_node_keys): + node_list.append(nodes_map[key]) + node_index_map[key] = i + + final_links = [] + for link in valid_links_no_cycle: + source_idx = node_index_map.get(link['source_key']) + target_idx = node_index_map.get(link['target_key']) + if source_idx is not None and target_idx is not None: + # 移除临时的 key,只保留 ECharts 需要的字段 + link.pop('source_key', None) + link.pop('target_key', None) + link['source'] = source_idx + link['target'] = target_idx + final_links.append(link) + + # ... (统计信息计算部分保持不变) ... + stats = { + 'total_nodes': len(node_list), 'total_flows': len(final_links), + 'total_flow_value': sum(link['value'] for link in final_links), + 'max_level': max((node['level'] for node in node_list), default=0), + 'node_type_counts': {ntype: sum(1 for n in node_list if n['type'] == ntype) for ntype in type_colors} + } + + return jsonify({ + 'success': True, + 'data': {'nodes': node_list, 'links': final_links, 'stats': stats} + }) + + +# 优化后的传导链分析 API +@app.route('/api/events//chain-analysis') +def get_event_chain_analysis(event_id): + """获取事件传导链分析数据""" + nodes = EventTransmissionNode.query.filter_by(event_id=event_id).all() + if not nodes: + return jsonify({'success': False, 'message': '暂无传导链分析数据'}) + + edges = EventTransmissionEdge.query.filter_by(event_id=event_id).all() + + # 过滤孤立节点 + connected_node_ids = set() + for edge in edges: + connected_node_ids.add(edge.from_node_id) + connected_node_ids.add(edge.to_node_id) + + # 只保留有连接的节点 + connected_nodes = [node for node in nodes if node.id in connected_node_ids] + + if not connected_nodes: + return jsonify({'success': False, 'message': '所有节点都是孤立的,暂无传导关系'}) + + # 节点分类,用于力导向图的图例 + categories = { + 'event': "事件", 'industry': "行业", 'company': "公司", + 'policy': "政策", 'technology': "技术", 'market': "市场", 'other': "其他" + } + + # 计算每个节点的连接数 + node_connection_count = {} + for node in connected_nodes: + count = sum(1 for edge in edges + if edge.from_node_id == node.id or edge.to_node_id == node.id) + node_connection_count[node.id] = count + + nodes_data = [] + for node in connected_nodes: + connection_count = node_connection_count[node.id] + + nodes_data.append({ + 'id': str(node.id), + 'name': node.node_name, + 'value': node.importance_score, # 用于控制节点大小的基础值 + 'category': categories.get(node.node_type), + 'extra': { + 'node_type': node.node_type, + 'description': node.node_description, + 'importance_score': node.importance_score, + 'stock_code': node.stock_code, + 'is_main_event': node.is_main_event, + 'connection_count': connection_count, # 添加连接数信息 + } + }) + + edges_data = [] + for edge in edges: + # 确保边的两端节点都在连接节点列表中 + if edge.from_node_id in connected_node_ids and edge.to_node_id in connected_node_ids: + edges_data.append({ + 'source': str(edge.from_node_id), + 'target': str(edge.to_node_id), + 'value': edge.strength, # 用于控制边的宽度 + 'extra': { + 'transmission_type': edge.transmission_type, + 'transmission_mechanism': edge.transmission_mechanism, + 'direction': edge.direction, + 'strength': edge.strength, + 'impact': edge.impact, + 'is_circular': edge.is_circular, + } + }) + + # 重新计算统计信息(基于连接的节点和边) + stats = { + 'total_nodes': len(connected_nodes), + 'total_edges': len(edges_data), + 'node_types': {cat: sum(1 for n in connected_nodes if n.node_type == node_type) + for node_type, cat in categories.items()}, + 'edge_types': {edge.transmission_type: sum(1 for e in edges_data + if e['extra']['transmission_type'] == edge.transmission_type) for + edge in edges}, + 'avg_importance': sum(node.importance_score for node in connected_nodes) / len( + connected_nodes) if connected_nodes else 0, + 'avg_strength': sum(edge.strength for edge in edges) / len(edges) if edges else 0 + } + + return jsonify({ + 'success': True, + 'data': { + 'nodes': nodes_data, + 'edges': edges_data, + 'categories': list(categories.values()), + 'stats': stats + } + }) + + +@app.route('/api/events//chain-node/', methods=['GET']) +@cross_origin() +def get_chain_node_detail(event_id, node_id): + """获取传导链节点及其直接关联节点的详细信息""" + node = db.session.get(EventTransmissionNode, node_id) + if not node or node.event_id != event_id: + return jsonify({'success': False, 'message': '节点不存在'}) + + # 验证节点是否为孤立节点 + total_connections = (EventTransmissionEdge.query.filter_by(from_node_id=node_id).count() + + EventTransmissionEdge.query.filter_by(to_node_id=node_id).count()) + + if total_connections == 0 and not node.is_main_event: + return jsonify({'success': False, 'message': '该节点为孤立节点,无连接关系'}) + + # 找出影响当前节点的父节点 + parents_info = [] + incoming_edges = EventTransmissionEdge.query.filter_by(to_node_id=node_id).all() + for edge in incoming_edges: + parent = db.session.get(EventTransmissionNode, edge.from_node_id) + if parent: + parents_info.append({ + 'id': parent.id, + 'name': parent.node_name, + 'type': parent.node_type, + 'direction': edge.direction, + 'strength': edge.strength, + 'transmission_type': edge.transmission_type, + 'transmission_mechanism': edge.transmission_mechanism, # 修复字段名 + 'is_circular': edge.is_circular, + 'impact': edge.impact + }) + + # 找出被当前节点影响的子节点 + children_info = [] + outgoing_edges = EventTransmissionEdge.query.filter_by(from_node_id=node_id).all() + for edge in outgoing_edges: + child = db.session.get(EventTransmissionNode, edge.to_node_id) + if child: + children_info.append({ + 'id': child.id, + 'name': child.node_name, + 'type': child.node_type, + 'direction': edge.direction, + 'strength': edge.strength, + 'transmission_type': edge.transmission_type, + 'transmission_mechanism': edge.transmission_mechanism, # 修复字段名 + 'is_circular': edge.is_circular, + 'impact': edge.impact + }) + + node_data = { + 'id': node.id, + 'name': node.node_name, + 'type': node.node_type, + 'description': node.node_description, + 'importance_score': node.importance_score, + 'stock_code': node.stock_code, + 'is_main_event': node.is_main_event, + 'total_connections': total_connections, + 'incoming_connections': len(incoming_edges), + 'outgoing_connections': len(outgoing_edges) + } + + return jsonify({ + 'success': True, + 'data': { + 'node': node_data, + 'parents': parents_info, + 'children': children_info + } + }) + + +@app.route('/api/events//posts', methods=['GET']) +def get_event_posts(event_id): + """获取事件下的帖子""" + try: + sort_type = request.args.get('sort', 'latest') + page = request.args.get('page', 1, type=int) + per_page = request.args.get('per_page', 20, type=int) + + # 查询事件下的帖子 + query = Post.query.filter_by(event_id=event_id, status='active') + + if sort_type == 'hot': + query = query.order_by(Post.likes_count.desc(), Post.created_at.desc()) + else: # latest + query = query.order_by(Post.created_at.desc()) + + # 分页 + pagination = query.paginate(page=page, per_page=per_page, error_out=False) + posts = pagination.items + + posts_data = [] + for post in posts: + post_dict = { + 'id': post.id, + 'event_id': post.event_id, + 'user_id': post.user_id, + 'title': post.title, + 'content': post.content, + 'content_type': post.content_type, + 'created_at': post.created_at.isoformat(), + 'updated_at': post.updated_at.isoformat(), + 'likes_count': post.likes_count, + 'comments_count': post.comments_count, + 'view_count': post.view_count, + 'is_top': post.is_top, + 'user': { + 'id': post.user.id, + 'username': post.user.username, + 'avatar_url': post.user.avatar_url + } if post.user else None, + 'liked': False # 后续可以根据当前用户判断 + } + posts_data.append(post_dict) + + return jsonify({ + 'success': True, + 'data': posts_data, + 'pagination': { + 'page': page, + 'per_page': per_page, + 'total': pagination.total, + 'pages': pagination.pages + } + }) + + except Exception as e: + print(f"获取帖子失败: {e}") + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + + +@app.route('/api/events//posts', methods=['POST']) +@login_required +def create_event_post(event_id): + """在事件下创建帖子""" + try: + data = request.get_json() + content = data.get('content', '').strip() + title = data.get('title', '').strip() + content_type = data.get('content_type', 'text') + + if not content: + return jsonify({ + 'success': False, + 'message': '帖子内容不能为空' + }), 400 + + # 创建新帖子 + post = Post( + event_id=event_id, + user_id=current_user.id, + title=title, + content=content, + content_type=content_type + ) + + db.session.add(post) + + # 更新事件的帖子数 + event = Event.query.get(event_id) + if event: + event.post_count = Post.query.filter_by(event_id=event_id, status='active').count() + + # 更新用户发帖数 + current_user.post_count = (current_user.post_count or 0) + 1 + + db.session.commit() + + return jsonify({ + 'success': True, + 'data': { + 'id': post.id, + 'event_id': post.event_id, + 'user_id': post.user_id, + 'title': post.title, + 'content': post.content, + 'content_type': post.content_type, + 'created_at': post.created_at.isoformat(), + 'user': { + 'id': current_user.id, + 'username': current_user.username, + 'avatar_url': current_user.avatar_url + } + }, + 'message': '帖子发布成功' + }) + + except Exception as e: + db.session.rollback() + print(f"创建帖子失败: {e}") + return jsonify({ + 'success': False, + 'message': str(e) + }), 500 + + +@app.route('/api/posts//comments', methods=['GET']) +def get_post_comments(post_id): + """获取帖子的评论""" + try: + sort_type = request.args.get('sort', 'latest') + + # 查询帖子的顶级评论(非回复) + query = Comment.query.filter_by(post_id=post_id, parent_id=None, status='active') + + if sort_type == 'hot': + comments = query.order_by(Comment.likes_count.desc(), Comment.created_at.desc()).all() + else: # latest + comments = query.order_by(Comment.created_at.desc()).all() + + comments_data = [] + for comment in comments: + comment_dict = { + 'id': comment.id, + 'post_id': comment.post_id, + 'user_id': comment.user_id, + 'content': comment.content, + 'created_at': comment.created_at.isoformat(), + 'updated_at': comment.updated_at.isoformat(), + 'likes_count': comment.likes_count, + 'user': { + 'id': comment.user.id, + 'username': comment.user.username, + 'avatar_url': comment.user.avatar_url + } if comment.user else None, + 'replies': [] # 加载回复 + } + + # 加载回复 + replies = Comment.query.filter_by(parent_id=comment.id, status='active').order_by(Comment.created_at).all() + for reply in replies: + reply_dict = { + 'id': reply.id, + 'post_id': reply.post_id, + 'user_id': reply.user_id, + 'content': reply.content, + 'parent_id': reply.parent_id, + 'created_at': reply.created_at.isoformat(), + 'likes_count': reply.likes_count, + 'user': { + 'id': reply.user.id, + 'username': reply.user.username, + 'avatar_url': reply.user.avatar_url + } if reply.user else None + } + comment_dict['replies'].append(reply_dict) + + comments_data.append(comment_dict) + + return jsonify({ + 'success': True, + 'data': comments_data + }) + + except Exception as e: + print(f"获取评论失败: {e}") + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + + +@app.route('/api/posts//comments', methods=['POST']) +@login_required +def create_post_comment(post_id): + """在帖子下创建评论""" + try: + data = request.get_json() + content = data.get('content', '').strip() + parent_id = data.get('parent_id') + + if not content: + return jsonify({ + 'success': False, + 'message': '评论内容不能为空' + }), 400 + + # 创建新评论 + comment = Comment( + post_id=post_id, + user_id=current_user.id, + content=content, + parent_id=parent_id + ) + + db.session.add(comment) + + # 更新帖子评论数 + post = Post.query.get(post_id) + if post: + post.comments_count = Comment.query.filter_by(post_id=post_id, status='active').count() + + # 更新用户评论数 + current_user.comment_count = (current_user.comment_count or 0) + 1 + + db.session.commit() + + return jsonify({ + 'success': True, + 'data': { + 'id': comment.id, + 'post_id': comment.post_id, + 'user_id': comment.user_id, + 'content': comment.content, + 'parent_id': comment.parent_id, + 'created_at': comment.created_at.isoformat(), + 'user': { + 'id': current_user.id, + 'username': current_user.username, + 'avatar_url': current_user.avatar_url + } + }, + 'message': '评论发布成功' + }) + + except Exception as e: + db.session.rollback() + print(f"创建评论失败: {e}") + return jsonify({ + 'success': False, + 'message': str(e) + }), 500 + + +# 兼容旧的评论接口,转换为帖子模式 +@app.route('/api/events//comments', methods=['GET']) +def get_event_comments(event_id): + """获取事件评论(兼容旧接口)""" + # 将事件评论转换为获取事件下所有帖子的评论 + return get_event_posts(event_id) + + +@app.route('/api/events//comments', methods=['POST']) +@login_required +def add_event_comment(event_id): + """添加事件评论(兼容旧接口)""" + try: + data = request.get_json() + content = data.get('content', '').strip() + parent_id = data.get('parent_id') + + if not content: + return jsonify({ + 'success': False, + 'message': '评论内容不能为空' + }), 400 + + # 如果有 parent_id,说明是回复,需要找到对应的帖子 + if parent_id: + # 这是一个回复,需要将其转换为对应帖子的评论 + # 首先需要找到 parent_id 对应的帖子 + # 这里假设旧的 parent_id 是之前的 EventComment id + # 需要在数据迁移时处理这个映射关系 + return jsonify({ + 'success': False, + 'message': '回复功能正在升级中,请稍后再试' + }), 503 + + # 如果没有 parent_id,说明是顶级评论,创建为新帖子 + post = Post( + event_id=event_id, + user_id=current_user.id, + content=content, + content_type='text' + ) + + db.session.add(post) + + # 更新事件的帖子数 + event = Event.query.get(event_id) + if event: + event.post_count = Post.query.filter_by(event_id=event_id, status='active').count() + + # 更新用户发帖数 + current_user.post_count = (current_user.post_count or 0) + 1 + + db.session.commit() + + # 返回兼容旧接口的数据格式 + return jsonify({ + 'success': True, + 'data': { + 'id': post.id, + 'event_id': post.event_id, + 'user_id': post.user_id, + 'author': current_user.username, + 'content': post.content, + 'parent_id': None, + 'likes': 0, + 'created_at': post.created_at.isoformat(), + 'status': 'active', + 'user': { + 'id': current_user.id, + 'username': current_user.username, + 'avatar_url': current_user.avatar_url + }, + 'replies': [] + }, + 'message': '评论发布成功' + }) + + except Exception as e: + db.session.rollback() + print(f"添加事件评论失败: {e}") + return jsonify({ + 'success': False, + 'message': str(e) + }), 500 + + +# ==================== WebSocket 事件处理器(实时事件推送) ==================== + +@socketio.on('connect') +def handle_connect(): + """客户端连接事件""" + print(f'\n[WebSocket DEBUG] ========== 客户端连接 ==========') + print(f'[WebSocket DEBUG] Socket ID: {request.sid}') + print(f'[WebSocket DEBUG] Remote Address: {request.remote_addr if hasattr(request, "remote_addr") else "N/A"}') + print(f'[WebSocket] 客户端已连接: {request.sid}') + + emit('connection_response', { + 'status': 'connected', + 'sid': request.sid, + 'message': '已连接到事件推送服务' + }) + print(f'[WebSocket DEBUG] ✓ 已发送 connection_response') + print(f'[WebSocket DEBUG] ========== 连接完成 ==========\n') + + +@socketio.on('subscribe_events') +def handle_subscribe(data): + """ + 客户端订阅事件推送 + data: { + 'event_type': 'all' | 'policy' | 'market' | 'tech' | ..., + 'importance': 'all' | 'S' | 'A' | 'B' | 'C', + 'filters': {...} # 可选的其他筛选条件 + } + """ + try: + print(f'\n[WebSocket DEBUG] ========== 收到订阅请求 ==========') + print(f'[WebSocket DEBUG] Socket ID: {request.sid}') + print(f'[WebSocket DEBUG] 订阅数据: {data}') + + event_type = data.get('event_type', 'all') + importance = data.get('importance', 'all') + + print(f'[WebSocket DEBUG] 事件类型: {event_type}') + print(f'[WebSocket DEBUG] 重要性: {importance}') + + # 加入对应的房间 + room_name = f"events_{event_type}" + print(f'[WebSocket DEBUG] 准备加入房间: {room_name}') + join_room(room_name) + print(f'[WebSocket DEBUG] ✓ 已加入房间: {room_name}') + + print(f'[WebSocket] 客户端 {request.sid} 订阅了房间: {room_name}') + + response_data = { + 'success': True, + 'room': room_name, + 'event_type': event_type, + 'importance': importance, + 'message': f'已订阅 {event_type} 类型的事件推送' + } + print(f'[WebSocket DEBUG] 准备发送 subscription_confirmed: {response_data}') + emit('subscription_confirmed', response_data) + print(f'[WebSocket DEBUG] ✓ 已发送 subscription_confirmed') + print(f'[WebSocket DEBUG] ========== 订阅完成 ==========\n') + + except Exception as e: + print(f'[WebSocket ERROR] 订阅失败: {e}') + import traceback + traceback.print_exc() + emit('subscription_error', { + 'success': False, + 'error': str(e) + }) + + +@socketio.on('unsubscribe_events') +def handle_unsubscribe(data): + """取消订阅事件推送""" + try: + print(f'\n[WebSocket DEBUG] ========== 收到取消订阅请求 ==========') + print(f'[WebSocket DEBUG] Socket ID: {request.sid}') + print(f'[WebSocket DEBUG] 数据: {data}') + + event_type = data.get('event_type', 'all') + room_name = f"events_{event_type}" + + print(f'[WebSocket DEBUG] 准备离开房间: {room_name}') + leave_room(room_name) + print(f'[WebSocket DEBUG] ✓ 已离开房间: {room_name}') + + print(f'[WebSocket] 客户端 {request.sid} 取消订阅房间: {room_name}') + + emit('unsubscription_confirmed', { + 'success': True, + 'room': room_name, + 'message': f'已取消订阅 {event_type} 类型的事件推送' + }) + print(f'[WebSocket DEBUG] ========== 取消订阅完成 ==========\n') + + except Exception as e: + print(f'[WebSocket ERROR] 取消订阅失败: {e}') + import traceback + traceback.print_exc() + emit('unsubscription_error', { + 'success': False, + 'error': str(e) + }) + + +@socketio.on('disconnect') +def handle_disconnect(): + """客户端断开连接事件""" + print(f'\n[WebSocket DEBUG] ========== 客户端断开 ==========') + print(f'[WebSocket DEBUG] Socket ID: {request.sid}') + print(f'[WebSocket] 客户端已断开: {request.sid}') + print(f'[WebSocket DEBUG] ========== 断开完成 ==========\n') + + +# ==================== WebSocket 辅助函数 ==================== + +def broadcast_new_event(event): + """ + 广播新事件到所有订阅的客户端 + 在创建新事件时调用此函数 + + Args: + event: Event 模型实例 + """ + try: + print(f'\n[WebSocket DEBUG] ========== 广播新事件 ==========') + print(f'[WebSocket DEBUG] 事件ID: {event.id}') + print(f'[WebSocket DEBUG] 事件标题: {event.title}') + print(f'[WebSocket DEBUG] 事件类型: {event.event_type}') + print(f'[WebSocket DEBUG] 重要性: {event.importance}') + + event_data = { + '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, + 'hot_score': event.hot_score, + 'view_count': event.view_count, + 'related_avg_chg': event.related_avg_chg, + 'related_max_chg': event.related_max_chg, + 'keywords': event.keywords_list if hasattr(event, 'keywords_list') else event.keywords, + } + + print(f'[WebSocket DEBUG] 准备发送的数据: {event_data}') + + # 发送到所有订阅者(all 房间) + print(f'[WebSocket DEBUG] 正在发送到房间: events_all') + socketio.emit('new_event', event_data, room='events_all', namespace='/') + print(f'[WebSocket DEBUG] ✓ 已发送到 events_all') + + # 发送到特定类型订阅者 + if event.event_type: + room_name = f"events_{event.event_type}" + print(f'[WebSocket DEBUG] 正在发送到房间: {room_name}') + socketio.emit('new_event', event_data, room=room_name, namespace='/') + print(f'[WebSocket DEBUG] ✓ 已发送到 {room_name}') + print(f'[WebSocket] 已推送新事件到房间: events_all, {room_name}') + else: + print(f'[WebSocket] 已推送新事件到房间: events_all') + + print(f'[WebSocket DEBUG] ========== 广播完成 ==========\n') + + except Exception as e: + print(f'[WebSocket ERROR] 推送新事件失败: {e}') + import traceback + traceback.print_exc() + + +# ==================== WebSocket 轮询机制(检测新事件) ==================== + +# 内存变量:记录近24小时内已知的事件ID集合和最大ID +known_event_ids_in_24h = set() # 近24小时内已知的所有事件ID +last_max_event_id = 0 # 已知的最大事件ID + +def poll_new_events(): + """ + 定期轮询数据库,检查是否有新事件 + 每 30 秒执行一次 + + 新的设计思路(修复 created_at 不是入库时间的问题): + 1. 查询近24小时内的所有活跃事件(按 created_at,因为这是事件发生时间) + 2. 通过对比事件ID(自增ID)来判断是否为新插入的事件 + 3. 推送 ID > last_max_event_id 的事件 + 4. 更新已知事件ID集合和最大ID + """ + global known_event_ids_in_24h, last_max_event_id + + try: + with app.app_context(): + from datetime import datetime, timedelta + + current_time = datetime.now() + print(f'\n[轮询 DEBUG] ========== 开始轮询 ==========') + print(f'[轮询 DEBUG] 当前时间: {current_time.strftime("%Y-%m-%d %H:%M:%S")}') + print(f'[轮询 DEBUG] 已知事件ID数量: {len(known_event_ids_in_24h)}') + print(f'[轮询 DEBUG] 当前最大事件ID: {last_max_event_id}') + + # 查询近24小时内的所有活跃事件(按事件发生时间 created_at) + time_24h_ago = current_time - timedelta(hours=24) + print(f'[轮询 DEBUG] 查询时间范围: 近24小时({time_24h_ago.strftime("%Y-%m-%d %H:%M:%S")} ~ 现在)') + + # 查询所有近24小时内的活跃事件 + events_in_24h = Event.query.filter( + Event.created_at >= time_24h_ago, + Event.status == 'active' + ).order_by(Event.id.asc()).all() + + print(f'[轮询 DEBUG] 数据库查询结果: 找到 {len(events_in_24h)} 个近24小时内的事件') + + # 找出新插入的事件(ID > last_max_event_id) + new_events = [ + event for event in events_in_24h + if event.id > last_max_event_id + ] + + print(f'[轮询 DEBUG] 新事件数量(ID > {last_max_event_id}): {len(new_events)} 个') + + if new_events: + print(f'[轮询] 发现 {len(new_events)} 个新事件') + + for event in new_events: + print(f'[轮询 DEBUG] 新事件详情:') + print(f'[轮询 DEBUG] - ID: {event.id}') + print(f'[轮询 DEBUG] - 标题: {event.title}') + print(f'[轮询 DEBUG] - 事件发生时间(created_at): {event.created_at}') + print(f'[轮询 DEBUG] - 事件类型: {event.event_type}') + + # 推送新事件 + print(f'[轮询 DEBUG] 准备推送事件 ID={event.id}') + broadcast_new_event(event) + print(f'[轮询] ✓ 已推送事件 ID={event.id}, 标题={event.title}') + + # 更新已知事件ID集合(所有近24小时内的事件ID) + known_event_ids_in_24h = set(event.id for event in events_in_24h) + + # 更新最大事件ID + new_max_id = max(event.id for event in events_in_24h) + print(f'[轮询 DEBUG] 更新最大事件ID: {last_max_event_id} -> {new_max_id}') + last_max_event_id = new_max_id + + print(f'[轮询 DEBUG] 更新后已知事件ID数量: {len(known_event_ids_in_24h)}') + + else: + print(f'[轮询 DEBUG] 没有新事件需要推送') + + # 即使没有新事件,也要更新已知事件集合(清理超过24小时的) + if events_in_24h: + known_event_ids_in_24h = set(event.id for event in events_in_24h) + current_max_id = max(event.id for event in events_in_24h) + if current_max_id != last_max_event_id: + print(f'[轮询 DEBUG] 更新最大事件ID: {last_max_event_id} -> {current_max_id}') + last_max_event_id = current_max_id + + print(f'[轮询 DEBUG] ========== 轮询结束 ==========\n') + + except Exception as e: + print(f'[轮询 ERROR] 检查新事件时出错: {e}') + import traceback + traceback.print_exc() + + +def initialize_event_polling(): + """ + 初始化事件轮询机制 + 在应用启动时调用 + """ + global known_event_ids_in_24h, last_max_event_id + + try: + from datetime import datetime, timedelta + + with app.app_context(): + current_time = datetime.now() + time_24h_ago = current_time - timedelta(hours=24) + + print(f'\n[轮询] ========== 初始化事件轮询 ==========') + print(f'[轮询] 当前时间: {current_time.strftime("%Y-%m-%d %H:%M:%S")}') + + # 查询近24小时内的所有活跃事件 + events_in_24h = Event.query.filter( + Event.created_at >= time_24h_ago, + Event.status == 'active' + ).order_by(Event.id.asc()).all() + + # 初始化已知事件ID集合 + known_event_ids_in_24h = set(event.id for event in events_in_24h) + + # 初始化最大事件ID + if events_in_24h: + last_max_event_id = max(event.id for event in events_in_24h) + print(f'[轮询] 近24小时内共有 {len(events_in_24h)} 个活跃事件') + print(f'[轮询] 初始最大事件ID: {last_max_event_id}') + print(f'[轮询] 事件ID范围: {min(event.id for event in events_in_24h)} ~ {last_max_event_id}') + else: + last_max_event_id = 0 + print(f'[轮询] 近24小时内没有活跃事件') + print(f'[轮询] 初始最大事件ID: 0') + + # 统计数据库中的事件总数 + total_events = Event.query.filter_by(status='active').count() + print(f'[轮询] 数据库中共有 {total_events} 个活跃事件(所有时间)') + print(f'[轮询] 只会推送 ID > {last_max_event_id} 的新事件') + print(f'[轮询] ========== 初始化完成 ==========\n') + + # 创建后台调度器 + scheduler = BackgroundScheduler() + # 每 30 秒执行一次轮询 + scheduler.add_job( + func=poll_new_events, + trigger='interval', + seconds=30, + id='poll_new_events', + name='检查新事件并推送', + replace_existing=True + ) + scheduler.start() + print('[轮询] 调度器已启动,每 30 秒检查一次新事件') + + except Exception as e: + print(f'[轮询] 初始化失败: {e}') + + +# ==================== 结束 WebSocket 部分 ==================== + + +@app.route('/api/posts//like', methods=['POST']) +@login_required +def like_post(post_id): + """点赞/取消点赞帖子""" + try: + post = Post.query.get_or_404(post_id) + + # 检查是否已经点赞 + existing_like = PostLike.query.filter_by( + post_id=post_id, + user_id=current_user.id + ).first() + + if existing_like: + # 取消点赞 + db.session.delete(existing_like) + post.likes_count = max(0, post.likes_count - 1) + message = '取消点赞成功' + liked = False + else: + # 添加点赞 + new_like = PostLike(post_id=post_id, user_id=current_user.id) + db.session.add(new_like) + post.likes_count += 1 + message = '点赞成功' + liked = True + + db.session.commit() + + return jsonify({ + 'success': True, + 'message': message, + 'likes_count': post.likes_count, + 'liked': liked + }) + + except Exception as e: + db.session.rollback() + print(f"点赞失败: {e}") + return jsonify({ + 'success': False, + 'message': str(e) + }), 500 + + +@app.route('/api/comments//like', methods=['POST']) +@login_required +def like_comment(comment_id): + """点赞/取消点赞评论""" + try: + comment = Comment.query.get_or_404(comment_id) + + # 检查是否已经点赞(需要创建 CommentLike 关联到新的 Comment 模型) + # 暂时使用简单的计数器 + comment.likes_count += 1 + db.session.commit() + + return jsonify({ + 'success': True, + 'message': '点赞成功', + 'likes_count': comment.likes_count + }) + + except Exception as e: + db.session.rollback() + print(f"点赞失败: {e}") + return jsonify({ + 'success': False, + 'message': str(e) + }), 500 + + +@app.route('/api/posts/', methods=['DELETE']) +@login_required +def delete_post(post_id): + """删除帖子""" + try: + post = Post.query.get_or_404(post_id) + + # 检查权限:只能删除自己的帖子 + if post.user_id != current_user.id: + return jsonify({ + 'success': False, + 'message': '您只能删除自己的帖子' + }), 403 + + # 软删除 + post.status = 'deleted' + + # 更新事件的帖子数 + event = Event.query.get(post.event_id) + if event: + event.post_count = Post.query.filter_by(event_id=post.event_id, status='active').count() + + # 更新用户发帖数 + if current_user.post_count > 0: + current_user.post_count -= 1 + + db.session.commit() + + return jsonify({ + 'success': True, + 'message': '帖子删除成功' + }) + + except Exception as e: + db.session.rollback() + print(f"删除帖子失败: {e}") + return jsonify({ + 'success': False, + 'message': str(e) + }), 500 + + +@app.route('/api/comments/', methods=['DELETE']) +@login_required +def delete_comment(comment_id): + """删除评论""" + try: + comment = Comment.query.get_or_404(comment_id) + + # 检查权限:只能删除自己的评论 + if comment.user_id != current_user.id: + return jsonify({ + 'success': False, + 'message': '您只能删除自己的评论' + }), 403 + + # 软删除 + comment.status = 'deleted' + comment.content = '[该评论已被删除]' + + # 更新帖子评论数 + post = Post.query.get(comment.post_id) + if post: + post.comments_count = Comment.query.filter_by(post_id=comment.post_id, status='active').count() + + # 更新用户评论数 + if current_user.comment_count > 0: + current_user.comment_count -= 1 + + db.session.commit() + + return jsonify({ + 'success': True, + 'message': '评论删除成功' + }) + + except Exception as e: + db.session.rollback() + print(f"删除评论失败: {e}") + return jsonify({ + 'success': False, + 'message': str(e) + }), 500 + + +def format_decimal(value): + """格式化decimal类型数据""" + if value is None: + return None + if isinstance(value, Decimal): + return float(value) + return float(value) + + +def format_date(date_obj): + """格式化日期""" + if date_obj is None: + return None + if isinstance(date_obj, datetime): + return date_obj.strftime('%Y-%m-%d') + return str(date_obj) + + +def remove_cycles_from_sankey_flows(flows_data): + """ + 移除Sankey图数据中的循环边,确保数据是DAG(有向无环图) + 使用拓扑排序算法检测循环,优先保留flow_ratio高的边 + + Args: + flows_data: list of flow objects with 'source', 'target', 'flow_metrics' keys + + Returns: + list of flows without cycles + """ + if not flows_data: + return flows_data + + # 按flow_ratio降序排序,优先保留重要的边 + sorted_flows = sorted( + flows_data, + key=lambda x: x.get('flow_metrics', {}).get('flow_ratio', 0) or 0, + reverse=True + ) + + # 构建图的邻接表和入度表 + def build_graph(flows): + graph = {} # node -> list of successors + in_degree = {} # node -> in-degree count + all_nodes = set() + + for flow in flows: + source = flow['source']['node_name'] + target = flow['target']['node_name'] + all_nodes.add(source) + all_nodes.add(target) + + if source not in graph: + graph[source] = [] + graph[source].append(target) + + if target not in in_degree: + in_degree[target] = 0 + in_degree[target] += 1 + + if source not in in_degree: + in_degree[source] = 0 + + return graph, in_degree, all_nodes + + # 使用Kahn算法检测是否有环 + def has_cycle(graph, in_degree, all_nodes): + # 找到所有入度为0的节点 + queue = [node for node in all_nodes if in_degree.get(node, 0) == 0] + visited_count = 0 + + while queue: + node = queue.pop(0) + visited_count += 1 + + # 访问所有邻居 + for neighbor in graph.get(node, []): + in_degree[neighbor] -= 1 + if in_degree[neighbor] == 0: + queue.append(neighbor) + + # 如果访问的节点数等于总节点数,说明没有环 + return visited_count < len(all_nodes) + + # 逐个添加边,如果添加后产生环则跳过 + result_flows = [] + + for flow in sorted_flows: + # 尝试添加这条边 + temp_flows = result_flows + [flow] + + # 检查是否产生环 + graph, in_degree, all_nodes = build_graph(temp_flows) + + # 复制in_degree用于检测(因为检测过程会修改它) + in_degree_copy = in_degree.copy() + + if not has_cycle(graph, in_degree_copy, all_nodes): + # 没有产生环,可以添加 + result_flows.append(flow) + else: + # 产生环,跳过这条边 + print(f"Skipping edge that creates cycle: {flow['source']['node_name']} -> {flow['target']['node_name']}") + + removed_count = len(flows_data) - len(result_flows) + if removed_count > 0: + print(f"Removed {removed_count} edges to eliminate cycles in Sankey diagram") + + return result_flows + + +def get_report_type(date_str): + """获取报告期类型""" + if not date_str: + return '' + if isinstance(date_str, str): + date = datetime.strptime(date_str, '%Y-%m-%d') + else: + date = date_str + + month = date.month + year = date.year + + if month == 3: + return f"{year}年一季报" + elif month == 6: + return f"{year}年中报" + elif month == 9: + return f"{year}年三季报" + elif month == 12: + return f"{year}年年报" + else: + return str(date_str) + + +@app.route('/api/financial/stock-info/', methods=['GET']) +def get_stock_info(seccode): + """获取股票基本信息和最新财务摘要""" + try: + # 获取最新的财务数据 + query = text(""" + SELECT distinct a.SECCODE, + a.SECNAME, + a.ENDDATE, + a.F003N as eps, + a.F004N as basic_eps, + a.F005N as diluted_eps, + a.F006N as deducted_eps, + a.F007N as undistributed_profit_ps, + a.F008N as bvps, + a.F010N as capital_reserve_ps, + a.F014N as roe, + a.F067N as roe_weighted, + a.F016N as roa, + a.F078N as gross_margin, + a.F017N as net_margin, + a.F089N as revenue, + a.F101N as net_profit, + a.F102N as parent_net_profit, + a.F118N as total_assets, + a.F121N as total_liabilities, + a.F128N as total_equity, + a.F052N as revenue_growth, + a.F053N as profit_growth, + a.F054N as equity_growth, + a.F056N as asset_growth, + a.F122N as share_capital + FROM ea_financialindex a + WHERE a.SECCODE = :seccode + ORDER BY a.ENDDATE DESC LIMIT 1 + """) + + result = engine.execute(query, seccode=seccode).fetchone() + + if not result: + return jsonify({ + 'success': False, + 'message': f'未找到股票代码 {seccode} 的财务数据' + }), 404 + + # 获取最近的业绩预告 + forecast_query = text(""" + SELECT distinct F001D as report_date, + F003V as forecast_type, + F004V as content, + F007N as profit_lower, + F008N as profit_upper, + F009N as change_lower, + F010N as change_upper + FROM ea_forecast + WHERE SECCODE = :seccode + AND F006C = 'T' + ORDER BY F001D DESC LIMIT 1 + """) + + forecast_result = engine.execute(forecast_query, seccode=seccode).fetchone() + + data = { + 'stock_code': result.SECCODE, + 'stock_name': result.SECNAME, + 'latest_period': format_date(result.ENDDATE), + 'report_type': get_report_type(result.ENDDATE), + 'key_metrics': { + 'eps': format_decimal(result.eps), + 'basic_eps': format_decimal(result.basic_eps), + 'diluted_eps': format_decimal(result.diluted_eps), + 'deducted_eps': format_decimal(result.deducted_eps), + 'bvps': format_decimal(result.bvps), + 'roe': format_decimal(result.roe), + 'roe_weighted': format_decimal(result.roe_weighted), + 'roa': format_decimal(result.roa), + 'gross_margin': format_decimal(result.gross_margin), + 'net_margin': format_decimal(result.net_margin), + }, + 'financial_summary': { + 'revenue': format_decimal(result.revenue), + 'net_profit': format_decimal(result.net_profit), + 'parent_net_profit': format_decimal(result.parent_net_profit), + 'total_assets': format_decimal(result.total_assets), + 'total_liabilities': format_decimal(result.total_liabilities), + 'total_equity': format_decimal(result.total_equity), + 'share_capital': format_decimal(result.share_capital), + }, + 'growth_rates': { + 'revenue_growth': format_decimal(result.revenue_growth), + 'profit_growth': format_decimal(result.profit_growth), + 'equity_growth': format_decimal(result.equity_growth), + 'asset_growth': format_decimal(result.asset_growth), + } + } + + # 添加业绩预告信息 + if forecast_result: + data['latest_forecast'] = { + 'report_date': format_date(forecast_result.report_date), + 'forecast_type': forecast_result.forecast_type, + 'content': forecast_result.content, + 'profit_range': { + 'lower': format_decimal(forecast_result.profit_lower), + 'upper': format_decimal(forecast_result.profit_upper), + }, + 'change_range': { + 'lower': format_decimal(forecast_result.change_lower), + 'upper': format_decimal(forecast_result.change_upper), + } + } + + return jsonify({ + 'success': True, + 'data': data + }) + + except Exception as e: + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + + +@app.route('/api/financial/balance-sheet/', methods=['GET']) +def get_balance_sheet(seccode): + """获取完整的资产负债表数据""" + try: + limit = request.args.get('limit', 12, type=int) + + query = text(""" + SELECT distinct ENDDATE, + DECLAREDATE, + -- 流动资产 + F006N as cash, -- 货币资金 + F007N as trading_financial_assets, -- 交易性金融资产 + F008N as notes_receivable, -- 应收票据 + F009N as accounts_receivable, -- 应收账款 + F010N as prepayments, -- 预付款项 + F011N as other_receivables, -- 其他应收款 + F013N as interest_receivable, -- 应收利息 + F014N as dividends_receivable, -- 应收股利 + F015N as inventory, -- 存货 + F016N as consumable_biological_assets, -- 消耗性生物资产 + F017N as non_current_assets_due_within_one_year, -- 一年内到期的非流动资产 + F018N as other_current_assets, -- 其他流动资产 + F019N as total_current_assets, -- 流动资产合计 + + -- 非流动资产 + F020N as available_for_sale_financial_assets, -- 可供出售金融资产 + F021N as held_to_maturity_investments, -- 持有至到期投资 + F022N as long_term_receivables, -- 长期应收款 + F023N as long_term_equity_investments, -- 长期股权投资 + F024N as investment_property, -- 投资性房地产 + F025N as fixed_assets, -- 固定资产 + F026N as construction_in_progress, -- 在建工程 + F027N as engineering_materials, -- 工程物资 + F029N as productive_biological_assets, -- 生产性生物资产 + F030N as oil_and_gas_assets, -- 油气资产 + F031N as intangible_assets, -- 无形资产 + F032N as development_expenditure, -- 开发支出 + F033N as goodwill, -- 商誉 + F034N as long_term_deferred_expenses, -- 长期待摊费用 + F035N as deferred_tax_assets, -- 递延所得税资产 + F036N as other_non_current_assets, -- 其他非流动资产 + F037N as total_non_current_assets, -- 非流动资产合计 + F038N as total_assets, -- 资产总计 + + -- 流动负债 + F039N as short_term_borrowings, -- 短期借款 + F040N as trading_financial_liabilities, -- 交易性金融负债 + F041N as notes_payable, -- 应付票据 + F042N as accounts_payable, -- 应付账款 + F043N as advance_receipts, -- 预收款项 + F044N as employee_compensation_payable, -- 应付职工薪酬 + F045N as taxes_payable, -- 应交税费 + F046N as interest_payable, -- 应付利息 + F047N as dividends_payable, -- 应付股利 + F048N as other_payables, -- 其他应付款 + F050N as non_current_liabilities_due_within_one_year, -- 一年内到期的非流动负债 + F051N as other_current_liabilities, -- 其他流动负债 + F052N as total_current_liabilities, -- 流动负债合计 + + -- 非流动负债 + F053N as long_term_borrowings, -- 长期借款 + F054N as bonds_payable, -- 应付债券 + F055N as long_term_payables, -- 长期应付款 + F056N as special_payables, -- 专项应付款 + F057N as estimated_liabilities, -- 预计负债 + F058N as deferred_tax_liabilities, -- 递延所得税负债 + F059N as other_non_current_liabilities, -- 其他非流动负债 + F060N as total_non_current_liabilities, -- 非流动负债合计 + F061N as total_liabilities, -- 负债合计 + + -- 所有者权益 + F062N as share_capital, -- 股本 + F063N as capital_reserve, -- 资本公积 + F064N as surplus_reserve, -- 盈余公积 + F065N as undistributed_profit, -- 未分配利润 + F066N as treasury_stock, -- 库存股 + F067N as minority_interests, -- 少数股东权益 + F070N as total_equity, -- 所有者权益合计 + F071N as total_liabilities_and_equity, -- 负债和所有者权益合计 + F073N as parent_company_equity, -- 归属于母公司所有者权益 + F074N as other_comprehensive_income, -- 其他综合收益 + + -- 新会计准则科目 + F110N as other_debt_investments, -- 其他债权投资 + F111N as other_equity_investments, -- 其他权益工具投资 + F112N as other_non_current_financial_assets, -- 其他非流动金融资产 + F115N as contract_liabilities, -- 合同负债 + F119N as contract_assets, -- 合同资产 + F120N as receivables_financing, -- 应收款项融资 + F121N as right_of_use_assets, -- 使用权资产 + F122N as lease_liabilities -- 租赁负债 + FROM ea_asset + WHERE SECCODE = :seccode + and F002V = '071001' + ORDER BY ENDDATE DESC LIMIT :limit + """) + + result = engine.execute(query, seccode=seccode, limit=limit) + data = [] + + for row in result: + # 安全计算关键比率,避免 Decimal 与 None 运算错误 + def to_float(v): + try: + return float(v) if v is not None else None + except Exception: + return None + + ta = to_float(row.total_assets) + tl = to_float(row.total_liabilities) + tca = to_float(row.total_current_assets) + tcl = to_float(row.total_current_liabilities) + inv = to_float(row.inventory) or 0.0 + + asset_liability_ratio_val = None + if ta is not None and ta != 0 and tl is not None: + asset_liability_ratio_val = (tl / ta) * 100 + + current_ratio_val = None + if tcl is not None and tcl != 0 and tca is not None: + current_ratio_val = tca / tcl + + quick_ratio_val = None + if tcl is not None and tcl != 0 and tca is not None: + quick_ratio_val = (tca - inv) / tcl + + period_data = { + 'period': format_date(row.ENDDATE), + 'declare_date': format_date(row.DECLAREDATE), + 'report_type': get_report_type(row.ENDDATE), + + # 资产部分 + 'assets': { + 'current_assets': { + 'cash': format_decimal(row.cash), + 'trading_financial_assets': format_decimal(row.trading_financial_assets), + 'notes_receivable': format_decimal(row.notes_receivable), + 'accounts_receivable': format_decimal(row.accounts_receivable), + 'prepayments': format_decimal(row.prepayments), + 'other_receivables': format_decimal(row.other_receivables), + 'inventory': format_decimal(row.inventory), + 'contract_assets': format_decimal(row.contract_assets), + 'other_current_assets': format_decimal(row.other_current_assets), + 'total': format_decimal(row.total_current_assets), + }, + 'non_current_assets': { + 'long_term_equity_investments': format_decimal(row.long_term_equity_investments), + 'investment_property': format_decimal(row.investment_property), + 'fixed_assets': format_decimal(row.fixed_assets), + 'construction_in_progress': format_decimal(row.construction_in_progress), + 'intangible_assets': format_decimal(row.intangible_assets), + 'goodwill': format_decimal(row.goodwill), + 'right_of_use_assets': format_decimal(row.right_of_use_assets), + 'deferred_tax_assets': format_decimal(row.deferred_tax_assets), + 'other_non_current_assets': format_decimal(row.other_non_current_assets), + 'total': format_decimal(row.total_non_current_assets), + }, + 'total': format_decimal(row.total_assets), + }, + + # 负债部分 + 'liabilities': { + 'current_liabilities': { + 'short_term_borrowings': format_decimal(row.short_term_borrowings), + 'notes_payable': format_decimal(row.notes_payable), + 'accounts_payable': format_decimal(row.accounts_payable), + 'advance_receipts': format_decimal(row.advance_receipts), + 'contract_liabilities': format_decimal(row.contract_liabilities), + 'employee_compensation_payable': format_decimal(row.employee_compensation_payable), + 'taxes_payable': format_decimal(row.taxes_payable), + 'other_payables': format_decimal(row.other_payables), + 'non_current_liabilities_due_within_one_year': format_decimal( + row.non_current_liabilities_due_within_one_year), + 'total': format_decimal(row.total_current_liabilities), + }, + 'non_current_liabilities': { + 'long_term_borrowings': format_decimal(row.long_term_borrowings), + 'bonds_payable': format_decimal(row.bonds_payable), + 'lease_liabilities': format_decimal(row.lease_liabilities), + 'deferred_tax_liabilities': format_decimal(row.deferred_tax_liabilities), + 'other_non_current_liabilities': format_decimal(row.other_non_current_liabilities), + 'total': format_decimal(row.total_non_current_liabilities), + }, + 'total': format_decimal(row.total_liabilities), + }, + + # 股东权益部分 + 'equity': { + 'share_capital': format_decimal(row.share_capital), + 'capital_reserve': format_decimal(row.capital_reserve), + 'surplus_reserve': format_decimal(row.surplus_reserve), + 'undistributed_profit': format_decimal(row.undistributed_profit), + 'treasury_stock': format_decimal(row.treasury_stock), + 'other_comprehensive_income': format_decimal(row.other_comprehensive_income), + 'parent_company_equity': format_decimal(row.parent_company_equity), + 'minority_interests': format_decimal(row.minority_interests), + 'total': format_decimal(row.total_equity), + }, + + # 关键比率 + 'key_ratios': { + 'asset_liability_ratio': format_decimal(asset_liability_ratio_val), + 'current_ratio': format_decimal(current_ratio_val), + 'quick_ratio': format_decimal(quick_ratio_val), + } + } + data.append(period_data) + + return jsonify({ + 'success': True, + 'data': data + }) + + except Exception as e: + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + + +@app.route('/api/financial/income-statement/', methods=['GET']) +def get_income_statement(seccode): + """获取完整的利润表数据""" + try: + limit = request.args.get('limit', 12, type=int) + + query = text(""" + SELECT distinct ENDDATE, + STARTDATE, + DECLAREDATE, + -- 营业收入部分 + F006N as revenue, -- 营业收入 + F035N as total_operating_revenue, -- 营业总收入 + F051N as other_income, -- 其他收入 + + -- 营业成本部分 + F007N as cost, -- 营业成本 + F008N as taxes_and_surcharges, -- 税金及附加 + F009N as selling_expenses, -- 销售费用 + F010N as admin_expenses, -- 管理费用 + F056N as rd_expenses, -- 研发费用 + F012N as financial_expenses, -- 财务费用 + F062N as interest_expense, -- 利息费用 + F063N as interest_income, -- 利息收入 + F013N as asset_impairment_loss, -- 资产减值损失(营业总成本) + F057N as credit_impairment_loss, -- 信用减值损失(营业总成本) + F036N as total_operating_cost, -- 营业总成本 + + -- 其他收益 + F014N as fair_value_change_income, -- 公允价值变动净收益 + F015N as investment_income, -- 投资收益 + F016N as investment_income_from_associates, -- 对联营企业和合营企业的投资收益 + F037N as exchange_income, -- 汇兑收益 + F058N as net_exposure_hedging_income, -- 净敞口套期收益 + F059N as asset_disposal_income, -- 资产处置收益 + + -- 利润部分 + F018N as operating_profit, -- 营业利润 + F019N as subsidy_income, -- 补贴收入 + F020N as non_operating_income, -- 营业外收入 + F021N as non_operating_expenses, -- 营业外支出 + F022N as non_current_asset_disposal_loss, -- 非流动资产处置损失 + F024N as total_profit, -- 利润总额 + F025N as income_tax_expense, -- 所得税 + F027N as net_profit, -- 净利润 + F028N as parent_net_profit, -- 归属于母公司所有者的净利润 + F029N as minority_profit, -- 少数股东损益 + + -- 持续经营 + F060N as continuing_operations_net_profit, -- 持续经营净利润 + F061N as discontinued_operations_net_profit, -- 终止经营净利润 + + -- 每股收益 + F031N as basic_eps, -- 基本每股收益 + F032N as diluted_eps, -- 稀释每股收益 + + -- 综合收益 + F038N as other_comprehensive_income_after_tax, -- 其他综合收益的税后净额 + F039N as total_comprehensive_income, -- 综合收益总额 + F040N as parent_company_comprehensive_income, -- 归属于母公司的综合收益 + F041N as minority_comprehensive_income -- 归属于少数股东的综合收益 + FROM ea_profit + WHERE SECCODE = :seccode + and F002V = '071001' + ORDER BY ENDDATE DESC LIMIT :limit + """) + + result = engine.execute(query, seccode=seccode, limit=limit) + data = [] + + for row in result: + # 计算一些衍生指标 + gross_profit = (row.revenue - row.cost) if row.revenue and row.cost else None + gross_margin = (gross_profit / row.revenue * 100) if row.revenue and gross_profit else None + operating_margin = ( + row.operating_profit / row.revenue * 100) if row.revenue and row.operating_profit else None + net_margin = (row.net_profit / row.revenue * 100) if row.revenue and row.net_profit else None + + # 三费合计 + three_expenses = 0 + if row.selling_expenses: + three_expenses += row.selling_expenses + if row.admin_expenses: + three_expenses += row.admin_expenses + if row.financial_expenses: + three_expenses += row.financial_expenses + + # 四费合计(加研发) + four_expenses = three_expenses + if row.rd_expenses: + four_expenses += row.rd_expenses + + period_data = { + 'period': format_date(row.ENDDATE), + 'start_date': format_date(row.STARTDATE), + 'declare_date': format_date(row.DECLAREDATE), + 'report_type': get_report_type(row.ENDDATE), + + # 收入部分 + 'revenue': { + 'operating_revenue': format_decimal(row.revenue), + 'total_operating_revenue': format_decimal(row.total_operating_revenue), + 'other_income': format_decimal(row.other_income), + }, + + # 成本费用部分 + 'costs': { + 'operating_cost': format_decimal(row.cost), + 'taxes_and_surcharges': format_decimal(row.taxes_and_surcharges), + 'selling_expenses': format_decimal(row.selling_expenses), + 'admin_expenses': format_decimal(row.admin_expenses), + 'rd_expenses': format_decimal(row.rd_expenses), + 'financial_expenses': format_decimal(row.financial_expenses), + 'interest_expense': format_decimal(row.interest_expense), + 'interest_income': format_decimal(row.interest_income), + 'asset_impairment_loss': format_decimal(row.asset_impairment_loss), + 'credit_impairment_loss': format_decimal(row.credit_impairment_loss), + 'total_operating_cost': format_decimal(row.total_operating_cost), + 'three_expenses_total': format_decimal(three_expenses), + 'four_expenses_total': format_decimal(four_expenses), + }, + + # 其他收益 + 'other_gains': { + 'fair_value_change': format_decimal(row.fair_value_change_income), + 'investment_income': format_decimal(row.investment_income), + 'investment_income_from_associates': format_decimal(row.investment_income_from_associates), + 'exchange_income': format_decimal(row.exchange_income), + 'asset_disposal_income': format_decimal(row.asset_disposal_income), + }, + + # 利润 + 'profit': { + 'gross_profit': format_decimal(gross_profit), + 'operating_profit': format_decimal(row.operating_profit), + 'total_profit': format_decimal(row.total_profit), + 'net_profit': format_decimal(row.net_profit), + 'parent_net_profit': format_decimal(row.parent_net_profit), + 'minority_profit': format_decimal(row.minority_profit), + 'continuing_operations_net_profit': format_decimal(row.continuing_operations_net_profit), + 'discontinued_operations_net_profit': format_decimal(row.discontinued_operations_net_profit), + }, + + # 非经营项目 + 'non_operating': { + 'subsidy_income': format_decimal(row.subsidy_income), + 'non_operating_income': format_decimal(row.non_operating_income), + 'non_operating_expenses': format_decimal(row.non_operating_expenses), + }, + + # 每股收益 + 'per_share': { + 'basic_eps': format_decimal(row.basic_eps), + 'diluted_eps': format_decimal(row.diluted_eps), + }, + + # 综合收益 + 'comprehensive_income': { + 'other_comprehensive_income': format_decimal(row.other_comprehensive_income_after_tax), + 'total_comprehensive_income': format_decimal(row.total_comprehensive_income), + 'parent_comprehensive_income': format_decimal(row.parent_company_comprehensive_income), + 'minority_comprehensive_income': format_decimal(row.minority_comprehensive_income), + }, + + # 关键比率 + 'margins': { + 'gross_margin': format_decimal(gross_margin), + 'operating_margin': format_decimal(operating_margin), + 'net_margin': format_decimal(net_margin), + 'expense_ratio': format_decimal(four_expenses / row.revenue * 100) if row.revenue else None, + 'rd_ratio': format_decimal( + row.rd_expenses / row.revenue * 100) if row.revenue and row.rd_expenses else None, + } + } + data.append(period_data) + + return jsonify({ + 'success': True, + 'data': data + }) + + except Exception as e: + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + + +@app.route('/api/financial/cashflow/', methods=['GET']) +def get_cashflow(seccode): + """获取完整的现金流量表数据""" + try: + limit = request.args.get('limit', 12, type=int) + + query = text(""" + SELECT distinct ENDDATE, + STARTDATE, + DECLAREDATE, + -- 经营活动现金流 + F006N as cash_from_sales, -- 销售商品、提供劳务收到的现金 + F007N as tax_refunds, -- 收到的税费返还 + F008N as other_operating_cash_received, -- 收到其他与经营活动有关的现金 + F009N as total_operating_cash_inflow, -- 经营活动现金流入小计 + F010N as cash_paid_for_goods, -- 购买商品、接受劳务支付的现金 + F011N as cash_paid_to_employees, -- 支付给职工以及为职工支付的现金 + F012N as taxes_paid, -- 支付的各项税费 + F013N as other_operating_cash_paid, -- 支付其他与经营活动有关的现金 + F014N as total_operating_cash_outflow, -- 经营活动现金流出小计 + F015N as net_operating_cash_flow, -- 经营活动产生的现金流量净额 + + -- 投资活动现金流 + F016N as cash_from_investment_recovery, -- 收回投资收到的现金 + F017N as cash_from_investment_income, -- 取得投资收益收到的现金 + F018N as cash_from_asset_disposal, -- 处置固定资产、无形资产和其他长期资产收回的现金净额 + F019N as cash_from_subsidiary_disposal, -- 处置子公司及其他营业单位收到的现金净额 + F020N as other_investment_cash_received, -- 收到其他与投资活动有关的现金 + F021N as total_investment_cash_inflow, -- 投资活动现金流入小计 + F022N as cash_paid_for_assets, -- 购建固定资产、无形资产和其他长期资产支付的现金 + F023N as cash_paid_for_investments, -- 投资支付的现金 + F024N as cash_paid_for_subsidiaries, -- 取得子公司及其他营业单位支付的现金净额 + F025N as other_investment_cash_paid, -- 支付其他与投资活动有关的现金 + F026N as total_investment_cash_outflow, -- 投资活动现金流出小计 + F027N as net_investment_cash_flow, -- 投资活动产生的现金流量净额 + + -- 筹资活动现金流 + F028N as cash_from_capital, -- 吸收投资收到的现金 + F029N as cash_from_borrowings, -- 取得借款收到的现金 + F030N as other_financing_cash_received, -- 收到其他与筹资活动有关的现金 + F031N as total_financing_cash_inflow, -- 筹资活动现金流入小计 + F032N as cash_paid_for_debt, -- 偿还债务支付的现金 + F033N as cash_paid_for_distribution, -- 分配股利、利润或偿付利息支付的现金 + F034N as other_financing_cash_paid, -- 支付其他与筹资活动有关的现金 + F035N as total_financing_cash_outflow, -- 筹资活动现金流出小计 + F036N as net_financing_cash_flow, -- 筹资活动产生的现金流量净额 + + -- 汇率变动影响 + F037N as exchange_rate_effect, -- 汇率变动对现金及现金等价物的影响 + F038N as other_cash_effect, -- 其他原因对现金的影响 + + -- 现金净增加额 + F039N as net_cash_increase, -- 现金及现金等价物净增加额 + F040N as beginning_cash_balance, -- 期初现金及现金等价物余额 + F041N as ending_cash_balance, -- 期末现金及现金等价物余额 + + -- 补充资料部分 + F044N as net_profit, -- 净利润 + F045N as asset_impairment, -- 资产减值准备 + F096N as credit_impairment, -- 信用减值损失 + F046N as depreciation, -- 固定资产折旧、油气资产折耗、生产性生物资产折旧 + F097N as right_of_use_asset_depreciation, -- 使用权资产折旧/摊销 + F047N as intangible_amortization, -- 无形资产摊销 + F048N as long_term_expense_amortization, -- 长期待摊费用摊销 + F049N as loss_on_disposal, -- 处置固定资产、无形资产和其他长期资产的损失 + F050N as fixed_asset_scrap_loss, -- 固定资产报废损失 + F051N as fair_value_change_loss, -- 公允价值变动损失 + F052N as financial_expenses, -- 财务费用 + F053N as investment_loss, -- 投资损失 + F054N as deferred_tax_asset_decrease, -- 递延所得税资产减少 + F055N as deferred_tax_liability_increase, -- 递延所得税负债增加 + F056N as inventory_decrease, -- 存货的减少 + F057N as operating_receivables_decrease, -- 经营性应收项目的减少 + F058N as operating_payables_increase, -- 经营性应付项目的增加 + F059N as other, -- 其他 + F060N as net_operating_cash_flow_indirect, -- 经营活动产生的现金流量净额(间接法) + + -- 特殊行业科目(金融) + F072N as customer_deposit_increase, -- 客户存款和同业存放款项净增加额 + F073N as central_bank_borrowing_increase, -- 向中央银行借款净增加额 + F081N as interest_and_commission_received, -- 收取利息、手续费及佣金的现金 + F087N as interest_and_commission_paid -- 支付利息、手续费及佣金的现金 + FROM ea_cashflow + WHERE SECCODE = :seccode + and F002V = '071001' + ORDER BY ENDDATE DESC LIMIT :limit + """) + + result = engine.execute(query, seccode=seccode, limit=limit) + data = [] + + for row in result: + # 计算一些衍生指标 + free_cash_flow = None + if row.net_operating_cash_flow and row.cash_paid_for_assets: + free_cash_flow = row.net_operating_cash_flow - row.cash_paid_for_assets + + period_data = { + 'period': format_date(row.ENDDATE), + 'start_date': format_date(row.STARTDATE), + 'declare_date': format_date(row.DECLAREDATE), + 'report_type': get_report_type(row.ENDDATE), + + # 经营活动现金流 + 'operating_activities': { + 'inflow': { + 'cash_from_sales': format_decimal(row.cash_from_sales), + 'tax_refunds': format_decimal(row.tax_refunds), + 'other': format_decimal(row.other_operating_cash_received), + 'total': format_decimal(row.total_operating_cash_inflow), + }, + 'outflow': { + 'cash_for_goods': format_decimal(row.cash_paid_for_goods), + 'cash_for_employees': format_decimal(row.cash_paid_to_employees), + 'taxes_paid': format_decimal(row.taxes_paid), + 'other': format_decimal(row.other_operating_cash_paid), + 'total': format_decimal(row.total_operating_cash_outflow), + }, + 'net_flow': format_decimal(row.net_operating_cash_flow), + }, + + # 投资活动现金流 + 'investment_activities': { + 'inflow': { + 'investment_recovery': format_decimal(row.cash_from_investment_recovery), + 'investment_income': format_decimal(row.cash_from_investment_income), + 'asset_disposal': format_decimal(row.cash_from_asset_disposal), + 'subsidiary_disposal': format_decimal(row.cash_from_subsidiary_disposal), + 'other': format_decimal(row.other_investment_cash_received), + 'total': format_decimal(row.total_investment_cash_inflow), + }, + 'outflow': { + 'asset_purchase': format_decimal(row.cash_paid_for_assets), + 'investments': format_decimal(row.cash_paid_for_investments), + 'subsidiaries': format_decimal(row.cash_paid_for_subsidiaries), + 'other': format_decimal(row.other_investment_cash_paid), + 'total': format_decimal(row.total_investment_cash_outflow), + }, + 'net_flow': format_decimal(row.net_investment_cash_flow), + }, + + # 筹资活动现金流 + 'financing_activities': { + 'inflow': { + 'capital': format_decimal(row.cash_from_capital), + 'borrowings': format_decimal(row.cash_from_borrowings), + 'other': format_decimal(row.other_financing_cash_received), + 'total': format_decimal(row.total_financing_cash_inflow), + }, + 'outflow': { + 'debt_repayment': format_decimal(row.cash_paid_for_debt), + 'distribution': format_decimal(row.cash_paid_for_distribution), + 'other': format_decimal(row.other_financing_cash_paid), + 'total': format_decimal(row.total_financing_cash_outflow), + }, + 'net_flow': format_decimal(row.net_financing_cash_flow), + }, + + # 现金变动 + 'cash_changes': { + 'exchange_rate_effect': format_decimal(row.exchange_rate_effect), + 'other_effect': format_decimal(row.other_cash_effect), + 'net_increase': format_decimal(row.net_cash_increase), + 'beginning_balance': format_decimal(row.beginning_cash_balance), + 'ending_balance': format_decimal(row.ending_cash_balance), + }, + + # 补充资料(间接法) + 'indirect_method': { + 'net_profit': format_decimal(row.net_profit), + 'adjustments': { + 'asset_impairment': format_decimal(row.asset_impairment), + 'credit_impairment': format_decimal(row.credit_impairment), + 'depreciation': format_decimal(row.depreciation), + 'intangible_amortization': format_decimal(row.intangible_amortization), + 'financial_expenses': format_decimal(row.financial_expenses), + 'investment_loss': format_decimal(row.investment_loss), + 'inventory_decrease': format_decimal(row.inventory_decrease), + 'receivables_decrease': format_decimal(row.operating_receivables_decrease), + 'payables_increase': format_decimal(row.operating_payables_increase), + }, + 'net_operating_cash_flow': format_decimal(row.net_operating_cash_flow_indirect), + }, + + # 关键指标 + 'key_metrics': { + 'free_cash_flow': format_decimal(free_cash_flow), + 'cash_flow_to_profit_ratio': format_decimal( + row.net_operating_cash_flow / row.net_profit) if row.net_profit and row.net_operating_cash_flow else None, + 'capex': format_decimal(row.cash_paid_for_assets), + } + } + data.append(period_data) + + return jsonify({ + 'success': True, + 'data': data + }) + + except Exception as e: + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + + +@app.route('/api/financial/financial-metrics/', methods=['GET']) +def get_financial_metrics(seccode): + """获取完整的财务指标数据""" + try: + limit = request.args.get('limit', 12, type=int) + + query = text(""" + SELECT distinct ENDDATE, + STARTDATE, + -- 每股指标 + F003N as eps, -- 每股收益 + F004N as basic_eps, -- 基本每股收益 + F005N as diluted_eps, -- 稀释每股收益 + F006N as deducted_eps, -- 扣除非经常性损益每股收益 + F007N as undistributed_profit_ps, -- 每股未分配利润 + F008N as bvps, -- 每股净资产 + F009N as adjusted_bvps, -- 调整后每股净资产 + F010N as capital_reserve_ps, -- 每股资本公积金 + F059N as cash_flow_ps, -- 每股现金流量 + F060N as operating_cash_flow_ps, -- 每股经营现金流量 + + -- 盈利能力指标 + F011N as operating_profit_margin, -- 营业利润率 + F012N as tax_rate, -- 营业税金率 + F013N as cost_ratio, -- 营业成本率 + F014N as roe, -- 净资产收益率 + F066N as roe_deducted, -- 净资产收益率(扣除非经常性损益) + F067N as roe_weighted, -- 净资产收益率-加权 + F068N as roe_weighted_deducted, -- 净资产收益率-加权(扣除非经常性损益) + F015N as investment_return, -- 投资收益率 + F016N as roa, -- 总资产报酬率 + F017N as net_profit_margin, -- 净利润率 + F078N as gross_margin, -- 毛利率 + F020N as cost_profit_ratio, -- 成本费用利润率 + + -- 费用率指标 + F018N as admin_expense_ratio, -- 管理费用率 + F019N as financial_expense_ratio, -- 财务费用率 + F021N as three_expense_ratio, -- 三费比重 + F091N as selling_expense, -- 销售费用 + F092N as admin_expense, -- 管理费用 + F093N as financial_expense, -- 财务费用 + F094N as three_expense_total, -- 三费合计 + F130N as rd_expense, -- 研发费用 + F131N as rd_expense_ratio, -- 研发费用率 + F132N as selling_expense_ratio, -- 销售费用率 + F133N as four_expense_ratio, -- 四费费用率 + + -- 运营能力指标 + F022N as receivable_turnover, -- 应收账款周转率 + F023N as inventory_turnover, -- 存货周转率 + F024N as working_capital_turnover, -- 运营资金周转率 + F025N as total_asset_turnover, -- 总资产周转率 + F026N as fixed_asset_turnover, -- 固定资产周转率 + F027N as receivable_days, -- 应收账款周转天数 + F028N as inventory_days, -- 存货周转天数 + F029N as current_asset_turnover, -- 流动资产周转率 + F030N as current_asset_days, -- 流动资产周转天数 + F031N as total_asset_days, -- 总资产周转天数 + F032N as equity_turnover, -- 股东权益周转率 + + -- 偿债能力指标 + F041N as asset_liability_ratio, -- 资产负债率 + F042N as current_ratio, -- 流动比率 + F043N as quick_ratio, -- 速动比率 + F044N as cash_ratio, -- 现金比率 + F045N as interest_coverage, -- 利息保障倍数 + F049N as conservative_quick_ratio, -- 保守速动比率 + F050N as cash_to_maturity_debt_ratio, -- 现金到期债务比率 + F051N as tangible_asset_debt_ratio, -- 有形资产净值债务率 + + -- 成长能力指标 + F052N as revenue_growth, -- 营业收入增长率 + F053N as net_profit_growth, -- 净利润增长率 + F054N as equity_growth, -- 净资产增长率 + F055N as fixed_asset_growth, -- 固定资产增长率 + F056N as total_asset_growth, -- 总资产增长率 + F057N as investment_income_growth, -- 投资收益增长率 + F058N as operating_profit_growth, -- 营业利润增长率 + F141N as deducted_profit_growth, -- 扣除非经常性损益后的净利润同比变化率 + F142N as parent_profit_growth, -- 归属于母公司所有者的净利润同比变化率 + F143N as operating_cash_flow_growth, -- 经营活动产生的现金流净额同比变化率 + + -- 现金流量指标 + F061N as operating_cash_to_short_debt, -- 经营净现金比率(短期债务) + F062N as operating_cash_to_total_debt, -- 经营净现金比率(全部债务) + F063N as operating_cash_to_profit_ratio, -- 经营活动现金净流量与净利润比率 + F064N as cash_revenue_ratio, -- 营业收入现金含量 + F065N as cash_recovery_rate, -- 全部资产现金回收率 + F082N as cash_to_profit_ratio, -- 净利含金量 + + -- 财务结构指标 + F033N as current_asset_ratio, -- 流动资产比率 + F034N as cash_ratio_structure, -- 货币资金比率 + F036N as inventory_ratio, -- 存货比率 + F037N as fixed_asset_ratio, -- 固定资产比率 + F038N as liability_structure_ratio, -- 负债结构比 + F039N as equity_ratio, -- 产权比率 + F040N as net_asset_ratio, -- 净资产比率 + F046N as working_capital, -- 营运资金 + F047N as non_current_liability_ratio, -- 非流动负债比率 + F048N as current_liability_ratio, -- 流动负债比率 + + -- 非经常性损益 + F076N as deducted_net_profit, -- 扣除非经常性损益后的净利润 + F077N as non_recurring_items, -- 非经常性损益合计 + F083N as non_recurring_ratio, -- 非经常性损益占比 + + -- 综合指标 + F085N as ebit, -- 基本获利能力(EBIT) + F086N as receivable_to_asset_ratio, -- 应收账款占比 + F087N as inventory_to_asset_ratio -- 存货占比 + FROM ea_financialindex + WHERE SECCODE = :seccode + ORDER BY ENDDATE DESC LIMIT :limit + """) + + result = engine.execute(query, seccode=seccode, limit=limit) + data = [] + + for row in result: + period_data = { + 'period': format_date(row.ENDDATE), + 'start_date': format_date(row.STARTDATE), + 'report_type': get_report_type(row.ENDDATE), + + # 每股指标 + 'per_share_metrics': { + 'eps': format_decimal(row.eps), + 'basic_eps': format_decimal(row.basic_eps), + 'diluted_eps': format_decimal(row.diluted_eps), + 'deducted_eps': format_decimal(row.deducted_eps), + 'bvps': format_decimal(row.bvps), + 'adjusted_bvps': format_decimal(row.adjusted_bvps), + 'undistributed_profit_ps': format_decimal(row.undistributed_profit_ps), + 'capital_reserve_ps': format_decimal(row.capital_reserve_ps), + 'cash_flow_ps': format_decimal(row.cash_flow_ps), + 'operating_cash_flow_ps': format_decimal(row.operating_cash_flow_ps), + }, + + # 盈利能力 + 'profitability': { + 'roe': format_decimal(row.roe), + 'roe_deducted': format_decimal(row.roe_deducted), + 'roe_weighted': format_decimal(row.roe_weighted), + 'roa': format_decimal(row.roa), + 'gross_margin': format_decimal(row.gross_margin), + 'net_profit_margin': format_decimal(row.net_profit_margin), + 'operating_profit_margin': format_decimal(row.operating_profit_margin), + 'cost_profit_ratio': format_decimal(row.cost_profit_ratio), + 'ebit': format_decimal(row.ebit), + }, + + # 费用率 + 'expense_ratios': { + 'selling_expense_ratio': format_decimal(row.selling_expense_ratio), + 'admin_expense_ratio': format_decimal(row.admin_expense_ratio), + 'financial_expense_ratio': format_decimal(row.financial_expense_ratio), + 'rd_expense_ratio': format_decimal(row.rd_expense_ratio), + 'three_expense_ratio': format_decimal(row.three_expense_ratio), + 'four_expense_ratio': format_decimal(row.four_expense_ratio), + }, + + # 运营能力 + 'operational_efficiency': { + 'receivable_turnover': format_decimal(row.receivable_turnover), + 'receivable_days': format_decimal(row.receivable_days), + 'inventory_turnover': format_decimal(row.inventory_turnover), + 'inventory_days': format_decimal(row.inventory_days), + 'total_asset_turnover': format_decimal(row.total_asset_turnover), + 'total_asset_days': format_decimal(row.total_asset_days), + 'fixed_asset_turnover': format_decimal(row.fixed_asset_turnover), + 'current_asset_turnover': format_decimal(row.current_asset_turnover), + 'working_capital_turnover': format_decimal(row.working_capital_turnover), + }, + + # 偿债能力 + 'solvency': { + 'current_ratio': format_decimal(row.current_ratio), + 'quick_ratio': format_decimal(row.quick_ratio), + 'cash_ratio': format_decimal(row.cash_ratio), + 'conservative_quick_ratio': format_decimal(row.conservative_quick_ratio), + 'asset_liability_ratio': format_decimal(row.asset_liability_ratio), + 'interest_coverage': format_decimal(row.interest_coverage), + 'cash_to_maturity_debt_ratio': format_decimal(row.cash_to_maturity_debt_ratio), + 'tangible_asset_debt_ratio': format_decimal(row.tangible_asset_debt_ratio), + }, + + # 成长能力 + 'growth': { + 'revenue_growth': format_decimal(row.revenue_growth), + 'net_profit_growth': format_decimal(row.net_profit_growth), + 'deducted_profit_growth': format_decimal(row.deducted_profit_growth), + 'parent_profit_growth': format_decimal(row.parent_profit_growth), + 'equity_growth': format_decimal(row.equity_growth), + 'total_asset_growth': format_decimal(row.total_asset_growth), + 'fixed_asset_growth': format_decimal(row.fixed_asset_growth), + 'operating_profit_growth': format_decimal(row.operating_profit_growth), + 'operating_cash_flow_growth': format_decimal(row.operating_cash_flow_growth), + }, + + # 现金流量 + 'cash_flow_quality': { + 'operating_cash_to_profit_ratio': format_decimal(row.operating_cash_to_profit_ratio), + 'cash_to_profit_ratio': format_decimal(row.cash_to_profit_ratio), + 'cash_revenue_ratio': format_decimal(row.cash_revenue_ratio), + 'cash_recovery_rate': format_decimal(row.cash_recovery_rate), + 'operating_cash_to_short_debt': format_decimal(row.operating_cash_to_short_debt), + 'operating_cash_to_total_debt': format_decimal(row.operating_cash_to_total_debt), + }, + + # 财务结构 + 'financial_structure': { + 'current_asset_ratio': format_decimal(row.current_asset_ratio), + 'fixed_asset_ratio': format_decimal(row.fixed_asset_ratio), + 'inventory_ratio': format_decimal(row.inventory_ratio), + 'receivable_to_asset_ratio': format_decimal(row.receivable_to_asset_ratio), + 'current_liability_ratio': format_decimal(row.current_liability_ratio), + 'non_current_liability_ratio': format_decimal(row.non_current_liability_ratio), + 'equity_ratio': format_decimal(row.equity_ratio), + }, + + # 非经常性损益 + 'non_recurring': { + 'deducted_net_profit': format_decimal(row.deducted_net_profit), + 'non_recurring_items': format_decimal(row.non_recurring_items), + 'non_recurring_ratio': format_decimal(row.non_recurring_ratio), + } + } + data.append(period_data) + + return jsonify({ + 'success': True, + 'data': data + }) + + except Exception as e: + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + + +@app.route('/api/financial/main-business/', methods=['GET']) +def get_main_business(seccode): + """获取主营业务构成数据(包括产品和行业分类)""" + try: + limit = request.args.get('periods', 4, type=int) # 获取最近几期的数据 + + # 获取最近的报告期 + period_query = text(""" + SELECT DISTINCT ENDDATE + FROM ea_mainproduct + WHERE SECCODE = :seccode + ORDER BY ENDDATE DESC LIMIT :limit + """) + + periods = engine.execute(period_query, seccode=seccode, limit=limit).fetchall() + + # 产品分类数据 + product_data = [] + for period in periods: + query = text(""" + SELECT distinct ENDDATE, + F002V as category, + F003V as content, + F005N as revenue, + F006N as cost, + F007N as profit + FROM ea_mainproduct + WHERE SECCODE = :seccode + AND ENDDATE = :enddate + ORDER BY F005N DESC + """) + + result = engine.execute(query, seccode=seccode, enddate=period[0]) + # Convert result to list to allow multiple iterations + rows = list(result) + + period_products = [] + total_revenue = 0 + for row in rows: + if row.revenue: + total_revenue += row.revenue + + for row in rows: + product = { + 'category': row.category, + 'content': row.content, + 'revenue': format_decimal(row.revenue), + 'cost': format_decimal(row.cost), + 'profit': format_decimal(row.profit), + 'profit_margin': format_decimal( + (row.profit / row.revenue * 100) if row.revenue and row.profit else None), + 'revenue_ratio': format_decimal( + (row.revenue / total_revenue * 100) if total_revenue and row.revenue else None) + } + period_products.append(product) + + if period_products: + product_data.append({ + 'period': format_date(period[0]), + 'report_type': get_report_type(period[0]), + 'total_revenue': format_decimal(total_revenue), + 'products': period_products + }) + + # 行业分类数据(从ea_mainind表) + industry_data = [] + for period in periods: + query = text(""" + SELECT distinct ENDDATE, + F002V as business_content, + F007N as main_revenue, + F008N as main_cost, + F009N as main_profit, + F010N as gross_margin, + F012N as revenue_ratio + FROM ea_mainind + WHERE SECCODE = :seccode + AND ENDDATE = :enddate + ORDER BY F007N DESC + """) + + result = engine.execute(query, seccode=seccode, enddate=period[0]) + # Convert result to list to allow multiple iterations + rows = list(result) + + period_industries = [] + for row in rows: + industry = { + 'content': row.business_content, + 'revenue': format_decimal(row.main_revenue), + 'cost': format_decimal(row.main_cost), + 'profit': format_decimal(row.main_profit), + 'gross_margin': format_decimal(row.gross_margin), + 'revenue_ratio': format_decimal(row.revenue_ratio) + } + period_industries.append(industry) + + if period_industries: + industry_data.append({ + 'period': format_date(period[0]), + 'report_type': get_report_type(period[0]), + 'industries': period_industries + }) + + return jsonify({ + 'success': True, + 'data': { + 'product_classification': product_data, + 'industry_classification': industry_data + } + }) + + except Exception as e: + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + + +@app.route('/api/financial/forecast/', methods=['GET']) +def get_forecast(seccode): + """获取业绩预告和预披露时间""" + try: + # 获取业绩预告 + forecast_query = text(""" + SELECT distinct DECLAREDATE, + F001D as report_date, + F002V as forecast_type_code, + F003V as forecast_type, + F004V as content, + F005V as reason, + F006C as latest_flag, + F007N as profit_lower, + F008N as profit_upper, + F009N as change_lower, + F010N as change_upper, + UPDATE_DATE + FROM ea_forecast + WHERE SECCODE = :seccode + ORDER BY F001D DESC, UPDATE_DATE DESC LIMIT 10 + """) + + forecast_result = engine.execute(forecast_query, seccode=seccode) + forecast_data = [] + + for row in forecast_result: + forecast = { + 'declare_date': format_date(row.DECLAREDATE), + 'report_date': format_date(row.report_date), + 'report_type': get_report_type(row.report_date), + 'forecast_type': row.forecast_type, + 'forecast_type_code': row.forecast_type_code, + 'content': row.content, + 'reason': row.reason, + 'is_latest': row.latest_flag == 'T', + 'profit_range': { + 'lower': format_decimal(row.profit_lower), + 'upper': format_decimal(row.profit_upper), + }, + 'change_range': { + 'lower': format_decimal(row.change_lower), + 'upper': format_decimal(row.change_upper), + }, + 'update_date': format_date(row.UPDATE_DATE) + } + forecast_data.append(forecast) + + # 获取预披露时间 + pretime_query = text(""" + SELECT distinct F001D as report_period, + F002D as scheduled_date, + F003D as change_date_1, + F004D as change_date_2, + F005D as change_date_3, + F006D as actual_date, + F007D as change_date_4, + F008D as change_date_5, + UPDATE_DATE + FROM ea_pretime + WHERE SECCODE = :seccode + ORDER BY F001D DESC LIMIT 8 + """) + + pretime_result = engine.execute(pretime_query, seccode=seccode) + pretime_data = [] + + for row in pretime_result: + # 收集所有变更日期 + change_dates = [] + for date in [row.change_date_1, row.change_date_2, row.change_date_3, + row.change_date_4, row.change_date_5]: + if date: + change_dates.append(format_date(date)) + + pretime = { + 'report_period': format_date(row.report_period), + 'report_type': get_report_type(row.report_period), + 'scheduled_date': format_date(row.scheduled_date), + 'actual_date': format_date(row.actual_date), + 'change_dates': change_dates, + 'update_date': format_date(row.UPDATE_DATE), + 'status': 'completed' if row.actual_date else 'pending' + } + pretime_data.append(pretime) + + return jsonify({ + 'success': True, + 'data': { + 'forecasts': forecast_data, + 'disclosure_schedule': pretime_data + } + }) + + except Exception as e: + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + + +@app.route('/api/financial/industry-rank/', methods=['GET']) +def get_industry_rank(seccode): + """获取行业排名数据""" + try: + limit = request.args.get('limit', 4, type=int) + + query = text(""" + SELECT distinct F001V as industry_level, + F002V as level_description, + F003D as report_date, + INDNAME as industry_name, + -- 每股收益 + F004N as eps, + F005N as eps_industry_avg, + F006N as eps_rank, + -- 扣除后每股收益 + F007N as deducted_eps, + F008N as deducted_eps_industry_avg, + F009N as deducted_eps_rank, + -- 每股净资产 + F010N as bvps, + F011N as bvps_industry_avg, + F012N as bvps_rank, + -- 净资产收益率 + F013N as roe, + F014N as roe_industry_avg, + F015N as roe_rank, + -- 每股未分配利润 + F016N as undistributed_profit_ps, + F017N as undistributed_profit_ps_industry_avg, + F018N as undistributed_profit_ps_rank, + -- 每股经营现金流量 + F019N as operating_cash_flow_ps, + F020N as operating_cash_flow_ps_industry_avg, + F021N as operating_cash_flow_ps_rank, + -- 营业收入增长率 + F022N as revenue_growth, + F023N as revenue_growth_industry_avg, + F024N as revenue_growth_rank, + -- 净利润增长率 + F025N as profit_growth, + F026N as profit_growth_industry_avg, + F027N as profit_growth_rank, + -- 营业利润率 + F028N as operating_margin, + F029N as operating_margin_industry_avg, + F030N as operating_margin_rank, + -- 资产负债率 + F031N as debt_ratio, + F032N as debt_ratio_industry_avg, + F033N as debt_ratio_rank, + -- 应收账款周转率 + F034N as receivable_turnover, + F035N as receivable_turnover_industry_avg, + F036N as receivable_turnover_rank, + UPDATE_DATE + FROM ea_finindexrank + WHERE SECCODE = :seccode + ORDER BY F003D DESC, F001V ASC LIMIT :limit_total + """) + + # 获取多个报告期的数据 + result = engine.execute(query, seccode=seccode, limit_total=limit * 4) + + # 按报告期和行业级别组织数据 + data_by_period = {} + for row in result: + period = format_date(row.report_date) + if period not in data_by_period: + data_by_period[period] = [] + + rank_data = { + 'industry_level': row.industry_level, + 'level_description': row.level_description, + 'industry_name': row.industry_name, + 'metrics': { + 'eps': { + 'value': format_decimal(row.eps), + 'industry_avg': format_decimal(row.eps_industry_avg), + 'rank': int(row.eps_rank) if row.eps_rank else None + }, + 'deducted_eps': { + 'value': format_decimal(row.deducted_eps), + 'industry_avg': format_decimal(row.deducted_eps_industry_avg), + 'rank': int(row.deducted_eps_rank) if row.deducted_eps_rank else None + }, + 'bvps': { + 'value': format_decimal(row.bvps), + 'industry_avg': format_decimal(row.bvps_industry_avg), + 'rank': int(row.bvps_rank) if row.bvps_rank else None + }, + 'roe': { + 'value': format_decimal(row.roe), + 'industry_avg': format_decimal(row.roe_industry_avg), + 'rank': int(row.roe_rank) if row.roe_rank else None + }, + 'operating_cash_flow_ps': { + 'value': format_decimal(row.operating_cash_flow_ps), + 'industry_avg': format_decimal(row.operating_cash_flow_ps_industry_avg), + 'rank': int(row.operating_cash_flow_ps_rank) if row.operating_cash_flow_ps_rank else None + }, + 'revenue_growth': { + 'value': format_decimal(row.revenue_growth), + 'industry_avg': format_decimal(row.revenue_growth_industry_avg), + 'rank': int(row.revenue_growth_rank) if row.revenue_growth_rank else None + }, + 'profit_growth': { + 'value': format_decimal(row.profit_growth), + 'industry_avg': format_decimal(row.profit_growth_industry_avg), + 'rank': int(row.profit_growth_rank) if row.profit_growth_rank else None + }, + 'operating_margin': { + 'value': format_decimal(row.operating_margin), + 'industry_avg': format_decimal(row.operating_margin_industry_avg), + 'rank': int(row.operating_margin_rank) if row.operating_margin_rank else None + }, + 'debt_ratio': { + 'value': format_decimal(row.debt_ratio), + 'industry_avg': format_decimal(row.debt_ratio_industry_avg), + 'rank': int(row.debt_ratio_rank) if row.debt_ratio_rank else None + }, + 'receivable_turnover': { + 'value': format_decimal(row.receivable_turnover), + 'industry_avg': format_decimal(row.receivable_turnover_industry_avg), + 'rank': int(row.receivable_turnover_rank) if row.receivable_turnover_rank else None + } + } + } + data_by_period[period].append(rank_data) + + # 转换为列表格式 + data = [] + for period, ranks in data_by_period.items(): + data.append({ + 'period': period, + 'report_type': get_report_type(period), + 'rankings': ranks + }) + + return jsonify({ + 'success': True, + 'data': data + }) + + except Exception as e: + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + + +@app.route('/api/financial/comparison/', methods=['GET']) +def get_period_comparison(seccode): + """获取不同报告期的对比数据""" + try: + periods = request.args.get('periods', 8, type=int) + + # 获取多期财务数据进行对比 + query = text(""" + SELECT distinct fi.ENDDATE, + fi.F089N as revenue, + fi.F101N as net_profit, + fi.F102N as parent_net_profit, + fi.F078N as gross_margin, + fi.F017N as net_margin, + fi.F014N as roe, + fi.F016N as roa, + fi.F052N as revenue_growth, + fi.F053N as profit_growth, + fi.F003N as eps, + fi.F060N as operating_cash_flow_ps, + fi.F042N as current_ratio, + fi.F041N as debt_ratio, + fi.F105N as operating_cash_flow, + fi.F118N as total_assets, + fi.F121N as total_liabilities, + fi.F128N as total_equity + FROM ea_financialindex fi + WHERE fi.SECCODE = :seccode + ORDER BY fi.ENDDATE DESC LIMIT :periods + """) + + result = engine.execute(query, seccode=seccode, periods=periods) + + data = [] + for row in result: + period_data = { + 'period': format_date(row.ENDDATE), + 'report_type': get_report_type(row.ENDDATE), + 'performance': { + 'revenue': format_decimal(row.revenue), + 'net_profit': format_decimal(row.net_profit), + 'parent_net_profit': format_decimal(row.parent_net_profit), + 'operating_cash_flow': format_decimal(row.operating_cash_flow), + }, + 'profitability': { + 'gross_margin': format_decimal(row.gross_margin), + 'net_margin': format_decimal(row.net_margin), + 'roe': format_decimal(row.roe), + 'roa': format_decimal(row.roa), + }, + 'growth': { + 'revenue_growth': format_decimal(row.revenue_growth), + 'profit_growth': format_decimal(row.profit_growth), + }, + 'per_share': { + 'eps': format_decimal(row.eps), + 'operating_cash_flow_ps': format_decimal(row.operating_cash_flow_ps), + }, + 'financial_health': { + 'current_ratio': format_decimal(row.current_ratio), + 'debt_ratio': format_decimal(row.debt_ratio), + 'total_assets': format_decimal(row.total_assets), + 'total_liabilities': format_decimal(row.total_liabilities), + 'total_equity': format_decimal(row.total_equity), + } + } + data.append(period_data) + + # 计算同比和环比变化 + for i in range(len(data)): + if i > 0: # 环比 + data[i]['qoq_changes'] = { + 'revenue': calculate_change(data[i]['performance']['revenue'], + data[i - 1]['performance']['revenue']), + 'net_profit': calculate_change(data[i]['performance']['net_profit'], + data[i - 1]['performance']['net_profit']), + } + + # 同比(找到去年同期) + current_period = data[i]['period'] + yoy_period = get_yoy_period(current_period) + for j in range(len(data)): + if data[j]['period'] == yoy_period: + data[i]['yoy_changes'] = { + 'revenue': calculate_change(data[i]['performance']['revenue'], + data[j]['performance']['revenue']), + 'net_profit': calculate_change(data[i]['performance']['net_profit'], + data[j]['performance']['net_profit']), + } + break + + return jsonify({ + 'success': True, + 'data': data + }) + + except Exception as e: + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + + +# 辅助函数 +def calculate_change(current, previous): + """计算变化率""" + if previous and current: + return format_decimal((current - previous) / abs(previous) * 100) + return None + + +def get_yoy_period(date_str): + """获取去年同期""" + if not date_str: + return None + try: + date = datetime.strptime(date_str, '%Y-%m-%d') + yoy_date = date.replace(year=date.year - 1) + return yoy_date.strftime('%Y-%m-%d') + except: + return None + + +@app.route('/api/market/trade/', methods=['GET']) +def get_trade_data(seccode): + """获取股票交易数据(日K线)""" + try: + days = request.args.get('days', 60, type=int) + end_date = request.args.get('end_date', datetime.now().strftime('%Y-%m-%d')) + + query = text(""" + SELECT TRADEDATE, + SECNAME, + F002N as pre_close, + F003N as open, + F004N as volume, + F005N as high, + F006N as low, + F007N as close, + F008N as trades_count, + F009N as change_amount, + F010N as change_percent, + F011N as amount, + F012N as turnover_rate, + F013N as amplitude, + F020N as total_shares, + F021N as float_shares, + F026N as pe_ratio + FROM ea_trade + WHERE SECCODE = :seccode + AND TRADEDATE <= :end_date + ORDER BY TRADEDATE DESC + LIMIT :days + """) + + result = engine.execute(query, seccode=seccode, end_date=end_date, days=days) + + data = [] + for row in result: + data.append({ + 'date': format_date(row.TRADEDATE), + 'stock_name': row.SECNAME, + 'open': format_decimal(row.open), + 'high': format_decimal(row.high), + 'low': format_decimal(row.low), + 'close': format_decimal(row.close), + 'pre_close': format_decimal(row.pre_close), + 'volume': format_decimal(row.volume), + 'amount': format_decimal(row.amount), + 'change_amount': format_decimal(row.change_amount), + 'change_percent': format_decimal(row.change_percent), + 'turnover_rate': format_decimal(row.turnover_rate), + 'amplitude': format_decimal(row.amplitude), + 'trades_count': format_decimal(row.trades_count), + 'pe_ratio': format_decimal(row.pe_ratio), + 'total_shares': format_decimal(row.total_shares), + 'float_shares': format_decimal(row.float_shares), + }) + + # 倒序,让最早的日期在前 + data.reverse() + + # 计算统计数据 + if data: + prices = [d['close'] for d in data if d['close']] + stats = { + 'highest': max(prices) if prices else None, + 'lowest': min(prices) if prices else None, + 'average': sum(prices) / len(prices) if prices else None, + 'latest_price': data[-1]['close'] if data else None, + 'total_volume': sum([d['volume'] for d in data if d['volume']]) if data else None, + 'total_amount': sum([d['amount'] for d in data if d['amount']]) if data else None, + } + else: + stats = {} + + return jsonify({ + 'success': True, + 'data': data, + 'stats': stats + }) + + except Exception as e: + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + + +@app.route('/api/market/funding/', methods=['GET']) +def get_funding_data(seccode): + """获取融资融券数据""" + try: + days = request.args.get('days', 30, type=int) + + query = text(""" + SELECT TRADEDATE, + SECNAME, + F001N as financing_balance, + F002N as financing_buy, + F003N as financing_repay, + F004N as securities_balance, + F006N as securities_sell, + F007N as securities_repay, + F008N as securities_balance_amount, + F009N as total_balance + FROM ea_funding + WHERE SECCODE = :seccode + ORDER BY TRADEDATE DESC LIMIT :days + """) + + result = engine.execute(query, seccode=seccode, days=days) + + data = [] + for row in result: + data.append({ + 'date': format_date(row.TRADEDATE), + 'stock_name': row.SECNAME, + 'financing': { + 'balance': format_decimal(row.financing_balance), + 'buy': format_decimal(row.financing_buy), + 'repay': format_decimal(row.financing_repay), + 'net': format_decimal( + row.financing_buy - row.financing_repay) if row.financing_buy and row.financing_repay else None + }, + 'securities': { + 'balance': format_decimal(row.securities_balance), + 'sell': format_decimal(row.securities_sell), + 'repay': format_decimal(row.securities_repay), + 'balance_amount': format_decimal(row.securities_balance_amount) + }, + 'total_balance': format_decimal(row.total_balance) + }) + + data.reverse() + + return jsonify({ + 'success': True, + 'data': data + }) + + except Exception as e: + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + + +@app.route('/api/market/bigdeal/', methods=['GET']) +def get_bigdeal_data(seccode): + """获取大宗交易数据""" + try: + days = request.args.get('days', 30, type=int) + + query = text(""" + SELECT TRADEDATE, + SECNAME, + F001V as exchange, + F002V as buyer_dept, + F003V as seller_dept, + F004N as price, + F005N as volume, + F006N as amount, + F007N as seq_no + FROM ea_bigdeal + WHERE SECCODE = :seccode + ORDER BY TRADEDATE DESC, F007N LIMIT :days + """) + + result = engine.execute(query, seccode=seccode, days=days) + + data = [] + for row in result: + data.append({ + 'date': format_date(row.TRADEDATE), + 'stock_name': row.SECNAME, + 'exchange': row.exchange, + 'buyer_dept': row.buyer_dept, + 'seller_dept': row.seller_dept, + 'price': format_decimal(row.price), + 'volume': format_decimal(row.volume), + 'amount': format_decimal(row.amount), + 'seq_no': int(row.seq_no) if row.seq_no else None + }) + + # 按日期分组统计 + daily_stats = {} + for item in data: + date = item['date'] + if date not in daily_stats: + daily_stats[date] = { + 'date': date, + 'count': 0, + 'total_volume': 0, + 'total_amount': 0, + 'avg_price': 0, + 'deals': [] + } + daily_stats[date]['count'] += 1 + daily_stats[date]['total_volume'] += item['volume'] or 0 + daily_stats[date]['total_amount'] += item['amount'] or 0 + daily_stats[date]['deals'].append(item) + + # 计算平均价格 + for date in daily_stats: + if daily_stats[date]['total_volume'] > 0: + daily_stats[date]['avg_price'] = daily_stats[date]['total_amount'] / daily_stats[date]['total_volume'] + + return jsonify({ + 'success': True, + 'data': data, + 'daily_stats': list(daily_stats.values()) + }) + + except Exception as e: + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + + +@app.route('/api/market/unusual/', methods=['GET']) +def get_unusual_data(seccode): + """获取龙虎榜数据""" + try: + days = request.args.get('days', 30, type=int) + + query = text(""" + SELECT TRADEDATE, + SECNAME, + F001V as info_type_code, + F002V as info_type, + F003C as trade_type, + F004N as rank_no, + F005V as dept_name, + F006N as buy_amount, + F007N as sell_amount, + F008N as net_amount + FROM ea_unusual + WHERE SECCODE = :seccode + ORDER BY TRADEDATE DESC, F004N LIMIT 100 + """) + + result = engine.execute(query, seccode=seccode) + + data = [] + for row in result: + data.append({ + 'date': format_date(row.TRADEDATE), + 'stock_name': row.SECNAME, + 'info_type': row.info_type, + 'info_type_code': row.info_type_code, + 'trade_type': 'buy' if row.trade_type == 'B' else 'sell' if row.trade_type == 'S' else 'unknown', + 'rank': int(row.rank_no) if row.rank_no else None, + 'dept_name': row.dept_name, + 'buy_amount': format_decimal(row.buy_amount), + 'sell_amount': format_decimal(row.sell_amount), + 'net_amount': format_decimal(row.net_amount) + }) + + # 按日期分组 + grouped_data = {} + for item in data: + date = item['date'] + if date not in grouped_data: + grouped_data[date] = { + 'date': date, + 'info_types': set(), + 'buyers': [], + 'sellers': [], + 'total_buy': 0, + 'total_sell': 0, + 'net_amount': 0 + } + + grouped_data[date]['info_types'].add(item['info_type']) + + if item['trade_type'] == 'buy': + grouped_data[date]['buyers'].append(item) + grouped_data[date]['total_buy'] += item['buy_amount'] or 0 + elif item['trade_type'] == 'sell': + grouped_data[date]['sellers'].append(item) + grouped_data[date]['total_sell'] += item['sell_amount'] or 0 + + grouped_data[date]['net_amount'] = grouped_data[date]['total_buy'] - grouped_data[date]['total_sell'] + + # 转换set为list + for date in grouped_data: + grouped_data[date]['info_types'] = list(grouped_data[date]['info_types']) + + return jsonify({ + 'success': True, + 'data': data, + 'grouped_data': list(grouped_data.values()) + }) + + except Exception as e: + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + + +@app.route('/api/market/pledge/', methods=['GET']) +def get_pledge_data(seccode): + """获取股权质押数据""" + try: + query = text(""" + SELECT ENDDATE, + STARTDATE, + SECNAME, + F001N as unrestricted_pledge, + F002N as restricted_pledge, + F003N as total_shares_a, + F004N as pledge_count, + F005N as pledge_ratio + FROM ea_pledgeratio + WHERE SECCODE = :seccode + ORDER BY ENDDATE DESC LIMIT 12 + """) + + result = engine.execute(query, seccode=seccode) + + data = [] + for row in result: + total_pledge = (row.unrestricted_pledge or 0) + (row.restricted_pledge or 0) + data.append({ + 'end_date': format_date(row.ENDDATE), + 'start_date': format_date(row.STARTDATE), + 'stock_name': row.SECNAME, + 'unrestricted_pledge': format_decimal(row.unrestricted_pledge), + 'restricted_pledge': format_decimal(row.restricted_pledge), + 'total_pledge': format_decimal(total_pledge), + 'total_shares': format_decimal(row.total_shares_a), + 'pledge_count': int(row.pledge_count) if row.pledge_count else None, + 'pledge_ratio': format_decimal(row.pledge_ratio) + }) + + return jsonify({ + 'success': True, + 'data': data + }) + + except Exception as e: + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + + +@app.route('/api/market/summary/', methods=['GET']) +def get_market_summary(seccode): + """获取市场数据汇总""" + try: + # 获取最新交易数据 + trade_query = text(""" + SELECT * + FROM ea_trade + WHERE SECCODE = :seccode + ORDER BY TRADEDATE DESC LIMIT 1 + """) + + # 获取最新融资融券数据 + funding_query = text(""" + SELECT * + FROM ea_funding + WHERE SECCODE = :seccode + ORDER BY TRADEDATE DESC LIMIT 1 + """) + + # 获取最新质押数据 + pledge_query = text(""" + SELECT * + FROM ea_pledgeratio + WHERE SECCODE = :seccode + ORDER BY ENDDATE DESC LIMIT 1 + """) + + trade_result = engine.execute(trade_query, seccode=seccode).fetchone() + funding_result = engine.execute(funding_query, seccode=seccode).fetchone() + pledge_result = engine.execute(pledge_query, seccode=seccode).fetchone() + + summary = { + 'stock_code': seccode, + 'stock_name': trade_result.SECNAME if trade_result else None, + 'latest_trade': { + 'date': format_date(trade_result.TRADEDATE) if trade_result else None, + 'close': format_decimal(trade_result.F007N) if trade_result else None, + 'change_percent': format_decimal(trade_result.F010N) if trade_result else None, + 'volume': format_decimal(trade_result.F004N) if trade_result else None, + 'amount': format_decimal(trade_result.F011N) if trade_result else None, + 'pe_ratio': format_decimal(trade_result.F026N) if trade_result else None, + 'turnover_rate': format_decimal(trade_result.F012N) if trade_result else None, + } if trade_result else None, + 'latest_funding': { + 'date': format_date(funding_result.TRADEDATE) if funding_result else None, + 'financing_balance': format_decimal(funding_result.F001N) if funding_result else None, + 'securities_balance': format_decimal(funding_result.F004N) if funding_result else None, + 'total_balance': format_decimal(funding_result.F009N) if funding_result else None, + } if funding_result else None, + 'latest_pledge': { + 'date': format_date(pledge_result.ENDDATE) if pledge_result else None, + 'pledge_ratio': format_decimal(pledge_result.F005N) if pledge_result else None, + 'pledge_count': int(pledge_result.F004N) if pledge_result and pledge_result.F004N else None, + } if pledge_result else None + } + + return jsonify({ + 'success': True, + 'data': summary + }) + + except Exception as e: + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + + +@app.route('/api/stocks/search', methods=['GET']) +def search_stocks(): + """搜索股票(支持股票代码、股票简称、拼音首字母)""" + try: + query = request.args.get('q', '').strip() + limit = request.args.get('limit', 20, type=int) + + if not query: + return jsonify({ + 'success': False, + 'error': '请输入搜索关键词' + }), 400 + + with engine.connect() as conn: + test_sql = text(""" + SELECT SECCODE, SECNAME, F001V, F003V, F010V, F011V + FROM ea_stocklist + WHERE SECCODE = '300750' + OR F001V LIKE '%ndsd%' LIMIT 5 + """) + test_result = conn.execute(test_sql).fetchall() + + # 构建搜索SQL - 支持股票代码、股票简称、拼音简称搜索 + search_sql = text(""" + SELECT DISTINCT SECCODE as stock_code, + SECNAME as stock_name, + F001V as pinyin_abbr, + F003V as security_type, + F005V as exchange, + F011V as listing_status + FROM ea_stocklist + WHERE ( + UPPER(SECCODE) LIKE UPPER(:query_pattern) + OR UPPER(SECNAME) LIKE UPPER(:query_pattern) + OR UPPER(F001V) LIKE UPPER(:query_pattern) + ) + -- 基本过滤条件:只搜索正常的A股和B股 + AND (F011V = '正常上市' OR F010V = '013001') -- 正常上市状态 + AND F003V IN ('A股', 'B股') -- 只搜索A股和B股 + ORDER BY CASE + WHEN UPPER(SECCODE) = UPPER(:exact_query) THEN 1 + WHEN UPPER(SECNAME) = UPPER(:exact_query) THEN 2 + WHEN UPPER(F001V) = UPPER(:exact_query) THEN 3 + WHEN UPPER(SECCODE) LIKE UPPER(:prefix_pattern) THEN 4 + WHEN UPPER(SECNAME) LIKE UPPER(:prefix_pattern) THEN 5 + WHEN UPPER(F001V) LIKE UPPER(:prefix_pattern) THEN 6 + ELSE 7 + END, + SECCODE LIMIT :limit + """) + + result = conn.execute(search_sql, { + 'query_pattern': f'%{query}%', + 'exact_query': query, + 'prefix_pattern': f'{query}%', + 'limit': limit + }).fetchall() + + stocks = [] + for row in result: + # 获取当前价格 + current_price, _ = get_latest_price_from_clickhouse(row.stock_code) + + stocks.append({ + 'stock_code': row.stock_code, + 'stock_name': row.stock_name, + 'current_price': current_price or 0, # 添加当前价格 + 'pinyin_abbr': row.pinyin_abbr, + 'security_type': row.security_type, + 'exchange': row.exchange, + 'listing_status': row.listing_status + }) + + return jsonify({ + 'success': True, + 'data': stocks, + 'count': len(stocks) + }) + + except Exception as e: + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + + +@app.route('/api/market/heatmap', methods=['GET']) +def get_market_heatmap(): + """获取市场热力图数据(基于市值和涨跌幅)""" + try: + # 获取交易日期参数 + trade_date = request.args.get('date') + # 前端显示用的limit,但统计数据会基于全部股票 + display_limit = request.args.get('limit', 500, type=int) + + with engine.connect() as conn: + # 如果没有指定日期,获取最新交易日 + if not trade_date: + latest_date_result = conn.execute(text(""" + SELECT MAX(TRADEDATE) as latest_date + FROM ea_trade + """)).fetchone() + trade_date = latest_date_result.latest_date if latest_date_result else None + + if not trade_date: + return jsonify({ + 'success': False, + 'error': '无法获取交易数据' + }), 404 + + # 获取全部股票数据用于统计 + all_stocks_sql = text(""" + SELECT t.SECCODE as stock_code, + t.SECNAME as stock_name, + t.F010N as change_percent, -- 涨跌幅 + t.F007N as close_price, -- 收盘价 + t.F021N * t.F007N / 100000000 as market_cap, -- 市值(亿元) + t.F011N / 100000000 as amount, -- 成交额(亿元) + t.F012N as turnover_rate, -- 换手率 + b.F034V as industry, -- 申万行业分类一级名称 + b.F026V as province -- 所属省份 + FROM ea_trade t + LEFT JOIN ea_baseinfo b ON t.SECCODE = b.SECCODE + WHERE t.TRADEDATE = :trade_date + AND t.F010N IS NOT NULL -- 仅统计当日有涨跌幅数据的股票 + ORDER BY market_cap DESC + """) + + all_result = conn.execute(all_stocks_sql, { + 'trade_date': trade_date + }).fetchall() + + # 计算统计数据(基于全部股票) + total_market_cap = 0 + total_amount = 0 + rising_count = 0 + falling_count = 0 + flat_count = 0 + + all_data = [] + for row in all_result: + # F010N 已在 SQL 中确保非空 + change_percent = float(row.change_percent) + market_cap = float(row.market_cap) if row.market_cap else 0 + amount = float(row.amount) if row.amount else 0 + + total_market_cap += market_cap + total_amount += amount + + if change_percent > 0: + rising_count += 1 + elif change_percent < 0: + falling_count += 1 + else: + flat_count += 1 + + all_data.append({ + 'stock_code': row.stock_code, + 'stock_name': row.stock_name, + 'change_percent': change_percent, + 'close_price': float(row.close_price) if row.close_price else 0, + 'market_cap': market_cap, + 'amount': amount, + 'turnover_rate': float(row.turnover_rate) if row.turnover_rate else 0, + 'industry': row.industry, + 'province': row.province + }) + + # 只返回前display_limit条用于热力图显示 + heatmap_data = all_data[:display_limit] + + return jsonify({ + 'success': True, + 'data': heatmap_data, + 'trade_date': trade_date.strftime('%Y-%m-%d') if hasattr(trade_date, 'strftime') else str(trade_date), + 'count': len(all_data), # 全部股票数量 + 'display_count': len(heatmap_data), # 显示的股票数量 + 'statistics': { + 'total_market_cap': round(total_market_cap, 2), # 总市值(亿元) + 'total_amount': round(total_amount, 2), # 总成交额(亿元) + 'rising_count': rising_count, # 上涨家数 + 'falling_count': falling_count, # 下跌家数 + 'flat_count': flat_count # 平盘家数 + } + }) + + except Exception as e: + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + + +@app.route('/api/market/statistics', methods=['GET']) +def get_market_statistics(): + """获取市场统计数据(从ea_blocktrading表)""" + try: + # 获取交易日期参数 + trade_date = request.args.get('date') + + with engine.connect() as conn: + # 如果没有指定日期,获取最新交易日 + if not trade_date: + latest_date_result = conn.execute(text(""" + SELECT MAX(TRADEDATE) as latest_date + FROM ea_blocktrading + """)).fetchone() + trade_date = latest_date_result.latest_date if latest_date_result else None + + if not trade_date: + return jsonify({ + 'success': False, + 'error': '无法获取统计数据' + }), 404 + + # 获取沪深两市的统计数据 + stats_sql = text(""" + SELECT EXCHANGECODE, + EXCHANGENAME, + F001V as indicator_code, + F002V as indicator_name, + F003N as indicator_value, + F004V as unit, + TRADEDATE + FROM ea_blocktrading + WHERE TRADEDATE = :trade_date + AND EXCHANGECODE IN ('012001', '012002') -- 只获取上交所和深交所的数据 + AND F001V IN ( + '250006', '250014', -- 深交所股票总市值、上交所市价总值 + '250007', '250015', -- 深交所股票流通市值、上交所流通市值 + '250008', -- 深交所股票成交金额 + '250010', '250019', -- 深交所股票平均市盈率、上交所平均市盈率 + '250050', '250001' -- 上交所上市公司家数、深交所上市公司数 + ) + """) + + result = conn.execute(stats_sql, { + 'trade_date': trade_date + }).fetchall() + + # 整理数据 + statistics = {} + for row in result: + key = f"{row.EXCHANGECODE}_{row.indicator_code}" + statistics[key] = { + 'exchange_code': row.EXCHANGECODE, + 'exchange_name': row.EXCHANGENAME, + 'indicator_code': row.indicator_code, + 'indicator_name': row.indicator_name, + 'value': float(row.indicator_value) if row.indicator_value else 0, + 'unit': row.unit + } + + # 汇总数据 + summary = { + 'total_market_cap': 0, # 总市值 + 'total_float_cap': 0, # 流通市值 + 'total_amount': 0, # 成交额 + 'sh_pe_ratio': 0, # 上交所市盈率 + 'sz_pe_ratio': 0, # 深交所市盈率 + 'sh_companies': 0, # 上交所上市公司数 + 'sz_companies': 0 # 深交所上市公司数 + } + + # 计算汇总值 + if '012001_250014' in statistics: # 上交所市价总值 + summary['total_market_cap'] += statistics['012001_250014']['value'] + if '012002_250006' in statistics: # 深交所股票总市值 + summary['total_market_cap'] += statistics['012002_250006']['value'] + + if '012001_250015' in statistics: # 上交所流通市值 + summary['total_float_cap'] += statistics['012001_250015']['value'] + if '012002_250007' in statistics: # 深交所股票流通市值 + summary['total_float_cap'] += statistics['012002_250007']['value'] + + # 成交额需要获取上交所的数据 + # 获取上交所成交金额 + sh_amount_result = conn.execute(text(""" + SELECT F003N + FROM ea_blocktrading + WHERE TRADEDATE = :trade_date + AND EXCHANGECODE = '012001' + AND F002V LIKE '%成交金额%' LIMIT 1 + """), {'trade_date': trade_date}).fetchone() + + sh_amount = float(sh_amount_result.F003N) if sh_amount_result and sh_amount_result.F003N else 0 + sz_amount = statistics['012002_250008']['value'] if '012002_250008' in statistics else 0 + summary['total_amount'] = sh_amount + sz_amount + + if '012001_250019' in statistics: # 上交所平均市盈率 + summary['sh_pe_ratio'] = statistics['012001_250019']['value'] + if '012002_250010' in statistics: # 深交所股票平均市盈率 + summary['sz_pe_ratio'] = statistics['012002_250010']['value'] + + if '012001_250050' in statistics: # 上交所上市公司家数 + summary['sh_companies'] = int(statistics['012001_250050']['value']) + if '012002_250001' in statistics: # 深交所上市公司数 + summary['sz_companies'] = int(statistics['012002_250001']['value']) + + # 获取可用的交易日期列表 + available_dates_result = conn.execute(text(""" + SELECT DISTINCT TRADEDATE + FROM ea_blocktrading + WHERE EXCHANGECODE IN ('012001', '012002') + ORDER BY TRADEDATE DESC LIMIT 30 + """)).fetchall() + + available_dates = [str(row.TRADEDATE) for row in available_dates_result] + + return jsonify({ + 'success': True, + 'trade_date': str(trade_date), + 'summary': summary, + 'details': list(statistics.values()), + 'available_dates': available_dates + }) + + except Exception as e: + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + + +@app.route('/api/concepts/daily-top', methods=['GET']) +def get_daily_top_concepts(): + """获取每日涨幅靠前的概念板块""" + try: + # 获取交易日期参数 + trade_date = request.args.get('date') + limit = request.args.get('limit', 6, type=int) + + # 构建概念中心API的URL + concept_api_url = 'http://222.128.1.157:16801/search' + + # 准备请求数据 + request_data = { + 'query': '', + 'size': limit, + 'page': 1, + 'sort_by': 'change_pct' + } + + if trade_date: + request_data['trade_date'] = trade_date + + # 调用概念中心API + response = requests.post(concept_api_url, json=request_data, timeout=10) + + if response.status_code == 200: + data = response.json() + top_concepts = [] + + for concept in data.get('results', []): + top_concepts.append({ + 'concept_id': concept.get('concept_id'), + 'concept_name': concept.get('concept'), + 'description': concept.get('description'), + 'change_percent': concept.get('price_info', {}).get('avg_change_pct', 0), + 'stock_count': concept.get('stock_count', 0), + 'stocks': concept.get('stocks', [])[:5] # 只返回前5只股票 + }) + + return jsonify({ + 'success': True, + 'data': top_concepts, + 'trade_date': data.get('price_date'), + 'count': len(top_concepts) + }) + else: + return jsonify({ + 'success': False, + 'error': '获取概念数据失败' + }), 500 + + except Exception as e: + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + + +@app.route('/api/market/rise-analysis/', methods=['GET']) +def get_rise_analysis(seccode): + """获取股票涨幅分析数据""" + try: + # 获取日期范围参数 + start_date = request.args.get('start_date') + end_date = request.args.get('end_date') + + query = text(""" + SELECT stock_code, + stock_name, + trade_date, + rise_rate, + close_price, + volume, + amount, + main_business, + rise_reason_brief, + rise_reason_detail, + news_summary, + announcements, + guba_sentiment, + analysis_time + FROM stock_rise_analysis + WHERE stock_code = :stock_code + """) + + params = {'stock_code': seccode} + + # 添加日期筛选 + if start_date and end_date: + query = text(""" + SELECT stock_code, + stock_name, + trade_date, + rise_rate, + close_price, + volume, + amount, + main_business, + rise_reason_brief, + rise_reason_detail, + news_summary, + announcements, + guba_sentiment, + analysis_time + FROM stock_rise_analysis + WHERE stock_code = :stock_code + AND trade_date BETWEEN :start_date AND :end_date + ORDER BY trade_date DESC + """) + params['start_date'] = start_date + params['end_date'] = end_date + else: + query = text(""" + SELECT stock_code, + stock_name, + trade_date, + rise_rate, + close_price, + volume, + amount, + main_business, + rise_reason_brief, + rise_reason_detail, + news_summary, + announcements, + guba_sentiment, + analysis_time + FROM stock_rise_analysis + WHERE stock_code = :stock_code + ORDER BY trade_date DESC LIMIT 100 + """) + + result = engine.execute(query, **params).fetchall() + + # 格式化数据 + rise_analysis_data = [] + for row in result: + rise_analysis_data.append({ + 'stock_code': row.stock_code, + 'stock_name': row.stock_name, + 'trade_date': format_date(row.trade_date), + 'rise_rate': format_decimal(row.rise_rate), + 'close_price': format_decimal(row.close_price), + 'volume': format_decimal(row.volume), + 'amount': format_decimal(row.amount), + 'main_business': row.main_business, + 'rise_reason_brief': row.rise_reason_brief, + 'rise_reason_detail': row.rise_reason_detail, + 'news_summary': row.news_summary, + 'announcements': row.announcements, + 'guba_sentiment': row.guba_sentiment, + 'analysis_time': row.analysis_time.strftime('%Y-%m-%d %H:%M:%S') if row.analysis_time else None + }) + + return jsonify({ + 'success': True, + 'data': rise_analysis_data, + 'count': len(rise_analysis_data) + }) + + except Exception as e: + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + + +# ============================================ +# 公司分析相关接口 +# ============================================ + +@app.route('/api/company/comprehensive-analysis/', methods=['GET']) +def get_comprehensive_analysis(company_code): + """获取公司综合分析数据""" + try: + # 获取公司定性分析 + qualitative_query = text(""" + SELECT one_line_intro, + investment_highlights, + business_model_desc, + company_story, + positioning_analysis, + unique_value_proposition, + business_logic_explanation, + revenue_driver_analysis, + customer_value_analysis, + strategy_description, + strategic_initiatives, + created_at, + updated_at + FROM company_analysis + WHERE company_code = :company_code + """) + + qualitative_result = engine.execute(qualitative_query, company_code=company_code).fetchone() + + # 获取业务板块分析 + segments_query = text(""" + SELECT segment_name, + segment_description, + competitive_position, + future_potential, + key_customers, + value_chain_position, + created_at, + updated_at + FROM business_segment_analysis + WHERE company_code = :company_code + ORDER BY created_at DESC + """) + + segments_result = engine.execute(segments_query, company_code=company_code).fetchall() + + # 获取竞争地位数据 - 最新一期 + competitive_query = text(""" + SELECT market_position_score, + technology_score, + brand_score, + operation_score, + finance_score, + innovation_score, + risk_score, + growth_score, + industry_avg_comparison, + main_competitors, + competitive_advantages, + competitive_disadvantages, + industry_rank, + total_companies, + report_period, + updated_at + FROM company_competitive_position + WHERE company_code = :company_code + ORDER BY report_period DESC LIMIT 1 + """) + + competitive_result = engine.execute(competitive_query, company_code=company_code).fetchone() + + # 获取业务结构数据 - 最新一期 + business_structure_query = text(""" + SELECT business_name, + parent_business, + business_level, + revenue, + revenue_unit, + revenue_ratio, + profit, + profit_unit, + profit_ratio, + revenue_growth, + profit_growth, + gross_margin, + customer_count, + market_share, + report_period + FROM company_business_structure + WHERE company_code = :company_code + AND report_period = (SELECT MAX(report_period) + FROM company_business_structure + WHERE company_code = :company_code) + ORDER BY revenue_ratio DESC + """) + + business_structure_result = engine.execute(business_structure_query, company_code=company_code).fetchall() + + # 构建返回数据 + response_data = { + 'company_code': company_code, + 'qualitative_analysis': None, + 'business_segments': [], + 'competitive_position': None, + 'business_structure': [] + } + + # 处理定性分析数据 + if qualitative_result: + response_data['qualitative_analysis'] = { + 'core_positioning': { + 'one_line_intro': qualitative_result.one_line_intro, + 'investment_highlights': qualitative_result.investment_highlights, + 'business_model_desc': qualitative_result.business_model_desc, + 'company_story': qualitative_result.company_story + }, + 'business_understanding': { + 'positioning_analysis': qualitative_result.positioning_analysis, + 'unique_value_proposition': qualitative_result.unique_value_proposition, + 'business_logic_explanation': qualitative_result.business_logic_explanation, + 'revenue_driver_analysis': qualitative_result.revenue_driver_analysis, + 'customer_value_analysis': qualitative_result.customer_value_analysis + }, + 'strategy': { + 'strategy_description': qualitative_result.strategy_description, + 'strategic_initiatives': qualitative_result.strategic_initiatives + }, + 'updated_at': qualitative_result.updated_at.strftime( + '%Y-%m-%d %H:%M:%S') if qualitative_result.updated_at else None + } + + # 处理业务板块数据 + for segment in segments_result: + response_data['business_segments'].append({ + 'segment_name': segment.segment_name, + 'segment_description': segment.segment_description, + 'competitive_position': segment.competitive_position, + 'future_potential': segment.future_potential, + 'key_customers': segment.key_customers, + 'value_chain_position': segment.value_chain_position, + 'updated_at': segment.updated_at.strftime('%Y-%m-%d %H:%M:%S') if segment.updated_at else None + }) + + # 处理竞争地位数据 + if competitive_result: + response_data['competitive_position'] = { + 'scores': { + 'market_position': competitive_result.market_position_score, + 'technology': competitive_result.technology_score, + 'brand': competitive_result.brand_score, + 'operation': competitive_result.operation_score, + 'finance': competitive_result.finance_score, + 'innovation': competitive_result.innovation_score, + 'risk': competitive_result.risk_score, + 'growth': competitive_result.growth_score + }, + 'analysis': { + 'industry_avg_comparison': competitive_result.industry_avg_comparison, + 'main_competitors': competitive_result.main_competitors, + 'competitive_advantages': competitive_result.competitive_advantages, + 'competitive_disadvantages': competitive_result.competitive_disadvantages + }, + 'ranking': { + 'industry_rank': competitive_result.industry_rank, + 'total_companies': competitive_result.total_companies, + 'rank_percentage': round( + (competitive_result.industry_rank / competitive_result.total_companies * 100), + 2) if competitive_result.industry_rank and competitive_result.total_companies else None + }, + 'report_period': competitive_result.report_period, + 'updated_at': competitive_result.updated_at.strftime( + '%Y-%m-%d %H:%M:%S') if competitive_result.updated_at else None + } + + # 处理业务结构数据 + for business in business_structure_result: + response_data['business_structure'].append({ + 'business_name': business.business_name, + 'parent_business': business.parent_business, + 'business_level': business.business_level, + 'revenue': format_decimal(business.revenue), + 'revenue_unit': business.revenue_unit, + 'profit': format_decimal(business.profit), + 'profit_unit': business.profit_unit, + 'financial_metrics': { + 'revenue': format_decimal(business.revenue), + 'revenue_ratio': format_decimal(business.revenue_ratio), + 'profit': format_decimal(business.profit), + 'profit_ratio': format_decimal(business.profit_ratio), + 'gross_margin': format_decimal(business.gross_margin) + }, + 'growth_metrics': { + 'revenue_growth': format_decimal(business.revenue_growth), + 'profit_growth': format_decimal(business.profit_growth) + }, + 'market_metrics': { + 'customer_count': business.customer_count, + 'market_share': format_decimal(business.market_share) + }, + 'report_period': business.report_period + }) + + return jsonify({ + 'success': True, + 'data': response_data + }) + + except Exception as e: + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + + +@app.route('/api/company/value-chain-analysis/', methods=['GET']) +def get_value_chain_analysis(company_code): + """获取公司产业链分析数据""" + try: + # 获取产业链节点数据 + nodes_query = text(""" + SELECT node_name, + node_type, + node_level, + node_description, + importance_score, + market_share, + dependency_degree, + created_at + FROM company_value_chain_nodes + WHERE company_code = :company_code + ORDER BY node_level ASC, importance_score DESC + """) + + nodes_result = engine.execute(nodes_query, company_code=company_code).fetchall() + + # 获取产业链流向数据 + flows_query = text(""" + SELECT source_node, + source_type, + source_level, + target_node, + target_type, + target_level, + flow_value, + flow_ratio, + flow_type, + relationship_desc, + transaction_volume + FROM company_value_chain_flows + WHERE company_code = :company_code + ORDER BY flow_ratio DESC + """) + + flows_result = engine.execute(flows_query, company_code=company_code).fetchall() + + # 构建节点数据结构 + nodes_by_level = {} + all_nodes = [] + + for node in nodes_result: + node_data = { + 'node_name': node.node_name, + 'node_type': node.node_type, + 'node_level': node.node_level, + 'node_description': node.node_description, + 'importance_score': node.importance_score, + 'market_share': format_decimal(node.market_share), + 'dependency_degree': format_decimal(node.dependency_degree), + 'created_at': node.created_at.strftime('%Y-%m-%d %H:%M:%S') if node.created_at else None + } + + all_nodes.append(node_data) + + # 按层级分组 + level_key = f"level_{node.node_level}" + if level_key not in nodes_by_level: + nodes_by_level[level_key] = [] + nodes_by_level[level_key].append(node_data) + + # 构建流向数据 + flows_data = [] + for flow in flows_result: + flows_data.append({ + 'source': { + 'node_name': flow.source_node, + 'node_type': flow.source_type, + 'node_level': flow.source_level + }, + 'target': { + 'node_name': flow.target_node, + 'node_type': flow.target_type, + 'node_level': flow.target_level + }, + 'flow_metrics': { + 'flow_value': format_decimal(flow.flow_value), + 'flow_ratio': format_decimal(flow.flow_ratio), + 'flow_type': flow.flow_type + }, + 'relationship_info': { + 'relationship_desc': flow.relationship_desc, + 'transaction_volume': flow.transaction_volume + } + }) + + # 移除循环边,确保Sankey图数据是DAG(有向无环图) + flows_data = remove_cycles_from_sankey_flows(flows_data) + + # 统计各层级节点数量 + level_stats = {} + for level_key, nodes in nodes_by_level.items(): + level_stats[level_key] = { + 'count': len(nodes), + 'avg_importance': round(sum(node['importance_score'] or 0 for node in nodes) / len(nodes), + 2) if nodes else 0 + } + + response_data = { + 'company_code': company_code, + 'value_chain_structure': { + 'nodes_by_level': nodes_by_level, + 'level_statistics': level_stats, + 'total_nodes': len(all_nodes) + }, + 'value_chain_flows': flows_data, + 'analysis_summary': { + 'total_flows': len(flows_data), + 'upstream_nodes': len([n for n in all_nodes if n['node_level'] < 0]), + 'company_nodes': len([n for n in all_nodes if n['node_level'] == 0]), + 'downstream_nodes': len([n for n in all_nodes if n['node_level'] > 0]) + } + } + + return jsonify({ + 'success': True, + 'data': response_data + }) + + except Exception as e: + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + + +@app.route('/api/company/key-factors-timeline/', methods=['GET']) +def get_key_factors_timeline(company_code): + """获取公司关键因素和时间线数据""" + try: + # 获取请求参数 + report_period = request.args.get('report_period') # 可选的报告期筛选 + event_limit = request.args.get('event_limit', 50, type=int) # 时间线事件数量限制 + + # 获取关键因素类别 + categories_query = text(""" + SELECT id, + category_name, + category_desc, + display_order + FROM company_key_factor_categories + WHERE company_code = :company_code + ORDER BY display_order ASC, created_at ASC + """) + + categories_result = engine.execute(categories_query, company_code=company_code).fetchall() + + # 获取关键因素详情 + factors_query = text(""" + SELECT kf.category_id, + kf.factor_name, + kf.factor_type, + kf.factor_value, + kf.factor_unit, + kf.factor_desc, + kf.impact_direction, + kf.impact_weight, + kf.report_period, + kf.year_on_year, + kf.data_source, + kf.created_at, + kf.updated_at + FROM company_key_factors kf + WHERE kf.company_code = :company_code + """) + + params = {'company_code': company_code} + + # 如果指定了报告期,添加筛选条件 + if report_period: + factors_query = text(""" + SELECT kf.category_id, + kf.factor_name, + kf.factor_type, + kf.factor_value, + kf.factor_unit, + kf.factor_desc, + kf.impact_direction, + kf.impact_weight, + kf.report_period, + kf.year_on_year, + kf.data_source, + kf.created_at, + kf.updated_at + FROM company_key_factors kf + WHERE kf.company_code = :company_code + AND kf.report_period = :report_period + ORDER BY kf.impact_weight DESC, kf.updated_at DESC + """) + params['report_period'] = report_period + else: + factors_query = text(""" + SELECT kf.category_id, + kf.factor_name, + kf.factor_type, + kf.factor_value, + kf.factor_unit, + kf.factor_desc, + kf.impact_direction, + kf.impact_weight, + kf.report_period, + kf.year_on_year, + kf.data_source, + kf.created_at, + kf.updated_at + FROM company_key_factors kf + WHERE kf.company_code = :company_code + ORDER BY kf.report_period DESC, kf.impact_weight DESC, kf.updated_at DESC + """) + + factors_result = engine.execute(factors_query, **params).fetchall() + + # 获取发展时间线事件 + timeline_query = text(""" + SELECT event_date, + event_type, + event_title, + event_desc, + impact_score, + is_positive, + related_products, + related_partners, + financial_impact, + created_at + FROM company_timeline_events + WHERE company_code = :company_code + ORDER BY event_date DESC LIMIT :limit + """) + + timeline_result = engine.execute(timeline_query, + company_code=company_code, + limit=event_limit).fetchall() + + # 构建关键因素数据结构 + key_factors_data = {} + factors_by_category = {} + + # 先建立类别索引 + categories_map = {} + for category in categories_result: + categories_map[category.id] = { + 'category_name': category.category_name, + 'category_desc': category.category_desc, + 'display_order': category.display_order, + 'factors': [] + } + + # 将因素分组到类别中 + for factor in factors_result: + factor_data = { + 'factor_name': factor.factor_name, + 'factor_type': factor.factor_type, + 'factor_value': factor.factor_value, + 'factor_unit': factor.factor_unit, + 'factor_desc': factor.factor_desc, + 'impact_direction': factor.impact_direction, + 'impact_weight': factor.impact_weight, + 'report_period': factor.report_period, + 'year_on_year': format_decimal(factor.year_on_year), + 'data_source': factor.data_source, + 'updated_at': factor.updated_at.strftime('%Y-%m-%d %H:%M:%S') if factor.updated_at else None + } + + category_id = factor.category_id + if category_id and category_id in categories_map: + categories_map[category_id]['factors'].append(factor_data) + + # 构建时间线数据 + timeline_data = [] + for event in timeline_result: + timeline_data.append({ + 'event_date': event.event_date.strftime('%Y-%m-%d') if event.event_date else None, + 'event_type': event.event_type, + 'event_title': event.event_title, + 'event_desc': event.event_desc, + 'impact_metrics': { + 'impact_score': event.impact_score, + 'is_positive': event.is_positive + }, + 'related_info': { + 'related_products': event.related_products, + 'related_partners': event.related_partners, + 'financial_impact': event.financial_impact + }, + 'created_at': event.created_at.strftime('%Y-%m-%d %H:%M:%S') if event.created_at else None + }) + + # 统计信息 + total_factors = len(factors_result) + positive_events = len([e for e in timeline_result if e.is_positive]) + negative_events = len(timeline_result) - positive_events + + response_data = { + 'company_code': company_code, + 'key_factors': { + 'categories': list(categories_map.values()), + 'total_factors': total_factors, + 'report_period': report_period + }, + 'development_timeline': { + 'events': timeline_data, + 'statistics': { + 'total_events': len(timeline_data), + 'positive_events': positive_events, + 'negative_events': negative_events, + 'event_types': list(set(event.event_type for event in timeline_result if event.event_type)) + } + } + } + + return jsonify({ + 'success': True, + 'data': response_data + }) + + except Exception as e: + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + + +# ============================================ +# 模拟盘服务函数 +# ============================================ + +def get_or_create_simulation_account(user_id): + """获取或创建模拟账户""" + account = SimulationAccount.query.filter_by(user_id=user_id).first() + if not account: + account = SimulationAccount( + user_id=user_id, + account_name=f'模拟账户_{user_id}', + initial_capital=1000000.00, + available_cash=1000000.00 + ) + db.session.add(account) + db.session.commit() + return account + + +def is_trading_time(): + """判断是否为交易时间""" + now = beijing_now() + # 检查是否为工作日 + if now.weekday() >= 5: # 周六日 + return False + + # 检查是否为交易时间 + current_time = now.time() + morning_start = dt_time(9, 30) + morning_end = dt_time(11, 30) + afternoon_start = dt_time(13, 0) + afternoon_end = dt_time(15, 0) + + if (morning_start <= current_time <= morning_end) or \ + (afternoon_start <= current_time <= afternoon_end): + return True + + return False + + +def get_latest_price_from_clickhouse(stock_code): + """从ClickHouse获取最新价格(优先分钟数据,备选日线数据)""" + try: + client = get_clickhouse_client() + + # 确保stock_code包含后缀 + if '.' not in stock_code: + stock_code = f"{stock_code}.SH" if stock_code.startswith('6') else f"{stock_code}.SZ" + + # 1. 首先尝试获取最新的分钟数据(近30天) + minute_query = """ + SELECT close, timestamp + FROM stock_minute + WHERE code = %(code)s + AND timestamp >= today() - 30 + ORDER BY timestamp DESC + LIMIT 1 \ + """ + + result = client.execute(minute_query, {'code': stock_code}) + + if result: + return float(result[0][0]), result[0][1] + + # 2. 如果没有分钟数据,获取最新的日线收盘价 + daily_query = """ + SELECT close, date + FROM stock_daily + WHERE code = %(code)s + AND date >= today() - 90 + ORDER BY date DESC + LIMIT 1 \ + """ + + daily_result = client.execute(daily_query, {'code': stock_code}) + + if daily_result: + return float(daily_result[0][0]), daily_result[0][1] + + # 3. 如果还是没有,尝试从其他表获取(如果有的话) + fallback_query = """ + SELECT close_price, trade_date + FROM stock_minute_kline + WHERE stock_code = %(code6)s + AND trade_date >= today() - 30 + ORDER BY trade_date DESC, trade_time DESC LIMIT 1 \ + """ + + # 提取6位代码 + code6 = stock_code.split('.')[0] + fallback_result = client.execute(fallback_query, {'code6': code6}) + + if fallback_result: + return float(fallback_result[0][0]), fallback_result[0][1] + + print(f"警告: 无法获取股票 {stock_code} 的价格数据") + return None, None + + except Exception as e: + print(f"获取最新价格失败 {stock_code}: {e}") + return None, None + + +def get_next_minute_price(stock_code, order_time): + """获取下单后一分钟内的收盘价作为成交价""" + try: + client = get_clickhouse_client() + + # 确保stock_code包含后缀 + if '.' not in stock_code: + stock_code = f"{stock_code}.SH" if stock_code.startswith('6') else f"{stock_code}.SZ" + + # 获取下单后一分钟内的数据 + query = """ + SELECT close, timestamp + FROM stock_minute + WHERE code = %(code)s + AND timestamp \ + > %(order_time)s + AND timestamp <= %(end_time)s + ORDER BY timestamp ASC + LIMIT 1 \ + """ + + end_time = order_time + timedelta(minutes=1) + + result = client.execute(query, { + 'code': stock_code, + 'order_time': order_time, + 'end_time': end_time + }) + + if result: + return float(result[0][0]), result[0][1] + + # 如果一分钟内没有数据,获取最近的数据 + query = """ + SELECT close, timestamp + FROM stock_minute + WHERE code = %(code)s + AND timestamp \ + > %(order_time)s + ORDER BY timestamp ASC + LIMIT 1 \ + """ + + result = client.execute(query, { + 'code': stock_code, + 'order_time': order_time + }) + + if result: + return float(result[0][0]), result[0][1] + + # 如果没有后续分钟数据,使用最新可用价格 + print(f"没有找到下单后的分钟数据,使用最新价格: {stock_code}") + return get_latest_price_from_clickhouse(stock_code) + + except Exception as e: + print(f"获取成交价格失败: {e}") + # 出错时也尝试获取最新价格 + return get_latest_price_from_clickhouse(stock_code) + + +def validate_and_get_stock_info(stock_input): + """验证股票输入并获取标准代码和名称 + + 支持输入格式: + - 股票代码:600519 或 600519.SH + - 股票名称:贵州茅台 + - 拼音首字母:gzmt + - 名称(代码):贵州茅台(600519) + + 返回: (stock_code_with_suffix, stock_code_6digit, stock_name) 或 (None, None, None) + """ + # 先尝试标准化输入 + code6, name_from_input = _normalize_stock_input(stock_input) + + if code6: + # 如果能解析出6位代码,查询股票名称 + stock_name = name_from_input or _query_stock_name_by_code(code6) + stock_code_full = f"{code6}.SH" if code6.startswith('6') else f"{code6}.SZ" + return stock_code_full, code6, stock_name + + # 如果不是标准代码格式,尝试搜索 + with engine.connect() as conn: + search_sql = text(""" + SELECT DISTINCT SECCODE as stock_code, + SECNAME as stock_name + FROM ea_stocklist + WHERE ( + UPPER(SECCODE) = UPPER(:exact_match) + OR UPPER(SECNAME) = UPPER(:exact_match) + OR UPPER(F001V) = UPPER(:exact_match) + ) + AND F011V = '正常上市' + AND F003V IN ('A股', 'B股') LIMIT 1 + """) + + result = conn.execute(search_sql, { + 'exact_match': stock_input.upper() + }).fetchone() + + if result: + code6 = result.stock_code + stock_name = result.stock_name + stock_code_full = f"{code6}.SH" if code6.startswith('6') else f"{code6}.SZ" + return stock_code_full, code6, stock_name + + return None, None, None + + +def execute_simulation_order(order): + """执行模拟订单(优化版)""" + try: + # 标准化股票代码 + stock_code_full, code6, stock_name = validate_and_get_stock_info(order.stock_code) + + if not stock_code_full: + order.status = 'REJECTED' + order.reject_reason = '无效的股票代码' + db.session.commit() + return False + + # 更新订单的股票信息 + order.stock_code = stock_code_full + order.stock_name = stock_name + + # 获取成交价格(下单后一分钟的收盘价) + filled_price, filled_time = get_next_minute_price(stock_code_full, order.order_time) + + if not filled_price: + # 如果无法获取价格,订单保持PENDING状态,等待后台处理 + order.status = 'PENDING' + db.session.commit() + return True # 返回True表示下单成功,但未成交 + + # 更新订单信息 + order.filled_qty = order.order_qty + order.filled_price = filled_price + order.filled_amount = filled_price * order.order_qty + order.filled_time = filled_time or beijing_now() + + # 计算费用 + order.calculate_fees() + + # 获取账户 + account = SimulationAccount.query.get(order.account_id) + + if order.order_type == 'BUY': + # 买入操作 + total_cost = float(order.filled_amount) + float(order.total_fee) + + # 检查资金是否充足 + if float(account.available_cash) < total_cost: + order.status = 'REJECTED' + order.reject_reason = '可用资金不足' + db.session.commit() + return False + + # 扣除资金 + account.available_cash -= Decimal(str(total_cost)) + + # 更新或创建持仓 + position = SimulationPosition.query.filter_by( + account_id=account.id, + stock_code=order.stock_code + ).first() + + if position: + # 更新持仓 + total_cost_before = float(position.avg_cost) * position.position_qty + total_cost_after = total_cost_before + float(order.filled_amount) + total_qty_after = position.position_qty + order.filled_qty + + position.avg_cost = Decimal(str(total_cost_after / total_qty_after)) + position.position_qty = total_qty_after + # 今日买入,T+1才可用 + position.frozen_qty += order.filled_qty + else: + # 创建新持仓 + position = SimulationPosition( + account_id=account.id, + stock_code=order.stock_code, + stock_name=order.stock_name, + position_qty=order.filled_qty, + available_qty=0, # T+1 + frozen_qty=order.filled_qty, # 今日买入冻结 + avg_cost=order.filled_price, + current_price=order.filled_price + ) + db.session.add(position) + + # 更新持仓市值 + position.update_market_value(order.filled_price) + + else: # SELL + # 卖出操作 + print(f"🔍 调试:查找持仓,账户ID: {account.id}, 股票代码: {order.stock_code}") + + # 先尝试用完整格式查找 + position = SimulationPosition.query.filter_by( + account_id=account.id, + stock_code=order.stock_code + ).first() + + # 如果没找到,尝试用6位数字格式查找 + if not position and '.' in order.stock_code: + code6 = order.stock_code.split('.')[0] + print(f"🔍 调试:尝试用6位格式查找: {code6}") + position = SimulationPosition.query.filter_by( + account_id=account.id, + stock_code=code6 + ).first() + + print(f"🔍 调试:找到持仓: {position}") + if position: + print( + f"🔍 调试:持仓详情 - 股票代码: {position.stock_code}, 持仓数量: {position.position_qty}, 可用数量: {position.available_qty}") + + # 检查持仓是否存在 + if not position: + order.status = 'REJECTED' + order.reject_reason = '持仓不存在' + db.session.commit() + return False + + # 检查总持仓数量是否足够(包括冻结的) + total_holdings = position.position_qty + if total_holdings < order.order_qty: + order.status = 'REJECTED' + order.reject_reason = f'持仓数量不足,当前持仓: {total_holdings} 股,需要: {order.order_qty} 股' + db.session.commit() + return False + + # 如果可用数量不足,但总持仓足够,则从冻结数量中解冻 + if position.available_qty < order.order_qty: + # 计算需要解冻的数量 + need_to_unfreeze = order.order_qty - position.available_qty + if position.frozen_qty >= need_to_unfreeze: + # 解冻部分冻结数量 + position.frozen_qty -= need_to_unfreeze + position.available_qty += need_to_unfreeze + print(f"解冻 {need_to_unfreeze} 股用于卖出") + else: + order.status = 'REJECTED' + order.reject_reason = f'可用数量不足,可用: {position.available_qty} 股,冻结: {position.frozen_qty} 股,需要: {order.order_qty} 股' + db.session.commit() + return False + + # 更新持仓 + position.position_qty -= order.filled_qty + position.available_qty -= order.filled_qty + + # 增加资金 + account.available_cash += Decimal(str(float(order.filled_amount) - float(order.total_fee))) + + # 如果全部卖出,删除持仓记录 + if position.position_qty == 0: + db.session.delete(position) + + # 创建成交记录 + transaction = SimulationTransaction( + account_id=account.id, + order_id=order.id, + transaction_no=f"T{int(beijing_now().timestamp() * 1000000)}", + stock_code=order.stock_code, + stock_name=order.stock_name, + transaction_type=order.order_type, + transaction_price=order.filled_price, + transaction_qty=order.filled_qty, + transaction_amount=order.filled_amount, + commission=order.commission, + stamp_tax=order.stamp_tax, + transfer_fee=order.transfer_fee, + total_fee=order.total_fee, + transaction_time=order.filled_time, + settlement_date=(order.filled_time + timedelta(days=1)).date() + ) + db.session.add(transaction) + + # 更新订单状态 + order.status = 'FILLED' + + # 更新账户总资产 + update_account_assets(account) + + db.session.commit() + return True + + except Exception as e: + print(f"执行订单失败: {e}") + db.session.rollback() + return False + + +def update_account_assets(account): + """更新账户资产(轻量级版本,不实时获取价格)""" + try: + # 只计算已有的持仓市值,不实时获取价格 + # 价格更新由后台脚本负责 + positions = SimulationPosition.query.filter_by(account_id=account.id).all() + total_market_value = sum(position.market_value or Decimal('0') for position in positions) + + account.position_value = total_market_value + account.calculate_total_assets() + + db.session.commit() + + except Exception as e: + print(f"更新账户资产失败: {e}") + db.session.rollback() + + +def update_all_positions_price(): + """更新所有持仓的最新价格(定时任务调用)""" + try: + positions = SimulationPosition.query.all() + + for position in positions: + latest_price, _ = get_latest_price_from_clickhouse(position.stock_code) + if latest_price: + # 记录昨日收盘价(用于计算今日盈亏) + yesterday_close = position.current_price + + # 更新市值 + position.update_market_value(latest_price) + + # 计算今日盈亏 + position.today_profit = (Decimal(str(latest_price)) - yesterday_close) * position.position_qty + position.today_profit_rate = ((Decimal( + str(latest_price)) - yesterday_close) / yesterday_close * 100) if yesterday_close > 0 else 0 + + db.session.commit() + + except Exception as e: + print(f"更新持仓价格失败: {e}") + db.session.rollback() + + +def process_t1_settlement(): + """处理T+1结算(每日收盘后运行)""" + try: + # 获取所有需要结算的持仓 + positions = SimulationPosition.query.filter(SimulationPosition.frozen_qty > 0).all() + + for position in positions: + # 将冻结数量转为可用数量 + position.available_qty += position.frozen_qty + position.frozen_qty = 0 + + db.session.commit() + + except Exception as e: + print(f"T+1结算失败: {e}") + db.session.rollback() + + +# ============================================ +# 模拟盘API接口 +# ============================================ + +@app.route('/api/simulation/account', methods=['GET']) +@login_required +def get_simulation_account(): + """获取模拟账户信息""" + try: + account = get_or_create_simulation_account(current_user.id) + + # 更新账户资产 + update_account_assets(account) + + return jsonify({ + 'success': True, + 'data': { + 'account_id': account.id, + 'account_name': account.account_name, + 'initial_capital': float(account.initial_capital), + 'available_cash': float(account.available_cash), + 'frozen_cash': float(account.frozen_cash), + 'position_value': float(account.position_value), + 'total_assets': float(account.total_assets), + 'total_profit': float(account.total_profit), + 'total_profit_rate': float(account.total_profit_rate), + 'daily_profit': float(account.daily_profit), + 'daily_profit_rate': float(account.daily_profit_rate), + 'created_at': account.created_at.isoformat(), + 'updated_at': account.updated_at.isoformat() + } + }) + + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + + +@app.route('/api/simulation/positions', methods=['GET']) +@login_required +def get_simulation_positions(): + """获取模拟持仓列表(优化版本,使用缓存的价格数据)""" + try: + account = get_or_create_simulation_account(current_user.id) + + # 直接获取持仓数据,不实时更新价格(由后台脚本负责) + positions = SimulationPosition.query.filter_by(account_id=account.id).all() + + positions_data = [] + for position in positions: + positions_data.append({ + 'id': position.id, + 'stock_code': position.stock_code, + 'stock_name': position.stock_name, + 'position_qty': position.position_qty, + 'available_qty': position.available_qty, + 'frozen_qty': position.frozen_qty, + 'avg_cost': float(position.avg_cost), + 'current_price': float(position.current_price or 0), + 'market_value': float(position.market_value or 0), + 'profit': float(position.profit or 0), + 'profit_rate': float(position.profit_rate or 0), + 'today_profit': float(position.today_profit or 0), + 'today_profit_rate': float(position.today_profit_rate or 0), + 'updated_at': position.updated_at.isoformat() + }) + + return jsonify({ + 'success': True, + 'data': positions_data + }) + + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + + +@app.route('/api/simulation/orders', methods=['GET']) +@login_required +def get_simulation_orders(): + """获取模拟订单列表""" + try: + account = get_or_create_simulation_account(current_user.id) + + # 获取查询参数 + status = request.args.get('status') # 订单状态筛选 + date_str = request.args.get('date') # 日期筛选 + limit = request.args.get('limit', 50, type=int) + + query = SimulationOrder.query.filter_by(account_id=account.id) + + if status: + query = query.filter_by(status=status) + + if date_str: + try: + date = datetime.strptime(date_str, '%Y-%m-%d').date() + start_time = datetime.combine(date, dt_time(0, 0, 0)) + end_time = datetime.combine(date, dt_time(23, 59, 59)) + query = query.filter(SimulationOrder.order_time.between(start_time, end_time)) + except ValueError: + pass + + orders = query.order_by(SimulationOrder.order_time.desc()).limit(limit).all() + + orders_data = [] + for order in orders: + orders_data.append({ + 'id': order.id, + 'order_no': order.order_no, + 'stock_code': order.stock_code, + 'stock_name': order.stock_name, + 'order_type': order.order_type, + 'price_type': order.price_type, + 'order_price': float(order.order_price) if order.order_price else None, + 'order_qty': order.order_qty, + 'filled_qty': order.filled_qty, + 'filled_price': float(order.filled_price) if order.filled_price else None, + 'filled_amount': float(order.filled_amount) if order.filled_amount else None, + 'commission': float(order.commission), + 'stamp_tax': float(order.stamp_tax), + 'transfer_fee': float(order.transfer_fee), + 'total_fee': float(order.total_fee), + 'status': order.status, + 'reject_reason': order.reject_reason, + 'order_time': order.order_time.isoformat(), + 'filled_time': order.filled_time.isoformat() if order.filled_time else None + }) + + return jsonify({ + 'success': True, + 'data': orders_data + }) + + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + + +@app.route('/api/simulation/place-order', methods=['POST']) +@login_required +def place_simulation_order(): + """下单""" + try: + # 移除交易时间检查,允许7x24小时下单 + # 非交易时间下的单子会保持PENDING状态,等待行情数据 + + data = request.get_json() + stock_code = data.get('stock_code') + order_type = data.get('order_type') # BUY/SELL + order_qty = data.get('order_qty') + price_type = data.get('price_type', 'MARKET') # 目前只支持市价单 + + # 标准化股票代码格式 + if stock_code and '.' not in stock_code: + # 如果没有后缀,根据股票代码添加后缀 + if stock_code.startswith('6'): + stock_code = f"{stock_code}.SH" + elif stock_code.startswith('0') or stock_code.startswith('3'): + stock_code = f"{stock_code}.SZ" + + # 参数验证 + if not all([stock_code, order_type, order_qty]): + return jsonify({'success': False, 'error': '缺少必要参数'}), 400 + + if order_type not in ['BUY', 'SELL']: + return jsonify({'success': False, 'error': '订单类型错误'}), 400 + + order_qty = int(order_qty) + if order_qty <= 0 or order_qty % 100 != 0: + return jsonify({'success': False, 'error': '下单数量必须为100的整数倍'}), 400 + + # 获取账户 + account = get_or_create_simulation_account(current_user.id) + + # 获取股票信息 + stock_name = None + with engine.connect() as conn: + result = conn.execute(text( + "SELECT SECNAME FROM ea_stocklist WHERE SECCODE = :code" + ), {"code": stock_code.split('.')[0]}).fetchone() + if result: + stock_name = result[0] + + # 创建订单 + order = SimulationOrder( + account_id=account.id, + order_no=f"O{int(beijing_now().timestamp() * 1000000)}", + stock_code=stock_code, + stock_name=stock_name, + order_type=order_type, + price_type=price_type, + order_qty=order_qty, + status='PENDING' + ) + + db.session.add(order) + db.session.commit() + + # 执行订单 + print(f"🔍 调试:开始执行订单,股票代码: {order.stock_code}, 订单类型: {order.order_type}") + success = execute_simulation_order(order) + print(f"🔍 调试:订单执行结果: {success}, 订单状态: {order.status}") + + if success: + # 重新查询订单状态,因为可能在execute_simulation_order中被修改 + db.session.refresh(order) + + if order.status == 'FILLED': + return jsonify({ + 'success': True, + 'message': '订单执行成功,已成交', + 'data': { + 'order_no': order.order_no, + 'status': 'FILLED', + 'filled_price': float(order.filled_price) if order.filled_price else None, + 'filled_qty': order.filled_qty, + 'filled_amount': float(order.filled_amount) if order.filled_amount else None, + 'total_fee': float(order.total_fee) + } + }) + elif order.status == 'PENDING': + return jsonify({ + 'success': True, + 'message': '订单提交成功,等待行情数据成交', + 'data': { + 'order_no': order.order_no, + 'status': 'PENDING', + 'order_qty': order.order_qty, + 'order_price': float(order.order_price) if order.order_price else None + } + }) + else: + return jsonify({ + 'success': False, + 'error': order.reject_reason or '订单状态异常' + }), 400 + else: + return jsonify({ + 'success': False, + 'error': order.reject_reason or '订单执行失败' + }), 400 + + except Exception as e: + db.session.rollback() + return jsonify({'success': False, 'error': str(e)}), 500 + + +@app.route('/api/simulation/cancel-order/', methods=['POST']) +@login_required +def cancel_simulation_order(order_id): + """撤销订单""" + try: + account = get_or_create_simulation_account(current_user.id) + + order = SimulationOrder.query.filter_by( + id=order_id, + account_id=account.id, + status='PENDING' + ).first() + + if not order: + return jsonify({'success': False, 'error': '订单不存在或无法撤销'}), 404 + + order.status = 'CANCELLED' + order.cancel_time = beijing_now() + + db.session.commit() + + return jsonify({ + 'success': True, + 'message': '订单已撤销' + }) + + except Exception as e: + db.session.rollback() + return jsonify({'success': False, 'error': str(e)}), 500 + + +@app.route('/api/simulation/transactions', methods=['GET']) +@login_required +def get_simulation_transactions(): + """获取成交记录""" + try: + account = get_or_create_simulation_account(current_user.id) + + # 获取查询参数 + date_str = request.args.get('date') + limit = request.args.get('limit', 100, type=int) + + query = SimulationTransaction.query.filter_by(account_id=account.id) + + if date_str: + try: + date = datetime.strptime(date_str, '%Y-%m-%d').date() + start_time = datetime.combine(date, dt_time(0, 0, 0)) + end_time = datetime.combine(date, dt_time(23, 59, 59)) + query = query.filter(SimulationTransaction.transaction_time.between(start_time, end_time)) + except ValueError: + pass + + transactions = query.order_by(SimulationTransaction.transaction_time.desc()).limit(limit).all() + + transactions_data = [] + for trans in transactions: + transactions_data.append({ + 'id': trans.id, + 'transaction_no': trans.transaction_no, + 'stock_code': trans.stock_code, + 'stock_name': trans.stock_name, + 'transaction_type': trans.transaction_type, + 'transaction_price': float(trans.transaction_price), + 'transaction_qty': trans.transaction_qty, + 'transaction_amount': float(trans.transaction_amount), + 'commission': float(trans.commission), + 'stamp_tax': float(trans.stamp_tax), + 'transfer_fee': float(trans.transfer_fee), + 'total_fee': float(trans.total_fee), + 'transaction_time': trans.transaction_time.isoformat(), + 'settlement_date': trans.settlement_date.isoformat() if trans.settlement_date else None + }) + + return jsonify({ + 'success': True, + 'data': transactions_data + }) + + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + + +def get_simulation_statistics(): + """获取模拟交易统计""" + try: + account = get_or_create_simulation_account(current_user.id) + + # 获取统计时间范围 + days = request.args.get('days', 30, type=int) + end_date = beijing_now().date() + start_date = end_date - timedelta(days=days) + + # 查询日统计数据 + daily_stats = SimulationDailyStats.query.filter( + SimulationDailyStats.account_id == account.id, + SimulationDailyStats.stat_date >= start_date, + SimulationDailyStats.stat_date <= end_date + ).order_by(SimulationDailyStats.stat_date).all() + + # 查询总体统计 + total_transactions = SimulationTransaction.query.filter_by(account_id=account.id).count() + win_transactions = SimulationTransaction.query.filter( + SimulationTransaction.account_id == account.id, + SimulationTransaction.transaction_type == 'SELL' + ).all() + + win_count = 0 + total_profit = Decimal('0') + for trans in win_transactions: + # 查找对应的买入记录计算盈亏 + position = SimulationPosition.query.filter_by( + account_id=account.id, + stock_code=trans.stock_code + ).first() + if position and trans.transaction_price > position.avg_cost: + win_count += 1 + profit = (trans.transaction_price - position.avg_cost) * trans.transaction_qty if position else 0 + total_profit += profit + + # 构建日收益曲线 + daily_returns = [] + for stat in daily_stats: + daily_returns.append({ + 'date': stat.stat_date.isoformat(), + 'daily_profit': float(stat.daily_profit), + 'daily_profit_rate': float(stat.daily_profit_rate), + 'total_profit': float(stat.total_profit), + 'total_profit_rate': float(stat.total_profit_rate), + 'closing_assets': float(stat.closing_assets) + }) + + return jsonify({ + 'success': True, + 'data': { + 'summary': { + 'total_transactions': total_transactions, + 'win_count': win_count, + 'win_rate': (win_count / len(win_transactions) * 100) if win_transactions else 0, + 'total_profit': float(total_profit), + 'average_profit_per_trade': float(total_profit / len(win_transactions)) if win_transactions else 0 + }, + 'daily_returns': daily_returns + } + }) + + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + + +@app.route('/api/simulation/t1-settlement', methods=['POST']) +@login_required +def trigger_t1_settlement(): + """手动触发T+1结算""" + try: + # 导入后台处理器的函数 + from simulation_background_processor import process_t1_settlement + + # 执行T+1结算 + process_t1_settlement() + + return jsonify({ + 'success': True, + 'message': 'T+1结算执行成功' + }) + + except Exception as e: + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + + +@app.route('/api/simulation/debug-positions', methods=['GET']) +@login_required +def debug_positions(): + """调试接口:查看持仓数据""" + try: + account = get_or_create_simulation_account(current_user.id) + + positions = SimulationPosition.query.filter_by(account_id=account.id).all() + + positions_data = [] + for position in positions: + positions_data.append({ + 'stock_code': position.stock_code, + 'stock_name': position.stock_name, + 'position_qty': position.position_qty, + 'available_qty': position.available_qty, + 'frozen_qty': position.frozen_qty, + 'avg_cost': float(position.avg_cost), + 'current_price': float(position.current_price or 0) + }) + + return jsonify({ + 'success': True, + 'data': positions_data + }) + + except Exception as e: + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + + +@app.route('/api/simulation/debug-transactions', methods=['GET']) +@login_required +def debug_transactions(): + """调试接口:查看成交记录数据""" + try: + account = get_or_create_simulation_account(current_user.id) + + transactions = SimulationTransaction.query.filter_by(account_id=account.id).all() + + transactions_data = [] + for trans in transactions: + transactions_data.append({ + 'id': trans.id, + 'transaction_no': trans.transaction_no, + 'stock_code': trans.stock_code, + 'stock_name': trans.stock_name, + 'transaction_type': trans.transaction_type, + 'transaction_price': float(trans.transaction_price), + 'transaction_qty': trans.transaction_qty, + 'transaction_amount': float(trans.transaction_amount), + 'commission': float(trans.commission), + 'stamp_tax': float(trans.stamp_tax), + 'transfer_fee': float(trans.transfer_fee), + 'total_fee': float(trans.total_fee), + 'transaction_time': trans.transaction_time.isoformat(), + 'settlement_date': trans.settlement_date.isoformat() if trans.settlement_date else None + }) + + return jsonify({ + 'success': True, + 'data': transactions_data, + 'count': len(transactions_data) + }) + + except Exception as e: + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + + +@app.route('/api/simulation/daily-settlement', methods=['POST']) +@login_required +def trigger_daily_settlement(): + """手动触发日结算""" + try: + # 导入后台处理器的函数 + from simulation_background_processor import generate_daily_stats + + # 执行日结算 + generate_daily_stats() + + return jsonify({ + 'success': True, + 'message': '日结算执行成功' + }) + + except Exception as e: + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + + +@app.route('/api/simulation/reset', methods=['POST']) +@login_required +def reset_simulation_account(): + """重置模拟账户""" + try: + account = SimulationAccount.query.filter_by(user_id=current_user.id).first() + + if account: + # 删除所有相关数据 + SimulationPosition.query.filter_by(account_id=account.id).delete() + SimulationOrder.query.filter_by(account_id=account.id).delete() + SimulationTransaction.query.filter_by(account_id=account.id).delete() + SimulationDailyStats.query.filter_by(account_id=account.id).delete() + + # 重置账户数据 + account.available_cash = account.initial_capital + account.frozen_cash = Decimal('0') + account.position_value = Decimal('0') + account.total_assets = account.initial_capital + account.total_profit = Decimal('0') + account.total_profit_rate = Decimal('0') + account.daily_profit = Decimal('0') + account.daily_profit_rate = Decimal('0') + account.updated_at = beijing_now() + + db.session.commit() + + return jsonify({ + 'success': True, + 'message': '模拟账户已重置' + }) + + except Exception as e: + db.session.rollback() + return jsonify({'success': False, 'error': str(e)}), 500 + + +if __name__ == '__main__': + # 创建数据库表 + with app.app_context(): + try: + db.create_all() + # 安全地初始化订阅套餐 + initialize_subscription_plans_safe() + except Exception as e: + app.logger.error(f"数据库初始化失败: {e}") + + # 初始化事件轮询机制(WebSocket 推送) + initialize_event_polling() + + # 使用 socketio.run 替代 app.run 以支持 WebSocket + socketio.run(app, host='0.0.0.0', port=5001, debug=False, allow_unsafe_werkzeug=True) \ No newline at end of file diff --git a/app.py.backup_20251114_145340 b/app.py.backup_20251114_145340 new file mode 100644 index 00000000..b5922c1a --- /dev/null +++ b/app.py.backup_20251114_145340 @@ -0,0 +1,12556 @@ +import base64 +import csv +import io +import os +import time +import urllib +import uuid +from functools import wraps +import qrcode +from flask_mail import Mail, Message +from flask_socketio import SocketIO, emit, join_room, leave_room +import pytz +import requests +from celery import Celery +from flask_compress import Compress +from pathlib import Path +import json +from sqlalchemy import Column, Integer, String, Boolean, DateTime, create_engine, text, func, or_ +from flask import Flask, render_template, request, jsonify, redirect, url_for, flash, session, render_template_string, \ + current_app, make_response +from flask_sqlalchemy import SQLAlchemy +from flask_login import LoginManager, UserMixin, login_user, logout_user, login_required, current_user +import random +from werkzeug.security import generate_password_hash, check_password_hash +import re +import string +from datetime import datetime, timedelta, time as dt_time, date +from clickhouse_driver import Client as Cclient +from flask_cors import CORS + +from collections import defaultdict +from functools import lru_cache +import jieba +import jieba.analyse +from flask_cors import cross_origin +from tencentcloud.common import credential +from tencentcloud.common.profile.client_profile import ClientProfile +from tencentcloud.common.profile.http_profile import HttpProfile +from tencentcloud.sms.v20210111 import sms_client, models +from tencentcloud.common.exception.tencent_cloud_sdk_exception import TencentCloudSDKException +from sqlalchemy import text, desc, and_ +import pandas as pd +from decimal import Decimal +from apscheduler.schedulers.background import BackgroundScheduler + +# 交易日数据缓存 +trading_days = [] +trading_days_set = set() + + +def load_trading_days(): + """加载交易日数据""" + global trading_days, trading_days_set + try: + with open('tdays.csv', 'r') as f: + reader = csv.DictReader(f) + for row in reader: + date_str = row['DateTime'] + # 解析日期 (格式: 2010/1/4) + date = datetime.strptime(date_str, '%Y/%m/%d').date() + trading_days.append(date) + trading_days_set.add(date) + + # 排序交易日 + trading_days.sort() + print(f"成功加载 {len(trading_days)} 个交易日数据") + except Exception as e: + print(f"加载交易日数据失败: {e}") + + +def get_trading_day_near_date(target_date): + """ + 获取距离目标日期最近的交易日 + 如果目标日期是交易日,返回该日期 + 如果不是,返回下一个交易日 + """ + if not trading_days: + load_trading_days() + + if not trading_days: + return None + + # 如果目标日期是datetime,转换为date + if isinstance(target_date, datetime): + target_date = target_date.date() + + # 检查目标日期是否是交易日 + if target_date in trading_days_set: + return target_date + + # 查找下一个交易日 + for trading_day in trading_days: + if trading_day >= target_date: + return trading_day + + # 如果没有找到,返回最后一个交易日 + return trading_days[-1] if trading_days else None + + +# 应用启动时加载交易日数据 +load_trading_days() + +engine = create_engine( + "mysql+pymysql://root:Zzl5588161!@222.128.1.157:33060/stock?charset=utf8mb4", + echo=False, + pool_size=10, + pool_recycle=3600, + pool_pre_ping=True, + pool_timeout=30, + max_overflow=20 +) +engine_med = create_engine( + "mysql+pymysql://root:Zzl5588161!@222.128.1.157:33060/med?charset=utf8mb4", + echo=False, + pool_size=5, + pool_recycle=3600, + pool_pre_ping=True, + pool_timeout=30, + max_overflow=10 +) +engine_2 = create_engine( + "mysql+pymysql://root:Zzl5588161!@222.128.1.157:33060/valuefrontier?charset=utf8mb4", + echo=False, + pool_size=5, + pool_recycle=3600, + pool_pre_ping=True, + pool_timeout=30, + max_overflow=10 +) +app = Flask(__name__) +# 存储验证码的临时字典(生产环境应使用Redis) +verification_codes = {} +wechat_qr_sessions = {} +# 腾讯云短信配置 +SMS_SECRET_ID = 'AKID2we9TacdTAhCjCSYTErHVimeJo9Yr00s' +SMS_SECRET_KEY = 'pMlBWijlkgT9fz5ziEXdWEnAPTJzRfkf' +SMS_SDK_APP_ID = "1400972398" +SMS_SIGN_NAME = "价值前沿科技" +SMS_TEMPLATE_REGISTER = "2386557" # 注册模板 +SMS_TEMPLATE_LOGIN = "2386540" # 登录模板 + +# 微信开放平台配置 +WECHAT_APPID = 'wxa8d74c47041b5f87' +WECHAT_APPSECRET = 'eedef95b11787fd7ca7f1acc6c9061bc' +WECHAT_REDIRECT_URI = 'http://valuefrontier.cn/api/auth/wechat/callback' + +# 邮件服务配置(QQ企业邮箱) +MAIL_SERVER = 'smtp.exmail.qq.com' +MAIL_PORT = 465 +MAIL_USE_SSL = True +MAIL_USE_TLS = False +MAIL_USERNAME = 'admin@valuefrontier.cn' +MAIL_PASSWORD = 'QYncRu6WUdASvTg4' +MAIL_DEFAULT_SENDER = 'admin@valuefrontier.cn' + +# Session和安全配置 +app.config['SECRET_KEY'] = ''.join(random.choices(string.ascii_letters + string.digits, k=32)) +app.config['SESSION_COOKIE_SECURE'] = False # 如果生产环境使用HTTPS,应设为True +app.config['SESSION_COOKIE_HTTPONLY'] = True # 生产环境应设为True,防止XSS攻击 +app.config['SESSION_COOKIE_SAMESITE'] = 'Lax' # 使用'Lax'以平衡安全性和功能性 +app.config['SESSION_COOKIE_DOMAIN'] = None # 不限制域名 +app.config['SESSION_COOKIE_PATH'] = '/' # 设置cookie路径 +app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(days=7) # session持续7天 +app.config['REMEMBER_COOKIE_DURATION'] = timedelta(days=30) # 记住登录30天 +app.config['REMEMBER_COOKIE_SECURE'] = False # 记住登录cookie不要求HTTPS +app.config['REMEMBER_COOKIE_HTTPONLY'] = False # 允许JavaScript访问 + +# 配置邮件 +app.config['MAIL_SERVER'] = MAIL_SERVER +app.config['MAIL_PORT'] = MAIL_PORT +app.config['MAIL_USE_SSL'] = MAIL_USE_SSL +app.config['MAIL_USE_TLS'] = MAIL_USE_TLS +app.config['MAIL_USERNAME'] = MAIL_USERNAME +app.config['MAIL_PASSWORD'] = MAIL_PASSWORD +app.config['MAIL_DEFAULT_SENDER'] = MAIL_DEFAULT_SENDER + +# 允许前端跨域访问 - 修复CORS配置 +try: + CORS(app, + origins=["http://localhost:3000", "http://127.0.0.1:3000", "http://localhost:5173", "https://valuefrontier.cn", + "http://valuefrontier.cn"], # 明确指定允许的源 + methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"], + allow_headers=["Content-Type", "Authorization", "X-Requested-With"], + supports_credentials=True, # 允许携带凭据 + expose_headers=["Content-Type", "Authorization"]) +except ImportError: + pass # 如果未安装flask_cors则跳过 + +# 初始化 Flask-Login +login_manager = LoginManager() +login_manager.init_app(app) +login_manager.login_view = 'login' +login_manager.login_message = '请先登录访问此页面' +login_manager.remember_cookie_duration = timedelta(days=30) # 记住登录持续时间 +Compress(app) +MAX_CONTENT_LENGTH = 16 * 1024 * 1024 # 16MB max file size +# Configure Flask-Compress +app.config['COMPRESS_ALGORITHM'] = ['gzip', 'br'] +app.config['COMPRESS_MIMETYPES'] = [ + 'text/html', + 'text/css', + 'text/xml', + 'application/json', + 'application/javascript', + 'application/x-javascript' +] +app.config['SQLALCHEMY_DATABASE_URI'] = 'mysql+pymysql://root:Zzl5588161!@222.128.1.157:33060/stock?charset=utf8mb4' +app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False +app.config['SQLALCHEMY_ENGINE_OPTIONS'] = { + 'pool_size': 10, + 'pool_recycle': 3600, + 'pool_pre_ping': True, + 'pool_timeout': 30, + 'max_overflow': 20 +} +# Cache directory setup +CACHE_DIR = Path('cache') +CACHE_DIR.mkdir(exist_ok=True) + + +def beijing_now(): + # 使用 pytz 处理时区,但返回 naive datetime(适合数据库存储) + beijing_tz = pytz.timezone('Asia/Shanghai') + return datetime.now(beijing_tz).replace(tzinfo=None) + + +# 检查用户是否登录的装饰器 +def login_required(f): + @wraps(f) + def decorated_function(*args, **kwargs): + if 'user_id' not in session: + return jsonify({'success': False, 'error': '未登录'}), 401 + return f(*args, **kwargs) + + return decorated_function + + +# Memory management constants +MAX_MEMORY_PERCENT = 75 +MEMORY_CHECK_INTERVAL = 300 +MAX_CACHE_ITEMS = 50 +db = SQLAlchemy(app) + +# 初始化邮件服务 +mail = Mail(app) + +# 初始化 Flask-SocketIO(用于实时事件推送) +socketio = SocketIO( + app, + cors_allowed_origins=["http://localhost:3000", "http://127.0.0.1:3000", "http://localhost:5173", + "https://valuefrontier.cn", "http://valuefrontier.cn"], + async_mode='gevent', + logger=True, + engineio_logger=False, + ping_timeout=120, # 心跳超时时间(秒),客户端120秒内无响应才断开 + ping_interval=25 # 心跳检测间隔(秒),每25秒发送一次ping +) + + +@login_manager.user_loader +def load_user(user_id): + """Flask-Login 用户加载回调""" + try: + return User.query.get(int(user_id)) + except Exception as e: + app.logger.error(f"用户加载错误: {e}") + return None + + +# 全局错误处理器 - 确保API接口始终返回JSON +@app.errorhandler(404) +def not_found_error(error): + """404错误处理""" + if request.path.startswith('/api/'): + return jsonify({'success': False, 'error': '接口不存在'}), 404 + return error + + +@app.errorhandler(500) +def internal_error(error): + """500错误处理""" + db.session.rollback() + if request.path.startswith('/api/'): + return jsonify({'success': False, 'error': '服务器内部错误'}), 500 + return error + + +@app.errorhandler(405) +def method_not_allowed_error(error): + """405错误处理""" + if request.path.startswith('/api/'): + return jsonify({'success': False, 'error': '请求方法不被允许'}), 405 + return error + + +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 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')) + + # 时间 + 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) + + # 状态 + status = db.Column(db.String(20), default='active') # active/hidden/deleted + + # 关系 + user = db.relationship('User', backref='comments') + replies = db.relationship('Comment', backref=db.backref('parent', remote_side=[id])) + + +class User(UserMixin, db.Model): + """用户模型 - 完全匹配现有数据库表结构""" + __tablename__ = 'user' + + # 主键 + id = db.Column(db.Integer, primary_key=True, autoincrement=True) + + # 基础账号信息 + username = db.Column(db.String(80), unique=True, nullable=False) + email = db.Column(db.String(120), unique=True, nullable=True) + password_hash = db.Column(db.String(255), nullable=True) + email_confirmed = db.Column(db.Boolean, nullable=True, default=True) + + # 时间字段 + created_at = db.Column(db.DateTime, nullable=True, default=beijing_now) + last_seen = db.Column(db.DateTime, nullable=True, default=beijing_now) + + # 账号状态 + status = db.Column(db.String(20), nullable=True, default='active') + + # 个人资料信息 + nickname = db.Column(db.String(30), nullable=True) + avatar_url = db.Column(db.String(200), nullable=True) + banner_url = db.Column(db.String(200), nullable=True) + bio = db.Column(db.String(200), nullable=True) + gender = db.Column(db.String(10), nullable=True) + birth_date = db.Column(db.Date, nullable=True) + location = db.Column(db.String(100), nullable=True) + + # 联系方式 + phone = db.Column(db.String(20), nullable=True) + wechat_id = db.Column(db.String(80), nullable=True) # 微信号 + + # 实名认证 + real_name = db.Column(db.String(30), nullable=True) + id_number = db.Column(db.String(18), nullable=True) + is_verified = db.Column(db.Boolean, nullable=True, default=False) + verify_time = db.Column(db.DateTime, nullable=True) + + # 投资偏好 + trading_experience = db.Column(db.String(200), nullable=True) + investment_style = db.Column(db.String(50), nullable=True) + risk_preference = db.Column(db.String(20), nullable=True) + investment_amount = db.Column(db.String(20), nullable=True) + preferred_markets = db.Column(db.String(200), nullable=True) + + # 社区数据 + user_level = db.Column(db.Integer, nullable=True, default=1) + reputation_score = db.Column(db.Integer, nullable=True, default=0) + contribution_point = db.Column(db.Integer, nullable=True, default=0) + post_count = db.Column(db.Integer, nullable=True, default=0) + comment_count = db.Column(db.Integer, nullable=True, default=0) + follower_count = db.Column(db.Integer, nullable=True, default=0) + following_count = db.Column(db.Integer, nullable=True, default=0) + + # 创作者相关 + is_creator = db.Column(db.Boolean, nullable=True, default=False) + creator_type = db.Column(db.String(20), nullable=True) + creator_tags = db.Column(db.String(200), nullable=True) + + # 通知设置 + email_notifications = db.Column(db.Boolean, nullable=True, default=True) + sms_notifications = db.Column(db.Boolean, nullable=True, default=False) + wechat_notifications = db.Column(db.Boolean, nullable=True, default=False) + notification_preferences = db.Column(db.String(500), nullable=True) + + # 隐私和界面设置 + privacy_level = db.Column(db.String(20), nullable=True, default='public') + theme_preference = db.Column(db.String(20), nullable=True, default='light') + blocked_keywords = db.Column(db.String(500), nullable=True) + + # 手机验证相关 + phone_confirmed = db.Column(db.Boolean, nullable=True, default=False) # 注意:原表中是blob,这里改为Boolean更合理 + phone_confirm_time = db.Column(db.DateTime, nullable=True) + + # 微信登录相关字段 + wechat_union_id = db.Column(db.String(100), nullable=True) # 微信UnionID + wechat_open_id = db.Column(db.String(100), nullable=True) # 微信OpenID + + 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.nickname = username # 默认昵称为用户名 + self.created_at = beijing_now() + self.last_seen = beijing_now() + + def set_password(self, password): + """设置密码""" + if password: + self.password_hash = generate_password_hash(password) + + def check_password(self, password): + """验证密码""" + if not password or not self.password_hash: + return False + return check_password_hash(self.password_hash, password) + + def update_last_seen(self): + """更新最后活跃时间""" + self.last_seen = beijing_now() + db.session.commit() + + def confirm_email(self): + """确认邮箱""" + self.email_confirmed = True + db.session.commit() + + def confirm_phone(self): + """确认手机号""" + self.phone_confirmed = True + self.phone_confirm_time = beijing_now() + db.session.commit() + + def bind_wechat(self, open_id, union_id=None, wechat_info=None): + """绑定微信账号""" + self.wechat_open_id = open_id + if union_id: + self.wechat_union_id = union_id + + # 如果提供了微信用户信息,更新头像和昵称 + if wechat_info: + if not self.avatar_url and wechat_info.get('headimgurl'): + self.avatar_url = wechat_info['headimgurl'] + if not self.nickname and wechat_info.get('nickname'): + # 确保昵称编码正确且长度合理 + nickname = self._sanitize_nickname(wechat_info['nickname']) + self.nickname = nickname + + db.session.commit() + + def _sanitize_nickname(self, nickname): + """清理和验证昵称""" + if not nickname: + return '微信用户' + + try: + # 确保是正确的UTF-8字符串 + sanitized = str(nickname).strip() + + # 移除可能的控制字符 + import re + sanitized = re.sub(r'[\x00-\x1f\x7f-\x9f]', '', sanitized) + + # 限制长度(避免过长的昵称) + if len(sanitized) > 50: + sanitized = sanitized[:47] + '...' + + # 如果清理后为空,使用默认值 + if not sanitized: + sanitized = '微信用户' + + return sanitized + except Exception as e: + return '微信用户' + + def unbind_wechat(self): + """解绑微信账号""" + self.wechat_open_id = None + self.wechat_union_id = None + db.session.commit() + + def increment_post_count(self): + """增加发帖数""" + self.post_count = (self.post_count or 0) + 1 + db.session.commit() + + def increment_comment_count(self): + """增加评论数""" + self.comment_count = (self.comment_count or 0) + 1 + db.session.commit() + + def add_reputation(self, points): + """增加声誉分数""" + self.reputation_score = (self.reputation_score or 0) + points + db.session.commit() + + def to_dict(self, include_sensitive=False): + """转换为字典""" + data = { + 'id': self.id, + 'username': self.username, + 'nickname': self.nickname or self.username, + 'avatar_url': self.avatar_url, + 'banner_url': self.banner_url, + 'bio': self.bio, + 'gender': self.gender, + 'location': self.location, + 'user_level': self.user_level or 1, + 'reputation_score': self.reputation_score or 0, + 'contribution_point': self.contribution_point or 0, + 'post_count': self.post_count or 0, + 'comment_count': self.comment_count or 0, + 'follower_count': self.follower_count or 0, + 'following_count': self.following_count or 0, + 'is_creator': self.is_creator or False, + 'creator_type': self.creator_type, + 'creator_tags': self.creator_tags, + 'is_verified': self.is_verified or False, + 'created_at': self.created_at.isoformat() if self.created_at else None, + 'last_seen': self.last_seen.isoformat() if self.last_seen else None, + 'status': self.status, + 'has_wechat': bool(self.wechat_open_id), + 'is_authenticated': True + } + + # 敏感信息只在需要时包含 + if include_sensitive: + data.update({ + 'email': self.email, + 'phone': self.phone, + 'email_confirmed': self.email_confirmed, + 'phone_confirmed': self.phone_confirmed, + 'real_name': self.real_name, + 'birth_date': self.birth_date.isoformat() if self.birth_date else None, + 'trading_experience': self.trading_experience, + 'investment_style': self.investment_style, + 'risk_preference': self.risk_preference, + 'investment_amount': self.investment_amount, + 'preferred_markets': self.preferred_markets, + 'email_notifications': self.email_notifications, + 'sms_notifications': self.sms_notifications, + 'wechat_notifications': self.wechat_notifications, + 'privacy_level': self.privacy_level, + 'theme_preference': self.theme_preference + }) + + return data + + def to_public_dict(self): + """公开信息字典(用于显示给其他用户)""" + return { + 'id': self.id, + 'username': self.username, + 'nickname': self.nickname or self.username, + 'avatar_url': self.avatar_url, + 'bio': self.bio, + 'user_level': self.user_level or 1, + 'reputation_score': self.reputation_score or 0, + 'post_count': self.post_count or 0, + 'follower_count': self.follower_count or 0, + 'is_creator': self.is_creator or False, + 'creator_type': self.creator_type, + 'is_verified': self.is_verified or False, + 'created_at': self.created_at.isoformat() if self.created_at else None + } + + @staticmethod + def find_by_login_info(login_info): + """根据登录信息查找用户(支持用户名、邮箱、手机号)""" + return User.query.filter( + db.or_( + User.username == login_info, + User.email == login_info, + User.phone == login_info + ) + ).first() + + @staticmethod + def find_by_wechat_openid(open_id): + """根据微信OpenID查找用户""" + return User.query.filter_by(wechat_open_id=open_id).first() + + @staticmethod + def find_by_wechat_unionid(union_id): + """根据微信UnionID查找用户""" + return User.query.filter_by(wechat_union_id=union_id).first() + + @staticmethod + def is_username_taken(username): + """检查用户名是否已被使用""" + return User.query.filter_by(username=username).first() is not None + + @staticmethod + def is_email_taken(email): + """检查邮箱是否已被使用""" + return User.query.filter_by(email=email).first() is not None + + @staticmethod + def is_phone_taken(phone): + """检查手机号是否已被使用""" + return User.query.filter_by(phone=phone).first() is not None + + def __repr__(self): + return f'' + + +# ============================================ +# 订阅功能模块(安全版本 - 独立表) +# ============================================ +class UserSubscription(db.Model): + """用户订阅表 - 独立于现有User表""" + __tablename__ = 'user_subscriptions' + + id = db.Column(db.Integer, primary_key=True, autoincrement=True) + user_id = db.Column(db.Integer, nullable=False, unique=True, index=True) + subscription_type = db.Column(db.String(10), nullable=False, default='free') + subscription_status = db.Column(db.String(20), nullable=False, default='active') + start_date = db.Column(db.DateTime, nullable=True) + end_date = db.Column(db.DateTime, nullable=True) + billing_cycle = db.Column(db.String(10), nullable=True) + auto_renewal = db.Column(db.Boolean, nullable=False, default=False) + created_at = db.Column(db.DateTime, default=beijing_now) + updated_at = db.Column(db.DateTime, default=beijing_now, onupdate=beijing_now) + + def is_active(self): + if self.subscription_status != 'active': + return False + if self.subscription_type == 'free': + return True + if self.end_date: + try: + now = beijing_now() + if self.end_date < now: + return False + except Exception as e: + return False + return True + + def days_left(self): + if self.subscription_type == 'free' or not self.end_date: + return 999 + try: + now = beijing_now() + delta = self.end_date - now + return max(0, delta.days) + except Exception as e: + return 0 + + def to_dict(self): + return { + 'type': self.subscription_type, + 'status': self.subscription_status, + 'is_active': self.is_active(), + 'days_left': self.days_left(), + 'start_date': self.start_date.isoformat() if self.start_date else None, + 'end_date': self.end_date.isoformat() if self.end_date else None, + 'billing_cycle': self.billing_cycle, + 'auto_renewal': self.auto_renewal + } + + +class SubscriptionPlan(db.Model): + """订阅套餐表""" + __tablename__ = 'subscription_plans' + + id = db.Column(db.Integer, primary_key=True, autoincrement=True) + name = db.Column(db.String(50), nullable=False, unique=True) + display_name = db.Column(db.String(100), nullable=False) + description = db.Column(db.Text, nullable=True) + monthly_price = db.Column(db.Numeric(10, 2), nullable=False) + yearly_price = db.Column(db.Numeric(10, 2), nullable=False) + features = db.Column(db.Text, nullable=True) + pricing_options = db.Column(db.Text, nullable=True) # JSON格式:[{"months": 1, "price": 99}, {"months": 12, "price": 999}] + is_active = db.Column(db.Boolean, default=True) + sort_order = db.Column(db.Integer, default=0) + created_at = db.Column(db.DateTime, default=beijing_now) + + def to_dict(self): + # 解析pricing_options(如果存在) + pricing_opts = None + if self.pricing_options: + try: + pricing_opts = json.loads(self.pricing_options) + except: + pricing_opts = None + + # 如果没有pricing_options,则从monthly_price和yearly_price生成默认选项 + if not pricing_opts: + pricing_opts = [ + { + 'months': 1, + 'price': float(self.monthly_price) if self.monthly_price else 0, + 'label': '月付', + 'cycle_key': 'monthly' + }, + { + 'months': 12, + 'price': float(self.yearly_price) if self.yearly_price else 0, + 'label': '年付', + 'cycle_key': 'yearly', + 'discount_percent': 20 # 年付默认20%折扣 + } + ] + + return { + 'id': self.id, + 'name': self.name, + 'display_name': self.display_name, + 'description': self.description, + 'monthly_price': float(self.monthly_price) if self.monthly_price else 0, + 'yearly_price': float(self.yearly_price) if self.yearly_price else 0, + 'pricing_options': pricing_opts, # 新增:灵活计费周期选项 + 'features': json.loads(self.features) if self.features else [], + 'is_active': self.is_active, + 'sort_order': self.sort_order + } + + +class PaymentOrder(db.Model): + """支付订单表""" + __tablename__ = 'payment_orders' + + id = db.Column(db.Integer, primary_key=True, autoincrement=True) + order_no = db.Column(db.String(32), unique=True, nullable=False) + user_id = db.Column(db.Integer, nullable=False) + plan_name = db.Column(db.String(20), nullable=False) + billing_cycle = db.Column(db.String(10), nullable=False) + amount = db.Column(db.Numeric(10, 2), nullable=False) + wechat_order_id = db.Column(db.String(64), nullable=True) + prepay_id = db.Column(db.String(64), nullable=True) + qr_code_url = db.Column(db.String(200), nullable=True) + status = db.Column(db.String(20), default='pending') + created_at = db.Column(db.DateTime, default=beijing_now) + paid_at = db.Column(db.DateTime, nullable=True) + expired_at = db.Column(db.DateTime, nullable=True) + remark = db.Column(db.String(200), nullable=True) + + def __init__(self, user_id, plan_name, billing_cycle, amount): + self.user_id = user_id + self.plan_name = plan_name + self.billing_cycle = billing_cycle + self.amount = amount + import random + timestamp = int(beijing_now().timestamp() * 1000000) + random_suffix = random.randint(1000, 9999) + self.order_no = f"{timestamp}{user_id:04d}{random_suffix}" + self.expired_at = beijing_now() + timedelta(minutes=30) + + def is_expired(self): + if not self.expired_at: + return False + try: + now = beijing_now() + return now > self.expired_at + except Exception as e: + return False + + def mark_as_paid(self, wechat_order_id, transaction_id=None): + self.status = 'paid' + self.paid_at = beijing_now() + self.wechat_order_id = wechat_order_id + + def to_dict(self): + return { + 'id': self.id, + 'order_no': self.order_no, + 'user_id': self.user_id, + 'plan_name': self.plan_name, + 'billing_cycle': self.billing_cycle, + 'amount': float(self.amount) if self.amount else 0, + 'original_amount': float(self.original_amount) if hasattr(self, 'original_amount') and self.original_amount else None, + 'discount_amount': float(self.discount_amount) if hasattr(self, 'discount_amount') and self.discount_amount else 0, + 'promo_code': self.promo_code.code if hasattr(self, 'promo_code') and self.promo_code else None, + 'is_upgrade': self.is_upgrade if hasattr(self, 'is_upgrade') else False, + 'qr_code_url': self.qr_code_url, + 'status': self.status, + 'is_expired': self.is_expired(), + 'created_at': self.created_at.isoformat() if self.created_at else None, + 'paid_at': self.paid_at.isoformat() if self.paid_at else None, + 'expired_at': self.expired_at.isoformat() if self.expired_at else None, + 'remark': self.remark + } + + +class PromoCode(db.Model): + """优惠码表""" + __tablename__ = 'promo_codes' + + id = db.Column(db.Integer, primary_key=True, autoincrement=True) + code = db.Column(db.String(50), unique=True, nullable=False, index=True) + description = db.Column(db.String(200), nullable=True) + + # 折扣类型和值 + discount_type = db.Column(db.String(20), nullable=False) # 'percentage' 或 'fixed_amount' + discount_value = db.Column(db.Numeric(10, 2), nullable=False) + + # 适用范围 + applicable_plans = db.Column(db.String(200), nullable=True) # JSON格式 + applicable_cycles = db.Column(db.String(50), nullable=True) # JSON格式 + min_amount = db.Column(db.Numeric(10, 2), nullable=True) + + # 使用限制 + max_uses = db.Column(db.Integer, nullable=True) + max_uses_per_user = db.Column(db.Integer, default=1) + current_uses = db.Column(db.Integer, default=0) + + # 有效期 + valid_from = db.Column(db.DateTime, nullable=False) + valid_until = db.Column(db.DateTime, nullable=False) + + # 状态 + is_active = db.Column(db.Boolean, default=True) + created_by = db.Column(db.Integer, nullable=True) + created_at = db.Column(db.DateTime, default=beijing_now) + updated_at = db.Column(db.DateTime, default=beijing_now, onupdate=beijing_now) + + def to_dict(self): + return { + 'id': self.id, + 'code': self.code, + 'description': self.description, + 'discount_type': self.discount_type, + 'discount_value': float(self.discount_value) if self.discount_value else 0, + 'applicable_plans': json.loads(self.applicable_plans) if self.applicable_plans else None, + 'applicable_cycles': json.loads(self.applicable_cycles) if self.applicable_cycles else None, + 'min_amount': float(self.min_amount) if self.min_amount else None, + 'max_uses': self.max_uses, + 'max_uses_per_user': self.max_uses_per_user, + 'current_uses': self.current_uses, + 'valid_from': self.valid_from.isoformat() if self.valid_from else None, + 'valid_until': self.valid_until.isoformat() if self.valid_until else None, + 'is_active': self.is_active + } + + +class PromoCodeUsage(db.Model): + """优惠码使用记录表""" + __tablename__ = 'promo_code_usage' + + id = db.Column(db.Integer, primary_key=True, autoincrement=True) + promo_code_id = db.Column(db.Integer, db.ForeignKey('promo_codes.id'), nullable=False) + user_id = db.Column(db.Integer, nullable=False, index=True) + order_id = db.Column(db.Integer, db.ForeignKey('payment_orders.id'), nullable=False) + + original_amount = db.Column(db.Numeric(10, 2), nullable=False) + discount_amount = db.Column(db.Numeric(10, 2), nullable=False) + final_amount = db.Column(db.Numeric(10, 2), nullable=False) + + used_at = db.Column(db.DateTime, default=beijing_now) + + # 关系 + promo_code = db.relationship('PromoCode', backref='usages') + order = db.relationship('PaymentOrder', backref='promo_usage') + + +class SubscriptionUpgrade(db.Model): + """订阅升级/降级记录表""" + __tablename__ = 'subscription_upgrades' + + id = db.Column(db.Integer, primary_key=True, autoincrement=True) + user_id = db.Column(db.Integer, nullable=False, index=True) + order_id = db.Column(db.Integer, db.ForeignKey('payment_orders.id'), nullable=False) + + # 原订阅信息 + from_plan = db.Column(db.String(20), nullable=False) + from_cycle = db.Column(db.String(10), nullable=False) + from_end_date = db.Column(db.DateTime, nullable=True) + + # 新订阅信息 + to_plan = db.Column(db.String(20), nullable=False) + to_cycle = db.Column(db.String(10), nullable=False) + to_end_date = db.Column(db.DateTime, nullable=False) + + # 价格计算 + remaining_value = db.Column(db.Numeric(10, 2), nullable=False) + upgrade_amount = db.Column(db.Numeric(10, 2), nullable=False) + actual_amount = db.Column(db.Numeric(10, 2), nullable=False) + + upgrade_type = db.Column(db.String(20), nullable=False) # 'plan_upgrade', 'cycle_change', 'both' + created_at = db.Column(db.DateTime, default=beijing_now) + + # 关系 + order = db.relationship('PaymentOrder', backref='upgrade_record') + + +# ============================================ +# 模拟盘相关模型 +# ============================================ +class SimulationAccount(db.Model): + """模拟账户""" + __tablename__ = 'simulation_accounts' + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False, unique=True) + account_name = db.Column(db.String(100), default='我的模拟账户') + initial_capital = db.Column(db.Numeric(15, 2), default=1000000.00) # 初始资金 + available_cash = db.Column(db.Numeric(15, 2), default=1000000.00) # 可用资金 + frozen_cash = db.Column(db.Numeric(15, 2), default=0.00) # 冻结资金 + position_value = db.Column(db.Numeric(15, 2), default=0.00) # 持仓市值 + total_assets = db.Column(db.Numeric(15, 2), default=1000000.00) # 总资产 + total_profit = db.Column(db.Numeric(15, 2), default=0.00) # 总盈亏 + total_profit_rate = db.Column(db.Numeric(10, 4), default=0.00) # 总收益率 + daily_profit = db.Column(db.Numeric(15, 2), default=0.00) # 日盈亏 + daily_profit_rate = db.Column(db.Numeric(10, 4), default=0.00) # 日收益率 + created_at = db.Column(db.DateTime, default=beijing_now) + updated_at = db.Column(db.DateTime, default=beijing_now, onupdate=beijing_now) + last_settlement_date = db.Column(db.Date) # 最后结算日期 + + # 关系 + user = db.relationship('User', backref='simulation_account') + positions = db.relationship('SimulationPosition', backref='account', lazy='dynamic') + orders = db.relationship('SimulationOrder', backref='account', lazy='dynamic') + transactions = db.relationship('SimulationTransaction', backref='account', lazy='dynamic') + + def calculate_total_assets(self): + """计算总资产""" + self.total_assets = self.available_cash + self.frozen_cash + self.position_value + self.total_profit = self.total_assets - self.initial_capital + self.total_profit_rate = (self.total_profit / self.initial_capital) * 100 if self.initial_capital > 0 else 0 + return self.total_assets + + +class SimulationPosition(db.Model): + """模拟持仓""" + __tablename__ = 'simulation_positions' + + id = db.Column(db.Integer, primary_key=True) + account_id = db.Column(db.Integer, db.ForeignKey('simulation_accounts.id'), nullable=False) + stock_code = db.Column(db.String(20), nullable=False) + stock_name = db.Column(db.String(100)) + position_qty = db.Column(db.Integer, default=0) # 持仓数量 + available_qty = db.Column(db.Integer, default=0) # 可用数量(T+1) + frozen_qty = db.Column(db.Integer, default=0) # 冻结数量 + avg_cost = db.Column(db.Numeric(10, 3), default=0.00) # 平均成本 + current_price = db.Column(db.Numeric(10, 3), default=0.00) # 当前价格 + market_value = db.Column(db.Numeric(15, 2), default=0.00) # 市值 + profit = db.Column(db.Numeric(15, 2), default=0.00) # 盈亏 + profit_rate = db.Column(db.Numeric(10, 4), default=0.00) # 盈亏比例 + today_profit = db.Column(db.Numeric(15, 2), default=0.00) # 今日盈亏 + today_profit_rate = db.Column(db.Numeric(10, 4), default=0.00) # 今日盈亏比例 + created_at = db.Column(db.DateTime, default=beijing_now) + updated_at = db.Column(db.DateTime, default=beijing_now, onupdate=beijing_now) + + __table_args__ = ( + db.UniqueConstraint('account_id', 'stock_code', name='unique_account_stock'), + ) + + def update_market_value(self, current_price): + """更新市值和盈亏""" + self.current_price = current_price + self.market_value = self.position_qty * current_price + total_cost = self.position_qty * self.avg_cost + self.profit = self.market_value - total_cost + self.profit_rate = (self.profit / total_cost * 100) if total_cost > 0 else 0 + return self.market_value + + +class SimulationOrder(db.Model): + """模拟订单""" + __tablename__ = 'simulation_orders' + + id = db.Column(db.Integer, primary_key=True) + account_id = db.Column(db.Integer, db.ForeignKey('simulation_accounts.id'), nullable=False) + order_no = db.Column(db.String(32), unique=True, nullable=False) + stock_code = db.Column(db.String(20), nullable=False) + stock_name = db.Column(db.String(100)) + order_type = db.Column(db.String(10), nullable=False) # BUY/SELL + price_type = db.Column(db.String(10), default='MARKET') # MARKET/LIMIT + order_price = db.Column(db.Numeric(10, 3)) # 委托价格 + order_qty = db.Column(db.Integer, nullable=False) # 委托数量 + filled_qty = db.Column(db.Integer, default=0) # 成交数量 + filled_price = db.Column(db.Numeric(10, 3)) # 成交价格 + filled_amount = db.Column(db.Numeric(15, 2)) # 成交金额 + commission = db.Column(db.Numeric(10, 2), default=0.00) # 手续费 + stamp_tax = db.Column(db.Numeric(10, 2), default=0.00) # 印花税 + transfer_fee = db.Column(db.Numeric(10, 2), default=0.00) # 过户费 + total_fee = db.Column(db.Numeric(10, 2), default=0.00) # 总费用 + status = db.Column(db.String(20), default='PENDING') # PENDING/PARTIAL/FILLED/CANCELLED/REJECTED + reject_reason = db.Column(db.String(200)) + order_time = db.Column(db.DateTime, default=beijing_now) + filled_time = db.Column(db.DateTime) + cancel_time = db.Column(db.DateTime) + + def calculate_fees(self): + """计算交易费用""" + if not self.filled_amount: + return 0 + + # 佣金(万分之2.5,最低5元) + self.commission = max(float(self.filled_amount) * 0.00025, 5.0) + + # 印花税(卖出时收取千分之1) + if self.order_type == 'SELL': + self.stamp_tax = float(self.filled_amount) * 0.001 + else: + self.stamp_tax = 0 + + # 过户费(双向收取,万分之0.2) + self.transfer_fee = float(self.filled_amount) * 0.00002 + + # 总费用 + self.total_fee = self.commission + self.stamp_tax + self.transfer_fee + + return self.total_fee + + +class SimulationTransaction(db.Model): + """模拟成交记录""" + __tablename__ = 'simulation_transactions' + + id = db.Column(db.Integer, primary_key=True) + account_id = db.Column(db.Integer, db.ForeignKey('simulation_accounts.id'), nullable=False) + order_id = db.Column(db.Integer, db.ForeignKey('simulation_orders.id'), nullable=False) + transaction_no = db.Column(db.String(32), unique=True, nullable=False) + stock_code = db.Column(db.String(20), nullable=False) + stock_name = db.Column(db.String(100)) + transaction_type = db.Column(db.String(10), nullable=False) # BUY/SELL + transaction_price = db.Column(db.Numeric(10, 3), nullable=False) + transaction_qty = db.Column(db.Integer, nullable=False) + transaction_amount = db.Column(db.Numeric(15, 2), nullable=False) + commission = db.Column(db.Numeric(10, 2), default=0.00) + stamp_tax = db.Column(db.Numeric(10, 2), default=0.00) + transfer_fee = db.Column(db.Numeric(10, 2), default=0.00) + total_fee = db.Column(db.Numeric(10, 2), default=0.00) + transaction_time = db.Column(db.DateTime, default=beijing_now) + settlement_date = db.Column(db.Date) # T+1结算日期 + + # 关系 + order = db.relationship('SimulationOrder', backref='transactions') + + +class SimulationDailyStats(db.Model): + """模拟账户日统计""" + __tablename__ = 'simulation_daily_stats' + + id = db.Column(db.Integer, primary_key=True) + account_id = db.Column(db.Integer, db.ForeignKey('simulation_accounts.id'), nullable=False) + stat_date = db.Column(db.Date, nullable=False) + opening_assets = db.Column(db.Numeric(15, 2)) # 期初资产 + closing_assets = db.Column(db.Numeric(15, 2)) # 期末资产 + daily_profit = db.Column(db.Numeric(15, 2)) # 日盈亏 + daily_profit_rate = db.Column(db.Numeric(10, 4)) # 日收益率 + total_profit = db.Column(db.Numeric(15, 2)) # 累计盈亏 + total_profit_rate = db.Column(db.Numeric(10, 4)) # 累计收益率 + trade_count = db.Column(db.Integer, default=0) # 交易次数 + win_count = db.Column(db.Integer, default=0) # 盈利次数 + loss_count = db.Column(db.Integer, default=0) # 亏损次数 + max_profit = db.Column(db.Numeric(15, 2)) # 最大盈利 + max_loss = db.Column(db.Numeric(15, 2)) # 最大亏损 + created_at = db.Column(db.DateTime, default=beijing_now) + + __table_args__ = ( + db.UniqueConstraint('account_id', 'stat_date', name='unique_account_date'), + ) + + +def get_user_subscription_safe(user_id): + """安全地获取用户订阅信息""" + try: + subscription = UserSubscription.query.filter_by(user_id=user_id).first() + if not subscription: + subscription = UserSubscription(user_id=user_id) + db.session.add(subscription) + db.session.commit() + return subscription + except Exception as e: + # 返回默认免费版本对象 + class DefaultSub: + def to_dict(self): + return { + 'type': 'free', + 'status': 'active', + 'is_active': True, + 'days_left': 999, + 'billing_cycle': None, + 'auto_renewal': False + } + + return DefaultSub() + + +def activate_user_subscription(user_id, plan_type, billing_cycle, extend_from_now=False): + """激活用户订阅 + + Args: + user_id: 用户ID + plan_type: 套餐类型 + billing_cycle: 计费周期 + extend_from_now: 是否从当前时间开始延长(用于升级场景) + """ + try: + subscription = UserSubscription.query.filter_by(user_id=user_id).first() + if not subscription: + subscription = UserSubscription(user_id=user_id) + db.session.add(subscription) + + subscription.subscription_type = plan_type + subscription.subscription_status = 'active' + subscription.billing_cycle = billing_cycle + + if not extend_from_now or not subscription.start_date: + subscription.start_date = beijing_now() + + if billing_cycle == 'monthly': + subscription.end_date = beijing_now() + timedelta(days=30) + else: # yearly + subscription.end_date = beijing_now() + timedelta(days=365) + + subscription.updated_at = beijing_now() + db.session.commit() + return subscription + except Exception as e: + return None + + +def validate_promo_code(code, plan_name, billing_cycle, amount, user_id): + """验证优惠码 + + Returns: + tuple: (promo_code_obj, error_message) + """ + try: + promo = PromoCode.query.filter_by(code=code.upper(), is_active=True).first() + + if not promo: + return None, "优惠码不存在或已失效" + + # 检查有效期 + now = beijing_now() + if now < promo.valid_from: + return None, "优惠码尚未生效" + if now > promo.valid_until: + return None, "优惠码已过期" + + # 检查使用次数 + if promo.max_uses and promo.current_uses >= promo.max_uses: + return None, "优惠码已被使用完" + + # 检查每用户使用次数 + if promo.max_uses_per_user: + user_usage_count = PromoCodeUsage.query.filter_by( + promo_code_id=promo.id, + user_id=user_id + ).count() + if user_usage_count >= promo.max_uses_per_user: + return None, f"您已使用过此优惠码(限用{promo.max_uses_per_user}次)" + + # 检查适用套餐 + if promo.applicable_plans: + try: + applicable = json.loads(promo.applicable_plans) + if plan_name not in applicable: + return None, "该优惠码不适用于此套餐" + except: + pass + + # 检查适用周期 + if promo.applicable_cycles: + try: + applicable = json.loads(promo.applicable_cycles) + if billing_cycle not in applicable: + return None, "该优惠码不适用于此计费周期" + except: + pass + + # 检查最低消费 + if promo.min_amount and amount < float(promo.min_amount): + return None, f"需满{float(promo.min_amount):.2f}元才可使用此优惠码" + + return promo, None + except Exception as e: + return None, f"验证优惠码时出错: {str(e)}" + + +def calculate_discount(promo_code, amount): + """计算优惠金额""" + try: + if promo_code.discount_type == 'percentage': + discount = amount * (float(promo_code.discount_value) / 100) + else: # fixed_amount + discount = float(promo_code.discount_value) + + # 确保折扣不超过总金额 + return min(discount, amount) + except: + return 0 + + +def calculate_remaining_value(subscription, current_plan): + """计算当前订阅的剩余价值""" + try: + if not subscription or not subscription.end_date: + return 0 + + now = beijing_now() + if subscription.end_date <= now: + return 0 + + days_left = (subscription.end_date - now).days + + if subscription.billing_cycle == 'monthly': + daily_value = float(current_plan.monthly_price) / 30 + else: # yearly + daily_value = float(current_plan.yearly_price) / 365 + + return daily_value * days_left + except: + return 0 + + +def calculate_upgrade_price(user_id, to_plan_name, to_cycle, promo_code=None): + """计算升级所需价格 + + Returns: + dict: 包含价格计算结果的字典 + """ + try: + # 1. 获取当前订阅 + current_sub = UserSubscription.query.filter_by(user_id=user_id).first() + + # 2. 获取目标套餐 + to_plan = SubscriptionPlan.query.filter_by(name=to_plan_name, is_active=True).first() + if not to_plan: + return {'error': '目标套餐不存在'} + + # 3. 计算目标套餐价格 + new_price = float(to_plan.yearly_price if to_cycle == 'yearly' else to_plan.monthly_price) + + # 4. 如果是新订阅(非升级) + if not current_sub or current_sub.subscription_type == 'free': + result = { + 'is_upgrade': False, + 'new_plan_price': new_price, + 'remaining_value': 0, + 'upgrade_amount': new_price, + 'original_amount': new_price, + 'discount_amount': 0, + 'final_amount': new_price, + 'promo_code': None + } + + # 应用优惠码 + if promo_code: + promo, error = validate_promo_code(promo_code, to_plan_name, to_cycle, new_price, user_id) + if promo: + discount = calculate_discount(promo, new_price) + result['discount_amount'] = discount + result['final_amount'] = new_price - discount + result['promo_code'] = promo.code + elif error: + result['promo_error'] = error + + return result + + # 5. 升级场景:计算剩余价值 + current_plan = SubscriptionPlan.query.filter_by(name=current_sub.subscription_type, is_active=True).first() + if not current_plan: + return {'error': '当前套餐信息不存在'} + + remaining_value = calculate_remaining_value(current_sub, current_plan) + + # 6. 计算升级差价 + upgrade_amount = max(0, new_price - remaining_value) + + # 7. 判断升级类型 + upgrade_type = 'new' + if current_sub.subscription_type != to_plan_name and current_sub.billing_cycle != to_cycle: + upgrade_type = 'both' + elif current_sub.subscription_type != to_plan_name: + upgrade_type = 'plan_upgrade' + elif current_sub.billing_cycle != to_cycle: + upgrade_type = 'cycle_change' + + result = { + 'is_upgrade': True, + 'upgrade_type': upgrade_type, + 'current_plan': current_sub.subscription_type, + 'current_cycle': current_sub.billing_cycle, + 'current_end_date': current_sub.end_date.isoformat() if current_sub.end_date else None, + 'new_plan_price': new_price, + 'remaining_value': remaining_value, + 'upgrade_amount': upgrade_amount, + 'original_amount': upgrade_amount, + 'discount_amount': 0, + 'final_amount': upgrade_amount, + 'promo_code': None + } + + # 8. 应用优惠码 + if promo_code and upgrade_amount > 0: + promo, error = validate_promo_code(promo_code, to_plan_name, to_cycle, upgrade_amount, user_id) + if promo: + discount = calculate_discount(promo, upgrade_amount) + result['discount_amount'] = discount + result['final_amount'] = upgrade_amount - discount + result['promo_code'] = promo.code + elif error: + result['promo_error'] = error + + return result + except Exception as e: + return {'error': str(e)} + + +def initialize_subscription_plans_safe(): + """安全地初始化订阅套餐""" + try: + if SubscriptionPlan.query.first(): + return + + pro_plan = SubscriptionPlan( + name='pro', + display_name='Pro版本', + description='适合个人投资者的基础功能套餐', + monthly_price=0.01, + yearly_price=0.08, + features=json.dumps([ + "基础股票分析工具", + "历史数据查询", + "基础财务报表", + "简单投资计划记录", + "标准客服支持" + ]), + sort_order=1 + ) + + max_plan = SubscriptionPlan( + name='max', + display_name='Max版本', + description='适合专业投资者的全功能套餐', + monthly_price=0.1, + yearly_price=0.8, + features=json.dumps([ + "全部Pro版本功能", + "高级分析工具", + "实时数据推送", + "专业财务分析报告", + "AI投资建议", + "无限投资计划存储", + "优先客服支持", + "独家研报访问" + ]), + sort_order=2 + ) + + db.session.add(pro_plan) + db.session.add(max_plan) + db.session.commit() + except Exception as e: + pass + + +# -------------------------------------------- +# 订阅等级工具函数 +# -------------------------------------------- +def _get_current_subscription_info(): + """获取当前登录用户订阅信息的字典形式,未登录或异常时视为免费用户。""" + try: + user_id = session.get('user_id') + if not user_id: + return { + 'type': 'free', + 'status': 'active', + 'is_active': True + } + sub = get_user_subscription_safe(user_id) + data = sub.to_dict() + # 标准化字段名 + return { + 'type': data.get('type') or data.get('subscription_type') or 'free', + 'status': data.get('status') or data.get('subscription_status') or 'active', + 'is_active': data.get('is_active', True) + } + except Exception: + return { + 'type': 'free', + 'status': 'active', + 'is_active': True + } + + +def _subscription_level(sub_type): + """将订阅类型映射到等级数值,free=0, pro=1, max=2。""" + mapping = {'free': 0, 'pro': 1, 'max': 2} + return mapping.get((sub_type or 'free').lower(), 0) + + +def _has_required_level(required: str) -> bool: + """判断当前用户是否达到所需订阅级别。""" + info = _get_current_subscription_info() + if not info.get('is_active', True): + return False + return _subscription_level(info.get('type')) >= _subscription_level(required) + + +# ============================================ +# 订阅相关API接口 +# ============================================ + +@app.route('/api/subscription/plans', methods=['GET']) +def get_subscription_plans(): + """获取订阅套餐列表""" + try: + plans = SubscriptionPlan.query.filter_by(is_active=True).order_by(SubscriptionPlan.sort_order).all() + return jsonify({ + 'success': True, + 'data': [plan.to_dict() for plan in plans] + }) + except Exception as e: + # 返回默认套餐(包含pricing_options以兼容新前端) + default_plans = [ + { + 'id': 1, + 'name': 'pro', + 'display_name': 'Pro版本', + 'description': '适合个人投资者的基础功能套餐', + 'monthly_price': 198, + 'yearly_price': 2000, + 'pricing_options': [ + {'months': 1, 'price': 198, 'label': '月付', 'cycle_key': 'monthly'}, + {'months': 3, 'price': 534, 'label': '3个月', 'cycle_key': '3months', 'discount_percent': 10}, + {'months': 6, 'price': 950, 'label': '半年', 'cycle_key': '6months', 'discount_percent': 20}, + {'months': 12, 'price': 2000, 'label': '1年', 'cycle_key': 'yearly', 'discount_percent': 16}, + {'months': 24, 'price': 3600, 'label': '2年', 'cycle_key': '2years', 'discount_percent': 24}, + {'months': 36, 'price': 5040, 'label': '3年', 'cycle_key': '3years', 'discount_percent': 29} + ], + 'features': ['基础股票分析工具', '历史数据查询', '基础财务报表', '简单投资计划记录', '标准客服支持'], + 'is_active': True, + 'sort_order': 1 + }, + { + 'id': 2, + 'name': 'max', + 'display_name': 'Max版本', + 'description': '适合专业投资者的全功能套餐', + 'monthly_price': 998, + 'yearly_price': 10000, + 'pricing_options': [ + {'months': 1, 'price': 998, 'label': '月付', 'cycle_key': 'monthly'}, + {'months': 3, 'price': 2695, 'label': '3个月', 'cycle_key': '3months', 'discount_percent': 10}, + {'months': 6, 'price': 4790, 'label': '半年', 'cycle_key': '6months', 'discount_percent': 20}, + {'months': 12, 'price': 10000, 'label': '1年', 'cycle_key': 'yearly', 'discount_percent': 17}, + {'months': 24, 'price': 18000, 'label': '2年', 'cycle_key': '2years', 'discount_percent': 25}, + {'months': 36, 'price': 25200, 'label': '3年', 'cycle_key': '3years', 'discount_percent': 30} + ], + 'features': ['全部Pro版本功能', '高级分析工具', '实时数据推送', 'API访问', '优先客服支持'], + 'is_active': True, + 'sort_order': 2 + } + ] + return jsonify({ + 'success': True, + 'data': default_plans + }) + + +@app.route('/api/subscription/current', methods=['GET']) +def get_current_subscription(): + """获取当前用户的订阅信息""" + try: + if 'user_id' not in session: + return jsonify({'success': False, 'error': '未登录'}), 401 + + subscription = get_user_subscription_safe(session['user_id']) + return jsonify({ + 'success': True, + 'data': subscription.to_dict() + }) + except Exception as e: + return jsonify({ + 'success': True, + 'data': { + 'type': 'free', + 'status': 'active', + 'is_active': True, + 'days_left': 999 + } + }) + + +@app.route('/api/subscription/info', methods=['GET']) +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': 999 + } + }) + + +@app.route('/api/promo-code/validate', methods=['POST']) +def validate_promo_code_api(): + """验证优惠码""" + try: + if 'user_id' not in session: + return jsonify({'success': False, 'error': '未登录'}), 401 + + data = request.get_json() + code = data.get('code', '').strip() + plan_name = data.get('plan_name') + billing_cycle = data.get('billing_cycle') + amount = data.get('amount', 0) + + if not code or not plan_name or not billing_cycle: + return jsonify({'success': False, 'error': '参数不完整'}), 400 + + # 验证优惠码 + promo, error = validate_promo_code(code, plan_name, billing_cycle, amount, session['user_id']) + + if error: + return jsonify({ + 'success': False, + 'valid': False, + 'error': error + }) + + # 计算折扣 + discount_amount = calculate_discount(promo, amount) + final_amount = amount - discount_amount + + return jsonify({ + 'success': True, + 'valid': True, + 'promo_code': promo.to_dict(), + 'discount_amount': discount_amount, + 'final_amount': final_amount + }) + + except Exception as e: + return jsonify({ + 'success': False, + 'error': f'验证失败: {str(e)}' + }), 500 + + +@app.route('/api/subscription/calculate-price', methods=['POST']) +def calculate_subscription_price(): + """计算订阅价格(支持升级和优惠码)""" + try: + if 'user_id' not in session: + return jsonify({'success': False, 'error': '未登录'}), 401 + + data = request.get_json() + to_plan = data.get('to_plan') + to_cycle = data.get('to_cycle') + promo_code = data.get('promo_code', '').strip() or None + + if not to_plan or not to_cycle: + return jsonify({'success': False, 'error': '参数不完整'}), 400 + + # 计算价格 + result = calculate_upgrade_price(session['user_id'], to_plan, to_cycle, promo_code) + + if 'error' in result: + return jsonify({ + 'success': False, + 'error': result['error'] + }), 400 + + return jsonify({ + 'success': True, + 'data': result + }) + + except Exception as e: + return jsonify({ + 'success': False, + 'error': f'计算失败: {str(e)}' + }), 500 + + +@app.route('/api/payment/create-order', methods=['POST']) +def create_payment_order(): + """创建支付订单(支持升级和优惠码)""" + try: + if 'user_id' not in session: + return jsonify({'success': False, 'error': '未登录'}), 401 + + data = request.get_json() + plan_name = data.get('plan_name') + billing_cycle = data.get('billing_cycle') + promo_code = data.get('promo_code', '').strip() or None + + if not plan_name or not billing_cycle: + return jsonify({'success': False, 'error': '参数不完整'}), 400 + + # 计算价格(包括升级和优惠码) + price_result = calculate_upgrade_price(session['user_id'], plan_name, billing_cycle, promo_code) + + if 'error' in price_result: + return jsonify({'success': False, 'error': price_result['error']}), 400 + + amount = price_result['final_amount'] + original_amount = price_result['original_amount'] + discount_amount = price_result['discount_amount'] + is_upgrade = price_result.get('is_upgrade', False) + + # 创建订单 + try: + order = PaymentOrder( + user_id=session['user_id'], + plan_name=plan_name, + billing_cycle=billing_cycle, + amount=amount + ) + + # 添加扩展字段(使用动态属性) + if hasattr(order, 'original_amount') or True: # 兼容性检查 + order.original_amount = original_amount + order.discount_amount = discount_amount + order.is_upgrade = is_upgrade + + # 如果使用了优惠码,关联优惠码 + if promo_code and price_result.get('promo_code'): + promo_obj = PromoCode.query.filter_by(code=promo_code.upper()).first() + if promo_obj: + order.promo_code_id = promo_obj.id + + # 如果是升级,记录原套餐信息 + if is_upgrade: + order.upgrade_from_plan = price_result.get('current_plan') + + db.session.add(order) + db.session.commit() + + # 如果是升级订单,创建升级记录 + if is_upgrade and price_result.get('upgrade_type'): + try: + upgrade_record = SubscriptionUpgrade( + user_id=session['user_id'], + order_id=order.id, + from_plan=price_result['current_plan'], + from_cycle=price_result['current_cycle'], + from_end_date=datetime.fromisoformat(price_result['current_end_date']) if price_result.get('current_end_date') else None, + to_plan=plan_name, + to_cycle=billing_cycle, + to_end_date=beijing_now() + timedelta(days=365 if billing_cycle == 'yearly' else 30), + remaining_value=price_result['remaining_value'], + upgrade_amount=price_result['upgrade_amount'], + actual_amount=amount, + upgrade_type=price_result['upgrade_type'] + ) + db.session.add(upgrade_record) + db.session.commit() + except Exception as e: + print(f"创建升级记录失败: {e}") + # 不影响主流程 + + except Exception as e: + db.session.rollback() + return jsonify({'success': False, 'error': f'订单创建失败: {str(e)}'}), 500 + + # 尝试调用真实的微信支付API + try: + from wechat_pay import create_wechat_pay_instance, check_wechat_pay_ready + + # 检查微信支付是否就绪 + is_ready, ready_msg = check_wechat_pay_ready() + if not is_ready: + # 使用模拟二维码 + order.qr_code_url = f"https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=wxpay://order/{order.order_no}" + order.remark = f"演示模式 - {ready_msg}" + else: + wechat_pay = create_wechat_pay_instance() + + # 创建微信支付订单 + plan_display_name = f"{plan_name.upper()}版本-{billing_cycle}" + wechat_result = wechat_pay.create_native_order( + order_no=order.order_no, + total_fee=float(amount), + body=f"VFr-{plan_display_name}", + product_id=f"{plan_name}_{billing_cycle}" + ) + + if wechat_result['success']: + + # 获取微信返回的原始code_url + wechat_code_url = wechat_result['code_url'] + + # 将微信协议URL转换为二维码图片URL + import urllib.parse + encoded_url = urllib.parse.quote(wechat_code_url, safe='') + qr_image_url = f"https://api.qrserver.com/v1/create-qr-code/?size=200x200&data={encoded_url}" + + order.qr_code_url = qr_image_url + order.prepay_id = wechat_result.get('prepay_id') + order.remark = f"微信支付 - {wechat_code_url}" + + else: + order.qr_code_url = f"https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=wxpay://order/{order.order_no}" + order.remark = f"微信支付失败: {wechat_result.get('error')}" + + except ImportError as e: + order.qr_code_url = f"https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=wxpay://order/{order.order_no}" + order.remark = "微信支付模块未配置" + except Exception as e: + order.qr_code_url = f"https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=wxpay://order/{order.order_no}" + order.remark = f"支付异常: {str(e)}" + + db.session.commit() + + return jsonify({ + 'success': True, + 'data': order.to_dict(), + 'message': '订单创建成功' + }) + + except Exception as e: + db.session.rollback() + return jsonify({'success': False, 'error': '创建订单失败'}), 500 + + +@app.route('/api/payment/order//status', methods=['GET']) +def check_order_status(order_id): + """查询订单支付状态""" + try: + if 'user_id' not in session: + return jsonify({'success': False, 'error': '未登录'}), 401 + + # 查找订单 + order = PaymentOrder.query.filter_by( + id=order_id, + user_id=session['user_id'] + ).first() + + if not order: + return jsonify({'success': False, 'error': '订单不存在'}), 404 + + # 如果订单已经是已支付状态,直接返回 + if order.status == 'paid': + return jsonify({ + 'success': True, + 'data': order.to_dict(), + 'message': '订单已支付', + 'payment_success': True + }) + + # 如果订单过期,标记为过期 + if order.is_expired(): + order.status = 'expired' + db.session.commit() + return jsonify({ + 'success': True, + 'data': order.to_dict(), + 'message': '订单已过期' + }) + + # 调用微信支付API查询真实状态 + try: + from wechat_pay import create_wechat_pay_instance + wechat_pay = create_wechat_pay_instance() + + query_result = wechat_pay.query_order(order_no=order.order_no) + + if query_result['success']: + trade_state = query_result.get('trade_state') + transaction_id = query_result.get('transaction_id') + + if trade_state == 'SUCCESS': + # 支付成功,更新订单状态 + order.mark_as_paid(transaction_id) + + # 激活用户订阅 + activate_user_subscription(order.user_id, order.plan_name, order.billing_cycle) + + return jsonify({ + 'success': True, + 'data': order.to_dict(), + 'message': '支付成功!订阅已激活', + 'payment_success': True + }) + elif trade_state in ['NOTPAY', 'USERPAYING']: + # 未支付或支付中 + return jsonify({ + 'success': True, + 'data': order.to_dict(), + 'message': '等待支付...', + 'payment_success': False + }) + else: + # 支付失败或取消 + order.status = 'cancelled' + db.session.commit() + return jsonify({ + 'success': True, + 'data': order.to_dict(), + 'message': '支付已取消', + 'payment_success': False + }) + else: + # 微信查询失败,返回当前状态 + return jsonify({ + 'success': True, + 'data': order.to_dict(), + 'message': f"查询失败: {query_result.get('error')}", + 'payment_success': False + }) + + except Exception as e: + # 查询失败,返回当前订单状态 + return jsonify({ + 'success': True, + 'data': order.to_dict(), + 'message': '无法查询支付状态,请稍后重试', + 'payment_success': False + }) + + except Exception as e: + return jsonify({'success': False, 'error': '查询失败'}), 500 + + +@app.route('/api/payment/order//force-update', methods=['POST']) +def force_update_order_status(order_id): + """强制更新订单支付状态(调试用)""" + try: + if 'user_id' not in session: + return jsonify({'success': False, 'error': '未登录'}), 401 + + # 查找订单 + order = PaymentOrder.query.filter_by( + id=order_id, + user_id=session['user_id'] + ).first() + + if not order: + return jsonify({'success': False, 'error': '订单不存在'}), 404 + + # 检查微信支付状态 + try: + from wechat_pay import create_wechat_pay_instance + wechat_pay = create_wechat_pay_instance() + + query_result = wechat_pay.query_order(order_no=order.order_no) + + if query_result['success'] and query_result.get('trade_state') == 'SUCCESS': + # 强制更新为已支付 + old_status = order.status + order.mark_as_paid(query_result.get('transaction_id')) + + # 激活用户订阅 + activate_user_subscription(order.user_id, order.plan_name, order.billing_cycle) + + # 记录优惠码使用(如果使用了优惠码) + if hasattr(order, 'promo_code_id') and order.promo_code_id: + try: + promo_usage = PromoCodeUsage( + promo_code_id=order.promo_code_id, + user_id=order.user_id, + order_id=order.id, + original_amount=order.original_amount if hasattr(order, 'original_amount') else order.amount, + discount_amount=order.discount_amount if hasattr(order, 'discount_amount') else 0, + final_amount=order.amount + ) + db.session.add(promo_usage) + + # 更新优惠码使用次数 + promo = PromoCode.query.get(order.promo_code_id) + if promo: + promo.current_uses = (promo.current_uses or 0) + 1 + except Exception as e: + print(f"记录优惠码使用失败: {e}") + + db.session.commit() + + print(f"✅ 订单状态强制更新成功: {old_status} -> paid") + + return jsonify({ + 'success': True, + 'message': f'订单状态已从 {old_status} 更新为 paid', + 'data': order.to_dict(), + 'payment_success': True + }) + else: + return jsonify({ + 'success': False, + 'error': '微信支付状态不是成功状态,无法强制更新' + }) + + except Exception as e: + print(f"❌ 强制更新失败: {e}") + return jsonify({ + 'success': False, + 'error': f'强制更新失败: {str(e)}' + }) + + except Exception as e: + print(f"强制更新订单状态失败: {str(e)}") + return jsonify({'success': False, 'error': '操作失败'}), 500 + + +@app.route('/api/payment/wechat/callback', methods=['POST']) +def wechat_payment_callback(): + """微信支付回调处理""" + try: + # 获取原始XML数据 + raw_data = request.get_data() + print(f"📥 收到微信支付回调: {raw_data}") + + # 验证回调数据 + try: + from wechat_pay import create_wechat_pay_instance + wechat_pay = create_wechat_pay_instance() + verify_result = wechat_pay.verify_callback(raw_data.decode('utf-8')) + + if not verify_result['success']: + print(f"❌ 微信支付回调验证失败: {verify_result['error']}") + return '' + + callback_data = verify_result['data'] + + except Exception as e: + print(f"❌ 微信支付回调处理异常: {e}") + # 简单解析XML(fallback) + callback_data = _parse_xml_callback(raw_data.decode('utf-8')) + if not callback_data: + return '' + + # 获取关键字段 + return_code = callback_data.get('return_code') + result_code = callback_data.get('result_code') + order_no = callback_data.get('out_trade_no') + transaction_id = callback_data.get('transaction_id') + + print(f"📦 回调数据解析:") + print(f" 返回码: {return_code}") + print(f" 结果码: {result_code}") + print(f" 订单号: {order_no}") + print(f" 交易号: {transaction_id}") + + if not order_no: + return '' + + # 查找订单 + order = PaymentOrder.query.filter_by(order_no=order_no).first() + if not order: + print(f"❌ 订单不存在: {order_no}") + return '' + + # 处理支付成功 + if return_code == 'SUCCESS' and result_code == 'SUCCESS': + print(f"🎉 支付回调成功: 订单 {order_no}") + + # 检查订单是否已经处理过 + if order.status == 'paid': + print(f"ℹ️ 订单已处理过: {order_no}") + db.session.commit() + return '' + + # 更新订单状态(无论之前是什么状态) + old_status = order.status + order.mark_as_paid(transaction_id) + print(f"📝 订单状态已更新: {old_status} -> paid") + + # 激活用户订阅 + subscription = activate_user_subscription(order.user_id, order.plan_name, order.billing_cycle) + + if subscription: + print(f"✅ 用户订阅已激活: 用户{order.user_id}, 套餐{order.plan_name}") + else: + print(f"⚠️ 订阅激活失败,但订单已标记为已支付") + + db.session.commit() + + # 返回成功响应给微信 + return '' + + except Exception as e: + db.session.rollback() + print(f"❌ 微信支付回调处理失败: {e}") + import traceback + app.logger.error(f"回调处理错误: {e}", exc_info=True) + return '' + + +def _parse_xml_callback(xml_data): + """简单的XML回调数据解析""" + try: + import xml.etree.ElementTree as ET + root = ET.fromstring(xml_data) + result = {} + for child in root: + result[child.tag] = child.text + return result + except Exception as e: + print(f"XML解析失败: {e}") + return None + + +@app.route('/api/auth/session', methods=['GET']) +def get_session_info(): + """获取当前登录用户信息""" + if 'user_id' in session: + user = User.query.get(session['user_id']) + if user: + # 获取用户订阅信息 + subscription_info = get_user_subscription_safe(user.id).to_dict() + + return jsonify({ + 'success': True, + 'isAuthenticated': True, + 'user': { + 'id': user.id, + 'username': user.username, + 'nickname': user.nickname or user.username, + 'email': user.email, + 'phone': user.phone, + 'phone_confirmed': bool(user.phone_confirmed), + 'email_confirmed': bool(user.email_confirmed) if hasattr(user, 'email_confirmed') else None, + 'avatar_url': user.avatar_url, + 'has_wechat': bool(user.wechat_open_id), + 'created_at': user.created_at.isoformat() if user.created_at else None, + 'last_seen': user.last_seen.isoformat() if user.last_seen else None, + # 将订阅字段映射到前端期望的字段名 + 'subscription_type': subscription_info['type'], + 'subscription_status': subscription_info['status'], + 'subscription_end_date': subscription_info['end_date'], + 'is_subscription_active': subscription_info['is_active'], + 'subscription_days_left': subscription_info['days_left'] + } + }) + + return jsonify({ + 'success': True, + 'isAuthenticated': False, + 'user': None + }) + + +def generate_verification_code(): + """生成6位数字验证码""" + return ''.join(random.choices(string.digits, k=6)) + + +@app.route('/api/auth/login', methods=['POST']) +def login(): + """传统登录 - 使用Session""" + try: + + username = request.form.get('username') + email = request.form.get('email') + phone = request.form.get('phone') + password = request.form.get('password') + + # 验证必要参数 + if not password: + return jsonify({'success': False, 'error': '密码不能为空'}), 400 + + # 根据提供的信息查找用户 + user = None + if username: + # 检查username是否为手机号格式 + if re.match(r'^1[3-9]\d{9}$', username): + # 如果username是手机号格式,先按手机号查找 + user = User.query.filter_by(phone=username).first() + if not user: + # 如果没找到,再按用户名查找 + user = User.find_by_login_info(username) + else: + # 不是手机号格式,按用户名查找 + user = User.find_by_login_info(username) + elif email: + user = User.query.filter_by(email=email).first() + elif phone: + user = User.query.filter_by(phone=phone).first() + else: + return jsonify({'success': False, 'error': '请提供用户名、邮箱或手机号'}), 400 + + if not user: + return jsonify({'success': False, 'error': '用户不存在'}), 404 + + # 尝试密码验证 + password_valid = user.check_password(password) + + if not password_valid: + # 还可以尝试直接验证 + if user.password_hash: + from werkzeug.security import check_password_hash + direct_check = check_password_hash(user.password_hash, password) + return jsonify({'success': False, 'error': '密码错误'}), 401 + + # 设置session + session.permanent = True # 使用永久session + session['user_id'] = user.id + session['username'] = user.username + session['logged_in'] = True + + # Flask-Login 登录 + login_user(user, remember=True) + + # 更新最后登录时间 + user.update_last_seen() + + return jsonify({ + 'success': True, + 'message': '登录成功', + 'user': { + 'id': user.id, + 'username': user.username, + 'nickname': user.nickname or user.username, + 'email': user.email, + 'phone': user.phone, + 'avatar_url': user.avatar_url, + 'has_wechat': bool(user.wechat_open_id) + } + }) + + except Exception as e: + import traceback + app.logger.error(f"回调处理错误: {e}", exc_info=True) + return jsonify({'success': False, 'error': '登录处理失败,请重试'}), 500 + + +# 添加OPTIONS请求处理 +@app.before_request +def handle_preflight(): + if request.method == "OPTIONS": + response = make_response() + response.headers.add("Access-Control-Allow-Origin", "*") + response.headers.add('Access-Control-Allow-Headers', "*") + response.headers.add('Access-Control-Allow-Methods', "*") + return response + + +# 修改密码API +@app.route('/api/account/change-password', methods=['POST']) +@login_required +def change_password(): + """修改当前用户密码""" + try: + data = request.get_json() or request.form + current_password = data.get('currentPassword') or data.get('current_password') + new_password = data.get('newPassword') or data.get('new_password') + is_first_set = data.get('isFirstSet', False) # 是否为首次设置密码 + + if not new_password: + return jsonify({'success': False, 'error': '新密码不能为空'}), 400 + + if len(new_password) < 6: + return jsonify({'success': False, 'error': '新密码至少需要6个字符'}), 400 + + # 获取当前用户 + user = current_user + if not user: + return jsonify({'success': False, 'error': '用户未登录'}), 401 + + # 检查是否为微信用户且首次设置密码 + is_wechat_user = bool(user.wechat_open_id) + + # 如果是微信用户首次设置密码,或者明确标记为首次设置,则跳过当前密码验证 + if is_first_set or (is_wechat_user and not current_password): + pass # 跳过当前密码验证 + else: + # 普通用户或非首次设置,需要验证当前密码 + if not current_password: + return jsonify({'success': False, 'error': '请输入当前密码'}), 400 + + if not user.check_password(current_password): + return jsonify({'success': False, 'error': '当前密码错误'}), 400 + + # 设置新密码 + user.set_password(new_password) + db.session.commit() + + return jsonify({ + 'success': True, + 'message': '密码设置成功' if (is_first_set or is_wechat_user) else '密码修改成功' + }) + + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + + +# 检查用户密码状态API +@app.route('/api/account/password-status', methods=['GET']) +@login_required +def get_password_status(): + """获取当前用户的密码状态信息""" + try: + user = current_user + if not user: + return jsonify({'success': False, 'error': '用户未登录'}), 401 + + is_wechat_user = bool(user.wechat_open_id) + + return jsonify({ + 'success': True, + 'data': { + 'isWechatUser': is_wechat_user, + 'hasPassword': bool(user.password_hash), + 'needsFirstTimeSetup': is_wechat_user # 微信用户需要首次设置 + } + }) + + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + + +# 检查用户信息完整性API +@app.route('/api/account/profile-completeness', methods=['GET']) +@login_required +def get_profile_completeness(): + try: + user = current_user + if not user: + return jsonify({'success': False, 'error': '用户未登录'}), 401 + + is_wechat_user = bool(user.wechat_open_id) + + # 检查各项信息 + completeness = { + 'hasPassword': bool(user.password_hash), + 'hasPhone': bool(user.phone), + 'hasEmail': bool(user.email and '@' in user.email and not user.email.endswith('@valuefrontier.temp')), + 'isWechatUser': is_wechat_user + } + + # 计算完整度 + total_items = 3 + completed_items = sum([completeness['hasPassword'], completeness['hasPhone'], completeness['hasEmail']]) + completeness_percentage = int((completed_items / total_items) * 100) + + # 智能判断是否需要提醒 + needs_attention = False + missing_items = [] + + # 只在用户首次登录或最近登录时提醒 + if is_wechat_user: + # 检查用户是否是新用户(注册7天内) + is_new_user = (datetime.now() - user.created_at).days < 7 + + # 检查是否最近没有提醒过(使用session记录) + last_reminder = session.get('last_completeness_reminder') + should_remind = False + + if not last_reminder: + should_remind = True + else: + # 每7天最多提醒一次 + days_since_reminder = (datetime.now() - datetime.fromisoformat(last_reminder)).days + should_remind = days_since_reminder >= 7 + + # 只对新用户或长时间未完善的用户提醒 + if (is_new_user or completeness_percentage < 50) and should_remind: + needs_attention = True + if not completeness['hasPassword']: + missing_items.append('登录密码') + if not completeness['hasPhone']: + missing_items.append('手机号') + if not completeness['hasEmail']: + missing_items.append('邮箱') + + # 记录本次提醒时间 + session['last_completeness_reminder'] = datetime.now().isoformat() + + return jsonify({ + 'success': True, + 'data': { + 'completeness': completeness, + 'completenessPercentage': completeness_percentage, + 'needsAttention': needs_attention, + 'missingItems': missing_items, + 'isComplete': completed_items == total_items, + 'showReminder': needs_attention # 前端使用这个字段决定是否显示提醒 + } + }) + + except Exception as e: + print(f"获取资料完整性错误: {e}") + return jsonify({'success': False, 'error': str(e)}), 500 + + +@app.route('/api/auth/logout', methods=['POST']) +def logout(): + """登出 - 清除Session""" + logout_user() # Flask-Login 登出 + session.clear() + return jsonify({'success': True, 'message': '已登出'}) + + +@app.route('/api/auth/send-verification-code', methods=['POST']) +def send_verification_code(): + """发送验证码(支持手机号和邮箱)""" + try: + data = request.get_json() + credential = data.get('credential') # 手机号或邮箱 + code_type = data.get('type') # 'phone' 或 'email' + purpose = data.get('purpose', 'login') # 'login' 或 'register' + + if not credential or not code_type: + return jsonify({'success': False, 'error': '缺少必要参数'}), 400 + + # 清理格式字符(空格、横线、括号等) + if code_type == 'phone': + # 移除手机号中的空格、横线、括号、加号等格式字符 + credential = re.sub(r'[\s\-\(\)\+]', '', credential) + print(f"📱 清理后的手机号: {credential}") + elif code_type == 'email': + # 邮箱只移除空格 + credential = credential.strip() + + # 生成验证码 + verification_code = generate_verification_code() + + # 存储验证码到session(实际生产环境建议使用Redis) + session_key = f'verification_code_{code_type}_{credential}_{purpose}' + session[session_key] = { + 'code': verification_code, + 'timestamp': time.time(), + 'attempts': 0 + } + + if code_type == 'phone': + # 手机号验证码发送 + if not re.match(r'^1[3-9]\d{9}$', credential): + return jsonify({'success': False, 'error': '手机号格式不正确'}), 400 + + # 发送真实短信验证码 + if send_sms_code(credential, verification_code, SMS_TEMPLATE_LOGIN): + print(f"[短信已发送] 验证码到 {credential}: {verification_code}") + else: + return jsonify({'success': False, 'error': '短信发送失败,请稍后重试'}), 500 + + elif code_type == 'email': + # 邮箱验证码发送 + if not re.match(r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$', credential): + return jsonify({'success': False, 'error': '邮箱格式不正确'}), 400 + + # 发送真实邮件验证码 + if send_email_code(credential, verification_code): + print(f"[邮件已发送] 验证码到 {credential}: {verification_code}") + else: + return jsonify({'success': False, 'error': '邮件发送失败,请稍后重试'}), 500 + + else: + return jsonify({'success': False, 'error': '不支持的验证码类型'}), 400 + + return jsonify({ + 'success': True, + 'message': f'验证码已发送到您的{code_type}' + }) + + except Exception as e: + print(f"发送验证码错误: {e}") + return jsonify({'success': False, 'error': '发送验证码失败'}), 500 + + +@app.route('/api/auth/login-with-code', methods=['POST']) +def login_with_verification_code(): + """使用验证码登录/注册(自动注册)""" + try: + data = request.get_json() + credential = data.get('credential') # 手机号或邮箱 + verification_code = data.get('verification_code') + login_type = data.get('login_type') # 'phone' 或 'email' + + if not credential or not verification_code or not login_type: + return jsonify({'success': False, 'error': '缺少必要参数'}), 400 + + # 清理格式字符(空格、横线、括号等) + if login_type == 'phone': + # 移除手机号中的空格、横线、括号、加号等格式字符 + original_credential = credential + credential = re.sub(r'[\s\-\(\)\+]', '', credential) + if original_credential != credential: + print(f"📱 登录时清理手机号: {original_credential} -> {credential}") + elif login_type == 'email': + # 邮箱只移除前后空格 + credential = credential.strip() + + # 检查验证码 + session_key = f'verification_code_{login_type}_{credential}_login' + stored_code_info = session.get(session_key) + + if not stored_code_info: + return jsonify({'success': False, 'error': '验证码已过期或不存在'}), 400 + + # 检查验证码是否过期(5分钟) + if time.time() - stored_code_info['timestamp'] > 300: + session.pop(session_key, None) + return jsonify({'success': False, 'error': '验证码已过期'}), 400 + + # 检查尝试次数 + if stored_code_info['attempts'] >= 3: + session.pop(session_key, None) + return jsonify({'success': False, 'error': '验证码错误次数过多'}), 400 + + # 验证码错误 + if stored_code_info['code'] != verification_code: + stored_code_info['attempts'] += 1 + session[session_key] = stored_code_info + return jsonify({'success': False, 'error': '验证码错误'}), 400 + + # 验证码正确,查找用户 + user = None + is_new_user = False + + if login_type == 'phone': + user = User.query.filter_by(phone=credential).first() + if not user: + # 自动注册新用户 + is_new_user = True + # 生成唯一用户名 + base_username = f"user_{credential}" + 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=credential) + user.phone_confirmed = True + user.email = f"{username}@valuefrontier.temp" # 临时邮箱 + db.session.add(user) + db.session.commit() + + elif login_type == 'email': + user = User.query.filter_by(email=credential).first() + if not user: + # 自动注册新用户 + is_new_user = True + # 从邮箱生成用户名 + email_prefix = credential.split('@')[0] + base_username = f"user_{email_prefix}" + username = base_username + counter = 1 + while User.query.filter_by(username=username).first(): + username = f"{base_username}_{counter}" + counter += 1 + + # 如果用户不存在,自动创建新用户 + if not user: + try: + # 生成用户名 + if login_type == 'phone': + # 使用手机号生成用户名 + base_username = f"用户{credential[-4:]}" + elif login_type == 'email': + # 使用邮箱前缀生成用户名 + base_username = credential.split('@')[0] + else: + base_username = "新用户" + + # 确保用户名唯一 + username = base_username + counter = 1 + while User.is_username_taken(username): + username = f"{base_username}_{counter}" + counter += 1 + + # 创建新用户 + user = User(username=username) + + # 设置手机号或邮箱 + if login_type == 'phone': + user.phone = credential + elif login_type == 'email': + user.email = credential + + # 设置默认密码(使用随机密码,用户后续可以修改) + user.set_password(uuid.uuid4().hex) + user.status = 'active' + user.nickname = username + + db.session.add(user) + db.session.commit() + + is_new_user = True + print(f"✅ 自动创建新用户: {username}, {login_type}: {credential}") + + except Exception as e: + print(f"❌ 创建用户失败: {e}") + db.session.rollback() + return jsonify({'success': False, 'error': '创建用户失败'}), 500 + + # 清除验证码 + session.pop(session_key, None) + + # 设置session + session.permanent = True + session['user_id'] = user.id + session['username'] = user.username + session['logged_in'] = True + + # Flask-Login 登录 + login_user(user, remember=True) + + # 更新最后登录时间 + user.update_last_seen() + + # 根据是否为新用户返回不同的消息 + message = '注册成功,欢迎加入!' if is_new_user else '登录成功' + + return jsonify({ + 'success': True, + 'message': message, + 'is_new_user': is_new_user, + 'user': { + 'id': user.id, + 'username': user.username, + 'nickname': user.nickname or user.username, + 'email': user.email, + 'phone': user.phone, + 'avatar_url': user.avatar_url, + 'has_wechat': bool(user.wechat_open_id) + } + }) + + except Exception as e: + print(f"验证码登录错误: {e}") + db.session.rollback() + return jsonify({'success': False, 'error': '登录失败'}), 500 + + +@app.route('/api/auth/register', methods=['POST']) +def register(): + """用户注册 - 使用Session""" + username = request.form.get('username') + email = request.form.get('email') + password = request.form.get('password') + + # 验证输入 + if not all([username, email, password]): + return jsonify({'success': False, 'error': '所有字段都是必填的'}), 400 + + # 检查用户名和邮箱是否已存在 + if User.is_username_taken(username): + return jsonify({'success': False, 'error': '用户名已存在'}), 400 + + if User.is_email_taken(email): + return jsonify({'success': False, 'error': '邮箱已被使用'}), 400 + + try: + # 创建新用户 + user = User(username=username, email=email) + user.set_password(password) + user.email_confirmed = True # 暂时默认已确认 + + db.session.add(user) + db.session.commit() + + # 自动登录 + session.permanent = True + session['user_id'] = user.id + session['username'] = user.username + session['logged_in'] = True + + # Flask-Login 登录 + login_user(user, remember=True) + + return jsonify({ + 'success': True, + 'message': '注册成功', + 'user': { + 'id': user.id, + 'username': user.username, + 'nickname': user.nickname or user.username, + 'email': user.email + } + }), 201 + + except Exception as e: + db.session.rollback() + print(f"验证码登录/注册错误: {e}") + return jsonify({'success': False, 'error': '登录失败'}), 500 + + +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, "5"] if template_id == SMS_TEMPLATE_LOGIN else [code] + } + 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 send_email_code(email, code): + """发送邮件验证码""" + try: + print(f"[邮件发送] 准备发送验证码到: {email}") + print(f"[邮件配置] 服务器: {MAIL_SERVER}, 端口: {MAIL_PORT}, SSL: {MAIL_USE_SSL}") + + msg = Message( + subject='价值前沿 - 验证码', + recipients=[email], + body=f'您的验证码是:{code},有效期5分钟。如非本人操作,请忽略此邮件。' + ) + mail.send(msg) + print(f"[邮件发送] 验证码邮件发送成功到: {email}") + return True + except Exception as e: + print(f"[邮件发送错误] 发送到 {email} 失败: {str(e)}") + print(f"[邮件发送错误] 错误类型: {type(e).__name__}") + return False + + +@app.route('/api/auth/send-sms-code', methods=['POST']) +def send_sms_verification(): + """发送手机验证码""" + data = request.get_json() + phone = data.get('phone') + + if not phone: + return jsonify({'error': '手机号不能为空'}), 400 + + # 注册时验证是否已注册;若用于绑定手机,需要另外接口 + # 这里保留原逻辑,新增绑定接口处理不同规则 + if User.query.filter_by(phone=phone).first(): + return jsonify({'error': '该手机号已注册'}), 400 + + # 生成验证码 + code = generate_verification_code() + + # 发送短信 + if send_sms_code(phone, code, SMS_TEMPLATE_REGISTER): + # 存储验证码(5分钟有效) + verification_codes[f'phone_{phone}'] = { + 'code': code, + 'expires': time.time() + 300 + } + return jsonify({'message': '验证码已发送'}), 200 + else: + return jsonify({'error': '验证码发送失败'}), 500 + + +@app.route('/api/auth/send-email-code', methods=['POST']) +def send_email_verification(): + """发送邮箱验证码""" + data = request.get_json() + email = data.get('email') + + if not email: + return jsonify({'error': '邮箱不能为空'}), 400 + + if User.query.filter_by(email=email).first(): + return jsonify({'error': '该邮箱已注册'}), 400 + + # 生成验证码 + code = generate_verification_code() + + # 发送邮件 + if send_email_code(email, code): + # 存储验证码(5分钟有效) + verification_codes[f'email_{email}'] = { + 'code': code, + 'expires': time.time() + 300 + } + return jsonify({'message': '验证码已发送'}), 200 + else: + return jsonify({'error': '验证码发送失败'}), 500 + + +@app.route('/api/auth/register/phone', methods=['POST']) +def register_with_phone(): + """手机号注册 - 使用Session""" + data = request.get_json() + phone = data.get('phone') + code = data.get('code') + password = data.get('password') + username = data.get('username') + + if not all([phone, code, password, username]): + return jsonify({'success': False, 'error': '所有字段都是必填的'}), 400 + + # 验证验证码 + stored_code = verification_codes.get(f'phone_{phone}') + if not stored_code or stored_code['expires'] < time.time(): + return jsonify({'success': False, 'error': '验证码已过期'}), 400 + + if stored_code['code'] != code: + return jsonify({'success': False, 'error': '验证码错误'}), 400 + + if User.query.filter_by(username=username).first(): + return jsonify({'success': False, 'error': '用户名已存在'}), 400 + + try: + # 创建用户 + user = User(username=username, phone=phone) + user.email = f"{username}@valuefrontier.temp" + user.set_password(password) + user.phone_confirmed = True + + db.session.add(user) + db.session.commit() + + # 清除验证码 + del verification_codes[f'phone_{phone}'] + + # 自动登录 + session.permanent = True + session['user_id'] = user.id + session['username'] = user.username + session['logged_in'] = True + + # Flask-Login 登录 + login_user(user, remember=True) + + return jsonify({ + 'success': True, + 'message': '注册成功', + 'user': { + 'id': user.id, + 'username': user.username, + 'phone': user.phone + } + }), 201 + + except Exception as e: + db.session.rollback() + return jsonify({'success': False, 'error': '注册失败,请重试'}), 500 + + +@app.route('/api/account/phone/send-code', methods=['POST']) +def send_sms_bind_code(): + """发送绑定手机验证码(需已登录)""" + if not session.get('logged_in'): + return jsonify({'error': '未登录'}), 401 + + data = request.get_json() + phone = data.get('phone') + if not phone: + return jsonify({'error': '手机号不能为空'}), 400 + + # 绑定时要求手机号未被占用 + if User.query.filter_by(phone=phone).first(): + return jsonify({'error': '该手机号已被其他账号使用'}), 400 + + code = generate_verification_code() + if send_sms_code(phone, code, SMS_TEMPLATE_REGISTER): + verification_codes[f'bind_{phone}'] = { + 'code': code, + 'expires': time.time() + 300 + } + return jsonify({'message': '验证码已发送'}), 200 + else: + return jsonify({'error': '验证码发送失败'}), 500 + + +@app.route('/api/account/phone/bind', methods=['POST']) +def bind_phone(): + """当前登录用户绑定手机号""" + if not session.get('logged_in'): + return jsonify({'error': '未登录'}), 401 + + data = request.get_json() + phone = data.get('phone') + code = data.get('code') + + if not phone or not code: + return jsonify({'error': '手机号和验证码不能为空'}), 400 + + stored = verification_codes.get(f'bind_{phone}') + if not stored or stored['expires'] < time.time(): + return jsonify({'error': '验证码已过期'}), 400 + if stored['code'] != code: + return jsonify({'error': '验证码错误'}), 400 + + if User.query.filter_by(phone=phone).first(): + return jsonify({'error': '该手机号已被其他账号使用'}), 400 + + try: + user = User.query.get(session.get('user_id')) + if not user: + return jsonify({'error': '用户不存在'}), 404 + + user.phone = phone + user.confirm_phone() + # 清除验证码 + del verification_codes[f'bind_{phone}'] + + return jsonify({'message': '绑定成功', 'success': True}), 200 + except Exception as e: + print(f"Bind phone error: {e}") + db.session.rollback() + return jsonify({'error': '绑定失败,请重试'}), 500 + + +@app.route('/api/account/phone/unbind', methods=['POST']) +def unbind_phone(): + """解绑手机号(需已登录)""" + if not session.get('logged_in'): + return jsonify({'error': '未登录'}), 401 + + try: + user = User.query.get(session.get('user_id')) + if not user: + return jsonify({'error': '用户不存在'}), 404 + + user.phone = None + user.phone_confirmed = False + user.phone_confirm_time = None + db.session.commit() + return jsonify({'message': '解绑成功', 'success': True}), 200 + except Exception as e: + print(f"Unbind phone error: {e}") + db.session.rollback() + return jsonify({'error': '解绑失败,请重试'}), 500 + + +@app.route('/api/account/email/send-bind-code', methods=['POST']) +def send_email_bind_code(): + """发送绑定邮箱验证码(需已登录)""" + if not session.get('logged_in'): + return jsonify({'error': '未登录'}), 401 + + data = request.get_json() + email = data.get('email') + + if not email: + return jsonify({'error': '邮箱不能为空'}), 400 + + # 邮箱格式验证 + if not re.match(r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$', email): + return jsonify({'error': '邮箱格式不正确'}), 400 + + # 检查邮箱是否已被其他账号使用 + if User.query.filter_by(email=email).first(): + return jsonify({'error': '该邮箱已被其他账号使用'}), 400 + + # 生成验证码 + code = ''.join(random.choices(string.digits, k=6)) + + if send_email_code(email, code): + # 存储验证码(5分钟有效) + verification_codes[f'bind_{email}'] = { + 'code': code, + 'expires': time.time() + 300 + } + return jsonify({'message': '验证码已发送'}), 200 + else: + return jsonify({'error': '验证码发送失败'}), 500 + + +@app.route('/api/account/email/bind', methods=['POST']) +def bind_email(): + """当前登录用户绑定邮箱""" + if not session.get('logged_in'): + return jsonify({'error': '未登录'}), 401 + + data = request.get_json() + email = data.get('email') + code = data.get('code') + + if not email or not code: + return jsonify({'error': '邮箱和验证码不能为空'}), 400 + + stored = verification_codes.get(f'bind_{email}') + if not stored or stored['expires'] < time.time(): + return jsonify({'error': '验证码已过期'}), 400 + if stored['code'] != code: + return jsonify({'error': '验证码错误'}), 400 + + if User.query.filter_by(email=email).first(): + return jsonify({'error': '该邮箱已被其他账号使用'}), 400 + + try: + user = User.query.get(session.get('user_id')) + if not user: + return jsonify({'error': '用户不存在'}), 404 + + user.email = email + user.confirm_email() + db.session.commit() + + # 清除验证码 + del verification_codes[f'bind_{email}'] + + return jsonify({ + 'message': '邮箱绑定成功', + 'success': True, + 'user': { + 'email': user.email, + 'email_confirmed': user.email_confirmed + } + }), 200 + except Exception as e: + print(f"Bind email error: {e}") + db.session.rollback() + return jsonify({'error': '绑定失败,请重试'}), 500 + + +@app.route('/api/account/email/unbind', methods=['POST']) +def unbind_email(): + """解绑邮箱(需已登录)""" + if not session.get('logged_in'): + return jsonify({'error': '未登录'}), 401 + + try: + user = User.query.get(session.get('user_id')) + if not user: + return jsonify({'error': '用户不存在'}), 404 + + user.email = None + user.email_confirmed = False + db.session.commit() + return jsonify({'message': '解绑成功', 'success': True}), 200 + except Exception as e: + print(f"Unbind email error: {e}") + db.session.rollback() + return jsonify({'error': '解绑失败,请重试'}), 500 + + +@app.route('/api/auth/register/email', methods=['POST']) +def register_with_email(): + """邮箱注册 - 使用Session""" + data = request.get_json() + email = data.get('email') + code = data.get('code') + password = data.get('password') + username = data.get('username') + + if not all([email, code, password, username]): + return jsonify({'success': False, 'error': '所有字段都是必填的'}), 400 + + # 验证验证码 + stored_code = verification_codes.get(f'email_{email}') + if not stored_code or stored_code['expires'] < time.time(): + return jsonify({'success': False, 'error': '验证码已过期'}), 400 + + if stored_code['code'] != code: + return jsonify({'success': False, 'error': '验证码错误'}), 400 + + if User.query.filter_by(username=username).first(): + return jsonify({'success': False, 'error': '用户名已存在'}), 400 + + try: + # 创建用户 + user = User(username=username, email=email) + user.set_password(password) + user.email_confirmed = True + + db.session.add(user) + db.session.commit() + + # 清除验证码 + del verification_codes[f'email_{email}'] + + # 自动登录 + session.permanent = True + session['user_id'] = user.id + session['username'] = user.username + session['logged_in'] = True + + # Flask-Login 登录 + login_user(user, remember=True) + + return jsonify({ + 'success': True, + 'message': '注册成功', + 'user': { + 'id': user.id, + 'username': user.username, + 'email': user.email + } + }), 201 + + except Exception as e: + db.session.rollback() + return jsonify({'success': False, 'error': '注册失败,请重试'}), 500 + + +def get_wechat_access_token(code): + """通过code获取微信access_token""" + url = "https://api.weixin.qq.com/sns/oauth2/access_token" + params = { + 'appid': WECHAT_APPID, + 'secret': WECHAT_APPSECRET, + 'code': code, + 'grant_type': 'authorization_code' + } + + try: + response = requests.get(url, params=params, timeout=10) + data = response.json() + + if 'errcode' in data: + print(f"WeChat access token error: {data}") + return None + + return data + except Exception as e: + print(f"WeChat access token request error: {e}") + return None + + +def get_wechat_userinfo(access_token, openid): + """获取微信用户信息(包含UnionID)""" + url = "https://api.weixin.qq.com/sns/userinfo" + params = { + 'access_token': access_token, + 'openid': openid, + 'lang': 'zh_CN' + } + + try: + response = requests.get(url, params=params, timeout=10) + response.encoding = 'utf-8' # 明确设置编码为UTF-8 + data = response.json() + + if 'errcode' in data: + print(f"WeChat userinfo error: {data}") + return None + + # 确保nickname字段的编码正确 + if 'nickname' in data and data['nickname']: + # 确保昵称是正确的UTF-8编码 + try: + # 检查是否已经是正确的UTF-8字符串 + data['nickname'] = data['nickname'].encode('utf-8').decode('utf-8') + except (UnicodeEncodeError, UnicodeDecodeError) as e: + print(f"Nickname encoding error: {e}, using default") + data['nickname'] = '微信用户' + + return data + except Exception as e: + print(f"WeChat userinfo request error: {e}") + return None + + +@app.route('/api/auth/wechat/qrcode', methods=['GET']) +def get_wechat_qrcode(): + """返回微信授权URL,前端使用iframe展示""" + # 生成唯一state参数 + state = uuid.uuid4().hex + + # URL编码回调地址 + redirect_uri = urllib.parse.quote_plus(WECHAT_REDIRECT_URI) + + # 构建微信授权URL + wechat_auth_url = ( + f"https://open.weixin.qq.com/connect/qrconnect?" + f"appid={WECHAT_APPID}&redirect_uri={redirect_uri}" + f"&response_type=code&scope=snsapi_login&state={state}" + "#wechat_redirect" + ) + + # 存储session信息 + wechat_qr_sessions[state] = { + 'status': 'waiting', + 'expires': time.time() + 300, # 5分钟过期 + 'user_info': None, + 'wechat_openid': None, + 'wechat_unionid': None + } + + return jsonify({"code":0, + "data": + { + 'auth_url': wechat_auth_url, + 'session_id': state, + 'expires_in': 300 + }}), 200 + + +@app.route('/api/account/wechat/qrcode', methods=['GET']) +def get_wechat_bind_qrcode(): + """发起微信绑定二维码,会话标记为绑定模式""" + if not session.get('logged_in'): + return jsonify({'error': '未登录'}), 401 + + # 生成唯一state参数 + state = uuid.uuid4().hex + + # URL编码回调地址 + redirect_uri = urllib.parse.quote_plus(WECHAT_REDIRECT_URI) + + # 构建微信授权URL + wechat_auth_url = ( + f"https://open.weixin.qq.com/connect/qrconnect?" + f"appid={WECHAT_APPID}&redirect_uri={redirect_uri}" + f"&response_type=code&scope=snsapi_login&state={state}" + "#wechat_redirect" + ) + + # 存储session信息,标记为绑定模式并记录目标用户 + wechat_qr_sessions[state] = { + 'status': 'waiting', + 'expires': time.time() + 300, + 'mode': 'bind', + 'bind_user_id': session.get('user_id'), + 'user_info': None, + 'wechat_openid': None, + 'wechat_unionid': None + } + + return jsonify({ + 'auth_url': wechat_auth_url, + 'session_id': state, + 'expires_in': 300 + }), 200 + + +@app.route('/api/auth/wechat/check', methods=['POST']) +def check_wechat_scan(): + """检查微信扫码状态""" + data = request.get_json() + session_id = data.get('session_id') + + if not session_id or session_id not in wechat_qr_sessions: + return jsonify({'status': 'invalid', 'error': '无效的session'}), 400 + + session = wechat_qr_sessions[session_id] + + # 检查是否过期 + if time.time() > session['expires']: + del wechat_qr_sessions[session_id] + return jsonify({'status': 'expired'}), 200 + + return jsonify({ + 'status': session['status'], + 'user_info': session.get('user_info'), + 'expires_in': int(session['expires'] - time.time()) + }), 200 + + +@app.route('/api/account/wechat/check', methods=['POST']) +def check_wechat_bind_scan(): + """检查微信扫码绑定状态""" + data = request.get_json() + session_id = data.get('session_id') + + if not session_id or session_id not in wechat_qr_sessions: + return jsonify({'status': 'invalid', 'error': '无效的session'}), 400 + + sess = wechat_qr_sessions[session_id] + + # 绑定模式限制 + if sess.get('mode') != 'bind': + return jsonify({'status': 'invalid', 'error': '会话模式错误'}), 400 + + # 过期处理 + if time.time() > sess['expires']: + del wechat_qr_sessions[session_id] + return jsonify({'status': 'expired'}), 200 + + return jsonify({ + 'status': sess['status'], + 'user_info': sess.get('user_info'), + 'expires_in': int(sess['expires'] - time.time()) + }), 200 + + +@app.route('/api/auth/wechat/callback', methods=['GET']) +def wechat_callback(): + """微信授权回调处理 - 使用Session""" + code = request.args.get('code') + state = request.args.get('state') + error = request.args.get('error') + + # 错误处理:用户拒绝授权 + if error: + if state in wechat_qr_sessions: + wechat_qr_sessions[state]['status'] = 'auth_denied' + wechat_qr_sessions[state]['error'] = '用户拒绝授权' + print(f"❌ 用户拒绝授权: state={state}") + return redirect('/auth/signin?error=wechat_auth_denied') + + # 参数验证 + if not code or not state: + if state in wechat_qr_sessions: + wechat_qr_sessions[state]['status'] = 'auth_failed' + wechat_qr_sessions[state]['error'] = '授权参数缺失' + return redirect('/auth/signin?error=wechat_auth_failed') + + # 验证state + if state not in wechat_qr_sessions: + return redirect('/auth/signin?error=session_expired') + + session_data = wechat_qr_sessions[state] + + # 检查过期 + if time.time() > session_data['expires']: + del wechat_qr_sessions[state] + return redirect('/auth/signin?error=session_expired') + + try: + # 步骤1: 用户已扫码并授权(微信回调过来说明用户已完成扫码+授权) + session_data['status'] = 'scanned' + print(f"✅ 微信扫码回调: state={state}, code={code[:10]}...") + + # 步骤2: 获取access_token + token_data = get_wechat_access_token(code) + if not token_data: + session_data['status'] = 'auth_failed' + session_data['error'] = '获取访问令牌失败' + print(f"❌ 获取微信access_token失败: state={state}") + return redirect('/auth/signin?error=token_failed') + + # 步骤3: Token获取成功,标记为已授权 + session_data['status'] = 'authorized' + print(f"✅ 微信授权成功: openid={token_data['openid']}") + + # 步骤4: 获取用户信息 + user_info = get_wechat_userinfo(token_data['access_token'], token_data['openid']) + if not user_info: + session_data['status'] = 'auth_failed' + session_data['error'] = '获取用户信息失败' + print(f"❌ 获取微信用户信息失败: openid={token_data['openid']}") + return redirect('/auth/signin?error=userinfo_failed') + + # 查找或创建用户 / 或处理绑定 + openid = token_data['openid'] + unionid = user_info.get('unionid') or token_data.get('unionid') + + # 如果是绑定流程 + session_item = wechat_qr_sessions.get(state) + if session_item and session_item.get('mode') == 'bind': + try: + target_user_id = session.get('user_id') or session_item.get('bind_user_id') + if not target_user_id: + return redirect('/auth/signin?error=bind_no_user') + + target_user = User.query.get(target_user_id) + if not target_user: + return redirect('/auth/signin?error=bind_user_missing') + + # 检查该微信是否已被其他账户绑定 + existing = None + if unionid: + existing = User.query.filter_by(wechat_union_id=unionid).first() + if not existing: + existing = User.query.filter_by(wechat_open_id=openid).first() + + if existing and existing.id != target_user.id: + session_item['status'] = 'bind_conflict' + return redirect('/home?bind=conflict') + + # 执行绑定 + target_user.bind_wechat(openid, unionid, wechat_info=user_info) + + # 标记绑定完成,供前端轮询 + session_item['status'] = 'bind_ready' + session_item['user_info'] = {'user_id': target_user.id} + + return redirect('/home?bind=success') + except Exception as e: + print(f"❌ 微信绑定失败: {e}") + db.session.rollback() + session_item['status'] = 'bind_failed' + return redirect('/home?bind=failed') + + user = None + is_new_user = False + + if unionid: + user = User.query.filter_by(wechat_union_id=unionid).first() + if not user: + user = User.query.filter_by(wechat_open_id=openid).first() + + if not user: + # 创建新用户 + # 先清理微信昵称 + raw_nickname = user_info.get('nickname', '微信用户') + # 创建临时用户实例以使用清理方法 + temp_user = User.__new__(User) + sanitized_nickname = temp_user._sanitize_nickname(raw_nickname) + + username = sanitized_nickname + counter = 1 + while User.is_username_taken(username): + username = f"{sanitized_nickname}_{counter}" + counter += 1 + + user = User(username=username) + user.nickname = sanitized_nickname + user.avatar_url = user_info.get('headimgurl') + user.wechat_open_id = openid + user.wechat_union_id = unionid + user.set_password(uuid.uuid4().hex) + user.status = 'active' + + db.session.add(user) + db.session.commit() + + is_new_user = True + print(f"✅ 微信扫码自动创建新用户: {username}, openid: {openid}") + + # 更新最后登录时间 + user.update_last_seen() + + # 设置session + session.permanent = True + session['user_id'] = user.id + session['username'] = user.username + session['logged_in'] = True + session['wechat_login'] = True # 标记是微信登录 + + # Flask-Login 登录 + login_user(user, remember=True) + + # 更新微信session状态,供前端轮询检测 + if state in wechat_qr_sessions: + session_item = wechat_qr_sessions[state] + # 仅处理登录/注册流程,不处理绑定流程 + if not session_item.get('mode'): + # 更新状态和用户信息 + session_item['status'] = 'register_ready' if is_new_user else 'login_ready' + session_item['user_info'] = {'user_id': user.id} + print(f"✅ 微信扫码状态已更新: {session_item['status']}, user_id: {user.id}") + + # 直接跳转到首页 + return redirect('/home') + + except Exception as e: + print(f"❌ 微信登录失败: {e}") + import traceback + traceback.print_exc() + db.session.rollback() + + # 更新session状态为失败 + if state in wechat_qr_sessions: + wechat_qr_sessions[state]['status'] = 'auth_failed' + wechat_qr_sessions[state]['error'] = str(e) + + return redirect('/auth/signin?error=login_failed') + + +@app.route('/api/auth/login/wechat', methods=['POST']) +def login_with_wechat(): + """微信登录 - 修复版本""" + data = request.get_json() + session_id = data.get('session_id') + + if not session_id: + return jsonify({'success': False, 'error': 'session_id不能为空'}), 400 + + # 验证session + session = wechat_qr_sessions.get(session_id) + if not session: + return jsonify({'success': False, 'error': '会话不存在或已过期'}), 400 + + # 检查session状态 + if session['status'] not in ['login_ready', 'register_ready']: + return jsonify({'success': False, 'error': '会话状态无效'}), 400 + + # 检查是否有用户信息 + user_info = session.get('user_info') + if not user_info or not user_info.get('user_id'): + return jsonify({'success': False, 'error': '用户信息不完整'}), 400 + + try: + user = User.query.get(user_info['user_id']) + if not user: + return jsonify({'success': False, 'error': '用户不存在'}), 404 + + # 更新最后登录时间 + user.update_last_seen() + + # 清除session + del wechat_qr_sessions[session_id] + + # 生成登录响应 + response_data = { + 'success': True, + 'message': '登录成功' if session['status'] == 'login_ready' else '注册并登录成功', + 'user': { + 'id': user.id, + 'username': user.username, + 'nickname': user.nickname or user.username, + 'email': user.email, + 'avatar_url': user.avatar_url, + 'has_wechat': True, + 'wechat_open_id': user.wechat_open_id, + 'wechat_union_id': user.wechat_union_id, + 'created_at': user.created_at.isoformat() if user.created_at else None, + 'last_seen': user.last_seen.isoformat() if user.last_seen else None + } + } + + # 如果需要token认证,可以在这里生成 + # response_data['token'] = generate_token(user.id) + + return jsonify(response_data), 200 + + except Exception as e: + print(f"❌ 微信登录错误: {e}") + import traceback + app.logger.error(f"回调处理错误: {e}", exc_info=True) + return jsonify({ + 'success': False, + 'error': '登录失败,请重试' + }), 500 + + +@app.route('/api/account/wechat/unbind', methods=['POST']) +def unbind_wechat_account(): + """解绑当前登录用户的微信""" + if not session.get('logged_in'): + return jsonify({'error': '未登录'}), 401 + + try: + user = User.query.get(session.get('user_id')) + if not user: + return jsonify({'error': '用户不存在'}), 404 + + user.unbind_wechat() + return jsonify({'message': '解绑成功', 'success': True}), 200 + except Exception as e: + print(f"Unbind wechat error: {e}") + db.session.rollback() + return jsonify({'error': '解绑失败,请重试'}), 500 + + +# 评论模型 +class EventComment(db.Model): + """事件评论""" + __tablename__ = 'event_comment' + + id = db.Column(db.Integer, primary_key=True) + event_id = db.Column(db.Integer, nullable=False) + user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=True) + author = db.Column(db.String(50), default='匿名用户') + content = db.Column(db.Text, nullable=False) + parent_id = db.Column(db.Integer, db.ForeignKey('event_comment.id')) + likes = db.Column(db.Integer, default=0) + created_at = db.Column(db.DateTime, default=beijing_now) + status = db.Column(db.String(20), default='active') + + user = db.relationship('User', backref='event_comments') + replies = db.relationship('EventComment', backref=db.backref('parent', remote_side=[id])) + + def to_dict(self, user_session_id=None, current_user_id=None): + # 检查当前用户是否已点赞 + user_liked = False + if user_session_id: + like_record = CommentLike.query.filter_by( + comment_id=self.id, + session_id=user_session_id + ).first() + user_liked = like_record is not None + + # 检查当前用户是否可以删除此评论 + can_delete = current_user_id is not None and self.user_id == current_user_id + + return { + 'id': self.id, + 'event_id': self.event_id, + 'author': self.author, + 'content': self.content, + 'parent_id': self.parent_id, + 'likes': self.likes, + 'created_at': self.created_at.isoformat() if self.created_at else None, + 'user_liked': user_liked, + 'can_delete': can_delete, + 'user_id': self.user_id, + 'replies': [reply.to_dict(user_session_id, current_user_id) for reply in self.replies if + reply.status == 'active'] + } + + +class CommentLike(db.Model): + """评论点赞记录""" + __tablename__ = 'comment_like' + + id = db.Column(db.Integer, primary_key=True) + comment_id = db.Column(db.Integer, db.ForeignKey('event_comment.id'), nullable=False) + session_id = db.Column(db.String(100), nullable=False) + created_at = db.Column(db.DateTime, default=beijing_now) + + __table_args__ = (db.UniqueConstraint('comment_id', 'session_id'),) + + +@app.after_request +def after_request(response): + """处理所有响应,添加CORS头部和安全头部""" + origin = request.headers.get('Origin') + allowed_origins = ['http://localhost:3000', 'http://127.0.0.1:3000', 'http://localhost:5173', + 'https://valuefrontier.cn', 'http://valuefrontier.cn'] + + if origin in allowed_origins: + response.headers['Access-Control-Allow-Origin'] = origin + response.headers['Access-Control-Allow-Credentials'] = 'true' + response.headers['Access-Control-Allow-Headers'] = 'Content-Type,Authorization,X-Requested-With' + response.headers['Access-Control-Allow-Methods'] = 'GET,PUT,POST,DELETE,OPTIONS' + response.headers['Access-Control-Expose-Headers'] = 'Content-Type,Authorization' + + # 处理预检请求 + if request.method == 'OPTIONS': + response.status_code = 200 + + return response + + +def add_cors_headers(response): + """添加CORS头(保留原有函数以兼容)""" + origin = request.headers.get('Origin') + allowed_origins = ['http://localhost:3000', 'http://127.0.0.1:3000', 'http://localhost:5173', + 'https://valuefrontier.cn', 'http://valuefrontier.cn'] + + if origin in allowed_origins: + response.headers['Access-Control-Allow-Origin'] = origin + else: + response.headers['Access-Control-Allow-Origin'] = 'http://localhost:3000' + + response.headers['Access-Control-Allow-Headers'] = 'Content-Type,Authorization,X-Requested-With' + response.headers['Access-Control-Allow-Methods'] = 'GET,PUT,POST,DELETE,OPTIONS' + response.headers['Access-Control-Allow-Credentials'] = 'true' + return response + + +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 FutureEventFollow(db.Model): + """未来事件关注""" + __tablename__ = 'future_event_follow' + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) + future_event_id = db.Column(db.Integer, nullable=False) # future_events表的id + created_at = db.Column(db.DateTime, default=beijing_now) + + user = db.relationship('User', backref='future_event_follows') + + __table_args__ = (db.UniqueConstraint('user_id', 'future_event_id'),) + + +# —— 自选股输入统一化与名称补全工具 —— +def _normalize_stock_input(raw_input: str): + """解析用户输入为标准6位股票代码与可选名称。 + + 支持: + - 6位代码: "600519",或带后缀 "600519.SH"/"600519.SZ" + - 名称(代码): "贵州茅台(600519)" 或 "贵州茅台(600519)" + 返回 (code6, name_or_none) + """ + if not raw_input: + return None, None + s = str(raw_input).strip() + + # 名称(600519) 或 名称(600519) + m = re.match(r"^(.+?)[\((]\s*(\d{6})\s*[\))]\s*$", s) + if m: + name = m.group(1).strip() + code = m.group(2) + return code, (name if name else None) + + # 600519 或 600519.SH / 600519.SZ + m2 = re.match(r"^(\d{6})(?:\.(?:SH|SZ))?$", s, re.IGNORECASE) + if m2: + return m2.group(1), None + + # SH600519 / SZ000001 + m3 = re.match(r"^(SH|SZ)(\d{6})$", s, re.IGNORECASE) + if m3: + return m3.group(2), None + + return None, None + + +def _query_stock_name_by_code(code6: str): + """根据6位代码查询股票名称,查不到返回None。""" + try: + with engine.connect() as conn: + q = text(""" + SELECT SECNAME + FROM ea_baseinfo + WHERE SECCODE = :c LIMIT 1 + """) + row = conn.execute(q, {'c': code6}).fetchone() + if row: + return row[0] + except Exception: + pass + return None + + +class Watchlist(db.Model): + """用户自选股""" + __tablename__ = 'watchlist' + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) + stock_code = db.Column(db.String(20), nullable=False) + stock_name = db.Column(db.String(100), nullable=True) + created_at = db.Column(db.DateTime, default=beijing_now) + + user = db.relationship('User', backref='watchlist') + + __table_args__ = (db.UniqueConstraint('user_id', 'stock_code'),) + + +@app.route('/api/account/watchlist', methods=['GET']) +def get_my_watchlist(): + """获取当前用户的自选股列表""" + try: + if 'user_id' not in session: + return jsonify({'success': False, 'error': '未登录'}), 401 + + items = Watchlist.query.filter_by(user_id=session['user_id']).order_by(Watchlist.created_at.desc()).all() + + # 懒更新:统一代码为6位、补全缺失的名称,并去重(同一代码保留一个记录) + from collections import defaultdict + groups = defaultdict(list) + for i in items: + code6, _ = _normalize_stock_input(i.stock_code) + normalized_code = code6 or (i.stock_code.strip().upper() if isinstance(i.stock_code, str) else i.stock_code) + groups[normalized_code].append(i) + + dirty = False + to_delete = [] + for code6, group in groups.items(): + # 选择保留记录:优先有名称的,其次创建时间早的 + def sort_key(x): + return (x.stock_name is None, x.created_at or datetime.min) + + group_sorted = sorted(group, key=sort_key) + keep = group_sorted[0] + # 规范保留项 + if keep.stock_code != code6: + keep.stock_code = code6 + dirty = True + if not keep.stock_name and code6: + nm = _query_stock_name_by_code(code6) + if nm: + keep.stock_name = nm + dirty = True + # 其余删除 + for g in group_sorted[1:]: + to_delete.append(g) + + if to_delete: + for g in to_delete: + db.session.delete(g) + dirty = True + + if dirty: + db.session.commit() + + return jsonify({'success': True, 'data': [ + { + 'id': i.id, + 'stock_code': i.stock_code, + 'stock_name': i.stock_name, + 'created_at': i.created_at.isoformat() if i.created_at else None + } for i in items + ]}) + except Exception as e: + print(f"Error in get_my_watchlist: {str(e)}") + return jsonify({'success': False, 'error': str(e)}), 500 + + +@app.route('/api/account/watchlist', methods=['POST']) +def add_to_watchlist(): + """添加到自选股""" + if 'user_id' not in session: + return jsonify({'success': False, 'error': '未登录'}), 401 + + data = request.get_json() or {} + raw_code = data.get('stock_code') + raw_name = data.get('stock_name') + + code6, name_from_input = _normalize_stock_input(raw_code) + if not code6: + return jsonify({'success': False, 'error': '无效的股票标识'}), 400 + + # 优先使用传入名称,其次从输入解析中获得,最后查库补全 + final_name = raw_name or name_from_input or _query_stock_name_by_code(code6) + + # 查找已存在记录,兼容历史:6位/带后缀 + candidates = [code6, f"{code6}.SH", f"{code6}.SZ"] + existing = Watchlist.query.filter( + Watchlist.user_id == session['user_id'], + Watchlist.stock_code.in_(candidates) + ).first() + if existing: + # 统一为6位,补全名称 + updated = False + if existing.stock_code != code6: + existing.stock_code = code6 + updated = True + if (not existing.stock_name) and final_name: + existing.stock_name = final_name + updated = True + if updated: + db.session.commit() + return jsonify({'success': True, 'data': {'id': existing.id}}) + + item = Watchlist(user_id=session['user_id'], stock_code=code6, stock_name=final_name) + db.session.add(item) + db.session.commit() + return jsonify({'success': True, 'data': {'id': item.id}}) + + +@app.route('/api/account/watchlist/', methods=['DELETE']) +def remove_from_watchlist(stock_code): + """从自选股移除""" + if 'user_id' not in session: + return jsonify({'success': False, 'error': '未登录'}), 401 + + code6, _ = _normalize_stock_input(stock_code) + candidates = [] + if code6: + candidates = [code6, f"{code6}.SH", f"{code6}.SZ"] + # 包含原始传入(以兼容历史) + if stock_code not in candidates: + candidates.append(stock_code) + + item = Watchlist.query.filter( + Watchlist.user_id == session['user_id'], + Watchlist.stock_code.in_(candidates) + ).first() + if not item: + return jsonify({'success': False, 'error': '未找到自选项'}), 404 + db.session.delete(item) + db.session.commit() + return jsonify({'success': True}) + + +@app.route('/api/account/watchlist/realtime', methods=['GET']) +def get_watchlist_realtime(): + """获取自选股实时行情数据(基于分钟线)""" + try: + if 'user_id' not in session: + return jsonify({'success': False, 'error': '未登录'}), 401 + + # 获取用户自选股列表 + watchlist = Watchlist.query.filter_by(user_id=session['user_id']).all() + if not watchlist: + return jsonify({'success': True, 'data': []}) + + # 获取股票代码列表 + stock_codes = [] + for item in watchlist: + code6, _ = _normalize_stock_input(item.stock_code) + # 统一内部查询代码 + normalized = code6 or str(item.stock_code).strip().upper() + stock_codes.append(normalized) + + # 使用现有的分钟线接口获取最新行情 + client = get_clickhouse_client() + quotes_data = {} + + # 获取最新交易日 + today = datetime.now().date() + + # 获取每只股票的最新价格 + for code in stock_codes: + raw_code = str(code).strip().upper() + if '.' in raw_code: + stock_code_full = raw_code + else: + stock_code_full = f"{raw_code}.SH" if raw_code.startswith('6') else f"{raw_code}.SZ" + + # 获取最新分钟线数据(先查近7天,若无数据再兜底倒序取最近一条) + query = """ + SELECT + close, timestamp, high, low, volume, amt + FROM stock_minute + WHERE code = %(code)s + AND timestamp >= %(start)s + ORDER BY timestamp DESC + LIMIT 1 \ + """ + + # 获取最近7天的分钟数据 + start_date = today - timedelta(days=7) + + result = client.execute(query, { + 'code': stock_code_full, + 'start': datetime.combine(start_date, dt_time(9, 30)) + }) + + # 若近7天无数据,兜底直接取最近一条 + if not result: + fallback_query = """ + SELECT + close, timestamp, high, low, volume, amt + FROM stock_minute + WHERE code = %(code)s + ORDER BY timestamp DESC + LIMIT 1 \ + """ + result = client.execute(fallback_query, {'code': stock_code_full}) + + if result: + latest_data = result[0] + latest_ts = latest_data[1] + + # 获取该bar所属交易日前一个交易日的收盘价 + prev_close_query = """ + SELECT close + FROM stock_minute + WHERE code = %(code)s + AND timestamp \ + < %(start)s + ORDER BY timestamp DESC + LIMIT 1 \ + """ + + prev_result = client.execute(prev_close_query, { + 'code': stock_code_full, + 'start': datetime.combine(latest_ts.date(), dt_time(9, 30)) + }) + + prev_close = float(prev_result[0][0]) if prev_result else float(latest_data[0]) + + # 计算涨跌幅 + change = float(latest_data[0]) - prev_close + change_percent = (change / prev_close * 100) if prev_close > 0 else 0.0 + + quotes_data[code] = { + 'price': float(latest_data[0]), + 'prev_close': float(prev_close), + 'change': float(change), + 'change_percent': float(change_percent), + 'high': float(latest_data[2]), + 'low': float(latest_data[3]), + 'volume': int(latest_data[4]), + 'amount': float(latest_data[5]), + 'update_time': latest_ts.strftime('%H:%M:%S') + } + + # 构建响应数据 + response_data = [] + for item in watchlist: + code6, _ = _normalize_stock_input(item.stock_code) + quote = quotes_data.get(code6 or item.stock_code, {}) + response_data.append({ + 'stock_code': code6 or item.stock_code, + 'stock_name': item.stock_name or (code6 and _query_stock_name_by_code(code6)) or None, + 'current_price': quote.get('price', 0), + 'prev_close': quote.get('prev_close', 0), + 'change': quote.get('change', 0), + 'change_percent': quote.get('change_percent', 0), + 'high': quote.get('high', 0), + 'low': quote.get('low', 0), + 'volume': quote.get('volume', 0), + 'amount': quote.get('amount', 0), + 'update_time': quote.get('update_time', ''), + # industry 字段在 Watchlist 模型中不存在,先不返回该字段 + }) + + return jsonify({ + 'success': True, + 'data': response_data + }) + + except Exception as e: + print(f"获取实时行情失败: {str(e)}") + return jsonify({'success': False, 'error': '获取实时行情失败'}), 500 + + +# 投资计划和复盘相关的模型 +class InvestmentPlan(db.Model): + __tablename__ = 'investment_plans' + id = db.Column(db.Integer, primary_key=True, autoincrement=True) + user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) + date = db.Column(db.Date, nullable=False) + title = db.Column(db.String(200), nullable=False) + content = db.Column(db.Text) + type = db.Column(db.String(20)) # 'plan' or 'review' + stocks = db.Column(db.Text) # JSON array of stock codes + tags = db.Column(db.String(500)) # JSON array of tags + status = db.Column(db.String(20), default='active') # active, completed, cancelled + created_at = db.Column(db.DateTime, default=datetime.utcnow) + updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + def to_dict(self): + return { + 'id': self.id, + 'date': self.date.isoformat() if self.date else None, + 'title': self.title, + 'content': self.content, + 'type': self.type, + 'stocks': json.loads(self.stocks) if self.stocks else [], + 'tags': json.loads(self.tags) if self.tags else [], + 'status': self.status, + 'created_at': self.created_at.isoformat() if self.created_at else None, + 'updated_at': self.updated_at.isoformat() if self.updated_at else None + } + + +@app.route('/api/account/investment-plans', methods=['GET']) +def get_investment_plans(): + """获取投资计划和复盘记录""" + try: + if 'user_id' not in session: + return jsonify({'success': False, 'error': '未登录'}), 401 + + plan_type = request.args.get('type') # 'plan', 'review', or None for all + start_date = request.args.get('start_date') + end_date = request.args.get('end_date') + + query = InvestmentPlan.query.filter_by(user_id=session['user_id']) + + if plan_type: + query = query.filter_by(type=plan_type) + + if start_date: + query = query.filter(InvestmentPlan.date >= datetime.fromisoformat(start_date).date()) + + if end_date: + query = query.filter(InvestmentPlan.date <= datetime.fromisoformat(end_date).date()) + + plans = query.order_by(InvestmentPlan.date.desc()).all() + + return jsonify({ + 'success': True, + 'data': [plan.to_dict() for plan in plans] + }) + + except Exception as e: + print(f"获取投资计划失败: {str(e)}") + return jsonify({'success': False, 'error': '获取数据失败'}), 500 + + +@app.route('/api/account/investment-plans', methods=['POST']) +def create_investment_plan(): + """创建投资计划或复盘记录""" + try: + if 'user_id' not in session: + return jsonify({'success': False, 'error': '未登录'}), 401 + + data = request.get_json() + + # 验证必要字段 + if not data.get('date') or not data.get('title') or not data.get('type'): + return jsonify({'success': False, 'error': '缺少必要字段'}), 400 + + plan = InvestmentPlan( + user_id=session['user_id'], + date=datetime.fromisoformat(data['date']).date(), + title=data['title'], + content=data.get('content', ''), + type=data['type'], + stocks=json.dumps(data.get('stocks', [])), + tags=json.dumps(data.get('tags', [])), + status=data.get('status', 'active') + ) + + db.session.add(plan) + db.session.commit() + + return jsonify({ + 'success': True, + 'data': plan.to_dict() + }) + + except Exception as e: + db.session.rollback() + print(f"创建投资计划失败: {str(e)}") + return jsonify({'success': False, 'error': '创建失败'}), 500 + + +@app.route('/api/account/investment-plans/', methods=['PUT']) +def update_investment_plan(plan_id): + """更新投资计划或复盘记录""" + try: + if 'user_id' not in session: + return jsonify({'success': False, 'error': '未登录'}), 401 + + plan = InvestmentPlan.query.filter_by(id=plan_id, user_id=session['user_id']).first() + if not plan: + return jsonify({'success': False, 'error': '未找到该记录'}), 404 + + data = request.get_json() + + if 'date' in data: + plan.date = datetime.fromisoformat(data['date']).date() + if 'title' in data: + plan.title = data['title'] + if 'content' in data: + plan.content = data['content'] + if 'stocks' in data: + plan.stocks = json.dumps(data['stocks']) + if 'tags' in data: + plan.tags = json.dumps(data['tags']) + if 'status' in data: + plan.status = data['status'] + + plan.updated_at = datetime.utcnow() + db.session.commit() + + return jsonify({ + 'success': True, + 'data': plan.to_dict() + }) + + except Exception as e: + db.session.rollback() + print(f"更新投资计划失败: {str(e)}") + return jsonify({'success': False, 'error': '更新失败'}), 500 + + +@app.route('/api/account/investment-plans/', methods=['DELETE']) +def delete_investment_plan(plan_id): + """删除投资计划或复盘记录""" + try: + if 'user_id' not in session: + return jsonify({'success': False, 'error': '未登录'}), 401 + + plan = InvestmentPlan.query.filter_by(id=plan_id, user_id=session['user_id']).first() + if not plan: + return jsonify({'success': False, 'error': '未找到该记录'}), 404 + + db.session.delete(plan) + db.session.commit() + + return jsonify({'success': True}) + + except Exception as e: + db.session.rollback() + print(f"删除投资计划失败: {str(e)}") + return jsonify({'success': False, 'error': '删除失败'}), 500 + + +@app.route('/api/account/events/following', methods=['GET']) +def get_my_following_events(): + """获取我关注的事件列表""" + if 'user_id' not in session: + return jsonify({'success': False, 'error': '未登录'}), 401 + + follows = EventFollow.query.filter_by(user_id=session['user_id']).order_by(EventFollow.created_at.desc()).all() + event_ids = [f.event_id for f in follows] + if not event_ids: + return jsonify({'success': True, 'data': []}) + + events = Event.query.filter(Event.id.in_(event_ids)).all() + data = [] + for ev in events: + data.append({ + 'id': ev.id, + 'title': ev.title, + 'event_type': ev.event_type, + 'start_time': ev.start_time.isoformat() if ev.start_time else None, + 'hot_score': ev.hot_score, + 'follower_count': ev.follower_count, + }) + return jsonify({'success': True, 'data': data}) + + +@app.route('/api/account/events/comments', methods=['GET']) +def get_my_event_comments(): + """获取我在事件上的评论(EventComment)""" + if 'user_id' not in session: + return jsonify({'success': False, 'error': '未登录'}), 401 + + comments = EventComment.query.filter_by(user_id=session['user_id']).order_by(EventComment.created_at.desc()).limit( + 100).all() + return jsonify({'success': True, 'data': [c.to_dict() for c in comments]}) + + +@app.route('/api/account/future-events/following', methods=['GET']) +def get_my_following_future_events(): + """获取当前用户关注的未来事件""" + if 'user_id' not in session: + return jsonify({'success': False, 'error': '未登录'}), 401 + + try: + # 获取用户关注的未来事件ID列表 + follows = FutureEventFollow.query.filter_by(user_id=session['user_id']).all() + future_event_ids = [f.future_event_id for f in follows] + + if not future_event_ids: + return jsonify({'success': True, 'data': []}) + + # 查询未来事件详情 + sql = """ + SELECT * + FROM future_events + WHERE data_id IN :event_ids + ORDER BY calendar_time \ + """ + + result = db.session.execute( + text(sql), + {'event_ids': tuple(future_event_ids)} + ) + + events = [] + for row in result: + event_data = { + 'id': row.data_id, + 'title': row.title, + 'type': row.type, + 'calendar_time': row.calendar_time.isoformat(), + 'star': row.star, + 'former': row.former, + 'forecast': row.forecast, + 'fact': row.fact, + 'is_following': True, # 这些都是已关注的 + 'related_stocks': parse_json_field(row.related_stocks), + 'concepts': parse_json_field(row.concepts) + } + events.append(event_data) + + return jsonify({'success': True, 'data': events}) + + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + + +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 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') + + @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) # 动量 + + +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 EventTransmissionNode(db.Model): + """事件传导节点模型""" + __tablename__ = 'event_transmission_nodes' + + id = db.Column(db.Integer, primary_key=True) + event_id = db.Column(db.Integer, db.ForeignKey('event.id'), nullable=False) + node_type = db.Column(db.Enum('company', 'industry', 'policy', 'technology', + 'market', 'event', 'other'), nullable=False) + node_name = db.Column(db.String(200), nullable=False) + node_description = db.Column(db.Text) + importance_score = db.Column(db.Integer, default=50) + stock_code = db.Column(db.String(20)) + is_main_event = db.Column(db.Boolean, default=False) + + created_at = db.Column(db.DateTime, default=beijing_now) + updated_at = db.Column(db.DateTime, default=beijing_now, onupdate=beijing_now) + + # Relationships + event = db.relationship('Event', backref='transmission_nodes') + outgoing_edges = db.relationship('EventTransmissionEdge', + foreign_keys='EventTransmissionEdge.from_node_id', + backref='from_node', cascade='all, delete-orphan') + incoming_edges = db.relationship('EventTransmissionEdge', + foreign_keys='EventTransmissionEdge.to_node_id', + backref='to_node', cascade='all, delete-orphan') + + __table_args__ = ( + db.Index('idx_event_id', 'event_id'), + db.Index('idx_node_type', 'node_type'), + db.Index('idx_main_event', 'is_main_event'), + ) + + +class EventTransmissionEdge(db.Model): + """事件传导边模型""" + __tablename__ = 'event_transmission_edges' + + id = db.Column(db.Integer, primary_key=True) + event_id = db.Column(db.Integer, db.ForeignKey('event.id'), nullable=False) + from_node_id = db.Column(db.Integer, db.ForeignKey('event_transmission_nodes.id'), nullable=False) + to_node_id = db.Column(db.Integer, db.ForeignKey('event_transmission_nodes.id'), nullable=False) + + transmission_type = db.Column(db.Enum('supply_chain', 'competition', 'policy', + 'technology', 'capital_flow', 'expectation', + 'cyclic_effect', 'other'), nullable=False) + transmission_mechanism = db.Column(db.Text) + direction = db.Column(db.Enum('positive', 'negative', 'neutral', 'mixed'), default='neutral') + strength = db.Column(db.Integer, default=50) + impact = db.Column(db.Text) + is_circular = db.Column(db.Boolean, default=False) + + created_at = db.Column(db.DateTime, default=beijing_now) + updated_at = db.Column(db.DateTime, default=beijing_now, onupdate=beijing_now) + + # Relationship + event = db.relationship('Event', backref='transmission_edges') + + __table_args__ = ( + db.Index('idx_event_id', 'event_id'), + db.Index('idx_strength', 'strength'), + db.Index('idx_from_to', 'from_node_id', 'to_node_id'), + db.Index('idx_circular', 'is_circular'), + ) + + +# 在 paste-2.txt 的模型定义部分添加 +class EventSankeyFlow(db.Model): + """事件桑基流模型""" + __tablename__ = 'event_sankey_flows' + + id = db.Column(db.Integer, primary_key=True) + event_id = db.Column(db.Integer, db.ForeignKey('event.id'), nullable=False) + + # 流的基本信息 + source_node = db.Column(db.String(200), nullable=False) + source_type = db.Column(db.Enum('event', 'policy', 'technology', 'industry', + 'company', 'product'), nullable=False) + source_level = db.Column(db.Integer, nullable=False, default=0) + + target_node = db.Column(db.String(200), nullable=False) + target_type = db.Column(db.Enum('policy', 'technology', 'industry', + 'company', 'product'), nullable=False) + target_level = db.Column(db.Integer, nullable=False, default=1) + + # 流量信息 + flow_value = db.Column(db.Numeric(10, 2), nullable=False) + flow_ratio = db.Column(db.Numeric(5, 4), nullable=False) + + # 传导机制 + transmission_path = db.Column(db.String(500)) + impact_description = db.Column(db.Text) + evidence_strength = db.Column(db.Integer, default=50) + + # 时间戳 + created_at = db.Column(db.DateTime, default=beijing_now) + updated_at = db.Column(db.DateTime, default=beijing_now, onupdate=beijing_now) + + # 关系 + event = db.relationship('Event', backref='sankey_flows') + + __table_args__ = ( + db.Index('idx_event_id', 'event_id'), + db.Index('idx_source_target', 'source_node', 'target_node'), + db.Index('idx_levels', 'source_level', 'target_level'), + db.Index('idx_flow_value', 'flow_value'), + ) + + +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'), + ) + + +# === 股票盈利预测(自有表) === +class StockForecastData(db.Model): + """股票盈利预测数据 + + 源于本地表 stock_forecast_data,由独立离线程序写入。 + 字段与表结构保持一致,仅用于读取聚合后输出前端报表所需的结构。 + """ + __tablename__ = 'stock_forecast_data' + + id = db.Column(db.Integer, primary_key=True) + stock_code = db.Column(db.String(6), nullable=False) + indicator_name = db.Column(db.String(50), nullable=False) + year_2022a = db.Column(db.Numeric(15, 2)) + year_2023a = db.Column(db.Numeric(15, 2)) + year_2024a = db.Column(db.Numeric(15, 2)) + year_2025e = db.Column(db.Numeric(15, 2)) + year_2026e = db.Column(db.Numeric(15, 2)) + year_2027e = db.Column(db.Numeric(15, 2)) + process_time = db.Column(db.DateTime, nullable=False) + + __table_args__ = ( + db.UniqueConstraint('stock_code', 'indicator_name', name='unique_stock_indicator'), + ) + + def values_by_year(self): + years = ['2022A', '2023A', '2024A', '2025E', '2026E', '2027E'] + vals = [self.year_2022a, self.year_2023a, self.year_2024a, self.year_2025e, self.year_2026e, self.year_2027e] + + def _to_float(x): + try: + return float(x) if x is not None else None + except Exception: + return None + + return years, [_to_float(v) for v in vals] + + +@app.route('/api/events/', methods=['GET']) +def get_event_detail(event_id): + """获取事件详情""" + try: + event = Event.query.get_or_404(event_id) + + # 增加浏览计数 + event.view_count += 1 + db.session.commit() + + return jsonify({ + 'success': True, + 'data': { + 'id': event.id, + 'title': event.title, + 'description': event.description, + 'event_type': event.event_type, + 'status': event.status, + 'start_time': event.start_time.isoformat() if event.start_time else None, + 'end_time': event.end_time.isoformat() if event.end_time else None, + 'created_at': event.created_at.isoformat() if event.created_at else None, + 'hot_score': event.hot_score, + 'view_count': event.view_count, + 'trending_score': event.trending_score, + 'post_count': event.post_count, + 'follower_count': event.follower_count, + 'related_industries': event.related_industries, + 'keywords': event.keywords_list, + 'importance': event.importance, + '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, + 'expectation_surprise_score': event.expectation_surprise_score, + 'creator_id': event.creator_id, + 'has_chain_analysis': ( + EventTransmissionNode.query.filter_by(event_id=event_id).first() is not None or + EventSankeyFlow.query.filter_by(event_id=event_id).first() is not None + ), + 'is_following': False, # 需要根据当前用户状态判断 + } + }) + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + + +@app.route('/api/events//stocks', methods=['GET']) +def get_related_stocks(event_id): + """获取相关股票列表""" + try: + # 订阅控制:相关标的需要 Pro 及以上 + if not _has_required_level('pro'): + return jsonify({'success': False, 'error': '需要Pro订阅', 'required_level': 'pro'}), 403 + event = Event.query.get_or_404(event_id) + stocks = event.related_stocks.order_by(RelatedStock.correlation.desc()).all() + + stocks_data = [] + for stock in stocks: + if stock.retrieved_sources is not None: + stocks_data.append({ + 'id': stock.id, + 'stock_code': stock.stock_code, + 'stock_name': stock.stock_name, + 'sector': stock.sector, + 'relation_desc': {"data":stock.retrieved_sources}, + 'retrieved_sources': stock.retrieved_sources, + 'correlation': stock.correlation, + 'momentum': stock.momentum, + 'created_at': stock.created_at.isoformat() if stock.created_at else None, + 'updated_at': stock.updated_at.isoformat() if stock.updated_at else None + }) + else: + stocks_data.append({ + '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, + 'created_at': stock.created_at.isoformat() if stock.created_at else None, + 'updated_at': stock.updated_at.isoformat() if stock.updated_at else None + }) + + return jsonify({ + 'success': True, + 'data': stocks_data + }) + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + + +@app.route('/api/events//stocks', methods=['POST']) +def add_related_stock(event_id): + """添加相关股票""" + try: + event = Event.query.get_or_404(event_id) + data = request.get_json() + + # 验证必要字段 + if not data.get('stock_code') or not data.get('relation_desc'): + return jsonify({'success': False, 'error': '缺少必要字段'}), 400 + + # 检查是否已存在 + existing = RelatedStock.query.filter_by( + event_id=event_id, + stock_code=data['stock_code'] + ).first() + + if existing: + return jsonify({'success': False, 'error': '该股票已存在'}), 400 + + # 创建新的相关股票记录 + new_stock = RelatedStock( + event_id=event_id, + stock_code=data['stock_code'], + stock_name=data.get('stock_name', ''), + sector=data.get('sector', ''), + relation_desc=data['relation_desc'], + correlation=data.get('correlation', 0.5), + momentum=data.get('momentum', '') + ) + + db.session.add(new_stock) + db.session.commit() + + return jsonify({ + 'success': True, + 'data': { + 'id': new_stock.id, + 'stock_code': new_stock.stock_code, + 'relation_desc': new_stock.relation_desc + } + }) + except Exception as e: + db.session.rollback() + return jsonify({'success': False, 'error': str(e)}), 500 + + +@app.route('/api/stocks/', methods=['DELETE']) +def delete_related_stock(stock_id): + """删除相关股票""" + try: + stock = RelatedStock.query.get_or_404(stock_id) + db.session.delete(stock) + db.session.commit() + + return jsonify({'success': True, 'message': '删除成功'}) + except Exception as e: + db.session.rollback() + return jsonify({'success': False, 'error': str(e)}), 500 + + +@app.route('/api/events//concepts', methods=['GET']) +def get_related_concepts(event_id): + """获取相关概念列表""" + try: + # 订阅控制:相关概念需要 Pro 及以上 + if not _has_required_level('pro'): + return jsonify({'success': False, 'error': '需要Pro订阅', 'required_level': 'pro'}), 403 + event = Event.query.get_or_404(event_id) + concepts = event.related_concepts.all() + + concepts_data = [] + for concept in concepts: + concepts_data.append({ + 'id': concept.id, + 'concept_code': concept.concept_code, + 'concept': concept.concept, + 'reason': concept.reason, + 'image_paths': concept.image_paths_list, + 'first_image_path': concept.get_first_image_path(), + 'created_at': concept.created_at.isoformat() if concept.created_at else None + }) + + return jsonify({ + 'success': True, + 'data': concepts_data + }) + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + + +@app.route('/api/events//historical', methods=['GET']) +def get_historical_events(event_id): + """获取历史事件对比""" + try: + event = Event.query.get_or_404(event_id) + historical_events = event.historical_events.order_by(HistoricalEvent.event_date.desc()).all() + + events_data = [] + for hist_event in historical_events: + 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, + 'importance': hist_event.importance, + 'relevance': hist_event.relevance, + 'created_at': hist_event.created_at.isoformat() if hist_event.created_at else None + }) + + # 订阅控制:免费用户仅返回前2条;Pro/Max返回全部 + info = _get_current_subscription_info() + sub_type = (info.get('type') or 'free').lower() + if sub_type == 'free': + return jsonify({ + 'success': True, + 'data': events_data[:2], + 'truncated': len(events_data) > 2, + 'required_level': 'pro' + }) + return jsonify({'success': True, 'data': events_data}) + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + + +@app.route('/api/historical-events//stocks', methods=['GET']) +def get_historical_event_stocks(event_id): + """获取历史事件相关股票列表""" + try: + # 直接查询历史事件,不需要通过主事件 + hist_event = HistoricalEvent.query.get_or_404(event_id) + stocks = hist_event.stocks.order_by(HistoricalEventStock.correlation.desc()).all() + + # 获取事件对应的交易日 + event_trading_date = None + if hist_event.event_date: + event_trading_date = get_trading_day_near_date(hist_event.event_date) + + stocks_data = [] + for stock in stocks: + 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, + 'created_at': stock.created_at.isoformat() if stock.created_at else None + } + + # 添加涨幅数据 + if event_trading_date: + try: + # 查询股票在事件对应交易日的数据 + with engine.connect() as conn: + query = text(""" + SELECT close_price, change_pct + FROM ea_dailyline + WHERE seccode = :stock_code + AND date = :trading_date + ORDER BY date DESC + LIMIT 1 + """) + + result = conn.execute(query, { + 'stock_code': stock.stock_code, + 'trading_date': event_trading_date + }).fetchone() + + if result: + stock_data['event_day_close'] = float(result[0]) if result[0] else None + stock_data['event_day_change_pct'] = float(result[1]) if result[1] else None + else: + stock_data['event_day_close'] = None + stock_data['event_day_change_pct'] = None + except Exception as e: + print(f"查询股票{stock.stock_code}在{event_trading_date}的数据失败: {e}") + stock_data['event_day_close'] = None + stock_data['event_day_change_pct'] = None + else: + stock_data['event_day_close'] = None + stock_data['event_day_change_pct'] = None + + stocks_data.append(stock_data) + + return jsonify({ + 'success': True, + 'data': stocks_data, + 'event_trading_date': event_trading_date.isoformat() if event_trading_date else None + }) + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + + +@app.route('/api/events//expectation-score', methods=['GET']) +def get_expectation_score(event_id): + """获取超预期得分""" + try: + event = Event.query.get_or_404(event_id) + + # 如果事件有超预期得分,直接返回 + if event.expectation_surprise_score is not None: + score = event.expectation_surprise_score + else: + # 如果没有,根据历史事件计算一个模拟得分 + historical_events = event.historical_events.all() + if historical_events: + # 基于历史事件数量和重要性计算得分 + total_importance = sum(ev.importance or 0 for ev in historical_events) + avg_importance = total_importance / len(historical_events) if historical_events else 0 + score = min(100, max(0, int(avg_importance * 20 + len(historical_events) * 5))) + else: + # 默认得分 + score = 65 + + return jsonify({ + 'success': True, + 'data': { + 'score': score, + 'description': '基于历史事件判断当前事件的超预期情况,满分100分' + } + }) + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + + +@app.route('/api/events//follow', methods=['POST']) +def toggle_event_follow(event_id): + """切换事件关注状态(需登录)""" + if 'user_id' not in session: + return jsonify({'success': False, 'error': '未登录'}), 401 + + try: + event = Event.query.get_or_404(event_id) + user_id = session['user_id'] + + existing = EventFollow.query.filter_by(user_id=user_id, event_id=event_id).first() + if existing: + # 取消关注 + db.session.delete(existing) + event.follower_count = max(0, (event.follower_count or 0) - 1) + db.session.commit() + return jsonify({'success': True, 'data': {'is_following': False, 'follower_count': event.follower_count}}) + else: + # 关注 + follow = EventFollow(user_id=user_id, event_id=event_id) + db.session.add(follow) + event.follower_count = (event.follower_count or 0) + 1 + db.session.commit() + return jsonify({'success': True, 'data': {'is_following': True, 'follower_count': event.follower_count}}) + except Exception as e: + db.session.rollback() + return jsonify({'success': False, 'error': str(e)}), 500 + + +@app.route('/api/events//transmission', methods=['GET']) +def get_transmission_chain(event_id): + try: + # 订阅控制:传导链分析需要 Max 及以上 + if not _has_required_level('max'): + return jsonify({'success': False, 'error': '需要Max订阅', 'required_level': 'max'}), 403 + # 确保数据库连接是活跃的 + db.session.execute(text('SELECT 1')) + + event = Event.query.get_or_404(event_id) + nodes = EventTransmissionNode.query.filter_by(event_id=event_id).all() + edges = EventTransmissionEdge.query.filter_by(event_id=event_id).all() + + # 过滤孤立节点 + connected_node_ids = set() + for edge in edges: + connected_node_ids.add(edge.from_node_id) + connected_node_ids.add(edge.to_node_id) + + # 只保留有连接的节点 + connected_nodes = [node for node in nodes if node.id in connected_node_ids] + + # 如果没有主事件节点,也保留主事件节点 + main_event_node = next((node for node in nodes if node.is_main_event), None) + if main_event_node and main_event_node not in connected_nodes: + connected_nodes.append(main_event_node) + + if not connected_nodes: + return jsonify({'success': False, 'message': '暂无传导链分析数据'}) + + # 节点类型到中文类别的映射 + categories = { + 'event': "事件", 'industry': "行业", 'company': "公司", + 'policy': "政策", 'technology': "技术", 'market': "市场", 'other': "其他" + } + + nodes_data = [] + for node in connected_nodes: + node_category = categories.get(node.node_type, "其他") + nodes_data.append({ + 'id': str(node.id), # 转换为字符串以保持一致性 + 'name': node.node_name, + 'category': node_category, + 'value': node.importance_score or 20, + 'extra': { + 'node_type': node.node_type, + 'description': node.node_description, + 'importance_score': node.importance_score, + 'stock_code': node.stock_code, + 'is_main_event': node.is_main_event + } + }) + + edges_data = [] + for edge in edges: + # 确保边的两端节点都在连接节点列表中 + if edge.from_node_id in connected_node_ids and edge.to_node_id in connected_node_ids: + edges_data.append({ + 'source': str(edge.from_node_id), # 转换为字符串以保持一致性 + 'target': str(edge.to_node_id), # 转换为字符串以保持一致性 + 'value': edge.strength or 50, + 'extra': { + 'transmission_type': edge.transmission_type, + 'transmission_mechanism': edge.transmission_mechanism, + 'direction': edge.direction, + 'strength': edge.strength, + 'impact': edge.impact, + 'is_circular': edge.is_circular, + } + }) + + return jsonify({ + 'success': True, + 'data': { + 'nodes': nodes_data, + 'edges': edges_data + } + }) + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + + +# 修复股票报价API - 支持GET和POST方法 +@app.route('/api/stock/quotes', methods=['GET', 'POST']) +def get_stock_quotes(): + try: + if request.method == 'GET': + # GET 请求从查询参数获取数据 + codes_str = request.args.get('codes', '') + codes = [code.strip() for code in codes_str.split(',') if code.strip()] + event_time_str = request.args.get('event_time') + else: + # POST 请求从 JSON 获取数据 + codes = request.json.get('codes', []) + event_time_str = request.json.get('event_time') + + if not codes: + return jsonify({'success': False, 'error': '请提供股票代码'}), 400 + + # 处理事件时间 + if event_time_str: + try: + event_time = datetime.fromisoformat(event_time_str.replace('Z', '+00:00')) + except: + event_time = datetime.now() + else: + event_time = datetime.now() + + current_time = datetime.now() + client = get_clickhouse_client() + + # Get stock names from MySQL + stock_names = {} + with engine.connect() as conn: + for code in codes: + codez = code.split('.')[0] + result = conn.execute(text( + "SELECT SECNAME FROM ea_stocklist WHERE SECCODE = :code" + ), {"code": codez}).fetchone() + if result: + stock_names[code] = result[0] + else: + stock_names[code] = f"股票{codez}" + + def get_trading_day_and_times(event_datetime): + event_date = event_datetime.date() + event_time = 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 < market_open: + # Before market opens - use full trading day + return event_date, market_open, market_close + elif event_time > 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, 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({ + 'success': True, + 'data': {code: {'name': name, 'price': None, 'change': None} + for code, name in stock_names.items()} + }) + + # 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(): + return jsonify({ + 'success': True, + 'data': {code: {'name': name, 'price': None, 'change': None} + for code, name in stock_names.items()} + }) + + results = {} + print(f"处理股票代码: {codes}, 交易日: {trading_day}, 时间范围: {start_datetime} - {end_datetime}") + + for code in codes: + try: + print(f"正在查询股票 {code} 的价格数据...") + # Get the first price and last price for the trading period + data = client.execute(""" + WITH first_price AS (SELECT close + FROM stock_minute + WHERE code = %(code)s + AND timestamp >= %(start)s + AND timestamp <= %(end)s + ORDER BY timestamp + LIMIT 1 + ), + last_price AS ( + SELECT close + FROM stock_minute + WHERE code = %(code)s + AND timestamp >= %(start)s + AND timestamp <= %(end)s + ORDER BY timestamp DESC + LIMIT 1 + ) + SELECT last_price.close as last_price, + (last_price.close - first_price.close) / first_price.close * 100 as change + FROM last_price + CROSS JOIN first_price + WHERE EXISTS (SELECT 1 FROM first_price) + AND EXISTS (SELECT 1 FROM last_price) + """, { + 'code': code, + 'start': start_datetime, + 'end': end_datetime + }) + + print(f"股票 {code} 查询结果: {data}") + if data and data[0] and data[0][0] is not None: + price = float(data[0][0]) if data[0][0] is not None else None + change = float(data[0][1]) if data[0][1] is not None else None + + results[code] = { + 'price': price, + 'change': change, + 'name': stock_names.get(code, f'股票{code.split(".")[0]}') + } + else: + results[code] = { + 'price': None, + 'change': None, + 'name': stock_names.get(code, f'股票{code.split(".")[0]}') + } + except Exception as e: + print(f"Error processing stock {code}: {e}") + results[code] = { + 'price': None, + 'change': None, + 'name': stock_names.get(code, f'股票{code.split(".")[0]}') + } + + # 返回标准格式 + return jsonify({'success': True, 'data': results}) + + except Exception as e: + print(f"Stock quotes API error: {e}") + return jsonify({'success': False, 'error': str(e)}), 500 + + +def get_clickhouse_client(): + return Cclient( + host='222.128.1.157', + port=18000, + user='default', + password='Zzl33818!', + database='stock' + ) + + +@app.route('/api/account/calendar/events', methods=['GET', 'POST']) +def account_calendar_events(): + """返回当前用户的投资计划与关注的未来事件(合并)。 + GET: 可按日期范围/月份过滤;POST: 新增投资计划(写入 InvestmentPlan)。 + """ + try: + if 'user_id' not in session: + return jsonify({'success': False, 'error': '未登录'}), 401 + + if request.method == 'POST': + data = request.get_json() or {} + title = data.get('title') + event_date_str = data.get('event_date') or data.get('date') + plan_type = data.get('type') or 'plan' + description = data.get('description') or data.get('content') or '' + stocks = data.get('stocks') or [] + + if not title or not event_date_str: + return jsonify({'success': False, 'error': '缺少必填字段'}), 400 + + try: + event_date = datetime.fromisoformat(event_date_str).date() + except Exception: + return jsonify({'success': False, 'error': '日期格式错误'}), 400 + + plan = InvestmentPlan( + user_id=session['user_id'], + date=event_date, + title=title, + content=description, + type=plan_type, + stocks=json.dumps(stocks), + tags=json.dumps(data.get('tags', [])), + status=data.get('status', 'active') + ) + db.session.add(plan) + db.session.commit() + + return jsonify({'success': True, 'data': { + 'id': plan.id, + 'title': plan.title, + 'event_date': plan.date.isoformat(), + 'type': plan.type, + 'description': plan.content, + 'stocks': json.loads(plan.stocks) if plan.stocks else [], + 'source': 'plan' + }}) + + # GET + # 解析过滤参数:date 或 (year, month) 或 (start_date, end_date) + date_str = request.args.get('date') + year = request.args.get('year', type=int) + month = request.args.get('month', type=int) + start_date_str = request.args.get('start_date') + end_date_str = request.args.get('end_date') + + start_date = None + end_date = None + if date_str: + try: + d = datetime.fromisoformat(date_str).date() + start_date = d + end_date = d + except Exception: + pass + elif year and month: + # 月份范围 + start_date = datetime(year, month, 1).date() + if month == 12: + end_date = datetime(year + 1, 1, 1).date() - timedelta(days=1) + else: + end_date = datetime(year, month + 1, 1).date() - timedelta(days=1) + elif start_date_str and end_date_str: + try: + start_date = datetime.fromisoformat(start_date_str).date() + end_date = datetime.fromisoformat(end_date_str).date() + except Exception: + start_date = None + end_date = None + + # 查询投资计划 + plans_query = InvestmentPlan.query.filter_by(user_id=session['user_id']) + if start_date and end_date: + plans_query = plans_query.filter(InvestmentPlan.date >= start_date, InvestmentPlan.date <= end_date) + elif start_date: + plans_query = plans_query.filter(InvestmentPlan.date == start_date) + plans = plans_query.order_by(InvestmentPlan.date.asc()).all() + + plan_events = [{ + 'id': p.id, + 'title': p.title, + 'event_date': p.date.isoformat(), + 'type': p.type or 'plan', + 'description': p.content, + 'importance': 3, + 'stocks': json.loads(p.stocks) if p.stocks else [], + 'source': 'plan' + } for p in plans] + + # 查询关注的未来事件 + follows = FutureEventFollow.query.filter_by(user_id=session['user_id']).all() + future_event_ids = [f.future_event_id for f in follows] + + future_events = [] + if future_event_ids: + base_sql = """ + SELECT data_id, \ + title, \ + type, \ + calendar_time, \ + star, \ + former, \ + forecast, \ + fact, \ + related_stocks, \ + concepts + FROM future_events + WHERE data_id IN :event_ids \ + """ + + params = {'event_ids': tuple(future_event_ids)} + # 日期过滤(按 calendar_time 的日期) + if start_date and end_date: + base_sql += " AND DATE(calendar_time) BETWEEN :start_date AND :end_date" + params.update({'start_date': start_date, 'end_date': end_date}) + elif start_date: + base_sql += " AND DATE(calendar_time) = :start_date" + params.update({'start_date': start_date}) + + base_sql += " ORDER BY calendar_time" + + result = db.session.execute(text(base_sql), params) + for row in result: + # related_stocks 形如 [[code,name,reason,score], ...] + rs = parse_json_field(row.related_stocks) + stock_tags = [] + try: + for it in rs: + if isinstance(it, (list, tuple)) and len(it) >= 2: + stock_tags.append(f"{it[0]} {it[1]}") + elif isinstance(it, str): + stock_tags.append(it) + except Exception: + pass + + future_events.append({ + 'id': row.data_id, + 'title': row.title, + 'event_date': (row.calendar_time.date().isoformat() if row.calendar_time else None), + 'type': 'future_event', + 'importance': int(row.star) if getattr(row, 'star', None) is not None else 3, + 'description': row.former or '', + 'stocks': stock_tags, + 'is_following': True, + 'source': 'future' + }) + + return jsonify({'success': True, 'data': plan_events + future_events}) + + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + + +@app.route('/api/account/calendar/events/', methods=['DELETE']) +def delete_account_calendar_event(event_id): + """删除用户创建的投资计划事件(不影响关注的未来事件)。""" + try: + if 'user_id' not in session: + return jsonify({'success': False, 'error': '未登录'}), 401 + plan = InvestmentPlan.query.filter_by(id=event_id, user_id=session['user_id']).first() + if not plan: + return jsonify({'success': False, 'error': '未找到该记录'}), 404 + db.session.delete(plan) + db.session.commit() + return jsonify({'success': True}) + except Exception as e: + db.session.rollback() + return jsonify({'success': False, 'error': str(e)}), 500 + + +@app.route('/api/stock//kline') +def get_stock_kline(stock_code): + chart_type = request.args.get('type', 'minute') + 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 + + # 获取股票名称 + 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' + + 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) + elif chart_type == 'timeline': + return get_timeline_data(stock_code, event_datetime, stock_name) + else: + # 对于未知的类型,返回错误 + return jsonify({'error': f'Unsupported chart type: {chart_type}'}), 400 + + +@app.route('/api/stock//latest-minute', methods=['GET']) +def get_latest_minute_data(stock_code): + """获取最新交易日的分钟频数据""" + client = get_clickhouse_client() + + # 确保股票代码包含后缀 + if '.' not in stock_code: + stock_code = f"{stock_code}.SH" if stock_code.startswith('6') else f"{stock_code}.SZ" + + # 获取股票名称 + 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' + + # 查找最近30天内有数据的最新交易日 + target_date = None + current_date = datetime.now().date() + + for i in range(30): + check_date = current_date - timedelta(days=i) + trading_day = get_trading_day_near_date(check_date) + + if trading_day and trading_day <= current_date: + # 检查这个交易日是否有分钟数据 + test_data = client.execute(""" + SELECT COUNT(*) + FROM stock_minute + WHERE code = %(code)s + AND timestamp BETWEEN %(start)s AND %(end)s + LIMIT 1 + """, { + 'code': stock_code, + 'start': datetime.combine(trading_day, dt_time(9, 30)), + 'end': datetime.combine(trading_day, dt_time(15, 0)) + }) + + if test_data and test_data[0][0] > 0: + target_date = trading_day + break + + if not target_date: + return jsonify({ + 'error': 'No data available', + 'code': stock_code, + 'name': stock_name, + 'data': [], + 'trade_date': current_date.strftime('%Y-%m-%d'), + 'type': 'minute' + }) + + # 获取目标日期的完整交易时段数据 + 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 = [{ + '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]) + } for row in data] + + return jsonify({ + 'code': stock_code, + 'name': stock_name, + 'data': kline_data, + 'trade_date': target_date.strftime('%Y-%m-%d'), + 'type': 'minute', + 'is_latest': True + }) + + +@app.route('/api/stock//forecast-report', methods=['GET']) +def get_stock_forecast_report(stock_code): + """基于 stock_forecast_data 输出报表所需数据结构 + + 返回: + - income_profit_trend: 营业收入/归母净利润趋势 + - growth_bars: 增长率柱状图数据(基于营业收入同比) + - eps_trend: EPS 折线 + - pe_peg_axes: PE/PEG 双轴 + - detail_table: 详细数据表格(与附件结构一致) + """ + try: + # 读取该股票所有指标 + rows = StockForecastData.query.filter_by(stock_code=stock_code).all() + if not rows: + return jsonify({'success': False, 'error': 'no_data'}), 404 + + # 将指标映射为字典 + indicators = {} + for r in rows: + years, vals = r.values_by_year() + indicators[r.indicator_name] = dict(zip(years, vals)) + + def safe(x): + return x if x is not None else None + + years = ['2022A', '2023A', '2024A', '2025E', '2026E', '2027E'] + + # 营业收入与净利润趋势 + income = indicators.get('营业总收入(百万元)', {}) + profit = indicators.get('归母净利润(百万元)', {}) + income_profit_trend = { + 'years': years, + 'income': [safe(income.get(y)) for y in years], + 'profit': [safe(profit.get(y)) for y in years] + } + + # 增长率柱状(若表内已有"增长率(%)",直接使用;否则按营业收入同比计算) + growth = indicators.get('增长率(%)') + if growth is None: + # 计算同比: (curr - prev)/prev*100 + growth_vals = [] + prev = None + for y in years: + curr = income.get(y) + if prev is not None and prev not in (None, 0) and curr is not None: + growth_vals.append(round((float(curr) - float(prev)) / float(prev) * 100, 2)) + else: + growth_vals.append(None) + prev = curr + else: + growth_vals = [safe(growth.get(y)) for y in years] + growth_bars = { + 'years': years, + 'revenue_growth_pct': growth_vals, + 'net_profit_growth_pct': None # 如后续需要可扩展 + } + + # EPS 趋势 + eps = indicators.get('EPS(稀释)') or indicators.get('EPS(元/股)') or {} + eps_trend = { + 'years': years, + 'eps': [safe(eps.get(y)) for y in years] + } + + # PE / PEG 双轴 + pe = indicators.get('PE') or {} + peg = indicators.get('PEG') or {} + pe_peg_axes = { + 'years': years, + 'pe': [safe(pe.get(y)) for y in years], + 'peg': [safe(peg.get(y)) for y in years] + } + + # 详细数据表格(列顺序固定) + def fmt(val): + try: + return None if val is None else round(float(val), 2) + except Exception: + return None + + detail_rows = [ + { + '指标': '营业总收入(百万元)', + **{y: fmt(income.get(y)) for y in years}, + }, + { + '指标': '增长率(%)', + **{y: fmt(v) for y, v in zip(years, growth_vals)}, + }, + { + '指标': '归母净利润(百万元)', + **{y: fmt(profit.get(y)) for y in years}, + }, + { + '指标': 'EPS(稀释)', + **{y: fmt(eps.get(y)) for y in years}, + }, + { + '指标': 'PE', + **{y: fmt(pe.get(y)) for y in years}, + }, + { + '指标': 'PEG', + **{y: fmt(peg.get(y)) for y in years}, + }, + ] + + return jsonify({ + 'success': True, + 'data': { + 'income_profit_trend': income_profit_trend, + 'growth_bars': growth_bars, + 'eps_trend': eps_trend, + 'pe_peg_axes': pe_peg_axes, + 'detail_table': { + 'years': years, + 'rows': detail_rows + } + } + }) + except Exception as e: + app.logger.error(f"forecast report error: {e}", exc_info=True) + return jsonify({'success': False, 'error': str(e)}), 500 + + +@app.route('/api/stock//basic-info', methods=['GET']) +def get_stock_basic_info(stock_code): + """获取股票基本信息(来自ea_baseinfo表)""" + try: + with engine.connect() as conn: + query = text(""" + SELECT SECCODE, + SECNAME, + ORGNAME, + F001V as en_name, + F002V as en_short_name, + F003V as legal_representative, + F004V as reg_address, + F005V as office_address, + F006V as post_code, + F007N as reg_capital, + F009V as currency, + F010D as establish_date, + F011V as website, + F012V as email, + F013V as tel, + F014V as fax, + F015V as main_business, + F016V as business_scope, + F017V as company_intro, + F018V as secretary, + F019V as secretary_tel, + F020V as secretary_fax, + F021V as secretary_email, + F024V as listing_status, + F026V as province, + F028V as city, + F030V as industry_l1, + F032V as industry_l2, + F034V as sw_industry_l1, + F036V as sw_industry_l2, + F038V as sw_industry_l3, + F039V as accounting_firm, + F040V as law_firm, + F041V as chairman, + F042V as general_manager, + F043V as independent_directors, + F050V as credit_code, + F054V as company_size, + UPDATE_DATE + FROM ea_baseinfo + WHERE SECCODE = :stock_code LIMIT 1 + """) + + result = conn.execute(query, {'stock_code': stock_code}).fetchone() + + if not result: + return jsonify({ + 'success': False, + 'error': f'未找到股票代码 {stock_code} 的基本信息' + }), 404 + + # 转换为字典 + basic_info = {} + for key, value in zip(result.keys(), result): + if isinstance(value, datetime): + basic_info[key] = value.strftime('%Y-%m-%d') + elif isinstance(value, Decimal): + basic_info[key] = float(value) + else: + basic_info[key] = value + + return jsonify({ + 'success': True, + 'data': basic_info + }) + + except Exception as e: + app.logger.error(f"Error getting stock basic info: {e}", exc_info=True) + return jsonify({'success': False, 'error': str(e)}), 500 + + +@app.route('/api/stock//announcements', methods=['GET']) +def get_stock_announcements(stock_code): + """获取股票公告列表""" + try: + limit = request.args.get('limit', 50, type=int) + + with engine.connect() as conn: + query = text(""" + SELECT F001D as announce_date, + F002V as title, + F003V as url, + F004V as format, + F005N as file_size, + F006V as info_type, + UPDATE_DATE + FROM ea_baseinfolist + WHERE SECCODE = :stock_code + ORDER BY F001D DESC LIMIT :limit + """) + + result = conn.execute(query, {'stock_code': stock_code, 'limit': limit}).fetchall() + + announcements = [] + for row in result: + announcement = {} + for key, value in zip(row.keys(), row): + if value is None: + announcement[key] = None + elif isinstance(value, datetime): + announcement[key] = value.strftime('%Y-%m-%d %H:%M:%S') + elif isinstance(value, date): + announcement[key] = value.strftime('%Y-%m-%d') + elif isinstance(value, Decimal): + announcement[key] = float(value) + else: + announcement[key] = value + announcements.append(announcement) + + return jsonify({ + 'success': True, + 'data': announcements, + 'total': len(announcements) + }) + + except Exception as e: + app.logger.error(f"Error getting stock announcements: {e}", exc_info=True) + return jsonify({'success': False, 'error': str(e)}), 500 + + +@app.route('/api/stock//disclosure-schedule', methods=['GET']) +def get_stock_disclosure_schedule(stock_code): + """获取股票财报预披露时间表""" + try: + with engine.connect() as conn: + query = text(""" + SELECT distinct F001D as report_period, + F002D as scheduled_date, + F003D as change_date1, + F004D as change_date2, + F005D as change_date3, + F006D as actual_date, + F007D as change_date4, + F008D as change_date5, + MODTIME as mod_time + FROM ea_pretime + WHERE SECCODE = :stock_code + ORDER BY F001D DESC LIMIT 20 + """) + + result = conn.execute(query, {'stock_code': stock_code}).fetchall() + + schedules = [] + for row in result: + schedule = {} + for key, value in zip(row.keys(), row): + if value is None: + schedule[key] = None + elif isinstance(value, datetime): + schedule[key] = value.strftime('%Y-%m-%d %H:%M:%S') + elif isinstance(value, date): + schedule[key] = value.strftime('%Y-%m-%d') + elif isinstance(value, Decimal): + schedule[key] = float(value) + else: + schedule[key] = value + + # 计算最新的预约日期 + latest_scheduled = schedule.get('scheduled_date') + for change_field in ['change_date5', 'change_date4', 'change_date3', 'change_date2', 'change_date1']: + if schedule.get(change_field): + latest_scheduled = schedule[change_field] + break + + schedule['latest_scheduled_date'] = latest_scheduled + schedule['is_disclosed'] = bool(schedule.get('actual_date')) + + # 格式化报告期名称 + if schedule.get('report_period'): + period_date = schedule['report_period'] + if period_date.endswith('-03-31'): + schedule['report_name'] = f"{period_date[:4]}年一季报" + elif period_date.endswith('-06-30'): + schedule['report_name'] = f"{period_date[:4]}年中报" + elif period_date.endswith('-09-30'): + schedule['report_name'] = f"{period_date[:4]}年三季报" + elif period_date.endswith('-12-31'): + schedule['report_name'] = f"{period_date[:4]}年年报" + else: + schedule['report_name'] = period_date + + schedules.append(schedule) + + return jsonify({ + 'success': True, + 'data': schedules, + 'total': len(schedules) + }) + + except Exception as e: + app.logger.error(f"Error getting disclosure schedule: {e}", exc_info=True) + return jsonify({'success': False, 'error': str(e)}), 500 + + +@app.route('/api/stock//actual-control', methods=['GET']) +def get_stock_actual_control(stock_code): + """获取股票实际控制人信息""" + try: + with engine.connect() as conn: + query = text(""" + SELECT DECLAREDATE as declare_date, + ENDDATE as end_date, + F001V as direct_holder_id, + F002V as direct_holder_name, + F003V as actual_controller_id, + F004V as actual_controller_name, + F005N as holding_shares, + F006N as holding_ratio, + F007V as control_type_code, + F008V as control_type, + F012V as direct_controller_id, + F013V as direct_controller_name, + F014V as controller_type, + ORGNAME as org_name, + SECCODE as sec_code, + SECNAME as sec_name + FROM ea_actualcon + WHERE SECCODE = :stock_code + ORDER BY ENDDATE DESC, DECLAREDATE DESC LIMIT 20 + """) + + result = conn.execute(query, {'stock_code': stock_code}).fetchall() + + control_info = [] + for row in result: + control_record = {} + for key, value in zip(row.keys(), row): + if value is None: + control_record[key] = None + elif isinstance(value, datetime): + control_record[key] = value.strftime('%Y-%m-%d %H:%M:%S') + elif isinstance(value, date): + control_record[key] = value.strftime('%Y-%m-%d') + elif isinstance(value, Decimal): + control_record[key] = float(value) + else: + control_record[key] = value + + control_info.append(control_record) + + return jsonify({ + 'success': True, + 'data': control_info, + 'total': len(control_info) + }) + + except Exception as e: + app.logger.error(f"Error getting actual control info: {e}", exc_info=True) + return jsonify({'success': False, 'error': str(e)}), 500 + + +@app.route('/api/stock//concentration', methods=['GET']) +def get_stock_concentration(stock_code): + """获取股票股权集中度信息""" + try: + with engine.connect() as conn: + query = text(""" + SELECT ENDDATE as end_date, + F001V as stat_item, + F002N as holding_shares, + F003N as holding_ratio, + F004N as ratio_change, + ORGNAME as org_name, + SECCODE as sec_code, + SECNAME as sec_name + FROM ea_concentration + WHERE SECCODE = :stock_code + ORDER BY ENDDATE DESC LIMIT 20 + """) + + result = conn.execute(query, {'stock_code': stock_code}).fetchall() + + concentration_info = [] + for row in result: + concentration_record = {} + for key, value in zip(row.keys(), row): + if value is None: + concentration_record[key] = None + elif isinstance(value, datetime): + concentration_record[key] = value.strftime('%Y-%m-%d %H:%M:%S') + elif isinstance(value, date): + concentration_record[key] = value.strftime('%Y-%m-%d') + elif isinstance(value, Decimal): + concentration_record[key] = float(value) + else: + concentration_record[key] = value + + concentration_info.append(concentration_record) + + return jsonify({ + 'success': True, + 'data': concentration_info, + 'total': len(concentration_info) + }) + + except Exception as e: + app.logger.error(f"Error getting concentration info: {e}", exc_info=True) + return jsonify({'success': False, 'error': str(e)}), 500 + + +@app.route('/api/stock//management', methods=['GET']) +def get_stock_management(stock_code): + """获取股票管理层信息""" + try: + # 获取是否只显示在职人员参数 + active_only = request.args.get('active_only', 'true').lower() == 'true' + + with engine.connect() as conn: + base_query = """ + SELECT DECLAREDATE as declare_date, \ + F001V as person_id, \ + F002V as name, \ + F007D as start_date, \ + F008D as end_date, \ + F009V as position_name, \ + F010V as gender, \ + F011V as education, \ + F012V as birth_year, \ + F013V as nationality, \ + F014V as position_category_code, \ + F015V as position_category, \ + F016V as position_code, \ + F017V as highest_degree, \ + F019V as resume, \ + F020C as is_active, \ + ORGNAME as org_name, \ + SECCODE as sec_code, \ + SECNAME as sec_name + FROM ea_management + WHERE SECCODE = :stock_code \ + """ + + if active_only: + base_query += " AND F020C = '1'" + + base_query += " ORDER BY DECLAREDATE DESC, F007D DESC" + + query = text(base_query) + + result = conn.execute(query, {'stock_code': stock_code}).fetchall() + + management_info = [] + for row in result: + management_record = {} + for key, value in zip(row.keys(), row): + if value is None: + management_record[key] = None + elif isinstance(value, datetime): + management_record[key] = value.strftime('%Y-%m-%d %H:%M:%S') + elif isinstance(value, date): + management_record[key] = value.strftime('%Y-%m-%d') + elif isinstance(value, Decimal): + management_record[key] = float(value) + else: + management_record[key] = value + + management_info.append(management_record) + + return jsonify({ + 'success': True, + 'data': management_info, + 'total': len(management_info) + }) + + except Exception as e: + app.logger.error(f"Error getting management info: {e}", exc_info=True) + return jsonify({'success': False, 'error': str(e)}), 500 + + +@app.route('/api/stock//top-circulation-shareholders', methods=['GET']) +def get_stock_top_circulation_shareholders(stock_code): + """获取股票十大流通股东信息""" + try: + limit = request.args.get('limit', 10, type=int) + + with engine.connect() as conn: + query = text(""" + SELECT DECLAREDATE as declare_date, + ENDDATE as end_date, + F001N as shareholder_rank, + F002V as shareholder_id, + F003V as shareholder_name, + F004V as shareholder_type, + F005N as holding_shares, + F006N as total_share_ratio, + F007N as circulation_share_ratio, + F011V as share_nature, + F012N as b_shares, + F013N as h_shares, + F014N as other_shares, + ORGNAME as org_name, + SECCODE as sec_code, + SECNAME as sec_name + FROM ea_tencirculation + WHERE SECCODE = :stock_code + ORDER BY ENDDATE DESC, F001N ASC LIMIT :limit + """) + + result = conn.execute(query, {'stock_code': stock_code, 'limit': limit}).fetchall() + + shareholders_info = [] + for row in result: + shareholder_record = {} + for key, value in zip(row.keys(), row): + if value is None: + shareholder_record[key] = None + elif isinstance(value, datetime): + shareholder_record[key] = value.strftime('%Y-%m-%d %H:%M:%S') + elif isinstance(value, date): + shareholder_record[key] = value.strftime('%Y-%m-%d') + elif isinstance(value, Decimal): + shareholder_record[key] = float(value) + else: + shareholder_record[key] = value + + shareholders_info.append(shareholder_record) + + return jsonify({ + 'success': True, + 'data': shareholders_info, + 'total': len(shareholders_info) + }) + + except Exception as e: + app.logger.error(f"Error getting top circulation shareholders: {e}", exc_info=True) + return jsonify({'success': False, 'error': str(e)}), 500 + + +@app.route('/api/stock//top-shareholders', methods=['GET']) +def get_stock_top_shareholders(stock_code): + """获取股票十大股东信息""" + try: + limit = request.args.get('limit', 10, type=int) + + with engine.connect() as conn: + query = text(""" + SELECT DECLAREDATE as declare_date, + ENDDATE as end_date, + F001N as shareholder_rank, + F002V as shareholder_name, + F003V as shareholder_id, + F004V as shareholder_type, + F005N as holding_shares, + F006N as total_share_ratio, + F007N as circulation_share_ratio, + F011V as share_nature, + F016N as restricted_shares, + F017V as concert_party_group, + F018N as circulation_shares, + ORGNAME as org_name, + SECCODE as sec_code, + SECNAME as sec_name + FROM ea_tenshareholder + WHERE SECCODE = :stock_code + ORDER BY ENDDATE DESC, F001N ASC LIMIT :limit + """) + + result = conn.execute(query, {'stock_code': stock_code, 'limit': limit}).fetchall() + + shareholders_info = [] + for row in result: + shareholder_record = {} + for key, value in zip(row.keys(), row): + if value is None: + shareholder_record[key] = None + elif isinstance(value, datetime): + shareholder_record[key] = value.strftime('%Y-%m-%d %H:%M:%S') + elif isinstance(value, date): + shareholder_record[key] = value.strftime('%Y-%m-%d') + elif isinstance(value, Decimal): + shareholder_record[key] = float(value) + else: + shareholder_record[key] = value + + shareholders_info.append(shareholder_record) + + return jsonify({ + 'success': True, + 'data': shareholders_info, + 'total': len(shareholders_info) + }) + + except Exception as e: + app.logger.error(f"Error getting top shareholders: {e}", exc_info=True) + return jsonify({'success': False, 'error': str(e)}), 500 + + +@app.route('/api/stock//branches', methods=['GET']) +def get_stock_branches(stock_code): + """获取股票分支机构信息""" + try: + with engine.connect() as conn: + query = text(""" + SELECT CRECODE as cre_code, + F001V as branch_name, + F002V as register_capital, + F003V as business_status, + F004D as register_date, + F005N as related_company_count, + F006V as legal_person, + ORGNAME as org_name, + SECCODE as sec_code, + SECNAME as sec_name + FROM ea_branch + WHERE SECCODE = :stock_code + ORDER BY F004D DESC + """) + + result = conn.execute(query, {'stock_code': stock_code}).fetchall() + + branches_info = [] + for row in result: + branch_record = {} + for key, value in zip(row.keys(), row): + if value is None: + branch_record[key] = None + elif isinstance(value, datetime): + branch_record[key] = value.strftime('%Y-%m-%d %H:%M:%S') + elif isinstance(value, date): + branch_record[key] = value.strftime('%Y-%m-%d') + elif isinstance(value, Decimal): + branch_record[key] = float(value) + else: + branch_record[key] = value + + branches_info.append(branch_record) + + return jsonify({ + 'success': True, + 'data': branches_info, + 'total': len(branches_info) + }) + + except Exception as e: + app.logger.error(f"Error getting branches info: {e}", exc_info=True) + return jsonify({'success': False, 'error': str(e)}), 500 + + +@app.route('/api/stock//patents', methods=['GET']) +def get_stock_patents(stock_code): + """获取股票专利信息""" + try: + limit = request.args.get('limit', 50, type=int) + patent_type = request.args.get('type', None) # 专利类型筛选 + + with engine.connect() as conn: + base_query = """ + SELECT CRECODE as cre_code, \ + F001V as patent_name, \ + F002V as application_number, \ + F003V as publication_number, \ + F004V as classification_number, \ + F005D as publication_date, \ + F006D as application_date, \ + F007V as patent_type, \ + F008V as applicant, \ + F009V as inventor, \ + ID as id, \ + ORGNAME as org_name, \ + SECCODE as sec_code, \ + SECNAME as sec_name + FROM ea_patent + WHERE SECCODE = :stock_code \ + """ + + params = {'stock_code': stock_code, 'limit': limit} + + if patent_type: + base_query += " AND F007V = :patent_type" + params['patent_type'] = patent_type + + base_query += " ORDER BY F006D DESC, F005D DESC LIMIT :limit" + + query = text(base_query) + + result = conn.execute(query, params).fetchall() + + patents_info = [] + for row in result: + patent_record = {} + for key, value in zip(row.keys(), row): + if value is None: + patent_record[key] = None + elif isinstance(value, datetime): + patent_record[key] = value.strftime('%Y-%m-%d %H:%M:%S') + elif isinstance(value, date): + patent_record[key] = value.strftime('%Y-%m-%d') + elif isinstance(value, Decimal): + patent_record[key] = float(value) + else: + patent_record[key] = value + + patents_info.append(patent_record) + + return jsonify({ + 'success': True, + 'data': patents_info, + 'total': len(patents_info) + }) + + except Exception as e: + app.logger.error(f"Error getting patents info: {e}", exc_info=True) + return jsonify({'success': False, 'error': str(e)}), 500 + + +def get_daily_kline(stock_code, event_datetime, stock_name): + """处理日K线数据""" + stock_code = stock_code.split('.')[0] + + 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 DATE_ADD(:trade_date, INTERVAL 30 DAY) \ + 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() + + 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 = [{ + 'time': row.TRADEDATE.strftime('%Y-%m-%d'), + 'open': float(row.open), + 'high': float(row.high), + 'low': float(row.low), + 'close': float(row.close), + 'volume': float(row.volume) + } for row in result] + + 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 + }) + + +def get_minute_kline(stock_code, event_datetime, stock_name): + """处理分钟K线数据""" + client = get_clickhouse_client() + + target_date = get_trading_day_near_date(event_datetime.date()) + is_after_market = event_datetime.time() > dt_time(15, 0) + + # 核心逻辑改动:先判断当前日期是否是交易日,以及是否已收盘 + if target_date and is_after_market: + # 如果是交易日且已收盘,查找下一个交易日 + next_trade_date = get_trading_day_near_date(target_date + timedelta(days=1)) + if next_trade_date: + target_date = next_trade_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' + }) + + # 获取目标日期的完整交易时段数据 + 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 = [{ + '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]) + } for row in data] + + return jsonify({ + '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() + }) + + +def get_timeline_data(stock_code, event_datetime, stock_name): + """处理分时均价线数据(timeline)。 + 规则: + - 若事件时间在交易日的15:00之后,则展示下一个交易日的分时数据; + - 若事件日非交易日,优先展示下一个交易日;如无,则回退到最近一个交易日; + - 数据区间固定为 09:30-15:00。 + """ + client = get_clickhouse_client() + + target_date = get_trading_day_near_date(event_datetime.date()) + is_after_market = event_datetime.time() > dt_time(15, 0) + + # 与分钟K逻辑保持一致的日期选择规则 + if target_date and is_after_market: + next_trade_date = get_trading_day_near_date(target_date + timedelta(days=1)) + if next_trade_date: + target_date = next_trade_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': 'timeline' + }) + + # 获取昨收盘价 + prev_close_query = """ + SELECT close + FROM stock_minute + WHERE code = %(code)s + AND timestamp \ + < %(start)s + ORDER BY timestamp DESC + LIMIT 1 \ + """ + + prev_close_result = client.execute(prev_close_query, { + 'code': stock_code, + 'start': datetime.combine(target_date, dt_time(9, 30)) + }) + + prev_close = float(prev_close_result[0][0]) if prev_close_result else None + + data = client.execute( + """ + SELECT + timestamp, close, volume + 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)), + } + ) + + timeline_data = [] + total_amount = 0 + total_volume = 0 + for row in data: + price = float(row[1]) + volume = float(row[2]) + total_amount += price * volume + total_volume += volume + avg_price = total_amount / total_volume if total_volume > 0 else price + + # 计算涨跌幅 + change_percent = ((price - prev_close) / prev_close * 100) if prev_close else 0 + + timeline_data.append({ + 'time': row[0].strftime('%H:%M'), + 'price': price, + 'avg_price': avg_price, + 'volume': volume, + 'change_percent': change_percent, + }) + + return jsonify({ + 'code': stock_code, + 'name': stock_name, + 'data': timeline_data, + 'trade_date': target_date.strftime('%Y-%m-%d'), + 'type': 'timeline', + 'is_history': target_date < event_datetime.date(), + 'prev_close': prev_close, + }) + + +# ==================== 指数行情API(与股票逻辑一致,数据表为 index_minute) ==================== +@app.route('/api/index//kline') +def get_index_kline(index_code): + chart_type = request.args.get('type', 'minute') + 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 + + # 指数名称(暂无索引表,先返回代码本身) + index_name = index_code + + if chart_type == 'minute': + return get_index_minute_kline(index_code, event_datetime, index_name) + elif chart_type == 'timeline': + return get_index_timeline_data(index_code, event_datetime, index_name) + elif chart_type == 'daily': + return get_index_daily_kline(index_code, event_datetime, index_name) + else: + return jsonify({'error': f'Unsupported chart type: {chart_type}'}), 400 + + +def get_index_minute_kline(index_code, event_datetime, index_name): + client = get_clickhouse_client() + target_date = get_trading_day_near_date(event_datetime.date()) + + if not target_date: + return jsonify({ + 'error': 'No data available', + 'code': index_code, + 'name': index_name, + 'data': [], + 'trade_date': event_datetime.date().strftime('%Y-%m-%d'), + 'type': 'minute' + }) + + data = client.execute( + """ + SELECT timestamp, open, high, low, close, volume, amt + FROM index_minute + WHERE code = %(code)s + AND timestamp BETWEEN %(start)s + AND %(end)s + ORDER BY timestamp + """, + { + 'code': index_code, + 'start': datetime.combine(target_date, dt_time(9, 30)), + 'end': datetime.combine(target_date, dt_time(15, 0)), + } + ) + + kline_data = [{ + '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]), + } for row in data] + + return jsonify({ + 'code': index_code, + 'name': index_name, + 'data': kline_data, + 'trade_date': target_date.strftime('%Y-%m-%d'), + 'type': 'minute', + 'is_history': target_date < event_datetime.date(), + }) + + +def get_index_timeline_data(index_code, event_datetime, index_name): + client = get_clickhouse_client() + target_date = get_trading_day_near_date(event_datetime.date()) + + if not target_date: + return jsonify({ + 'error': 'No data available', + 'code': index_code, + 'name': index_name, + 'data': [], + 'trade_date': event_datetime.date().strftime('%Y-%m-%d'), + 'type': 'timeline' + }) + + data = client.execute( + """ + SELECT timestamp, close, volume + FROM index_minute + WHERE code = %(code)s + AND timestamp BETWEEN %(start)s + AND %(end)s + ORDER BY timestamp + """, + { + 'code': index_code, + 'start': datetime.combine(target_date, dt_time(9, 30)), + 'end': datetime.combine(target_date, dt_time(15, 0)), + } + ) + + timeline = [] + total_amount = 0 + total_volume = 0 + for row in data: + price = float(row[1]) + volume = float(row[2]) + total_amount += price * volume + total_volume += volume + avg_price = total_amount / total_volume if total_volume > 0 else price + timeline.append({ + 'time': row[0].strftime('%H:%M'), + 'price': price, + 'avg_price': avg_price, + 'volume': volume, + }) + + return jsonify({ + 'code': index_code, + 'name': index_name, + 'data': timeline, + 'trade_date': target_date.strftime('%Y-%m-%d'), + 'type': 'timeline', + 'is_history': target_date < event_datetime.date(), + }) + + +def get_index_daily_kline(index_code, event_datetime, index_name): + """从 MySQL 的 stock.ea_exchangetrade 获取指数日线 + 注意:表中 INDEXCODE 无后缀,例如 000001.SH -> 000001 + 字段: + F003N 开市指数 -> open + F004N 最高指数 -> high + F005N 最低指数 -> low + F006N 最近指数 -> close(作为当日收盘或最近价使用) + F007N 昨日收市指数 -> prev_close + """ + # 去掉后缀 + code_no_suffix = index_code.split('.')[0] + + # 选择展示的最后交易日 + target_date = get_trading_day_near_date(event_datetime.date()) + if not target_date: + return jsonify({ + 'error': 'No data available', + 'code': index_code, + 'name': index_name, + 'data': [], + 'trade_date': event_datetime.date().strftime('%Y-%m-%d'), + 'type': 'daily' + }) + + # 取最近一段时间的日线(倒序再反转为升序) + with engine.connect() as conn: + rows = conn.execute(text( + """ + SELECT TRADEDATE, F003N, F004N, F005N, F006N, F007N + FROM ea_exchangetrade + WHERE INDEXCODE = :code + AND TRADEDATE <= :end_dt + ORDER BY TRADEDATE DESC LIMIT 180 + """ + ), { + 'code': code_no_suffix, + 'end_dt': datetime.combine(target_date, dt_time(23, 59, 59)) + }).fetchall() + + # 反转为时间升序 + rows = list(reversed(rows)) + + daily = [] + for i, r in enumerate(rows): + trade_dt = r[0] + open_v = r[1] + high_v = r[2] + low_v = r[3] + last_v = r[4] + prev_close_v = r[5] + + # 正确的前收盘价逻辑:使用前一个交易日的F006N(收盘价) + calculated_prev_close = None + if i > 0 and rows[i - 1][4] is not None: + # 使用前一个交易日的收盘价作为前收盘价 + calculated_prev_close = float(rows[i - 1][4]) + else: + # 第一条记录,尝试使用F007N字段作为备选 + if prev_close_v is not None and prev_close_v > 0: + calculated_prev_close = float(prev_close_v) + + daily.append({ + 'time': trade_dt.strftime('%Y-%m-%d') if hasattr(trade_dt, 'strftime') else str(trade_dt), + 'open': float(open_v) if open_v is not None else None, + 'high': float(high_v) if high_v is not None else None, + 'low': float(low_v) if low_v is not None else None, + 'close': float(last_v) if last_v is not None else None, + 'prev_close': calculated_prev_close, + }) + + return jsonify({ + 'code': index_code, + 'name': index_name, + 'data': daily, + 'trade_date': target_date.strftime('%Y-%m-%d'), + 'type': 'daily', + 'is_history': target_date < event_datetime.date(), + }) + + +# ==================== 日历API ==================== +@app.route('/api/v1/calendar/event-counts', methods=['GET']) +def get_event_counts(): + """获取日历事件数量统计""" + try: + # 获取月份参数 + year = request.args.get('year', datetime.now().year, type=int) + month = request.args.get('month', datetime.now().month, type=int) + + # 计算月份的开始和结束日期 + start_date = datetime(year, month, 1) + if month == 12: + end_date = datetime(year + 1, 1, 1) + else: + end_date = datetime(year, month + 1, 1) + + # 查询事件数量 + 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 = [] + for day in result: + events.append({ + 'date': day.date.isoformat(), + 'count': day.count, + 'className': get_event_class(day.count) + }) + + return jsonify({ + 'success': True, + 'data': events + }) + + except Exception as e: + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + + +@app.route('/api/v1/calendar/events', methods=['GET']) +def get_calendar_events(): + """获取指定日期的事件列表""" + date_str = request.args.get('date') + event_type = request.args.get('type', 'all') + + if not date_str: + return jsonify({ + 'success': False, + 'error': 'Date parameter required' + }), 400 + + try: + date = datetime.strptime(date_str, '%Y-%m-%d') + except ValueError: + return jsonify({ + 'success': False, + 'error': 'Invalid date format' + }), 400 + + # 修复SQL语法:去掉函数名后的空格,去掉参数前的空格 + query = """ + SELECT * + FROM future_events + WHERE DATE(calendar_time) = :date + """ + + params = {'date': date} + + if event_type != 'all': + query += " AND type = :type" + params['type'] = event_type + + query += " ORDER BY calendar_time" + + result = db.session.execute(text(query), params) + + events = [] + user_following_ids = set() + if 'user_id' in session: + follows = FutureEventFollow.query.filter_by(user_id=session['user_id']).all() + user_following_ids = {f.future_event_id for f in follows} + + for row in result: + event_data = { + 'id': row.data_id, + 'title': row.title, + 'type': row.type, + 'calendar_time': row.calendar_time.isoformat(), + 'star': row.star, + 'former': row.former, + 'forecast': row.forecast, + 'fact': row.fact, + 'is_following': row.data_id in user_following_ids + } + + # 解析相关股票和概念 + if row.related_stocks: + try: + if isinstance(row.related_stocks, str): + if row.related_stocks.startswith('['): + event_data['related_stocks'] = json.loads(row.related_stocks) + else: + event_data['related_stocks'] = row.related_stocks.split(',') + else: + event_data['related_stocks'] = row.related_stocks + except: + event_data['related_stocks'] = [] + else: + event_data['related_stocks'] = [] + + if row.concepts: + try: + if isinstance(row.concepts, str): + if row.concepts.startswith('['): + event_data['concepts'] = json.loads(row.concepts) + else: + event_data['concepts'] = row.concepts.split(',') + else: + event_data['concepts'] = row.concepts + except: + event_data['concepts'] = [] + else: + event_data['concepts'] = [] + + events.append(event_data) + + return jsonify({ + 'success': True, + 'data': events + }) + +@app.route('/api/v1/calendar/events/', methods=['GET']) +def get_calendar_event_detail(event_id): + """获取日历事件详情""" + try: + sql = """ + SELECT * + FROM future_events + WHERE data_id = :event_id \ + """ + + result = db.session.execute(text(sql), {'event_id': event_id}).first() + + if not result: + return jsonify({ + 'success': False, + 'error': 'Event not found' + }), 404 + + event_data = { + 'id': result.data_id, + 'title': result.title, + 'type': result.type, + 'calendar_time': result.calendar_time.isoformat(), + 'star': result.star, + 'former': result.former, + 'forecast': result.forecast, + 'fact': result.fact, + 'related_stocks': parse_json_field(result.related_stocks), + 'concepts': parse_json_field(result.concepts) + } + + # 检查当前用户是否关注了该未来事件 + if 'user_id' in session: + is_following = FutureEventFollow.query.filter_by( + user_id=session['user_id'], + future_event_id=event_id + ).first() is not None + event_data['is_following'] = is_following + else: + event_data['is_following'] = False + + return jsonify({ + 'success': True, + 'data': event_data + }) + + except Exception as e: + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + + +@app.route('/api/v1/calendar/events//follow', methods=['POST']) +def toggle_future_event_follow(event_id): + """切换未来事件关注状态(需登录)""" + if 'user_id' not in session: + return jsonify({'success': False, 'error': '未登录'}), 401 + + try: + # 检查未来事件是否存在 + sql = """ + SELECT data_id \ + FROM future_events \ + WHERE data_id = :event_id \ + """ + result = db.session.execute(text(sql), {'event_id': event_id}).first() + + if not result: + return jsonify({'success': False, 'error': '未来事件不存在'}), 404 + + user_id = session['user_id'] + + # 检查是否已关注 + existing = FutureEventFollow.query.filter_by( + user_id=user_id, + future_event_id=event_id + ).first() + + if existing: + # 取消关注 + db.session.delete(existing) + db.session.commit() + return jsonify({ + 'success': True, + 'data': {'is_following': False} + }) + else: + # 关注 + follow = FutureEventFollow( + user_id=user_id, + future_event_id=event_id + ) + db.session.add(follow) + db.session.commit() + return jsonify({ + 'success': True, + 'data': {'is_following': True} + }) + except Exception as e: + db.session.rollback() + return jsonify({'success': False, 'error': str(e)}), 500 + + +def get_event_class(count): + """根据事件数量返回CSS类名""" + if count >= 10: + return 'event-high' + elif count >= 5: + return 'event-medium' + elif count > 0: + return 'event-low' + return '' + + +def parse_json_field(field_value): + """解析JSON字段""" + if not field_value: + return [] + try: + if isinstance(field_value, str): + if field_value.startswith('['): + return json.loads(field_value) + else: + return field_value.split(',') + else: + return field_value + except: + return [] + + +# ==================== 行业API ==================== +@app.route('/api/classifications', methods=['GET']) +def get_classifications(): + """获取申银万国行业分类树形结构""" + try: + # 查询申银万国行业分类的所有数据 + sql = """ + SELECT f003v as code, f004v as level1, f005v as level2, f006v as level3,f007v as level4 + FROM ea_sector + WHERE f002v = '申银万国行业分类' + AND f003v IS NOT NULL + AND f004v IS NOT NULL + ORDER BY f003v + """ + + result = db.session.execute(text(sql)).all() + + # 构建树形结构 + tree_dict = {} + + for row in result: + code = row.code + level1 = row.level1 + level2 = row.level2 + level3 = row.level3 + + # 跳过空数据 + if not level1: + continue + + # 第一层 + if level1 not in tree_dict: + # 获取第一层的code(取前3位或前缀) + level1_code = code[:3] if len(code) >= 3 else code + tree_dict[level1] = { + 'value': level1_code, + 'label': level1, + 'children_dict': {} + } + + # 第二层 + if level2: + if level2 not in tree_dict[level1]['children_dict']: + # 获取第二层的code(取前6位) + level2_code = code[:6] if len(code) >= 6 else code + tree_dict[level1]['children_dict'][level2] = { + 'value': level2_code, + 'label': level2, + 'children_dict': {} + } + + # 第三层 + if level3: + if level3 not in tree_dict[level1]['children_dict'][level2]['children_dict']: + tree_dict[level1]['children_dict'][level2]['children_dict'][level3] = { + 'value': code, + 'label': level3 + } + + # 转换为最终格式 + result_list = [] + for level1_name, level1_data in tree_dict.items(): + level1_node = { + 'value': level1_data['value'], + 'label': level1_data['label'] + } + + # 处理第二层 + if level1_data['children_dict']: + level1_children = [] + for level2_name, level2_data in level1_data['children_dict'].items(): + level2_node = { + 'value': level2_data['value'], + 'label': level2_data['label'] + } + + # 处理第三层 + if level2_data['children_dict']: + level2_children = [] + for level3_name, level3_data in level2_data['children_dict'].items(): + level2_children.append({ + 'value': level3_data['value'], + 'label': level3_data['label'] + }) + if level2_children: + level2_node['children'] = level2_children + + level1_children.append(level2_node) + + if level1_children: + level1_node['children'] = level1_children + + result_list.append(level1_node) + + return jsonify({ + 'success': True, + 'data': result_list + }) + + except Exception as e: + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + + +@app.route('/api/stocklist', methods=['GET']) +def get_stock_list(): + """获取股票列表""" + try: + sql = """ + SELECT DISTINCT SECCODE as code, SECNAME as name + FROM ea_stocklist + ORDER BY SECCODE + """ + + result = db.session.execute(text(sql)).all() + + stocks = [{'code': row.code, 'name': row.name} for row in result] + + return jsonify(stocks) + + except Exception as e: + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + + +@app.route('/api/events', methods=['GET'], strict_slashes=False) +def api_get_events(): + """ + 获取事件列表API - 支持筛选、排序、分页,兼容前端调用 + """ + 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) + + # 行业筛选参数(只支持申银万国行业分类) + industry_code = request.args.get('industry_code') # 申万行业代码,如 "S370502" + + # 概念/标签筛选参数 + 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) + # 支持多个重要性级别筛选,用逗号分隔(如 importance=S,A) + if importance != 'all': + if ',' in importance: + # 多个重要性级别 + importance_list = [imp.strip() for imp in importance.split(',') if imp.strip()] + query = query.filter(Event.importance.in_(importance_list)) + else: + # 单个重要性级别 + query = query.filter_by(importance=importance) + if creator_id: + query = query.filter_by(creator_id=creator_id) + # 新增:行业代码过滤(申银万国行业分类) + if industry_code: + # related_industries 格式: [{"申银万国行业分类": "S370502"}, ...] + # 支持多个行业代码,用逗号分隔 + json_path = '$[*]."申银万国行业分类"' + + # 如果包含逗号,说明是多个行业代码 + if ',' in industry_code: + codes = [code.strip() for code in industry_code.split(',') if code.strip()] + # 使用 OR 条件匹配任意一个行业代码 + conditions = [] + for code in codes: + conditions.append( + text("JSON_CONTAINS(JSON_EXTRACT(related_industries, :json_path), :code)") + .bindparams(json_path=json_path, code=json.dumps(code)) + ) + query = query.filter(db.or_(*conditions)) + else: + # 单个行业代码 + query = query.filter( + text("JSON_CONTAINS(JSON_EXTRACT(related_industries, :json_path), :industry_code)") + ).params(json_path=json_path, industry_code=json.dumps(industry_code)) + # 新增:关键词/全文搜索过滤(MySQL JSON) + if search_query: + like_pattern = f"%{search_query}%" + query = query.filter( + db.or_( + Event.title.ilike(like_pattern), + Event.description.ilike(like_pattern), + text(f"JSON_SEARCH(keywords, 'one', '%{search_query}%') IS NOT NULL") + ) + ) + if recent_days: + from datetime import datetime, timedelta + 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: + from datetime import datetime + 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: + from datetime import datetime + 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 min_view_count is not None: + query = query.filter(Event.view_count >= min_view_count) + # 排序 + from sqlalchemy import desc, asc, case + 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)) + # 分页 + paginated = query.paginate(page=page, per_page=per_page, error_out=False) + events_data = [] + for event in paginated.items: + 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, + } + 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' + } + event_dict['keywords'] = event.keywords_list if hasattr(event, 'keywords_list') else event.keywords + event_dict['related_industries'] = event.related_industries + if include_related_data: + pass + 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_code: + applied_filters['industry_code'] = industry_code + 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': applied_filters, + 'total_count': paginated.total + } + } + }) + 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 + + +@app.route('/api/events/hot', methods=['GET']) +def get_hot_events(): + """获取热点事件""" + try: + from datetime import datetime, timedelta + days = request.args.get('days', 3, type=int) + limit = request.args.get('limit', 4, type=int) + since_date = datetime.now() - timedelta(days=days) + hot_events = Event.query.filter( + Event.status == 'active', + Event.created_at >= since_date, + Event.related_avg_chg != None, + Event.related_avg_chg > 0 + ).order_by(Event.related_avg_chg.desc()).limit(limit).all() + if len(hot_events) < limit: + additional_events = Event.query.filter( + Event.status == 'active', + Event.created_at >= since_date, + ~Event.id.in_([event.id for event in hot_events]) + ).order_by(Event.hot_score.desc()).limit(limit - len(hot_events)).all() + hot_events.extend(additional_events) + events_data = [] + for event in hot_events: + events_data.append({ + 'id': event.id, + 'title': event.title, + 'description': event.description, + 'importance': event.importance, + 'created_at': event.created_at.isoformat() if event.created_at else None, + 'related_avg_chg': event.related_avg_chg, + 'creator': { + 'username': event.creator.username if event.creator else 'Anonymous' + } + }) + return jsonify({'success': True, 'data': events_data}) + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + + +@app.route('/api/events/keywords/popular', methods=['GET']) +def get_popular_keywords(): + """获取热门关键词""" + try: + limit = request.args.get('limit', 20, type=int) + sql = ''' + WITH RECURSIVE \ + numbers AS (SELECT 0 as n \ + UNION ALL \ + SELECT n + 1 \ + FROM numbers \ + WHERE n < 100), \ + json_array AS (SELECT JSON_UNQUOTE(JSON_EXTRACT(e.keywords, CONCAT('$[', n.n, ']'))) as keyword, \ + COUNT(*) as count + FROM event e + CROSS JOIN numbers n + WHERE + e.status = 'active' + AND JSON_EXTRACT(e.keywords \ + , CONCAT('$[' \ + , n.n \ + , ']')) IS NOT NULL + GROUP BY JSON_UNQUOTE(JSON_EXTRACT(e.keywords, CONCAT('$[', n.n, ']'))) + HAVING keyword IS NOT NULL + ) + SELECT keyword, count + FROM json_array + ORDER BY count DESC, keyword LIMIT :limit \ + ''' + result = db.session.execute(text(sql), {'limit': limit}).all() + keywords_data = [{'keyword': row.keyword, 'count': row.count} for row in result] + return jsonify({'success': True, 'data': keywords_data}) + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + + +@app.route('/api/events//sankey-data') +def get_event_sankey_data(event_id): + """ + 获取事件桑基图数据 (最终优化版) + - 处理重名节点 + - 检测并打破循环依赖 + """ + flows = EventSankeyFlow.query.filter_by(event_id=event_id).order_by( + EventSankeyFlow.source_level, EventSankeyFlow.target_level + ).all() + + if not flows: + return jsonify({'success': False, 'message': '暂无桑基图数据'}) + + nodes_map = {} + links = [] + type_colors = { + 'event': '#ff4757', 'policy': '#10ac84', 'technology': '#ee5a6f', + 'industry': '#00d2d3', 'company': '#54a0ff', 'product': '#ffd93d' + } + + # --- 1. 识别并处理重名节点 (与上一版相同) --- + all_node_keys = set() + name_counts = {} + for flow in flows: + source_key = f"{flow.source_node}|{flow.source_level}" + target_key = f"{flow.target_node}|{flow.target_level}" + all_node_keys.add(source_key) + all_node_keys.add(target_key) + name_counts.setdefault(flow.source_node, set()).add(flow.source_level) + name_counts.setdefault(flow.target_node, set()).add(flow.target_level) + + duplicate_names = {name for name, levels in name_counts.items() if len(levels) > 1} + + for flow in flows: + source_key = f"{flow.source_node}|{flow.source_level}" + if source_key not in nodes_map: + display_name = f"{flow.source_node} (L{flow.source_level})" if flow.source_node in duplicate_names else flow.source_node + nodes_map[source_key] = {'name': display_name, 'type': flow.source_type, 'level': flow.source_level, + 'color': type_colors.get(flow.source_type)} + + target_key = f"{flow.target_node}|{flow.target_level}" + if target_key not in nodes_map: + display_name = f"{flow.target_node} (L{flow.target_level})" if flow.target_node in duplicate_names else flow.target_node + nodes_map[target_key] = {'name': display_name, 'type': flow.target_type, 'level': flow.target_level, + 'color': type_colors.get(flow.target_type)} + + links.append({ + 'source_key': source_key, 'target_key': target_key, 'value': float(flow.flow_value), + 'ratio': float(flow.flow_ratio), 'transmission_path': flow.transmission_path, + 'impact_description': flow.impact_description, 'evidence_strength': flow.evidence_strength + }) + + # --- 2. 循环检测与处理 --- + # 构建邻接表 + adj = defaultdict(list) + for link in links: + adj[link['source_key']].append(link['target_key']) + + # 深度优先搜索(DFS)来检测循环 + path = set() # 记录当前递归路径上的节点 + visited = set() # 记录所有访问过的节点 + back_edges = set() # 记录导致循环的"回流边" + + def detect_cycle_util(node): + path.add(node) + visited.add(node) + for neighbour in adj.get(node, []): + if neighbour in path: + # 发现了循环,记录这条回流边 (target, source) + back_edges.add((neighbour, node)) + elif neighbour not in visited: + detect_cycle_util(neighbour) + path.remove(node) + + # 从所有节点开始检测 + for node_key in list(adj.keys()): + if node_key not in visited: + detect_cycle_util(node_key) + + # 过滤掉导致循环的边 + if back_edges: + print(f"检测到并移除了 {len(back_edges)} 条循环边: {back_edges}") + + valid_links_no_cycle = [] + for link in links: + if (link['source_key'], link['target_key']) not in back_edges and \ + (link['target_key'], link['source_key']) not in back_edges: # 移除非严格意义上的双向边 + valid_links_no_cycle.append(link) + + # --- 3. 构建最终的 JSON 响应 (与上一版相似) --- + node_list = [] + node_index_map = {} + sorted_node_keys = sorted(nodes_map.keys(), key=lambda k: (nodes_map[k]['level'], nodes_map[k]['name'])) + + for i, key in enumerate(sorted_node_keys): + node_list.append(nodes_map[key]) + node_index_map[key] = i + + final_links = [] + for link in valid_links_no_cycle: + source_idx = node_index_map.get(link['source_key']) + target_idx = node_index_map.get(link['target_key']) + if source_idx is not None and target_idx is not None: + # 移除临时的 key,只保留 ECharts 需要的字段 + link.pop('source_key', None) + link.pop('target_key', None) + link['source'] = source_idx + link['target'] = target_idx + final_links.append(link) + + # ... (统计信息计算部分保持不变) ... + stats = { + 'total_nodes': len(node_list), 'total_flows': len(final_links), + 'total_flow_value': sum(link['value'] for link in final_links), + 'max_level': max((node['level'] for node in node_list), default=0), + 'node_type_counts': {ntype: sum(1 for n in node_list if n['type'] == ntype) for ntype in type_colors} + } + + return jsonify({ + 'success': True, + 'data': {'nodes': node_list, 'links': final_links, 'stats': stats} + }) + + +# 优化后的传导链分析 API +@app.route('/api/events//chain-analysis') +def get_event_chain_analysis(event_id): + """获取事件传导链分析数据""" + nodes = EventTransmissionNode.query.filter_by(event_id=event_id).all() + if not nodes: + return jsonify({'success': False, 'message': '暂无传导链分析数据'}) + + edges = EventTransmissionEdge.query.filter_by(event_id=event_id).all() + + # 过滤孤立节点 + connected_node_ids = set() + for edge in edges: + connected_node_ids.add(edge.from_node_id) + connected_node_ids.add(edge.to_node_id) + + # 只保留有连接的节点 + connected_nodes = [node for node in nodes if node.id in connected_node_ids] + + if not connected_nodes: + return jsonify({'success': False, 'message': '所有节点都是孤立的,暂无传导关系'}) + + # 节点分类,用于力导向图的图例 + categories = { + 'event': "事件", 'industry': "行业", 'company': "公司", + 'policy': "政策", 'technology': "技术", 'market': "市场", 'other': "其他" + } + + # 计算每个节点的连接数 + node_connection_count = {} + for node in connected_nodes: + count = sum(1 for edge in edges + if edge.from_node_id == node.id or edge.to_node_id == node.id) + node_connection_count[node.id] = count + + nodes_data = [] + for node in connected_nodes: + connection_count = node_connection_count[node.id] + + nodes_data.append({ + 'id': str(node.id), + 'name': node.node_name, + 'value': node.importance_score, # 用于控制节点大小的基础值 + 'category': categories.get(node.node_type), + 'extra': { + 'node_type': node.node_type, + 'description': node.node_description, + 'importance_score': node.importance_score, + 'stock_code': node.stock_code, + 'is_main_event': node.is_main_event, + 'connection_count': connection_count, # 添加连接数信息 + } + }) + + edges_data = [] + for edge in edges: + # 确保边的两端节点都在连接节点列表中 + if edge.from_node_id in connected_node_ids and edge.to_node_id in connected_node_ids: + edges_data.append({ + 'source': str(edge.from_node_id), + 'target': str(edge.to_node_id), + 'value': edge.strength, # 用于控制边的宽度 + 'extra': { + 'transmission_type': edge.transmission_type, + 'transmission_mechanism': edge.transmission_mechanism, + 'direction': edge.direction, + 'strength': edge.strength, + 'impact': edge.impact, + 'is_circular': edge.is_circular, + } + }) + + # 重新计算统计信息(基于连接的节点和边) + stats = { + 'total_nodes': len(connected_nodes), + 'total_edges': len(edges_data), + 'node_types': {cat: sum(1 for n in connected_nodes if n.node_type == node_type) + for node_type, cat in categories.items()}, + 'edge_types': {edge.transmission_type: sum(1 for e in edges_data + if e['extra']['transmission_type'] == edge.transmission_type) for + edge in edges}, + 'avg_importance': sum(node.importance_score for node in connected_nodes) / len( + connected_nodes) if connected_nodes else 0, + 'avg_strength': sum(edge.strength for edge in edges) / len(edges) if edges else 0 + } + + return jsonify({ + 'success': True, + 'data': { + 'nodes': nodes_data, + 'edges': edges_data, + 'categories': list(categories.values()), + 'stats': stats + } + }) + + +@app.route('/api/events//chain-node/', methods=['GET']) +@cross_origin() +def get_chain_node_detail(event_id, node_id): + """获取传导链节点及其直接关联节点的详细信息""" + node = db.session.get(EventTransmissionNode, node_id) + if not node or node.event_id != event_id: + return jsonify({'success': False, 'message': '节点不存在'}) + + # 验证节点是否为孤立节点 + total_connections = (EventTransmissionEdge.query.filter_by(from_node_id=node_id).count() + + EventTransmissionEdge.query.filter_by(to_node_id=node_id).count()) + + if total_connections == 0 and not node.is_main_event: + return jsonify({'success': False, 'message': '该节点为孤立节点,无连接关系'}) + + # 找出影响当前节点的父节点 + parents_info = [] + incoming_edges = EventTransmissionEdge.query.filter_by(to_node_id=node_id).all() + for edge in incoming_edges: + parent = db.session.get(EventTransmissionNode, edge.from_node_id) + if parent: + parents_info.append({ + 'id': parent.id, + 'name': parent.node_name, + 'type': parent.node_type, + 'direction': edge.direction, + 'strength': edge.strength, + 'transmission_type': edge.transmission_type, + 'transmission_mechanism': edge.transmission_mechanism, # 修复字段名 + 'is_circular': edge.is_circular, + 'impact': edge.impact + }) + + # 找出被当前节点影响的子节点 + children_info = [] + outgoing_edges = EventTransmissionEdge.query.filter_by(from_node_id=node_id).all() + for edge in outgoing_edges: + child = db.session.get(EventTransmissionNode, edge.to_node_id) + if child: + children_info.append({ + 'id': child.id, + 'name': child.node_name, + 'type': child.node_type, + 'direction': edge.direction, + 'strength': edge.strength, + 'transmission_type': edge.transmission_type, + 'transmission_mechanism': edge.transmission_mechanism, # 修复字段名 + 'is_circular': edge.is_circular, + 'impact': edge.impact + }) + + node_data = { + 'id': node.id, + 'name': node.node_name, + 'type': node.node_type, + 'description': node.node_description, + 'importance_score': node.importance_score, + 'stock_code': node.stock_code, + 'is_main_event': node.is_main_event, + 'total_connections': total_connections, + 'incoming_connections': len(incoming_edges), + 'outgoing_connections': len(outgoing_edges) + } + + return jsonify({ + 'success': True, + 'data': { + 'node': node_data, + 'parents': parents_info, + 'children': children_info + } + }) + + +@app.route('/api/events//posts', methods=['GET']) +def get_event_posts(event_id): + """获取事件下的帖子""" + try: + sort_type = request.args.get('sort', 'latest') + page = request.args.get('page', 1, type=int) + per_page = request.args.get('per_page', 20, type=int) + + # 查询事件下的帖子 + query = Post.query.filter_by(event_id=event_id, status='active') + + if sort_type == 'hot': + query = query.order_by(Post.likes_count.desc(), Post.created_at.desc()) + else: # latest + query = query.order_by(Post.created_at.desc()) + + # 分页 + pagination = query.paginate(page=page, per_page=per_page, error_out=False) + posts = pagination.items + + posts_data = [] + for post in posts: + post_dict = { + 'id': post.id, + 'event_id': post.event_id, + 'user_id': post.user_id, + 'title': post.title, + 'content': post.content, + 'content_type': post.content_type, + 'created_at': post.created_at.isoformat(), + 'updated_at': post.updated_at.isoformat(), + 'likes_count': post.likes_count, + 'comments_count': post.comments_count, + 'view_count': post.view_count, + 'is_top': post.is_top, + 'user': { + 'id': post.user.id, + 'username': post.user.username, + 'avatar_url': post.user.avatar_url + } if post.user else None, + 'liked': False # 后续可以根据当前用户判断 + } + posts_data.append(post_dict) + + return jsonify({ + 'success': True, + 'data': posts_data, + 'pagination': { + 'page': page, + 'per_page': per_page, + 'total': pagination.total, + 'pages': pagination.pages + } + }) + + except Exception as e: + print(f"获取帖子失败: {e}") + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + + +@app.route('/api/events//posts', methods=['POST']) +@login_required +def create_event_post(event_id): + """在事件下创建帖子""" + try: + data = request.get_json() + content = data.get('content', '').strip() + title = data.get('title', '').strip() + content_type = data.get('content_type', 'text') + + if not content: + return jsonify({ + 'success': False, + 'message': '帖子内容不能为空' + }), 400 + + # 创建新帖子 + post = Post( + event_id=event_id, + user_id=current_user.id, + title=title, + content=content, + content_type=content_type + ) + + db.session.add(post) + + # 更新事件的帖子数 + event = Event.query.get(event_id) + if event: + event.post_count = Post.query.filter_by(event_id=event_id, status='active').count() + + # 更新用户发帖数 + current_user.post_count = (current_user.post_count or 0) + 1 + + db.session.commit() + + return jsonify({ + 'success': True, + 'data': { + 'id': post.id, + 'event_id': post.event_id, + 'user_id': post.user_id, + 'title': post.title, + 'content': post.content, + 'content_type': post.content_type, + 'created_at': post.created_at.isoformat(), + 'user': { + 'id': current_user.id, + 'username': current_user.username, + 'avatar_url': current_user.avatar_url + } + }, + 'message': '帖子发布成功' + }) + + except Exception as e: + db.session.rollback() + print(f"创建帖子失败: {e}") + return jsonify({ + 'success': False, + 'message': str(e) + }), 500 + + +@app.route('/api/posts//comments', methods=['GET']) +def get_post_comments(post_id): + """获取帖子的评论""" + try: + sort_type = request.args.get('sort', 'latest') + + # 查询帖子的顶级评论(非回复) + query = Comment.query.filter_by(post_id=post_id, parent_id=None, status='active') + + if sort_type == 'hot': + comments = query.order_by(Comment.likes_count.desc(), Comment.created_at.desc()).all() + else: # latest + comments = query.order_by(Comment.created_at.desc()).all() + + comments_data = [] + for comment in comments: + comment_dict = { + 'id': comment.id, + 'post_id': comment.post_id, + 'user_id': comment.user_id, + 'content': comment.content, + 'created_at': comment.created_at.isoformat(), + 'updated_at': comment.updated_at.isoformat(), + 'likes_count': comment.likes_count, + 'user': { + 'id': comment.user.id, + 'username': comment.user.username, + 'avatar_url': comment.user.avatar_url + } if comment.user else None, + 'replies': [] # 加载回复 + } + + # 加载回复 + replies = Comment.query.filter_by(parent_id=comment.id, status='active').order_by(Comment.created_at).all() + for reply in replies: + reply_dict = { + 'id': reply.id, + 'post_id': reply.post_id, + 'user_id': reply.user_id, + 'content': reply.content, + 'parent_id': reply.parent_id, + 'created_at': reply.created_at.isoformat(), + 'likes_count': reply.likes_count, + 'user': { + 'id': reply.user.id, + 'username': reply.user.username, + 'avatar_url': reply.user.avatar_url + } if reply.user else None + } + comment_dict['replies'].append(reply_dict) + + comments_data.append(comment_dict) + + return jsonify({ + 'success': True, + 'data': comments_data + }) + + except Exception as e: + print(f"获取评论失败: {e}") + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + + +@app.route('/api/posts//comments', methods=['POST']) +@login_required +def create_post_comment(post_id): + """在帖子下创建评论""" + try: + data = request.get_json() + content = data.get('content', '').strip() + parent_id = data.get('parent_id') + + if not content: + return jsonify({ + 'success': False, + 'message': '评论内容不能为空' + }), 400 + + # 创建新评论 + comment = Comment( + post_id=post_id, + user_id=current_user.id, + content=content, + parent_id=parent_id + ) + + db.session.add(comment) + + # 更新帖子评论数 + post = Post.query.get(post_id) + if post: + post.comments_count = Comment.query.filter_by(post_id=post_id, status='active').count() + + # 更新用户评论数 + current_user.comment_count = (current_user.comment_count or 0) + 1 + + db.session.commit() + + return jsonify({ + 'success': True, + 'data': { + 'id': comment.id, + 'post_id': comment.post_id, + 'user_id': comment.user_id, + 'content': comment.content, + 'parent_id': comment.parent_id, + 'created_at': comment.created_at.isoformat(), + 'user': { + 'id': current_user.id, + 'username': current_user.username, + 'avatar_url': current_user.avatar_url + } + }, + 'message': '评论发布成功' + }) + + except Exception as e: + db.session.rollback() + print(f"创建评论失败: {e}") + return jsonify({ + 'success': False, + 'message': str(e) + }), 500 + + +# 兼容旧的评论接口,转换为帖子模式 +@app.route('/api/events//comments', methods=['GET']) +def get_event_comments(event_id): + """获取事件评论(兼容旧接口)""" + # 将事件评论转换为获取事件下所有帖子的评论 + return get_event_posts(event_id) + + +@app.route('/api/events//comments', methods=['POST']) +@login_required +def add_event_comment(event_id): + """添加事件评论(兼容旧接口)""" + try: + data = request.get_json() + content = data.get('content', '').strip() + parent_id = data.get('parent_id') + + if not content: + return jsonify({ + 'success': False, + 'message': '评论内容不能为空' + }), 400 + + # 如果有 parent_id,说明是回复,需要找到对应的帖子 + if parent_id: + # 这是一个回复,需要将其转换为对应帖子的评论 + # 首先需要找到 parent_id 对应的帖子 + # 这里假设旧的 parent_id 是之前的 EventComment id + # 需要在数据迁移时处理这个映射关系 + return jsonify({ + 'success': False, + 'message': '回复功能正在升级中,请稍后再试' + }), 503 + + # 如果没有 parent_id,说明是顶级评论,创建为新帖子 + post = Post( + event_id=event_id, + user_id=current_user.id, + content=content, + content_type='text' + ) + + db.session.add(post) + + # 更新事件的帖子数 + event = Event.query.get(event_id) + if event: + event.post_count = Post.query.filter_by(event_id=event_id, status='active').count() + + # 更新用户发帖数 + current_user.post_count = (current_user.post_count or 0) + 1 + + db.session.commit() + + # 返回兼容旧接口的数据格式 + return jsonify({ + 'success': True, + 'data': { + 'id': post.id, + 'event_id': post.event_id, + 'user_id': post.user_id, + 'author': current_user.username, + 'content': post.content, + 'parent_id': None, + 'likes': 0, + 'created_at': post.created_at.isoformat(), + 'status': 'active', + 'user': { + 'id': current_user.id, + 'username': current_user.username, + 'avatar_url': current_user.avatar_url + }, + 'replies': [] + }, + 'message': '评论发布成功' + }) + + except Exception as e: + db.session.rollback() + print(f"添加事件评论失败: {e}") + return jsonify({ + 'success': False, + 'message': str(e) + }), 500 + + +# ==================== WebSocket 事件处理器(实时事件推送) ==================== + +@socketio.on('connect') +def handle_connect(): + """客户端连接事件""" + print(f'\n[WebSocket DEBUG] ========== 客户端连接 ==========') + print(f'[WebSocket DEBUG] Socket ID: {request.sid}') + print(f'[WebSocket DEBUG] Remote Address: {request.remote_addr if hasattr(request, "remote_addr") else "N/A"}') + print(f'[WebSocket] 客户端已连接: {request.sid}') + + emit('connection_response', { + 'status': 'connected', + 'sid': request.sid, + 'message': '已连接到事件推送服务' + }) + print(f'[WebSocket DEBUG] ✓ 已发送 connection_response') + print(f'[WebSocket DEBUG] ========== 连接完成 ==========\n') + + +@socketio.on('subscribe_events') +def handle_subscribe(data): + """ + 客户端订阅事件推送 + data: { + 'event_type': 'all' | 'policy' | 'market' | 'tech' | ..., + 'importance': 'all' | 'S' | 'A' | 'B' | 'C', + 'filters': {...} # 可选的其他筛选条件 + } + """ + try: + print(f'\n[WebSocket DEBUG] ========== 收到订阅请求 ==========') + print(f'[WebSocket DEBUG] Socket ID: {request.sid}') + print(f'[WebSocket DEBUG] 订阅数据: {data}') + + event_type = data.get('event_type', 'all') + importance = data.get('importance', 'all') + + print(f'[WebSocket DEBUG] 事件类型: {event_type}') + print(f'[WebSocket DEBUG] 重要性: {importance}') + + # 加入对应的房间 + room_name = f"events_{event_type}" + print(f'[WebSocket DEBUG] 准备加入房间: {room_name}') + join_room(room_name) + print(f'[WebSocket DEBUG] ✓ 已加入房间: {room_name}') + + print(f'[WebSocket] 客户端 {request.sid} 订阅了房间: {room_name}') + + response_data = { + 'success': True, + 'room': room_name, + 'event_type': event_type, + 'importance': importance, + 'message': f'已订阅 {event_type} 类型的事件推送' + } + print(f'[WebSocket DEBUG] 准备发送 subscription_confirmed: {response_data}') + emit('subscription_confirmed', response_data) + print(f'[WebSocket DEBUG] ✓ 已发送 subscription_confirmed') + print(f'[WebSocket DEBUG] ========== 订阅完成 ==========\n') + + except Exception as e: + print(f'[WebSocket ERROR] 订阅失败: {e}') + import traceback + traceback.print_exc() + emit('subscription_error', { + 'success': False, + 'error': str(e) + }) + + +@socketio.on('unsubscribe_events') +def handle_unsubscribe(data): + """取消订阅事件推送""" + try: + print(f'\n[WebSocket DEBUG] ========== 收到取消订阅请求 ==========') + print(f'[WebSocket DEBUG] Socket ID: {request.sid}') + print(f'[WebSocket DEBUG] 数据: {data}') + + event_type = data.get('event_type', 'all') + room_name = f"events_{event_type}" + + print(f'[WebSocket DEBUG] 准备离开房间: {room_name}') + leave_room(room_name) + print(f'[WebSocket DEBUG] ✓ 已离开房间: {room_name}') + + print(f'[WebSocket] 客户端 {request.sid} 取消订阅房间: {room_name}') + + emit('unsubscription_confirmed', { + 'success': True, + 'room': room_name, + 'message': f'已取消订阅 {event_type} 类型的事件推送' + }) + print(f'[WebSocket DEBUG] ========== 取消订阅完成 ==========\n') + + except Exception as e: + print(f'[WebSocket ERROR] 取消订阅失败: {e}') + import traceback + traceback.print_exc() + emit('unsubscription_error', { + 'success': False, + 'error': str(e) + }) + + +@socketio.on('disconnect') +def handle_disconnect(): + """客户端断开连接事件""" + print(f'\n[WebSocket DEBUG] ========== 客户端断开 ==========') + print(f'[WebSocket DEBUG] Socket ID: {request.sid}') + print(f'[WebSocket] 客户端已断开: {request.sid}') + print(f'[WebSocket DEBUG] ========== 断开完成 ==========\n') + + +# ==================== WebSocket 辅助函数 ==================== + +def broadcast_new_event(event): + """ + 广播新事件到所有订阅的客户端 + 在创建新事件时调用此函数 + + Args: + event: Event 模型实例 + """ + try: + print(f'\n[WebSocket DEBUG] ========== 广播新事件 ==========') + print(f'[WebSocket DEBUG] 事件ID: {event.id}') + print(f'[WebSocket DEBUG] 事件标题: {event.title}') + print(f'[WebSocket DEBUG] 事件类型: {event.event_type}') + print(f'[WebSocket DEBUG] 重要性: {event.importance}') + + event_data = { + '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, + 'hot_score': event.hot_score, + 'view_count': event.view_count, + 'related_avg_chg': event.related_avg_chg, + 'related_max_chg': event.related_max_chg, + 'keywords': event.keywords_list if hasattr(event, 'keywords_list') else event.keywords, + } + + print(f'[WebSocket DEBUG] 准备发送的数据: {event_data}') + + # 发送到所有订阅者(all 房间) + print(f'[WebSocket DEBUG] 正在发送到房间: events_all') + socketio.emit('new_event', event_data, room='events_all', namespace='/') + print(f'[WebSocket DEBUG] ✓ 已发送到 events_all') + + # 发送到特定类型订阅者 + if event.event_type: + room_name = f"events_{event.event_type}" + print(f'[WebSocket DEBUG] 正在发送到房间: {room_name}') + socketio.emit('new_event', event_data, room=room_name, namespace='/') + print(f'[WebSocket DEBUG] ✓ 已发送到 {room_name}') + print(f'[WebSocket] 已推送新事件到房间: events_all, {room_name}') + else: + print(f'[WebSocket] 已推送新事件到房间: events_all') + + print(f'[WebSocket DEBUG] ========== 广播完成 ==========\n') + + except Exception as e: + print(f'[WebSocket ERROR] 推送新事件失败: {e}') + import traceback + traceback.print_exc() + + +# ==================== WebSocket 轮询机制(检测新事件) ==================== + +# 内存变量:记录近24小时内已知的事件ID集合和最大ID +known_event_ids_in_24h = set() # 近24小时内已知的所有事件ID +last_max_event_id = 0 # 已知的最大事件ID + +def poll_new_events(): + """ + 定期轮询数据库,检查是否有新事件 + 每 30 秒执行一次 + + 新的设计思路(修复 created_at 不是入库时间的问题): + 1. 查询近24小时内的所有活跃事件(按 created_at,因为这是事件发生时间) + 2. 通过对比事件ID(自增ID)来判断是否为新插入的事件 + 3. 推送 ID > last_max_event_id 的事件 + 4. 更新已知事件ID集合和最大ID + """ + global known_event_ids_in_24h, last_max_event_id + + try: + with app.app_context(): + from datetime import datetime, timedelta + + current_time = datetime.now() + print(f'\n[轮询 DEBUG] ========== 开始轮询 ==========') + print(f'[轮询 DEBUG] 当前时间: {current_time.strftime("%Y-%m-%d %H:%M:%S")}') + print(f'[轮询 DEBUG] 已知事件ID数量: {len(known_event_ids_in_24h)}') + print(f'[轮询 DEBUG] 当前最大事件ID: {last_max_event_id}') + + # 查询近24小时内的所有活跃事件(按事件发生时间 created_at) + time_24h_ago = current_time - timedelta(hours=24) + print(f'[轮询 DEBUG] 查询时间范围: 近24小时({time_24h_ago.strftime("%Y-%m-%d %H:%M:%S")} ~ 现在)') + + # 查询所有近24小时内的活跃事件 + events_in_24h = Event.query.filter( + Event.created_at >= time_24h_ago, + Event.status == 'active' + ).order_by(Event.id.asc()).all() + + print(f'[轮询 DEBUG] 数据库查询结果: 找到 {len(events_in_24h)} 个近24小时内的事件') + + # 找出新插入的事件(ID > last_max_event_id) + new_events = [ + event for event in events_in_24h + if event.id > last_max_event_id + ] + + print(f'[轮询 DEBUG] 新事件数量(ID > {last_max_event_id}): {len(new_events)} 个') + + if new_events: + print(f'[轮询] 发现 {len(new_events)} 个新事件') + + for event in new_events: + print(f'[轮询 DEBUG] 新事件详情:') + print(f'[轮询 DEBUG] - ID: {event.id}') + print(f'[轮询 DEBUG] - 标题: {event.title}') + print(f'[轮询 DEBUG] - 事件发生时间(created_at): {event.created_at}') + print(f'[轮询 DEBUG] - 事件类型: {event.event_type}') + + # 推送新事件 + print(f'[轮询 DEBUG] 准备推送事件 ID={event.id}') + broadcast_new_event(event) + print(f'[轮询] ✓ 已推送事件 ID={event.id}, 标题={event.title}') + + # 更新已知事件ID集合(所有近24小时内的事件ID) + known_event_ids_in_24h = set(event.id for event in events_in_24h) + + # 更新最大事件ID + new_max_id = max(event.id for event in events_in_24h) + print(f'[轮询 DEBUG] 更新最大事件ID: {last_max_event_id} -> {new_max_id}') + last_max_event_id = new_max_id + + print(f'[轮询 DEBUG] 更新后已知事件ID数量: {len(known_event_ids_in_24h)}') + + else: + print(f'[轮询 DEBUG] 没有新事件需要推送') + + # 即使没有新事件,也要更新已知事件集合(清理超过24小时的) + if events_in_24h: + known_event_ids_in_24h = set(event.id for event in events_in_24h) + current_max_id = max(event.id for event in events_in_24h) + if current_max_id != last_max_event_id: + print(f'[轮询 DEBUG] 更新最大事件ID: {last_max_event_id} -> {current_max_id}') + last_max_event_id = current_max_id + + print(f'[轮询 DEBUG] ========== 轮询结束 ==========\n') + + except Exception as e: + print(f'[轮询 ERROR] 检查新事件时出错: {e}') + import traceback + traceback.print_exc() + + +def initialize_event_polling(): + """ + 初始化事件轮询机制 + 在应用启动时调用 + """ + global known_event_ids_in_24h, last_max_event_id + + try: + from datetime import datetime, timedelta + + with app.app_context(): + current_time = datetime.now() + time_24h_ago = current_time - timedelta(hours=24) + + print(f'\n[轮询] ========== 初始化事件轮询 ==========') + print(f'[轮询] 当前时间: {current_time.strftime("%Y-%m-%d %H:%M:%S")}') + + # 查询近24小时内的所有活跃事件 + events_in_24h = Event.query.filter( + Event.created_at >= time_24h_ago, + Event.status == 'active' + ).order_by(Event.id.asc()).all() + + # 初始化已知事件ID集合 + known_event_ids_in_24h = set(event.id for event in events_in_24h) + + # 初始化最大事件ID + if events_in_24h: + last_max_event_id = max(event.id for event in events_in_24h) + print(f'[轮询] 近24小时内共有 {len(events_in_24h)} 个活跃事件') + print(f'[轮询] 初始最大事件ID: {last_max_event_id}') + print(f'[轮询] 事件ID范围: {min(event.id for event in events_in_24h)} ~ {last_max_event_id}') + else: + last_max_event_id = 0 + print(f'[轮询] 近24小时内没有活跃事件') + print(f'[轮询] 初始最大事件ID: 0') + + # 统计数据库中的事件总数 + total_events = Event.query.filter_by(status='active').count() + print(f'[轮询] 数据库中共有 {total_events} 个活跃事件(所有时间)') + print(f'[轮询] 只会推送 ID > {last_max_event_id} 的新事件') + print(f'[轮询] ========== 初始化完成 ==========\n') + + # 创建后台调度器 + scheduler = BackgroundScheduler() + # 每 30 秒执行一次轮询 + scheduler.add_job( + func=poll_new_events, + trigger='interval', + seconds=30, + id='poll_new_events', + name='检查新事件并推送', + replace_existing=True + ) + scheduler.start() + print('[轮询] 调度器已启动,每 30 秒检查一次新事件') + + except Exception as e: + print(f'[轮询] 初始化失败: {e}') + + +# ==================== 结束 WebSocket 部分 ==================== + + +@app.route('/api/posts//like', methods=['POST']) +@login_required +def like_post(post_id): + """点赞/取消点赞帖子""" + try: + post = Post.query.get_or_404(post_id) + + # 检查是否已经点赞 + existing_like = PostLike.query.filter_by( + post_id=post_id, + user_id=current_user.id + ).first() + + if existing_like: + # 取消点赞 + db.session.delete(existing_like) + post.likes_count = max(0, post.likes_count - 1) + message = '取消点赞成功' + liked = False + else: + # 添加点赞 + new_like = PostLike(post_id=post_id, user_id=current_user.id) + db.session.add(new_like) + post.likes_count += 1 + message = '点赞成功' + liked = True + + db.session.commit() + + return jsonify({ + 'success': True, + 'message': message, + 'likes_count': post.likes_count, + 'liked': liked + }) + + except Exception as e: + db.session.rollback() + print(f"点赞失败: {e}") + return jsonify({ + 'success': False, + 'message': str(e) + }), 500 + + +@app.route('/api/comments//like', methods=['POST']) +@login_required +def like_comment(comment_id): + """点赞/取消点赞评论""" + try: + comment = Comment.query.get_or_404(comment_id) + + # 检查是否已经点赞(需要创建 CommentLike 关联到新的 Comment 模型) + # 暂时使用简单的计数器 + comment.likes_count += 1 + db.session.commit() + + return jsonify({ + 'success': True, + 'message': '点赞成功', + 'likes_count': comment.likes_count + }) + + except Exception as e: + db.session.rollback() + print(f"点赞失败: {e}") + return jsonify({ + 'success': False, + 'message': str(e) + }), 500 + + +@app.route('/api/posts/', methods=['DELETE']) +@login_required +def delete_post(post_id): + """删除帖子""" + try: + post = Post.query.get_or_404(post_id) + + # 检查权限:只能删除自己的帖子 + if post.user_id != current_user.id: + return jsonify({ + 'success': False, + 'message': '您只能删除自己的帖子' + }), 403 + + # 软删除 + post.status = 'deleted' + + # 更新事件的帖子数 + event = Event.query.get(post.event_id) + if event: + event.post_count = Post.query.filter_by(event_id=post.event_id, status='active').count() + + # 更新用户发帖数 + if current_user.post_count > 0: + current_user.post_count -= 1 + + db.session.commit() + + return jsonify({ + 'success': True, + 'message': '帖子删除成功' + }) + + except Exception as e: + db.session.rollback() + print(f"删除帖子失败: {e}") + return jsonify({ + 'success': False, + 'message': str(e) + }), 500 + + +@app.route('/api/comments/', methods=['DELETE']) +@login_required +def delete_comment(comment_id): + """删除评论""" + try: + comment = Comment.query.get_or_404(comment_id) + + # 检查权限:只能删除自己的评论 + if comment.user_id != current_user.id: + return jsonify({ + 'success': False, + 'message': '您只能删除自己的评论' + }), 403 + + # 软删除 + comment.status = 'deleted' + comment.content = '[该评论已被删除]' + + # 更新帖子评论数 + post = Post.query.get(comment.post_id) + if post: + post.comments_count = Comment.query.filter_by(post_id=comment.post_id, status='active').count() + + # 更新用户评论数 + if current_user.comment_count > 0: + current_user.comment_count -= 1 + + db.session.commit() + + return jsonify({ + 'success': True, + 'message': '评论删除成功' + }) + + except Exception as e: + db.session.rollback() + print(f"删除评论失败: {e}") + return jsonify({ + 'success': False, + 'message': str(e) + }), 500 + + +def format_decimal(value): + """格式化decimal类型数据""" + if value is None: + return None + if isinstance(value, Decimal): + return float(value) + return float(value) + + +def format_date(date_obj): + """格式化日期""" + if date_obj is None: + return None + if isinstance(date_obj, datetime): + return date_obj.strftime('%Y-%m-%d') + return str(date_obj) + + +def remove_cycles_from_sankey_flows(flows_data): + """ + 移除Sankey图数据中的循环边,确保数据是DAG(有向无环图) + 使用拓扑排序算法检测循环,优先保留flow_ratio高的边 + + Args: + flows_data: list of flow objects with 'source', 'target', 'flow_metrics' keys + + Returns: + list of flows without cycles + """ + if not flows_data: + return flows_data + + # 按flow_ratio降序排序,优先保留重要的边 + sorted_flows = sorted( + flows_data, + key=lambda x: x.get('flow_metrics', {}).get('flow_ratio', 0) or 0, + reverse=True + ) + + # 构建图的邻接表和入度表 + def build_graph(flows): + graph = {} # node -> list of successors + in_degree = {} # node -> in-degree count + all_nodes = set() + + for flow in flows: + source = flow['source']['node_name'] + target = flow['target']['node_name'] + all_nodes.add(source) + all_nodes.add(target) + + if source not in graph: + graph[source] = [] + graph[source].append(target) + + if target not in in_degree: + in_degree[target] = 0 + in_degree[target] += 1 + + if source not in in_degree: + in_degree[source] = 0 + + return graph, in_degree, all_nodes + + # 使用Kahn算法检测是否有环 + def has_cycle(graph, in_degree, all_nodes): + # 找到所有入度为0的节点 + queue = [node for node in all_nodes if in_degree.get(node, 0) == 0] + visited_count = 0 + + while queue: + node = queue.pop(0) + visited_count += 1 + + # 访问所有邻居 + for neighbor in graph.get(node, []): + in_degree[neighbor] -= 1 + if in_degree[neighbor] == 0: + queue.append(neighbor) + + # 如果访问的节点数等于总节点数,说明没有环 + return visited_count < len(all_nodes) + + # 逐个添加边,如果添加后产生环则跳过 + result_flows = [] + + for flow in sorted_flows: + # 尝试添加这条边 + temp_flows = result_flows + [flow] + + # 检查是否产生环 + graph, in_degree, all_nodes = build_graph(temp_flows) + + # 复制in_degree用于检测(因为检测过程会修改它) + in_degree_copy = in_degree.copy() + + if not has_cycle(graph, in_degree_copy, all_nodes): + # 没有产生环,可以添加 + result_flows.append(flow) + else: + # 产生环,跳过这条边 + print(f"Skipping edge that creates cycle: {flow['source']['node_name']} -> {flow['target']['node_name']}") + + removed_count = len(flows_data) - len(result_flows) + if removed_count > 0: + print(f"Removed {removed_count} edges to eliminate cycles in Sankey diagram") + + return result_flows + + +def get_report_type(date_str): + """获取报告期类型""" + if not date_str: + return '' + if isinstance(date_str, str): + date = datetime.strptime(date_str, '%Y-%m-%d') + else: + date = date_str + + month = date.month + year = date.year + + if month == 3: + return f"{year}年一季报" + elif month == 6: + return f"{year}年中报" + elif month == 9: + return f"{year}年三季报" + elif month == 12: + return f"{year}年年报" + else: + return str(date_str) + + +@app.route('/api/financial/stock-info/', methods=['GET']) +def get_stock_info(seccode): + """获取股票基本信息和最新财务摘要""" + try: + # 获取最新的财务数据 + query = text(""" + SELECT distinct a.SECCODE, + a.SECNAME, + a.ENDDATE, + a.F003N as eps, + a.F004N as basic_eps, + a.F005N as diluted_eps, + a.F006N as deducted_eps, + a.F007N as undistributed_profit_ps, + a.F008N as bvps, + a.F010N as capital_reserve_ps, + a.F014N as roe, + a.F067N as roe_weighted, + a.F016N as roa, + a.F078N as gross_margin, + a.F017N as net_margin, + a.F089N as revenue, + a.F101N as net_profit, + a.F102N as parent_net_profit, + a.F118N as total_assets, + a.F121N as total_liabilities, + a.F128N as total_equity, + a.F052N as revenue_growth, + a.F053N as profit_growth, + a.F054N as equity_growth, + a.F056N as asset_growth, + a.F122N as share_capital + FROM ea_financialindex a + WHERE a.SECCODE = :seccode + ORDER BY a.ENDDATE DESC LIMIT 1 + """) + + result = engine.execute(query, seccode=seccode).fetchone() + + if not result: + return jsonify({ + 'success': False, + 'message': f'未找到股票代码 {seccode} 的财务数据' + }), 404 + + # 获取最近的业绩预告 + forecast_query = text(""" + SELECT distinct F001D as report_date, + F003V as forecast_type, + F004V as content, + F007N as profit_lower, + F008N as profit_upper, + F009N as change_lower, + F010N as change_upper + FROM ea_forecast + WHERE SECCODE = :seccode + AND F006C = 'T' + ORDER BY F001D DESC LIMIT 1 + """) + + forecast_result = engine.execute(forecast_query, seccode=seccode).fetchone() + + data = { + 'stock_code': result.SECCODE, + 'stock_name': result.SECNAME, + 'latest_period': format_date(result.ENDDATE), + 'report_type': get_report_type(result.ENDDATE), + 'key_metrics': { + 'eps': format_decimal(result.eps), + 'basic_eps': format_decimal(result.basic_eps), + 'diluted_eps': format_decimal(result.diluted_eps), + 'deducted_eps': format_decimal(result.deducted_eps), + 'bvps': format_decimal(result.bvps), + 'roe': format_decimal(result.roe), + 'roe_weighted': format_decimal(result.roe_weighted), + 'roa': format_decimal(result.roa), + 'gross_margin': format_decimal(result.gross_margin), + 'net_margin': format_decimal(result.net_margin), + }, + 'financial_summary': { + 'revenue': format_decimal(result.revenue), + 'net_profit': format_decimal(result.net_profit), + 'parent_net_profit': format_decimal(result.parent_net_profit), + 'total_assets': format_decimal(result.total_assets), + 'total_liabilities': format_decimal(result.total_liabilities), + 'total_equity': format_decimal(result.total_equity), + 'share_capital': format_decimal(result.share_capital), + }, + 'growth_rates': { + 'revenue_growth': format_decimal(result.revenue_growth), + 'profit_growth': format_decimal(result.profit_growth), + 'equity_growth': format_decimal(result.equity_growth), + 'asset_growth': format_decimal(result.asset_growth), + } + } + + # 添加业绩预告信息 + if forecast_result: + data['latest_forecast'] = { + 'report_date': format_date(forecast_result.report_date), + 'forecast_type': forecast_result.forecast_type, + 'content': forecast_result.content, + 'profit_range': { + 'lower': format_decimal(forecast_result.profit_lower), + 'upper': format_decimal(forecast_result.profit_upper), + }, + 'change_range': { + 'lower': format_decimal(forecast_result.change_lower), + 'upper': format_decimal(forecast_result.change_upper), + } + } + + return jsonify({ + 'success': True, + 'data': data + }) + + except Exception as e: + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + + +@app.route('/api/financial/balance-sheet/', methods=['GET']) +def get_balance_sheet(seccode): + """获取完整的资产负债表数据""" + try: + limit = request.args.get('limit', 12, type=int) + + query = text(""" + SELECT distinct ENDDATE, + DECLAREDATE, + -- 流动资产 + F006N as cash, -- 货币资金 + F007N as trading_financial_assets, -- 交易性金融资产 + F008N as notes_receivable, -- 应收票据 + F009N as accounts_receivable, -- 应收账款 + F010N as prepayments, -- 预付款项 + F011N as other_receivables, -- 其他应收款 + F013N as interest_receivable, -- 应收利息 + F014N as dividends_receivable, -- 应收股利 + F015N as inventory, -- 存货 + F016N as consumable_biological_assets, -- 消耗性生物资产 + F017N as non_current_assets_due_within_one_year, -- 一年内到期的非流动资产 + F018N as other_current_assets, -- 其他流动资产 + F019N as total_current_assets, -- 流动资产合计 + + -- 非流动资产 + F020N as available_for_sale_financial_assets, -- 可供出售金融资产 + F021N as held_to_maturity_investments, -- 持有至到期投资 + F022N as long_term_receivables, -- 长期应收款 + F023N as long_term_equity_investments, -- 长期股权投资 + F024N as investment_property, -- 投资性房地产 + F025N as fixed_assets, -- 固定资产 + F026N as construction_in_progress, -- 在建工程 + F027N as engineering_materials, -- 工程物资 + F029N as productive_biological_assets, -- 生产性生物资产 + F030N as oil_and_gas_assets, -- 油气资产 + F031N as intangible_assets, -- 无形资产 + F032N as development_expenditure, -- 开发支出 + F033N as goodwill, -- 商誉 + F034N as long_term_deferred_expenses, -- 长期待摊费用 + F035N as deferred_tax_assets, -- 递延所得税资产 + F036N as other_non_current_assets, -- 其他非流动资产 + F037N as total_non_current_assets, -- 非流动资产合计 + F038N as total_assets, -- 资产总计 + + -- 流动负债 + F039N as short_term_borrowings, -- 短期借款 + F040N as trading_financial_liabilities, -- 交易性金融负债 + F041N as notes_payable, -- 应付票据 + F042N as accounts_payable, -- 应付账款 + F043N as advance_receipts, -- 预收款项 + F044N as employee_compensation_payable, -- 应付职工薪酬 + F045N as taxes_payable, -- 应交税费 + F046N as interest_payable, -- 应付利息 + F047N as dividends_payable, -- 应付股利 + F048N as other_payables, -- 其他应付款 + F050N as non_current_liabilities_due_within_one_year, -- 一年内到期的非流动负债 + F051N as other_current_liabilities, -- 其他流动负债 + F052N as total_current_liabilities, -- 流动负债合计 + + -- 非流动负债 + F053N as long_term_borrowings, -- 长期借款 + F054N as bonds_payable, -- 应付债券 + F055N as long_term_payables, -- 长期应付款 + F056N as special_payables, -- 专项应付款 + F057N as estimated_liabilities, -- 预计负债 + F058N as deferred_tax_liabilities, -- 递延所得税负债 + F059N as other_non_current_liabilities, -- 其他非流动负债 + F060N as total_non_current_liabilities, -- 非流动负债合计 + F061N as total_liabilities, -- 负债合计 + + -- 所有者权益 + F062N as share_capital, -- 股本 + F063N as capital_reserve, -- 资本公积 + F064N as surplus_reserve, -- 盈余公积 + F065N as undistributed_profit, -- 未分配利润 + F066N as treasury_stock, -- 库存股 + F067N as minority_interests, -- 少数股东权益 + F070N as total_equity, -- 所有者权益合计 + F071N as total_liabilities_and_equity, -- 负债和所有者权益合计 + F073N as parent_company_equity, -- 归属于母公司所有者权益 + F074N as other_comprehensive_income, -- 其他综合收益 + + -- 新会计准则科目 + F110N as other_debt_investments, -- 其他债权投资 + F111N as other_equity_investments, -- 其他权益工具投资 + F112N as other_non_current_financial_assets, -- 其他非流动金融资产 + F115N as contract_liabilities, -- 合同负债 + F119N as contract_assets, -- 合同资产 + F120N as receivables_financing, -- 应收款项融资 + F121N as right_of_use_assets, -- 使用权资产 + F122N as lease_liabilities -- 租赁负债 + FROM ea_asset + WHERE SECCODE = :seccode + and F002V = '071001' + ORDER BY ENDDATE DESC LIMIT :limit + """) + + result = engine.execute(query, seccode=seccode, limit=limit) + data = [] + + for row in result: + # 安全计算关键比率,避免 Decimal 与 None 运算错误 + def to_float(v): + try: + return float(v) if v is not None else None + except Exception: + return None + + ta = to_float(row.total_assets) + tl = to_float(row.total_liabilities) + tca = to_float(row.total_current_assets) + tcl = to_float(row.total_current_liabilities) + inv = to_float(row.inventory) or 0.0 + + asset_liability_ratio_val = None + if ta is not None and ta != 0 and tl is not None: + asset_liability_ratio_val = (tl / ta) * 100 + + current_ratio_val = None + if tcl is not None and tcl != 0 and tca is not None: + current_ratio_val = tca / tcl + + quick_ratio_val = None + if tcl is not None and tcl != 0 and tca is not None: + quick_ratio_val = (tca - inv) / tcl + + period_data = { + 'period': format_date(row.ENDDATE), + 'declare_date': format_date(row.DECLAREDATE), + 'report_type': get_report_type(row.ENDDATE), + + # 资产部分 + 'assets': { + 'current_assets': { + 'cash': format_decimal(row.cash), + 'trading_financial_assets': format_decimal(row.trading_financial_assets), + 'notes_receivable': format_decimal(row.notes_receivable), + 'accounts_receivable': format_decimal(row.accounts_receivable), + 'prepayments': format_decimal(row.prepayments), + 'other_receivables': format_decimal(row.other_receivables), + 'inventory': format_decimal(row.inventory), + 'contract_assets': format_decimal(row.contract_assets), + 'other_current_assets': format_decimal(row.other_current_assets), + 'total': format_decimal(row.total_current_assets), + }, + 'non_current_assets': { + 'long_term_equity_investments': format_decimal(row.long_term_equity_investments), + 'investment_property': format_decimal(row.investment_property), + 'fixed_assets': format_decimal(row.fixed_assets), + 'construction_in_progress': format_decimal(row.construction_in_progress), + 'intangible_assets': format_decimal(row.intangible_assets), + 'goodwill': format_decimal(row.goodwill), + 'right_of_use_assets': format_decimal(row.right_of_use_assets), + 'deferred_tax_assets': format_decimal(row.deferred_tax_assets), + 'other_non_current_assets': format_decimal(row.other_non_current_assets), + 'total': format_decimal(row.total_non_current_assets), + }, + 'total': format_decimal(row.total_assets), + }, + + # 负债部分 + 'liabilities': { + 'current_liabilities': { + 'short_term_borrowings': format_decimal(row.short_term_borrowings), + 'notes_payable': format_decimal(row.notes_payable), + 'accounts_payable': format_decimal(row.accounts_payable), + 'advance_receipts': format_decimal(row.advance_receipts), + 'contract_liabilities': format_decimal(row.contract_liabilities), + 'employee_compensation_payable': format_decimal(row.employee_compensation_payable), + 'taxes_payable': format_decimal(row.taxes_payable), + 'other_payables': format_decimal(row.other_payables), + 'non_current_liabilities_due_within_one_year': format_decimal( + row.non_current_liabilities_due_within_one_year), + 'total': format_decimal(row.total_current_liabilities), + }, + 'non_current_liabilities': { + 'long_term_borrowings': format_decimal(row.long_term_borrowings), + 'bonds_payable': format_decimal(row.bonds_payable), + 'lease_liabilities': format_decimal(row.lease_liabilities), + 'deferred_tax_liabilities': format_decimal(row.deferred_tax_liabilities), + 'other_non_current_liabilities': format_decimal(row.other_non_current_liabilities), + 'total': format_decimal(row.total_non_current_liabilities), + }, + 'total': format_decimal(row.total_liabilities), + }, + + # 股东权益部分 + 'equity': { + 'share_capital': format_decimal(row.share_capital), + 'capital_reserve': format_decimal(row.capital_reserve), + 'surplus_reserve': format_decimal(row.surplus_reserve), + 'undistributed_profit': format_decimal(row.undistributed_profit), + 'treasury_stock': format_decimal(row.treasury_stock), + 'other_comprehensive_income': format_decimal(row.other_comprehensive_income), + 'parent_company_equity': format_decimal(row.parent_company_equity), + 'minority_interests': format_decimal(row.minority_interests), + 'total': format_decimal(row.total_equity), + }, + + # 关键比率 + 'key_ratios': { + 'asset_liability_ratio': format_decimal(asset_liability_ratio_val), + 'current_ratio': format_decimal(current_ratio_val), + 'quick_ratio': format_decimal(quick_ratio_val), + } + } + data.append(period_data) + + return jsonify({ + 'success': True, + 'data': data + }) + + except Exception as e: + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + + +@app.route('/api/financial/income-statement/', methods=['GET']) +def get_income_statement(seccode): + """获取完整的利润表数据""" + try: + limit = request.args.get('limit', 12, type=int) + + query = text(""" + SELECT distinct ENDDATE, + STARTDATE, + DECLAREDATE, + -- 营业收入部分 + F006N as revenue, -- 营业收入 + F035N as total_operating_revenue, -- 营业总收入 + F051N as other_income, -- 其他收入 + + -- 营业成本部分 + F007N as cost, -- 营业成本 + F008N as taxes_and_surcharges, -- 税金及附加 + F009N as selling_expenses, -- 销售费用 + F010N as admin_expenses, -- 管理费用 + F056N as rd_expenses, -- 研发费用 + F012N as financial_expenses, -- 财务费用 + F062N as interest_expense, -- 利息费用 + F063N as interest_income, -- 利息收入 + F013N as asset_impairment_loss, -- 资产减值损失(营业总成本) + F057N as credit_impairment_loss, -- 信用减值损失(营业总成本) + F036N as total_operating_cost, -- 营业总成本 + + -- 其他收益 + F014N as fair_value_change_income, -- 公允价值变动净收益 + F015N as investment_income, -- 投资收益 + F016N as investment_income_from_associates, -- 对联营企业和合营企业的投资收益 + F037N as exchange_income, -- 汇兑收益 + F058N as net_exposure_hedging_income, -- 净敞口套期收益 + F059N as asset_disposal_income, -- 资产处置收益 + + -- 利润部分 + F018N as operating_profit, -- 营业利润 + F019N as subsidy_income, -- 补贴收入 + F020N as non_operating_income, -- 营业外收入 + F021N as non_operating_expenses, -- 营业外支出 + F022N as non_current_asset_disposal_loss, -- 非流动资产处置损失 + F024N as total_profit, -- 利润总额 + F025N as income_tax_expense, -- 所得税 + F027N as net_profit, -- 净利润 + F028N as parent_net_profit, -- 归属于母公司所有者的净利润 + F029N as minority_profit, -- 少数股东损益 + + -- 持续经营 + F060N as continuing_operations_net_profit, -- 持续经营净利润 + F061N as discontinued_operations_net_profit, -- 终止经营净利润 + + -- 每股收益 + F031N as basic_eps, -- 基本每股收益 + F032N as diluted_eps, -- 稀释每股收益 + + -- 综合收益 + F038N as other_comprehensive_income_after_tax, -- 其他综合收益的税后净额 + F039N as total_comprehensive_income, -- 综合收益总额 + F040N as parent_company_comprehensive_income, -- 归属于母公司的综合收益 + F041N as minority_comprehensive_income -- 归属于少数股东的综合收益 + FROM ea_profit + WHERE SECCODE = :seccode + and F002V = '071001' + ORDER BY ENDDATE DESC LIMIT :limit + """) + + result = engine.execute(query, seccode=seccode, limit=limit) + data = [] + + for row in result: + # 计算一些衍生指标 + gross_profit = (row.revenue - row.cost) if row.revenue and row.cost else None + gross_margin = (gross_profit / row.revenue * 100) if row.revenue and gross_profit else None + operating_margin = ( + row.operating_profit / row.revenue * 100) if row.revenue and row.operating_profit else None + net_margin = (row.net_profit / row.revenue * 100) if row.revenue and row.net_profit else None + + # 三费合计 + three_expenses = 0 + if row.selling_expenses: + three_expenses += row.selling_expenses + if row.admin_expenses: + three_expenses += row.admin_expenses + if row.financial_expenses: + three_expenses += row.financial_expenses + + # 四费合计(加研发) + four_expenses = three_expenses + if row.rd_expenses: + four_expenses += row.rd_expenses + + period_data = { + 'period': format_date(row.ENDDATE), + 'start_date': format_date(row.STARTDATE), + 'declare_date': format_date(row.DECLAREDATE), + 'report_type': get_report_type(row.ENDDATE), + + # 收入部分 + 'revenue': { + 'operating_revenue': format_decimal(row.revenue), + 'total_operating_revenue': format_decimal(row.total_operating_revenue), + 'other_income': format_decimal(row.other_income), + }, + + # 成本费用部分 + 'costs': { + 'operating_cost': format_decimal(row.cost), + 'taxes_and_surcharges': format_decimal(row.taxes_and_surcharges), + 'selling_expenses': format_decimal(row.selling_expenses), + 'admin_expenses': format_decimal(row.admin_expenses), + 'rd_expenses': format_decimal(row.rd_expenses), + 'financial_expenses': format_decimal(row.financial_expenses), + 'interest_expense': format_decimal(row.interest_expense), + 'interest_income': format_decimal(row.interest_income), + 'asset_impairment_loss': format_decimal(row.asset_impairment_loss), + 'credit_impairment_loss': format_decimal(row.credit_impairment_loss), + 'total_operating_cost': format_decimal(row.total_operating_cost), + 'three_expenses_total': format_decimal(three_expenses), + 'four_expenses_total': format_decimal(four_expenses), + }, + + # 其他收益 + 'other_gains': { + 'fair_value_change': format_decimal(row.fair_value_change_income), + 'investment_income': format_decimal(row.investment_income), + 'investment_income_from_associates': format_decimal(row.investment_income_from_associates), + 'exchange_income': format_decimal(row.exchange_income), + 'asset_disposal_income': format_decimal(row.asset_disposal_income), + }, + + # 利润 + 'profit': { + 'gross_profit': format_decimal(gross_profit), + 'operating_profit': format_decimal(row.operating_profit), + 'total_profit': format_decimal(row.total_profit), + 'net_profit': format_decimal(row.net_profit), + 'parent_net_profit': format_decimal(row.parent_net_profit), + 'minority_profit': format_decimal(row.minority_profit), + 'continuing_operations_net_profit': format_decimal(row.continuing_operations_net_profit), + 'discontinued_operations_net_profit': format_decimal(row.discontinued_operations_net_profit), + }, + + # 非经营项目 + 'non_operating': { + 'subsidy_income': format_decimal(row.subsidy_income), + 'non_operating_income': format_decimal(row.non_operating_income), + 'non_operating_expenses': format_decimal(row.non_operating_expenses), + }, + + # 每股收益 + 'per_share': { + 'basic_eps': format_decimal(row.basic_eps), + 'diluted_eps': format_decimal(row.diluted_eps), + }, + + # 综合收益 + 'comprehensive_income': { + 'other_comprehensive_income': format_decimal(row.other_comprehensive_income_after_tax), + 'total_comprehensive_income': format_decimal(row.total_comprehensive_income), + 'parent_comprehensive_income': format_decimal(row.parent_company_comprehensive_income), + 'minority_comprehensive_income': format_decimal(row.minority_comprehensive_income), + }, + + # 关键比率 + 'margins': { + 'gross_margin': format_decimal(gross_margin), + 'operating_margin': format_decimal(operating_margin), + 'net_margin': format_decimal(net_margin), + 'expense_ratio': format_decimal(four_expenses / row.revenue * 100) if row.revenue else None, + 'rd_ratio': format_decimal( + row.rd_expenses / row.revenue * 100) if row.revenue and row.rd_expenses else None, + } + } + data.append(period_data) + + return jsonify({ + 'success': True, + 'data': data + }) + + except Exception as e: + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + + +@app.route('/api/financial/cashflow/', methods=['GET']) +def get_cashflow(seccode): + """获取完整的现金流量表数据""" + try: + limit = request.args.get('limit', 12, type=int) + + query = text(""" + SELECT distinct ENDDATE, + STARTDATE, + DECLAREDATE, + -- 经营活动现金流 + F006N as cash_from_sales, -- 销售商品、提供劳务收到的现金 + F007N as tax_refunds, -- 收到的税费返还 + F008N as other_operating_cash_received, -- 收到其他与经营活动有关的现金 + F009N as total_operating_cash_inflow, -- 经营活动现金流入小计 + F010N as cash_paid_for_goods, -- 购买商品、接受劳务支付的现金 + F011N as cash_paid_to_employees, -- 支付给职工以及为职工支付的现金 + F012N as taxes_paid, -- 支付的各项税费 + F013N as other_operating_cash_paid, -- 支付其他与经营活动有关的现金 + F014N as total_operating_cash_outflow, -- 经营活动现金流出小计 + F015N as net_operating_cash_flow, -- 经营活动产生的现金流量净额 + + -- 投资活动现金流 + F016N as cash_from_investment_recovery, -- 收回投资收到的现金 + F017N as cash_from_investment_income, -- 取得投资收益收到的现金 + F018N as cash_from_asset_disposal, -- 处置固定资产、无形资产和其他长期资产收回的现金净额 + F019N as cash_from_subsidiary_disposal, -- 处置子公司及其他营业单位收到的现金净额 + F020N as other_investment_cash_received, -- 收到其他与投资活动有关的现金 + F021N as total_investment_cash_inflow, -- 投资活动现金流入小计 + F022N as cash_paid_for_assets, -- 购建固定资产、无形资产和其他长期资产支付的现金 + F023N as cash_paid_for_investments, -- 投资支付的现金 + F024N as cash_paid_for_subsidiaries, -- 取得子公司及其他营业单位支付的现金净额 + F025N as other_investment_cash_paid, -- 支付其他与投资活动有关的现金 + F026N as total_investment_cash_outflow, -- 投资活动现金流出小计 + F027N as net_investment_cash_flow, -- 投资活动产生的现金流量净额 + + -- 筹资活动现金流 + F028N as cash_from_capital, -- 吸收投资收到的现金 + F029N as cash_from_borrowings, -- 取得借款收到的现金 + F030N as other_financing_cash_received, -- 收到其他与筹资活动有关的现金 + F031N as total_financing_cash_inflow, -- 筹资活动现金流入小计 + F032N as cash_paid_for_debt, -- 偿还债务支付的现金 + F033N as cash_paid_for_distribution, -- 分配股利、利润或偿付利息支付的现金 + F034N as other_financing_cash_paid, -- 支付其他与筹资活动有关的现金 + F035N as total_financing_cash_outflow, -- 筹资活动现金流出小计 + F036N as net_financing_cash_flow, -- 筹资活动产生的现金流量净额 + + -- 汇率变动影响 + F037N as exchange_rate_effect, -- 汇率变动对现金及现金等价物的影响 + F038N as other_cash_effect, -- 其他原因对现金的影响 + + -- 现金净增加额 + F039N as net_cash_increase, -- 现金及现金等价物净增加额 + F040N as beginning_cash_balance, -- 期初现金及现金等价物余额 + F041N as ending_cash_balance, -- 期末现金及现金等价物余额 + + -- 补充资料部分 + F044N as net_profit, -- 净利润 + F045N as asset_impairment, -- 资产减值准备 + F096N as credit_impairment, -- 信用减值损失 + F046N as depreciation, -- 固定资产折旧、油气资产折耗、生产性生物资产折旧 + F097N as right_of_use_asset_depreciation, -- 使用权资产折旧/摊销 + F047N as intangible_amortization, -- 无形资产摊销 + F048N as long_term_expense_amortization, -- 长期待摊费用摊销 + F049N as loss_on_disposal, -- 处置固定资产、无形资产和其他长期资产的损失 + F050N as fixed_asset_scrap_loss, -- 固定资产报废损失 + F051N as fair_value_change_loss, -- 公允价值变动损失 + F052N as financial_expenses, -- 财务费用 + F053N as investment_loss, -- 投资损失 + F054N as deferred_tax_asset_decrease, -- 递延所得税资产减少 + F055N as deferred_tax_liability_increase, -- 递延所得税负债增加 + F056N as inventory_decrease, -- 存货的减少 + F057N as operating_receivables_decrease, -- 经营性应收项目的减少 + F058N as operating_payables_increase, -- 经营性应付项目的增加 + F059N as other, -- 其他 + F060N as net_operating_cash_flow_indirect, -- 经营活动产生的现金流量净额(间接法) + + -- 特殊行业科目(金融) + F072N as customer_deposit_increase, -- 客户存款和同业存放款项净增加额 + F073N as central_bank_borrowing_increase, -- 向中央银行借款净增加额 + F081N as interest_and_commission_received, -- 收取利息、手续费及佣金的现金 + F087N as interest_and_commission_paid -- 支付利息、手续费及佣金的现金 + FROM ea_cashflow + WHERE SECCODE = :seccode + and F002V = '071001' + ORDER BY ENDDATE DESC LIMIT :limit + """) + + result = engine.execute(query, seccode=seccode, limit=limit) + data = [] + + for row in result: + # 计算一些衍生指标 + free_cash_flow = None + if row.net_operating_cash_flow and row.cash_paid_for_assets: + free_cash_flow = row.net_operating_cash_flow - row.cash_paid_for_assets + + period_data = { + 'period': format_date(row.ENDDATE), + 'start_date': format_date(row.STARTDATE), + 'declare_date': format_date(row.DECLAREDATE), + 'report_type': get_report_type(row.ENDDATE), + + # 经营活动现金流 + 'operating_activities': { + 'inflow': { + 'cash_from_sales': format_decimal(row.cash_from_sales), + 'tax_refunds': format_decimal(row.tax_refunds), + 'other': format_decimal(row.other_operating_cash_received), + 'total': format_decimal(row.total_operating_cash_inflow), + }, + 'outflow': { + 'cash_for_goods': format_decimal(row.cash_paid_for_goods), + 'cash_for_employees': format_decimal(row.cash_paid_to_employees), + 'taxes_paid': format_decimal(row.taxes_paid), + 'other': format_decimal(row.other_operating_cash_paid), + 'total': format_decimal(row.total_operating_cash_outflow), + }, + 'net_flow': format_decimal(row.net_operating_cash_flow), + }, + + # 投资活动现金流 + 'investment_activities': { + 'inflow': { + 'investment_recovery': format_decimal(row.cash_from_investment_recovery), + 'investment_income': format_decimal(row.cash_from_investment_income), + 'asset_disposal': format_decimal(row.cash_from_asset_disposal), + 'subsidiary_disposal': format_decimal(row.cash_from_subsidiary_disposal), + 'other': format_decimal(row.other_investment_cash_received), + 'total': format_decimal(row.total_investment_cash_inflow), + }, + 'outflow': { + 'asset_purchase': format_decimal(row.cash_paid_for_assets), + 'investments': format_decimal(row.cash_paid_for_investments), + 'subsidiaries': format_decimal(row.cash_paid_for_subsidiaries), + 'other': format_decimal(row.other_investment_cash_paid), + 'total': format_decimal(row.total_investment_cash_outflow), + }, + 'net_flow': format_decimal(row.net_investment_cash_flow), + }, + + # 筹资活动现金流 + 'financing_activities': { + 'inflow': { + 'capital': format_decimal(row.cash_from_capital), + 'borrowings': format_decimal(row.cash_from_borrowings), + 'other': format_decimal(row.other_financing_cash_received), + 'total': format_decimal(row.total_financing_cash_inflow), + }, + 'outflow': { + 'debt_repayment': format_decimal(row.cash_paid_for_debt), + 'distribution': format_decimal(row.cash_paid_for_distribution), + 'other': format_decimal(row.other_financing_cash_paid), + 'total': format_decimal(row.total_financing_cash_outflow), + }, + 'net_flow': format_decimal(row.net_financing_cash_flow), + }, + + # 现金变动 + 'cash_changes': { + 'exchange_rate_effect': format_decimal(row.exchange_rate_effect), + 'other_effect': format_decimal(row.other_cash_effect), + 'net_increase': format_decimal(row.net_cash_increase), + 'beginning_balance': format_decimal(row.beginning_cash_balance), + 'ending_balance': format_decimal(row.ending_cash_balance), + }, + + # 补充资料(间接法) + 'indirect_method': { + 'net_profit': format_decimal(row.net_profit), + 'adjustments': { + 'asset_impairment': format_decimal(row.asset_impairment), + 'credit_impairment': format_decimal(row.credit_impairment), + 'depreciation': format_decimal(row.depreciation), + 'intangible_amortization': format_decimal(row.intangible_amortization), + 'financial_expenses': format_decimal(row.financial_expenses), + 'investment_loss': format_decimal(row.investment_loss), + 'inventory_decrease': format_decimal(row.inventory_decrease), + 'receivables_decrease': format_decimal(row.operating_receivables_decrease), + 'payables_increase': format_decimal(row.operating_payables_increase), + }, + 'net_operating_cash_flow': format_decimal(row.net_operating_cash_flow_indirect), + }, + + # 关键指标 + 'key_metrics': { + 'free_cash_flow': format_decimal(free_cash_flow), + 'cash_flow_to_profit_ratio': format_decimal( + row.net_operating_cash_flow / row.net_profit) if row.net_profit and row.net_operating_cash_flow else None, + 'capex': format_decimal(row.cash_paid_for_assets), + } + } + data.append(period_data) + + return jsonify({ + 'success': True, + 'data': data + }) + + except Exception as e: + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + + +@app.route('/api/financial/financial-metrics/', methods=['GET']) +def get_financial_metrics(seccode): + """获取完整的财务指标数据""" + try: + limit = request.args.get('limit', 12, type=int) + + query = text(""" + SELECT distinct ENDDATE, + STARTDATE, + -- 每股指标 + F003N as eps, -- 每股收益 + F004N as basic_eps, -- 基本每股收益 + F005N as diluted_eps, -- 稀释每股收益 + F006N as deducted_eps, -- 扣除非经常性损益每股收益 + F007N as undistributed_profit_ps, -- 每股未分配利润 + F008N as bvps, -- 每股净资产 + F009N as adjusted_bvps, -- 调整后每股净资产 + F010N as capital_reserve_ps, -- 每股资本公积金 + F059N as cash_flow_ps, -- 每股现金流量 + F060N as operating_cash_flow_ps, -- 每股经营现金流量 + + -- 盈利能力指标 + F011N as operating_profit_margin, -- 营业利润率 + F012N as tax_rate, -- 营业税金率 + F013N as cost_ratio, -- 营业成本率 + F014N as roe, -- 净资产收益率 + F066N as roe_deducted, -- 净资产收益率(扣除非经常性损益) + F067N as roe_weighted, -- 净资产收益率-加权 + F068N as roe_weighted_deducted, -- 净资产收益率-加权(扣除非经常性损益) + F015N as investment_return, -- 投资收益率 + F016N as roa, -- 总资产报酬率 + F017N as net_profit_margin, -- 净利润率 + F078N as gross_margin, -- 毛利率 + F020N as cost_profit_ratio, -- 成本费用利润率 + + -- 费用率指标 + F018N as admin_expense_ratio, -- 管理费用率 + F019N as financial_expense_ratio, -- 财务费用率 + F021N as three_expense_ratio, -- 三费比重 + F091N as selling_expense, -- 销售费用 + F092N as admin_expense, -- 管理费用 + F093N as financial_expense, -- 财务费用 + F094N as three_expense_total, -- 三费合计 + F130N as rd_expense, -- 研发费用 + F131N as rd_expense_ratio, -- 研发费用率 + F132N as selling_expense_ratio, -- 销售费用率 + F133N as four_expense_ratio, -- 四费费用率 + + -- 运营能力指标 + F022N as receivable_turnover, -- 应收账款周转率 + F023N as inventory_turnover, -- 存货周转率 + F024N as working_capital_turnover, -- 运营资金周转率 + F025N as total_asset_turnover, -- 总资产周转率 + F026N as fixed_asset_turnover, -- 固定资产周转率 + F027N as receivable_days, -- 应收账款周转天数 + F028N as inventory_days, -- 存货周转天数 + F029N as current_asset_turnover, -- 流动资产周转率 + F030N as current_asset_days, -- 流动资产周转天数 + F031N as total_asset_days, -- 总资产周转天数 + F032N as equity_turnover, -- 股东权益周转率 + + -- 偿债能力指标 + F041N as asset_liability_ratio, -- 资产负债率 + F042N as current_ratio, -- 流动比率 + F043N as quick_ratio, -- 速动比率 + F044N as cash_ratio, -- 现金比率 + F045N as interest_coverage, -- 利息保障倍数 + F049N as conservative_quick_ratio, -- 保守速动比率 + F050N as cash_to_maturity_debt_ratio, -- 现金到期债务比率 + F051N as tangible_asset_debt_ratio, -- 有形资产净值债务率 + + -- 成长能力指标 + F052N as revenue_growth, -- 营业收入增长率 + F053N as net_profit_growth, -- 净利润增长率 + F054N as equity_growth, -- 净资产增长率 + F055N as fixed_asset_growth, -- 固定资产增长率 + F056N as total_asset_growth, -- 总资产增长率 + F057N as investment_income_growth, -- 投资收益增长率 + F058N as operating_profit_growth, -- 营业利润增长率 + F141N as deducted_profit_growth, -- 扣除非经常性损益后的净利润同比变化率 + F142N as parent_profit_growth, -- 归属于母公司所有者的净利润同比变化率 + F143N as operating_cash_flow_growth, -- 经营活动产生的现金流净额同比变化率 + + -- 现金流量指标 + F061N as operating_cash_to_short_debt, -- 经营净现金比率(短期债务) + F062N as operating_cash_to_total_debt, -- 经营净现金比率(全部债务) + F063N as operating_cash_to_profit_ratio, -- 经营活动现金净流量与净利润比率 + F064N as cash_revenue_ratio, -- 营业收入现金含量 + F065N as cash_recovery_rate, -- 全部资产现金回收率 + F082N as cash_to_profit_ratio, -- 净利含金量 + + -- 财务结构指标 + F033N as current_asset_ratio, -- 流动资产比率 + F034N as cash_ratio_structure, -- 货币资金比率 + F036N as inventory_ratio, -- 存货比率 + F037N as fixed_asset_ratio, -- 固定资产比率 + F038N as liability_structure_ratio, -- 负债结构比 + F039N as equity_ratio, -- 产权比率 + F040N as net_asset_ratio, -- 净资产比率 + F046N as working_capital, -- 营运资金 + F047N as non_current_liability_ratio, -- 非流动负债比率 + F048N as current_liability_ratio, -- 流动负债比率 + + -- 非经常性损益 + F076N as deducted_net_profit, -- 扣除非经常性损益后的净利润 + F077N as non_recurring_items, -- 非经常性损益合计 + F083N as non_recurring_ratio, -- 非经常性损益占比 + + -- 综合指标 + F085N as ebit, -- 基本获利能力(EBIT) + F086N as receivable_to_asset_ratio, -- 应收账款占比 + F087N as inventory_to_asset_ratio -- 存货占比 + FROM ea_financialindex + WHERE SECCODE = :seccode + ORDER BY ENDDATE DESC LIMIT :limit + """) + + result = engine.execute(query, seccode=seccode, limit=limit) + data = [] + + for row in result: + period_data = { + 'period': format_date(row.ENDDATE), + 'start_date': format_date(row.STARTDATE), + 'report_type': get_report_type(row.ENDDATE), + + # 每股指标 + 'per_share_metrics': { + 'eps': format_decimal(row.eps), + 'basic_eps': format_decimal(row.basic_eps), + 'diluted_eps': format_decimal(row.diluted_eps), + 'deducted_eps': format_decimal(row.deducted_eps), + 'bvps': format_decimal(row.bvps), + 'adjusted_bvps': format_decimal(row.adjusted_bvps), + 'undistributed_profit_ps': format_decimal(row.undistributed_profit_ps), + 'capital_reserve_ps': format_decimal(row.capital_reserve_ps), + 'cash_flow_ps': format_decimal(row.cash_flow_ps), + 'operating_cash_flow_ps': format_decimal(row.operating_cash_flow_ps), + }, + + # 盈利能力 + 'profitability': { + 'roe': format_decimal(row.roe), + 'roe_deducted': format_decimal(row.roe_deducted), + 'roe_weighted': format_decimal(row.roe_weighted), + 'roa': format_decimal(row.roa), + 'gross_margin': format_decimal(row.gross_margin), + 'net_profit_margin': format_decimal(row.net_profit_margin), + 'operating_profit_margin': format_decimal(row.operating_profit_margin), + 'cost_profit_ratio': format_decimal(row.cost_profit_ratio), + 'ebit': format_decimal(row.ebit), + }, + + # 费用率 + 'expense_ratios': { + 'selling_expense_ratio': format_decimal(row.selling_expense_ratio), + 'admin_expense_ratio': format_decimal(row.admin_expense_ratio), + 'financial_expense_ratio': format_decimal(row.financial_expense_ratio), + 'rd_expense_ratio': format_decimal(row.rd_expense_ratio), + 'three_expense_ratio': format_decimal(row.three_expense_ratio), + 'four_expense_ratio': format_decimal(row.four_expense_ratio), + }, + + # 运营能力 + 'operational_efficiency': { + 'receivable_turnover': format_decimal(row.receivable_turnover), + 'receivable_days': format_decimal(row.receivable_days), + 'inventory_turnover': format_decimal(row.inventory_turnover), + 'inventory_days': format_decimal(row.inventory_days), + 'total_asset_turnover': format_decimal(row.total_asset_turnover), + 'total_asset_days': format_decimal(row.total_asset_days), + 'fixed_asset_turnover': format_decimal(row.fixed_asset_turnover), + 'current_asset_turnover': format_decimal(row.current_asset_turnover), + 'working_capital_turnover': format_decimal(row.working_capital_turnover), + }, + + # 偿债能力 + 'solvency': { + 'current_ratio': format_decimal(row.current_ratio), + 'quick_ratio': format_decimal(row.quick_ratio), + 'cash_ratio': format_decimal(row.cash_ratio), + 'conservative_quick_ratio': format_decimal(row.conservative_quick_ratio), + 'asset_liability_ratio': format_decimal(row.asset_liability_ratio), + 'interest_coverage': format_decimal(row.interest_coverage), + 'cash_to_maturity_debt_ratio': format_decimal(row.cash_to_maturity_debt_ratio), + 'tangible_asset_debt_ratio': format_decimal(row.tangible_asset_debt_ratio), + }, + + # 成长能力 + 'growth': { + 'revenue_growth': format_decimal(row.revenue_growth), + 'net_profit_growth': format_decimal(row.net_profit_growth), + 'deducted_profit_growth': format_decimal(row.deducted_profit_growth), + 'parent_profit_growth': format_decimal(row.parent_profit_growth), + 'equity_growth': format_decimal(row.equity_growth), + 'total_asset_growth': format_decimal(row.total_asset_growth), + 'fixed_asset_growth': format_decimal(row.fixed_asset_growth), + 'operating_profit_growth': format_decimal(row.operating_profit_growth), + 'operating_cash_flow_growth': format_decimal(row.operating_cash_flow_growth), + }, + + # 现金流量 + 'cash_flow_quality': { + 'operating_cash_to_profit_ratio': format_decimal(row.operating_cash_to_profit_ratio), + 'cash_to_profit_ratio': format_decimal(row.cash_to_profit_ratio), + 'cash_revenue_ratio': format_decimal(row.cash_revenue_ratio), + 'cash_recovery_rate': format_decimal(row.cash_recovery_rate), + 'operating_cash_to_short_debt': format_decimal(row.operating_cash_to_short_debt), + 'operating_cash_to_total_debt': format_decimal(row.operating_cash_to_total_debt), + }, + + # 财务结构 + 'financial_structure': { + 'current_asset_ratio': format_decimal(row.current_asset_ratio), + 'fixed_asset_ratio': format_decimal(row.fixed_asset_ratio), + 'inventory_ratio': format_decimal(row.inventory_ratio), + 'receivable_to_asset_ratio': format_decimal(row.receivable_to_asset_ratio), + 'current_liability_ratio': format_decimal(row.current_liability_ratio), + 'non_current_liability_ratio': format_decimal(row.non_current_liability_ratio), + 'equity_ratio': format_decimal(row.equity_ratio), + }, + + # 非经常性损益 + 'non_recurring': { + 'deducted_net_profit': format_decimal(row.deducted_net_profit), + 'non_recurring_items': format_decimal(row.non_recurring_items), + 'non_recurring_ratio': format_decimal(row.non_recurring_ratio), + } + } + data.append(period_data) + + return jsonify({ + 'success': True, + 'data': data + }) + + except Exception as e: + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + + +@app.route('/api/financial/main-business/', methods=['GET']) +def get_main_business(seccode): + """获取主营业务构成数据(包括产品和行业分类)""" + try: + limit = request.args.get('periods', 4, type=int) # 获取最近几期的数据 + + # 获取最近的报告期 + period_query = text(""" + SELECT DISTINCT ENDDATE + FROM ea_mainproduct + WHERE SECCODE = :seccode + ORDER BY ENDDATE DESC LIMIT :limit + """) + + periods = engine.execute(period_query, seccode=seccode, limit=limit).fetchall() + + # 产品分类数据 + product_data = [] + for period in periods: + query = text(""" + SELECT distinct ENDDATE, + F002V as category, + F003V as content, + F005N as revenue, + F006N as cost, + F007N as profit + FROM ea_mainproduct + WHERE SECCODE = :seccode + AND ENDDATE = :enddate + ORDER BY F005N DESC + """) + + result = engine.execute(query, seccode=seccode, enddate=period[0]) + # Convert result to list to allow multiple iterations + rows = list(result) + + period_products = [] + total_revenue = 0 + for row in rows: + if row.revenue: + total_revenue += row.revenue + + for row in rows: + product = { + 'category': row.category, + 'content': row.content, + 'revenue': format_decimal(row.revenue), + 'cost': format_decimal(row.cost), + 'profit': format_decimal(row.profit), + 'profit_margin': format_decimal( + (row.profit / row.revenue * 100) if row.revenue and row.profit else None), + 'revenue_ratio': format_decimal( + (row.revenue / total_revenue * 100) if total_revenue and row.revenue else None) + } + period_products.append(product) + + if period_products: + product_data.append({ + 'period': format_date(period[0]), + 'report_type': get_report_type(period[0]), + 'total_revenue': format_decimal(total_revenue), + 'products': period_products + }) + + # 行业分类数据(从ea_mainind表) + industry_data = [] + for period in periods: + query = text(""" + SELECT distinct ENDDATE, + F002V as business_content, + F007N as main_revenue, + F008N as main_cost, + F009N as main_profit, + F010N as gross_margin, + F012N as revenue_ratio + FROM ea_mainind + WHERE SECCODE = :seccode + AND ENDDATE = :enddate + ORDER BY F007N DESC + """) + + result = engine.execute(query, seccode=seccode, enddate=period[0]) + # Convert result to list to allow multiple iterations + rows = list(result) + + period_industries = [] + for row in rows: + industry = { + 'content': row.business_content, + 'revenue': format_decimal(row.main_revenue), + 'cost': format_decimal(row.main_cost), + 'profit': format_decimal(row.main_profit), + 'gross_margin': format_decimal(row.gross_margin), + 'revenue_ratio': format_decimal(row.revenue_ratio) + } + period_industries.append(industry) + + if period_industries: + industry_data.append({ + 'period': format_date(period[0]), + 'report_type': get_report_type(period[0]), + 'industries': period_industries + }) + + return jsonify({ + 'success': True, + 'data': { + 'product_classification': product_data, + 'industry_classification': industry_data + } + }) + + except Exception as e: + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + + +@app.route('/api/financial/forecast/', methods=['GET']) +def get_forecast(seccode): + """获取业绩预告和预披露时间""" + try: + # 获取业绩预告 + forecast_query = text(""" + SELECT distinct DECLAREDATE, + F001D as report_date, + F002V as forecast_type_code, + F003V as forecast_type, + F004V as content, + F005V as reason, + F006C as latest_flag, + F007N as profit_lower, + F008N as profit_upper, + F009N as change_lower, + F010N as change_upper, + UPDATE_DATE + FROM ea_forecast + WHERE SECCODE = :seccode + ORDER BY F001D DESC, UPDATE_DATE DESC LIMIT 10 + """) + + forecast_result = engine.execute(forecast_query, seccode=seccode) + forecast_data = [] + + for row in forecast_result: + forecast = { + 'declare_date': format_date(row.DECLAREDATE), + 'report_date': format_date(row.report_date), + 'report_type': get_report_type(row.report_date), + 'forecast_type': row.forecast_type, + 'forecast_type_code': row.forecast_type_code, + 'content': row.content, + 'reason': row.reason, + 'is_latest': row.latest_flag == 'T', + 'profit_range': { + 'lower': format_decimal(row.profit_lower), + 'upper': format_decimal(row.profit_upper), + }, + 'change_range': { + 'lower': format_decimal(row.change_lower), + 'upper': format_decimal(row.change_upper), + }, + 'update_date': format_date(row.UPDATE_DATE) + } + forecast_data.append(forecast) + + # 获取预披露时间 + pretime_query = text(""" + SELECT distinct F001D as report_period, + F002D as scheduled_date, + F003D as change_date_1, + F004D as change_date_2, + F005D as change_date_3, + F006D as actual_date, + F007D as change_date_4, + F008D as change_date_5, + UPDATE_DATE + FROM ea_pretime + WHERE SECCODE = :seccode + ORDER BY F001D DESC LIMIT 8 + """) + + pretime_result = engine.execute(pretime_query, seccode=seccode) + pretime_data = [] + + for row in pretime_result: + # 收集所有变更日期 + change_dates = [] + for date in [row.change_date_1, row.change_date_2, row.change_date_3, + row.change_date_4, row.change_date_5]: + if date: + change_dates.append(format_date(date)) + + pretime = { + 'report_period': format_date(row.report_period), + 'report_type': get_report_type(row.report_period), + 'scheduled_date': format_date(row.scheduled_date), + 'actual_date': format_date(row.actual_date), + 'change_dates': change_dates, + 'update_date': format_date(row.UPDATE_DATE), + 'status': 'completed' if row.actual_date else 'pending' + } + pretime_data.append(pretime) + + return jsonify({ + 'success': True, + 'data': { + 'forecasts': forecast_data, + 'disclosure_schedule': pretime_data + } + }) + + except Exception as e: + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + + +@app.route('/api/financial/industry-rank/', methods=['GET']) +def get_industry_rank(seccode): + """获取行业排名数据""" + try: + limit = request.args.get('limit', 4, type=int) + + query = text(""" + SELECT distinct F001V as industry_level, + F002V as level_description, + F003D as report_date, + INDNAME as industry_name, + -- 每股收益 + F004N as eps, + F005N as eps_industry_avg, + F006N as eps_rank, + -- 扣除后每股收益 + F007N as deducted_eps, + F008N as deducted_eps_industry_avg, + F009N as deducted_eps_rank, + -- 每股净资产 + F010N as bvps, + F011N as bvps_industry_avg, + F012N as bvps_rank, + -- 净资产收益率 + F013N as roe, + F014N as roe_industry_avg, + F015N as roe_rank, + -- 每股未分配利润 + F016N as undistributed_profit_ps, + F017N as undistributed_profit_ps_industry_avg, + F018N as undistributed_profit_ps_rank, + -- 每股经营现金流量 + F019N as operating_cash_flow_ps, + F020N as operating_cash_flow_ps_industry_avg, + F021N as operating_cash_flow_ps_rank, + -- 营业收入增长率 + F022N as revenue_growth, + F023N as revenue_growth_industry_avg, + F024N as revenue_growth_rank, + -- 净利润增长率 + F025N as profit_growth, + F026N as profit_growth_industry_avg, + F027N as profit_growth_rank, + -- 营业利润率 + F028N as operating_margin, + F029N as operating_margin_industry_avg, + F030N as operating_margin_rank, + -- 资产负债率 + F031N as debt_ratio, + F032N as debt_ratio_industry_avg, + F033N as debt_ratio_rank, + -- 应收账款周转率 + F034N as receivable_turnover, + F035N as receivable_turnover_industry_avg, + F036N as receivable_turnover_rank, + UPDATE_DATE + FROM ea_finindexrank + WHERE SECCODE = :seccode + ORDER BY F003D DESC, F001V ASC LIMIT :limit_total + """) + + # 获取多个报告期的数据 + result = engine.execute(query, seccode=seccode, limit_total=limit * 4) + + # 按报告期和行业级别组织数据 + data_by_period = {} + for row in result: + period = format_date(row.report_date) + if period not in data_by_period: + data_by_period[period] = [] + + rank_data = { + 'industry_level': row.industry_level, + 'level_description': row.level_description, + 'industry_name': row.industry_name, + 'metrics': { + 'eps': { + 'value': format_decimal(row.eps), + 'industry_avg': format_decimal(row.eps_industry_avg), + 'rank': int(row.eps_rank) if row.eps_rank else None + }, + 'deducted_eps': { + 'value': format_decimal(row.deducted_eps), + 'industry_avg': format_decimal(row.deducted_eps_industry_avg), + 'rank': int(row.deducted_eps_rank) if row.deducted_eps_rank else None + }, + 'bvps': { + 'value': format_decimal(row.bvps), + 'industry_avg': format_decimal(row.bvps_industry_avg), + 'rank': int(row.bvps_rank) if row.bvps_rank else None + }, + 'roe': { + 'value': format_decimal(row.roe), + 'industry_avg': format_decimal(row.roe_industry_avg), + 'rank': int(row.roe_rank) if row.roe_rank else None + }, + 'operating_cash_flow_ps': { + 'value': format_decimal(row.operating_cash_flow_ps), + 'industry_avg': format_decimal(row.operating_cash_flow_ps_industry_avg), + 'rank': int(row.operating_cash_flow_ps_rank) if row.operating_cash_flow_ps_rank else None + }, + 'revenue_growth': { + 'value': format_decimal(row.revenue_growth), + 'industry_avg': format_decimal(row.revenue_growth_industry_avg), + 'rank': int(row.revenue_growth_rank) if row.revenue_growth_rank else None + }, + 'profit_growth': { + 'value': format_decimal(row.profit_growth), + 'industry_avg': format_decimal(row.profit_growth_industry_avg), + 'rank': int(row.profit_growth_rank) if row.profit_growth_rank else None + }, + 'operating_margin': { + 'value': format_decimal(row.operating_margin), + 'industry_avg': format_decimal(row.operating_margin_industry_avg), + 'rank': int(row.operating_margin_rank) if row.operating_margin_rank else None + }, + 'debt_ratio': { + 'value': format_decimal(row.debt_ratio), + 'industry_avg': format_decimal(row.debt_ratio_industry_avg), + 'rank': int(row.debt_ratio_rank) if row.debt_ratio_rank else None + }, + 'receivable_turnover': { + 'value': format_decimal(row.receivable_turnover), + 'industry_avg': format_decimal(row.receivable_turnover_industry_avg), + 'rank': int(row.receivable_turnover_rank) if row.receivable_turnover_rank else None + } + } + } + data_by_period[period].append(rank_data) + + # 转换为列表格式 + data = [] + for period, ranks in data_by_period.items(): + data.append({ + 'period': period, + 'report_type': get_report_type(period), + 'rankings': ranks + }) + + return jsonify({ + 'success': True, + 'data': data + }) + + except Exception as e: + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + + +@app.route('/api/financial/comparison/', methods=['GET']) +def get_period_comparison(seccode): + """获取不同报告期的对比数据""" + try: + periods = request.args.get('periods', 8, type=int) + + # 获取多期财务数据进行对比 + query = text(""" + SELECT distinct fi.ENDDATE, + fi.F089N as revenue, + fi.F101N as net_profit, + fi.F102N as parent_net_profit, + fi.F078N as gross_margin, + fi.F017N as net_margin, + fi.F014N as roe, + fi.F016N as roa, + fi.F052N as revenue_growth, + fi.F053N as profit_growth, + fi.F003N as eps, + fi.F060N as operating_cash_flow_ps, + fi.F042N as current_ratio, + fi.F041N as debt_ratio, + fi.F105N as operating_cash_flow, + fi.F118N as total_assets, + fi.F121N as total_liabilities, + fi.F128N as total_equity + FROM ea_financialindex fi + WHERE fi.SECCODE = :seccode + ORDER BY fi.ENDDATE DESC LIMIT :periods + """) + + result = engine.execute(query, seccode=seccode, periods=periods) + + data = [] + for row in result: + period_data = { + 'period': format_date(row.ENDDATE), + 'report_type': get_report_type(row.ENDDATE), + 'performance': { + 'revenue': format_decimal(row.revenue), + 'net_profit': format_decimal(row.net_profit), + 'parent_net_profit': format_decimal(row.parent_net_profit), + 'operating_cash_flow': format_decimal(row.operating_cash_flow), + }, + 'profitability': { + 'gross_margin': format_decimal(row.gross_margin), + 'net_margin': format_decimal(row.net_margin), + 'roe': format_decimal(row.roe), + 'roa': format_decimal(row.roa), + }, + 'growth': { + 'revenue_growth': format_decimal(row.revenue_growth), + 'profit_growth': format_decimal(row.profit_growth), + }, + 'per_share': { + 'eps': format_decimal(row.eps), + 'operating_cash_flow_ps': format_decimal(row.operating_cash_flow_ps), + }, + 'financial_health': { + 'current_ratio': format_decimal(row.current_ratio), + 'debt_ratio': format_decimal(row.debt_ratio), + 'total_assets': format_decimal(row.total_assets), + 'total_liabilities': format_decimal(row.total_liabilities), + 'total_equity': format_decimal(row.total_equity), + } + } + data.append(period_data) + + # 计算同比和环比变化 + for i in range(len(data)): + if i > 0: # 环比 + data[i]['qoq_changes'] = { + 'revenue': calculate_change(data[i]['performance']['revenue'], + data[i - 1]['performance']['revenue']), + 'net_profit': calculate_change(data[i]['performance']['net_profit'], + data[i - 1]['performance']['net_profit']), + } + + # 同比(找到去年同期) + current_period = data[i]['period'] + yoy_period = get_yoy_period(current_period) + for j in range(len(data)): + if data[j]['period'] == yoy_period: + data[i]['yoy_changes'] = { + 'revenue': calculate_change(data[i]['performance']['revenue'], + data[j]['performance']['revenue']), + 'net_profit': calculate_change(data[i]['performance']['net_profit'], + data[j]['performance']['net_profit']), + } + break + + return jsonify({ + 'success': True, + 'data': data + }) + + except Exception as e: + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + + +# 辅助函数 +def calculate_change(current, previous): + """计算变化率""" + if previous and current: + return format_decimal((current - previous) / abs(previous) * 100) + return None + + +def get_yoy_period(date_str): + """获取去年同期""" + if not date_str: + return None + try: + date = datetime.strptime(date_str, '%Y-%m-%d') + yoy_date = date.replace(year=date.year - 1) + return yoy_date.strftime('%Y-%m-%d') + except: + return None + + +@app.route('/api/market/trade/', methods=['GET']) +def get_trade_data(seccode): + """获取股票交易数据(日K线)""" + try: + days = request.args.get('days', 60, type=int) + end_date = request.args.get('end_date', datetime.now().strftime('%Y-%m-%d')) + + query = text(""" + SELECT TRADEDATE, + SECNAME, + F002N as pre_close, + F003N as open, + F004N as volume, + F005N as high, + F006N as low, + F007N as close, + F008N as trades_count, + F009N as change_amount, + F010N as change_percent, + F011N as amount, + F012N as turnover_rate, + F013N as amplitude, + F020N as total_shares, + F021N as float_shares, + F026N as pe_ratio + FROM ea_trade + WHERE SECCODE = :seccode + AND TRADEDATE <= :end_date + ORDER BY TRADEDATE DESC + LIMIT :days + """) + + result = engine.execute(query, seccode=seccode, end_date=end_date, days=days) + + data = [] + for row in result: + data.append({ + 'date': format_date(row.TRADEDATE), + 'stock_name': row.SECNAME, + 'open': format_decimal(row.open), + 'high': format_decimal(row.high), + 'low': format_decimal(row.low), + 'close': format_decimal(row.close), + 'pre_close': format_decimal(row.pre_close), + 'volume': format_decimal(row.volume), + 'amount': format_decimal(row.amount), + 'change_amount': format_decimal(row.change_amount), + 'change_percent': format_decimal(row.change_percent), + 'turnover_rate': format_decimal(row.turnover_rate), + 'amplitude': format_decimal(row.amplitude), + 'trades_count': format_decimal(row.trades_count), + 'pe_ratio': format_decimal(row.pe_ratio), + 'total_shares': format_decimal(row.total_shares), + 'float_shares': format_decimal(row.float_shares), + }) + + # 倒序,让最早的日期在前 + data.reverse() + + # 计算统计数据 + if data: + prices = [d['close'] for d in data if d['close']] + stats = { + 'highest': max(prices) if prices else None, + 'lowest': min(prices) if prices else None, + 'average': sum(prices) / len(prices) if prices else None, + 'latest_price': data[-1]['close'] if data else None, + 'total_volume': sum([d['volume'] for d in data if d['volume']]) if data else None, + 'total_amount': sum([d['amount'] for d in data if d['amount']]) if data else None, + } + else: + stats = {} + + return jsonify({ + 'success': True, + 'data': data, + 'stats': stats + }) + + except Exception as e: + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + + +@app.route('/api/market/funding/', methods=['GET']) +def get_funding_data(seccode): + """获取融资融券数据""" + try: + days = request.args.get('days', 30, type=int) + + query = text(""" + SELECT TRADEDATE, + SECNAME, + F001N as financing_balance, + F002N as financing_buy, + F003N as financing_repay, + F004N as securities_balance, + F006N as securities_sell, + F007N as securities_repay, + F008N as securities_balance_amount, + F009N as total_balance + FROM ea_funding + WHERE SECCODE = :seccode + ORDER BY TRADEDATE DESC LIMIT :days + """) + + result = engine.execute(query, seccode=seccode, days=days) + + data = [] + for row in result: + data.append({ + 'date': format_date(row.TRADEDATE), + 'stock_name': row.SECNAME, + 'financing': { + 'balance': format_decimal(row.financing_balance), + 'buy': format_decimal(row.financing_buy), + 'repay': format_decimal(row.financing_repay), + 'net': format_decimal( + row.financing_buy - row.financing_repay) if row.financing_buy and row.financing_repay else None + }, + 'securities': { + 'balance': format_decimal(row.securities_balance), + 'sell': format_decimal(row.securities_sell), + 'repay': format_decimal(row.securities_repay), + 'balance_amount': format_decimal(row.securities_balance_amount) + }, + 'total_balance': format_decimal(row.total_balance) + }) + + data.reverse() + + return jsonify({ + 'success': True, + 'data': data + }) + + except Exception as e: + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + + +@app.route('/api/market/bigdeal/', methods=['GET']) +def get_bigdeal_data(seccode): + """获取大宗交易数据""" + try: + days = request.args.get('days', 30, type=int) + + query = text(""" + SELECT TRADEDATE, + SECNAME, + F001V as exchange, + F002V as buyer_dept, + F003V as seller_dept, + F004N as price, + F005N as volume, + F006N as amount, + F007N as seq_no + FROM ea_bigdeal + WHERE SECCODE = :seccode + ORDER BY TRADEDATE DESC, F007N LIMIT :days + """) + + result = engine.execute(query, seccode=seccode, days=days) + + data = [] + for row in result: + data.append({ + 'date': format_date(row.TRADEDATE), + 'stock_name': row.SECNAME, + 'exchange': row.exchange, + 'buyer_dept': row.buyer_dept, + 'seller_dept': row.seller_dept, + 'price': format_decimal(row.price), + 'volume': format_decimal(row.volume), + 'amount': format_decimal(row.amount), + 'seq_no': int(row.seq_no) if row.seq_no else None + }) + + # 按日期分组统计 + daily_stats = {} + for item in data: + date = item['date'] + if date not in daily_stats: + daily_stats[date] = { + 'date': date, + 'count': 0, + 'total_volume': 0, + 'total_amount': 0, + 'avg_price': 0, + 'deals': [] + } + daily_stats[date]['count'] += 1 + daily_stats[date]['total_volume'] += item['volume'] or 0 + daily_stats[date]['total_amount'] += item['amount'] or 0 + daily_stats[date]['deals'].append(item) + + # 计算平均价格 + for date in daily_stats: + if daily_stats[date]['total_volume'] > 0: + daily_stats[date]['avg_price'] = daily_stats[date]['total_amount'] / daily_stats[date]['total_volume'] + + return jsonify({ + 'success': True, + 'data': data, + 'daily_stats': list(daily_stats.values()) + }) + + except Exception as e: + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + + +@app.route('/api/market/unusual/', methods=['GET']) +def get_unusual_data(seccode): + """获取龙虎榜数据""" + try: + days = request.args.get('days', 30, type=int) + + query = text(""" + SELECT TRADEDATE, + SECNAME, + F001V as info_type_code, + F002V as info_type, + F003C as trade_type, + F004N as rank_no, + F005V as dept_name, + F006N as buy_amount, + F007N as sell_amount, + F008N as net_amount + FROM ea_unusual + WHERE SECCODE = :seccode + ORDER BY TRADEDATE DESC, F004N LIMIT 100 + """) + + result = engine.execute(query, seccode=seccode) + + data = [] + for row in result: + data.append({ + 'date': format_date(row.TRADEDATE), + 'stock_name': row.SECNAME, + 'info_type': row.info_type, + 'info_type_code': row.info_type_code, + 'trade_type': 'buy' if row.trade_type == 'B' else 'sell' if row.trade_type == 'S' else 'unknown', + 'rank': int(row.rank_no) if row.rank_no else None, + 'dept_name': row.dept_name, + 'buy_amount': format_decimal(row.buy_amount), + 'sell_amount': format_decimal(row.sell_amount), + 'net_amount': format_decimal(row.net_amount) + }) + + # 按日期分组 + grouped_data = {} + for item in data: + date = item['date'] + if date not in grouped_data: + grouped_data[date] = { + 'date': date, + 'info_types': set(), + 'buyers': [], + 'sellers': [], + 'total_buy': 0, + 'total_sell': 0, + 'net_amount': 0 + } + + grouped_data[date]['info_types'].add(item['info_type']) + + if item['trade_type'] == 'buy': + grouped_data[date]['buyers'].append(item) + grouped_data[date]['total_buy'] += item['buy_amount'] or 0 + elif item['trade_type'] == 'sell': + grouped_data[date]['sellers'].append(item) + grouped_data[date]['total_sell'] += item['sell_amount'] or 0 + + grouped_data[date]['net_amount'] = grouped_data[date]['total_buy'] - grouped_data[date]['total_sell'] + + # 转换set为list + for date in grouped_data: + grouped_data[date]['info_types'] = list(grouped_data[date]['info_types']) + + return jsonify({ + 'success': True, + 'data': data, + 'grouped_data': list(grouped_data.values()) + }) + + except Exception as e: + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + + +@app.route('/api/market/pledge/', methods=['GET']) +def get_pledge_data(seccode): + """获取股权质押数据""" + try: + query = text(""" + SELECT ENDDATE, + STARTDATE, + SECNAME, + F001N as unrestricted_pledge, + F002N as restricted_pledge, + F003N as total_shares_a, + F004N as pledge_count, + F005N as pledge_ratio + FROM ea_pledgeratio + WHERE SECCODE = :seccode + ORDER BY ENDDATE DESC LIMIT 12 + """) + + result = engine.execute(query, seccode=seccode) + + data = [] + for row in result: + total_pledge = (row.unrestricted_pledge or 0) + (row.restricted_pledge or 0) + data.append({ + 'end_date': format_date(row.ENDDATE), + 'start_date': format_date(row.STARTDATE), + 'stock_name': row.SECNAME, + 'unrestricted_pledge': format_decimal(row.unrestricted_pledge), + 'restricted_pledge': format_decimal(row.restricted_pledge), + 'total_pledge': format_decimal(total_pledge), + 'total_shares': format_decimal(row.total_shares_a), + 'pledge_count': int(row.pledge_count) if row.pledge_count else None, + 'pledge_ratio': format_decimal(row.pledge_ratio) + }) + + return jsonify({ + 'success': True, + 'data': data + }) + + except Exception as e: + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + + +@app.route('/api/market/summary/', methods=['GET']) +def get_market_summary(seccode): + """获取市场数据汇总""" + try: + # 获取最新交易数据 + trade_query = text(""" + SELECT * + FROM ea_trade + WHERE SECCODE = :seccode + ORDER BY TRADEDATE DESC LIMIT 1 + """) + + # 获取最新融资融券数据 + funding_query = text(""" + SELECT * + FROM ea_funding + WHERE SECCODE = :seccode + ORDER BY TRADEDATE DESC LIMIT 1 + """) + + # 获取最新质押数据 + pledge_query = text(""" + SELECT * + FROM ea_pledgeratio + WHERE SECCODE = :seccode + ORDER BY ENDDATE DESC LIMIT 1 + """) + + trade_result = engine.execute(trade_query, seccode=seccode).fetchone() + funding_result = engine.execute(funding_query, seccode=seccode).fetchone() + pledge_result = engine.execute(pledge_query, seccode=seccode).fetchone() + + summary = { + 'stock_code': seccode, + 'stock_name': trade_result.SECNAME if trade_result else None, + 'latest_trade': { + 'date': format_date(trade_result.TRADEDATE) if trade_result else None, + 'close': format_decimal(trade_result.F007N) if trade_result else None, + 'change_percent': format_decimal(trade_result.F010N) if trade_result else None, + 'volume': format_decimal(trade_result.F004N) if trade_result else None, + 'amount': format_decimal(trade_result.F011N) if trade_result else None, + 'pe_ratio': format_decimal(trade_result.F026N) if trade_result else None, + 'turnover_rate': format_decimal(trade_result.F012N) if trade_result else None, + } if trade_result else None, + 'latest_funding': { + 'date': format_date(funding_result.TRADEDATE) if funding_result else None, + 'financing_balance': format_decimal(funding_result.F001N) if funding_result else None, + 'securities_balance': format_decimal(funding_result.F004N) if funding_result else None, + 'total_balance': format_decimal(funding_result.F009N) if funding_result else None, + } if funding_result else None, + 'latest_pledge': { + 'date': format_date(pledge_result.ENDDATE) if pledge_result else None, + 'pledge_ratio': format_decimal(pledge_result.F005N) if pledge_result else None, + 'pledge_count': int(pledge_result.F004N) if pledge_result and pledge_result.F004N else None, + } if pledge_result else None + } + + return jsonify({ + 'success': True, + 'data': summary + }) + + except Exception as e: + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + + +@app.route('/api/stocks/search', methods=['GET']) +def search_stocks(): + """搜索股票(支持股票代码、股票简称、拼音首字母)""" + try: + query = request.args.get('q', '').strip() + limit = request.args.get('limit', 20, type=int) + + if not query: + return jsonify({ + 'success': False, + 'error': '请输入搜索关键词' + }), 400 + + with engine.connect() as conn: + test_sql = text(""" + SELECT SECCODE, SECNAME, F001V, F003V, F010V, F011V + FROM ea_stocklist + WHERE SECCODE = '300750' + OR F001V LIKE '%ndsd%' LIMIT 5 + """) + test_result = conn.execute(test_sql).fetchall() + + # 构建搜索SQL - 支持股票代码、股票简称、拼音简称搜索 + search_sql = text(""" + SELECT DISTINCT SECCODE as stock_code, + SECNAME as stock_name, + F001V as pinyin_abbr, + F003V as security_type, + F005V as exchange, + F011V as listing_status + FROM ea_stocklist + WHERE ( + UPPER(SECCODE) LIKE UPPER(:query_pattern) + OR UPPER(SECNAME) LIKE UPPER(:query_pattern) + OR UPPER(F001V) LIKE UPPER(:query_pattern) + ) + -- 基本过滤条件:只搜索正常的A股和B股 + AND (F011V = '正常上市' OR F010V = '013001') -- 正常上市状态 + AND F003V IN ('A股', 'B股') -- 只搜索A股和B股 + ORDER BY CASE + WHEN UPPER(SECCODE) = UPPER(:exact_query) THEN 1 + WHEN UPPER(SECNAME) = UPPER(:exact_query) THEN 2 + WHEN UPPER(F001V) = UPPER(:exact_query) THEN 3 + WHEN UPPER(SECCODE) LIKE UPPER(:prefix_pattern) THEN 4 + WHEN UPPER(SECNAME) LIKE UPPER(:prefix_pattern) THEN 5 + WHEN UPPER(F001V) LIKE UPPER(:prefix_pattern) THEN 6 + ELSE 7 + END, + SECCODE LIMIT :limit + """) + + result = conn.execute(search_sql, { + 'query_pattern': f'%{query}%', + 'exact_query': query, + 'prefix_pattern': f'{query}%', + 'limit': limit + }).fetchall() + + stocks = [] + for row in result: + # 获取当前价格 + current_price, _ = get_latest_price_from_clickhouse(row.stock_code) + + stocks.append({ + 'stock_code': row.stock_code, + 'stock_name': row.stock_name, + 'current_price': current_price or 0, # 添加当前价格 + 'pinyin_abbr': row.pinyin_abbr, + 'security_type': row.security_type, + 'exchange': row.exchange, + 'listing_status': row.listing_status + }) + + return jsonify({ + 'success': True, + 'data': stocks, + 'count': len(stocks) + }) + + except Exception as e: + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + + +@app.route('/api/market/heatmap', methods=['GET']) +def get_market_heatmap(): + """获取市场热力图数据(基于市值和涨跌幅)""" + try: + # 获取交易日期参数 + trade_date = request.args.get('date') + # 前端显示用的limit,但统计数据会基于全部股票 + display_limit = request.args.get('limit', 500, type=int) + + with engine.connect() as conn: + # 如果没有指定日期,获取最新交易日 + if not trade_date: + latest_date_result = conn.execute(text(""" + SELECT MAX(TRADEDATE) as latest_date + FROM ea_trade + """)).fetchone() + trade_date = latest_date_result.latest_date if latest_date_result else None + + if not trade_date: + return jsonify({ + 'success': False, + 'error': '无法获取交易数据' + }), 404 + + # 获取全部股票数据用于统计 + all_stocks_sql = text(""" + SELECT t.SECCODE as stock_code, + t.SECNAME as stock_name, + t.F010N as change_percent, -- 涨跌幅 + t.F007N as close_price, -- 收盘价 + t.F021N * t.F007N / 100000000 as market_cap, -- 市值(亿元) + t.F011N / 100000000 as amount, -- 成交额(亿元) + t.F012N as turnover_rate, -- 换手率 + b.F034V as industry, -- 申万行业分类一级名称 + b.F026V as province -- 所属省份 + FROM ea_trade t + LEFT JOIN ea_baseinfo b ON t.SECCODE = b.SECCODE + WHERE t.TRADEDATE = :trade_date + AND t.F010N IS NOT NULL -- 仅统计当日有涨跌幅数据的股票 + ORDER BY market_cap DESC + """) + + all_result = conn.execute(all_stocks_sql, { + 'trade_date': trade_date + }).fetchall() + + # 计算统计数据(基于全部股票) + total_market_cap = 0 + total_amount = 0 + rising_count = 0 + falling_count = 0 + flat_count = 0 + + all_data = [] + for row in all_result: + # F010N 已在 SQL 中确保非空 + change_percent = float(row.change_percent) + market_cap = float(row.market_cap) if row.market_cap else 0 + amount = float(row.amount) if row.amount else 0 + + total_market_cap += market_cap + total_amount += amount + + if change_percent > 0: + rising_count += 1 + elif change_percent < 0: + falling_count += 1 + else: + flat_count += 1 + + all_data.append({ + 'stock_code': row.stock_code, + 'stock_name': row.stock_name, + 'change_percent': change_percent, + 'close_price': float(row.close_price) if row.close_price else 0, + 'market_cap': market_cap, + 'amount': amount, + 'turnover_rate': float(row.turnover_rate) if row.turnover_rate else 0, + 'industry': row.industry, + 'province': row.province + }) + + # 只返回前display_limit条用于热力图显示 + heatmap_data = all_data[:display_limit] + + return jsonify({ + 'success': True, + 'data': heatmap_data, + 'trade_date': trade_date.strftime('%Y-%m-%d') if hasattr(trade_date, 'strftime') else str(trade_date), + 'count': len(all_data), # 全部股票数量 + 'display_count': len(heatmap_data), # 显示的股票数量 + 'statistics': { + 'total_market_cap': round(total_market_cap, 2), # 总市值(亿元) + 'total_amount': round(total_amount, 2), # 总成交额(亿元) + 'rising_count': rising_count, # 上涨家数 + 'falling_count': falling_count, # 下跌家数 + 'flat_count': flat_count # 平盘家数 + } + }) + + except Exception as e: + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + + +@app.route('/api/market/statistics', methods=['GET']) +def get_market_statistics(): + """获取市场统计数据(从ea_blocktrading表)""" + try: + # 获取交易日期参数 + trade_date = request.args.get('date') + + with engine.connect() as conn: + # 如果没有指定日期,获取最新交易日 + if not trade_date: + latest_date_result = conn.execute(text(""" + SELECT MAX(TRADEDATE) as latest_date + FROM ea_blocktrading + """)).fetchone() + trade_date = latest_date_result.latest_date if latest_date_result else None + + if not trade_date: + return jsonify({ + 'success': False, + 'error': '无法获取统计数据' + }), 404 + + # 获取沪深两市的统计数据 + stats_sql = text(""" + SELECT EXCHANGECODE, + EXCHANGENAME, + F001V as indicator_code, + F002V as indicator_name, + F003N as indicator_value, + F004V as unit, + TRADEDATE + FROM ea_blocktrading + WHERE TRADEDATE = :trade_date + AND EXCHANGECODE IN ('012001', '012002') -- 只获取上交所和深交所的数据 + AND F001V IN ( + '250006', '250014', -- 深交所股票总市值、上交所市价总值 + '250007', '250015', -- 深交所股票流通市值、上交所流通市值 + '250008', -- 深交所股票成交金额 + '250010', '250019', -- 深交所股票平均市盈率、上交所平均市盈率 + '250050', '250001' -- 上交所上市公司家数、深交所上市公司数 + ) + """) + + result = conn.execute(stats_sql, { + 'trade_date': trade_date + }).fetchall() + + # 整理数据 + statistics = {} + for row in result: + key = f"{row.EXCHANGECODE}_{row.indicator_code}" + statistics[key] = { + 'exchange_code': row.EXCHANGECODE, + 'exchange_name': row.EXCHANGENAME, + 'indicator_code': row.indicator_code, + 'indicator_name': row.indicator_name, + 'value': float(row.indicator_value) if row.indicator_value else 0, + 'unit': row.unit + } + + # 汇总数据 + summary = { + 'total_market_cap': 0, # 总市值 + 'total_float_cap': 0, # 流通市值 + 'total_amount': 0, # 成交额 + 'sh_pe_ratio': 0, # 上交所市盈率 + 'sz_pe_ratio': 0, # 深交所市盈率 + 'sh_companies': 0, # 上交所上市公司数 + 'sz_companies': 0 # 深交所上市公司数 + } + + # 计算汇总值 + if '012001_250014' in statistics: # 上交所市价总值 + summary['total_market_cap'] += statistics['012001_250014']['value'] + if '012002_250006' in statistics: # 深交所股票总市值 + summary['total_market_cap'] += statistics['012002_250006']['value'] + + if '012001_250015' in statistics: # 上交所流通市值 + summary['total_float_cap'] += statistics['012001_250015']['value'] + if '012002_250007' in statistics: # 深交所股票流通市值 + summary['total_float_cap'] += statistics['012002_250007']['value'] + + # 成交额需要获取上交所的数据 + # 获取上交所成交金额 + sh_amount_result = conn.execute(text(""" + SELECT F003N + FROM ea_blocktrading + WHERE TRADEDATE = :trade_date + AND EXCHANGECODE = '012001' + AND F002V LIKE '%成交金额%' LIMIT 1 + """), {'trade_date': trade_date}).fetchone() + + sh_amount = float(sh_amount_result.F003N) if sh_amount_result and sh_amount_result.F003N else 0 + sz_amount = statistics['012002_250008']['value'] if '012002_250008' in statistics else 0 + summary['total_amount'] = sh_amount + sz_amount + + if '012001_250019' in statistics: # 上交所平均市盈率 + summary['sh_pe_ratio'] = statistics['012001_250019']['value'] + if '012002_250010' in statistics: # 深交所股票平均市盈率 + summary['sz_pe_ratio'] = statistics['012002_250010']['value'] + + if '012001_250050' in statistics: # 上交所上市公司家数 + summary['sh_companies'] = int(statistics['012001_250050']['value']) + if '012002_250001' in statistics: # 深交所上市公司数 + summary['sz_companies'] = int(statistics['012002_250001']['value']) + + # 获取可用的交易日期列表 + available_dates_result = conn.execute(text(""" + SELECT DISTINCT TRADEDATE + FROM ea_blocktrading + WHERE EXCHANGECODE IN ('012001', '012002') + ORDER BY TRADEDATE DESC LIMIT 30 + """)).fetchall() + + available_dates = [str(row.TRADEDATE) for row in available_dates_result] + + return jsonify({ + 'success': True, + 'trade_date': str(trade_date), + 'summary': summary, + 'details': list(statistics.values()), + 'available_dates': available_dates + }) + + except Exception as e: + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + + +@app.route('/api/concepts/daily-top', methods=['GET']) +def get_daily_top_concepts(): + """获取每日涨幅靠前的概念板块""" + try: + # 获取交易日期参数 + trade_date = request.args.get('date') + limit = request.args.get('limit', 6, type=int) + + # 构建概念中心API的URL + concept_api_url = 'http://222.128.1.157:16801/search' + + # 准备请求数据 + request_data = { + 'query': '', + 'size': limit, + 'page': 1, + 'sort_by': 'change_pct' + } + + if trade_date: + request_data['trade_date'] = trade_date + + # 调用概念中心API + response = requests.post(concept_api_url, json=request_data, timeout=10) + + if response.status_code == 200: + data = response.json() + top_concepts = [] + + for concept in data.get('results', []): + top_concepts.append({ + 'concept_id': concept.get('concept_id'), + 'concept_name': concept.get('concept'), + 'description': concept.get('description'), + 'change_percent': concept.get('price_info', {}).get('avg_change_pct', 0), + 'stock_count': concept.get('stock_count', 0), + 'stocks': concept.get('stocks', [])[:5] # 只返回前5只股票 + }) + + return jsonify({ + 'success': True, + 'data': top_concepts, + 'trade_date': data.get('price_date'), + 'count': len(top_concepts) + }) + else: + return jsonify({ + 'success': False, + 'error': '获取概念数据失败' + }), 500 + + except Exception as e: + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + + +@app.route('/api/market/rise-analysis/', methods=['GET']) +def get_rise_analysis(seccode): + """获取股票涨幅分析数据""" + try: + # 获取日期范围参数 + start_date = request.args.get('start_date') + end_date = request.args.get('end_date') + + query = text(""" + SELECT stock_code, + stock_name, + trade_date, + rise_rate, + close_price, + volume, + amount, + main_business, + rise_reason_brief, + rise_reason_detail, + news_summary, + announcements, + guba_sentiment, + analysis_time + FROM stock_rise_analysis + WHERE stock_code = :stock_code + """) + + params = {'stock_code': seccode} + + # 添加日期筛选 + if start_date and end_date: + query = text(""" + SELECT stock_code, + stock_name, + trade_date, + rise_rate, + close_price, + volume, + amount, + main_business, + rise_reason_brief, + rise_reason_detail, + news_summary, + announcements, + guba_sentiment, + analysis_time + FROM stock_rise_analysis + WHERE stock_code = :stock_code + AND trade_date BETWEEN :start_date AND :end_date + ORDER BY trade_date DESC + """) + params['start_date'] = start_date + params['end_date'] = end_date + else: + query = text(""" + SELECT stock_code, + stock_name, + trade_date, + rise_rate, + close_price, + volume, + amount, + main_business, + rise_reason_brief, + rise_reason_detail, + news_summary, + announcements, + guba_sentiment, + analysis_time + FROM stock_rise_analysis + WHERE stock_code = :stock_code + ORDER BY trade_date DESC LIMIT 100 + """) + + result = engine.execute(query, **params).fetchall() + + # 格式化数据 + rise_analysis_data = [] + for row in result: + rise_analysis_data.append({ + 'stock_code': row.stock_code, + 'stock_name': row.stock_name, + 'trade_date': format_date(row.trade_date), + 'rise_rate': format_decimal(row.rise_rate), + 'close_price': format_decimal(row.close_price), + 'volume': format_decimal(row.volume), + 'amount': format_decimal(row.amount), + 'main_business': row.main_business, + 'rise_reason_brief': row.rise_reason_brief, + 'rise_reason_detail': row.rise_reason_detail, + 'news_summary': row.news_summary, + 'announcements': row.announcements, + 'guba_sentiment': row.guba_sentiment, + 'analysis_time': row.analysis_time.strftime('%Y-%m-%d %H:%M:%S') if row.analysis_time else None + }) + + return jsonify({ + 'success': True, + 'data': rise_analysis_data, + 'count': len(rise_analysis_data) + }) + + except Exception as e: + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + + +# ============================================ +# 公司分析相关接口 +# ============================================ + +@app.route('/api/company/comprehensive-analysis/', methods=['GET']) +def get_comprehensive_analysis(company_code): + """获取公司综合分析数据""" + try: + # 获取公司定性分析 + qualitative_query = text(""" + SELECT one_line_intro, + investment_highlights, + business_model_desc, + company_story, + positioning_analysis, + unique_value_proposition, + business_logic_explanation, + revenue_driver_analysis, + customer_value_analysis, + strategy_description, + strategic_initiatives, + created_at, + updated_at + FROM company_analysis + WHERE company_code = :company_code + """) + + qualitative_result = engine.execute(qualitative_query, company_code=company_code).fetchone() + + # 获取业务板块分析 + segments_query = text(""" + SELECT segment_name, + segment_description, + competitive_position, + future_potential, + key_customers, + value_chain_position, + created_at, + updated_at + FROM business_segment_analysis + WHERE company_code = :company_code + ORDER BY created_at DESC + """) + + segments_result = engine.execute(segments_query, company_code=company_code).fetchall() + + # 获取竞争地位数据 - 最新一期 + competitive_query = text(""" + SELECT market_position_score, + technology_score, + brand_score, + operation_score, + finance_score, + innovation_score, + risk_score, + growth_score, + industry_avg_comparison, + main_competitors, + competitive_advantages, + competitive_disadvantages, + industry_rank, + total_companies, + report_period, + updated_at + FROM company_competitive_position + WHERE company_code = :company_code + ORDER BY report_period DESC LIMIT 1 + """) + + competitive_result = engine.execute(competitive_query, company_code=company_code).fetchone() + + # 获取业务结构数据 - 最新一期 + business_structure_query = text(""" + SELECT business_name, + parent_business, + business_level, + revenue, + revenue_unit, + revenue_ratio, + profit, + profit_unit, + profit_ratio, + revenue_growth, + profit_growth, + gross_margin, + customer_count, + market_share, + report_period + FROM company_business_structure + WHERE company_code = :company_code + AND report_period = (SELECT MAX(report_period) + FROM company_business_structure + WHERE company_code = :company_code) + ORDER BY revenue_ratio DESC + """) + + business_structure_result = engine.execute(business_structure_query, company_code=company_code).fetchall() + + # 构建返回数据 + response_data = { + 'company_code': company_code, + 'qualitative_analysis': None, + 'business_segments': [], + 'competitive_position': None, + 'business_structure': [] + } + + # 处理定性分析数据 + if qualitative_result: + response_data['qualitative_analysis'] = { + 'core_positioning': { + 'one_line_intro': qualitative_result.one_line_intro, + 'investment_highlights': qualitative_result.investment_highlights, + 'business_model_desc': qualitative_result.business_model_desc, + 'company_story': qualitative_result.company_story + }, + 'business_understanding': { + 'positioning_analysis': qualitative_result.positioning_analysis, + 'unique_value_proposition': qualitative_result.unique_value_proposition, + 'business_logic_explanation': qualitative_result.business_logic_explanation, + 'revenue_driver_analysis': qualitative_result.revenue_driver_analysis, + 'customer_value_analysis': qualitative_result.customer_value_analysis + }, + 'strategy': { + 'strategy_description': qualitative_result.strategy_description, + 'strategic_initiatives': qualitative_result.strategic_initiatives + }, + 'updated_at': qualitative_result.updated_at.strftime( + '%Y-%m-%d %H:%M:%S') if qualitative_result.updated_at else None + } + + # 处理业务板块数据 + for segment in segments_result: + response_data['business_segments'].append({ + 'segment_name': segment.segment_name, + 'segment_description': segment.segment_description, + 'competitive_position': segment.competitive_position, + 'future_potential': segment.future_potential, + 'key_customers': segment.key_customers, + 'value_chain_position': segment.value_chain_position, + 'updated_at': segment.updated_at.strftime('%Y-%m-%d %H:%M:%S') if segment.updated_at else None + }) + + # 处理竞争地位数据 + if competitive_result: + response_data['competitive_position'] = { + 'scores': { + 'market_position': competitive_result.market_position_score, + 'technology': competitive_result.technology_score, + 'brand': competitive_result.brand_score, + 'operation': competitive_result.operation_score, + 'finance': competitive_result.finance_score, + 'innovation': competitive_result.innovation_score, + 'risk': competitive_result.risk_score, + 'growth': competitive_result.growth_score + }, + 'analysis': { + 'industry_avg_comparison': competitive_result.industry_avg_comparison, + 'main_competitors': competitive_result.main_competitors, + 'competitive_advantages': competitive_result.competitive_advantages, + 'competitive_disadvantages': competitive_result.competitive_disadvantages + }, + 'ranking': { + 'industry_rank': competitive_result.industry_rank, + 'total_companies': competitive_result.total_companies, + 'rank_percentage': round( + (competitive_result.industry_rank / competitive_result.total_companies * 100), + 2) if competitive_result.industry_rank and competitive_result.total_companies else None + }, + 'report_period': competitive_result.report_period, + 'updated_at': competitive_result.updated_at.strftime( + '%Y-%m-%d %H:%M:%S') if competitive_result.updated_at else None + } + + # 处理业务结构数据 + for business in business_structure_result: + response_data['business_structure'].append({ + 'business_name': business.business_name, + 'parent_business': business.parent_business, + 'business_level': business.business_level, + 'revenue': format_decimal(business.revenue), + 'revenue_unit': business.revenue_unit, + 'profit': format_decimal(business.profit), + 'profit_unit': business.profit_unit, + 'financial_metrics': { + 'revenue': format_decimal(business.revenue), + 'revenue_ratio': format_decimal(business.revenue_ratio), + 'profit': format_decimal(business.profit), + 'profit_ratio': format_decimal(business.profit_ratio), + 'gross_margin': format_decimal(business.gross_margin) + }, + 'growth_metrics': { + 'revenue_growth': format_decimal(business.revenue_growth), + 'profit_growth': format_decimal(business.profit_growth) + }, + 'market_metrics': { + 'customer_count': business.customer_count, + 'market_share': format_decimal(business.market_share) + }, + 'report_period': business.report_period + }) + + return jsonify({ + 'success': True, + 'data': response_data + }) + + except Exception as e: + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + + +@app.route('/api/company/value-chain-analysis/', methods=['GET']) +def get_value_chain_analysis(company_code): + """获取公司产业链分析数据""" + try: + # 获取产业链节点数据 + nodes_query = text(""" + SELECT node_name, + node_type, + node_level, + node_description, + importance_score, + market_share, + dependency_degree, + created_at + FROM company_value_chain_nodes + WHERE company_code = :company_code + ORDER BY node_level ASC, importance_score DESC + """) + + nodes_result = engine.execute(nodes_query, company_code=company_code).fetchall() + + # 获取产业链流向数据 + flows_query = text(""" + SELECT source_node, + source_type, + source_level, + target_node, + target_type, + target_level, + flow_value, + flow_ratio, + flow_type, + relationship_desc, + transaction_volume + FROM company_value_chain_flows + WHERE company_code = :company_code + ORDER BY flow_ratio DESC + """) + + flows_result = engine.execute(flows_query, company_code=company_code).fetchall() + + # 构建节点数据结构 + nodes_by_level = {} + all_nodes = [] + + for node in nodes_result: + node_data = { + 'node_name': node.node_name, + 'node_type': node.node_type, + 'node_level': node.node_level, + 'node_description': node.node_description, + 'importance_score': node.importance_score, + 'market_share': format_decimal(node.market_share), + 'dependency_degree': format_decimal(node.dependency_degree), + 'created_at': node.created_at.strftime('%Y-%m-%d %H:%M:%S') if node.created_at else None + } + + all_nodes.append(node_data) + + # 按层级分组 + level_key = f"level_{node.node_level}" + if level_key not in nodes_by_level: + nodes_by_level[level_key] = [] + nodes_by_level[level_key].append(node_data) + + # 构建流向数据 + flows_data = [] + for flow in flows_result: + flows_data.append({ + 'source': { + 'node_name': flow.source_node, + 'node_type': flow.source_type, + 'node_level': flow.source_level + }, + 'target': { + 'node_name': flow.target_node, + 'node_type': flow.target_type, + 'node_level': flow.target_level + }, + 'flow_metrics': { + 'flow_value': format_decimal(flow.flow_value), + 'flow_ratio': format_decimal(flow.flow_ratio), + 'flow_type': flow.flow_type + }, + 'relationship_info': { + 'relationship_desc': flow.relationship_desc, + 'transaction_volume': flow.transaction_volume + } + }) + + # 移除循环边,确保Sankey图数据是DAG(有向无环图) + flows_data = remove_cycles_from_sankey_flows(flows_data) + + # 统计各层级节点数量 + level_stats = {} + for level_key, nodes in nodes_by_level.items(): + level_stats[level_key] = { + 'count': len(nodes), + 'avg_importance': round(sum(node['importance_score'] or 0 for node in nodes) / len(nodes), + 2) if nodes else 0 + } + + response_data = { + 'company_code': company_code, + 'value_chain_structure': { + 'nodes_by_level': nodes_by_level, + 'level_statistics': level_stats, + 'total_nodes': len(all_nodes) + }, + 'value_chain_flows': flows_data, + 'analysis_summary': { + 'total_flows': len(flows_data), + 'upstream_nodes': len([n for n in all_nodes if n['node_level'] < 0]), + 'company_nodes': len([n for n in all_nodes if n['node_level'] == 0]), + 'downstream_nodes': len([n for n in all_nodes if n['node_level'] > 0]) + } + } + + return jsonify({ + 'success': True, + 'data': response_data + }) + + except Exception as e: + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + + +@app.route('/api/company/key-factors-timeline/', methods=['GET']) +def get_key_factors_timeline(company_code): + """获取公司关键因素和时间线数据""" + try: + # 获取请求参数 + report_period = request.args.get('report_period') # 可选的报告期筛选 + event_limit = request.args.get('event_limit', 50, type=int) # 时间线事件数量限制 + + # 获取关键因素类别 + categories_query = text(""" + SELECT id, + category_name, + category_desc, + display_order + FROM company_key_factor_categories + WHERE company_code = :company_code + ORDER BY display_order ASC, created_at ASC + """) + + categories_result = engine.execute(categories_query, company_code=company_code).fetchall() + + # 获取关键因素详情 + factors_query = text(""" + SELECT kf.category_id, + kf.factor_name, + kf.factor_type, + kf.factor_value, + kf.factor_unit, + kf.factor_desc, + kf.impact_direction, + kf.impact_weight, + kf.report_period, + kf.year_on_year, + kf.data_source, + kf.created_at, + kf.updated_at + FROM company_key_factors kf + WHERE kf.company_code = :company_code + """) + + params = {'company_code': company_code} + + # 如果指定了报告期,添加筛选条件 + if report_period: + factors_query = text(""" + SELECT kf.category_id, + kf.factor_name, + kf.factor_type, + kf.factor_value, + kf.factor_unit, + kf.factor_desc, + kf.impact_direction, + kf.impact_weight, + kf.report_period, + kf.year_on_year, + kf.data_source, + kf.created_at, + kf.updated_at + FROM company_key_factors kf + WHERE kf.company_code = :company_code + AND kf.report_period = :report_period + ORDER BY kf.impact_weight DESC, kf.updated_at DESC + """) + params['report_period'] = report_period + else: + factors_query = text(""" + SELECT kf.category_id, + kf.factor_name, + kf.factor_type, + kf.factor_value, + kf.factor_unit, + kf.factor_desc, + kf.impact_direction, + kf.impact_weight, + kf.report_period, + kf.year_on_year, + kf.data_source, + kf.created_at, + kf.updated_at + FROM company_key_factors kf + WHERE kf.company_code = :company_code + ORDER BY kf.report_period DESC, kf.impact_weight DESC, kf.updated_at DESC + """) + + factors_result = engine.execute(factors_query, **params).fetchall() + + # 获取发展时间线事件 + timeline_query = text(""" + SELECT event_date, + event_type, + event_title, + event_desc, + impact_score, + is_positive, + related_products, + related_partners, + financial_impact, + created_at + FROM company_timeline_events + WHERE company_code = :company_code + ORDER BY event_date DESC LIMIT :limit + """) + + timeline_result = engine.execute(timeline_query, + company_code=company_code, + limit=event_limit).fetchall() + + # 构建关键因素数据结构 + key_factors_data = {} + factors_by_category = {} + + # 先建立类别索引 + categories_map = {} + for category in categories_result: + categories_map[category.id] = { + 'category_name': category.category_name, + 'category_desc': category.category_desc, + 'display_order': category.display_order, + 'factors': [] + } + + # 将因素分组到类别中 + for factor in factors_result: + factor_data = { + 'factor_name': factor.factor_name, + 'factor_type': factor.factor_type, + 'factor_value': factor.factor_value, + 'factor_unit': factor.factor_unit, + 'factor_desc': factor.factor_desc, + 'impact_direction': factor.impact_direction, + 'impact_weight': factor.impact_weight, + 'report_period': factor.report_period, + 'year_on_year': format_decimal(factor.year_on_year), + 'data_source': factor.data_source, + 'updated_at': factor.updated_at.strftime('%Y-%m-%d %H:%M:%S') if factor.updated_at else None + } + + category_id = factor.category_id + if category_id and category_id in categories_map: + categories_map[category_id]['factors'].append(factor_data) + + # 构建时间线数据 + timeline_data = [] + for event in timeline_result: + timeline_data.append({ + 'event_date': event.event_date.strftime('%Y-%m-%d') if event.event_date else None, + 'event_type': event.event_type, + 'event_title': event.event_title, + 'event_desc': event.event_desc, + 'impact_metrics': { + 'impact_score': event.impact_score, + 'is_positive': event.is_positive + }, + 'related_info': { + 'related_products': event.related_products, + 'related_partners': event.related_partners, + 'financial_impact': event.financial_impact + }, + 'created_at': event.created_at.strftime('%Y-%m-%d %H:%M:%S') if event.created_at else None + }) + + # 统计信息 + total_factors = len(factors_result) + positive_events = len([e for e in timeline_result if e.is_positive]) + negative_events = len(timeline_result) - positive_events + + response_data = { + 'company_code': company_code, + 'key_factors': { + 'categories': list(categories_map.values()), + 'total_factors': total_factors, + 'report_period': report_period + }, + 'development_timeline': { + 'events': timeline_data, + 'statistics': { + 'total_events': len(timeline_data), + 'positive_events': positive_events, + 'negative_events': negative_events, + 'event_types': list(set(event.event_type for event in timeline_result if event.event_type)) + } + } + } + + return jsonify({ + 'success': True, + 'data': response_data + }) + + except Exception as e: + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + + +# ============================================ +# 模拟盘服务函数 +# ============================================ + +def get_or_create_simulation_account(user_id): + """获取或创建模拟账户""" + account = SimulationAccount.query.filter_by(user_id=user_id).first() + if not account: + account = SimulationAccount( + user_id=user_id, + account_name=f'模拟账户_{user_id}', + initial_capital=1000000.00, + available_cash=1000000.00 + ) + db.session.add(account) + db.session.commit() + return account + + +def is_trading_time(): + """判断是否为交易时间""" + now = beijing_now() + # 检查是否为工作日 + if now.weekday() >= 5: # 周六日 + return False + + # 检查是否为交易时间 + current_time = now.time() + morning_start = dt_time(9, 30) + morning_end = dt_time(11, 30) + afternoon_start = dt_time(13, 0) + afternoon_end = dt_time(15, 0) + + if (morning_start <= current_time <= morning_end) or \ + (afternoon_start <= current_time <= afternoon_end): + return True + + return False + + +def get_latest_price_from_clickhouse(stock_code): + """从ClickHouse获取最新价格(优先分钟数据,备选日线数据)""" + try: + client = get_clickhouse_client() + + # 确保stock_code包含后缀 + if '.' not in stock_code: + stock_code = f"{stock_code}.SH" if stock_code.startswith('6') else f"{stock_code}.SZ" + + # 1. 首先尝试获取最新的分钟数据(近30天) + minute_query = """ + SELECT close, timestamp + FROM stock_minute + WHERE code = %(code)s + AND timestamp >= today() - 30 + ORDER BY timestamp DESC + LIMIT 1 \ + """ + + result = client.execute(minute_query, {'code': stock_code}) + + if result: + return float(result[0][0]), result[0][1] + + # 2. 如果没有分钟数据,获取最新的日线收盘价 + daily_query = """ + SELECT close, date + FROM stock_daily + WHERE code = %(code)s + AND date >= today() - 90 + ORDER BY date DESC + LIMIT 1 \ + """ + + daily_result = client.execute(daily_query, {'code': stock_code}) + + if daily_result: + return float(daily_result[0][0]), daily_result[0][1] + + # 3. 如果还是没有,尝试从其他表获取(如果有的话) + fallback_query = """ + SELECT close_price, trade_date + FROM stock_minute_kline + WHERE stock_code = %(code6)s + AND trade_date >= today() - 30 + ORDER BY trade_date DESC, trade_time DESC LIMIT 1 \ + """ + + # 提取6位代码 + code6 = stock_code.split('.')[0] + fallback_result = client.execute(fallback_query, {'code6': code6}) + + if fallback_result: + return float(fallback_result[0][0]), fallback_result[0][1] + + print(f"警告: 无法获取股票 {stock_code} 的价格数据") + return None, None + + except Exception as e: + print(f"获取最新价格失败 {stock_code}: {e}") + return None, None + + +def get_next_minute_price(stock_code, order_time): + """获取下单后一分钟内的收盘价作为成交价""" + try: + client = get_clickhouse_client() + + # 确保stock_code包含后缀 + if '.' not in stock_code: + stock_code = f"{stock_code}.SH" if stock_code.startswith('6') else f"{stock_code}.SZ" + + # 获取下单后一分钟内的数据 + query = """ + SELECT close, timestamp + FROM stock_minute + WHERE code = %(code)s + AND timestamp \ + > %(order_time)s + AND timestamp <= %(end_time)s + ORDER BY timestamp ASC + LIMIT 1 \ + """ + + end_time = order_time + timedelta(minutes=1) + + result = client.execute(query, { + 'code': stock_code, + 'order_time': order_time, + 'end_time': end_time + }) + + if result: + return float(result[0][0]), result[0][1] + + # 如果一分钟内没有数据,获取最近的数据 + query = """ + SELECT close, timestamp + FROM stock_minute + WHERE code = %(code)s + AND timestamp \ + > %(order_time)s + ORDER BY timestamp ASC + LIMIT 1 \ + """ + + result = client.execute(query, { + 'code': stock_code, + 'order_time': order_time + }) + + if result: + return float(result[0][0]), result[0][1] + + # 如果没有后续分钟数据,使用最新可用价格 + print(f"没有找到下单后的分钟数据,使用最新价格: {stock_code}") + return get_latest_price_from_clickhouse(stock_code) + + except Exception as e: + print(f"获取成交价格失败: {e}") + # 出错时也尝试获取最新价格 + return get_latest_price_from_clickhouse(stock_code) + + +def validate_and_get_stock_info(stock_input): + """验证股票输入并获取标准代码和名称 + + 支持输入格式: + - 股票代码:600519 或 600519.SH + - 股票名称:贵州茅台 + - 拼音首字母:gzmt + - 名称(代码):贵州茅台(600519) + + 返回: (stock_code_with_suffix, stock_code_6digit, stock_name) 或 (None, None, None) + """ + # 先尝试标准化输入 + code6, name_from_input = _normalize_stock_input(stock_input) + + if code6: + # 如果能解析出6位代码,查询股票名称 + stock_name = name_from_input or _query_stock_name_by_code(code6) + stock_code_full = f"{code6}.SH" if code6.startswith('6') else f"{code6}.SZ" + return stock_code_full, code6, stock_name + + # 如果不是标准代码格式,尝试搜索 + with engine.connect() as conn: + search_sql = text(""" + SELECT DISTINCT SECCODE as stock_code, + SECNAME as stock_name + FROM ea_stocklist + WHERE ( + UPPER(SECCODE) = UPPER(:exact_match) + OR UPPER(SECNAME) = UPPER(:exact_match) + OR UPPER(F001V) = UPPER(:exact_match) + ) + AND F011V = '正常上市' + AND F003V IN ('A股', 'B股') LIMIT 1 + """) + + result = conn.execute(search_sql, { + 'exact_match': stock_input.upper() + }).fetchone() + + if result: + code6 = result.stock_code + stock_name = result.stock_name + stock_code_full = f"{code6}.SH" if code6.startswith('6') else f"{code6}.SZ" + return stock_code_full, code6, stock_name + + return None, None, None + + +def execute_simulation_order(order): + """执行模拟订单(优化版)""" + try: + # 标准化股票代码 + stock_code_full, code6, stock_name = validate_and_get_stock_info(order.stock_code) + + if not stock_code_full: + order.status = 'REJECTED' + order.reject_reason = '无效的股票代码' + db.session.commit() + return False + + # 更新订单的股票信息 + order.stock_code = stock_code_full + order.stock_name = stock_name + + # 获取成交价格(下单后一分钟的收盘价) + filled_price, filled_time = get_next_minute_price(stock_code_full, order.order_time) + + if not filled_price: + # 如果无法获取价格,订单保持PENDING状态,等待后台处理 + order.status = 'PENDING' + db.session.commit() + return True # 返回True表示下单成功,但未成交 + + # 更新订单信息 + order.filled_qty = order.order_qty + order.filled_price = filled_price + order.filled_amount = filled_price * order.order_qty + order.filled_time = filled_time or beijing_now() + + # 计算费用 + order.calculate_fees() + + # 获取账户 + account = SimulationAccount.query.get(order.account_id) + + if order.order_type == 'BUY': + # 买入操作 + total_cost = float(order.filled_amount) + float(order.total_fee) + + # 检查资金是否充足 + if float(account.available_cash) < total_cost: + order.status = 'REJECTED' + order.reject_reason = '可用资金不足' + db.session.commit() + return False + + # 扣除资金 + account.available_cash -= Decimal(str(total_cost)) + + # 更新或创建持仓 + position = SimulationPosition.query.filter_by( + account_id=account.id, + stock_code=order.stock_code + ).first() + + if position: + # 更新持仓 + total_cost_before = float(position.avg_cost) * position.position_qty + total_cost_after = total_cost_before + float(order.filled_amount) + total_qty_after = position.position_qty + order.filled_qty + + position.avg_cost = Decimal(str(total_cost_after / total_qty_after)) + position.position_qty = total_qty_after + # 今日买入,T+1才可用 + position.frozen_qty += order.filled_qty + else: + # 创建新持仓 + position = SimulationPosition( + account_id=account.id, + stock_code=order.stock_code, + stock_name=order.stock_name, + position_qty=order.filled_qty, + available_qty=0, # T+1 + frozen_qty=order.filled_qty, # 今日买入冻结 + avg_cost=order.filled_price, + current_price=order.filled_price + ) + db.session.add(position) + + # 更新持仓市值 + position.update_market_value(order.filled_price) + + else: # SELL + # 卖出操作 + print(f"🔍 调试:查找持仓,账户ID: {account.id}, 股票代码: {order.stock_code}") + + # 先尝试用完整格式查找 + position = SimulationPosition.query.filter_by( + account_id=account.id, + stock_code=order.stock_code + ).first() + + # 如果没找到,尝试用6位数字格式查找 + if not position and '.' in order.stock_code: + code6 = order.stock_code.split('.')[0] + print(f"🔍 调试:尝试用6位格式查找: {code6}") + position = SimulationPosition.query.filter_by( + account_id=account.id, + stock_code=code6 + ).first() + + print(f"🔍 调试:找到持仓: {position}") + if position: + print( + f"🔍 调试:持仓详情 - 股票代码: {position.stock_code}, 持仓数量: {position.position_qty}, 可用数量: {position.available_qty}") + + # 检查持仓是否存在 + if not position: + order.status = 'REJECTED' + order.reject_reason = '持仓不存在' + db.session.commit() + return False + + # 检查总持仓数量是否足够(包括冻结的) + total_holdings = position.position_qty + if total_holdings < order.order_qty: + order.status = 'REJECTED' + order.reject_reason = f'持仓数量不足,当前持仓: {total_holdings} 股,需要: {order.order_qty} 股' + db.session.commit() + return False + + # 如果可用数量不足,但总持仓足够,则从冻结数量中解冻 + if position.available_qty < order.order_qty: + # 计算需要解冻的数量 + need_to_unfreeze = order.order_qty - position.available_qty + if position.frozen_qty >= need_to_unfreeze: + # 解冻部分冻结数量 + position.frozen_qty -= need_to_unfreeze + position.available_qty += need_to_unfreeze + print(f"解冻 {need_to_unfreeze} 股用于卖出") + else: + order.status = 'REJECTED' + order.reject_reason = f'可用数量不足,可用: {position.available_qty} 股,冻结: {position.frozen_qty} 股,需要: {order.order_qty} 股' + db.session.commit() + return False + + # 更新持仓 + position.position_qty -= order.filled_qty + position.available_qty -= order.filled_qty + + # 增加资金 + account.available_cash += Decimal(str(float(order.filled_amount) - float(order.total_fee))) + + # 如果全部卖出,删除持仓记录 + if position.position_qty == 0: + db.session.delete(position) + + # 创建成交记录 + transaction = SimulationTransaction( + account_id=account.id, + order_id=order.id, + transaction_no=f"T{int(beijing_now().timestamp() * 1000000)}", + stock_code=order.stock_code, + stock_name=order.stock_name, + transaction_type=order.order_type, + transaction_price=order.filled_price, + transaction_qty=order.filled_qty, + transaction_amount=order.filled_amount, + commission=order.commission, + stamp_tax=order.stamp_tax, + transfer_fee=order.transfer_fee, + total_fee=order.total_fee, + transaction_time=order.filled_time, + settlement_date=(order.filled_time + timedelta(days=1)).date() + ) + db.session.add(transaction) + + # 更新订单状态 + order.status = 'FILLED' + + # 更新账户总资产 + update_account_assets(account) + + db.session.commit() + return True + + except Exception as e: + print(f"执行订单失败: {e}") + db.session.rollback() + return False + + +def update_account_assets(account): + """更新账户资产(轻量级版本,不实时获取价格)""" + try: + # 只计算已有的持仓市值,不实时获取价格 + # 价格更新由后台脚本负责 + positions = SimulationPosition.query.filter_by(account_id=account.id).all() + total_market_value = sum(position.market_value or Decimal('0') for position in positions) + + account.position_value = total_market_value + account.calculate_total_assets() + + db.session.commit() + + except Exception as e: + print(f"更新账户资产失败: {e}") + db.session.rollback() + + +def update_all_positions_price(): + """更新所有持仓的最新价格(定时任务调用)""" + try: + positions = SimulationPosition.query.all() + + for position in positions: + latest_price, _ = get_latest_price_from_clickhouse(position.stock_code) + if latest_price: + # 记录昨日收盘价(用于计算今日盈亏) + yesterday_close = position.current_price + + # 更新市值 + position.update_market_value(latest_price) + + # 计算今日盈亏 + position.today_profit = (Decimal(str(latest_price)) - yesterday_close) * position.position_qty + position.today_profit_rate = ((Decimal( + str(latest_price)) - yesterday_close) / yesterday_close * 100) if yesterday_close > 0 else 0 + + db.session.commit() + + except Exception as e: + print(f"更新持仓价格失败: {e}") + db.session.rollback() + + +def process_t1_settlement(): + """处理T+1结算(每日收盘后运行)""" + try: + # 获取所有需要结算的持仓 + positions = SimulationPosition.query.filter(SimulationPosition.frozen_qty > 0).all() + + for position in positions: + # 将冻结数量转为可用数量 + position.available_qty += position.frozen_qty + position.frozen_qty = 0 + + db.session.commit() + + except Exception as e: + print(f"T+1结算失败: {e}") + db.session.rollback() + + +# ============================================ +# 模拟盘API接口 +# ============================================ + +@app.route('/api/simulation/account', methods=['GET']) +@login_required +def get_simulation_account(): + """获取模拟账户信息""" + try: + account = get_or_create_simulation_account(current_user.id) + + # 更新账户资产 + update_account_assets(account) + + return jsonify({ + 'success': True, + 'data': { + 'account_id': account.id, + 'account_name': account.account_name, + 'initial_capital': float(account.initial_capital), + 'available_cash': float(account.available_cash), + 'frozen_cash': float(account.frozen_cash), + 'position_value': float(account.position_value), + 'total_assets': float(account.total_assets), + 'total_profit': float(account.total_profit), + 'total_profit_rate': float(account.total_profit_rate), + 'daily_profit': float(account.daily_profit), + 'daily_profit_rate': float(account.daily_profit_rate), + 'created_at': account.created_at.isoformat(), + 'updated_at': account.updated_at.isoformat() + } + }) + + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + + +@app.route('/api/simulation/positions', methods=['GET']) +@login_required +def get_simulation_positions(): + """获取模拟持仓列表(优化版本,使用缓存的价格数据)""" + try: + account = get_or_create_simulation_account(current_user.id) + + # 直接获取持仓数据,不实时更新价格(由后台脚本负责) + positions = SimulationPosition.query.filter_by(account_id=account.id).all() + + positions_data = [] + for position in positions: + positions_data.append({ + 'id': position.id, + 'stock_code': position.stock_code, + 'stock_name': position.stock_name, + 'position_qty': position.position_qty, + 'available_qty': position.available_qty, + 'frozen_qty': position.frozen_qty, + 'avg_cost': float(position.avg_cost), + 'current_price': float(position.current_price or 0), + 'market_value': float(position.market_value or 0), + 'profit': float(position.profit or 0), + 'profit_rate': float(position.profit_rate or 0), + 'today_profit': float(position.today_profit or 0), + 'today_profit_rate': float(position.today_profit_rate or 0), + 'updated_at': position.updated_at.isoformat() + }) + + return jsonify({ + 'success': True, + 'data': positions_data + }) + + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + + +@app.route('/api/simulation/orders', methods=['GET']) +@login_required +def get_simulation_orders(): + """获取模拟订单列表""" + try: + account = get_or_create_simulation_account(current_user.id) + + # 获取查询参数 + status = request.args.get('status') # 订单状态筛选 + date_str = request.args.get('date') # 日期筛选 + limit = request.args.get('limit', 50, type=int) + + query = SimulationOrder.query.filter_by(account_id=account.id) + + if status: + query = query.filter_by(status=status) + + if date_str: + try: + date = datetime.strptime(date_str, '%Y-%m-%d').date() + start_time = datetime.combine(date, dt_time(0, 0, 0)) + end_time = datetime.combine(date, dt_time(23, 59, 59)) + query = query.filter(SimulationOrder.order_time.between(start_time, end_time)) + except ValueError: + pass + + orders = query.order_by(SimulationOrder.order_time.desc()).limit(limit).all() + + orders_data = [] + for order in orders: + orders_data.append({ + 'id': order.id, + 'order_no': order.order_no, + 'stock_code': order.stock_code, + 'stock_name': order.stock_name, + 'order_type': order.order_type, + 'price_type': order.price_type, + 'order_price': float(order.order_price) if order.order_price else None, + 'order_qty': order.order_qty, + 'filled_qty': order.filled_qty, + 'filled_price': float(order.filled_price) if order.filled_price else None, + 'filled_amount': float(order.filled_amount) if order.filled_amount else None, + 'commission': float(order.commission), + 'stamp_tax': float(order.stamp_tax), + 'transfer_fee': float(order.transfer_fee), + 'total_fee': float(order.total_fee), + 'status': order.status, + 'reject_reason': order.reject_reason, + 'order_time': order.order_time.isoformat(), + 'filled_time': order.filled_time.isoformat() if order.filled_time else None + }) + + return jsonify({ + 'success': True, + 'data': orders_data + }) + + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + + +@app.route('/api/simulation/place-order', methods=['POST']) +@login_required +def place_simulation_order(): + """下单""" + try: + # 移除交易时间检查,允许7x24小时下单 + # 非交易时间下的单子会保持PENDING状态,等待行情数据 + + data = request.get_json() + stock_code = data.get('stock_code') + order_type = data.get('order_type') # BUY/SELL + order_qty = data.get('order_qty') + price_type = data.get('price_type', 'MARKET') # 目前只支持市价单 + + # 标准化股票代码格式 + if stock_code and '.' not in stock_code: + # 如果没有后缀,根据股票代码添加后缀 + if stock_code.startswith('6'): + stock_code = f"{stock_code}.SH" + elif stock_code.startswith('0') or stock_code.startswith('3'): + stock_code = f"{stock_code}.SZ" + + # 参数验证 + if not all([stock_code, order_type, order_qty]): + return jsonify({'success': False, 'error': '缺少必要参数'}), 400 + + if order_type not in ['BUY', 'SELL']: + return jsonify({'success': False, 'error': '订单类型错误'}), 400 + + order_qty = int(order_qty) + if order_qty <= 0 or order_qty % 100 != 0: + return jsonify({'success': False, 'error': '下单数量必须为100的整数倍'}), 400 + + # 获取账户 + account = get_or_create_simulation_account(current_user.id) + + # 获取股票信息 + stock_name = None + with engine.connect() as conn: + result = conn.execute(text( + "SELECT SECNAME FROM ea_stocklist WHERE SECCODE = :code" + ), {"code": stock_code.split('.')[0]}).fetchone() + if result: + stock_name = result[0] + + # 创建订单 + order = SimulationOrder( + account_id=account.id, + order_no=f"O{int(beijing_now().timestamp() * 1000000)}", + stock_code=stock_code, + stock_name=stock_name, + order_type=order_type, + price_type=price_type, + order_qty=order_qty, + status='PENDING' + ) + + db.session.add(order) + db.session.commit() + + # 执行订单 + print(f"🔍 调试:开始执行订单,股票代码: {order.stock_code}, 订单类型: {order.order_type}") + success = execute_simulation_order(order) + print(f"🔍 调试:订单执行结果: {success}, 订单状态: {order.status}") + + if success: + # 重新查询订单状态,因为可能在execute_simulation_order中被修改 + db.session.refresh(order) + + if order.status == 'FILLED': + return jsonify({ + 'success': True, + 'message': '订单执行成功,已成交', + 'data': { + 'order_no': order.order_no, + 'status': 'FILLED', + 'filled_price': float(order.filled_price) if order.filled_price else None, + 'filled_qty': order.filled_qty, + 'filled_amount': float(order.filled_amount) if order.filled_amount else None, + 'total_fee': float(order.total_fee) + } + }) + elif order.status == 'PENDING': + return jsonify({ + 'success': True, + 'message': '订单提交成功,等待行情数据成交', + 'data': { + 'order_no': order.order_no, + 'status': 'PENDING', + 'order_qty': order.order_qty, + 'order_price': float(order.order_price) if order.order_price else None + } + }) + else: + return jsonify({ + 'success': False, + 'error': order.reject_reason or '订单状态异常' + }), 400 + else: + return jsonify({ + 'success': False, + 'error': order.reject_reason or '订单执行失败' + }), 400 + + except Exception as e: + db.session.rollback() + return jsonify({'success': False, 'error': str(e)}), 500 + + +@app.route('/api/simulation/cancel-order/', methods=['POST']) +@login_required +def cancel_simulation_order(order_id): + """撤销订单""" + try: + account = get_or_create_simulation_account(current_user.id) + + order = SimulationOrder.query.filter_by( + id=order_id, + account_id=account.id, + status='PENDING' + ).first() + + if not order: + return jsonify({'success': False, 'error': '订单不存在或无法撤销'}), 404 + + order.status = 'CANCELLED' + order.cancel_time = beijing_now() + + db.session.commit() + + return jsonify({ + 'success': True, + 'message': '订单已撤销' + }) + + except Exception as e: + db.session.rollback() + return jsonify({'success': False, 'error': str(e)}), 500 + + +@app.route('/api/simulation/transactions', methods=['GET']) +@login_required +def get_simulation_transactions(): + """获取成交记录""" + try: + account = get_or_create_simulation_account(current_user.id) + + # 获取查询参数 + date_str = request.args.get('date') + limit = request.args.get('limit', 100, type=int) + + query = SimulationTransaction.query.filter_by(account_id=account.id) + + if date_str: + try: + date = datetime.strptime(date_str, '%Y-%m-%d').date() + start_time = datetime.combine(date, dt_time(0, 0, 0)) + end_time = datetime.combine(date, dt_time(23, 59, 59)) + query = query.filter(SimulationTransaction.transaction_time.between(start_time, end_time)) + except ValueError: + pass + + transactions = query.order_by(SimulationTransaction.transaction_time.desc()).limit(limit).all() + + transactions_data = [] + for trans in transactions: + transactions_data.append({ + 'id': trans.id, + 'transaction_no': trans.transaction_no, + 'stock_code': trans.stock_code, + 'stock_name': trans.stock_name, + 'transaction_type': trans.transaction_type, + 'transaction_price': float(trans.transaction_price), + 'transaction_qty': trans.transaction_qty, + 'transaction_amount': float(trans.transaction_amount), + 'commission': float(trans.commission), + 'stamp_tax': float(trans.stamp_tax), + 'transfer_fee': float(trans.transfer_fee), + 'total_fee': float(trans.total_fee), + 'transaction_time': trans.transaction_time.isoformat(), + 'settlement_date': trans.settlement_date.isoformat() if trans.settlement_date else None + }) + + return jsonify({ + 'success': True, + 'data': transactions_data + }) + + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + + +def get_simulation_statistics(): + """获取模拟交易统计""" + try: + account = get_or_create_simulation_account(current_user.id) + + # 获取统计时间范围 + days = request.args.get('days', 30, type=int) + end_date = beijing_now().date() + start_date = end_date - timedelta(days=days) + + # 查询日统计数据 + daily_stats = SimulationDailyStats.query.filter( + SimulationDailyStats.account_id == account.id, + SimulationDailyStats.stat_date >= start_date, + SimulationDailyStats.stat_date <= end_date + ).order_by(SimulationDailyStats.stat_date).all() + + # 查询总体统计 + total_transactions = SimulationTransaction.query.filter_by(account_id=account.id).count() + win_transactions = SimulationTransaction.query.filter( + SimulationTransaction.account_id == account.id, + SimulationTransaction.transaction_type == 'SELL' + ).all() + + win_count = 0 + total_profit = Decimal('0') + for trans in win_transactions: + # 查找对应的买入记录计算盈亏 + position = SimulationPosition.query.filter_by( + account_id=account.id, + stock_code=trans.stock_code + ).first() + if position and trans.transaction_price > position.avg_cost: + win_count += 1 + profit = (trans.transaction_price - position.avg_cost) * trans.transaction_qty if position else 0 + total_profit += profit + + # 构建日收益曲线 + daily_returns = [] + for stat in daily_stats: + daily_returns.append({ + 'date': stat.stat_date.isoformat(), + 'daily_profit': float(stat.daily_profit), + 'daily_profit_rate': float(stat.daily_profit_rate), + 'total_profit': float(stat.total_profit), + 'total_profit_rate': float(stat.total_profit_rate), + 'closing_assets': float(stat.closing_assets) + }) + + return jsonify({ + 'success': True, + 'data': { + 'summary': { + 'total_transactions': total_transactions, + 'win_count': win_count, + 'win_rate': (win_count / len(win_transactions) * 100) if win_transactions else 0, + 'total_profit': float(total_profit), + 'average_profit_per_trade': float(total_profit / len(win_transactions)) if win_transactions else 0 + }, + 'daily_returns': daily_returns + } + }) + + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + + +@app.route('/api/simulation/t1-settlement', methods=['POST']) +@login_required +def trigger_t1_settlement(): + """手动触发T+1结算""" + try: + # 导入后台处理器的函数 + from simulation_background_processor import process_t1_settlement + + # 执行T+1结算 + process_t1_settlement() + + return jsonify({ + 'success': True, + 'message': 'T+1结算执行成功' + }) + + except Exception as e: + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + + +@app.route('/api/simulation/debug-positions', methods=['GET']) +@login_required +def debug_positions(): + """调试接口:查看持仓数据""" + try: + account = get_or_create_simulation_account(current_user.id) + + positions = SimulationPosition.query.filter_by(account_id=account.id).all() + + positions_data = [] + for position in positions: + positions_data.append({ + 'stock_code': position.stock_code, + 'stock_name': position.stock_name, + 'position_qty': position.position_qty, + 'available_qty': position.available_qty, + 'frozen_qty': position.frozen_qty, + 'avg_cost': float(position.avg_cost), + 'current_price': float(position.current_price or 0) + }) + + return jsonify({ + 'success': True, + 'data': positions_data + }) + + except Exception as e: + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + + +@app.route('/api/simulation/debug-transactions', methods=['GET']) +@login_required +def debug_transactions(): + """调试接口:查看成交记录数据""" + try: + account = get_or_create_simulation_account(current_user.id) + + transactions = SimulationTransaction.query.filter_by(account_id=account.id).all() + + transactions_data = [] + for trans in transactions: + transactions_data.append({ + 'id': trans.id, + 'transaction_no': trans.transaction_no, + 'stock_code': trans.stock_code, + 'stock_name': trans.stock_name, + 'transaction_type': trans.transaction_type, + 'transaction_price': float(trans.transaction_price), + 'transaction_qty': trans.transaction_qty, + 'transaction_amount': float(trans.transaction_amount), + 'commission': float(trans.commission), + 'stamp_tax': float(trans.stamp_tax), + 'transfer_fee': float(trans.transfer_fee), + 'total_fee': float(trans.total_fee), + 'transaction_time': trans.transaction_time.isoformat(), + 'settlement_date': trans.settlement_date.isoformat() if trans.settlement_date else None + }) + + return jsonify({ + 'success': True, + 'data': transactions_data, + 'count': len(transactions_data) + }) + + except Exception as e: + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + + +@app.route('/api/simulation/daily-settlement', methods=['POST']) +@login_required +def trigger_daily_settlement(): + """手动触发日结算""" + try: + # 导入后台处理器的函数 + from simulation_background_processor import generate_daily_stats + + # 执行日结算 + generate_daily_stats() + + return jsonify({ + 'success': True, + 'message': '日结算执行成功' + }) + + except Exception as e: + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + + +@app.route('/api/simulation/reset', methods=['POST']) +@login_required +def reset_simulation_account(): + """重置模拟账户""" + try: + account = SimulationAccount.query.filter_by(user_id=current_user.id).first() + + if account: + # 删除所有相关数据 + SimulationPosition.query.filter_by(account_id=account.id).delete() + SimulationOrder.query.filter_by(account_id=account.id).delete() + SimulationTransaction.query.filter_by(account_id=account.id).delete() + SimulationDailyStats.query.filter_by(account_id=account.id).delete() + + # 重置账户数据 + account.available_cash = account.initial_capital + account.frozen_cash = Decimal('0') + account.position_value = Decimal('0') + account.total_assets = account.initial_capital + account.total_profit = Decimal('0') + account.total_profit_rate = Decimal('0') + account.daily_profit = Decimal('0') + account.daily_profit_rate = Decimal('0') + account.updated_at = beijing_now() + + db.session.commit() + + return jsonify({ + 'success': True, + 'message': '模拟账户已重置' + }) + + except Exception as e: + db.session.rollback() + return jsonify({'success': False, 'error': str(e)}), 500 + + +if __name__ == '__main__': + # 创建数据库表 + with app.app_context(): + try: + db.create_all() + # 安全地初始化订阅套餐 + initialize_subscription_plans_safe() + except Exception as e: + app.logger.error(f"数据库初始化失败: {e}") + + # 初始化事件轮询机制(WebSocket 推送) + initialize_event_polling() + + # 使用 socketio.run 替代 app.run 以支持 WebSocket + socketio.run(app, host='0.0.0.0', port=5001, debug=False, allow_unsafe_werkzeug=True) \ No newline at end of file From 8315aac4d933519d79716200143a3eba8558c20b Mon Sep 17 00:00:00 2001 From: zzlgreat Date: Fri, 14 Nov 2025 15:14:23 +0800 Subject: [PATCH 2/5] update ui --- app.py | 32 ++++++++++++++++++++++---------- 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/app.py b/app.py index bd9d5431..0384f2c2 100755 --- a/app.py +++ b/app.py @@ -68,6 +68,17 @@ def load_trading_days(): print(f"加载交易日数据失败: {e}") +def row_to_dict(row): + """ + 将 SQLAlchemy Row 对象转换为字典 + 兼容 SQLAlchemy 1.4+ 版本 + """ + if row is None: + return None + # 使用 _mapping 属性来访问列数据 + return dict(row._mapping) + + def get_trading_day_near_date(target_date): """ 获取距离目标日期最近的交易日 @@ -5642,7 +5653,8 @@ def get_stock_basic_info(stock_code): # 转换为字典 basic_info = {} - for key, value in zip(result.keys(), result): + result_dict = row_to_dict(result) + for key, value in result_dict.items(): if isinstance(value, datetime): basic_info[key] = value.strftime('%Y-%m-%d') elif isinstance(value, Decimal): @@ -5685,7 +5697,7 @@ def get_stock_announcements(stock_code): announcements = [] for row in result: announcement = {} - for key, value in zip(row.keys(), row): + for key, value in row_to_dict(row).items(): if value is None: announcement[key] = None elif isinstance(value, datetime): @@ -5734,7 +5746,7 @@ def get_stock_disclosure_schedule(stock_code): schedules = [] for row in result: schedule = {} - for key, value in zip(row.keys(), row): + for key, value in row_to_dict(row).items(): if value is None: schedule[key] = None elif isinstance(value, datetime): @@ -5815,7 +5827,7 @@ def get_stock_actual_control(stock_code): control_info = [] for row in result: control_record = {} - for key, value in zip(row.keys(), row): + for key, value in row_to_dict(row).items(): if value is None: control_record[key] = None elif isinstance(value, datetime): @@ -5864,7 +5876,7 @@ def get_stock_concentration(stock_code): concentration_info = [] for row in result: concentration_record = {} - for key, value in zip(row.keys(), row): + for key, value in row_to_dict(row).items(): if value is None: concentration_record[key] = None elif isinstance(value, datetime): @@ -5933,7 +5945,7 @@ def get_stock_management(stock_code): management_info = [] for row in result: management_record = {} - for key, value in zip(row.keys(), row): + for key, value in row_to_dict(row).items(): if value is None: management_record[key] = None elif isinstance(value, datetime): @@ -5992,7 +6004,7 @@ def get_stock_top_circulation_shareholders(stock_code): shareholders_info = [] for row in result: shareholder_record = {} - for key, value in zip(row.keys(), row): + for key, value in row_to_dict(row).items(): if value is None: shareholder_record[key] = None elif isinstance(value, datetime): @@ -6051,7 +6063,7 @@ def get_stock_top_shareholders(stock_code): shareholders_info = [] for row in result: shareholder_record = {} - for key, value in zip(row.keys(), row): + for key, value in row_to_dict(row).items(): if value is None: shareholder_record[key] = None elif isinstance(value, datetime): @@ -6102,7 +6114,7 @@ def get_stock_branches(stock_code): branches_info = [] for row in result: branch_record = {} - for key, value in zip(row.keys(), row): + for key, value in row_to_dict(row).items(): if value is None: branch_record[key] = None elif isinstance(value, datetime): @@ -6169,7 +6181,7 @@ def get_stock_patents(stock_code): patents_info = [] for row in result: patent_record = {} - for key, value in zip(row.keys(), row): + for key, value in row_to_dict(row).items(): if value is None: patent_record[key] = None elif isinstance(value, datetime): From fc9b4e6257483256c0e7e7d50772b67a413d5a85 Mon Sep 17 00:00:00 2001 From: zzlgreat Date: Fri, 14 Nov 2025 15:20:58 +0800 Subject: [PATCH 3/5] update ui --- app.py | 60 ++++++++++++++++++++++++++++------------------------------ 1 file changed, 29 insertions(+), 31 deletions(-) diff --git a/app.py b/app.py index 0384f2c2..2f633395 100755 --- a/app.py +++ b/app.py @@ -8657,7 +8657,7 @@ def get_stock_info(seccode): """) with engine.connect() as conn: - result = conn.execute(query, seccode=seccode).fetchone() + result = conn.execute(query, {'seccode': seccode}).fetchone() if not result: return jsonify({ @@ -8681,7 +8681,7 @@ def get_stock_info(seccode): """) with engine.connect() as conn: - forecast_result = conn.execute(forecast_query, seccode=seccode).fetchone() + forecast_result = conn.execute(forecast_query, {'seccode': seccode}).fetchone() data = { 'stock_code': result.SECCODE, @@ -8843,7 +8843,7 @@ def get_balance_sheet(seccode): """) with engine.connect() as conn: - result = conn.execute(query, seccode=seccode, limit=limit) + result = conn.execute(query, {'seccode': seccode, 'limit': limit}) data = [] for row in result: @@ -9034,7 +9034,7 @@ def get_income_statement(seccode): """) with engine.connect() as conn: - result = conn.execute(query, seccode=seccode, limit=limit) + result = conn.execute(query, {'seccode': seccode, 'limit': limit}) data = [] for row in result: @@ -9244,7 +9244,7 @@ def get_cashflow(seccode): """) with engine.connect() as conn: - result = conn.execute(query, seccode=seccode, limit=limit) + result = conn.execute(query, {'seccode': seccode, 'limit': limit}) data = [] for row in result: @@ -9480,7 +9480,7 @@ def get_financial_metrics(seccode): """) with engine.connect() as conn: - result = conn.execute(query, seccode=seccode, limit=limit) + result = conn.execute(query, {'seccode': seccode, 'limit': limit}) data = [] for row in result: @@ -9621,7 +9621,7 @@ def get_main_business(seccode): """) with engine.connect() as conn: - periods = conn.execute(period_query, seccode=seccode, limit=limit).fetchall() + periods = conn.execute(period_query, {'seccode': seccode, 'limit': limit}).fetchall() # 产品分类数据 product_data = [] @@ -9640,7 +9640,7 @@ def get_main_business(seccode): """) with engine.connect() as conn: - result = conn.execute(query, seccode=seccode, enddate=period[0]) + result = conn.execute(query, {'seccode': seccode, 'enddate': period[0]}) # Convert result to list to allow multiple iterations rows = list(result) @@ -9690,7 +9690,7 @@ def get_main_business(seccode): """) with engine.connect() as conn: - result = conn.execute(query, seccode=seccode, enddate=period[0]) + result = conn.execute(query, {'seccode': seccode, 'enddate': period[0]}) # Convert result to list to allow multiple iterations rows = list(result) @@ -9752,7 +9752,7 @@ def get_forecast(seccode): """) with engine.connect() as conn: - forecast_result = conn.execute(forecast_query, seccode=seccode) + forecast_result = conn.execute(forecast_query, {'seccode': seccode}) forecast_data = [] for row in forecast_result: @@ -9794,7 +9794,7 @@ def get_forecast(seccode): """) with engine.connect() as conn: - pretime_result = conn.execute(pretime_query, seccode=seccode) + pretime_result = conn.execute(pretime_query, {'seccode': seccode}) pretime_data = [] for row in pretime_result: @@ -9894,7 +9894,7 @@ def get_industry_rank(seccode): # 获取多个报告期的数据 with engine.connect() as conn: - result = conn.execute(query, seccode=seccode, limit_total=limit * 4) + result = conn.execute(query, {'seccode': seccode, 'limit_total': limit * 4}) # 按报告期和行业级别组织数据 data_by_period = {} @@ -10015,7 +10015,7 @@ def get_period_comparison(seccode): """) with engine.connect() as conn: - result = conn.execute(query, seccode=seccode, periods=periods) + result = conn.execute(query, {'seccode': seccode, 'periods': periods}) data = [] for row in result: @@ -10140,7 +10140,7 @@ def get_trade_data(seccode): """) with engine.connect() as conn: - result = conn.execute(query, seccode=seccode, end_date=end_date, days=days) + result = conn.execute(query, {'seccode': seccode, 'end_date': end_date, 'days': days}) data = [] for row in result: @@ -10217,7 +10217,7 @@ def get_funding_data(seccode): """) with engine.connect() as conn: - result = conn.execute(query, seccode=seccode, days=days) + result = conn.execute(query, {'seccode': seccode, 'days': days}) data = [] for row in result: @@ -10276,7 +10276,7 @@ def get_bigdeal_data(seccode): """) with engine.connect() as conn: - result = conn.execute(query, seccode=seccode, days=days) + result = conn.execute(query, {'seccode': seccode, 'days': days}) data = [] for row in result: @@ -10351,7 +10351,7 @@ def get_unusual_data(seccode): """) with engine.connect() as conn: - result = conn.execute(query, seccode=seccode) + result = conn.execute(query, {'seccode': seccode}) data = [] for row in result: @@ -10430,7 +10430,7 @@ def get_pledge_data(seccode): """) with engine.connect() as conn: - result = conn.execute(query, seccode=seccode) + result = conn.execute(query, {'seccode': seccode}) data = [] for row in result: @@ -10488,11 +10488,11 @@ def get_market_summary(seccode): """) with engine.connect() as conn: - trade_result = conn.execute(trade_query, seccode=seccode).fetchone() + trade_result = conn.execute(trade_query, {'seccode': seccode}).fetchone() with engine.connect() as conn: - funding_result = conn.execute(funding_query, seccode=seccode).fetchone() + funding_result = conn.execute(funding_query, {'seccode': seccode}).fetchone() with engine.connect() as conn: - pledge_result = conn.execute(pledge_query, seccode=seccode).fetchone() + pledge_result = conn.execute(pledge_query, {'seccode': seccode}).fetchone() summary = { 'stock_code': seccode, @@ -11051,7 +11051,7 @@ def get_comprehensive_analysis(company_code): """) with engine.connect() as conn: - qualitative_result = conn.execute(qualitative_query, company_code=company_code).fetchone() + qualitative_result = conn.execute(qualitative_query, {'company_code': company_code}).fetchone() # 获取业务板块分析 segments_query = text(""" @@ -11069,7 +11069,7 @@ def get_comprehensive_analysis(company_code): """) with engine.connect() as conn: - segments_result = conn.execute(segments_query, company_code=company_code).fetchall() + segments_result = conn.execute(segments_query, {'company_code': company_code}).fetchall() # 获取竞争地位数据 - 最新一期 competitive_query = text(""" @@ -11095,7 +11095,7 @@ def get_comprehensive_analysis(company_code): """) with engine.connect() as conn: - competitive_result = conn.execute(competitive_query, company_code=company_code).fetchone() + competitive_result = conn.execute(competitive_query, {'company_code': company_code}).fetchone() # 获取业务结构数据 - 最新一期 business_structure_query = text(""" @@ -11123,7 +11123,7 @@ def get_comprehensive_analysis(company_code): """) with engine.connect() as conn: - business_structure_result = conn.execute(business_structure_query, company_code=company_code).fetchall() + business_structure_result = conn.execute(business_structure_query, {'company_code': company_code}).fetchall() # 构建返回数据 response_data = { @@ -11261,7 +11261,7 @@ def get_value_chain_analysis(company_code): """) with engine.connect() as conn: - nodes_result = conn.execute(nodes_query, company_code=company_code).fetchall() + nodes_result = conn.execute(nodes_query, {'company_code': company_code}).fetchall() # 获取产业链流向数据 flows_query = text(""" @@ -11282,7 +11282,7 @@ def get_value_chain_analysis(company_code): """) with engine.connect() as conn: - flows_result = conn.execute(flows_query, company_code=company_code).fetchall() + flows_result = conn.execute(flows_query, {'company_code': company_code}).fetchall() # 构建节点数据结构 nodes_by_level = {} @@ -11393,7 +11393,7 @@ def get_key_factors_timeline(company_code): """) with engine.connect() as conn: - categories_result = conn.execute(categories_query, company_code=company_code).fetchall() + categories_result = conn.execute(categories_query, {'company_code': company_code}).fetchall() # 获取关键因素详情 factors_query = text(""" @@ -11479,9 +11479,7 @@ def get_key_factors_timeline(company_code): """) with engine.connect() as conn: - timeline_result = conn.execute(timeline_query, - company_code=company_code, - limit=event_limit).fetchall() + timeline_result = conn.execute(timeline_query, {'company_code': company_code, 'limit': event_limit}).fetchall() # 构建关键因素数据结构 key_factors_data = {} From 8828340d8c7e8dd2e5ea3e7308d5fb08fc9f9fe1 Mon Sep 17 00:00:00 2001 From: zzlgreat Date: Fri, 14 Nov 2025 15:36:02 +0800 Subject: [PATCH 4/5] update ui --- __pycache__/app.cpython-310.pyc | Bin 326459 -> 0 bytes __pycache__/config.cpython-311.pyc | Bin 3522 -> 0 bytes __pycache__/mcp_server.cpython-310.pyc | Bin 36620 -> 0 bytes __pycache__/wechat_pay.cpython-310.pyc | Bin 11311 -> 0 bytes __pycache__/wechat_pay_config.cpython-310.pyc | Bin 1826 -> 0 bytes app.py | 4 ++-- 6 files changed, 2 insertions(+), 2 deletions(-) delete mode 100644 __pycache__/app.cpython-310.pyc delete mode 100755 __pycache__/config.cpython-311.pyc delete mode 100644 __pycache__/mcp_server.cpython-310.pyc delete mode 100644 __pycache__/wechat_pay.cpython-310.pyc delete mode 100644 __pycache__/wechat_pay_config.cpython-310.pyc diff --git a/__pycache__/app.cpython-310.pyc b/__pycache__/app.cpython-310.pyc deleted file mode 100644 index 31faf388e93f011f5fe3ffdad543a0e6e40e3bc7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 326459 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 diff --git a/__pycache__/config.cpython-311.pyc b/__pycache__/config.cpython-311.pyc deleted file mode 100755 index c1e8c3f89c1264680f6391c13489773392facd45..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3522 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 diff --git a/__pycache__/wechat_pay.cpython-310.pyc b/__pycache__/wechat_pay.cpython-310.pyc deleted file mode 100644 index 2a421f0521435f9d9825315379d1adfc065a817b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 11311 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 diff --git a/__pycache__/wechat_pay_config.cpython-310.pyc b/__pycache__/wechat_pay_config.cpython-310.pyc deleted file mode 100644 index cabda7019d53fbf538c7814ff3bea8f41287bdc3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1826 zcmb7^%}?A$6u@oom)8jifg}yI2Op{^TUEPVFc8tE3JPhA0ar6us4tkdP+J&>v>12~60dp!dgbnTxS9Gg+7PUmy1M5nmtm^=rO%zlV-}LXj4$GFxHeoV}4NY|c7&@`csOt?37L z?#I%#(XI8IGjXG^`iv{)mznZQn0vWL-J&uPwQmSjHtCfYCx|;g-c0p*D1p(EA z-Ox~9FAP-26EfBeJt1WkIi+CL5Q!bn;`Bh(%VXWR-yIUV3?r*ICK8vVl!-gEjA|%Y zi_0pBlgLW_X^52Q#d4QqBxEU-YM110EAVl1ofU3vy3q3J*%)hurEFGIkQGkLT|`G< ztq5PRqGStVRx-LQ7HqG_sTo7*=od{bWkq$RQx%Q=EVej9lMr_MG92*k6M$YA+8Jp2 zZ4jN*5M=;0Rn!cSGfQg+2B{e!XcqDd{-Em*fxp`Ihkbu!*MHpgv&4_0h)V^AXtWP# zIXuJ+(q9E+cTT|Ab$Q{HA-zw zVSTjee2bmGUb_9L1VO0Q7^9J!u90G3z7B%?E*wu`<*|1~3d^JR!lXSt-^j-z)}f1M z&wSK$K|J5|xp?MW>jy0#TAXf30CcYc^bo?ZiYz9hTS1qnFz!=yL$?Acti}RXSkZM8 z>skeT$qE2#KT#UV$vN9JPyOPaUPONXvlqIUz;y1FBEedP#FIUiJ63{LYyxw-zu)J@8!Z?dFI^Nux~E% zJ5`JKfU)OR%r{|GU7UCBJ#{7)o#|h8P+EZ7;hm6IS6iVfMMM#0WKq-(!lKRq1^Pm@VE_OC diff --git a/app.py b/app.py index 2f633395..e681df2f 100755 --- a/app.py +++ b/app.py @@ -10988,7 +10988,7 @@ def get_rise_analysis(seccode): """) with engine.connect() as conn: - result = conn.execute(query, **params).fetchall() + result = conn.execute(query, params).fetchall() # 格式化数据 rise_analysis_data = [] @@ -11459,7 +11459,7 @@ def get_key_factors_timeline(company_code): """) with engine.connect() as conn: - factors_result = conn.execute(factors_query, **params).fetchall() + factors_result = conn.execute(factors_query, params).fetchall() # 获取发展时间线事件 timeline_query = text(""" From e428caf57838951cfc1f334ff42da4ace851177f Mon Sep 17 00:00:00 2001 From: zzlgreat Date: Fri, 14 Nov 2025 15:50:21 +0800 Subject: [PATCH 5/5] update ui --- src/components/Citation/CitedContent.js | 27 ++++++++++++++++--- .../DynamicNewsDetail/StockListItem.js | 1 + .../components/HistoricalEvents.js | 1 + .../components/TransmissionChainAnalysis.js | 7 +++-- 4 files changed, 30 insertions(+), 6 deletions(-) diff --git a/src/components/Citation/CitedContent.js b/src/components/Citation/CitedContent.js index a9726ab3..0f1426ef 100644 --- a/src/components/Citation/CitedContent.js +++ b/src/components/Citation/CitedContent.js @@ -21,6 +21,8 @@ const { Text } = Typography; * @param {Object} props.prefixStyle - 前缀标签的自定义样式(可选) * @param {boolean} props.showAIBadge - 是否显示右上角 AI 标识,默认 true(可选) * @param {Object} props.containerStyle - 容器额外样式(可选) + * @param {string} props.textColor - 文本颜色,默认自动判断背景色(可选) + * @param {string} props.titleColor - 标题颜色,默认继承 textColor(可选) * * @example * */ const CitedContent = ({ @@ -38,7 +41,9 @@ const CitedContent = ({ prefix = '', prefixStyle = {}, showAIBadge = true, - containerStyle = {} + containerStyle = {}, + textColor, + titleColor }) => { // 处理数据 const processed = processCitationData(data); @@ -52,6 +57,19 @@ const CitedContent = ({ return null; } + // 自动判断文本颜色:如果容器背景是深色,使用浅色文本 + const bgColor = containerStyle.backgroundColor; + const isDarkBg = bgColor && ( + bgColor.includes('rgba(0,0,0') || + bgColor.includes('rgba(0, 0, 0') || + bgColor === 'transparent' || + bgColor.includes('#1A202C') || + bgColor.includes('#171923') + ); + + const finalTextColor = textColor || (isDarkBg ? '#E2E8F0' : '#262626'); + const finalTitleColor = titleColor || finalTextColor; + return (
- + {title}
@@ -105,6 +123,7 @@ const CitedContent = ({ fontWeight: 'bold', display: 'inline', marginRight: 4, + color: finalTextColor, ...prefixStyle }}> {prefix} @@ -114,7 +133,7 @@ const CitedContent = ({ {processed.segments.map((segment, index) => ( {/* 文本片段 */} - + {segment.text} @@ -126,7 +145,7 @@ const CitedContent = ({ {/* 在片段之间添加逗号分隔符(最后一个不加) */} {index < processed.segments.length - 1 && ( - + )} ))} diff --git a/src/views/Community/components/DynamicNewsDetail/StockListItem.js b/src/views/Community/components/DynamicNewsDetail/StockListItem.js index d10adfb5..f5a361e4 100644 --- a/src/views/Community/components/DynamicNewsDetail/StockListItem.js +++ b/src/views/Community/components/DynamicNewsDetail/StockListItem.js @@ -311,6 +311,7 @@ const StockListItem = ({ data={stock.relation_desc} title="" showAIBadge={true} + textColor={PROFESSIONAL_COLORS.text.primary} containerStyle={{ backgroundColor: 'transparent', borderRadius: '0', diff --git a/src/views/EventDetail/components/HistoricalEvents.js b/src/views/EventDetail/components/HistoricalEvents.js index 48cc4204..e5539ea4 100644 --- a/src/views/EventDetail/components/HistoricalEvents.js +++ b/src/views/EventDetail/components/HistoricalEvents.js @@ -344,6 +344,7 @@ const HistoricalEvents = ({ data={content} title="" showAIBadge={true} + textColor={PROFESSIONAL_COLORS.text.primary} containerStyle={{ backgroundColor: useColorModeValue('#f7fafc', 'rgba(45, 55, 72, 0.6)'), borderRadius: '8px', diff --git a/src/views/EventDetail/components/TransmissionChainAnalysis.js b/src/views/EventDetail/components/TransmissionChainAnalysis.js index 34009aa0..ab6896a8 100644 --- a/src/views/EventDetail/components/TransmissionChainAnalysis.js +++ b/src/views/EventDetail/components/TransmissionChainAnalysis.js @@ -972,6 +972,7 @@ const TransmissionChainAnalysis = ({ eventId }) => { ) : ( `${selectedNode.extra.description}(AI合成)` @@ -1081,7 +1082,8 @@ const TransmissionChainAnalysis = ({ eventId }) => { data={parent.transmission_mechanism} title="" prefix="机制:" - prefixStyle={{ fontSize: 12, color: '#666', fontWeight: 'bold' }} + prefixStyle={{ fontSize: 12, color: PROFESSIONAL_COLORS.text.secondary, fontWeight: 'bold' }} + textColor={PROFESSIONAL_COLORS.text.primary} containerStyle={{ marginTop: 8 }} showAIBadge={false} /> @@ -1136,7 +1138,8 @@ const TransmissionChainAnalysis = ({ eventId }) => { data={child.transmission_mechanism} title="" prefix="机制:" - prefixStyle={{ fontSize: 12, color: '#666', fontWeight: 'bold' }} + prefixStyle={{ fontSize: 12, color: PROFESSIONAL_COLORS.text.secondary, fontWeight: 'bold' }} + textColor={PROFESSIONAL_COLORS.text.primary} containerStyle={{ marginTop: 8 }} showAIBadge={false} />