diff --git a/app.py b/app.py index 43eda8a5..dc0e112c 100755 --- a/app.py +++ b/app.py @@ -4485,6 +4485,225 @@ class PostLike(db.Model): __table_args__ = (db.UniqueConstraint('user_id', 'post_id'),) +# =========================== +# 预测市场系统模型 +# =========================== + +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) # 累计消费 + + # 时间 + 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) # 最后一次领取每日奖励时间 + + # 关系 + user = db.relationship('User', backref=db.backref('credit_account', uselist=False)) + + def __repr__(self): + return f'' + + +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) + + # 奖池 + 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方领主 + + # 状态 + status = db.Column(db.String(20), default='active', nullable=False) # active/settled/cancelled + result = db.Column(db.String(10)) # yes/no/draw(结算结果) + + # 时间 + 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') + positions = db.relationship('PredictionPosition', backref='topic', lazy='dynamic') + transactions = db.relationship('PredictionTransaction', backref='topic', lazy='dynamic') + comments = db.relationship('TopicComment', backref='topic', lazy='dynamic') + + def __repr__(self): + return f'' + + +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) # 总投入 + + # 时间 + 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): + return f'' + + +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) + + # 交易信息 + transaction_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) # 成交价格 + + # 费用 + 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): + return f'' + + +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) # 交易后余额 + + # 关联 + 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)) # 交易描述 + + # 时间 + 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') + + def __repr__(self): + return f'' + + +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(回复功能) + + # 状态 + 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) + + # 时间 + 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') + + def __repr__(self): + return f'' + + +class TopicCommentLike(db.Model): + """话题评论点赞""" + __tablename__ = 'topic_comment_like' + + 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) + 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): + return f'' + + class Event(db.Model): """事件模型""" id = db.Column(db.Integer, primary_key=True) @@ -12999,6 +13218,844 @@ def reset_simulation_account(): return jsonify({'success': False, 'error': str(e)}), 500 +# =========================== +# 预测市场 API 路由 +# 请将此文件内容插入到 app.py 的 `if __name__ == '__main__':` 之前 +# =========================== + +# --- 积分系统 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) + 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 + + +@app.route('/api/prediction/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 --- + +@app.route('/api/prediction/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')) + 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 + ) + 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 + + +@app.route('/api/prediction/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 + + 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_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': topic.deadline.isoformat(), + 'created_at': topic.created_at.isoformat(), + 'views_count': topic.views_count, + 'comments_count': topic.comments_count, + 'participants_count': topic.participants_count, + 'creator': { + 'id': topic.creator.id, + 'username': topic.creator.username, + 'nickname': topic.creator.nickname or topic.creator.username + } + }) + + 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: + return jsonify({'success': False, 'error': str(e)}), 500 + + +@app.route('/api/prediction/topics/', methods=['GET']) +def get_prediction_topic_detail(topic_id): + """获取预测话题详情""" + try: + 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, + 'creator': { + 'id': topic.creator.id, + 'username': topic.creator.username, + 'nickname': topic.creator.nickname or topic.creator.username, + 'avatar_url': topic.creator.avatar_url + } + } + }) + + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + + +@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 + + # 验证状态 + 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 --- + +@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 + + 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 + + # 获取积分账户 + 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 + 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': + # 找到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: + # 找到NO方持仓最多的用户 + 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, + transaction_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 + + +@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() + + 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 --- + +@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) + + 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 + + +@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 # 只获取顶级评论 + ).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 + + +@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 + ) + 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 + + if __name__ == '__main__': # 创建数据库表 with app.app_context(): diff --git a/prediction_market_routes_to_add.py b/prediction_market_routes_to_add.py new file mode 100644 index 00000000..c1c9a23a --- /dev/null +++ b/prediction_market_routes_to_add.py @@ -0,0 +1,836 @@ +# =========================== +# 预测市场 API 路由 +# 请将此文件内容插入到 app.py 的 `if __name__ == '__main__':` 之前 +# =========================== + +# --- 积分系统 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) + 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 + + +@app.route('/api/prediction/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 --- + +@app.route('/api/prediction/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')) + 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 + ) + 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 + + +@app.route('/api/prediction/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 + + 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_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': topic.deadline.isoformat(), + 'created_at': topic.created_at.isoformat(), + 'views_count': topic.views_count, + 'comments_count': topic.comments_count, + 'participants_count': topic.participants_count, + 'creator': { + 'id': topic.creator.id, + 'username': topic.creator.username, + 'nickname': topic.creator.nickname or topic.creator.username + } + }) + + 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: + return jsonify({'success': False, 'error': str(e)}), 500 + + +@app.route('/api/prediction/topics/', methods=['GET']) +def get_prediction_topic_detail(topic_id): + """获取预测话题详情""" + try: + 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, + 'creator': { + 'id': topic.creator.id, + 'username': topic.creator.username, + 'nickname': topic.creator.nickname or topic.creator.username, + 'avatar_url': topic.creator.avatar_url + } + } + }) + + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + + +@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 + + # 验证状态 + 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 --- + +@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 + + 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 + + # 获取积分账户 + 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 + 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': + # 找到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: + # 找到NO方持仓最多的用户 + 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, + transaction_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 + + +@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() + + 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 --- + +@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) + + 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 + + +@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 # 只获取顶级评论 + ).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 + + +@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 + ) + 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 diff --git a/src/views/ValueForum/components/PredictionGuideModal.js b/src/views/ValueForum/components/PredictionGuideModal.js new file mode 100644 index 00000000..68b91260 --- /dev/null +++ b/src/views/ValueForum/components/PredictionGuideModal.js @@ -0,0 +1,713 @@ +/** + * 预测市场玩法说明模态框 + * 帮助用户理解游戏规则和操作流程 + */ + +import React, { useState } from 'react'; +import { + Modal, + ModalOverlay, + ModalContent, + ModalHeader, + ModalBody, + ModalFooter, + ModalCloseButton, + Button, + VStack, + HStack, + Text, + Box, + Icon, + Badge, + Tabs, + TabList, + TabPanels, + Tab, + TabPanel, + Divider, + List, + ListItem, + ListIcon, + Accordion, + AccordionItem, + AccordionButton, + AccordionPanel, + AccordionIcon, + Image, + SimpleGrid, + Flex, + useColorModeValue, +} from '@chakra-ui/react'; +import { + Zap, + TrendingUp, + TrendingDown, + Crown, + DollarSign, + Users, + Clock, + Trophy, + CheckCircle2, + AlertCircle, + ArrowRight, + Target, + Wallet, + RefreshCw, + Gift, +} from 'lucide-react'; +import { motion } from 'framer-motion'; +import { forumColors } from '@theme/forumTheme'; + +const MotionBox = motion(Box); + +const PredictionGuideModal = ({ isOpen, onClose }) => { + const [activeTab, setActiveTab] = useState(0); + + // 特性列表 + const features = [ + { + icon: Zap, + title: '预测市场', + desc: '对未来事件进行预测,购买看涨/看跌席位', + color: 'yellow.400', + }, + { + icon: DollarSign, + title: '虚拟积分', + desc: '使用论坛积分参与,初始10,000积分', + color: 'green.400', + }, + { + icon: Crown, + title: '领主系统', + desc: '持有最多份额者成为领主,拥有特权', + color: 'orange.400', + }, + { + icon: Trophy, + title: '赢取奖池', + desc: '预测正确者分享奖池,最高收益无上限', + color: 'purple.400', + }, + ]; + + // 操作步骤 + const steps = [ + { + step: 1, + title: '发起预测话题', + desc: '创建一个可预测的话题,设置截止时间', + icon: Zap, + details: [ + '点击"发起预测"按钮', + '填写话题标题和描述', + '选择分类和截止时间', + '支付100积分创建费用(进入奖池)', + ], + }, + { + step: 2, + title: '购买席位表达观点', + desc: '选择看涨或看跌方向,购买席位', + icon: TrendingUp, + details: [ + '浏览预测话题,选择感兴趣的', + '点击"购买席位"', + '选择方向:看涨(Yes) 或 看跌(No)', + '调整购买份额(1-10份)', + '支付积分(含2%交易税)', + ], + }, + { + step: 3, + title: '成为领主获得特权', + desc: '持有最多份额自动成为领主', + icon: Crown, + details: [ + '持有某方向最多份额即成为领主', + '领主评论自动置顶(计划中)', + '专属👑领主徽章显示', + '领主身份随交易实时变更', + ], + }, + { + step: 4, + title: '等待结果揭晓', + desc: '话题到期后,作者提交结果', + icon: Clock, + details: [ + '话题到达截止时间,停止交易', + '作者提交结果和证据', + '系统自动计算奖池分配', + '获胜方按份额比例分享奖池', + ], + }, + ]; + + // 常见问题 + const faqs = [ + { + q: '💰 初始积分是多少?会破产吗?', + a: '每个用户初始获得10,000积分。系统有破产保护机制,最低保留100积分,不会完全归零。每日签到可获得100积分奖励。', + }, + { + q: '📊 价格是如何变动的?', + a: '价格根据供需关系动态调整。购买某方向会导致该方向价格上涨,对方价格下跌。采用简化版AMM(自动做市商)算法。', + }, + { + q: '👑 领主有什么特权?', + a: '领主是持有某方向最多份额的用户。特权包括:评论自动置顶(计划中)、专属徽章显示、更高的话语权。领主身份随交易实时变更。', + }, + { + q: '🎯 如何计算收益?', + a: '获胜方按持有份额比例分享奖池。例如:奖池1000积分,你持有获胜方30%份额,则获得300积分 + 返还本金。', + }, + { + q: '💸 交易税去哪了?', + a: '每笔交易收取2%交易税,全部进入话题奖池。这确保了即使无人参与对立面,奖池也有资金可分配。', + }, + { + q: '🔄 可以卖出席位吗?', + a: '可以!在话题截止前,随时可以卖出席位。卖出价格同样根据市场供需动态计算,可能高于或低于买入价。', + }, + { + q: '⚖️ 如何确保公平?', + a: '作者不能参与自己发起的话题。结果需要提交证据。未来将引入社区投票仲裁机制处理争议。', + }, + { + q: '📈 单次最多能买多少?', + a: '单次购买上限1000积分,避免大户操纵市场。每个方向最多5个席位持有者,确保市场活跃度。', + }, + ]; + + // 示例场景 + const exampleScenario = { + title: '📖 完整示例:贵州茅台预测', + steps: [ + { + title: '1. 创建话题', + content: '小明创建话题:"贵州茅台下周会涨吗?",支付100积分。奖池:100积分。', + }, + { + title: '2. 用户参与', + content: [ + '小红购买3份"看涨",花费1,530积分(含税)', + '小李购买2份"看跌",花费1,020积分(含税)', + '交易税进入奖池:51积分', + '当前奖池:151积分', + ], + }, + { + title: '3. 价格变动', + content: '看涨份额增加 → 看涨价格上涨至680积分/份,看跌价格下跌至320积分/份', + }, + { + title: '4. 领主诞生', + content: '小红持有看涨方最多份额,成为看涨方👑领主', + }, + { + title: '5. 结果揭晓', + content: [ + '一周后,茅台上涨5%,小明提交结果:"看涨"获胜', + '小红获得:本金1500积分 + 奖池151积分 = 1651积分', + '小红净收益:1651 - 1530 = +121积分 (收益率7.9%)', + '小李损失本金1000积分', + ], + }, + ], + }; + + return ( + + + + + + + + + 预测市场玩法说明 + + + 5分钟快速上手,开启预测之旅 + + + + + + + + + + + + 核心玩法 + + + + 操作步骤 + + + + 常见问题 + + + + 示例演示 + + + + + {/* 核心玩法 */} + + + {/* 核心特性 */} + + {features.map((feature, index) => ( + + + + + + + + {feature.title} + + + {feature.desc} + + + + + ))} + + + + + {/* 游戏规则 */} + + + 📜 游戏规则 + + + + + + + 积分系统 + + + + + + 初始积分:10,000积分(免费领取) + + + + 最低保留:100积分(破产保护) + + + + 单次上限:1,000积分(防止操纵) + + + + 每日签到:+100积分(每天领取) + + + + + + + + + 双向市场 + + + + + + 看涨 (Yes):预测会上涨/发生 + + + + 看跌 (No):预测会下跌/不发生 + + + + 价格动态变化,根据供需关系调整 + + + + 可以随时买入和卖出(截止前) + + + + + + + + + 领主系统 + + + + + + 持有最多份额者自动成为领主 + + + + 领主评论自动置顶(计划中) + + + + 专属👑徽章和金色边框 + + + + 领主身份随交易实时变更 + + + + + + + + + 奖池分配 + + + + + + 交易税:每笔交易2%进入奖池 + + + + 获胜方按份额比例分享奖池 + + + + 返还本金 + 奖池分成 + + + + 失败方损失本金 + + + + + + + + + {/* 操作步骤 */} + + + {steps.map((step, index) => ( + + {/* 步骤编号 */} + + {step.step} + + + + + + + + + + + 步骤 {step.step} + + + + {step.title} + + + {step.desc} + + + + + + + + {step.details.map((detail, i) => ( + + + + {detail} + + + ))} + + + ))} + + + + {/* 常见问题 */} + + + {faqs.map((faq, index) => ( + + + + + {faq.q} + + + + + + + {faq.a} + + + + ))} + + + + {/* 示例演示 */} + + + + + {exampleScenario.title} + + + 通过完整案例理解预测市场的运作流程 + + + + {exampleScenario.steps.map((item, index) => ( + + + {item.title} + + {Array.isArray(item.content) ? ( + + {item.content.map((line, i) => ( + + + + {line} + + + ))} + + ) : ( + + {item.content} + + )} + + ))} + + {/* 关键提示 */} + + + + + 💡 关键提示 + + + + + ✅ 早期参与价格低,后期参与价格高 + + + ✅ 可以通过"低买高卖"赚取差价(Flipper玩法) + + + ✅ 即使预测失败,也可能通过卖出获利 + + + ✅ 成为领主可获得社区影响力 + + + + + + + + + + + + + 💡 提示:每日签到可获得100积分 + + + + + + + ); +}; + +export default PredictionGuideModal; diff --git a/src/views/ValueForum/index.js b/src/views/ValueForum/index.js index da42d192..3d5444f7 100644 --- a/src/views/ValueForum/index.js +++ b/src/views/ValueForum/index.js @@ -29,7 +29,7 @@ import { TabPanel, Icon, } from '@chakra-ui/react'; -import { Search, PenSquare, TrendingUp, Clock, Heart, Zap } from 'lucide-react'; +import { Search, PenSquare, TrendingUp, Clock, Heart, Zap, HelpCircle } from 'lucide-react'; import { motion, AnimatePresence } from 'framer-motion'; import { forumColors } from '@theme/forumTheme'; import { getPosts, searchPosts } from '@services/elasticsearchService'; @@ -38,6 +38,7 @@ import PostCard from './components/PostCard'; import PredictionTopicCard from './components/PredictionTopicCard'; import CreatePostModal from './components/CreatePostModal'; import CreatePredictionModal from './components/CreatePredictionModal'; +import PredictionGuideModal from './components/PredictionGuideModal'; const MotionBox = motion(Box); @@ -54,6 +55,7 @@ const ValueForum = () => { const { isOpen: isPostModalOpen, onOpen: onPostModalOpen, onClose: onPostModalClose } = useDisclosure(); const { isOpen: isPredictionModalOpen, onOpen: onPredictionModalOpen, onClose: onPredictionModalClose } = useDisclosure(); + const { isOpen: isGuideModalOpen, onOpen: onGuideModalOpen, onClose: onGuideModalClose } = useDisclosure(); // 获取帖子列表 const fetchPosts = async (currentPage = 1, reset = false) => { @@ -178,6 +180,24 @@ const ValueForum = () => { {/* 发帖按钮 */} + +