2026-01-31 02:59:49 +08:00
"""
2026-04-06 16:47:36 +07:00
Community Service - indicator community service
2026-01-31 02:59:49 +08:00
2026-04-06 16:47:36 +07:00
Handles functions such as indicator markets, purchases, and comments.
2026-01-31 02:59:49 +08:00
"""
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 :
2026-04-06 16:47:36 +07:00
"""Indicator Community Service Category"""
2026-01-31 02:59:49 +08:00
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
# ==========================================
2026-04-06 16:47:36 +07:00
# indicator market
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'
2026-04-06 16:47:36 +07:00
user_id : int = None # Current user ID, used to determine whether purchase has been made
2026-01-31 02:59:49 +08:00
) -> Dict [ str , Any ]:
2026-04-06 16:47:36 +07:00
"""Get a list of published indicators on the market"""
2026-01-31 02:59:49 +08:00
offset = ( page - 1 ) * page_size
try :
with get_db_connection () as db :
cur = db . cursor ()
2026-04-06 16:47:36 +07:00
# Build query conditions - only display published and approved indicators
2026-01-31 02:59:49 +08:00
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 )
2026-04-06 16:47:36 +07:00
# sort
2026-01-31 02:59:49 +08:00
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' )
2026-04-06 16:47:36 +07:00
# Get total
2026-01-31 02:59:49 +08:00
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' ]
2026-04-06 16:47:36 +07:00
# Get the list (join the table to query author information)
2026-01-31 02:59:49 +08:00
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 []
2026-04-06 16:47:36 +07:00
# If there is a current user, query the purchased indicators
2026-01-31 02:59:49 +08:00
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 ()
2026-04-06 16:47:36 +07:00
#Format return data
2026-01-31 02:59:49 +08:00
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 ()
2026-04-06 16:47:36 +07:00
# Get indicator information
2026-01-31 02:59:49 +08:00
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
2026-04-06 16:47:36 +07:00
# Check if it has been published to the community (or its own indicator)
2026-01-31 02:59:49 +08:00
if not row [ 'publish_to_community' ] and row [ 'user_id' ] != user_id :
cur . close ()
return None
2026-04-06 16:47:36 +07:00
# Check if purchased
2026-01-31 02:59:49 +08:00
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
2026-04-06 16:47:36 +07:00
# Increase the number of views
2026-01-31 02:59:49 +08:00
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
# ==========================================
2026-04-06 16:47:36 +07:00
# Purchase function
2026-01-31 02:59:49 +08:00
# ==========================================
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 ()
2026-04-06 16:47:36 +07:00
# 1. Get indicator information
2026-01-31 02:59:49 +08:00
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
2026-04-06 16:47:36 +07:00
# 2. Check whether to buy your own indicator
2026-01-31 02:59:49 +08:00
if seller_id == buyer_id :
cur . close ()
return False , 'cannot_buy_own' , {}
2026-04-06 16:47:36 +07:00
# 3. Check if purchased
2026-01-31 02:59:49 +08:00
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' , {}
2026-04-06 16:47:36 +07:00
# 4. If it is a paid indicator, check and deduct points
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-04-06 16:47:36 +07:00
# Deduct buyer points
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 )
)
2026-04-06 16:47:36 +07:00
# Record buyer points log
2026-01-31 02:59:49 +08:00
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-04-06 16:47:36 +07:00
f "Buy indicator: { indicator [ 'name' ] } " ))
2026-01-31 02:59:49 +08:00
2026-04-06 16:47:36 +07:00
# Increase points for sellers (the commission ratio can be configured, here 100% is given to sellers first)
2026-01-31 02:59:49 +08:00
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 )
)
2026-04-06 16:47:36 +07:00
# Record seller points log
2026-01-31 02:59:49 +08:00
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-04-06 16:47:36 +07:00
f "Sell indicator: { indicator [ 'name' ] } " ))
2026-01-31 02:59:49 +08:00
2026-04-06 16:47:36 +07:00
# 5. Create purchase record
2026-01-31 02:59:49 +08:00
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
2026-04-06 16:47:36 +07:00
# 6. Copy the indicator to the buyer’ s account
2026-01-31 02:59:49 +08:00
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
))
2026-04-06 16:47:36 +07:00
# 7. Update indicator purchase times
2026-01-31 02:59:49 +08:00
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 ()
2026-04-06 16:47:36 +07:00
# Get total
2026-01-31 02:59:49 +08:00
cur . execute (
"SELECT COUNT(*) as count FROM qd_indicator_purchases WHERE buyer_id = ?" ,
( user_id ,)
)
total = cur . fetchone ()[ 'count' ]
2026-04-06 16:47:36 +07:00
# Get list
2026-01-31 02:59:49 +08:00
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 }
# ==========================================
2026-04-06 16:47:36 +07:00
# Comment function
2026-01-31 02:59:49 +08:00
# ==========================================
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 ()
2026-04-06 16:47:36 +07:00
# Get the total number (only first-level comments are counted)
2026-01-31 02:59:49 +08:00
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' ]
2026-04-06 16:47:36 +07:00
# Get the list of comments
2026-01-31 02:59:49 +08:00
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 :
2026-04-06 16:47:36 +07:00
# Verify score range
2026-01-31 02:59:49 +08:00
rating = max ( 1 , min ( 5 , int ( rating )))
2026-04-06 16:47:36 +07:00
content = ( content or '' ) . strip ()[: 500 ] # Limit 500 words
2026-01-31 02:59:49 +08:00
with get_db_connection () as db :
cur = db . cursor ()
2026-04-06 16:47:36 +07:00
# Check if the indicator exists
2026-01-31 02:59:49 +08:00
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' , {}
2026-04-06 16:47:36 +07:00
# Cannot comment on own indicators
2026-01-31 02:59:49 +08:00
if indicator [ 'user_id' ] == user_id :
cur . close ()
return False , 'cannot_comment_own' , {}
2026-04-06 16:47:36 +07:00
# Check if it has been purchased (free indicators also need to be "acquired" to comment)
2026-01-31 02:59:49 +08:00
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' , {}
2026-04-06 16:47:36 +07:00
# Check if comments have been made
2026-01-31 02:59:49 +08:00
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' , {}
2026-04-06 16:47:36 +07:00
#Add comment
2026-01-31 02:59:49 +08:00
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
2026-04-06 16:47:36 +07:00
# Update the scoring statistics of the indicator
2026-01-31 02:59:49 +08:00
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 ()
2026-04-06 16:47:36 +07:00
# Check if the comment exists and belongs to the current user
2026-01-31 02:59:49 +08:00
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' ]
2026-04-06 16:47:36 +07:00
# Update comment
2026-01-31 02:59:49 +08:00
cur . execute ( """
UPDATE qd_indicator_comments
SET rating = ?, content = ?, updated_at = NOW()
WHERE id = ?
""" , ( rating , content , comment_id ))
2026-04-06 16:47:36 +07:00
# If the rating changes, update the average rating of the metric
2026-01-31 02:59:49 +08:00
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
# ==========================================
2026-04-06 16:47:36 +07:00
# Administrator review function
2026-01-31 02:59:49 +08:00
# ==========================================
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 ()
2026-04-06 16:47:36 +07:00
# Build query conditions
2026-01-31 02:59:49 +08:00
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 )
2026-04-06 16:47:36 +07:00
# Get total
2026-01-31 02:59:49 +08:00
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' ]
2026-04-06 16:47:36 +07:00
# Get the list
2026-01-31 02:59:49 +08:00
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 '' ,
2026-04-06 16:47:36 +07:00
'code' : row [ 'code' ] or '' , # Administrators can view the code
2026-01-31 02:59:49 +08:00
'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 ()
2026-04-06 16:47:36 +07:00
# Check if the indicator exists and has been published to the community
2026-01-31 02:59:49 +08:00
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'
2026-04-06 16:47:36 +07:00
# Update review status
2026-01-31 02:59:49 +08:00
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 ()
2026-04-06 16:47:36 +07:00
# Check if the indicator exists
2026-01-31 02:59:49 +08:00
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'
2026-04-06 16:47:36 +07:00
# Remove from shelves (cancel publication)
2026-01-31 02:59:49 +08:00
cur . execute ( """
UPDATE qd_indicator_codes
SET publish_to_community = 0, review_status = 'rejected',
review_note = ?, reviewed_at = NOW(), reviewed_by = ?
WHERE id = ?
2026-04-06 16:47:36 +07:00
""" , ( f "Removal: { note } " if note else "Removal by administrator" , admin_id , indicator_id ))
2026-01-31 02:59:49 +08:00
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 ()
2026-04-06 16:47:36 +07:00
# Check if the indicator exists
2026-01-31 02:59:49 +08:00
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'
2026-04-06 16:47:36 +07:00
# Delete associated comments
2026-01-31 02:59:49 +08:00
cur . execute ( "DELETE FROM qd_indicator_comments WHERE indicator_id = ?" , ( indicator_id ,))
2026-04-06 16:47:36 +07:00
# Delete associated purchase history
2026-01-31 02:59:49 +08:00
cur . execute ( "DELETE FROM qd_indicator_purchases WHERE indicator_id = ?" , ( indicator_id ,))
2026-04-06 16:47:36 +07:00
# Delete indicator
2026-01-31 02:59:49 +08:00
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 ]:
2026-04-06 16:47:36 +07:00
"""Get audit statistics"""
2026-01-31 02:59:49 +08:00
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-04-06 16:47:36 +07:00
# Real performance (aggregated backtest + real transaction data)
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
2026-04-06 16:47:36 +07:00
# ---------- Part 1: Backtest data (parsed from result_json) ----------
2026-02-27 01:57:04 +08:00
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 )
2026-04-06 16:47:36 +07:00
# ---------- Part 2: Real transaction data ----------
2026-02-27 01:57:04 +08:00
live_strategy_count = 0
live_trade_count = 0
live_win_rate = 0.0
live_total_profit = 0.0
try :
2026-04-06 16:47:36 +07:00
# Find the strategy that uses this indicator (matches indicator_id in indicator_config JSON)
2026-02-27 01:57:04 +08:00
cur . execute ( """
SELECT id FROM qd_strategies_trading
WHERE indicator_config::text LIKE %s
""" , ( f '%"indicator_id": { indicator_id } %' ,))
strategy_rows = cur . fetchall ()
2026-04-06 16:47:36 +07:00
# Also try to match formats without spaces
2026-02-27 01:57:04 +08:00
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
2026-04-06 16:47:36 +07:00
# Comprehensive winning rate: Prioritize real offer > Backtest average
2026-02-27 01:57:04 +08:00
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
2026-04-06 16:47:36 +07:00
# Average return (backtest totalReturn %)
2026-02-27 01:57:04 +08:00
avg_return = round ( sum ( bt_returns ) / len ( bt_returns ), 2 ) if bt_returns else 0.0
2026-04-06 16:47:36 +07:00
#Total profit: priority is given to the absolute profit of the real offer. If there is no real offer, the average return rate of the backtest will be displayed.
2026-02-27 01:57:04 +08:00
combined_profit = live_total_profit if live_trade_count > 0 else avg_return
2026-04-06 16:47:36 +07:00
# The maximum drawdown is the worst in the backtest (maxDrawdown is a negative number, the smallest is the worst)
2026-02-27 01:57:04 +08:00
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
2026-04-06 16:47:36 +07:00
# Global singleton
2026-01-31 02:59:49 +08:00
_community_service = None
def get_community_service () -> CommunityService :
"""获取社区服务单例"""
global _community_service
if _community_service is None :
_community_service = CommunityService ()
return _community_service