chawin.chen Qwen-Coder commited on
Commit
606732d
·
1 Parent(s): 102bb27

feat: 新增用户设备列表API、region筛选和配置接口

Browse files

- 新增 /facescore/user_devices 接口,支持分页、device_id/region 筛选
- 新增 /facescore/config 配置接口,返回 region_codes 等字典配置
- 新增 /facescore/clean_debug_data 清理调试数据接口
- 新增 region_code_utils.py 地区代码转国家名称工具类(完整250+国家)
- database.py 新增 fetch_user_devices/count_user_devices/clean_debug_data
- models.py 新增 UserDeviceItem/UserDeviceListResponse,ImageScoreItem 增加 region/region_country
- 修改 outputs 接口支持 region 筛选并返回 region/region_country
- 所有 App 端接口(/app/analyze, /restore, /upscale, /face_verify, /upcolor, /anime_style, /celebrity/match)添加 region 获取和存储逻辑
- 设备列表支持检测总量统计(子查询关联 tpl_app_processed_images)

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

Files changed (4) hide show
  1. api_routes.py +150 -17
  2. database.py +118 -3
  3. models.py +26 -0
  4. region_code_utils.py +294 -0
api_routes.py CHANGED
@@ -76,10 +76,42 @@ from database import (
76
  infer_category_from_filename,
77
  fetch_today_category_counts,
78
  upsert_device_record,
 
 
 
79
  )
 
80
 
81
  SERVER_HOSTNAME = os.environ.get("HOSTNAME", "")
82
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
83
  # 尝试导入DeepFace
84
  deepface_module = None
85
  if DEEPFACE_AVAILABLE:
@@ -339,6 +371,8 @@ from models import (
339
  CelebrityMatchResponse,
340
  CategoryStatsResponse,
341
  CategoryStatItem,
 
 
342
  )
343
 
344
  from face_analyzer import EnhancedFaceAnalyzer
@@ -743,6 +777,7 @@ async def _record_output_file(
743
  category: Optional[str] = None,
744
  bos_uploaded: bool = False,
745
  score: Optional[float] = None,
 
746
  extra: Optional[Dict[str, Any]] = None,
747
  ) -> None:
748
  """封装的图片记录写入,避免影响主流程"""
@@ -762,6 +797,7 @@ async def _record_output_file(
762
  category=category,
763
  bos_uploaded=bos_uploaded,
764
  score=score_value,
 
765
  extra_metadata=extra,
766
  )
767
  # 使用与数据库一致的路径转换
