chawin.chen commited on
Commit ·
4d76fa1
1
Parent(s): 37ecdee
feat: 排行榜按用户最高分去重,避免一人霸榜
Browse files- README.md +7 -0
- database.py +68 -16
- test/test_ranking_consistency.py +53 -6
README.md
CHANGED
|
@@ -165,3 +165,10 @@ python -m unittest discover -s test -p test_upload_form_limits.py -v
|
|
| 165 |
### 排行榜图片路径
|
| 166 |
|
| 167 |
`/facescore/leaderboard`、`/facescore/app/leaderboard`、`/facescore/wechat/leaderboard` 的 GET/POST 响应中,`items[]` 增加 `file_path` 字符串,原样返回图片表中的路径(例如 `face/example.webp`)。新查询和缓存命中均包含该字段;不是新增完整 URL,也不改变前端当前头像展示。无路径时返回空字符串。本次字段扩展不需要数据库迁移。
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 165 |
### 排行榜图片路径
|
| 166 |
|
| 167 |
`/facescore/leaderboard`、`/facescore/app/leaderboard`、`/facescore/wechat/leaderboard` 的 GET/POST 响应中,`items[]` 增加 `file_path` 字符串,原样返回图片表中的路径(例如 `face/example.webp`)。新查询和缓存命中均包含该字段;不是新增完整 URL,也不改变前端当前头像展示。无路径时返回空字符串。本次字段扩展不需要数据库迁移。
|
| 168 |
+
|
| 169 |
+
### 排行榜按用户去重(2026-09-30)
|
| 170 |
+
|
| 171 |
+
为避免单一用户多次上传高分照片霸占排行榜前排(“一人霸榜”):
|
| 172 |
+
- 排行榜查询(`/facescore/leaderboard`、`/facescore/app/leaderboard`、`/facescore/wechat/leaderboard`)按用户标识(`nickname`)去重,每个用户仅保留最高分的一条记录进榜;若未提供昵称(匿名上传)则独立保留每张记录。
|
| 173 |
+
- 排名计算(`/facescore/rank`、`/facescore/app/rank`、`/facescore/wechat/rank`)同步按用户最高分口径聚合统计,保证测完打分结果页的排位与排行榜数据完全自洽。
|
| 174 |
+
- 本次改动纯逻辑优化,不需要数据库迁移。
|
database.py
CHANGED
|
@@ -759,16 +759,22 @@ async def get_face_score_rank(
|
|
| 759 |
region_percentile = 0.0
|
| 760 |
|
| 761 |
try:
|
| 762 |
-
# 排行榜按
|
|
|
|
| 763 |
g_stats_row = await fetch_one(
|
| 764 |
f"""
|
| 765 |
SELECT COUNT(*) AS t,
|
| 766 |
-
COALESCE(SUM(CASE WHEN
|
| 767 |
-
FROM
|
| 768 |
-
|
| 769 |
-
|
| 770 |
-
|
| 771 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 772 |
""",
|
| 773 |
(score_val, client_source)
|
| 774 |
)
|
|
@@ -787,12 +793,17 @@ async def get_face_score_rank(
|
|
| 787 |
r_stats_row = await fetch_one(
|
| 788 |
f"""
|
| 789 |
SELECT COUNT(*) AS t,
|
| 790 |
-
COALESCE(SUM(CASE WHEN
|
| 791 |
-
FROM
|
| 792 |
-
|
| 793 |
-
|
| 794 |
-
|
| 795 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 796 |
""",
|
| 797 |
(score_val, region_code, client_source)
|
| 798 |
)
|
|
@@ -869,7 +880,7 @@ async def _get_cached_face_score_leaderboard(
|
|
| 869 |
) -> Dict[str, Any]:
|
| 870 |
"""
|
| 871 |
获取颜值评分记录排行榜 Top N (支持地区榜与全球榜)。
|
| 872 |
-
|
| 873 |
"""
|
| 874 |
from region_code_utils import get_country_name_by_region_code
|
| 875 |
scope = _ranking_scope(client_source, "candidate")
|
|
@@ -905,6 +916,8 @@ async def _get_cached_face_score_leaderboard(
|
|
| 905 |
success = True
|
| 906 |
items = []
|
| 907 |
candidate_score_expr = "(CASE WHEN candidate.score <= 10.0 THEN candidate.score * 10.0 ELSE candidate.score END)"
|
|
|
|
|
|
|
| 908 |
try:
|
| 909 |
if region_code:
|
| 910 |
query = f"""
|
|
@@ -914,10 +927,26 @@ async def _get_cached_face_score_leaderboard(
|
|
| 914 |
WHERE candidate.category = 'face' AND candidate.score > 0 AND candidate.region = %s
|
| 915 |
AND {scope}
|
| 916 |
AND candidate.updated_at >= DATE_SUB(NOW(), INTERVAL {days_limit} DAY)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 917 |
ORDER BY score DESC, candidate.id ASC
|
| 918 |
LIMIT {safe_limit}
|
| 919 |
"""
|
| 920 |
-
rows = await fetch_all(query, (region_code, client_source))
|
| 921 |
else:
|
| 922 |
query = f"""
|
| 923 |
SELECT candidate.id, {candidate_score_expr} AS score, candidate.region,
|
|
@@ -926,12 +955,35 @@ async def _get_cached_face_score_leaderboard(
|
|
| 926 |
WHERE candidate.category = 'face' AND candidate.score > 0
|
| 927 |
AND {scope}
|
| 928 |
AND candidate.updated_at >= DATE_SUB(NOW(), INTERVAL {days_limit} DAY)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 929 |
ORDER BY score DESC, candidate.id ASC
|
| 930 |
LIMIT {safe_limit}
|
| 931 |
"""
|
| 932 |
-
rows = await fetch_all(query, (client_source,))
|
| 933 |
|
|
|
|
| 934 |
for row in rows:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 935 |
row_id = int(row.get("id") or 0)
|
| 936 |
score_val = round(float(row.get("score") or 0.0), 2)
|
| 937 |
row_region = row.get("region") or region_code or "US"
|
|
|
|
| 759 |
region_percentile = 0.0
|
| 760 |
|
| 761 |
try:
|
| 762 |
+
# 排行榜按用户最高分去重;/rank 必须使用完全相同的用户最高分去重口径。
|
| 763 |
+
user_key_expr = "(CASE WHEN nickname IS NOT NULL AND nickname != '' THEN nickname ELSE CONCAT('id_', id) END)"
|
| 764 |
g_stats_row = await fetch_one(
|
| 765 |
f"""
|
| 766 |
SELECT COUNT(*) AS t,
|
| 767 |
+
COALESCE(SUM(CASE WHEN max_score > %s THEN 1 ELSE 0 END), 0) + 1 AS r
|
| 768 |
+
FROM (
|
| 769 |
+
SELECT {user_key_expr} AS user_key,
|
| 770 |
+
MAX({score_expr}) AS max_score
|
| 771 |
+
FROM tpl_app_processed_images
|
| 772 |
+
WHERE category = 'face'
|
| 773 |
+
AND {scope}
|
| 774 |
+
AND updated_at >= DATE_SUB(NOW(), INTERVAL {days_limit} DAY)
|
| 775 |
+
AND score > 0
|
| 776 |
+
GROUP BY user_key
|
| 777 |
+
) AS ranked_users
|
| 778 |
""",
|
| 779 |
(score_val, client_source)
|
| 780 |
)
|
|
|
|
| 793 |
r_stats_row = await fetch_one(
|
| 794 |
f"""
|
| 795 |
SELECT COUNT(*) AS t,
|
| 796 |
+
COALESCE(SUM(CASE WHEN max_score > %s THEN 1 ELSE 0 END), 0) + 1 AS r
|
| 797 |
+
FROM (
|
| 798 |
+
SELECT {user_key_expr} AS user_key,
|
| 799 |
+
MAX({score_expr}) AS max_score
|
| 800 |
+
FROM tpl_app_processed_images
|
| 801 |
+
WHERE category = 'face' AND region = %s
|
| 802 |
+
AND {scope}
|
| 803 |
+
AND updated_at >= DATE_SUB(NOW(), INTERVAL {days_limit} DAY)
|
| 804 |
+
AND score > 0
|
| 805 |
+
GROUP BY user_key
|
| 806 |
+
) AS ranked_users
|
| 807 |
""",
|
| 808 |
(score_val, region_code, client_source)
|
| 809 |
)
|
|
|
|
| 880 |
) -> Dict[str, Any]:
|
| 881 |
"""
|
| 882 |
获取颜值评分记录排行榜 Top N (支持地区榜与全球榜)。
|
| 883 |
+
按用户(nickname)去重,每个用户仅保留最高分记录;无昵称的匿名记录独立保留。
|
| 884 |
"""
|
| 885 |
from region_code_utils import get_country_name_by_region_code
|
| 886 |
scope = _ranking_scope(client_source, "candidate")
|
|
|
|
| 916 |
success = True
|
| 917 |
items = []
|
| 918 |
candidate_score_expr = "(CASE WHEN candidate.score <= 10.0 THEN candidate.score * 10.0 ELSE candidate.score END)"
|
| 919 |
+
better_score_expr = "(CASE WHEN better.score <= 10.0 THEN better.score * 10.0 ELSE better.score END)"
|
| 920 |
+
better_scope = _ranking_scope(client_source, "better")
|
| 921 |
try:
|
| 922 |
if region_code:
|
| 923 |
query = f"""
|
|
|
|
| 927 |
WHERE candidate.category = 'face' AND candidate.score > 0 AND candidate.region = %s
|
| 928 |
AND {scope}
|
| 929 |
AND candidate.updated_at >= DATE_SUB(NOW(), INTERVAL {days_limit} DAY)
|
| 930 |
+
AND (
|
| 931 |
+
candidate.nickname IS NULL
|
| 932 |
+
OR candidate.nickname = ''
|
| 933 |
+
OR NOT EXISTS (
|
| 934 |
+
SELECT 1
|
| 935 |
+
FROM tpl_app_processed_images AS better
|
| 936 |
+
WHERE better.nickname = candidate.nickname
|
| 937 |
+
AND better.category = 'face' AND better.score > 0 AND better.region = %s
|
| 938 |
+
AND {better_scope}
|
| 939 |
+
AND better.updated_at >= DATE_SUB(NOW(), INTERVAL {days_limit} DAY)
|
| 940 |
+
AND (
|
| 941 |
+
{better_score_expr} > {candidate_score_expr}
|
| 942 |
+
OR ({better_score_expr} = {candidate_score_expr} AND better.id < candidate.id)
|
| 943 |
+
)
|
| 944 |
+
)
|
| 945 |
+
)
|
| 946 |
ORDER BY score DESC, candidate.id ASC
|
| 947 |
LIMIT {safe_limit}
|
| 948 |
"""
|
| 949 |
+
rows = await fetch_all(query, (region_code, client_source, region_code, client_source))
|
| 950 |
else:
|
| 951 |
query = f"""
|
| 952 |
SELECT candidate.id, {candidate_score_expr} AS score, candidate.region,
|
|
|
|
| 955 |
WHERE candidate.category = 'face' AND candidate.score > 0
|
| 956 |
AND {scope}
|
| 957 |
AND candidate.updated_at >= DATE_SUB(NOW(), INTERVAL {days_limit} DAY)
|
| 958 |
+
AND (
|
| 959 |
+
candidate.nickname IS NULL
|
| 960 |
+
OR candidate.nickname = ''
|
| 961 |
+
OR NOT EXISTS (
|
| 962 |
+
SELECT 1
|
| 963 |
+
FROM tpl_app_processed_images AS better
|
| 964 |
+
WHERE better.nickname = candidate.nickname
|
| 965 |
+
AND better.category = 'face' AND better.score > 0
|
| 966 |
+
AND {better_scope}
|
| 967 |
+
AND better.updated_at >= DATE_SUB(NOW(), INTERVAL {days_limit} DAY)
|
| 968 |
+
AND (
|
| 969 |
+
{better_score_expr} > {candidate_score_expr}
|
| 970 |
+
OR ({better_score_expr} = {candidate_score_expr} AND better.id < candidate.id)
|
| 971 |
+
)
|
| 972 |
+
)
|
| 973 |
+
)
|
| 974 |
ORDER BY score DESC, candidate.id ASC
|
| 975 |
LIMIT {safe_limit}
|
| 976 |
"""
|
| 977 |
+
rows = await fetch_all(query, (client_source, client_source))
|
| 978 |
|
| 979 |
+
seen_users = set()
|
| 980 |
for row in rows:
|
| 981 |
+
raw_nick = str(row.get("nickname") or "").strip()
|
| 982 |
+
user_key = raw_nick if raw_nick else f"id_{row.get('id')}"
|
| 983 |
+
if user_key in seen_users:
|
| 984 |
+
continue
|
| 985 |
+
seen_users.add(user_key)
|
| 986 |
+
|
| 987 |
row_id = int(row.get("id") or 0)
|
| 988 |
score_val = round(float(row.get("score") or 0.0), 2)
|
| 989 |
row_region = row.get("region") or region_code or "US"
|
test/test_ranking_consistency.py
CHANGED
|
@@ -43,7 +43,7 @@ class RankingConsistencyTest(unittest.IsolatedAsyncioTestCase):
|
|
| 43 |
database._RANK_CACHE.clear()
|
| 44 |
database._LEADERBOARD_CACHE.clear()
|
| 45 |
|
| 46 |
-
async def
|
| 47 |
fetch_one = AsyncMock(side_effect=[{"t": 40, "r": 7}, {"t": 3, "r": 1}])
|
| 48 |
with patch.object(database, "fetch_one", fetch_one):
|
| 49 |
result = await database.get_face_score_rank(88.85, "CN")
|
|
@@ -53,11 +53,11 @@ class RankingConsistencyTest(unittest.IsolatedAsyncioTestCase):
|
|
| 53 |
self.assertEqual(40, result["global_total"])
|
| 54 |
self.assertEqual(1, result["region_rank"])
|
| 55 |
self.assertEqual(3, result["region_total"])
|
| 56 |
-
self.
|
| 57 |
self.assertEqual((88.85, "android"), fetch_one.await_args_list[0].args[1])
|
| 58 |
self.assertEqual((88.85, "CN", "android"), fetch_one.await_args_list[1].args[1])
|
| 59 |
|
| 60 |
-
async def
|
| 61 |
rows = [
|
| 62 |
{
|
| 63 |
"id": 4,
|
|
@@ -83,17 +83,64 @@ class RankingConsistencyTest(unittest.IsolatedAsyncioTestCase):
|
|
| 83 |
result = await database.get_face_score_leaderboard(None, 50, "current-device")
|
| 84 |
|
| 85 |
self.assertTrue(result["success"])
|
| 86 |
-
self.assertEqual(
|
|
|
|
| 87 |
self.assertEqual(88.85, result["items"][0]["score"])
|
| 88 |
self.assertTrue(result["items"][0]["is_current_user"])
|
| 89 |
-
self.assertTrue(result["items"][1]["is_current_user"])
|
| 90 |
self.assertEqual("android_release_current-device", result["items"][0]["nickname"])
|
| 91 |
self.assertEqual("小星星", result["items"][0]["display_name"])
|
| 92 |
query = fetch_all.await_args_list[0].args[0]
|
| 93 |
-
self.
|
|
|
|
| 94 |
self.assertIn("ORDER BY score DESC, candidate.id ASC", query)
|
| 95 |
self.assertIn("LIMIT 50", query)
|
| 96 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
async def test_different_limits_do_not_share_partial_cache(self):
|
| 98 |
fetch_all = AsyncMock(return_value=[])
|
| 99 |
with patch.object(database, "fetch_all", fetch_all):
|
|
|
|
| 43 |
database._RANK_CACHE.clear()
|
| 44 |
database._LEADERBOARD_CACHE.clear()
|
| 45 |
|
| 46 |
+
async def test_rank_counts_unique_users_highest_scores(self):
|
| 47 |
fetch_one = AsyncMock(side_effect=[{"t": 40, "r": 7}, {"t": 3, "r": 1}])
|
| 48 |
with patch.object(database, "fetch_one", fetch_one):
|
| 49 |
result = await database.get_face_score_rank(88.85, "CN")
|
|
|
|
| 53 |
self.assertEqual(40, result["global_total"])
|
| 54 |
self.assertEqual(1, result["region_rank"])
|
| 55 |
self.assertEqual(3, result["region_total"])
|
| 56 |
+
self.assertIn("GROUP BY user_key", fetch_one.await_args_list[0].args[0])
|
| 57 |
self.assertEqual((88.85, "android"), fetch_one.await_args_list[0].args[1])
|
| 58 |
self.assertEqual((88.85, "CN", "android"), fetch_one.await_args_list[1].args[1])
|
| 59 |
|
| 60 |
+
async def test_leaderboard_deduplicates_by_user_and_keeps_highest_score(self):
|
| 61 |
rows = [
|
| 62 |
{
|
| 63 |
"id": 4,
|
|
|
|
| 83 |
result = await database.get_face_score_leaderboard(None, 50, "current-device")
|
| 84 |
|
| 85 |
self.assertTrue(result["success"])
|
| 86 |
+
self.assertEqual(1, result["total_records"])
|
| 87 |
+
self.assertEqual(1, len(result["items"]))
|
| 88 |
self.assertEqual(88.85, result["items"][0]["score"])
|
| 89 |
self.assertTrue(result["items"][0]["is_current_user"])
|
|
|
|
| 90 |
self.assertEqual("android_release_current-device", result["items"][0]["nickname"])
|
| 91 |
self.assertEqual("小星星", result["items"][0]["display_name"])
|
| 92 |
query = fetch_all.await_args_list[0].args[0]
|
| 93 |
+
self.assertIn("NOT EXISTS", query)
|
| 94 |
+
self.assertIn("better.nickname = candidate.nickname", query)
|
| 95 |
self.assertIn("ORDER BY score DESC, candidate.id ASC", query)
|
| 96 |
self.assertIn("LIMIT 50", query)
|
| 97 |
|
| 98 |
+
async def test_leaderboard_keeps_multiple_unique_users_and_anonymous_records(self):
|
| 99 |
+
rows = [
|
| 100 |
+
{
|
| 101 |
+
"id": 1,
|
| 102 |
+
"score": 95.0,
|
| 103 |
+
"region": "CN",
|
| 104 |
+
"nickname": "user_a",
|
| 105 |
+
"extra_metadata": '{"gender":"Male","age":"22"}',
|
| 106 |
+
"updated_at": "2026-09-25 10:00:00",
|
| 107 |
+
},
|
| 108 |
+
{
|
| 109 |
+
"id": 2,
|
| 110 |
+
"score": 90.0,
|
| 111 |
+
"region": "CN",
|
| 112 |
+
"nickname": "user_a",
|
| 113 |
+
"extra_metadata": '{"gender":"Male","age":"22"}',
|
| 114 |
+
"updated_at": "2026-09-24 10:00:00",
|
| 115 |
+
},
|
| 116 |
+
{
|
| 117 |
+
"id": 3,
|
| 118 |
+
"score": 92.0,
|
| 119 |
+
"region": "CN",
|
| 120 |
+
"nickname": "user_b",
|
| 121 |
+
"extra_metadata": '{"gender":"Female","age":"24"}',
|
| 122 |
+
"updated_at": "2026-09-25 11:00:00",
|
| 123 |
+
},
|
| 124 |
+
{
|
| 125 |
+
"id": 4,
|
| 126 |
+
"score": 91.0,
|
| 127 |
+
"region": "CN",
|
| 128 |
+
"nickname": None, # 匿名记录
|
| 129 |
+
"extra_metadata": '{"gender":"Female","age":"23"}',
|
| 130 |
+
"updated_at": "2026-09-25 12:00:00",
|
| 131 |
+
},
|
| 132 |
+
]
|
| 133 |
+
fetch_all = AsyncMock(return_value=rows)
|
| 134 |
+
with patch.object(database, "fetch_all", fetch_all):
|
| 135 |
+
result = await database.get_face_score_leaderboard(None, 50, client_source="wechat")
|
| 136 |
+
|
| 137 |
+
self.assertTrue(result["success"])
|
| 138 |
+
# user_a (95) 保留,user_a (90) 被去重;user_b (92) 保留;匿名 (91) 保留
|
| 139 |
+
self.assertEqual(3, result["total_records"])
|
| 140 |
+
self.assertEqual([95.0, 92.0, 91.0], [i["score"] for i in result["items"]])
|
| 141 |
+
self.assertEqual([1, 2, 3], [i["rank"] for i in result["items"]])
|
| 142 |
+
self.assertEqual(["user_a", "user_b", ""], [i["nickname"] for i in result["items"]])
|
| 143 |
+
|
| 144 |
async def test_different_limits_do_not_share_partial_cache(self):
|
| 145 |
fetch_all = AsyncMock(return_value=[])
|
| 146 |
with patch.object(database, "fetch_all", fetch_all):
|