diff --git a/__pycache__/app.cpython-310.pyc b/__pycache__/app.cpython-310.pyc index 71691407..c9a5525c 100644 Binary files a/__pycache__/app.cpython-310.pyc and b/__pycache__/app.cpython-310.pyc differ diff --git a/__pycache__/prediction_api.cpython-310.pyc b/__pycache__/prediction_api.cpython-310.pyc new file mode 100644 index 00000000..d833d775 Binary files /dev/null and b/__pycache__/prediction_api.cpython-310.pyc differ diff --git a/app.py b/app.py index b0fe6f2b..81db026c 100755 --- a/app.py +++ b/app.py @@ -19069,15 +19069,19 @@ def reset_simulation_account(): # =========================== -# 预测市场 API 路由 -# 请将此文件内容插入到 app.py 的 `if __name__ == '__main__':` 之前 +# 预测市场 API 路由(已抽离到 prediction_api.py) +# 以下代码已移动到 prediction_api.py Blueprint 中 # =========================== +''' +# --- 以下预测市场 API 已抽离到 prediction_api.py --- +# 如需恢复,取消此多行注释 + # --- 积分系统 API --- @app.route('/api/prediction/credit/account', methods=['GET']) @login_required -def get_credit_account(): +def get_credit_account_OLD(): """获取用户积分账户""" try: account = UserCreditAccount.query.filter_by(user_id=current_user.id).first() @@ -20270,6 +20274,9 @@ def get_position_bids(topic_id): except Exception as e: return jsonify({'success': False, 'error': str(e)}), 500 +''' +# --- 预测市场 API 代码结束 --- + # ==================== 时间胶囊 API ==================== @@ -20666,6 +20673,16 @@ app.register_blueprint(community_bp) register_community_socketio(socketio) +# ============================================================ +# 注册预测市场 Blueprint +# ============================================================ +from prediction_api import prediction_bp, init_prediction_api + +# 初始化并注册预测市场 Blueprint +init_prediction_api(db, beijing_now) +app.register_blueprint(prediction_bp) + + if __name__ == '__main__': # 创建数据库表 with app.app_context(): diff --git a/prediction_api.py b/prediction_api.py new file mode 100644 index 00000000..dbc19ef2 --- /dev/null +++ b/prediction_api.py @@ -0,0 +1,1229 @@ +""" +预测市场 API Blueprint +从 app.py 抽离的预测市场相关接口 +""" + +from flask import Blueprint, request, jsonify +from flask_login import login_required, current_user +from sqlalchemy import desc +from datetime import datetime + +# 创建 Blueprint +prediction_bp = Blueprint('prediction', __name__, url_prefix='/api/prediction') + + +def init_prediction_api(db, beijing_now): + """ + 初始化预测市场 API + + Args: + db: SQLAlchemy 数据库实例 + beijing_now: 北京时间函数 + + Returns: + Blueprint: 配置好的 Blueprint + """ + + # 延迟导入模型,避免循环导入 + from app import ( + UserCreditAccount, PredictionTopic, PredictionPosition, + PredictionTransaction, CreditTransaction, TopicComment, + TopicCommentLike, CommentInvestment, CommentPositionBid, User + ) + + # ==================== 积分 API ==================== + + @prediction_bp.route('/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) + db.session.commit() + + return jsonify({ + 'success': True, + 'data': { + 'balance': float(account.balance), + 'frozen_balance': float(account.frozen_balance), + 'available_balance': float(account.balance - account.frozen_balance), + 'total_earned': float(account.total_earned), + 'total_spent': float(account.total_spent), + 'last_daily_bonus_at': account.last_daily_bonus_at.isoformat() if account.last_daily_bonus_at else None + } + }) + + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + + @prediction_bp.route('/credit/daily-bonus', methods=['POST']) + @login_required + def claim_daily_bonus(): + """领取每日奖励(100积分)""" + 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) + + # 检查是否已领取今日奖励 + today = beijing_now().date() + if account.last_daily_bonus_at and account.last_daily_bonus_at.date() == today: + return jsonify({ + 'success': False, + '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='每日登录奖励' + ) + db.session.add(transaction) + db.session.commit() + + return jsonify({ + 'success': True, + 'message': f'领取成功,获得 {bonus_amount} 积分', + 'data': { + 'bonus_amount': bonus_amount, + 'new_balance': float(account.balance) + } + }) + + except Exception as e: + db.session.rollback() + return jsonify({'success': False, 'error': str(e)}), 500 + + # ==================== 预测话题 API ==================== + + @prediction_bp.route('/topics', methods=['POST']) + @login_required + def create_prediction_topic(): + """创建预测话题(消耗100积分)""" + try: + data = request.get_json() + title = data.get('title', '').strip() + description = data.get('description', '').strip() + category = data.get('category', 'stock') + deadline_str = data.get('deadline') + + # 验证参数 + if not title or len(title) < 5: + return jsonify({'success': False, 'error': '标题至少5个字符'}), 400 + + if not deadline_str: + return jsonify({'success': False, 'error': '请设置截止时间'}), 400 + + # 解析截止时间(移除时区信息以匹配数据库格式) + deadline = datetime.fromisoformat(deadline_str.replace('Z', '+00:00')) + # 移除时区信息,转换为naive datetime + if deadline.tzinfo is not None: + deadline = deadline.replace(tzinfo=None) + + if deadline <= beijing_now(): + 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 + + # 扣除创建费用 + create_cost = 100.0 + account.balance -= create_cost + account.total_spent += create_cost + + # 创建话题,创建费用进入奖池 + topic = PredictionTopic( + creator_id=current_user.id, + title=title, + description=description, + category=category, + deadline=deadline, + total_pool=create_cost # 创建费用进入奖池 + ) + 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}' + ) + db.session.add(transaction) + db.session.commit() + + return jsonify({ + 'success': True, + 'message': '话题创建成功', + 'data': { + 'topic_id': topic.id, + 'title': topic.title, + 'new_balance': float(account.balance) + } + }) + + except Exception as e: + db.session.rollback() + return jsonify({'success': False, 'error': str(e)}), 500 + + @prediction_bp.route('/topics', methods=['GET']) + def get_prediction_topics(): + """获取预测话题列表""" + try: + status = request.args.get('status', 'active') + category = request.args.get('category') + sort_by = request.args.get('sort_by', 'created_at') + page = request.args.get('page', 1, type=int) + per_page = request.args.get('per_page', 20, type=int) + + # 构建查询 + query = PredictionTopic.query + + if status: + query = query.filter_by(status=status) + if category: + query = query.filter_by(category=category) + + # 排序 + if sort_by == 'hot': + query = query.order_by(desc(PredictionTopic.views_count)) + elif sort_by == 'participants': + query = query.order_by(desc(PredictionTopic.participants_count)) + 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,确保移除时区信息 + deadline = topic.deadline + if hasattr(deadline, 'replace') and deadline.tzinfo is not None: + deadline = deadline.replace(tzinfo=None) + + created_at = topic.created_at + if hasattr(created_at, 'replace') and created_at.tzinfo is not None: + created_at = created_at.replace(tzinfo=None) + + topics_data.append({ + 'id': topic.id, + 'title': topic.title, + 'description': topic.description, + 'category': topic.category, + 'status': topic.status, + 'yes_price': float(topic.yes_price), + 'no_price': float(topic.no_price), + 'yes_total_shares': topic.yes_total_shares, + 'no_total_shares': topic.no_total_shares, + 'yes_probability': round(yes_prob, 1), + 'total_pool': float(topic.total_pool), + 'yes_lord': { + 'id': topic.yes_lord.id, + 'username': topic.yes_lord.username, + 'nickname': topic.yes_lord.nickname or topic.yes_lord.username, + 'avatar_url': topic.yes_lord.avatar_url + } if topic.yes_lord else None, + 'no_lord': { + 'id': topic.no_lord.id, + 'username': topic.no_lord.username, + 'nickname': topic.no_lord.nickname or topic.no_lord.username, + 'avatar_url': topic.no_lord.avatar_url + } if topic.no_lord else None, + 'deadline': deadline.isoformat() if deadline else None, + 'created_at': created_at.isoformat() if created_at else None, + 'views_count': topic.views_count, + 'comments_count': topic.comments_count, + 'participants_count': topic.participants_count, + # 添加作者信息 - 修复发起者显示问题 + 'author_id': topic.creator_id, + 'author_name': topic.creator.nickname or topic.creator.username if topic.creator else '未知用户', + 'author_avatar': topic.creator.avatar_url if topic.creator else None, + 'creator': { + 'id': topic.creator.id, + 'username': topic.creator.username, + 'nickname': topic.creator.nickname or topic.creator.username, + 'avatar_url': topic.creator.avatar_url + } if topic.creator else None + }) + + return jsonify({ + 'success': True, + 'data': topics_data, + 'pagination': { + 'page': page, + 'per_page': per_page, + 'total': pagination.total, + 'pages': pagination.pages, + 'has_next': pagination.has_next, + 'has_prev': pagination.has_prev + } + }) + + except Exception as e: + import traceback + print(f"[ERROR] 获取话题列表失败: {str(e)}") + print(traceback.format_exc()) + return jsonify({'success': False, 'error': str(e)}), 500 + + @prediction_bp.route('/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个) + yes_top_positions = PredictionPosition.query.filter_by( + topic_id=topic_id, + direction='yes' + ).order_by(desc(PredictionPosition.shares)).limit(5).all() + + no_top_positions = PredictionPosition.query.filter_by( + topic_id=topic_id, + direction='no' + ).order_by(desc(PredictionPosition.shares)).limit(5).all() + + def format_position(position): + return { + 'user': { + 'id': position.user.id, + 'username': position.user.username, + 'nickname': position.user.nickname or position.user.username, + 'avatar_url': position.user.avatar_url + }, + 'shares': position.shares, + 'avg_cost': float(position.avg_cost), + 'total_invested': float(position.total_invested), + 'is_lord': (topic.yes_lord_id == position.user_id and position.direction == 'yes') or + (topic.no_lord_id == position.user_id and position.direction == 'no') + } + + return jsonify({ + 'success': True, + 'data': { + 'id': topic.id, + 'title': topic.title, + 'description': topic.description, + 'category': topic.category, + 'status': topic.status, + 'result': topic.result, + 'yes_price': float(topic.yes_price), + 'no_price': float(topic.no_price), + 'yes_total_shares': topic.yes_total_shares, + 'no_total_shares': topic.no_total_shares, + 'yes_probability': round(yes_prob, 1), + 'no_probability': round(100 - yes_prob, 1), + 'total_pool': float(topic.total_pool), + 'yes_lord': { + 'id': topic.yes_lord.id, + 'username': topic.yes_lord.username, + 'nickname': topic.yes_lord.nickname or topic.yes_lord.username, + 'avatar_url': topic.yes_lord.avatar_url + } if topic.yes_lord else None, + 'no_lord': { + 'id': topic.no_lord.id, + 'username': topic.no_lord.username, + 'nickname': topic.no_lord.nickname or topic.no_lord.username, + 'avatar_url': topic.no_lord.avatar_url + } if topic.no_lord else None, + 'yes_top_positions': [format_position(p) for p in yes_top_positions], + 'no_top_positions': [format_position(p) for p in no_top_positions], + 'deadline': topic.deadline.isoformat(), + 'settled_at': topic.settled_at.isoformat() if topic.settled_at else None, + 'created_at': topic.created_at.isoformat(), + 'views_count': topic.views_count, + 'comments_count': topic.comments_count, + 'participants_count': topic.participants_count, + # 添加作者信息 - 修复发起者显示问题 + 'author_id': topic.creator_id, + 'author_name': topic.creator.nickname or topic.creator.username if topic.creator else '未知用户', + 'author_avatar': topic.creator.avatar_url if topic.creator else None, + 'creator': { + 'id': topic.creator.id, + 'username': topic.creator.username, + 'nickname': topic.creator.nickname or topic.creator.username, + 'avatar_url': topic.creator.avatar_url + } if topic.creator else None + } + }) + + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + + @prediction_bp.route('/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 + + # 验证状态 + if topic.status != 'active': + return jsonify({'success': False, 'error': '话题已结算或已取消'}), 400 + + # 验证截止时间 + if beijing_now() < topic.deadline: + 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 + + # 更新话题状态 + 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) + + for position in all_positions: + if total_invested > 0: + 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}' + ) + db.session.add(transaction) + + else: + # YES 或 NO 获胜 + winning_direction = result + winning_positions = PredictionPosition.query.filter_by( + topic_id=topic_id, + direction=winning_direction + ).all() + + if winning_positions: + 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}' + ) + db.session.add(transaction) + + db.session.commit() + + return jsonify({ + 'success': True, + 'message': f'话题已结算,结果为:{result}', + 'data': { + 'topic_id': topic.id, + 'result': result, + 'total_pool': float(topic.total_pool), + 'settled_at': topic.settled_at.isoformat() + } + }) + + except Exception as e: + db.session.rollback() + return jsonify({'success': False, 'error': str(e)}), 500 + + # ==================== 交易 API ==================== + + @prediction_bp.route('/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 + + if shares > 1000: + return jsonify({'success': False, 'error': '单次最多买入1000份额'}), 400 + + # 获取话题 + topic = PredictionTopic.query.get_or_404(topic_id) + + if topic.status != 'active': + return jsonify({'success': False, 'error': '话题已结算或已取消'}), 400 + + if beijing_now() >= topic.deadline: + return jsonify({'success': False, 'error': '话题已截止'}), 400 + + # 不能参与自己创建的话题 + if topic.creator_id == current_user.id: + 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() + + # 计算价格 + total_shares = topic.yes_total_shares + topic.no_total_shares + if total_shares > 0: + if direction == 'yes': + current_price = (topic.yes_total_shares / total_shares) * 1000 + else: + current_price = (topic.no_total_shares / total_shares) * 1000 + else: + current_price = 500.0 # 初始价格 + + # 买入后价格会上涨,使用平均价格 + after_total = total_shares + shares + if direction == 'yes': + after_yes_shares = topic.yes_total_shares + shares + after_price = (after_yes_shares / after_total) * 1000 + else: + after_no_shares = topic.no_total_shares + shares + after_price = (after_no_shares / after_total) * 1000 + + avg_price = (current_price + after_price) / 2 + + # 计算费用 + amount = avg_price * shares + tax = amount * 0.02 # 2% 手续费 + total_cost = amount + tax + + # 检查余额 + if account.balance < total_cost: + return jsonify({'success': False, 'error': '积分不足'}), 400 + + # 扣除费用 + account.balance -= total_cost + account.total_spent += total_cost + + # 更新话题数据 + if direction == 'yes': + topic.yes_total_shares += shares + topic.yes_price = after_price + else: + topic.no_total_shares += shares + topic.no_price = after_price + + topic.total_pool += tax # 手续费进入奖池 + + # 更新或创建持仓 + position = PredictionPosition.query.filter_by( + user_id=current_user.id, + topic_id=topic_id, + direction=direction + ).first() + + if position: + # 更新平均成本 + old_cost = position.avg_cost * position.shares + new_cost = avg_price * shares + position.shares += shares + position.avg_cost = (old_cost + new_cost) / position.shares + position.total_invested += total_cost + else: + position = PredictionPosition( + user_id=current_user.id, + topic_id=topic_id, + direction=direction, + shares=shares, + avg_cost=avg_price, + total_invested=total_cost + ) + db.session.add(position) + topic.participants_count += 1 + + # 更新领主 + if direction == 'yes': + top_yes = db.session.query(PredictionPosition).filter_by( + topic_id=topic_id, + direction='yes' + ).order_by(desc(PredictionPosition.shares)).first() + if top_yes: + topic.yes_lord_id = top_yes.user_id + else: + top_no = db.session.query(PredictionPosition).filter_by( + topic_id=topic_id, + direction='no' + ).order_by(desc(PredictionPosition.shares)).first() + if top_no: + topic.no_lord_id = top_no.user_id + + # 记录交易 + transaction = PredictionTransaction( + user_id=current_user.id, + topic_id=topic_id, + trade_type='buy', + direction=direction, + shares=shares, + price=avg_price, + amount=amount, + tax=tax, + total_cost=total_cost + ) + db.session.add(transaction) + + # 记录积分交易 + credit_transaction = CreditTransaction( + user_id=current_user.id, + transaction_type='prediction_buy', + amount=-total_cost, + balance_after=account.balance, + related_topic_id=topic_id, + related_transaction_id=transaction.id, + description=f'买入 {direction.upper()} 份额:{topic.title}' + ) + db.session.add(credit_transaction) + + db.session.commit() + + return jsonify({ + 'success': True, + 'message': '买入成功', + 'data': { + 'transaction_id': transaction.id, + 'shares': shares, + 'price': round(avg_price, 2), + 'total_cost': round(total_cost, 2), + 'tax': round(tax, 2), + 'new_balance': float(account.balance), + 'new_position': { + 'shares': position.shares, + 'avg_cost': float(position.avg_cost) + } + } + }) + + except Exception as e: + db.session.rollback() + return jsonify({'success': False, 'error': str(e)}), 500 + + @prediction_bp.route('/positions', methods=['GET']) + @login_required + def get_user_positions(): + """获取用户的所有持仓""" + try: + positions = PredictionPosition.query.filter_by(user_id=current_user.id).all() + + positions_data = [] + for position in positions: + topic = position.topic + + # 计算当前市值(如果话题还在进行中) + current_value = 0 + profit = 0 + profit_rate = 0 + + if topic.status == 'active': + current_price = topic.yes_price if position.direction == 'yes' else topic.no_price + current_value = current_price * position.shares + profit = current_value - position.total_invested + profit_rate = (profit / position.total_invested * 100) if position.total_invested > 0 else 0 + + positions_data.append({ + 'id': position.id, + 'topic': { + 'id': topic.id, + 'title': topic.title, + 'status': topic.status, + 'result': topic.result, + 'deadline': topic.deadline.isoformat() + }, + 'direction': position.direction, + 'shares': position.shares, + 'avg_cost': float(position.avg_cost), + 'total_invested': float(position.total_invested), + 'current_value': round(current_value, 2), + 'profit': round(profit, 2), + 'profit_rate': round(profit_rate, 2), + 'created_at': position.created_at.isoformat(), + 'is_lord': (topic.yes_lord_id == current_user.id and position.direction == 'yes') or + (topic.no_lord_id == current_user.id and position.direction == 'no') + }) + + return jsonify({ + 'success': True, + 'data': positions_data, + 'count': len(positions_data) + }) + + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + + # ==================== 评论 API ==================== + + @prediction_bp.route('/topics//comments', methods=['POST']) + @login_required + def create_topic_comment(topic_id): + """发表话题评论""" + try: + topic = PredictionTopic.query.get_or_404(topic_id) + + data = request.get_json() + content = data.get('content', '').strip() + parent_id = data.get('parent_id') + + if not content or len(content) < 2: + return jsonify({'success': False, 'error': '评论内容至少2个字符'}), 400 + + # 创建评论 + comment = TopicComment( + topic_id=topic_id, + user_id=current_user.id, + content=content, + 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': '评论成功', + 'data': { + 'comment_id': comment.id, + 'content': comment.content, + 'is_pinned': comment.is_pinned, + 'created_at': comment.created_at.isoformat() + } + }) + + except Exception as e: + db.session.rollback() + return jsonify({'success': False, 'error': str(e)}), 500 + + @prediction_bp.route('/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 # 只获取顶级评论 + ).order_by( + desc(TopicComment.is_pinned), + desc(TopicComment.created_at) + ) + + pagination = query.paginate(page=page, per_page=per_page, error_out=False) + comments = pagination.items + + def format_comment(comment): + # 获取回复 + replies = TopicComment.query.filter_by( + parent_id=comment.id, + status='active' + ).order_by(TopicComment.created_at).limit(5).all() + + return { + 'id': comment.id, + 'content': comment.content, + 'is_pinned': comment.is_pinned, + 'likes_count': comment.likes_count, + 'created_at': comment.created_at.isoformat(), + 'user': { + 'id': comment.user.id, + 'username': comment.user.username, + 'nickname': comment.user.nickname or comment.user.username, + 'avatar_url': comment.user.avatar_url + }, + 'is_lord': (topic.yes_lord_id == comment.user_id) or (topic.no_lord_id == comment.user_id), + 'replies': [{ + 'id': reply.id, + 'content': reply.content, + 'created_at': reply.created_at.isoformat(), + 'user': { + 'id': reply.user.id, + 'username': reply.user.username, + 'nickname': reply.user.nickname or reply.user.username, + 'avatar_url': reply.user.avatar_url + } + } for reply in replies] + } + + comments_data = [format_comment(comment) for comment in comments] + + return jsonify({ + 'success': True, + 'data': comments_data, + 'pagination': { + 'page': page, + 'per_page': per_page, + 'total': pagination.total, + 'pages': pagination.pages + } + }) + + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + + @prediction_bp.route('/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 + ) + db.session.add(like) + comment.likes_count += 1 + action = 'liked' + + db.session.commit() + + return jsonify({ + 'success': True, + 'action': action, + 'likes_count': comment.likes_count + }) + + except Exception as e: + db.session.rollback() + return jsonify({'success': False, 'error': str(e)}), 500 + + # ==================== 观点IPO API ==================== + + @prediction_bp.route('/comments//invest', methods=['POST']) + @login_required + def invest_comment(comment_id): + """投资评论(观点IPO)""" + 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 + + # 检查是否是自己的评论 + if comment.user_id == current_user.id: + return jsonify({'success': False, 'error': '不能投资自己的评论'}), 400 + + # 计算投资金额 + 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 + + # 检查余额 + if account.balance < amount: + return jsonify({'success': False, 'error': '积分不足'}), 400 + + # 扣减积分 + account.balance -= amount + + # 检查是否已有投资记录 + existing_investment = CommentInvestment.query.filter_by( + comment_id=comment_id, + user_id=current_user.id, + status='active' + ).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, + shares=shares, + amount=amount, + avg_price=price_per_share + ) + db.session.add(investment) + comment.investor_count += 1 + + # 更新评论统计 + comment.total_investment += amount + + # 记录积分交易 + transaction = CreditTransaction( + user_id=current_user.id, + transaction_type='comment_investment', + amount=-amount, + balance_after=account.balance, + description=f'投资评论 #{comment_id}' + ) + db.session.add(transaction) + + db.session.commit() + + return jsonify({ + 'success': True, + 'data': { + 'shares': shares, + 'amount': amount, + 'price_per_share': price_per_share, + 'total_investment': comment.total_investment, + 'investor_count': comment.investor_count, + 'new_balance': account.balance + } + }) + + except Exception as e: + db.session.rollback() + return jsonify({'success': False, 'error': str(e)}), 500 + + @prediction_bp.route('/comments//investments', methods=['GET']) + def get_comment_investments(comment_id): + """获取评论的投资列表""" + try: + investments = CommentInvestment.query.filter_by( + comment_id=comment_id, + status='active' + ).all() + + result = [] + for inv in investments: + user = User.query.get(inv.user_id) + result.append({ + 'id': inv.id, + 'user_id': inv.user_id, + 'user_name': user.username if user else '未知用户', + 'user_avatar': user.avatar_url if user else None, + 'shares': inv.shares, + 'amount': inv.amount, + 'avg_price': inv.avg_price, + 'created_at': inv.created_at.strftime('%Y-%m-%d %H:%M:%S') + }) + + return jsonify({ + 'success': True, + 'data': result + }) + + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + + @prediction_bp.route('/comments//verify', methods=['POST']) + @login_required + def verify_comment(comment_id): + """管理员验证评论预测结果""" + try: + # 检查管理员权限(简化版:假设 user_id=1 是管理员) + if current_user.id != 1: + 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 + + comment = TopicComment.query.get_or_404(comment_id) + + # 检查是否已验证 + if comment.is_verified: + return jsonify({'success': False, 'error': '该评论已验证'}), 400 + + # 更新验证状态 + comment.is_verified = True + comment.verification_result = result + + # 如果预测正确,进行收益分配 + if result == 'correct' and comment.total_investment > 0: + # 获取所有投资记录 + investments = CommentInvestment.query.filter_by( + comment_id=comment_id, + status='active' + ).all() + + # 计算总收益(总投资额的1.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, + transaction_type='comment_investment_profit', + amount=investor_reward, + balance_after=account.balance, + description=f'评论投资收益 #{comment_id}' + ) + db.session.add(transaction) + + # 更新投资状态 + inv.status = 'settled' + + # 评论作者也获得奖励(总投资额的20%) + author_reward = int(comment.total_investment * 0.2) + author_account = UserCreditAccount.query.filter_by(user_id=comment.user_id).first() + if author_account: + author_account.balance += author_reward + + transaction = CreditTransaction( + user_id=comment.user_id, + transaction_type='comment_author_bonus', + amount=author_reward, + balance_after=author_account.balance, + description=f'评论作者奖励 #{comment_id}' + ) + db.session.add(transaction) + + db.session.commit() + + return jsonify({ + 'success': True, + 'data': { + 'comment_id': comment_id, + 'verification_result': result, + 'total_investment': comment.total_investment + } + }) + + except Exception as e: + db.session.rollback() + return jsonify({'success': False, 'error': str(e)}), 500 + + # ==================== 竞拍 API ==================== + + @prediction_bp.route('/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 + + if bid_amount < 500: + return jsonify({'success': False, 'error': '最低出价500积分'}), 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 + + # 检查该位置的当前最高出价 + current_highest = CommentPositionBid.query.filter_by( + topic_id=topic_id, + position=position, + status='pending' + ).order_by(CommentPositionBid.bid_amount.desc()).first() + + if current_highest and bid_amount <= current_highest.bid_amount: + return jsonify({ + 'success': False, + 'error': f'出价必须高于当前最高价 {current_highest.bid_amount}' + }), 400 + + # 扣减积分(冻结) + account.balance -= bid_amount + account.frozen_balance += bid_amount + + # 如果有之前的出价,退还积分 + user_previous_bid = CommentPositionBid.query.filter_by( + topic_id=topic_id, + position=position, + user_id=current_user.id, + status='pending' + ).first() + + if user_previous_bid: + account.frozen_balance -= user_previous_bid.bid_amount + 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 + ) + db.session.add(bid) + + # 记录积分交易 + transaction = CreditTransaction( + user_id=current_user.id, + transaction_type='position_bid', + amount=-bid_amount, + balance_after=account.balance, + description=f'竞拍评论位置 #{position} (话题#{topic_id})' + ) + db.session.add(transaction) + + db.session.commit() + + return jsonify({ + 'success': True, + 'data': { + 'bid_id': bid.id, + 'position': position, + 'bid_amount': bid_amount, + 'new_balance': account.balance, + 'frozen': account.frozen_balance + } + }) + + except Exception as e: + db.session.rollback() + return jsonify({'success': False, 'error': str(e)}), 500 + + @prediction_bp.route('/topics//position-bids', methods=['GET']) + def get_position_bids(topic_id): + """获取话题的位置竞拍列表""" + try: + result = {} + + for position in [1, 2, 3]: + bids = CommentPositionBid.query.filter_by( + topic_id=topic_id, + position=position, + status='pending' + ).order_by(CommentPositionBid.bid_amount.desc()).limit(5).all() + + position_bids = [] + for bid in bids: + user = User.query.get(bid.user_id) + position_bids.append({ + 'id': bid.id, + 'user_id': bid.user_id, + 'user_name': user.username if user else '未知用户', + 'user_avatar': user.avatar_url if user else None, + 'bid_amount': bid.bid_amount, + 'created_at': bid.created_at.strftime('%Y-%m-%d %H:%M:%S') + }) + + result[f'position_{position}'] = position_bids + + return jsonify({ + 'success': True, + 'data': result + }) + + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + + return prediction_bp