diff --git a/app.py b/app.py index fb9336fb..6784d046 100755 --- a/app.py +++ b/app.py @@ -1,11 +1,11 @@ -# ============ Eventlet/Gevent Monkey Patching(必须放在所有 import 之前!)============ -# 用于支持 Gunicorn + eventlet/gevent 异步模式,使 requests 等阻塞调用变为非阻塞 +# ============ Eventlet/Gevent Monkey Patching锛堝繀椤绘斁鍦ㄦ墍鏈?import 涔嬪墠锛侊級============ +# 鐢ㄤ簬鏀寔 Gunicorn + eventlet/gevent 寮傛妯″紡锛屼娇 requests 绛夐樆濉炶皟鐢ㄥ彉涓洪潪闃诲 import os import sys def _detect_async_env(): - """检测当前异步环境""" - # 检测 eventlet + """妫€娴嬪綋鍓嶅紓姝ョ幆澧?"" + # 妫€娴?eventlet try: import eventlet if hasattr(eventlet, 'is_monkey_patched') and eventlet.is_monkey_patched('socket'): @@ -14,7 +14,7 @@ def _detect_async_env(): except ImportError: pass - # 检测 gevent + # 妫€娴?gevent try: from gevent import monkey if monkey.is_module_patched('socket'): @@ -27,18 +27,18 @@ def _detect_async_env(): _async_env = _detect_async_env() -# Gunicorn eventlet worker 会自动 patch,这里只打印状态 +# Gunicorn eventlet worker 浼氳嚜鍔?patch锛岃繖閲屽彧鎵撳嵃鐘舵€? if _async_env == 'eventlet_patched': - print("✅ Eventlet monkey patching 已由 Worker 启用") + print("鉁?Eventlet monkey patching 宸茬敱 Worker 鍚敤") elif _async_env == 'gevent_patched': - print("✅ Gevent monkey patching 已由 Worker 启用") + print("鉁?Gevent monkey patching 宸茬敱 Worker 鍚敤") elif _async_env == 'eventlet_available': - print("📡 Eventlet 可用,等待 Gunicorn worker 初始化") + print("馃摗 Eventlet 鍙敤锛岀瓑寰?Gunicorn worker 鍒濆鍖?) elif _async_env == 'gevent_available': - print("📡 Gevent 可用,等待 Gunicorn worker 初始化") + print("馃摗 Gevent 鍙敤锛岀瓑寰?Gunicorn worker 鍒濆鍖?) else: - print("⚠️ 未检测到 eventlet 或 gevent,将使用 threading 模式") -# ============ Monkey Patching 检测结束 ============ + print("鈿狅笍 鏈娴嬪埌 eventlet 鎴?gevent锛屽皢浣跨敤 threading 妯″紡") +# ============ Monkey Patching 妫€娴嬬粨鏉?============ import base64 import csv @@ -89,47 +89,47 @@ 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) + # 瑙f瀽鏃ユ湡 (鏍煎紡: 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)} 个交易日数据") + print(f"鎴愬姛鍔犺浇 {len(trading_days)} 涓氦鏄撴棩鏁版嵁") except Exception as e: - print(f"加载交易日数据失败: {e}") + print(f"鍔犺浇浜ゆ槗鏃ユ暟鎹け璐? {e}") def row_to_dict(row): """ - 将 SQLAlchemy Row 对象转换为字典 - 兼容 SQLAlchemy 1.4+ 版本 + 灏?SQLAlchemy Row 瀵硅薄杞崲涓哄瓧鍏? + 鍏煎 SQLAlchemy 1.4+ 鐗堟湰 """ if row is None: return None - # 使用 _mapping 属性来访问列数据 + # 浣跨敤 _mapping 灞炴€ф潵璁块棶鍒楁暟鎹? return dict(row._mapping) def get_trading_day_near_date(target_date): """ - 获取距离目标日期最近的交易日 - 如果目标日期是交易日,返回该日期 - 如果不是,返回下一个交易日 + 鑾峰彇璺濈鐩爣鏃ユ湡鏈€杩戠殑浜ゆ槗鏃? + 濡傛灉鐩爣鏃ユ湡鏄氦鏄撴棩锛岃繑鍥炶鏃ユ湡 + 濡傛灉涓嶆槸锛岃繑鍥炰笅涓€涓氦鏄撴棩 """ if not trading_days: load_trading_days() @@ -137,37 +137,37 @@ def get_trading_day_near_date(target_date): if not trading_days: return None - # 如果目标日期是datetime,转换为date + # 濡傛灉鐩爣鏃ユ湡鏄痙atetime锛岃浆鎹负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 def get_target_and_prev_trading_day(event_datetime): """ - 根据事件时间确定目标交易日和前一交易日(用于计算涨跌幅) + 鏍规嵁浜嬩欢鏃堕棿纭畾鐩爣浜ゆ槗鏃ュ拰鍓嶄竴浜ゆ槗鏃ワ紙鐢ㄤ簬璁$畻娑ㄨ穼骞咃級 - 处理跨周末场景: - - 周五15:00后到周一15:00前,分时图显示周一行情,涨跌幅基于周五收盘价 + 澶勭悊璺ㄥ懆鏈満鏅細 + - 鍛ㄤ簲15:00鍚庡埌鍛ㄤ竴15:00鍓嶏紝鍒嗘椂鍥炬樉绀哄懆涓€琛屾儏锛屾定璺屽箙鍩轰簬鍛ㄤ簲鏀剁洏浠? - 逻辑: - - 如果事件时间在交易日的 9:00-15:00 之间,显示当天数据,涨跌幅基于前一交易日 - - 如果事件时间在交易日的 15:00 之后,显示下一个交易日数据,涨跌幅基于当天 - - 如果事件时间在非交易日(周末/节假日),显示下一个交易日数据,涨跌幅基于上一个交易日 - - 如果事件时间在交易日的 9:00 之前,显示当天数据,涨跌幅基于前一交易日 + 閫昏緫锛? + - 濡傛灉浜嬩欢鏃堕棿鍦ㄤ氦鏄撴棩鐨?9:00-15:00 涔嬮棿锛屾樉绀哄綋澶╂暟鎹紝娑ㄨ穼骞呭熀浜庡墠涓€浜ゆ槗鏃? + - 濡傛灉浜嬩欢鏃堕棿鍦ㄤ氦鏄撴棩鐨?15:00 涔嬪悗锛屾樉绀轰笅涓€涓氦鏄撴棩鏁版嵁锛屾定璺屽箙鍩轰簬褰撳ぉ + - 濡傛灉浜嬩欢鏃堕棿鍦ㄩ潪浜ゆ槗鏃ワ紙鍛ㄦ湯/鑺傚亣鏃ワ級锛屾樉绀轰笅涓€涓氦鏄撴棩鏁版嵁锛屾定璺屽箙鍩轰簬涓婁竴涓氦鏄撴棩 + - 濡傛灉浜嬩欢鏃堕棿鍦ㄤ氦鏄撴棩鐨?9:00 涔嬪墠锛屾樉绀哄綋澶╂暟鎹紝娑ㄨ穼骞呭熀浜庡墠涓€浜ゆ槗鏃? - 返回:(target_date, prev_close_date) - 分时图显示日期和涨跌幅基准日期 + 杩斿洖锛?target_date, prev_close_date) - 鍒嗘椂鍥炬樉绀烘棩鏈熷拰娑ㄨ穼骞呭熀鍑嗘棩鏈? """ if not trading_days: load_trading_days() @@ -175,36 +175,36 @@ def get_target_and_prev_trading_day(event_datetime): if not trading_days: return None, None - # 如果是datetime,提取date和time + # 濡傛灉鏄痙atetime锛屾彁鍙杁ate鍜宼ime if isinstance(event_datetime, datetime): event_date = event_datetime.date() event_time = event_datetime.time() else: event_date = event_datetime - event_time = dt_time(12, 0) # 默认中午,认为在盘中 + event_time = dt_time(12, 0) # 榛樿涓崍锛岃涓哄湪鐩樹腑 - # 检查事件日期是否是交易日 + # 妫€鏌ヤ簨浠舵棩鏈熸槸鍚︽槸浜ゆ槗鏃? is_trading_day = event_date in trading_days_set - # 收盘时间判断 + # 鏀剁洏鏃堕棿鍒ゆ柇 market_close_time = dt_time(15, 0) is_after_market = event_time > market_close_time if is_trading_day: if is_after_market: - # 交易日收盘后:显示下一个交易日,涨跌幅基于当天(即本交易日) + # 浜ゆ槗鏃ユ敹鐩樺悗锛氭樉绀轰笅涓€涓氦鏄撴棩锛屾定璺屽箙鍩轰簬褰撳ぉ锛堝嵆鏈氦鏄撴棩锛? target_date = get_trading_day_near_date(event_date + timedelta(days=1)) prev_close_date = event_date else: - # 交易日盘中或开盘前:显示当天,涨跌幅基于前一交易日 + # 浜ゆ槗鏃ョ洏涓垨寮€鐩樺墠锛氭樉绀哄綋澶╋紝娑ㄨ穼骞呭熀浜庡墠涓€浜ゆ槗鏃? target_date = event_date - # 找前一个交易日 + # 鎵惧墠涓€涓氦鏄撴棩 target_idx = trading_days.index(event_date) if event_date in trading_days else -1 prev_close_date = trading_days[target_idx - 1] if target_idx > 0 else None else: - # 非交易日(周末/节假日):显示下一个交易日,涨跌幅基于上一个交易日 + # 闈炰氦鏄撴棩锛堝懆鏈?鑺傚亣鏃ワ級锛氭樉绀轰笅涓€涓氦鏄撴棩锛屾定璺屽箙鍩轰簬涓婁竴涓氦鏄撴棩 target_date = get_trading_day_near_date(event_date) - # 找上一个交易日作为基准 + # 鎵句笂涓€涓氦鏄撴棩浣滀负鍩哄噯 prev_close_date = None for td in reversed(trading_days): if td < event_date: @@ -214,7 +214,7 @@ def get_target_and_prev_trading_day(event_datetime): return target_date, prev_close_date -# 应用启动时加载交易日数据 +# 搴旂敤鍚姩鏃跺姞杞戒氦鏄撴棩鏁版嵁 load_trading_days() engine = create_engine( @@ -227,7 +227,7 @@ engine = create_engine( max_overflow=20 ) -# Elasticsearch 客户端初始化 +# Elasticsearch 瀹㈡埛绔垵濮嬪寲 es_client = Elasticsearch( hosts=["http://222.128.1.157:19200"], request_timeout=30, @@ -237,30 +237,30 @@ es_client = Elasticsearch( app = Flask(__name__) -# ============ ProxyFix 配置(信任反向代理头)============ -# 重要:解决 Nginx 反向代理后 Flask 无法识别 HTTPS 的问题 -# 这会导致 SESSION_COOKIE_SECURE=True 时 cookie 被清除 -# x_for=1: 信任 1 层代理的 X-Forwarded-For 头(获取真实客户端 IP) -# x_proto=1: 信任 1 层代理的 X-Forwarded-Proto 头(识别 HTTPS) -# x_host=1: 信任 1 层代理的 X-Forwarded-Host 头(获取原始 Host) -# x_prefix=1: 信任 1 层代理的 X-Forwarded-Prefix 头(URL 前缀) +# ============ ProxyFix 閰嶇疆锛堜俊浠诲弽鍚戜唬鐞嗗ご锛?=========== +# 閲嶈锛氳В鍐?Nginx 鍙嶅悜浠g悊鍚?Flask 鏃犳硶璇嗗埆 HTTPS 鐨勯棶棰? +# 杩欎細瀵艰嚧 SESSION_COOKIE_SECURE=True 鏃?cookie 琚竻闄? +# x_for=1: 淇′换 1 灞備唬鐞嗙殑 X-Forwarded-For 澶达紙鑾峰彇鐪熷疄瀹㈡埛绔?IP锛? +# x_proto=1: 淇′换 1 灞備唬鐞嗙殑 X-Forwarded-Proto 澶达紙璇嗗埆 HTTPS锛? +# x_host=1: 淇′换 1 灞備唬鐞嗙殑 X-Forwarded-Host 澶达紙鑾峰彇鍘熷 Host锛? +# x_prefix=1: 淇′换 1 灞備唬鐞嗙殑 X-Forwarded-Prefix 澶达紙URL 鍓嶇紑锛? app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1) -print("✅ ProxyFix 已配置,Flask 将信任反向代理头(X-Forwarded-Proto 等)") +print("鉁?ProxyFix 宸查厤缃紝Flask 灏嗕俊浠诲弽鍚戜唬鐞嗗ご锛圶-Forwarded-Proto 绛夛級") -# ============ Redis 连接配置(支持环境变量覆盖) ============ +# ============ Redis 杩炴帴閰嶇疆锛堟敮鎸佺幆澧冨彉閲忚鐩栵級 ============ _REDIS_HOST = os.environ.get('REDIS_HOST', 'localhost') _REDIS_PORT = int(os.environ.get('REDIS_PORT', 6379)) -_REDIS_PASSWORD = os.environ.get('REDIS_PASSWORD', 'VF_Redis_2024') # Redis 密码(安全加固) +_REDIS_PASSWORD = os.environ.get('REDIS_PASSWORD', 'VF_Redis_2024') # Redis 瀵嗙爜锛堝畨鍏ㄥ姞鍥猴級 redis_client = redis.Redis(host=_REDIS_HOST, port=_REDIS_PORT, db=0, password=_REDIS_PASSWORD, decode_responses=True) -print(f"📦 Redis 配置: {_REDIS_HOST}:{_REDIS_PORT}/db=0 (已启用密码认证)") +print(f"馃摝 Redis 閰嶇疆: {_REDIS_HOST}:{_REDIS_PORT}/db=0 (宸插惎鐢ㄥ瘑鐮佽璇?") -# ============ 验证码 Redis 存储(支持多进程/多 Worker) ============ +# ============ 楠岃瘉鐮?Redis 瀛樺偍锛堟敮鎸佸杩涚▼/澶?Worker锛?============ VERIFICATION_CODE_PREFIX = "vf_code:" -VERIFICATION_CODE_EXPIRE = 300 # 验证码过期时间(5分钟) +VERIFICATION_CODE_EXPIRE = 300 # 楠岃瘉鐮佽繃鏈熸椂闂达紙5鍒嗛挓锛? def set_verification_code(key, code, expires_in=VERIFICATION_CODE_EXPIRE): - """存储验证码到 Redis""" + """瀛樺偍楠岃瘉鐮佸埌 Redis""" try: data = { 'code': code, @@ -273,39 +273,39 @@ def set_verification_code(key, code, expires_in=VERIFICATION_CODE_EXPIRE): ) return True except Exception as e: - print(f"❌ Redis 存储验证码失败: {e}") + print(f"鉂?Redis 瀛樺偍楠岃瘉鐮佸け璐? {e}") return False def get_verification_code(key): - """从 Redis 获取验证码""" + """浠?Redis 鑾峰彇楠岃瘉鐮?"" try: data = redis_client.get(f"{VERIFICATION_CODE_PREFIX}{key}") if data: return json.loads(data) return None except Exception as e: - print(f"❌ Redis 获取验证码失败: {e}") + print(f"鉂?Redis 鑾峰彇楠岃瘉鐮佸け璐? {e}") return None def delete_verification_code(key): - """从 Redis 删除验证码""" + """浠?Redis 鍒犻櫎楠岃瘉鐮?"" try: redis_client.delete(f"{VERIFICATION_CODE_PREFIX}{key}") except Exception as e: - print(f"❌ Redis 删除验证码失败: {e}") + print(f"鉂?Redis 鍒犻櫎楠岃瘉鐮佸け璐? {e}") -print(f"📦 验证码存储: Redis, 过期时间: {VERIFICATION_CODE_EXPIRE}秒") +print(f"馃摝 楠岃瘉鐮佸瓨鍌? Redis, 杩囨湡鏃堕棿: {VERIFICATION_CODE_EXPIRE}绉?) -# ============ 微信登录 Session 管理(Redis 存储,支持多进程) ============ -WECHAT_SESSION_EXPIRE = 300 # Session 过期时间(5分钟) +# ============ 寰俊鐧诲綍 Session 绠$悊锛圧edis 瀛樺偍锛屾敮鎸佸杩涚▼锛?============ +WECHAT_SESSION_EXPIRE = 300 # Session 杩囨湡鏃堕棿锛?鍒嗛挓锛? WECHAT_SESSION_PREFIX = "wechat_session:" def set_wechat_session(state, data): - """存储微信登录 session 到 Redis""" + """瀛樺偍寰俊鐧诲綍 session 鍒?Redis""" try: redis_client.setex( f"{WECHAT_SESSION_PREFIX}{state}", @@ -314,29 +314,29 @@ def set_wechat_session(state, data): ) return True except Exception as e: - print(f"❌ Redis 存储 wechat session 失败: {e}") + print(f"鉂?Redis 瀛樺偍 wechat session 澶辫触: {e}") return False def get_wechat_session(state): - """从 Redis 获取微信登录 session""" + """浠?Redis 鑾峰彇寰俊鐧诲綍 session""" try: data = redis_client.get(f"{WECHAT_SESSION_PREFIX}{state}") if data: return json.loads(data) return None except Exception as e: - print(f"❌ Redis 获取 wechat session 失败: {e}") + print(f"鉂?Redis 鑾峰彇 wechat session 澶辫触: {e}") return None def update_wechat_session(state, updates): - """更新微信登录 session(合并更新)""" + """鏇存柊寰俊鐧诲綍 session锛堝悎骞舵洿鏂帮級""" try: data = get_wechat_session(state) if data: data.update(updates) - # 获取剩余 TTL,保持原有过期时间 + # 鑾峰彇鍓╀綑 TTL锛屼繚鎸佸師鏈夎繃鏈熸椂闂? ttl = redis_client.ttl(f"{WECHAT_SESSION_PREFIX}{state}") if ttl > 0: redis_client.setex( @@ -345,258 +345,128 @@ def update_wechat_session(state, updates): json.dumps(data) ) else: - # 如果 TTL 无效,使用默认过期时间 + # 濡傛灉 TTL 鏃犳晥锛屼娇鐢ㄩ粯璁よ繃鏈熸椂闂? set_wechat_session(state, data) return True return False except Exception as e: - print(f"❌ Redis 更新 wechat session 失败: {e}") + print(f"鉂?Redis 鏇存柊 wechat session 澶辫触: {e}") return False def delete_wechat_session(state): - """删除微信登录 session""" + """鍒犻櫎寰俊鐧诲綍 session""" try: redis_client.delete(f"{WECHAT_SESSION_PREFIX}{state}") return True except Exception as e: - print(f"❌ Redis 删除 wechat session 失败: {e}") + print(f"鉂?Redis 鍒犻櫎 wechat session 澶辫触: {e}") return False def wechat_session_exists(state): - """检查微信登录 session 是否存在""" + """妫€鏌ュ井淇$櫥褰?session 鏄惁瀛樺湪""" try: return redis_client.exists(f"{WECHAT_SESSION_PREFIX}{state}") > 0 except Exception as e: - print(f"❌ Redis 检查 wechat session 失败: {e}") + print(f"鉂?Redis 妫€鏌?wechat session 澶辫触: {e}") return False -# ============ 微信登录 Session 管理结束 ============ +# ============ 寰俊鐧诲綍 Session 绠$悊缁撴潫 ============ -# ============ 股票数据 Redis 缓存(股票名称 + 前收盘价) ============ -STOCK_NAME_PREFIX = "vf:stock:name:" # 股票名称缓存前缀 -STOCK_NAME_EXPIRE = 86400 # 股票名称缓存24小时 -PREV_CLOSE_PREFIX = "vf:stock:prev_close:" # 前收盘价缓存前缀 -PREV_CLOSE_EXPIRE = 86400 # 前收盘价缓存24小时(当日有效) - - -def get_cached_stock_names(base_codes): +# ============ 鑲$エ鏁版嵁鏌ヨ锛堢洿鎺ユ煡 MySQL锛?============ +def get_stock_names(base_codes): """ - 批量获取股票名称(优先从 Redis 缓存读取) - :param base_codes: 股票代码列表(不带后缀,如 ['600000', '000001']) + 鎵归噺鑾峰彇鑲$エ鍚嶇О锛堢洿鎺ヤ粠 MySQL 鏌ヨ锛? + :param base_codes: 鑲$エ浠g爜鍒楄〃锛堜笉甯﹀悗缂€锛屽 ['600000', '000001']锛? :return: dict {code: name} """ if not base_codes: return {} result = {} - missing_codes = [] - try: - # 批量从 Redis 获取 - pipe = redis_client.pipeline() - for code in base_codes: - pipe.get(f"{STOCK_NAME_PREFIX}{code}") - cached_values = pipe.execute() + with engine.connect() as conn: + placeholders = ','.join([f':code{i}' for i in range(len(base_codes))]) + params = {f'code{i}': code for i, code in enumerate(base_codes)} + db_result = conn.execute(text( + f"SELECT SECCODE, SECNAME FROM ea_stocklist WHERE SECCODE IN ({placeholders})" + ), params).fetchall() - for code, cached_name in zip(base_codes, cached_values): - if cached_name: - result[code] = cached_name - else: - missing_codes.append(code) + for row in db_result: + code, name = row[0], row[1] + result[code] = name except Exception as e: - print(f"⚠️ Redis 批量获取股票名称失败: {e},降级为数据库查询") - missing_codes = base_codes - - # 从数据库查询缺失的股票名称 - if missing_codes: - try: - with engine.connect() as conn: - placeholders = ','.join([f':code{i}' for i in range(len(missing_codes))]) - params = {f'code{i}': code for i, code in enumerate(missing_codes)} - db_result = conn.execute(text( - f"SELECT SECCODE, SECNAME FROM ea_stocklist WHERE SECCODE IN ({placeholders})" - ), params).fetchall() - - # 写入 Redis 缓存 - pipe = redis_client.pipeline() - for row in db_result: - code, name = row[0], row[1] - result[code] = name - pipe.setex(f"{STOCK_NAME_PREFIX}{code}", STOCK_NAME_EXPIRE, name) - - try: - pipe.execute() - except Exception as e: - print(f"⚠️ Redis 缓存股票名称失败: {e}") - except Exception as e: - print(f"❌ 数据库查询股票名称失败: {e}") + print(f"鉂?鏁版嵁搴撴煡璇㈣偂绁ㄥ悕绉板け璐? {e}") return result -def get_cached_prev_close(base_codes, trade_date_str): +def get_prev_close(base_codes, trade_date_str): """ - 批量获取前收盘价(优先从 Redis 缓存读取) - :param base_codes: 股票代码列表(不带后缀,如 ['600000', '000001']) - :param trade_date_str: 交易日期字符串(格式 YYYYMMDD) + 鎵归噺鑾峰彇鍓嶆敹鐩樹环锛堢洿鎺ヤ粠 MySQL 鏌ヨ锛? + :param base_codes: 鑲$エ浠g爜鍒楄〃锛堜笉甯﹀悗缂€锛屽 ['600000', '000001']锛? + :param trade_date_str: 浜ゆ槗鏃ユ湡瀛楃涓诧紙鏍煎紡 YYYYMMDD锛? :return: dict {code: close_price} """ if not base_codes or not trade_date_str: return {} result = {} - missing_codes = [] - try: - # 批量从 Redis 获取(缓存键包含日期,确保不会跨日混用) - pipe = redis_client.pipeline() - for code in base_codes: - pipe.get(f"{PREV_CLOSE_PREFIX}{trade_date_str}:{code}") - cached_values = pipe.execute() + with engine.connect() as conn: + placeholders = ','.join([f':code{i}' for i in range(len(base_codes))]) + params = {f'code{i}': code for i, code in enumerate(base_codes)} + params['trade_date'] = trade_date_str + db_result = conn.execute(text(f""" + SELECT SECCODE, F007N as close_price + FROM ea_trade + WHERE SECCODE IN ({placeholders}) + AND TRADEDATE = :trade_date + AND F007N > 0 + """), params).fetchall() - for code, cached_price in zip(base_codes, cached_values): - if cached_price: - result[code] = float(cached_price) - else: - missing_codes.append(code) + for row in db_result: + code, close_price = row[0], float(row[1]) if row[1] else None + if close_price: + result[code] = close_price except Exception as e: - print(f"⚠️ Redis 批量获取前收盘价失败: {e},降级为数据库查询") - missing_codes = base_codes - - # 从数据库查询缺失的前收盘价 - if missing_codes: - try: - with engine.connect() as conn: - placeholders = ','.join([f':code{i}' for i in range(len(missing_codes))]) - params = {f'code{i}': code for i, code in enumerate(missing_codes)} - params['trade_date'] = trade_date_str - db_result = conn.execute(text(f""" - SELECT SECCODE, F007N as close_price - FROM ea_trade - WHERE SECCODE IN ({placeholders}) - AND TRADEDATE = :trade_date - AND F007N > 0 - """), params).fetchall() - - # 写入 Redis 缓存 - pipe = redis_client.pipeline() - for row in db_result: - code, close_price = row[0], float(row[1]) if row[1] else None - if close_price: - result[code] = close_price - pipe.setex(f"{PREV_CLOSE_PREFIX}{trade_date_str}:{code}", PREV_CLOSE_EXPIRE, str(close_price)) - - try: - pipe.execute() - except Exception as e: - print(f"⚠️ Redis 缓存前收盘价失败: {e}") - except Exception as e: - print(f"❌ 数据库查询前收盘价失败: {e}") + print(f"鉂?鏁版嵁搴撴煡璇㈠墠鏀剁洏浠峰け璐? {e}") return result +# ============ 鑲$エ鏁版嵁鏌ヨ缁撴潫 ============ - -def preload_stock_cache(): - """ - 预热股票缓存(定时任务,每天 9:25 执行) - - 批量加载所有股票名称 - - 批量加载前一交易日收盘价 - """ - from datetime import datetime, timedelta - print(f"[缓存预热] 开始预热股票缓存... {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") - - try: - # 1. 预热股票名称(全量加载) - with engine.connect() as conn: - result = conn.execute(text("SELECT SECCODE, SECNAME FROM ea_stocklist")).fetchall() - pipe = redis_client.pipeline() - count = 0 - for row in result: - code, name = row[0], row[1] - if code and name: - pipe.setex(f"{STOCK_NAME_PREFIX}{code}", STOCK_NAME_EXPIRE, name) - count += 1 - pipe.execute() - print(f"[缓存预热] 股票名称: {count} 条已加载到 Redis") - - # 2. 预热前收盘价(获取前一交易日) - # 使用全局 trading_days 获取前一交易日 - today = datetime.now().date() - today_str = today.strftime('%Y-%m-%d') - - prev_trading_day = None - if 'trading_days' in globals() and trading_days: - # 找到今天之前最近的交易日 - for td in reversed(trading_days): - if td < today_str: - prev_trading_day = td - break - - if prev_trading_day: - prev_date_str = prev_trading_day.replace('-', '') # YYYYMMDD 格式 - with engine.connect() as conn: - result = conn.execute(text(""" - SELECT SECCODE, F007N as close_price - FROM ea_trade - WHERE TRADEDATE = :trade_date AND F007N > 0 - """), {'trade_date': prev_date_str}).fetchall() - - pipe = redis_client.pipeline() - count = 0 - for row in result: - code, close_price = row[0], row[1] - if code and close_price: - pipe.setex(f"{PREV_CLOSE_PREFIX}{prev_date_str}:{code}", PREV_CLOSE_EXPIRE, str(close_price)) - count += 1 - pipe.execute() - print(f"[缓存预热] 前收盘价({prev_trading_day}): {count} 条已加载到 Redis") - else: - print(f"[缓存预热] 未找到前一交易日,跳过前收盘价预热") - - print(f"[缓存预热] 预热完成 ✅ {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") - - except Exception as e: - print(f"[缓存预热] 预热失败 ❌: {e}") - import traceback - traceback.print_exc() - - -print(f"📦 股票缓存: Redis, 名称过期 {STOCK_NAME_EXPIRE}秒, 收盘价过期 {PREV_CLOSE_EXPIRE}秒") -# ============ 股票数据 Redis 缓存结束 ============ - -# 腾讯云短信配置 +# 鑵捐浜戠煭淇¢厤缃? SMS_SECRET_ID = 'AKID2we9TacdTAhCjCSYTErHVimeJo9Yr00s' SMS_SECRET_KEY = 'pMlBWijlkgT9fz5ziEXdWEnAPTJzRfkf' SMS_SDK_APP_ID = "1400972398" -SMS_SIGN_NAME = "价值前沿科技" -SMS_TEMPLATE_REGISTER = "2386557" # 注册模板 -SMS_TEMPLATE_LOGIN = "2386540" # 登录模板 +SMS_SIGN_NAME = "浠峰€煎墠娌跨鎶€" +SMS_TEMPLATE_REGISTER = "2386557" # 娉ㄥ唽妯℃澘 +SMS_TEMPLATE_LOGIN = "2386540" # 鐧诲綍妯℃澘 -# 微信开放平台配置(PC 扫码登录用) +# 寰俊寮€鏀惧钩鍙伴厤缃紙PC 鎵爜鐧诲綍鐢級 WECHAT_OPEN_APPID = 'wxa8d74c47041b5f87' WECHAT_OPEN_APPSECRET = 'eedef95b11787fd7ca7f1acc6c9061bc' -# 微信公众号配置(H5 网页授权用) +# 寰俊鍏紬鍙烽厤缃紙H5 缃戦〉鎺堟潈鐢級 WECHAT_MP_APPID = 'wx8afd36f7c7b21ba0' WECHAT_MP_APPSECRET = 'c3ec5a227ddb26ad8a1d4c55efa1cf86' -# 微信小程序配置(H5 跳转小程序用) +# 寰俊灏忕▼搴忛厤缃紙H5 璺宠浆灏忕▼搴忕敤锛? WECHAT_MINIPROGRAM_APPID = 'wx0edeaab76d4fa414' WECHAT_MINIPROGRAM_APPSECRET = os.environ.get('WECHAT_MINIPROGRAM_APPSECRET', '0d0c70084f05a8c1411f6b89da7e815d') WECHAT_MINIPROGRAM_ORIGINAL_ID = 'gh_fd2fd8dd2fb5' -# Redis 缓存键前缀(微信 token) +# Redis 缂撳瓨閿墠缂€锛堝井淇?token锛? WECHAT_ACCESS_TOKEN_PREFIX = "wechat:access_token:" WECHAT_JSAPI_TICKET_PREFIX = "wechat:jsapi_ticket:" -# 微信回调地址 +# 寰俊鍥炶皟鍦板潃 WECHAT_REDIRECT_URI = 'https://api.valuefrontier.cn/api/auth/wechat/callback' -# 前端域名(用于登录成功后重定向) +# 鍓嶇鍩熷悕锛堢敤浜庣櫥褰曟垚鍔熷悗閲嶅畾鍚戯級 FRONTEND_URL = 'https://valuefrontier.cn' -# 邮件服务配置(QQ企业邮箱) +# 閭欢鏈嶅姟閰嶇疆锛圦Q浼佷笟閭锛? MAIL_SERVER = 'smtp.exmail.qq.com' MAIL_PORT = 465 MAIL_USE_SSL = True @@ -605,70 +475,70 @@ MAIL_USERNAME = 'admin@valuefrontier.cn' MAIL_PASSWORD = 'QYncRu6WUdASvTg4' MAIL_DEFAULT_SENDER = 'admin@valuefrontier.cn' -# Session和安全配置 -# 使用固定的 SECRET_KEY,确保服务器重启后用户登录状态不丢失 -# 重要:生产环境请使用环境变量配置,不要硬编码 +# Session鍜屽畨鍏ㄩ厤缃? +# 浣跨敤鍥哄畾鐨?SECRET_KEY锛岀‘淇濇湇鍔″櫒閲嶅惎鍚庣敤鎴风櫥褰曠姸鎬佷笉涓㈠け +# 閲嶈锛氱敓浜х幆澧冭浣跨敤鐜鍙橀噺閰嶇疆锛屼笉瑕佺‖缂栫爜 import os app.config['SECRET_KEY'] = os.environ.get('FLASK_SECRET_KEY', 'vf_production_secret_key_2024_valuefrontier_cn') -# ============ Redis Session 配置(支持多进程/多 Worker)============ -# 使用 Redis 存储 session,确保多个 Gunicorn worker 共享 session -# 通过环境变量控制是否启用 Redis Session(排查问题时可以禁用) +# ============ Redis Session 閰嶇疆锛堟敮鎸佸杩涚▼/澶?Worker锛?=========== +# 浣跨敤 Redis 瀛樺偍 session锛岀‘淇濆涓?Gunicorn worker 鍏变韩 session +# 閫氳繃鐜鍙橀噺鎺у埗鏄惁鍚敤 Redis Session锛堟帓鏌ラ棶棰樻椂鍙互绂佺敤锛? USE_REDIS_SESSION = os.environ.get('USE_REDIS_SESSION', 'true').lower() == 'true' if USE_REDIS_SESSION: app.config['SESSION_TYPE'] = 'redis' - app.config['SESSION_REDIS'] = redis.Redis(host=_REDIS_HOST, port=_REDIS_PORT, db=1, password=_REDIS_PASSWORD) # db=1 用于 session + app.config['SESSION_REDIS'] = redis.Redis(host=_REDIS_HOST, port=_REDIS_PORT, db=1, password=_REDIS_PASSWORD) # db=1 鐢ㄤ簬 session app.config['SESSION_PERMANENT'] = True - app.config['SESSION_USE_SIGNER'] = True # 对 session cookie 签名,提高安全性 - app.config['SESSION_KEY_PREFIX'] = 'vf_session:' # session key 前缀 - app.config['SESSION_REFRESH_EACH_REQUEST'] = True # 每次请求都刷新 session TTL - # 注意:Flask-Session 使用 PERMANENT_SESSION_LIFETIME 作为 Redis TTL(下面已配置为7天) - print(f"📦 Flask Session 配置: Redis {_REDIS_HOST}:{_REDIS_PORT}/db=1, 过期时间: 7天") + app.config['SESSION_USE_SIGNER'] = True # 瀵?session cookie 绛惧悕锛屾彁楂樺畨鍏ㄦ€? + app.config['SESSION_KEY_PREFIX'] = 'vf_session:' # session key 鍓嶇紑 + app.config['SESSION_REFRESH_EACH_REQUEST'] = True # 姣忔璇锋眰閮藉埛鏂?session TTL + # 娉ㄦ剰锛欶lask-Session 浣跨敤 PERMANENT_SESSION_LIFETIME 浣滀负 Redis TTL锛堜笅闈㈠凡閰嶇疆涓?澶╋級 + print(f"馃摝 Flask Session 閰嶇疆: Redis {_REDIS_HOST}:{_REDIS_PORT}/db=1, 杩囨湡鏃堕棿: 7澶?) else: - # 使用默认的 cookie session(单 Worker 模式可用) - app.config['SESSION_TYPE'] = 'null' # 禁用服务端 session,使用 cookie - print(f"📦 Flask Session 配置: Cookie 模式(单 Worker)") -# ============ Redis Session 配置结束 ============ + # 浣跨敤榛樿鐨?cookie session锛堝崟 Worker 妯″紡鍙敤锛? + app.config['SESSION_TYPE'] = 'null' # 绂佺敤鏈嶅姟绔?session锛屼娇鐢?cookie + print(f"馃摝 Flask Session 閰嶇疆: Cookie 妯″紡锛堝崟 Worker锛?) +# ============ Redis Session 閰嶇疆缁撴潫 ============ -# Cookie 配置 - 重要:HTTPS 环境必须设置 SECURE=True -app.config['SESSION_COOKIE_SECURE'] = True # 生产环境使用 HTTPS,必须为 True -app.config['SESSION_COOKIE_HTTPONLY'] = True # 生产环境应设为True,防止XSS攻击 -# SameSite='None' 允许微信内置浏览器在 OAuth 重定向后携带 Cookie -# 必须配合 Secure=True 使用(已在上面配置) -app.config['SESSION_COOKIE_SAMESITE'] = 'None' # 微信浏览器兼容性:必须为 None -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'] = True # 生产环境使用 HTTPS,必须为 True -app.config['REMEMBER_COOKIE_HTTPONLY'] = True # 防止XSS攻击 -app.config['REMEMBER_COOKIE_SAMESITE'] = 'None' # 微信浏览器兼容性 +# Cookie 閰嶇疆 - 閲嶈锛欻TTPS 鐜蹇呴』璁剧疆 SECURE=True +app.config['SESSION_COOKIE_SECURE'] = True # 鐢熶骇鐜浣跨敤 HTTPS锛屽繀椤讳负 True +app.config['SESSION_COOKIE_HTTPONLY'] = True # 鐢熶骇鐜搴旇涓篢rue锛岄槻姝SS鏀诲嚮 +# SameSite='None' 鍏佽寰俊鍐呯疆娴忚鍣ㄥ湪 OAuth 閲嶅畾鍚戝悗鎼哄甫 Cookie +# 蹇呴』閰嶅悎 Secure=True 浣跨敤锛堝凡鍦ㄤ笂闈㈤厤缃級 +app.config['SESSION_COOKIE_SAMESITE'] = 'None' # 寰俊娴忚鍣ㄥ吋瀹规€э細蹇呴』涓?None +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'] = True # 鐢熶骇鐜浣跨敤 HTTPS锛屽繀椤讳负 True +app.config['REMEMBER_COOKIE_HTTPONLY'] = True # 闃叉XSS鏀诲嚮 +app.config['REMEMBER_COOKIE_SAMESITE'] = 'None' # 寰俊娴忚鍣ㄥ吋瀹规€? -# 初始化 Flask-Session(仅在启用 Redis Session 时) +# 鍒濆鍖?Flask-Session锛堜粎鍦ㄥ惎鐢?Redis Session 鏃讹級 if USE_REDIS_SESSION: Session(app) - print("✅ Flask-Session (Redis) 已初始化,支持多 Worker 共享 session") + print("鉁?Flask-Session (Redis) 宸插垵濮嬪寲锛屾敮鎸佸 Worker 鍏变韩 session") - # 确保 session 使用永久模式并刷新 TTL(解决 Flask-Session 0.8.0 TTL 问题) + # 纭繚 session 浣跨敤姘镐箙妯″紡骞跺埛鏂?TTL锛堣В鍐?Flask-Session 0.8.0 TTL 闂锛? @app.before_request def refresh_session_ttl(): """ - 每次请求开始时: - 1. 确保 session 是永久的,使用 PERMANENT_SESSION_LIFETIME 作为 TTL - 2. 标记 session 为已修改,触发 Redis TTL 刷新 + 姣忔璇锋眰寮€濮嬫椂锛? + 1. 纭繚 session 鏄案涔呯殑锛屼娇鐢?PERMANENT_SESSION_LIFETIME 浣滀负 TTL + 2. 鏍囪 session 涓哄凡淇敼锛岃Е鍙?Redis TTL 鍒锋柊 - 注意:必须在 before_request 中设置 session.modified = True - 因为 Flask-Session 的 save_session 在 after_request 之前执行 - 如果在 after_request 中设置,TTL 不会被刷新 + 娉ㄦ剰锛氬繀椤诲湪 before_request 涓缃?session.modified = True + 鍥犱负 Flask-Session 鐨?save_session 鍦?after_request 涔嬪墠鎵ц + 濡傛灉鍦?after_request 涓缃紝TTL 涓嶄細琚埛鏂? """ from flask import session session.permanent = True - # 只有当 session 中有用户数据时才刷新 TTL(避免为匿名用户创建 session) + # 鍙湁褰?session 涓湁鐢ㄦ埛鏁版嵁鏃舵墠鍒锋柊 TTL锛堥伩鍏嶄负鍖垮悕鐢ㄦ埛鍒涘缓 session锛? if session.get('user_id') or session.get('_user_id'): session.modified = True -# 配置邮件 +# 閰嶇疆閭欢 app.config['MAIL_SERVER'] = MAIL_SERVER app.config['MAIL_PORT'] = MAIL_PORT app.config['MAIL_USE_SSL'] = MAIL_USE_SSL @@ -677,25 +547,25 @@ app.config['MAIL_USERNAME'] = MAIL_USERNAME app.config['MAIL_PASSWORD'] = MAIL_PASSWORD app.config['MAIL_DEFAULT_SENDER'] = MAIL_DEFAULT_SENDER -# 允许前端跨域访问 - 修复CORS配置 +# 鍏佽鍓嶇璺ㄥ煙璁块棶 - 淇CORS閰嶇疆 try: CORS(app, origins=["http://localhost:3000", "http://127.0.0.1:3000", "http://localhost:5173", "https://valuefrontier.cn", "http://valuefrontier.cn", - "https://www.valuefrontier.cn", "http://www.valuefrontier.cn"], # 明确指定允许的源 + "https://www.valuefrontier.cn", "http://www.valuefrontier.cn"], # 鏄庣‘鎸囧畾鍏佽鐨勬簮 methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"], allow_headers=["Content-Type", "Authorization", "X-Requested-With", "Cache-Control"], - supports_credentials=True, # 允许携带凭据 + supports_credentials=True, # 鍏佽鎼哄甫鍑嵁 expose_headers=["Content-Type", "Authorization"]) except ImportError: - pass # 如果未安装flask_cors则跳过 + pass # 濡傛灉鏈畨瑁協lask_cors鍒欒烦杩? -# 初始化 Flask-Login +# 鍒濆鍖?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) # 记住登录持续时间 +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 @@ -723,17 +593,17 @@ CACHE_DIR.mkdir(exist_ok=True) def beijing_now(): - # 使用 pytz 处理时区,但返回 naive datetime(适合数据库存储) + # 浣跨敤 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 jsonify({'success': False, 'error': '鏈櫥褰?}), 401 return f(*args, **kwargs) return decorated_function @@ -745,19 +615,19 @@ MEMORY_CHECK_INTERVAL = 300 MAX_CACHE_ITEMS = 50 db = SQLAlchemy(app) -# 初始化邮件服务 +# 鍒濆鍖栭偖浠舵湇鍔? mail = Mail(app) -# 初始化 Flask-SocketIO(用于实时事件推送) -# 支持通过环境变量指定模式: SOCKETIO_ASYNC_MODE=gevent|threading +# 鍒濆鍖?Flask-SocketIO锛堢敤浜庡疄鏃朵簨浠舵帹閫侊級 +# 鏀寔閫氳繃鐜鍙橀噺鎸囧畾妯″紡: SOCKETIO_ASYNC_MODE=gevent|threading def _detect_async_mode(): - """检测可用的异步模式""" - # 允许通过环境变量强制指定 + """妫€娴嬪彲鐢ㄧ殑寮傛妯″紡""" + # 鍏佽閫氳繃鐜鍙橀噺寮哄埗鎸囧畾 forced_mode = os.environ.get('SOCKETIO_ASYNC_MODE', '').lower() if forced_mode in ('gevent', 'threading', 'eventlet'): return forced_mode - # 检测 gevent 是否已被 patch(Gunicorn -k gevent 会自动 patch) + # 妫€娴?gevent 鏄惁宸茶 patch锛圙unicorn -k gevent 浼氳嚜鍔?patch锛? try: from gevent import monkey if monkey.is_module_patched('socket'): @@ -765,19 +635,19 @@ def _detect_async_mode(): except ImportError: pass - # 默认使用 threading(最稳定,配合 simple-websocket) + # 榛樿浣跨敤 threading锛堟渶绋冲畾锛岄厤鍚?simple-websocket锛? return 'threading' _async_mode = _detect_async_mode() -print(f"📡 Flask-SocketIO async_mode: {_async_mode}") +print(f"馃摗 Flask-SocketIO async_mode: {_async_mode}") -# Redis 消息队列 URL(支持多 Worker 之间的消息同步) -# 使用 127.0.0.1 而非 localhost,避免 eventlet DNS 问题 -# 格式: redis://:password@host:port/db +# Redis 娑堟伅闃熷垪 URL锛堟敮鎸佸 Worker 涔嬮棿鐨勬秷鎭悓姝ワ級 +# 浣跨敤 127.0.0.1 鑰岄潪 localhost锛岄伩鍏?eventlet DNS 闂 +# 鏍煎紡: redis://:password@host:port/db SOCKETIO_MESSAGE_QUEUE = os.environ.get('SOCKETIO_REDIS_URL', f'redis://:{_REDIS_PASSWORD}@{_REDIS_HOST}:{_REDIS_PORT}/2') -# 检测是否需要启用消息队列 -# 默认启用(多 Worker 模式需要,单 Worker 模式也兼容) +# 妫€娴嬫槸鍚﹂渶瑕佸惎鐢ㄦ秷鎭槦鍒? +# 榛樿鍚敤锛堝 Worker 妯″紡闇€瑕侊紝鍗?Worker 妯″紡涔熷吋瀹癸級 _use_message_queue = os.environ.get('SOCKETIO_USE_QUEUE', 'true').lower() == 'true' socketio = SocketIO( @@ -786,130 +656,130 @@ socketio = SocketIO( "https://valuefrontier.cn", "http://valuefrontier.cn"], async_mode=_async_mode, message_queue=SOCKETIO_MESSAGE_QUEUE if _use_message_queue else None, - manage_session=False, # 让 Flask-Session 管理 session,避免与 SocketIO 冲突 + manage_session=False, # 璁?Flask-Session 绠$悊 session锛岄伩鍏嶄笌 SocketIO 鍐茬獊 logger=True, engineio_logger=False, - ping_timeout=120, # 心跳超时时间(秒),客户端120秒内无响应才断开 - ping_interval=25 # 心跳检测间隔(秒),每25秒发送一次ping + ping_timeout=120, # 蹇冭烦瓒呮椂鏃堕棿锛堢锛夛紝瀹㈡埛绔?20绉掑唴鏃犲搷搴旀墠鏂紑 + ping_interval=25 # 蹇冭烦妫€娴嬮棿闅旓紙绉掞級锛屾瘡25绉掑彂閫佷竴娆ing ) if _use_message_queue: - print(f"✅ Flask-SocketIO 已配置 Redis 消息队列: {SOCKETIO_MESSAGE_QUEUE}") + print(f"鉁?Flask-SocketIO 宸查厤缃?Redis 娑堟伅闃熷垪: {SOCKETIO_MESSAGE_QUEUE}") else: - print(f"📡 Flask-SocketIO 单 Worker 模式(无消息队列)") + print(f"馃摗 Flask-SocketIO 鍗?Worker 妯″紡锛堟棤娑堟伅闃熷垪锛?) @login_manager.user_loader def load_user(user_id): - """Flask-Login 用户加载回调""" + """Flask-Login 鐢ㄦ埛鍔犺浇鍥炶皟""" try: return User.query.get(int(user_id)) except Exception as e: - app.logger.error(f"用户加载错误: {e}") + app.logger.error(f"鐢ㄦ埛鍔犺浇閿欒: {e}") return None -# 全局错误处理器 - 确保API接口始终返回JSON +# 鍏ㄥ眬閿欒澶勭悊鍣?- 纭繚API鎺ュ彛濮嬬粓杩斿洖JSON @app.errorhandler(404) def not_found_error(error): - """404错误处理""" + """404閿欒澶勭悊""" if request.path.startswith('/api/'): - return jsonify({'success': False, 'error': '接口不存在'}), 404 + return jsonify({'success': False, 'error': '鎺ュ彛涓嶅瓨鍦?}), 404 return error @app.errorhandler(500) def internal_error(error): - """500错误处理""" + """500閿欒澶勭悊""" db.session.rollback() if request.path.startswith('/api/'): - return jsonify({'success': False, 'error': '服务器内部错误'}), 500 + return jsonify({'success': False, 'error': '鏈嶅姟鍣ㄥ唴閮ㄩ敊璇?}), 500 return error @app.errorhandler(405) def method_not_allowed_error(error): - """405错误处理""" + """405閿欒澶勭悊""" if request.path.startswith('/api/'): - return jsonify({'success': False, 'error': '请求方法不被允许'}), 405 + 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 + # 鍐呭 + 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) # 是否置顶 + 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) @@ -918,24 +788,24 @@ class User(UserMixin, db.Model): 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) # 微信号 + 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) @@ -944,32 +814,32 @@ class User(UserMixin, db.Model): 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_confirmed = db.Column(db.Boolean, nullable=True, default=False) # 娉ㄦ剰锛氬師琛ㄤ腑鏄痓lob锛岃繖閲屾敼涓築oolean鏇村悎鐞? 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 + # 寰俊鐧诲綍鐩稿叧瀛楁 + 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 @@ -977,102 +847,102 @@ class User(UserMixin, db.Model): self.phone = phone if password: self.set_password(password) - self.nickname = username # 默认昵称为用户名 + 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'): - # 确保昵称编码正确且长度合理 + # 纭繚鏄电О缂栫爜姝g‘涓旈暱搴﹀悎鐞? nickname = self._sanitize_nickname(wechat_info['nickname']) self.nickname = nickname db.session.commit() def _sanitize_nickname(self, nickname): - """清理和验证昵称""" + """娓呯悊鍜岄獙璇佹樀绉?"" if not nickname: - return '微信用户' + return '寰俊鐢ㄦ埛' try: - # 确保是正确的UTF-8字符串 + # 纭繚鏄纭殑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 = '微信用户' + sanitized = '寰俊鐢ㄦ埛' return sanitized except Exception as e: - return '微信用户' + return '寰俊鐢ㄦ埛' def unbind_wechat(self): - """解绑微信账号""" + """瑙g粦寰俊璐﹀彿""" 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, @@ -1100,7 +970,7 @@ class User(UserMixin, db.Model): 'is_authenticated': True } - # 获取用户订阅信息(从 user_subscriptions 表) + # 鑾峰彇鐢ㄦ埛璁㈤槄淇℃伅锛堜粠 user_subscriptions 琛級 subscription = UserSubscription.query.filter_by(user_id=self.id).first() if subscription: data.update({ @@ -1112,7 +982,7 @@ class User(UserMixin, db.Model): 'auto_renewal': subscription.auto_renewal }) else: - # 无订阅时使用默认值 + # 鏃犺闃呮椂浣跨敤榛樿鍊? data.update({ 'subscription_type': 'free', 'subscription_status': 'inactive', @@ -1122,7 +992,7 @@ class User(UserMixin, db.Model): 'auto_renewal': False }) - # 敏感信息只在需要时包含 + # 鏁忔劅淇℃伅鍙湪闇€瑕佹椂鍖呭惈 if include_sensitive: data.update({ 'email': self.email, @@ -1146,7 +1016,7 @@ class User(UserMixin, db.Model): return data def to_public_dict(self): - """公开信息字典(用于显示给其他用户)""" + """鍏紑淇℃伅瀛楀吀锛堢敤浜庢樉绀虹粰鍏朵粬鐢ㄦ埛锛?"" return { 'id': self.id, 'username': self.username, @@ -1165,7 +1035,7 @@ class User(UserMixin, db.Model): @staticmethod def find_by_login_info(login_info): - """根据登录信息查找用户(支持用户名、邮箱、手机号)""" + """鏍规嵁鐧诲綍淇℃伅鏌ユ壘鐢ㄦ埛锛堟敮鎸佺敤鎴峰悕銆侀偖绠便€佹墜鏈哄彿锛?"" return User.query.filter( db.or_( User.username == login_info, @@ -1176,27 +1046,27 @@ class User(UserMixin, db.Model): @staticmethod def find_by_wechat_openid(open_id): - """根据微信OpenID查找用户""" + """鏍规嵁寰俊OpenID鏌ユ壘鐢ㄦ埛""" return User.query.filter_by(wechat_open_id=open_id).first() @staticmethod def find_by_wechat_unionid(union_id): - """根据微信UnionID查找用户""" + """鏍规嵁寰俊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): @@ -1204,10 +1074,10 @@ class User(UserMixin, db.Model): # ============================================ -# 订阅功能模块(安全版本 - 独立表) +# 璁㈤槄鍔熻兘妯″潡锛堝畨鍏ㄧ増鏈?- 鐙珛琛級 # ============================================ class UserSubscription(db.Model): - """用户订阅表 - 独立于现有User表""" + """鐢ㄦ埛璁㈤槄琛?- 鐙珛浜庣幇鏈塙ser琛?"" __tablename__ = 'user_subscriptions' id = db.Column(db.Integer, primary_key=True, autoincrement=True) @@ -1259,7 +1129,7 @@ class UserSubscription(db.Model): class SubscriptionPlan(db.Model): - """订阅套餐表""" + """璁㈤槄濂楅琛?"" __tablename__ = 'subscription_plans' id = db.Column(db.Integer, primary_key=True, autoincrement=True) @@ -1269,13 +1139,13 @@ class SubscriptionPlan(db.Model): 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}] + 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(如果存在) + # 瑙f瀽pricing_options锛堝鏋滃瓨鍦級 pricing_opts = None if self.pricing_options: try: @@ -1283,21 +1153,21 @@ class SubscriptionPlan(db.Model): except: pricing_opts = None - # 如果没有pricing_options,则从monthly_price和yearly_price生成默认选项 + # 濡傛灉娌℃湁pricing_options锛屽垯浠巑onthly_price鍜寉early_price鐢熸垚榛樿閫夐」 if not pricing_opts: pricing_opts = [ { 'months': 1, 'price': float(self.monthly_price) if self.monthly_price else 0, - 'label': '月付', + 'label': '鏈堜粯', 'cycle_key': 'monthly' }, { 'months': 12, 'price': float(self.yearly_price) if self.yearly_price else 0, - 'label': '年付', + 'label': '骞翠粯', 'cycle_key': 'yearly', - 'discount_percent': 20 # 年付默认20%折扣 + 'discount_percent': 20 # 骞翠粯榛樿20%鎶樻墸 } ] @@ -1308,7 +1178,7 @@ class SubscriptionPlan(db.Model): '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, # 新增:灵活计费周期选项 + 'pricing_options': pricing_opts, # 鏂板锛氱伒娲昏璐瑰懆鏈熼€夐」 'features': json.loads(self.features) if self.features else [], 'is_active': self.is_active, 'sort_order': self.sort_order @@ -1316,7 +1186,7 @@ class SubscriptionPlan(db.Model): class PaymentOrder(db.Model): - """支付订单表""" + """鏀粯璁㈠崟琛?"" __tablename__ = 'payment_orders' id = db.Column(db.Integer, primary_key=True, autoincrement=True) @@ -1325,22 +1195,22 @@ class PaymentOrder(db.Model): 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) - original_amount = db.Column(db.Numeric(10, 2), nullable=True) # 原价 - discount_amount = db.Column(db.Numeric(10, 2), nullable=True, default=0) # 折扣金额 - promo_code_id = db.Column(db.Integer, db.ForeignKey('promo_codes.id'), nullable=True) # 优惠码ID - payment_method = db.Column(db.String(20), default='wechat') # 支付方式: wechat/alipay - wechat_order_id = db.Column(db.String(64), nullable=True) # 微信交易号 - alipay_trade_no = db.Column(db.String(64), nullable=True) # 支付宝交易号 + original_amount = db.Column(db.Numeric(10, 2), nullable=True) # 鍘熶环 + discount_amount = db.Column(db.Numeric(10, 2), nullable=True, default=0) # 鎶樻墸閲戦 + promo_code_id = db.Column(db.Integer, db.ForeignKey('promo_codes.id'), nullable=True) # 浼樻儬鐮両D + payment_method = db.Column(db.String(20), default='wechat') # 鏀粯鏂瑰紡: wechat/alipay + wechat_order_id = db.Column(db.String(64), nullable=True) # 寰俊浜ゆ槗鍙? + alipay_trade_no = 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) # 微信支付二维码URL - pay_url = db.Column(db.String(2000), nullable=True) # 支付宝支付链接(较长) + qr_code_url = db.Column(db.String(200), nullable=True) # 寰俊鏀粯浜岀淮鐮乁RL + pay_url = db.Column(db.String(2000), 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) - # 关联优惠码 + # 鍏宠仈浼樻儬鐮? promo_code = db.relationship('PromoCode', backref='orders', lazy=True, foreign_keys=[promo_code_id]) def __init__(self, user_id, plan_name, billing_cycle, amount, original_amount=None, discount_amount=0): @@ -1367,16 +1237,16 @@ class PaymentOrder(db.Model): def mark_as_paid(self, transaction_id, payment_method=None): """ - 标记订单为已支付 + 鏍囪璁㈠崟涓哄凡鏀粯 Args: - transaction_id: 交易号(微信或支付宝) - payment_method: 支付方式(可选,如果已设置则不覆盖) + transaction_id: 浜ゆ槗鍙凤紙寰俊鎴栨敮浠樺疂锛? + payment_method: 鏀粯鏂瑰紡锛堝彲閫夛紝濡傛灉宸茶缃垯涓嶈鐩栵級 """ self.status = 'paid' self.paid_at = beijing_now() - # 根据支付方式存储交易号 + # 鏍规嵁鏀粯鏂瑰紡瀛樺偍浜ゆ槗鍙? if payment_method: self.payment_method = payment_method @@ -1409,32 +1279,32 @@ class PaymentOrder(db.Model): 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_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格式 + # 閫傜敤鑼冨洿 + 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) @@ -1460,7 +1330,7 @@ class PromoCode(db.Model): class PromoCodeUsage(db.Model): - """优惠码使用记录表""" + """浼樻儬鐮佷娇鐢ㄨ褰曡〃""" __tablename__ = 'promo_code_usage' id = db.Column(db.Integer, primary_key=True, autoincrement=True) @@ -1474,30 +1344,30 @@ class PromoCodeUsage(db.Model): 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) @@ -1505,41 +1375,41 @@ class SubscriptionUpgrade(db.Model): 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) # 日收益率 + 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) # 最后结算日期 + 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 @@ -1547,23 +1417,23 @@ class SimulationAccount(db.Model): 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) # 今日盈亏比例 + 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) @@ -1572,7 +1442,7 @@ class SimulationPosition(db.Model): ) 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 @@ -1582,7 +1452,7 @@ class SimulationPosition(db.Model): class SimulationOrder(db.Model): - """模拟订单""" + """妯℃嫙璁㈠崟""" __tablename__ = 'simulation_orders' id = db.Column(db.Integer, primary_key=True) @@ -1592,15 +1462,15 @@ class SimulationOrder(db.Model): 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) # 总费用 + 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) @@ -1608,30 +1478,30 @@ class SimulationOrder(db.Model): cancel_time = db.Column(db.DateTime) def calculate_fees(self): - """计算交易费用""" + """璁$畻浜ゆ槗璐圭敤""" if not self.filled_amount: return 0 - # 佣金(万分之2.5,最低5元) + # 浣i噾锛堜竾鍒嗕箣2.5锛屾渶浣?鍏冿級 self.commission = max(float(self.filled_amount) * 0.00025, 5.0) - # 印花税(卖出时收取千分之1) + # 鍗拌姳绋庯紙鍗栧嚭鏃舵敹鍙栧崈鍒嗕箣1锛? if self.order_type == 'SELL': self.stamp_tax = float(self.filled_amount) * 0.001 else: self.stamp_tax = 0 - # 过户费(双向收取,万分之0.2) + # 杩囨埛璐癸紙鍙屽悜鏀跺彇锛屼竾鍒嗕箣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) @@ -1649,30 +1519,30 @@ class SimulationTransaction(db.Model): 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结算日期 + 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)) # 最大亏损 + 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__ = ( @@ -1681,7 +1551,7 @@ class SimulationDailyStats(db.Model): def get_user_subscription_safe(user_id): - """安全地获取用户订阅信息""" + """瀹夊叏鍦拌幏鍙栫敤鎴疯闃呬俊鎭?"" try: subscription = UserSubscription.query.filter_by(user_id=user_id).first() if not subscription: @@ -1690,7 +1560,7 @@ def get_user_subscription_safe(user_id): db.session.commit() return subscription except Exception as e: - # 返回默认免费版本对象 + # 杩斿洖榛樿鍏嶈垂鐗堟湰瀵硅薄 class DefaultSub: def to_dict(self): return { @@ -1707,47 +1577,47 @@ def get_user_subscription_safe(user_id): def activate_user_subscription(user_id, plan_type, billing_cycle, extend_from_now=False): """ - 激活用户订阅(新版:续费时从当前订阅结束时间开始延长) + 婵€娲荤敤鎴疯闃咃紙鏂扮増锛氱画璐规椂浠庡綋鍓嶈闃呯粨鏉熸椂闂村紑濮嬪欢闀匡級 Args: - user_id: 用户ID - plan_type: 套餐类型 (pro/max) - billing_cycle: 计费周期 (monthly/quarterly/semiannual/yearly) - extend_from_now: 废弃参数,保留以兼容(现在自动判断) + user_id: 鐢ㄦ埛ID + plan_type: 濂楅绫诲瀷 (pro/max) + billing_cycle: 璁¤垂鍛ㄦ湡 (monthly/quarterly/semiannual/yearly) + extend_from_now: 搴熷純鍙傛暟锛屼繚鐣欎互鍏煎锛堢幇鍦ㄨ嚜鍔ㄥ垽鏂級 Returns: - UserSubscription 对象 或 None + UserSubscription 瀵硅薄 鎴?None """ 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 - # 计算订阅周期天数 + # 璁$畻璁㈤槄鍛ㄦ湡澶╂暟 cycle_days_map = { 'monthly': 30, - 'quarterly': 90, # 3个月 - 'semiannual': 180, # 6个月 + 'quarterly': 90, # 3涓湀 + 'semiannual': 180, # 6涓湀 'yearly': 365 } days = cycle_days_map.get(billing_cycle, 30) now = beijing_now() - # 判断是新购还是续费 + # 鍒ゆ柇鏄柊璐繕鏄画璐? if subscription.end_date and subscription.end_date > now: - # 续费:从当前订阅结束时间开始延长 + # 缁垂锛氫粠褰撳墠璁㈤槄缁撴潫鏃堕棿寮€濮嬪欢闀? start_date = subscription.end_date end_date = start_date + timedelta(days=days) else: - # 新购或过期后重新购买:从当前时间开始 + # 鏂拌喘鎴栬繃鏈熷悗閲嶆柊璐拱锛氫粠褰撳墠鏃堕棿寮€濮? start_date = now end_date = now + timedelta(days=days) subscription.start_date = start_date @@ -1759,13 +1629,13 @@ def activate_user_subscription(user_id, plan_type, billing_cycle, extend_from_no return subscription except Exception as e: - print(f"激活订阅失败: {e}") + print(f"婵€娲昏闃呭け璐? {e}") db.session.rollback() return None def validate_promo_code(code, plan_name, billing_cycle, amount, user_id): - """验证优惠码 + """楠岃瘉浼樻儬鐮? Returns: tuple: (promo_code_obj, error_message) @@ -1774,64 +1644,64 @@ def validate_promo_code(code, plan_name, billing_cycle, amount, user_id): promo = PromoCode.query.filter_by(code=code.upper(), is_active=True).first() if not promo: - return None, "优惠码不存在或已失效" + return None, "浼樻儬鐮佷笉瀛樺湪鎴栧凡澶辨晥" - # 检查有效期 + # 妫€鏌ユ湁鏁堟湡 now = beijing_now() if now < promo.valid_from: - return None, "优惠码尚未生效" + return None, "浼樻儬鐮佸皻鏈敓鏁? if now > promo.valid_until: - return None, "优惠码已过期" + return None, "浼樻儬鐮佸凡杩囨湡" - # 检查使用次数 + # 妫€鏌ヤ娇鐢ㄦ鏁? if promo.max_uses and promo.current_uses >= promo.max_uses: - return None, "优惠码已被使用完" + 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}次)" + 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, "该优惠码不适用于此套餐" + return None, "璇ヤ紭鎯犵爜涓嶉€傜敤浜庢濂楅" except: pass - # 检查适用周期 + # 妫€鏌ラ€傜敤鍛ㄦ湡 if promo.applicable_cycles: try: applicable = json.loads(promo.applicable_cycles) if billing_cycle not in applicable: - return None, "该优惠码不适用于此计费周期" + return None, "璇ヤ紭鎯犵爜涓嶉€傜敤浜庢璁¤垂鍛ㄦ湡" except: pass - # 检查最低消费 + # 妫€鏌ユ渶浣庢秷璐? if promo.min_amount and amount < float(promo.min_amount): - return None, f"需满{float(promo.min_amount):.2f}元才可使用此优惠码" + return None, f"闇€婊float(promo.min_amount):.2f}鍏冩墠鍙娇鐢ㄦ浼樻儬鐮? return promo, None except Exception as e: - return None, f"验证优惠码时出错: {str(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 @@ -1839,49 +1709,49 @@ def calculate_discount(promo_code, amount): def calculate_subscription_price_simple(user_id, to_plan_name, to_cycle, promo_code=None): """ - 简化版价格计算:续费用户和新用户价格完全一致,不计算剩余价值 + 绠€鍖栫増浠锋牸璁$畻锛氱画璐圭敤鎴峰拰鏂扮敤鎴蜂环鏍煎畬鍏ㄤ竴鑷达紝涓嶈绠楀墿浣欎环鍊? Args: - user_id: 用户ID - to_plan_name: 目标套餐名称 (pro/max) - to_cycle: 计费周期 (monthly/quarterly/semiannual/yearly) - promo_code: 优惠码(可选) + user_id: 鐢ㄦ埛ID + to_plan_name: 鐩爣濂楅鍚嶇О (pro/max) + to_cycle: 璁¤垂鍛ㄦ湡 (monthly/quarterly/semiannual/yearly) + promo_code: 浼樻儬鐮侊紙鍙€夛級 Returns: dict: { - 'is_renewal': False/True, # 是否为续费 - 'subscription_type': 'new'/'renew', # 订阅类型 - 'current_plan': 'pro', # 当前套餐(如果有) - 'current_cycle': 'yearly', # 当前周期(如果有) - 'new_plan_price': 2699.00, # 新套餐价格 - 'original_amount': 2699.00, # 原价 - 'discount_amount': 0, # 优惠金额 - 'final_amount': 2699.00, # 实付金额 - 'promo_code': None, # 使用的优惠码 - 'promo_error': None # 优惠码错误信息 + 'is_renewal': False/True, # 鏄惁涓虹画璐? + 'subscription_type': 'new'/'renew', # 璁㈤槄绫诲瀷 + 'current_plan': 'pro', # 褰撳墠濂楅锛堝鏋滄湁锛? + 'current_cycle': 'yearly', # 褰撳墠鍛ㄦ湡锛堝鏋滄湁锛? + 'new_plan_price': 2699.00, # 鏂板椁愪环鏍? + 'original_amount': 2699.00, # 鍘熶环 + 'discount_amount': 0, # 浼樻儬閲戦 + 'final_amount': 2699.00, # 瀹炰粯閲戦 + 'promo_code': None, # 浣跨敤鐨勪紭鎯犵爜 + 'promo_error': None # 浼樻儬鐮侀敊璇俊鎭? } """ try: - # 1. 获取当前订阅 + # 1. 鑾峰彇褰撳墠璁㈤槄 current_sub = UserSubscription.query.filter_by(user_id=user_id).first() - # 2. 获取目标套餐 + # 2. 鑾峰彇鐩爣濂楅 to_plan = SubscriptionPlan.query.filter_by(name=to_plan_name, is_active=True).first() if not to_plan: - return {'error': '目标套餐不存在'} + return {'error': '鐩爣濂楅涓嶅瓨鍦?} - # 3. 根据计费周期获取价格 - # 优先从 pricing_options 获取价格 + # 3. 鏍规嵁璁¤垂鍛ㄦ湡鑾峰彇浠锋牸 + # 浼樺厛浠?pricing_options 鑾峰彇浠锋牸 price = None if to_plan.pricing_options: try: pricing_opts = json.loads(to_plan.pricing_options) - # 查找匹配的周期 + # 鏌ユ壘鍖归厤鐨勫懆鏈? for opt in pricing_opts: cycle_key = opt.get('cycle_key', '') months = opt.get('months', 0) - # 匹配逻辑 + # 鍖归厤閫昏緫 if (cycle_key == to_cycle or (to_cycle == 'monthly' and months == 1) or (to_cycle == 'quarterly' and months == 3) or @@ -1892,17 +1762,17 @@ def calculate_subscription_price_simple(user_id, to_plan_name, to_cycle, promo_c except: pass - # 如果 pricing_options 中没有找到,使用旧的 monthly_price/yearly_price + # 濡傛灉 pricing_options 涓病鏈夋壘鍒帮紝浣跨敤鏃х殑 monthly_price/yearly_price if price is None: if to_cycle == 'yearly': price = float(to_plan.yearly_price) if to_plan.yearly_price else 0 - else: # 默认月付 + else: # 榛樿鏈堜粯 price = float(to_plan.monthly_price) if to_plan.monthly_price else 0 if price <= 0: - return {'error': f'{to_cycle} 周期价格未配置'} + return {'error': f'{to_cycle} 鍛ㄦ湡浠锋牸鏈厤缃?} - # 4. 判断订阅类型和计算价格 + # 4. 鍒ゆ柇璁㈤槄绫诲瀷鍜岃绠椾环鏍? is_renewal = False is_upgrade = False is_downgrade = False @@ -1917,31 +1787,31 @@ def calculate_subscription_price_simple(user_id, to_plan_name, to_cycle, promo_c current_cycle = current_sub.billing_cycle if current_plan == to_plan_name: - # 同级续费:延长时长,全价购买 + # 鍚岀骇缁垂锛氬欢闀挎椂闀匡紝鍏ㄤ环璐拱 is_renewal = True subscription_type = 'renew' elif current_plan == 'pro' and to_plan_name == 'max': - # 升级:Pro → Max,需要计算差价 + # 鍗囩骇锛歅ro 鈫?Max锛岄渶瑕佽绠楀樊浠? is_upgrade = True subscription_type = 'upgrade' - # 计算当前订阅的剩余价值 + # 璁$畻褰撳墠璁㈤槄鐨勫墿浣欎环鍊? if current_sub.end_date and current_sub.end_date > datetime.utcnow(): - # 获取当前套餐的原始价格 + # 鑾峰彇褰撳墠濂楅鐨勫師濮嬩环鏍? current_plan_obj = SubscriptionPlan.query.filter_by(name=current_plan, is_active=True).first() if current_plan_obj: current_price = None - # 优先从 pricing_options 获取价格 + # 浼樺厛浠?pricing_options 鑾峰彇浠锋牸 if current_plan_obj.pricing_options: try: pricing_opts = json.loads(current_plan_obj.pricing_options) - # 如果 current_cycle 为空或无效,根据剩余天数推断计费周期 + # 濡傛灉 current_cycle 涓虹┖鎴栨棤鏁堬紝鏍规嵁鍓╀綑澶╂暟鎺ㄦ柇璁¤垂鍛ㄦ湡 if not current_cycle or current_cycle.strip() == '': remaining_days_total = (current_sub.end_date - current_sub.start_date).days if current_sub.start_date else 365 - # 根据总天数推断计费周期 + # 鏍规嵁鎬诲ぉ鏁版帹鏂璐瑰懆鏈? if remaining_days_total <= 35: inferred_cycle = 'monthly' elif remaining_days_total <= 100: @@ -1956,21 +1826,21 @@ def calculate_subscription_price_simple(user_id, to_plan_name, to_cycle, promo_c for opt in pricing_opts: if opt.get('cycle_key') == inferred_cycle: current_price = float(opt.get('price', 0)) - current_cycle = inferred_cycle # 更新周期信息 + current_cycle = inferred_cycle # 鏇存柊鍛ㄦ湡淇℃伅 break except: pass - # 如果 pricing_options 中没找到,使用 yearly_price 作为默认 + # 濡傛灉 pricing_options 涓病鎵惧埌锛屼娇鐢?yearly_price 浣滀负榛樿 if current_price is None or current_price <= 0: current_price = float(current_plan_obj.yearly_price) if current_plan_obj.yearly_price else 0 current_cycle = 'yearly' if current_price and current_price > 0: - # 计算剩余天数 + # 璁$畻鍓╀綑澶╂暟 remaining_days = (current_sub.end_date - datetime.utcnow()).days - # 计算总天数 + # 璁$畻鎬诲ぉ鏁? cycle_days_map = { 'monthly': 30, 'quarterly': 90, @@ -1979,24 +1849,24 @@ def calculate_subscription_price_simple(user_id, to_plan_name, to_cycle, promo_c } total_days = cycle_days_map.get(current_cycle, 365) - # 计算剩余价值 + # 璁$畻鍓╀綑浠峰€? if total_days > 0 and remaining_days > 0: remaining_value = current_price * (remaining_days / total_days) - # 实付金额 = 新套餐价格 - 剩余价值 + # 瀹炰粯閲戦 = 鏂板椁愪环鏍?- 鍓╀綑浠峰€? final_price = max(0, price - remaining_value) - # 如果剩余价值 >= 新套餐价格,标记为免费升级 + # 濡傛灉鍓╀綑浠峰€?>= 鏂板椁愪环鏍硷紝鏍囪涓哄厤璐瑰崌绾? if remaining_value >= price: final_price = 0 elif current_plan == 'max' and to_plan_name == 'pro': - # 降级:Max → Pro,到期后切换,全价购买 + # 闄嶇骇锛歁ax 鈫?Pro锛屽埌鏈熷悗鍒囨崲锛屽叏浠疯喘涔? is_downgrade = True subscription_type = 'downgrade' else: - # 其他情况视为新购 + # 鍏朵粬鎯呭喌瑙嗕负鏂拌喘 subscription_type = 'new' - # 5. 构建结果 + # 5. 鏋勫缓缁撴灉 result = { 'is_renewal': is_renewal, 'is_upgrade': is_upgrade, @@ -2005,8 +1875,8 @@ def calculate_subscription_price_simple(user_id, to_plan_name, to_cycle, promo_c 'current_plan': current_plan, 'current_cycle': current_cycle, 'new_plan_price': price, - 'original_price': price, # 新套餐原价 - 'remaining_value': remaining_value, # 当前订阅剩余价值(仅升级时有效) + 'original_price': price, # 鏂板椁愬師浠? + 'remaining_value': remaining_value, # 褰撳墠璁㈤槄鍓╀綑浠峰€硷紙浠呭崌绾ф椂鏈夋晥锛? 'original_amount': price, 'discount_amount': 0, 'final_amount': final_price, @@ -2014,9 +1884,9 @@ def calculate_subscription_price_simple(user_id, to_plan_name, to_cycle, promo_c 'promo_error': None } - # 6. 应用优惠码(基于差价后的金额) + # 6. 搴旂敤浼樻儬鐮侊紙鍩轰簬宸环鍚庣殑閲戦锛? if promo_code and promo_code.strip(): - # 优惠码作用于差价后的金额 + # 浼樻儬鐮佷綔鐢ㄤ簬宸环鍚庣殑閲戦 promo, error = validate_promo_code(promo_code, to_plan_name, to_cycle, final_price, user_id) if promo: discount = calculate_discount(promo, final_price) @@ -2029,56 +1899,56 @@ def calculate_subscription_price_simple(user_id, to_plan_name, to_cycle, promo_c return result except Exception as e: - return {'error': f'价格计算失败: {str(e)}'} + return {'error': f'浠锋牸璁$畻澶辫触: {str(e)}'} -# 保留旧函数以兼容(标记为废弃) +# 淇濈暀鏃у嚱鏁颁互鍏煎锛堟爣璁颁负搴熷純锛? def calculate_upgrade_price(user_id, to_plan_name, to_cycle, promo_code=None): """ - 【已废弃】旧版升级价格计算函数,保留以兼容旧代码 - 新代码请使用 calculate_subscription_price_simple + 銆愬凡搴熷純銆戞棫鐗堝崌绾т环鏍艰绠楀嚱鏁帮紝淇濈暀浠ュ吋瀹规棫浠g爜 + 鏂颁唬鐮佽浣跨敤 calculate_subscription_price_simple """ - # 直接调用新函数 + # 鐩存帴璋冪敤鏂板嚱鏁? return calculate_subscription_price_simple(user_id, to_plan_name, to_cycle, promo_code) def initialize_subscription_plans_safe(): - """安全地初始化订阅套餐""" + """瀹夊叏鍦板垵濮嬪寲璁㈤槄濂楅""" try: if SubscriptionPlan.query.first(): return pro_plan = SubscriptionPlan( name='pro', - display_name='Pro 专业版', - description='事件关联股票深度分析 | 历史事件智能对比复盘 | 事件概念关联与挖掘 | 概念板块个股追踪 | 概念深度研报与解读 | 个股异动实时预警', + 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='包含Pro版全部功能 | 事件传导链路智能分析 | 概念演变时间轴追溯 | 个股全方位深度研究 | 价小前投研助手无限使用 | 新功能优先体验权 | 专属客服一对一服务', + display_name='Max 鏃楄埌鐗?, + description='鍖呭惈Pro鐗堝叏閮ㄥ姛鑳?| 浜嬩欢浼犲閾捐矾鏅鸿兘鍒嗘瀽 | 姒傚康婕斿彉鏃堕棿杞磋拷婧?| 涓偂鍏ㄦ柟浣嶆繁搴︾爺绌?| 浠峰皬鍓嶆姇鐮斿姪鎵嬫棤闄愪娇鐢?| 鏂板姛鑳戒紭鍏堜綋楠屾潈 | 涓撳睘瀹㈡湇涓€瀵逛竴鏈嶅姟', monthly_price=0.1, yearly_price=0.8, features=json.dumps([ - "全部Pro版本功能", - "高级分析工具", - "实时数据推送", - "专业财务分析报告", - "AI投资建议", - "无限投资计划存储", - "优先客服支持", - "独家研报访问" + "鍏ㄩ儴Pro鐗堟湰鍔熻兘", + "楂樼骇鍒嗘瀽宸ュ叿", + "瀹炴椂鏁版嵁鎺ㄩ€?, + "涓撲笟璐㈠姟鍒嗘瀽鎶ュ憡", + "AI鎶曡祫寤鸿", + "鏃犻檺鎶曡祫璁″垝瀛樺偍", + "浼樺厛瀹㈡湇鏀寔", + "鐙鐮旀姤璁块棶" ]), sort_order=2 ) @@ -2091,10 +1961,10 @@ def initialize_subscription_plans_safe(): # -------------------------------------------- -# 订阅等级工具函数 +# 璁㈤槄绛夌骇宸ュ叿鍑芥暟 # -------------------------------------------- def _get_current_subscription_info(): - """获取当前登录用户订阅信息的字典形式,未登录或异常时视为免费用户。""" + """鑾峰彇褰撳墠鐧诲綍鐢ㄦ埛璁㈤槄淇℃伅鐨勫瓧鍏稿舰寮忥紝鏈櫥褰曟垨寮傚父鏃惰涓哄厤璐圭敤鎴枫€?"" try: user_id = session.get('user_id') if not user_id: @@ -2105,7 +1975,7 @@ def _get_current_subscription_info(): } 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', @@ -2120,13 +1990,13 @@ def _get_current_subscription_info(): def _subscription_level(sub_type): - """将订阅类型映射到等级数值,free=0, pro=1, max=2。""" + """灏嗚闃呯被鍨嬫槧灏勫埌绛夌骇鏁板€硷紝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 @@ -2134,28 +2004,28 @@ def _has_required_level(required: str) -> bool: # ============================================ -# 微信开放平台域名校验 +# 寰俊寮€鏀惧钩鍙板煙鍚嶆牎楠? # ============================================ @app.route('/gvQnxIQ5Rs.txt', methods=['GET']) def wechat_domain_verify(): - """微信开放平台域名校验文件""" + """寰俊寮€鏀惧钩鍙板煙鍚嶆牎楠屾枃浠?"" return 'd526e9e857dbd2621e5100811972e8c5', 200, {'Content-Type': 'text/plain'} @app.route('/MP_verify_17Fo4JhapMw6vtNa.txt', methods=['GET']) def wechat_mp_domain_verify(): - """微信公众号网页授权域名校验文件""" + """寰俊鍏紬鍙风綉椤垫巿鏉冨煙鍚嶆牎楠屾枃浠?"" return '17Fo4JhapMw6vtNa', 200, {'Content-Type': 'text/plain'} # ============================================ -# 订阅相关API接口 +# 璁㈤槄鐩稿叧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({ @@ -2163,43 +2033,43 @@ def get_subscription_plans(): 'data': [plan.to_dict() for plan in plans] }) except Exception as e: - # 返回默认套餐(包含pricing_options以兼容新前端) + # 杩斿洖榛樿濂楅锛堝寘鍚玴ricing_options浠ュ吋瀹规柊鍓嶇锛? default_plans = [ { 'id': 1, 'name': 'pro', - 'display_name': 'Pro版本', - 'description': '适合个人投资者的基础功能套餐', + '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} + {'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': ['基础股票分析工具', '历史数据查询', '基础财务报表', '简单投资计划记录', '标准客服支持'], + 'features': ['鍩虹鑲$エ鍒嗘瀽宸ュ叿', '鍘嗗彶鏁版嵁鏌ヨ', '鍩虹璐㈠姟鎶ヨ〃', '绠€鍗曟姇璧勮鍒掕褰?, '鏍囧噯瀹㈡湇鏀寔'], 'is_active': True, 'sort_order': 1 }, { 'id': 2, 'name': 'max', - 'display_name': 'Max版本', - 'description': '适合专业投资者的全功能套餐', + '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} + {'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访问', '优先客服支持'], + 'features': ['鍏ㄩ儴Pro鐗堟湰鍔熻兘', '楂樼骇鍒嗘瀽宸ュ叿', '瀹炴椂鏁版嵁鎺ㄩ€?, 'API璁块棶', '浼樺厛瀹㈡湇鏀寔'], 'is_active': True, 'sort_order': 2 } @@ -2212,10 +2082,10 @@ def get_subscription_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 + return jsonify({'success': False, 'error': '鏈櫥褰?}), 401 subscription = get_user_subscription_safe(session['user_id']) return jsonify({ @@ -2236,7 +2106,7 @@ def get_current_subscription(): @app.route('/api/subscription/info', methods=['GET']) def get_subscription_info(): - """获取当前用户的订阅信息 - 前端专用接口""" + """鑾峰彇褰撳墠鐢ㄦ埛鐨勮闃呬俊鎭?- 鍓嶇涓撶敤鎺ュ彛""" try: info = _get_current_subscription_info() return jsonify({ @@ -2244,7 +2114,7 @@ def get_subscription_info(): 'data': info }) except Exception as e: - print(f"获取订阅信息错误: {e}") + print(f"鑾峰彇璁㈤槄淇℃伅閿欒: {e}") return jsonify({ 'success': True, 'data': { @@ -2258,10 +2128,10 @@ def get_subscription_info(): @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 + return jsonify({'success': False, 'error': '鏈櫥褰?}), 401 data = request.get_json() code = data.get('code', '').strip() @@ -2270,9 +2140,9 @@ def validate_promo_code_api(): amount = data.get('amount', 0) if not code or not plan_name or not billing_cycle: - return jsonify({'success': False, 'error': '参数不完整'}), 400 + return jsonify({'success': False, 'error': '鍙傛暟涓嶅畬鏁?}), 400 - # 验证优惠码 + # 楠岃瘉浼樻儬鐮? promo, error = validate_promo_code(code, plan_name, billing_cycle, amount, session['user_id']) if error: @@ -2282,7 +2152,7 @@ def validate_promo_code_api(): 'error': error }) - # 计算折扣 + # 璁$畻鎶樻墸 discount_amount = calculate_discount(promo, amount) final_amount = amount - discount_amount @@ -2297,30 +2167,30 @@ def validate_promo_code_api(): except Exception as e: return jsonify({ 'success': False, - 'error': f'验证失败: {str(e)}' + 'error': f'楠岃瘉澶辫触: {str(e)}' }), 500 @app.route('/api/subscription/calculate-price', methods=['POST']) def calculate_subscription_price(): """ - 计算订阅价格(新版:续费和新购价格一致) + 璁$畻璁㈤槄浠锋牸锛堟柊鐗堬細缁垂鍜屾柊璐环鏍间竴鑷达級 Request Body: { "to_plan": "pro", "to_cycle": "yearly", - "promo_code": "WELCOME2025" // 可选 + "promo_code": "WELCOME2025" // 鍙€? } Response: { "success": true, "data": { - "is_renewal": true, // 是否为续费 - "subscription_type": "renew", // new 或 renew - "current_plan": "pro", // 当前套餐(如果有) - "current_cycle": "monthly", // 当前周期(如果有) + "is_renewal": true, // 鏄惁涓虹画璐? + "subscription_type": "renew", // new 鎴?renew + "current_plan": "pro", // 褰撳墠濂楅锛堝鏋滄湁锛? + "current_cycle": "monthly", // 褰撳墠鍛ㄦ湡锛堝鏋滄湁锛? "new_plan_price": 2699.00, "original_amount": 2699.00, "discount_amount": 0, @@ -2332,7 +2202,7 @@ def calculate_subscription_price(): """ try: if 'user_id' not in session: - return jsonify({'success': False, 'error': '未登录'}), 401 + return jsonify({'success': False, 'error': '鏈櫥褰?}), 401 data = request.get_json() to_plan = data.get('to_plan') @@ -2340,9 +2210,9 @@ def calculate_subscription_price(): promo_code = (data.get('promo_code') or '').strip() or None if not to_plan or not to_cycle: - return jsonify({'success': False, 'error': '参数不完整'}), 400 + return jsonify({'success': False, 'error': '鍙傛暟涓嶅畬鏁?}), 400 - # 使用新的简化价格计算函数 + # 浣跨敤鏂扮殑绠€鍖栦环鏍艰绠楀嚱鏁? result = calculate_subscription_price_simple(session['user_id'], to_plan, to_cycle, promo_code) if 'error' in result: @@ -2359,7 +2229,7 @@ def calculate_subscription_price(): except Exception as e: return jsonify({ 'success': False, - 'error': f'计算失败: {str(e)}' + 'error': f'璁$畻澶辫触: {str(e)}' }), 500 @@ -2367,7 +2237,7 @@ def calculate_subscription_price(): @login_required def free_upgrade_subscription(): """ - 免费升级订阅(当剩余价值 >= 新套餐价格时) + 鍏嶈垂鍗囩骇璁㈤槄锛堝綋鍓╀綑浠峰€?>= 鏂板椁愪环鏍兼椂锛? Request Body: { @@ -2381,31 +2251,31 @@ def free_upgrade_subscription(): billing_cycle = data.get('billing_cycle') if not plan_name or not billing_cycle: - return jsonify({'success': False, 'error': '参数不完整'}), 400 + return jsonify({'success': False, 'error': '鍙傛暟涓嶅畬鏁?}), 400 user_id = current_user.id - # 计算价格,验证是否可以免费升级 + # 璁$畻浠锋牸锛岄獙璇佹槸鍚﹀彲浠ュ厤璐瑰崌绾? price_result = calculate_subscription_price_simple(user_id, plan_name, billing_cycle, None) if 'error' in price_result: return jsonify({'success': False, 'error': price_result['error']}), 400 - # 检查是否为升级且实付金额为0 + # 妫€鏌ユ槸鍚︿负鍗囩骇涓斿疄浠橀噾棰濅负0 if not price_result.get('is_upgrade') or price_result.get('final_amount', 1) > 0: - return jsonify({'success': False, 'error': '当前情况不符合免费升级条件'}), 400 + return jsonify({'success': False, 'error': '褰撳墠鎯呭喌涓嶇鍚堝厤璐瑰崌绾ф潯浠?}), 400 - # 获取当前订阅 + # 鑾峰彇褰撳墠璁㈤槄 subscription = UserSubscription.query.filter_by(user_id=user_id).first() if not subscription: - return jsonify({'success': False, 'error': '未找到订阅记录'}), 404 + return jsonify({'success': False, 'error': '鏈壘鍒拌闃呰褰?}), 404 - # 计算新的到期时间(按剩余价值折算) + # 璁$畻鏂扮殑鍒版湡鏃堕棿锛堟寜鍓╀綑浠峰€兼姌绠楋級 remaining_value = price_result.get('remaining_value', 0) new_plan_price = price_result.get('new_plan_price', 0) if new_plan_price > 0: - # 计算可以兑换的新套餐天数 + # 璁$畻鍙互鍏戞崲鐨勬柊濂楅澶╂暟 value_ratio = remaining_value / new_plan_price cycle_days_map = { @@ -2416,10 +2286,10 @@ def free_upgrade_subscription(): } new_cycle_days = cycle_days_map.get(billing_cycle, 365) - # 新的到期天数 = 周期天数 × 价值比例 + # 鏂扮殑鍒版湡澶╂暟 = 鍛ㄦ湡澶╂暟 脳 浠峰€兼瘮渚? new_days = int(new_cycle_days * value_ratio) - # 更新订阅信息 + # 鏇存柊璁㈤槄淇℃伅 subscription.subscription_type = plan_name subscription.billing_cycle = billing_cycle subscription.start_date = datetime.utcnow() @@ -2431,7 +2301,7 @@ def free_upgrade_subscription(): return jsonify({ 'success': True, - 'message': f'升级成功!您的{plan_name.upper()}版本将持续{new_days}天', + 'message': f'鍗囩骇鎴愬姛锛佹偍鐨剓plan_name.upper()}鐗堟湰灏嗘寔缁瓄new_days}澶?, 'data': { 'subscription_type': plan_name, 'end_date': subscription.end_date.isoformat(), @@ -2439,28 +2309,28 @@ def free_upgrade_subscription(): } }) else: - return jsonify({'success': False, 'error': '价格计算异常'}), 500 + return jsonify({'success': False, 'error': '浠锋牸璁$畻寮傚父'}), 500 except Exception as e: db.session.rollback() - return jsonify({'success': False, 'error': f'升级失败: {str(e)}'}), 500 + return jsonify({'success': False, 'error': f'鍗囩骇澶辫触: {str(e)}'}), 500 @app.route('/api/payment/create-order', methods=['POST']) def create_payment_order(): """ - 创建支付订单(新版:简化逻辑,不再记录升级) + 鍒涘缓鏀粯璁㈠崟锛堟柊鐗堬細绠€鍖栭€昏緫锛屼笉鍐嶈褰曞崌绾э級 Request Body: { "plan_name": "pro", "billing_cycle": "yearly", - "promo_code": "WELCOME2025" // 可选 + "promo_code": "WELCOME2025" // 鍙€? } """ try: if 'user_id' not in session: - return jsonify({'success': False, 'error': '未登录'}), 401 + return jsonify({'success': False, 'error': '鏈櫥褰?}), 401 data = request.get_json() plan_name = data.get('plan_name') @@ -2468,29 +2338,29 @@ def create_payment_order(): promo_code = (data.get('promo_code') or '').strip() or None if not plan_name or not billing_cycle: - return jsonify({'success': False, 'error': '参数不完整'}), 400 + return jsonify({'success': False, 'error': '鍙傛暟涓嶅畬鏁?}), 400 - # 使用新的简化价格计算 + # 浣跨敤鏂扮殑绠€鍖栦环鏍艰绠? price_result = calculate_subscription_price_simple(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'] - subscription_type = price_result.get('subscription_type', 'new') # new 或 renew + subscription_type = price_result.get('subscription_type', 'new') # new 鎴?renew - # 检查是否为免费升级(金额为0) + # 妫€鏌ユ槸鍚︿负鍏嶈垂鍗囩骇锛堥噾棰濅负0锛? if amount <= 0 and price_result.get('is_upgrade'): return jsonify({ 'success': False, - 'error': '当前剩余价值可直接免费升级,请使用免费升级功能', + 'error': '褰撳墠鍓╀綑浠峰€煎彲鐩存帴鍏嶈垂鍗囩骇锛岃浣跨敤鍏嶈垂鍗囩骇鍔熻兘', 'should_free_upgrade': True, 'price_info': price_result }), 400 - # 创建订单 + # 鍒涘缓璁㈠崟 try: - # 获取原价和折扣金额 + # 鑾峰彇鍘熶环鍜屾姌鎵i噾棰? original_amount = price_result.get('original_amount', amount) discount_amount = price_result.get('discount_amount', 0) @@ -2503,32 +2373,32 @@ def create_payment_order(): discount_amount=discount_amount ) - # 添加订阅类型标记(用于前端展示) - order.remark = f"{subscription_type}订阅" if subscription_type == 'renew' else "新购订阅" + # 娣诲姞璁㈤槄绫诲瀷鏍囪锛堢敤浜庡墠绔睍绀猴級 + order.remark = f"{subscription_type}璁㈤槄" if subscription_type == 'renew' else "鏂拌喘璁㈤槄" - # 如果使用了优惠码,关联优惠码 + # 濡傛灉浣跨敤浜嗕紭鎯犵爜锛屽叧鑱斾紭鎯犵爜 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 - print(f"📦 订单关联优惠码: {promo_obj.code} (ID: {promo_obj.id})") + print(f"馃摝 璁㈠崟鍏宠仈浼樻儬鐮? {promo_obj.code} (ID: {promo_obj.id})") db.session.add(order) db.session.commit() except Exception as e: db.session.rollback() - return jsonify({'success': False, 'error': f'订单创建失败: {str(e)}'}), 500 + return jsonify({'success': False, 'error': f'璁㈠崟鍒涘缓澶辫触: {str(e)}'}), 500 - # 尝试调用真实的微信支付API(使用 subprocess 绕过 eventlet DNS 问题) + # 灏濊瘯璋冪敤鐪熷疄鐨勫井淇℃敮浠楢PI锛堜娇鐢?subprocess 缁曡繃 eventlet DNS 闂锛? try: import subprocess import urllib.parse - # 使用独立脚本检查配置 + # 浣跨敤鐙珛鑴氭湰妫€鏌ラ厤缃? script_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'wechat_pay_worker.py') - # 先检查配置 + # 鍏堟鏌ラ厤缃? check_result = subprocess.run( [sys.executable, script_path, 'check'], capture_output=True, text=True, timeout=10 @@ -2536,12 +2406,12 @@ def create_payment_order(): if check_result.returncode != 0: check_data = json.loads(check_result.stdout) if check_result.stdout else {} - ready_msg = check_data.get('error', check_data.get('message', '未知错误')) + ready_msg = check_data.get('error', check_data.get('message', '鏈煡閿欒')) 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}" + order.remark = f"婕旂ず妯″紡 - {ready_msg}" else: - # 创建微信支付订单 - plan_display_name = f"{plan_name.upper()}版本-{billing_cycle}" + # 鍒涘缓寰俊鏀粯璁㈠崟 + plan_display_name = f"{plan_name.upper()}鐗堟湰-{billing_cycle}" body = f"VFr-{plan_display_name}" product_id = f"{plan_name}_{billing_cycle}" @@ -2550,90 +2420,90 @@ def create_payment_order(): capture_output=True, text=True, timeout=60 ) - print(f"[微信支付] 创建订单返回: {create_result.stdout}") + print(f"[寰俊鏀粯] 鍒涘缓璁㈠崟杩斿洖: {create_result.stdout}") if create_result.stderr: - print(f"[微信支付] 错误输出: {create_result.stderr}") + print(f"[寰俊鏀粯] 閿欒杈撳嚭: {create_result.stderr}") - wechat_result = json.loads(create_result.stdout) if create_result.stdout else {'success': False, 'error': '无返回'} + wechat_result = json.loads(create_result.stdout) if create_result.stdout else {'success': False, 'error': '鏃犺繑鍥?} if wechat_result.get('success'): - # 获取微信返回的原始code_url + # 鑾峰彇寰俊杩斿洖鐨勫師濮媍ode_url wechat_code_url = wechat_result['code_url'] - # 将微信协议URL转换为二维码图片URL + # 灏嗗井淇″崗璁甎RL杞崲涓轰簩缁寸爜鍥剧墖URL 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}" + 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')}" + order.remark = f"寰俊鏀粯澶辫触: {wechat_result.get('error')}" except subprocess.TimeoutExpired: order.qr_code_url = f"https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=wxpay://order/{order.order_no}" - order.remark = "微信支付超时" + order.remark = "寰俊鏀粯瓒呮椂" except json.JSONDecodeError 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)}" + order.remark = f"寰俊鏀粯杩斿洖瑙f瀽澶辫触: {str(e)}" except Exception as e: import traceback - print(f"[微信支付] Exception: {e}") + print(f"[寰俊鏀粯] Exception: {e}") traceback.print_exc() 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)}" + order.remark = f"鏀粯寮傚父: {str(e)}" db.session.commit() return jsonify({ 'success': True, 'data': order.to_dict(), - 'message': '订单创建成功' + 'message': '璁㈠崟鍒涘缓鎴愬姛' }) except Exception as e: db.session.rollback() - return jsonify({'success': False, 'error': '创建订单失败'}), 500 + 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 + 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 + return jsonify({'success': False, 'error': '璁㈠崟涓嶅瓨鍦?}), 404 - # 如果订单已经是已支付状态,直接返回 + # 濡傛灉璁㈠崟宸茬粡鏄凡鏀粯鐘舵€侊紝鐩存帴杩斿洖 if order.status == 'paid': return jsonify({ 'success': True, 'data': order.to_dict(), - 'message': '订单已支付', + 'message': '璁㈠崟宸叉敮浠?, 'payment_success': True }) - # 如果订单过期,标记为过期 + # 濡傛灉璁㈠崟杩囨湡锛屾爣璁颁负杩囨湡 if order.is_expired(): order.status = 'expired' db.session.commit() return jsonify({ 'success': True, 'data': order.to_dict(), - 'message': '订单已过期' + 'message': '璁㈠崟宸茶繃鏈? }) - # 调用微信支付API查询真实状态(使用 subprocess 绕过 eventlet DNS 问题) + # 璋冪敤寰俊鏀粯API鏌ヨ鐪熷疄鐘舵€侊紙浣跨敤 subprocess 缁曡繃 eventlet DNS 闂锛? try: import subprocess @@ -2644,20 +2514,20 @@ def check_order_status(order_id): capture_output=True, text=True, timeout=30 ) - query_result = json.loads(query_proc.stdout) if query_proc.stdout else {'success': False, 'error': '无返回'} + query_result = json.loads(query_proc.stdout) if query_proc.stdout else {'success': False, 'error': '鏃犺繑鍥?} if query_result.get('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) - # 记录优惠码使用情况 + # 璁板綍浼樻儬鐮佷娇鐢ㄦ儏鍐? if order.promo_code_id: try: existing_usage = PromoCodeUsage.query.filter_by(order_id=order.id).first() @@ -2674,75 +2544,75 @@ def check_order_status(order_id): promo = PromoCode.query.get(order.promo_code_id) if promo: promo.current_uses = (promo.current_uses or 0) + 1 - print(f"🎫 优惠码使用记录已创建: {promo.code}") + print(f"馃帿 浼樻儬鐮佷娇鐢ㄨ褰曞凡鍒涘缓: {promo.code}") except Exception as e: - print(f"⚠️ 记录优惠码使用失败: {e}") + print(f"鈿狅笍 璁板綍浼樻儬鐮佷娇鐢ㄥけ璐? {e}") db.session.commit() return jsonify({ 'success': True, 'data': order.to_dict(), - 'message': '支付成功!订阅已激活', + 'message': '鏀粯鎴愬姛锛佽闃呭凡婵€娲?, 'payment_success': True }) elif trade_state in ['NOTPAY', 'USERPAYING']: - # 未支付或支付中 + # 鏈敮浠樻垨鏀粯涓? return jsonify({ 'success': True, 'data': order.to_dict(), - 'message': '等待支付...', + 'message': '绛夊緟鏀粯...', 'payment_success': False }) else: - # 支付失败或取消 + # 鏀粯澶辫触鎴栧彇娑? order.status = 'cancelled' db.session.commit() return jsonify({ 'success': True, 'data': order.to_dict(), - 'message': '支付已取消', + 'message': '鏀粯宸插彇娑?, 'payment_success': False }) else: - # 微信查询失败,返回当前状态 + # 寰俊鏌ヨ澶辫触锛岃繑鍥炲綋鍓嶇姸鎬? return jsonify({ 'success': True, 'data': order.to_dict(), - 'message': f"查询失败: {query_result.get('error')}", + 'message': f"鏌ヨ澶辫触: {query_result.get('error')}", 'payment_success': False }) except Exception as e: - # 查询失败,返回当前订单状态 + # 鏌ヨ澶辫触锛岃繑鍥炲綋鍓嶈鍗曠姸鎬? return jsonify({ 'success': True, 'data': order.to_dict(), - 'message': '无法查询支付状态,请稍后重试', + 'message': '鏃犳硶鏌ヨ鏀粯鐘舵€侊紝璇风◢鍚庨噸璇?, 'payment_success': False }) except Exception as e: - return jsonify({'success': False, 'error': '查询失败'}), 500 + 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 + 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 + return jsonify({'success': False, 'error': '璁㈠崟涓嶅瓨鍦?}), 404 - # 检查微信支付状态(使用 subprocess 绕过 eventlet DNS 问题) + # 妫€鏌ュ井淇℃敮浠樼姸鎬侊紙浣跨敤 subprocess 缁曡繃 eventlet DNS 闂锛? try: import subprocess @@ -2753,20 +2623,20 @@ def force_update_order_status(order_id): capture_output=True, text=True, timeout=30 ) - query_result = json.loads(query_proc.stdout) if query_proc.stdout else {'success': False, 'error': '无返回'} + query_result = json.loads(query_proc.stdout) if query_proc.stdout else {'success': False, 'error': '鏃犺繑鍥?} if query_result.get('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 order.promo_code_id: try: - # 检查是否已经记录过(防止重复) + # 妫€鏌ユ槸鍚﹀凡缁忚褰曡繃锛堥槻姝㈤噸澶嶏級 existing_usage = PromoCodeUsage.query.filter_by(order_id=order.id).first() if not existing_usage: promo_usage = PromoCodeUsage( @@ -2779,125 +2649,125 @@ def force_update_order_status(order_id): ) db.session.add(promo_usage) - # 更新优惠码使用次数 + # 鏇存柊浼樻儬鐮佷娇鐢ㄦ鏁? promo = PromoCode.query.get(order.promo_code_id) if promo: promo.current_uses = (promo.current_uses or 0) + 1 - print(f"🎫 优惠码使用记录已创建: {promo.code}") + print(f"馃帿 浼樻儬鐮佷娇鐢ㄨ褰曞凡鍒涘缓: {promo.code}") else: - print(f"ℹ️ 优惠码使用记录已存在,跳过") + print(f"鈩癸笍 浼樻儬鐮佷娇鐢ㄨ褰曞凡瀛樺湪锛岃烦杩?) except Exception as e: - print(f"⚠️ 记录优惠码使用失败: {e}") + print(f"鈿狅笍 璁板綍浼樻儬鐮佷娇鐢ㄥけ璐? {e}") db.session.commit() - print(f"✅ 订单状态强制更新成功: {old_status} -> paid") + print(f"鉁?璁㈠崟鐘舵€佸己鍒舵洿鏂版垚鍔? {old_status} -> paid") return jsonify({ 'success': True, - 'message': f'订单状态已从 {old_status} 更新为 paid', + 'message': f'璁㈠崟鐘舵€佸凡浠?{old_status} 鏇存柊涓?paid', 'data': order.to_dict(), 'payment_success': True }) else: return jsonify({ 'success': False, - 'error': '微信支付状态不是成功状态,无法强制更新' + 'error': '寰俊鏀粯鐘舵€佷笉鏄垚鍔熺姸鎬侊紝鏃犳硶寮哄埗鏇存柊' }) except Exception as e: - print(f"❌ 强制更新失败: {e}") + print(f"鉂?寮哄埗鏇存柊澶辫触: {e}") return jsonify({ 'success': False, - 'error': f'强制更新失败: {str(e)}' + 'error': f'寮哄埗鏇存柊澶辫触: {str(e)}' }) except Exception as e: - print(f"强制更新订单状态失败: {str(e)}") - return jsonify({'success': False, 'error': '操作失败'}), 500 + print(f"寮哄埗鏇存柊璁㈠崟鐘舵€佸け璐? {str(e)}") + return jsonify({'success': False, 'error': '鎿嶄綔澶辫触'}), 500 @app.route('/api/payment/wechat/callback', methods=['POST']) def wechat_payment_callback(): - """微信支付回调处理""" + """寰俊鏀粯鍥炶皟澶勭悊""" try: - # 获取原始XML数据 + # 鑾峰彇鍘熷XML鏁版嵁 raw_data = request.get_data() - print(f"📥 收到微信支付回调: {raw_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 '' + print(f"鉂?寰俊鏀粯鍥炶皟楠岃瘉澶辫触: {verify_result['error']}") + return '' callback_data = verify_result['data'] except Exception as e: - print(f"❌ 微信支付回调处理异常: {e}") - # 简单解析XML(fallback) + print(f"鉂?寰俊鏀粯鍥炶皟澶勭悊寮傚父: {e}") + # 绠€鍗曡В鏋怷ML锛坒allback锛? callback_data = _parse_xml_callback(raw_data.decode('utf-8')) if not callback_data: - return '' + 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}") + print(f"馃摝 鍥炶皟鏁版嵁瑙f瀽:") + print(f" 杩斿洖鐮? {return_code}") + print(f" 缁撴灉鐮? {result_code}") + print(f" 璁㈠崟鍙? {order_no}") + print(f" 浜ゆ槗鍙? {transaction_id}") if not order_no: - return '' + return '' - # 查找订单 + # 鏌ユ壘璁㈠崟 order = PaymentOrder.query.filter_by(order_no=order_no).first() if not order: - print(f"❌ 订单不存在: {order_no}") - return '' + print(f"鉂?璁㈠崟涓嶅瓨鍦? {order_no}") + return '' - # 处理支付成功 + # 澶勭悊鏀粯鎴愬姛 if return_code == 'SUCCESS' and result_code == 'SUCCESS': - print(f"🎉 支付回调成功: 订单 {order_no}") + print(f"馃帀 鏀粯鍥炶皟鎴愬姛: 璁㈠崟 {order_no}") - # 检查订单是否已经处理过 + # 妫€鏌ヨ鍗曟槸鍚﹀凡缁忓鐞嗚繃 if order.status == 'paid': - print(f"ℹ️ 订单已处理过: {order_no}") + print(f"鈩癸笍 璁㈠崟宸插鐞嗚繃: {order_no}") db.session.commit() return '' - # 更新订单状态(无论之前是什么状态) + # 鏇存柊璁㈠崟鐘舵€侊紙鏃犺涔嬪墠鏄粈涔堢姸鎬侊級 old_status = order.status order.mark_as_paid(transaction_id) - print(f"📝 订单状态已更新: {old_status} -> paid") + 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}") + print(f"鉁?鐢ㄦ埛璁㈤槄宸叉縺娲? 鐢ㄦ埛{order.user_id}, 濂楅{order.plan_name}") else: - print(f"⚠️ 订阅激活失败,但订单已标记为已支付") + print(f"鈿狅笍 璁㈤槄婵€娲诲け璐ワ紝浣嗚鍗曞凡鏍囪涓哄凡鏀粯") - # 记录优惠码使用情况 + # 璁板綍浼樻儬鐮佷娇鐢ㄦ儏鍐? if order.promo_code_id: try: - # 检查是否已经记录过(防止重复) + # 妫€鏌ユ槸鍚﹀凡缁忚褰曡繃锛堥槻姝㈤噸澶嶏級 existing_usage = PromoCodeUsage.query.filter_by( order_id=order.id ).first() if not existing_usage: - # 创建优惠码使用记录 + # 鍒涘缓浼樻儬鐮佷娇鐢ㄨ褰? usage = PromoCodeUsage( promo_code_id=order.promo_code_id, user_id=order.user_id, @@ -2908,32 +2778,32 @@ def wechat_payment_callback(): ) db.session.add(usage) - # 更新优惠码使用次数 + # 鏇存柊浼樻儬鐮佷娇鐢ㄦ鏁? promo = PromoCode.query.get(order.promo_code_id) if promo: promo.current_uses = (promo.current_uses or 0) + 1 - print(f"🎫 优惠码使用记录已创建: {promo.code}, 当前使用次数: {promo.current_uses}") + print(f"馃帿 浼樻儬鐮佷娇鐢ㄨ褰曞凡鍒涘缓: {promo.code}, 褰撳墠浣跨敤娆℃暟: {promo.current_uses}") else: - print(f"ℹ️ 优惠码使用记录已存在,跳过") + print(f"鈩癸笍 浼樻儬鐮佷娇鐢ㄨ褰曞凡瀛樺湪锛岃烦杩?) except Exception as e: - print(f"⚠️ 记录优惠码使用失败: {e}") - # 不影响主流程,继续执行 + print(f"鈿狅笍 璁板綍浼樻儬鐮佷娇鐢ㄥけ璐? {e}") + # 涓嶅奖鍝嶄富娴佺▼锛岀户缁墽琛? db.session.commit() - # 返回成功响应给微信 + # 杩斿洖鎴愬姛鍝嶅簲缁欏井淇? return '' except Exception as e: db.session.rollback() - print(f"❌ 微信支付回调处理失败: {e}") + print(f"鉂?寰俊鏀粯鍥炶皟澶勭悊澶辫触: {e}") import traceback - app.logger.error(f"回调处理错误: {e}", exc_info=True) - return '' + app.logger.error(f"鍥炶皟澶勭悊閿欒: {e}", exc_info=True) + return '' def _parse_xml_callback(xml_data): - """简单的XML回调数据解析""" + """绠€鍗曠殑XML鍥炶皟鏁版嵁瑙f瀽""" try: import xml.etree.ElementTree as ET root = ET.fromstring(xml_data) @@ -2942,42 +2812,42 @@ def _parse_xml_callback(xml_data): result[child.tag] = child.text return result except Exception as e: - print(f"XML解析失败: {e}") + print(f"XML瑙f瀽澶辫触: {e}") return None # ======================================== -# 支付宝支付相关API +# 鏀粯瀹濇敮浠樼浉鍏矨PI # ======================================== @app.route('/api/payment/alipay/create-order', methods=['POST']) def create_alipay_order(): """ - 创建支付宝支付订单 + 鍒涘缓鏀粯瀹濇敮浠樿鍗? Request Body: { "plan_name": "pro", "billing_cycle": "yearly", - "promo_code": "WELCOME2025", // 可选 - "is_mobile": true // 可选,是否为手机端(自动使用 WAP 支付) + "promo_code": "WELCOME2025", // 鍙€? + "is_mobile": true // 鍙€夛紝鏄惁涓烘墜鏈虹锛堣嚜鍔ㄤ娇鐢?WAP 鏀粯锛? } """ try: if 'user_id' not in session: - return jsonify({'success': False, 'error': '未登录'}), 401 + 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') or '').strip() or None - # 前端传入的设备类型,用于决定使用 page 支付还是 wap 支付 + # 鍓嶇浼犲叆鐨勮澶囩被鍨嬶紝鐢ㄤ簬鍐冲畾浣跨敤 page 鏀粯杩樻槸 wap 鏀粯 is_mobile = data.get('is_mobile', False) if not plan_name or not billing_cycle: - return jsonify({'success': False, 'error': '参数不完整'}), 400 + return jsonify({'success': False, 'error': '鍙傛暟涓嶅畬鏁?}), 400 - # 使用简化价格计算 + # 浣跨敤绠€鍖栦环鏍艰绠? price_result = calculate_subscription_price_simple(session['user_id'], plan_name, billing_cycle, promo_code) if 'error' in price_result: @@ -2986,16 +2856,16 @@ def create_alipay_order(): amount = price_result['final_amount'] subscription_type = price_result.get('subscription_type', 'new') - # 检查是否为免费升级 + # 妫€鏌ユ槸鍚︿负鍏嶈垂鍗囩骇 if amount <= 0 and price_result.get('is_upgrade'): return jsonify({ 'success': False, - 'error': '当前剩余价值可直接免费升级,请使用免费升级功能', + 'error': '褰撳墠鍓╀綑浠峰€煎彲鐩存帴鍏嶈垂鍗囩骇锛岃浣跨敤鍏嶈垂鍗囩骇鍔熻兘', 'should_free_upgrade': True, 'price_info': price_result }), 400 - # 创建订单 + # 鍒涘缓璁㈠崟 try: original_amount = price_result.get('original_amount', amount) discount_amount = price_result.get('discount_amount', 0) @@ -3009,31 +2879,31 @@ def create_alipay_order(): discount_amount=discount_amount ) - # 设置支付方式为支付宝 + # 璁剧疆鏀粯鏂瑰紡涓烘敮浠樺疂 order.payment_method = 'alipay' - order.remark = f"{subscription_type}订阅" if subscription_type == 'renew' else "新购订阅" + order.remark = f"{subscription_type}璁㈤槄" if subscription_type == 'renew' else "鏂拌喘璁㈤槄" - # 关联优惠码 + # 鍏宠仈浼樻儬鐮? 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 - print(f"📦 订单关联优惠码: {promo_obj.code} (ID: {promo_obj.id})") + print(f"馃摝 璁㈠崟鍏宠仈浼樻儬鐮? {promo_obj.code} (ID: {promo_obj.id})") db.session.add(order) db.session.commit() except Exception as e: db.session.rollback() - return jsonify({'success': False, 'error': f'订单创建失败: {str(e)}'}), 500 + return jsonify({'success': False, 'error': f'璁㈠崟鍒涘缓澶辫触: {str(e)}'}), 500 - # 调用支付宝支付API(使用 subprocess 绕过 eventlet DNS 问题) + # 璋冪敤鏀粯瀹濇敮浠楢PI锛堜娇鐢?subprocess 缁曡繃 eventlet DNS 闂锛? try: import subprocess script_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'alipay_pay_worker.py') - # 先检查配置 + # 鍏堟鏌ラ厤缃? check_result = subprocess.run( [sys.executable, script_path, 'check'], capture_output=True, text=True, timeout=10 @@ -3041,147 +2911,147 @@ def create_alipay_order(): if check_result.returncode != 0: check_data = json.loads(check_result.stdout) if check_result.stdout else {} - error_msg = check_data.get('error', check_data.get('message', '支付宝配置错误')) - order.remark = f"支付宝配置错误 - {error_msg}" + error_msg = check_data.get('error', check_data.get('message', '鏀粯瀹濋厤缃敊璇?)) + order.remark = f"鏀粯瀹濋厤缃敊璇?- {error_msg}" db.session.commit() return jsonify({ 'success': False, - 'error': f'支付宝支付暂不可用: {error_msg}' + 'error': f'鏀粯瀹濇敮浠樻殏涓嶅彲鐢? {error_msg}' }), 500 - # 创建支付宝订单 - plan_display_name = f"{plan_name.upper()}版本-{billing_cycle}" + # 鍒涘缓鏀粯瀹濊鍗? + plan_display_name = f"{plan_name.upper()}鐗堟湰-{billing_cycle}" subject = f"VFr-{plan_display_name}" - body = f"价值前沿订阅服务-{plan_display_name}" + body = f"浠峰€煎墠娌胯闃呮湇鍔?{plan_display_name}" - # 金额格式化为两位小数(支付宝要求) + # 閲戦鏍煎紡鍖栦负涓や綅灏忔暟锛堟敮浠樺疂瑕佹眰锛? amount_str = f"{float(amount):.2f}" - # 根据设备类型选择支付方式:wap=手机网站支付,page=电脑网站支付 + # 鏍规嵁璁惧绫诲瀷閫夋嫨鏀粯鏂瑰紡锛歸ap=鎵嬫満缃戠珯鏀粯锛宲age=鐢佃剳缃戠珯鏀粯 pay_type = 'wap' if is_mobile else 'page' - print(f"[支付宝] 设备类型: {'手机' if is_mobile else '电脑'}, 支付方式: {pay_type}") + print(f"[鏀粯瀹漖 璁惧绫诲瀷: {'鎵嬫満' if is_mobile else '鐢佃剳'}, 鏀粯鏂瑰紡: {pay_type}") create_result = subprocess.run( [sys.executable, script_path, 'create', order.order_no, amount_str, subject, body, pay_type], capture_output=True, text=True, timeout=60 ) - print(f"[支付宝] 创建订单返回: {create_result.stdout}") + print(f"[鏀粯瀹漖 鍒涘缓璁㈠崟杩斿洖: {create_result.stdout}") if create_result.stderr: - print(f"[支付宝] 错误输出: {create_result.stderr}") + print(f"[鏀粯瀹漖 閿欒杈撳嚭: {create_result.stderr}") - alipay_result = json.loads(create_result.stdout) if create_result.stdout else {'success': False, 'error': '无返回'} + alipay_result = json.loads(create_result.stdout) if create_result.stdout else {'success': False, 'error': '鏃犺繑鍥?} if alipay_result.get('success'): - # 获取支付宝返回的支付链接 + # 鑾峰彇鏀粯瀹濊繑鍥炵殑鏀粯閾炬帴 pay_url = alipay_result['pay_url'] order.pay_url = pay_url - order.remark = f"支付宝支付 - 订单已创建" + order.remark = f"鏀粯瀹濇敮浠?- 璁㈠崟宸插垱寤? db.session.commit() return jsonify({ 'success': True, 'data': order.to_dict(), - 'message': '订单创建成功' + 'message': '璁㈠崟鍒涘缓鎴愬姛' }) else: - order.remark = f"支付宝支付失败: {alipay_result.get('error')}" + order.remark = f"鏀粯瀹濇敮浠樺け璐? {alipay_result.get('error')}" db.session.commit() return jsonify({ 'success': False, - 'error': f"支付宝订单创建失败: {alipay_result.get('error')}" + 'error': f"鏀粯瀹濊鍗曞垱寤哄け璐? {alipay_result.get('error')}" }), 500 except subprocess.TimeoutExpired: - order.remark = "支付宝支付超时" + order.remark = "鏀粯瀹濇敮浠樿秴鏃? db.session.commit() - return jsonify({'success': False, 'error': '支付宝支付超时'}), 500 + return jsonify({'success': False, 'error': '鏀粯瀹濇敮浠樿秴鏃?}), 500 except json.JSONDecodeError as e: - order.remark = f"支付宝返回解析失败: {str(e)}" + order.remark = f"鏀粯瀹濊繑鍥炶В鏋愬け璐? {str(e)}" db.session.commit() - return jsonify({'success': False, 'error': '支付宝返回数据异常'}), 500 + return jsonify({'success': False, 'error': '鏀粯瀹濊繑鍥炴暟鎹紓甯?}), 500 except Exception as e: import traceback - print(f"[支付宝] Exception: {e}") + print(f"[鏀粯瀹漖 Exception: {e}") traceback.print_exc() - order.remark = f"支付异常: {str(e)}" + order.remark = f"鏀粯寮傚父: {str(e)}" db.session.commit() - return jsonify({'success': False, 'error': '支付异常'}), 500 + return jsonify({'success': False, 'error': '鏀粯寮傚父'}), 500 except Exception as e: db.session.rollback() - return jsonify({'success': False, 'error': '创建订单失败'}), 500 + return jsonify({'success': False, 'error': '鍒涘缓璁㈠崟澶辫触'}), 500 @app.route('/api/payment/alipay/callback', methods=['POST']) def alipay_payment_callback(): - """支付宝异步回调处理""" + """鏀粯瀹濆紓姝ュ洖璋冨鐞?"" try: - # 获取POST参数 + # 鑾峰彇POST鍙傛暟 callback_params = request.form.to_dict() - print(f"📥 收到支付宝支付回调: {callback_params}") + print(f"馃摜 鏀跺埌鏀粯瀹濇敮浠樺洖璋? {callback_params}") - # 验证回调数据 + # 楠岃瘉鍥炶皟鏁版嵁 try: from alipay_pay import create_alipay_instance alipay = create_alipay_instance() verify_result = alipay.verify_callback(callback_params.copy()) if not verify_result['success']: - print(f"❌ 支付宝回调签名验证失败: {verify_result['error']}") + print(f"鉂?鏀粯瀹濆洖璋冪鍚嶉獙璇佸け璐? {verify_result['error']}") return 'fail' callback_data = verify_result['data'] except Exception as e: - print(f"❌ 支付宝回调处理异常: {e}") + print(f"鉂?鏀粯瀹濆洖璋冨鐞嗗紓甯? {e}") return 'fail' - # 获取关键字段 + # 鑾峰彇鍏抽敭瀛楁 trade_status = callback_data.get('trade_status') - out_trade_no = callback_data.get('out_trade_no') # 商户订单号 - trade_no = callback_data.get('trade_no') # 支付宝交易号 + out_trade_no = callback_data.get('out_trade_no') # 鍟嗘埛璁㈠崟鍙? + trade_no = callback_data.get('trade_no') # 鏀粯瀹濅氦鏄撳彿 total_amount = callback_data.get('total_amount') - print(f"📦 支付宝回调数据解析:") - print(f" 交易状态: {trade_status}") - print(f" 订单号: {out_trade_no}") - print(f" 交易号: {trade_no}") - print(f" 金额: {total_amount}") + print(f"馃摝 鏀粯瀹濆洖璋冩暟鎹В鏋?") + print(f" 浜ゆ槗鐘舵€? {trade_status}") + print(f" 璁㈠崟鍙? {out_trade_no}") + print(f" 浜ゆ槗鍙? {trade_no}") + print(f" 閲戦: {total_amount}") if not out_trade_no: - print("❌ 缺少订单号") + print("鉂?缂哄皯璁㈠崟鍙?) return 'fail' - # 查找订单 + # 鏌ユ壘璁㈠崟 order = PaymentOrder.query.filter_by(order_no=out_trade_no).first() if not order: - print(f"❌ 订单不存在: {out_trade_no}") + print(f"鉂?璁㈠崟涓嶅瓨鍦? {out_trade_no}") return 'fail' - # 只处理交易成功的回调 + # 鍙鐞嗕氦鏄撴垚鍔熺殑鍥炶皟 if trade_status in ['TRADE_SUCCESS', 'TRADE_FINISHED']: - print(f"🎉 支付宝支付成功: 订单 {out_trade_no}") + print(f"馃帀 鏀粯瀹濇敮浠樻垚鍔? 璁㈠崟 {out_trade_no}") - # 检查订单是否已经处理过 + # 妫€鏌ヨ鍗曟槸鍚﹀凡缁忓鐞嗚繃 if order.status == 'paid': - print(f"ℹ️ 订单已处理过: {out_trade_no}") + print(f"鈩癸笍 璁㈠崟宸插鐞嗚繃: {out_trade_no}") return 'success' - # 更新订单状态 + # 鏇存柊璁㈠崟鐘舵€? old_status = order.status order.mark_as_paid(trade_no, 'alipay') - print(f"📝 订单状态已更新: {old_status} -> paid") + 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}") + print(f"鉁?鐢ㄦ埛璁㈤槄宸叉縺娲? 鐢ㄦ埛{order.user_id}, 濂楅{order.plan_name}") else: - print(f"⚠️ 订阅激活失败,但订单已标记为已支付") + print(f"鈿狅笍 璁㈤槄婵€娲诲け璐ワ紝浣嗚鍗曞凡鏍囪涓哄凡鏀粯") - # 记录优惠码使用情况 + # 璁板綍浼樻儬鐮佷娇鐢ㄦ儏鍐? if order.promo_code_id: try: existing_usage = PromoCodeUsage.query.filter_by(order_id=order.id).first() @@ -3200,89 +3070,89 @@ def alipay_payment_callback(): promo = PromoCode.query.get(order.promo_code_id) if promo: promo.current_uses = (promo.current_uses or 0) + 1 - print(f"🎫 优惠码使用记录已创建: {promo.code}, 当前使用次数: {promo.current_uses}") + print(f"馃帿 浼樻儬鐮佷娇鐢ㄨ褰曞凡鍒涘缓: {promo.code}, 褰撳墠浣跨敤娆℃暟: {promo.current_uses}") else: - print(f"ℹ️ 优惠码使用记录已存在,跳过") + print(f"鈩癸笍 浼樻儬鐮佷娇鐢ㄨ褰曞凡瀛樺湪锛岃烦杩?) except Exception as e: - print(f"⚠️ 记录优惠码使用失败: {e}") + print(f"鈿狅笍 璁板綍浼樻儬鐮佷娇鐢ㄥけ璐? {e}") db.session.commit() elif trade_status == 'TRADE_CLOSED': - # 交易关闭 + # 浜ゆ槗鍏抽棴 if order.status not in ['paid', 'cancelled']: order.status = 'cancelled' db.session.commit() - print(f"📝 订单已关闭: {out_trade_no}") + print(f"馃摑 璁㈠崟宸插叧闂? {out_trade_no}") - # 返回成功响应给支付宝 + # 杩斿洖鎴愬姛鍝嶅簲缁欐敮浠樺疂 return 'success' except Exception as e: db.session.rollback() - print(f"❌ 支付宝回调处理失败: {e}") + print(f"鉂?鏀粯瀹濆洖璋冨鐞嗗け璐? {e}") import traceback - app.logger.error(f"支付宝回调处理错误: {e}", exc_info=True) + app.logger.error(f"鏀粯瀹濆洖璋冨鐞嗛敊璇? {e}", exc_info=True) return 'fail' @app.route('/api/payment/alipay/return', methods=['GET']) def alipay_payment_return(): - """支付宝同步返回处理(用户支付后跳转回来)""" + """鏀粯瀹濆悓姝ヨ繑鍥炲鐞嗭紙鐢ㄦ埛鏀粯鍚庤烦杞洖鏉ワ級""" try: - # 获取GET参数 + # 鑾峰彇GET鍙傛暟 return_params = request.args.to_dict() - print(f"📥 支付宝同步返回: {return_params}") + print(f"馃摜 鏀粯瀹濆悓姝ヨ繑鍥? {return_params}") out_trade_no = return_params.get('out_trade_no') if out_trade_no: - # 重定向到前端支付结果页面 + # 閲嶅畾鍚戝埌鍓嶇鏀粯缁撴灉椤甸潰 return redirect(f'{FRONTEND_URL}/pricing?payment_return=alipay&order_no={out_trade_no}') else: return redirect(f'{FRONTEND_URL}/pricing?payment_return=alipay&error=missing_order') except Exception as e: - print(f"❌ 支付宝同步返回处理失败: {e}") + print(f"鉂?鏀粯瀹濆悓姝ヨ繑鍥炲鐞嗗け璐? {e}") return redirect(f'{FRONTEND_URL}/pricing?payment_return=alipay&error=exception') @app.route('/api/payment/alipay/order//status', methods=['GET']) def check_alipay_order_status(order_id): - """查询支付宝订单支付状态""" + """鏌ヨ鏀粯瀹濊鍗曟敮浠樼姸鎬?"" try: if 'user_id' not in session: - return jsonify({'success': False, 'error': '未登录'}), 401 + 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 + return jsonify({'success': False, 'error': '璁㈠崟涓嶅瓨鍦?}), 404 - # 如果订单已经是已支付状态,直接返回 + # 濡傛灉璁㈠崟宸茬粡鏄凡鏀粯鐘舵€侊紝鐩存帴杩斿洖 if order.status == 'paid': return jsonify({ 'success': True, 'data': order.to_dict(), - 'message': '订单已支付', + 'message': '璁㈠崟宸叉敮浠?, 'payment_success': True }) - # 如果订单过期,标记为过期 + # 濡傛灉璁㈠崟杩囨湡锛屾爣璁颁负杩囨湡 if order.is_expired(): order.status = 'expired' db.session.commit() return jsonify({ 'success': True, 'data': order.to_dict(), - 'message': '订单已过期' + 'message': '璁㈠崟宸茶繃鏈? }) - # 调用支付宝API查询真实状态 + # 璋冪敤鏀粯瀹滱PI鏌ヨ鐪熷疄鐘舵€? try: import subprocess @@ -3293,20 +3163,20 @@ def check_alipay_order_status(order_id): capture_output=True, text=True, timeout=30 ) - query_result = json.loads(query_proc.stdout) if query_proc.stdout else {'success': False, 'error': '无返回'} + query_result = json.loads(query_proc.stdout) if query_proc.stdout else {'success': False, 'error': '鏃犺繑鍥?} if query_result.get('success'): trade_state = query_result.get('trade_state') trade_no = query_result.get('trade_no') if trade_state == 'SUCCESS': - # 支付成功,更新订单状态 + # 鏀粯鎴愬姛锛屾洿鏂拌鍗曠姸鎬? order.mark_as_paid(trade_no, 'alipay') - # 激活用户订阅 + # 婵€娲荤敤鎴疯闃? activate_user_subscription(order.user_id, order.plan_name, order.billing_cycle) - # 记录优惠码使用情况 + # 璁板綍浼樻儬鐮佷娇鐢ㄦ儏鍐? if order.promo_code_id: try: existing_usage = PromoCodeUsage.query.filter_by(order_id=order.id).first() @@ -3323,96 +3193,96 @@ def check_alipay_order_status(order_id): promo = PromoCode.query.get(order.promo_code_id) if promo: promo.current_uses = (promo.current_uses or 0) + 1 - print(f"🎫 优惠码使用记录已创建: {promo.code}") + print(f"馃帿 浼樻儬鐮佷娇鐢ㄨ褰曞凡鍒涘缓: {promo.code}") except Exception as e: - print(f"⚠️ 记录优惠码使用失败: {e}") + print(f"鈿狅笍 璁板綍浼樻儬鐮佷娇鐢ㄥけ璐? {e}") db.session.commit() return jsonify({ 'success': True, 'data': order.to_dict(), - 'message': '支付成功!订阅已激活', + 'message': '鏀粯鎴愬姛锛佽闃呭凡婵€娲?, 'payment_success': True }) elif trade_state in ['NOTPAY', 'WAIT_BUYER_PAY']: - # 未支付或等待支付 + # 鏈敮浠樻垨绛夊緟鏀粯 return jsonify({ 'success': True, 'data': order.to_dict(), - 'message': '等待支付...', + 'message': '绛夊緟鏀粯...', 'payment_success': False }) elif trade_state in ['CLOSED', 'TRADE_CLOSED']: - # 交易关闭 + # 浜ゆ槗鍏抽棴 order.status = 'cancelled' db.session.commit() return jsonify({ 'success': True, 'data': order.to_dict(), - 'message': '交易已关闭', + 'message': '浜ゆ槗宸插叧闂?, 'payment_success': False }) else: - # 其他状态 + # 鍏朵粬鐘舵€? return jsonify({ 'success': True, 'data': order.to_dict(), - 'message': f'当前状态: {trade_state}', + 'message': f'褰撳墠鐘舵€? {trade_state}', 'payment_success': False }) else: - # 支付宝查询失败,返回当前状态 + # 鏀粯瀹濇煡璇㈠け璐ワ紝杩斿洖褰撳墠鐘舵€? return jsonify({ 'success': True, 'data': order.to_dict(), - 'message': f"查询失败: {query_result.get('error')}", + 'message': f"鏌ヨ澶辫触: {query_result.get('error')}", 'payment_success': False }) except Exception as e: - # 查询失败,返回当前订单状态 + # 鏌ヨ澶辫触锛岃繑鍥炲綋鍓嶈鍗曠姸鎬? return jsonify({ 'success': True, 'data': order.to_dict(), - 'message': '无法查询支付状态,请稍后重试', + 'message': '鏃犳硶鏌ヨ鏀粯鐘舵€侊紝璇风◢鍚庨噸璇?, 'payment_success': False }) except Exception as e: - return jsonify({'success': False, 'error': '查询失败'}), 500 + return jsonify({'success': False, 'error': '鏌ヨ澶辫触'}), 500 @app.route('/api/payment/alipay/order-by-no//status', methods=['GET']) def check_alipay_order_status_by_no(order_no): - """通过订单号查询支付宝订单支付状态(用于手机端支付返回)""" + """閫氳繃璁㈠崟鍙锋煡璇㈡敮浠樺疂璁㈠崟鏀粯鐘舵€侊紙鐢ㄤ簬鎵嬫満绔敮浠樿繑鍥烇級""" try: if 'user_id' not in session: - return jsonify({'success': False, 'error': '未登录'}), 401 + return jsonify({'success': False, 'error': '鏈櫥褰?}), 401 - # 通过订单号查找订单 + # 閫氳繃璁㈠崟鍙锋煡鎵捐鍗? order = PaymentOrder.query.filter_by( order_no=order_no, user_id=session['user_id'] ).first() if not order: - return jsonify({'success': False, 'error': '订单不存在'}), 404 + return jsonify({'success': False, 'error': '璁㈠崟涓嶅瓨鍦?}), 404 - # 复用现有的状态检查逻辑 + # 澶嶇敤鐜版湁鐨勭姸鎬佹鏌ラ€昏緫 return check_alipay_order_status(str(order.id)) except Exception as e: - return jsonify({'success': False, 'error': '查询失败'}), 500 + return jsonify({'success': False, 'error': '鏌ヨ澶辫触'}), 500 @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({ @@ -3430,7 +3300,7 @@ def get_session_info(): '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'], @@ -3447,13 +3317,13 @@ def get_session_info(): def generate_verification_code(): - """生成6位数字验证码""" + """鐢熸垚6浣嶆暟瀛楅獙璇佺爜""" return ''.join(random.choices(string.digits, k=6)) @app.route('/api/auth/login', methods=['POST']) def login(): - """传统登录 - 使用Session""" + """浼犵粺鐧诲綍 - 浣跨敤Session""" try: username = request.form.get('username') @@ -3461,58 +3331,58 @@ def login(): phone = request.form.get('phone') password = request.form.get('password') - # 验证必要参数 + # 楠岃瘉蹇呰鍙傛暟 if not password: - return jsonify({'success': False, 'error': '密码不能为空'}), 400 + return jsonify({'success': False, 'error': '瀵嗙爜涓嶈兘涓虹┖'}), 400 - # 根据提供的信息查找用户 + # 鏍规嵁鎻愪緵鐨勪俊鎭煡鎵剧敤鎴? user = None if username: - # 检查username是否为手机号格式 + # 妫€鏌sername鏄惁涓烘墜鏈哄彿鏍煎紡 if re.match(r'^1[3-9]\d{9}$', username): - # 如果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 + return jsonify({'success': False, 'error': '璇锋彁渚涚敤鎴峰悕銆侀偖绠辨垨鎵嬫満鍙?}), 400 if not user: - return jsonify({'success': False, 'error': '用户不存在'}), 404 + 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 + return jsonify({'success': False, 'error': '瀵嗙爜閿欒'}), 401 - # 设置session - session.permanent = True # 使用永久session + # 璁剧疆session + session.permanent = True # 浣跨敤姘镐箙session session['user_id'] = user.id session['username'] = user.username session['logged_in'] = True - # Flask-Login 登录 + # Flask-Login 鐧诲綍 login_user(user, remember=True) - # 更新最后登录时间 + # 鏇存柊鏈€鍚庣櫥褰曟椂闂? user.update_last_seen() return jsonify({ 'success': True, - 'message': '登录成功', + 'message': '鐧诲綍鎴愬姛', 'user': { 'id': user.id, 'username': user.username, @@ -3526,11 +3396,11 @@ def login(): except Exception as e: import traceback - app.logger.error(f"回调处理错误: {e}", exc_info=True) - return jsonify({'success': False, 'error': '登录处理失败,请重试'}), 500 + app.logger.error(f"鍥炶皟澶勭悊閿欒: {e}", exc_info=True) + return jsonify({'success': False, 'error': '鐧诲綍澶勭悊澶辫触锛岃閲嶈瘯'}), 500 -# 添加OPTIONS请求处理 +# 娣诲姞OPTIONS璇锋眰澶勭悊 @app.before_request def handle_preflight(): if request.method == "OPTIONS": @@ -3541,64 +3411,64 @@ def handle_preflight(): return response -# 修改密码API +# 淇敼瀵嗙爜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) # 是否为首次设置密码 + is_first_set = data.get('isFirstSet', False) # 鏄惁涓洪娆¤缃瘑鐮? if not new_password: - return jsonify({'success': False, 'error': '新密码不能为空'}), 400 + return jsonify({'success': False, 'error': '鏂板瘑鐮佷笉鑳戒负绌?}), 400 if len(new_password) < 6: - return jsonify({'success': False, 'error': '新密码至少需要6个字符'}), 400 + return jsonify({'success': False, 'error': '鏂板瘑鐮佽嚦灏戦渶瑕?涓瓧绗?}), 400 - # 获取当前用户 + # 鑾峰彇褰撳墠鐢ㄦ埛 user = current_user if not user: - return jsonify({'success': False, 'error': '用户未登录'}), 401 + 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 # 跳过当前密码验证 + pass # 璺宠繃褰撳墠瀵嗙爜楠岃瘉 else: - # 普通用户或非首次设置,需要验证当前密码 + # 鏅€氱敤鎴锋垨闈為娆¤缃紝闇€瑕侀獙璇佸綋鍓嶅瘑鐮? if not current_password: - return jsonify({'success': False, 'error': '请输入当前密码'}), 400 + return jsonify({'success': False, 'error': '璇疯緭鍏ュ綋鍓嶅瘑鐮?}), 400 if not user.check_password(current_password): - return jsonify({'success': False, 'error': '当前密码错误'}), 400 + 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 '密码修改成功' + 'message': '瀵嗙爜璁剧疆鎴愬姛' if (is_first_set or is_wechat_user) else '瀵嗙爜淇敼鎴愬姛' }) except Exception as e: return jsonify({'success': False, 'error': str(e)}), 500 -# 检查用户密码状态API +# 妫€鏌ョ敤鎴峰瘑鐮佺姸鎬丄PI @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 + return jsonify({'success': False, 'error': '鐢ㄦ埛鏈櫥褰?}), 401 is_wechat_user = bool(user.wechat_open_id) @@ -3607,7 +3477,7 @@ def get_password_status(): 'data': { 'isWechatUser': is_wechat_user, 'hasPassword': bool(user.password_hash), - 'needsFirstTimeSetup': is_wechat_user # 微信用户需要首次设置 + 'needsFirstTimeSetup': is_wechat_user # 寰俊鐢ㄦ埛闇€瑕侀娆¤缃? } }) @@ -3615,18 +3485,18 @@ def get_password_status(): return jsonify({'success': False, 'error': str(e)}), 500 -# 检查用户信息完整性API +# 妫€鏌ョ敤鎴蜂俊鎭畬鏁存€PI @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 + return jsonify({'success': False, 'error': '鐢ㄦ埛鏈櫥褰?}), 401 is_wechat_user = bool(user.wechat_open_id) - # 检查各项信息 + # 妫€鏌ュ悇椤逛俊鎭? completeness = { 'hasPassword': bool(user.password_hash), 'hasPhone': bool(user.phone), @@ -3634,42 +3504,42 @@ def get_profile_completeness(): '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天内) + # 妫€鏌ョ敤鎴锋槸鍚︽槸鏂扮敤鎴凤紙娉ㄥ唽7澶╁唴锛? is_new_user = (datetime.now() - user.created_at).days < 7 - # 检查是否最近没有提醒过(使用session记录) + # 妫€鏌ユ槸鍚︽渶杩戞病鏈夋彁閱掕繃锛堜娇鐢╯ession璁板綍锛? 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('登录密码') + missing_items.append('鐧诲綍瀵嗙爜') if not completeness['hasPhone']: - missing_items.append('手机号') + missing_items.append('鎵嬫満鍙?) if not completeness['hasEmail']: - missing_items.append('邮箱') + missing_items.append('閭') - # 记录本次提醒时间 + # 璁板綍鏈鎻愰啋鏃堕棿 session['last_completeness_reminder'] = datetime.now().isoformat() return jsonify({ @@ -3680,48 +3550,48 @@ def get_profile_completeness(): 'needsAttention': needs_attention, 'missingItems': missing_items, 'isComplete': completed_items == total_items, - 'showReminder': needs_attention # 前端使用这个字段决定是否显示提醒 + 'showReminder': needs_attention # 鍓嶇浣跨敤杩欎釜瀛楁鍐冲畾鏄惁鏄剧ず鎻愰啋 } }) except Exception as e: - print(f"获取资料完整性错误: {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""" + logout_user() # Flask-Login 鐧诲嚭 session.clear() - return jsonify({'success': True, 'message': '已登出'}) + 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' + 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 + return jsonify({'success': False, 'error': '缂哄皯蹇呰鍙傛暟'}), 400 - # 清理格式字符(空格、横线、括号等) + # 娓呯悊鏍煎紡瀛楃锛堢┖鏍笺€佹í绾裤€佹嫭鍙风瓑锛? if code_type == 'phone': - # 移除手机号中的空格、横线、括号、加号等格式字符 + # 绉婚櫎鎵嬫満鍙蜂腑鐨勭┖鏍笺€佹í绾裤€佹嫭鍙枫€佸姞鍙风瓑鏍煎紡瀛楃 credential = re.sub(r'[\s\-\(\)\+]', '', credential) - print(f"📱 清理后的手机号: {credential}") + print(f"馃摫 娓呯悊鍚庣殑鎵嬫満鍙? {credential}") elif code_type == 'email': - # 邮箱只移除空格 + # 閭鍙Щ闄ょ┖鏍? credential = credential.strip() - # 生成验证码 + # 鐢熸垚楠岃瘉鐮? verification_code = generate_verification_code() - # 存储验证码到session(实际生产环境建议使用Redis) + # 瀛樺偍楠岃瘉鐮佸埌session锛堝疄闄呯敓浜х幆澧冨缓璁娇鐢≧edis锛? session_key = f'verification_code_{code_type}_{credential}_{purpose}' session[session_key] = { 'code': verification_code, @@ -3730,96 +3600,96 @@ def send_verification_code(): } if code_type == 'phone': - # 手机号验证码发送 + # 鎵嬫満鍙烽獙璇佺爜鍙戦€? if not re.match(r'^1[3-9]\d{9}$', credential): - return jsonify({'success': False, 'error': '手机号格式不正确'}), 400 + return jsonify({'success': False, 'error': '鎵嬫満鍙锋牸寮忎笉姝g‘'}), 400 - # 发送真实短信验证码 + # 鍙戦€佺湡瀹炵煭淇¢獙璇佺爜 if send_sms_code(credential, verification_code, SMS_TEMPLATE_LOGIN): - print(f"[短信已发送] 验证码到 {credential}: {verification_code}") + print(f"[鐭俊宸插彂閫乚 楠岃瘉鐮佸埌 {credential}: {verification_code}") else: - return jsonify({'success': False, 'error': '短信发送失败,请稍后重试'}), 500 + 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 + return jsonify({'success': False, 'error': '閭鏍煎紡涓嶆纭?}), 400 - # 发送真实邮件验证码 + # 鍙戦€佺湡瀹為偖浠堕獙璇佺爜 if send_email_code(credential, verification_code): - print(f"[邮件已发送] 验证码到 {credential}: {verification_code}") + print(f"[閭欢宸插彂閫乚 楠岃瘉鐮佸埌 {credential}: {verification_code}") else: - return jsonify({'success': False, 'error': '邮件发送失败,请稍后重试'}), 500 + return jsonify({'success': False, 'error': '閭欢鍙戦€佸け璐ワ紝璇风◢鍚庨噸璇?}), 500 else: - return jsonify({'success': False, 'error': '不支持的验证码类型'}), 400 + return jsonify({'success': False, 'error': '涓嶆敮鎸佺殑楠岃瘉鐮佺被鍨?}), 400 return jsonify({ 'success': True, - 'message': f'验证码已发送到您的{code_type}' + 'message': f'楠岃瘉鐮佸凡鍙戦€佸埌鎮ㄧ殑{code_type}' }) except Exception as e: - print(f"发送验证码错误: {e}") - return jsonify({'success': False, 'error': '发送验证码失败'}), 500 + 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') # 手机号或邮箱 + credential = data.get('credential') # 鎵嬫満鍙锋垨閭 verification_code = data.get('verification_code') - login_type = data.get('login_type') # 'phone' 或 'email' + 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 + 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}") + 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 + 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 + return jsonify({'success': False, 'error': '楠岃瘉鐮佸凡杩囨湡'}), 400 - # 检查尝试次数 + # 妫€鏌ュ皾璇曟鏁? if stored_code_info['attempts'] >= 3: session.pop(session_key, None) - return jsonify({'success': False, 'error': '验证码错误次数过多'}), 400 + 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 + 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 @@ -3827,19 +3697,19 @@ def login_with_verification_code(): username = f"{base_username}_{counter}" counter += 1 - # 创建新用户 + # 鍒涘缓鏂扮敤鎴? user = User(username=username, phone=credential) user.phone_confirmed = True - user.email = f"{username}@valuefrontier.temp" # 临时邮箱 + 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 @@ -3848,36 +3718,36 @@ def login_with_verification_code(): username = f"{base_username}_{counter}" counter += 1 - # 如果用户不存在,自动创建新用户 + # 濡傛灉鐢ㄦ埛涓嶅瓨鍦紝鑷姩鍒涘缓鏂扮敤鎴? if not user: try: - # 生成用户名 + # 鐢熸垚鐢ㄦ埛鍚? if login_type == 'phone': - # 使用手机号生成用户名 - base_username = f"用户{credential[-4:]}" + # 浣跨敤鎵嬫満鍙风敓鎴愮敤鎴峰悕 + base_username = f"鐢ㄦ埛{credential[-4:]}" elif login_type == 'email': - # 使用邮箱前缀生成用户名 + # 浣跨敤閭鍓嶇紑鐢熸垚鐢ㄦ埛鍚? base_username = credential.split('@')[0] else: - base_username = "新用户" + 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 @@ -3886,30 +3756,30 @@ def login_with_verification_code(): db.session.commit() is_new_user = True - print(f"✅ 自动创建新用户: {username}, {login_type}: {credential}") + print(f"鉁?鑷姩鍒涘缓鏂扮敤鎴? {username}, {login_type}: {credential}") except Exception as e: - print(f"❌ 创建用户失败: {e}") + print(f"鉂?鍒涘缓鐢ㄦ埛澶辫触: {e}") db.session.rollback() - return jsonify({'success': False, 'error': '创建用户失败'}), 500 + return jsonify({'success': False, 'error': '鍒涘缓鐢ㄦ埛澶辫触'}), 500 - # 清除验证码 + # 娓呴櫎楠岃瘉鐮? session.pop(session_key, None) - # 设置session + # 璁剧疆session session.permanent = True session['user_id'] = user.id session['username'] = user.username session['logged_in'] = True - # Flask-Login 登录 + # Flask-Login 鐧诲綍 login_user(user, remember=True) - # 更新最后登录时间 + # 鏇存柊鏈€鍚庣櫥褰曟椂闂? user.update_last_seen() - # 根据是否为新用户返回不同的消息 - message = '注册成功,欢迎加入!' if is_new_user else '登录成功' + # 鏍规嵁鏄惁涓烘柊鐢ㄦ埛杩斿洖涓嶅悓鐨勬秷鎭? + message = '娉ㄥ唽鎴愬姛锛屾杩庡姞鍏ワ紒' if is_new_user else '鐧诲綍鎴愬姛' return jsonify({ 'success': True, @@ -3927,39 +3797,39 @@ def login_with_verification_code(): }) except Exception as e: - print(f"验证码登录错误: {e}") + print(f"楠岃瘉鐮佺櫥褰曢敊璇? {e}") db.session.rollback() - return jsonify({'success': False, 'error': '登录失败'}), 500 + return jsonify({'success': False, 'error': '鐧诲綍澶辫触'}), 500 @app.route('/api/auth/register', methods=['POST']) def register(): - """用户注册 - 使用Session""" + """鐢ㄦ埛娉ㄥ唽 - 浣跨敤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 + return jsonify({'success': False, 'error': '鎵€鏈夊瓧娈甸兘鏄繀濉殑'}), 400 - # 检查用户名和邮箱是否已存在 + # 妫€鏌ョ敤鎴峰悕鍜岄偖绠辨槸鍚﹀凡瀛樺湪 if User.is_username_taken(username): - return jsonify({'success': False, 'error': '用户名已存在'}), 400 + return jsonify({'success': False, 'error': '鐢ㄦ埛鍚嶅凡瀛樺湪'}), 400 if User.is_email_taken(email): - return jsonify({'success': False, 'error': '邮箱已被使用'}), 400 + return jsonify({'success': False, 'error': '閭宸茶浣跨敤'}), 400 try: - # 创建新用户 + # 鍒涘缓鏂扮敤鎴? user = User(username=username, email=email) user.set_password(password) - user.email_confirmed = True # 暂时默认已确认 + user.email_confirmed = True # 鏆傛椂榛樿宸茬‘璁? db.session.add(user) - db.session.flush() # 获取 user.id + db.session.flush() # 鑾峰彇 user.id - # 自动创建积分账户,初始10000积分 + # 鑷姩鍒涘缓绉垎璐︽埛锛屽垵濮?0000绉垎 credit_account = UserCreditAccount( user_id=user.id, balance=10000, @@ -3969,18 +3839,18 @@ def register(): db.session.commit() - # 自动登录 + # 鑷姩鐧诲綍 session.permanent = True session['user_id'] = user.id session['username'] = user.username session['logged_in'] = True - # Flask-Login 登录 + # Flask-Login 鐧诲綍 login_user(user, remember=True) return jsonify({ 'success': True, - 'message': '注册成功', + 'message': '娉ㄥ唽鎴愬姛', 'user': { 'id': user.id, 'username': user.username, @@ -3991,25 +3861,25 @@ def register(): except Exception as e: db.session.rollback() - print(f"验证码登录/注册错误: {e}") - return jsonify({'success': False, 'error': '登录失败'}), 500 + print(f"楠岃瘉鐮佺櫥褰?娉ㄥ唽閿欒: {e}") + return jsonify({'success': False, 'error': '鐧诲綍澶辫触'}), 500 def send_sms_code(phone, code, template_id): - """发送短信验证码(使用 subprocess 绕过 eventlet DNS 问题)""" + """鍙戦€佺煭淇¢獙璇佺爜锛堜娇鐢?subprocess 缁曡繃 eventlet DNS 闂锛?"" import subprocess import os try: - # 获取脚本路径 + # 鑾峰彇鑴氭湰璺緞 script_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'sms_sender.py') - print(f"[短信] 准备发送验证码到 {phone},模板ID: {template_id}") + print(f"[鐭俊] 鍑嗗鍙戦€侀獙璇佺爜鍒?{phone}锛屾ā鏉縄D: {template_id}") - # 使用 subprocess 在独立进程中发送短信(绕过 eventlet DNS) + # 浣跨敤 subprocess 鍦ㄧ嫭绔嬭繘绋嬩腑鍙戦€佺煭淇★紙缁曡繃 eventlet DNS锛? result = subprocess.run( [ - sys.executable, # 使用当前 Python 解释器 + sys.executable, # 浣跨敤褰撳墠 Python 瑙i噴鍣? script_path, phone, code, @@ -4025,39 +3895,39 @@ def send_sms_code(phone, code, template_id): ) if result.returncode == 0: - print(f"[短信] ✓ 发送成功: {result.stdout.strip()}") + print(f"[鐭俊] 鉁?鍙戦€佹垚鍔? {result.stdout.strip()}") return True else: - print(f"[短信] ✗ 发送失败: {result.stderr.strip()}") + print(f"[鐭俊] 鉁?鍙戦€佸け璐? {result.stderr.strip()}") return False except subprocess.TimeoutExpired: - print(f"[短信] ✗ 发送超时") + print(f"[鐭俊] 鉁?鍙戦€佽秴鏃?) return False except Exception as e: - print(f"[短信] ✗ 发送异常: {type(e).__name__}: {e}") + print(f"[鐭俊] 鉁?鍙戦€佸紓甯? {type(e).__name__}: {e}") return False def send_email_code(email, code): - """发送邮件验证码(使用 subprocess 绕过 eventlet DNS 问题)""" + """鍙戦€侀偖浠堕獙璇佺爜锛堜娇鐢?subprocess 缁曡繃 eventlet DNS 闂锛?"" import subprocess import os try: - # 获取脚本路径 + # 鑾峰彇鑴氭湰璺緞 script_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'email_sender.py') - subject = '价值前沿 - 验证码' - body = f'您的验证码是:{code},有效期5分钟。如非本人操作,请忽略此邮件。' + subject = '浠峰€煎墠娌?- 楠岃瘉鐮? + body = f'鎮ㄧ殑楠岃瘉鐮佹槸锛歿code}锛屾湁鏁堟湡5鍒嗛挓銆傚闈炴湰浜烘搷浣滐紝璇峰拷鐣ユ閭欢銆? - print(f"[邮件] 准备发送验证码到 {email}") - print(f"[邮件] 服务器: {MAIL_SERVER}:{MAIL_PORT}, SSL: {MAIL_USE_SSL}") + print(f"[閭欢] 鍑嗗鍙戦€侀獙璇佺爜鍒?{email}") + print(f"[閭欢] 鏈嶅姟鍣? {MAIL_SERVER}:{MAIL_PORT}, SSL: {MAIL_USE_SSL}") - # 使用 subprocess 在独立进程中发送邮件(绕过 eventlet DNS) + # 浣跨敤 subprocess 鍦ㄧ嫭绔嬭繘绋嬩腑鍙戦€侀偖浠讹紙缁曡繃 eventlet DNS锛? result = subprocess.run( [ - sys.executable, # 使用当前 Python 解释器 + sys.executable, # 浣跨敤褰撳墠 Python 瑙i噴鍣? script_path, email, subject, @@ -4074,73 +3944,73 @@ def send_email_code(email, code): ) if result.returncode == 0: - print(f"[邮件] ✓ 发送成功: {result.stdout.strip()}") + print(f"[閭欢] 鉁?鍙戦€佹垚鍔? {result.stdout.strip()}") return True else: - print(f"[邮件] ✗ 发送失败: {result.stderr.strip()}") + print(f"[閭欢] 鉁?鍙戦€佸け璐? {result.stderr.strip()}") return False except subprocess.TimeoutExpired: - print(f"[邮件] ✗ 发送超时") + print(f"[閭欢] 鉁?鍙戦€佽秴鏃?) return False except Exception as e: - print(f"[邮件] ✗ 发送异常: {type(e).__name__}: {e}") + print(f"[閭欢] 鉁?鍙戦€佸紓甯? {type(e).__name__}: {e}") 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 + return jsonify({'error': '鎵嬫満鍙蜂笉鑳戒负绌?}), 400 - # 注册时验证是否已注册;若用于绑定手机,需要另外接口 - # 这里保留原逻辑,新增绑定接口处理不同规则 + # 娉ㄥ唽鏃堕獙璇佹槸鍚﹀凡娉ㄥ唽锛涜嫢鐢ㄤ簬缁戝畾鎵嬫満锛岄渶瑕佸彟澶栨帴鍙? + # 杩欓噷淇濈暀鍘熼€昏緫锛屾柊澧炵粦瀹氭帴鍙e鐞嗕笉鍚岃鍒? if User.query.filter_by(phone=phone).first(): - return jsonify({'error': '该手机号已注册'}), 400 + return jsonify({'error': '璇ユ墜鏈哄彿宸叉敞鍐?}), 400 - # 生成验证码 + # 鐢熸垚楠岃瘉鐮? code = generate_verification_code() - # 发送短信 + # 鍙戦€佺煭淇? if send_sms_code(phone, code, SMS_TEMPLATE_REGISTER): - # 存储验证码到 Redis(5分钟有效) + # 瀛樺偍楠岃瘉鐮佸埌 Redis锛?鍒嗛挓鏈夋晥锛? set_verification_code(f'phone_{phone}', code) - return jsonify({'message': '验证码已发送'}), 200 + return jsonify({'message': '楠岃瘉鐮佸凡鍙戦€?}), 200 else: - return jsonify({'error': '验证码发送失败'}), 500 + 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 + return jsonify({'error': '閭涓嶈兘涓虹┖'}), 400 if User.query.filter_by(email=email).first(): - return jsonify({'error': '该邮箱已注册'}), 400 + return jsonify({'error': '璇ラ偖绠卞凡娉ㄥ唽'}), 400 - # 生成验证码 + # 鐢熸垚楠岃瘉鐮? code = generate_verification_code() - # 发送邮件 + # 鍙戦€侀偖浠? if send_email_code(email, code): - # 存储验证码到 Redis(5分钟有效) + # 瀛樺偍楠岃瘉鐮佸埌 Redis锛?鍒嗛挓鏈夋晥锛? set_verification_code(f'email_{email}', code) - return jsonify({'message': '验证码已发送'}), 200 + return jsonify({'message': '楠岃瘉鐮佸凡鍙戦€?}), 200 else: - return jsonify({'error': '验证码发送失败'}), 500 + return jsonify({'error': '楠岃瘉鐮佸彂閫佸け璐?}), 500 @app.route('/api/auth/register/phone', methods=['POST']) def register_with_phone(): - """手机号注册 - 使用Session""" + """鎵嬫満鍙锋敞鍐?- 浣跨敤Session""" data = request.get_json() phone = data.get('phone') code = data.get('code') @@ -4148,30 +4018,30 @@ def register_with_phone(): username = data.get('username') if not all([phone, code, password, username]): - return jsonify({'success': False, 'error': '所有字段都是必填的'}), 400 + return jsonify({'success': False, 'error': '鎵€鏈夊瓧娈甸兘鏄繀濉殑'}), 400 - # 验证验证码(从 Redis 获取) + # 楠岃瘉楠岃瘉鐮侊紙浠?Redis 鑾峰彇锛? stored_code = get_verification_code(f'phone_{phone}') if not stored_code or stored_code['expires'] < time.time(): - return jsonify({'success': False, 'error': '验证码已过期'}), 400 + return jsonify({'success': False, 'error': '楠岃瘉鐮佸凡杩囨湡'}), 400 if stored_code['code'] != code: - return jsonify({'success': False, 'error': '验证码错误'}), 400 + return jsonify({'success': False, 'error': '楠岃瘉鐮侀敊璇?}), 400 if User.query.filter_by(username=username).first(): - return jsonify({'success': False, 'error': '用户名已存在'}), 400 + 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.flush() # 获取 user.id + db.session.flush() # 鑾峰彇 user.id - # 自动创建积分账户,初始10000积分 + # 鑷姩鍒涘缓绉垎璐︽埛锛屽垵濮?0000绉垎 credit_account = UserCreditAccount( user_id=user.id, balance=10000, @@ -4181,21 +4051,21 @@ def register_with_phone(): db.session.commit() - # 清除验证码(从 Redis 删除) + # 娓呴櫎楠岃瘉鐮侊紙浠?Redis 鍒犻櫎锛? delete_verification_code(f'phone_{phone}') - # 自动登录 + # 鑷姩鐧诲綍 session.permanent = True session['user_id'] = user.id session['username'] = user.username session['logged_in'] = True - # Flask-Login 登录 + # Flask-Login 鐧诲綍 login_user(user, remember=True) return jsonify({ 'success': True, - 'message': '注册成功', + 'message': '娉ㄥ唽鎴愬姛', 'user': { 'id': user.id, 'username': user.username, @@ -4205,172 +4075,172 @@ def register_with_phone(): except Exception as e: db.session.rollback() - return jsonify({'success': False, 'error': '注册失败,请重试'}), 500 + return jsonify({'success': False, 'error': '娉ㄥ唽澶辫触锛岃閲嶈瘯'}), 500 @app.route('/api/account/phone/send-code', methods=['POST']) def send_sms_bind_code(): - """发送绑定手机验证码(需已登录)""" - # 调试日志:检查 session 状态 + """鍙戦€佺粦瀹氭墜鏈洪獙璇佺爜锛堥渶宸茬櫥褰曪級""" + # 璋冭瘯鏃ュ織锛氭鏌?session 鐘舵€? user_agent = request.headers.get('User-Agent', '') is_wechat = 'MicroMessenger' in user_agent - print(f"[绑定手机验证码] User-Agent: {user_agent[:100]}...") - print(f"[绑定手机验证码] 是否微信浏览器: {is_wechat}") - print(f"[绑定手机验证码] session 内容: logged_in={session.get('logged_in')}, user_id={session.get('user_id')}") - print(f"[绑定手机验证码] Cookie: {request.cookies.get('session', 'None')[:20] if request.cookies.get('session') else 'None'}...") + print(f"[缁戝畾鎵嬫満楠岃瘉鐮乚 User-Agent: {user_agent[:100]}...") + print(f"[缁戝畾鎵嬫満楠岃瘉鐮乚 鏄惁寰俊娴忚鍣? {is_wechat}") + print(f"[缁戝畾鎵嬫満楠岃瘉鐮乚 session 鍐呭: logged_in={session.get('logged_in')}, user_id={session.get('user_id')}") + print(f"[缁戝畾鎵嬫満楠岃瘉鐮乚 Cookie: {request.cookies.get('session', 'None')[:20] if request.cookies.get('session') else 'None'}...") if not session.get('logged_in'): - print(f"[绑定手机验证码] ❌ 未登录,拒绝请求") - return jsonify({'error': '未登录'}), 401 + print(f"[缁戝畾鎵嬫満楠岃瘉鐮乚 鉂?鏈櫥褰曪紝鎷掔粷璇锋眰") + return jsonify({'error': '鏈櫥褰?}), 401 data = request.get_json() phone = data.get('phone') if not phone: - return jsonify({'error': '手机号不能为空'}), 400 + return jsonify({'error': '鎵嬫満鍙蜂笉鑳戒负绌?}), 400 - # 绑定时要求手机号未被占用 + # 缁戝畾鏃惰姹傛墜鏈哄彿鏈鍗犵敤 if User.query.filter_by(phone=phone).first(): - return jsonify({'error': '该手机号已被其他账号使用'}), 400 + return jsonify({'error': '璇ユ墜鏈哄彿宸茶鍏朵粬璐﹀彿浣跨敤'}), 400 code = generate_verification_code() if send_sms_code(phone, code, SMS_TEMPLATE_REGISTER): - # 存储验证码到 Redis(5分钟有效) + # 瀛樺偍楠岃瘉鐮佸埌 Redis锛?鍒嗛挓鏈夋晥锛? set_verification_code(f'bind_{phone}', code) - return jsonify({'message': '验证码已发送'}), 200 + return jsonify({'message': '楠岃瘉鐮佸凡鍙戦€?}), 200 else: - return jsonify({'error': '验证码发送失败'}), 500 + 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 + 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 + return jsonify({'error': '鎵嬫満鍙峰拰楠岃瘉鐮佷笉鑳戒负绌?}), 400 - # 从 Redis 获取验证码 + # 浠?Redis 鑾峰彇楠岃瘉鐮? stored = get_verification_code(f'bind_{phone}') if not stored or stored['expires'] < time.time(): - return jsonify({'error': '验证码已过期'}), 400 + return jsonify({'error': '楠岃瘉鐮佸凡杩囨湡'}), 400 if stored['code'] != code: - return jsonify({'error': '验证码错误'}), 400 + return jsonify({'error': '楠岃瘉鐮侀敊璇?}), 400 if User.query.filter_by(phone=phone).first(): - return jsonify({'error': '该手机号已被其他账号使用'}), 400 + return jsonify({'error': '璇ユ墜鏈哄彿宸茶鍏朵粬璐﹀彿浣跨敤'}), 400 try: user = User.query.get(session.get('user_id')) if not user: - return jsonify({'error': '用户不存在'}), 404 + return jsonify({'error': '鐢ㄦ埛涓嶅瓨鍦?}), 404 user.phone = phone user.confirm_phone() - # 清除验证码(从 Redis 删除) + # 娓呴櫎楠岃瘉鐮侊紙浠?Redis 鍒犻櫎锛? delete_verification_code(f'bind_{phone}') - return jsonify({'message': '绑定成功', 'success': True}), 200 + return jsonify({'message': '缁戝畾鎴愬姛', 'success': True}), 200 except Exception as e: print(f"Bind phone error: {e}") db.session.rollback() - return jsonify({'error': '绑定失败,请重试'}), 500 + return jsonify({'error': '缁戝畾澶辫触锛岃閲嶈瘯'}), 500 @app.route('/api/account/phone/unbind', methods=['POST']) def unbind_phone(): - """解绑手机号(需已登录)""" + """瑙g粦鎵嬫満鍙凤紙闇€宸茬櫥褰曪級""" if not session.get('logged_in'): - return jsonify({'error': '未登录'}), 401 + return jsonify({'error': '鏈櫥褰?}), 401 try: user = User.query.get(session.get('user_id')) if not user: - return jsonify({'error': '用户不存在'}), 404 + 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 + return jsonify({'message': '瑙g粦鎴愬姛', 'success': True}), 200 except Exception as e: print(f"Unbind phone error: {e}") db.session.rollback() - return jsonify({'error': '解绑失败,请重试'}), 500 + return jsonify({'error': '瑙g粦澶辫触锛岃閲嶈瘯'}), 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 + return jsonify({'error': '鏈櫥褰?}), 401 data = request.get_json() email = data.get('email') if not email: - return jsonify({'error': '邮箱不能为空'}), 400 + 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 + return jsonify({'error': '閭鏍煎紡涓嶆纭?}), 400 - # 检查邮箱是否已被其他账号使用 + # 妫€鏌ラ偖绠辨槸鍚﹀凡琚叾浠栬处鍙蜂娇鐢? if User.query.filter_by(email=email).first(): - return jsonify({'error': '该邮箱已被其他账号使用'}), 400 + return jsonify({'error': '璇ラ偖绠卞凡琚叾浠栬处鍙蜂娇鐢?}), 400 - # 生成验证码 + # 鐢熸垚楠岃瘉鐮? code = ''.join(random.choices(string.digits, k=6)) if send_email_code(email, code): - # 存储验证码到 Redis(5分钟有效) + # 瀛樺偍楠岃瘉鐮佸埌 Redis锛?鍒嗛挓鏈夋晥锛? set_verification_code(f'bind_{email}', code) - return jsonify({'message': '验证码已发送'}), 200 + return jsonify({'message': '楠岃瘉鐮佸凡鍙戦€?}), 200 else: - return jsonify({'error': '验证码发送失败'}), 500 + 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 + 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 + return jsonify({'error': '閭鍜岄獙璇佺爜涓嶈兘涓虹┖'}), 400 - # 从 Redis 获取验证码 + # 浠?Redis 鑾峰彇楠岃瘉鐮? stored = get_verification_code(f'bind_{email}') if not stored or stored['expires'] < time.time(): - return jsonify({'error': '验证码已过期'}), 400 + return jsonify({'error': '楠岃瘉鐮佸凡杩囨湡'}), 400 if stored['code'] != code: - return jsonify({'error': '验证码错误'}), 400 + return jsonify({'error': '楠岃瘉鐮侀敊璇?}), 400 if User.query.filter_by(email=email).first(): - return jsonify({'error': '该邮箱已被其他账号使用'}), 400 + return jsonify({'error': '璇ラ偖绠卞凡琚叾浠栬处鍙蜂娇鐢?}), 400 try: user = User.query.get(session.get('user_id')) if not user: - return jsonify({'error': '用户不存在'}), 404 + return jsonify({'error': '鐢ㄦ埛涓嶅瓨鍦?}), 404 user.email = email user.confirm_email() db.session.commit() - # 清除验证码(从 Redis 删除) + # 娓呴櫎楠岃瘉鐮侊紙浠?Redis 鍒犻櫎锛? delete_verification_code(f'bind_{email}') return jsonify({ - 'message': '邮箱绑定成功', + 'message': '閭缁戝畾鎴愬姛', 'success': True, 'user': { 'email': user.email, @@ -4380,33 +4250,33 @@ def bind_email(): except Exception as e: print(f"Bind email error: {e}") db.session.rollback() - return jsonify({'error': '绑定失败,请重试'}), 500 + return jsonify({'error': '缁戝畾澶辫触锛岃閲嶈瘯'}), 500 @app.route('/api/account/email/unbind', methods=['POST']) def unbind_email(): - """解绑邮箱(需已登录)""" + """瑙g粦閭锛堥渶宸茬櫥褰曪級""" if not session.get('logged_in'): - return jsonify({'error': '未登录'}), 401 + return jsonify({'error': '鏈櫥褰?}), 401 try: user = User.query.get(session.get('user_id')) if not user: - return jsonify({'error': '用户不存在'}), 404 + return jsonify({'error': '鐢ㄦ埛涓嶅瓨鍦?}), 404 user.email = None user.email_confirmed = False db.session.commit() - return jsonify({'message': '解绑成功', 'success': True}), 200 + return jsonify({'message': '瑙g粦鎴愬姛', 'success': True}), 200 except Exception as e: print(f"Unbind email error: {e}") db.session.rollback() - return jsonify({'error': '解绑失败,请重试'}), 500 + return jsonify({'error': '瑙g粦澶辫触锛岃閲嶈瘯'}), 500 @app.route('/api/auth/register/email', methods=['POST']) def register_with_email(): - """邮箱注册 - 使用Session""" + """閭娉ㄥ唽 - 浣跨敤Session""" data = request.get_json() email = data.get('email') code = data.get('code') @@ -4414,29 +4284,29 @@ def register_with_email(): username = data.get('username') if not all([email, code, password, username]): - return jsonify({'success': False, 'error': '所有字段都是必填的'}), 400 + return jsonify({'success': False, 'error': '鎵€鏈夊瓧娈甸兘鏄繀濉殑'}), 400 - # 验证验证码(从 Redis 获取) + # 楠岃瘉楠岃瘉鐮侊紙浠?Redis 鑾峰彇锛? stored_code = get_verification_code(f'email_{email}') if not stored_code or stored_code['expires'] < time.time(): - return jsonify({'success': False, 'error': '验证码已过期'}), 400 + return jsonify({'success': False, 'error': '楠岃瘉鐮佸凡杩囨湡'}), 400 if stored_code['code'] != code: - return jsonify({'success': False, 'error': '验证码错误'}), 400 + return jsonify({'success': False, 'error': '楠岃瘉鐮侀敊璇?}), 400 if User.query.filter_by(username=username).first(): - return jsonify({'success': False, 'error': '用户名已存在'}), 400 + 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.flush() # 获取 user.id + db.session.flush() # 鑾峰彇 user.id - # 自动创建积分账户,初始10000积分 + # 鑷姩鍒涘缓绉垎璐︽埛锛屽垵濮?0000绉垎 credit_account = UserCreditAccount( user_id=user.id, balance=10000, @@ -4446,21 +4316,21 @@ def register_with_email(): db.session.commit() - # 清除验证码(从 Redis 删除) + # 娓呴櫎楠岃瘉鐮侊紙浠?Redis 鍒犻櫎锛? delete_verification_code(f'email_{email}') - # 自动登录 + # 鑷姩鐧诲綍 session.permanent = True session['user_id'] = user.id session['username'] = user.username session['logged_in'] = True - # Flask-Login 登录 + # Flask-Login 鐧诲綍 login_user(user, remember=True) return jsonify({ 'success': True, - 'message': '注册成功', + 'message': '娉ㄥ唽鎴愬姛', 'user': { 'id': user.id, 'username': user.username, @@ -4470,18 +4340,18 @@ def register_with_email(): except Exception as e: db.session.rollback() - return jsonify({'success': False, 'error': '注册失败,请重试'}), 500 + return jsonify({'success': False, 'error': '娉ㄥ唽澶辫触锛岃閲嶈瘯'}), 500 def _safe_http_get(url, params=None, timeout=10): - """安全的 HTTP GET 请求(绕过 eventlet DNS 问题) + """瀹夊叏鐨?HTTP GET 璇锋眰锛堢粫杩?eventlet DNS 闂锛? - 使用 subprocess 调用 curl,完全绕过 Python/eventlet 的网络栈 + 浣跨敤 subprocess 璋冪敤 curl锛屽畬鍏ㄧ粫杩?Python/eventlet 鐨勭綉缁滄爤 """ import subprocess import urllib.parse - # 构建完整 URL + # 鏋勫缓瀹屾暣 URL if params: query_string = urllib.parse.urlencode(params) full_url = f"{url}?{query_string}" @@ -4489,7 +4359,7 @@ def _safe_http_get(url, params=None, timeout=10): full_url = url try: - # 使用 curl 发起请求,绕过 eventlet DNS 问题 + # 浣跨敤 curl 鍙戣捣璇锋眰锛岀粫杩?eventlet DNS 闂 result = subprocess.run( ['curl', '-s', '-m', str(timeout), full_url], capture_output=True, @@ -4498,10 +4368,10 @@ def _safe_http_get(url, params=None, timeout=10): ) if result.returncode != 0: - print(f"❌ curl 请求失败: returncode={result.returncode}, stderr={result.stderr}") + print(f"鉂?curl 璇锋眰澶辫触: returncode={result.returncode}, stderr={result.stderr}") return None - # 返回一个模拟 Response 对象 + # 杩斿洖涓€涓ā鎷?Response 瀵硅薄 class MockResponse: def __init__(self, text): self.text = text @@ -4514,20 +4384,20 @@ def _safe_http_get(url, params=None, timeout=10): return MockResponse(result.stdout) except subprocess.TimeoutExpired: - print(f"❌ curl 请求超时: {full_url}") + print(f"鉂?curl 璇锋眰瓒呮椂: {full_url}") return None except Exception as e: - print(f"❌ curl 请求异常: {type(e).__name__}: {e}") + print(f"鉂?curl 璇锋眰寮傚父: {type(e).__name__}: {e}") return None def get_wechat_access_token(code, appid=None, appsecret=None): - """通过code获取微信access_token + """閫氳繃code鑾峰彇寰俊access_token Args: - code: 微信授权后返回的 code - appid: 微信 AppID(可选,默认使用开放平台配置) - appsecret: 微信 AppSecret(可选,默认使用开放平台配置) + code: 寰俊鎺堟潈鍚庤繑鍥炵殑 code + appid: 寰俊 AppID锛堝彲閫夛紝榛樿浣跨敤寮€鏀惧钩鍙伴厤缃級 + appsecret: 寰俊 AppSecret锛堝彲閫夛紝榛樿浣跨敤寮€鏀惧钩鍙伴厤缃級 """ url = "https://api.weixin.qq.com/sns/oauth2/access_token" params = { @@ -4538,25 +4408,25 @@ def get_wechat_access_token(code, appid=None, appsecret=None): } try: - print(f"🔄 正在获取微信 access_token... (appid={params['appid'][:8]}...)") + print(f"馃攧 姝e湪鑾峰彇寰俊 access_token... (appid={params['appid'][:8]}...)") response = _safe_http_get(url, params=params, timeout=15) data = response.json() if 'errcode' in data: - print(f"❌ WeChat access token error: {data}") + print(f"鉂?WeChat access token error: {data}") return None - print(f"✅ 成功获取 access_token: openid={data.get('openid', 'N/A')}") + print(f"鉁?鎴愬姛鑾峰彇 access_token: openid={data.get('openid', 'N/A')}") return data except Exception as e: - print(f"❌ WeChat access token request error: {type(e).__name__}: {e}") + print(f"鉂?WeChat access token request error: {type(e).__name__}: {e}") import traceback traceback.print_exc() return None def get_wechat_userinfo(access_token, openid): - """获取微信用户信息(包含UnionID)""" + """鑾峰彇寰俊鐢ㄦ埛淇℃伅锛堝寘鍚玌nionID锛?"" url = "https://api.weixin.qq.com/sns/userinfo" params = { 'access_token': access_token, @@ -4565,29 +4435,29 @@ def get_wechat_userinfo(access_token, openid): } try: - print(f"🔄 正在获取微信用户信息... (openid={openid})") + print(f"馃攧 姝e湪鑾峰彇寰俊鐢ㄦ埛淇℃伅... (openid={openid})") response = _safe_http_get(url, params=params, timeout=15) - response.encoding = 'utf-8' # 明确设置编码为UTF-8 + response.encoding = 'utf-8' # 鏄庣‘璁剧疆缂栫爜涓篣TF-8 data = response.json() if 'errcode' in data: - print(f"❌ WeChat userinfo error: {data}") + print(f"鉂?WeChat userinfo error: {data}") return None - # 确保nickname字段的编码正确 + # 纭繚nickname瀛楁鐨勭紪鐮佹纭? if 'nickname' in data and data['nickname']: - # 确保昵称是正确的UTF-8编码 + # 纭繚鏄电О鏄纭殑UTF-8缂栫爜 try: - # 检查是否已经是正确的UTF-8字符串 + # 妫€鏌ユ槸鍚﹀凡缁忔槸姝g‘鐨刄TF-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'] = '微信用户' + data['nickname'] = '寰俊鐢ㄦ埛' - print(f"✅ 成功获取用户信息: nickname={data.get('nickname', 'N/A')}") + print(f"鉁?鎴愬姛鑾峰彇鐢ㄦ埛淇℃伅: nickname={data.get('nickname', 'N/A')}") return data except Exception as e: - print(f"❌ WeChat userinfo request error: {type(e).__name__}: {e}") + print(f"鉂?WeChat userinfo request error: {type(e).__name__}: {e}") import traceback traceback.print_exc() return None @@ -4595,14 +4465,14 @@ def get_wechat_userinfo(access_token, openid): @app.route('/api/auth/wechat/qrcode', methods=['GET']) def get_wechat_qrcode(): - """返回微信授权URL,前端使用iframe展示""" - # 生成唯一state参数 + """杩斿洖寰俊鎺堟潈URL锛屽墠绔娇鐢╥frame灞曠ず""" + # 鐢熸垚鍞竴state鍙傛暟 state = uuid.uuid4().hex - # URL编码回调地址 + # URL缂栫爜鍥炶皟鍦板潃 redirect_uri = urllib.parse.quote_plus(WECHAT_REDIRECT_URI) - # 构建微信授权URL(PC 扫码登录使用开放平台 AppID) + # 鏋勫缓寰俊鎺堟潈URL锛圥C 鎵爜鐧诲綍浣跨敤寮€鏀惧钩鍙?AppID锛? wechat_auth_url = ( f"https://open.weixin.qq.com/connect/qrconnect?" f"appid={WECHAT_OPEN_APPID}&redirect_uri={redirect_uri}" @@ -4610,14 +4480,14 @@ def get_wechat_qrcode(): "#wechat_redirect" ) - # 存储session信息到 Redis + # 瀛樺偍session淇℃伅鍒?Redis if not set_wechat_session(state, { 'status': 'waiting', 'user_info': None, 'wechat_openid': None, 'wechat_unionid': None }): - return jsonify({'error': '服务暂时不可用,请稍后重试'}), 500 + return jsonify({'error': '鏈嶅姟鏆傛椂涓嶅彲鐢紝璇风◢鍚庨噸璇?}), 500 return jsonify({"code":0, "data": @@ -4631,19 +4501,19 @@ def get_wechat_qrcode(): @app.route('/api/auth/wechat/h5-auth', methods=['POST']) def get_wechat_h5_auth_url(): """ - 获取微信 H5 网页授权 URL - 用于手机浏览器跳转微信 App 授权 + 鑾峰彇寰俊 H5 缃戦〉鎺堟潈 URL + 鐢ㄤ簬鎵嬫満娴忚鍣ㄨ烦杞井淇?App 鎺堟潈 """ data = request.get_json() or {} frontend_redirect = data.get('redirect_url', '/home') - # 生成唯一 state + # 鐢熸垚鍞竴 state state = uuid.uuid4().hex - # 编码回调地址 + # 缂栫爜鍥炶皟鍦板潃 redirect_uri = urllib.parse.quote_plus(WECHAT_REDIRECT_URI) - # 构建授权 URL(H5 网页授权使用公众号 AppID) + # 鏋勫缓鎺堟潈 URL锛圚5 缃戦〉鎺堟潈浣跨敤鍏紬鍙?AppID锛? auth_url = ( f"https://open.weixin.qq.com/connect/oauth2/authorize?" f"appid={WECHAT_MP_APPID}&redirect_uri={redirect_uri}" @@ -4651,16 +4521,16 @@ def get_wechat_h5_auth_url(): "#wechat_redirect" ) - # 存储 session 信息到 Redis + # 瀛樺偍 session 淇℃伅鍒?Redis if not set_wechat_session(state, { 'status': 'waiting', - 'mode': 'h5', # 标记为 H5 模式 + 'mode': 'h5', # 鏍囪涓?H5 妯″紡 'frontend_redirect': frontend_redirect, 'user_info': None, 'wechat_openid': None, 'wechat_unionid': None }): - return jsonify({'error': '服务暂时不可用,请稍后重试'}), 500 + return jsonify({'error': '鏈嶅姟鏆傛椂涓嶅彲鐢紝璇风◢鍚庨噸璇?}), 500 return jsonify({ 'auth_url': auth_url, @@ -4670,17 +4540,17 @@ def get_wechat_h5_auth_url(): @app.route('/api/account/wechat/qrcode', methods=['GET']) def get_wechat_bind_qrcode(): - """发起微信绑定二维码,会话标记为绑定模式""" + """鍙戣捣寰俊缁戝畾浜岀淮鐮侊紝浼氳瘽鏍囪涓虹粦瀹氭ā寮?"" if not session.get('logged_in'): - return jsonify({'error': '未登录'}), 401 + return jsonify({'error': '鏈櫥褰?}), 401 - # 生成唯一state参数 + # 鐢熸垚鍞竴state鍙傛暟 state = uuid.uuid4().hex - # URL编码回调地址 + # URL缂栫爜鍥炶皟鍦板潃 redirect_uri = urllib.parse.quote_plus(WECHAT_REDIRECT_URI) - # 构建微信授权URL(PC 扫码绑定使用开放平台 AppID) + # 鏋勫缓寰俊鎺堟潈URL锛圥C 鎵爜缁戝畾浣跨敤寮€鏀惧钩鍙?AppID锛? wechat_auth_url = ( f"https://open.weixin.qq.com/connect/qrconnect?" f"appid={WECHAT_OPEN_APPID}&redirect_uri={redirect_uri}" @@ -4688,7 +4558,7 @@ def get_wechat_bind_qrcode(): "#wechat_redirect" ) - # 存储session信息到 Redis,标记为绑定模式并记录目标用户 + # 瀛樺偍session淇℃伅鍒?Redis锛屾爣璁颁负缁戝畾妯″紡骞惰褰曠洰鏍囩敤鎴? if not set_wechat_session(state, { 'status': 'waiting', 'mode': 'bind', @@ -4697,7 +4567,7 @@ def get_wechat_bind_qrcode(): 'wechat_openid': None, 'wechat_unionid': None }): - return jsonify({'error': '服务暂时不可用,请稍后重试'}), 500 + return jsonify({'error': '鏈嶅姟鏆傛椂涓嶅彲鐢紝璇风◢鍚庨噸璇?}), 500 return jsonify({ 'auth_url': wechat_auth_url, @@ -4708,19 +4578,19 @@ def get_wechat_bind_qrcode(): @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: - return jsonify({'status': 'invalid', 'error': '无效的session'}), 400 + return jsonify({'status': 'invalid', 'error': '鏃犳晥鐨剆ession'}), 400 - # 从 Redis 获取 session + # 浠?Redis 鑾峰彇 session sess = get_wechat_session(session_id) if not sess: - return jsonify({'status': 'expired'}), 200 # Redis 自动过期,返回 expired + return jsonify({'status': 'expired'}), 200 # Redis 鑷姩杩囨湡锛岃繑鍥?expired - # 获取剩余 TTL + # 鑾峰彇鍓╀綑 TTL ttl = redis_client.ttl(f"{WECHAT_SESSION_PREFIX}{session_id}") expires_in = max(0, ttl) if ttl > 0 else 0 @@ -4733,23 +4603,23 @@ def check_wechat_scan(): @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: - return jsonify({'status': 'invalid', 'error': '无效的session'}), 400 + return jsonify({'status': 'invalid', 'error': '鏃犳晥鐨剆ession'}), 400 - # 从 Redis 获取 session + # 浠?Redis 鑾峰彇 session sess = get_wechat_session(session_id) if not sess: - return jsonify({'status': 'expired'}), 200 # Redis 自动过期,返回 expired + return jsonify({'status': 'expired'}), 200 # Redis 鑷姩杩囨湡锛岃繑鍥?expired - # 绑定模式限制 + # 缁戝畾妯″紡闄愬埗 if sess.get('mode') != 'bind': - return jsonify({'status': 'invalid', 'error': '会话模式错误'}), 400 + return jsonify({'status': 'invalid', 'error': '浼氳瘽妯″紡閿欒'}), 400 - # 获取剩余 TTL + # 鑾峰彇鍓╀綑 TTL ttl = redis_client.ttl(f"{WECHAT_SESSION_PREFIX}{session_id}") expires_in = max(0, ttl) if ttl > 0 else 0 @@ -4762,68 +4632,68 @@ def check_wechat_bind_scan(): @app.route('/api/auth/wechat/callback', methods=['GET']) def wechat_callback(): - """微信授权回调处理 - 使用Session""" + """寰俊鎺堟潈鍥炶皟澶勭悊 - 浣跨敤Session""" code = request.args.get('code') state = request.args.get('state') error = request.args.get('error') - # 错误处理:用户拒绝授权 + # 閿欒澶勭悊锛氱敤鎴锋嫆缁濇巿鏉? if error: if state and wechat_session_exists(state): - update_wechat_session(state, {'status': 'auth_denied', 'error': '用户拒绝授权'}) - print(f"❌ 用户拒绝授权: state={state}") + update_wechat_session(state, {'status': 'auth_denied', 'error': '鐢ㄦ埛鎷掔粷鎺堟潈'}) + print(f"鉂?鐢ㄦ埛鎷掔粷鎺堟潈: state={state}") return redirect(f'{FRONTEND_URL}/home?error=wechat_auth_denied') - # 参数验证 + # 鍙傛暟楠岃瘉 if not code or not state: if state and wechat_session_exists(state): - update_wechat_session(state, {'status': 'auth_failed', 'error': '授权参数缺失'}) + update_wechat_session(state, {'status': 'auth_failed', 'error': '鎺堟潈鍙傛暟缂哄け'}) return redirect(f'{FRONTEND_URL}/home?error=wechat_auth_failed') - # 从 Redis 获取 session(自动处理过期) + # 浠?Redis 鑾峰彇 session锛堣嚜鍔ㄥ鐞嗚繃鏈燂級 session_data = get_wechat_session(state) if not session_data: return redirect(f'{FRONTEND_URL}/home?error=session_expired') try: - # 步骤1: 用户已扫码并授权(微信回调过来说明用户已完成扫码+授权) + # 姝ラ1: 鐢ㄦ埛宸叉壂鐮佸苟鎺堟潈锛堝井淇″洖璋冭繃鏉ヨ鏄庣敤鎴峰凡瀹屾垚鎵爜+鎺堟潈锛? update_wechat_session(state, {'status': 'scanned'}) - print(f"✅ 微信扫码回调: state={state}, code={code[:10]}...") + print(f"鉁?寰俊鎵爜鍥炶皟: state={state}, code={code[:10]}...") - # 步骤2: 根据授权模式选择对应的 AppID/AppSecret - # H5 模式使用公众号配置,PC 扫码和绑定模式使用开放平台配置 + # 姝ラ2: 鏍规嵁鎺堟潈妯″紡閫夋嫨瀵瑰簲鐨?AppID/AppSecret + # H5 妯″紡浣跨敤鍏紬鍙烽厤缃紝PC 鎵爜鍜岀粦瀹氭ā寮忎娇鐢ㄥ紑鏀惧钩鍙伴厤缃? if session_data.get('mode') == 'h5': appid = WECHAT_MP_APPID appsecret = WECHAT_MP_APPSECRET - print(f"📱 H5 模式授权,使用公众号配置") + print(f"馃摫 H5 妯″紡鎺堟潈锛屼娇鐢ㄥ叕浼楀彿閰嶇疆") else: appid = WECHAT_OPEN_APPID appsecret = WECHAT_OPEN_APPSECRET - print(f"💻 PC 模式授权,使用开放平台配置") + print(f"馃捇 PC 妯″紡鎺堟潈锛屼娇鐢ㄥ紑鏀惧钩鍙伴厤缃?) - # 步骤3: 获取access_token + # 姝ラ3: 鑾峰彇access_token token_data = get_wechat_access_token(code, appid, appsecret) if not token_data: - update_wechat_session(state, {'status': 'auth_failed', 'error': '获取访问令牌失败'}) - print(f"❌ 获取微信access_token失败: state={state}") + update_wechat_session(state, {'status': 'auth_failed', 'error': '鑾峰彇璁块棶浠ょ墝澶辫触'}) + print(f"鉂?鑾峰彇寰俊access_token澶辫触: state={state}") return redirect(f'{FRONTEND_URL}/home?error=token_failed') - # 步骤3: Token获取成功,标记为已授权 + # 姝ラ3: Token鑾峰彇鎴愬姛锛屾爣璁颁负宸叉巿鏉? update_wechat_session(state, {'status': 'authorized'}) - print(f"✅ 微信授权成功: openid={token_data['openid']}") + print(f"鉁?寰俊鎺堟潈鎴愬姛: openid={token_data['openid']}") - # 步骤4: 获取用户信息 + # 姝ラ4: 鑾峰彇鐢ㄦ埛淇℃伅 user_info = get_wechat_userinfo(token_data['access_token'], token_data['openid']) if not user_info: - update_wechat_session(state, {'status': 'auth_failed', 'error': '获取用户信息失败'}) - print(f"❌ 获取微信用户信息失败: openid={token_data['openid']}") + update_wechat_session(state, {'status': 'auth_failed', 'error': '鑾峰彇鐢ㄦ埛淇℃伅澶辫触'}) + print(f"鉂?鑾峰彇寰俊鐢ㄦ埛淇℃伅澶辫触: openid={token_data['openid']}") return redirect(f'{FRONTEND_URL}/home?error=userinfo_failed') - # 查找或创建用户 / 或处理绑定 + # 鏌ユ壘鎴栧垱寤虹敤鎴?/ 鎴栧鐞嗙粦瀹? openid = token_data['openid'] unionid = user_info.get('unionid') or token_data.get('unionid') - # 如果是绑定流程 + # 濡傛灉鏄粦瀹氭祦绋? if session_data.get('mode') == 'bind': try: target_user_id = session.get('user_id') or session_data.get('bind_user_id') @@ -4834,7 +4704,7 @@ def wechat_callback(): if not target_user: return redirect(f'{FRONTEND_URL}/home?error=bind_user_missing') - # 检查该微信是否已被其他账户绑定 + # 妫€鏌ヨ寰俊鏄惁宸茶鍏朵粬璐︽埛缁戝畾 existing = None if unionid: existing = User.query.filter_by(wechat_union_id=unionid).first() @@ -4845,15 +4715,15 @@ def wechat_callback(): update_wechat_session(state, {'status': 'bind_conflict'}) return redirect(f'{FRONTEND_URL}/home?bind=conflict') - # 执行绑定 + # 鎵ц缁戝畾 target_user.bind_wechat(openid, unionid, wechat_info=user_info) - # 标记绑定完成,供前端轮询 + # 鏍囪缁戝畾瀹屾垚锛屼緵鍓嶇杞 update_wechat_session(state, {'status': 'bind_ready', 'user_info': {'user_id': target_user.id}}) return redirect(f'{FRONTEND_URL}/home?bind=success') except Exception as e: - print(f"❌ 微信绑定失败: {e}") + print(f"鉂?寰俊缁戝畾澶辫触: {e}") db.session.rollback() update_wechat_session(state, {'status': 'bind_failed'}) return redirect(f'{FRONTEND_URL}/home?bind=failed') @@ -4861,13 +4731,13 @@ def wechat_callback(): user = None is_new_user = False - # 统一使用 unionid 匹配用户(H5 和 PC 模式都一样) + # 缁熶竴浣跨敤 unionid 鍖归厤鐢ㄦ埛锛圚5 鍜?PC 妯″紡閮戒竴鏍凤級 if not unionid: - # 没有获取到 unionid,无法关联账号 + # 娌℃湁鑾峰彇鍒?unionid锛屾棤娉曞叧鑱旇处鍙? mode_name = 'H5' if session_data.get('mode') == 'h5' else 'PC' - update_wechat_session(state, {'status': 'auth_failed', 'error': f'{mode_name}授权未返回unionid'}) - print(f"❌ {mode_name} 授权未返回 unionid, openid={openid}, user_info={user_info}") - # 调试信息:将微信返回的数据通过 URL 传给前端 + update_wechat_session(state, {'status': 'auth_failed', 'error': f'{mode_name}鎺堟潈鏈繑鍥瀠nionid'}) + print(f"鉂?{mode_name} 鎺堟潈鏈繑鍥?unionid, openid={openid}, user_info={user_info}") + # 璋冭瘯淇℃伅锛氬皢寰俊杩斿洖鐨勬暟鎹€氳繃 URL 浼犵粰鍓嶇 debug_params = urllib.parse.urlencode({ 'error': 'no_unionid', 'debug_mode': mode_name, @@ -4882,10 +4752,10 @@ def wechat_callback(): user = User.query.filter_by(wechat_union_id=unionid).first() if not user: - # 创建新用户 - # 先清理微信昵称 - raw_nickname = user_info.get('nickname', '微信用户') - # 创建临时用户实例以使用清理方法 + # 鍒涘缓鏂扮敤鎴? + # 鍏堟竻鐞嗗井淇℃樀绉? + raw_nickname = user_info.get('nickname', '寰俊鐢ㄦ埛') + # 鍒涘缓涓存椂鐢ㄦ埛瀹炰緥浠ヤ娇鐢ㄦ竻鐞嗘柟娉? temp_user = User.__new__(User) sanitized_nickname = temp_user._sanitize_nickname(raw_nickname) @@ -4907,31 +4777,31 @@ def wechat_callback(): db.session.commit() is_new_user = True - print(f"✅ 微信扫码自动创建新用户: {username}, openid: {openid}") + print(f"鉁?寰俊鎵爜鑷姩鍒涘缓鏂扮敤鎴? {username}, openid: {openid}") - # 更新最后登录时间 + # 鏇存柊鏈€鍚庣櫥褰曟椂闂? user.update_last_seen() - # 设置session + # 璁剧疆session session.permanent = True session['user_id'] = user.id session['username'] = user.username session['logged_in'] = True - session['wechat_login'] = True # 标记是微信登录 + session['wechat_login'] = True # 鏍囪鏄井淇$櫥褰? - # Flask-Login 登录 + # Flask-Login 鐧诲綍 login_user(user, remember=True) - # 更新微信session状态,供前端轮询检测 + # 鏇存柊寰俊session鐘舵€侊紝渚涘墠绔疆璇㈡娴? mode = session_data.get('mode') - # H5 模式:重定向到前端回调页面 + # H5 妯″紡锛氶噸瀹氬悜鍒板墠绔洖璋冮〉闈? if mode == 'h5': frontend_redirect = session_data.get('frontend_redirect', '/home/wechat-callback') - # 清理 session + # 娓呯悊 session delete_wechat_session(state) - print(f"✅ H5 微信登录成功,重定向到: {frontend_redirect}") - # 调试信息:携带微信返回的关键数据 + print(f"鉁?H5 寰俊鐧诲綍鎴愬姛锛岄噸瀹氬悜鍒? {frontend_redirect}") + # 璋冭瘯淇℃伅锛氭惡甯﹀井淇¤繑鍥炵殑鍏抽敭鏁版嵁 debug_params = urllib.parse.urlencode({ 'wechat_login': 'success', 'debug_is_new_user': '1' if is_new_user else '0', @@ -4941,78 +4811,78 @@ def wechat_callback(): 'debug_user_id': user.id, 'debug_nickname': user_info.get('nickname', '')[:10], }) - # ⚡ 修复:正确处理已有查询参数的 URL + # 鈿?淇锛氭纭鐞嗗凡鏈夋煡璇㈠弬鏁扮殑 URL separator = '&' if '?' in frontend_redirect else '?' return redirect(f"{frontend_redirect}{separator}{debug_params}") - # PC 扫码模式:更新状态供前端轮询 + # PC 鎵爜妯″紡锛氭洿鏂扮姸鎬佷緵鍓嶇杞 if not mode: new_status = 'register_ready' if is_new_user else 'login_ready' update_wechat_session(state, {'status': new_status, 'user_info': {'user_id': user.id}}) - print(f"✅ 微信扫码状态已更新: {new_status}, user_id: {user.id}") + print(f"鉁?寰俊鎵爜鐘舵€佸凡鏇存柊: {new_status}, user_id: {user.id}") - # ⚡ PC 扫码模式:重定向到前端回调页面 - # 微信扫码登录会跳转整个页面,所以需要重定向到前端处理 + # 鈿?PC 鎵爜妯″紡锛氶噸瀹氬悜鍒板墠绔洖璋冮〉闈? + # 寰俊鎵爜鐧诲綍浼氳烦杞暣涓〉闈紝鎵€浠ラ渶瑕侀噸瀹氬悜鍒板墠绔鐞? pc_redirect_params = urllib.parse.urlencode({ 'wechat_login': 'success', 'state': state, 'is_new_user': '1' if is_new_user else '0', }) - print(f"✅ PC 微信登录成功,重定向到前端回调页面") + print(f"鉁?PC 寰俊鐧诲綍鎴愬姛锛岄噸瀹氬悜鍒板墠绔洖璋冮〉闈?) return redirect(f"{FRONTEND_URL}/home/wechat-callback?{pc_redirect_params}") except Exception as e: - print(f"❌ 微信登录失败: {e}") + print(f"鉂?寰俊鐧诲綍澶辫触: {e}") import traceback traceback.print_exc() db.session.rollback() - # 更新session状态为失败 + # 鏇存柊session鐘舵€佷负澶辫触 if wechat_session_exists(state): update_wechat_session(state, {'status': 'auth_failed', 'error': str(e)}) - # ⚡ 重定向到首页并显示错误 + # 鈿?閲嶅畾鍚戝埌棣栭〉骞舵樉绀洪敊璇? return redirect(f'{FRONTEND_URL}/home?error=wechat_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 + return jsonify({'success': False, 'error': 'session_id涓嶈兘涓虹┖'}), 400 - # 从 Redis 获取 session + # 浠?Redis 鑾峰彇 session wechat_sess = get_wechat_session(session_id) if not wechat_sess: - return jsonify({'success': False, 'error': '会话不存在或已过期'}), 400 + return jsonify({'success': False, 'error': '浼氳瘽涓嶅瓨鍦ㄦ垨宸茶繃鏈?}), 400 - # 检查session状态 + # 妫€鏌ession鐘舵€? if wechat_sess['status'] not in ['login_ready', 'register_ready']: - return jsonify({'success': False, 'error': '会话状态无效'}), 400 + return jsonify({'success': False, 'error': '浼氳瘽鐘舵€佹棤鏁?}), 400 - # 检查是否有用户信息 + # 妫€鏌ユ槸鍚︽湁鐢ㄦ埛淇℃伅 user_info = wechat_sess.get('user_info') if not user_info or not user_info.get('user_id'): - return jsonify({'success': False, 'error': '用户信息不完整'}), 400 + return jsonify({'success': False, 'error': '鐢ㄦ埛淇℃伅涓嶅畬鏁?}), 400 try: user = User.query.get(user_info['user_id']) if not user: - return jsonify({'success': False, 'error': '用户不存在'}), 404 + return jsonify({'success': False, 'error': '鐢ㄦ埛涓嶅瓨鍦?}), 404 - # 更新最后登录时间 + # 鏇存柊鏈€鍚庣櫥褰曟椂闂? user.update_last_seen() - # Redis 会自动过期,无需手动延迟删除 - # 保留 session 状态供前端轮询,Redis TTL 会自动清理 + # Redis 浼氳嚜鍔ㄨ繃鏈燂紝鏃犻渶鎵嬪姩寤惰繜鍒犻櫎 + # 淇濈暀 session 鐘舵€佷緵鍓嶇杞锛孯edis TTL 浼氳嚜鍔ㄦ竻鐞? - # 生成登录响应 + # 鐢熸垚鐧诲綍鍝嶅簲 response_data = { 'success': True, - 'message': '登录成功' if wechat_sess['status'] == 'login_ready' else '注册并登录成功', + 'message': '鐧诲綍鎴愬姛' if wechat_sess['status'] == 'login_ready' else '娉ㄥ唽骞剁櫥褰曟垚鍔?, 'user': { 'id': user.id, 'username': user.username, @@ -5027,71 +4897,71 @@ def login_with_wechat(): 'created_at': user.created_at.isoformat() if user.created_at else None, 'last_seen': user.last_seen.isoformat() if user.last_seen else None }, - 'isNewUser': wechat_sess['status'] == 'register_ready' # 标记是否为新用户 + 'isNewUser': wechat_sess['status'] == 'register_ready' # 鏍囪鏄惁涓烘柊鐢ㄦ埛 } - # 如果需要token认证,可以在这里生成 + # 濡傛灉闇€瑕乼oken璁よ瘉锛屽彲浠ュ湪杩欓噷鐢熸垚 # response_data['token'] = generate_token(user.id) return jsonify(response_data), 200 except Exception as e: - print(f"❌ 微信登录错误: {e}") + print(f"鉂?寰俊鐧诲綍閿欒: {e}") import traceback - app.logger.error(f"回调处理错误: {e}", exc_info=True) + app.logger.error(f"鍥炶皟澶勭悊閿欒: {e}", exc_info=True) return jsonify({ 'success': False, - 'error': '登录失败,请重试' + 'error': '鐧诲綍澶辫触锛岃閲嶈瘯' }), 500 @app.route('/api/account/wechat/unbind', methods=['POST']) def unbind_wechat_account(): - """解绑当前登录用户的微信""" + """瑙g粦褰撳墠鐧诲綍鐢ㄦ埛鐨勫井淇?"" if not session.get('logged_in'): - return jsonify({'error': '未登录'}), 401 + return jsonify({'error': '鏈櫥褰?}), 401 try: user = User.query.get(session.get('user_id')) if not user: - return jsonify({'error': '用户不存在'}), 404 + return jsonify({'error': '鐢ㄦ埛涓嶅瓨鍦?}), 404 user.unbind_wechat() - return jsonify({'message': '解绑成功', 'success': True}), 200 + return jsonify({'message': '瑙g粦鎴愬姛', 'success': True}), 200 except Exception as e: print(f"Unbind wechat error: {e}") db.session.rollback() - return jsonify({'error': '解绑失败,请重试'}), 500 + return jsonify({'error': '瑙g粦澶辫触锛岃閲嶈瘯'}), 500 -# ============ H5 跳转小程序相关 API ============ +# ============ H5 璺宠浆灏忕▼搴忕浉鍏?API ============ def get_wechat_access_token_cached(appid, appsecret): """ - 获取微信 access_token(Redis 缓存,支持多 Worker) + 鑾峰彇寰俊 access_token锛圧edis 缂撳瓨锛屾敮鎸佸 Worker锛? Args: - appid: 微信 AppID(公众号或小程序) - appsecret: 对应的 AppSecret + appid: 寰俊 AppID锛堝叕浼楀彿鎴栧皬绋嬪簭锛? + appsecret: 瀵瑰簲鐨?AppSecret Returns: - access_token 字符串,失败返回 None + access_token 瀛楃涓诧紝澶辫触杩斿洖 None """ cache_key = f"{WECHAT_ACCESS_TOKEN_PREFIX}{appid}" - # 1. 尝试从 Redis 获取缓存 + # 1. 灏濊瘯浠?Redis 鑾峰彇缂撳瓨 try: cached = redis_client.get(cache_key) if cached: data = json.loads(cached) - # 提前 5 分钟刷新,避免临界问题 + # 鎻愬墠 5 鍒嗛挓鍒锋柊锛岄伩鍏嶄复鐣岄棶棰? if data.get('expires_at', 0) > time.time() + 300: - print(f"[access_token] 使用缓存: appid={appid[:8]}...") + print(f"[access_token] 浣跨敤缂撳瓨: appid={appid[:8]}...") return data['token'] except Exception as e: - print(f"[access_token] Redis 读取失败: {e}") + print(f"[access_token] Redis 璇诲彇澶辫触: {e}") - # 2. 请求新 token + # 2. 璇锋眰鏂?token url = "https://api.weixin.qq.com/cgi-bin/token" params = { 'grant_type': 'client_credential', @@ -5107,7 +4977,7 @@ def get_wechat_access_token_cached(appid, appsecret): token = result['access_token'] expires_in = result.get('expires_in', 7200) - # 3. 存入 Redis(TTL 比 token 有效期短 60 秒) + # 3. 瀛樺叆 Redis锛圱TL 姣?token 鏈夋晥鏈熺煭 60 绉掞級 cache_data = { 'token': token, 'expires_at': time.time() + expires_in @@ -5118,41 +4988,41 @@ def get_wechat_access_token_cached(appid, appsecret): json.dumps(cache_data) ) - print(f"[access_token] 获取成功: appid={appid[:8]}..., expires_in={expires_in}s") + print(f"[access_token] 鑾峰彇鎴愬姛: appid={appid[:8]}..., expires_in={expires_in}s") return token else: - print(f"[access_token] 获取失败: errcode={result.get('errcode')}, errmsg={result.get('errmsg')}") + print(f"[access_token] 鑾峰彇澶辫触: errcode={result.get('errcode')}, errmsg={result.get('errmsg')}") return None except Exception as e: - print(f"[access_token] 请求异常: {e}") + print(f"[access_token] 璇锋眰寮傚父: {e}") return None def get_jsapi_ticket_cached(appid, appsecret): """ - 获取 jsapi_ticket(Redis 缓存) - 用于 JS-SDK 签名 + 鑾峰彇 jsapi_ticket锛圧edis 缂撳瓨锛? + 鐢ㄤ簬 JS-SDK 绛惧悕 """ cache_key = f"{WECHAT_JSAPI_TICKET_PREFIX}{appid}" - # 1. 尝试从缓存获取 + # 1. 灏濊瘯浠庣紦瀛樿幏鍙? try: cached = redis_client.get(cache_key) if cached: data = json.loads(cached) if data.get('expires_at', 0) > time.time() + 300: - print(f"[jsapi_ticket] 使用缓存") + print(f"[jsapi_ticket] 浣跨敤缂撳瓨") return data['ticket'] except Exception as e: - print(f"[jsapi_ticket] Redis 读取失败: {e}") + print(f"[jsapi_ticket] Redis 璇诲彇澶辫触: {e}") - # 2. 获取 access_token + # 2. 鑾峰彇 access_token access_token = get_wechat_access_token_cached(appid, appsecret) if not access_token: return None - # 3. 请求 jsapi_ticket + # 3. 璇锋眰 jsapi_ticket url = "https://api.weixin.qq.com/cgi-bin/ticket/getticket" params = { 'access_token': access_token, @@ -5167,7 +5037,7 @@ def get_jsapi_ticket_cached(appid, appsecret): ticket = result['ticket'] expires_in = result.get('expires_in', 7200) - # 存入 Redis + # 瀛樺叆 Redis cache_data = { 'ticket': ticket, 'expires_at': time.time() + expires_in @@ -5178,44 +5048,44 @@ def get_jsapi_ticket_cached(appid, appsecret): json.dumps(cache_data) ) - print(f"[jsapi_ticket] 获取成功, expires_in={expires_in}s") + print(f"[jsapi_ticket] 鑾峰彇鎴愬姛, expires_in={expires_in}s") return ticket else: - print(f"[jsapi_ticket] 获取失败: errcode={result.get('errcode')}, errmsg={result.get('errmsg')}") + print(f"[jsapi_ticket] 鑾峰彇澶辫触: errcode={result.get('errcode')}, errmsg={result.get('errmsg')}") return None except Exception as e: - print(f"[jsapi_ticket] 请求异常: {e}") + print(f"[jsapi_ticket] 璇锋眰寮傚父: {e}") return None def generate_jssdk_signature(url, appid, appsecret): """ - 生成 JS-SDK 签名配置 + 鐢熸垚 JS-SDK 绛惧悕閰嶇疆 Args: - url: 当前页面 URL(不含 # 及其后的部分) - appid: 公众号 AppID - appsecret: 公众号 AppSecret + url: 褰撳墠椤甸潰 URL锛堜笉鍚?# 鍙婂叾鍚庣殑閮ㄥ垎锛? + appid: 鍏紬鍙?AppID + appsecret: 鍏紬鍙?AppSecret Returns: - 签名配置字典,失败返回 None + 绛惧悕閰嶇疆瀛楀吀锛屽け璐ヨ繑鍥?None """ import hashlib - # 获取 jsapi_ticket + # 鑾峰彇 jsapi_ticket ticket = get_jsapi_ticket_cached(appid, appsecret) if not ticket: return None - # 生成签名参数 + # 鐢熸垚绛惧悕鍙傛暟 timestamp = int(time.time()) nonce_str = uuid.uuid4().hex - # 签名字符串(必须按字典序排序!) + # 绛惧悕瀛楃涓诧紙蹇呴』鎸夊瓧鍏稿簭鎺掑簭锛侊級 sign_str = f"jsapi_ticket={ticket}&noncestr={nonce_str}×tamp={timestamp}&url={url}" - # SHA1 签名 + # SHA1 绛惧悕 signature = hashlib.sha1(sign_str.encode('utf-8')).hexdigest() return { @@ -5230,52 +5100,52 @@ def generate_jssdk_signature(url, appid, appsecret): @app.route('/api/wechat/jssdk-config', methods=['POST']) def api_wechat_jssdk_config(): - """获取微信 JS-SDK 签名配置(用于开放标签)""" + """鑾峰彇寰俊 JS-SDK 绛惧悕閰嶇疆锛堢敤浜庡紑鏀炬爣绛撅級""" try: - print(f"[JS-SDK Config] 收到请求") + print(f"[JS-SDK Config] 鏀跺埌璇锋眰") data = request.get_json() or {} url = data.get('url') print(f"[JS-SDK Config] URL: {url}") if not url: - print(f"[JS-SDK Config] 错误: 缺少 url 参数") + print(f"[JS-SDK Config] 閿欒: 缂哄皯 url 鍙傛暟") return jsonify({ 'code': 400, - 'message': '缺少必要参数 url', + 'message': '缂哄皯蹇呰鍙傛暟 url', 'data': None }), 400 - # URL 校验:必须是允许的域名 + # URL 鏍¢獙锛氬繀椤绘槸鍏佽鐨勫煙鍚? from urllib.parse import urlparse parsed = urlparse(url) - # 扩展允许的域名列表,包括 API 域名 + # 鎵╁睍鍏佽鐨勫煙鍚嶅垪琛紝鍖呮嫭 API 鍩熷悕 allowed_domains = ['valuefrontier.cn', 'www.valuefrontier.cn', 'api.valuefrontier.cn', 'localhost', '127.0.0.1'] domain = parsed.netloc.split(':')[0] - print(f"[JS-SDK Config] 解析域名: {domain}") + print(f"[JS-SDK Config] 瑙f瀽鍩熷悕: {domain}") if domain not in allowed_domains: return jsonify({ 'code': 400, - 'message': 'URL 域名不在允许范围内', + 'message': 'URL 鍩熷悕涓嶅湪鍏佽鑼冨洿鍐?, 'data': None }), 400 - # URL 处理:移除 hash 部分 + # URL 澶勭悊锛氱Щ闄?hash 閮ㄥ垎 if '#' in url: url = url.split('#')[0] - # 生成签名(使用公众号配置) - print(f"[JS-SDK Config] 开始生成签名...") + # 鐢熸垚绛惧悕锛堜娇鐢ㄥ叕浼楀彿閰嶇疆锛? + print(f"[JS-SDK Config] 寮€濮嬬敓鎴愮鍚?..") config = generate_jssdk_signature( url=url, appid=WECHAT_MP_APPID, appsecret=WECHAT_MP_APPSECRET ) - print(f"[JS-SDK Config] 签名生成完成: {config is not None}") + print(f"[JS-SDK Config] 绛惧悕鐢熸垚瀹屾垚: {config is not None}") if not config: return jsonify({ 'code': 500, - 'message': '获取签名配置失败,请稍后重试', + 'message': '鑾峰彇绛惧悕閰嶇疆澶辫触锛岃绋嶅悗閲嶈瘯', 'data': None }), 500 @@ -5286,21 +5156,21 @@ def api_wechat_jssdk_config(): }) except Exception as e: - print(f"[JS-SDK Config] 异常: {e}") + print(f"[JS-SDK Config] 寮傚父: {e}") import traceback traceback.print_exc() return jsonify({ 'code': 500, - 'message': '服务器内部错误', + 'message': '鏈嶅姟鍣ㄥ唴閮ㄩ敊璇?, 'data': None }), 500 @app.route('/api/miniprogram/url-scheme', methods=['POST']) def api_miniprogram_url_scheme(): - """生成小程序 URL Scheme(外部浏览器跳转小程序用)""" + """鐢熸垚灏忕▼搴?URL Scheme锛堝閮ㄦ祻瑙堝櫒璺宠浆灏忕▼搴忕敤锛?"" try: - # 频率限制 + # 棰戠巼闄愬埗 client_ip = request.headers.get('X-Forwarded-For', request.remote_addr) if client_ip: client_ip = client_ip.split(',')[0].strip() @@ -5309,21 +5179,21 @@ def api_miniprogram_url_scheme(): current = redis_client.incr(rate_key) if current == 1: redis_client.expire(rate_key, 60) - if current > 30: # 每分钟最多 30 次 + if current > 30: # 姣忓垎閽熸渶澶?30 娆? return jsonify({ 'code': 429, - 'message': '请求过于频繁,请稍后再试', + 'message': '璇锋眰杩囦簬棰戠箒锛岃绋嶅悗鍐嶈瘯', 'data': None }), 429 data = request.get_json() or {} - # 参数校验 + # 鍙傛暟鏍¢獙 path = data.get('path') if path and not path.startswith('/'): - path = '/' + path # 自动补全 / + path = '/' + path # 鑷姩琛ュ叏 / - # 获取小程序 access_token + # 鑾峰彇灏忕▼搴?access_token access_token = get_wechat_access_token_cached( WECHAT_MINIPROGRAM_APPID, WECHAT_MINIPROGRAM_APPSECRET @@ -5331,21 +5201,21 @@ def api_miniprogram_url_scheme(): if not access_token: return jsonify({ 'code': 500, - 'message': '获取访问令牌失败', + 'message': '鑾峰彇璁块棶浠ょ墝澶辫触', 'data': None }), 500 - # 构建请求参数 + # 鏋勫缓璇锋眰鍙傛暟 wx_url = f"https://api.weixin.qq.com/wxa/generatescheme?access_token={access_token}" expire_type = data.get('expire_type', 1) - expire_interval = min(data.get('expire_interval', 30), 30) # 最长30天 + expire_interval = min(data.get('expire_interval', 30), 30) # 鏈€闀?0澶? payload = { "is_expire": expire_type == 1 } - # 跳转信息 + # 璺宠浆淇℃伅 if path or data.get('query'): payload["jump_wxa"] = {} if path: @@ -5353,7 +5223,7 @@ def api_miniprogram_url_scheme(): if data.get('query'): payload["jump_wxa"]["query"] = data.get('query') - # 有效期设置 + # 鏈夋晥鏈熻缃? if expire_type == 1: if data.get('expire_time'): payload["expire_time"] = data.get('expire_time') @@ -5374,33 +5244,33 @@ def api_miniprogram_url_scheme(): } }) else: - print(f"[URL Scheme] 生成失败: errcode={result.get('errcode')}, errmsg={result.get('errmsg')}") + print(f"[URL Scheme] 鐢熸垚澶辫触: errcode={result.get('errcode')}, errmsg={result.get('errmsg')}") return jsonify({ 'code': 500, - 'message': f"生成 URL Scheme 失败: {result.get('errmsg', '未知错误')}", + 'message': f"鐢熸垚 URL Scheme 澶辫触: {result.get('errmsg', '鏈煡閿欒')}", 'data': None }), 500 except Exception as e: - print(f"[URL Scheme] 异常: {e}") + print(f"[URL Scheme] 寮傚父: {e}") import traceback traceback.print_exc() return jsonify({ 'code': 500, - 'message': '服务器内部错误', + 'message': '鏈嶅姟鍣ㄥ唴閮ㄩ敊璇?, 'data': None }), 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='匿名用户') + 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) @@ -5411,7 +5281,7 @@ class EventComment(db.Model): 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( @@ -5420,7 +5290,7 @@ class EventComment(db.Model): ).first() user_liked = like_record is not None - # 检查当前用户是否可以删除此评论 + # 妫€鏌ュ綋鍓嶇敤鎴锋槸鍚﹀彲浠ュ垹闄ゆ璇勮 can_delete = current_user_id is not None and self.user_id == current_user_id return { @@ -5440,7 +5310,7 @@ class EventComment(db.Model): class CommentLike(db.Model): - """评论点赞记录""" + """璇勮鐐硅禐璁板綍""" __tablename__ = 'comment_like' id = db.Column(db.Integer, primary_key=True) @@ -5453,7 +5323,7 @@ class CommentLike(db.Model): @app.after_request def after_request(response): - """处理所有响应,添加CORS头部和安全头部""" + """澶勭悊鎵€鏈夊搷搴旓紝娣诲姞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', @@ -5466,7 +5336,7 @@ def after_request(response): 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 @@ -5474,7 +5344,7 @@ def after_request(response): def add_cors_headers(response): - """添加CORS头(保留原有函数以兼容)""" + """娣诲姞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', @@ -5492,7 +5362,7 @@ def add_cors_headers(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) @@ -5504,12 +5374,12 @@ class EventFollow(db.Model): 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 + 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') @@ -5517,27 +5387,27 @@ class FutureEventFollow(db.Model): __table_args__ = (db.UniqueConstraint('user_id', 'future_event_id'),) -# —— 自选股输入统一化与名称补全工具 —— +# 鈥斺€?鑷€夎偂杈撳叆缁熶竴鍖栦笌鍚嶇О琛ュ叏宸ュ叿 鈥斺€? def _normalize_stock_input(raw_input: str): - """解析用户输入为标准6位股票代码与可选名称。 + """瑙f瀽鐢ㄦ埛杈撳叆涓烘爣鍑?浣嶈偂绁ㄤ唬鐮佷笌鍙€夊悕绉般€? - 支持: - - 6位代码: "600519",或带后缀 "600519.SH"/"600519.SZ" - - 名称(代码): "贵州茅台(600519)" 或 "贵州茅台(600519)" - 返回 (code6, name_or_none) + 鏀寔锛? + - 6浣嶄唬鐮? "600519"锛屾垨甯﹀悗缂€ "600519.SH"/"600519.SZ" + - 鍚嶇О(浠g爜): "璐靛窞鑼呭彴(600519)" 鎴?"璐靛窞鑼呭彴锛?00519锛? + 杩斿洖 (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) + # 鍚嶇О(600519) 鎴?鍚嶇О锛?00519锛? + 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 + # 600519 鎴?600519.SH / 600519.SZ m2 = re.match(r"^(\d{6})(?:\.(?:SH|SZ))?$", s, re.IGNORECASE) if m2: return m2.group(1), None @@ -5551,7 +5421,7 @@ def _normalize_stock_input(raw_input: str): def _query_stock_name_by_code(code6: str): - """根据6位代码查询股票名称,查不到返回None。""" + """鏍规嵁6浣嶄唬鐮佹煡璇㈣偂绁ㄥ悕绉帮紝鏌ヤ笉鍒拌繑鍥濶one銆?"" try: with engine.connect() as conn: q = text(""" @@ -5568,7 +5438,7 @@ def _query_stock_name_by_code(code6: str): class Watchlist(db.Model): - """用户自选股""" + """鐢ㄦ埛鑷€夎偂""" __tablename__ = 'watchlist' id = db.Column(db.Integer, primary_key=True) @@ -5584,14 +5454,14 @@ class Watchlist(db.Model): @app.route('/api/account/watchlist', methods=['GET']) def get_my_watchlist(): - """获取当前用户的自选股列表""" + """鑾峰彇褰撳墠鐢ㄦ埛鐨勮嚜閫夎偂鍒楄〃""" try: if 'user_id' not in session: - return jsonify({'success': False, 'error': '未登录'}), 401 + return jsonify({'success': False, 'error': '鏈櫥褰?}), 401 items = Watchlist.query.filter_by(user_id=session['user_id']).order_by(Watchlist.created_at.desc()).all() - # 懒更新:统一代码为6位、补全缺失的名称,并去重(同一代码保留一个记录) + # 鎳掓洿鏂帮細缁熶竴浠g爜涓?浣嶃€佽ˉ鍏ㄧ己澶辩殑鍚嶇О锛屽苟鍘婚噸锛堝悓涓€浠g爜淇濈暀涓€涓褰曪級 from collections import defaultdict groups = defaultdict(list) for i in items: @@ -5602,13 +5472,13 @@ def get_my_watchlist(): 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 @@ -5617,7 +5487,7 @@ def get_my_watchlist(): if nm: keep.stock_name = nm dirty = True - # 其余删除 + # 鍏朵綑鍒犻櫎 for g in group_sorted[1:]: to_delete.append(g) @@ -5644,9 +5514,9 @@ def get_my_watchlist(): @app.route('/api/account/watchlist', methods=['POST']) def add_to_watchlist(): - """添加到自选股""" + """娣诲姞鍒拌嚜閫夎偂""" if 'user_id' not in session: - return jsonify({'success': False, 'error': '未登录'}), 401 + return jsonify({'success': False, 'error': '鏈櫥褰?}), 401 data = request.get_json() or {} raw_code = data.get('stock_code') @@ -5654,19 +5524,19 @@ def add_to_watchlist(): code6, name_from_input = _normalize_stock_input(raw_code) if not code6: - return jsonify({'success': False, 'error': '无效的股票标识'}), 400 + return jsonify({'success': False, 'error': '鏃犳晥鐨勮偂绁ㄦ爣璇?}), 400 - # 优先使用传入名称,其次从输入解析中获得,最后查库补全 + # 浼樺厛浣跨敤浼犲叆鍚嶇О锛屽叾娆′粠杈撳叆瑙f瀽涓幏寰楋紝鏈€鍚庢煡搴撹ˉ鍏? 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 @@ -5684,27 +5554,27 @@ def add_to_watchlist(): return jsonify({'success': True, 'data': {'id': item.id}}) -# 注意:/realtime 路由必须在 / 之前定义,否则会被错误匹配 +# 娉ㄦ剰锛?realtime 璺敱蹇呴』鍦?/ 涔嬪墠瀹氫箟锛屽惁鍒欎細琚敊璇尮閰? @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 + 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': []}) - # 获取股票代码列表并标准化 - code_mapping = {} # code6 -> full_code 映射 + # 鑾峰彇鑲$エ浠g爜鍒楄〃骞舵爣鍑嗗寲 + code_mapping = {} # code6 -> full_code 鏄犲皠 full_codes = [] for item in watchlist: code6, _ = _normalize_stock_input(item.stock_code) normalized = code6 or str(item.stock_code).strip().upper() - # 转换为带后缀的完整代码 + # 杞崲涓哄甫鍚庣紑鐨勫畬鏁翠唬鐮? if '.' in normalized: full_code = normalized elif normalized.startswith('6'): @@ -5720,12 +5590,12 @@ def get_watchlist_realtime(): if not full_codes: return jsonify({'success': True, 'data': []}) - # 使用批量查询获取最新行情(单次查询) + # 浣跨敤鎵归噺鏌ヨ鑾峰彇鏈€鏂拌鎯咃紙鍗曟鏌ヨ锛? client = get_clickhouse_client() today = datetime.now().date() start_date = today - timedelta(days=7) - # 批量查询:获取每只股票的最新一条分钟数据 + # 鎵归噺鏌ヨ锛氳幏鍙栨瘡鍙偂绁ㄧ殑鏈€鏂颁竴鏉″垎閽熸暟鎹? batch_query = """ WITH latest AS ( SELECT @@ -5751,7 +5621,7 @@ def get_watchlist_realtime(): 'start': datetime.combine(start_date, dt_time(9, 30)) }) - # 构建最新价格映射 + # 鏋勫缓鏈€鏂颁环鏍兼槧灏? latest_data_map = {} for row in result: code, close, ts, high, low, volume, amt = row @@ -5764,10 +5634,10 @@ def get_watchlist_realtime(): 'amount': float(amt) } - # 批量查询前收盘价(使用 ea_trade 表,更准确) + # 鎵归噺鏌ヨ鍓嶆敹鐩樹环锛堜娇鐢?ea_trade 琛紝鏇村噯纭級 prev_close_map = {} if latest_data_map: - # 获取前一交易日 + # 鑾峰彇鍓嶄竴浜ゆ槗鏃? prev_trading_day = None for td in reversed(trading_days): if td < today: @@ -5795,7 +5665,7 @@ def get_watchlist_realtime(): if close_price: prev_close_map[base_code] = float(close_price) - # 构建响应数据 + # 鏋勫缓鍝嶅簲鏁版嵁 quotes_data = {} for code6, full_code in code_mapping.items(): latest = latest_data_map.get(full_code) @@ -5842,23 +5712,23 @@ def get_watchlist_realtime(): }) except Exception as e: - print(f"获取实时行情失败: {str(e)}") + print(f"鑾峰彇瀹炴椂琛屾儏澶辫触: {str(e)}") import traceback traceback.print_exc() - return jsonify({'success': False, 'error': '获取实时行情失败'}), 500 + return jsonify({'success': False, 'error': '鑾峰彇瀹炴椂琛屾儏澶辫触'}), 500 @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 + 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) @@ -5867,13 +5737,13 @@ def remove_from_watchlist(stock_code): Watchlist.stock_code.in_(candidates) ).first() if not item: - return jsonify({'success': False, 'error': '未找到自选项'}), 404 + return jsonify({'success': False, 'error': '鏈壘鍒拌嚜閫夐」'}), 404 db.session.delete(item) db.session.commit() return jsonify({'success': True}) -# 投资计划和复盘相关的模型 +# 鎶曡祫璁″垝鍜屽鐩樼浉鍏崇殑妯″瀷 class InvestmentPlan(db.Model): __tablename__ = 'investment_plans' id = db.Column(db.Integer, primary_key=True, autoincrement=True) @@ -5905,10 +5775,10 @@ class InvestmentPlan(db.Model): @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 + 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') @@ -5933,22 +5803,22 @@ def get_investment_plans(): }) except Exception as e: - print(f"获取投资计划失败: {str(e)}") - return jsonify({'success': False, 'error': '获取数据失败'}), 500 + 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 + 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 + return jsonify({'success': False, 'error': '缂哄皯蹇呰瀛楁'}), 400 plan = InvestmentPlan( user_id=session['user_id'], @@ -5971,20 +5841,20 @@ def create_investment_plan(): except Exception as e: db.session.rollback() - print(f"创建投资计划失败: {str(e)}") - return jsonify({'success': False, 'error': '创建失败'}), 500 + 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 + 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 + return jsonify({'success': False, 'error': '鏈壘鍒拌璁板綍'}), 404 data = request.get_json() @@ -6011,20 +5881,20 @@ def update_investment_plan(plan_id): except Exception as e: db.session.rollback() - print(f"更新投资计划失败: {str(e)}") - return jsonify({'success': False, 'error': '更新失败'}), 500 + 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 + 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 + return jsonify({'success': False, 'error': '鏈壘鍒拌璁板綍'}), 404 db.session.delete(plan) db.session.commit() @@ -6033,15 +5903,15 @@ def delete_investment_plan(plan_id): except Exception as e: db.session.rollback() - print(f"删除投资计划失败: {str(e)}") - return jsonify({'success': False, 'error': '删除失败'}), 500 + 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 + 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] @@ -6065,9 +5935,9 @@ def get_my_following_events(): @app.route('/api/account/events/comments', methods=['GET']) def get_my_event_comments(): - """获取我在事件上的评论(EventComment)""" + """鑾峰彇鎴戝湪浜嬩欢涓婄殑璇勮锛圗ventComment锛?"" if 'user_id' not in session: - return jsonify({'success': False, 'error': '未登录'}), 401 + 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() @@ -6076,12 +5946,12 @@ def get_my_event_comments(): @app.route('/api/account/events/posts', methods=['GET']) def get_my_event_posts(): - """获取我在事件上的帖子(Post)- 用于个人中心显示""" + """鑾峰彇鎴戝湪浜嬩欢涓婄殑甯栧瓙锛圥ost锛? 鐢ㄤ簬涓汉涓績鏄剧ず""" if 'user_id' not in session: - return jsonify({'success': False, 'error': '未登录'}), 401 + return jsonify({'success': False, 'error': '鏈櫥褰?}), 401 try: - # 查询当前用户的所有 Post(按创建时间倒序) + # 鏌ヨ褰撳墠鐢ㄦ埛鐨勬墍鏈?Post锛堟寜鍒涘缓鏃堕棿鍊掑簭锛? posts = Post.query.filter_by( user_id=session['user_id'], status='active' @@ -6089,25 +5959,25 @@ def get_my_event_posts(): posts_data = [] for post in posts: - # 获取关联的事件信息 + # 鑾峰彇鍏宠仈鐨勪簨浠朵俊鎭? event = Event.query.get(post.event_id) - event_title = event.title if event else '未知事件' + event_title = event.title if event else '鏈煡浜嬩欢' - # 获取用户信息 + # 鑾峰彇鐢ㄦ埛淇℃伅 user = User.query.get(post.user_id) - author = user.username if user else '匿名用户' + author = user.username if user else '鍖垮悕鐢ㄦ埛' - # ⚡ 返回格式兼容旧 EventComment.to_dict() + # 鈿?杩斿洖鏍煎紡鍏煎鏃?EventComment.to_dict() posts_data.append({ 'id': post.id, 'event_id': post.event_id, - 'event_title': event_title, # ⚡ 新增字段(旧 API 没有) + 'event_title': event_title, # 鈿?鏂板瀛楁锛堟棫 API 娌℃湁锛? 'user_id': post.user_id, - 'author': author, # ⚡ 兼容旧格式(字符串类型) + 'author': author, # 鈿?鍏煎鏃ф牸寮忥紙瀛楃涓茬被鍨嬶級 'content': post.content, - 'title': post.title, # Post 独有字段(可选) - 'content_type': post.content_type, # Post 独有字段 - 'likes': post.likes_count, # ⚡ 兼容旧字段名 + 'title': post.title, # Post 鐙湁瀛楁锛堝彲閫夛級 + 'content_type': post.content_type, # Post 鐙湁瀛楁 + 'likes': post.likes_count, # 鈿?鍏煎鏃у瓧娈靛悕 'created_at': post.created_at.isoformat(), 'updated_at': post.updated_at.isoformat(), 'status': post.status, @@ -6116,25 +5986,25 @@ def get_my_event_posts(): return jsonify({'success': True, 'data': posts_data}) except Exception as e: - print(f"获取用户帖子失败: {e}") - return jsonify({'success': False, 'error': '获取帖子失败'}), 500 + print(f"鑾峰彇鐢ㄦ埛甯栧瓙澶辫触: {e}") + return jsonify({'success': False, 'error': '鑾峰彇甯栧瓙澶辫触'}), 500 @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 + return jsonify({'success': False, 'error': '鏈櫥褰?}), 401 try: - # 获取用户关注的未来事件ID列表 + # 鑾峰彇鐢ㄦ埛鍏虫敞鐨勬湭鏉ヤ簨浠禝D鍒楄〃 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 @@ -6148,7 +6018,7 @@ def get_my_following_future_events(): ) events = [] - # 所有返回的事件都是已关注的 + # 鎵€鏈夎繑鍥炵殑浜嬩欢閮芥槸宸插叧娉ㄧ殑 following_ids = set(future_event_ids) for row in result: event_data = process_future_event_row(row, following_ids) @@ -6161,7 +6031,7 @@ def get_my_following_future_events(): 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) @@ -6173,28 +6043,28 @@ class PostLike(db.Model): # =========================== -# 预测市场系统模型 +# 棰勬祴甯傚満绯荤粺妯″瀷 # =========================== class UserCreditAccount(db.Model): - """用户积分账户""" + """鐢ㄦ埛绉垎璐︽埛""" __tablename__ = 'user_credit_account' id = db.Column(db.Integer, primary_key=True) user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False, unique=True) - # 积分余额 - balance = db.Column(db.Float, default=10000.0, nullable=False) # 初始10000积分 - frozen_balance = db.Column(db.Float, default=0.0, nullable=False) # 冻结积分 - total_earned = db.Column(db.Float, default=0.0, nullable=False) # 累计获得 - total_spent = db.Column(db.Float, default=0.0, nullable=False) # 累计消费 + # 绉垎浣欓 + balance = db.Column(db.Float, default=10000.0, nullable=False) # 鍒濆10000绉垎 + frozen_balance = db.Column(db.Float, default=0.0, nullable=False) # 鍐荤粨绉垎 + total_earned = db.Column(db.Float, default=0.0, nullable=False) # 绱鑾峰緱 + total_spent = db.Column(db.Float, default=0.0, nullable=False) # 绱娑堣垂 - # 时间 + # 鏃堕棿 created_at = db.Column(db.DateTime, default=beijing_now, nullable=False) updated_at = db.Column(db.DateTime, default=beijing_now, onupdate=beijing_now) - last_daily_bonus_at = db.Column(db.DateTime) # 最后一次领取每日奖励时间 + last_daily_bonus_at = db.Column(db.DateTime) # 鏈€鍚庝竴娆¢鍙栨瘡鏃ュ鍔辨椂闂? - # 关系 + # 鍏崇郴 user = db.relationship('User', backref=db.backref('credit_account', uselist=False)) def __repr__(self): @@ -6202,46 +6072,46 @@ class UserCreditAccount(db.Model): class PredictionTopic(db.Model): - """预测话题""" + """棰勬祴璇濋""" __tablename__ = 'prediction_topic' id = db.Column(db.Integer, primary_key=True) creator_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) - # 基本信息 + # 鍩烘湰淇℃伅 title = db.Column(db.String(200), nullable=False) description = db.Column(db.Text) category = db.Column(db.String(50), default='stock') # stock/event/market - # 市场数据 - yes_total_shares = db.Column(db.Integer, default=0, nullable=False) # YES方总份额 - no_total_shares = db.Column(db.Integer, default=0, nullable=False) # NO方总份额 - yes_price = db.Column(db.Float, default=500.0, nullable=False) # YES方价格(0-1000) - no_price = db.Column(db.Float, default=500.0, nullable=False) # NO方价格(0-1000) + # 甯傚満鏁版嵁 + yes_total_shares = db.Column(db.Integer, default=0, nullable=False) # YES鏂规€讳唤棰? + no_total_shares = db.Column(db.Integer, default=0, nullable=False) # NO鏂规€讳唤棰? + yes_price = db.Column(db.Float, default=500.0, nullable=False) # YES鏂逛环鏍硷紙0-1000锛? + no_price = db.Column(db.Float, default=500.0, nullable=False) # NO鏂逛环鏍硷紙0-1000锛? - # 奖池 - total_pool = db.Column(db.Float, default=0.0, nullable=False) # 总奖池(2%交易税累积) + # 濂栨睜 + total_pool = db.Column(db.Float, default=0.0, nullable=False) # 鎬诲姹狅紙2%浜ゆ槗绋庣疮绉級 - # 领主信息 - yes_lord_id = db.Column(db.Integer, db.ForeignKey('user.id')) # YES方领主 - no_lord_id = db.Column(db.Integer, db.ForeignKey('user.id')) # NO方领主 + # 棰嗕富淇℃伅 + yes_lord_id = db.Column(db.Integer, db.ForeignKey('user.id')) # YES鏂归涓? + no_lord_id = db.Column(db.Integer, db.ForeignKey('user.id')) # NO鏂归涓? - # 状态 + # 鐘舵€? status = db.Column(db.String(20), default='active', nullable=False) # active/settled/cancelled - result = db.Column(db.String(10)) # yes/no/draw(结算结果) + result = db.Column(db.String(10)) # yes/no/draw锛堢粨绠楃粨鏋滐級 - # 时间 - deadline = db.Column(db.DateTime, nullable=False) # 截止时间 - settled_at = db.Column(db.DateTime) # 结算时间 + # 鏃堕棿 + deadline = db.Column(db.DateTime, nullable=False) # 鎴鏃堕棿 + settled_at = db.Column(db.DateTime) # 缁撶畻鏃堕棿 created_at = db.Column(db.DateTime, default=beijing_now, nullable=False) updated_at = db.Column(db.DateTime, default=beijing_now, onupdate=beijing_now) - # 统计 + # 缁熻 views_count = db.Column(db.Integer, default=0) comments_count = db.Column(db.Integer, default=0) participants_count = db.Column(db.Integer, default=0) - # 关系 + # 鍏崇郴 creator = db.relationship('User', foreign_keys=[creator_id], backref='created_topics') yes_lord = db.relationship('User', foreign_keys=[yes_lord_id], backref='yes_lord_topics') no_lord = db.relationship('User', foreign_keys=[no_lord_id], backref='no_lord_topics') @@ -6254,27 +6124,27 @@ class PredictionTopic(db.Model): class PredictionPosition(db.Model): - """用户持仓""" + """鐢ㄦ埛鎸佷粨""" __tablename__ = 'prediction_position' id = db.Column(db.Integer, primary_key=True) user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) topic_id = db.Column(db.Integer, db.ForeignKey('prediction_topic.id'), nullable=False) - # 持仓信息 + # 鎸佷粨淇℃伅 direction = db.Column(db.String(3), nullable=False) # yes/no - shares = db.Column(db.Integer, default=0, nullable=False) # 持有份额 - avg_cost = db.Column(db.Float, default=0.0, nullable=False) # 平均成本 - total_invested = db.Column(db.Float, default=0.0, nullable=False) # 总投入 + shares = db.Column(db.Integer, default=0, nullable=False) # 鎸佹湁浠介 + avg_cost = db.Column(db.Float, default=0.0, nullable=False) # 骞冲潎鎴愭湰 + total_invested = db.Column(db.Float, default=0.0, nullable=False) # 鎬绘姇鍏? - # 时间 + # 鏃堕棿 created_at = db.Column(db.DateTime, default=beijing_now, nullable=False) updated_at = db.Column(db.DateTime, default=beijing_now, onupdate=beijing_now) - # 关系 + # 鍏崇郴 user = db.relationship('User', backref='prediction_positions') - # 唯一约束:每个用户在每个话题的每个方向只能有一个持仓 + # 鍞竴绾︽潫锛氭瘡涓敤鎴峰湪姣忎釜璇濋鐨勬瘡涓柟鍚戝彧鑳芥湁涓€涓寔浠? __table_args__ = (db.UniqueConstraint('user_id', 'topic_id', 'direction'),) def __repr__(self): @@ -6282,28 +6152,28 @@ class PredictionPosition(db.Model): class PredictionTransaction(db.Model): - """预测交易记录""" + """棰勬祴浜ゆ槗璁板綍""" __tablename__ = 'prediction_transaction' id = db.Column(db.Integer, primary_key=True) user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) topic_id = db.Column(db.Integer, db.ForeignKey('prediction_topic.id'), nullable=False) - # 交易信息 + # 浜ゆ槗淇℃伅 trade_type = db.Column(db.String(10), nullable=False) # buy/sell direction = db.Column(db.String(3), nullable=False) # yes/no - shares = db.Column(db.Integer, nullable=False) # 份额数量 - price = db.Column(db.Float, nullable=False) # 成交价格 + shares = db.Column(db.Integer, nullable=False) # 浠介鏁伴噺 + price = db.Column(db.Float, nullable=False) # 鎴愪氦浠锋牸 - # 费用 - amount = db.Column(db.Float, nullable=False) # 交易金额 - tax = db.Column(db.Float, default=0.0, nullable=False) # 手续费(2%) - total_cost = db.Column(db.Float, nullable=False) # 总成本(amount + tax) + # 璐圭敤 + amount = db.Column(db.Float, nullable=False) # 浜ゆ槗閲戦 + tax = db.Column(db.Float, default=0.0, nullable=False) # 鎵嬬画璐癸紙2%锛? + total_cost = db.Column(db.Float, nullable=False) # 鎬绘垚鏈紙amount + tax锛? - # 时间 + # 鏃堕棿 created_at = db.Column(db.DateTime, default=beijing_now, nullable=False) - # 关系 + # 鍏崇郴 user = db.relationship('User', backref='prediction_transactions') def __repr__(self): @@ -6311,28 +6181,28 @@ class PredictionTransaction(db.Model): class CreditTransaction(db.Model): - """积分交易记录""" + """绉垎浜ゆ槗璁板綍""" __tablename__ = 'credit_transaction' id = db.Column(db.Integer, primary_key=True) user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) - # 交易信息 + # 浜ゆ槗淇℃伅 transaction_type = db.Column(db.String(30), nullable=False) # prediction_buy/prediction_sell/daily_bonus/create_topic/settle_win - amount = db.Column(db.Float, nullable=False) # 金额(正数=增加,负数=减少) - balance_after = db.Column(db.Float, nullable=False) # 交易后余额 + amount = db.Column(db.Float, nullable=False) # 閲戦锛堟鏁?澧炲姞锛岃礋鏁?鍑忓皯锛? + balance_after = db.Column(db.Float, nullable=False) # 浜ゆ槗鍚庝綑棰? - # 关联 - related_topic_id = db.Column(db.Integer, db.ForeignKey('prediction_topic.id')) # 相关话题 - related_transaction_id = db.Column(db.Integer, db.ForeignKey('prediction_transaction.id')) # 相关预测交易 + # 鍏宠仈 + related_topic_id = db.Column(db.Integer, db.ForeignKey('prediction_topic.id')) # 鐩稿叧璇濋 + related_transaction_id = db.Column(db.Integer, db.ForeignKey('prediction_transaction.id')) # 鐩稿叧棰勬祴浜ゆ槗 - # 描述 - description = db.Column(db.String(200)) # 交易描述 + # 鎻忚堪 + description = db.Column(db.String(200)) # 浜ゆ槗鎻忚堪 - # 时间 + # 鏃堕棿 created_at = db.Column(db.DateTime, default=beijing_now, nullable=False) - # 关系 + # 鍏崇郴 user = db.relationship('User', backref='credit_transactions') related_topic = db.relationship('PredictionTopic', backref='credit_transactions') @@ -6341,36 +6211,36 @@ class CreditTransaction(db.Model): class TopicComment(db.Model): - """话题评论""" + """璇濋璇勮""" __tablename__ = 'topic_comment' id = db.Column(db.Integer, primary_key=True) topic_id = db.Column(db.Integer, db.ForeignKey('prediction_topic.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('topic_comment.id')) # 父评论ID(回复功能) + parent_id = db.Column(db.Integer, db.ForeignKey('topic_comment.id')) # 鐖惰瘎璁篒D锛堝洖澶嶅姛鑳斤級 - # 状态 - is_pinned = db.Column(db.Boolean, default=False, nullable=False) # 是否置顶(领主特权) + # 鐘舵€? + is_pinned = db.Column(db.Boolean, default=False, nullable=False) # 鏄惁缃《锛堥涓荤壒鏉冿級 status = db.Column(db.String(20), default='active') # active/hidden/deleted - # 统计 + # 缁熻 likes_count = db.Column(db.Integer, default=0, nullable=False) - # 观点IPO 相关 - total_investment = db.Column(db.Integer, default=0, nullable=False) # 总投资额 - investor_count = db.Column(db.Integer, default=0, nullable=False) # 投资人数 - is_verified = db.Column(db.Boolean, default=False, nullable=False) # 是否已验证 - verification_result = db.Column(db.String(20)) # 验证结果:correct/incorrect/null - position_rank = db.Column(db.Integer) # 评论位置排名(用于首发权拍卖) + # 瑙傜偣IPO 鐩稿叧 + total_investment = db.Column(db.Integer, default=0, nullable=False) # 鎬绘姇璧勯 + investor_count = db.Column(db.Integer, default=0, nullable=False) # 鎶曡祫浜烘暟 + is_verified = db.Column(db.Boolean, default=False, nullable=False) # 鏄惁宸查獙璇? + verification_result = db.Column(db.String(20)) # 楠岃瘉缁撴灉锛歝orrect/incorrect/null + position_rank = db.Column(db.Integer) # 璇勮浣嶇疆鎺掑悕锛堢敤浜庨鍙戞潈鎷嶅崠锛? - # 时间 + # 鏃堕棿 created_at = db.Column(db.DateTime, default=beijing_now, nullable=False) updated_at = db.Column(db.DateTime, default=beijing_now, onupdate=beijing_now) - # 关系 + # 鍏崇郴 user = db.relationship('User', backref='topic_comments') replies = db.relationship('TopicComment', backref=db.backref('parent', remote_side=[id]), lazy='dynamic') likes = db.relationship('TopicCommentLike', backref='comment', lazy='dynamic') @@ -6380,7 +6250,7 @@ class TopicComment(db.Model): class TopicCommentLike(db.Model): - """话题评论点赞""" + """璇濋璇勮鐐硅禐""" __tablename__ = 'topic_comment_like' id = db.Column(db.Integer, primary_key=True) @@ -6388,10 +6258,10 @@ class TopicCommentLike(db.Model): user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) created_at = db.Column(db.DateTime, default=beijing_now, nullable=False) - # 关系 + # 鍏崇郴 user = db.relationship('User', backref='topic_comment_likes') - # 唯一约束 + # 鍞竴绾︽潫 __table_args__ = (db.UniqueConstraint('comment_id', 'user_id'),) def __repr__(self): @@ -6399,25 +6269,25 @@ class TopicCommentLike(db.Model): class CommentInvestment(db.Model): - """评论投资记录(观点IPO)""" + """璇勮鎶曡祫璁板綍锛堣鐐笽PO锛?"" __tablename__ = 'comment_investment' id = db.Column(db.Integer, primary_key=True) comment_id = db.Column(db.Integer, db.ForeignKey('topic_comment.id'), nullable=False) user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) - # 投资数据 - shares = db.Column(db.Integer, nullable=False) # 投资份额 - amount = db.Column(db.Integer, nullable=False) # 投资金额 - avg_price = db.Column(db.Float, nullable=False) # 平均价格 + # 鎶曡祫鏁版嵁 + shares = db.Column(db.Integer, nullable=False) # 鎶曡祫浠介 + amount = db.Column(db.Integer, nullable=False) # 鎶曡祫閲戦 + avg_price = db.Column(db.Float, nullable=False) # 骞冲潎浠锋牸 - # 状态 + # 鐘舵€? status = db.Column(db.String(20), default='active', nullable=False) # active/settled - # 时间 + # 鏃堕棿 created_at = db.Column(db.DateTime, default=beijing_now, nullable=False) - # 关系 + # 鍏崇郴 user = db.relationship('User', backref='comment_investments') comment = db.relationship('TopicComment', backref='investments') @@ -6426,23 +6296,23 @@ class CommentInvestment(db.Model): class CommentPositionBid(db.Model): - """评论位置竞拍记录(首发权拍卖)""" + """璇勮浣嶇疆绔炴媿璁板綍锛堥鍙戞潈鎷嶅崠锛?"" __tablename__ = 'comment_position_bid' id = db.Column(db.Integer, primary_key=True) topic_id = db.Column(db.Integer, db.ForeignKey('prediction_topic.id'), nullable=False) user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) - # 竞拍数据 - position = db.Column(db.Integer, nullable=False) # 位置:1/2/3 - bid_amount = db.Column(db.Integer, nullable=False) # 出价金额 + # 绔炴媿鏁版嵁 + position = db.Column(db.Integer, nullable=False) # 浣嶇疆锛?/2/3 + bid_amount = db.Column(db.Integer, nullable=False) # 鍑轰环閲戦 status = db.Column(db.String(20), default='pending', nullable=False) # pending/won/lost - # 时间 + # 鏃堕棿 created_at = db.Column(db.DateTime, default=beijing_now, nullable=False) - expires_at = db.Column(db.DateTime, nullable=False) # 竞拍截止时间 + expires_at = db.Column(db.DateTime, nullable=False) # 绔炴媿鎴鏃堕棿 - # 关系 + # 鍏崇郴 user = db.relationship('User', backref='comment_position_bids') topic = db.relationship('PredictionTopic', backref='position_bids') @@ -6451,35 +6321,35 @@ class CommentPositionBid(db.Model): class TimeCapsuleTopic(db.Model): - """时间胶囊话题(长期预测)""" + """鏃堕棿鑳跺泭璇濋锛堥暱鏈熼娴嬶級""" __tablename__ = 'time_capsule_topic' id = db.Column(db.Integer, primary_key=True) user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) - # 话题内容 + # 璇濋鍐呭 title = db.Column(db.String(200), nullable=False) description = db.Column(db.Text) - encrypted_content = db.Column(db.Text) # 加密的预测内容 - encryption_key = db.Column(db.String(500)) # 加密密钥(后端存储) + encrypted_content = db.Column(db.Text) # 鍔犲瘑鐨勯娴嬪唴瀹? + encryption_key = db.Column(db.String(500)) # 鍔犲瘑瀵嗛挜锛堝悗绔瓨鍌級 - # 时间范围 - start_year = db.Column(db.Integer, nullable=False) # 起始年份 - end_year = db.Column(db.Integer, nullable=False) # 结束年份 + # 鏃堕棿鑼冨洿 + start_year = db.Column(db.Integer, nullable=False) # 璧峰骞翠唤 + end_year = db.Column(db.Integer, nullable=False) # 缁撴潫骞翠唤 - # 状态 + # 鐘舵€? status = db.Column(db.String(20), default='active', nullable=False) # active/settled - is_decrypted = db.Column(db.Boolean, default=False, nullable=False) # 是否已解密 - actual_happened_year = db.Column(db.Integer) # 实际发生年份 + is_decrypted = db.Column(db.Boolean, default=False, nullable=False) # 鏄惁宸茶В瀵? + actual_happened_year = db.Column(db.Integer) # 瀹為檯鍙戠敓骞翠唤 - # 统计 - total_pool = db.Column(db.Integer, default=0, nullable=False) # 总奖池 + # 缁熻 + total_pool = db.Column(db.Integer, default=0, nullable=False) # 鎬诲姹? - # 时间 + # 鏃堕棿 created_at = db.Column(db.DateTime, default=beijing_now, nullable=False) updated_at = db.Column(db.DateTime, default=beijing_now, onupdate=beijing_now) - # 关系 + # 鍏崇郴 user = db.relationship('User', backref='time_capsule_topics') time_slots = db.relationship('TimeCapsuleTimeSlot', backref='topic', lazy='dynamic') @@ -6488,29 +6358,29 @@ class TimeCapsuleTopic(db.Model): class TimeCapsuleTimeSlot(db.Model): - """时间胶囊时间段""" + """鏃堕棿鑳跺泭鏃堕棿娈?"" __tablename__ = 'time_capsule_time_slot' id = db.Column(db.Integer, primary_key=True) topic_id = db.Column(db.Integer, db.ForeignKey('time_capsule_topic.id'), nullable=False) - # 时间段 + # 鏃堕棿娈? year_start = db.Column(db.Integer, nullable=False) year_end = db.Column(db.Integer, nullable=False) - # 竞拍数据 - current_holder_id = db.Column(db.Integer, db.ForeignKey('user.id')) # 当前持有者 - current_price = db.Column(db.Integer, default=100, nullable=False) # 当前价格 - total_bids = db.Column(db.Integer, default=0, nullable=False) # 总竞拍次数 + # 绔炴媿鏁版嵁 + current_holder_id = db.Column(db.Integer, db.ForeignKey('user.id')) # 褰撳墠鎸佹湁鑰? + current_price = db.Column(db.Integer, default=100, nullable=False) # 褰撳墠浠锋牸 + total_bids = db.Column(db.Integer, default=0, nullable=False) # 鎬荤珵鎷嶆鏁? - # 状态 + # 鐘舵€? status = db.Column(db.String(20), default='active', nullable=False) # active/won/expired - # 时间 + # 鏃堕棿 created_at = db.Column(db.DateTime, default=beijing_now, nullable=False) updated_at = db.Column(db.DateTime, default=beijing_now, onupdate=beijing_now) - # 关系 + # 鍏崇郴 current_holder = db.relationship('User', foreign_keys=[current_holder_id]) bids = db.relationship('TimeSlotBid', backref='time_slot', lazy='dynamic') @@ -6519,21 +6389,21 @@ class TimeCapsuleTimeSlot(db.Model): class TimeSlotBid(db.Model): - """时间段竞拍记录""" + """鏃堕棿娈电珵鎷嶈褰?"" __tablename__ = 'time_slot_bid' id = db.Column(db.Integer, primary_key=True) slot_id = db.Column(db.Integer, db.ForeignKey('time_capsule_time_slot.id'), nullable=False) user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) - # 竞拍数据 + # 绔炴媿鏁版嵁 bid_amount = db.Column(db.Integer, nullable=False) status = db.Column(db.String(20), default='outbid', nullable=False) # outbid/holding/won - # 时间 + # 鏃堕棿 created_at = db.Column(db.DateTime, default=beijing_now, nullable=False) - # 关系 + # 鍏崇郴 user = db.relationship('User', backref='time_slot_bids') def __repr__(self): @@ -6541,30 +6411,30 @@ class TimeSlotBid(db.Model): 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.String(20)) # 申万行业代码,如 "S640701" + # 鍏宠仈淇℃伅 + related_industries = db.Column(db.String(20)) # 鐢充竾琛屼笟浠g爜锛屽 "S640701" keywords = db.Column(db.JSON) files = db.Column(db.JSON) importance = db.Column(db.String(20)) @@ -6572,14 +6442,14 @@ class Event(db.Model): related_max_chg = db.Column(db.Float, default=0) related_week_chg = db.Column(db.Float, default=0) - # 新增字段 - invest_score = db.Column(db.Integer) # 超预期得分 + # 鏂板瀛楁 + 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') @@ -6589,7 +6459,7 @@ class Event(db.Model): @property def keywords_list(self): - """返回解析后的关键词列表""" + """杩斿洖瑙f瀽鍚庣殑鍏抽敭璇嶅垪琛?"" if not self.keywords: return [] @@ -6597,10 +6467,10 @@ class Event(db.Model): return self.keywords try: - # 如果是字符串,尝试解析JSON + # 濡傛灉鏄瓧绗︿覆锛屽皾璇曡В鏋怞SON if isinstance(self.keywords, str): decoded = json.loads(self.keywords) - # 处理Unicode编码的情况 + # 澶勭悊Unicode缂栫爜鐨勬儏鍐? if isinstance(decoded, list): return [ keyword.encode('utf-8').decode('unicode_escape') @@ -6610,82 +6480,82 @@ class Event(db.Model): ] 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字符串 + # 灏濊瘯瑙f瀽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,将其作为单个关键词 + # 濡傛灉涓嶆槸鏈夋晥鐨凧SON锛屽皢鍏朵綔涓哄崟涓叧閿瘝 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)) # 关联原因描述 + stock_code = db.Column(db.String(20)) # 鑲$エ浠g爜 + 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) # 动量 + 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) # 数据描述 + 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): - """事件关联概念模型""" + """浜嬩欢鍏宠仈姒傚康妯″瀷""" __tablename__ = 'related_concepts' id = db.Column(db.Integer, primary_key=True) event_id = db.Column(db.Integer, db.ForeignKey('event.id')) - concept = db.Column(db.String(255)) # 概念名称 - reason = db.Column(db.Text) # 关联原因(AI 分析) + concept = db.Column(db.String(255)) # 姒傚康鍚嶇О + reason = db.Column(db.Text) # 鍏宠仈鍘熷洜锛圓I 鍒嗘瀽锛? created_at = db.Column(db.DateTime, default=beijing_now) 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) # 时间衰减因子 + 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) @@ -6718,7 +6588,7 @@ class EventTransmissionNode(db.Model): class EventTransmissionEdge(db.Model): - """事件传导边模型""" + """浜嬩欢浼犲杈规ā鍨?"" __tablename__ = 'event_transmission_edges' id = db.Column(db.Integer, primary_key=True) @@ -6749,15 +6619,15 @@ class EventTransmissionEdge(db.Model): ) -# 在 paste-2.txt 的模型定义部分添加 +# 鍦?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) @@ -6768,20 +6638,20 @@ class EventSankeyFlow(db.Model): '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__ = ( @@ -6793,24 +6663,24 @@ class EventSankeyFlow(db.Model): 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字段 + 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) @@ -6827,12 +6697,12 @@ class HistoricalEventStock(db.Model): ) -# === 股票盈利预测(自有表) === +# === 鑲$エ鐩堝埄棰勬祴锛堣嚜鏈夎〃锛?=== class StockForecastData(db.Model): - """股票盈利预测数据 + """鑲$エ鐩堝埄棰勬祴鏁版嵁 - 源于本地表 stock_forecast_data,由独立离线程序写入。 - 字段与表结构保持一致,仅用于读取聚合后输出前端报表所需的结构。 + 婧愪簬鏈湴琛?stock_forecast_data锛岀敱鐙珛绂荤嚎绋嬪簭鍐欏叆銆? + 瀛楁涓庤〃缁撴瀯淇濇寔涓€鑷达紝浠呯敤浜庤鍙栬仛鍚堝悗杈撳嚭鍓嶇鎶ヨ〃鎵€闇€鐨勭粨鏋勩€? """ __tablename__ = 'stock_forecast_data' @@ -6866,11 +6736,11 @@ class StockForecastData(db.Model): @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() @@ -6903,7 +6773,7 @@ def get_event_detail(event_id): 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, # 需要根据当前用户状态判断 + 'is_following': False, # 闇€瑕佹牴鎹綋鍓嶇敤鎴风姸鎬佸垽鏂? } }) except Exception as e: @@ -6912,22 +6782,22 @@ def get_event_detail(event_id): @app.route('/api/events//stocks', methods=['GET']) def get_related_stocks(event_id): - """获取相关股票列表""" + """鑾峰彇鐩稿叧鑲$エ鍒楄〃""" try: - # 订阅控制:相关标的需要 Pro 及以上 + # 璁㈤槄鎺у埗锛氱浉鍏虫爣鐨勯渶瑕?Pro 鍙婁互涓? if not _has_required_level('pro'): - return jsonify({'success': False, 'error': '需要Pro订阅', 'required_level': 'pro'}), 403 + return jsonify({'success': False, 'error': '闇€瑕丳ro璁㈤槄', '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: - # 处理 relation_desc:只有当 retrieved_sources 是数组时才使用新格式 + # 澶勭悊 relation_desc锛氬彧鏈夊綋 retrieved_sources 鏄暟缁勬椂鎵嶄娇鐢ㄦ柊鏍煎紡 if stock.retrieved_sources is not None and isinstance(stock.retrieved_sources, list): - # retrieved_sources 是有效数组,使用新格式 + # retrieved_sources 鏄湁鏁堟暟缁勶紝浣跨敤鏂版牸寮? relation_desc_value = {"data": stock.retrieved_sources} else: - # retrieved_sources 不是数组(可能是 {"raw": "..."} 等异常格式),回退到原始文本 + # retrieved_sources 涓嶆槸鏁扮粍锛堝彲鑳芥槸 {"raw": "..."} 绛夊紓甯告牸寮忥級锛屽洖閫€鍒板師濮嬫枃鏈? relation_desc_value = stock.relation_desc stocks_data.append({ @@ -6953,25 +6823,25 @@ def get_related_stocks(event_id): @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 + 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 + return jsonify({'success': False, 'error': '璇ヨ偂绁ㄥ凡瀛樺湪'}), 400 - # 创建新的相关股票记录 + # 鍒涘缓鏂扮殑鐩稿叧鑲$エ璁板綍 new_stock = RelatedStock( event_id=event_id, stock_code=data['stock_code'], @@ -7000,13 +6870,13 @@ def add_related_stock(event_id): @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': '删除成功'}) + return jsonify({'success': True, 'message': '鍒犻櫎鎴愬姛'}) except Exception as e: db.session.rollback() return jsonify({'success': False, 'error': str(e)}), 500 @@ -7015,15 +6885,15 @@ def delete_related_stock(stock_id): @app.route('/api/events/by-stocks', methods=['POST']) def get_events_by_stocks(): """ - 通过股票代码列表获取关联的事件(新闻) - 用于概念中心时间轴:聚合概念下所有股票的相关新闻 + 閫氳繃鑲$エ浠g爜鍒楄〃鑾峰彇鍏宠仈鐨勪簨浠讹紙鏂伴椈锛? + 鐢ㄤ簬姒傚康涓績鏃堕棿杞达細鑱氬悎姒傚康涓嬫墍鏈夎偂绁ㄧ殑鐩稿叧鏂伴椈 - 请求体: + 璇锋眰浣? { - "stock_codes": ["000001.SZ", "600000.SH", ...], # 股票代码列表 - "start_date": "2024-01-01", # 可选,开始日期 - "end_date": "2024-12-31", # 可选,结束日期 - "limit": 100 # 可选,限制返回数量,默认100 + "stock_codes": ["000001.SZ", "600000.SH", ...], # 鑲$エ浠g爜鍒楄〃 + "start_date": "2024-01-01", # 鍙€夛紝寮€濮嬫棩鏈? + "end_date": "2024-12-31", # 鍙€夛紝缁撴潫鏃ユ湡 + "limit": 100 # 鍙€夛紝闄愬埗杩斿洖鏁伴噺锛岄粯璁?00 } """ try: @@ -7034,46 +6904,46 @@ def get_events_by_stocks(): limit = data.get('limit', 100) if not stock_codes: - return jsonify({'success': False, 'error': '缺少股票代码列表'}), 400 + return jsonify({'success': False, 'error': '缂哄皯鑲$エ浠g爜鍒楄〃'}), 400 - # 转换股票代码格式:概念API返回的是不带后缀的(如600000), - # 但related_stock表中存储的是带后缀的(如600000.SH) + # 杞崲鑲$エ浠g爜鏍煎紡锛氭蹇礎PI杩斿洖鐨勬槸涓嶅甫鍚庣紑鐨勶紙濡?00000锛夛紝 + # 浣唕elated_stock琛ㄤ腑瀛樺偍鐨勬槸甯﹀悗缂€鐨勶紙濡?00000.SH锛? def normalize_stock_code(code): - """将股票代码标准化为带后缀的格式""" + """灏嗚偂绁ㄤ唬鐮佹爣鍑嗗寲涓哄甫鍚庣紑鐨勬牸寮?"" if not code: return code - # 如果已经带后缀,直接返回 + # 濡傛灉宸茬粡甯﹀悗缂€锛岀洿鎺ヨ繑鍥? if '.' in str(code): return code code = str(code).strip() - # 根据代码前缀判断交易所 + # 鏍规嵁浠g爜鍓嶇紑鍒ゆ柇浜ゆ槗鎵€ if code.startswith('6'): - return f"{code}.SH" # 上海 + return f"{code}.SH" # 涓婃捣 elif code.startswith('0') or code.startswith('3'): - return f"{code}.SZ" # 深圳 + return f"{code}.SZ" # 娣卞湷 elif code.startswith('8') or code.startswith('4'): - return f"{code}.BJ" # 北交所 + return f"{code}.BJ" # 鍖椾氦鎵€ else: - return code # 未知格式,保持原样 + return code # 鏈煡鏍煎紡锛屼繚鎸佸師鏍? - # 同时包含带后缀和不带后缀的版本,提高匹配率 + # 鍚屾椂鍖呭惈甯﹀悗缂€鍜屼笉甯﹀悗缂€鐨勭増鏈紝鎻愰珮鍖归厤鐜? normalized_codes = set() for code in stock_codes: if code: - normalized_codes.add(str(code)) # 原始格式 - normalized_codes.add(normalize_stock_code(code)) # 带后缀格式 - # 如果原始带后缀,也加入不带后缀的版本 + normalized_codes.add(str(code)) # 鍘熷鏍煎紡 + normalized_codes.add(normalize_stock_code(code)) # 甯﹀悗缂€鏍煎紡 + # 濡傛灉鍘熷甯﹀悗缂€锛屼篃鍔犲叆涓嶅甫鍚庣紑鐨勭増鏈? if '.' in str(code): normalized_codes.add(str(code).split('.')[0]) - # 构建查询:通过 RelatedStock 表找到关联的事件 + # 鏋勫缓鏌ヨ锛氶€氳繃 RelatedStock 琛ㄦ壘鍒板叧鑱旂殑浜嬩欢 query = db.session.query(Event).join( RelatedStock, Event.id == RelatedStock.event_id ).filter( RelatedStock.stock_code.in_(list(normalized_codes)) ) - # 日期过滤(使用 start_time 字段) + # 鏃ユ湡杩囨护锛堜娇鐢?start_time 瀛楁锛? if start_date_str: try: start_date = datetime.strptime(start_date_str, '%Y-%m-%d') @@ -7084,25 +6954,25 @@ def get_events_by_stocks(): if end_date_str: try: end_date = datetime.strptime(end_date_str, '%Y-%m-%d') - # 设置为当天结束 + # 璁剧疆涓哄綋澶╃粨鏉? end_date = end_date.replace(hour=23, minute=59, second=59) query = query.filter(Event.start_time <= end_date) except ValueError: pass - # 去重并排序(使用 start_time 字段) + # 鍘婚噸骞舵帓搴忥紙浣跨敤 start_time 瀛楁锛? query = query.distinct().order_by(Event.start_time.desc()) - # 限制数量 + # 闄愬埗鏁伴噺 if limit: query = query.limit(limit) events = query.all() - # 构建返回数据 + # 鏋勫缓杩斿洖鏁版嵁 events_data = [] for event in events: - # 获取该事件关联的股票信息(在请求的股票列表中的) + # 鑾峰彇璇ヤ簨浠跺叧鑱旂殑鑲$エ淇℃伅锛堝湪璇锋眰鐨勮偂绁ㄥ垪琛ㄤ腑鐨勶級 related_stocks_in_list = [ { 'stock_code': rs.stock_code, @@ -7119,7 +6989,7 @@ def get_events_by_stocks(): 'description': event.description, 'event_date': event.start_time.isoformat() if event.start_time else None, 'published_time': event.start_time.strftime('%Y-%m-%d %H:%M:%S') if event.start_time else None, - 'source': 'event', # 标记来源为事件系统 + 'source': 'event', # 鏍囪鏉ユ簮涓轰簨浠剁郴缁? 'importance': event.importance, 'view_count': event.view_count, 'hot_score': event.hot_score, @@ -7140,11 +7010,11 @@ def get_events_by_stocks(): @app.route('/api/events//concepts', methods=['GET']) def get_related_concepts(event_id): - """获取相关概念列表(从 related_concepts 表)""" + """鑾峰彇鐩稿叧姒傚康鍒楄〃锛堜粠 related_concepts 琛級""" try: - # 订阅控制:相关概念需要 Pro 及以上 + # 璁㈤槄鎺у埗锛氱浉鍏虫蹇甸渶瑕?Pro 鍙婁互涓? if not _has_required_level('pro'): - return jsonify({'success': False, 'error': '需要Pro订阅', 'required_level': 'pro'}), 403 + return jsonify({'success': False, 'error': '闇€瑕丳ro璁㈤槄', 'required_level': 'pro'}), 403 event = Event.query.get_or_404(event_id) concepts = event.related_concepts.all() @@ -7167,7 +7037,7 @@ def get_related_concepts(event_id): @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() @@ -7184,7 +7054,7 @@ def get_historical_events(event_id): 'created_at': hist_event.created_at.isoformat() if hist_event.created_at else None }) - # 订阅控制:免费用户仅返回前2条;Pro/Max返回全部 + # 璁㈤槄鎺у埗锛氬厤璐圭敤鎴蜂粎杩斿洖鍓?鏉★紱Pro/Max杩斿洖鍏ㄩ儴 info = _get_current_subscription_info() sub_type = (info.get('type') or 'free').lower() if sub_type == 'free': @@ -7201,13 +7071,13 @@ def get_historical_events(event_id): @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) @@ -7224,13 +7094,13 @@ def get_historical_event_stocks(event_id): 'created_at': stock.created_at.isoformat() if stock.created_at else None } - # 添加涨幅数据 + # 娣诲姞娑ㄥ箙鏁版嵁 if event_trading_date: try: - # 查询股票在事件对应交易日的数据 - # ea_trade 表字段:F007N=最近成交价(收盘价), F010N=涨跌幅 + # 鏌ヨ鑲$エ鍦ㄤ簨浠跺搴斾氦鏄撴棩鐨勬暟鎹? + # ea_trade 琛ㄥ瓧娈碉細F007N=鏈€杩戞垚浜や环(鏀剁洏浠?, F010N=娑ㄨ穼骞? base_stock_code = stock.stock_code.split('.')[0] if stock.stock_code else '' - # 日期格式转换为 YYYYMMDD 整数(ea_trade.TRADEDATE 是 int 类型) + # 鏃ユ湡鏍煎紡杞崲涓?YYYYMMDD 鏁存暟锛坋a_trade.TRADEDATE 鏄?int 绫诲瀷锛? if hasattr(event_trading_date, 'strftime'): trade_date_int = int(event_trading_date.strftime('%Y%m%d')) else: @@ -7252,12 +7122,12 @@ def get_historical_event_stocks(event_id): 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 - print(f"[DEBUG] 股票{base_stock_code}在{trade_date_int}: close={result[0]}, change_pct={result[1]}") + print(f"[DEBUG] 鑲$エ{base_stock_code}鍦▄trade_date_int}: close={result[0]}, change_pct={result[1]}") 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}") + print(f"鏌ヨ鑲$エ{stock.stock_code}鍦▄event_trading_date}鐨勬暟鎹け璐? {e}") stock_data['event_day_close'] = None stock_data['event_day_change_pct'] = None else: @@ -7277,30 +7147,30 @@ def get_historical_event_stocks(event_id): @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分' + 'description': '鍩轰簬鍘嗗彶浜嬩欢鍒ゆ柇褰撳墠浜嬩欢鐨勮秴棰勬湡鎯呭喌锛屾弧鍒?00鍒? } }) except Exception as e: @@ -7309,9 +7179,9 @@ def get_expectation_score(event_id): @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 + return jsonify({'success': False, 'error': '鏈櫥褰?}), 401 try: event = Event.query.get_or_404(event_id) @@ -7319,13 +7189,13 @@ def toggle_event_follow(event_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 @@ -7339,44 +7209,44 @@ def toggle_event_follow(event_id): @app.route('/api/events//transmission', methods=['GET']) def get_transmission_chain(event_id): try: - # 订阅控制:传导链分析需要 Max 及以上 + # 璁㈤槄鎺у埗锛氫紶瀵奸摼鍒嗘瀽闇€瑕?Max 鍙婁互涓? if not _has_required_level('max'): - return jsonify({'success': False, 'error': '需要Max订阅', 'required_level': 'max'}), 403 - # 确保数据库连接是活跃的 + return jsonify({'success': False, 'error': '闇€瑕丮ax璁㈤槄', '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': '暂无传导链分析数据'}) + return jsonify({'success': False, 'message': '鏆傛棤浼犲閾惧垎鏋愭暟鎹?}) - # 节点类型到中文类别的映射 + # 鑺傜偣绫诲瀷鍒颁腑鏂囩被鍒殑鏄犲皠 categories = { - 'event': "事件", 'industry': "行业", 'company': "公司", - 'policy': "政策", 'technology': "技术", 'market': "市场", 'other': "其他" + 'event': "浜嬩欢", 'industry': "琛屼笟", 'company': "鍏徃", + 'policy': "鏀跨瓥", 'technology': "鎶€鏈?, 'market': "甯傚満", 'other': "鍏朵粬" } nodes_data = [] for node in connected_nodes: - node_category = categories.get(node.node_type, "其他") + node_category = categories.get(node.node_type, "鍏朵粬") nodes_data.append({ - 'id': str(node.id), # 转换为字符串以保持一致性 + 'id': str(node.id), # 杞崲涓哄瓧绗︿覆浠ヤ繚鎸佷竴鑷存€? 'name': node.node_name, 'category': node_category, 'value': node.importance_score or 20, @@ -7391,11 +7261,11 @@ def get_transmission_chain(event_id): 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), # 转换为字符串以保持一致性 + 'source': str(edge.from_node_id), # 杞崲涓哄瓧绗︿覆浠ヤ繚鎸佷竴鑷存€? + 'target': str(edge.to_node_id), # 杞崲涓哄瓧绗︿覆浠ヤ繚鎸佷竴鑷存€? 'value': edge.strength or 50, 'extra': { 'transmission_type': edge.transmission_type, @@ -7418,15 +7288,15 @@ def get_transmission_chain(event_id): return jsonify({'success': False, 'error': str(e)}), 500 -# 修复股票报价API - 支持GET和POST方法 +# 淇鑲$エ鎶ヤ环API - 鏀寔GET鍜孭OST鏂规硶 @app.route('/api/stock/quotes', methods=['GET', 'POST']) def get_stock_quotes(): """ - 获取股票行情数据(使用全局交易日数据,与 batch-kline 保持一致) - - 股票名称:从 MySQL ea_stocklist 查询 - - 交易日数据:使用全局 trading_days(从 tdays.csv 加载) - - 前一交易日收盘价:从 MySQL ea_trade 查询 - - 实时价格:从 ClickHouse stock_minute 查询 + 鑾峰彇鑲$エ琛屾儏鏁版嵁锛堜娇鐢ㄥ叏灞€浜ゆ槗鏃ユ暟鎹紝涓?batch-kline 淇濇寔涓€鑷达級 + - 鑲$エ鍚嶇О锛氫粠 MySQL ea_stocklist 鏌ヨ + - 浜ゆ槗鏃ユ暟鎹細浣跨敤鍏ㄥ眬 trading_days锛堜粠 tdays.csv 鍔犺浇锛? + - 鍓嶄竴浜ゆ槗鏃ユ敹鐩樹环锛氫粠 MySQL ea_trade 鏌ヨ + - 瀹炴椂浠锋牸锛氫粠 ClickHouse stock_minute 鏌ヨ """ try: if request.method == 'GET': @@ -7438,9 +7308,9 @@ def get_stock_quotes(): event_time_str = request.json.get('event_time') if not codes: - return jsonify({'success': False, 'error': '请提供股票代码'}), 400 + return jsonify({'success': False, 'error': '璇锋彁渚涜偂绁ㄤ唬鐮?}), 400 - # 标准化股票代码 + # 鏍囧噯鍖栬偂绁ㄤ唬鐮? def normalize_stock_code(code): if '.' in code: return code @@ -7455,7 +7325,7 @@ def get_stock_quotes(): normalized_codes = [normalize_stock_code(code) for code in codes] code_mapping = dict(zip(original_codes, normalized_codes)) - # 处理事件时间 + # 澶勭悊浜嬩欢鏃堕棿 if event_time_str: try: event_time = datetime.fromisoformat(event_time_str.replace('Z', '+00:00')) @@ -7466,27 +7336,27 @@ def get_stock_quotes(): current_time = datetime.now() - # ==================== 查询股票名称(使用 Redis 缓存) ==================== + # ==================== 鏌ヨ鑲$エ鍚嶇О锛堜娇鐢?Redis 缂撳瓨锛?==================== base_codes = list(set([code.split('.')[0] for code in codes])) - stock_names = get_cached_stock_names(base_codes) + stock_names = get_stock_names(base_codes) - # 构建完整的名称映射 + # 鏋勫缓瀹屾暣鐨勫悕绉版槧灏? full_stock_names = {} for orig_code, norm_code in code_mapping.items(): base_code = orig_code.split('.')[0] - name = stock_names.get(base_code, f"股票{base_code}") + name = stock_names.get(base_code, f"鑲$エ{base_code}") full_stock_names[orig_code] = name full_stock_names[norm_code] = name - # ==================== 使用全局交易日数据(处理跨周末场景) ==================== - # 使用新的辅助函数处理跨周末场景: - # - 周五15:00后到周一15:00前,分时图显示周一行情,涨跌幅基于周五收盘价 + # ==================== 浣跨敤鍏ㄥ眬浜ゆ槗鏃ユ暟鎹紙澶勭悊璺ㄥ懆鏈満鏅級 ==================== + # 浣跨敤鏂扮殑杈呭姪鍑芥暟澶勭悊璺ㄥ懆鏈満鏅細 + # - 鍛ㄤ簲15:00鍚庡埌鍛ㄤ竴15:00鍓嶏紝鍒嗘椂鍥炬樉绀哄懆涓€琛屾儏锛屾定璺屽箙鍩轰簬鍛ㄤ簲鏀剁洏浠? target_date, prev_trading_day = get_target_and_prev_trading_day(event_time) if not target_date: return jsonify({ 'success': True, - 'data': {code: {'name': full_stock_names.get(code, f'股票{code}'), 'price': None, 'change': None} + 'data': {code: {'name': full_stock_names.get(code, f'鑲$エ{code}'), 'price': None, 'change': None} for code in original_codes} }) @@ -7494,31 +7364,31 @@ def get_stock_quotes(): end_datetime = datetime.combine(target_date, dt_time(15, 0)) results = {} - print(f"批量处理 {len(codes)} 只股票: {codes[:5]}{'...' if len(codes) > 5 else ''}, 目标交易日: {target_date}, 涨跌幅基准日: {prev_trading_day}, 时间范围: {start_datetime} - {end_datetime}") + print(f"鎵归噺澶勭悊 {len(codes)} 鍙偂绁? {codes[:5]}{'...' if len(codes) > 5 else ''}, 鐩爣浜ゆ槗鏃? {target_date}, 娑ㄨ穼骞呭熀鍑嗘棩: {prev_trading_day}, 鏃堕棿鑼冨洿: {start_datetime} - {end_datetime}") - # 初始化 ClickHouse 客户端 + # 鍒濆鍖?ClickHouse 瀹㈡埛绔? client = get_clickhouse_client() - # ==================== 查询前一交易日收盘价(使用 Redis 缓存) ==================== + # ==================== 鏌ヨ鍓嶄竴浜ゆ槗鏃ユ敹鐩樹环锛堜娇鐢?Redis 缂撳瓨锛?==================== try: prev_close_map = {} if prev_trading_day: - # ea_trade 表的 TRADEDATE 格式是 YYYYMMDD(无连字符) + # ea_trade 琛ㄧ殑 TRADEDATE 鏍煎紡鏄?YYYYMMDD锛堟棤杩炲瓧绗︼級 prev_day_str = prev_trading_day.strftime('%Y%m%d') if hasattr(prev_trading_day, 'strftime') else str(prev_trading_day).replace('-', '') base_codes = list(set([code.split('.')[0] for code in codes])) - # 使用 Redis 缓存获取前收盘价 - base_close_map = get_cached_prev_close(base_codes, prev_day_str) - print(f"前一交易日({prev_day_str})收盘价: 获取到 {len(base_close_map)} 条(Redis缓存)") + # 浣跨敤 Redis 缂撳瓨鑾峰彇鍓嶆敹鐩樹环 + base_close_map = get_prev_close(base_codes, prev_day_str) + print(f"鍓嶄竴浜ゆ槗鏃?{prev_day_str})鏀剁洏浠? 鑾峰彇鍒?{len(base_close_map)} 鏉★紙Redis缂撳瓨锛?) - # 为每个标准化代码分配收盘价 + # 涓烘瘡涓爣鍑嗗寲浠g爜鍒嗛厤鏀剁洏浠? for norm_code in normalized_codes: base_code = norm_code.split('.')[0] if base_code in base_close_map: prev_close_map[norm_code] = base_close_map[base_code] - # 批量查询当前价格数据(从 ClickHouse) - # 使用 argMax 函数获取最新价格,比窗口函数效率高很多 + # 鎵归噺鏌ヨ褰撳墠浠锋牸鏁版嵁锛堜粠 ClickHouse锛? + # 浣跨敤 argMax 鍑芥暟鑾峰彇鏈€鏂颁环鏍硷紝姣旂獥鍙e嚱鏁版晥鐜囬珮寰堝 batch_price_query = """ SELECT code, @@ -7536,16 +7406,16 @@ def get_stock_quotes(): 'end': end_datetime }) - print(f"批量查询返回 {len(batch_data)} 条价格数据") + print(f"鎵归噺鏌ヨ杩斿洖 {len(batch_data)} 鏉′环鏍兼暟鎹?) - # 解析批量查询结果 + # 瑙f瀽鎵归噺鏌ヨ缁撴灉 price_data_map = {} for row in batch_data: code = row[0] last_price = float(row[1]) if row[1] is not None else None prev_close = prev_close_map.get(code) - # 计算涨跌幅 + # 璁$畻娑ㄨ穼骞? change_pct = None if last_price is not None and prev_close is not None and prev_close > 0: change_pct = (last_price - prev_close) / prev_close * 100 @@ -7555,7 +7425,7 @@ def get_stock_quotes(): 'change': change_pct } - # 组装结果 + # 缁勮缁撴灉 for orig_code in original_codes: norm_code = code_mapping[orig_code] price_info = price_data_map.get(norm_code) @@ -7563,22 +7433,22 @@ def get_stock_quotes(): results[orig_code] = { 'price': price_info['price'], 'change': price_info['change'], - 'name': full_stock_names.get(orig_code, f'股票{orig_code.split(".")[0]}') + 'name': full_stock_names.get(orig_code, f'鑲$エ{orig_code.split(".")[0]}') } else: results[orig_code] = { 'price': None, 'change': None, - 'name': full_stock_names.get(orig_code, f'股票{orig_code.split(".")[0]}') + 'name': full_stock_names.get(orig_code, f'鑲$エ{orig_code.split(".")[0]}') } except Exception as e: - print(f"批量查询失败: {e},回退到逐只查询") - # 降级方案:逐只股票查询 + print(f"鎵归噺鏌ヨ澶辫触: {e}锛屽洖閫€鍒伴€愬彧鏌ヨ") + # 闄嶇骇鏂规锛氶€愬彧鑲$エ鏌ヨ for orig_code in original_codes: norm_code = code_mapping[orig_code] try: - # 查询当前价格 + # 鏌ヨ褰撳墠浠锋牸 current_data = client.execute(""" SELECT close FROM stock_minute WHERE code = %(code)s AND timestamp >= %(start)s AND timestamp <= %(end)s @@ -7587,11 +7457,11 @@ def get_stock_quotes(): last_price = float(current_data[0][0]) if current_data and current_data[0] and current_data[0][0] else None - # 查询前一交易日收盘价 + # 鏌ヨ鍓嶄竴浜ゆ槗鏃ユ敹鐩樹环 prev_close = None if prev_trading_day and last_price is not None: base_code = orig_code.split('.')[0] - # ea_trade 表的 TRADEDATE 格式是 YYYYMMDD(无连字符) + # ea_trade 琛ㄧ殑 TRADEDATE 鏍煎紡鏄?YYYYMMDD锛堟棤杩炲瓧绗︼級 prev_day_str = prev_trading_day.strftime('%Y%m%d') if hasattr(prev_trading_day, 'strftime') else str(prev_trading_day).replace('-', '') with engine.connect() as conn: prev_result = conn.execute(text(""" @@ -7601,7 +7471,7 @@ def get_stock_quotes(): """), {'code': base_code, 'trade_date': prev_day_str}).fetchone() prev_close = float(prev_result[0]) if prev_result and prev_result[0] else None - # 计算涨跌幅 + # 璁$畻娑ㄨ穼骞? change_pct = None if last_price is not None and prev_close is not None and prev_close > 0: change_pct = (last_price - prev_close) / prev_close * 100 @@ -7609,13 +7479,13 @@ def get_stock_quotes(): results[orig_code] = { 'price': last_price, 'change': change_pct, - 'name': full_stock_names.get(orig_code, f'股票{orig_code.split(".")[0]}') + 'name': full_stock_names.get(orig_code, f'鑲$エ{orig_code.split(".")[0]}') } except Exception as inner_e: print(f"Error processing stock {orig_code}: {inner_e}") - results[orig_code] = {'price': None, 'change': None, 'name': full_stock_names.get(orig_code, f'股票{orig_code.split(".")[0]}')} + results[orig_code] = {'price': None, 'change': None, 'name': full_stock_names.get(orig_code, f'鑲$エ{orig_code.split(".")[0]}')} - # 返回标准格式 + # 杩斿洖鏍囧噯鏍煎紡 return jsonify({'success': True, 'data': results}) except Exception as e: @@ -7623,12 +7493,12 @@ def get_stock_quotes(): return jsonify({'success': False, 'error': str(e)}), 500 -# ==================== ClickHouse 连接池(单例模式) ==================== +# ==================== ClickHouse 杩炴帴姹狅紙鍗曚緥妯″紡锛?==================== _clickhouse_client = None _clickhouse_client_lock = threading.Lock() def get_clickhouse_client(): - """获取 ClickHouse 客户端(单例模式,避免重复创建连接)""" + """鑾峰彇 ClickHouse 瀹㈡埛绔紙鍗曚緥妯″紡锛岄伩鍏嶉噸澶嶅垱寤鸿繛鎺ワ級""" global _clickhouse_client if _clickhouse_client is None: with _clickhouse_client_lock: @@ -7640,18 +7510,18 @@ def get_clickhouse_client(): password='Zzl33818!', database='stock' ) - print("[ClickHouse] 创建新连接(单例)") + print("[ClickHouse] 鍒涘缓鏂拌繛鎺ワ紙鍗曚緥锛?) return _clickhouse_client @app.route('/api/account/calendar/events', methods=['GET', 'POST']) def account_calendar_events(): - """返回当前用户的投资计划与关注的未来事件(合并)。 - GET: 可按日期范围/月份过滤;POST: 新增投资计划(写入 InvestmentPlan)。 + """杩斿洖褰撳墠鐢ㄦ埛鐨勬姇璧勮鍒掍笌鍏虫敞鐨勬湭鏉ヤ簨浠讹紙鍚堝苟锛夈€? + GET: 鍙寜鏃ユ湡鑼冨洿/鏈堜唤杩囨护锛汸OST: 鏂板鎶曡祫璁″垝锛堝啓鍏?InvestmentPlan锛夈€? """ try: if 'user_id' not in session: - return jsonify({'success': False, 'error': '未登录'}), 401 + return jsonify({'success': False, 'error': '鏈櫥褰?}), 401 if request.method == 'POST': data = request.get_json() or {} @@ -7662,12 +7532,12 @@ def account_calendar_events(): stocks = data.get('stocks') or [] if not title or not event_date_str: - return jsonify({'success': False, 'error': '缺少必填字段'}), 400 + return jsonify({'success': False, 'error': '缂哄皯蹇呭~瀛楁'}), 400 try: event_date = datetime.fromisoformat(event_date_str).date() except Exception: - return jsonify({'success': False, 'error': '日期格式错误'}), 400 + return jsonify({'success': False, 'error': '鏃ユ湡鏍煎紡閿欒'}), 400 plan = InvestmentPlan( user_id=session['user_id'], @@ -7693,7 +7563,7 @@ def account_calendar_events(): }}) # GET - # 解析过滤参数:date 或 (year, month) 或 (start_date, end_date) + # 瑙f瀽杩囨护鍙傛暟锛歞ate 鎴?(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) @@ -7710,7 +7580,7 @@ def account_calendar_events(): 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) @@ -7724,7 +7594,7 @@ def account_calendar_events(): 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) @@ -7743,13 +7613,13 @@ def account_calendar_events(): '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: - # 使用 SELECT * 以便获取所有字段(包括新字段) + # 浣跨敤 SELECT * 浠ヤ究鑾峰彇鎵€鏈夊瓧娈碉紙鍖呮嫭鏂板瓧娈碉級 base_sql = """ SELECT * FROM future_events @@ -7757,7 +7627,7 @@ def account_calendar_events(): """ params = {'event_ids': tuple(future_event_ids)} - # 日期过滤(按 calendar_time 的日期) + # 鏃ユ湡杩囨护锛堟寜 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}) @@ -7769,22 +7639,22 @@ def account_calendar_events(): result = db.session.execute(text(base_sql), params) for row in result: - # 使用新字段回退逻辑获取 former + # 浣跨敤鏂板瓧娈靛洖閫€閫昏緫鑾峰彇 former former_value = get_future_event_field(row, 'second_modified_text', 'former') - # 获取 related_stocks,优先使用 best_matches + # 鑾峰彇 related_stocks锛屼紭鍏堜娇鐢?best_matches best_matches = getattr(row, 'best_matches', None) if hasattr(row, 'best_matches') else None if best_matches and str(best_matches).strip(): rs = parse_best_matches(best_matches) else: rs = parse_json_field(getattr(row, 'related_stocks', None)) - # 生成股票标签列表 + # 鐢熸垚鑲$エ鏍囩鍒楄〃 stock_tags = [] try: for it in rs: if isinstance(it, dict): - # 新结构 + # 鏂扮粨鏋? stock_tags.append(f"{it.get('code', '')} {it.get('name', '')}") elif isinstance(it, (list, tuple)) and len(it) >= 2: stock_tags.append(f"{it[0]} {it[1]}") @@ -7813,13 +7683,13 @@ def account_calendar_events(): @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 + 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 + return jsonify({'success': False, 'error': '鏈壘鍒拌璁板綍'}), 404 db.session.delete(plan) db.session.commit() return jsonify({'success': True}) @@ -7828,27 +7698,27 @@ def delete_account_calendar_event(event_id): return jsonify({'success': False, 'error': str(e)}), 500 -# ==================== 灵活屏实时行情 API ==================== -# 从 ClickHouse 实时行情表获取最新数据(用于盘后/WebSocket 无数据时的回退) +# ==================== 鐏垫椿灞忓疄鏃惰鎯?API ==================== +# 浠?ClickHouse 瀹炴椂琛屾儏琛ㄨ幏鍙栨渶鏂版暟鎹紙鐢ㄤ簬鐩樺悗/WebSocket 鏃犳暟鎹椂鐨勫洖閫€锛? @app.route('/api/flex-screen/quotes', methods=['POST']) def get_flex_screen_quotes(): """ - 获取灵活屏行情数据 - 优先从实时行情表查询,如果没有则从分钟线表查询 + 鑾峰彇鐏垫椿灞忚鎯呮暟鎹? + 浼樺厛浠庡疄鏃惰鎯呰〃鏌ヨ锛屽鏋滄病鏈夊垯浠庡垎閽熺嚎琛ㄦ煡璇? - 请求体: + 璇锋眰浣? { "codes": ["000001.SZ", "399001.SZ", "600519.SH"], - "include_order_book": false // 是否包含五档盘口 + "include_order_book": false // 鏄惁鍖呭惈浜旀。鐩樺彛 } - 返回: + 杩斿洖: { "success": true, "data": { "000001.SZ": { "security_id": "000001", - "name": "平安银行", + "name": "骞冲畨閾惰", "last_px": 10.50, "prev_close_px": 10.20, "open_px": 10.30, @@ -7866,7 +7736,7 @@ def get_flex_screen_quotes(): }, ... }, - "source": "realtime" | "minute" // 数据来源 + "source": "realtime" | "minute" // 鏁版嵁鏉ユ簮 } """ try: @@ -7875,29 +7745,29 @@ def get_flex_screen_quotes(): include_order_book = data.get('include_order_book', False) if not codes: - return jsonify({'success': False, 'error': '请提供股票代码'}), 400 + return jsonify({'success': False, 'error': '璇锋彁渚涜偂绁ㄤ唬鐮?}), 400 client = get_clickhouse_client() results = {} source = 'realtime' - # 分离上交所和深交所代码 - sse_codes = [] # 上交所 - szse_stock_codes = [] # 深交所股票 - szse_index_codes = [] # 深交所指数 + # 鍒嗙涓婁氦鎵€鍜屾繁浜ゆ墍浠g爜 + sse_codes = [] # 涓婁氦鎵€ + szse_stock_codes = [] # 娣变氦鎵€鑲$エ + szse_index_codes = [] # 娣变氦鎵€鎸囨暟 for code in codes: base_code = code.split('.')[0] if code.endswith('.SH'): sse_codes.append(base_code) elif code.endswith('.SZ'): - # 399 开头是指数 + # 399 寮€澶存槸鎸囨暟 if base_code.startswith('399'): szse_index_codes.append(base_code) else: szse_stock_codes.append(base_code) - # 获取股票名称 + # 鑾峰彇鑲$エ鍚嶇О stock_names = {} with engine.connect() as conn: base_codes = list(set([code.split('.')[0] for code in codes])) @@ -7909,7 +7779,7 @@ def get_flex_screen_quotes(): ), params).fetchall() stock_names = {row[0]: row[1] for row in result} - # 查询深交所股票实时行情 + # 鏌ヨ娣变氦鎵€鑲$エ瀹炴椂琛屾儏 if szse_stock_codes: try: order_book_cols = "" @@ -7980,9 +7850,9 @@ def get_flex_screen_quotes(): results[full_code] = quote except Exception as e: - print(f"查询深交所实时行情失败: {e}") + print(f"鏌ヨ娣变氦鎵€瀹炴椂琛屾儏澶辫触: {e}") - # 查询深交所指数实时行情 + # 鏌ヨ娣变氦鎵€鎸囨暟瀹炴椂琛屾儏 if szse_index_codes: try: szse_index_query = """ @@ -8036,9 +7906,9 @@ def get_flex_screen_quotes(): } except Exception as e: - print(f"查询深交所指数实时行情失败: {e}") + print(f"鏌ヨ娣变氦鎵€鎸囨暟瀹炴椂琛屾儏澶辫触: {e}") - # 查询上交所实时行情(如果有 sse_stock_realtime 表) + # 鏌ヨ涓婁氦鎵€瀹炴椂琛屾儏锛堝鏋滄湁 sse_stock_realtime 琛級 if sse_codes: try: sse_query = """ @@ -8088,14 +7958,14 @@ def get_flex_screen_quotes(): } except Exception as e: - print(f"查询上交所实时行情失败: {e},尝试从分钟线表查询") + print(f"鏌ヨ涓婁氦鎵€瀹炴椂琛屾儏澶辫触: {e}锛屽皾璇曚粠鍒嗛挓绾胯〃鏌ヨ") - # 对于实时表中没有数据的股票,从分钟线表查询 + # 瀵逛簬瀹炴椂琛ㄤ腑娌℃湁鏁版嵁鐨勮偂绁紝浠庡垎閽熺嚎琛ㄦ煡璇? missing_codes = [code for code in codes if code not in results] if missing_codes: source = 'minute' if not results else 'mixed' try: - # 从分钟线表查询最新数据 + # 浠庡垎閽熺嚎琛ㄦ煡璇㈡渶鏂版暟鎹? minute_query = """ SELECT code, @@ -8114,12 +7984,12 @@ def get_flex_screen_quotes(): """ minute_data = client.execute(minute_query, {'codes': missing_codes}) - # 获取昨收价 + # 鑾峰彇鏄ㄦ敹浠? prev_close_map = {} with engine.connect() as conn: base_codes = list(set([code.split('.')[0] for code in missing_codes])) if base_codes: - # 获取上一交易日 + # 鑾峰彇涓婁竴浜ゆ槗鏃? prev_day_result = conn.execute(text(""" SELECT EXCHANGE_DATE FROM trading_days WHERE EXCHANGE_DATE < CURDATE() @@ -8167,7 +8037,7 @@ def get_flex_screen_quotes(): } except Exception as e: - print(f"查询分钟线数据失败: {e}") + print(f"鏌ヨ鍒嗛挓绾挎暟鎹け璐? {e}") return jsonify({ 'success': True, @@ -8176,7 +8046,7 @@ def get_flex_screen_quotes(): }) except Exception as e: - print(f"灵活屏行情查询失败: {e}") + print(f"鐏垫椿灞忚鎯呮煡璇㈠け璐? {e}") import traceback traceback.print_exc() return jsonify({'success': False, 'error': str(e)}), 500 @@ -8187,9 +8057,9 @@ def get_stock_kline(stock_code): chart_type = request.args.get('type', 'minute') event_time = request.args.get('event_time') - # 是否跳过"下一个交易日"逻辑: - # - 如果没有传 event_time(灵活屏等实时行情场景),盘后应显示当天数据 - # - 如果传了 event_time(Community 事件等场景),使用原逻辑 + # 鏄惁璺宠繃"涓嬩竴涓氦鏄撴棩"閫昏緫锛? + # - 濡傛灉娌℃湁浼?event_time锛堢伒娲诲睆绛夊疄鏃惰鎯呭満鏅級锛岀洏鍚庡簲鏄剧ず褰撳ぉ鏁版嵁 + # - 濡傛灉浼犱簡 event_time锛圕ommunity 浜嬩欢绛夊満鏅級锛屼娇鐢ㄥ師閫昏緫 skip_next_day = event_time is None try: @@ -8197,16 +8067,16 @@ def get_stock_kline(stock_code): except ValueError: return jsonify({'error': 'Invalid event_time format'}), 400 - # 确保股票代码包含后缀(ClickHouse 中数据带后缀) + # 纭繚鑲$エ浠g爜鍖呭惈鍚庣紑锛圕lickHouse 涓暟鎹甫鍚庣紑锛? if '.' not in stock_code: if stock_code.startswith('6'): - stock_code = f"{stock_code}.SH" # 上海 + stock_code = f"{stock_code}.SH" # 涓婃捣 elif stock_code.startswith(('8', '9', '4')): - stock_code = f"{stock_code}.BJ" # 北交所 + stock_code = f"{stock_code}.BJ" # 鍖椾氦鎵€ else: - stock_code = f"{stock_code}.SZ" # 深圳 + stock_code = f"{stock_code}.SZ" # 娣卞湷 - # 获取股票名称 + # 鑾峰彇鑲$エ鍚嶇О with engine.connect() as conn: result = conn.execute(text( "SELECT SECNAME FROM ea_stocklist WHERE SECCODE = :code" @@ -8220,50 +8090,50 @@ def get_stock_kline(stock_code): 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/batch-kline', methods=['POST']) def get_batch_kline_data(): - """批量获取多只股票的K线/分时数据 - 请求体:{ + """鎵归噺鑾峰彇澶氬彧鑲$エ鐨凨绾?鍒嗘椂鏁版嵁 + 璇锋眰浣擄細{ codes: string[], type: 'timeline'|'daily', event_time?: string, - days_before?: number, # 查询事件日期前多少天的数据,默认60,最大365 - end_date?: string # 分页加载时指定结束日期(用于加载更早的数据) + days_before?: number, # 鏌ヨ浜嬩欢鏃ユ湡鍓嶅灏戝ぉ鐨勬暟鎹紝榛樿60锛屾渶澶?65 + end_date?: string # 鍒嗛〉鍔犺浇鏃舵寚瀹氱粨鏉熸棩鏈燂紙鐢ㄤ簬鍔犺浇鏇存棭鐨勬暟鎹級 } - 返回:{ success: true, data: { [code]: { data: [], trade_date: '', ... } }, has_more: boolean } + 杩斿洖锛歿 success: true, data: { [code]: { data: [], trade_date: '', ... } }, has_more: boolean } """ try: data = request.json codes = data.get('codes', []) chart_type = data.get('type', 'timeline') event_time = data.get('event_time') - days_before = min(int(data.get('days_before', 60)), 365) # 默认60天,最多365天 - custom_end_date = data.get('end_date') # 用于分页加载更早数据 + days_before = min(int(data.get('days_before', 60)), 365) # 榛樿60澶╋紝鏈€澶?65澶? + custom_end_date = data.get('end_date') # 鐢ㄤ簬鍒嗛〉鍔犺浇鏇存棭鏁版嵁 if not codes: - return jsonify({'success': False, 'error': '请提供股票代码列表'}), 400 + return jsonify({'success': False, 'error': '璇锋彁渚涜偂绁ㄤ唬鐮佸垪琛?}), 400 if len(codes) > 50: - return jsonify({'success': False, 'error': '单次最多查询50只股票'}), 400 + return jsonify({'success': False, 'error': '鍗曟鏈€澶氭煡璇?0鍙偂绁?}), 400 - # 标准化股票代码(确保带后缀,用于 ClickHouse 查询) + # 鏍囧噯鍖栬偂绁ㄤ唬鐮侊紙纭繚甯﹀悗缂€锛岀敤浜?ClickHouse 鏌ヨ锛? def normalize_stock_code(code): - """将股票代码标准化为带后缀格式(如 300274.SZ)""" + """灏嗚偂绁ㄤ唬鐮佹爣鍑嗗寲涓哄甫鍚庣紑鏍煎紡锛堝 300274.SZ锛?"" if '.' in code: - return code # 已经带后缀 - # 根据代码规则添加后缀 + return code # 宸茬粡甯﹀悗缂€ + # 鏍规嵁浠g爜瑙勫垯娣诲姞鍚庣紑 if code.startswith('6'): - return f"{code}.SH" # 上海 + return f"{code}.SH" # 涓婃捣 elif code.startswith(('8', '9', '4')): - return f"{code}.BJ" # 北交所 + return f"{code}.BJ" # 鍖椾氦鎵€ else: - return f"{code}.SZ" # 深圳 + return f"{code}.SZ" # 娣卞湷 - # 保留原始代码用于返回结果,同时创建标准化代码用于 ClickHouse 查询 + # 淇濈暀鍘熷浠g爜鐢ㄤ簬杩斿洖缁撴灉锛屽悓鏃跺垱寤烘爣鍑嗗寲浠g爜鐢ㄤ簬 ClickHouse 鏌ヨ original_codes = codes normalized_codes = [normalize_stock_code(code) for code in codes] code_mapping = dict(zip(original_codes, normalized_codes)) @@ -8276,16 +8146,16 @@ def get_batch_kline_data(): client = get_clickhouse_client() - # 批量获取股票名称(使用 Redis 缓存) + # 鎵归噺鑾峰彇鑲$エ鍚嶇О锛堜娇鐢?Redis 缂撳瓨锛? base_codes = list(set([code.split('.')[0] for code in codes])) - stock_names = get_cached_stock_names(base_codes) + stock_names = get_stock_names(base_codes) - # 确定目标交易日和涨跌幅基准日(处理跨周末场景) - # - 周五15:00后到周一15:00前,分时图显示周一行情,涨跌幅基于周五收盘价 + # 纭畾鐩爣浜ゆ槗鏃ュ拰娑ㄨ穼骞呭熀鍑嗘棩锛堝鐞嗚法鍛ㄦ湯鍦烘櫙锛? + # - 鍛ㄤ簲15:00鍚庡埌鍛ㄤ竴15:00鍓嶏紝鍒嗘椂鍥炬樉绀哄懆涓€琛屾儏锛屾定璺屽箙鍩轰簬鍛ㄤ簲鏀剁洏浠? target_date, prev_trading_day = get_target_and_prev_trading_day(event_datetime) if not target_date: - # 返回空数据(使用原始代码作为 key) + # 杩斿洖绌烘暟鎹紙浣跨敤鍘熷浠g爜浣滀负 key锛? return jsonify({ 'success': True, 'data': {code: {'data': [], 'trade_date': event_datetime.date().strftime('%Y-%m-%d'), 'type': chart_type} for code in original_codes} @@ -8297,16 +8167,16 @@ def get_batch_kline_data(): results = {} if chart_type == 'timeline': - # 批量获取前收盘价(使用 Redis 缓存) - # 使用 prev_trading_day 作为基准日期(处理跨周末场景) + # 鎵归噺鑾峰彇鍓嶆敹鐩樹环锛堜娇鐢?Redis 缂撳瓨锛? + # 浣跨敤 prev_trading_day 浣滀负鍩哄噯鏃ユ湡锛堝鐞嗚法鍛ㄦ湯鍦烘櫙锛? prev_close_map = {} if prev_trading_day: prev_date_str = prev_trading_day.strftime('%Y%m%d') base_codes = list(set([code.split('.')[0] for code in codes])) - prev_close_map = get_cached_prev_close(base_codes, prev_date_str) - print(f"分时图基准日期: {prev_trading_day}, 获取到 {len(prev_close_map)} 条前收盘价(Redis缓存)") + prev_close_map = get_prev_close(base_codes, prev_date_str) + print(f"鍒嗘椂鍥惧熀鍑嗘棩鏈? {prev_trading_day}, 鑾峰彇鍒?{len(prev_close_map)} 鏉″墠鏀剁洏浠凤紙Redis缂撳瓨锛?) - # 批量查询分时数据(使用标准化代码查询 ClickHouse) + # 鎵归噺鏌ヨ鍒嗘椂鏁版嵁锛堜娇鐢ㄦ爣鍑嗗寲浠g爜鏌ヨ ClickHouse锛? batch_data = client.execute(""" SELECT code, timestamp, close, volume FROM stock_minute @@ -8314,14 +8184,14 @@ def get_batch_kline_data(): AND timestamp BETWEEN %(start)s AND %(end)s ORDER BY code, timestamp """, { - 'codes': normalized_codes, # 使用标准化代码 + 'codes': normalized_codes, # 浣跨敤鏍囧噯鍖栦唬鐮? 'start': start_time, 'end': end_time }) - # 按股票代码分组,同时计算均价和涨跌幅 + # 鎸夎偂绁ㄤ唬鐮佸垎缁勶紝鍚屾椂璁$畻鍧囦环鍜屾定璺屽箙 stock_data = {} - stock_accum = {} # 用于计算均价的累计值 + stock_accum = {} # 鐢ㄤ簬璁$畻鍧囦环鐨勭疮璁″€? for row in batch_data: norm_code = row[0] base_code = norm_code.split('.')[0] @@ -8332,13 +8202,13 @@ def get_batch_kline_data(): stock_data[norm_code] = [] stock_accum[norm_code] = {'total_amount': 0, 'total_volume': 0} - # 累计计算均价 + # 绱璁$畻鍧囦环 stock_accum[norm_code]['total_amount'] += price * volume stock_accum[norm_code]['total_volume'] += volume total_vol = stock_accum[norm_code]['total_volume'] avg_price = stock_accum[norm_code]['total_amount'] / total_vol if total_vol > 0 else price - # 计算涨跌幅 + # 璁$畻娑ㄨ穼骞? prev_close = prev_close_map.get(base_code) change_percent = ((price - prev_close) / prev_close * 100) if prev_close and prev_close > 0 else 0 @@ -8350,11 +8220,11 @@ def get_batch_kline_data(): 'change_percent': round(change_percent, 2) }) - # 组装结果(使用原始代码作为 key 返回) + # 缁勮缁撴灉锛堜娇鐢ㄥ師濮嬩唬鐮佷綔涓?key 杩斿洖锛? for orig_code in original_codes: norm_code = code_mapping[orig_code] base_code = orig_code.split('.')[0] - stock_name = stock_names.get(base_code, f'股票{base_code}') + stock_name = stock_names.get(base_code, f'鑲$エ{base_code}') data_list = stock_data.get(norm_code, []) prev_close = prev_close_map.get(base_code) @@ -8368,15 +8238,15 @@ def get_batch_kline_data(): } elif chart_type == 'daily': - # 批量查询日线数据(从MySQL ea_trade表) + # 鎵归噺鏌ヨ鏃ョ嚎鏁版嵁锛堜粠MySQL ea_trade琛級 with engine.connect() as conn: base_codes = list(set([code.split('.')[0] for code in codes])) if base_codes: placeholders = ','.join([f':code{i}' for i in range(len(base_codes))]) params = {f'code{i}': code for i, code in enumerate(base_codes)} - # 确定查询的日期范围 - # 如果指定了 custom_end_date,用于分页加载更早的数据 + # 纭畾鏌ヨ鐨勬棩鏈熻寖鍥? + # 濡傛灉鎸囧畾浜?custom_end_date锛岀敤浜庡垎椤靛姞杞芥洿鏃╃殑鏁版嵁 if custom_end_date: try: end_date_obj = datetime.strptime(custom_end_date, '%Y-%m-%d').date() @@ -8385,7 +8255,7 @@ def get_batch_kline_data(): else: end_date_obj = target_date - # TRADEDATE 是整数格式 YYYYMMDD,需要转换日期格式 + # TRADEDATE 鏄暣鏁版牸寮?YYYYMMDD锛岄渶瑕佽浆鎹㈡棩鏈熸牸寮? start_date = end_date_obj - timedelta(days=days_before) params['start_date'] = int(start_date.strftime('%Y%m%d')) params['end_date'] = int(end_date_obj.strftime('%Y%m%d')) @@ -8398,23 +8268,23 @@ def get_batch_kline_data(): ORDER BY SECCODE, TRADEDATE """), params).fetchall() - # 按股票代码分组 + # 鎸夎偂绁ㄤ唬鐮佸垎缁? stock_data = {} for row in daily_result: code_base = row[0] if code_base not in stock_data: stock_data[code_base] = [] - # 日期格式处理:TRADEDATE 可能是 datetime 或 int(YYYYMMDD) + # 鏃ユ湡鏍煎紡澶勭悊锛歍RADEDATE 鍙兘鏄?datetime 鎴?int(YYYYMMDD) trade_date_val = row[1] if hasattr(trade_date_val, 'strftime'): date_str = trade_date_val.strftime('%Y-%m-%d') elif isinstance(trade_date_val, int): - # 整数格式 YYYYMMDD -> YYYY-MM-DD + # 鏁存暟鏍煎紡 YYYYMMDD -> YYYY-MM-DD date_str = f"{str(trade_date_val)[:4]}-{str(trade_date_val)[4:6]}-{str(trade_date_val)[6:8]}" else: date_str = str(trade_date_val) stock_data[code_base].append({ - 'time': date_str, # 统一使用 time 字段,与前端期望一致 + 'time': date_str, # 缁熶竴浣跨敤 time 瀛楁锛屼笌鍓嶇鏈熸湜涓€鑷? 'open': float(row[2]) if row[2] else 0, 'high': float(row[3]) if row[3] else 0, 'low': float(row[4]) if row[4] else 0, @@ -8422,15 +8292,15 @@ def get_batch_kline_data(): 'volume': float(row[6]) if row[6] else 0 }) - # 组装结果(使用原始代码作为 key 返回) - # 同时计算最早日期,用于判断是否还有更多数据 + # 缁勮缁撴灉锛堜娇鐢ㄥ師濮嬩唬鐮佷綔涓?key 杩斿洖锛? + # 鍚屾椂璁$畻鏈€鏃╂棩鏈燂紝鐢ㄤ簬鍒ゆ柇鏄惁杩樻湁鏇村鏁版嵁 earliest_dates = {} for orig_code in original_codes: base_code = orig_code.split('.')[0] - stock_name = stock_names.get(base_code, f'股票{base_code}') + stock_name = stock_names.get(base_code, f'鑲$エ{base_code}') data_list = stock_data.get(base_code, []) - # 记录每只股票的最早日期 + # 璁板綍姣忓彧鑲$エ鐨勬渶鏃╂棩鏈? if data_list: earliest_dates[orig_code] = data_list[0]['time'] @@ -8443,13 +8313,13 @@ def get_batch_kline_data(): 'earliest_date': data_list[0]['time'] if data_list else None } - # 计算是否还有更多历史数据(基于事件日期往前推365天) + # 璁$畻鏄惁杩樻湁鏇村鍘嗗彶鏁版嵁锛堝熀浜庝簨浠舵棩鏈熷線鍓嶆帹365澶╋級 event_date = event_datetime.date() one_year_ago = event_date - timedelta(days=365) - # 如果当前查询的起始日期还没到一年前,则还有更多数据 + # 濡傛灉褰撳墠鏌ヨ鐨勮捣濮嬫棩鏈熻繕娌″埌涓€骞村墠锛屽垯杩樻湁鏇村鏁版嵁 has_more = start_date > one_year_ago if chart_type == 'daily' else False - print(f"批量K线查询完成: {len(codes)} 只股票, 类型: {chart_type}, 交易日: {target_date}, days_before: {days_before}, has_more: {has_more}") + print(f"鎵归噺K绾挎煡璇㈠畬鎴? {len(codes)} 鍙偂绁? 绫诲瀷: {chart_type}, 浜ゆ槗鏃? {target_date}, days_before: {days_before}, has_more: {has_more}") return jsonify({ 'success': True, @@ -8460,32 +8330,32 @@ def get_batch_kline_data(): }) except Exception as e: - print(f"批量K线查询错误: {e}") + print(f"鎵归噺K绾挎煡璇㈤敊璇? {e}") return jsonify({'success': False, 'error': str(e)}), 500 @app.route('/api/stock//latest-minute', methods=['GET']) def get_latest_minute_data(stock_code): - """获取最新交易日的分钟频数据""" + """鑾峰彇鏈€鏂颁氦鏄撴棩鐨勫垎閽熼鏁版嵁""" client = get_clickhouse_client() - # 确保股票代码包含后缀 + # 纭繚鑲$エ浠g爜鍖呭惈鍚庣紑 if '.' not in stock_code: if stock_code.startswith('6'): - stock_code = f"{stock_code}.SH" # 上海 + stock_code = f"{stock_code}.SH" # 涓婃捣 elif stock_code.startswith(('8', '9', '4')): - stock_code = f"{stock_code}.BJ" # 北交所 + stock_code = f"{stock_code}.BJ" # 鍖椾氦鎵€ else: - stock_code = f"{stock_code}.SZ" # 深圳 + stock_code = 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天内有数据的最新交易日 + # 鏌ユ壘鏈€杩?0澶╁唴鏈夋暟鎹殑鏈€鏂颁氦鏄撴棩 target_date = None current_date = datetime.now().date() @@ -8494,7 +8364,7 @@ def get_latest_minute_data(stock_code): 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 @@ -8521,7 +8391,7 @@ def get_latest_minute_data(stock_code): 'type': 'minute' }) - # 获取目标日期的完整交易时段数据 + # 鑾峰彇鐩爣鏃ユ湡鐨勫畬鏁翠氦鏄撴椂娈垫暟鎹? data = client.execute(""" SELECT timestamp, @@ -8563,22 +8433,22 @@ def get_latest_minute_data(stock_code): @app.route('/api/stock//forecast-report', methods=['GET']) def get_stock_forecast_report(stock_code): - """基于 stock_forecast_data 输出报表所需数据结构 + """鍩轰簬 stock_forecast_data 杈撳嚭鎶ヨ〃鎵€闇€鏁版嵁缁撴瀯 - 返回: - - income_profit_trend: 营业收入/归母净利润趋势 - - growth_bars: 增长率柱状图数据(基于营业收入同比) - - eps_trend: EPS 折线 - - pe_peg_axes: PE/PEG 双轴 - - detail_table: 详细数据表格(与附件结构一致) + 杩斿洖锛? + - 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() @@ -8589,19 +8459,19 @@ def get_stock_forecast_report(stock_code): years = ['2022A', '2023A', '2024A', '2025E', '2026E', '2027E'] - # 营业收入与净利润趋势 - income = indicators.get('营业总收入(百万元)', {}) - profit = indicators.get('归母净利润(百万元)', {}) + # 钀ヤ笟鏀跺叆涓庡噣鍒╂鼎瓒嬪娍 + 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('增长率(%)') + # 澧為暱鐜囨煴鐘讹紙鑻ヨ〃鍐呭凡鏈?澧為暱鐜?%)"锛岀洿鎺ヤ娇鐢紱鍚﹀垯鎸夎惀涓氭敹鍏ュ悓姣旇绠楋級 + growth = indicators.get('澧為暱鐜?%)') if growth is None: - # 计算同比: (curr - prev)/prev*100 + # 璁$畻鍚屾瘮锛?(curr - prev)/prev*100 growth_vals = [] prev = None for y in years: @@ -8616,17 +8486,17 @@ def get_stock_forecast_report(stock_code): growth_bars = { 'years': years, 'revenue_growth_pct': growth_vals, - 'net_profit_growth_pct': None # 如后续需要可扩展 + 'net_profit_growth_pct': None # 濡傚悗缁渶瑕佸彲鎵╁睍 } - # EPS 趋势 - eps = indicators.get('EPS(稀释)') or indicators.get('EPS(元/股)') or {} + # 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 / PEG 鍙岃酱 pe = indicators.get('PE') or {} peg = indicators.get('PEG') or {} pe_peg_axes = { @@ -8635,7 +8505,7 @@ def get_stock_forecast_report(stock_code): 'peg': [safe(peg.get(y)) for y in years] } - # 详细数据表格(列顺序固定) + # 璇︾粏鏁版嵁琛ㄦ牸锛堝垪椤哄簭鍥哄畾锛? def fmt(val): try: return None if val is None else round(float(val), 2) @@ -8644,27 +8514,27 @@ def get_stock_forecast_report(stock_code): 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(稀释)', + '鎸囨爣': 'EPS(绋€閲?', **{y: fmt(eps.get(y)) for y in years}, }, { - '指标': 'PE', + '鎸囨爣': 'PE', **{y: fmt(pe.get(y)) for y in years}, }, { - '指标': 'PEG', + '鎸囨爣': 'PEG', **{y: fmt(peg.get(y)) for y in years}, }, ] @@ -8689,7 +8559,7 @@ def get_stock_forecast_report(stock_code): @app.route('/api/stock//basic-info', methods=['GET']) def get_stock_basic_info(stock_code): - """获取股票基本信息(来自ea_baseinfo表)""" + """鑾峰彇鑲$エ鍩烘湰淇℃伅锛堟潵鑷猠a_baseinfo琛級""" try: with engine.connect() as conn: query = text(""" @@ -8741,10 +8611,10 @@ def get_stock_basic_info(stock_code): if not result: return jsonify({ 'success': False, - 'error': f'未找到股票代码 {stock_code} 的基本信息' + 'error': f'鏈壘鍒拌偂绁ㄤ唬鐮?{stock_code} 鐨勫熀鏈俊鎭? }), 404 - # 转换为字典 + # 杞崲涓哄瓧鍏? basic_info = {} result_dict = row_to_dict(result) for key, value in result_dict.items(): @@ -8767,16 +8637,16 @@ def get_stock_basic_info(stock_code): @app.route('/api/stock//quote-detail', methods=['GET']) def get_stock_quote_detail(stock_code): - """获取股票完整行情数据 - 供 StockQuoteCard 使用 + """鑾峰彇鑲$エ瀹屾暣琛屾儏鏁版嵁 - 渚?StockQuoteCard 浣跨敤 - 返回数据包括: - - 基础信息:名称、代码、行业分类 - - 价格信息:现价、涨跌幅、开盘、收盘、最高、最低 - - 关键指标:市盈率、市净率、流通市值、52周高低 - - 主力动态:主力净流入、机构持仓(如有) + 杩斿洖鏁版嵁鍖呮嫭锛? + - 鍩虹淇℃伅锛氬悕绉般€佷唬鐮併€佽涓氬垎绫? + - 浠锋牸淇℃伅锛氱幇浠枫€佹定璺屽箙銆佸紑鐩樸€佹敹鐩樸€佹渶楂樸€佹渶浣? + - 鍏抽敭鎸囨爣锛氬競鐩堢巼銆佸競鍑€鐜囥€佹祦閫氬競鍊笺€?2鍛ㄩ珮浣? + - 涓诲姏鍔ㄦ€侊細涓诲姏鍑€娴佸叆銆佹満鏋勬寔浠擄紙濡傛湁锛? """ try: - # 标准化股票代码(去除后缀) + # 鏍囧噯鍖栬偂绁ㄤ唬鐮侊紙鍘婚櫎鍚庣紑锛? base_code = stock_code.split('.')[0] if '.' in stock_code else stock_code result_data = { @@ -8787,7 +8657,7 @@ def get_stock_quote_detail(stock_code): 'sw_industry_l1': '', 'sw_industry_l2': '', - # 价格信息 + # 浠锋牸淇℃伅 'current_price': None, 'change_percent': None, 'today_open': None, @@ -8795,19 +8665,19 @@ def get_stock_quote_detail(stock_code): 'today_high': None, 'today_low': None, - # 关键指标 + # 鍏抽敭鎸囨爣 'pe': None, 'pb': None, 'eps': None, 'market_cap': None, 'circ_mv': None, - 'total_shares': None, # 发行总股本(亿股) - 'float_shares': None, # 流通股本(亿股) + 'total_shares': None, # 鍙戣鎬昏偂鏈紙浜胯偂锛? + 'float_shares': None, # 娴侀€氳偂鏈紙浜胯偂锛? 'turnover_rate': None, 'week52_high': None, 'week52_low': None, - # 主力动态(预留字段) + # 涓诲姏鍔ㄦ€侊紙棰勭暀瀛楁锛? 'main_net_inflow': None, 'institution_holding': None, 'buy_ratio': None, @@ -8817,7 +8687,7 @@ def get_stock_quote_detail(stock_code): } with engine.connect() as conn: - # 1. 获取最新交易数据(来自 ea_trade) + # 1. 鑾峰彇鏈€鏂颁氦鏄撴暟鎹紙鏉ヨ嚜 ea_trade锛? trade_query = text(""" SELECT t.SECCODE, @@ -8849,7 +8719,7 @@ def get_stock_quote_detail(stock_code): if trade_result: row = row_to_dict(trade_result) - # 调试日志:打印所有字段 + # 璋冭瘯鏃ュ織锛氭墦鍗版墍鏈夊瓧娈? app.logger.info(f"[quote-detail] stock={base_code}, row keys={list(row.keys())}") app.logger.info(f"[quote-detail] total_shares={row.get('total_shares')}, float_shares={row.get('float_shares')}, pe_ratio={row.get('pe_ratio')}") result_data['name'] = row.get('SECNAME') or '' @@ -8867,27 +8737,27 @@ def get_stock_quote_detail(stock_code): result_data['industry_l1'] = row.get('industry_l1') or '' result_data['industry'] = row.get('sw_industry_l2') or row.get('sw_industry_l1') or '' - # 计算股本和市值(兼容别名和原始字段名) + # 璁$畻鑲℃湰鍜屽競鍊硷紙鍏煎鍒悕鍜屽師濮嬪瓧娈靛悕锛? total_shares = float(row.get('total_shares') or row.get('F020N') or 0) float_shares = float(row.get('float_shares') or row.get('F021N') or 0) close_price = float(row.get('close_price') or row.get('F007N') or 0) app.logger.info(f"[quote-detail] calculated: total_shares={total_shares}, float_shares={float_shares}") - # 发行总股本(亿股) + # 鍙戣鎬昏偂鏈紙浜胯偂锛? if total_shares > 0: - total_shares_yi = total_shares / 100000000 # 转为亿股 + total_shares_yi = total_shares / 100000000 # 杞负浜胯偂 result_data['total_shares'] = round(total_shares_yi, 2) - # 流通股本(亿股) + # 娴侀€氳偂鏈紙浜胯偂锛? if float_shares > 0: - float_shares_yi = float_shares / 100000000 # 转为亿股 + float_shares_yi = float_shares / 100000000 # 杞负浜胯偂 result_data['float_shares'] = round(float_shares_yi, 2) - # 计算流通市值(亿元) + # 璁$畻娴侀€氬競鍊硷紙浜垮厓锛? if float_shares > 0 and close_price > 0: - circ_mv = (float_shares * close_price) / 100000000 # 转为亿 + circ_mv = (float_shares * close_price) / 100000000 # 杞负浜? result_data['circ_mv'] = round(circ_mv, 2) - result_data['market_cap'] = f"{round(circ_mv, 2)}亿" + result_data['market_cap'] = f"{round(circ_mv, 2)}浜? trade_date = row.get('TRADEDATE') if trade_date: @@ -8896,7 +8766,7 @@ def get_stock_quote_detail(stock_code): else: result_data['update_time'] = str(trade_date) - # 2. 获取52周高低价 + # 2. 鑾峰彇52鍛ㄩ珮浣庝环 week52_query = text(""" SELECT MAX(F005N) as week52_high, @@ -8925,7 +8795,7 @@ def get_stock_quote_detail(stock_code): @app.route('/api/stock//announcements', methods=['GET']) def get_stock_announcements(stock_code): - """获取股票公告列表""" + """鑾峰彇鑲$エ鍏憡鍒楄〃""" try: limit = request.args.get('limit', 50, type=int) @@ -8974,7 +8844,7 @@ def get_stock_announcements(stock_code): @app.route('/api/stock//disclosure-schedule', methods=['GET']) def get_stock_disclosure_schedule(stock_code): - """获取股票财报预披露时间表""" + """鑾峰彇鑲$エ璐㈡姤棰勬姭闇叉椂闂磋〃""" try: with engine.connect() as conn: query = text(""" @@ -9009,7 +8879,7 @@ def get_stock_disclosure_schedule(stock_code): 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): @@ -9019,17 +8889,17 @@ def get_stock_disclosure_schedule(stock_code): 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]}年一季报" + schedule['report_name'] = f"{period_date[:4]}骞翠竴瀛f姤" elif period_date.endswith('-06-30'): - schedule['report_name'] = f"{period_date[:4]}年中报" + schedule['report_name'] = f"{period_date[:4]}骞翠腑鎶? elif period_date.endswith('-09-30'): - schedule['report_name'] = f"{period_date[:4]}年三季报" + schedule['report_name'] = f"{period_date[:4]}骞翠笁瀛f姤" elif period_date.endswith('-12-31'): - schedule['report_name'] = f"{period_date[:4]}年年报" + schedule['report_name'] = f"{period_date[:4]}骞村勾鎶? else: schedule['report_name'] = period_date @@ -9048,7 +8918,7 @@ def get_stock_disclosure_schedule(stock_code): @app.route('/api/stock//actual-control', methods=['GET']) def get_stock_actual_control(stock_code): - """获取股票实际控制人信息""" + """鑾峰彇鑲$エ瀹為檯鎺у埗浜轰俊鎭?"" try: with engine.connect() as conn: query = text(""" @@ -9105,7 +8975,7 @@ def get_stock_actual_control(stock_code): @app.route('/api/stock//concentration', methods=['GET']) def get_stock_concentration(stock_code): - """获取股票股权集中度信息""" + """鑾峰彇鑲$エ鑲℃潈闆嗕腑搴︿俊鎭?"" try: with engine.connect() as conn: query = text(""" @@ -9154,9 +9024,9 @@ def get_stock_concentration(stock_code): @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: @@ -9223,7 +9093,7 @@ def get_stock_management(stock_code): @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) @@ -9282,7 +9152,7 @@ def get_stock_top_circulation_shareholders(stock_code): @app.route('/api/stock//top-shareholders', methods=['GET']) def get_stock_top_shareholders(stock_code): - """获取股票十大股东信息""" + """鑾峰彇鑲$エ鍗佸ぇ鑲′笢淇℃伅""" try: limit = request.args.get('limit', 10, type=int) @@ -9341,7 +9211,7 @@ def get_stock_top_shareholders(stock_code): @app.route('/api/stock//branches', methods=['GET']) def get_stock_branches(stock_code): - """获取股票分支机构信息""" + """鑾峰彇鑲$エ鍒嗘敮鏈烘瀯淇℃伅""" try: with engine.connect() as conn: query = text(""" @@ -9392,10 +9262,10 @@ def get_stock_branches(stock_code): @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) # 专利类型筛选 + patent_type = request.args.get('type', None) # 涓撳埄绫诲瀷绛涢€? with engine.connect() as conn: base_query = """ @@ -9458,11 +9328,11 @@ def get_stock_patents(stock_code): def get_daily_kline(stock_code, event_datetime, stock_name): - """处理日K线数据""" + """澶勭悊鏃绾挎暟鎹?"" stock_code = stock_code.split('.')[0] with engine.connect() as conn: - # 获取事件日期前后的数据(前365天/1年,后30天) + # 鑾峰彇浜嬩欢鏃ユ湡鍓嶅悗鐨勬暟鎹紙鍓?65澶?1骞达紝鍚?0澶╋級 kline_sql = """ WITH date_range AS (SELECT TRADEDATE \ FROM ea_trade \ @@ -9519,23 +9389,23 @@ def get_daily_kline(stock_code, event_datetime, stock_name): def get_minute_kline(stock_code, event_datetime, stock_name, skip_next_day=False): - """处理分钟K线数据 + """澶勭悊鍒嗛挓K绾挎暟鎹? Args: - stock_code: 股票代码 - event_datetime: 事件时间 - stock_name: 股票名称 - skip_next_day: 是否跳过"下一个交易日"逻辑(用于灵活屏盘后查看当天数据) + stock_code: 鑲$エ浠g爜 + event_datetime: 浜嬩欢鏃堕棿 + stock_name: 鑲$エ鍚嶇О + skip_next_day: 鏄惁璺宠繃"涓嬩竴涓氦鏄撴棩"閫昏緫锛堢敤浜庣伒娲诲睆鐩樺悗鏌ョ湅褰撳ぉ鏁版嵁锛? """ client = get_clickhouse_client() target_date = get_trading_day_near_date(event_datetime.date()) is_after_market = event_datetime.time() > dt_time(15, 0) - # 只有在指定了 event_time 参数时(如 Community 页面事件)才跳转到下一个交易日 - # 灵活屏等实时行情场景,盘后应显示当天数据 + # 鍙湁鍦ㄦ寚瀹氫簡 event_time 鍙傛暟鏃讹紙濡?Community 椤甸潰浜嬩欢锛夋墠璺宠浆鍒颁笅涓€涓氦鏄撴棩 + # 鐏垫椿灞忕瓑瀹炴椂琛屾儏鍦烘櫙锛岀洏鍚庡簲鏄剧ず褰撳ぉ鏁版嵁 if target_date and is_after_market and not skip_next_day: - # 如果是交易日且已收盘,查找下一个交易日 + # 濡傛灉鏄氦鏄撴棩涓斿凡鏀剁洏锛屾煡鎵句笅涓€涓氦鏄撴棩 next_trade_date = get_trading_day_near_date(target_date + timedelta(days=1)) if next_trade_date: target_date = next_trade_date @@ -9550,7 +9420,7 @@ def get_minute_kline(stock_code, event_datetime, stock_name, skip_next_day=False 'type': 'minute' }) - # 获取目标日期的完整交易时段数据 + # 鑾峰彇鐩爣鏃ユ湡鐨勫畬鏁翠氦鏄撴椂娈垫暟鎹? data = client.execute(""" SELECT timestamp, open, high, low, close, volume, amt @@ -9586,18 +9456,18 @@ def get_minute_kline(stock_code, event_datetime, stock_name, skip_next_day=False def get_timeline_data(stock_code, event_datetime, stock_name): - """处理分时均价线数据(timeline)。 - 规则: - - 若事件时间在交易日的15:00之后,则展示下一个交易日的分时数据; - - 若事件日非交易日,优先展示下一个交易日;如无,则回退到最近一个交易日; - - 数据区间固定为 09:30-15:00。 + """澶勭悊鍒嗘椂鍧囦环绾挎暟鎹紙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: @@ -9613,14 +9483,14 @@ def get_timeline_data(stock_code, event_datetime, stock_name): 'type': 'timeline' }) - # 获取昨收盘价 - 优先从 MySQL ea_trade 表获取(更可靠) + # 鑾峰彇鏄ㄦ敹鐩樹环 - 浼樺厛浠?MySQL ea_trade 琛ㄨ幏鍙栵紙鏇村彲闈狅級 prev_close = None base_code = stock_code.split('.')[0] target_date_str = target_date.strftime('%Y%m%d') try: with engine.connect() as conn: - # F007N 是昨收价字段 + # F007N 鏄槰鏀朵环瀛楁 result = conn.execute(text(""" SELECT F007N FROM ea_trade WHERE SECCODE = :code AND TRADEDATE = :trade_date AND F007N > 0 @@ -9628,9 +9498,9 @@ def get_timeline_data(stock_code, event_datetime, stock_name): if result and result[0]: prev_close = float(result[0]) except Exception as e: - logger.warning(f"从 ea_trade 获取昨收价失败: {e}") + logger.warning(f"浠?ea_trade 鑾峰彇鏄ㄦ敹浠峰け璐? {e}") - # 如果 MySQL 没有数据,回退到 ClickHouse + # 濡傛灉 MySQL 娌℃湁鏁版嵁锛屽洖閫€鍒?ClickHouse if prev_close is None: prev_close_query = """ SELECT close FROM stock_minute @@ -9671,7 +9541,7 @@ def get_timeline_data(stock_code, event_datetime, stock_name): 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({ @@ -9693,30 +9563,30 @@ def get_timeline_data(stock_code, event_datetime, stock_name): }) -# ==================== 指数行情API(与股票逻辑一致,数据表为 index_minute) ==================== +# ==================== 鎸囨暟琛屾儏API锛堜笌鑲$エ閫昏緫涓€鑷达紝鏁版嵁琛ㄤ负 index_minute锛?==================== @app.route('/api/index//realtime') def get_index_realtime(index_code): """ - 获取指数实时行情(用于交易时间内的行情更新) - 从 index_minute 表获取最新的分钟数据 - 返回: 最新价、涨跌幅、涨跌额、开盘价、最高价、最低价、昨收价 + 鑾峰彇鎸囨暟瀹炴椂琛屾儏锛堢敤浜庝氦鏄撴椂闂村唴鐨勮鎯呮洿鏂帮級 + 浠?index_minute 琛ㄨ幏鍙栨渶鏂扮殑鍒嗛挓鏁版嵁 + 杩斿洖: 鏈€鏂颁环銆佹定璺屽箙銆佹定璺岄銆佸紑鐩樹环銆佹渶楂樹环銆佹渶浣庝环銆佹槰鏀朵环 """ - # 确保指数代码包含后缀(ClickHouse 中存储的是带后缀的代码) - # 上证指数: 000xxx.SH, 深证指数: 399xxx.SZ + # 纭繚鎸囨暟浠g爜鍖呭惈鍚庣紑锛圕lickHouse 涓瓨鍌ㄧ殑鏄甫鍚庣紑鐨勪唬鐮侊級 + # 涓婅瘉鎸囨暟: 000xxx.SH, 娣辫瘉鎸囨暟: 399xxx.SZ if '.' not in index_code: if index_code.startswith('399'): index_code = f"{index_code}.SZ" else: - # 000开头的上证指数,以及其他指数默认上海 + # 000寮€澶寸殑涓婅瘉鎸囨暟锛屼互鍙婂叾浠栨寚鏁伴粯璁や笂娴? index_code = f"{index_code}.SH" client = get_clickhouse_client() today = date.today() - # 判断今天是否是交易日 + # 鍒ゆ柇浠婂ぉ鏄惁鏄氦鏄撴棩 if today not in trading_days_set: - # 非交易日,获取最近一个交易日的收盘数据 + # 闈炰氦鏄撴棩锛岃幏鍙栨渶杩戜竴涓氦鏄撴棩鐨勬敹鐩樻暟鎹? target_date = get_trading_day_near_date(today) if not target_date: return jsonify({ @@ -9727,15 +9597,15 @@ def get_index_realtime(index_code): is_trading = False else: target_date = today - # 判断是否在交易时间内 + # 鍒ゆ柇鏄惁鍦ㄤ氦鏄撴椂闂村唴 now = datetime.now() current_minutes = now.hour * 60 + now.minute # 9:30-11:30 = 570-690, 13:00-15:00 = 780-900 is_trading = (570 <= current_minutes <= 690) or (780 <= current_minutes <= 900) try: - # 获取当天/最近交易日的第一条数据(开盘价)和最后一条数据(最新价) - # 同时获取最高价和最低价 + # 鑾峰彇褰撳ぉ/鏈€杩戜氦鏄撴棩鐨勭涓€鏉℃暟鎹紙寮€鐩樹环锛夊拰鏈€鍚庝竴鏉℃暟鎹紙鏈€鏂颁环锛? + # 鍚屾椂鑾峰彇鏈€楂樹环鍜屾渶浣庝环 data = client.execute( """ SELECT @@ -9768,12 +9638,12 @@ def get_index_realtime(index_code): latest_close = float(row[3]) if row[3] else None latest_time = row[4] - # 获取昨收价(从 MySQL ea_exchangetrade 表) + # 鑾峰彇鏄ㄦ敹浠凤紙浠?MySQL ea_exchangetrade 琛級 code_no_suffix = index_code.split('.')[0] prev_close = None with engine.connect() as conn: - # 获取前一个交易日的收盘价 + # 鑾峰彇鍓嶄竴涓氦鏄撴棩鐨勬敹鐩樹环 prev_result = conn.execute(text( """ SELECT F006N @@ -9790,7 +9660,7 @@ def get_index_realtime(index_code): if prev_result and prev_result[0]: prev_close = float(prev_result[0]) - # 计算涨跌额和涨跌幅 + # 璁$畻娑ㄨ穼棰濆拰娑ㄨ穼骞? change_amount = None change_pct = None if latest_close is not None and prev_close is not None and prev_close > 0: @@ -9815,7 +9685,7 @@ def get_index_realtime(index_code): }) except Exception as e: - app.logger.error(f"获取指数实时行情失败: {index_code}, 错误: {str(e)}") + app.logger.error(f"鑾峰彇鎸囨暟瀹炴椂琛屾儏澶辫触: {index_code}, 閿欒: {str(e)}") return jsonify({ 'success': False, 'error': str(e), @@ -9833,12 +9703,12 @@ def get_index_kline(index_code): except ValueError: return jsonify({'error': 'Invalid event_time format'}), 400 - # 确保指数代码包含后缀(ClickHouse 中数据带后缀) - # 399xxx -> 深交所, 其他(000xxx等)-> 上交所 + # 纭繚鎸囨暟浠g爜鍖呭惈鍚庣紑锛圕lickHouse 涓暟鎹甫鍚庣紑锛? + # 399xxx -> 娣变氦鎵€, 鍏朵粬锛?00xxx绛夛級-> 涓婁氦鎵€ if '.' not in index_code: index_code = f"{index_code}.SZ" if index_code.startswith('39') else f"{index_code}.SH" - # 指数名称(暂无索引表,先返回代码本身) + # 鎸囨暟鍚嶇О锛堟殏鏃犵储寮曡〃锛屽厛杩斿洖浠g爜鏈韩锛? index_name = index_code.split('.')[0] if chart_type == 'minute': @@ -9958,19 +9828,19 @@ def get_index_timeline_data(index_code, event_datetime, index_name): 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 + """浠?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({ @@ -9982,7 +9852,7 @@ def get_index_daily_kline(index_code, event_datetime, index_name): 'type': 'daily' }) - # 取最近一段时间的日线(倒序再反转为升序) + # 鍙栨渶杩戜竴娈垫椂闂寸殑鏃ョ嚎锛堝€掑簭鍐嶅弽杞负鍗囧簭锛? with engine.connect() as conn: rows = conn.execute(text( """ @@ -9997,7 +9867,7 @@ def get_index_daily_kline(index_code, event_datetime, index_name): 'end_dt': datetime.combine(target_date, dt_time(23, 59, 59)) }).fetchall() - # 反转为时间升序 + # 鍙嶈浆涓烘椂闂村崌搴? rows = list(reversed(rows)) daily = [] @@ -10009,13 +9879,13 @@ def get_index_daily_kline(index_code, event_datetime, index_name): last_v = r[4] prev_close_v = r[5] - # 正确的前收盘价逻辑:使用前一个交易日的F006N(收盘价) + # 姝g‘鐨勫墠鏀剁洏浠烽€昏緫锛氫娇鐢ㄥ墠涓€涓氦鏄撴棩鐨凢006N锛堟敹鐩樹环锛? 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字段作为备选 + # 绗竴鏉¤褰曪紝灏濊瘯浣跨敤F007N瀛楁浣滀负澶囬€? if prev_close_v is not None and prev_close_v > 0: calculated_prev_close = float(prev_close_v) @@ -10038,23 +9908,23 @@ def get_index_daily_kline(index_code, event_datetime, index_name): }) -# ==================== 日历API ==================== +# ==================== 鏃ュ巻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 @@ -10068,7 +9938,7 @@ def get_event_counts(): 'end_date': end_date }) - # 格式化结果 + # 鏍煎紡鍖栫粨鏋? events = [] for day in result: events.append({ @@ -10091,7 +9961,7 @@ def get_event_counts(): @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') @@ -10109,7 +9979,7 @@ def get_calendar_events(): 'error': 'Invalid date format' }), 400 - # 修复SQL语法:去掉函数名后的空格,去掉参数前的空格 + # 淇SQL璇硶锛氬幓鎺夊嚱鏁板悕鍚庣殑绌烘牸锛屽幓鎺夊弬鏁板墠鐨勭┖鏍? query = """ SELECT * FROM future_events @@ -10133,7 +10003,7 @@ def get_calendar_events(): user_following_ids = {f.future_event_id for f in follows} for row in result: - # 使用统一的处理函数,支持新字段回退和 best_matches 解析 + # 浣跨敤缁熶竴鐨勫鐞嗗嚱鏁帮紝鏀寔鏂板瓧娈靛洖閫€鍜?best_matches 瑙f瀽 event_data = process_future_event_row(row, user_following_ids) events.append(event_data) @@ -10144,7 +10014,7 @@ def get_calendar_events(): @app.route('/api/v1/calendar/events/', methods=['GET']) def get_calendar_event_detail(event_id): - """获取日历事件详情""" + """鑾峰彇鏃ュ巻浜嬩欢璇︽儏""" try: sql = """ SELECT * @@ -10160,7 +10030,7 @@ def get_calendar_event_detail(event_id): 'error': 'Event not found' }), 404 - # 检查当前用户是否关注了该未来事件 + # 妫€鏌ュ綋鍓嶇敤鎴锋槸鍚﹀叧娉ㄤ簡璇ユ湭鏉ヤ簨浠? user_following_ids = set() if 'user_id' in session: is_following = FutureEventFollow.query.filter_by( @@ -10170,7 +10040,7 @@ def get_calendar_event_detail(event_id): if is_following: user_following_ids.add(event_id) - # 使用统一的处理函数,支持新字段回退和 best_matches 解析 + # 浣跨敤缁熶竴鐨勫鐞嗗嚱鏁帮紝鏀寔鏂板瓧娈靛洖閫€鍜?best_matches 瑙f瀽 event_data = process_future_event_row(result, user_following_ids) return jsonify({ @@ -10187,12 +10057,12 @@ def get_calendar_event_detail(event_id): @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 + return jsonify({'success': False, 'error': '鏈櫥褰?}), 401 try: - # 检查未来事件是否存在 + # 妫€鏌ユ湭鏉ヤ簨浠舵槸鍚﹀瓨鍦? sql = """ SELECT data_id \ FROM future_events \ @@ -10201,18 +10071,18 @@ def toggle_future_event_follow(event_id): result = db.session.execute(text(sql), {'event_id': event_id}).first() if not result: - return jsonify({'success': False, 'error': '未来事件不存在'}), 404 + 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({ @@ -10220,7 +10090,7 @@ def toggle_future_event_follow(event_id): 'data': {'is_following': False} }) else: - # 关注 + # 鍏虫敞 follow = FutureEventFollow( user_id=user_id, future_event_id=event_id @@ -10237,7 +10107,7 @@ def toggle_future_event_follow(event_id): def get_event_class(count): - """根据事件数量返回CSS类名""" + """鏍规嵁浜嬩欢鏁伴噺杩斿洖CSS绫诲悕""" if count >= 10: return 'event-high' elif count >= 5: @@ -10248,7 +10118,7 @@ def get_event_class(count): def parse_json_field(field_value): - """解析JSON字段""" + """瑙f瀽JSON瀛楁""" if not field_value: return [] try: @@ -10265,13 +10135,13 @@ def parse_json_field(field_value): def get_future_event_field(row, new_field, old_field): """ - 获取 future_events 表字段值,支持新旧字段回退 - 如果新字段存在且不为空,使用新字段;否则使用旧字段 + 鑾峰彇 future_events 琛ㄥ瓧娈靛€硷紝鏀寔鏂版棫瀛楁鍥為€€ + 濡傛灉鏂板瓧娈靛瓨鍦ㄤ笖涓嶄负绌猴紝浣跨敤鏂板瓧娈碉紱鍚﹀垯浣跨敤鏃у瓧娈? """ new_value = getattr(row, new_field, None) if hasattr(row, new_field) else None old_value = getattr(row, old_field, None) if hasattr(row, old_field) else None - # 如果新字段有值(不为空字符串),使用新字段 + # 濡傛灉鏂板瓧娈垫湁鍊硷紙涓嶄负绌哄瓧绗︿覆锛夛紝浣跨敤鏂板瓧娈? if new_value is not None and str(new_value).strip(): return new_value return old_value @@ -10279,18 +10149,18 @@ def get_future_event_field(row, new_field, old_field): def parse_best_matches(best_matches_value): """ - 解析新的 best_matches 数据结构(含研报引用信息) + 瑙f瀽鏂扮殑 best_matches 鏁版嵁缁撴瀯锛堝惈鐮旀姤寮曠敤淇℃伅锛? - 新结构示例: + 鏂扮粨鏋勭ず渚? [ { "stock_code": "300451.SZ", - "company_name": "创业慧康", - "original_description": "核心标的,医疗信息化...", - "best_report_title": "报告标题", - "best_report_author": "作者", - "best_report_sentences": "相关内容", - "best_report_match_score": "好", + "company_name": "鍒涗笟鎱у悍", + "original_description": "鏍稿績鏍囩殑锛屽尰鐤椾俊鎭寲...", + "best_report_title": "鎶ュ憡鏍囬", + "best_report_author": "浣滆€?, + "best_report_sentences": "鐩稿叧鍐呭", + "best_report_match_score": "濂?, "best_report_match_ratio": 0.9285714285714286, "best_report_declare_date": "2023-04-25T00:00:00", "total_reports": 9, @@ -10299,13 +10169,13 @@ def parse_best_matches(best_matches_value): ... ] - 返回统一格式的股票列表,兼容旧格式 + 杩斿洖缁熶竴鏍煎紡鐨勮偂绁ㄥ垪琛紝鍏煎鏃ф牸寮? """ if not best_matches_value: return [] try: - # 解析 JSON + # 瑙f瀽 JSON if isinstance(best_matches_value, str): data = json.loads(best_matches_value) else: @@ -10317,13 +10187,13 @@ def parse_best_matches(best_matches_value): result = [] for item in data: if isinstance(item, dict): - # 新结构:包含研报信息的字典 + # 鏂扮粨鏋勶細鍖呭惈鐮旀姤淇℃伅鐨勫瓧鍏? stock_info = { 'code': item.get('stock_code', ''), 'name': item.get('company_name', ''), 'description': item.get('original_description', ''), 'score': item.get('best_report_match_ratio', 0), - # 研报引用信息 + # 鐮旀姤寮曠敤淇℃伅 'report': { 'title': item.get('best_report_title', ''), 'author': item.get('best_report_author', ''), @@ -10337,7 +10207,7 @@ def parse_best_matches(best_matches_value): } result.append(stock_info) elif isinstance(item, (list, tuple)) and len(item) >= 2: - # 旧结构:[code, name, description, score] + # 鏃х粨鏋勶細[code, name, description, score] result.append({ 'code': item[0], 'name': item[1], @@ -10354,22 +10224,22 @@ def parse_best_matches(best_matches_value): def process_future_event_row(row, user_following_ids=None): """ - 统一处理 future_events 表的行数据 - 支持新字段回退和 best_matches 解析 + 缁熶竴澶勭悊 future_events 琛ㄧ殑琛屾暟鎹? + 鏀寔鏂板瓧娈靛洖閫€鍜?best_matches 瑙f瀽 """ if user_following_ids is None: user_following_ids = set() - # 获取字段值,支持新旧回退 + # 鑾峰彇瀛楁鍊硷紝鏀寔鏂版棫鍥為€€ # second_modified_text -> former - # second_modified_text.1 -> forecast (MySQL 中用反引号) + # second_modified_text.1 -> forecast (MySQL 涓敤鍙嶅紩鍙? former_value = get_future_event_field(row, 'second_modified_text', 'former') - # 处理 second_modified_text.1 字段(特殊字段名) + # 澶勭悊 second_modified_text.1 瀛楁锛堢壒娈婂瓧娈靛悕锛? forecast_new = None if hasattr(row, 'second_modified_text.1'): forecast_new = getattr(row, 'second_modified_text.1', None) - # 尝试其他可能的属性名 + # 灏濊瘯鍏朵粬鍙兘鐨勫睘鎬у悕 for attr_name in ['second_modified_text.1', 'second_modified_text_1']: if hasattr(row, attr_name): val = getattr(row, attr_name, None) @@ -10385,7 +10255,7 @@ def process_future_event_row(row, user_following_ids=None): else: related_stocks = parse_json_field(getattr(row, 'related_stocks', None)) - # 构建事件数据 + # 鏋勫缓浜嬩欢鏁版嵁 event_data = { 'id': row.data_id, 'title': row.title, @@ -10404,16 +10274,16 @@ def process_future_event_row(row, user_following_ids=None): return event_data -# ==================== 行业API ==================== +# ==================== 琛屼笟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 = '申银万国行业分类' + WHERE f002v = '鐢抽摱涓囧浗琛屼笟鍒嗙被' AND f003v IS NOT NULL AND f004v IS NOT NULL ORDER BY f003v @@ -10421,7 +10291,7 @@ def get_classifications(): result = db.session.execute(text(sql)).all() - # 构建树形结构 + # 鏋勫缓鏍戝舰缁撴瀯 tree_dict = {} for row in result: @@ -10430,13 +10300,13 @@ def get_classifications(): level2 = row.level2 level3 = row.level3 - # 跳过空数据 + # 璺宠繃绌烘暟鎹? if not level1: continue - # 第一层 + # 绗竴灞? if level1 not in tree_dict: - # 获取第一层的code(取前3位或前缀) + # 鑾峰彇绗竴灞傜殑code锛堝彇鍓?浣嶆垨鍓嶇紑锛? level1_code = code[:3] if len(code) >= 3 else code tree_dict[level1] = { 'value': level1_code, @@ -10444,10 +10314,10 @@ def get_classifications(): 'children_dict': {} } - # 第二层 + # 绗簩灞? if level2: if level2 not in tree_dict[level1]['children_dict']: - # 获取第二层的code(取前6位) + # 鑾峰彇绗簩灞傜殑code锛堝彇鍓?浣嶏級 level2_code = code[:6] if len(code) >= 6 else code tree_dict[level1]['children_dict'][level2] = { 'value': level2_code, @@ -10455,7 +10325,7 @@ def get_classifications(): '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] = { @@ -10463,7 +10333,7 @@ def get_classifications(): 'label': level3 } - # 转换为最终格式 + # 杞崲涓烘渶缁堟牸寮? result_list = [] for level1_name, level1_data in tree_dict.items(): level1_node = { @@ -10471,7 +10341,7 @@ def get_classifications(): 'label': level1_data['label'] } - # 处理第二层 + # 澶勭悊绗簩灞? if level1_data['children_dict']: level1_children = [] for level2_name, level2_data in level1_data['children_dict'].items(): @@ -10480,7 +10350,7 @@ def get_classifications(): 'label': level2_data['label'] } - # 处理第三层 + # 澶勭悊绗笁灞? if level2_data['children_dict']: level2_children = [] for level3_name, level3_data in level2_data['children_dict'].items(): @@ -10512,7 +10382,7 @@ def get_classifications(): @app.route('/api/stocklist', methods=['GET']) def get_stock_list(): - """获取股票列表""" + """鑾峰彇鑲$エ鍒楄〃""" try: sql = """ SELECT DISTINCT SECCODE as code, SECNAME as name @@ -10533,94 +10403,46 @@ def get_stock_list(): }), 500 -# ==================== 事件列表缓存 ==================== -EVENTS_LIST_CACHE_PREFIX = "vf:events:list:" -EVENTS_LIST_CACHE_EXPIRE = 30 # 30秒缓存(事件列表变化较快) - -def get_events_cache_key(args_dict): - """生成事件列表缓存 key""" - # 只对简单查询缓存(不缓存搜索、复杂过滤) - simple_params = ['page', 'per_page', 'sort', 'importance', 'order'] - cache_parts = [] - for key in sorted(simple_params): - val = args_dict.get(key, '') - if val: - cache_parts.append(f"{key}={val}") - return EVENTS_LIST_CACHE_PREFIX + "&".join(cache_parts) - -def is_simple_events_query(args_dict): - """判断是否为简单查询(可缓存)""" - # 第1页不缓存(保证新事件推送后立即可见) - page = int(args_dict.get('page', 1)) - if page <= 1: - return False - - # 如果有搜索、日期范围、行业等复杂过滤,不缓存 - complex_params = ['q', 'search_query', 'start_date', 'end_date', 'date_range', - 'industry_code', 'tag', 'tags', 'keywords', 'creator_id', - 'min_avg_return', 'max_avg_return', 'min_max_return', 'max_max_return', - 'min_week_return', 'max_week_return', 'min_hot_score', 'max_hot_score'] - for param in complex_params: - if args_dict.get(param): - return False - return True - - @app.route('/api/events', methods=['GET'], strict_slashes=False) def api_get_events(): """ - 获取事件列表API - 支持筛选、排序、分页,兼容前端调用 + 鑾峰彇浜嬩欢鍒楄〃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))) - # ==================== 缓存检查 ==================== - args_dict = request.args.to_dict() - use_cache = is_simple_events_query(args_dict) - cache_key = None - - if use_cache: - cache_key = get_events_cache_key(args_dict) - try: - cached = redis_client.get(cache_key) - if cached: - import json - return jsonify(json.loads(cached)) - except Exception as e: - print(f"[Events] 缓存读取失败: {e}") - - # 基础筛选参数 + # 鍩虹绛涢€夊弬鏁? 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" + # 琛屼笟绛涢€夊弬鏁帮紙鍙敮鎸佺敵閾朵竾鍥借涓氬垎绫伙級 + industry_code = request.args.get('industry_code') # 鐢充竾琛屼笟浠g爜锛屽 "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) @@ -10628,24 +10450,24 @@ def api_get_events(): 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' - # ==================== 构建查询 ==================== + # ==================== 鏋勫缓鏌ヨ ==================== from sqlalchemy.orm import joinedload - # 使用 joinedload 预加载 creator,解决 N+1 查询问题 + # 浣跨敤 joinedload 棰勫姞杞?creator锛岃В鍐?N+1 鏌ヨ闂 query = Event.query.options(joinedload(Event.creator)) - # 只返回有关联股票的事件(没有关联股票的事件不计入列表) + # 鍙繑鍥炴湁鍏宠仈鑲$エ鐨勪簨浠讹紙娌℃湁鍏宠仈鑲$エ鐨勪簨浠朵笉璁″叆鍒楄〃锛? from sqlalchemy import exists query = query.filter( exists().where(RelatedStock.event_id == Event.id) @@ -10655,67 +10477,67 @@ def api_get_events(): query = query.filter_by(status=event_status) if event_type != 'all': query = query.filter_by(event_type=event_type) - # 支持多个重要性级别筛选,用逗号分隔(如 importance=S,A) + # 鏀寔澶氫釜閲嶈鎬х骇鍒瓫閫夛紝鐢ㄩ€楀彿鍒嗛殧锛堝 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) - # 新增:行业代码过滤(申银万国行业分类)- 支持前缀匹配 - # 申万行业分类层级:一级 Sxx, 二级 Sxxxx, 三级 Sxxxxxx - # 搜索 S22 会匹配所有 S22xxxx 的事件(如 S2203, S220309 等) - # related_industries 格式: varchar,如 "S640701" + # 鏂板锛氳涓氫唬鐮佽繃婊わ紙鐢抽摱涓囧浗琛屼笟鍒嗙被锛? 鏀寔鍓嶇紑鍖归厤 + # 鐢充竾琛屼笟鍒嗙被灞傜骇锛氫竴绾?Sxx, 浜岀骇 Sxxxx, 涓夌骇 Sxxxxxx + # 鎼滅储 S22 浼氬尮閰嶆墍鏈?S22xxxx 鐨勪簨浠讹紙濡?S2203, S220309 绛夛級 + # related_industries 鏍煎紡: varchar锛屽 "S640701" if industry_code: - # 判断是否需要前缀匹配:一级(3字符)或二级(5字符)行业代码 + # 鍒ゆ柇鏄惁闇€瑕佸墠缂€鍖归厤锛氫竴绾?3瀛楃)鎴栦簩绾?5瀛楃)琛屼笟浠g爜 def is_prefix_code(code): - """判断是否为需要前缀匹配的行业代码(一级或二级)""" + """鍒ゆ柇鏄惁涓洪渶瑕佸墠缂€鍖归厤鐨勮涓氫唬鐮侊紙涓€绾ф垨浜岀骇锛?"" code = code.strip() - # 申万行业代码格式:S + 数字 - # 一级: S + 2位数字 (如 S22) = 3字符 - # 二级: S + 4位数字 (如 S2203) = 5字符 - # 三级: S + 6位数字 (如 S220309) = 7字符 + # 鐢充竾琛屼笟浠g爜鏍煎紡锛歋 + 鏁板瓧 + # 涓€绾? S + 2浣嶆暟瀛?(濡?S22) = 3瀛楃 + # 浜岀骇: S + 4浣嶆暟瀛?(濡?S2203) = 5瀛楃 + # 涓夌骇: S + 6浣嶆暟瀛?(濡?S220309) = 7瀛楃 return len(code) < 7 and code.startswith('S') - # 如果包含逗号,说明是多个行业代码 + # 濡傛灉鍖呭惈閫楀彿锛岃鏄庢槸澶氫釜琛屼笟浠g爜 if ',' in industry_code: codes = [code.strip() for code in industry_code.split(',') if code.strip()] conditions = [] for code in codes: if is_prefix_code(code): - # 前缀匹配:使用 LIKE + # 鍓嶇紑鍖归厤锛氫娇鐢?LIKE conditions.append(Event.related_industries.like(f"{code}%")) else: - # 精确匹配(三级行业代码) + # 绮剧‘鍖归厤锛堜笁绾ц涓氫唬鐮侊級 conditions.append(Event.related_industries == code) query = query.filter(db.or_(*conditions)) else: - # 单个行业代码 + # 鍗曚釜琛屼笟浠g爜 if is_prefix_code(industry_code): - # 前缀匹配:使用 LIKE + # 鍓嶇紑鍖归厤锛氫娇鐢?LIKE query = query.filter(Event.related_industries.like(f"{industry_code}%")) else: - # 精确匹配(三级行业代码) + # 绮剧‘鍖归厤锛堜笁绾ц涓氫唬鐮侊級 query = query.filter(Event.related_industries == industry_code) - # 新增:关键词/全文搜索过滤(MySQL JSON) + # 鏂板锛氬叧閿瘝/鍏ㄦ枃鎼滅储杩囨护锛圡ySQL JSON锛? if search_query: like_pattern = f"%{search_query}%" - # 子查询:查找关联股票中匹配的事件ID - # stock_code 格式:600111.SH / 000001.SZ / 830001.BJ,支持不带后缀搜索 + # 瀛愭煡璇細鏌ユ壘鍏宠仈鑲$エ涓尮閰嶇殑浜嬩欢ID + # stock_code 鏍煎紡锛?00111.SH / 000001.SZ / 830001.BJ锛屾敮鎸佷笉甯﹀悗缂€鎼滅储 stock_subquery = db.session.query(RelatedStock.event_id).filter( db.or_( - RelatedStock.stock_code.ilike(like_pattern), # 支持股票代码搜索 + RelatedStock.stock_code.ilike(like_pattern), # 鏀寔鑲$エ浠g爜鎼滅储 RelatedStock.stock_name.ilike(like_pattern), RelatedStock.relation_desc.ilike(like_pattern) ) ).distinct() - # 主查询:搜索事件标题、描述、关键词或关联股票 + # 涓绘煡璇細鎼滅储浜嬩欢鏍囬銆佹弿杩般€佸叧閿瘝鎴栧叧鑱旇偂绁? query = query.filter( db.or_( Event.title.ilike(like_pattern), @@ -10729,9 +10551,9 @@ def api_get_events(): cutoff_date = datetime.now() - timedelta(days=recent_days) query = query.filter(Event.created_at >= cutoff_date) else: - if date_range and ' 至 ' in date_range: + if date_range and ' 鑷?' in date_range: try: - start_date_str, end_date_str = date_range.split(' 至 ') + start_date_str, end_date_str = date_range.split(' 鑷?') start_date = start_date_str.strip() end_date = end_date_str.strip() except ValueError: @@ -10759,7 +10581,7 @@ def api_get_events(): 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': @@ -10767,7 +10589,7 @@ def api_get_events(): elif sort_by == 'new': query = query.order_by(order_func(Event.created_at)) elif sort_by == 'returns' or sort_by.startswith('returns_'): - # 支持两种格式: + # 鏀寔涓ょ鏍煎紡锛? # 1. sort=returns + return_type=avg/max/week # 2. sort=returns_avg / sort=returns_max / sort=returns_week effective_return_type = return_type @@ -10781,7 +10603,7 @@ def api_get_events(): elif effective_return_type == 'week': query = query.order_by(order_func(Event.related_week_chg)) else: - # 默认按平均收益排序 + # 榛樿鎸夊钩鍧囨敹鐩婃帓搴? query = query.order_by(order_func(Event.related_avg_chg)) elif sort_by == 'importance': importance_order = case( @@ -10797,7 +10619,7 @@ def api_get_events(): 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: @@ -10855,7 +10677,7 @@ def api_get_events(): applied_filters['search_query'] = search_query applied_filters['search_type'] = search_type - response_data = { + return jsonify({ 'success': True, 'data': { 'events': events_data, @@ -10872,19 +10694,9 @@ def api_get_events(): 'total_count': paginated.total } } - } - - # ==================== 写入缓存 ==================== - if use_cache and cache_key: - try: - import json - redis_client.setex(cache_key, EVENTS_LIST_CACHE_EXPIRE, json.dumps(response_data)) - except Exception as e: - print(f"[Events] 缓存写入失败: {e}") - - return jsonify(response_data) + }) except Exception as e: - app.logger.error(f"获取事件列表出错: {str(e)}", exc_info=True) + app.logger.error(f"鑾峰彇浜嬩欢鍒楄〃鍑洪敊: {str(e)}", exc_info=True) return jsonify({ 'success': False, 'error': str(e), @@ -10894,7 +10706,7 @@ def api_get_events(): @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) @@ -10935,7 +10747,7 @@ def get_hot_events(): @app.route('/api/events/keywords/popular', methods=['GET']) def get_popular_keywords(): - """获取热门关键词""" + """鑾峰彇鐑棬鍏抽敭璇?"" try: limit = request.args.get('limit', 20, type=int) sql = ''' @@ -10972,16 +10784,16 @@ def get_popular_keywords(): @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': '暂无桑基图数据'}) + return jsonify({'success': False, 'message': '鏆傛棤妗戝熀鍥炬暟鎹?}) nodes_map = {} links = [] @@ -10990,7 +10802,7 @@ def get_event_sankey_data(event_id): 'industry': '#00d2d3', 'company': '#54a0ff', 'product': '#ffd93d' } - # --- 1. 识别并处理重名节点 (与上一版相同) --- + # --- 1. 璇嗗埆骞跺鐞嗛噸鍚嶈妭鐐?(涓庝笂涓€鐗堢浉鍚? --- all_node_keys = set() name_counts = {} for flow in flows: @@ -11022,44 +10834,44 @@ def get_event_sankey_data(event_id): 'impact_description': flow.impact_description, 'evidence_strength': flow.evidence_strength }) - # --- 2. 循环检测与处理 --- - # 构建邻接表 + # --- 2. 寰幆妫€娴嬩笌澶勭悊 --- + # 鏋勫缓閭绘帴琛? adj = defaultdict(list) for link in links: adj[link['source_key']].append(link['target_key']) - # 深度优先搜索(DFS)来检测循环 - path = set() # 记录当前递归路径上的节点 - visited = set() # 记录所有访问过的节点 - back_edges = set() # 记录导致循环的"回流边" + # 娣卞害浼樺厛鎼滅储锛圖FS锛夋潵妫€娴嬪惊鐜? + 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) + # 鍙戠幇浜嗗惊鐜紝璁板綍杩欐潯鍥炴祦杈?(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}") + 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: # 移除非严格意义上的双向边 + (link['target_key'], link['source_key']) not in back_edges: # 绉婚櫎闈炰弗鏍兼剰涔変笂鐨勫弻鍚戣竟 valid_links_no_cycle.append(link) - # --- 3. 构建最终的 JSON 响应 (与上一版相似) --- + # --- 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'])) @@ -11073,14 +10885,14 @@ def get_event_sankey_data(event_id): 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 需要的字段 + # 绉婚櫎涓存椂鐨?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), @@ -11094,35 +10906,35 @@ def get_event_sankey_data(event_id): }) -# 优化后的传导链分析 API +# 浼樺寲鍚庣殑浼犲閾惧垎鏋?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': '暂无传导链分析数据'}) + 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': '所有节点都是孤立的,暂无传导关系'}) + return jsonify({'success': False, 'message': '鎵€鏈夎妭鐐归兘鏄绔嬬殑锛屾殏鏃犱紶瀵煎叧绯?}) - # 节点分类,用于力导向图的图例 + # 鑺傜偣鍒嗙被锛岀敤浜庡姏瀵煎悜鍥剧殑鍥句緥 categories = { - 'event': "事件", 'industry': "行业", 'company': "公司", - 'policy': "政策", 'technology': "技术", 'market': "市场", 'other': "其他" + 'event': "浜嬩欢", 'industry': "琛屼笟", 'company': "鍏徃", + 'policy': "鏀跨瓥", 'technology': "鎶€鏈?, 'market': "甯傚満", 'other': "鍏朵粬" } - # 计算每个节点的连接数 + # 璁$畻姣忎釜鑺傜偣鐨勮繛鎺ユ暟 node_connection_count = {} for node in connected_nodes: count = sum(1 for edge in edges @@ -11136,7 +10948,7 @@ def get_event_chain_analysis(event_id): nodes_data.append({ 'id': str(node.id), 'name': node.node_name, - 'value': node.importance_score, # 用于控制节点大小的基础值 + 'value': node.importance_score, # 鐢ㄤ簬鎺у埗鑺傜偣澶у皬鐨勫熀纭€鍊? 'category': categories.get(node.node_type), 'extra': { 'node_type': node.node_type, @@ -11144,18 +10956,18 @@ def get_event_chain_analysis(event_id): 'importance_score': node.importance_score, 'stock_code': node.stock_code, 'is_main_event': node.is_main_event, - 'connection_count': connection_count, # 添加连接数信息 + '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, # 用于控制边的宽度 + 'value': edge.strength, # 鐢ㄤ簬鎺у埗杈圭殑瀹藉害 'extra': { 'transmission_type': edge.transmission_type, 'transmission_mechanism': edge.transmission_mechanism, @@ -11166,7 +10978,7 @@ def get_event_chain_analysis(event_id): } }) - # 重新计算统计信息(基于连接的节点和边) + # 閲嶆柊璁$畻缁熻淇℃伅锛堝熀浜庤繛鎺ョ殑鑺傜偣鍜岃竟锛? stats = { 'total_nodes': len(connected_nodes), 'total_edges': len(edges_data), @@ -11194,19 +11006,19 @@ def get_event_chain_analysis(event_id): @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': '节点不存在'}) + 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': '该节点为孤立节点,无连接关系'}) + return jsonify({'success': False, 'message': '璇ヨ妭鐐逛负瀛ょ珛鑺傜偣锛屾棤杩炴帴鍏崇郴'}) - # 找出影响当前节点的父节点 + # 鎵惧嚭褰卞搷褰撳墠鑺傜偣鐨勭埗鑺傜偣 parents_info = [] incoming_edges = EventTransmissionEdge.query.filter_by(to_node_id=node_id).all() for edge in incoming_edges: @@ -11219,12 +11031,12 @@ def get_chain_node_detail(event_id, node_id): 'direction': edge.direction, 'strength': edge.strength, 'transmission_type': edge.transmission_type, - 'transmission_mechanism': edge.transmission_mechanism, # 修复字段名 + '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: @@ -11237,7 +11049,7 @@ def get_chain_node_detail(event_id, node_id): 'direction': edge.direction, 'strength': edge.strength, 'transmission_type': edge.transmission_type, - 'transmission_mechanism': edge.transmission_mechanism, # 修复字段名 + 'transmission_mechanism': edge.transmission_mechanism, # 淇瀛楁鍚? 'is_circular': edge.is_circular, 'impact': edge.impact }) @@ -11267,13 +11079,13 @@ def get_chain_node_detail(event_id, node_id): @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': @@ -11281,7 +11093,7 @@ def get_event_posts(event_id): 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 @@ -11305,7 +11117,7 @@ def get_event_posts(event_id): 'username': post.user.username, 'avatar_url': post.user.avatar_url } if post.user else None, - 'liked': False # 后续可以根据当前用户判断 + 'liked': False # 鍚庣画鍙互鏍规嵁褰撳墠鐢ㄦ埛鍒ゆ柇 } posts_data.append(post_dict) @@ -11321,7 +11133,7 @@ def get_event_posts(event_id): }) except Exception as e: - print(f"获取帖子失败: {e}") + print(f"鑾峰彇甯栧瓙澶辫触: {e}") return jsonify({ 'success': False, 'error': str(e) @@ -11331,7 +11143,7 @@ def get_event_posts(event_id): @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() @@ -11341,10 +11153,10 @@ def create_event_post(event_id): if not content: return jsonify({ 'success': False, - 'message': '帖子内容不能为空' + 'message': '甯栧瓙鍐呭涓嶈兘涓虹┖' }), 400 - # 创建新帖子 + # 鍒涘缓鏂板笘瀛? post = Post( event_id=event_id, user_id=current_user.id, @@ -11355,12 +11167,12 @@ def create_event_post(event_id): 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() @@ -11377,17 +11189,17 @@ def create_event_post(event_id): 'created_at': post.created_at.isoformat(), 'user': { 'id': current_user.id, - 'nickname': current_user.nickname, # 添加昵称,与导航区保持一致 + 'nickname': current_user.nickname, # 娣诲姞鏄电О锛屼笌瀵艰埅鍖轰繚鎸佷竴鑷? 'username': current_user.username, 'avatar_url': current_user.avatar_url } }, - 'message': '帖子发布成功' + 'message': '甯栧瓙鍙戝竷鎴愬姛' }) except Exception as e: db.session.rollback() - print(f"创建帖子失败: {e}") + print(f"鍒涘缓甯栧瓙澶辫触: {e}") return jsonify({ 'success': False, 'message': str(e) @@ -11396,11 +11208,11 @@ def create_event_post(event_id): @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': @@ -11423,10 +11235,10 @@ def get_post_comments(post_id): 'username': comment.user.username, 'avatar_url': comment.user.avatar_url } if comment.user else None, - 'replies': [] # 加载回复 + 'replies': [] # 鍔犺浇鍥炲 } - # 加载回复 + # 鍔犺浇鍥炲 replies = Comment.query.filter_by(parent_id=comment.id, status='active').order_by(Comment.created_at).all() for reply in replies: reply_dict = { @@ -11453,7 +11265,7 @@ def get_post_comments(post_id): }) except Exception as e: - print(f"获取评论失败: {e}") + print(f"鑾峰彇璇勮澶辫触: {e}") return jsonify({ 'success': False, 'error': str(e) @@ -11463,7 +11275,7 @@ def get_post_comments(post_id): @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() @@ -11472,10 +11284,10 @@ def create_post_comment(post_id): if not content: return jsonify({ 'success': False, - 'message': '评论内容不能为空' + 'message': '璇勮鍐呭涓嶈兘涓虹┖' }), 400 - # 创建新评论 + # 鍒涘缓鏂拌瘎璁? comment = Comment( post_id=post_id, user_id=current_user.id, @@ -11485,12 +11297,12 @@ def create_post_comment(post_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() @@ -11510,30 +11322,30 @@ def create_post_comment(post_id): 'avatar_url': current_user.avatar_url } }, - 'message': '评论发布成功' + 'message': '璇勮鍙戝竷鎴愬姛' }) except Exception as e: db.session.rollback() - print(f"创建评论失败: {e}") + 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() @@ -11542,21 +11354,21 @@ def add_event_comment(event_id): if not content: return jsonify({ 'success': False, - 'message': '评论内容不能为空' + 'message': '璇勮鍐呭涓嶈兘涓虹┖' }), 400 - # 如果有 parent_id,说明是回复,需要找到对应的帖子 + # 濡傛灉鏈?parent_id锛岃鏄庢槸鍥炲锛岄渶瑕佹壘鍒板搴旂殑甯栧瓙 if parent_id: - # 这是一个回复,需要将其转换为对应帖子的评论 - # 首先需要找到 parent_id 对应的帖子 - # 这里假设旧的 parent_id 是之前的 EventComment id - # 需要在数据迁移时处理这个映射关系 + # 杩欐槸涓€涓洖澶嶏紝闇€瑕佸皢鍏惰浆鎹负瀵瑰簲甯栧瓙鐨勮瘎璁? + # 棣栧厛闇€瑕佹壘鍒?parent_id 瀵瑰簲鐨勫笘瀛? + # 杩欓噷鍋囪鏃х殑 parent_id 鏄箣鍓嶇殑 EventComment id + # 闇€瑕佸湪鏁版嵁杩佺Щ鏃跺鐞嗚繖涓槧灏勫叧绯? return jsonify({ 'success': False, - 'message': '回复功能正在升级中,请稍后再试' + 'message': '鍥炲鍔熻兘姝e湪鍗囩骇涓紝璇风◢鍚庡啀璇? }), 503 - # 如果没有 parent_id,说明是顶级评论,创建为新帖子 + # 濡傛灉娌℃湁 parent_id锛岃鏄庢槸椤剁骇璇勮锛屽垱寤轰负鏂板笘瀛? post = Post( event_id=event_id, user_id=current_user.id, @@ -11566,17 +11378,17 @@ def add_event_comment(event_id): 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() - # 返回兼容旧接口的数据格式 + # 杩斿洖鍏煎鏃ф帴鍙g殑鏁版嵁鏍煎紡 return jsonify({ 'success': True, 'data': { @@ -11596,80 +11408,80 @@ def add_event_comment(event_id): }, 'replies': [] }, - 'message': '评论发布成功' + 'message': '璇勮鍙戝竷鎴愬姛' }) except Exception as e: db.session.rollback() - print(f"添加事件评论失败: {e}") + print(f"娣诲姞浜嬩欢璇勮澶辫触: {e}") return jsonify({ 'success': False, 'message': str(e) }), 500 -# ==================== WebSocket 事件处理器(实时事件推送) ==================== +# ==================== WebSocket 浜嬩欢澶勭悊鍣紙瀹炴椂浜嬩欢鎺ㄩ€侊級 ==================== @socketio.on('connect') def handle_connect(): - """客户端连接事件""" - print(f'\n[WebSocket DEBUG] ========== 客户端连接 ==========') + """瀹㈡埛绔繛鎺ヤ簨浠?"" + 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}') + print(f'[WebSocket] 瀹㈡埛绔凡杩炴帴: {request.sid}') emit('connection_response', { 'status': 'connected', 'sid': request.sid, - 'message': '已连接到事件推送服务' + 'message': '宸茶繛鎺ュ埌浜嬩欢鎺ㄩ€佹湇鍔? }) - print(f'[WebSocket DEBUG] ✓ 已发送 connection_response') - print(f'[WebSocket DEBUG] ========== 连接完成 ==========\n') + 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': {...} # 可选的其他筛选条件 + 'filters': {...} # 鍙€夌殑鍏朵粬绛涢€夋潯浠? } """ try: - print(f'\n[WebSocket DEBUG] ========== 收到订阅请求 ==========') + print(f'\n[WebSocket DEBUG] ========== 鏀跺埌璁㈤槄璇锋眰 ==========') print(f'[WebSocket DEBUG] Socket ID: {request.sid}') - print(f'[WebSocket DEBUG] 订阅数据: {data}') + 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}') + print(f'[WebSocket DEBUG] 浜嬩欢绫诲瀷: {event_type}') + print(f'[WebSocket DEBUG] 閲嶈鎬? {importance}') - # 加入对应的房间 + # 鍔犲叆瀵瑰簲鐨勬埧闂? room_name = f"events_{event_type}" - print(f'[WebSocket DEBUG] 准备加入房间: {room_name}') + print(f'[WebSocket DEBUG] 鍑嗗鍔犲叆鎴块棿: {room_name}') join_room(room_name) - print(f'[WebSocket DEBUG] ✓ 已加入房间: {room_name}') + print(f'[WebSocket DEBUG] 鉁?宸插姞鍏ユ埧闂? {room_name}') - print(f'[WebSocket] 客户端 {request.sid} 订阅了房间: {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} 类型的事件推送' + 'message': f'宸茶闃?{event_type} 绫诲瀷鐨勪簨浠舵帹閫? } - print(f'[WebSocket DEBUG] 准备发送 subscription_confirmed: {response_data}') + print(f'[WebSocket DEBUG] 鍑嗗鍙戦€?subscription_confirmed: {response_data}') emit('subscription_confirmed', response_data) - print(f'[WebSocket DEBUG] ✓ 已发送 subscription_confirmed') - print(f'[WebSocket DEBUG] ========== 订阅完成 ==========\n') + print(f'[WebSocket DEBUG] 鉁?宸插彂閫?subscription_confirmed') + print(f'[WebSocket DEBUG] ========== 璁㈤槄瀹屾垚 ==========\n') except Exception as e: - print(f'[WebSocket ERROR] 订阅失败: {e}') + print(f'[WebSocket ERROR] 璁㈤槄澶辫触: {e}') import traceback traceback.print_exc() emit('subscription_error', { @@ -11680,30 +11492,30 @@ def handle_subscribe(data): @socketio.on('unsubscribe_events') def handle_unsubscribe(data): - """取消订阅事件推送""" + """鍙栨秷璁㈤槄浜嬩欢鎺ㄩ€?"" try: - print(f'\n[WebSocket DEBUG] ========== 收到取消订阅请求 ==========') + print(f'\n[WebSocket DEBUG] ========== 鏀跺埌鍙栨秷璁㈤槄璇锋眰 ==========') print(f'[WebSocket DEBUG] Socket ID: {request.sid}') - print(f'[WebSocket DEBUG] 数据: {data}') + print(f'[WebSocket DEBUG] 鏁版嵁: {data}') event_type = data.get('event_type', 'all') room_name = f"events_{event_type}" - print(f'[WebSocket DEBUG] 准备离开房间: {room_name}') + print(f'[WebSocket DEBUG] 鍑嗗绂诲紑鎴块棿: {room_name}') leave_room(room_name) - print(f'[WebSocket DEBUG] ✓ 已离开房间: {room_name}') + print(f'[WebSocket DEBUG] 鉁?宸茬寮€鎴块棿: {room_name}') - print(f'[WebSocket] 客户端 {request.sid} 取消订阅房间: {room_name}') + print(f'[WebSocket] 瀹㈡埛绔?{request.sid} 鍙栨秷璁㈤槄鎴块棿: {room_name}') emit('unsubscription_confirmed', { 'success': True, 'room': room_name, - 'message': f'已取消订阅 {event_type} 类型的事件推送' + 'message': f'宸插彇娑堣闃?{event_type} 绫诲瀷鐨勪簨浠舵帹閫? }) - print(f'[WebSocket DEBUG] ========== 取消订阅完成 ==========\n') + print(f'[WebSocket DEBUG] ========== 鍙栨秷璁㈤槄瀹屾垚 ==========\n') except Exception as e: - print(f'[WebSocket ERROR] 取消订阅失败: {e}') + print(f'[WebSocket ERROR] 鍙栨秷璁㈤槄澶辫触: {e}') import traceback traceback.print_exc() emit('unsubscription_error', { @@ -11714,29 +11526,29 @@ def handle_unsubscribe(data): @socketio.on('disconnect') def handle_disconnect(): - """客户端断开连接事件""" - print(f'\n[WebSocket DEBUG] ========== 客户端断开 ==========') + """瀹㈡埛绔柇寮€杩炴帴浜嬩欢""" + print(f'\n[WebSocket DEBUG] ========== 瀹㈡埛绔柇寮€ ==========') print(f'[WebSocket DEBUG] Socket ID: {request.sid}') - print(f'[WebSocket] 客户端已断开: {request.sid}') - print(f'[WebSocket DEBUG] ========== 断开完成 ==========\n') + print(f'[WebSocket] 瀹㈡埛绔凡鏂紑: {request.sid}') + print(f'[WebSocket DEBUG] ========== 鏂紑瀹屾垚 ==========\n') -# ==================== WebSocket 辅助函数 ==================== +# ==================== WebSocket 杈呭姪鍑芥暟 ==================== def broadcast_new_event(event): """ - 广播新事件到所有订阅的客户端 - 在创建新事件时调用此函数 + 骞挎挱鏂颁簨浠跺埌鎵€鏈夎闃呯殑瀹㈡埛绔? + 鍦ㄥ垱寤烘柊浜嬩欢鏃惰皟鐢ㄦ鍑芥暟 Args: - event: Event 模型实例 + 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}') + 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, @@ -11753,118 +11565,118 @@ def broadcast_new_event(event): 'keywords': event.keywords_list if hasattr(event, 'keywords_list') else event.keywords, } - print(f'[WebSocket DEBUG] 准备发送的数据: {event_data}') + print(f'[WebSocket DEBUG] 鍑嗗鍙戦€佺殑鏁版嵁: {event_data}') - # 发送到所有订阅者(all 房间) - print(f'[WebSocket DEBUG] 正在发送到房间: events_all') + # 鍙戦€佸埌鎵€鏈夎闃呰€咃紙all 鎴块棿锛? + print(f'[WebSocket DEBUG] 姝e湪鍙戦€佸埌鎴块棿: events_all') socketio.emit('new_event', event_data, room='events_all', namespace='/') - print(f'[WebSocket DEBUG] ✓ 已发送到 events_all') + print(f'[WebSocket DEBUG] 鉁?宸插彂閫佸埌 events_all') - # 发送到特定类型订阅者 + # 鍙戦€佸埌鐗瑰畾绫诲瀷璁㈤槄鑰? if event.event_type: room_name = f"events_{event.event_type}" - print(f'[WebSocket DEBUG] 正在发送到房间: {room_name}') + print(f'[WebSocket DEBUG] 姝e湪鍙戦€佸埌鎴块棿: {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}') + print(f'[WebSocket DEBUG] 鉁?宸插彂閫佸埌 {room_name}') + print(f'[WebSocket] 宸叉帹閫佹柊浜嬩欢鍒版埧闂? events_all, {room_name}') else: - print(f'[WebSocket] 已推送新事件到房间: events_all') + print(f'[WebSocket] 宸叉帹閫佹柊浜嬩欢鍒版埧闂? events_all') - print(f'[WebSocket DEBUG] ========== 广播完成 ==========\n') + print(f'[WebSocket DEBUG] ========== 骞挎挱瀹屾垚 ==========\n') except Exception as e: - print(f'[WebSocket ERROR] 推送新事件失败: {e}') + print(f'[WebSocket ERROR] 鎺ㄩ€佹柊浜嬩欢澶辫触: {e}') import traceback traceback.print_exc() -# ==================== WebSocket 轮询机制(检测新事件) ==================== +# ==================== WebSocket 杞鏈哄埗锛堟娴嬫柊浜嬩欢锛?==================== -# Redis Key 用于多 Worker 协调 +# Redis Key 鐢ㄤ簬澶?Worker 鍗忚皟 REDIS_KEY_LAST_MAX_EVENT_ID = 'vf:event_polling:last_max_id' REDIS_KEY_POLLING_LOCK = 'vf:event_polling:lock' -REDIS_KEY_PENDING_EVENTS = 'vf:event_polling:pending_events' # 待推送事件集合(没有 related_stocks 的事件) +REDIS_KEY_PENDING_EVENTS = 'vf:event_polling:pending_events' # 寰呮帹閫佷簨浠堕泦鍚堬紙娌℃湁 related_stocks 鐨勪簨浠讹級 -# 本地缓存(减少 Redis 查询) +# 鏈湴缂撳瓨锛堝噺灏?Redis 鏌ヨ锛? _local_last_max_event_id = 0 _polling_initialized = False def _add_pending_event(event_id): - """将事件添加到待推送列表""" + """灏嗕簨浠舵坊鍔犲埌寰呮帹閫佸垪琛?"" try: redis_client.sadd(REDIS_KEY_PENDING_EVENTS, str(event_id)) except Exception as e: - print(f'[轮询 WARN] 添加待推送事件失败: {e}') + print(f'[杞 WARN] 娣诲姞寰呮帹閫佷簨浠跺け璐? {e}') def _remove_pending_event(event_id): - """从待推送列表移除事件""" + """浠庡緟鎺ㄩ€佸垪琛ㄧЩ闄や簨浠?"" try: redis_client.srem(REDIS_KEY_PENDING_EVENTS, str(event_id)) except Exception as e: - print(f'[轮询 WARN] 移除待推送事件失败: {e}') + print(f'[杞 WARN] 绉婚櫎寰呮帹閫佷簨浠跺け璐? {e}') def _get_pending_events(): - """获取所有待推送事件ID""" + """鑾峰彇鎵€鏈夊緟鎺ㄩ€佷簨浠禝D""" try: pending = redis_client.smembers(REDIS_KEY_PENDING_EVENTS) return [int(eid) for eid in pending] if pending else [] except Exception as e: - print(f'[轮询 WARN] 获取待推送事件失败: {e}') + print(f'[杞 WARN] 鑾峰彇寰呮帹閫佷簨浠跺け璐? {e}') return [] def _get_last_max_event_id(): - """从 Redis 获取最大事件 ID""" + """浠?Redis 鑾峰彇鏈€澶т簨浠?ID""" try: val = redis_client.get(REDIS_KEY_LAST_MAX_EVENT_ID) return int(val) if val else 0 except Exception as e: - print(f'[轮询 WARN] 读取 Redis 失败: {e}') + print(f'[杞 WARN] 璇诲彇 Redis 澶辫触: {e}') return _local_last_max_event_id def _set_last_max_event_id(new_id): - """设置最大事件 ID 到 Redis""" + """璁剧疆鏈€澶т簨浠?ID 鍒?Redis""" global _local_last_max_event_id try: redis_client.set(REDIS_KEY_LAST_MAX_EVENT_ID, str(new_id)) _local_last_max_event_id = new_id except Exception as e: - print(f'[轮询 WARN] 写入 Redis 失败: {e}') + print(f'[杞 WARN] 鍐欏叆 Redis 澶辫触: {e}') _local_last_max_event_id = new_id def poll_new_events(): """ - 定期轮询数据库,检查是否有新事件 - 每 30 秒执行一次 + 瀹氭湡杞鏁版嵁搴擄紝妫€鏌ユ槸鍚︽湁鏂颁簨浠? + 姣?30 绉掓墽琛屼竴娆? - 多 Worker 协调机制: - 1. 使用 Redis 分布式锁,确保同一时刻只有一个 Worker 执行轮询 - 2. 使用 Redis 存储 last_max_event_id,所有 Worker 共享状态 - 3. 通过 Redis 消息队列广播到所有 Worker 的客户端 + 澶?Worker 鍗忚皟鏈哄埗锛? + 1. 浣跨敤 Redis 鍒嗗竷寮忛攣锛岀‘淇濆悓涓€鏃跺埢鍙湁涓€涓?Worker 鎵ц杞 + 2. 浣跨敤 Redis 瀛樺偍 last_max_event_id锛屾墍鏈?Worker 鍏变韩鐘舵€? + 3. 閫氳繃 Redis 娑堟伅闃熷垪骞挎挱鍒版墍鏈?Worker 鐨勫鎴风 - 待推送事件机制: - - 当事件首次被检测到但没有 related_stocks 时,加入待推送列表 - - 每次轮询时检查待推送列表中的事件是否已有 related_stocks - - 有则推送并从列表移除,超过24小时的事件自动清理 + 寰呮帹閫佷簨浠舵満鍒讹細 + - 褰撲簨浠堕娆¤妫€娴嬪埌浣嗘病鏈?related_stocks 鏃讹紝鍔犲叆寰呮帹閫佸垪琛? + - 姣忔杞鏃舵鏌ュ緟鎺ㄩ€佸垪琛ㄤ腑鐨勪簨浠舵槸鍚﹀凡鏈?related_stocks + - 鏈夊垯鎺ㄩ€佸苟浠庡垪琛ㄧЩ闄わ紝瓒呰繃24灏忔椂鐨勪簨浠惰嚜鍔ㄦ竻鐞? """ import os try: - # 尝试获取分布式锁(30秒超时,防止死锁) + # 灏濊瘯鑾峰彇鍒嗗竷寮忛攣锛?0绉掕秴鏃讹紝闃叉姝婚攣锛? lock_acquired = redis_client.set( REDIS_KEY_POLLING_LOCK, os.getpid(), - nx=True, # 只在不存在时设置 - ex=30 # 30秒后自动过期 + nx=True, # 鍙湪涓嶅瓨鍦ㄦ椂璁剧疆 + ex=30 # 30绉掑悗鑷姩杩囨湡 ) if not lock_acquired: - # 其他 Worker 正在轮询,跳过本次 + # 鍏朵粬 Worker 姝e湪杞锛岃烦杩囨湰娆? return with app.app_context(): @@ -11873,27 +11685,27 @@ def poll_new_events(): current_time = datetime.now() last_max_event_id = _get_last_max_event_id() - print(f'\n[轮询] ========== 开始轮询 (PID: {os.getpid()}) ==========') - print(f'[轮询] 当前时间: {current_time.strftime("%Y-%m-%d %H:%M:%S")}') - print(f'[轮询] 当前最大事件ID: {last_max_event_id}') + print(f'\n[杞] ========== 寮€濮嬭疆璇?(PID: {os.getpid()}) ==========') + print(f'[杞] 褰撳墠鏃堕棿: {current_time.strftime("%Y-%m-%d %H:%M:%S")}') + print(f'[杞] 褰撳墠鏈€澶т簨浠禝D: {last_max_event_id}') - # 查询近24小时内的所有活跃事件(按事件发生时间 created_at) + # 鏌ヨ杩?4灏忔椂鍐呯殑鎵€鏈夋椿璺冧簨浠讹紙鎸変簨浠跺彂鐢熸椂闂?created_at锛? time_24h_ago = current_time - timedelta(hours=24) - # 查询所有近24小时内的活跃事件 + # 鏌ヨ鎵€鏈夎繎24灏忔椂鍐呯殑娲昏穬浜嬩欢 events_in_24h = Event.query.filter( Event.created_at >= time_24h_ago, Event.status == 'active' ).order_by(Event.id.asc()).all() - print(f'[轮询] 数据库查询: 找到 {len(events_in_24h)} 个近24小时内的事件') + print(f'[杞] 鏁版嵁搴撴煡璇? 鎵惧埌 {len(events_in_24h)} 涓繎24灏忔椂鍐呯殑浜嬩欢') - # 创建事件ID到事件对象的映射 + # 鍒涘缓浜嬩欢ID鍒颁簨浠跺璞$殑鏄犲皠 events_map = {event.id: event for event in events_in_24h} - # === 步骤1: 检查待推送列表中的事件 === + # === 姝ラ1: 妫€鏌ュ緟鎺ㄩ€佸垪琛ㄤ腑鐨勪簨浠?=== pending_event_ids = _get_pending_events() - print(f'[轮询] 待推送列表: {len(pending_event_ids)} 个事件') + print(f'[杞] 寰呮帹閫佸垪琛? {len(pending_event_ids)} 涓簨浠?) pushed_from_pending = 0 for pending_id in pending_event_ids: @@ -11902,74 +11714,74 @@ def poll_new_events(): related_stocks_count = event.related_stocks.count() if related_stocks_count > 0: - # 事件现在有 related_stocks 了,推送它 + # 浜嬩欢鐜板湪鏈?related_stocks 浜嗭紝鎺ㄩ€佸畠 broadcast_new_event(event) _remove_pending_event(pending_id) pushed_from_pending += 1 - print(f'[轮询] ✓ 待推送事件 ID={pending_id} 现在有 {related_stocks_count} 个关联股票,已推送') + print(f'[杞] 鉁?寰呮帹閫佷簨浠?ID={pending_id} 鐜板湪鏈?{related_stocks_count} 涓叧鑱旇偂绁紝宸叉帹閫?) else: - print(f'[轮询] - 待推送事件 ID={pending_id} 仍无关联股票,继续等待') + print(f'[杞] - 寰呮帹閫佷簨浠?ID={pending_id} 浠嶆棤鍏宠仈鑲$エ锛岀户缁瓑寰?) else: - # 事件已超过24小时或已删除,从待推送列表移除 + # 浜嬩欢宸茶秴杩?4灏忔椂鎴栧凡鍒犻櫎锛屼粠寰呮帹閫佸垪琛ㄧЩ闄? _remove_pending_event(pending_id) - print(f'[轮询] × 待推送事件 ID={pending_id} 已过期或不存在,已移除') + print(f'[杞] 脳 寰呮帹閫佷簨浠?ID={pending_id} 宸茶繃鏈熸垨涓嶅瓨鍦紝宸茬Щ闄?) if pushed_from_pending > 0: - print(f'[轮询] 从待推送列表推送了 {pushed_from_pending} 个事件') + print(f'[杞] 浠庡緟鎺ㄩ€佸垪琛ㄦ帹閫佷簡 {pushed_from_pending} 涓簨浠?) - # === 步骤2: 检查新事件 === - # 找出新插入的事件(ID > last_max_event_id) + # === 姝ラ2: 妫€鏌ユ柊浜嬩欢 === + # 鎵惧嚭鏂版彃鍏ョ殑浜嬩欢锛圛D > last_max_event_id锛? new_events = [ event for event in events_in_24h if event.id > last_max_event_id ] - print(f'[轮询] 新事件数量(ID > {last_max_event_id}): {len(new_events)} 个') + print(f'[杞] 鏂颁簨浠舵暟閲忥紙ID > {last_max_event_id}锛? {len(new_events)} 涓?) if new_events: - print(f'[轮询] 发现 {len(new_events)} 个新事件') + print(f'[杞] 鍙戠幇 {len(new_events)} 涓柊浜嬩欢') pushed_count = 0 pending_count = 0 for event in new_events: - # 检查事件是否有关联股票(只推送有关联股票的事件) + # 妫€鏌ヤ簨浠舵槸鍚︽湁鍏宠仈鑲$エ锛堝彧鎺ㄩ€佹湁鍏宠仈鑲$エ鐨勪簨浠讹級 related_stocks_count = event.related_stocks.count() - print(f'[轮询] 事件 ID={event.id}: {event.title} (关联股票: {related_stocks_count})') + print(f'[杞] 浜嬩欢 ID={event.id}: {event.title} (鍏宠仈鑲$エ: {related_stocks_count})') - # 只推送有关联股票的事件 + # 鍙帹閫佹湁鍏宠仈鑲$エ鐨勪簨浠? if related_stocks_count > 0: broadcast_new_event(event) pushed_count += 1 - print(f'[轮询] ✓ 已推送事件 ID={event.id}') + print(f'[杞] 鉁?宸叉帹閫佷簨浠?ID={event.id}') else: - # 没有关联股票,加入待推送列表 + # 娌℃湁鍏宠仈鑲$エ锛屽姞鍏ュ緟鎺ㄩ€佸垪琛? _add_pending_event(event.id) pending_count += 1 - print(f'[轮询] → 加入待推送列表(暂无关联股票)') + print(f'[杞] 鈫?鍔犲叆寰呮帹閫佸垪琛紙鏆傛棤鍏宠仈鑲$エ锛?) - print(f'[轮询] 本轮: 推送 {pushed_count} 个, 加入待推送 {pending_count} 个') + print(f'[杞] 鏈疆: 鎺ㄩ€?{pushed_count} 涓? 鍔犲叆寰呮帹閫?{pending_count} 涓?) - # 更新最大事件ID + # 鏇存柊鏈€澶т簨浠禝D new_max_id = max(event.id for event in events_in_24h) _set_last_max_event_id(new_max_id) - print(f'[轮询] 更新最大事件ID: {last_max_event_id} -> {new_max_id}') + print(f'[杞] 鏇存柊鏈€澶т簨浠禝D: {last_max_event_id} -> {new_max_id}') else: - # 即使没有新事件,也要更新最大ID(防止状态不同步) + # 鍗充娇娌℃湁鏂颁簨浠讹紝涔熻鏇存柊鏈€澶D锛堥槻姝㈢姸鎬佷笉鍚屾锛? if events_in_24h: current_max_id = max(event.id for event in events_in_24h) if current_max_id != last_max_event_id: _set_last_max_event_id(current_max_id) - print(f'[轮询] ========== 轮询结束 ==========\n') + print(f'[杞] ========== 杞缁撴潫 ==========\n') except Exception as e: - print(f'[轮询 ERROR] 检查新事件时出错: {e}') + print(f'[杞 ERROR] 妫€鏌ユ柊浜嬩欢鏃跺嚭閿? {e}') import traceback traceback.print_exc() finally: - # 释放锁 + # 閲婃斁閿? try: redis_client.delete(REDIS_KEY_POLLING_LOCK) except: @@ -11978,14 +11790,14 @@ def poll_new_events(): def initialize_event_polling(): """ - 初始化事件轮询机制 - 在应用启动时调用(支持 gunicorn 多 Worker) + 鍒濆鍖栦簨浠惰疆璇㈡満鍒? + 鍦ㄥ簲鐢ㄥ惎鍔ㄦ椂璋冪敤锛堟敮鎸?gunicorn 澶?Worker锛? """ global _polling_initialized - # 防止重复初始化 + # 闃叉閲嶅鍒濆鍖? if _polling_initialized: - print('[轮询] 已经初始化过,跳过') + print('[杞] 宸茬粡鍒濆鍖栬繃锛岃烦杩?) return try: @@ -11996,113 +11808,102 @@ def initialize_event_polling(): current_time = datetime.now() time_24h_ago = current_time - timedelta(hours=24) - print(f'\n[轮询] ========== 初始化事件轮询 (PID: {os.getpid()}) ==========') - print(f'[轮询] 当前时间: {current_time.strftime("%Y-%m-%d %H:%M:%S")}') + print(f'\n[杞] ========== 鍒濆鍖栦簨浠惰疆璇?(PID: {os.getpid()}) ==========') + print(f'[杞] 褰撳墠鏃堕棿: {current_time.strftime("%Y-%m-%d %H:%M:%S")}') - # 查询数据库中最大的事件 ID(不限于 24 小时) + # 鏌ヨ鏁版嵁搴撲腑鏈€澶х殑浜嬩欢 ID锛堜笉闄愪簬 24 灏忔椂锛? max_event = Event.query.filter_by(status='active').order_by(Event.id.desc()).first() db_max_id = max_event.id if max_event else 0 - # 获取 Redis 中当前保存的最大 ID + # 鑾峰彇 Redis 涓綋鍓嶄繚瀛樼殑鏈€澶?ID current_redis_max = _get_last_max_event_id() - print(f'[轮询] 数据库最大事件ID: {db_max_id}') - print(f'[轮询] Redis 中的最大事件ID: {current_redis_max}') + print(f'[杞] 鏁版嵁搴撴渶澶т簨浠禝D: {db_max_id}') + print(f'[杞] Redis 涓殑鏈€澶т簨浠禝D: {current_redis_max}') - # 始终使用数据库中的最大 ID(避免推送历史事件) - # 只在 Redis 值为 0 或小于数据库最大值时更新 + # 濮嬬粓浣跨敤鏁版嵁搴撲腑鐨勬渶澶?ID锛堥伩鍏嶆帹閫佸巻鍙蹭簨浠讹級 + # 鍙湪 Redis 鍊间负 0 鎴栧皬浜庢暟鎹簱鏈€澶у€兼椂鏇存柊 if current_redis_max == 0 or db_max_id > current_redis_max: _set_last_max_event_id(db_max_id) - print(f'[轮询] 更新最大事件ID为: {db_max_id}(避免推送历史事件)') + print(f'[杞] 鏇存柊鏈€澶т簨浠禝D涓? {db_max_id}锛堥伩鍏嶆帹閫佸巻鍙蹭簨浠讹級') else: - print(f'[轮询] 保持 Redis 中的最大事件ID: {current_redis_max}') + print(f'[杞] 淇濇寔 Redis 涓殑鏈€澶т簨浠禝D: {current_redis_max}') - # 统计数据库中的事件总数 + # 缁熻鏁版嵁搴撲腑鐨勪簨浠舵€绘暟 total_events = Event.query.filter_by(status='active').count() events_in_24h_count = Event.query.filter( Event.created_at >= time_24h_ago, Event.status == 'active' ).count() - print(f'[轮询] 数据库中共有 {total_events} 个活跃事件(其中近24小时: {events_in_24h_count} 个)') - print(f'[轮询] 只会推送 ID > {max(current_redis_max, db_max_id)} 的新事件') - print(f'[轮询] ========== 初始化完成 ==========\n') + print(f'[杞] 鏁版嵁搴撲腑鍏辨湁 {total_events} 涓椿璺冧簨浠讹紙鍏朵腑杩?4灏忔椂: {events_in_24h_count} 涓級') + print(f'[杞] 鍙細鎺ㄩ€?ID > {max(current_redis_max, db_max_id)} 鐨勬柊浜嬩欢') + print(f'[杞] ========== 鍒濆鍖栧畬鎴?==========\n') - # 检测是否在 eventlet 环境下运行 + # 妫€娴嬫槸鍚﹀湪 eventlet 鐜涓嬭繍琛? is_eventlet = False try: import eventlet - # 检查 eventlet 是否已经 monkey patch + # 妫€鏌?eventlet 鏄惁宸茬粡 monkey patch if hasattr(eventlet, 'patcher') and eventlet.patcher.is_monkey_patched('socket'): is_eventlet = True except ImportError: pass if is_eventlet: - # Eventlet 环境:使用 eventlet 的协程定时器 - print(f'[轮询] 检测到 Eventlet 环境,使用 eventlet 协程调度器') + # Eventlet 鐜锛氫娇鐢?eventlet 鐨勫崗绋嬪畾鏃跺櫒 + print(f'[杞] 妫€娴嬪埌 Eventlet 鐜锛屼娇鐢?eventlet 鍗忕▼璋冨害鍣?) def eventlet_polling_loop(): - """Eventlet 兼容的轮询循环""" + """Eventlet 鍏煎鐨勮疆璇㈠惊鐜?"" import eventlet while True: try: - eventlet.sleep(30) # 等待 30 秒 + eventlet.sleep(30) # 绛夊緟 30 绉? poll_new_events() except Exception as e: - print(f'[轮询 ERROR] Eventlet 轮询循环出错: {e}') + print(f'[杞 ERROR] Eventlet 杞寰幆鍑洪敊: {e}') import traceback traceback.print_exc() - eventlet.sleep(30) # 出错后等待 30 秒再继续 + eventlet.sleep(30) # 鍑洪敊鍚庣瓑寰?30 绉掑啀缁х画 - # 启动 eventlet 协程 + # 鍚姩 eventlet 鍗忕▼ eventlet.spawn(eventlet_polling_loop) - print(f'[轮询] Eventlet 协程调度器已启动 (PID: {os.getpid()}),每 30 秒检查一次新事件') + print(f'[杞] Eventlet 鍗忕▼璋冨害鍣ㄥ凡鍚姩 (PID: {os.getpid()})锛屾瘡 30 绉掓鏌ヤ竴娆℃柊浜嬩欢') else: - # 非 Eventlet 环境:使用 APScheduler - print(f'[轮询] 使用 APScheduler BackgroundScheduler') + # 闈?Eventlet 鐜锛氫娇鐢?APScheduler + print(f'[杞] 浣跨敤 APScheduler BackgroundScheduler') scheduler = BackgroundScheduler() - # 每 30 秒执行一次轮询 + # 姣?30 绉掓墽琛屼竴娆¤疆璇? scheduler.add_job( func=poll_new_events, trigger='interval', seconds=30, id='poll_new_events', - name='检查新事件并推送', + name='妫€鏌ユ柊浜嬩欢骞舵帹閫?, replace_existing=True ) - # 每天 9:25 预热股票缓存(开盘前 5 分钟) - from apscheduler.triggers.cron import CronTrigger - scheduler.add_job( - func=preload_stock_cache, - trigger=CronTrigger(hour=9, minute=25), - id='preload_stock_cache', - name='预热股票缓存(股票名称+前收盘价)', - replace_existing=True - ) - print(f'[缓存] 已添加定时任务: 每天 9:25 预热股票缓存') - scheduler.start() - print(f'[轮询] APScheduler 调度器已启动 (PID: {os.getpid()}),每 30 秒检查一次新事件') + print(f'[杞] APScheduler 璋冨害鍣ㄥ凡鍚姩 (PID: {os.getpid()})锛屾瘡 30 绉掓鏌ヤ竴娆℃柊浜嬩欢') _polling_initialized = True except Exception as e: - print(f'[轮询] 初始化失败: {e}') + print(f'[杞] 鍒濆鍖栧け璐? {e}') import traceback traceback.print_exc() -# ==================== Gunicorn 兼容:自动初始化轮询 ==================== +# ==================== Gunicorn 鍏煎锛氳嚜鍔ㄥ垵濮嬪寲杞 ==================== -# Redis key 用于确保只有一个 Worker 启动调度器 +# Redis key 鐢ㄤ簬纭繚鍙湁涓€涓?Worker 鍚姩璋冨害鍣? REDIS_KEY_SCHEDULER_LOCK = 'vf:event_polling:scheduler_lock' def _auto_init_polling(): """ - 自动初始化事件轮询(兼容 gunicorn) - 使用 Redis 锁确保整个集群只有一个 Worker 启动调度器 + 鑷姩鍒濆鍖栦簨浠惰疆璇紙鍏煎 gunicorn锛? + 浣跨敤 Redis 閿佺‘淇濇暣涓泦缇ゅ彧鏈変竴涓?Worker 鍚姩璋冨害鍣? """ global _polling_initialized import os @@ -12111,63 +11912,63 @@ def _auto_init_polling(): return try: - # 尝试获取调度器锁(10分钟过期,防止死锁) + # 灏濊瘯鑾峰彇璋冨害鍣ㄩ攣锛?0鍒嗛挓杩囨湡锛岄槻姝㈡閿侊級 lock_acquired = redis_client.set( REDIS_KEY_SCHEDULER_LOCK, str(os.getpid()), - nx=True, # 只在不存在时设置 - ex=600 # 10分钟过期 + nx=True, # 鍙湪涓嶅瓨鍦ㄦ椂璁剧疆 + ex=600 # 10鍒嗛挓杩囨湡 ) if lock_acquired: - print(f'[轮询] Worker {os.getpid()} 获得调度器锁,启动轮询调度器') + print(f'[杞] Worker {os.getpid()} 鑾峰緱璋冨害鍣ㄩ攣锛屽惎鍔ㄨ疆璇㈣皟搴﹀櫒') initialize_event_polling() else: - # 其他 Worker 已经启动了调度器 - _polling_initialized = True # 标记为已初始化,避免重复尝试 - print(f'[轮询] Worker {os.getpid()} 跳过调度器初始化(已由其他 Worker 启动)') + # 鍏朵粬 Worker 宸茬粡鍚姩浜嗚皟搴﹀櫒 + _polling_initialized = True # 鏍囪涓哄凡鍒濆鍖栵紝閬垮厤閲嶅灏濊瘯 + print(f'[杞] Worker {os.getpid()} 璺宠繃璋冨害鍣ㄥ垵濮嬪寲锛堝凡鐢卞叾浠?Worker 鍚姩锛?) except Exception as e: - print(f'[轮询] 自动初始化失败: {e}') + print(f'[杞] 鑷姩鍒濆鍖栧け璐? {e}') -# 注册 before_request 钩子,确保 gunicorn 启动后也能初始化轮询 +# 娉ㄥ唽 before_request 閽╁瓙锛岀‘淇?gunicorn 鍚姩鍚庝篃鑳藉垵濮嬪寲杞 @app.before_request def ensure_polling_initialized(): - """确保轮询机制已初始化(只执行一次)""" + """纭繚杞鏈哄埗宸插垵濮嬪寲锛堝彧鎵ц涓€娆★級""" global _polling_initialized if not _polling_initialized: _auto_init_polling() -# ==================== 结束 WebSocket 部分 ==================== +# ==================== 缁撴潫 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 = '取消点赞成功' + 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 = '点赞成功' + message = '鐐硅禐鎴愬姛' liked = True db.session.commit() @@ -12181,7 +11982,7 @@ def like_post(post_id): except Exception as e: db.session.rollback() - print(f"点赞失败: {e}") + print(f"鐐硅禐澶辫触: {e}") return jsonify({ 'success': False, 'message': str(e) @@ -12191,24 +11992,24 @@ def like_post(post_id): @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 模型) - # 暂时使用简单的计数器 + # 妫€鏌ユ槸鍚﹀凡缁忕偣璧烇紙闇€瑕佸垱寤?CommentLike 鍏宠仈鍒版柊鐨?Comment 妯″瀷锛? + # 鏆傛椂浣跨敤绠€鍗曠殑璁℃暟鍣? comment.likes_count += 1 db.session.commit() return jsonify({ 'success': True, - 'message': '点赞成功', + 'message': '鐐硅禐鎴愬姛', 'likes_count': comment.likes_count }) except Exception as e: db.session.rollback() - print(f"点赞失败: {e}") + print(f"鐐硅禐澶辫触: {e}") return jsonify({ 'success': False, 'message': str(e) @@ -12218,26 +12019,26 @@ def like_comment(comment_id): @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': '您只能删除自己的帖子' + '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 @@ -12245,12 +12046,12 @@ def delete_post(post_id): return jsonify({ 'success': True, - 'message': '帖子删除成功' + 'message': '甯栧瓙鍒犻櫎鎴愬姛' }) except Exception as e: db.session.rollback() - print(f"删除帖子失败: {e}") + print(f"鍒犻櫎甯栧瓙澶辫触: {e}") return jsonify({ 'success': False, 'message': str(e) @@ -12260,27 +12061,27 @@ def delete_post(post_id): @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': '您只能删除自己的评论' + 'message': '鎮ㄥ彧鑳藉垹闄よ嚜宸辩殑璇勮' }), 403 - # 软删除 + # 杞垹闄? comment.status = 'deleted' - comment.content = '[该评论已被删除]' + 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 @@ -12288,12 +12089,12 @@ def delete_comment(comment_id): return jsonify({ 'success': True, - 'message': '评论删除成功' + 'message': '璇勮鍒犻櫎鎴愬姛' }) except Exception as e: db.session.rollback() - print(f"删除评论失败: {e}") + print(f"鍒犻櫎璇勮澶辫触: {e}") return jsonify({ 'success': False, 'message': str(e) @@ -12301,7 +12102,7 @@ def delete_comment(comment_id): def format_decimal(value): - """格式化decimal类型数据""" + """鏍煎紡鍖杁ecimal绫诲瀷鏁版嵁""" if value is None: return None if isinstance(value, Decimal): @@ -12310,7 +12111,7 @@ def format_decimal(value): def format_date(date_obj): - """格式化日期""" + """鏍煎紡鍖栨棩鏈?"" if date_obj is None: return None if isinstance(date_obj, datetime): @@ -12320,8 +12121,8 @@ def format_date(date_obj): def remove_cycles_from_sankey_flows(flows_data): """ - 移除Sankey图数据中的循环边,确保数据是DAG(有向无环图) - 使用拓扑排序算法检测循环,优先保留flow_ratio高的边 + 绉婚櫎Sankey鍥炬暟鎹腑鐨勫惊鐜竟锛岀‘淇濇暟鎹槸DAG锛堟湁鍚戞棤鐜浘锛? + 浣跨敤鎷撴墤鎺掑簭绠楁硶妫€娴嬪惊鐜紝浼樺厛淇濈暀flow_ratio楂樼殑杈? Args: flows_data: list of flow objects with 'source', 'target', 'flow_metrics' keys @@ -12332,14 +12133,14 @@ def remove_cycles_from_sankey_flows(flows_data): if not flows_data: return flows_data - # 按flow_ratio降序排序,优先保留重要的边 + # 鎸塮low_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 @@ -12364,9 +12165,9 @@ def remove_cycles_from_sankey_flows(flows_data): return graph, in_degree, all_nodes - # 使用Kahn算法检测是否有环 + # 浣跨敤Kahn绠楁硶妫€娴嬫槸鍚︽湁鐜? def has_cycle(graph, in_degree, all_nodes): - # 找到所有入度为0的节点 + # 鎵惧埌鎵€鏈夊叆搴︿负0鐨勮妭鐐? queue = [node for node in all_nodes if in_degree.get(node, 0) == 0] visited_count = 0 @@ -12374,33 +12175,33 @@ def remove_cycles_from_sankey_flows(flows_data): 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鐢ㄤ簬妫€娴嬶紙鍥犱负妫€娴嬭繃绋嬩細淇敼瀹冿級 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) @@ -12411,7 +12212,7 @@ def remove_cycles_from_sankey_flows(flows_data): def get_report_type(date_str): - """获取报告期类型""" + """鑾峰彇鎶ュ憡鏈熺被鍨?"" if not date_str: return '' if isinstance(date_str, str): @@ -12423,22 +12224,22 @@ def get_report_type(date_str): year = date.year if month == 3: - return f"{year}年一季报" + return f"{year}骞翠竴瀛f姤" elif month == 6: - return f"{year}年中报" + return f"{year}骞翠腑鎶? elif month == 9: - return f"{year}年三季报" + return f"{year}骞翠笁瀛f姤" elif month == 12: - return f"{year}年年报" + 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, @@ -12477,10 +12278,10 @@ def get_stock_info(seccode): if not result: return jsonify({ 'success': False, - 'message': f'未找到股票代码 {seccode} 的财务数据' + 'message': f'鏈壘鍒拌偂绁ㄤ唬鐮?{seccode} 鐨勮储鍔℃暟鎹? }), 404 - # 获取最近的业绩预告 + # 鑾峰彇鏈€杩戠殑涓氱哗棰勫憡 forecast_query = text(""" SELECT distinct F001D as report_date, F003V as forecast_type, @@ -12532,7 +12333,7 @@ def get_stock_info(seccode): } } - # 添加业绩预告信息 + # 娣诲姞涓氱哗棰勫憡淇℃伅 if forecast_result: data['latest_forecast'] = { 'report_date': format_date(forecast_result.report_date), @@ -12562,95 +12363,95 @@ def get_stock_info(seccode): @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, -- 流动资产合计 + -- 娴佸姩璧勪骇 + 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, -- 资产总计 + -- 闈炴祦鍔ㄨ祫浜? + 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, -- 流动负债合计 + -- 娴佸姩璐熷€? + 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, -- 负债合计 + -- 闈炴祦鍔ㄨ礋鍊? + 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, -- 其他综合收益 + -- 鎵€鏈夎€呮潈鐩? + 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 -- 租赁负债 + -- 鏂颁細璁″噯鍒欑鐩? + 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' @@ -12662,7 +12463,7 @@ def get_balance_sheet(seccode): data = [] for row in result: - # 安全计算关键比率,避免 Decimal 与 None 运算错误 + # 瀹夊叏璁$畻鍏抽敭姣旂巼锛岄伩鍏?Decimal 涓?None 杩愮畻閿欒 def to_float(v): try: return float(v) if v is not None else None @@ -12692,7 +12493,7 @@ def get_balance_sheet(seccode): 'declare_date': format_date(row.DECLAREDATE), 'report_type': get_report_type(row.ENDDATE), - # 资产部分 + # 璧勪骇閮ㄥ垎 'assets': { 'current_assets': { 'cash': format_decimal(row.cash), @@ -12721,7 +12522,7 @@ def get_balance_sheet(seccode): 'total': format_decimal(row.total_assets), }, - # 负债部分 + # 璐熷€洪儴鍒? 'liabilities': { 'current_liabilities': { 'short_term_borrowings': format_decimal(row.short_term_borrowings), @@ -12747,7 +12548,7 @@ def get_balance_sheet(seccode): 'total': format_decimal(row.total_liabilities), }, - # 股东权益部分 + # 鑲′笢鏉冪泭閮ㄥ垎 'equity': { 'share_capital': format_decimal(row.share_capital), 'capital_reserve': format_decimal(row.capital_reserve), @@ -12760,7 +12561,7 @@ def get_balance_sheet(seccode): 'total': format_decimal(row.total_equity), }, - # 关键比率 + # 鍏抽敭姣旂巼 'key_ratios': { 'asset_liability_ratio': format_decimal(asset_liability_ratio_val), 'current_ratio': format_decimal(current_ratio_val), @@ -12783,7 +12584,7 @@ def get_balance_sheet(seccode): @app.route('/api/financial/income-statement/', methods=['GET']) def get_income_statement(seccode): - """获取完整的利润表数据""" + """鑾峰彇瀹屾暣鐨勫埄娑﹁〃鏁版嵁""" try: limit = request.args.get('limit', 12, type=int) @@ -12791,57 +12592,57 @@ def get_income_statement(seccode): SELECT distinct ENDDATE, STARTDATE, DECLAREDATE, - -- 营业收入部分 - F006N as revenue, -- 营业收入 - F035N as total_operating_revenue, -- 营业总收入 - F051N as other_income, -- 其他收入 + -- 钀ヤ笟鏀跺叆閮ㄥ垎 + 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, -- 营业总成本 + -- 钀ヤ笟鎴愭湰閮ㄥ垎 + 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, -- 资产处置收益 + -- 鍏朵粬鏀剁泭 + 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, -- 少数股东损益 + -- 鍒╂鼎閮ㄥ垎 + 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, -- 终止经营净利润 + -- 鎸佺画缁忚惀 + F060N as continuing_operations_net_profit, -- 鎸佺画缁忚惀鍑€鍒╂鼎 + F061N as discontinued_operations_net_profit, -- 缁堟缁忚惀鍑€鍒╂鼎 - -- 每股收益 - F031N as basic_eps, -- 基本每股收益 - F032N as diluted_eps, -- 稀释每股收益 + -- 姣忚偂鏀剁泭 + 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 -- 归属于少数股东的综合收益 + -- 缁煎悎鏀剁泭 + 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' @@ -12853,14 +12654,14 @@ def get_income_statement(seccode): 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 @@ -12869,7 +12670,7 @@ def get_income_statement(seccode): if row.financial_expenses: three_expenses += row.financial_expenses - # 四费合计(加研发) + # 鍥涜垂鍚堣锛堝姞鐮斿彂锛? four_expenses = three_expenses if row.rd_expenses: four_expenses += row.rd_expenses @@ -12880,14 +12681,14 @@ def get_income_statement(seccode): '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), @@ -12904,7 +12705,7 @@ def get_income_statement(seccode): '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), @@ -12913,7 +12714,7 @@ def get_income_statement(seccode): 'asset_disposal_income': format_decimal(row.asset_disposal_income), }, - # 利润 + # 鍒╂鼎 'profit': { 'gross_profit': format_decimal(gross_profit), 'operating_profit': format_decimal(row.operating_profit), @@ -12925,20 +12726,20 @@ def get_income_statement(seccode): '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), @@ -12946,7 +12747,7 @@ def get_income_statement(seccode): 'minority_comprehensive_income': format_decimal(row.minority_comprehensive_income), }, - # 关键比率 + # 鍏抽敭姣旂巼 'margins': { 'gross_margin': format_decimal(gross_margin), 'operating_margin': format_decimal(operating_margin), @@ -12972,7 +12773,7 @@ def get_income_statement(seccode): @app.route('/api/financial/cashflow/', methods=['GET']) def get_cashflow(seccode): - """获取完整的现金流量表数据""" + """鑾峰彇瀹屾暣鐨勭幇閲戞祦閲忚〃鏁版嵁""" try: limit = request.args.get('limit', 12, type=int) @@ -12980,78 +12781,78 @@ def get_cashflow(seccode): 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, -- 经营活动产生的现金流量净额 + -- 缁忚惀娲诲姩鐜伴噾娴? + 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, -- 投资活动产生的现金流量净额 + -- 鎶曡祫娲诲姩鐜伴噾娴? + 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, -- 筹资活动产生的现金流量净额 + -- 绛硅祫娲诲姩鐜伴噾娴? + 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, -- 其他原因对现金的影响 + -- 姹囩巼鍙樺姩褰卞搷 + 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, -- 期末现金及现金等价物余额 + -- 鐜伴噾鍑€澧炲姞棰? + 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, -- 经营活动产生的现金流量净额(间接法) + -- 琛ュ厖璧勬枡閮ㄥ垎 + 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 -- 支付利息、手续费及佣金的现金 + -- 鐗规畩琛屼笟绉戠洰锛堥噾铻嶏級 + 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' @@ -13063,7 +12864,7 @@ def get_cashflow(seccode): 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 @@ -13074,7 +12875,7 @@ def get_cashflow(seccode): '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), @@ -13092,7 +12893,7 @@ def get_cashflow(seccode): 'net_flow': format_decimal(row.net_operating_cash_flow), }, - # 投资活动现金流 + # 鎶曡祫娲诲姩鐜伴噾娴? 'investment_activities': { 'inflow': { 'investment_recovery': format_decimal(row.cash_from_investment_recovery), @@ -13112,7 +12913,7 @@ def get_cashflow(seccode): 'net_flow': format_decimal(row.net_investment_cash_flow), }, - # 筹资活动现金流 + # 绛硅祫娲诲姩鐜伴噾娴? 'financing_activities': { 'inflow': { 'capital': format_decimal(row.cash_from_capital), @@ -13129,7 +12930,7 @@ def get_cashflow(seccode): '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), @@ -13138,7 +12939,7 @@ def get_cashflow(seccode): 'ending_balance': format_decimal(row.ending_cash_balance), }, - # 补充资料(间接法) + # 琛ュ厖璧勬枡锛堥棿鎺ユ硶锛? 'indirect_method': { 'net_profit': format_decimal(row.net_profit), 'adjustments': { @@ -13155,7 +12956,7 @@ def get_cashflow(seccode): '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( @@ -13179,116 +12980,116 @@ def get_cashflow(seccode): @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, -- 每股经营现金流量 + -- 姣忚偂鎸囨爣 + F003N as eps, -- 姣忚偂鏀剁泭 + F004N as basic_eps, -- 鍩烘湰姣忚偂鏀剁泭 + F005N as diluted_eps, -- 绋€閲婃瘡鑲℃敹鐩? + F006N as deducted_eps, -- 鎵i櫎闈炵粡甯告€ф崯鐩婃瘡鑲℃敹鐩? + 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, -- 成本费用利润率 + -- 鐩堝埄鑳藉姏鎸囨爣 + F011N as operating_profit_margin, -- 钀ヤ笟鍒╂鼎鐜? + F012N as tax_rate, -- 钀ヤ笟绋庨噾鐜? + F013N as cost_ratio, -- 钀ヤ笟鎴愭湰鐜? + F014N as roe, -- 鍑€璧勪骇鏀剁泭鐜? + F066N as roe_deducted, -- 鍑€璧勪骇鏀剁泭鐜?鎵i櫎闈炵粡甯告€ф崯鐩? + F067N as roe_weighted, -- 鍑€璧勪骇鏀剁泭鐜?鍔犳潈 + F068N as roe_weighted_deducted, -- 鍑€璧勪骇鏀剁泭鐜?鍔犳潈(鎵i櫎闈炵粡甯告€ф崯鐩? + 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, -- 四费费用率 + -- 璐圭敤鐜囨寚鏍? + 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, -- 股东权益周转率 + -- 杩愯惀鑳藉姏鎸囨爣 + 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, -- 有形资产净值债务率 + -- 鍋垮€鸿兘鍔涙寚鏍? + 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, -- 经营活动产生的现金流净额同比变化率 + -- 鎴愰暱鑳藉姏鎸囨爣 + 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, -- 鎵i櫎闈炵粡甯告€ф崯鐩婂悗鐨勫噣鍒╂鼎鍚屾瘮鍙樺寲鐜? + 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, -- 净利含金量 + -- 鐜伴噾娴侀噺鎸囨爣 + 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, -- 流动负债比率 + -- 璐㈠姟缁撴瀯鎸囨爣 + 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, -- 非经常性损益占比 + -- 闈炵粡甯告€ф崯鐩? + F076N as deducted_net_profit, -- 鎵i櫎闈炵粡甯告€ф崯鐩婂悗鐨勫噣鍒╂鼎 + 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 -- 存货占比 + -- 缁煎悎鎸囨爣 + 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 @@ -13304,7 +13105,7 @@ def get_financial_metrics(seccode): '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), @@ -13318,7 +13119,7 @@ def get_financial_metrics(seccode): 'operating_cash_flow_ps': format_decimal(row.operating_cash_flow_ps), }, - # 盈利能力 + # 鐩堝埄鑳藉姏 'profitability': { 'roe': format_decimal(row.roe), 'roe_deducted': format_decimal(row.roe_deducted), @@ -13331,7 +13132,7 @@ def get_financial_metrics(seccode): '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), @@ -13341,7 +13142,7 @@ def get_financial_metrics(seccode): '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), @@ -13354,7 +13155,7 @@ def get_financial_metrics(seccode): 'working_capital_turnover': format_decimal(row.working_capital_turnover), }, - # 偿债能力 + # 鍋垮€鸿兘鍔? 'solvency': { 'current_ratio': format_decimal(row.current_ratio), 'quick_ratio': format_decimal(row.quick_ratio), @@ -13366,7 +13167,7 @@ def get_financial_metrics(seccode): '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), @@ -13379,7 +13180,7 @@ def get_financial_metrics(seccode): '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), @@ -13389,7 +13190,7 @@ def get_financial_metrics(seccode): '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), @@ -13400,7 +13201,7 @@ def get_financial_metrics(seccode): '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), @@ -13423,11 +13224,11 @@ def get_financial_metrics(seccode): @app.route('/api/financial/main-business/', methods=['GET']) def get_main_business(seccode): - """获取主营业务构成数据(包括产品和行业分类)""" + """鑾峰彇涓昏惀涓氬姟鏋勬垚鏁版嵁锛堝寘鎷骇鍝佸拰琛屼笟鍒嗙被锛?"" try: - limit = request.args.get('periods', 4, type=int) # 获取最近几期的数据 + limit = request.args.get('periods', 4, type=int) # 鑾峰彇鏈€杩戝嚑鏈熺殑鏁版嵁 - # 获取最近的报告期 + # 鑾峰彇鏈€杩戠殑鎶ュ憡鏈? period_query = text(""" SELECT DISTINCT ENDDATE FROM ea_mainproduct @@ -13438,7 +13239,7 @@ def get_main_business(seccode): with engine.connect() as conn: periods = conn.execute(period_query, {'seccode': seccode, 'limit': limit}).fetchall() - # 产品分类数据 + # 浜у搧鍒嗙被鏁版嵁 product_data = [] for period in periods: query = text(""" @@ -13487,7 +13288,7 @@ def get_main_business(seccode): 'products': period_products }) - # 行业分类数据(从ea_mainind表) + # 琛屼笟鍒嗙被鏁版嵁锛堜粠ea_mainind琛級 industry_data = [] for period in periods: query = text(""" @@ -13545,9 +13346,9 @@ def get_main_business(seccode): @app.route('/api/financial/forecast/', methods=['GET']) def get_forecast(seccode): - """获取业绩预告和预披露时间""" + """鑾峰彇涓氱哗棰勫憡鍜岄鎶湶鏃堕棿""" try: - # 获取业绩预告 + # 鑾峰彇涓氱哗棰勫憡 forecast_query = text(""" SELECT distinct DECLAREDATE, F001D as report_date, @@ -13592,7 +13393,7 @@ def get_forecast(seccode): } forecast_data.append(forecast) - # 获取预披露时间 + # 鑾峰彇棰勬姭闇叉椂闂? pretime_query = text(""" SELECT distinct F001D as report_period, F002D as scheduled_date, @@ -13613,7 +13414,7 @@ def get_forecast(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]: @@ -13648,7 +13449,7 @@ def get_forecast(seccode): @app.route('/api/financial/industry-rank/', methods=['GET']) def get_industry_rank(seccode): - """获取行业排名数据""" + """鑾峰彇琛屼笟鎺掑悕鏁版嵁""" try: limit = request.args.get('limit', 4, type=int) @@ -13657,47 +13458,47 @@ def get_industry_rank(seccode): F002V as level_description, F003D as report_date, INDNAME as industry_name, - -- 每股收益 + -- 姣忚偂鏀剁泭 F004N as eps, F005N as eps_industry_avg, F006N as eps_rank, - -- 扣除后每股收益 + -- 鎵i櫎鍚庢瘡鑲℃敹鐩? 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, @@ -13707,11 +13508,11 @@ def get_industry_rank(seccode): ORDER BY F003D DESC, F001V ASC LIMIT :limit_total """) - # 获取多个报告期的数据 + # 鑾峰彇澶氫釜鎶ュ憡鏈熺殑鏁版嵁 with engine.connect() as conn: result = conn.execute(query, {'seccode': seccode, 'limit_total': limit * 4}) - # 按报告期和行业级别组织数据 + # 鎸夋姤鍛婃湡鍜岃涓氱骇鍒粍缁囨暟鎹? data_by_period = {} for row in result: period = format_date(row.report_date) @@ -13777,7 +13578,7 @@ def get_industry_rank(seccode): } data_by_period[period].append(rank_data) - # 转换为列表格式 + # 杞崲涓哄垪琛ㄦ牸寮? data = [] for period, ranks in data_by_period.items(): data.append({ @@ -13800,11 +13601,11 @@ def get_industry_rank(seccode): @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, @@ -13867,9 +13668,9 @@ def get_period_comparison(seccode): } data.append(period_data) - # 计算同比和环比变化 + # 璁$畻鍚屾瘮鍜岀幆姣斿彉鍖? for i in range(len(data)): - if i > 0: # 环比 + if i > 0: # 鐜瘮 data[i]['qoq_changes'] = { 'revenue': calculate_change(data[i]['performance']['revenue'], data[i - 1]['performance']['revenue']), @@ -13877,7 +13678,7 @@ def get_period_comparison(seccode): data[i - 1]['performance']['net_profit']), } - # 同比(找到去年同期) + # 鍚屾瘮锛堟壘鍒板幓骞村悓鏈燂級 current_period = data[i]['period'] yoy_period = get_yoy_period(current_period) for j in range(len(data)): @@ -13902,16 +13703,16 @@ def get_period_comparison(seccode): }), 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: @@ -13924,7 +13725,7 @@ def get_yoy_period(date_str): @app.route('/api/market/trade/', methods=['GET']) def get_trade_data(seccode): - """获取股票交易数据(日K线)""" + """鑾峰彇鑲$エ浜ゆ槗鏁版嵁锛堟棩K绾匡級""" try: days = request.args.get('days', 60, type=int) end_date = request.args.get('end_date', datetime.now().strftime('%Y-%m-%d')) @@ -13979,10 +13780,10 @@ def get_trade_data(seccode): 'float_shares': format_decimal(row.float_shares), }) - # 倒序,让最早的日期在前 + # 鍊掑簭锛岃鏈€鏃╃殑鏃ユ湡鍦ㄥ墠 data.reverse() - # 计算统计数据 + # 璁$畻缁熻鏁版嵁 if data: prices = [d['close'] for d in data if d['close']] stats = { @@ -14011,12 +13812,12 @@ def get_trade_data(seccode): @app.route('/api/market/trade/batch', methods=['POST']) def get_batch_trade_data(): - """批量获取多只股票的交易数据(日K线) - 请求体:{ - codes: string[], // 股票代码列表(6位代码) - days: number // 获取天数,默认1 + """鎵归噺鑾峰彇澶氬彧鑲$エ鐨勪氦鏄撴暟鎹紙鏃绾匡級 + 璇锋眰浣擄細{ + codes: string[], // 鑲$エ浠g爜鍒楄〃锛?浣嶄唬鐮侊級 + days: number // 鑾峰彇澶╂暟锛岄粯璁? } - 返回:{ success: true, data: { [seccode]: { data: [], stats: {} } } } + 杩斿洖锛歿 success: true, data: { [seccode]: { data: [], stats: {} } } } """ try: data = request.json @@ -14025,12 +13826,12 @@ def get_batch_trade_data(): end_date = data.get('end_date', datetime.now().strftime('%Y-%m-%d')) if not codes: - return jsonify({'success': False, 'error': '请提供股票代码列表'}), 400 + return jsonify({'success': False, 'error': '璇锋彁渚涜偂绁ㄤ唬鐮佸垪琛?}), 400 if len(codes) > 100: - return jsonify({'success': False, 'error': '单次最多查询100只股票'}), 400 + return jsonify({'success': False, 'error': '鍗曟鏈€澶氭煡璇?00鍙偂绁?}), 400 - # 构建批量查询 + # 鏋勫缓鎵归噺鏌ヨ placeholders = ','.join([f':code{i}' for i in range(len(codes))]) params = {f'code{i}': code for i, code in enumerate(codes)} params['end_date'] = end_date @@ -14062,7 +13863,7 @@ def get_batch_trade_data(): result = conn.execute(query, params) rows = result.fetchall() - # 按股票代码分组,每只股票只取最近N天 + # 鎸夎偂绁ㄤ唬鐮佸垎缁勶紝姣忓彧鑲$エ鍙彇鏈€杩慛澶? stock_data = {} stock_counts = {} @@ -14072,7 +13873,7 @@ def get_batch_trade_data(): stock_data[seccode] = [] stock_counts[seccode] = 0 - # 只取指定天数的数据 + # 鍙彇鎸囧畾澶╂暟鐨勬暟鎹? if stock_counts[seccode] < days: stock_data[seccode].append({ 'date': format_date(row.TRADEDATE), @@ -14092,7 +13893,7 @@ def get_batch_trade_data(): }) stock_counts[seccode] += 1 - # 倒序每只股票的数据(让最早的日期在前) + # 鍊掑簭姣忓彧鑲$エ鐨勬暟鎹紙璁╂渶鏃╃殑鏃ユ湡鍦ㄥ墠锛? results = {} for seccode, data_list in stock_data.items(): data_list.reverse() @@ -14104,7 +13905,7 @@ def get_batch_trade_data(): } if data_list else {} } - # 为没有数据的股票返回空结果 + # 涓烘病鏈夋暟鎹殑鑲$エ杩斿洖绌虹粨鏋? for code in codes: if code not in results: results[code] = {'data': [], 'stats': {}} @@ -14123,7 +13924,7 @@ def get_batch_trade_data(): @app.route('/api/market/funding/', methods=['GET']) def get_funding_data(seccode): - """获取融资融券数据""" + """鑾峰彇铻嶈祫铻嶅埜鏁版嵁""" try: days = request.args.get('days', 30, type=int) @@ -14183,7 +13984,7 @@ def get_funding_data(seccode): @app.route('/api/market/bigdeal/', methods=['GET']) def get_bigdeal_data(seccode): - """获取大宗交易数据""" + """鑾峰彇澶у畻浜ゆ槗鏁版嵁""" try: days = request.args.get('days', 30, type=int) @@ -14219,7 +14020,7 @@ def get_bigdeal_data(seccode): 'seq_no': int(row.seq_no) if row.seq_no else None }) - # 按日期分组统计 + # 鎸夋棩鏈熷垎缁勭粺璁? daily_stats = {} for item in data: date = item['date'] @@ -14237,7 +14038,7 @@ def get_bigdeal_data(seccode): 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'] @@ -14257,7 +14058,7 @@ def get_bigdeal_data(seccode): @app.route('/api/market/unusual/', methods=['GET']) def get_unusual_data(seccode): - """获取龙虎榜数据""" + """鑾峰彇榫欒檸姒滄暟鎹?"" try: days = request.args.get('days', 30, type=int) @@ -14295,7 +14096,7 @@ def get_unusual_data(seccode): 'net_amount': format_decimal(row.net_amount) }) - # 按日期分组 + # 鎸夋棩鏈熷垎缁? grouped_data = {} for item in data: date = item['date'] @@ -14321,7 +14122,7 @@ def get_unusual_data(seccode): grouped_data[date]['net_amount'] = grouped_data[date]['total_buy'] - grouped_data[date]['total_sell'] - # 转换set为list + # 杞崲set涓簂ist for date in grouped_data: grouped_data[date]['info_types'] = list(grouped_data[date]['info_types']) @@ -14340,7 +14141,7 @@ def get_unusual_data(seccode): @app.route('/api/market/pledge/', methods=['GET']) def get_pledge_data(seccode): - """获取股权质押数据""" + """鑾峰彇鑲℃潈璐ㄦ娂鏁版嵁""" try: query = text(""" SELECT ENDDATE, @@ -14388,9 +14189,9 @@ def get_pledge_data(seccode): @app.route('/api/market/summary/', methods=['GET']) def get_market_summary(seccode): - """获取市场数据汇总""" + """鑾峰彇甯傚満鏁版嵁姹囨€?"" try: - # 获取最新交易数据 + # 鑾峰彇鏈€鏂颁氦鏄撴暟鎹? trade_query = text(""" SELECT * FROM ea_trade @@ -14398,7 +14199,7 @@ def get_market_summary(seccode): ORDER BY TRADEDATE DESC LIMIT 1 """) - # 获取最新融资融券数据 + # 鑾峰彇鏈€鏂拌瀺璧勮瀺鍒告暟鎹? funding_query = text(""" SELECT * FROM ea_funding @@ -14406,7 +14207,7 @@ def get_market_summary(seccode): ORDER BY TRADEDATE DESC LIMIT 1 """) - # 获取最新质押数据 + # 鑾峰彇鏈€鏂拌川鎶兼暟鎹? pledge_query = text(""" SELECT * FROM ea_pledgeratio @@ -14460,7 +14261,7 @@ def get_market_summary(seccode): @app.route('/api/stocks/search', methods=['GET']) def search_stocks(): - """搜索股票和指数(支持代码、名称搜索)""" + """鎼滅储鑲$エ鍜屾寚鏁帮紙鏀寔浠g爜銆佸悕绉版悳绱級""" try: query = request.args.get('q', '').strip() limit = request.args.get('limit', 20, type=int) @@ -14469,13 +14270,13 @@ def search_stocks(): if not query: return jsonify({ 'success': False, - 'error': '请输入搜索关键词' + 'error': '璇疯緭鍏ユ悳绱㈠叧閿瘝' }), 400 results = [] with engine.connect() as conn: - # 搜索指数(优先显示指数,因为通常用户搜索代码时指数更常用) + # 鎼滅储鎸囨暟锛堜紭鍏堟樉绀烘寚鏁帮紝鍥犱负閫氬父鐢ㄦ埛鎼滅储浠g爜鏃舵寚鏁版洿甯哥敤锛? if search_type in ('all', 'index'): index_sql = text(""" SELECT DISTINCT @@ -14514,10 +14315,10 @@ def search_stocks(): 'full_name': row.full_name, 'exchange': row.exchange, 'isIndex': True, - 'security_type': '指数' + 'security_type': '鎸囨暟' }) - # 搜索股票 + # 鎼滅储鑲$エ if search_type in ('all', 'stock'): stock_sql = text(""" SELECT DISTINCT SECCODE as stock_code, @@ -14532,8 +14333,8 @@ def search_stocks(): OR UPPER(SECNAME) LIKE UPPER(:query_pattern) OR UPPER(F001V) LIKE UPPER(:query_pattern) ) - AND (F011V = '正常上市' OR F010V = '013001') - AND F003V IN ('A股', 'B股') + AND (F011V = '姝e父涓婂競' OR F010V = '013001') + AND F003V IN ('A鑲?, 'B鑲?) ORDER BY CASE WHEN UPPER(SECCODE) = UPPER(:exact_query) THEN 1 WHEN UPPER(SECNAME) = UPPER(:exact_query) THEN 2 @@ -14565,29 +14366,29 @@ def search_stocks(): 'isIndex': False }) - # 如果搜索全部,按相关性重新排序(精确匹配优先) + # 濡傛灉鎼滅储鍏ㄩ儴锛屾寜鐩稿叧鎬ч噸鏂版帓搴忥紙绮剧‘鍖归厤浼樺厛锛? if search_type == 'all': def sort_key(item): code = item['stock_code'].upper() name = item['stock_name'].upper() q = query.upper() - # 精确匹配代码优先 + # 绮剧‘鍖归厤浠g爜浼樺厛 if code == q: - return (0, not item['isIndex'], code) # 指数优先 - # 精确匹配名称 + return (0, not item['isIndex'], code) # 鎸囨暟浼樺厛 + # 绮剧‘鍖归厤鍚嶇О if name == q: return (1, not item['isIndex'], code) - # 前缀匹配代码 + # 鍓嶇紑鍖归厤浠g爜 if code.startswith(q): return (2, not item['isIndex'], code) - # 前缀匹配名称 + # 鍓嶇紑鍖归厤鍚嶇О if name.startswith(q): return (3, not item['isIndex'], code) return (4, not item['isIndex'], code) results.sort(key=sort_key) - # 限制总数 + # 闄愬埗鎬绘暟 results = results[:limit] return jsonify({ @@ -14597,7 +14398,7 @@ def search_stocks(): }) except Exception as e: - app.logger.error(f"搜索股票/指数错误: {e}") + app.logger.error(f"鎼滅储鑲$エ/鎸囨暟閿欒: {e}") return jsonify({ 'success': False, 'error': str(e) @@ -14606,15 +14407,15 @@ def search_stocks(): @app.route('/api/market/heatmap', methods=['GET']) def get_market_heatmap(): - """获取市场热力图数据(基于市值和涨跌幅)""" + """鑾峰彇甯傚満鐑姏鍥炬暟鎹紙鍩轰簬甯傚€煎拰娑ㄨ穼骞咃級""" try: - # 获取交易日期参数 + # 鑾峰彇浜ゆ槗鏃ユ湡鍙傛暟 trade_date = request.args.get('date') - # 前端显示用的limit,但统计数据会基于全部股票 + # 鍓嶇鏄剧ず鐢ㄧ殑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 @@ -14625,24 +14426,24 @@ def get_market_heatmap(): if not trade_date: return jsonify({ 'success': False, - 'error': '无法获取交易数据' + '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 -- 所属省份 + 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 -- 仅统计当日有涨跌幅数据的股票 + AND t.F010N IS NOT NULL -- 浠呯粺璁″綋鏃ユ湁娑ㄨ穼骞呮暟鎹殑鑲$エ ORDER BY market_cap DESC """) @@ -14650,7 +14451,7 @@ def get_market_heatmap(): 'trade_date': trade_date }).fetchall() - # 计算统计数据(基于全部股票) + # 璁$畻缁熻鏁版嵁锛堝熀浜庡叏閮ㄨ偂绁級 total_market_cap = 0 total_amount = 0 rising_count = 0 @@ -14659,7 +14460,7 @@ def get_market_heatmap(): all_data = [] for row in all_result: - # F010N 已在 SQL 中确保非空 + # 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 @@ -14686,21 +14487,21 @@ def get_market_heatmap(): 'province': row.province }) - # 只返回前display_limit条用于热力图显示 + # 鍙繑鍥炲墠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), # 显示的股票数量 + '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 # 平盘家数 + '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 # 骞崇洏瀹舵暟 } }) @@ -14713,13 +14514,13 @@ def get_market_heatmap(): @app.route('/api/market/statistics', methods=['GET']) def get_market_statistics(): - """获取市场统计数据(从ea_blocktrading表)""" + """鑾峰彇甯傚満缁熻鏁版嵁锛堜粠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 @@ -14730,10 +14531,10 @@ def get_market_statistics(): if not trade_date: return jsonify({ 'success': False, - 'error': '无法获取统计数据' + 'error': '鏃犳硶鑾峰彇缁熻鏁版嵁' }), 404 - # 获取沪深两市的统计数据 + # 鑾峰彇娌繁涓ゅ競鐨勭粺璁℃暟鎹? stats_sql = text(""" SELECT EXCHANGECODE, EXCHANGENAME, @@ -14744,13 +14545,13 @@ def get_market_statistics(): TRADEDATE FROM ea_blocktrading WHERE TRADEDATE = :trade_date - AND EXCHANGECODE IN ('012001', '012002') -- 只获取上交所和深交所的数据 + AND EXCHANGECODE IN ('012001', '012002') -- 鍙幏鍙栦笂浜ゆ墍鍜屾繁浜ゆ墍鐨勬暟鎹? AND F001V IN ( - '250006', '250014', -- 深交所股票总市值、上交所市价总值 - '250007', '250015', -- 深交所股票流通市值、上交所流通市值 - '250008', -- 深交所股票成交金额 - '250010', '250019', -- 深交所股票平均市盈率、上交所平均市盈率 - '250050', '250001' -- 上交所上市公司家数、深交所上市公司数 + '250006', '250014', -- 娣变氦鎵€鑲$エ鎬诲競鍊笺€佷笂浜ゆ墍甯備环鎬诲€? + '250007', '250015', -- 娣变氦鎵€鑲$エ娴侀€氬競鍊笺€佷笂浜ゆ墍娴侀€氬競鍊? + '250008', -- 娣变氦鎵€鑲$エ鎴愪氦閲戦 + '250010', '250019', -- 娣变氦鎵€鑲$エ骞冲潎甯傜泩鐜囥€佷笂浜ゆ墍骞冲潎甯傜泩鐜? + '250050', '250001' -- 涓婁氦鎵€涓婂競鍏徃瀹舵暟銆佹繁浜ゆ墍涓婂競鍏徃鏁? ) """) @@ -14758,7 +14559,7 @@ def get_market_statistics(): 'trade_date': trade_date }).fetchall() - # 整理数据 + # 鏁寸悊鏁版嵁 statistics = {} for row in result: key = f"{row.EXCHANGECODE}_{row.indicator_code}" @@ -14771,53 +14572,53 @@ def get_market_statistics(): '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 # 深交所上市公司数 + '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: # 上交所市价总值 + # 璁$畻姹囨€诲€? + if '012001_250014' in statistics: # 涓婁氦鎵€甯備环鎬诲€? summary['total_market_cap'] += statistics['012001_250014']['value'] - if '012002_250006' in statistics: # 深交所股票总市值 + if '012002_250006' in statistics: # 娣变氦鎵€鑲$エ鎬诲競鍊? summary['total_market_cap'] += statistics['012002_250006']['value'] - if '012001_250015' in statistics: # 上交所流通市值 + if '012001_250015' in statistics: # 涓婁氦鎵€娴侀€氬競鍊? summary['total_float_cap'] += statistics['012001_250015']['value'] - if '012002_250007' in statistics: # 深交所股票流通市值 + 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 + 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: # 上交所平均市盈率 + if '012001_250019' in statistics: # 涓婁氦鎵€骞冲潎甯傜泩鐜? summary['sh_pe_ratio'] = statistics['012001_250019']['value'] - if '012002_250010' in statistics: # 深交所股票平均市盈率 + if '012002_250010' in statistics: # 娣变氦鎵€鑲$エ骞冲潎甯傜泩鐜? summary['sz_pe_ratio'] = statistics['012002_250010']['value'] - if '012001_250050' in statistics: # 上交所上市公司家数 + if '012001_250050' in statistics: # 涓婁氦鎵€涓婂競鍏徃瀹舵暟 summary['sh_companies'] = int(statistics['012001_250050']['value']) - if '012002_250001' in statistics: # 深交所上市公司数 + 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 @@ -14827,7 +14628,7 @@ def get_market_statistics(): available_dates = [str(row.TRADEDATE) for row in available_dates_result] - # 格式化日期为 YYYY-MM-DD + # 鏍煎紡鍖栨棩鏈熶负 YYYY-MM-DD formatted_trade_date = trade_date.strftime('%Y-%m-%d') if hasattr(trade_date, 'strftime') else str(trade_date).split(' ')[0][:10] formatted_available_dates = [ d.strftime('%Y-%m-%d') if hasattr(d, 'strftime') else str(d).split(' ')[0][:10] @@ -14851,16 +14652,16 @@ def get_market_statistics(): @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 + # 鏋勫缓姒傚康涓績API鐨刄RL concept_api_url = 'http://222.128.1.157:16801/search' - # 准备请求数据 + # 鍑嗗璇锋眰鏁版嵁 request_data = { 'query': '', 'size': limit, @@ -14871,7 +14672,7 @@ def get_daily_top_concepts(): if trade_date: request_data['trade_date'] = trade_date - # 调用概念中心API + # 璋冪敤姒傚康涓績API response = requests.post(concept_api_url, json=request_data, timeout=10) if response.status_code == 200: @@ -14879,39 +14680,39 @@ def get_daily_top_concepts(): top_concepts = [] for concept in data.get('results', []): - # 处理 stocks 字段:兼容 {name, code} 和 {stock_name, stock_code} 两种格式 + # 澶勭悊 stocks 瀛楁锛氬吋瀹?{name, code} 鍜?{stock_name, stock_code} 涓ょ鏍煎紡 raw_stocks = concept.get('stocks', []) formatted_stocks = [] for stock in raw_stocks: - # 优先使用 stock_name,其次使用 name + # 浼樺厛浣跨敤 stock_name锛屽叾娆′娇鐢?name stock_name = stock.get('stock_name') or stock.get('name', '') stock_code = stock.get('stock_code') or stock.get('code', '') formatted_stocks.append({ 'stock_name': stock_name, 'stock_code': stock_code, - 'name': stock_name, # 兼容旧格式 - 'code': stock_code # 兼容旧格式 + 'name': stock_name, # 鍏煎鏃ф牸寮? + 'code': stock_code # 鍏煎鏃ф牸寮? }) - # 保持与 /concept-api/search 相同的字段结构,并添加新字段 + # 淇濇寔涓?/concept-api/search 鐩稿悓鐨勫瓧娈电粨鏋勶紝骞舵坊鍔犳柊瀛楁 top_concepts.append({ 'concept_id': concept.get('concept_id'), - 'concept': concept.get('concept'), # 原始字段名 - 'concept_name': concept.get('concept'), # 兼容旧字段名 + 'concept': concept.get('concept'), # 鍘熷瀛楁鍚? + 'concept_name': concept.get('concept'), # 鍏煎鏃у瓧娈靛悕 'description': concept.get('description'), 'stock_count': concept.get('stock_count', 0), 'score': concept.get('score'), 'match_type': concept.get('match_type'), - 'price_info': concept.get('price_info', {}), # 完整的价格信息 - 'change_percent': concept.get('price_info', {}).get('avg_change_pct', 0), # 兼容旧字段 - 'tags': concept.get('tags', []), # 标签列表 - 'outbreak_dates': concept.get('outbreak_dates', []), # 爆发日期列表 - 'hierarchy': concept.get('hierarchy'), # 层级信息 {lv1, lv2, lv3} - 'stocks': formatted_stocks, # 返回格式化后的股票列表 + 'price_info': concept.get('price_info', {}), # 瀹屾暣鐨勪环鏍间俊鎭? + 'change_percent': concept.get('price_info', {}).get('avg_change_pct', 0), # 鍏煎鏃у瓧娈? + 'tags': concept.get('tags', []), # 鏍囩鍒楄〃 + 'outbreak_dates': concept.get('outbreak_dates', []), # 鐖嗗彂鏃ユ湡鍒楄〃 + 'hierarchy': concept.get('hierarchy'), # 灞傜骇淇℃伅 {lv1, lv2, lv3} + 'stocks': formatted_stocks, # 杩斿洖鏍煎紡鍖栧悗鐨勮偂绁ㄥ垪琛? 'hot_score': concept.get('hot_score') }) - # 格式化日期为 YYYY-MM-DD + # 鏍煎紡鍖栨棩鏈熶负 YYYY-MM-DD price_date = data.get('price_date', '') formatted_date = str(price_date).split(' ')[0][:10] if price_date else '' @@ -14924,7 +14725,7 @@ def get_daily_top_concepts(): else: return jsonify({ 'success': False, - 'error': '获取概念数据失败' + 'error': '鑾峰彇姒傚康鏁版嵁澶辫触' }), 500 except Exception as e: @@ -14934,23 +14735,23 @@ def get_daily_top_concepts(): }), 500 -# ==================== 热点概览 API ==================== +# ==================== 鐑偣姒傝 API ==================== @app.route('/api/market/hotspot-overview', methods=['GET']) def get_hotspot_overview(): """ - 获取热点概览数据(用于个股中心的热点概览图表) - 返回:指数分时数据 + 概念异动标注 + 鑾峰彇鐑偣姒傝鏁版嵁锛堢敤浜庝釜鑲′腑蹇冪殑鐑偣姒傝鍥捐〃锛? + 杩斿洖锛氭寚鏁板垎鏃舵暟鎹?+ 姒傚康寮傚姩鏍囨敞 - 数据来源: - - 指数分时:ClickHouse index_minute 表 - - 概念异动:MySQL concept_anomaly_hybrid 表(来自 realtime_detector.py) + 鏁版嵁鏉ユ簮锛? + - 鎸囨暟鍒嗘椂锛欳lickHouse index_minute 琛? + - 姒傚康寮傚姩锛歁ySQL concept_anomaly_hybrid 琛紙鏉ヨ嚜 realtime_detector.py锛? """ try: trade_date = request.args.get('date') index_code = request.args.get('index', '000001.SH') - # 如果没有指定日期,使用最新交易日 + # 濡傛灉娌℃湁鎸囧畾鏃ユ湡锛屼娇鐢ㄦ渶鏂颁氦鏄撴棩 if not trade_date: today = date.today() if today in trading_days_set: @@ -14959,7 +14760,7 @@ def get_hotspot_overview(): target_date = get_trading_day_near_date(today) trade_date = target_date.strftime('%Y-%m-%d') if target_date else today.strftime('%Y-%m-%d') - # 1. 获取指数分时数据 + # 1. 鑾峰彇鎸囨暟鍒嗘椂鏁版嵁 client = get_clickhouse_client() target_date_obj = datetime.strptime(trade_date, '%Y-%m-%d').date() @@ -14977,7 +14778,7 @@ def get_hotspot_overview(): } ) - # 获取昨收价 + # 鑾峰彇鏄ㄦ敹浠? code_no_suffix = index_code.split('.')[0] prev_close = None with engine.connect() as conn: @@ -14993,7 +14794,7 @@ def get_hotspot_overview(): if prev_result and prev_result[0]: prev_close = float(prev_result[0]) - # 格式化指数数据 + # 鏍煎紡鍖栨寚鏁版暟鎹? index_timeline = [] for row in index_data: ts, open_p, high_p, low_p, close_p, vol = row @@ -15012,12 +14813,12 @@ def get_hotspot_overview(): 'change_pct': change_pct }) - # 2. 获取概念异动数据(优先从 V2 表,fallback 到旧表) + # 2. 鑾峰彇姒傚康寮傚姩鏁版嵁锛堜紭鍏堜粠 V2 琛紝fallback 鍒版棫琛級 alerts = [] use_v2 = False with engine.connect() as conn: - # 尝试查询 V2 表(时间片对齐 + 持续确认版本) + # 灏濊瘯鏌ヨ V2 琛紙鏃堕棿鐗囧榻?+ 鎸佺画纭鐗堟湰锛? try: v2_result = conn.execute(text(""" SELECT @@ -15042,7 +14843,7 @@ def get_hotspot_overview(): alerts.append({ 'concept_id': row[0], - 'concept_name': row[0], # 后面会填充 + 'concept_name': row[0], # 鍚庨潰浼氬~鍏? 'time': row[1].strftime('%H:%M') if row[1] else None, 'timestamp': row[1].isoformat() if row[1] else None, 'alert_type': row[3], @@ -15050,7 +14851,7 @@ def get_hotspot_overview(): 'rule_score': float(row[5]) if row[5] else None, 'ml_score': float(row[6]) if row[6] else None, 'trigger_reason': row[7], - # V2 新增字段 + # V2 鏂板瀛楁 'confirm_ratio': float(row[8]) if row[8] else None, 'alpha': float(row[9]) if row[9] else None, 'alpha_zscore': float(row[10]) if row[10] else None, @@ -15060,14 +14861,14 @@ def get_hotspot_overview(): 'momentum_5m': float(row[14]) if row[14] else None, 'limit_up_ratio': float(row[15]) if row[15] else 0, 'triggered_rules': triggered_rules, - # 兼容字段 + # 鍏煎瀛楁 'importance_score': float(row[4]) / 100 if row[4] else None, 'is_v2': True, }) except Exception as v2_err: - app.logger.debug(f"V2 表查询失败,使用旧表: {v2_err}") + app.logger.debug(f"V2 琛ㄦ煡璇㈠け璐ワ紝浣跨敤鏃ц〃: {v2_err}") - # Fallback: 查询旧表 + # Fallback: 鏌ヨ鏃ц〃 if not use_v2: try: alert_result = conn.execute(text(""" @@ -15118,12 +14919,12 @@ def get_hotspot_overview(): 'is_v2': False, }) except Exception as old_err: - app.logger.debug(f"旧表查询也失败: {old_err}") + app.logger.debug(f"鏃ц〃鏌ヨ涔熷け璐? {old_err}") - # 尝试批量获取概念名称 + # 灏濊瘯鎵归噺鑾峰彇姒傚康鍚嶇О if alerts: concept_ids = list(set(a['concept_id'] for a in alerts)) - concept_names = {} # 初始化 concept_names 字典 + concept_names = {} # 鍒濆鍖?concept_names 瀛楀吀 try: from elasticsearch import Elasticsearch es_client = Elasticsearch(["http://222.128.1.157:19200"]) @@ -15135,14 +14936,14 @@ def get_hotspot_overview(): for doc in es_result.get('docs', []): if doc.get('found') and doc.get('_source'): concept_names[doc['_id']] = doc['_source'].get('concept', doc['_id']) - # 更新 alerts 中的概念名称 + # 鏇存柊 alerts 涓殑姒傚康鍚嶇О for alert in alerts: if alert['concept_id'] in concept_names: alert['concept_name'] = concept_names[alert['concept_id']] except Exception as e: - app.logger.warning(f"获取概念名称失败: {e}") + app.logger.warning(f"鑾峰彇姒傚康鍚嶇О澶辫触: {e}") - # 计算统计信息 + # 璁$畻缁熻淇℃伅 day_high = max([d['price'] for d in index_timeline if d['price']], default=None) day_low = min([d['price'] for d in index_timeline if d['price']], default=None) latest_price = index_timeline[-1]['price'] if index_timeline else None @@ -15154,7 +14955,7 @@ def get_hotspot_overview(): 'trade_date': trade_date, 'index': { 'code': index_code, - 'name': '上证指数' if index_code == '000001.SH' else index_code, + 'name': '涓婅瘉鎸囨暟' if index_code == '000001.SH' else index_code, 'prev_close': prev_close, 'latest_price': latest_price, 'change_pct': latest_change_pct, @@ -15181,24 +14982,24 @@ def get_hotspot_overview(): except Exception as e: import traceback error_trace = traceback.format_exc() - app.logger.error(f"获取热点概览数据失败: {error_trace}") + app.logger.error(f"鑾峰彇鐑偣姒傝鏁版嵁澶辫触: {error_trace}") return jsonify({ 'success': False, 'error': str(e), - 'traceback': error_trace # 临时返回完整错误信息用于调试 + 'traceback': error_trace # 涓存椂杩斿洖瀹屾暣閿欒淇℃伅鐢ㄤ簬璋冭瘯 }), 500 @app.route('/api/concept//stocks', methods=['GET']) def get_concept_stocks(concept_id): """ - 获取概念的相关股票列表(带实时涨跌幅) + 鑾峰彇姒傚康鐨勭浉鍏宠偂绁ㄥ垪琛紙甯﹀疄鏃舵定璺屽箙锛? Args: - concept_id: 概念 ID 或概念名称(支持两种方式查询) + concept_id: 姒傚康 ID 鎴栨蹇靛悕绉帮紙鏀寔涓ょ鏂瑰紡鏌ヨ锛? Returns: - - stocks: 股票列表 [{code, name, reason, change_pct}, ...] + - stocks: 鑲$エ鍒楄〃 [{code, name, reason, change_pct}, ...] """ try: from elasticsearch import Elasticsearch @@ -15206,11 +15007,11 @@ def get_concept_stocks(concept_id): es_client = Elasticsearch(["http://222.128.1.157:19200"]) - # 1. 尝试多种方式获取概念数据 + # 1. 灏濊瘯澶氱鏂瑰紡鑾峰彇姒傚康鏁版嵁 source = None concept_name = concept_id - # 方式1: 先尝试按 ID 查询 + # 鏂瑰紡1: 鍏堝皾璇曟寜 ID 鏌ヨ try: es_result = es_client.get(index='concept_library_v3', id=concept_id) if es_result.get('found'): @@ -15219,7 +15020,7 @@ def get_concept_stocks(concept_id): except: pass - # 方式2: 如果按 ID 没找到,尝试按概念名称搜索 + # 鏂瑰紡2: 濡傛灉鎸?ID 娌℃壘鍒帮紝灏濊瘯鎸夋蹇靛悕绉版悳绱? if not source: try: search_result = es_client.search( @@ -15238,12 +15039,12 @@ def get_concept_stocks(concept_id): source = hits[0].get('_source', {}) concept_name = source.get('concept', concept_id) except Exception as search_err: - app.logger.debug(f"ES 搜索概念失败: {search_err}") + app.logger.debug(f"ES 鎼滅储姒傚康澶辫触: {search_err}") if not source: return jsonify({ 'success': False, - 'error': f'概念 {concept_id} 不存在' + 'error': f'姒傚康 {concept_id} 涓嶅瓨鍦? }), 404 raw_stocks = source.get('stocks', []) @@ -15258,7 +15059,7 @@ def get_concept_stocks(concept_id): } }) - # 提取股票代码和原因 + # 鎻愬彇鑲$エ浠g爜鍜屽師鍥? stocks_info = [] stock_codes = [] for s in raw_stocks: @@ -15282,13 +15083,13 @@ def get_concept_stocks(concept_id): } }) - # 2. 获取最新交易日和前一交易日 + # 2. 鑾峰彇鏈€鏂颁氦鏄撴棩鍜屽墠涓€浜ゆ槗鏃? today = datetime.now().date() trading_day = None prev_trading_day = None with engine.connect() as conn: - # 获取最新交易日 + # 鑾峰彇鏈€鏂颁氦鏄撴棩 result = conn.execute(text(""" SELECT EXCHANGE_DATE FROM trading_days WHERE EXCHANGE_DATE <= :today @@ -15297,7 +15098,7 @@ def get_concept_stocks(concept_id): if result: trading_day = result[0].date() if hasattr(result[0], 'date') else result[0] - # 获取前一交易日 + # 鑾峰彇鍓嶄竴浜ゆ槗鏃? if trading_day: result = conn.execute(text(""" SELECT EXCHANGE_DATE FROM trading_days @@ -15307,7 +15108,7 @@ def get_concept_stocks(concept_id): if result: prev_trading_day = result[0].date() if hasattr(result[0], 'date') else result[0] - # 3. 从 MySQL ea_trade 获取前一交易日收盘价(F007N) + # 3. 浠?MySQL ea_trade 鑾峰彇鍓嶄竴浜ゆ槗鏃ユ敹鐩樹环锛團007N锛? prev_close_map = {} if prev_trading_day and stock_codes: with engine.connect() as conn: @@ -15325,7 +15126,7 @@ def get_concept_stocks(concept_id): prev_close_map = {row[0]: float(row[1]) for row in result if row[1]} - # 4. 从 ClickHouse 获取最新价格 + # 4. 浠?ClickHouse 鑾峰彇鏈€鏂颁环鏍? current_price_map = {} if stock_codes: try: @@ -15337,7 +15138,7 @@ def get_concept_stocks(concept_id): database='stock' ) - # 转换为 ClickHouse 格式 + # 杞崲涓?ClickHouse 鏍煎紡 ch_codes = [] code_mapping = {} for code in stock_codes: @@ -15352,7 +15153,7 @@ def get_concept_stocks(concept_id): ch_codes_str = "','".join(ch_codes) - # 查询当天最新价格 + # 鏌ヨ褰撳ぉ鏈€鏂颁环鏍? query = f""" SELECT code, close FROM stock_minute @@ -15370,9 +15171,9 @@ def get_concept_stocks(concept_id): current_price_map[original_code] = float(close_price) except Exception as ch_err: - app.logger.warning(f"ClickHouse 获取价格失败: {ch_err}") + app.logger.warning(f"ClickHouse 鑾峰彇浠锋牸澶辫触: {ch_err}") - # 5. 计算涨跌幅并合并数据 + # 5. 璁$畻娑ㄨ穼骞呭苟鍚堝苟鏁版嵁 result_stocks = [] for stock in stocks_info: code = stock['code'] @@ -15392,7 +15193,7 @@ def get_concept_stocks(concept_id): 'prev_close': prev_close }) - # 按涨跌幅排序(涨停优先) + # 鎸夋定璺屽箙鎺掑簭锛堟定鍋滀紭鍏堬級 result_stocks.sort(key=lambda x: x.get('change_pct') if x.get('change_pct') is not None else -999, reverse=True) return jsonify({ @@ -15408,7 +15209,7 @@ def get_concept_stocks(concept_id): except Exception as e: import traceback - app.logger.error(f"获取概念股票失败: {traceback.format_exc()}") + app.logger.error(f"鑾峰彇姒傚康鑲$エ澶辫触: {traceback.format_exc()}") return jsonify({ 'success': False, 'error': str(e) @@ -15418,7 +15219,7 @@ def get_concept_stocks(concept_id): @app.route('/api/market/concept-alerts', methods=['GET']) def get_concept_alerts(): """ - 获取概念异动列表(支持分页和筛选) + 鑾峰彇姒傚康寮傚姩鍒楄〃锛堟敮鎸佸垎椤靛拰绛涢€夛級 """ try: trade_date = request.args.get('date') @@ -15427,7 +15228,7 @@ def get_concept_alerts(): limit = request.args.get('limit', 50, type=int) offset = request.args.get('offset', 0, type=int) - # 构建查询条件 + # 鏋勫缓鏌ヨ鏉′欢 conditions = [] params = {'limit': limit, 'offset': offset} @@ -15448,11 +15249,11 @@ def get_concept_alerts(): where_clause = " AND ".join(conditions) if conditions else "1=1" with engine.connect() as conn: - # 获取总数 + # 鑾峰彇鎬绘暟 count_sql = text(f"SELECT COUNT(*) FROM concept_minute_alert WHERE {where_clause}") total = conn.execute(count_sql, params).scalar() - # 获取数据 + # 鑾峰彇鏁版嵁 query_sql = text(f""" SELECT id, concept_id, concept_name, alert_time, alert_type, trade_date, @@ -15511,7 +15312,7 @@ def get_concept_alerts(): except Exception as e: import traceback - app.logger.error(f"获取概念异动列表失败: {traceback.format_exc()}") + app.logger.error(f"鑾峰彇姒傚康寮傚姩鍒楄〃澶辫触: {traceback.format_exc()}") return jsonify({ 'success': False, 'error': str(e) @@ -15520,19 +15321,19 @@ def get_concept_alerts(): @app.route('/api/market/rise-analysis/', methods=['GET']) def get_rise_analysis(seccode): - """获取股票涨幅分析数据(从 Elasticsearch 获取)""" + """鑾峰彇鑲$エ娑ㄥ箙鍒嗘瀽鏁版嵁锛堜粠 Elasticsearch 鑾峰彇锛?"" try: - # 获取日期范围参数 + # 鑾峰彇鏃ユ湡鑼冨洿鍙傛暟 start_date = request.args.get('start_date') end_date = request.args.get('end_date') limit = request.args.get('limit', 100, type=int) - # 构建 ES 查询 + # 鏋勫缓 ES 鏌ヨ must_conditions = [ {"term": {"stock_code": seccode}} ] - # 添加日期范围筛选 + # 娣诲姞鏃ユ湡鑼冨洿绛涢€? if start_date and end_date: must_conditions.append({ "range": { @@ -15555,19 +15356,19 @@ def get_rise_analysis(seccode): ], "size": limit, "_source": { - "excludes": ["rise_reason_detail_embedding"] # 排除向量字段 + "excludes": ["rise_reason_detail_embedding"] # 鎺掗櫎鍚戦噺瀛楁 } } - # 执行 ES 查询 + # 鎵ц ES 鏌ヨ response = es_client.search(index="stock_rise_analysis", body=es_query) - # 格式化数据 + # 鏍煎紡鍖栨暟鎹? rise_analysis_data = [] for hit in response['hits']['hits']: source = hit['_source'] - # 处理研报引用数据 + # 澶勭悊鐮旀姤寮曠敤鏁版嵁 verification_reports = [] if source.get('has_verification_info') and source.get('verification_info'): v_info = source['verification_info'] @@ -15611,7 +15412,7 @@ def get_rise_analysis(seccode): except Exception as e: import traceback - print(f"ES查询错误: {traceback.format_exc()}") + print(f"ES鏌ヨ閿欒: {traceback.format_exc()}") return jsonify({ 'success': False, 'error': str(e) @@ -15619,14 +15420,14 @@ def get_rise_analysis(seccode): # ============================================ -# 公司分析相关接口 +# 鍏徃鍒嗘瀽鐩稿叧鎺ュ彛 # ============================================ @app.route('/api/company/comprehensive-analysis/', methods=['GET']) def get_comprehensive_analysis(company_code): - """获取公司综合分析数据""" + """鑾峰彇鍏徃缁煎悎鍒嗘瀽鏁版嵁""" try: - # 获取公司定性分析 + # 鑾峰彇鍏徃瀹氭€у垎鏋? qualitative_query = text(""" SELECT one_line_intro, investment_highlights, @@ -15648,7 +15449,7 @@ def get_comprehensive_analysis(company_code): with engine.connect() as conn: qualitative_result = conn.execute(qualitative_query, {'company_code': company_code}).fetchone() - # 获取业务板块分析 + # 鑾峰彇涓氬姟鏉垮潡鍒嗘瀽 segments_query = text(""" SELECT segment_name, segment_description, @@ -15666,7 +15467,7 @@ def get_comprehensive_analysis(company_code): with engine.connect() as conn: segments_result = conn.execute(segments_query, {'company_code': company_code}).fetchall() - # 获取竞争地位数据 - 最新一期 + # 鑾峰彇绔炰簤鍦颁綅鏁版嵁 - 鏈€鏂颁竴鏈? competitive_query = text(""" SELECT market_position_score, technology_score, @@ -15692,7 +15493,7 @@ def get_comprehensive_analysis(company_code): with engine.connect() as conn: competitive_result = conn.execute(competitive_query, {'company_code': company_code}).fetchone() - # 获取业务结构数据 - 最新一期 + # 鑾峰彇涓氬姟缁撴瀯鏁版嵁 - 鏈€鏂颁竴鏈? business_structure_query = text(""" SELECT business_name, parent_business, @@ -15720,7 +15521,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() - # 构建返回数据 + # 鏋勫缓杩斿洖鏁版嵁 response_data = { 'company_code': company_code, 'qualitative_analysis': None, @@ -15729,7 +15530,7 @@ def get_comprehensive_analysis(company_code): 'business_structure': [] } - # 处理定性分析数据 + # 澶勭悊瀹氭€у垎鏋愭暟鎹? if qualitative_result: response_data['qualitative_analysis'] = { 'core_positioning': { @@ -15753,7 +15554,7 @@ def get_comprehensive_analysis(company_code): '%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, @@ -15765,7 +15566,7 @@ def get_comprehensive_analysis(company_code): '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': { @@ -15796,7 +15597,7 @@ def get_comprehensive_analysis(company_code): '%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, @@ -15838,9 +15639,9 @@ def get_comprehensive_analysis(company_code): @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, @@ -15858,7 +15659,7 @@ def get_value_chain_analysis(company_code): with engine.connect() as conn: nodes_result = conn.execute(nodes_query, {'company_code': company_code}).fetchall() - # 获取产业链流向数据 + # 鑾峰彇浜т笟閾炬祦鍚戞暟鎹? flows_query = text(""" SELECT source_node, source_type, @@ -15879,7 +15680,7 @@ def get_value_chain_analysis(company_code): with engine.connect() as conn: flows_result = conn.execute(flows_query, {'company_code': company_code}).fetchall() - # 构建节点数据结构 + # 鏋勫缓鑺傜偣鏁版嵁缁撴瀯 nodes_by_level = {} all_nodes = [] @@ -15897,13 +15698,13 @@ def get_value_chain_analysis(company_code): 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({ @@ -15928,10 +15729,10 @@ def get_value_chain_analysis(company_code): } }) - # 移除循环边,确保Sankey图数据是DAG(有向无环图) + # 绉婚櫎寰幆杈癸紝纭繚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] = { @@ -15971,9 +15772,9 @@ def get_value_chain_analysis(company_code): @app.route('/api/company/value-chain/related-companies', methods=['GET']) def get_related_companies_by_node(): """ - 根据产业链节点名称查询相关公司(结合nodes和flows表) - 参数: node_name - 节点名称(如 "中芯国际"、"EDA/IP"等) - 返回: 包含该节点的所有公司列表,附带节点层级、类型、关系描述等信息 + 鏍规嵁浜т笟閾捐妭鐐瑰悕绉版煡璇㈢浉鍏冲叕鍙革紙缁撳悎nodes鍜宖lows琛級 + 鍙傛暟: node_name - 鑺傜偣鍚嶇О锛堝 "涓姱鍥介檯"銆?EDA/IP"绛夛級 + 杩斿洖: 鍖呭惈璇ヨ妭鐐圭殑鎵€鏈夊叕鍙稿垪琛紝闄勫甫鑺傜偣灞傜骇銆佺被鍨嬨€佸叧绯绘弿杩扮瓑淇℃伅 """ try: node_name = request.args.get('node_name') @@ -15981,10 +15782,10 @@ def get_related_companies_by_node(): if not node_name: return jsonify({ 'success': False, - 'error': '缺少必需参数 node_name' + 'error': '缂哄皯蹇呴渶鍙傛暟 node_name' }), 400 - # 查询包含该节点的所有公司及其节点信息 + # 鏌ヨ鍖呭惈璇ヨ妭鐐圭殑鎵€鏈夊叕鍙稿強鍏惰妭鐐逛俊鎭? query = text(""" SELECT DISTINCT n.company_code as stock_code, @@ -16005,7 +15806,7 @@ def get_related_companies_by_node(): with engine.connect() as conn: nodes_result = conn.execute(query, {'node_name': node_name}).fetchall() - # 构建返回数据 + # 鏋勫缓杩斿洖鏁版嵁 companies = [] for row in nodes_result: company_data = { @@ -16023,7 +15824,7 @@ def get_related_companies_by_node(): 'relationships': [] } - # 查询该节点在该公司产业链中的流向关系 + # 鏌ヨ璇ヨ妭鐐瑰湪璇ュ叕鍙镐骇涓氶摼涓殑娴佸悜鍏崇郴 flows_query = text(""" SELECT source_node, @@ -16049,9 +15850,9 @@ def get_related_companies_by_node(): 'node_name': node_name }).fetchall() - # 添加流向关系信息 + # 娣诲姞娴佸悜鍏崇郴淇℃伅 for flow in flows_result: - # 判断节点在流向中的角色 + # 鍒ゆ柇鑺傜偣鍦ㄦ祦鍚戜腑鐨勮鑹? is_source = (flow.source_node == node_name) relationship = { @@ -16083,13 +15884,13 @@ def get_related_companies_by_node(): @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) # 时间线事件数量限制 + # 鑾峰彇璇锋眰鍙傛暟 + report_period = request.args.get('report_period') # 鍙€夌殑鎶ュ憡鏈熺瓫閫? + event_limit = request.args.get('event_limit', 50, type=int) # 鏃堕棿绾夸簨浠舵暟閲忛檺鍒? - # 获取关键因素类别 + # 鑾峰彇鍏抽敭鍥犵礌绫诲埆 categories_query = text(""" SELECT id, category_name, @@ -16103,7 +15904,7 @@ def get_key_factors_timeline(company_code): with engine.connect() as conn: categories_result = conn.execute(categories_query, {'company_code': company_code}).fetchall() - # 获取关键因素详情 + # 鑾峰彇鍏抽敭鍥犵礌璇︽儏 factors_query = text(""" SELECT kf.category_id, kf.factor_name, @@ -16124,7 +15925,7 @@ def get_key_factors_timeline(company_code): params = {'company_code': company_code} - # 如果指定了报告期,添加筛选条件 + # 濡傛灉鎸囧畾浜嗘姤鍛婃湡锛屾坊鍔犵瓫閫夋潯浠? if report_period: factors_query = text(""" SELECT kf.category_id, @@ -16169,7 +15970,7 @@ def get_key_factors_timeline(company_code): with engine.connect() as conn: factors_result = conn.execute(factors_query, params).fetchall() - # 获取发展时间线事件 + # 鑾峰彇鍙戝睍鏃堕棿绾夸簨浠? timeline_query = text(""" SELECT event_date, event_type, @@ -16189,11 +15990,11 @@ 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() - # 构建关键因素数据结构 + # 鏋勫缓鍏抽敭鍥犵礌鏁版嵁缁撴瀯 key_factors_data = {} factors_by_category = {} - # 先建立类别索引 + # 鍏堝缓绔嬬被鍒储寮? categories_map = {} for category in categories_result: categories_map[category.id] = { @@ -16203,7 +16004,7 @@ def get_key_factors_timeline(company_code): 'factors': [] } - # 将因素分组到类别中 + # 灏嗗洜绱犲垎缁勫埌绫诲埆涓? for factor in factors_result: factor_data = { 'factor_name': factor.factor_name, @@ -16223,7 +16024,7 @@ def get_key_factors_timeline(company_code): 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({ @@ -16243,7 +16044,7 @@ def get_key_factors_timeline(company_code): '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 @@ -16279,16 +16080,16 @@ def get_key_factors_timeline(company_code): # ============================================ -# 模拟盘服务函数 +# 妯℃嫙鐩樻湇鍔″嚱鏁? # ============================================ 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}', + account_name=f'妯℃嫙璐︽埛_{user_id}', initial_capital=1000000.00, available_cash=1000000.00 ) @@ -16298,13 +16099,13 @@ def get_or_create_simulation_account(user_id): def is_trading_time(): - """判断是否为交易时间""" + """鍒ゆ柇鏄惁涓轰氦鏄撴椂闂?"" now = beijing_now() - # 检查是否为工作日 - if now.weekday() >= 5: # 周六日 + # 妫€鏌ユ槸鍚︿负宸ヤ綔鏃? + if now.weekday() >= 5: # 鍛ㄥ叚鏃? return False - # 检查是否为交易时间 + # 妫€鏌ユ槸鍚︿负浜ゆ槗鏃堕棿 current_time = now.time() morning_start = dt_time(9, 30) morning_end = dt_time(11, 30) @@ -16319,20 +16120,20 @@ def is_trading_time(): def get_latest_price_from_clickhouse(stock_code): - """从ClickHouse获取最新价格(优先分钟数据,备选日线数据)""" + """浠嶤lickHouse鑾峰彇鏈€鏂颁环鏍硷紙浼樺厛鍒嗛挓鏁版嵁锛屽閫夋棩绾挎暟鎹級""" try: client = get_clickhouse_client() - # 确保stock_code包含后缀 + # 纭繚stock_code鍖呭惈鍚庣紑 if '.' not in stock_code: if stock_code.startswith('6'): - stock_code = f"{stock_code}.SH" # 上海 + stock_code = f"{stock_code}.SH" # 涓婃捣 elif stock_code.startswith(('8', '9', '4')): - stock_code = f"{stock_code}.BJ" # 北交所 + stock_code = f"{stock_code}.BJ" # 鍖椾氦鎵€ else: - stock_code = f"{stock_code}.SZ" # 深圳 + stock_code = f"{stock_code}.SZ" # 娣卞湷 - # 1. 首先尝试获取最新的分钟数据(近30天) + # 1. 棣栧厛灏濊瘯鑾峰彇鏈€鏂扮殑鍒嗛挓鏁版嵁锛堣繎30澶╋級 minute_query = """ SELECT close, timestamp FROM stock_minute @@ -16347,7 +16148,7 @@ def get_latest_price_from_clickhouse(stock_code): if result: return float(result[0][0]), result[0][1] - # 2. 如果没有分钟数据,获取最新的日线收盘价 + # 2. 濡傛灉娌℃湁鍒嗛挓鏁版嵁锛岃幏鍙栨渶鏂扮殑鏃ョ嚎鏀剁洏浠? daily_query = """ SELECT close, date FROM stock_daily @@ -16362,7 +16163,7 @@ def get_latest_price_from_clickhouse(stock_code): if daily_result: return float(daily_result[0][0]), daily_result[0][1] - # 3. 如果还是没有,尝试从其他表获取(如果有的话) + # 3. 濡傛灉杩樻槸娌℃湁锛屽皾璇曚粠鍏朵粬琛ㄨ幏鍙栵紙濡傛灉鏈夌殑璇濓級 fallback_query = """ SELECT close_price, trade_date FROM stock_minute_kline @@ -16371,36 +16172,36 @@ def get_latest_price_from_clickhouse(stock_code): ORDER BY trade_date DESC, trade_time DESC LIMIT 1 \ """ - # 提取6位代码 + # 鎻愬彇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} 的价格数据") + print(f"璀﹀憡: 鏃犳硶鑾峰彇鑲$エ {stock_code} 鐨勪环鏍兼暟鎹?) return None, None except Exception as e: - print(f"获取最新价格失败 {stock_code}: {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包含后缀 + # 纭繚stock_code鍖呭惈鍚庣紑 if '.' not in stock_code: if stock_code.startswith('6'): - stock_code = f"{stock_code}.SH" # 上海 + stock_code = f"{stock_code}.SH" # 涓婃捣 elif stock_code.startswith(('8', '9', '4')): - stock_code = f"{stock_code}.BJ" # 北交所 + stock_code = f"{stock_code}.BJ" # 鍖椾氦鎵€ else: - stock_code = f"{stock_code}.SZ" # 深圳 + stock_code = f"{stock_code}.SZ" # 娣卞湷 - # 获取下单后一分钟内的数据 + # 鑾峰彇涓嬪崟鍚庝竴鍒嗛挓鍐呯殑鏁版嵁 query = """ SELECT close, timestamp FROM stock_minute @@ -16423,7 +16224,7 @@ def get_next_minute_price(stock_code, order_time): if result: return float(result[0][0]), result[0][1] - # 如果一分钟内没有数据,获取最近的数据 + # 濡傛灉涓€鍒嗛挓鍐呮病鏈夋暟鎹紝鑾峰彇鏈€杩戠殑鏁版嵁 query = """ SELECT close, timestamp FROM stock_minute @@ -16442,42 +16243,42 @@ def get_next_minute_price(stock_code, order_time): if result: return float(result[0][0]), result[0][1] - # 如果没有后续分钟数据,使用最新可用价格 - print(f"没有找到下单后的分钟数据,使用最新价格: {stock_code}") + # 濡傛灉娌℃湁鍚庣画鍒嗛挓鏁版嵁锛屼娇鐢ㄦ渶鏂板彲鐢ㄤ环鏍? + print(f"娌℃湁鎵惧埌涓嬪崟鍚庣殑鍒嗛挓鏁版嵁锛屼娇鐢ㄦ渶鏂颁环鏍? {stock_code}") return get_latest_price_from_clickhouse(stock_code) except Exception as e: - print(f"获取成交价格失败: {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) + 鏀寔杈撳叆鏍煎紡锛? + - 鑲$エ浠g爜锛?00519 鎴?600519.SH + - 鑲$エ鍚嶇О锛氳吹宸炶寘鍙? + - 鎷奸煶棣栧瓧姣嶏細gzmt + - 鍚嶇О(浠g爜)锛氳吹宸炶寘鍙?600519) - 返回: (stock_code_with_suffix, stock_code_6digit, stock_name) 或 (None, None, None) + 杩斿洖: (stock_code_with_suffix, stock_code_6digit, stock_name) 鎴?(None, None, None) """ - # 先尝试标准化输入 + # 鍏堝皾璇曟爣鍑嗗寲杈撳叆 code6, name_from_input = _normalize_stock_input(stock_input) if code6: - # 如果能解析出6位代码,查询股票名称 + # 濡傛灉鑳借В鏋愬嚭6浣嶄唬鐮侊紝鏌ヨ鑲$エ鍚嶇О stock_name = name_from_input or _query_stock_name_by_code(code6) if code6.startswith('6'): - stock_code_full = f"{code6}.SH" # 上海 + stock_code_full = f"{code6}.SH" # 涓婃捣 elif code6.startswith(('8', '9', '4')): - stock_code_full = f"{code6}.BJ" # 北交所 + stock_code_full = f"{code6}.BJ" # 鍖椾氦鎵€ else: - stock_code_full = f"{code6}.SZ" # 深圳 + stock_code_full = f"{code6}.SZ" # 娣卞湷 return stock_code_full, code6, stock_name - # 如果不是标准代码格式,尝试搜索 + # 濡傛灉涓嶆槸鏍囧噯浠g爜鏍煎紡锛屽皾璇曟悳绱? with engine.connect() as conn: search_sql = text(""" SELECT DISTINCT SECCODE as stock_code, @@ -16488,8 +16289,8 @@ def validate_and_get_stock_info(stock_input): OR UPPER(SECNAME) = UPPER(:exact_match) OR UPPER(F001V) = UPPER(:exact_match) ) - AND F011V = '正常上市' - AND F003V IN ('A股', 'B股') LIMIT 1 + AND F011V = '姝e父涓婂競' + AND F003V IN ('A鑲?, 'B鑲?) LIMIT 1 """) result = conn.execute(search_sql, { @@ -16500,166 +16301,166 @@ def validate_and_get_stock_info(stock_input): code6 = result.stock_code stock_name = result.stock_name if code6.startswith('6'): - stock_code_full = f"{code6}.SH" # 上海 + stock_code_full = f"{code6}.SH" # 涓婃捣 elif code6.startswith(('8', '9', '4')): - stock_code_full = f"{code6}.BJ" # 北交所 + stock_code_full = f"{code6}.BJ" # 鍖椾氦鎵€ else: - stock_code_full = f"{code6}.SZ" # 深圳 + stock_code_full = 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 = '无效的股票代码' + 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状态,等待后台处理 + # 濡傛灉鏃犳硶鑾峰彇浠锋牸锛岃鍗曚繚鎸丳ENDING鐘舵€侊紝绛夊緟鍚庡彴澶勭悊 order.status = 'PENDING' db.session.commit() - return True # 返回True表示下单成功,但未成交 + 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 = '可用资金不足' + order.reject_reason = '鍙敤璧勯噾涓嶈冻' db.session.commit() return False - # 扣除资金 + # 鎵i櫎璧勯噾 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才可用 + # 浠婃棩涔板叆锛孴+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, # 今日买入冻结 + 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}") + # 鍗栧嚭鎿嶄綔 + print(f"馃攳 璋冭瘯锛氭煡鎵炬寔浠擄紝璐︽埛ID: {account.id}, 鑲$エ浠g爜: {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}") + print(f"馃攳 璋冭瘯锛氬皾璇曠敤6浣嶆牸寮忔煡鎵? {code6}") position = SimulationPosition.query.filter_by( account_id=account.id, stock_code=code6 ).first() - print(f"🔍 调试:找到持仓: {position}") + print(f"馃攳 璋冭瘯锛氭壘鍒版寔浠? {position}") if position: print( - f"🔍 调试:持仓详情 - 股票代码: {position.stock_code}, 持仓数量: {position.position_qty}, 可用数量: {position.available_qty}") + f"馃攳 璋冭瘯锛氭寔浠撹鎯?- 鑲$エ浠g爜: {position.stock_code}, 鎸佷粨鏁伴噺: {position.position_qty}, 鍙敤鏁伴噺: {position.available_qty}") - # 检查持仓是否存在 + # 妫€鏌ユ寔浠撴槸鍚﹀瓨鍦? if not position: order.status = 'REJECTED' - order.reject_reason = '持仓不存在' + 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} 股' + 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: - # 解冻部分冻结数量 + # 瑙e喕閮ㄥ垎鍐荤粨鏁伴噺 position.frozen_qty -= need_to_unfreeze position.available_qty += need_to_unfreeze - print(f"解冻 {need_to_unfreeze} 股用于卖出") + print(f"瑙e喕 {need_to_unfreeze} 鑲$敤浜庡崠鍑?) else: order.status = 'REJECTED' - order.reject_reason = f'可用数量不足,可用: {position.available_qty} 股,冻结: {position.frozen_qty} 股,需要: {order.order_qty} 股' + 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, @@ -16679,26 +16480,26 @@ def execute_simulation_order(order): ) db.session.add(transaction) - # 更新订单状态 + # 鏇存柊璁㈠崟鐘舵€? order.status = 'FILLED' - # 更新账户总资产 + # 鏇存柊璐︽埛鎬昏祫浜? update_account_assets(account) db.session.commit() return True except Exception as e: - print(f"执行订单失败: {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) @@ -16708,25 +16509,25 @@ def update_account_assets(account): db.session.commit() except Exception as e: - print(f"更新账户资产失败: {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 @@ -16734,40 +16535,40 @@ def update_all_positions_price(): db.session.commit() except Exception as e: - print(f"更新持仓价格失败: {e}") + print(f"鏇存柊鎸佷粨浠锋牸澶辫触: {e}") db.session.rollback() def process_t1_settlement(): - """处理T+1结算(每日收盘后运行)""" + """澶勭悊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}") + print(f"T+1缁撶畻澶辫触: {e}") db.session.rollback() # ============================================ -# 模拟盘API接口 +# 妯℃嫙鐩楢PI鎺ュ彛 # ============================================ @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({ @@ -16796,11 +16597,11 @@ def get_simulation_account(): @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 = [] @@ -16834,13 +16635,13 @@ def get_simulation_positions(): @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') # 日期筛选 + # 鑾峰彇鏌ヨ鍙傛暟 + 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) @@ -16895,40 +16696,40 @@ def get_simulation_orders(): @app.route('/api/simulation/place-order', methods=['POST']) @login_required def place_simulation_order(): - """下单""" + """涓嬪崟""" try: - # 移除交易时间检查,允许7x24小时下单 - # 非交易时间下的单子会保持PENDING状态,等待行情数据 + # 绉婚櫎浜ゆ槗鏃堕棿妫€鏌ワ紝鍏佽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') # 目前只支持市价单 + 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 + return jsonify({'success': False, 'error': '缂哄皯蹇呰鍙傛暟'}), 400 if order_type not in ['BUY', 'SELL']: - return jsonify({'success': False, 'error': '订单类型错误'}), 400 + 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 + return jsonify({'success': False, 'error': '涓嬪崟鏁伴噺蹇呴』涓?00鐨勬暣鏁板€?}), 400 - # 获取账户 + # 鑾峰彇璐︽埛 account = get_or_create_simulation_account(current_user.id) - # 获取股票信息 + # 鑾峰彇鑲$エ淇℃伅 stock_name = None with engine.connect() as conn: result = conn.execute(text( @@ -16937,7 +16738,7 @@ def place_simulation_order(): if result: stock_name = result[0] - # 创建订单 + # 鍒涘缓璁㈠崟 order = SimulationOrder( account_id=account.id, order_no=f"O{int(beijing_now().timestamp() * 1000000)}", @@ -16952,19 +16753,19 @@ def place_simulation_order(): db.session.add(order) db.session.commit() - # 执行订单 - print(f"🔍 调试:开始执行订单,股票代码: {order.stock_code}, 订单类型: {order.order_type}") + # 鎵ц璁㈠崟 + print(f"馃攳 璋冭瘯锛氬紑濮嬫墽琛岃鍗曪紝鑲$エ浠g爜: {order.stock_code}, 璁㈠崟绫诲瀷: {order.order_type}") success = execute_simulation_order(order) - print(f"🔍 调试:订单执行结果: {success}, 订单状态: {order.status}") + print(f"馃攳 璋冭瘯锛氳鍗曟墽琛岀粨鏋? {success}, 璁㈠崟鐘舵€? {order.status}") if success: - # 重新查询订单状态,因为可能在execute_simulation_order中被修改 + # 閲嶆柊鏌ヨ璁㈠崟鐘舵€侊紝鍥犱负鍙兘鍦╡xecute_simulation_order涓淇敼 db.session.refresh(order) if order.status == 'FILLED': return jsonify({ 'success': True, - 'message': '订单执行成功,已成交', + 'message': '璁㈠崟鎵ц鎴愬姛锛屽凡鎴愪氦', 'data': { 'order_no': order.order_no, 'status': 'FILLED', @@ -16977,7 +16778,7 @@ def place_simulation_order(): elif order.status == 'PENDING': return jsonify({ 'success': True, - 'message': '订单提交成功,等待行情数据成交', + 'message': '璁㈠崟鎻愪氦鎴愬姛锛岀瓑寰呰鎯呮暟鎹垚浜?, 'data': { 'order_no': order.order_no, 'status': 'PENDING', @@ -16988,12 +16789,12 @@ def place_simulation_order(): else: return jsonify({ 'success': False, - 'error': order.reject_reason or '订单状态异常' + 'error': order.reject_reason or '璁㈠崟鐘舵€佸紓甯? }), 400 else: return jsonify({ 'success': False, - 'error': order.reject_reason or '订单执行失败' + 'error': order.reject_reason or '璁㈠崟鎵ц澶辫触' }), 400 except Exception as e: @@ -17004,7 +16805,7 @@ def place_simulation_order(): @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) @@ -17015,7 +16816,7 @@ def cancel_simulation_order(order_id): ).first() if not order: - return jsonify({'success': False, 'error': '订单不存在或无法撤销'}), 404 + return jsonify({'success': False, 'error': '璁㈠崟涓嶅瓨鍦ㄦ垨鏃犳硶鎾ら攢'}), 404 order.status = 'CANCELLED' order.cancel_time = beijing_now() @@ -17024,7 +16825,7 @@ def cancel_simulation_order(order_id): return jsonify({ 'success': True, - 'message': '订单已撤销' + 'message': '璁㈠崟宸叉挙閿€' }) except Exception as e: @@ -17035,11 +16836,11 @@ def cancel_simulation_order(order_id): @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) @@ -17085,23 +16886,23 @@ def get_simulation_transactions(): 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, @@ -17111,7 +16912,7 @@ def get_simulation_statistics(): 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 @@ -17121,7 +16922,7 @@ def get_simulation_statistics(): 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({ @@ -17154,17 +16955,17 @@ def get_simulation_statistics(): @app.route('/api/simulation/t1-settlement', methods=['POST']) @login_required def trigger_t1_settlement(): - """手动触发T+1结算""" + """鎵嬪姩瑙﹀彂T+1缁撶畻""" try: - # 导入后台处理器的函数 + # 瀵煎叆鍚庡彴澶勭悊鍣ㄧ殑鍑芥暟 from simulation_background_processor import process_t1_settlement - # 执行T+1结算 + # 鎵цT+1缁撶畻 process_t1_settlement() return jsonify({ 'success': True, - 'message': 'T+1结算执行成功' + 'message': 'T+1缁撶畻鎵ц鎴愬姛' }) except Exception as e: @@ -17177,7 +16978,7 @@ def trigger_t1_settlement(): @app.route('/api/simulation/debug-positions', methods=['GET']) @login_required def debug_positions(): - """调试接口:查看持仓数据""" + """璋冭瘯鎺ュ彛锛氭煡鐪嬫寔浠撴暟鎹?"" try: account = get_or_create_simulation_account(current_user.id) @@ -17210,7 +17011,7 @@ def debug_positions(): @app.route('/api/simulation/debug-transactions', methods=['GET']) @login_required def debug_transactions(): - """调试接口:查看成交记录数据""" + """璋冭瘯鎺ュ彛锛氭煡鐪嬫垚浜よ褰曟暟鎹?"" try: account = get_or_create_simulation_account(current_user.id) @@ -17251,17 +17052,17 @@ def debug_transactions(): @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': '日结算执行成功' + 'message': '鏃ョ粨绠楁墽琛屾垚鍔? }) except Exception as e: @@ -17274,18 +17075,18 @@ def trigger_daily_settlement(): @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') @@ -17300,7 +17101,7 @@ def reset_simulation_account(): return jsonify({ 'success': True, - 'message': '模拟账户已重置' + 'message': '妯℃嫙璐︽埛宸查噸缃? }) except Exception as e: @@ -17309,20 +17110,20 @@ def reset_simulation_account(): # =========================== -# 预测市场 API 路由 -# 请将此文件内容插入到 app.py 的 `if __name__ == '__main__':` 之前 +# 棰勬祴甯傚満 API 璺敱 +# 璇峰皢姝ゆ枃浠跺唴瀹规彃鍏ュ埌 app.py 鐨?`if __name__ == '__main__':` 涔嬪墠 # =========================== -# --- 积分系统 API --- +# --- 绉垎绯荤粺 API --- @app.route('/api/prediction/credit/account', methods=['GET']) @login_required def get_credit_account(): - """获取用户积分账户""" + """鑾峰彇鐢ㄦ埛绉垎璐︽埛""" try: account = UserCreditAccount.query.filter_by(user_id=current_user.id).first() - # 如果账户不存在,自动创建 + # 濡傛灉璐︽埛涓嶅瓨鍦紝鑷姩鍒涘缓 if not account: account = UserCreditAccount(user_id=current_user.id) db.session.add(account) @@ -17347,7 +17148,7 @@ def get_credit_account(): @app.route('/api/prediction/credit/daily-bonus', methods=['POST']) @login_required def claim_daily_bonus(): - """领取每日奖励(100积分)""" + """棰嗗彇姣忔棩濂栧姳锛?00绉垎锛?"" try: account = UserCreditAccount.query.filter_by(user_id=current_user.id).first() @@ -17355,34 +17156,34 @@ def claim_daily_bonus(): account = UserCreditAccount(user_id=current_user.id) db.session.add(account) - # 检查是否已领取今日奖励 + # 妫€鏌ユ槸鍚﹀凡棰嗗彇浠婃棩濂栧姳 today = beijing_now().date() if account.last_daily_bonus_at and account.last_daily_bonus_at.date() == today: return jsonify({ 'success': False, - 'error': '今日奖励已领取' + 'error': '浠婃棩濂栧姳宸查鍙? }), 400 - # 发放奖励 + # 鍙戞斁濂栧姳 bonus_amount = 100.0 account.balance += bonus_amount account.total_earned += bonus_amount account.last_daily_bonus_at = beijing_now() - # 记录交易 + # 璁板綍浜ゆ槗 transaction = CreditTransaction( user_id=current_user.id, transaction_type='daily_bonus', amount=bonus_amount, balance_after=account.balance, - description='每日登录奖励' + description='姣忔棩鐧诲綍濂栧姳' ) db.session.add(transaction) db.session.commit() return jsonify({ 'success': True, - 'message': f'领取成功,获得 {bonus_amount} 积分', + 'message': f'棰嗗彇鎴愬姛锛岃幏寰?{bonus_amount} 绉垎', 'data': { 'bonus_amount': bonus_amount, 'new_balance': float(account.balance) @@ -17394,12 +17195,12 @@ def claim_daily_bonus(): return jsonify({'success': False, 'error': str(e)}), 500 -# --- 预测话题 API --- +# --- 棰勬祴璇濋 API --- @app.route('/api/prediction/topics', methods=['POST']) @login_required def create_prediction_topic(): - """创建预测话题(消耗100积分)""" + """鍒涘缓棰勬祴璇濋锛堟秷鑰?00绉垎锛?"" try: data = request.get_json() title = data.get('title', '').strip() @@ -17407,33 +17208,33 @@ def create_prediction_topic(): category = data.get('category', 'stock') deadline_str = data.get('deadline') - # 验证参数 + # 楠岃瘉鍙傛暟 if not title or len(title) < 5: - return jsonify({'success': False, 'error': '标题至少5个字符'}), 400 + return jsonify({'success': False, 'error': '鏍囬鑷冲皯5涓瓧绗?}), 400 if not deadline_str: - return jsonify({'success': False, 'error': '请设置截止时间'}), 400 + return jsonify({'success': False, 'error': '璇疯缃埅姝㈡椂闂?}), 400 - # 解析截止时间(移除时区信息以匹配数据库格式) + # 瑙f瀽鎴鏃堕棿锛堢Щ闄ゆ椂鍖轰俊鎭互鍖归厤鏁版嵁搴撴牸寮忥級 deadline = datetime.fromisoformat(deadline_str.replace('Z', '+00:00')) - # 移除时区信息,转换为naive datetime + # 绉婚櫎鏃跺尯淇℃伅锛岃浆鎹负naive datetime if deadline.tzinfo is not None: deadline = deadline.replace(tzinfo=None) if deadline <= beijing_now(): - return jsonify({'success': False, 'error': '截止时间必须在未来'}), 400 + return jsonify({'success': False, 'error': '鎴鏃堕棿蹇呴』鍦ㄦ湭鏉?}), 400 - # 检查积分账户 + # 妫€鏌ョН鍒嗚处鎴? account = UserCreditAccount.query.filter_by(user_id=current_user.id).first() if not account or account.balance < 100: - return jsonify({'success': False, 'error': '积分不足(需要100积分)'}), 400 + return jsonify({'success': False, 'error': '绉垎涓嶈冻锛堥渶瑕?00绉垎锛?}), 400 - # 扣除创建费用 + # 鎵i櫎鍒涘缓璐圭敤 create_cost = 100.0 account.balance -= create_cost account.total_spent += create_cost - # 创建话题 + # 鍒涘缓璇濋 topic = PredictionTopic( creator_id=current_user.id, title=title, @@ -17443,20 +17244,20 @@ def create_prediction_topic(): ) db.session.add(topic) - # 记录积分交易 + # 璁板綍绉垎浜ゆ槗 transaction = CreditTransaction( user_id=current_user.id, transaction_type='create_topic', amount=-create_cost, balance_after=account.balance, - description=f'创建预测话题:{title}' + description=f'鍒涘缓棰勬祴璇濋锛歿title}' ) db.session.add(transaction) db.session.commit() return jsonify({ 'success': True, - 'message': '话题创建成功', + 'message': '璇濋鍒涘缓鎴愬姛', 'data': { 'topic_id': topic.id, 'title': topic.title, @@ -17471,7 +17272,7 @@ def create_prediction_topic(): @app.route('/api/prediction/topics', methods=['GET']) def get_prediction_topics(): - """获取预测话题列表""" + """鑾峰彇棰勬祴璇濋鍒楄〃""" try: status = request.args.get('status', 'active') category = request.args.get('category') @@ -17479,7 +17280,7 @@ def get_prediction_topics(): page = request.args.get('page', 1, type=int) per_page = request.args.get('per_page', 20, type=int) - # 构建查询 + # 鏋勫缓鏌ヨ query = PredictionTopic.query if status: @@ -17487,7 +17288,7 @@ def get_prediction_topics(): if category: query = query.filter_by(category=category) - # 排序 + # 鎺掑簭 if sort_by == 'hot': query = query.order_by(desc(PredictionTopic.views_count)) elif sort_by == 'participants': @@ -17495,18 +17296,18 @@ def get_prediction_topics(): else: query = query.order_by(desc(PredictionTopic.created_at)) - # 分页 + # 鍒嗛〉 pagination = query.paginate(page=page, per_page=per_page, error_out=False) topics = pagination.items - # 格式化返回数据 + # 鏍煎紡鍖栬繑鍥炴暟鎹? topics_data = [] for topic in topics: - # 计算市场倾向 + # 璁$畻甯傚満鍊惧悜 total_shares = topic.yes_total_shares + topic.no_total_shares yes_prob = (topic.yes_total_shares / total_shares * 100) if total_shares > 0 else 50.0 - # 处理datetime,确保移除时区信息 + # 澶勭悊datetime锛岀‘淇濈Щ闄ゆ椂鍖轰俊鎭? deadline = topic.deadline if hasattr(deadline, 'replace') and deadline.tzinfo is not None: deadline = deadline.replace(tzinfo=None) @@ -17564,29 +17365,29 @@ def get_prediction_topics(): except Exception as e: import traceback - print(f"[ERROR] 获取话题列表失败: {str(e)}") + print(f"[ERROR] 鑾峰彇璇濋鍒楄〃澶辫触: {str(e)}") print(traceback.format_exc()) return jsonify({'success': False, 'error': str(e)}), 500 @app.route('/api/prediction/topics/', methods=['GET']) def get_prediction_topic_detail(topic_id): - """获取预测话题详情""" + """鑾峰彇棰勬祴璇濋璇︽儏""" try: - # 刷新会话,确保获取最新数据 + # 鍒锋柊浼氳瘽锛岀‘淇濊幏鍙栨渶鏂版暟鎹? db.session.expire_all() topic = PredictionTopic.query.get_or_404(topic_id) - # 增加浏览量 + # 澧炲姞娴忚閲? topic.views_count += 1 db.session.commit() - # 计算市场倾向 + # 璁$畻甯傚満鍊惧悜 total_shares = topic.yes_total_shares + topic.no_total_shares yes_prob = (topic.yes_total_shares / total_shares * 100) if total_shares > 0 else 50.0 - # 获取 TOP 5 持仓(YES 和 NO 各5个) + # 鑾峰彇 TOP 5 鎸佷粨锛圷ES 鍜?NO 鍚?涓級 yes_top_positions = PredictionPosition.query.filter_by( topic_id=topic_id, direction='yes' @@ -17664,37 +17465,37 @@ def get_prediction_topic_detail(topic_id): @app.route('/api/prediction/topics//settle', methods=['POST']) @login_required def settle_prediction_topic(topic_id): - """结算预测话题(仅创建者可操作)""" + """缁撶畻棰勬祴璇濋锛堜粎鍒涘缓鑰呭彲鎿嶄綔锛?"" try: topic = PredictionTopic.query.get_or_404(topic_id) - # 验证权限 + # 楠岃瘉鏉冮檺 if topic.creator_id != current_user.id: - return jsonify({'success': False, 'error': '只有创建者可以结算'}), 403 + return jsonify({'success': False, 'error': '鍙湁鍒涘缓鑰呭彲浠ョ粨绠?}), 403 - # 验证状态 + # 楠岃瘉鐘舵€? if topic.status != 'active': - return jsonify({'success': False, 'error': '话题已结算或已取消'}), 400 + return jsonify({'success': False, 'error': '璇濋宸茬粨绠楁垨宸插彇娑?}), 400 - # 验证截止时间 + # 楠岃瘉鎴鏃堕棿 if beijing_now() < topic.deadline: - return jsonify({'success': False, 'error': '未到截止时间'}), 400 + return jsonify({'success': False, 'error': '鏈埌鎴鏃堕棿'}), 400 - # 获取结算结果 + # 鑾峰彇缁撶畻缁撴灉 data = request.get_json() result = data.get('result') # 'yes', 'no', 'draw' if result not in ['yes', 'no', 'draw']: - return jsonify({'success': False, 'error': '无效的结算结果'}), 400 + return jsonify({'success': False, 'error': '鏃犳晥鐨勭粨绠楃粨鏋?}), 400 - # 更新话题状态 + # 鏇存柊璇濋鐘舵€? topic.status = 'settled' topic.result = result topic.settled_at = beijing_now() - # 获取获胜方的所有持仓 + # 鑾峰彇鑾疯儨鏂圭殑鎵€鏈夋寔浠? if result == 'draw': - # 平局:所有人按投入比例分配奖池 + # 骞冲眬锛氭墍鏈変汉鎸夋姇鍏ユ瘮渚嬪垎閰嶅姹? all_positions = PredictionPosition.query.filter_by(topic_id=topic_id).all() total_invested = sum(p.total_invested for p in all_positions) @@ -17703,25 +17504,25 @@ def settle_prediction_topic(topic_id): share_ratio = position.total_invested / total_invested prize = topic.total_pool * share_ratio - # 发放奖金 + # 鍙戞斁濂栭噾 account = UserCreditAccount.query.filter_by(user_id=position.user_id).first() if account: account.balance += prize account.total_earned += prize - # 记录交易 + # 璁板綍浜ゆ槗 transaction = CreditTransaction( user_id=position.user_id, transaction_type='settle_win', amount=prize, balance_after=account.balance, related_topic_id=topic_id, - description=f'预测平局,获得奖池分红:{topic.title}' + description=f'棰勬祴骞冲眬锛岃幏寰楀姹犲垎绾細{topic.title}' ) db.session.add(transaction) else: - # YES 或 NO 获胜 + # YES 鎴?NO 鑾疯儨 winning_direction = result winning_positions = PredictionPosition.query.filter_by( topic_id=topic_id, @@ -17732,24 +17533,24 @@ def settle_prediction_topic(topic_id): total_winning_shares = sum(p.shares for p in winning_positions) for position in winning_positions: - # 按份额比例分配奖池 + # 鎸変唤棰濇瘮渚嬪垎閰嶅姹? share_ratio = position.shares / total_winning_shares prize = topic.total_pool * share_ratio - # 发放奖金 + # 鍙戞斁濂栭噾 account = UserCreditAccount.query.filter_by(user_id=position.user_id).first() if account: account.balance += prize account.total_earned += prize - # 记录交易 + # 璁板綍浜ゆ槗 transaction = CreditTransaction( user_id=position.user_id, transaction_type='settle_win', amount=prize, balance_after=account.balance, related_topic_id=topic_id, - description=f'预测正确,获得奖金:{topic.title}' + description=f'棰勬祴姝g‘锛岃幏寰楀閲戯細{topic.title}' ) db.session.add(transaction) @@ -17757,7 +17558,7 @@ def settle_prediction_topic(topic_id): return jsonify({ 'success': True, - 'message': f'话题已结算,结果为:{result}', + 'message': f'璇濋宸茬粨绠楋紝缁撴灉涓猴細{result}', 'data': { 'topic_id': topic.id, 'result': result, @@ -17771,45 +17572,45 @@ def settle_prediction_topic(topic_id): return jsonify({'success': False, 'error': str(e)}), 500 -# --- 交易 API --- +# --- 浜ゆ槗 API --- @app.route('/api/prediction/trade/buy', methods=['POST']) @login_required def buy_prediction_shares(): - """买入预测份额""" + """涔板叆棰勬祴浠介""" try: data = request.get_json() topic_id = data.get('topic_id') direction = data.get('direction') # 'yes' or 'no' shares = data.get('shares', 0) - # 验证参数 + # 楠岃瘉鍙傛暟 if not topic_id or direction not in ['yes', 'no'] or shares <= 0: - return jsonify({'success': False, 'error': '参数错误'}), 400 + return jsonify({'success': False, 'error': '鍙傛暟閿欒'}), 400 if shares > 1000: - return jsonify({'success': False, 'error': '单次最多买入1000份额'}), 400 + return jsonify({'success': False, 'error': '鍗曟鏈€澶氫拱鍏?000浠介'}), 400 - # 获取话题 + # 鑾峰彇璇濋 topic = PredictionTopic.query.get_or_404(topic_id) if topic.status != 'active': - return jsonify({'success': False, 'error': '话题已结算或已取消'}), 400 + return jsonify({'success': False, 'error': '璇濋宸茬粨绠楁垨宸插彇娑?}), 400 if beijing_now() >= topic.deadline: - return jsonify({'success': False, 'error': '话题已截止'}), 400 + return jsonify({'success': False, 'error': '璇濋宸叉埅姝?}), 400 - # 获取积分账户 + # 鑾峰彇绉垎璐︽埛 account = UserCreditAccount.query.filter_by(user_id=current_user.id).first() if not account: account = UserCreditAccount(user_id=current_user.id) db.session.add(account) db.session.flush() - # 计算价格 + # 璁$畻浠锋牸 current_price = topic.yes_price if direction == 'yes' else topic.no_price - # 简化的AMM定价:price = (对应方份额 / 总份额) * 1000 + # 绠€鍖栫殑AMM瀹氫环锛歱rice = (瀵瑰簲鏂逛唤棰?/ 鎬讳唤棰? * 1000 total_shares = topic.yes_total_shares + topic.no_total_shares if total_shares > 0: if direction == 'yes': @@ -17817,9 +17618,9 @@ def buy_prediction_shares(): else: current_price = (topic.no_total_shares / total_shares) * 1000 else: - current_price = 500.0 # 初始价格 + current_price = 500.0 # 鍒濆浠锋牸 - # 买入后价格会上涨,使用平均价格 + # 涔板叆鍚庝环鏍间細涓婃定锛屼娇鐢ㄥ钩鍧囦环鏍? after_total = total_shares + shares if direction == 'yes': after_yes_shares = topic.yes_total_shares + shares @@ -17830,20 +17631,20 @@ def buy_prediction_shares(): avg_price = (current_price + after_price) / 2 - # 计算费用 + # 璁$畻璐圭敤 amount = avg_price * shares - tax = amount * 0.02 # 2% 手续费 + tax = amount * 0.02 # 2% 鎵嬬画璐? total_cost = amount + tax - # 检查余额 + # 妫€鏌ヤ綑棰? if account.balance < total_cost: - return jsonify({'success': False, 'error': '积分不足'}), 400 + return jsonify({'success': False, 'error': '绉垎涓嶈冻'}), 400 - # 扣除费用 + # 鎵i櫎璐圭敤 account.balance -= total_cost account.total_spent += total_cost - # 更新话题数据 + # 鏇存柊璇濋鏁版嵁 if direction == 'yes': topic.yes_total_shares += shares topic.yes_price = after_price @@ -17851,9 +17652,9 @@ def buy_prediction_shares(): topic.no_total_shares += shares topic.no_price = after_price - topic.total_pool += tax # 手续费进入奖池 + topic.total_pool += tax # 鎵嬬画璐硅繘鍏ュ姹? - # 更新或创建持仓 + # 鏇存柊鎴栧垱寤烘寔浠? position = PredictionPosition.query.filter_by( user_id=current_user.id, topic_id=topic_id, @@ -17861,7 +17662,7 @@ def buy_prediction_shares(): ).first() if position: - # 更新平均成本 + # 鏇存柊骞冲潎鎴愭湰 old_cost = position.avg_cost * position.shares new_cost = avg_price * shares position.shares += shares @@ -17879,9 +17680,9 @@ def buy_prediction_shares(): db.session.add(position) topic.participants_count += 1 - # 更新领主 + # 鏇存柊棰嗕富 if direction == 'yes': - # 找到YES方持仓最多的用户 + # 鎵惧埌YES鏂规寔浠撴渶澶氱殑鐢ㄦ埛 top_yes = db.session.query(PredictionPosition).filter_by( topic_id=topic_id, direction='yes' @@ -17889,7 +17690,7 @@ def buy_prediction_shares(): if top_yes: topic.yes_lord_id = top_yes.user_id else: - # 找到NO方持仓最多的用户 + # 鎵惧埌NO鏂规寔浠撴渶澶氱殑鐢ㄦ埛 top_no = db.session.query(PredictionPosition).filter_by( topic_id=topic_id, direction='no' @@ -17897,7 +17698,7 @@ def buy_prediction_shares(): if top_no: topic.no_lord_id = top_no.user_id - # 记录交易 + # 璁板綍浜ゆ槗 transaction = PredictionTransaction( user_id=current_user.id, topic_id=topic_id, @@ -17911,7 +17712,7 @@ def buy_prediction_shares(): ) db.session.add(transaction) - # 记录积分交易 + # 璁板綍绉垎浜ゆ槗 credit_transaction = CreditTransaction( user_id=current_user.id, transaction_type='prediction_buy', @@ -17919,7 +17720,7 @@ def buy_prediction_shares(): balance_after=account.balance, related_topic_id=topic_id, related_transaction_id=transaction.id, - description=f'买入 {direction.upper()} 份额:{topic.title}' + description=f'涔板叆 {direction.upper()} 浠介锛歿topic.title}' ) db.session.add(credit_transaction) @@ -17927,7 +17728,7 @@ def buy_prediction_shares(): return jsonify({ 'success': True, - 'message': '买入成功', + 'message': '涔板叆鎴愬姛', 'data': { 'transaction_id': transaction.id, 'shares': shares, @@ -17950,7 +17751,7 @@ def buy_prediction_shares(): @app.route('/api/prediction/positions', methods=['GET']) @login_required def get_user_positions(): - """获取用户的所有持仓""" + """鑾峰彇鐢ㄦ埛鐨勬墍鏈夋寔浠?"" try: positions = PredictionPosition.query.filter_by(user_id=current_user.id).all() @@ -17958,7 +17759,7 @@ def get_user_positions(): for position in positions: topic = position.topic - # 计算当前市值(如果话题还在进行中) + # 璁$畻褰撳墠甯傚€硷紙濡傛灉璇濋杩樺湪杩涜涓級 current_value = 0 profit = 0 profit_rate = 0 @@ -18000,12 +17801,12 @@ def get_user_positions(): return jsonify({'success': False, 'error': str(e)}), 500 -# --- 评论 API --- +# --- 璇勮 API --- @app.route('/api/prediction/topics//comments', methods=['POST']) @login_required def create_topic_comment(topic_id): - """发表话题评论""" + """鍙戣〃璇濋璇勮""" try: topic = PredictionTopic.query.get_or_404(topic_id) @@ -18014,9 +17815,9 @@ def create_topic_comment(topic_id): parent_id = data.get('parent_id') if not content or len(content) < 2: - return jsonify({'success': False, 'error': '评论内容至少2个字符'}), 400 + return jsonify({'success': False, 'error': '璇勮鍐呭鑷冲皯2涓瓧绗?}), 400 - # 创建评论 + # 鍒涘缓璇勮 comment = TopicComment( topic_id=topic_id, user_id=current_user.id, @@ -18024,21 +17825,21 @@ def create_topic_comment(topic_id): parent_id=parent_id ) - # 如果是领主评论,自动置顶 + # 濡傛灉鏄涓昏瘎璁猴紝鑷姩缃《 is_lord = (topic.yes_lord_id == current_user.id) or (topic.no_lord_id == current_user.id) if is_lord: comment.is_pinned = True db.session.add(comment) - # 更新话题评论数 + # 鏇存柊璇濋璇勮鏁? topic.comments_count += 1 db.session.commit() return jsonify({ 'success': True, - 'message': '评论成功', + 'message': '璇勮鎴愬姛', 'data': { 'comment_id': comment.id, 'content': comment.content, @@ -18054,18 +17855,18 @@ def create_topic_comment(topic_id): @app.route('/api/prediction/topics//comments', methods=['GET']) def get_topic_comments(topic_id): - """获取话题评论列表""" + """鑾峰彇璇濋璇勮鍒楄〃""" try: topic = PredictionTopic.query.get_or_404(topic_id) page = request.args.get('page', 1, type=int) per_page = request.args.get('per_page', 20, type=int) - # 置顶评论在前,然后按时间倒序 + # 缃《璇勮鍦ㄥ墠锛岀劧鍚庢寜鏃堕棿鍊掑簭 query = TopicComment.query.filter_by( topic_id=topic_id, status='active', - parent_id=None # 只获取顶级评论 + parent_id=None # 鍙幏鍙栭《绾ц瘎璁? ).order_by( desc(TopicComment.is_pinned), desc(TopicComment.created_at) @@ -18075,7 +17876,7 @@ def get_topic_comments(topic_id): comments = pagination.items def format_comment(comment): - # 获取回复 + # 鑾峰彇鍥炲 replies = TopicComment.query.filter_by( parent_id=comment.id, status='active' @@ -18127,23 +17928,23 @@ def get_topic_comments(topic_id): @app.route('/api/prediction/comments//like', methods=['POST']) @login_required def like_topic_comment(comment_id): - """点赞/取消点赞评论""" + """鐐硅禐/鍙栨秷鐐硅禐璇勮""" try: comment = TopicComment.query.get_or_404(comment_id) - # 检查是否已点赞 + # 妫€鏌ユ槸鍚﹀凡鐐硅禐 existing_like = TopicCommentLike.query.filter_by( comment_id=comment_id, user_id=current_user.id ).first() if existing_like: - # 取消点赞 + # 鍙栨秷鐐硅禐 db.session.delete(existing_like) comment.likes_count = max(0, comment.likes_count - 1) action = 'unliked' else: - # 点赞 + # 鐐硅禐 like = TopicCommentLike( comment_id=comment_id, user_id=current_user.id @@ -18165,46 +17966,46 @@ def like_topic_comment(comment_id): return jsonify({'success': False, 'error': str(e)}), 500 -# ==================== 观点IPO API ==================== +# ==================== 瑙傜偣IPO API ==================== @app.route('/api/prediction/comments//invest', methods=['POST']) @login_required def invest_comment(comment_id): - """投资评论(观点IPO)""" + """鎶曡祫璇勮锛堣鐐笽PO锛?"" try: data = request.json shares = data.get('shares', 1) - # 获取评论 + # 鑾峰彇璇勮 comment = TopicComment.query.get_or_404(comment_id) - # 检查评论是否已结算 + # 妫€鏌ヨ瘎璁烘槸鍚﹀凡缁撶畻 if comment.is_verified: - return jsonify({'success': False, 'error': '该评论已结算,无法继续投资'}), 400 + return jsonify({'success': False, 'error': '璇ヨ瘎璁哄凡缁撶畻锛屾棤娉曠户缁姇璧?}), 400 - # 检查是否是自己的评论 + # 妫€鏌ユ槸鍚︽槸鑷繁鐨勮瘎璁? if comment.user_id == current_user.id: - return jsonify({'success': False, 'error': '不能投资自己的评论'}), 400 + return jsonify({'success': False, 'error': '涓嶈兘鎶曡祫鑷繁鐨勮瘎璁?}), 400 - # 计算投资金额(简化:每份100积分基础价格 + 已有投资额/10) + # 璁$畻鎶曡祫閲戦锛堢畝鍖栵細姣忎唤100绉垎鍩虹浠锋牸 + 宸叉湁鎶曡祫棰?10锛? base_price = 100 price_increase = comment.total_investment / 10 if comment.total_investment > 0 else 0 price_per_share = base_price + price_increase amount = int(price_per_share * shares) - # 获取用户积分账户 + # 鑾峰彇鐢ㄦ埛绉垎璐︽埛 account = UserCreditAccount.query.filter_by(user_id=current_user.id).first() if not account: - return jsonify({'success': False, 'error': '账户不存在'}), 404 + return jsonify({'success': False, 'error': '璐︽埛涓嶅瓨鍦?}), 404 - # 检查余额 + # 妫€鏌ヤ綑棰? if account.balance < amount: - return jsonify({'success': False, 'error': '积分不足'}), 400 + return jsonify({'success': False, 'error': '绉垎涓嶈冻'}), 400 - # 扣减积分 + # 鎵e噺绉垎 account.balance -= amount - # 检查是否已有投资记录 + # 妫€鏌ユ槸鍚﹀凡鏈夋姇璧勮褰? existing_investment = CommentInvestment.query.filter_by( comment_id=comment_id, user_id=current_user.id, @@ -18212,14 +18013,14 @@ def invest_comment(comment_id): ).first() if existing_investment: - # 更新投资记录 + # 鏇存柊鎶曡祫璁板綍 total_shares = existing_investment.shares + shares total_amount = existing_investment.amount + amount existing_investment.shares = total_shares existing_investment.amount = total_amount existing_investment.avg_price = total_amount / total_shares else: - # 创建新投资记录 + # 鍒涘缓鏂版姇璧勮褰? investment = CommentInvestment( comment_id=comment_id, user_id=current_user.id, @@ -18230,16 +18031,16 @@ def invest_comment(comment_id): db.session.add(investment) comment.investor_count += 1 - # 更新评论统计 + # 鏇存柊璇勮缁熻 comment.total_investment += amount - # 记录积分交易 + # 璁板綍绉垎浜ゆ槗 transaction = CreditTransaction( user_id=current_user.id, type='comment_investment', amount=-amount, balance_after=account.balance, - description=f'投资评论 #{comment_id}' + description=f'鎶曡祫璇勮 #{comment_id}' ) db.session.add(transaction) @@ -18264,7 +18065,7 @@ def invest_comment(comment_id): @app.route('/api/prediction/comments//investments', methods=['GET']) def get_comment_investments(comment_id): - """获取评论的投资列表""" + """鑾峰彇璇勮鐨勬姇璧勫垪琛?"" try: investments = CommentInvestment.query.filter_by( comment_id=comment_id, @@ -18277,7 +18078,7 @@ def get_comment_investments(comment_id): result.append({ 'id': inv.id, 'user_id': inv.user_id, - 'user_name': user.username if user else '未知用户', + 'user_name': user.username if user else '鏈煡鐢ㄦ埛', 'user_avatar': user.avatar if user else None, 'shares': inv.shares, 'amount': inv.amount, @@ -18297,65 +18098,65 @@ def get_comment_investments(comment_id): @app.route('/api/prediction/comments//verify', methods=['POST']) @login_required def verify_comment(comment_id): - """管理员验证评论预测结果""" + """绠$悊鍛橀獙璇佽瘎璁洪娴嬬粨鏋?"" try: - # 检查管理员权限(简化版:假设 user_id=1 是管理员) + # 妫€鏌ョ鐞嗗憳鏉冮檺锛堢畝鍖栫増锛氬亣璁?user_id=1 鏄鐞嗗憳锛? if current_user.id != 1: - return jsonify({'success': False, 'error': '无权限操作'}), 403 + return jsonify({'success': False, 'error': '鏃犳潈闄愭搷浣?}), 403 data = request.json result = data.get('result') # 'correct' or 'incorrect' if result not in ['correct', 'incorrect']: - return jsonify({'success': False, 'error': '无效的验证结果'}), 400 + return jsonify({'success': False, 'error': '鏃犳晥鐨勯獙璇佺粨鏋?}), 400 comment = TopicComment.query.get_or_404(comment_id) - # 检查是否已验证 + # 妫€鏌ユ槸鍚﹀凡楠岃瘉 if comment.is_verified: - return jsonify({'success': False, 'error': '该评论已验证'}), 400 + return jsonify({'success': False, 'error': '璇ヨ瘎璁哄凡楠岃瘉'}), 400 - # 更新验证状态 + # 鏇存柊楠岃瘉鐘舵€? comment.is_verified = True comment.verification_result = result - # 如果预测正确,进行收益分配 + # 濡傛灉棰勬祴姝g‘锛岃繘琛屾敹鐩婂垎閰? if result == 'correct' and comment.total_investment > 0: - # 获取所有投资记录 + # 鑾峰彇鎵€鏈夋姇璧勮褰? investments = CommentInvestment.query.filter_by( comment_id=comment_id, status='active' ).all() - # 计算总收益(总投资额的1.5倍) + # 璁$畻鎬绘敹鐩婏紙鎬绘姇璧勯鐨?.5鍊嶏級 total_reward = int(comment.total_investment * 1.5) - # 按份额比例分配收益 + # 鎸変唤棰濇瘮渚嬪垎閰嶆敹鐩? total_shares = sum([inv.shares for inv in investments]) for inv in investments: - # 计算该投资者的收益 + # 璁$畻璇ユ姇璧勮€呯殑鏀剁泭 investor_reward = int((inv.shares / total_shares) * total_reward) - # 获取投资者账户 + # 鑾峰彇鎶曡祫鑰呰处鎴? account = UserCreditAccount.query.filter_by(user_id=inv.user_id).first() if account: account.balance += investor_reward - # 记录积分交易 + # 璁板綍绉垎浜ゆ槗 transaction = CreditTransaction( user_id=inv.user_id, type='comment_investment_profit', amount=investor_reward, balance_after=account.balance, - description=f'评论投资收益 #{comment_id}' + description=f'璇勮鎶曡祫鏀剁泭 #{comment_id}' ) db.session.add(transaction) - # 更新投资状态 + # 鏇存柊鎶曡祫鐘舵€? inv.status = 'settled' - # 评论作者也获得奖励(总投资额的20%) + # 璇勮浣滆€呬篃鑾峰緱濂栧姳锛堟€绘姇璧勯鐨?0%锛? author_reward = int(comment.total_investment * 0.2) author_account = UserCreditAccount.query.filter_by(user_id=comment.user_id).first() if author_account: @@ -18366,7 +18167,7 @@ def verify_comment(comment_id): type='comment_author_bonus', amount=author_reward, balance_after=author_account.balance, - description=f'评论作者奖励 #{comment_id}' + description=f'璇勮浣滆€呭鍔?#{comment_id}' ) db.session.add(transaction) @@ -18389,24 +18190,24 @@ def verify_comment(comment_id): @app.route('/api/prediction/topics//bid-position', methods=['POST']) @login_required def bid_comment_position(topic_id): - """竞拍评论位置(首发权拍卖)""" + """绔炴媿璇勮浣嶇疆锛堥鍙戞潈鎷嶅崠锛?"" try: data = request.json position = data.get('position') # 1/2/3 bid_amount = data.get('bid_amount') if position not in [1, 2, 3]: - return jsonify({'success': False, 'error': '无效的位置'}), 400 + return jsonify({'success': False, 'error': '鏃犳晥鐨勪綅缃?}), 400 if bid_amount < 500: - return jsonify({'success': False, 'error': '最低出价500积分'}), 400 + return jsonify({'success': False, 'error': '鏈€浣庡嚭浠?00绉垎'}), 400 - # 获取用户积分账户 + # 鑾峰彇鐢ㄦ埛绉垎璐︽埛 account = UserCreditAccount.query.filter_by(user_id=current_user.id).first() if not account or account.balance < bid_amount: - return jsonify({'success': False, 'error': '积分不足'}), 400 + return jsonify({'success': False, 'error': '绉垎涓嶈冻'}), 400 - # 检查该位置的当前最高出价 + # 妫€鏌ヨ浣嶇疆鐨勫綋鍓嶆渶楂樺嚭浠? current_highest = CommentPositionBid.query.filter_by( topic_id=topic_id, position=position, @@ -18416,14 +18217,14 @@ def bid_comment_position(topic_id): if current_highest and bid_amount <= current_highest.bid_amount: return jsonify({ 'success': False, - 'error': f'出价必须高于当前最高价 {current_highest.bid_amount}' + 'error': f'鍑轰环蹇呴』楂樹簬褰撳墠鏈€楂樹环 {current_highest.bid_amount}' }), 400 - # 扣减积分(冻结) + # 鎵e噺绉垎锛堝喕缁擄級 account.balance -= bid_amount account.frozen += bid_amount - # 如果有之前的出价,退还积分 + # 濡傛灉鏈変箣鍓嶇殑鍑轰环锛岄€€杩樼Н鍒? user_previous_bid = CommentPositionBid.query.filter_by( topic_id=topic_id, position=position, @@ -18436,24 +18237,24 @@ def bid_comment_position(topic_id): account.balance += user_previous_bid.bid_amount user_previous_bid.status = 'lost' - # 创建竞拍记录 + # 鍒涘缓绔炴媿璁板綍 topic = PredictionTopic.query.get_or_404(topic_id) bid = CommentPositionBid( topic_id=topic_id, user_id=current_user.id, position=position, bid_amount=bid_amount, - expires_at=topic.deadline # 竞拍截止时间与话题截止时间相同 + expires_at=topic.deadline # 绔炴媿鎴鏃堕棿涓庤瘽棰樻埅姝㈡椂闂寸浉鍚? ) db.session.add(bid) - # 记录积分交易 + # 璁板綍绉垎浜ゆ槗 transaction = CreditTransaction( user_id=current_user.id, type='position_bid', amount=-bid_amount, balance_after=account.balance, - description=f'竞拍评论位置 #{position} (话题#{topic_id})' + description=f'绔炴媿璇勮浣嶇疆 #{position} (璇濋#{topic_id})' ) db.session.add(transaction) @@ -18477,7 +18278,7 @@ def bid_comment_position(topic_id): @app.route('/api/prediction/topics//position-bids', methods=['GET']) def get_position_bids(topic_id): - """获取话题的位置竞拍列表""" + """鑾峰彇璇濋鐨勪綅缃珵鎷嶅垪琛?"" try: result = {} @@ -18494,7 +18295,7 @@ def get_position_bids(topic_id): position_bids.append({ 'id': bid.id, 'user_id': bid.user_id, - 'user_name': user.username if user else '未知用户', + 'user_name': user.username if user else '鏈煡鐢ㄦ埛', 'user_avatar': user.avatar if user else None, 'bid_amount': bid.bid_amount, 'created_at': bid.created_at.strftime('%Y-%m-%d %H:%M:%S') @@ -18511,12 +18312,12 @@ def get_position_bids(topic_id): return jsonify({'success': False, 'error': str(e)}), 500 -# ==================== 时间胶囊 API ==================== +# ==================== 鏃堕棿鑳跺泭 API ==================== @app.route('/api/time-capsule/topics', methods=['POST']) @login_required def create_time_capsule_topic(): - """创建时间胶囊话题""" + """鍒涘缓鏃堕棿鑳跺泭璇濋""" try: data = request.json title = data.get('title') @@ -18526,22 +18327,22 @@ def create_time_capsule_topic(): start_year = data.get('start_year') end_year = data.get('end_year') - # 验证 + # 楠岃瘉 if not title or not encrypted_content or not encryption_key: - return jsonify({'success': False, 'error': '缺少必要参数'}), 400 + return jsonify({'success': False, 'error': '缂哄皯蹇呰鍙傛暟'}), 400 if not start_year or not end_year or end_year <= start_year: - return jsonify({'success': False, 'error': '无效的时间范围'}), 400 + return jsonify({'success': False, 'error': '鏃犳晥鐨勬椂闂磋寖鍥?}), 400 - # 获取用户积分账户 + # 鑾峰彇鐢ㄦ埛绉垎璐︽埛 account = UserCreditAccount.query.filter_by(user_id=current_user.id).first() if not account or account.balance < 100: - return jsonify({'success': False, 'error': '积分不足,需要100积分'}), 400 + return jsonify({'success': False, 'error': '绉垎涓嶈冻锛岄渶瑕?00绉垎'}), 400 - # 扣减积分 + # 鎵e噺绉垎 account.balance -= 100 - # 创建话题 + # 鍒涘缓璇濋 topic = TimeCapsuleTopic( user_id=current_user.id, title=title, @@ -18550,12 +18351,12 @@ def create_time_capsule_topic(): encryption_key=encryption_key, start_year=start_year, end_year=end_year, - total_pool=100 # 创建费用进入奖池 + total_pool=100 # 鍒涘缓璐圭敤杩涘叆濂栨睜 ) db.session.add(topic) - db.session.flush() # 获取 topic.id + db.session.flush() # 鑾峰彇 topic.id - # 自动创建时间段(每年一个时间段) + # 鑷姩鍒涘缓鏃堕棿娈碉紙姣忓勾涓€涓椂闂存锛? for year in range(start_year, end_year + 1): slot = TimeCapsuleTimeSlot( topic_id=topic.id, @@ -18564,13 +18365,13 @@ def create_time_capsule_topic(): ) db.session.add(slot) - # 记录积分交易 + # 璁板綍绉垎浜ゆ槗 transaction = CreditTransaction( user_id=current_user.id, type='time_capsule_create', amount=-100, balance_after=account.balance, - description=f'创建时间胶囊话题 #{topic.id}' + description=f'鍒涘缓鏃堕棿鑳跺泭璇濋 #{topic.id}' ) db.session.add(transaction) @@ -18592,7 +18393,7 @@ def create_time_capsule_topic(): @app.route('/api/time-capsule/topics', methods=['GET']) def get_time_capsule_topics(): - """获取时间胶囊话题列表""" + """鑾峰彇鏃堕棿鑳跺泭璇濋鍒楄〃""" try: status = request.args.get('status', 'active') @@ -18601,10 +18402,10 @@ def get_time_capsule_topics(): result = [] for topic in topics: - # 获取用户信息 + # 鑾峰彇鐢ㄦ埛淇℃伅 user = User.query.get(topic.user_id) - # 获取时间段统计 + # 鑾峰彇鏃堕棿娈电粺璁? slots = TimeCapsuleTimeSlot.query.filter_by(topic_id=topic.id).all() total_slots = len(slots) active_slots = len([s for s in slots if s.status == 'active']) @@ -18621,7 +18422,7 @@ def get_time_capsule_topics(): 'is_decrypted': topic.is_decrypted, 'status': topic.status, 'author_id': topic.user_id, - 'author_name': user.username if user else '未知用户', + 'author_name': user.username if user else '鏈煡鐢ㄦ埛', 'author_avatar': user.avatar if user else None, 'created_at': topic.created_at.strftime('%Y-%m-%d %H:%M:%S') }) @@ -18637,12 +18438,12 @@ def get_time_capsule_topics(): @app.route('/api/time-capsule/topics/', methods=['GET']) def get_time_capsule_topic(topic_id): - """获取时间胶囊话题详情""" + """鑾峰彇鏃堕棿鑳跺泭璇濋璇︽儏""" try: topic = TimeCapsuleTopic.query.get_or_404(topic_id) user = User.query.get(topic.user_id) - # 获取所有时间段 + # 鑾峰彇鎵€鏈夋椂闂存 slots = TimeCapsuleTimeSlot.query.filter_by(topic_id=topic_id).order_by(TimeCapsuleTimeSlot.year_start).all() slots_data = [] @@ -18673,7 +18474,7 @@ def get_time_capsule_topic(topic_id): 'actual_happened_year': topic.actual_happened_year, 'status': topic.status, 'author_id': topic.user_id, - 'author_name': user.username if user else '未知用户', + 'author_name': user.username if user else '鏈煡鐢ㄦ埛', 'author_avatar': user.avatar if user else None, 'time_slots': slots_data, 'created_at': topic.created_at.strftime('%Y-%m-%d %H:%M:%S') @@ -18691,40 +18492,40 @@ def get_time_capsule_topic(topic_id): @app.route('/api/time-capsule/slots//bid', methods=['POST']) @login_required def bid_time_slot(slot_id): - """竞拍时间段""" + """绔炴媿鏃堕棿娈?"" try: data = request.json bid_amount = data.get('bid_amount') slot = TimeCapsuleTimeSlot.query.get_or_404(slot_id) - # 检查时间段是否还在竞拍 + # 妫€鏌ユ椂闂存鏄惁杩樺湪绔炴媿 if slot.status != 'active': - return jsonify({'success': False, 'error': '该时间段已结束竞拍'}), 400 + return jsonify({'success': False, 'error': '璇ユ椂闂存宸茬粨鏉熺珵鎷?}), 400 - # 检查出价是否高于当前价格 - min_bid = slot.current_price + 50 # 至少比当前价格高50积分 + # 妫€鏌ュ嚭浠锋槸鍚﹂珮浜庡綋鍓嶄环鏍? + min_bid = slot.current_price + 50 # 鑷冲皯姣斿綋鍓嶄环鏍奸珮50绉垎 if bid_amount < min_bid: return jsonify({ 'success': False, - 'error': f'出价必须至少为 {min_bid} 积分' + 'error': f'鍑轰环蹇呴』鑷冲皯涓?{min_bid} 绉垎' }), 400 - # 获取用户积分账户 + # 鑾峰彇鐢ㄦ埛绉垎璐︽埛 account = UserCreditAccount.query.filter_by(user_id=current_user.id).first() if not account or account.balance < bid_amount: - return jsonify({'success': False, 'error': '积分不足'}), 400 + return jsonify({'success': False, 'error': '绉垎涓嶈冻'}), 400 - # 扣减积分 + # 鎵e噺绉垎 account.balance -= bid_amount - # 如果有前任持有者,退还积分 + # 濡傛灉鏈夊墠浠绘寔鏈夎€咃紝閫€杩樼Н鍒? if slot.current_holder_id: prev_holder_account = UserCreditAccount.query.filter_by(user_id=slot.current_holder_id).first() if prev_holder_account: prev_holder_account.balance += slot.current_price - # 更新前任的竞拍记录状态 + # 鏇存柊鍓嶄换鐨勭珵鎷嶈褰曠姸鎬? prev_bid = TimeSlotBid.query.filter_by( slot_id=slot_id, user_id=slot.current_holder_id, @@ -18733,7 +18534,7 @@ def bid_time_slot(slot_id): if prev_bid: prev_bid.status = 'outbid' - # 创建竞拍记录 + # 鍒涘缓绔炴媿璁板綍 bid = TimeSlotBid( slot_id=slot_id, user_id=current_user.id, @@ -18742,23 +18543,23 @@ def bid_time_slot(slot_id): ) db.session.add(bid) - # 更新时间段 + # 鏇存柊鏃堕棿娈? slot.current_holder_id = current_user.id slot.current_price = bid_amount slot.total_bids += 1 - # 更新话题奖池 + # 鏇存柊璇濋濂栨睜 topic = TimeCapsuleTopic.query.get(slot.topic_id) price_increase = bid_amount - (slot.current_price if slot.current_holder_id else 100) topic.total_pool += price_increase - # 记录积分交易 + # 璁板綍绉垎浜ゆ槗 transaction = CreditTransaction( user_id=current_user.id, type='time_slot_bid', amount=-bid_amount, balance_after=account.balance, - description=f'竞拍时间段 {slot.year_start}-{slot.year_end}' + description=f'绔炴媿鏃堕棿娈?{slot.year_start}-{slot.year_end}' ) db.session.add(transaction) @@ -18782,19 +18583,19 @@ def bid_time_slot(slot_id): @app.route('/api/time-capsule/topics//decrypt', methods=['POST']) @login_required def decrypt_time_capsule(topic_id): - """解密时间胶囊(管理员或作者)""" + """瑙e瘑鏃堕棿鑳跺泭锛堢鐞嗗憳鎴栦綔鑰咃級""" try: topic = TimeCapsuleTopic.query.get_or_404(topic_id) - # 检查权限(管理员或作者) + # 妫€鏌ユ潈闄愶紙绠$悊鍛樻垨浣滆€咃級 if current_user.id != 1 and current_user.id != topic.user_id: - return jsonify({'success': False, 'error': '无权限操作'}), 403 + return jsonify({'success': False, 'error': '鏃犳潈闄愭搷浣?}), 403 - # 检查是否已解密 + # 妫€鏌ユ槸鍚﹀凡瑙e瘑 if topic.is_decrypted: - return jsonify({'success': False, 'error': '该话题已解密'}), 400 + return jsonify({'success': False, 'error': '璇ヨ瘽棰樺凡瑙e瘑'}), 400 - # 解密(前端会用密钥解密内容) + # 瑙e瘑锛堝墠绔細鐢ㄥ瘑閽ヨВ瀵嗗唴瀹癸級 topic.is_decrypted = True db.session.commit() @@ -18815,48 +18616,48 @@ def decrypt_time_capsule(topic_id): @app.route('/api/time-capsule/topics//settle', methods=['POST']) @login_required def settle_time_capsule(topic_id): - """结算时间胶囊话题""" + """缁撶畻鏃堕棿鑳跺泭璇濋""" try: - # 检查管理员权限 + # 妫€鏌ョ鐞嗗憳鏉冮檺 if current_user.id != 1: - return jsonify({'success': False, 'error': '无权限操作'}), 403 + return jsonify({'success': False, 'error': '鏃犳潈闄愭搷浣?}), 403 data = request.json happened_year = data.get('happened_year') topic = TimeCapsuleTopic.query.get_or_404(topic_id) - # 检查是否已结算 + # 妫€鏌ユ槸鍚﹀凡缁撶畻 if topic.status == 'settled': - return jsonify({'success': False, 'error': '该话题已结算'}), 400 + return jsonify({'success': False, 'error': '璇ヨ瘽棰樺凡缁撶畻'}), 400 - # 更新话题状态 + # 鏇存柊璇濋鐘舵€? topic.status = 'settled' topic.actual_happened_year = happened_year - # 找到中奖的时间段 + # 鎵惧埌涓鐨勬椂闂存 winning_slot = TimeCapsuleTimeSlot.query.filter_by( topic_id=topic_id, year_start=happened_year ).first() if winning_slot and winning_slot.current_holder_id: - # 中奖者获得全部奖池 + # 涓鑰呰幏寰楀叏閮ㄥ姹? winner_account = UserCreditAccount.query.filter_by(user_id=winning_slot.current_holder_id).first() if winner_account: winner_account.balance += topic.total_pool - # 记录积分交易 + # 璁板綍绉垎浜ゆ槗 transaction = CreditTransaction( user_id=winning_slot.current_holder_id, type='time_capsule_win', amount=topic.total_pool, balance_after=winner_account.balance, - description=f'时间胶囊中奖 #{topic_id}' + description=f'鏃堕棿鑳跺泭涓 #{topic_id}' ) db.session.add(transaction) - # 更新竞拍记录 + # 鏇存柊绔炴媿璁板綍 winning_bid = TimeSlotBid.query.filter_by( slot_id=winning_slot.id, user_id=winning_slot.current_holder_id, @@ -18865,10 +18666,10 @@ def settle_time_capsule(topic_id): if winning_bid: winning_bid.status = 'won' - # 更新时间段状态 + # 鏇存柊鏃堕棿娈电姸鎬? winning_slot.status = 'won' - # 其他时间段设为过期 + # 鍏朵粬鏃堕棿娈佃涓鸿繃鏈? other_slots = TimeCapsuleTimeSlot.query.filter( TimeCapsuleTimeSlot.topic_id == topic_id, TimeCapsuleTimeSlot.id != (winning_slot.id if winning_slot else None) @@ -18895,24 +18696,18 @@ def settle_time_capsule(topic_id): if __name__ == '__main__': - # 创建数据库表 + # 鍒涘缓鏁版嵁搴撹〃 with app.app_context(): try: db.create_all() - # 安全地初始化订阅套餐 + # 瀹夊叏鍦板垵濮嬪寲璁㈤槄濂楅 initialize_subscription_plans_safe() except Exception as e: - app.logger.error(f"数据库初始化失败: {e}") + app.logger.error(f"鏁版嵁搴撳垵濮嬪寲澶辫触: {e}") - # 初始化事件轮询机制(WebSocket 推送) + # 鍒濆鍖栦簨浠惰疆璇㈡満鍒讹紙WebSocket 鎺ㄩ€侊級 initialize_event_polling() - # 启动时预热股票缓存(股票名称 + 前收盘价) - print("[启动] 正在预热股票缓存...") - try: - preload_stock_cache() - except Exception as e: - print(f"[启动] 预热缓存失败(不影响服务启动): {e}") - # 使用 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 + # 浣跨敤 socketio.run 鏇夸唬 app.run 浠ユ敮鎸?WebSocket + socketio.run(app, host='0.0.0.0', port=5001, debug=False, allow_unsafe_werkzeug=True)