1237 lines
49 KiB
Python
1237 lines
49 KiB
Python
"""
|
||
预测市场 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', '') # 默认为空,表示全部状态
|
||
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
|
||
|
||
# status 为空、'all' 或 'all_status' 时不过滤,返回全部
|
||
if status and status not in ('all', 'all_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))
|
||
elif sort_by == 'ending_soon':
|
||
# 即将截止:按截止时间升序,最快截止的在前面(只显示未结算的)
|
||
query = query.order_by(PredictionTopic.deadline.asc())
|
||
elif sort_by == 'highest_pool':
|
||
# 奖池最高:按总奖池降序
|
||
query = query.order_by(desc(PredictionTopic.total_pool))
|
||
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/<int:topic_id>', 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/<int:topic_id>/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/<int:topic_id>/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/<int:topic_id>/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/<int:comment_id>/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/<int:comment_id>/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/<int:comment_id>/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/<int:comment_id>/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/<int:topic_id>/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/<int:topic_id>/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
|