Skip to content

Commit fae42fa

Browse files
committed
feat: 手机号国家筛选 + 归属地自动识别
- 新增 phone_service.py(基于 phonenumbers 库) - Sender 模型新增 country_code/country/phone_location 字段 - 3313 条发送者手机号自动解析归属地 - API 支持按国家和归属地下拉筛选 - GET /senders/countries/list 国家列表 - GET /senders/locations/list 归属地列表
1 parent 5b18cd0 commit fae42fa

3 files changed

Lines changed: 167 additions & 0 deletions

File tree

backend/app/api/senders.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616
async def list_senders(
1717
has_phone: Optional[bool] = Query(None, description="筛选有手机号的发送者"),
1818
keyword: Optional[str] = Query(None, description="搜索用户名或手机号"),
19+
country: Optional[str] = Query(None, description="按国家筛选"),
20+
phone_location: Optional[str] = Query(None, description="按归属地筛选"),
1921
page: int = Query(1, ge=1),
2022
page_size: int = Query(50, ge=1, le=200),
2123
db: AsyncSession = Depends(get_db),
@@ -34,6 +36,10 @@ async def list_senders(
3436
Sender.phone.ilike(f"%{keyword}%"),
3537
)
3638
)
39+
if country:
40+
conditions.append(Sender.country == country)
41+
if phone_location:
42+
conditions.append(Sender.phone_location.ilike(f"%{phone_location}%"))
3743

3844
# 总数
3945
count_query = select(func.count(Sender.id))
@@ -78,6 +84,9 @@ async def list_senders(
7884
"is_premium": s.is_premium,
7985
"message_count": s.message_count,
8086
"alert_count": real_alert_count or 0,
87+
"country": s.country or "",
88+
"country_code": s.country_code or "",
89+
"phone_location": s.phone_location or "",
8190
"created_at": s.created_at.isoformat() if s.created_at else None,
8291
})
8392

@@ -192,3 +201,32 @@ async def get_sender_messages(
192201
"page_size": page_size,
193202
"total_pages": (total + page_size - 1) // page_size if total else 0,
194203
}
204+
205+
206+
@router.get("/countries/list")
207+
async def list_countries(db: AsyncSession = Depends(get_db)):
208+
"""获取所有国家/地区列表(去重)"""
209+
from sqlalchemy import text
210+
result = await db.execute(
211+
text("SELECT DISTINCT country, COUNT(*) as cnt FROM senders WHERE country IS NOT NULL AND country != '' GROUP BY country ORDER BY cnt DESC")
212+
)
213+
return [{"name": row[0], "count": row[1]} for row in result.all()]
214+
215+
216+
@router.get("/locations/list")
217+
async def list_locations(
218+
country: Optional[str] = Query(None, description="按国家筛选"),
219+
db: AsyncSession = Depends(get_db),
220+
):
221+
"""获取归属地列表(去重)"""
222+
from sqlalchemy import text
223+
if country:
224+
result = await db.execute(
225+
text("SELECT DISTINCT phone_location, COUNT(*) as cnt FROM senders WHERE phone_location IS NOT NULL AND phone_location != '' AND country = :country GROUP BY phone_location ORDER BY cnt DESC"),
226+
{"country": country}
227+
)
228+
else:
229+
result = await db.execute(
230+
text("SELECT DISTINCT phone_location, COUNT(*) as cnt FROM senders WHERE phone_location IS NOT NULL AND phone_location != '' GROUP BY phone_location ORDER BY cnt DESC")
231+
)
232+
return [{"name": row[0], "count": row[1]} for row in result.all()]

