2026-01-31 02:59:49 +08:00
|
|
|
|
"""
|
|
|
|
|
|
Community Service - 指标社区服务
|
|
|
|
|
|
|
|
|
|
|
|
处理指标市场、购买、评论等功能。
|
|
|
|
|
|
"""
|
2026-02-27 01:57:04 +08:00
|
|
|
|
import json
|
2026-01-31 02:59:49 +08:00
|
|
|
|
import time
|
|
|
|
|
|
from decimal import Decimal
|
|
|
|
|
|
from typing import Dict, Any, List, Optional, Tuple
|
|
|
|
|
|
|
|
|
|
|
|
from app.utils.db import get_db_connection
|
|
|
|
|
|
from app.utils.logger import get_logger
|
|
|
|
|
|
from app.services.billing_service import get_billing_service
|
|
|
|
|
|
|
|
|
|
|
|
logger = get_logger(__name__)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class CommunityService:
|
|
|
|
|
|
"""指标社区服务类"""
|
|
|
|
|
|
|
|
|
|
|
|
def __init__(self):
|
|
|
|
|
|
self.billing = get_billing_service()
|
2026-02-27 01:57:04 +08:00
|
|
|
|
# Best-effort: ensure vip_free column exists (for old databases)
|
|
|
|
|
|
try:
|
|
|
|
|
|
with get_db_connection() as db:
|
|
|
|
|
|
cur = db.cursor()
|
|
|
|
|
|
cur.execute("ALTER TABLE qd_indicator_codes ADD COLUMN IF NOT EXISTS vip_free BOOLEAN DEFAULT FALSE")
|
|
|
|
|
|
db.commit()
|
|
|
|
|
|
cur.close()
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
2026-01-31 02:59:49 +08:00
|
|
|
|
|
|
|
|
|
|
# ==========================================
|
|
|
|
|
|
# 指标市场
|
|
|
|
|
|
# ==========================================
|
|
|
|
|
|
|
|
|
|
|
|
def get_market_indicators(
|
|
|
|
|
|
self,
|
|
|
|
|
|
page: int = 1,
|
|
|
|
|
|
page_size: int = 12,
|
|
|
|
|
|
keyword: str = None,
|
|
|
|
|
|
pricing_type: str = None, # 'free' / 'paid' / None(all)
|
|
|
|
|
|
sort_by: str = 'newest', # 'newest' / 'hot' / 'price_asc' / 'price_desc' / 'rating'
|
|
|
|
|
|
user_id: int = None # 当前用户ID,用于判断是否已购买
|
|
|
|
|
|
) -> Dict[str, Any]:
|
|
|
|
|
|
"""获取市场上已发布的指标列表"""
|
|
|
|
|
|
offset = (page - 1) * page_size
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
with get_db_connection() as db:
|
|
|
|
|
|
cur = db.cursor()
|
|
|
|
|
|
|
|
|
|
|
|
# 构建查询条件 - 只显示已发布且审核通过的指标
|
|
|
|
|
|
where_clauses = ["i.publish_to_community = 1", "(i.review_status = 'approved' OR i.review_status IS NULL)"]
|
|
|
|
|
|
params = []
|
|
|
|
|
|
|
|
|
|
|
|
if keyword and keyword.strip():
|
|
|
|
|
|
where_clauses.append("(i.name ILIKE ? OR i.description ILIKE ?)")
|
|
|
|
|
|
search_term = f"%{keyword.strip()}%"
|
|
|
|
|
|
params.extend([search_term, search_term])
|
|
|
|
|
|
|
|
|
|
|
|
if pricing_type == 'free':
|
|
|
|
|
|
where_clauses.append("(i.pricing_type = 'free' OR i.price <= 0)")
|
|
|
|
|
|
elif pricing_type == 'paid':
|
|
|
|
|
|
where_clauses.append("(i.pricing_type != 'free' AND i.price > 0)")
|
|
|
|
|
|
|
|
|
|
|
|
where_sql = " AND ".join(where_clauses)
|
|
|
|
|
|
|
|
|
|
|
|
# 排序
|
|
|
|
|
|
order_map = {
|
|
|
|
|
|
'newest': 'i.created_at DESC',
|
|
|
|
|
|
'hot': 'i.purchase_count DESC, i.view_count DESC',
|
|
|
|
|
|
'price_asc': 'i.price ASC, i.created_at DESC',
|
|
|
|
|
|
'price_desc': 'i.price DESC, i.created_at DESC',
|
|
|
|
|
|
'rating': 'i.avg_rating DESC, i.rating_count DESC'
|
|
|
|
|
|
}
|
|
|
|
|
|
order_sql = order_map.get(sort_by, 'i.created_at DESC')
|
|
|
|
|
|
|
|
|
|
|
|
# 获取总数
|
|
|
|
|
|
count_sql = f"""
|
|
|
|
|
|
SELECT COUNT(*) as count
|
|
|
|
|
|
FROM qd_indicator_codes i
|
|
|
|
|
|
WHERE {where_sql}
|
|
|
|
|
|
"""
|
|
|
|
|
|
cur.execute(count_sql, tuple(params))
|
|
|
|
|
|
total = cur.fetchone()['count']
|
|
|
|
|
|
|
|
|
|
|
|
# 获取列表(联表查询作者信息)
|
|
|
|
|
|
query_sql = f"""
|
|
|
|
|
|
SELECT
|
2026-02-27 01:57:04 +08:00
|
|
|
|
i.id, i.name, i.description, i.pricing_type, i.price, COALESCE(i.vip_free, FALSE) as vip_free,
|
2026-01-31 02:59:49 +08:00
|
|
|
|
i.preview_image, i.purchase_count, i.avg_rating, i.rating_count,
|
|
|
|
|
|
i.view_count, i.created_at, i.updated_at,
|
|
|
|
|
|
u.id as author_id, u.username as author_username,
|
|
|
|
|
|
u.nickname as author_nickname, u.avatar as author_avatar
|
|
|
|
|
|
FROM qd_indicator_codes i
|
|
|
|
|
|
LEFT JOIN qd_users u ON i.user_id = u.id
|
|
|
|
|
|
WHERE {where_sql}
|
|
|
|
|
|
ORDER BY {order_sql}
|
|
|
|
|
|
LIMIT ? OFFSET ?
|
|
|
|
|
|
"""
|
|
|
|
|
|
cur.execute(query_sql, tuple(params + [page_size, offset]))
|
|
|
|
|
|
rows = cur.fetchall() or []
|
|
|
|
|
|
|
|
|
|
|
|
# 如果有当前用户,查询已购买的指标
|
|
|
|
|
|
purchased_ids = set()
|
|
|
|
|
|
if user_id:
|
|
|
|
|
|
indicator_ids = [r['id'] for r in rows]
|
|
|
|
|
|
if indicator_ids:
|
|
|
|
|
|
placeholders = ','.join(['?'] * len(indicator_ids))
|
|
|
|
|
|
cur.execute(
|
|
|
|
|
|
f"SELECT indicator_id FROM qd_indicator_purchases WHERE buyer_id = ? AND indicator_id IN ({placeholders})",
|
|
|
|
|
|
tuple([user_id] + indicator_ids)
|
|
|
|
|
|
)
|
|
|
|
|
|
purchased_ids = {r['indicator_id'] for r in (cur.fetchall() or [])}
|
|
|
|
|
|
|
|
|
|
|
|
cur.close()
|
|
|
|
|
|
|
|
|
|
|
|
# 格式化返回数据
|
|
|
|
|
|
items = []
|
|
|
|
|
|
for row in rows:
|
|
|
|
|
|
items.append({
|
|
|
|
|
|
'id': row['id'],
|
|
|
|
|
|
'name': row['name'],
|
|
|
|
|
|
'description': row['description'][:200] if row['description'] else '',
|
|
|
|
|
|
'pricing_type': row['pricing_type'] or 'free',
|
|
|
|
|
|
'price': float(row['price'] or 0),
|
2026-02-27 01:57:04 +08:00
|
|
|
|
'vip_free': bool(row.get('vip_free') or False),
|
2026-01-31 02:59:49 +08:00
|
|
|
|
'preview_image': row['preview_image'] or '',
|
|
|
|
|
|
'purchase_count': row['purchase_count'] or 0,
|
|
|
|
|
|
'avg_rating': float(row['avg_rating'] or 0),
|
|
|
|
|
|
'rating_count': row['rating_count'] or 0,
|
|
|
|
|
|
'view_count': row['view_count'] or 0,
|
|
|
|
|
|
'created_at': row['created_at'].isoformat() if row['created_at'] else None,
|
|
|
|
|
|
'author': {
|
|
|
|
|
|
'id': row['author_id'],
|
|
|
|
|
|
'username': row['author_username'],
|
|
|
|
|
|
'nickname': row['author_nickname'] or row['author_username'],
|
|
|
|
|
|
'avatar': row['author_avatar'] or '/avatar2.jpg'
|
|
|
|
|
|
},
|
|
|
|
|
|
'is_purchased': row['id'] in purchased_ids,
|
|
|
|
|
|
'is_own': row['author_id'] == user_id
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
'items': items,
|
|
|
|
|
|
'total': total,
|
|
|
|
|
|
'page': page,
|
|
|
|
|
|
'page_size': page_size,
|
|
|
|
|
|
'total_pages': (total + page_size - 1) // page_size if total > 0 else 0
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"get_market_indicators failed: {e}")
|
|
|
|
|
|
return {'items': [], 'total': 0, 'page': 1, 'page_size': page_size, 'total_pages': 0}
|
|
|
|
|
|
|
|
|
|
|
|
def get_indicator_detail(self, indicator_id: int, user_id: int = None) -> Optional[Dict[str, Any]]:
|
|
|
|
|
|
"""获取指标详情"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
with get_db_connection() as db:
|
|
|
|
|
|
cur = db.cursor()
|
|
|
|
|
|
|
|
|
|
|
|
# 获取指标信息
|
|
|
|
|
|
cur.execute("""
|
|
|
|
|
|
SELECT
|
2026-02-27 01:57:04 +08:00
|
|
|
|
i.id, i.name, i.description, i.pricing_type, i.price, COALESCE(i.vip_free, FALSE) as vip_free,
|
2026-01-31 02:59:49 +08:00
|
|
|
|
i.preview_image, i.purchase_count, i.avg_rating, i.rating_count,
|
|
|
|
|
|
i.view_count, i.publish_to_community, i.created_at, i.updated_at,
|
|
|
|
|
|
i.user_id,
|
|
|
|
|
|
u.id as author_id, u.username as author_username,
|
|
|
|
|
|
u.nickname as author_nickname, u.avatar as author_avatar
|
|
|
|
|
|
FROM qd_indicator_codes i
|
|
|
|
|
|
LEFT JOIN qd_users u ON i.user_id = u.id
|
|
|
|
|
|
WHERE i.id = ?
|
|
|
|
|
|
""", (indicator_id,))
|
|
|
|
|
|
row = cur.fetchone()
|
|
|
|
|
|
|
|
|
|
|
|
if not row:
|
|
|
|
|
|
cur.close()
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
# 检查是否已发布到社区(或者是自己的指标)
|
|
|
|
|
|
if not row['publish_to_community'] and row['user_id'] != user_id:
|
|
|
|
|
|
cur.close()
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
# 检查是否已购买
|
|
|
|
|
|
is_purchased = False
|
|
|
|
|
|
if user_id:
|
|
|
|
|
|
cur.execute(
|
|
|
|
|
|
"SELECT id FROM qd_indicator_purchases WHERE indicator_id = ? AND buyer_id = ?",
|
|
|
|
|
|
(indicator_id, user_id)
|
|
|
|
|
|
)
|
|
|
|
|
|
is_purchased = cur.fetchone() is not None
|
|
|
|
|
|
|
|
|
|
|
|
# 增加浏览次数
|
|
|
|
|
|
cur.execute(
|
|
|
|
|
|
"UPDATE qd_indicator_codes SET view_count = COALESCE(view_count, 0) + 1 WHERE id = ?",
|
|
|
|
|
|
(indicator_id,)
|
|
|
|
|
|
)
|
|
|
|
|
|
db.commit()
|
|
|
|
|
|
cur.close()
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
'id': row['id'],
|
|
|
|
|
|
'name': row['name'],
|
|
|
|
|
|
'description': row['description'] or '',
|
|
|
|
|
|
'pricing_type': row['pricing_type'] or 'free',
|
|
|
|
|
|
'price': float(row['price'] or 0),
|
2026-02-27 01:57:04 +08:00
|
|
|
|
'vip_free': bool(row.get('vip_free') or False),
|
2026-01-31 02:59:49 +08:00
|
|
|
|
'preview_image': row['preview_image'] or '',
|
|
|
|
|
|
'purchase_count': row['purchase_count'] or 0,
|
|
|
|
|
|
'avg_rating': float(row['avg_rating'] or 0),
|
|
|
|
|
|
'rating_count': row['rating_count'] or 0,
|
|
|
|
|
|
'view_count': (row['view_count'] or 0) + 1,
|
|
|
|
|
|
'created_at': row['created_at'].isoformat() if row['created_at'] else None,
|
|
|
|
|
|
'updated_at': row['updated_at'].isoformat() if row['updated_at'] else None,
|
|
|
|
|
|
'author': {
|
|
|
|
|
|
'id': row['author_id'],
|
|
|
|
|
|
'username': row['author_username'],
|
|
|
|
|
|
'nickname': row['author_nickname'] or row['author_username'],
|
|
|
|
|
|
'avatar': row['author_avatar'] or '/avatar2.jpg'
|
|
|
|
|
|
},
|
|
|
|
|
|
'is_purchased': is_purchased,
|
|
|
|
|
|
'is_own': row['user_id'] == user_id
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"get_indicator_detail failed: {e}")
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
# ==========================================
|
|
|
|
|
|
# 购买功能
|
|
|
|
|
|
# ==========================================
|
|
|
|
|
|
|
|
|
|
|
|
def purchase_indicator(self, buyer_id: int, indicator_id: int) -> Tuple[bool, str, Dict[str, Any]]:
|
|
|
|
|
|
"""
|
|
|
|
|
|
购买指标
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
(success, message, data)
|
|
|
|
|
|
"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
with get_db_connection() as db:
|
|
|
|
|
|
cur = db.cursor()
|
|
|
|
|
|
|
|
|
|
|
|
# 1. 获取指标信息
|
|
|
|
|
|
cur.execute("""
|
2026-02-27 01:57:04 +08:00
|
|
|
|
SELECT id, user_id, name, code, description, pricing_type, price, COALESCE(vip_free, FALSE) as vip_free,
|
2026-01-31 02:59:49 +08:00
|
|
|
|
preview_image, is_encrypted
|
|
|
|
|
|
FROM qd_indicator_codes
|
|
|
|
|
|
WHERE id = ? AND publish_to_community = 1
|
|
|
|
|
|
""", (indicator_id,))
|
|
|
|
|
|
indicator = cur.fetchone()
|
|
|
|
|
|
|
|
|
|
|
|
if not indicator:
|
|
|
|
|
|
cur.close()
|
|
|
|
|
|
return False, 'indicator_not_found', {}
|
|
|
|
|
|
|
|
|
|
|
|
seller_id = indicator['user_id']
|
|
|
|
|
|
price = float(indicator['price'] or 0)
|
|
|
|
|
|
pricing_type = indicator['pricing_type'] or 'free'
|
2026-02-27 01:57:04 +08:00
|
|
|
|
vip_free = bool(indicator.get('vip_free') or False)
|
|
|
|
|
|
is_vip, _ = self.billing.get_user_vip_status(buyer_id)
|
|
|
|
|
|
|
|
|
|
|
|
# VIP-free indicator: VIP users can get it without credits charge
|
|
|
|
|
|
effective_price = 0.0 if (vip_free and is_vip) else price
|
2026-01-31 02:59:49 +08:00
|
|
|
|
|
|
|
|
|
|
# 2. 检查是否购买自己的指标
|
|
|
|
|
|
if seller_id == buyer_id:
|
|
|
|
|
|
cur.close()
|
|
|
|
|
|
return False, 'cannot_buy_own', {}
|
|
|
|
|
|
|
|
|
|
|
|
# 3. 检查是否已购买
|
|
|
|
|
|
cur.execute(
|
|
|
|
|
|
"SELECT id FROM qd_indicator_purchases WHERE indicator_id = ? AND buyer_id = ?",
|
|
|
|
|
|
(indicator_id, buyer_id)
|
|
|
|
|
|
)
|
|
|
|
|
|
if cur.fetchone():
|
|
|
|
|
|
cur.close()
|
|
|
|
|
|
return False, 'already_purchased', {}
|
|
|
|
|
|
|
|
|
|
|
|
# 4. 如果是付费指标,检查并扣除积分
|
2026-02-27 01:57:04 +08:00
|
|
|
|
if pricing_type != 'free' and effective_price > 0:
|
2026-01-31 02:59:49 +08:00
|
|
|
|
buyer_credits = self.billing.get_user_credits(buyer_id)
|
2026-02-27 01:57:04 +08:00
|
|
|
|
if buyer_credits < effective_price:
|
2026-01-31 02:59:49 +08:00
|
|
|
|
cur.close()
|
|
|
|
|
|
return False, 'insufficient_credits', {
|
2026-02-27 01:57:04 +08:00
|
|
|
|
'required': effective_price,
|
2026-01-31 02:59:49 +08:00
|
|
|
|
'current': float(buyer_credits)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
# 扣除买家积分
|
2026-02-27 01:57:04 +08:00
|
|
|
|
new_buyer_balance = buyer_credits - Decimal(str(effective_price))
|
2026-01-31 02:59:49 +08:00
|
|
|
|
cur.execute(
|
|
|
|
|
|
"UPDATE qd_users SET credits = ?, updated_at = NOW() WHERE id = ?",
|
|
|
|
|
|
(float(new_buyer_balance), buyer_id)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# 记录买家积分日志
|
|
|
|
|
|
cur.execute("""
|
|
|
|
|
|
INSERT INTO qd_credits_log
|
|
|
|
|
|
(user_id, action, amount, balance_after, feature, reference_id, remark, created_at)
|
|
|
|
|
|
VALUES (?, 'indicator_purchase', ?, ?, 'indicator_purchase', ?, ?, NOW())
|
2026-02-27 01:57:04 +08:00
|
|
|
|
""", (buyer_id, -effective_price, float(new_buyer_balance), str(indicator_id),
|
2026-01-31 02:59:49 +08:00
|
|
|
|
f"购买指标: {indicator['name']}"))
|
|
|
|
|
|
|
|
|
|
|
|
# 给卖家增加积分(可配置抽成比例,这里先100%给卖家)
|
|
|
|
|
|
seller_credits = self.billing.get_user_credits(seller_id)
|
2026-02-27 01:57:04 +08:00
|
|
|
|
new_seller_balance = seller_credits + Decimal(str(effective_price))
|
2026-01-31 02:59:49 +08:00
|
|
|
|
cur.execute(
|
|
|
|
|
|
"UPDATE qd_users SET credits = ?, updated_at = NOW() WHERE id = ?",
|
|
|
|
|
|
(float(new_seller_balance), seller_id)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# 记录卖家积分日志
|
|
|
|
|
|
cur.execute("""
|
|
|
|
|
|
INSERT INTO qd_credits_log
|
|
|
|
|
|
(user_id, action, amount, balance_after, feature, reference_id, remark, created_at)
|
|
|
|
|
|
VALUES (?, 'indicator_sale', ?, ?, 'indicator_sale', ?, ?, NOW())
|
2026-02-27 01:57:04 +08:00
|
|
|
|
""", (seller_id, effective_price, float(new_seller_balance), str(indicator_id),
|
2026-01-31 02:59:49 +08:00
|
|
|
|
f"出售指标: {indicator['name']}"))
|
|
|
|
|
|
|
|
|
|
|
|
# 5. 创建购买记录
|
|
|
|
|
|
cur.execute("""
|
|
|
|
|
|
INSERT INTO qd_indicator_purchases
|
|
|
|
|
|
(indicator_id, buyer_id, seller_id, price, created_at)
|
|
|
|
|
|
VALUES (?, ?, ?, ?, NOW())
|
2026-02-27 01:57:04 +08:00
|
|
|
|
""", (indicator_id, buyer_id, seller_id, effective_price))
|
2026-01-31 02:59:49 +08:00
|
|
|
|
|
|
|
|
|
|
# 6. 复制指标到买家账户
|
|
|
|
|
|
now_ts = int(time.time())
|
2026-02-28 00:15:58 +08:00
|
|
|
|
# Get vip_free as boolean from indicator
|
|
|
|
|
|
vip_free_value = bool(indicator.get('vip_free') or False)
|
2026-01-31 02:59:49 +08:00
|
|
|
|
cur.execute("""
|
|
|
|
|
|
INSERT INTO qd_indicator_codes
|
|
|
|
|
|
(user_id, is_buy, end_time, name, code, description,
|
2026-02-27 01:57:04 +08:00
|
|
|
|
publish_to_community, pricing_type, price, is_encrypted, preview_image, vip_free,
|
2026-01-31 02:59:49 +08:00
|
|
|
|
createtime, updatetime, created_at, updated_at)
|
2026-02-28 00:15:58 +08:00
|
|
|
|
VALUES (?, 1, 0, ?, ?, ?, 0, 'free', 0, ?, ?, ?, ?, ?, NOW(), NOW())
|
2026-01-31 02:59:49 +08:00
|
|
|
|
""", (
|
|
|
|
|
|
buyer_id,
|
|
|
|
|
|
indicator['name'],
|
|
|
|
|
|
indicator['code'],
|
|
|
|
|
|
indicator['description'],
|
|
|
|
|
|
indicator['is_encrypted'] or 0,
|
|
|
|
|
|
indicator['preview_image'],
|
2026-02-28 00:15:58 +08:00
|
|
|
|
vip_free_value, # Use boolean value instead of integer 0
|
2026-01-31 02:59:49 +08:00
|
|
|
|
now_ts, now_ts
|
|
|
|
|
|
))
|
|
|
|
|
|
|
|
|
|
|
|
# 7. 更新指标购买次数
|
|
|
|
|
|
cur.execute("""
|
|
|
|
|
|
UPDATE qd_indicator_codes
|
|
|
|
|
|
SET purchase_count = COALESCE(purchase_count, 0) + 1
|
|
|
|
|
|
WHERE id = ?
|
|
|
|
|
|
""", (indicator_id,))
|
|
|
|
|
|
|
|
|
|
|
|
db.commit()
|
|
|
|
|
|
cur.close()
|
|
|
|
|
|
|
2026-02-27 01:57:04 +08:00
|
|
|
|
logger.info(f"User {buyer_id} purchased indicator {indicator_id} for {effective_price} credits (vip_free={vip_free}, is_vip={is_vip})")
|
|
|
|
|
|
return True, 'success', {'indicator_name': indicator['name'], 'price': price, 'charged': effective_price, 'vip_free': vip_free}
|
2026-01-31 02:59:49 +08:00
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"purchase_indicator failed: {e}")
|
|
|
|
|
|
return False, f'error: {str(e)}', {}
|
|
|
|
|
|
|
|
|
|
|
|
def get_my_purchases(self, user_id: int, page: int = 1, page_size: int = 20) -> Dict[str, Any]:
|
|
|
|
|
|
"""获取用户购买的指标列表"""
|
|
|
|
|
|
offset = (page - 1) * page_size
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
with get_db_connection() as db:
|
|
|
|
|
|
cur = db.cursor()
|
|
|
|
|
|
|
|
|
|
|
|
# 获取总数
|
|
|
|
|
|
cur.execute(
|
|
|
|
|
|
"SELECT COUNT(*) as count FROM qd_indicator_purchases WHERE buyer_id = ?",
|
|
|
|
|
|
(user_id,)
|
|
|
|
|
|
)
|
|
|
|
|
|
total = cur.fetchone()['count']
|
|
|
|
|
|
|
|
|
|
|
|
# 获取列表
|
|
|
|
|
|
cur.execute("""
|
|
|
|
|
|
SELECT
|
|
|
|
|
|
p.id as purchase_id, p.price as purchase_price, p.created_at as purchase_time,
|
|
|
|
|
|
i.id, i.name, i.description, i.preview_image, i.avg_rating,
|
|
|
|
|
|
u.nickname as seller_nickname, u.avatar as seller_avatar
|
|
|
|
|
|
FROM qd_indicator_purchases p
|
|
|
|
|
|
LEFT JOIN qd_indicator_codes i ON p.indicator_id = i.id
|
|
|
|
|
|
LEFT JOIN qd_users u ON p.seller_id = u.id
|
|
|
|
|
|
WHERE p.buyer_id = ?
|
|
|
|
|
|
ORDER BY p.created_at DESC
|
|
|
|
|
|
LIMIT ? OFFSET ?
|
|
|
|
|
|
""", (user_id, page_size, offset))
|
|
|
|
|
|
rows = cur.fetchall() or []
|
|
|
|
|
|
cur.close()
|
|
|
|
|
|
|
|
|
|
|
|
items = []
|
|
|
|
|
|
for row in rows:
|
|
|
|
|
|
items.append({
|
|
|
|
|
|
'purchase_id': row['purchase_id'],
|
|
|
|
|
|
'purchase_price': float(row['purchase_price'] or 0),
|
|
|
|
|
|
'purchase_time': row['purchase_time'].isoformat() if row['purchase_time'] else None,
|
|
|
|
|
|
'indicator': {
|
|
|
|
|
|
'id': row['id'],
|
|
|
|
|
|
'name': row['name'],
|
|
|
|
|
|
'description': row['description'][:100] if row['description'] else '',
|
|
|
|
|
|
'preview_image': row['preview_image'] or '',
|
|
|
|
|
|
'avg_rating': float(row['avg_rating'] or 0)
|
|
|
|
|
|
},
|
|
|
|
|
|
'seller': {
|
|
|
|
|
|
'nickname': row['seller_nickname'],
|
|
|
|
|
|
'avatar': row['seller_avatar'] or '/avatar2.jpg'
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
'items': items,
|
|
|
|
|
|
'total': total,
|
|
|
|
|
|
'page': page,
|
|
|
|
|
|
'page_size': page_size,
|
|
|
|
|
|
'total_pages': (total + page_size - 1) // page_size if total > 0 else 0
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"get_my_purchases failed: {e}")
|
|
|
|
|
|
return {'items': [], 'total': 0, 'page': 1, 'page_size': page_size, 'total_pages': 0}
|
|
|
|
|
|
|
|
|
|
|
|
# ==========================================
|
|
|
|
|
|
# 评论功能
|
|
|
|
|
|
# ==========================================
|
|
|
|
|
|
|
|
|
|
|
|
def get_comments(self, indicator_id: int, page: int = 1, page_size: int = 20) -> Dict[str, Any]:
|
|
|
|
|
|
"""获取指标评论列表"""
|
|
|
|
|
|
offset = (page - 1) * page_size
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
with get_db_connection() as db:
|
|
|
|
|
|
cur = db.cursor()
|
|
|
|
|
|
|
|
|
|
|
|
# 获取总数(只统计一级评论)
|
|
|
|
|
|
cur.execute("""
|
|
|
|
|
|
SELECT COUNT(*) as count FROM qd_indicator_comments
|
|
|
|
|
|
WHERE indicator_id = ? AND parent_id IS NULL AND is_deleted = 0
|
|
|
|
|
|
""", (indicator_id,))
|
|
|
|
|
|
total = cur.fetchone()['count']
|
|
|
|
|
|
|
|
|
|
|
|
# 获取评论列表
|
|
|
|
|
|
cur.execute("""
|
|
|
|
|
|
SELECT
|
|
|
|
|
|
c.id, c.rating, c.content, c.created_at,
|
|
|
|
|
|
u.id as user_id, u.nickname, u.avatar
|
|
|
|
|
|
FROM qd_indicator_comments c
|
|
|
|
|
|
LEFT JOIN qd_users u ON c.user_id = u.id
|
|
|
|
|
|
WHERE c.indicator_id = ? AND c.parent_id IS NULL AND c.is_deleted = 0
|
|
|
|
|
|
ORDER BY c.created_at DESC
|
|
|
|
|
|
LIMIT ? OFFSET ?
|
|
|
|
|
|
""", (indicator_id, page_size, offset))
|
|
|
|
|
|
rows = cur.fetchall() or []
|
|
|
|
|
|
cur.close()
|
|
|
|
|
|
|
|
|
|
|
|
items = []
|
|
|
|
|
|
for row in rows:
|
|
|
|
|
|
items.append({
|
|
|
|
|
|
'id': row['id'],
|
|
|
|
|
|
'rating': row['rating'],
|
|
|
|
|
|
'content': row['content'],
|
|
|
|
|
|
'created_at': row['created_at'].isoformat() if row['created_at'] else None,
|
|
|
|
|
|
'user': {
|
|
|
|
|
|
'id': row['user_id'],
|
|
|
|
|
|
'nickname': row['nickname'],
|
|
|
|
|
|
'avatar': row['avatar'] or '/avatar2.jpg'
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
'items': items,
|
|
|
|
|
|
'total': total,
|
|
|
|
|
|
'page': page,
|
|
|
|
|
|
'page_size': page_size,
|
|
|
|
|
|
'total_pages': (total + page_size - 1) // page_size if total > 0 else 0
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"get_comments failed: {e}")
|
|
|
|
|
|
return {'items': [], 'total': 0, 'page': 1, 'page_size': page_size, 'total_pages': 0}
|
|
|
|
|
|
|
|
|
|
|
|
def add_comment(
|
|
|
|
|
|
self,
|
|
|
|
|
|
user_id: int,
|
|
|
|
|
|
indicator_id: int,
|
|
|
|
|
|
rating: int,
|
|
|
|
|
|
content: str
|
|
|
|
|
|
) -> Tuple[bool, str, Dict[str, Any]]:
|
|
|
|
|
|
"""
|
|
|
|
|
|
添加评论(只有购买过的用户可以评论,且只能评论一次)
|
|
|
|
|
|
"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
# 验证评分范围
|
|
|
|
|
|
rating = max(1, min(5, int(rating)))
|
|
|
|
|
|
content = (content or '').strip()[:500] # 限制500字
|
|
|
|
|
|
|
|
|
|
|
|
with get_db_connection() as db:
|
|
|
|
|
|
cur = db.cursor()
|
|
|
|
|
|
|
|
|
|
|
|
# 检查指标是否存在
|
|
|
|
|
|
cur.execute(
|
|
|
|
|
|
"SELECT id, user_id FROM qd_indicator_codes WHERE id = ? AND publish_to_community = 1",
|
|
|
|
|
|
(indicator_id,)
|
|
|
|
|
|
)
|
|
|
|
|
|
indicator = cur.fetchone()
|
|
|
|
|
|
if not indicator:
|
|
|
|
|
|
cur.close()
|
|
|
|
|
|
return False, 'indicator_not_found', {}
|
|
|
|
|
|
|
|
|
|
|
|
# 不能评论自己的指标
|
|
|
|
|
|
if indicator['user_id'] == user_id:
|
|
|
|
|
|
cur.close()
|
|
|
|
|
|
return False, 'cannot_comment_own', {}
|
|
|
|
|
|
|
|
|
|
|
|
# 检查是否已购买(免费指标也需要"获取"才能评论)
|
|
|
|
|
|
cur.execute(
|
|
|
|
|
|
"SELECT id FROM qd_indicator_purchases WHERE indicator_id = ? AND buyer_id = ?",
|
|
|
|
|
|
(indicator_id, user_id)
|
|
|
|
|
|
)
|
|
|
|
|
|
if not cur.fetchone():
|
|
|
|
|
|
cur.close()
|
|
|
|
|
|
return False, 'not_purchased', {}
|
|
|
|
|
|
|
|
|
|
|
|
# 检查是否已评论
|
|
|
|
|
|
cur.execute(
|
|
|
|
|
|
"SELECT id FROM qd_indicator_comments WHERE indicator_id = ? AND user_id = ? AND parent_id IS NULL",
|
|
|
|
|
|
(indicator_id, user_id)
|
|
|
|
|
|
)
|
|
|
|
|
|
if cur.fetchone():
|
|
|
|
|
|
cur.close()
|
|
|
|
|
|
return False, 'already_commented', {}
|
|
|
|
|
|
|
|
|
|
|
|
# 添加评论
|
|
|
|
|
|
cur.execute("""
|
|
|
|
|
|
INSERT INTO qd_indicator_comments
|
|
|
|
|
|
(indicator_id, user_id, rating, content, created_at, updated_at)
|
|
|
|
|
|
VALUES (?, ?, ?, ?, NOW(), NOW())
|
|
|
|
|
|
""", (indicator_id, user_id, rating, content))
|
|
|
|
|
|
comment_id = cur.lastrowid
|
|
|
|
|
|
|
|
|
|
|
|
# 更新指标的评分统计
|
|
|
|
|
|
cur.execute("""
|
|
|
|
|
|
UPDATE qd_indicator_codes
|
|
|
|
|
|
SET
|
|
|
|
|
|
rating_count = COALESCE(rating_count, 0) + 1,
|
|
|
|
|
|
avg_rating = (
|
|
|
|
|
|
SELECT AVG(rating) FROM qd_indicator_comments
|
|
|
|
|
|
WHERE indicator_id = ? AND parent_id IS NULL AND is_deleted = 0
|
|
|
|
|
|
)
|
|
|
|
|
|
WHERE id = ?
|
|
|
|
|
|
""", (indicator_id, indicator_id))
|
|
|
|
|
|
|
|
|
|
|
|
db.commit()
|
|
|
|
|
|
cur.close()
|
|
|
|
|
|
|
|
|
|
|
|
logger.info(f"User {user_id} commented on indicator {indicator_id} with rating {rating}")
|
|
|
|
|
|
return True, 'success', {'comment_id': comment_id}
|
|
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"add_comment failed: {e}")
|
|
|
|
|
|
return False, f'error: {str(e)}', {}
|
|
|
|
|
|
|
|
|
|
|
|
def update_comment(
|
|
|
|
|
|
self,
|
|
|
|
|
|
user_id: int,
|
|
|
|
|
|
comment_id: int,
|
|
|
|
|
|
indicator_id: int,
|
|
|
|
|
|
rating: int,
|
|
|
|
|
|
content: str
|
|
|
|
|
|
) -> Tuple[bool, str, Dict[str, Any]]:
|
|
|
|
|
|
"""
|
|
|
|
|
|
更新评论(只能修改自己的评论)
|
|
|
|
|
|
"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
rating = max(1, min(5, int(rating)))
|
|
|
|
|
|
content = (content or '').strip()[:500]
|
|
|
|
|
|
|
|
|
|
|
|
with get_db_connection() as db:
|
|
|
|
|
|
cur = db.cursor()
|
|
|
|
|
|
|
|
|
|
|
|
# 检查评论是否存在且属于当前用户
|
|
|
|
|
|
cur.execute("""
|
|
|
|
|
|
SELECT id, rating as old_rating FROM qd_indicator_comments
|
|
|
|
|
|
WHERE id = ? AND user_id = ? AND indicator_id = ? AND is_deleted = 0
|
|
|
|
|
|
""", (comment_id, user_id, indicator_id))
|
|
|
|
|
|
comment = cur.fetchone()
|
|
|
|
|
|
|
|
|
|
|
|
if not comment:
|
|
|
|
|
|
cur.close()
|
|
|
|
|
|
return False, 'comment_not_found', {}
|
|
|
|
|
|
|
|
|
|
|
|
old_rating = comment['old_rating']
|
|
|
|
|
|
|
|
|
|
|
|
# 更新评论
|
|
|
|
|
|
cur.execute("""
|
|
|
|
|
|
UPDATE qd_indicator_comments
|
|
|
|
|
|
SET rating = ?, content = ?, updated_at = NOW()
|
|
|
|
|
|
WHERE id = ?
|
|
|
|
|
|
""", (rating, content, comment_id))
|
|
|
|
|
|
|
|
|
|
|
|
# 如果评分变了,更新指标的平均评分
|
|
|
|
|
|
if old_rating != rating:
|
|
|
|
|
|
cur.execute("""
|
|
|
|
|
|
UPDATE qd_indicator_codes
|
|
|
|
|
|
SET avg_rating = (
|
|
|
|
|
|
SELECT AVG(rating) FROM qd_indicator_comments
|
|
|
|
|
|
WHERE indicator_id = ? AND parent_id IS NULL AND is_deleted = 0
|
|
|
|
|
|
)
|
|
|
|
|
|
WHERE id = ?
|
|
|
|
|
|
""", (indicator_id, indicator_id))
|
|
|
|
|
|
|
|
|
|
|
|
db.commit()
|
|
|
|
|
|
cur.close()
|
|
|
|
|
|
|
|
|
|
|
|
logger.info(f"User {user_id} updated comment {comment_id}")
|
|
|
|
|
|
return True, 'success', {'comment_id': comment_id}
|
|
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"update_comment failed: {e}")
|
|
|
|
|
|
return False, f'error: {str(e)}', {}
|
|
|
|
|
|
|
|
|
|
|
|
def get_user_comment(self, user_id: int, indicator_id: int) -> Optional[Dict[str, Any]]:
|
|
|
|
|
|
"""获取用户对某个指标的评论"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
with get_db_connection() as db:
|
|
|
|
|
|
cur = db.cursor()
|
|
|
|
|
|
cur.execute("""
|
|
|
|
|
|
SELECT id, rating, content, created_at, updated_at
|
|
|
|
|
|
FROM qd_indicator_comments
|
|
|
|
|
|
WHERE user_id = ? AND indicator_id = ? AND parent_id IS NULL AND is_deleted = 0
|
|
|
|
|
|
""", (user_id, indicator_id))
|
|
|
|
|
|
row = cur.fetchone()
|
|
|
|
|
|
cur.close()
|
|
|
|
|
|
|
|
|
|
|
|
if not row:
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
'id': row['id'],
|
|
|
|
|
|
'rating': row['rating'],
|
|
|
|
|
|
'content': row['content'],
|
|
|
|
|
|
'created_at': row['created_at'].isoformat() if row['created_at'] else None,
|
|
|
|
|
|
'updated_at': row['updated_at'].isoformat() if row['updated_at'] else None
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"get_user_comment failed: {e}")
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
# ==========================================
|
|
|
|
|
|
# 管理员审核功能
|
|
|
|
|
|
# ==========================================
|
|
|
|
|
|
|
|
|
|
|
|
def get_pending_indicators(
|
|
|
|
|
|
self,
|
|
|
|
|
|
page: int = 1,
|
|
|
|
|
|
page_size: int = 20,
|
|
|
|
|
|
review_status: str = 'pending' # 'pending' / 'approved' / 'rejected' / 'all'
|
|
|
|
|
|
) -> Dict[str, Any]:
|
|
|
|
|
|
"""获取待审核的指标列表(管理员用)"""
|
|
|
|
|
|
offset = (page - 1) * page_size
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
with get_db_connection() as db:
|
|
|
|
|
|
cur = db.cursor()
|
|
|
|
|
|
|
|
|
|
|
|
# 构建查询条件
|
|
|
|
|
|
where_clauses = ["i.publish_to_community = 1"]
|
|
|
|
|
|
params = []
|
|
|
|
|
|
|
|
|
|
|
|
if review_status and review_status != 'all':
|
|
|
|
|
|
where_clauses.append("i.review_status = ?")
|
|
|
|
|
|
params.append(review_status)
|
|
|
|
|
|
|
|
|
|
|
|
where_sql = " AND ".join(where_clauses)
|
|
|
|
|
|
|
|
|
|
|
|
# 获取总数
|
|
|
|
|
|
count_sql = f"""
|
|
|
|
|
|
SELECT COUNT(*) as count
|
|
|
|
|
|
FROM qd_indicator_codes i
|
|
|
|
|
|
WHERE {where_sql}
|
|
|
|
|
|
"""
|
|
|
|
|
|
cur.execute(count_sql, tuple(params))
|
|
|
|
|
|
total = cur.fetchone()['count']
|
|
|
|
|
|
|
|
|
|
|
|
# 获取列表
|
|
|
|
|
|
query_sql = f"""
|
|
|
|
|
|
SELECT
|
|
|
|
|
|
i.id, i.name, i.description, i.pricing_type, i.price,
|
|
|
|
|
|
i.preview_image, i.code, i.review_status, i.review_note,
|
|
|
|
|
|
i.reviewed_at, i.reviewed_by, i.created_at,
|
|
|
|
|
|
u.id as author_id, u.username as author_username,
|
|
|
|
|
|
u.nickname as author_nickname, u.avatar as author_avatar,
|
|
|
|
|
|
r.username as reviewer_username
|
|
|
|
|
|
FROM qd_indicator_codes i
|
|
|
|
|
|
LEFT JOIN qd_users u ON i.user_id = u.id
|
|
|
|
|
|
LEFT JOIN qd_users r ON i.reviewed_by = r.id
|
|
|
|
|
|
WHERE {where_sql}
|
|
|
|
|
|
ORDER BY i.created_at DESC
|
|
|
|
|
|
LIMIT ? OFFSET ?
|
|
|
|
|
|
"""
|
|
|
|
|
|
cur.execute(query_sql, tuple(params + [page_size, offset]))
|
|
|
|
|
|
rows = cur.fetchall() or []
|
|
|
|
|
|
cur.close()
|
|
|
|
|
|
|
|
|
|
|
|
items = []
|
|
|
|
|
|
for row in rows:
|
|
|
|
|
|
items.append({
|
|
|
|
|
|
'id': row['id'],
|
|
|
|
|
|
'name': row['name'],
|
|
|
|
|
|
'description': row['description'][:300] if row['description'] else '',
|
|
|
|
|
|
'pricing_type': row['pricing_type'] or 'free',
|
|
|
|
|
|
'price': float(row['price'] or 0),
|
|
|
|
|
|
'preview_image': row['preview_image'] or '',
|
|
|
|
|
|
'code': row['code'] or '', # 管理员可以看代码
|
|
|
|
|
|
'review_status': row['review_status'] or 'pending',
|
|
|
|
|
|
'review_note': row['review_note'] or '',
|
|
|
|
|
|
'reviewed_at': row['reviewed_at'].isoformat() if row['reviewed_at'] else None,
|
|
|
|
|
|
'reviewer_username': row['reviewer_username'],
|
|
|
|
|
|
'created_at': row['created_at'].isoformat() if row['created_at'] else None,
|
|
|
|
|
|
'author': {
|
|
|
|
|
|
'id': row['author_id'],
|
|
|
|
|
|
'username': row['author_username'],
|
|
|
|
|
|
'nickname': row['author_nickname'] or row['author_username'],
|
|
|
|
|
|
'avatar': row['author_avatar'] or '/avatar2.jpg'
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
'items': items,
|
|
|
|
|
|
'total': total,
|
|
|
|
|
|
'page': page,
|
|
|
|
|
|
'page_size': page_size,
|
|
|
|
|
|
'total_pages': (total + page_size - 1) // page_size if total > 0 else 0
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"get_pending_indicators failed: {e}")
|
|
|
|
|
|
return {'items': [], 'total': 0, 'page': 1, 'page_size': page_size, 'total_pages': 0}
|
|
|
|
|
|
|
|
|
|
|
|
def review_indicator(
|
|
|
|
|
|
self,
|
|
|
|
|
|
admin_id: int,
|
|
|
|
|
|
indicator_id: int,
|
|
|
|
|
|
action: str, # 'approve' / 'reject'
|
|
|
|
|
|
note: str = ''
|
|
|
|
|
|
) -> Tuple[bool, str]:
|
|
|
|
|
|
"""审核指标"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
new_status = 'approved' if action == 'approve' else 'rejected'
|
|
|
|
|
|
note = (note or '').strip()[:500]
|
|
|
|
|
|
|
|
|
|
|
|
with get_db_connection() as db:
|
|
|
|
|
|
cur = db.cursor()
|
|
|
|
|
|
|
|
|
|
|
|
# 检查指标是否存在且已发布到社区
|
|
|
|
|
|
cur.execute("""
|
|
|
|
|
|
SELECT id, name, user_id FROM qd_indicator_codes
|
|
|
|
|
|
WHERE id = ? AND publish_to_community = 1
|
|
|
|
|
|
""", (indicator_id,))
|
|
|
|
|
|
indicator = cur.fetchone()
|
|
|
|
|
|
|
|
|
|
|
|
if not indicator:
|
|
|
|
|
|
cur.close()
|
|
|
|
|
|
return False, 'indicator_not_found'
|
|
|
|
|
|
|
|
|
|
|
|
# 更新审核状态
|
|
|
|
|
|
cur.execute("""
|
|
|
|
|
|
UPDATE qd_indicator_codes
|
|
|
|
|
|
SET review_status = ?, review_note = ?, reviewed_at = NOW(), reviewed_by = ?
|
|
|
|
|
|
WHERE id = ?
|
|
|
|
|
|
""", (new_status, note, admin_id, indicator_id))
|
|
|
|
|
|
|
|
|
|
|
|
db.commit()
|
|
|
|
|
|
cur.close()
|
|
|
|
|
|
|
|
|
|
|
|
logger.info(f"Admin {admin_id} {action}d indicator {indicator_id}")
|
|
|
|
|
|
return True, 'success'
|
|
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"review_indicator failed: {e}")
|
|
|
|
|
|
return False, f'error: {str(e)}'
|
|
|
|
|
|
|
|
|
|
|
|
def unpublish_indicator(self, admin_id: int, indicator_id: int, note: str = '') -> Tuple[bool, str]:
|
|
|
|
|
|
"""下架指标(取消发布)"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
note = (note or '').strip()[:500]
|
|
|
|
|
|
|
|
|
|
|
|
with get_db_connection() as db:
|
|
|
|
|
|
cur = db.cursor()
|
|
|
|
|
|
|
|
|
|
|
|
# 检查指标是否存在
|
|
|
|
|
|
cur.execute("""
|
|
|
|
|
|
SELECT id, name FROM qd_indicator_codes WHERE id = ?
|
|
|
|
|
|
""", (indicator_id,))
|
|
|
|
|
|
indicator = cur.fetchone()
|
|
|
|
|
|
|
|
|
|
|
|
if not indicator:
|
|
|
|
|
|
cur.close()
|
|
|
|
|
|
return False, 'indicator_not_found'
|
|
|
|
|
|
|
|
|
|
|
|
# 下架(取消发布)
|
|
|
|
|
|
cur.execute("""
|
|
|
|
|
|
UPDATE qd_indicator_codes
|
|
|
|
|
|
SET publish_to_community = 0, review_status = 'rejected',
|
|
|
|
|
|
review_note = ?, reviewed_at = NOW(), reviewed_by = ?
|
|
|
|
|
|
WHERE id = ?
|
|
|
|
|
|
""", (f"下架: {note}" if note else "管理员下架", admin_id, indicator_id))
|
|
|
|
|
|
|
|
|
|
|
|
db.commit()
|
|
|
|
|
|
cur.close()
|
|
|
|
|
|
|
|
|
|
|
|
logger.info(f"Admin {admin_id} unpublished indicator {indicator_id}")
|
|
|
|
|
|
return True, 'success'
|
|
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"unpublish_indicator failed: {e}")
|
|
|
|
|
|
return False, f'error: {str(e)}'
|
|
|
|
|
|
|
|
|
|
|
|
def admin_delete_indicator(self, admin_id: int, indicator_id: int) -> Tuple[bool, str]:
|
|
|
|
|
|
"""管理员删除指标"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
with get_db_connection() as db:
|
|
|
|
|
|
cur = db.cursor()
|
|
|
|
|
|
|
|
|
|
|
|
# 检查指标是否存在
|
|
|
|
|
|
cur.execute("SELECT id, name FROM qd_indicator_codes WHERE id = ?", (indicator_id,))
|
|
|
|
|
|
indicator = cur.fetchone()
|
|
|
|
|
|
|
|
|
|
|
|
if not indicator:
|
|
|
|
|
|
cur.close()
|
|
|
|
|
|
return False, 'indicator_not_found'
|
|
|
|
|
|
|
|
|
|
|
|
# 删除关联的评论
|
|
|
|
|
|
cur.execute("DELETE FROM qd_indicator_comments WHERE indicator_id = ?", (indicator_id,))
|
|
|
|
|
|
|
|
|
|
|
|
# 删除关联的购买记录
|
|
|
|
|
|
cur.execute("DELETE FROM qd_indicator_purchases WHERE indicator_id = ?", (indicator_id,))
|
|
|
|
|
|
|
|
|
|
|
|
# 删除指标
|
|
|
|
|
|
cur.execute("DELETE FROM qd_indicator_codes WHERE id = ?", (indicator_id,))
|
|
|
|
|
|
|
|
|
|
|
|
db.commit()
|
|
|
|
|
|
cur.close()
|
|
|
|
|
|
|
|
|
|
|
|
logger.info(f"Admin {admin_id} deleted indicator {indicator_id}")
|
|
|
|
|
|
return True, 'success'
|
|
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"admin_delete_indicator failed: {e}")
|
|
|
|
|
|
return False, f'error: {str(e)}'
|
|
|
|
|
|
|
|
|
|
|
|
def get_review_stats(self) -> Dict[str, int]:
|
|
|
|
|
|
"""获取审核统计"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
with get_db_connection() as db:
|
|
|
|
|
|
cur = db.cursor()
|
|
|
|
|
|
cur.execute("""
|
|
|
|
|
|
SELECT
|
|
|
|
|
|
COUNT(*) FILTER (WHERE review_status = 'pending' OR review_status IS NULL) as pending_count,
|
|
|
|
|
|
COUNT(*) FILTER (WHERE review_status = 'approved') as approved_count,
|
|
|
|
|
|
COUNT(*) FILTER (WHERE review_status = 'rejected') as rejected_count
|
|
|
|
|
|
FROM qd_indicator_codes
|
|
|
|
|
|
WHERE publish_to_community = 1
|
|
|
|
|
|
""")
|
|
|
|
|
|
row = cur.fetchone()
|
|
|
|
|
|
cur.close()
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
'pending': row['pending_count'] or 0,
|
|
|
|
|
|
'approved': row['approved_count'] or 0,
|
|
|
|
|
|
'rejected': row['rejected_count'] or 0
|
|
|
|
|
|
}
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"get_review_stats failed: {e}")
|
|
|
|
|
|
return {'pending': 0, 'approved': 0, 'rejected': 0}
|
|
|
|
|
|
|
|
|
|
|
|
# ==========================================
|
2026-02-27 01:57:04 +08:00
|
|
|
|
# 实盘表现(聚合回测 + 实盘交易数据)
|
2026-01-31 02:59:49 +08:00
|
|
|
|
# ==========================================
|
2026-02-27 01:57:04 +08:00
|
|
|
|
|
2026-01-31 02:59:49 +08:00
|
|
|
|
def get_indicator_performance(self, indicator_id: int) -> Dict[str, Any]:
|
|
|
|
|
|
"""
|
|
|
|
|
|
获取指标的实盘表现统计
|
2026-02-27 01:57:04 +08:00
|
|
|
|
|
|
|
|
|
|
数据来源:
|
|
|
|
|
|
1. qd_backtest_runs - 回测记录(result_json 内含 totalReturn / winRate 等)
|
|
|
|
|
|
2. qd_strategy_trades + qd_strategies_trading - 真实实盘交易记录
|
2026-01-31 02:59:49 +08:00
|
|
|
|
"""
|
|
|
|
|
|
default_result = {
|
|
|
|
|
|
'strategy_count': 0,
|
|
|
|
|
|
'trade_count': 0,
|
|
|
|
|
|
'win_rate': 0,
|
|
|
|
|
|
'total_profit': 0,
|
|
|
|
|
|
'avg_return': 0,
|
|
|
|
|
|
'max_drawdown': 0
|
|
|
|
|
|
}
|
2026-02-27 01:57:04 +08:00
|
|
|
|
|
2026-01-31 02:59:49 +08:00
|
|
|
|
try:
|
|
|
|
|
|
with get_db_connection() as db:
|
|
|
|
|
|
cur = db.cursor()
|
2026-02-27 01:57:04 +08:00
|
|
|
|
|
|
|
|
|
|
# ---------- Part 1: 回测数据(从 result_json 解析) ----------
|
|
|
|
|
|
bt_returns = []
|
|
|
|
|
|
bt_win_rates = []
|
|
|
|
|
|
bt_drawdowns = []
|
|
|
|
|
|
bt_trade_counts = []
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
cur.execute("""
|
|
|
|
|
|
SELECT result_json
|
|
|
|
|
|
FROM qd_backtest_runs
|
|
|
|
|
|
WHERE indicator_id = %s AND status = 'success'
|
|
|
|
|
|
AND result_json IS NOT NULL AND result_json != ''
|
|
|
|
|
|
""", (indicator_id,))
|
|
|
|
|
|
rows = cur.fetchall()
|
|
|
|
|
|
|
|
|
|
|
|
for row in rows:
|
|
|
|
|
|
try:
|
|
|
|
|
|
rj = json.loads(row['result_json']) if isinstance(row['result_json'], str) else {}
|
|
|
|
|
|
tr = float(rj.get('totalReturn', 0) or 0)
|
|
|
|
|
|
wr = float(rj.get('winRate', 0) or 0)
|
|
|
|
|
|
md = float(rj.get('maxDrawdown', 0) or 0)
|
|
|
|
|
|
tc = int(rj.get('totalTrades', 0) or 0)
|
|
|
|
|
|
bt_returns.append(tr)
|
|
|
|
|
|
bt_win_rates.append(wr)
|
|
|
|
|
|
bt_drawdowns.append(md)
|
|
|
|
|
|
bt_trade_counts.append(tc)
|
|
|
|
|
|
except (json.JSONDecodeError, TypeError, ValueError):
|
|
|
|
|
|
continue
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
logger.debug("Backtest runs query skipped or failed", exc_info=True)
|
|
|
|
|
|
|
|
|
|
|
|
bt_run_count = len(bt_returns)
|
|
|
|
|
|
|
|
|
|
|
|
# ---------- Part 2: 实盘交易数据 ----------
|
|
|
|
|
|
live_strategy_count = 0
|
|
|
|
|
|
live_trade_count = 0
|
|
|
|
|
|
live_win_rate = 0.0
|
|
|
|
|
|
live_total_profit = 0.0
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
# 找出使用该指标的策略(indicator_config JSON 中 indicator_id 匹配)
|
|
|
|
|
|
cur.execute("""
|
|
|
|
|
|
SELECT id FROM qd_strategies_trading
|
|
|
|
|
|
WHERE indicator_config::text LIKE %s
|
|
|
|
|
|
""", (f'%"indicator_id": {indicator_id}%',))
|
|
|
|
|
|
strategy_rows = cur.fetchall()
|
|
|
|
|
|
|
|
|
|
|
|
# 也尝试匹配无空格的格式
|
|
|
|
|
|
if not strategy_rows:
|
|
|
|
|
|
cur.execute("""
|
|
|
|
|
|
SELECT id FROM qd_strategies_trading
|
|
|
|
|
|
WHERE indicator_config::text LIKE %s
|
|
|
|
|
|
""", (f'%"indicator_id":{indicator_id}%',))
|
|
|
|
|
|
strategy_rows = cur.fetchall()
|
|
|
|
|
|
|
|
|
|
|
|
if strategy_rows:
|
|
|
|
|
|
strategy_ids = [r['id'] for r in strategy_rows]
|
|
|
|
|
|
live_strategy_count = len(strategy_ids)
|
|
|
|
|
|
|
|
|
|
|
|
placeholders = ','.join(['%s'] * len(strategy_ids))
|
|
|
|
|
|
cur.execute(f"""
|
|
|
|
|
|
SELECT
|
|
|
|
|
|
COUNT(*) as trade_count,
|
|
|
|
|
|
SUM(CASE WHEN profit > 0 THEN 1 ELSE 0 END) as win_count,
|
|
|
|
|
|
SUM(profit) as total_profit
|
|
|
|
|
|
FROM qd_strategy_trades
|
|
|
|
|
|
WHERE strategy_id IN ({placeholders})
|
|
|
|
|
|
AND profit != 0
|
|
|
|
|
|
""", tuple(strategy_ids))
|
|
|
|
|
|
trade_row = cur.fetchone()
|
|
|
|
|
|
|
|
|
|
|
|
if trade_row and (trade_row['trade_count'] or 0) > 0:
|
|
|
|
|
|
live_trade_count = int(trade_row['trade_count'] or 0)
|
|
|
|
|
|
win_count = int(trade_row['win_count'] or 0)
|
|
|
|
|
|
live_win_rate = round(win_count / live_trade_count * 100, 2) if live_trade_count > 0 else 0.0
|
|
|
|
|
|
live_total_profit = round(float(trade_row['total_profit'] or 0), 2)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
logger.debug("Live trading query skipped or failed", exc_info=True)
|
|
|
|
|
|
|
2026-01-31 02:59:49 +08:00
|
|
|
|
cur.close()
|
2026-02-27 01:57:04 +08:00
|
|
|
|
|
|
|
|
|
|
# ---------- Combine results ----------
|
|
|
|
|
|
total_strategy_count = bt_run_count + live_strategy_count
|
|
|
|
|
|
total_trade_count = sum(bt_trade_counts) + live_trade_count
|
|
|
|
|
|
|
|
|
|
|
|
# 综合胜率:优先实盘 > 回测平均
|
|
|
|
|
|
if live_trade_count > 0:
|
|
|
|
|
|
combined_win_rate = live_win_rate
|
|
|
|
|
|
elif bt_win_rates:
|
|
|
|
|
|
combined_win_rate = round(sum(bt_win_rates) / len(bt_win_rates), 2)
|
|
|
|
|
|
else:
|
|
|
|
|
|
combined_win_rate = 0.0
|
|
|
|
|
|
|
|
|
|
|
|
# 平均收益率(回测 totalReturn %)
|
|
|
|
|
|
avg_return = round(sum(bt_returns) / len(bt_returns), 2) if bt_returns else 0.0
|
|
|
|
|
|
|
|
|
|
|
|
# 总利润:优先用实盘绝对利润,无实盘则显示回测平均收益率
|
|
|
|
|
|
combined_profit = live_total_profit if live_trade_count > 0 else avg_return
|
|
|
|
|
|
|
|
|
|
|
|
# 最大回撤取回测中最差的(maxDrawdown 是负数,取最小即最差)
|
|
|
|
|
|
avg_drawdown = round(min(bt_drawdowns), 2) if bt_drawdowns else 0.0
|
|
|
|
|
|
|
|
|
|
|
|
if total_strategy_count == 0 and total_trade_count == 0:
|
2026-01-31 02:59:49 +08:00
|
|
|
|
return default_result
|
2026-02-27 01:57:04 +08:00
|
|
|
|
|
2026-01-31 02:59:49 +08:00
|
|
|
|
return {
|
2026-02-27 01:57:04 +08:00
|
|
|
|
'strategy_count': total_strategy_count,
|
|
|
|
|
|
'trade_count': total_trade_count,
|
|
|
|
|
|
'win_rate': combined_win_rate,
|
|
|
|
|
|
'total_profit': round(combined_profit, 2),
|
|
|
|
|
|
'avg_return': avg_return,
|
|
|
|
|
|
'max_drawdown': avg_drawdown
|
2026-01-31 02:59:49 +08:00
|
|
|
|
}
|
2026-02-27 01:57:04 +08:00
|
|
|
|
|
2026-01-31 02:59:49 +08:00
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"get_indicator_performance failed: {e}")
|
|
|
|
|
|
return default_result
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# 全局单例
|
|
|
|
|
|
_community_service = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_community_service() -> CommunityService:
|
|
|
|
|
|
"""获取社区服务单例"""
|
|
|
|
|
|
global _community_service
|
|
|
|
|
|
if _community_service is None:
|
|
|
|
|
|
_community_service = CommunityService()
|
|
|
|
|
|
return _community_service
|