diff --git a/.gitignore b/.gitignore index 4c87d95..fa98e0e 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/app/api/v1/meituan.py b/app/api/v1/meituan.py index 5bb0031..c1d07a1 100644 --- a/app/api/v1/meituan.py +++ b/app/api/v1/meituan.py @@ -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") diff --git a/app/main.py b/app/main.py index 6aded37..3b3b6ff 100644 --- a/app/main.py +++ b/app/main.py @@ -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() diff --git a/app/schemas/meituan.py b/app/schemas/meituan.py index b40f3ed..a15e4c8 100644 --- a/app/schemas/meituan.py +++ b/app/schemas/meituan.py @@ -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只到店; 不填=全部(同城销量)") # ───────────────── 换链 请求 / 响应 ───────────────── diff --git a/app/utils/__init__.py b/app/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/utils/data/city_dict.txt b/app/utils/data/city_dict.txt new file mode 100644 index 0000000..e8b0958 --- /dev/null +++ b/app/utils/data/city_dict.txt @@ -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 重庆市 重庆市 \ No newline at end of file diff --git a/app/utils/geo.py b/app/utils/geo.py new file mode 100644 index 0000000..03e4577 --- /dev/null +++ b/app/utils/geo.py @@ -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", "")), + } diff --git a/app/utils/meituan_city.py b/app/utils/meituan_city.py new file mode 100644 index 0000000..22d0e0f --- /dev/null +++ b/app/utils/meituan_city.py @@ -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))) diff --git a/pyproject.toml b/pyproject.toml index aa4ba63..1c4c03a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"] diff --git a/scripts/load_meituan_coupon_tsv.py b/scripts/load_meituan_coupon_tsv.py new file mode 100644 index 0000000..70e624c --- /dev/null +++ b/scripts/load_meituan_coupon_tsv.py @@ -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() diff --git a/tests/test_geo.py b/tests/test_geo.py new file mode 100644 index 0000000..98b70c4 --- /dev/null +++ b/tests/test_geo.py @@ -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) diff --git a/tests/test_meituan_city.py b/tests/test_meituan_city.py new file mode 100644 index 0000000..c6928ad --- /dev/null +++ b/tests/test_meituan_city.py @@ -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"