feat(meituan-cps): 经纬度→城市离线反查 + rec/销量最高按城市过滤 (#116)
## 主要功能 新增离线「经纬度 → 美团 cityId」反查,让 `rec`(智能推荐)与 `top-sales`(销量最高)从离线库只返**同城券**(此前会混返异地券)。 - `app/utils/geo.py` + `app/utils/meituan_city.py`:坐标 → 美团 `city_id`(reverse_geocoder 离线反查,零网络)。 - `feed?tab=rec` / `/top-sales`:按 `city_id` 过滤;解析不出 / 老客户端不带坐标 → 降级返空。 - `top-sales` 与 `rec` 一致置空库内距离(相对城市默认点、对用户无意义)。 --------- Co-authored-by: guke <guke@autohome.com.cn> Reviewed-on: #116 Co-authored-by: guke <guke@wonderable.ai> Co-committed-by: guke <guke@wonderable.ai>
This commit was merged in pull request #116.
This commit is contained in:
@@ -48,3 +48,12 @@ secrets/*
|
||||
# 运行日志(run.sh 输出, 不入库)
|
||||
*.log
|
||||
logs/
|
||||
|
||||
# Claude Code 自动持久化的权限 allowlist / 个人本地设置(会话专属,不入库)。
|
||||
# 需要团队共享的 Claude 配置(commands/ 等)可单独 git add -f,不受此忽略影响。
|
||||
.claude/settings.json
|
||||
.claude/settings.local.json
|
||||
tests/meituan_coupon_bj.tsv
|
||||
tests/meituan_coupon_data.tsv
|
||||
tests/meituan_coupon_fz.tsv
|
||||
tests/meituan_coupon_xm.tsv
|
||||
|
||||
+41
-4
@@ -25,9 +25,19 @@ from app.schemas.meituan import (
|
||||
ReferralLinkResponse,
|
||||
TopSalesRequest,
|
||||
)
|
||||
from app.utils.meituan_city import get_meituan_city
|
||||
|
||||
logger = logging.getLogger("shagua.meituan")
|
||||
|
||||
|
||||
def _resolve_city_id(latitude: float, longitude: float) -> str:
|
||||
"""经纬度 → 美团城市 ID;解析失败返 ""(调用方应降级返空)。"""
|
||||
try:
|
||||
return get_meituan_city(latitude, longitude).get("city_id", "")
|
||||
except Exception:
|
||||
logger.exception("get_meituan_city 失败")
|
||||
return ""
|
||||
|
||||
router = APIRouter(prefix="/api/v1/meituan", tags=["meituan-cps"])
|
||||
|
||||
|
||||
@@ -175,13 +185,19 @@ def feed(req: FeedRequest, db: Session = Depends(get_db)) -> FeedResponse:
|
||||
status = "degraded" if (not cards and wm_fail and dd_fail) else ("ok" if cards else "empty")
|
||||
return FeedResponse(items=cards, has_next=wm_hn or dd_hn, page=req.page, status=status)
|
||||
|
||||
# 智能推荐(rec):走【离线库】筛佣金率 ≥ 3%,分页返回(SQL 侧去重+排序+分页,秒级、不打美团)。
|
||||
# 智能推荐(rec):走【离线库】筛佣金率 ≥ 3%,按城市过滤,分页返回(SQL 侧去重+排序+分页,秒级、不打美团)。
|
||||
# 实测库里佣金≥3% 去重后仅 ~578 条(几乎全是外卖;到店团购佣金普遍 <3%):实时按"同城热销榜单"
|
||||
# 拉既撞限流、又填不满(该榜单中位佣金 ~0.8%,筛完每页剩 0-1 条),故从库出。佣金阈值逻辑不变。
|
||||
if tab == "rec":
|
||||
city_id = _resolve_city_id(lat, lon)
|
||||
if not city_id:
|
||||
return FeedResponse(items=[], has_next=False, page=req.page, status="degraded")
|
||||
PAGE = 20
|
||||
try:
|
||||
base = select(MeituanCoupon).where(MeituanCoupon.commission_percent >= 3.0)
|
||||
base = select(MeituanCoupon).where(
|
||||
MeituanCoupon.commission_percent >= 3.0,
|
||||
MeituanCoupon.city_id == city_id,
|
||||
)
|
||||
deduped = base.distinct(MeituanCoupon.dedup_key).order_by(
|
||||
MeituanCoupon.dedup_key,
|
||||
MeituanCoupon.commission_percent.desc(),
|
||||
@@ -211,6 +227,9 @@ def feed(req: FeedRequest, db: Session = Depends(get_db)) -> FeedResponse:
|
||||
card.distance_text = None
|
||||
card.distance_meters = None
|
||||
cards.append(card)
|
||||
if not cards and req.page == 1:
|
||||
# 命中城市却 0 券:该城确无 ≥3% 券,或 ETL 灌的 city_id 与 city_dict 口径不一致。
|
||||
logger.info("[feed] rec city_id=%s 命中 0 券(该城确无券?或 ETL/city_dict 的 city_id 口径不一致)", city_id)
|
||||
return FeedResponse(items=cards, has_next=has_next, page=req.page,
|
||||
status="ok" if cards else "empty")
|
||||
|
||||
@@ -254,14 +273,24 @@ def referral_link(req: ReferralLinkRequest) -> ReferralLinkResponse:
|
||||
|
||||
|
||||
@router.post("/top-sales", response_model=CouponListResponse,
|
||||
summary="销量最高(从离线库 meituan_coupon 按销量降序 + 跨源去重,不实时打美团)")
|
||||
summary="销量最高(从离线库 meituan_coupon 按销量降序 + 跨源去重,按城市过滤,不实时打美团)")
|
||||
def top_sales(req: TopSalesRequest, db: Session = Depends(get_db)) -> CouponListResponse:
|
||||
# 按设备经纬度定位城市,只查同城券;老客户端不带坐标 → 降级返空(不 422、不误返全城)。
|
||||
if req.latitude is None or req.longitude is None:
|
||||
return CouponListResponse(items=[], has_next=False, search_id=None, status="degraded")
|
||||
city_id = _resolve_city_id(req.latitude, req.longitude)
|
||||
if not city_id:
|
||||
return CouponListResponse(items=[], has_next=False, search_id=None, status="degraded")
|
||||
|
||||
# 去重 + 排序 + 分页全在 SQL 做,每页只取并解析当前页 ~20 条。
|
||||
# (之前实现每翻一页都全表拉取 + 全量 from_raw 解析,翻页慢 → 客户端滑动卡顿/翻不动。)
|
||||
# 库为空(prod 刚部署 / ETL 未跑完)时返空 + status=empty,不崩;库查询异常降级 degraded。
|
||||
try:
|
||||
# 1) DISTINCT ON (dedup_key):每个去重键(品牌|名|价)只留销量最高那条(同销量再按佣金)
|
||||
base = select(MeituanCoupon).where(MeituanCoupon.sale_volume_num.isnot(None))
|
||||
base = select(MeituanCoupon).where(
|
||||
MeituanCoupon.sale_volume_num.isnot(None),
|
||||
MeituanCoupon.city_id == city_id,
|
||||
)
|
||||
if req.platform is not None:
|
||||
base = base.where(MeituanCoupon.platform == req.platform)
|
||||
deduped = base.distinct(MeituanCoupon.dedup_key).order_by(
|
||||
@@ -292,6 +321,14 @@ def top_sales(req: TopSalesRequest, db: Session = Depends(get_db)) -> CouponList
|
||||
except Exception: # noqa: BLE001
|
||||
continue
|
||||
if card.product_view_sign:
|
||||
# 不显示距离:库里的距离是相对城市默认点的(对用户无意义、且误导)。
|
||||
# 置空后前端"距离 店名"那行只剩店名、自动顶到最左(店名移到原距离的位置)。
|
||||
# 逻辑与推荐流保持一致
|
||||
card.distance_text = None
|
||||
card.distance_meters = None
|
||||
cards.append(card)
|
||||
if not cards and req.page == 1:
|
||||
# 命中城市却 0 券:可能该城确无券,也可能 ETL 灌的 city_id 与 city_dict 口径不一致(静默降级的隐患)。
|
||||
logger.info("[top-sales] city_id=%s 命中 0 券(该城确无券?或 ETL/city_dict 的 city_id 口径不一致)", city_id)
|
||||
return CouponListResponse(items=cards, has_next=has_next, search_id=None,
|
||||
status="ok" if cards else "empty")
|
||||
|
||||
@@ -71,6 +71,12 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]:
|
||||
settings.DATABASE_URL.split("://", 1)[0],
|
||||
)
|
||||
get_pricebot_client() # 预热透传 client:把建 SSL 上下文的一次性成本付在启动,首个领券请求即热
|
||||
try:
|
||||
# 预热离线地理库:首次加载 ~2.5M 行 CSV + 建 KDTree,摊到启动、不砸首个按城市过滤的请求
|
||||
from app.utils import geo
|
||||
geo.ensure_loaded()
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception("reverse_geocoder 预热失败(城市反查将在首个请求时懒加载)")
|
||||
reconcile_task = start_withdraw_reconcile_worker()
|
||||
heartbeat_task = start_heartbeat_monitor()
|
||||
daily_exchange_task = start_daily_exchange_worker()
|
||||
|
||||
@@ -174,9 +174,13 @@ class FeedResponse(BaseModel):
|
||||
|
||||
class TopSalesRequest(BaseModel):
|
||||
"""销量最高 tab:从离线库 meituan_coupon 按销量降序取(不实时打美团)。"""
|
||||
# 可选:老客户端(本次改动前发版)不带经纬度。缺省时后端降级返空(status=degraded),
|
||||
# 不做 422 硬拒,也不误返"全城"结果。新客户端会传坐标 → 按城市过滤。
|
||||
longitude: float | None = Field(None, description="经度(用于定位城市;缺省=老客户端,降级返空)")
|
||||
latitude: float | None = Field(None, description="纬度(用于定位城市;缺省=老客户端,降级返空)")
|
||||
page: int = Field(1, ge=1)
|
||||
page_size: int = Field(20, ge=1, le=50)
|
||||
platform: int | None = Field(None, description="可选: 1只外卖 / 2只到店; 不填=全部(全城销量)")
|
||||
platform: int | None = Field(None, description="可选: 1只外卖 / 2只到店; 不填=全部(同城销量)")
|
||||
|
||||
|
||||
# ───────────────── 换链 请求 / 响应 ─────────────────
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
城市ID 城市名称 省份名称
|
||||
3NUYJKKJXPHVNZUHFK3HWUDHNM 宣城市 安徽省
|
||||
LXXSHOY7LNK74ZK2SKVUXFY72Q 阜阳市 安徽省
|
||||
ZEBF2LBJOEHGM4XGFPNW4IHBIA 合肥市 安徽省
|
||||
WADWEY3GR6IARJLSNGQWG2KI4E 滁州市 安徽省
|
||||
Z62QL3X66AOT6MSY7LB6LVO4CI 芜湖市 安徽省
|
||||
M6QCWRCECRT6ZEJ6RERR6IWHFI 淮南市 安徽省
|
||||
UP2NBJACSAO7FUH4XQQDMBOUE4 马鞍山市 安徽省
|
||||
WLL7BSHUBPTLTMITFITJX2GKWY 蚌埠市 安徽省
|
||||
ECTBNJ4KNNHPGXEVI4TBJNU7BY 亳州市 安徽省
|
||||
KNEGDNOSRGNQPAMOMH54ATLIUU 六安市 安徽省
|
||||
YH53FRR55H6VA4KXW36OJ7RNSE 宿州市 安徽省
|
||||
LH6LX5DUVFPOFCAQ6NEGPWCLBI 淮北市 安徽省
|
||||
RIFXWJ46SEJXE7EP6HZXEUIALU 铜陵市 安徽省
|
||||
FK6F7KMO4WNARZKUXMMSBWGATM 安庆市 安徽省
|
||||
M3CHGNNUSFSPS5HRV3W7MCBWFI 黄山市 安徽省
|
||||
XFI6PM7SRO6NHBGYQUZEEA2WT4 池州市 安徽省
|
||||
D2ZILSASTTGEFQWZIDTHA7PWU4 澳门 澳门特别行政区
|
||||
WKV2HMXUEK634WP64CUCUQGM64 北京市 北京市
|
||||
HPMKHLM3QR6EZGMY7GEI4H3QYQ 泉州市 福建省
|
||||
6V523YRU54Y3PEAM2XPADNJM2U 福州市 福建省
|
||||
HH3ZZCERPVQIYUZPW4A2U4JKZI 莆田市 福建省
|
||||
BQ5RWEJS4O7W27SQMLPMRIRJDU 宁德市 福建省
|
||||
TDN7XDQMZEP6ZCK6UO3VVMCSMM 三明市 福建省
|
||||
ZKNTORN6YTZL2BXRYUSRGV3CHU 厦门市 福建省
|
||||
Y2DI2QLZN5NNACMD3KI2DBR4IA 龙岩市 福建省
|
||||
C4RS32I6QAHWLP55UQWI3N5LLY 南平市 福建省
|
||||
5T23WDEOAP7RYNU4JNL2ZG7PDQ 漳州市 福建省
|
||||
UHZBROATFB2KWNMNLS23DPRJBY 定西市 甘肃省
|
||||
WLPAHIUOIVKS644QSN4V5ZY5XQ 金昌市 甘肃省
|
||||
J7TO3UHZ57ABNUKIQUBCIJZQFA 白银市 甘肃省
|
||||
GX277SS75375VEFYVCEHBL6ISA 临夏回族自治州 甘肃省
|
||||
ORA3R7F2LSJHUOCIDTZCP54G7Q 张掖市 甘肃省
|
||||
L6VQYYOJNTHSLW5JCR5SXBITDM 武威市 甘肃省
|
||||
65OXQPQVFXNOYHXTMCE2RNFRL4 兰州市 甘肃省
|
||||
IUIZYQ7E2SPMEAIGOUUNZWBQOA 天水市 甘肃省
|
||||
BYBRGRDRV4NKAWCU6GWBIVLX3Q 酒泉市 甘肃省
|
||||
KDST2VRETG6WK5SMJO2G2FN2RU 嘉峪关市 甘肃省
|
||||
R3Q2XWVFVF4T2ADZZZMPZJRWBA 庆阳市 甘肃省
|
||||
7FWNT2TP66SU4QEP6IBBMZWFNE 陇南市 甘肃省
|
||||
T33S2GYGPVHAL5SR2FLSPTJSBE 平凉市 甘肃省
|
||||
RR6KAWBLOKD4H2UINKYPFCPXO4 甘南藏族自治州 甘肃省
|
||||
QKX4DS3CTJJG7SFW5SBHPLD42I 茂名市 广东省
|
||||
JJZ75A32XCQNZU4IN2ZCEUGN3M 梅州市 广东省
|
||||
SJSOXOSJASLUT6LBH4E32SUKKQ 清远市 广东省
|
||||
SQQWAN5BQOVSX55S7EPF7QHMAU 珠海市 广东省
|
||||
NGRJMW6JMRS6U2KUJEONSORAEY 韶关市 广东省
|
||||
FAUMIGOSOET4E5WR5BL6P3OZHA 佛山市 广东省
|
||||
JSBIH55ICFZQ2D3LEV47YMZ2NI 河源市 广东省
|
||||
KOYAYPD2DBLDCF2ZI5GCW4LF6Y 中山市 广东省
|
||||
647JGFPUYM4VWVLZCPSHT63XKQ 汕头市 广东省
|
||||
AMOIPZW3Q2NMTDSEREFVM4SV74 深圳市 广东省
|
||||
XIHEJY4H2CDZJCLIXDIN36BKXQ 广州市 广东省
|
||||
UZ6OT4CYUR42KCTTED2KJW6EGA 东莞市 广东省
|
||||
RM3HLOIUEYKTQ5O2JSVEKDMFRY 阳江市 广东省
|
||||
C6XNJAZA6N3NNUDBUU3JIZPTDI 潮州市 广东省
|
||||
AUPF3G2ULSV4TDT4L3NMHRTY6Y 揭阳市 广东省
|
||||
7HIITKBPRXTVBA2FBBN443XITQ 云浮市 广东省
|
||||
TO6ILZ7MPJMJN3S7W2SXMIFQQY 江门市 广东省
|
||||
DTTBMGCIOMPCZY5NETUEKWJ6PY 汕尾市 广东省
|
||||
HJY7JYWBA6FQY42RYSKX3RZTRI 湛江市 广东省
|
||||
SLICHB4FBDVDLI53MR74WVUUNI 肇庆市 广东省
|
||||
ZPX4JXJVBBYSSD2KTWHAPXO6NE 惠州市 广东省
|
||||
JH4Q44RQA4EZ3Q6MHQVEVE7KZQ 百色市 广西壮族自治区
|
||||
H2JXFEJFIL4PPFMYOS4MHZ5IBM 崇左市 广西壮族自治区
|
||||
SXIRRISUOEGBU335AWT2ZFL6A4 贵港市 广西壮族自治区
|
||||
HEQHKC4KP7YGGYVBZM5JEUI5AQ 北海市 广西壮族自治区
|
||||
SKGG7KMFKVDIDKRVQEPTS7SIE4 贺州市 广西壮族自治区
|
||||
N4WR7CWCULNA5Z35OTDJSZYDCU 钦州市 广西壮族自治区
|
||||
T4RXX2WY6WPQYZEUXVJNZBXQZU 梧州市 广西壮族自治区
|
||||
3R23AS3EIY7EYE2D5MWWORZODI 河池市 广西壮族自治区
|
||||
57SMWWCV7X44E256P4I23OQ3AA 防城港市 广西壮族自治区
|
||||
YHGHVIQ37UCTNQ4JKPQEAUWIQA 桂林市 广西壮族自治区
|
||||
MQJZTM455OKZLAN5WQYUTA5TDE 柳州市 广西壮族自治区
|
||||
C6FZPLB4NJQ6VUPSKDJH3EWQDM 玉林市 广西壮族自治区
|
||||
BFSU5W6E5XBIDFPQLVPGRDSATY 南宁市 广西壮族自治区
|
||||
CKXOQUZDNOVNME3PEBOY2CULQQ 来宾市 广西壮族自治区
|
||||
LV32FV6IQTKFR7JIBEQMHRUVCA 贵阳市 贵州省
|
||||
F26RCNKMFTZONJCSJ5C6FHVY74 毕节市 贵州省
|
||||
G2LMYRWVRCK7BTD2WM4NWX4SYM 黔南布依族苗族自治州 贵州省
|
||||
MFSOO3NBMB2PVLIVSI5EJK7MWY 黔西南布依族苗族自治州 贵州省
|
||||
KWUL44L7SEMJGIMXCWSSEB3OOA 遵义市 贵州省
|
||||
T2P3OFGQZUGR7D6TGRCMDF22GI 铜仁市 贵州省
|
||||
AGUFUANSZNGC4TMOPZO65IRSPI 六盘水市 贵州省
|
||||
2XOCOSNUAK3J5QDGTKIBWKK7KU 安顺市 贵州省
|
||||
6XRTSAEYJTA2UBKXO4XEPQE5ZY 黔东南苗族侗族自治州 贵州省
|
||||
YRMKRP2GOE2VMRS73N4YRIZUHY 三亚市 海南省
|
||||
CJRGVLBNLJAVBJ4ZKKIZ3FZ2LY 白沙黎族自治县 海南省
|
||||
5XOUAJ5Z4J4K7SVIQGXL2OM2JQ 保亭黎族苗族自治县 海南省
|
||||
2UFQ6A2QRJPXPH3VOYEAQHMVSQ 海口市 海南省
|
||||
TGCVXVS4M7NDQM4ROUDCVI6I3A 承德市 河北省
|
||||
JEUP6QWCOXPSM3SQTINQCJKIGM 衡水市 河北省
|
||||
Z442MNCW6BO2BBHIRUPRBACXPI 唐山市 河北省
|
||||
RFE6R34GD4FY3LUKC2ICSF6AFY 张家口市 河北省
|
||||
CWJN55M73VZDCYJEQ7AHDBWGGY 沧州市 河北省
|
||||
DFL4ES776ECRGBYNOPLWKB247I 雄安新区 河北省
|
||||
ZLSXYY34IHBHIC2NOVPQQBFTBE 保定市 河北省
|
||||
3DO6Z2QRJQFMPLLDS55PG7DSBU 石家庄市 河北省
|
||||
PR57XT25LI3246VGASEPSHP63E 邢台市 河北省
|
||||
SKYLNH737BS56TD452FOKYL36U 邯郸市 河北省
|
||||
5PWPERL7GQKJD6QPLR2TWUUM7E 秦皇岛市 河北省
|
||||
5T2TGV6SJFVL3MO7HIMN2KTQTA 廊坊市 河北省
|
||||
ECSTLZ7GP7IX3MB5EVNKS47MLE 焦作市 河南省
|
||||
VTWW34QB2F5Q4LW7ISNUMWX7GY 开封市 河南省
|
||||
D2NUN47NY4Q55X3UED4JMSI6CM 周口市 河南省
|
||||
TR3XJFQR4EFYRRIX7TUQF3B26Y 郑州市 河南省
|
||||
CKJGF5S6XMHW5ZJEBU7MJC47QA 新乡市 河南省
|
||||
IFASZ625MCFJQKPLJ7EA2SMJUU 商丘市 河南省
|
||||
SKXPYKTTRG4YAUHE2HZXWRWXGM 鹤壁市 河南省
|
||||
G5LXE74CUHO2K6BBRN7Q5DSRJY 漯河市 河南省
|
||||
SLNOAFJV2LTBSH7SJCRXJA36K4 驻马店市 河南省
|
||||
65WO7LH7CFDUGKYMXQLRAYKKWM 安阳市 河南省
|
||||
RIX2X7FAVTZCAQ5RT2C2CWK22Q 南阳市 河南省
|
||||
SZOW5OY3U54SSY4WRC65VJUNTI 平顶山市 河南省
|
||||
7VPIDDUS4P2LSZ6Q5S57MAQDEM 信阳市 河南省
|
||||
4VYCRORUOZ4DC2U6S3CT6H6KWE 洛阳市 河南省
|
||||
M5WNO2BQ3UGLLHBCG4NCEFPP5U 濮阳市 河南省
|
||||
LY3O6PBPIWETMA3ZOL6ETY5UB4 三门峡市 河南省
|
||||
FXXJLIRE72LS2W4OWWQVJMRJHA 许昌市 河南省
|
||||
5XH353QTY3VWF2KYOCZCL3TOXY 牡丹江市 黑龙江省
|
||||
2KGRZKF6IECV2W7K5J64Y2LY4M 齐齐哈尔市 黑龙江省
|
||||
FASGWS5ADVSTFGJG6TGBZPZP6Q 鹤岗市 黑龙江省
|
||||
2O6CDIXSWIKBXILZEEPKCS7MVI 双鸭山市 黑龙江省
|
||||
OZ2PTOBYTBG57XJZMIC23QFJKM 佳木斯市 黑龙江省
|
||||
FO24MQMULT3J5JW64APNXSQEPU 伊春市 黑龙江省
|
||||
ETZ2HYWVU6U7SKU6G4JAO64RUQ 黑河市 黑龙江省
|
||||
PR7EJNBY2VZBEUT36JAWE3TM7I 七台河市 黑龙江省
|
||||
HADAAVLERKIW4SQGCTQYGX4AL4 哈尔滨市 黑龙江省
|
||||
CGTU45YC5C3JYLHMA47USDPA7Y 大庆市 黑龙江省
|
||||
T4W7SQIPOM4EYMEFFRAB5BSTII 鸡西市 黑龙江省
|
||||
TYGZHNQL6YT7CX6EEG5DJQQHMA 绥化市 黑龙江省
|
||||
OOSJTSN2CVUUCKD6XAB7EYYIPY 大兴安岭地区 黑龙江省
|
||||
I3YF3EKZHIZTN6TZOTYTGZ2UXQ 随州市 湖北省
|
||||
ESGVBOSTHW7JWEVCGYJUTEHEBQ 宜昌市 湖北省
|
||||
MTJRWJ53XBW5SBTWHKNNZDLM7U 十堰市 湖北省
|
||||
PXZLF2ISKQL5ACM67ZCBNOGDT4 黄石市 湖北省
|
||||
44RMTOEHPUFXBHZXX4IQ4IRZVQ 荆州市 湖北省
|
||||
OTKZGG743NFC474ADMMRX4ZOOA 鄂州市 湖北省
|
||||
EXOUAZAQ73OEFAK72CHQ32GQHQ 恩施土家族苗族自治州 湖北省
|
||||
SUCY7I72QJDZD7EBFXREIQ67SI 咸宁市 湖北省
|
||||
QENSGB5R7HGYDXCG2LQZQTO3TU 荆门市 湖北省
|
||||
OHIWL6SAE2PR4EJR4BOMLAE6FU 武汉市 湖北省
|
||||
ZXCE4WV2CDVPQTA4HAOVELQMNE 襄阳市 湖北省
|
||||
KEFN5OPSS4ZZF6NU2TTL72S6HE 孝感市 湖北省
|
||||
ROAHLMQ67H6M5NDFVXROJG723E 黄冈市 湖北省
|
||||
YBEBX2YYN4WPBNH6Z6C73DNE7I 张家界市 湖南省
|
||||
45XGRKYGSCPE5VNRYF4FVJGFMM 株洲市 湖南省
|
||||
LK3SEIBRU7GTDT4J2EPTLIO33U 永州市 湖南省
|
||||
R4YXFIK53W5E556BSGSBJWS4DM 郴州市 湖南省
|
||||
PQDO3RNADWXX75OWZW2GSXJ4SE 怀化市 湖南省
|
||||
RRRT6QOJYEJ432L3F76ZN5NHCA 长沙市 湖南省
|
||||
B6WPNMCZ3ENQSV4NFY5MSTPDAM 岳阳市 湖南省
|
||||
KNDZW5EHDPKP2DX7HBLKP4DYLM 益阳市 湖南省
|
||||
PA2GHG3XZ7I47HTKZ4YAFH3OYY 湘西土家族苗族自治州 湖南省
|
||||
I7CNIUA5PYV2EHDEW3RGYT2R4U 邵阳市 湖南省
|
||||
SRI2SU4FN66FMJJCKQOCZD72ZY 常德市 湖南省
|
||||
H7UHHJAMQUL7UA5QEUTGKNSL3A 湘潭市 湖南省
|
||||
EFB255OBTB2BUDZENR5UVIC7ZQ 衡阳市 湖南省
|
||||
RDMANB4KCM3OJSNVGZWVYVME6E 娄底市 湖南省
|
||||
LD37PDU5OB4UAV5QDOBMKG5YTY 吉林市 吉林省
|
||||
EO3GF4XNF5RXWRPUVAT3KTQO4U 四平市 吉林省
|
||||
4GD7OS4CAQABH5YIWVK5SKGHMY 通化市 吉林省
|
||||
6DRI2R5VAWMYJHJPCJKUNMDYEQ 延边朝鲜族自治州 吉林省
|
||||
TVBCNVGUND4MOUOOUXFGX7DIUA 松原市 吉林省
|
||||
JYY62HSKBUVK5OU7KGJDKQ4RTA 白城市 吉林省
|
||||
QEDUHKMZ36CHJTKRD6O2ZPLNBU 长春市 吉林省
|
||||
4EADVCBJMZ5UBH2FVRT6QCLS2U 辽源市 吉林省
|
||||
EUQD5EGS2LR5KJSFNG6PPSIHHI 白山市 吉林省
|
||||
YLTIISPCLBEGTZZX3WUWAD7WDE 淮安市 江苏省
|
||||
L6U5DZP6MESXPMHOHCDMJS55O4 宿迁市 江苏省
|
||||
HQMLYA7TDGMYQAXFCDUXBZPYHI 镇江市 江苏省
|
||||
K6XJ4UN65ZD6XQKYEG5YN7HCRI 盐城市 江苏省
|
||||
36I4X3EZZU4EHOSCLQI5OAKKBE 南通市 江苏省
|
||||
S3GWFQU6QAVRDKLJT77LD6OFLE 泰州市 江苏省
|
||||
IO6F4AFGAVIFRGYTZEC4TXM7W4 无锡市 江苏省
|
||||
NUXNK2VOFSD2JFTEO2AMWX6NSU 扬州市 江苏省
|
||||
TEVZU6CU6SK57HFW7DFNGMQ44A 南京市 江苏省
|
||||
UTYSRBQ4FSB7XLWCF3Z2HTKNUA 常州市 江苏省
|
||||
OCZOBCJDEXKE7KBN3BD7AYQG2Q 徐州市 江苏省
|
||||
6LIBPJGZROLXE3CLZGJRYMYBOU 连云港市 江苏省
|
||||
FS4PIU74F7QKYARDWR5ZMOLICI 苏州市 江苏省
|
||||
R2F4OWUO65HYZW2IQIKINORZ7Y 赣州市 江西省
|
||||
YW346BTN3VFYNRC3744UR5MZXY 抚州市 江西省
|
||||
QR3FDR26U2EJIXOMBHL7IJLQSA 南昌市 江西省
|
||||
OAJHJL7L7VNW2Q5UXRE7F4CUJQ 九江市 江西省
|
||||
OMH7D45R4DX2KNHLV3G2UP56OY 景德镇市 江西省
|
||||
YSB2PAEROB2IZSJZFVFH7KJPEI 鹰潭市 江西省
|
||||
5OYAMNORCXKYA6UF7DW6KFFBIU 上饶市 江西省
|
||||
SMHZOYKE7BXQJ2NT6Q24TFMLEQ 吉安市 江西省
|
||||
2RZV26OUPKUHUJ5ZPB673VDGZU 萍乡市 江西省
|
||||
232VHZEEZ6SXACE4AC5HQ4ZTFQ 新余市 江西省
|
||||
QRLM74YXNDW2QDBWLTFGEMXK2I 宜春市 江西省
|
||||
S6OHUVUKIIWPVMQD44RREUMNT4 葫芦岛市 辽宁省
|
||||
DQQ4OIFUGFYJY3XZRK5VDWMLCA 辽阳市 辽宁省
|
||||
S4YXGFEYXEUG6ISZ6O337OPVSI 阜新市 辽宁省
|
||||
D3JHM7A4CG6RJMBD7YRDS5JOYU 盘锦市 辽宁省
|
||||
VTRWMOSS6PCUYUAIPG6VPBKUUQ 营口市 辽宁省
|
||||
ZPHFGWBIEVLKP5CVZNZUB3CRT4 朝阳市 辽宁省
|
||||
NGYYULZ4UAGD3Q2PG726FFXSHU 抚顺市 辽宁省
|
||||
Q5BRTSW752VSHIAKLLL7KL5TNA 锦州市 辽宁省
|
||||
ZGV3WNPOSS7J4ZWBP6ZQG46BNM 沈阳市 辽宁省
|
||||
XEX676YYMTYIV5QPIUZB4TA7IY 本溪市 辽宁省
|
||||
PRTEQZMLNLQNZXJHRCYYLWZB4E 丹东市 辽宁省
|
||||
4GN4WF6UQRFU64T4FVZPRDXRWQ 鞍山市 辽宁省
|
||||
3QTZDFLJFSLLOOVCZ65PSDAVOU 铁岭市 辽宁省
|
||||
CC4ZTMKKXI73ZEVT5QQTJN5SMM 大连市 辽宁省
|
||||
3MBJEFDLAVOMQZ7L7CM5MNSYKA 鄂尔多斯市 内蒙古自治区
|
||||
5NOS4YC5WO2IZCQPVB6MCBYDJ4 呼和浩特市 内蒙古自治区
|
||||
OQNIP675H7L5R64652BH7KHUOQ 通辽市 内蒙古自治区
|
||||
4WA6I63MGVINV5DNLNWRRHCDDM 阿拉善盟 内蒙古自治区
|
||||
ELI6BDJBAN6RCYTETMK2EX2UKU 乌兰察布市 内蒙古自治区
|
||||
PV5ZAAXFW2DZCVZKCF4I4KK7BQ 巴彦淖尔市 内蒙古自治区
|
||||
YU6UUT6G6T6AMWTJFECDIUQFEQ 乌海市 内蒙古自治区
|
||||
V4MYANW5QFZCXG3FIDPXA3HOTE 呼伦贝尔市 内蒙古自治区
|
||||
S5DCFJWJ7J3MJSLY2PHKWLNPOQ 包头市 内蒙古自治区
|
||||
LY7SAZRFSJJMRU3JEO5SKNKIVM 兴安盟 内蒙古自治区
|
||||
NM2XP54CNQCFOILKACYEQWUSGM 锡林郭勒盟 内蒙古自治区
|
||||
S5G3IO75IDEJPZQA6VFM3OYPDI 赤峰市 内蒙古自治区
|
||||
UUFUUPM5RT6ZU5UKILQC5YQV54 吴忠市 宁夏回族自治区
|
||||
VMSRLIATK44WQXQEWAL63AXJ3M 固原市 宁夏回族自治区
|
||||
4GWWCAAKGNJV2SMQPSWWZNCGYY 中卫市 宁夏回族自治区
|
||||
6IE7GEETBQEF7GUSGU2FLIUEEM 石嘴山市 宁夏回族自治区
|
||||
VI4YIH3URSON4Q4MWOEESXJ56Q 银川市 宁夏回族自治区
|
||||
SIE4ED6QWVRT727GEHWBFH3DAA 海东市 青海省
|
||||
JNJH6OJZIOQKXDXWW5ZGEHG5MA 海西蒙古族藏族自治州 青海省
|
||||
MJADYNCKQNDJU2TXACTDP5I52M 海北藏族自治州 青海省
|
||||
LRGFXIVB6RJWQWYAFH7EIUHCPE 黄南藏族自治州 青海省
|
||||
J4TG3PCK2ZEMNEUMIPZF32UNQY 果洛藏族自治州 青海省
|
||||
2YS5POGG53LKZGFBIUMDWP57SM 玉树藏族自治州 青海省
|
||||
GRZMJEZCA2DNCZK3O6ZSUHMRPM 西宁市 青海省
|
||||
NBFQIACRBBCAH5AZWJ5LVT7AU4 海南藏族自治州 青海省
|
||||
MQUKCLQ76P4FRRECDBA3HBKT7Q 滨州市 山东省
|
||||
633FVSBDDBM5WSMXSKOCX6QC5I 潍坊市 山东省
|
||||
4434FVT3PXLMV6UAWLEW6O3M5A 菏泽市 山东省
|
||||
P7PK4UBVCOHW3PI6IPEIA54DLY 济南市 山东省
|
||||
I5M6JGTGSQEWX6HL7E5I6GRBAY 德州市 山东省
|
||||
V562AOMBVU5NG5GB3EPK6U42XY 烟台市 山东省
|
||||
4OSPHTE5TD24J6DYGR6DXMEDKY 淄博市 山东省
|
||||
227TLAVTUJABWPJD4S4ZECJ3FY 临沂市 山东省
|
||||
DAEZKZU32ZAPJGUTA6LLGO3WTY 聊城市 山东省
|
||||
LBRRK2EOYJN5MLYJWT4R3QBSXM 东营市 山东省
|
||||
AB6PBGCDBTNTG4KUQROY2FJ4GY 枣庄市 山东省
|
||||
EVANGU7WZCVRAAM6NWTDJVP7SU 济宁市 山东省
|
||||
GNUEGWZ3OKRWAKKVJ5THHHX6YY 泰安市 山东省
|
||||
F3VWSF4ART2FYYBBOZYWKRXTUI 青岛市 山东省
|
||||
KD6MNWWLVKB4E655XMV6MMA3KE 日照市 山东省
|
||||
LHYVF4LBCOZ34G3WNZYUVEIQGA 威海市 山东省
|
||||
ENVYDMYGDO3BMDXSAVQYZXLX74 阳泉市 山西省
|
||||
UXOUG4UIF7ZRJYCNMQJ3LDN5FY 临汾市 山西省
|
||||
4NZPT6Z35BMYACJ2HZGHUWRJ6E 吕梁市 山西省
|
||||
KSNXQME2A3VFHCE3DM3SFZKIJQ 晋城市 山西省
|
||||
HDOX7WKYSHJKEHET6TUYMVCTMQ 太原市 山西省
|
||||
DFJIZVXJGBGBIABPSL3DGMIDIE 长治市 山西省
|
||||
5KQYYTJR2EMP653QIALMA6LXXI 忻州市 山西省
|
||||
T76EOJA332RIHML7B6LYS5LF4U 朔州市 山西省
|
||||
HVX67CKT5TS6GPRDFDYOOLK4PE 大同市 山西省
|
||||
S4NGXQJDOH7E4IHDWOH3EK6IIE 晋中市 山西省
|
||||
GWDLZXLAWU54FKQ6G3HRQRR7E4 运城市 山西省
|
||||
3FFTTN5PPV7MBCE5AGY2NGYOOI 安康市 陕西省
|
||||
GACVPL3SWO3ZKH73JMJV6YI4NY 延安市 陕西省
|
||||
6KPS7VRMW57P2DAC6OPR4ISHQQ 商洛市 陕西省
|
||||
OMMF6XLNDNYWG5TBSNTWO2ZJZ4 渭南市 陕西省
|
||||
EALFXGMWYRS6E6TWXQ2K3YHV4M 咸阳市 陕西省
|
||||
WFG7U6JNUWDIS5ZZYM4FSM5C64 榆林市 陕西省
|
||||
R3VBMYTCF5LVHO35X3MYJQFOOE 宝鸡市 陕西省
|
||||
RQOWP7C234IS4RKSHB26IYZ5IU 西安市 陕西省
|
||||
K4YU6B4T5GZLRWVHGVCR3576HI 铜川市 陕西省
|
||||
VVFVAPLKSCN5KN4Q6RK2GPGUUA 汉中市 陕西省
|
||||
2QSF6IG3KMDXWO5VP7FXHMMKXA 上海市 上海市
|
||||
X3JCRNIPTCUU6DGOFFJ4MUK37M 眉山市 四川省
|
||||
GQ24IZNTZJ3PUB5FDAMDA4W7UI 攀枝花市 四川省
|
||||
6B6WT62WHBZRHPQUT7BAD2N6ZI 泸州市 四川省
|
||||
HJ35P4KXL442MLIII7PFFWUNAE 雅安市 四川省
|
||||
K4A6VSJH2AJYT46LMUSVQZPCCU 资阳市 四川省
|
||||
646ZNPATOOM3MHI3LDU6HI4KFI 阿坝藏族羌族自治州 四川省
|
||||
MU735ZDBFPXRQDUZ3I35JK3XEU 内江市 四川省
|
||||
4WPGGJ63USY77GSRN2PPFCYKPQ 广安市 四川省
|
||||
O4FFS4DALDAAKIFAUH4F5V5VS4 宜宾市 四川省
|
||||
IRFJVK2KXBE6BZ7CSN4UFCI624 绵阳市 四川省
|
||||
NELFD7FEKKUNDJ46VLD55SMDCE 甘孜藏族自治州 四川省
|
||||
TKMVEUPZSQCXNRZPBEIK3F45AI 遂宁市 四川省
|
||||
VQW7DPB4KTUI65COJBO3NODU24 巴中市 四川省
|
||||
STP4ELXTVGQSB572LFRFJRIUUY 南充市 四川省
|
||||
6ST5EX2JVXUCLR5GP5VEFSKN5M 成都市 四川省
|
||||
UWNFCMW3HYJRALQI2MJH6EM2O4 德阳市 四川省
|
||||
J5ZYU7XRV6CHSJAGOPQKS5YXNA 达州市 四川省
|
||||
KYJTF5S746T35RFBMR65BLGM6U 凉山彝族自治州 四川省
|
||||
RDUXR23XROLB4NGRVDDLXFXDSE 乐山市 四川省
|
||||
4TUBIBHMVESJUCGMTUSLJPHXWI 广元市 四川省
|
||||
AXQL57AO27NCHYMEOLRHAAKMTA 自贡市 四川省
|
||||
4RXX566RZORCXS6HLEX3DIICSM 花莲县 台湾
|
||||
BD5Y7SISWSSQGP3HTVPU6TXAH4 台东县 台湾
|
||||
I4DNWLECRYOJAZLGYQB7PBBJXQ 台中市 台湾
|
||||
GIZQIESFOMAEQSDQKOEQ5RTTPA 南投县 台湾
|
||||
MPM6M2C634FAW7KYG3KIHERDTU 彰化县 台湾
|
||||
NH2NK6JVOBBYYTK2G53ADWLX4Y 苗栗县 台湾
|
||||
DW2Q2R2UEEDNQA7IHBGYV423K4 新竹市 台湾
|
||||
FX5AOPFRPHGHHYZB4XNIPXLNNM 新北市 台湾
|
||||
UVNZNB6G4M35RV3IGRUN6OXMXE 屏东县 台湾
|
||||
MPB3M2YK24ZORO3EDCJ6UDWIGQ 基隆市 台湾
|
||||
EBHVITJPDHJEEMMZTM4TD4UVRU 台北市 台湾
|
||||
FQS4PZNCTQEX6I5F34Z2AGJUZM 高雄市 台湾
|
||||
WVOJ636Q7MGT6RMN6QYQ4SZWIE 嘉义市 台湾
|
||||
QRLER4EEMMKYGQLER2RWEQA74E 台南市 台湾
|
||||
K2LHF64R2P4OJ7MDHJ6J2NSTPE 桃园市 台湾
|
||||
BILG6LJIWUCTXZ6CDPXYAVM6XI 澎湖县 台湾
|
||||
3FYRA3O2HUMLIQAAJQCPX2TETE 宜兰县 台湾
|
||||
4MW6X22PAPVMHB6SBGF3RYS324 天津市 天津市
|
||||
YEYPP4SQOBXU5UCNDN7ORSR6DI 拉萨市 西藏自治区
|
||||
UNE6UPENGWQDOWGABDJEAQ2FEY 山南市 西藏自治区
|
||||
VDXKN2YCIUPOHBZKKQHPN6HJWE 林芝市 西藏自治区
|
||||
EAWIMNI77H72EYSOAYV3M76CB4 阿里地区 西藏自治区
|
||||
HCF6UHTXOOKIOA43AIJH3ARKXA 昌都市 西藏自治区
|
||||
Y47QI3KJY352QV3VOPXHM2IDWU 日喀则市 西藏自治区
|
||||
R4UWJX44GVAA54NFKHT4Y4ZC5A 那曲市 西藏自治区
|
||||
2D37GB5XUALJDXONWJIGXV3QXU 香港 香港特别行政区
|
||||
PA5W7Z255K3EGYA4LTA7BGLHRA 巴音郭楞蒙古自治州 新疆维吾尔自治区
|
||||
RJZOCU5ECQCOLCOCHJH2UNLQJM 哈密市 新疆维吾尔自治区
|
||||
RYK6AR3VDQJFXZLX3MHYFV5VAI 塔城地区 新疆维吾尔自治区
|
||||
MXUVGU5NPVTNINAKKPNLWUQ54Q 博尔塔拉蒙古自治州 新疆维吾尔自治区
|
||||
T5RKMSH5VIGRTHDZOKV2EIKPIM 伊犁哈萨克自治州 新疆维吾尔自治区
|
||||
NABMKFZPOUCZMS4TUVJSZ24DNA 克孜勒苏柯尔克孜自治州 新疆维吾尔自治区
|
||||
2YQBXWNFYVJX4NU6WXB5II6X34 昌吉回族自治州 新疆维吾尔自治区
|
||||
DBKCQCCQU2URQG2EP5ZNPKBETY 乌鲁木齐市 新疆维吾尔自治区
|
||||
DON6KYBCR2XJQOJQGOZTYV4RMM 阿克苏地区 新疆维吾尔自治区
|
||||
WKG47NEVYII5JISZJN7QSI2BDQ 克拉玛依市 新疆维吾尔自治区
|
||||
DWLK6D3OUOXOVQHSEJVTRIDRAI 喀什地区 新疆维吾尔自治区
|
||||
SAPKF2PQGJD4UMVJZTC3IZKI64 阿勒泰地区 新疆维吾尔自治区
|
||||
ARWSLGG54LGUGN3XMIWW76NW34 吐鲁番市 新疆维吾尔自治区
|
||||
VQCWAKL6ADHYFTSVPBGPDFSB2I 北屯市 新疆维吾尔自治区
|
||||
ES5A6MOROAG6F2XAQ25TYYPWUE 铁门关市 新疆维吾尔自治区
|
||||
36IUY52AESIPF4QEQAR2RTNQYA 和田地区 新疆维吾尔自治区
|
||||
Z26KSUL6ULS65ITZNUCRRWBYJM 文山壮族苗族自治州 云南省
|
||||
XBBUUATPBD2IUJR47FCKVUXB2I 昭通市 云南省
|
||||
TK2LP3JCYYMPTV4OA3WJCH7UQ4 怒江傈僳族自治州 云南省
|
||||
ISJ4FESOYKCQ5LXOQO6NL7TQHA 曲靖市 云南省
|
||||
ELBUBVI5UMUIEQGIOAHPMESXFA 西双版纳傣族自治州 云南省
|
||||
QHOYHEGZM4WSJZPFEU6FCBYIWY 玉溪市 云南省
|
||||
4G4SPJ7MVHMYZAWMQ4642PMLVI 保山市 云南省
|
||||
HCHRV2LGJ2TJ4X6BWNWI2IMID4 普洱市 云南省
|
||||
IS4Q6NASBWHO3RFO7UCIWOXVI4 昆明市 云南省
|
||||
TDJZOAZFQUQPYRR5BHOJTG6RWM 红河哈尼族彝族自治州 云南省
|
||||
EIYC62RNU4SHQW3RLAMDPTQYTI 大理白族自治州 云南省
|
||||
BA3XFPITAYKBUWDKU3QONRHBC4 德宏傣族景颇族自治州 云南省
|
||||
6P6FFFO6C5MLNCVRJAJUICSVQI 临沧市 云南省
|
||||
OXHMWH2TSIDI7BQ43EHAMXJ6N4 丽江市 云南省
|
||||
QQPDT4LBI2K2KMBNXZ6YH7X2FI 楚雄彝族自治州 云南省
|
||||
XBG4EJAWCRJL2TDPNJ23PTFYHQ 迪庆藏族自治州 云南省
|
||||
DINNCH54AP74TJ62MICEYAZP74 宁波市 浙江省
|
||||
XYTSLYGB2ETU6HG7GIXA7X5SOE 嘉兴市 浙江省
|
||||
NNAALJZXGAWALR3LGE2V4UZT6U 丽水市 浙江省
|
||||
H5UOJ5MQYJ737GS3TXYN2OJHUU 杭州市 浙江省
|
||||
HG5VQGOMSCEGNXJXKO6XCNCHMY 湖州市 浙江省
|
||||
LJ2SWEPRINTYDH5A2QHRMI5US4 衢州市 浙江省
|
||||
TW4RRM62TDA7WWU77FDSLSAXGY 台州市 浙江省
|
||||
HBBN247QZ6YUW5ZYSS6D7RVJCA 绍兴市 浙江省
|
||||
GVFB23SJZGRPRXXTIKDCAOCDPI 金华市 浙江省
|
||||
UEW4ENX7N7IFGFM7FD5SZ5GI7Y 舟山市 浙江省
|
||||
HCCXS5DGRJQMYZMRLGVAMUIQEA 温州市 浙江省
|
||||
FDGY55I6IHKY76E3MWDBOT2R6Y 重庆市 重庆市
|
||||
@@ -0,0 +1,65 @@
|
||||
"""通过经纬度反查城市(reverse_geocoder 离线库,零网络调用)。
|
||||
|
||||
reverse_geocoder 内置 ~2.5M 条全球城市/聚居点的经纬度→地名映射表,
|
||||
构建一次 KDTree(~几十MB 内存)后,查询为纯内存搜索,不作任何外部网络调用。
|
||||
|
||||
⚠️ 必须持有单例、且用 mode=1(单进程):
|
||||
- reverse_geocoder 的模块级 rg.search()/rg.get() **每次调用都会 new 一个 RGeocoder**,
|
||||
即每次都重新解析 ~2.5M 行 CSV + 重建 KDTree(数秒/次)。绝不能在服务端按请求调用。
|
||||
- 默认 mode=2 用 multiprocessing 按 CPU 数 spawn 子进程做并行查询;在服务端 / Windows
|
||||
spawn 下会重复 import 主模块(无 __main__ guard 时直接报错),既慢又危险。
|
||||
故本模块持有一个 mode=1 的 RGeocoder 单例,建一次树、复用;查询走单进程内存搜索。
|
||||
|
||||
⚠️ 精度说明:gazetteer 里的中国数据粒度不一致——直辖市/省会通常直接命中城市名,
|
||||
但部分城市会命中到区/街道级(如天津→Erwangzhuang、西安→Zhangjiabao),
|
||||
此时 admin1(省级行政区)可作为回退。业务侧建议优先用 admin1 做城市级判定。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import reverse_geocoder as rg # type: ignore[import-untyped]
|
||||
|
||||
# mode=1 单进程 KDTree 的单例;None 表示尚未构建(见 ensure_loaded)。
|
||||
_geocoder: rg.RGeocoder | None = None
|
||||
|
||||
|
||||
def ensure_loaded() -> None:
|
||||
"""构建(或复用)RGeocoder 单例(幂等)。
|
||||
|
||||
首次调用解析 ~2.5M 行 CSV + 构建 KDTree(~秒级、数十 MB)。生产应在 main.py 的
|
||||
lifespan 启动阶段主动调用一次,把这份一次性成本摊到启动,避免砸在第一个
|
||||
/feed?tab=rec / /top-sales 请求上。mode=1 = 单进程,不 spawn 子进程。
|
||||
"""
|
||||
global _geocoder
|
||||
if _geocoder is None:
|
||||
_geocoder = rg.RGeocoder(mode=1, verbose=False)
|
||||
|
||||
|
||||
def get_city(latitude: float, longitude: float) -> dict[str, str]:
|
||||
"""根据经纬度反查最近聚居点。
|
||||
|
||||
返回 dict:
|
||||
- name: 最近聚居点名称(英文),如 "Beijing" / "Fengsheng";
|
||||
中国境内可能是区/街道级;海洋/无人区返 ""
|
||||
- admin1: 省级行政区(英文),如 "Beijing" / "Hubei" / "Chongqing Shi";
|
||||
直辖市 admin1 即为城市名
|
||||
- country: ISO 3166-1 alpha-2,如 "CN"
|
||||
- latitude: 匹配到的参考点纬度(字符串)
|
||||
- longitude: 匹配到的参考点经度(字符串)
|
||||
|
||||
未匹配到(海洋/远洋)时返回空字符串字段。
|
||||
"""
|
||||
ensure_loaded()
|
||||
assert _geocoder is not None # ensure_loaded 保证已构建
|
||||
results: list[dict[str, Any]] = _geocoder.query([(latitude, longitude)])
|
||||
if not results:
|
||||
return {"name": "", "admin1": "", "country": "", "latitude": "", "longitude": ""}
|
||||
r = results[0]
|
||||
return {
|
||||
"name": str(r.get("name", "")),
|
||||
"admin1": str(r.get("admin1", "")),
|
||||
"country": str(r.get("cc", "")),
|
||||
"latitude": str(r.get("lat", "")),
|
||||
"longitude": str(r.get("lon", "")),
|
||||
}
|
||||
@@ -0,0 +1,448 @@
|
||||
"""美团城市词典 + reverse_geocoder 离线反查。
|
||||
|
||||
从 feed 入参的 latitude/longitude 计算出美团城市 ID,
|
||||
用于后续美团 CPS 接口的 cityId 参数。
|
||||
|
||||
⚠️ 跨系统耦合:本模块返回的 city_id 取自 data/city_dict.txt,而离线库
|
||||
`meituan_coupon.city_id` 由 ETL(另一套系统)灌入。二者必须用同一份城市 ID 口径,
|
||||
否则 `WHERE city_id == <本模块结果>` 会静默查到 0 行 → 接口永久降级返空。
|
||||
改动 city_dict.txt 或 ETL 的城市 ID 来源时,务必同步两侧。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
from app.utils.geo import get_city as _get_geo_city
|
||||
|
||||
logger = logging.getLogger("shagua.meituan_city")
|
||||
|
||||
# city_dict.txt 作为运行时数据随包分发(见 pyproject [tool.setuptools.package-data])
|
||||
_CITY_DICT_PATH = Path(__file__).resolve().parent / "data" / "city_dict.txt"
|
||||
|
||||
# ─────────── 反向地理编码 admin1 → 中文省份名 ───────────
|
||||
# reverse_geocoder 的 admin1 格式不统一:
|
||||
# 直辖市: "Beijing" / "Shanghai Shi" / "Tianjin Shi" / "Chongqing Shi"
|
||||
# 省份: "Guangdong" / "Jiangsu Sheng" / "Hubei" ...
|
||||
# 自治区: "Xinjiang Uygur Zizhiqu" / "Tibet Autonomous Region" ...
|
||||
# 下面用前缀匹配,去掉了 Sheng/Shi/Zizhiqu/Autonomous Region 等后缀。
|
||||
|
||||
_PROVINCE_EN_PREFIX: list[tuple[str, str]] = [
|
||||
# 直辖市 — admin1 即城市名
|
||||
("Beijing", "北京市"),
|
||||
("Shanghai", "上海市"),
|
||||
("Tianjin", "天津市"),
|
||||
("Chongqing", "重庆市"),
|
||||
# 省
|
||||
("Hebei", "河北省"),
|
||||
("Shanxi", "山西省"), # 注意: 指山西省,不是陕西
|
||||
("Liaoning", "辽宁省"),
|
||||
("Jilin", "吉林省"),
|
||||
("Heilongjiang", "黑龙江省"),
|
||||
("Jiangsu", "江苏省"),
|
||||
("Zhejiang", "浙江省"),
|
||||
("Anhui", "安徽省"),
|
||||
("Fujian", "福建省"),
|
||||
("Jiangxi", "江西省"),
|
||||
("Shandong", "山东省"),
|
||||
("Henan", "河南省"),
|
||||
("Hubei", "湖北省"),
|
||||
("Hunan", "湖南省"),
|
||||
("Guangdong", "广东省"),
|
||||
("Hainan", "海南省"),
|
||||
("Sichuan", "四川省"),
|
||||
("Guizhou", "贵州省"),
|
||||
("Yunnan", "云南省"),
|
||||
("Shaanxi", "陕西省"), # 双写 a 是官方拼音
|
||||
("Gansu", "甘肃省"),
|
||||
("Qinghai", "青海省"),
|
||||
# 自治区 — 注意匹配顺序, Xinjiang 要在 Guangxi 前面(Guangxi 也是 Xi 开头但先匹配 Xin 不会误判)
|
||||
("Guangxi", "广西壮族自治区"),
|
||||
("Inner Mongolia", "内蒙古自治区"),
|
||||
("Nei Mongol", "内蒙古自治区"),
|
||||
("Tibet", "西藏自治区"),
|
||||
("Xizang", "西藏自治区"),
|
||||
("Ningxia", "宁夏回族自治区"),
|
||||
("Xinjiang", "新疆维吾尔自治区"),
|
||||
# 特别行政区
|
||||
("Hong Kong", "香港特别行政区"),
|
||||
("Macau", "澳门特别行政区"),
|
||||
("Macao", "澳门特别行政区"),
|
||||
# 台湾(city_dict 里省份名为 "台湾",没有省/自治区后缀)
|
||||
("Taiwan", "台湾"),
|
||||
]
|
||||
|
||||
# ─────────── 常见城市名 英文→中文 映射 ───────────
|
||||
# 覆盖所有直辖市 + 省会 + 一线城市 + 部分 reverse_geocoder 只能命中到区/县的城市。
|
||||
# key 全小写,匹配时做小写比较。
|
||||
_CITY_EN_TO_CN: dict[str, str] = {
|
||||
# 直辖市
|
||||
"beijing": "北京市",
|
||||
"shanghai": "上海市",
|
||||
"tianjin": "天津市",
|
||||
"chongqing": "重庆市",
|
||||
# 省会 / 副省级
|
||||
"guangzhou": "广州市",
|
||||
"shenzhen": "深圳市",
|
||||
"chengdu": "成都市",
|
||||
"hangzhou": "杭州市",
|
||||
"wuhan": "武汉市",
|
||||
"xi'an": "西安市",
|
||||
"nanjing": "南京市",
|
||||
"changsha": "长沙市",
|
||||
"zhengzhou": "郑州市",
|
||||
"jinan": "济南市",
|
||||
"kunming": "昆明市",
|
||||
"fuzhou": "福州市",
|
||||
"harbin": "哈尔滨市",
|
||||
"lanzhou": "兰州市",
|
||||
"guiyang": "贵阳市",
|
||||
"nanning": "南宁市",
|
||||
"shijiazhuang": "石家庄市",
|
||||
"taiyuan": "太原市",
|
||||
"shenyang": "沈阳市",
|
||||
"changchun": "长春市",
|
||||
"hefei": "合肥市",
|
||||
"nanchang": "南昌市",
|
||||
"haikou": "海口市",
|
||||
"hohhot": "呼和浩特市",
|
||||
"huhehaote": "呼和浩特市",
|
||||
"urumqi": "乌鲁木齐市",
|
||||
"wulumuqi": "乌鲁木齐市",
|
||||
"lhasa": "拉萨市",
|
||||
"yinchuan": "银川市",
|
||||
"xining": "西宁市",
|
||||
# 其他常见城市
|
||||
"xiamen": "厦门市",
|
||||
"suzhou": "苏州市",
|
||||
"qingdao": "青岛市",
|
||||
"dalian": "大连市",
|
||||
"ningbo": "宁波市",
|
||||
"wuxi": "无锡市",
|
||||
"foshan": "佛山市",
|
||||
"dongguan": "东莞市",
|
||||
"zhuhai": "珠海市",
|
||||
"zhongshan": "中山市",
|
||||
"wenzhou": "温州市",
|
||||
"shaoxing": "绍兴市",
|
||||
"jiaxing": "嘉兴市",
|
||||
"jinhua": "金华市",
|
||||
"taizhou": "台州市",
|
||||
"yangzhou": "扬州市",
|
||||
"nantong": "南通市",
|
||||
"changzhou": "常州市",
|
||||
"xuzhou": "徐州市",
|
||||
"zhengjiang": "镇江市",
|
||||
"yantai": "烟台市",
|
||||
"weifang": "潍坊市",
|
||||
"zibo": "淄博市",
|
||||
"linyi": "临沂市",
|
||||
"weihai": "威海市",
|
||||
"rizhao": "日照市",
|
||||
"luoyang": "洛阳市",
|
||||
"kaifeng": "开封市",
|
||||
"xinxiang": "新乡市",
|
||||
"nanyang": "南阳市",
|
||||
"yichang": "宜昌市",
|
||||
"xiangyang": "襄阳市",
|
||||
"huangshi": "黄石市",
|
||||
"zhuzhou": "株洲市",
|
||||
"xiangtan": "湘潭市",
|
||||
"yueyang": "岳阳市",
|
||||
"hengyang": "衡阳市",
|
||||
"mianyang": "绵阳市",
|
||||
"luzhou": "泸州市",
|
||||
"yibin": "宜宾市",
|
||||
"nanchong": "南充市",
|
||||
"zigong": "自贡市",
|
||||
"qujing": "曲靖市",
|
||||
"yuxi": "玉溪市",
|
||||
"zunyi": "遵义市",
|
||||
"guilin": "桂林市",
|
||||
"liuzhou": "柳州市",
|
||||
"sanya": "三亚市",
|
||||
"tangshan": "唐山市",
|
||||
"baoding": "保定市",
|
||||
"handan": "邯郸市",
|
||||
"qinhuangdao": "秦皇岛市",
|
||||
"langfang": "廊坊市",
|
||||
"datong": "大同市",
|
||||
"changzhi": "长治市",
|
||||
"linfen": "临汾市",
|
||||
"baotou": "包头市",
|
||||
"ordos": "鄂尔多斯市",
|
||||
"eerduosi": "鄂尔多斯市",
|
||||
"daqing": "大庆市",
|
||||
"qiqihar": "齐齐哈尔市",
|
||||
"jilin_city": "吉林市",
|
||||
"anshan": "鞍山市",
|
||||
"fushun": "抚顺市",
|
||||
"benxi": "本溪市",
|
||||
"jinzhou": "锦州市",
|
||||
"yingkou": "营口市",
|
||||
"dandong": "丹东市",
|
||||
"huizhou": "惠州市",
|
||||
"jiangmen": "江门市",
|
||||
"zhanjiang": "湛江市",
|
||||
"maoming": "茂名市",
|
||||
"zhaoqing": "肇庆市",
|
||||
"chaozhou": "潮州市",
|
||||
"shantou": "汕头市",
|
||||
"shaoguan": "韶关市",
|
||||
"meizhou": "梅州市",
|
||||
"jieyang": "揭阳市",
|
||||
"qingyuan": "清远市",
|
||||
"heyuan": "河源市",
|
||||
"yangjiang": "阳江市",
|
||||
"shanwei": "汕尾市",
|
||||
"yunfu": "云浮市",
|
||||
}
|
||||
|
||||
|
||||
# ─────────── 省会映射(城市匹配失败时回退) ───────────
|
||||
# city_dict.txt 内省份的第一个城市不一定是省会,故显式维护。
|
||||
_PROVINCE_CAPITAL: dict[str, str] = {
|
||||
"安徽省": "合肥市",
|
||||
"澳门特别行政区": "澳门",
|
||||
"北京市": "北京市",
|
||||
"福建省": "福州市",
|
||||
"甘肃省": "兰州市",
|
||||
"广东省": "广州市",
|
||||
"广西壮族自治区": "南宁市",
|
||||
"贵州省": "贵阳市",
|
||||
"海南省": "海口市",
|
||||
"河北省": "石家庄市",
|
||||
"河南省": "郑州市",
|
||||
"黑龙江省": "哈尔滨市",
|
||||
"湖北省": "武汉市",
|
||||
"湖南省": "长沙市",
|
||||
"吉林省": "长春市",
|
||||
"江苏省": "南京市",
|
||||
"江西省": "南昌市",
|
||||
"辽宁省": "沈阳市",
|
||||
"内蒙古自治区": "呼和浩特市",
|
||||
"宁夏回族自治区": "银川市",
|
||||
"青海省": "西宁市",
|
||||
"山东省": "济南市",
|
||||
"山西省": "太原市",
|
||||
"陕西省": "西安市",
|
||||
"上海市": "上海市",
|
||||
"四川省": "成都市",
|
||||
"台湾": "台北市",
|
||||
"天津市": "天津市",
|
||||
"西藏自治区": "拉萨市",
|
||||
"香港特别行政区": "香港",
|
||||
"新疆维吾尔自治区": "乌鲁木齐市",
|
||||
"云南省": "昆明市",
|
||||
"浙江省": "杭州市",
|
||||
"重庆市": "重庆市",
|
||||
}
|
||||
|
||||
|
||||
# ─────────── 城市字典加载 ───────────
|
||||
|
||||
def _parse_city_dict(path: str | Path) -> list[dict[str, str]]:
|
||||
"""解析 city_dict.txt,返回 [{city_id, city_name, province_name}, ...]。
|
||||
|
||||
city_dict.txt 格式(TSV):
|
||||
城市ID\t城市名称\t省份名称
|
||||
|
||||
示例行:
|
||||
3NUYJKKJXPHVNZUHFK3HWUDHNM\t宣城市\t安徽省
|
||||
"""
|
||||
data: list[dict[str, str]] = []
|
||||
with open(path, encoding="utf-8") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
parts = line.split("\t")
|
||||
if len(parts) < 3:
|
||||
continue
|
||||
city_id, city_name, province_name = parts[0], parts[1], parts[2]
|
||||
if city_id == "城市ID":
|
||||
continue # 跳过表头
|
||||
if city_id and city_name and province_name:
|
||||
data.append({
|
||||
"city_id": city_id,
|
||||
"city_name": city_name,
|
||||
"province_name": province_name,
|
||||
})
|
||||
return data
|
||||
|
||||
|
||||
# 模块加载时一次解析
|
||||
try:
|
||||
_CITY_DICT: list[dict[str, str]] = _parse_city_dict(_CITY_DICT_PATH)
|
||||
except Exception:
|
||||
logger.exception("加载 city_dict.txt 失败,美团城市反查将不可用")
|
||||
_CITY_DICT = []
|
||||
|
||||
|
||||
def _build_province_index() -> dict[str, list[dict[str, str]]]:
|
||||
"""构建 省份名 → 该省全部城市列表 的索引。"""
|
||||
idx: dict[str, list[dict[str, str]]] = {}
|
||||
for entry in _CITY_DICT:
|
||||
idx.setdefault(entry["province_name"], []).append(entry)
|
||||
return idx
|
||||
|
||||
|
||||
_PROVINCE_INDEX: dict[str, list[dict[str, str]]] | None = None
|
||||
|
||||
|
||||
def _get_province_index() -> dict[str, list[dict[str, str]]]:
|
||||
global _PROVINCE_INDEX
|
||||
if _PROVINCE_INDEX is None:
|
||||
_PROVINCE_INDEX = _build_province_index()
|
||||
return _PROVINCE_INDEX
|
||||
|
||||
|
||||
# ─────────── 查询 ───────────
|
||||
|
||||
def _map_admin1_to_cn_province(admin1: str) -> str:
|
||||
"""将 reverse_geocoder 的 admin1 映射到 city_dict 中的中文省份名。"""
|
||||
if not admin1:
|
||||
return ""
|
||||
normalized = admin1.strip()
|
||||
# 多级匹配:先精确、再前缀
|
||||
for en_prefix, cn_name in _PROVINCE_EN_PREFIX:
|
||||
if normalized == en_prefix or normalized.startswith(en_prefix):
|
||||
return cn_name
|
||||
return ""
|
||||
|
||||
|
||||
def _lookup_city_in_province(city_en_lower: str, province_cn: str) -> str:
|
||||
"""在指定省份内查找匹配的城市名(EN→CN 映射)。"""
|
||||
if not province_cn:
|
||||
return ""
|
||||
index = _get_province_index()
|
||||
candidates = index.get(province_cn, [])
|
||||
if not candidates:
|
||||
return ""
|
||||
|
||||
# 1) 精确映射
|
||||
if city_en_lower in _CITY_EN_TO_CN:
|
||||
cn_city = _CITY_EN_TO_CN[city_en_lower]
|
||||
for c in candidates:
|
||||
if c["city_name"] == cn_city:
|
||||
return cn_city
|
||||
|
||||
# 2) 前缀/包含匹配(处理 admin1 直辖市场景:行政区 → 直辖市本身)
|
||||
for c in candidates:
|
||||
# 去掉"市"后缀比较
|
||||
city_core = c["city_name"].rstrip("市")
|
||||
if city_en_lower.startswith(city_core.lower()) or city_core.lower().startswith(city_en_lower):
|
||||
return c["city_name"]
|
||||
# city_en_lower 可能是拼音,city_core 是中文,尝试从 EN→CN 映射反向匹配
|
||||
for en_k, cn_v in _CITY_EN_TO_CN.items():
|
||||
if cn_v == c["city_name"] and (city_en_lower in en_k or en_k in city_en_lower):
|
||||
return cn_v
|
||||
|
||||
# 3) 匹配不到 → 返回省会
|
||||
capital = _PROVINCE_CAPITAL.get(province_cn, "")
|
||||
if capital:
|
||||
for c in candidates:
|
||||
if c["city_name"] == capital:
|
||||
return capital
|
||||
return candidates[0]["city_name"] # 终极兜底
|
||||
|
||||
|
||||
def _sanitize_city_name(name: str) -> str:
|
||||
"""去除 reverse_geocoder name 中常见的行政后缀使匹配更鲁棒。"""
|
||||
# 去掉 " District" / " Qu" / " Shi" 等英文后缀
|
||||
for suffix in ("District", "Qu", "Shi", "Sheng", "Xian", "Cun", "Zhen", "Xiang",
|
||||
"Zizhiqu", "Autonomous Region", "Special Administrative Region"):
|
||||
name = re.sub(rf"\s+{suffix}$", "", name, flags=re.IGNORECASE)
|
||||
return name.strip()
|
||||
|
||||
|
||||
@lru_cache(maxsize=512)
|
||||
def _resolve_meituan_city(latitude: float, longitude: float) -> dict[str, str]:
|
||||
"""反查实现;入参已量化(见 get_meituan_city),故 lru_cache 命中率高。
|
||||
|
||||
返回的 dict 被缓存复用 —— 调用方勿原地修改(get_meituan_city 已返回副本)。
|
||||
"""
|
||||
if not _CITY_DICT:
|
||||
return {"city_id": "", "city_name": "", "province_name": ""}
|
||||
|
||||
logger.debug("resolve_meituan_city: lat=%.2f lon=%.2f", latitude, longitude)
|
||||
geo = _get_geo_city(latitude, longitude)
|
||||
name_en = _sanitize_city_name(geo.get("name", ""))
|
||||
admin1 = geo.get("admin1", "")
|
||||
country = geo.get("country", "")
|
||||
|
||||
if country != "CN":
|
||||
logger.debug("resolve_meituan_city: 坐标(%.2f,%.2f)不在中国境内(country=%s)", latitude, longitude, country)
|
||||
return {"city_id": "", "city_name": "", "province_name": ""}
|
||||
|
||||
# 1) 映射省份
|
||||
province_cn = _map_admin1_to_cn_province(admin1)
|
||||
if not province_cn:
|
||||
logger.warning("get_meituan_city: admin1=%r 无法映射到中文省份", admin1)
|
||||
return {"city_id": "", "city_name": "", "province_name": ""}
|
||||
|
||||
# 2) 查找城市
|
||||
name_lower = name_en.lower()
|
||||
city_cn = _lookup_city_in_province(name_lower, province_cn)
|
||||
|
||||
# 3) 按省份+城市匹配 city_dict 中的城市 ID
|
||||
index = _get_province_index()
|
||||
candidates = index.get(province_cn, [])
|
||||
for c in candidates:
|
||||
if city_cn and c["city_name"] == city_cn:
|
||||
return {
|
||||
"city_id": c["city_id"],
|
||||
"city_name": c["city_name"],
|
||||
"province_name": province_cn,
|
||||
}
|
||||
|
||||
# 4) 最终回退:返回该省省会
|
||||
if candidates:
|
||||
capital = _PROVINCE_CAPITAL.get(province_cn, "")
|
||||
if capital:
|
||||
for c in candidates:
|
||||
if c["city_name"] == capital:
|
||||
logger.info("get_meituan_city: 城市匹配失败 name_en=%r, 回退到省会 %s", name_en, capital)
|
||||
return {
|
||||
"city_id": c["city_id"],
|
||||
"city_name": capital,
|
||||
"province_name": province_cn,
|
||||
}
|
||||
# 终极兜底:第一个城市
|
||||
fallback = candidates[0]
|
||||
logger.info("get_meituan_city: 城市匹配失败 name_en=%r, 回退到 %s", name_en, fallback["city_name"])
|
||||
return {
|
||||
"city_id": fallback["city_id"],
|
||||
"city_name": fallback["city_name"],
|
||||
"province_name": province_cn,
|
||||
}
|
||||
|
||||
return {"city_id": "", "city_name": "", "province_name": ""}
|
||||
|
||||
|
||||
def get_meituan_city(latitude: float, longitude: float) -> dict[str, str]:
|
||||
"""根据经纬度反查美团城市 ID + 城市名 + 省份名(对外入口)。
|
||||
|
||||
返回:
|
||||
- city_id: 美团城市 ID(如 3NUYJKKJXPHVNZUHFK3HWUDHNM);
|
||||
匹配失败时返回 ""
|
||||
- city_name: 中文城市名(如 "北京市")
|
||||
- province_name: 中文省份名(如 "北京市")
|
||||
|
||||
原理:
|
||||
1. reverse_geocoder 根据经纬度查出英文地名 + 省份
|
||||
2. 英文省份→中文省份映射(前缀匹配)
|
||||
3. 英文地名→中文城市名映射(精确映射 + 省内候选回退)
|
||||
4. 在 city_dict.txt 中按省份+城市名匹配城市 ID
|
||||
|
||||
城市名匹配失败的策略:
|
||||
- 直辖市(京沪津渝): admin1 本身即城市名,直接取
|
||||
- 省会: 回退到该省第一个城市(city_dict.txt 中每个省的省会通常排第一位)
|
||||
|
||||
实现说明:先把坐标量化到 ~1km(round 到 2 位小数)再进 lru_cache —— 原始 GPS 坐标
|
||||
每次抖动到小数点后 5~6 位,直接做缓存 key 几乎不命中;城市级解析对 1km 误差不敏感,
|
||||
量化后"同一地点反复请求"可命中缓存。返回缓存 dict 的副本,调用方可安全读写。
|
||||
"""
|
||||
return dict(_resolve_meituan_city(round(latitude, 2), round(longitude, 2)))
|
||||
@@ -37,6 +37,9 @@ dependencies = [
|
||||
|
||||
# 邀请指纹归因:解析浏览器 UA 拿手机型号(Build.MODEL),跨端匹配用
|
||||
"user-agents>=2.2.0",
|
||||
|
||||
# 离线经纬度→城市反查(美团 CPS 按城市过滤);会带入 numpy/scipy 依赖
|
||||
"reverse_geocoder>=1.5.1",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
@@ -50,6 +53,10 @@ dev = [
|
||||
[tool.setuptools.packages.find]
|
||||
include = ["app*"]
|
||||
|
||||
# 随包分发的运行时数据文件(美团城市词典;被 app/utils/meituan_city.py 加载)
|
||||
[tool.setuptools.package-data]
|
||||
"app.utils" = ["data/*.txt"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
testpaths = ["tests"]
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
"""把 meituan_coupon 的线上采样 TSV 灌进本地 SQLite。
|
||||
|
||||
用途:本地开发/调试时,把线上 `meituan_coupon` 表的采样数据(tests/meituan_coupon_data.tsv)
|
||||
灌进 dev 库(默认 `./data/app.db`),免得每次都实时打美团接口。
|
||||
|
||||
TSV 说明:
|
||||
- 制表符分隔,每行一条记录,列顺序与线上 PostgreSQL 物理列一致
|
||||
(image_size / image_type 是后加的迁移,排在最后两列 —— 与本地 SQLite 一致)。
|
||||
- 空字段 = NULL(文件里没有 `\\N` 标记)。
|
||||
- 个别记录的文本/JSON 字段内含换行,会把一条逻辑行拆成多物理行 —— 按“累计到 26 列”重组。
|
||||
- 文件尾部可能有一条被导出截断的残行(列数不足 / raw JSON 不完整),直接跳过。
|
||||
|
||||
datetime 三列(first_seen/last_seen/updated_at)去掉尾部时区偏移(`+08`),
|
||||
存成 SQLAlchemy 在 SQLite 上用的朴素格式 `YYYY-MM-DD HH:MM:SS.ffffff`,保证 ORM 能读回。
|
||||
|
||||
用法:
|
||||
python scripts/load_meituan_coupon_tsv.py # 默认 TSV + .env 里的库
|
||||
python scripts/load_meituan_coupon_tsv.py path/to.tsv # 指定 TSV
|
||||
DATABASE_URL=sqlite:///./data/app.db python scripts/load_meituan_coupon_tsv.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sqlite3
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
_PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
# 列顺序 = TSV 字段顺序 = 本地 SQLite 物理列顺序
|
||||
COLS = [
|
||||
"id", "source", "platform", "biz_line", "city_id", "product_view_sign",
|
||||
"sku_view_id", "name", "brand_name", "sell_price_cents", "original_price_cents",
|
||||
"head_url", "sale_volume", "sale_volume_num", "commission_percent",
|
||||
"commission_amount_cents", "poi_name", "available_poi_num", "delivery_distance_m",
|
||||
"dedup_key", "raw", "first_seen", "last_seen", "updated_at", "image_size", "image_type",
|
||||
]
|
||||
NCOL = len(COLS)
|
||||
|
||||
# 按列做类型转换(空串 -> None)。未列出的列 = 原样字符串(source/city_id/... 等 NOT NULL 文本)。
|
||||
_INT_COLS = {0, 2, 3, 9, 10, 13, 15, 17, 24} # id, platform, biz_line, prices, ...
|
||||
_FLOAT_COLS = {14, 18} # commission_percent, delivery_distance_m
|
||||
_NULLABLE_STR_COLS = {6, 7, 8, 11, 12, 16, 25} # sku_view_id, name, brand_name, ...
|
||||
_DT_COLS = {21, 22, 23} # first_seen, last_seen, updated_at
|
||||
_RAW_COL = 20
|
||||
|
||||
_TZ_SUFFIX = re.compile(r"[+-]\d{2}(:?\d{2})?$") # 尾部时区偏移 +08 / +08:00 / +0800
|
||||
|
||||
|
||||
def _resolve_sqlite_path() -> Path:
|
||||
"""从 DATABASE_URL(env 或 .env)解析出 SQLite 文件路径。只支持 sqlite://。"""
|
||||
url = os.environ.get("DATABASE_URL", "")
|
||||
if not url:
|
||||
env = _PROJECT_ROOT / ".env"
|
||||
if env.exists():
|
||||
for line in env.read_text(encoding="utf-8").splitlines():
|
||||
if line.strip().startswith("DATABASE_URL="):
|
||||
url = line.split("=", 1)[1].strip()
|
||||
break
|
||||
if not url:
|
||||
url = "sqlite:///./data/app.db"
|
||||
if not url.startswith("sqlite:"):
|
||||
sys.exit(f"仅支持 sqlite:// 库,当前 DATABASE_URL={url!r}")
|
||||
rest = url.split("sqlite:///", 1)[1] if "sqlite:///" in url else url.split("sqlite://", 1)[1]
|
||||
p = Path(rest)
|
||||
if not p.is_absolute():
|
||||
p = (_PROJECT_ROOT / rest).resolve()
|
||||
return p
|
||||
|
||||
|
||||
def _reconstruct_rows(text: str) -> tuple[list[list[str]], int]:
|
||||
"""把文件文本重组成一条条 26 列的逻辑行。返回 (rows, skipped)。
|
||||
|
||||
单个字段内含换行 -> 一条逻辑行被拆成多物理行:累计字段,拆点用 \\n 重新拼回,
|
||||
直到凑满 26 列。列数溢出(内嵌 TAB / 错位)或文件尾残行 -> 跳过并计数。
|
||||
"""
|
||||
lines = text.split("\n")
|
||||
while lines and lines[-1] == "":
|
||||
lines.pop()
|
||||
rows: list[list[str]] = []
|
||||
skipped = 0
|
||||
buf: list[str] = []
|
||||
for raw_line in lines:
|
||||
parts = raw_line.split("\t")
|
||||
if not buf:
|
||||
buf = parts
|
||||
else:
|
||||
buf[-1] += "\n" + parts[0] # 拼回被换行拆开的字段
|
||||
buf.extend(parts[1:])
|
||||
if len(buf) == NCOL:
|
||||
rows.append(buf)
|
||||
buf = []
|
||||
elif len(buf) > NCOL: # 溢出:数据异常,丢弃这段重新开始
|
||||
print(f" [skip] 列数溢出({len(buf)}>{NCOL}),field0={buf[0][:20]!r}")
|
||||
skipped += 1
|
||||
buf = []
|
||||
if buf: # 文件尾被截断的残行
|
||||
print(f" [skip] 尾部残行不足 {NCOL} 列(实 {len(buf)} 列),field0={buf[0][:20]!r}")
|
||||
skipped += 1
|
||||
return rows, skipped
|
||||
|
||||
|
||||
def _convert(row: list[str]) -> tuple | None:
|
||||
"""按列类型转换一行;非法(必填 int 为空 / raw 非 JSON)返回 None。"""
|
||||
out: list = []
|
||||
for i, v in enumerate(row):
|
||||
if i in _DT_COLS:
|
||||
out.append(_TZ_SUFFIX.sub("", v))
|
||||
continue
|
||||
if i == _RAW_COL:
|
||||
try:
|
||||
json.loads(v)
|
||||
except Exception as e:
|
||||
print(f" [skip] id={row[0]} raw 非法 JSON: {e}")
|
||||
return None
|
||||
out.append(v)
|
||||
continue
|
||||
if i in _INT_COLS:
|
||||
out.append(int(v) if v != "" else None)
|
||||
elif i in _FLOAT_COLS:
|
||||
out.append(float(v) if v != "" else None)
|
||||
elif i in _NULLABLE_STR_COLS:
|
||||
out.append(v if v != "" else None)
|
||||
else: # 必填文本列,原样
|
||||
out.append(v)
|
||||
return tuple(out)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
tsv = Path(sys.argv[1]) if len(sys.argv) > 1 else _PROJECT_ROOT / "tests" / "meituan_coupon_data.tsv"
|
||||
if not tsv.is_absolute():
|
||||
tsv = (_PROJECT_ROOT / tsv).resolve()
|
||||
db = _resolve_sqlite_path()
|
||||
print(f"TSV: {tsv}")
|
||||
print(f"DB : {db}")
|
||||
if not tsv.exists():
|
||||
sys.exit(f"TSV 不存在: {tsv}")
|
||||
if not db.exists():
|
||||
sys.exit(f"SQLite 库不存在: {db}(先跑 alembic upgrade head 建表)")
|
||||
|
||||
rows, skipped = _reconstruct_rows(tsv.read_text(encoding="utf-8"))
|
||||
print(f"重组逻辑行: {len(rows)} 跳过(残/异常): {skipped}")
|
||||
|
||||
records = []
|
||||
bad = 0
|
||||
for r in rows:
|
||||
rec = _convert(r)
|
||||
if rec is None:
|
||||
bad += 1
|
||||
continue
|
||||
records.append(rec)
|
||||
print(f"可入库: {len(records)} 转换失败: {bad}")
|
||||
|
||||
placeholders = ",".join(["?"] * NCOL)
|
||||
sql = f"INSERT OR REPLACE INTO meituan_coupon ({','.join(COLS)}) VALUES ({placeholders})"
|
||||
con = sqlite3.connect(str(db))
|
||||
try:
|
||||
before = con.execute("SELECT count(*) FROM meituan_coupon").fetchone()[0]
|
||||
con.executemany(sql, records)
|
||||
con.commit()
|
||||
after = con.execute("SELECT count(*) FROM meituan_coupon").fetchone()[0]
|
||||
finally:
|
||||
con.close()
|
||||
print(f"入库前 {before} 行 -> 入库后 {after} 行(本次 {len(records)} 条)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,189 @@
|
||||
"""给指定用户灌一批 mock 比价记录(开发 / 真机走查用)。
|
||||
|
||||
为什么:首页「上次比价」横幅(4 分钟新鲜窗口)、比价记录页、「我的」省钱战绩卡都读
|
||||
`comparison_record`。dev 库里这个用户没数据时这些 UI 是空的,本脚本灌一批真实感的外卖
|
||||
比价记录,方便端上走查。
|
||||
|
||||
用法(项目根、已 pip install -e . 的环境):
|
||||
python scripts/mock_compare_records.py # 默认 user 昵称后缀 DlSKoc5S7, 10 条
|
||||
python scripts/mock_compare_records.py --user DlSKoc5S7 # 按 昵称后缀/用户名/邀请码/手机号
|
||||
python scripts/mock_compare_records.py --user 5 # 直接按 user.id
|
||||
python scripts/mock_compare_records.py --user 5 --count 6
|
||||
|
||||
要点:
|
||||
- 幂等:trace_id 固定为 mock-<标识>-NN,重跑覆盖同号记录(不会越灌越多)。
|
||||
- 第 1 条 created_at = 跑脚本当下(4 分钟内),其余铺在近 7 天;重跑会刷新时间戳
|
||||
(所以重跑一次即可让「上次比价」横幅重新进入 4 分钟窗口)。
|
||||
- best_* / saved / status 由真实的 repositories.comparison.upsert_record 从 comparison_results 派生。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from sqlalchemy import or_, select
|
||||
|
||||
from app.core.rewards import CN_TZ
|
||||
from app.db.session import SessionLocal
|
||||
from app.models.user import User
|
||||
from app.repositories.comparison import upsert_record
|
||||
from app.schemas.compare_record import ComparisonRecordIn
|
||||
|
||||
# 平台名 → id(展示用;横幅按 platform_name 渲染,id 仅留痕)
|
||||
_PLATFORM_ID = {"美团": "meituan", "京东": "jd", "淘宝闪购": "taobao", "饿了么": "ele"}
|
||||
|
||||
# 10 单真实感外卖订单:source=美团,targets=(平台, 到手价/元, 优惠来源名|None, 红包额/元)。
|
||||
# failed=True 表示目标平台没采到价(targets 价置 None → 派生 status=failed)。
|
||||
_CATALOG = [
|
||||
dict(store="瑞幸咖啡(国贸店)", items=[("生椰拿铁(大杯)", 2)],
|
||||
source=("美团", 39.8),
|
||||
targets=[("淘宝闪购", 29.9, "平台红包", 6.0), ("京东", 33.0, None, 0)]),
|
||||
dict(store="茶百道(大悦城店)", items=[("杨梅冰萃(大杯)", 1), ("脆波波奶茶", 1)],
|
||||
source=("美团", 31.0),
|
||||
targets=[("京东", 24.5, "百亿补贴", 4.5), ("饿了么", 27.9, None, 0)]),
|
||||
dict(store="麦当劳(西单店)", items=[("板烧鸡腿堡套餐", 1)],
|
||||
source=("美团", 32.0),
|
||||
targets=[("京东", 25.9, "神券", 3.0), ("淘宝闪购", 28.0, None, 0)]),
|
||||
dict(store="肯德基(朝阳大悦城店)", items=[("疯狂星期四全家桶", 1)],
|
||||
source=("美团", 89.0),
|
||||
targets=[("京东", 79.9, "百亿补贴", 9.0)]),
|
||||
dict(store="张亮麻辣烫(双井店)", items=[("自选麻辣烫", 1)],
|
||||
source=("美团", 35.0),
|
||||
targets=[("京东", None, None, 0), ("淘宝闪购", None, None, 0)],
|
||||
failed=True, fail_reason="京东外卖、淘宝闪购均未找到该店"),
|
||||
dict(store="必胜客(王府井店)", items=[("超级至尊比萨(9寸)", 1), ("香辣鸡翅", 1)],
|
||||
source=("美团", 78.0),
|
||||
targets=[("淘宝闪购", 62.0, "跨店满减", 10.0), ("京东", 69.0, None, 0)]),
|
||||
dict(store="蜜雪冰城(中关村店)", items=[("多肉葡萄", 2), ("冰鲜柠檬水", 1)],
|
||||
source=("美团", 21.0),
|
||||
targets=[("京东", 16.5, None, 0)]),
|
||||
dict(store="海底捞外送(三里屯店)", items=[("番茄锅底", 1), ("鲜毛肚", 2), ("虾滑", 1)],
|
||||
source=("美团", 168.0),
|
||||
targets=[("京东", 155.0, "大额神券", 13.0), ("饿了么", 162.0, None, 0)]),
|
||||
dict(store="华莱士(回龙观店)", items=[("全鸡汉堡套餐", 1)],
|
||||
source=("美团", 26.0),
|
||||
targets=[("京东", None, None, 0)],
|
||||
failed=True, fail_reason="该商品在京东外卖未上架"),
|
||||
dict(store="星巴克(国贸店)", items=[("燕麦拿铁(大杯)", 2)],
|
||||
source=("美团", 66.0),
|
||||
targets=[("饿了么", 58.0, "会员券", 8.0), ("京东", 61.0, None, 0)]),
|
||||
]
|
||||
|
||||
# 第 2..N 条的 created_at 相对当下的回退量(都 > 4 分钟,确保只有第 1 条落在新鲜窗口)。
|
||||
_OFFSETS = [
|
||||
timedelta(minutes=25), timedelta(hours=2, minutes=10), timedelta(hours=6),
|
||||
timedelta(days=1, hours=3), timedelta(days=1, hours=20), timedelta(days=2, hours=9),
|
||||
timedelta(days=3, hours=14), timedelta(days=5, hours=7), timedelta(days=7, hours=2),
|
||||
]
|
||||
|
||||
|
||||
def _resolve_user(db, ident: str) -> User | None:
|
||||
"""按 id(纯数字)/ 用户名 / 邀请码 / 手机号 / 昵称(或昵称去掉「用户」前缀的后缀)解析用户。"""
|
||||
if ident.isdigit():
|
||||
u = db.get(User, int(ident))
|
||||
if u is not None:
|
||||
return u
|
||||
return db.execute(
|
||||
select(User).where(
|
||||
or_(
|
||||
User.username == ident,
|
||||
User.invite_code == ident,
|
||||
User.phone == ident,
|
||||
User.nickname == ident,
|
||||
User.nickname == f"用户{ident}",
|
||||
User.nickname.like(f"%{ident}"),
|
||||
)
|
||||
)
|
||||
).scalars().first()
|
||||
|
||||
|
||||
def _build_results(order: dict) -> tuple[list[dict], str]:
|
||||
"""造 comparison_results + information 文案;返回 (results, information)。"""
|
||||
src_name, src_price = order["source"]
|
||||
failed = order.get("failed", False)
|
||||
rows: list[dict] = [dict(
|
||||
platform_id=_PLATFORM_ID.get(src_name), platform_name=src_name,
|
||||
price=src_price, is_source=True, status="success",
|
||||
)]
|
||||
for pname, price, cname, csaved in order["targets"]:
|
||||
row = dict(
|
||||
platform_id=_PLATFORM_ID.get(pname), platform_name=pname,
|
||||
price=(None if failed else price), is_source=False,
|
||||
status=("store_not_found" if failed else "success"),
|
||||
)
|
||||
if not failed and csaved:
|
||||
row["coupon_saved"] = csaved
|
||||
row["coupon_name"] = cname
|
||||
rows.append(row)
|
||||
|
||||
# rank:有价的按升序 1..N;无价的不排
|
||||
priced = sorted((r for r in rows if r["price"] is not None), key=lambda r: r["price"])
|
||||
for i, r in enumerate(priced, start=1):
|
||||
r["rank"] = i
|
||||
|
||||
if failed:
|
||||
return rows, order.get("fail_reason", "目标平台未找到该商品")
|
||||
best = priced[0]
|
||||
saved = src_price - best["price"]
|
||||
if best["is_source"]:
|
||||
info = f"{src_name}已是最低价,本单未比出更低"
|
||||
else:
|
||||
info = f"已在{best['platform_name']}比出最低价 ¥{best['price']:.2f}(比{src_name}省 ¥{saved:.2f})"
|
||||
return rows, info
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="给用户灌一批 mock 比价记录")
|
||||
parser.add_argument("--user", default="DlSKoc5S7", help="user.id / 用户名 / 邀请码 / 手机号 / 昵称后缀")
|
||||
parser.add_argument("--count", type=int, default=10, help="条数(1..10),默认 10")
|
||||
args = parser.parse_args()
|
||||
|
||||
count = max(1, min(args.count, len(_CATALOG)))
|
||||
now = datetime.now(CN_TZ).replace(tzinfo=None)
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
user = _resolve_user(db, args.user)
|
||||
if user is None:
|
||||
raise SystemExit(f"❌ 找不到用户:{args.user}(试试 --user <id>)")
|
||||
print(f"→ 用户 id={user.id} nickname={user.nickname} phone={user.phone}")
|
||||
|
||||
ok = failed = 0
|
||||
for idx in range(count):
|
||||
order = _CATALOG[idx]
|
||||
src_name, src_price = order["source"]
|
||||
results, info = _build_results(order)
|
||||
payload = ComparisonRecordIn(
|
||||
trace_id=f"mock-{args.user}-{idx:02d}",
|
||||
business_type="food",
|
||||
device_id="mock-seed",
|
||||
store_name=order["store"],
|
||||
source_platform_id=_PLATFORM_ID.get(src_name),
|
||||
source_platform_name=src_name,
|
||||
source_price=src_price,
|
||||
items=[{"name": n, "qty": q} for n, q in order["items"]],
|
||||
comparison_results=results,
|
||||
total_dish_count=sum(q for _, q in order["items"]),
|
||||
information=info,
|
||||
app_version="mock",
|
||||
)
|
||||
rec = upsert_record(db, user_id=user.id, payload=payload)
|
||||
# created_at:第 1 条 = 当下(4 分钟新鲜窗口);其余铺近 7 天。重跑刷新时间戳。
|
||||
rec.created_at = now if idx == 0 else now - _OFFSETS[idx - 1]
|
||||
db.commit()
|
||||
if rec.status == "success":
|
||||
ok += 1
|
||||
else:
|
||||
failed += 1
|
||||
tag = "🆕now" if idx == 0 else f"-{_OFFSETS[idx - 1]}"
|
||||
print(f" [{rec.status:7}] id={rec.id} {order['store']} best={rec.best_platform_name} "
|
||||
f"¥{(rec.best_price_cents or 0)/100:.2f} saved=¥{(rec.saved_amount_cents or 0)/100:.2f} {tag}")
|
||||
|
||||
print(f"✅ 已为 user id={user.id} 写入 {count} 条(success {ok} / failed {failed});"
|
||||
f"第 1 条 created_at=当下,4 分钟内进 app 可见「上次比价」横幅。")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,137 @@
|
||||
"""reverse_geocoder 经纬度→城市 测试。
|
||||
|
||||
验证离线库对国内主要城市的匹配准确性。注意:gazetteer 的中国数据粒度不一致——
|
||||
直辖市/省会通常直接命中城市名,部分城市可能命中到区/街道级(如天津→Erwangzhuang、
|
||||
西安→Zhangjiabao),此时 admin1 为省级行政区。测试以 admin1(省级)匹配为主。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from app.utils.geo import get_city
|
||||
|
||||
# ─────────────── 国内主要城市 ───────────────
|
||||
# (城市, 纬度, 经度, 期望 admin1 包含字串)
|
||||
_CITY_CASES = [
|
||||
# 直辖市 — admin1 即城市名(可能带 Shi 后缀)
|
||||
("北京", 39.9042, 116.4074, "Beijing"),
|
||||
("上海", 31.2304, 121.4737, "Shanghai"),
|
||||
("重庆", 29.4316, 106.9123, "Chongqing"),
|
||||
("天津", 39.3434, 117.3616, "Tianjin"),
|
||||
# 省会 / 一线 — admin1 为省份
|
||||
("广州", 23.1291, 113.2644, "Guangdong"),
|
||||
("深圳", 22.5431, 114.0579, "Guangdong"),
|
||||
("成都", 30.5728, 104.0668, "Sichuan"),
|
||||
("杭州", 30.2741, 120.1551, "Zhejiang"),
|
||||
("武汉", 30.5928, 114.3055, "Hubei"),
|
||||
("西安", 34.3416, 108.9398, "Shaanxi"),
|
||||
("南京", 32.0603, 118.7969, "Jiangsu"),
|
||||
("长沙", 28.2282, 112.9388, "Hunan"),
|
||||
("郑州", 34.7466, 113.6253, "Henan"),
|
||||
("济南", 36.6512, 116.9946, "Shandong"),
|
||||
("昆明", 25.0389, 102.7183, "Yunnan"),
|
||||
("福州", 26.0745, 119.2965, "Fujian"),
|
||||
("哈尔滨", 45.8038, 126.5350, "Heilongjiang"),
|
||||
("乌鲁木齐", 43.8256, 87.6168, "Xinjiang"),
|
||||
("拉萨", 29.6500, 91.1000, "Tibet"),
|
||||
# 非省会
|
||||
("厦门", 24.4798, 118.0894, "Fujian"),
|
||||
("苏州", 31.2990, 120.5853, "Jiangsu"),
|
||||
("青岛", 36.0671, 120.3826, "Shandong"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("label,lat,lon,expected_admin1", _CITY_CASES)
|
||||
def test_city_admin1_match(label: str, lat: float, lon: float, expected_admin1: str) -> None:
|
||||
"""所有城市经纬度应能匹配到正确的省级行政区 (admin1)。"""
|
||||
r = get_city(lat, lon)
|
||||
assert r["name"] != "", f"{label}: name should not be empty"
|
||||
assert r["country"] == "CN", f"{label}: expected country=CN, got={r['country']}"
|
||||
assert expected_admin1 in r["admin1"], \
|
||||
f"{label}: expected admin1 to contain '{expected_admin1}', got={r['admin1']!r}"
|
||||
|
||||
|
||||
# ─────────────── 直辖市 / 省会直接命中城市名 ───────────────
|
||||
# 这些城市在 gazetteer 中的坐标恰好命中城市级条目(而非区/街道级),
|
||||
# 验证 name 字段也正确。
|
||||
_DIRECT_HIT_CASES = [
|
||||
("北京", 39.9042, 116.4074, "Beijing"),
|
||||
("上海", 31.2304, 121.4737, "Shanghai"),
|
||||
("广州", 23.1291, 113.2644, "Guangzhou"),
|
||||
("深圳", 22.5431, 114.0579, "Shenzhen"),
|
||||
("成都", 30.5728, 104.0668, "Chengdu"),
|
||||
("杭州", 30.2741, 120.1551, "Hangzhou"),
|
||||
("郑州", 34.7466, 113.6253, "Zhengzhou"),
|
||||
("济南", 36.6512, 116.9946, "Jinan"),
|
||||
("昆明", 25.0389, 102.7183, "Kunming"),
|
||||
("哈尔滨", 45.8038, 126.5350, "Harbin"),
|
||||
("厦门", 24.4798, 118.0894, "Xiamen"),
|
||||
("苏州", 31.2990, 120.5853, "Suzhou"),
|
||||
("青岛", 36.0671, 120.3826, "Qingdao"),
|
||||
("拉萨", 29.6500, 91.1000, "Lhasa"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("label,lat,lon,expected_name", _DIRECT_HIT_CASES)
|
||||
def test_city_name_direct_hit(label: str, lat: float, lon: float, expected_name: str) -> None:
|
||||
"""直辖市/省会等主要城市坐标应直接命中城市名(而非区/街道级)。"""
|
||||
r = get_city(lat, lon)
|
||||
assert r["name"] == expected_name, \
|
||||
f"{label}: expected name={expected_name}, got={r['name']!r}"
|
||||
|
||||
|
||||
# ─────────────── 边界情况 ───────────────
|
||||
|
||||
def test_ocean_not_china() -> None:
|
||||
"""远洋坐标不应误判为国内城市。"""
|
||||
# 太平洋中部 → 可能匹配到最近有人岛(如法属波利尼西亚 Taiohae),但绝不应是 CN
|
||||
r = get_city(0.0, -140.0)
|
||||
assert r["country"] != "CN", f"mid-Pacific should not be CN, got {r}"
|
||||
|
||||
# 南大西洋
|
||||
r2 = get_city(-30.0, -20.0)
|
||||
assert r2["country"] != "CN", f"South Atlantic should not be CN, got {r2}"
|
||||
|
||||
|
||||
def test_return_keys_and_types() -> None:
|
||||
"""返回 dict 应包含全部五个字段且类型为 str。"""
|
||||
r = get_city(39.9042, 116.4074)
|
||||
for key in ("name", "admin1", "country", "latitude", "longitude"):
|
||||
assert key in r, f"missing key: {key}"
|
||||
assert isinstance(r[key], str), f"key {key} should be str, got {type(r[key])}"
|
||||
|
||||
|
||||
def test_empty_result_keys() -> None:
|
||||
"""结果始终应包含完整字段且全为 str 类型(即使匹配到偏远地)。"""
|
||||
# reverse_geocoder KDTree 总找最近聚居点;业务侧如需判定"是否有效城市"
|
||||
# 应自行按 country / admin1 做二次校验,而非依赖空字符串。
|
||||
r = get_city(0.0, -140.0)
|
||||
assert r["name"] != ""
|
||||
assert isinstance(r["name"], str)
|
||||
assert isinstance(r["admin1"], str)
|
||||
assert isinstance(r["country"], str)
|
||||
assert isinstance(r["latitude"], str)
|
||||
assert isinstance(r["longitude"], str)
|
||||
|
||||
|
||||
def test_same_coords_consistent() -> None:
|
||||
"""同一坐标两次查询应返回相同结果(幂等)。"""
|
||||
r1 = get_city(31.2304, 121.4737)
|
||||
r2 = get_city(31.2304, 121.4737)
|
||||
assert r1 == r2
|
||||
|
||||
|
||||
def test_near_border_has_result() -> None:
|
||||
"""省界附近的坐标应返回结果(非空 + 国内)。"""
|
||||
# 苏鲁豫皖交界区域(徐州/商丘/宿州附近)
|
||||
r = get_city(34.2, 116.8)
|
||||
assert r["name"] != "", "border region should find a nearby populated place"
|
||||
assert r["country"] == "CN"
|
||||
|
||||
|
||||
def test_extreme_lat_lon_no_crash() -> None:
|
||||
"""极值经纬度不应抛异常。"""
|
||||
r1 = get_city(90.0, 0.0) # 北极
|
||||
r2 = get_city(-90.0, 0.0) # 南极
|
||||
assert isinstance(r1, dict)
|
||||
assert isinstance(r2, dict)
|
||||
@@ -0,0 +1,64 @@
|
||||
"""app.utils.meituan_city.get_meituan_city 经纬度→美团城市 反查测试。
|
||||
|
||||
覆盖 get_meituan_city 的分支(test_geo.py 只覆盖底层 get_city):
|
||||
- 主要城市 → 正确省份 + 非空 city_id(+ 直辖市/省会命中中文城市名)
|
||||
- 非中国境内坐标 → city_id 为空(接口据此降级返空)
|
||||
- 返回 dict 结构 / 类型
|
||||
- 坐标量化(~1km)后近点命中同一缓存结果
|
||||
- 返回的是缓存副本(调用方原地修改不污染缓存)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from app.utils.meituan_city import get_meituan_city
|
||||
|
||||
# (标签, 纬度, 经度, 期望省份名, 期望城市名)
|
||||
_CASES = [
|
||||
("北京", 39.9042, 116.4074, "北京市", "北京市"),
|
||||
("上海", 31.2304, 121.4737, "上海市", "上海市"),
|
||||
("广州", 23.1291, 113.2644, "广东省", "广州市"),
|
||||
("深圳", 22.5431, 114.0579, "广东省", "深圳市"),
|
||||
("成都", 30.5728, 104.0668, "四川省", "成都市"),
|
||||
("杭州", 30.2741, 120.1551, "浙江省", "杭州市"),
|
||||
("武汉", 30.5928, 114.3055, "湖北省", "武汉市"),
|
||||
("郑州", 34.7466, 113.6253, "河南省", "郑州市"),
|
||||
("厦门", 24.4798, 118.0894, "福建省", "厦门市"),
|
||||
("青岛", 36.0671, 120.3826, "山东省", "青岛市"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("label,lat,lon,exp_province,exp_city", _CASES)
|
||||
def test_major_city_resolves(label: str, lat: float, lon: float,
|
||||
exp_province: str, exp_city: str) -> None:
|
||||
r = get_meituan_city(lat, lon)
|
||||
assert r["province_name"] == exp_province, f"{label}: province {r!r}"
|
||||
assert r["city_name"] == exp_city, f"{label}: city {r!r}"
|
||||
assert r["city_id"], f"{label}: city_id 不应为空 {r!r}"
|
||||
|
||||
|
||||
def test_non_china_returns_empty_city_id() -> None:
|
||||
"""境外/远洋坐标 → city_id 空(接口据此返回 degraded 空列表)。"""
|
||||
r = get_meituan_city(0.0, -140.0) # 太平洋中部
|
||||
assert r["city_id"] == "", f"境外不应给出 city_id: {r!r}"
|
||||
|
||||
|
||||
def test_return_shape_and_types() -> None:
|
||||
r = get_meituan_city(39.9042, 116.4074)
|
||||
for key in ("city_id", "city_name", "province_name"):
|
||||
assert key in r and isinstance(r[key], str)
|
||||
|
||||
|
||||
def test_quantized_coords_hit_same_result() -> None:
|
||||
"""相距 <1km(round 到 2 位小数后相同)的两点应解析出同一城市。"""
|
||||
a = get_meituan_city(39.9042, 116.4074)
|
||||
b = get_meituan_city(39.9031, 116.4066) # round 后同为 (39.90, 116.41)
|
||||
assert a == b
|
||||
|
||||
|
||||
def test_result_is_defensive_copy() -> None:
|
||||
"""返回的是缓存副本:原地修改不应污染后续查询。"""
|
||||
first = get_meituan_city(31.2304, 121.4737)
|
||||
first["city_id"] = "TAMPERED"
|
||||
second = get_meituan_city(31.2304, 121.4737)
|
||||
assert second["city_id"] != "TAMPERED"
|
||||
Reference in New Issue
Block a user