chawin.chen commited on
Commit
4d76fa1
·
1 Parent(s): 37ecdee

feat: 排行榜按用户最高分去重,避免一人霸榜

Browse files
Files changed (3) hide show
  1. README.md +7 -0
  2. database.py +68 -16
  3. 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
- # 排行榜按每次评分记录排名;/rank 必须使用完全相同的记录口径。
 
763
  g_stats_row = await fetch_one(
764
  f"""
765
  SELECT COUNT(*) AS t,
766
- COALESCE(SUM(CASE WHEN {score_expr} > %s THEN 1 ELSE 0 END), 0) + 1 AS r
767
- FROM tpl_app_processed_images
768
- WHERE category = 'face'
769
- AND {scope}
770
- AND updated_at >= DATE_SUB(NOW(), INTERVAL {days_limit} DAY)
771
- AND score > 0
 
 
 
 
 
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 {score_expr} > %s THEN 1 ELSE 0 END), 0) + 1 AS r
791
- FROM tpl_app_processed_images
792
- WHERE category = 'face' AND region = %s
793
- AND {scope}
794
- AND updated_at >= DATE_SUB(NOW(), INTERVAL {days_limit} DAY)
795
- AND score > 0
 
 
 
 
 
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 test_rank_counts_individual_score_records(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,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.assertNotIn("GROUP BY nickname", 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_keeps_each_record_and_marks_current_device(self):
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(2, result["total_records"])
 
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.assertNotIn("NOT EXISTS", query)
 
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):