backend/app/models/sender.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ class Sender(Base):
1818
first_name = Column(String(100), comment="名字")
1919
last_name = Column(String(100), comment="姓氏")
2020
phone = Column(String(20), comment="手机号")
21+
country_code = Column(String(10), comment="国家代码")
22+
country = Column(String(50), comment="国家/地区")
23+
phone_location = Column(String(100), comment="手机号归属地")
2124
is_bot = Column(Boolean, default=False, comment="是否Bot")
2225
is_verified = Column(Boolean, default=False, comment="是否认证")
2326
is_premium = Column(Boolean, default=False, comment="是否Premium")
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
"""手机号解析服务 - 基于 Google phonenumbers 库"""
2+
import phonenumbers
3+
from phonenumbers import geocoder, carrier as ph_carrier, timezone as ph_tz
4+
from typing import Optional, Tuple
5+
6+
# 国家代码 → 国家名
7+
COUNTRY_NAMES = {
8+
"CN": "中国", "HK": "香港", "MO": "澳门", "TW": "台湾",
9+
"US": "美国", "CA": "加拿大", "GB": "英国", "JP": "日本",
10+
"KR": "韩国", "AU": "澳大利亚", "DE": "德国", "FR": "法国",
11+
"IN": "印度", "ID": "印尼", "TH": "泰国", "PH": "菲律宾",
12+
"VN": "越南", "MY": "马来西亚", "SG": "新加坡", "RU": "俄罗斯",
13+
"BR": "巴西", "MX": "墨西哥", "ES": "西班牙", "IT": "意大利",
14+
"NL": "荷兰", "SE": "瑞典", "NO": "挪威", "TR": "土耳其",
15+
"SA": "沙特", "AE": "阿联酋", "EG": "埃及", "ZA": "南非",
16+
"PK": "巴基斯坦", "BD": "孟加拉", "NG": "尼日利亚",
17+
}
18+
19+
20+
# 已知国家代码前缀 → 区域映射
21+
PREFIX_REGION_MAP = {
22+
"86": "CN", "852": "HK", "853": "MO", "886": "TW",
23+
"44": "GB", "81": "JP", "82": "KR", "61": "AU",
24+
"33": "DE", "39": "IT", "91": "IN", "62": "ID",
25+
"66": "TH", "63": "PH", "84": "VN", "60": "MY",
26+
"65": "SG", "90": "TR", "966": "SA", "971": "AE",
27+
"7": "RU", "55": "BR", "52": "MX", "34": "ES",
28+
"31": "NL", "46": "SE", "47": "NO", "380": "UA",
29+
"855": "KH", "95": "MM", "977": "NP", "94": "LK",
30+
}
31+
32+
33+
def parse_phone(phone: str) -> Optional[phonenumbers.PhoneNumber]:
34+
"""解析手机号 - 智能区域检测"""
35+
if not phone or not phone.strip():
36+
return None
37+
try:
38+
normalized = phone.replace(" ", "").replace("-", "").replace("(", "").replace(")", "")
39+
40+
# 带+号的直接解析
41+
if normalized.startswith("+"):
42+
return phonenumbers.parse(normalized, None)
43+
44+
# 没有+号的,尝试加+解析
45+
if normalized.startswith("86") and len(normalized) >= 13:
46+
return phonenumbers.parse("+" + normalized, None)
47+
48+
# 中国11位手机号(以1开头)
49+
if len(normalized) == 11 and normalized.startswith("1"):
50+
return phonenumbers.parse("+86" + normalized, None)
51+
52+
# 尝试匹配已知国家代码前缀
53+
for prefix, region in sorted(PREFIX_REGION_MAP.items(), key=lambda x: -len(x[0])):
54+
if normalized.startswith(prefix):
55+
try:
56+
return phonenumbers.parse("+" + normalized, region)
57+
except Exception:
58+
continue
59+
60+
# 兜底:用中国区域解析
61+
return phonenumbers.parse(normalized, "CN")
62+
except Exception:
63+
return None
64+
65+
66+
def get_country_info(phone: str) -> Tuple[str, str]:
67+
"""获取国家代码和国家名"""
68+
pn = parse_phone(phone)
69+
if not pn:
70+
return ("", "")
71+
region = phonenumbers.region_code_for_number(pn)
72+
if not region:
73+
return ("", "未知")
74+
country_name = COUNTRY_NAMES.get(region, region)
75+
return (region, country_name)
76+
77+
78+
def get_carrier_name(phone: str) -> str:
79+
"""获取运营商名称"""
80+
pn = parse_phone(phone)
81+
if not pn:
82+
return ""
83+
name = ph_carrier.name_for_number(pn, "zh")
84+
return name or ""
85+
86+
87+
def get_location_name(phone: str) -> str:
88+
"""获取归属地"""
89+
pn = parse_phone(phone)
90+
if not pn:
91+
return ""
92+
name = geocoder.description_for_number(pn, "zh")
93+
return name or ""
94+
95+
96+
def get_full_info(phone: str) -> dict:
97+
"""获取手机号完整信息"""
98+
if not phone:
99+
return {"raw": "", "country_code": "", "country": "", "carrier": "", "location": ""}
100+
101+
pn = parse_phone(phone)
102+
if not pn:
103+
return {"raw": phone, "country_code": "", "country": "", "carrier": "", "location": ""}
104+
105+
region = phonenumbers.region_code_for_number(pn) or ""
106+
country_name = COUNTRY_NAMES.get(region, region)
107+
carrier = ph_carrier.name_for_number(pn, "zh") or ""
108+
location = geocoder.description_for_number(pn, "zh") or ""
109+
110+
return {
111+
"raw": phone,
112+
"country_code": region,
113+
"country": country_name,
114+
"carrier": carrier,
115+
"location": location,
116+
}
117+
118+
119+
def normalize_phone(phone: str) -> str:
120+
"""标准化手机号"""
121+
if not phone:
122+
return ""
123+
pn = parse_phone(phone)
124+
if pn and phonenumbers.is_valid_number(pn):
125+
return phonenumbers.format_number(pn, phonenumbers.PhoneNumberFormat.E164)
126+
return phone

0 commit comments

Comments
 (0)