@@ -1602,23 +1638,7 @@ async def app_analyze_face(
1602
  App专用人脸分析接口,参数与原 /analyze 保持完全一致并透传。
1603
  """
1604
  # 异步记录设备信息
1605
- device_id = request.headers.get("x-device-id")
1606
- if device_id:
1607
- try:
1608
- device_info = {
1609
- "device_id": device_id,
1610
- "device_type": request.headers.get("x-device-type"),
1611
- "device_model": request.headers.get("x-device-model"),
1612
- "os_version": request.headers.get("x-os-version"),
1613
- "app_version": request.headers.get("x-app-version"),
1614
- "region": request.headers.get("x-region"),
1615
- "timezone": request.headers.get("x-timezone"),
1616
- "language": request.headers.get("Accept-Language"),
1617
- }
1618
- # 创建异步任务,不阻塞当前请求
1619
- asyncio.create_task(upsert_device_record(**device_info))
1620
- except Exception as e:
1621
- logger.warning(f"Failed to record device info for {device_id}: {e}")
1622
 
1623
  return await analyze_face(
1624
  request=request,
@@ -1877,6 +1897,87 @@ async def get_daily_category_stats():
1877
  return CategoryStatsResponse(stats=stats, total=total)
1878
 
1879
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1880
  @api_router.post("/outputs", response_model=PagedImageFileList, tags=["检测列表"])
1881
  @log_api_params
1882
  async def list_outputs(
@@ -1951,6 +2052,8 @@ async def list_outputs(
1951
  is_cropped = False
1952
  nickname_value = record.get("nickname") if record else None
1953
  hostname_value = record.get("hostname") if record else None
 
 
1954
  last_modified_dt = None
1955
 
1956
  if record:
@@ -1987,6 +2090,8 @@ async def list_outputs(
1987
  "last_modified": last_modified_str,
1988
  "nickname": nickname_value,
1989
  "hostname": hostname_value,
 
 
1990
  }
1991
  all_files.append(file_info)
1992
 
@@ -2012,9 +2117,11 @@ async def list_outputs(
2012
  # 普通文件列表模式(无关键词或CLIP不可用)
2013
  logger.info("Returning regular file list")
2014
  try:
 
2015
  total_count = await count_image_records(
2016
  category=category,
2017
  nickname=nickname_filter,
 
2018
  is_cropped_face=is_cropped_filter,
2019
  )
2020
  if total_count > 0:
@@ -2022,6 +2129,7 @@ async def list_outputs(
2022
  rows = await fetch_paged_image_records(
2023
  category=category,
2024
  nickname=nickname_filter,
 
2025
  offset=offset,
2026
  limit=page_size,
2027
  is_cropped_face=is_cropped_filter,
@@ -2038,6 +2146,9 @@ async def list_outputs(
2038
  else:
2039
  last_modified_dt = last_modified
2040
  size_bytes = int(row.get("size_bytes") or 0)
 
 
 
2041
  paged_results.append({
2042
  "file_path": row.get("file_path"),
2043
  "score": float(row.get("score") or 0.0),
@@ -2048,6 +2159,8 @@ async def list_outputs(
2048
  "%Y-%m-%d %H:%M:%S") if last_modified_dt else "",
2049
  "nickname": row.get("nickname"),
2050
  "hostname": row.get("hostname"),
 
 
2051
  })
2052
  total_pages = (total_count + page_size - 1) // page_size
2053
  return PagedImageFileList(
@@ -2097,6 +2210,8 @@ async def list_outputs(
2097
  ),
2098
  "nickname": None,
2099
  "hostname": None,
 
 
2100
  }
2101
  all_files.append(file_info)
2102
 
@@ -2336,6 +2451,7 @@ async def sync_bos_resources(
2336
  @api_router.post("/restore")
2337
  @log_api_params
2338
  async def restore_old_photo(
 
2339
  file: UploadFile = File(...),
2340
  md5: str = Query(None, description="前端传递的文件md5,用于提前保存记录"),
2341
  colorize: bool = Query(False, description="是否对黑白照片进行上色"),
@@ -2453,6 +2569,7 @@ async def restore_old_photo(
2453
  nickname=nickname,
2454
  category="restore",
2455
  bos_uploaded=True,
 
2456
  extra={
2457
  "source": "restore",
2458
  "colorize": colorize,
@@ -2484,6 +2601,7 @@ async def restore_old_photo(
2484
  @api_router.post("/upcolor")
2485
  @log_api_params
2486
  async def colorize_photo(
 
2487
  file: UploadFile = File(...),
2488
  md5: str = Query(None, description="前端传递的文件md5,用于提前保存记录"),
2489
  nickname: str = Form(None, description="操作者昵称"),
@@ -2563,6 +2681,7 @@ async def colorize_photo(
2563
  nickname=nickname,
2564
  category="upcolor",
2565
  bos_uploaded=True,
 
2566
  extra={
2567
  "source": "upcolor",
2568
  "md5": actual_md5,
@@ -2663,6 +2782,7 @@ async def preload_anime_models(
2663
  @api_router.post("/anime_style")
2664
  @log_api_params
2665
  async def anime_stylize_photo(
 
2666
  file: UploadFile = File(...),
2667
  style_type: str = Form("handdrawn",
2668
  description="动漫风格类型: handdrawn=手绘风格, disney=迪士尼风格, illustration=插画风格, artstyle=艺术风格, anime=二次元风格, sketch=素描风格"),
@@ -2755,6 +2875,7 @@ async def anime_stylize_photo(
2755
  nickname=nickname,
2756
  category="anime_style",
2757
  bos_uploaded=original_bos_uploaded,
 
2758
  extra={
2759
  "source": "anime_style",
2760
  "style_type": style_type,
@@ -2793,6 +2914,7 @@ async def anime_stylize_photo(
2793
  nickname=nickname,
2794
  category="anime_style",
2795
  bos_uploaded=bos_uploaded,
 
2796
  extra={
2797
  "source": "anime_style",
2798
  "style_type": style_type,
@@ -2922,6 +3044,7 @@ async def grayscale_photo(
2922
  @api_router.post("/upscale")
2923
  @log_api_params
2924
  async def upscale_photo(
 
2925
  file: UploadFile = File(...),
2926
  md5: str = Query(None, description="前端传递的文件md5,用于提前保存记录"),
2927
  scale: int = Query(UPSCALE_SIZE, description="放大倍数,支持2或4倍"),
@@ -3010,6 +3133,7 @@ async def upscale_photo(
3010
  nickname=nickname,
3011
  category="upscale",
3012
  bos_uploaded=True,
 
3013
  extra={
3014
  "source": "upscale",
3015
  "md5": actual_md5,
@@ -4283,6 +4407,7 @@ async def load_celebrity_database():
4283
  @api_router.post("/celebrity/match", tags=["Face Recognition"])
4284
  @log_api_params
4285
  async def match_celebrity_face(
 
4286
  file: UploadFile = File(..., description="待匹配的用户图片"),
4287
  nickname: str = Form(None, description="操作者昵称"),
4288
  ):
@@ -4346,6 +4471,7 @@ async def match_celebrity_face(
4346
  file_path=temp_path,
4347
  nickname=nickname,
4348
  category="celebrity",
 
4349
  extra={
4350
  "source": "celebrity_match",
4351
  "role": "query",
@@ -4502,6 +4628,7 @@ async def match_celebrity_face(
4502
  file_path=face_path,
4503
  nickname=nickname,
4504
  category="face",
 
4505
  extra={
4506
  "source": "celebrity_match",
4507
  "role": "face_crop",
@@ -4522,6 +4649,7 @@ async def match_celebrity_face(
4522
  file_path=temp_path,
4523
  nickname=nickname,
4524
  category="celebrity",
 
4525
  extra={
4526
  "source": "celebrity_match",
4527
  "role": "annotated",
@@ -4571,6 +4699,7 @@ async def match_celebrity_face(
4571
  @api_router.post("/face_verify")
4572
  @log_api_params
4573
  async def face_similarity_verification(
 
4574
  file1: UploadFile = File(..., description="第一张人脸图片"),
4575
  file2: UploadFile = File(..., description="第二张人脸图片"),
4576
  nickname: str = Form(None, description="操作者昵称"),
@@ -4773,6 +4902,7 @@ async def face_similarity_verification(
4773
  file_path=original_path1,
4774
  nickname=nickname,
4775
  category="original",
 
4776
  extra={
4777
  "source": "face_verify",
4778
  "role": "original1",
@@ -4784,6 +4914,7 @@ async def face_similarity_verification(
4784
  file_path=original_path2,
4785
  nickname=nickname,
4786
  category="original",
 
4787
  extra={
4788
  "source": "face_verify",
4789
  "role": "original2",
@@ -4816,6 +4947,7 @@ async def face_similarity_verification(
4816
  file_path=face_path1,
4817
  nickname=nickname,
4818
  category="face",
 
4819
  extra={
4820
  "source": "face_verify",
4821
  "role": "face1",
@@ -4826,6 +4958,7 @@ async def face_similarity_verification(
4826
  file_path=face_path2,
4827
  nickname=nickname,
4828
  category="face",
 
4829
  extra={
4830
  "source": "face_verify",
4831
  "role": "face2",
 
76
  infer_category_from_filename,
77
  fetch_today_category_counts,
78
  upsert_device_record,
79
+ fetch_user_devices,
80
+ count_user_devices,
81
+ clean_debug_data,
82
  )
83
+ from region_code_utils import get_country_name_by_region_code, REGION_CODE_TO_NAME
84
 
85
  SERVER_HOSTNAME = os.environ.get("HOSTNAME", "")
86
 
87
+
88
+ async def _record_device_info(request: Request) -> None:
89
+ """
90
+ 从请求头中提取设备信息并异步记录到数据库
91
+ 获取不到 region 时存空串
92
+ """
93
+ device_id = request.headers.get("x-device-id")
94
+ if device_id:
95
+ try:
96
+ device_info = {
97
+ "device_id": device_id,
98
+ "device_type": request.headers.get("x-device-type"),
99
+ "device_model": request.headers.get("x-device-model"),
100
+ "os_version": request.headers.get("x-os-version"),
101
+ "app_version": request.headers.get("x-app-version"),
102
+ "region": request.headers.get("x-region") or "",
103
+ "timezone": request.headers.get("x-timezone"),
104
+ "language": request.headers.get("Accept-Language"),
105
+ }
106
+ asyncio.create_task(upsert_device_record(**device_info))
107
+ except Exception as e:
108
+ logger.warning(f"Failed to record device info for {device_id}: {e}")
109
+
110
+
111
+ def _get_region_from_headers(request: Request) -> str:
112
+ """从请求头获取 region,获取不到返回空串"""
113
+ return request.headers.get("x-region") or ""
114
+
115
  # 尝试导入DeepFace
116
  deepface_module = None
117
  if DEEPFACE_AVAILABLE:
 
371
  CelebrityMatchResponse,
372
  CategoryStatsResponse,
373
  CategoryStatItem,
374
+ UserDeviceItem,
375
+ UserDeviceListResponse,
376
  )
377
 
378
  from face_analyzer import EnhancedFaceAnalyzer
 
777
  category: Optional[str] = None,
778
  bos_uploaded: bool = False,
779
  score: Optional[float] = None,
780
+ region: Optional[str] = None,
781
  extra: Optional[Dict[str, Any]] = None,
782
  ) -> None:
783
  """封装的图片记录写入,避免影响主流程"""
 
797
  category=category,
798
  bos_uploaded=bos_uploaded,
799
  score=score_value,
800
+ region=region,
801
  extra_metadata=extra,
802
  )
803
  # 使用与数据库一致的路径转换
 
1638
  App专用人脸分析接口,参数与原 /analyze 保持完全一致并透传。
1639
  """
1640
  # 异步记录设备信息
1641
+ await _record_device_info(request)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1642
 
1643
  return await analyze_face(
1644
  request=request,
 
1897
  return CategoryStatsResponse(stats=stats, total=total)
1898
 
1899
 
1900
+ @api_router.post("/user_devices", response_model=UserDeviceListResponse, tags=["用户设备列表"])
1901
+ @log_api_params
1902
+ async def list_user_devices(
1903
+ device_id: Optional[str] = Query(None, description="设备 ID 筛选"),
1904
+ region: Optional[str] = Query(None, description="地区筛选"),
1905
+ page: int = Query(1, ge=1, description="页码"),
1906
+ page_size: int = Query(10, ge=1, le=100, description="每页数量"),
1907
+ ):
1908
+ """查询用户设备列表,支持按 device_id 和 region 筛选,按 created_at 倒序"""
1909
+ try:
1910
+ offset = (page - 1) * page_size
1911
+
1912
+ devices = await fetch_user_devices(
1913
+ device_id=device_id,
1914
+ region=region,
1915
+ offset=offset,
1916
+ limit=page_size,
1917
+ )
1918
+
1919
+ total_count = await count_user_devices(device_id=device_id, region=region)
1920
+ total_pages = (total_count + page_size - 1) // page_size
1921
+
1922
+ results = []
1923
+ for device in devices:
1924
+ region_code = device.get("region") or ""
1925
+ region_country = get_country_name_by_region_code(region_code) if region_code else ""
1926
+
1927
+ results.append(UserDeviceItem(
1928
+ device_id=device.get("device_id", ""),
1929
+ device_type=device.get("device_type"),
1930
+ device_model=device.get("device_model"),
1931
+ os_version=device.get("os_version"),
1932
+ app_version=device.get("app_version"),
1933
+ timezone=device.get("timezone"),
1934
+ region=region_code,
1935
+ region_country=region_country,
1936
+ language=device.get("language"),
1937
+ created_at=device.get("created_at", "").strftime("%Y-%m-%d %H:%M:%S") if device.get("created_at") else "",
1938
+ updated_at=device.get("updated_at", "").strftime("%Y-%m-%d %H:%M:%S") if device.get("updated_at") else "",
1939
+ ))
1940
+ # 添加 detection_count 字段
1941
+ results[-1].detection_count = device.get("detection_count", 0)
1942
+
1943
+ return UserDeviceListResponse(
1944
+ results=results,
1945
+ count=total_count,
1946
+ page=page,
1947
+ page_size=page_size,
1948
+ total_pages=total_pages,
1949
+ )
1950
+ except Exception as e:
1951
+ logger.error(f"Failed to get user devices list: {str(e)}")
1952
+ raise HTTPException(status_code=500, detail=str(e))
1953
+
1954
+
1955
+ @api_router.post("/clean_debug_data", tags=["系统维护"])
1956
+ async def clean_debug_data_api():
1957
+ """清理调试数据接口"""
1958
+ try:
1959
+ results = await clean_debug_data()
1960
+ return {
1961
+ "success": True,
1962
+ "message": "清理成功",
1963
+ "details": results,
1964
+ }
1965
+ except Exception as e:
1966
+ logger.error(f"Failed to clean debug data: {str(e)}")
1967
+ raise HTTPException(status_code=500, detail=str(e))
1968
+
1969
+
1970
+ @api_router.get("/config", tags=["系统配置"])
1971
+ async def get_app_config():
1972
+ """
1973
+ 获取前端应用配置字典
1974
+ 返回地区代码映射表等配置项,支持后续扩展
1975
+ """
1976
+ return {
1977
+ "region_codes": REGION_CODE_TO_NAME,
1978
+ }
1979
+
1980
+
1981
  @api_router.post("/outputs", response_model=PagedImageFileList, tags=["检测列表"])
1982
  @log_api_params
1983
  async def list_outputs(
 
2052
  is_cropped = False
2053
  nickname_value = record.get("nickname") if record else None
2054
  hostname_value = record.get("hostname") if record else None
2055
+ region_code = record.get("region") or "" if record else ""
2056
+ region_country = get_country_name_by_region_code(region_code) if region_code else ""
2057
  last_modified_dt = None
2058
 
2059
  if record:
 
2090
  "last_modified": last_modified_str,
2091
  "nickname": nickname_value,
2092
  "hostname": hostname_value,
2093
+ "region": region_code,
2094
+ "region_country": region_country,
2095
  }
2096
  all_files.append(file_info)
2097
 
 
2117
  # 普通文件列表模式(无关键词或CLIP不可用)
2118
  logger.info("Returning regular file list")
2119
  try:
2120
+ region_filter = request.region if hasattr(request, 'region') else None
2121
  total_count = await count_image_records(
2122
  category=category,
2123
  nickname=nickname_filter,
2124
+ region=region_filter,
2125
  is_cropped_face=is_cropped_filter,
2126
  )
2127
  if total_count > 0:
 
2129
  rows = await fetch_paged_image_records(
2130
  category=category,
2131
  nickname=nickname_filter,
2132
+ region=region_filter,
2133
  offset=offset,
2134
  limit=page_size,
2135
  is_cropped_face=is_cropped_filter,
 
2146
  else:
2147
  last_modified_dt = last_modified
2148
  size_bytes = int(row.get("size_bytes") or 0)
2149
+ region_code = row.get("region") or ""
2150
+ region_country = get_country_name_by_region_code(region_code) if region_code else ""
2151
+
2152
  paged_results.append({
2153
  "file_path": row.get("file_path"),
2154
  "score": float(row.get("score") or 0.0),
 
2159
  "%Y-%m-%d %H:%M:%S") if last_modified_dt else "",
2160
  "nickname": row.get("nickname"),
2161
  "hostname": row.get("hostname"),
2162
+ "region": region_code,
2163
+ "region_country": region_country,
2164
  })
2165
  total_pages = (total_count + page_size - 1) // page_size
2166
  return PagedImageFileList(
 
2210
  ),
2211
  "nickname": None,
2212
  "hostname": None,
2213
+ "region": None,
2214
+ "region_country": None,
2215
  }
2216
  all_files.append(file_info)
2217
 
 
2451
  @api_router.post("/restore")
2452
  @log_api_params
2453
  async def restore_old_photo(
2454
+ request: Request,
2455
  file: UploadFile = File(...),
2456
  md5: str = Query(None, description="前端传递的文件md5,用于提前保存记录"),
2457
  colorize: bool = Query(False, description="是否对黑白照片进行上色"),
 
2569
  nickname=nickname,
2570
  category="restore",
2571
  bos_uploaded=True,
2572
+ region=_get_region_from_headers(request),
2573
  extra={
2574
  "source": "restore",
2575
  "colorize": colorize,
 
2601
  @api_router.post("/upcolor")
2602
  @log_api_params
2603
  async def colorize_photo(
2604
+ request: Request,
2605
  file: UploadFile = File(...),
2606
  md5: str = Query(None, description="前端传递的文件md5,用于提前保存记录"),
2607
  nickname: str = Form(None, description="操作者昵称"),
 
2681
  nickname=nickname,
2682
  category="upcolor",
2683
  bos_uploaded=True,
2684
+ region=_get_region_from_headers(request),
2685
  extra={
2686
  "source": "upcolor",
2687
  "md5": actual_md5,
 
2782
  @api_router.post("/anime_style")
2783
  @log_api_params
2784
  async def anime_stylize_photo(
2785
+ request: Request,
2786
  file: UploadFile = File(...),
2787
  style_type: str = Form("handdrawn",
2788
  description="动漫风格类型: handdrawn=手绘风格, disney=迪士尼风格, illustration=插画风格, artstyle=艺术风格, anime=二次元风格, sketch=素描风格"),
 
2875
  nickname=nickname,
2876
  category="anime_style",
2877
  bos_uploaded=original_bos_uploaded,
2878
+ region=_get_region_from_headers(request),
2879
  extra={
2880
  "source": "anime_style",
2881
  "style_type": style_type,
 
2914
  nickname=nickname,
2915
  category="anime_style",
2916
  bos_uploaded=bos_uploaded,
2917
+ region=_get_region_from_headers(request),
2918
  extra={
2919
  "source": "anime_style",
2920
  "style_type": style_type,
 
3044
  @api_router.post("/upscale")
3045
  @log_api_params
3046
  async def upscale_photo(
3047
+ request: Request,
3048
  file: UploadFile = File(...),
3049
  md5: str = Query(None, description="前端传递的文件md5,用于提前保存记录"),
3050
  scale: int = Query(UPSCALE_SIZE, description="放大倍数,支持2或4倍"),
 
3133
  nickname=nickname,
3134
  category="upscale",
3135
  bos_uploaded=True,
3136
+ region=_get_region_from_headers(request),
3137
  extra={
3138
  "source": "upscale",
3139
  "md5": actual_md5,
 
4407
  @api_router.post("/celebrity/match", tags=["Face Recognition"])
4408
  @log_api_params
4409
  async def match_celebrity_face(
4410
+ request: Request,
4411
  file: UploadFile = File(..., description="待匹配的用户图片"),
4412
  nickname: str = Form(None, description="操作者昵称"),
4413
  ):
 
4471
  file_path=temp_path,
4472
  nickname=nickname,
4473
  category="celebrity",
4474
+ region=_get_region_from_headers(request),
4475
  extra={
4476
  "source": "celebrity_match",
4477
  "role": "query",
 
4628
  file_path=face_path,
4629
  nickname=nickname,
4630
  category="face",
4631
+ region=_get_region_from_headers(request),
4632
  extra={
4633
  "source": "celebrity_match",
4634
  "role": "face_crop",
 
4649
  file_path=temp_path,
4650
  nickname=nickname,
4651
  category="celebrity",
4652
+ region=_get_region_from_headers(request),
4653
  extra={
4654
  "source": "celebrity_match",
4655
  "role": "annotated",
 
4699
  @api_router.post("/face_verify")
4700
  @log_api_params
4701
  async def face_similarity_verification(
4702
+ request: Request,
4703
  file1: UploadFile = File(..., description="第一张人脸图片"),
4704
  file2: UploadFile = File(..., description="第二张人脸图片"),
4705
  nickname: str = Form(None, description="操作者昵称"),
 
4902
  file_path=original_path1,
4903
  nickname=nickname,
4904
  category="original",
4905
+ region=_get_region_from_headers(request),
4906
  extra={
4907
  "source": "face_verify",
4908
  "role": "original1",
 
4914
  file_path=original_path2,
4915
  nickname=nickname,
4916
  category="original",
4917
+ region=_get_region_from_headers(request),
4918
  extra={
4919
  "source": "face_verify",
4920
  "role": "original2",
 
4947
  file_path=face_path1,
4948
  nickname=nickname,
4949
  category="face",
4950
+ region=_get_region_from_headers(request),
4951
  extra={
4952
  "source": "face_verify",
4953
  "role": "face1",
 
4958
  file_path=face_path2,
4959
  nickname=nickname,
4960
  category="face",
4961
+ region=_get_region_from_headers(request),
4962
  extra={
4963
  "source": "face_verify",
4964
  "role": "face2",
database.py CHANGED
@@ -125,6 +125,7 @@ async def upsert_image_record(
125
  last_modified: datetime,
126
  bos_uploaded: bool,
127
  hostname: Optional[str] = None,
 
128
  extra_metadata: Optional[Dict[str, Any]] = None,
129
  ) -> None:
130
  """写入或更新图片记录"""
@@ -139,8 +140,9 @@ async def upsert_image_record(
139
  last_modified,
140
  bos_uploaded,
141
  hostname,
 
142
  extra_metadata
143
- ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
144
  ON DUPLICATE KEY UPDATE
145
  category = VALUES(category),
146
  nickname = VALUES(nickname),
@@ -150,6 +152,7 @@ async def upsert_image_record(
150
  last_modified = VALUES(last_modified),
151
  bos_uploaded = VALUES(bos_uploaded),
152
  hostname = VALUES(hostname),
 
153
  extra_metadata = VALUES(extra_metadata),
154
  updated_at = CURRENT_TIMESTAMP
155
  """
@@ -166,6 +169,7 @@ async def upsert_image_record(
166
  last_modified,
167
  1 if bos_uploaded else 0,
168
  hostname,
 
169
  extra_value,
170
  ),
171
  )
@@ -175,6 +179,7 @@ async def fetch_paged_image_records(
175
  *,
176
  category: Optional[str],
177
  nickname: Optional[str],
 
178
  offset: int,
179
  limit: int,
180
  is_cropped_face: Optional[int] = None,
@@ -188,6 +193,9 @@ async def fetch_paged_image_records(
188
  if nickname:
189
  where_clauses.append("nickname LIKE %s")
190
  params.append(f"{nickname}%")
 
 
 
191
  if is_cropped_face is not None:
192
  where_clauses.append("is_cropped_face = %s")
193
  params.append(is_cropped_face)
@@ -202,7 +210,8 @@ async def fetch_paged_image_records(
202
  size_bytes,
203
  last_modified,
204
  bos_uploaded,
205
- hostname
 
206
  FROM tpl_app_processed_images
207
  {where_sql}
208
  ORDER BY last_modified DESC, id DESC
@@ -216,6 +225,7 @@ async def count_image_records(
216
  *,
217
  category: Optional[str],
218
  nickname: Optional[str],
 
219
  is_cropped_face: Optional[int] = None,
220
  ) -> int:
221
  """按条件统计图片记录数量"""
@@ -227,6 +237,9 @@ async def count_image_records(
227
  if nickname:
228
  where_clauses.append("nickname LIKE %s")
229
  params.append(f"{nickname}%")
 
 
 
230
  if is_cropped_face is not None:
231
  where_clauses.append("is_cropped_face = %s")
232
  params.append(is_cropped_face)
@@ -277,7 +290,8 @@ async def fetch_records_by_paths(file_paths: Iterable[str]) -> Dict[
277
  size_bytes,
278
  last_modified,
279
  bos_uploaded,
280
- hostname
 
281
  FROM tpl_app_processed_images
282
  WHERE file_path IN ({placeholders})
283
  """
@@ -389,6 +403,79 @@ async def upsert_device_record(
389
  logger.warning(f"写入设备记录失败: {exc}")
390
 
391
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
392
  async def record_image_creation(
393
  *,
394
  file_path: str,
@@ -396,6 +483,7 @@ async def record_image_creation(
396
  score: float = 0.0,
397
  category: Optional[str] = None,
398
  bos_uploaded: bool = False,
 
399
  extra_metadata: Optional[Dict[str, Any]] = None,
400
  ) -> None:
401
  """
@@ -437,7 +525,34 @@ async def record_image_creation(
437
  last_modified=last_modified,
438
  bos_uploaded=bos_uploaded,
439
  hostname=HOSTNAME,
 
440
  extra_metadata=extra_metadata,
441
  )
442
  except Exception as exc:
443
  logger.warning(f"写入图片记录失败: {exc}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
125
  last_modified: datetime,
126
  bos_uploaded: bool,
127
  hostname: Optional[str] = None,
128
+ region: Optional[str] = None,
129
  extra_metadata: Optional[Dict[str, Any]] = None,
130
  ) -> None:
131
  """写入或更新图片记录"""
 
140
  last_modified,
141
  bos_uploaded,
142
  hostname,
143
+ region,
144
  extra_metadata
145
+ ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
146
  ON DUPLICATE KEY UPDATE
147
  category = VALUES(category),
148
  nickname = VALUES(nickname),
 
152
  last_modified = VALUES(last_modified),
153
  bos_uploaded = VALUES(bos_uploaded),
154
  hostname = VALUES(hostname),
155
+ region = VALUES(region),
156
  extra_metadata = VALUES(extra_metadata),
157
  updated_at = CURRENT_TIMESTAMP
158
  """
 
169
  last_modified,
170
  1 if bos_uploaded else 0,
171
  hostname,
172
+ region,
173
  extra_value,
174
  ),
175
  )
 
179
  *,
180
  category: Optional[str],
181
  nickname: Optional[str],
182
+ region: Optional[str] = None,
183
  offset: int,
184
  limit: int,
185
  is_cropped_face: Optional[int] = None,
 
193
  if nickname:
194
  where_clauses.append("nickname LIKE %s")
195
  params.append(f"{nickname}%")
196
+ if region:
197
+ where_clauses.append("region = %s")
198
+ params.append(region)
199
  if is_cropped_face is not None:
200
  where_clauses.append("is_cropped_face = %s")
201
  params.append(is_cropped_face)
 
210
  size_bytes,
211
  last_modified,
212
  bos_uploaded,
213
+ hostname,
214
+ region
215
  FROM tpl_app_processed_images
216
  {where_sql}
217
  ORDER BY last_modified DESC, id DESC
 
225
  *,
226
  category: Optional[str],
227
  nickname: Optional[str],
228
+ region: Optional[str] = None,
229
  is_cropped_face: Optional[int] = None,
230
  ) -> int:
231
  """按条件统计图片记录数量"""
 
237
  if nickname:
238
  where_clauses.append("nickname LIKE %s")
239
  params.append(f"{nickname}%")
240
+ if region:
241
+ where_clauses.append("region = %s")
242
+ params.append(region)
243
  if is_cropped_face is not None:
244
  where_clauses.append("is_cropped_face = %s")
245
  params.append(is_cropped_face)
 
290
  size_bytes,
291
  last_modified,
292
  bos_uploaded,
293
+ hostname,
294
+ region
295
  FROM tpl_app_processed_images
296
  WHERE file_path IN ({placeholders})
297
  """
 
403
  logger.warning(f"写入设备记录失败: {exc}")
404
 
405
 
406
+ async def fetch_user_devices(
407
+ *,
408
+ device_id: Optional[str] = None,
409
+ region: Optional[str] = None,
410
+ offset: int,
411
+ limit: int,
412
+ ) -> List[Dict[str, Any]]:
413
+ """查询用户设备列表,支持按 device_id 和 region 筛选,按 created_at 倒序"""
414
+ where_clauses: List[str] = []
415
+ params: List[Any] = []
416
+
417
+ if device_id:
418
+ where_clauses.append("d.device_id LIKE %s")
419
+ params.append(f"{device_id}%")
420
+
421
+ if region:
422
+ where_clauses.append("d.region = %s")
423
+ params.append(region)
424
+
425
+ where_sql = f"WHERE {' AND '.join(where_clauses)}" if where_clauses else ""
426
+
427
+ query = f"""
428
+ SELECT
429
+ d.device_id,
430
+ d.device_type,
431
+ d.device_model,
432
+ d.os_version,
433
+ d.app_version,
434
+ d.timezone,
435
+ d.region,
436
+ d.language,
437
+ d.created_at,
438
+ d.updated_at,
439
+ COALESCE((
440
+ SELECT COUNT(*)
441
+ FROM tpl_app_processed_images p
442
+ WHERE p.nickname LIKE CONCAT('android_release_', d.device_id, '%%')
443
+ ), 0) AS detection_count
444
+ FROM tpl_app_user_devices d
445
+ {where_sql}
446
+ ORDER BY d.created_at DESC
447
+ LIMIT %s OFFSET %s
448
+ """
449
+ params.extend([limit, offset])
450
+ return await fetch_all(query, params)
451
+
452
+
453
+ async def count_user_devices(
454
+ *,
455
+ device_id: Optional[str] = None,
456
+ region: Optional[str] = None,
457
+ ) -> int:
458
+ """统计用户设备数量"""
459
+ where_clauses: List[str] = []
460
+ params: List[Any] = []
461
+
462
+ if device_id:
463
+ where_clauses.append("d.device_id LIKE %s")
464
+ params.append(f"{device_id}%")
465
+
466
+ if region:
467
+ where_clauses.append("d.region = %s")
468
+ params.append(region)
469
+
470
+ where_sql = f"WHERE {' AND '.join(where_clauses)}" if where_clauses else ""
471
+
472
+ query = f"SELECT COUNT(*) AS total FROM tpl_app_user_devices d {where_sql}"
473
+ rows = await fetch_all(query, params)
474
+ if not rows:
475
+ return 0
476
+ return int(rows[0].get("total", 0) or 0)
477
+
478
+
479
  async def record_image_creation(
480
  *,
481
  file_path: str,
 
483
  score: float = 0.0,
484
  category: Optional[str] = None,
485
  bos_uploaded: bool = False,
486
+ region: Optional[str] = None,
487
  extra_metadata: Optional[Dict[str, Any]] = None,
488
  ) -> None:
489
  """
 
525
  last_modified=last_modified,
526
  bos_uploaded=bos_uploaded,
527
  hostname=HOSTNAME,
528
+ region=region,
529
  extra_metadata=extra_metadata,
530
  )
531
  except Exception as exc:
532
  logger.warning(f"写入图片记录失败: {exc}")
533
+
534
+
535
+ # 清理调试数据 SQL 列表,可在此处增删 SQL
536
+ CLEAN_DEBUG_SQLS = [
537
+ "DELETE FROM tpl_app_user_devices WHERE device_model='Xiaomi 23127PN0CC' AND region='CN'",
538
+ "DELETE FROM tpl_app_processed_images WHERE nickname LIKE 'android_debug%'",
539
+ "DELETE FROM tpl_app_processed_images WHERE nickname='android_release_1b708ef79ac4f484'",
540
+ "DELETE FROM tpl_app_processed_images WHERE nickname='9527'",
541
+ ]
542
+
543
+
544
+ async def clean_debug_data() -> Dict[str, int]:
545
+ """
546
+ 清理调试数据
547
+ :return: 返回每个 SQL 影响的行数
548
+ """
549
+ results = {}
550
+ for idx, sql in enumerate(CLEAN_DEBUG_SQLS):
551
+ try:
552
+ affected = await execute(sql)
553
+ results[f"sql_{idx + 1}"] = affected
554
+ logger.info(f"Clean debug SQL #{idx + 1} executed, affected: {affected}")
555
+ except Exception as exc:
556
+ logger.warning(f"Clean debug SQL #{idx + 1} failed: {exc}")
557
+ results[f"sql_{idx + 1}"] = -1
558
+ return results
models.py CHANGED
@@ -21,6 +21,8 @@ class ImageScoreItem(BaseModel):
21
  last_modified: str
22
  nickname: Optional[str] = None
23
  hostname: Optional[str] = None
 
 
24
 
25
 
26
  class SearchRequest(BaseModel):
@@ -29,6 +31,7 @@ class SearchRequest(BaseModel):
29
  top_k: Optional[int] = 5
30
  score_threshold: float = 0.0
31
  nickname: Optional[str] = None
 
32
 
33
 
34
  class ImageSearchRequest(BaseModel):
@@ -68,3 +71,26 @@ class CategoryStatItem(BaseModel):
68
  class CategoryStatsResponse(BaseModel):
69
  stats: List[CategoryStatItem]
70
  total: int
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
  last_modified: str
22
  nickname: Optional[str] = None
23
  hostname: Optional[str] = None
24
+ region: Optional[str] = None
25
+ region_country: Optional[str] = None
26
 
27
 
28
  class SearchRequest(BaseModel):
 
31
  top_k: Optional[int] = 5
32
  score_threshold: float = 0.0
33
  nickname: Optional[str] = None
34
+ region: Optional[str] = None
35
 
36
 
37
  class ImageSearchRequest(BaseModel):
 
71
  class CategoryStatsResponse(BaseModel):
72
  stats: List[CategoryStatItem]
73
  total: int
74
+
75
+
76
+ class UserDeviceItem(BaseModel):
77
+ device_id: str
78
+ device_type: Optional[str] = None
79
+ device_model: Optional[str] = None
80
+ os_version: Optional[str] = None
81
+ app_version: Optional[str] = None
82
+ timezone: Optional[str] = None
83
+ region: Optional[str] = None
84
+ region_country: Optional[str] = None
85
+ language: Optional[str] = None
86
+ detection_count: int = 0
87
+ created_at: str
88
+ updated_at: str
89
+
90
+
91
+ class UserDeviceListResponse(BaseModel):
92
+ results: List[UserDeviceItem]
93
+ count: int
94
+ page: int
95
+ page_size: int
96
+ total_pages: int
region_code_utils.py ADDED
@@ -0,0 +1,294 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # region_code_utils.py
2
+ """
3
+ 地区代码转换工具类
4
+ 将 ISO 3166-1 alpha-2 两位字母代码转换为完整国家名称
5
+ """
6
+
7
+ # ISO 3166-1 alpha-2 国家代码映射表 (完整)
8
+ REGION_CODE_TO_NAME = {
9
+ # A
10
+ "AF": "阿富汗",
11
+ "AL": "阿尔巴尼亚",
12
+ "DZ": "阿尔及利亚",
13
+ "AS": "美属萨摩亚",
14
+ "AD": "安道尔",
15
+ "AO": "安哥拉",
16
+ "AI": "安圭拉",
17
+ "AQ": "南极洲",
18
+ "AG": "安提瓜和巴布达",
19
+ "AR": "阿根廷",
20
+ "AM": "亚美尼亚",
21
+ "AW": "阿鲁巴",
22
+ "AU": "澳大利亚",
23
+ "AT": "奥地利",
24
+ "AZ": "阿塞拜疆",
25
+ # B
26
+ "BS": "巴哈马",
27
+ "BH": "巴林",
28
+ "BD": "孟加拉国",
29
+ "BB": "巴巴多斯",
30
+ "BY": "白俄罗斯",
31
+ "BE": "比利时",
32
+ "BZ": "伯利兹",
33
+ "BJ": "贝宁",
34
+ "BM": "百慕大",
35
+ "BT": "不丹",
36
+ "BO": "玻利维亚",
37
+ "BA": "波黑",
38
+ "BW": "博茨瓦纳",
39
+ "BV": "布韦岛",
40
+ "BR": "巴西",
41
+ "IO": "英属印度洋领地",
42
+ "BN": "文莱",
43
+ "BG": "保加利亚",
44
+ "BF": "布基纳法索",
45
+ "BI": "布隆迪",
46
+ # C
47
+ "KH": "柬埔寨",
48
+ "CM": "喀麦隆",
49
+ "CA": "加拿大",
50
+ "CV": "佛得角",
51
+ "KY": "开曼群岛",
52
+ "CF": "中非",
53
+ "TD": "乍得",
54
+ "CL": "智利",
55
+ "CN": "中国",
56
+ "CX": "圣诞岛",
57
+ "CC": "科科斯群岛",
58
+ "CO": "哥伦比亚",
59
+ "KM": "科摩罗",
60
+ "CG": "刚果",
61
+ "CD": "刚果民主共和国",
62
+ "CK": "库克群岛",
63
+ "CR": "哥斯达黎加",
64
+ "CI": "科特迪瓦",
65
+ "HR": "克罗地亚",
66
+ "CU": "古巴",
67
+ "CY": "塞浦路斯",
68
+ "CZ": "捷克",
69
+ # D
70
+ "DK": "丹麦",
71
+ "DJ": "吉布提",
72
+ "DM": "多米尼加",
73
+ "DO": "多米尼加共和国",
74
+ # E
75
+ "EC": "厄瓜多尔",
76
+ "EG": "埃及",
77
+ "SV": "萨尔瓦多",
78
+ "GQ": "赤道几内亚",
79
+ "ER": "厄立特里亚",
80
+ "EE": "爱沙尼亚",
81
+ "ET": "埃塞俄比亚",
82
+ # F
83
+ "FK": "福克兰群岛",
84
+ "FO": "法罗群岛",
85
+ "FJ": "斐济",
86
+ "FI": "芬兰",
87
+ "FR": "法国",
88
+ "GF": "法属圭亚那",
89
+ "PF": "法属波利尼西亚",
90
+ "TF": "法属南部领地",
91
+ "GA": "加蓬",
92
+ "GM": "冈比亚",
93
+ "GE": "格鲁吉亚",
94
+ "DE": "德国",
95
+ "GH": "加纳",
96
+ "GI": "直布罗陀",
97
+ "GR": "希腊",
98
+ "GL": "格陵兰",
99
+ "GD": "格林纳达",
100
+ "GP": "瓜德罗普",
101
+ "GU": "关岛",
102
+ "GT": "危地马拉",
103
+ "GN": "几内亚",
104
+ "GW": "几内亚比绍",
105
+ "GY": "圭亚那",
106
+ # H
107
+ "HT": "海地",
108
+ "HM": "赫德岛和麦克唐纳岛",
109
+ "HN": "洪都拉斯",
110
+ "HK": "中国香港",
111
+ "HU": "匈牙利",
112
+ # I
113
+ "IS": "冰岛",
114
+ "IN": "印度",
115
+ "ID": "印度尼西亚",
116
+ "IR": "伊朗",
117
+ "IQ": "伊拉克",
118
+ "IE": "爱尔兰",
119
+ "IL": "以色列",
120
+ "IT": "意大利",
121
+ "JM": "牙买加",
122
+ "JP": "日本",
123
+ "JO": "约旦",
124
+ # K
125
+ "KZ": "哈萨克斯坦",
126
+ "KE": "肯尼亚",
127
+ "KI": "基里巴斯",
128
+ "KP": "朝鲜",
129
+ "KR": "韩国",
130
+ "KW": "科威特",
131
+ "KG": "吉尔吉斯斯坦",
132
+ "LA": "老挝",
133
+ "LV": "拉脱维亚",
134
+ "LB": "黎巴嫩",
135
+ "LS": "莱索托",
136
+ "LR": "利比里亚",
137
+ "LY": "利比亚",
138
+ "LI": "列支敦士登",
139
+ "LT": "立陶宛",
140
+ "LU": "卢森堡",
141
+ "MO": "中国澳门",
142
+ # M
143
+ "MK": "北马其顿",
144
+ "MG": "马达加斯加",
145
+ "MW": "马拉维",
146
+ "MY": "马来西亚",
147
+ "MV": "马尔代夫",
148
+ "ML": "马里",
149
+ "MT": "马耳他",
150
+ "MH": "马绍尔群岛",
151
+ "MQ": "马提尼克",
152
+ "MR": "毛里塔尼亚",
153
+ "MU": "毛里求斯",
154
+ "YT": "马约特",
155
+ "MX": "墨西哥",
156
+ "FM": "密克罗尼西亚",
157
+ "MD": "摩尔多瓦",
158
+ "MC": "摩纳哥",
159
+ "MN": "蒙古",
160
+ "ME": "黑山",
161
+ "MS": "蒙特塞拉特",
162
+ "MA": "摩洛哥",
163
+ "MZ": "莫桑比克",
164
+ "MM": "缅甸",
165
+ # N
166
+ "NA": "纳米比亚",
167
+ "NR": "瑙鲁",
168
+ "NP": "尼泊尔",
169
+ "NL": "荷兰",
170
+ "NC": "新喀里多尼亚",
171
+ "NZ": "新西兰",
172
+ "NI": "尼加拉瓜",
173
+ "NE": "尼日尔",
174
+ "NG": "尼日利亚",
175
+ "NU": "纽埃",
176
+ "NF": "诺福克岛",
177
+ "MP": "北马里亚纳群岛",
178
+ "NO": "挪威",
179
+ # O
180
+ "OM": "阿曼",
181
+ # P
182
+ "PK": "巴基斯坦",
183
+ "PW": "帕劳",
184
+ "PS": "巴勒斯坦",
185
+ "PA": "巴拿马",
186
+ "PG": "巴布亚新几内亚",
187
+ "PY": "巴拉圭",
188
+ "PE": "秘鲁",
189
+ "PH": "菲律宾",
190
+ "PN": "皮特凯恩群岛",
191
+ "PL": "波兰",
192
+ "PT": "葡萄牙",
193
+ "PR": "波多黎各",
194
+ "QA": "卡塔尔",
195
+ # R
196
+ "RE": "留尼汪",
197
+ "RO": "罗马尼亚",
198
+ "RU": "俄罗斯",
199
+ "RW": "卢旺达",
200
+ # S
201
+ "BL": "圣巴泰勒米",
202
+ "SH": "圣赫勒拿",
203
+ "KN": "圣基茨和尼维斯",
204
+ "LC": "圣卢西亚",
205
+ "MF": "法属圣马丁",
206
+ "PM": "圣皮埃尔和密克隆",
207
+ "VC": "圣文森特和格林纳丁斯",
208
+ "WS": "萨摩亚",
209
+ "SM": "圣马力诺",
210
+ "ST": "圣多美和普林西比",
211
+ "SA": "沙特阿拉伯",
212
+ "SN": "塞内加尔",
213
+ "RS": "塞尔维亚",
214
+ "SC": "塞舌尔",
215
+ "SL": "塞拉利昂",
216
+ "SG": "新加坡",
217
+ "SX": "荷属圣马丁",
218
+ "SK": "斯洛伐克",
219
+ "SI": "斯洛文尼亚",
220
+ "SB": "所罗门群岛",
221
+ "SO": "索马里",
222
+ "ZA": "南非",
223
+ "GS": "南乔治亚和南桑威奇群岛",
224
+ "SS": "南苏丹",
225
+ "ES": "西班牙",
226
+ "LK": "斯里兰卡",
227
+ "SD": "苏丹",
228
+ "SR": "苏里南",
229
+ "SJ": "斯瓦尔巴和扬马延",
230
+ "SZ": "斯威士兰",
231
+ "SE": "瑞典",
232
+ "CH": "瑞士",
233
+ "SY": "叙利亚",
234
+ # T
235
+ "TW": "中国台湾",
236
+ "TJ": "塔吉克斯坦",
237
+ "TZ": "坦桑尼亚",
238
+ "TH": "泰国",
239
+ "TL": "东帝汶",
240
+ "TG": "多哥",
241
+ "TK": "托克劳",
242
+ "TO": "汤加",
243
+ "TT": "特立尼达和多巴哥",
244
+ "TN": "突尼斯",
245
+ "TR": "土耳其",
246
+ "TM": "土库曼斯坦",
247
+ "TC": "特克斯和凯科斯群岛",
248
+ "TV": "图瓦卢",
249
+ "UG": "乌干达",
250
+ "UA": "乌克兰",
251
+ "AE": "阿联酋",
252
+ "GB": "英国",
253
+ "US": "美国",
254
+ "UM": "美国本土外小岛屿",
255
+ "UY": "乌拉圭",
256
+ "UZ": "乌兹别克斯坦",
257
+ # V
258
+ "VU": "瓦努阿图",
259
+ "VA": "梵蒂冈",
260
+ "VE": "委内瑞拉",
261
+ "VN": "越南",
262
+ "VG": "英属维尔京群岛",
263
+ "VI": "美属维尔京群岛",
264
+ # W
265
+ "WF": "瓦利斯和富图纳",
266
+ "EH": "西撒哈拉",
267
+ "YE": "也门",
268
+ "ZM": "赞比亚",
269
+ "ZW": "津巴布韦",
270
+ "AX": "奥兰群岛",
271
+ "BQ": "荷兰加勒比区",
272
+ "CW": "库拉索",
273
+ "GG": "根西岛",
274
+ "IM": "马恩岛",
275
+ "JE": "泽西岛",
276
+ "XK": "科索沃",
277
+ "AQ": "南极洲",
278
+ }
279
+
280
+
281
+ def get_country_name_by_region_code(region_code: str) -> str:
282
+ """
283
+ 根据地区代码获取国家名称
284
+ :param region_code: ISO 3166-1 alpha-2 两位字母代码,如 "CN", "US"
285
+ :return: 完整国家名称,如 "中国", "美国";如果代码不存在则返回原始代码
286
+ """
287
+ if not region_code:
288
+ return ""
289
+
290
+ upper_code = region_code.strip().upper()
291
+ if not upper_code:
292
+ return ""
293
+
294
+ return REGION_CODE_TO_NAME.get(upper_code, region_code)