chawin.chen commited on
Commit ·
640e3c9
1
Parent(s): 3183d9b
feat: 新增按国家/地区分组的统计功能
Browse files- 后端: 添加 device_region_stats 和 image_region_stats API,支持按地区GROUP BY统计
- 前端: 在用户设备列表和检测历史页面添加国家分布统计条,显示Top10国家占比
- 支持点击国家行进行筛选
- api_routes.py +58 -0
- database.py +58 -0
- models.py +11 -0
api_routes.py
CHANGED
|
@@ -80,6 +80,8 @@ from database import (
|
|
| 80 |
upsert_device_record,
|
| 81 |
fetch_user_devices,
|
| 82 |
count_user_devices,
|
|
|
|
|
|
|
| 83 |
clean_debug_data,
|
| 84 |
fetch_all,
|
| 85 |
)
|
|
@@ -535,6 +537,8 @@ from models import (
|
|
| 535 |
CelebrityMatchResponse,
|
| 536 |
CategoryStatsResponse,
|
| 537 |
CategoryStatItem,
|
|
|
|
|
|
|
| 538 |
UserDeviceItem,
|
| 539 |
UserDeviceListResponse,
|
| 540 |
)
|
|
@@ -2320,6 +2324,60 @@ async def list_user_devices(
|
|
| 2320 |
raise HTTPException(status_code=500, detail=str(e))
|
| 2321 |
|
| 2322 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2323 |
@api_router.post("/clean_debug_data", tags=["系统维护"])
|
| 2324 |
async def clean_debug_data_api():
|
| 2325 |
"""清理调试数据接口"""
|
|
|
|
| 80 |
upsert_device_record,
|
| 81 |
fetch_user_devices,
|
| 82 |
count_user_devices,
|
| 83 |
+
fetch_device_region_stats,
|
| 84 |
+
fetch_image_region_stats,
|
| 85 |
clean_debug_data,
|
| 86 |
fetch_all,
|
| 87 |
)
|
|
|
|
| 537 |
CelebrityMatchResponse,
|
| 538 |
CategoryStatsResponse,
|
| 539 |
CategoryStatItem,
|
| 540 |
+
RegionStatsResponse,
|
| 541 |
+
RegionStatItem,
|
| 542 |
UserDeviceItem,
|
| 543 |
UserDeviceListResponse,
|
| 544 |
)
|
|
|
|
| 2324 |
raise HTTPException(status_code=500, detail=str(e))
|
| 2325 |
|
| 2326 |
|
| 2327 |
+
@api_router.get("/device_region_stats", response_model=RegionStatsResponse, tags=["用户设备列表"])
|
| 2328 |
+
async def get_device_region_stats():
|
| 2329 |
+
"""查询用户设备按国家/地区分组的统计信息"""
|
| 2330 |
+
try:
|
| 2331 |
+
rows = await fetch_device_region_stats()
|
| 2332 |
+
stats: List[RegionStatItem] = []
|
| 2333 |
+
for row in rows:
|
| 2334 |
+
region_code = str(row.get("region") or "")
|
| 2335 |
+
region_country = get_country_name_by_region_code(region_code) if region_code else "未知地区"
|
| 2336 |
+
stats.append(RegionStatItem(
|
| 2337 |
+
region=region_code,
|
| 2338 |
+
region_country=region_country,
|
| 2339 |
+
count=int(row.get("count", 0) or 0),
|
| 2340 |
+
))
|
| 2341 |
+
total = sum(item.count for item in stats)
|
| 2342 |
+
return RegionStatsResponse(stats=stats, total=total)
|
| 2343 |
+
except Exception as e:
|
| 2344 |
+
logger.error(f"Failed to get device region stats: {str(e)}")
|
| 2345 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 2346 |
+
|
| 2347 |
+
|
| 2348 |
+
@api_router.post("/image_region_stats", response_model=RegionStatsResponse, tags=["图片记录"])
|
| 2349 |
+
async def get_image_region_stats(
|
| 2350 |
+
category: Optional[str] = Query(None, description="类别筛选"),
|
| 2351 |
+
nickname: Optional[str] = Query(None, description="昵称筛选"),
|
| 2352 |
+
region: Optional[str] = Query(None, description="地区筛选"),
|
| 2353 |
+
platform: Optional[str] = Query(None, description="平台筛选"),
|
| 2354 |
+
is_cropped_face: Optional[int] = Query(None, description="是否裁剪人脸"),
|
| 2355 |
+
):
|
| 2356 |
+
"""查询图片记录按国家/地区分组的统计信息"""
|
| 2357 |
+
try:
|
| 2358 |
+
rows = await fetch_image_region_stats(
|
| 2359 |
+
category=category,
|
| 2360 |
+
nickname=nickname,
|
| 2361 |
+
region=region,
|
| 2362 |
+
platform=platform,
|
| 2363 |
+
is_cropped_face=is_cropped_face,
|
| 2364 |
+
)
|
| 2365 |
+
stats: List[RegionStatItem] = []
|
| 2366 |
+
for row in rows:
|
| 2367 |
+
region_code = str(row.get("region") or "")
|
| 2368 |
+
region_country = get_country_name_by_region_code(region_code) if region_code else "未知地区"
|
| 2369 |
+
stats.append(RegionStatItem(
|
| 2370 |
+
region=region_code,
|
| 2371 |
+
region_country=region_country,
|
| 2372 |
+
count=int(row.get("count", 0) or 0),
|
| 2373 |
+
))
|
| 2374 |
+
total = sum(item.count for item in stats)
|
| 2375 |
+
return RegionStatsResponse(stats=stats, total=total)
|
| 2376 |
+
except Exception as e:
|
| 2377 |
+
logger.error(f"Failed to get image region stats: {str(e)}")
|
| 2378 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 2379 |
+
|
| 2380 |
+
|
| 2381 |
@api_router.post("/clean_debug_data", tags=["系统维护"])
|
| 2382 |
async def clean_debug_data_api():
|
| 2383 |
"""清理调试数据接口"""
|
database.py
CHANGED
|
@@ -496,6 +496,64 @@ async def count_user_devices(
|
|
| 496 |
return int(rows[0].get("total", 0) or 0)
|
| 497 |
|
| 498 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 499 |
async def record_image_creation(
|
| 500 |
*,
|
| 501 |
file_path: str,
|
|
|
|
| 496 |
return int(rows[0].get("total", 0) or 0)
|
| 497 |
|
| 498 |
|
| 499 |
+
async def fetch_device_region_stats() -> List[Dict[str, Any]]:
|
| 500 |
+
"""统计用户设备按地区分组的数量"""
|
| 501 |
+
query = """
|
| 502 |
+
SELECT
|
| 503 |
+
COALESCE(region, '') AS region,
|
| 504 |
+
COUNT(*) AS count
|
| 505 |
+
FROM tpl_app_user_devices
|
| 506 |
+
GROUP BY COALESCE(region, '')
|
| 507 |
+
ORDER BY count DESC
|
| 508 |
+
"""
|
| 509 |
+
return await fetch_all(query)
|
| 510 |
+
|
| 511 |
+
|
| 512 |
+
async def fetch_image_region_stats(
|
| 513 |
+
*,
|
| 514 |
+
category: Optional[str],
|
| 515 |
+
nickname: Optional[str],
|
| 516 |
+
region: Optional[str] = None,
|
| 517 |
+
platform: Optional[str] = None,
|
| 518 |
+
is_cropped_face: Optional[int] = None,
|
| 519 |
+
) -> List[Dict[str, Any]]:
|
| 520 |
+
"""统计图片记录按地区分组的数量"""
|
| 521 |
+
where_clauses: List[str] = []
|
| 522 |
+
params: List[Any] = []
|
| 523 |
+
if category and category != "all":
|
| 524 |
+
where_clauses.append("category = %s")
|
| 525 |
+
params.append(category)
|
| 526 |
+
if nickname:
|
| 527 |
+
where_clauses.append("nickname LIKE %s")
|
| 528 |
+
params.append(f"{nickname}%")
|
| 529 |
+
if platform == "android":
|
| 530 |
+
where_clauses.append("nickname LIKE %s")
|
| 531 |
+
params.append("android%")
|
| 532 |
+
elif platform == "ios":
|
| 533 |
+
where_clauses.append("nickname LIKE %s")
|
| 534 |
+
params.append("ios%")
|
| 535 |
+
elif platform == "miniprogram":
|
| 536 |
+
where_clauses.append("region = %s")
|
| 537 |
+
params.append("")
|
| 538 |
+
if region:
|
| 539 |
+
where_clauses.append("region = %s")
|
| 540 |
+
params.append(region)
|
| 541 |
+
if is_cropped_face is not None:
|
| 542 |
+
where_clauses.append("is_cropped_face = %s")
|
| 543 |
+
params.append(is_cropped_face)
|
| 544 |
+
where_sql = f"WHERE {' AND '.join(where_clauses)}" if where_clauses else ""
|
| 545 |
+
query = f"""
|
| 546 |
+
SELECT
|
| 547 |
+
COALESCE(region, '') AS region,
|
| 548 |
+
COUNT(*) AS count
|
| 549 |
+
FROM tpl_app_processed_images
|
| 550 |
+
{where_sql}
|
| 551 |
+
GROUP BY COALESCE(region, '')
|
| 552 |
+
ORDER BY count DESC
|
| 553 |
+
"""
|
| 554 |
+
return await fetch_all(query, params)
|
| 555 |
+
|
| 556 |
+
|
| 557 |
async def record_image_creation(
|
| 558 |
*,
|
| 559 |
file_path: str,
|
models.py
CHANGED
|
@@ -74,6 +74,17 @@ class CategoryStatsResponse(BaseModel):
|
|
| 74 |
total: int
|
| 75 |
|
| 76 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
class UserDeviceItem(BaseModel):
|
| 78 |
device_id: str
|
| 79 |
device_type: Optional[str] = None
|
|
|
|
| 74 |
total: int
|
| 75 |
|
| 76 |
|
| 77 |
+
class RegionStatItem(BaseModel):
|
| 78 |
+
region: str
|
| 79 |
+
region_country: str
|
| 80 |
+
count: int
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
class RegionStatsResponse(BaseModel):
|
| 84 |
+
stats: List[RegionStatItem]
|
| 85 |
+
total: int
|
| 86 |
+
|
| 87 |
+
|
| 88 |
class UserDeviceItem(BaseModel):
|
| 89 |
device_id: str
|
| 90 |
device_type: Optional[str] = None
|