Переглянути джерело

feat(visa): add protected HTTP endpoint for visa free_config fetching

jerry 1 тиждень тому
батько
коміт
88d3cb4ab8

+ 31 - 1
app/api/router.py

@@ -58,6 +58,8 @@ from app.services.configuration_service import ConfigurationService
 from app.services.visametric_service import VisametricService
 from app.services.sms_service import save_short_message, query_short_message, send_sms
 from app.services.email_authorizations_service import EmailAuthorizationService
+from app.schemas.visa_free_config import VisaFreeConfigFetchRequest
+from app.services.visa_free_config_service import fetch_visa_free_config
 from app.services.emails_service import EmailsService
 from app.services.short_url_service import ShortUrlService
 from app.services.task_service import TaskService
@@ -1372,4 +1374,32 @@ async def get_ticket_messages(
         page=page,
         size=size
     )
-    return success(data=msgs)
+    return success(data=msgs)
+
+@protected_router.post("/visa/free-config/fetch", summary="获取签证中心 free_config 配置", tags=["Visa Config"], response_model=ApiResponse[Dict[str, Any]])
+async def fetch_visa_free_config_post(payload: VisaFreeConfigFetchRequest = Body(...)):
+    res = fetch_visa_free_config(
+        provider=payload.provider,
+        origin=payload.origin,
+        target=payload.target,
+        visa_type=payload.visa_type,
+        vac=payload.vac
+    )
+    return success(data=res)
+
+@protected_router.get("/visa/free-config/fetch", summary="获取签证中心 free_config 配置(GET)", tags=["Visa Config"], response_model=ApiResponse[Dict[str, Any]])
+async def fetch_visa_free_config_get(
+    provider: str = Query(...),
+    origin: str = Query(...),
+    target: str = Query(...),
+    visa_type: str = Query(...),
+    vac: Optional[str] = Query(None)
+):
+    res = fetch_visa_free_config(
+        provider=provider,
+        origin=origin,
+        target=target,
+        visa_type=visa_type,
+        vac=vac
+    )
+    return success(data=res)

+ 4 - 0
app/metadata/bel_meta.json

@@ -0,0 +1,4 @@
+{
+  "provider": "bel",
+  "missions": {}
+}

+ 33 - 0
app/metadata/bls_meta.json

@@ -0,0 +1,33 @@
+{
+  "provider": "bls",
+  "missions": {
+    "ie_es": {
+      "domain": "ireland.blsspainglobal.com",
+      "ocr_model": "data/ctc.pth"
+    },
+    "gb_es": {
+      "domain": "uk.blsspainglobal.com",
+      "ocr_model": "data/ocr.pth"
+    }
+  },
+  "vacs": {
+    "ie_es_dub_tourist": {
+      "location": "Dublin",
+      "jurisdiction": null,
+      "visa_type": "Schengen Visa/ Short Term Visa",
+      "visa_subtype": "Tourist Visa",
+      "appointment_type": "Individual",
+      "appointment_category": "Normal",
+      "mission_code": "EMBASSY_DUBLIN"
+    },
+    "gb_es_lon_tourist": {
+      "location": "Dublin",
+      "jurisdiction": "Greater London",
+      "visa_type": "Short Term Visa(Maximum stay of 90 days)",
+      "visa_subtype": "Tourist Visa",
+      "appointment_type": "Individual",
+      "appointment_category": "Normal",
+      "mission_code": "LHR"
+    }
+  }
+}

+ 9 - 0
app/metadata/de_meta.json

@@ -0,0 +1,9 @@
+{
+  "provider": "de",
+  "missions": {
+    "ie_de": {
+      "base_url": "https://ie-appointment.visametric.com",
+      "consularid": 1
+    }
+  }
+}

+ 4 - 0
app/metadata/grc_meta.json

@@ -0,0 +1,4 @@
+{
+  "provider": "grc",
+  "missions": {}
+}

+ 4 - 0
app/metadata/ita_meta.json

@@ -0,0 +1,4 @@
+{
+  "provider": "ita",
+  "missions": {}
+}

+ 15 - 0
app/metadata/pol_meta.json

@@ -0,0 +1,15 @@
+{
+  "provider": "pol",
+  "missions": {
+    "ie_pl": {
+      "query_url": "https://secure.e-konsulat.gov.pl/placowki/151/wiza-schengen/wizyty/weryfikacja-obrazkowa",
+      "service_type": "Wiza Schengen",
+      "location": "Dublin"
+    },
+    "jp_pl": {
+      "query_url": "https://secure.e-konsulat.gov.pl/placowki/178/wiza-krajowa/wizyty/weryfikacja-obrazkowa",
+      "service_type": "wiza krajowa",
+      "location": "Tokio"
+    }
+  }
+}

+ 25 - 0
app/metadata/tls_meta.json

@@ -0,0 +1,25 @@
+{
+  "provider": "tls",
+  "missions": {
+    "gb_fr": {
+      "tls_url": "https://visas-fr.tlscontact.com/en-us/country/gb/vac/gbLON2fr",
+      "location": "London",
+      "site_id": "gbLON2fr"
+    },
+    "cn_bjs": {
+      "tls_url": "https://visas-fr.tlscontact.com/en-us/country/cn/vac/cnBJS2fr",
+      "location": "Beijing",
+      "site_id": "cnBJS2fr"
+    },
+    "cn_sha": {
+      "tls_url": "https://visas-fr.tlscontact.com/en-us/country/cn/vac/cnSHA2fr",
+      "location": "Shanghai",
+      "site_id": "cnSHA2fr"
+    },
+    "ie_fr": {
+      "tls_url": "https://visas-fr.tlscontact.com/en-us/country/ie/vac/ieDUB2fr",
+      "location": "Dublin",
+      "site_id": "ieDUB2fr"
+    }
+  }
+}

+ 9 - 0
app/metadata/usa_meta.json

@@ -0,0 +1,9 @@
+{
+  "provider": "usa",
+  "missions": {
+    "cn_us": {
+      "usa_url": "https://www.usvisascheduling.com",
+      "location": "BEIJING"
+    }
+  }
+}

+ 165 - 0
app/metadata/vfs_meta.json

@@ -0,0 +1,165 @@
+{
+  "provider": "vfs",
+  "vacs": {
+    "ie_nl_dub_tourist": {
+      "routing_key": "slot.dub.nl.tourist",
+      "vac_code": "NTDB",
+      "center_name": "Netherlands Visa Application Center - Dublin",
+      "address": "Cunningham House, 130 Francis Street, Dublin 8  D08 H48R",
+      "category_code": "TA",
+      "category_name": "All Short stay Categories",
+      "subcategory_code": "To",
+      "subcategory_name": "Tourist"
+    },
+    "sg_fr_sin_tourist": {
+      "routing_key": "slot.sin.fr.tourist",
+      "vac_code": "FRSN",
+      "center_name": "France Visa Application Center, Singapore",
+      "address": "79 Anson Road #15-01 Singapore 079906",
+      "category_code": "02",
+      "category_name": "Short Stay",
+      "subcategory_code": "Six",
+      "subcategory_name": "Short Stay Tourist, Family Visit"
+    },
+    "au_fr_syd_tourist": {
+      "routing_key": "slot.syd.fr.tourist",
+      "vac_code": "SYD",
+      "center_name": "France Visa Application Center - Sydney",
+      "address": "France Visa Application Center,Level 6, 88 Pitt Street,Sydney NSW 2000",
+      "category_code": "VISA",
+      "category_name": "VISA",
+      "subcategory_code": "ShortStaySchengenVisa",
+      "subcategory_name": "Short Stay Schengen Visa"
+    },
+    "au_fr_mel_tourist": {
+      "routing_key": "slot.mel.fr.tourist",
+      "vac_code": "MEL",
+      "center_name": "France Visa Application Center - Melbourne",
+      "address": "Level 5 332 St. Kilda road level 5 Melbourne 3004",
+      "category_code": "VISA",
+      "category_name": "VISA",
+      "subcategory_code": "ShortStaySchengenVisa",
+      "subcategory_name": "Short Stay Schengen Visa"
+    },
+    "gb_it_lon_tourist": {
+      "routing_key": "slot.lon.it.tourist",
+      "vac_code": "ILON",
+      "center_name": "Italy Visa Application Centre, London",
+      "address": "Ground floor, 8- 20  Pocock St London SE1 0BW , United Kingdom",
+      "category_code": "UKITVED",
+      "category_name": "Italy UK VisaCategory",
+      "subcategory_code": "TBE",
+      "subcategory_name": "Tourist/ Business/ EU Family"
+    },
+    "gb_it_man_tourist": {
+      "routing_key": "slot.man.it.tourist",
+      "vac_code": "IMAN",
+      "center_name": "Italy Visa Application Centre, Manchester",
+      "address": "50 Devonshire Street North, M12 6JH",
+      "category_code": "UKITVED",
+      "category_name": "Italy UK VisaCategory",
+      "subcategory_code": "TBE",
+      "subcategory_name": "Tourist/ Business/ EU Family"
+    },
+    "gb_nl_lon_tourist": {
+      "routing_key": "slot.lon.nl.tourist",
+      "vac_code": "NAKN",
+      "center_name": "Netherlands Visa application centre - London",
+      "address": "66 Wilson Street, EC2A 2BT",
+      "category_code": "Schengen Visa",
+      "category_name": "Schengen Visa",
+      "subcategory_code": "TA",
+      "subcategory_name": "Tourism"
+    },
+    "gb_nl_man_tourist": {
+      "routing_key": "slot.man.nl.tourist",
+      "vac_code": "NAKT",
+      "center_name": "Netherlands Visa application centre - Manchester",
+      "address": "50 Devonshire Street North, M12 6JH",
+      "category_code": "Schengen Visa",
+      "category_name": "Schengen Visa",
+      "subcategory_code": "TA",
+      "subcategory_name": "Tourism"
+    },
+    "gb_no_lon_tourist": {
+      "routing_key": "slot.lon.no.tourist",
+      "vac_code": "NLON",
+      "center_name": "Norway Visa Application Centre, London",
+      "address": "66 Wilson street, EC2A 2BT",
+      "category_code": "SCHVISA",
+      "category_name": "Schengen Visa C",
+      "subcategory_code": "TOU",
+      "subcategory_name": "Tourist Visa"
+    },
+    "ie_at_dub_tourist": {
+      "routing_key": "slot.dub.at.tourist",
+      "vac_code": "AUT-DUB",
+      "center_name": "Austria / Switzerland / Liechtenstein/ Slovenia Visa Application Center, Dublin",
+      "address": "Cunningham House, 130 Francis Street, Dublin 8 D08 H48R",
+      "category_code": "Default_Austria_Ireland ",
+      "category_name": "Other Visas",
+      "subcategory_code": "TA",
+      "subcategory_name": "All Visas "
+    },
+    "ie_dk_dub_tourist": {
+      "routing_key": "slot.dub.dk.tourist",
+      "vac_code": "DIDUB",
+      "center_name": "Denmark Visa Application Center, Dublin ",
+      "address": "Cunningham House, 130 Francis Street, Dublin 8 D08 H48R",
+      "category_code": "SV",
+      "category_name": "Schengen Visa",
+      "subcategory_code": "TV",
+      "subcategory_name": "Tourism"
+    },
+    "ie_fi_dub_tourist": {
+      "routing_key": "slot.dub.fi.tourist",
+      "vac_code": "Dubb",
+      "center_name": "Application Centre, Dublin",
+      "address": "Cunningham House, 130 Francis Street, Dublin 8 D08 H48R",
+      "category_code": "S S",
+      "category_name": "VISA",
+      "subcategory_code": "Tourist Category",
+      "subcategory_name": "Tourist Category"
+    },
+    "ie_hu_dub_tourist": {
+      "routing_key": "slot.dub.hu.tourist",
+      "vac_code": "DUB",
+      "center_name": "Ireland Visa Application Center,Dublin",
+      "address": "Cunningham House, 130 Francis Street Dublin",
+      "category_code": "SS",
+      "category_name": "Short Stay",
+      "subcategory_code": "Schengen Visa",
+      "subcategory_name": "Schengen Visa"
+    },
+    "ie_is_dub_tourist": {
+      "routing_key": "slot.dub.is.tourist",
+      "vac_code": "DUB",
+      "center_name": "Iceland Visa Application Center- Dublin",
+      "address": "Cunningham House, 130 Francis Street, Dublin, Ireland- DO8 H48R",
+      "category_code": "CVI",
+      "category_name": "C-Visa",
+      "subcategory_code": "OTT",
+      "subcategory_name": "Tourism"
+    },
+    "gb_at_lon_tourist": {
+      "routing_key": "slot.lon.at.tourist",
+      "vac_code": "ADN",
+      "center_name": "Austria Visa Application Centre, London",
+      "address": "66 Wilson Street, EC2A 2BT",
+      "category_code": "Visa  to  Austria",
+      "category_name": "Visa  to  Austria",
+      "subcategory_code": "TA",
+      "subcategory_name": "Tourism"
+    },
+    "cn_at_can_tourist": {
+      "routing_key": "slot.can.at.tourist",
+      "vac_code": "Gua",
+      "center_name": "Austria Visa Application Center, Guangzhou",
+      "address": "7/F, GAL Tower, No. 78, Pazhou Avenue, Haizhu District",
+      "category_code": "VTC",
+      "category_name": "Visa Type C-Schengen",
+      "subcategory_code": "TR",
+      "subcategory_name": "Tourism (90 days)"
+    }
+  }
+}

+ 9 - 0
app/schemas/visa_free_config.py

@@ -0,0 +1,9 @@
+from pydantic import BaseModel
+from typing import Optional
+
+class VisaFreeConfigFetchRequest(BaseModel):
+    provider: str
+    origin: str
+    target: str
+    visa_type: str
+    vac: Optional[str] = None

+ 6 - 0
app/services/visa_free_config_service.py

@@ -0,0 +1,6 @@
+from typing import Dict, Any, Optional
+from app.visa_free_config_fetchers import get_fetcher
+
+def fetch_visa_free_config(provider: str, origin: str, target: str, visa_type: str, vac: Optional[str] = None) -> Dict[str, Any]:
+    fetcher = get_fetcher(provider)
+    return fetcher.fetch_free_config(origin=origin, target=target, visa_type=visa_type, vac_code=vac)

+ 34 - 0
app/visa_free_config_fetchers/__init__.py

@@ -0,0 +1,34 @@
+# -*- coding: utf-8 -*-
+from app.visa_free_config_fetchers.base_fetcher import BaseFetcher
+from app.visa_free_config_fetchers.vfs_fetcher import VFSFetcher
+from app.visa_free_config_fetchers.bls_fetcher import BLSFetcher
+from app.visa_free_config_fetchers.tls_fetcher import TLSFetcher
+from app.visa_free_config_fetchers.pol_fetcher import POLFetcher
+from app.visa_free_config_fetchers.de_fetcher import DEFetcher
+from app.visa_free_config_fetchers.bel_fetcher import BELFetcher
+from app.visa_free_config_fetchers.usa_fetcher import USAFetcher
+from app.visa_free_config_fetchers.grc_fetcher import GRCFetcher
+from app.visa_free_config_fetchers.ita_fetcher import ITAFetcher
+from app.visa_free_config_fetchers.generic_fetcher import GenericFetcher
+
+_FETCHER_REGISTRY = {}
+
+def register_fetcher(name, fetcher_cls):
+    _FETCHER_REGISTRY[name.lower()] = fetcher_cls
+
+def get_fetcher(provider_name):
+    key = provider_name.lower()
+    if key in _FETCHER_REGISTRY:
+        return _FETCHER_REGISTRY[key]()
+    # Dynamic fallback to GenericFetcher for existing metadata
+    return GenericFetcher(key)
+
+register_fetcher("vfs", VFSFetcher)
+register_fetcher("bls", BLSFetcher)
+register_fetcher("tls", TLSFetcher)
+register_fetcher("pol", POLFetcher)
+register_fetcher("de", DEFetcher)
+register_fetcher("bel", BELFetcher)
+register_fetcher("usa", USAFetcher)
+register_fetcher("grc", GRCFetcher)
+register_fetcher("ita", ITAFetcher)

+ 36 - 0
app/visa_free_config_fetchers/base_fetcher.py

@@ -0,0 +1,36 @@
+# -*- coding: utf-8 -*-
+import os
+import json
+from abc import ABCMeta, abstractmethod
+
+DEFAULT_METADATA_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "metadata"))
+
+class BaseFetcher(object):
+    __metaclass__ = ABCMeta
+
+    def __init__(self, provider_name, metadata_dir=None):
+        if self.__class__ is BaseFetcher:
+            raise TypeError("Cannot instantiate abstract class BaseFetcher directly")
+        self.provider_name = provider_name
+        self.metadata_dir = metadata_dir or DEFAULT_METADATA_DIR
+        self.metadata = self._load_metadata()
+
+    def _load_metadata(self):
+        file_path = os.path.join(self.metadata_dir, "{}_meta.json".format(self.provider_name))
+        if not os.path.exists(file_path):
+            return {}
+        try:
+            with open(file_path, "r") as f:
+                return json.load(f)
+        except Exception:
+            return {}
+
+    def fetch_external_vac_info(self, origin, target, visa_type, vac_code=None):
+        """
+        Hook for retrieving VAC information online from external tools/APIs when not found in metadata.
+        """
+        return None
+
+    @abstractmethod
+    def fetch_free_config(self, origin, target, visa_type, vac_code=None):
+        pass

+ 24 - 0
app/visa_free_config_fetchers/bel_fetcher.py

@@ -0,0 +1,24 @@
+# -*- coding: utf-8 -*-
+from app.visa_free_config_fetchers.base_fetcher import BaseFetcher
+
+class BELFetcher(BaseFetcher):
+    def __init__(self):
+        super(BELFetcher, self).__init__("bel")
+
+    def fetch_free_config(self, origin, target, visa_type, vac_code=None):
+        origin_clean = origin.lower().strip()
+        target_clean = target.lower().strip()
+        vac_clean = (vac_code or "").lower().strip()
+
+        missions = self.metadata.get("missions", {})
+        mission_key = "{}_{}".format(origin_clean, target_clean)
+        if vac_clean and "{}_{}_{}".format(origin_clean, vac_clean, target_clean) in missions:
+            mission_key = "{}_{}_{}".format(origin_clean, vac_clean, target_clean)
+
+        mission_data = missions.get(mission_key, {})
+        if not mission_data:
+            mission_data = {
+                "base_url": "https://visaonweb.diplomatie.be/en"
+            }
+
+        return mission_data

+ 38 - 0
app/visa_free_config_fetchers/bls_fetcher.py

@@ -0,0 +1,38 @@
+# -*- coding: utf-8 -*-
+from app.visa_free_config_fetchers.base_fetcher import BaseFetcher
+
+class BLSFetcher(BaseFetcher):
+    def __init__(self):
+        super(BLSFetcher, self).__init__("bls")
+
+    def fetch_free_config(self, origin, target, visa_type, vac_code=None):
+        origin_clean = origin.lower().strip()
+        target_clean = target.lower().strip()
+        vac_clean = (vac_code or "").lower().strip()
+        visa_clean = visa_type.lower().strip()
+
+        missions = self.metadata.get("missions", {})
+        mission_key = "{}_{}".format(origin_clean, target_clean)
+        mission_data = missions.get(mission_key, {})
+
+        free_config = {
+            "domain": mission_data.get("domain", ""),
+            "ocr_model": mission_data.get("ocr_model", "bls_v1"),
+            "apt_configs": {}
+        }
+
+        vacs = self.metadata.get("vacs", {})
+        vac_key = "{}_{}_{}_{}".format(origin_clean, target_clean, vac_clean, visa_clean)
+        vac_info = vacs.get(vac_key, {})
+
+        if not vac_info and vac_clean:
+            for k, v in vacs.items():
+                if k.startswith("{}_{}_{}".format(origin_clean, target_clean, vac_clean)):
+                    vac_info = v
+                    break
+
+        if vac_info or vac_clean:
+            routing_key = "slot.{}.{}.{}".format(vac_clean, target_clean, visa_clean)
+            free_config["apt_configs"][routing_key] = vac_info
+
+        return free_config

+ 26 - 0
app/visa_free_config_fetchers/de_fetcher.py

@@ -0,0 +1,26 @@
+# -*- coding: utf-8 -*-
+from app.visa_free_config_fetchers.base_fetcher import BaseFetcher
+
+class DEFetcher(BaseFetcher):
+    def __init__(self):
+        super(DEFetcher, self).__init__("de")
+
+    def fetch_free_config(self, origin, target, visa_type, vac_code=None):
+        origin_clean = origin.lower().strip()
+        target_clean = target.lower().strip()
+        vac_clean = (vac_code or "").lower().strip()
+
+        missions = self.metadata.get("missions", {})
+        mission_key = "{}_{}".format(origin_clean, target_clean)
+        if vac_clean and "{}_{}_{}".format(origin_clean, vac_clean, target_clean) in missions:
+            mission_key = "{}_{}_{}".format(origin_clean, vac_clean, target_clean)
+
+        mission_data = missions.get(mission_key, {})
+        if not mission_data:
+            base_url = "https://{}-appointment.visametric.com".format(origin_clean)
+            mission_data = {
+                "base_url": base_url,
+                "consularid": 1
+            }
+
+        return mission_data

+ 25 - 0
app/visa_free_config_fetchers/generic_fetcher.py

@@ -0,0 +1,25 @@
+# -*- coding: utf-8 -*-
+from app.visa_free_config_fetchers.base_fetcher import BaseFetcher
+
+class GenericFetcher(BaseFetcher):
+    def __init__(self, provider_name):
+        super(GenericFetcher, self).__init__(provider_name)
+
+    def fetch_free_config(self, origin, target, visa_type, vac_code=None):
+        origin_clean = origin.lower().strip()
+        target_clean = target.lower().strip()
+        vac_clean = (vac_code or "").lower().strip()
+
+        missions = self.metadata.get("missions", {})
+        mission_key = "{}_{}".format(origin_clean, target_clean)
+        if vac_clean and "{}_{}_{}".format(origin_clean, vac_clean, target_clean) in missions:
+            mission_key = "{}_{}_{}".format(origin_clean, vac_clean, target_clean)
+
+        mission_data = missions.get(mission_key, {})
+        if not mission_data:
+            for k, v in missions.items():
+                if k.endswith("_{}".format(target_clean)):
+                    mission_data = v
+                    break
+
+        return mission_data

+ 24 - 0
app/visa_free_config_fetchers/grc_fetcher.py

@@ -0,0 +1,24 @@
+# -*- coding: utf-8 -*-
+from app.visa_free_config_fetchers.base_fetcher import BaseFetcher
+
+class GRCFetcher(BaseFetcher):
+    def __init__(self):
+        super(GRCFetcher, self).__init__("grc")
+
+    def fetch_free_config(self, origin, target, visa_type, vac_code=None):
+        origin_clean = origin.lower().strip()
+        target_clean = target.lower().strip()
+        vac_clean = (vac_code or "").lower().strip()
+
+        missions = self.metadata.get("missions", {})
+        mission_key = "{}_{}".format(origin_clean, target_clean)
+        if vac_clean and "{}_{}_{}".format(origin_clean, vac_clean, target_clean) in missions:
+            mission_key = "{}_{}_{}".format(origin_clean, vac_clean, target_clean)
+
+        mission_data = missions.get(mission_key, {})
+        if not mission_data:
+            mission_data = {
+                "website": "https://www.supersaas.com/schedule"
+            }
+
+        return mission_data

+ 24 - 0
app/visa_free_config_fetchers/ita_fetcher.py

@@ -0,0 +1,24 @@
+# -*- coding: utf-8 -*-
+from app.visa_free_config_fetchers.base_fetcher import BaseFetcher
+
+class ITAFetcher(BaseFetcher):
+    def __init__(self):
+        super(ITAFetcher, self).__init__("ita")
+
+    def fetch_free_config(self, origin, target, visa_type, vac_code=None):
+        origin_clean = origin.lower().strip()
+        target_clean = target.lower().strip()
+        vac_clean = (vac_code or "").lower().strip()
+
+        missions = self.metadata.get("missions", {})
+        mission_key = "{}_{}".format(origin_clean, target_clean)
+        if vac_clean and "{}_{}_{}".format(origin_clean, vac_clean, target_clean) in missions:
+            mission_key = "{}_{}_{}".format(origin_clean, vac_clean, target_clean)
+
+        mission_data = missions.get(mission_key, {})
+        if not mission_data:
+            mission_data = {
+                "base_url": "https://prenotami.esteri.it"
+            }
+
+        return mission_data

+ 35 - 0
app/visa_free_config_fetchers/pol_fetcher.py

@@ -0,0 +1,35 @@
+# -*- coding: utf-8 -*-
+from app.visa_free_config_fetchers.base_fetcher import BaseFetcher
+
+CITY_MAP = {
+    "bjs": "Beijing",
+    "sha": "Shanghai",
+    "dub": "Dublin",
+    "tok": "Tokio",
+    "tyo": "Tokio"
+}
+
+class POLFetcher(BaseFetcher):
+    def __init__(self):
+        super(POLFetcher, self).__init__("pol")
+
+    def fetch_free_config(self, origin, target, visa_type, vac_code=None):
+        origin_clean = origin.lower().strip()
+        target_clean = target.lower().strip()
+        vac_clean = (vac_code or "").lower().strip()
+
+        missions = self.metadata.get("missions", {})
+        mission_key = "{}_{}".format(origin_clean, target_clean)
+        if vac_clean and "{}_{}_{}".format(origin_clean, vac_clean, target_clean) in missions:
+            mission_key = "{}_{}_{}".format(origin_clean, vac_clean, target_clean)
+
+        mission_data = missions.get(mission_key, {})
+        if not mission_data:
+            location = CITY_MAP.get(vac_clean, vac_clean.capitalize() if vac_clean else "")
+            mission_data = {
+                "query_url": "https://secure.e-konsulat.gov.pl",
+                "service_type": "Wiza Schengen",
+                "location": location
+            }
+
+        return mission_data

+ 57 - 0
app/visa_free_config_fetchers/tls_fetcher.py

@@ -0,0 +1,57 @@
+# -*- coding: utf-8 -*-
+from app.visa_free_config_fetchers.base_fetcher import BaseFetcher
+
+CITY_MAP = {
+    "bjs": "Beijing",
+    "can": "Guangzhou",
+    "cng": "Chengdu",
+    "sha": "Shanghai",
+    "she": "Shenyang",
+    "wuh": "Wuhan",
+    "ckg": "Chongqing",
+    "csx": "Changsha",
+    "foc": "Fuzhou",
+    "hgh": "Hangzhou",
+    "kmg": "Kunming",
+    "nkg": "Nanjing",
+    "szx": "Shenzhen",
+    "tna": "Jinan",
+    "xiy": "Xi'an",
+    "lon": "London",
+    "dub": "Dublin",
+}
+
+class TLSFetcher(BaseFetcher):
+    def __init__(self):
+        super(TLSFetcher, self).__init__("tls")
+
+    def fetch_free_config(self, origin, target, visa_type, vac_code=None):
+        origin_clean = origin.lower().strip()
+        target_clean = target.lower().strip()
+        vac_clean = (vac_code or "").lower().strip()
+
+        missions = self.metadata.get("missions", {})
+        
+        mission_key = "{}_{}".format(origin_clean, target_clean)
+        if vac_clean:
+            mission_key = "{}_{}_{}".format(origin_clean, vac_clean, target_clean)
+
+        mission_data = missions.get(mission_key, {})
+        if not mission_data and vac_clean:
+            mission_key_no_vac = "{}_{}".format(origin_clean, target_clean)
+            mission_data = missions.get(mission_key_no_vac, {})
+
+        if not mission_data:
+            vac_upper = vac_clean.upper()
+            site_id = "{}{}2{}".format(origin_clean, vac_upper, target_clean)
+            tls_url = "https://visas-{}.tlscontact.com/en-us/country/{}/vac/{}".format(
+                target_clean, origin_clean, site_id
+            )
+            location = CITY_MAP.get(vac_clean, vac_clean.capitalize() if vac_clean else "")
+            mission_data = {
+                "tls_url": tls_url,
+                "site_id": site_id,
+                "location": location
+            }
+
+        return mission_data

+ 25 - 0
app/visa_free_config_fetchers/usa_fetcher.py

@@ -0,0 +1,25 @@
+# -*- coding: utf-8 -*-
+from app.visa_free_config_fetchers.base_fetcher import BaseFetcher
+
+class USAFetcher(BaseFetcher):
+    def __init__(self):
+        super(USAFetcher, self).__init__("usa")
+
+    def fetch_free_config(self, origin, target, visa_type, vac_code=None):
+        origin_clean = origin.lower().strip()
+        target_clean = target.lower().strip()
+        vac_clean = (vac_code or "").lower().strip()
+
+        missions = self.metadata.get("missions", {})
+        mission_key = "{}_{}".format(origin_clean, target_clean)
+        if vac_clean and "{}_{}_{}".format(origin_clean, vac_clean, target_clean) in missions:
+            mission_key = "{}_{}_{}".format(origin_clean, vac_clean, target_clean)
+
+        mission_data = missions.get(mission_key, {})
+        if not mission_data:
+            mission_data = {
+                "usa_url": "https://www.usvisascheduling.com",
+                "location": vac_clean.upper() if vac_clean else ""
+            }
+
+        return mission_data

+ 308 - 0
app/visa_free_config_fetchers/vfs_fetcher.py

@@ -0,0 +1,308 @@
+# -*- coding: utf-8 -*-
+import os
+import re
+import time
+import json
+from typing import List, Dict, Any, Optional
+from curl_cffi import requests
+from app.visa_free_config_fetchers.base_fetcher import BaseFetcher
+
+COUNTRY_INFO = {
+    "ie": {"iso3": "irl", "name": "Ireland"},
+    "irl": {"iso3": "irl", "name": "Ireland"},
+    "gb": {"iso3": "gbr", "name": "United Kingdom"},
+    "uk": {"iso3": "gb", "name": "United Kingdom"},
+    "gbr": {"iso3": "gbr", "name": "United Kingdom"},
+    "cn": {"iso3": "chn", "name": "China"},
+    "chn": {"iso3": "chn", "name": "China"},
+    "sg": {"iso3": "sgp", "name": "Singapore"},
+    "sgp": {"iso3": "sgp", "name": "Singapore"},
+    "au": {"iso3": "aus", "name": "Australia"},
+    "aus": {"iso3": "aus", "name": "Australia"},
+    "nl": {"iso3": "nld", "name": "Netherlands"},
+    "nld": {"iso3": "nld", "name": "Netherlands"},
+    "fr": {"iso3": "fra", "name": "France"},
+    "fra": {"iso3": "fra", "name": "France"},
+    "it": {"iso3": "ita", "name": "Italy"},
+    "ita": {"iso3": "ita", "name": "Italy"},
+    "at": {"iso3": "aut", "name": "Austria"},
+    "aut": {"iso3": "aut", "name": "Austria"},
+    "dk": {"iso3": "dnk", "name": "Denmark"},
+    "dnk": {"iso3": "dnk", "name": "Denmark"},
+    "fi": {"iso3": "fin", "name": "Finland"},
+    "fin": {"iso3": "fin", "name": "Finland"},
+    "hu": {"iso3": "hun", "name": "Hungary"},
+    "hun": {"iso3": "hun", "name": "Hungary"},
+    "is": {"iso3": "isl", "name": "Iceland"},
+    "isl": {"iso3": "isl", "name": "Iceland"},
+    "no": {"iso3": "nor", "name": "Norway"},
+    "nor": {"iso3": "nor", "name": "Norway"},
+    "es": {"iso3": "esp", "name": "Spain"},
+    "esp": {"iso3": "esp", "name": "Spain"},
+    "de": {"iso3": "deu", "name": "Germany"},
+    "deu": {"iso3": "deu", "name": "Germany"},
+    "gr": {"iso3": "grc", "name": "Greece"},
+    "grc": {"iso3": "grc", "name": "Greece"},
+    "be": {"iso3": "bel", "name": "Belgium"},
+    "bel": {"iso3": "bel", "name": "Belgium"},
+    "pl": {"iso3": "pol", "name": "Poland"},
+    "pol": {"iso3": "pol", "name": "Poland"}
+}
+
+ISO2_TO_ISO3_API = {
+    "gb": "gbr",
+    "uk": "gbr",
+    "cn": "chn",
+    "ie": "irl",
+    "sg": "sgp",
+    "au": "aus",
+    "nl": "nld",
+    "fr": "fra",
+    "it": "ita",
+    "at": "aut",
+    "dk": "dnk",
+    "fi": "fin",
+    "hu": "hun",
+    "is": "isl",
+    "no": "nor",
+    "es": "esp",
+    "de": "deu",
+    "gr": "grc",
+    "be": "bel",
+    "pl": "pol"
+}
+
+def _select_candidate_with_llm(candidates: List[Dict[str, Any]], prompt_context: str) -> Dict[str, Any]:
+    if not candidates:
+        raise RuntimeError("Empty candidate list passed to LLM selection")
+    if len(candidates) == 1:
+        return candidates[0]
+
+    api_key = os.environ.get("QWEN_API_KEY") or os.environ.get("DASHSCOPE_API_KEY") or "sk-893e895724c6403d81374e515ffaf427"
+    url = "https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions"
+
+    formatted_candidates = "\n".join([f"{i}: {json.dumps(c, ensure_ascii=False)}" for i, c in enumerate(candidates)])
+    prompt = (
+        f"上下文要求:{prompt_context}\n"
+        f"候选列表:\n{formatted_candidates}\n"
+        f"请判断哪个候选最符合要求。仅输出该候选的索引数字(0-indexed),不要有任何其他文字。"
+    )
+
+    headers = {
+        "Authorization": f"Bearer {api_key}",
+        "Content-Type": "application/json"
+    }
+    payload = {
+        "model": "qwen-plus",
+        "messages": [
+            {"role": "system", "content": "你是一个配置选择助手。按指令仅输出数字索引。"},
+            {"role": "user", "content": prompt}
+        ],
+        "temperature": 0.0
+    }
+
+    try:
+        resp = requests.post(url, headers=headers, json=payload, timeout=10)
+        if resp.status_code == 200:
+            content = resp.json()["choices"][0]["message"]["content"].strip()
+            match = re.search(r"\d+", content)
+            if match:
+                idx = int(match.group())
+                if 0 <= idx < len(candidates):
+                    return candidates[idx]
+    except Exception as e:
+        print(f"[WARN] LLM candidate selection failed: {e}")
+
+    return candidates[0]
+
+def _vfs_get(url: str, headers: dict, retries: int = 2) -> requests.Response:
+    for attempt in range(retries + 1):
+        resp = requests.get(url, headers=headers, impersonate="chrome120", timeout=10)
+        if resp.status_code == 200 or attempt == retries:
+            return resp
+        time.sleep(0.5)
+    return resp
+
+class VFSFetcher(BaseFetcher):
+    def __init__(self):
+        super(VFSFetcher, self).__init__("vfs")
+
+    def fetch_external_vac_info(self, origin: str, target: str, visa_type: str, vac_code: str) -> Dict[str, Any]:
+        """
+        Call external VFS master API using curl-cffi when vac_info is missing from metadata.
+        Raises RuntimeError if data fetching or matching fails.
+        """
+        origin_clean = origin.lower().strip()
+        target_clean = target.lower().strip()
+        vac_clean = vac_code.lower().strip()
+        visa_clean = visa_type.lower().strip()
+
+        origin_info = COUNTRY_INFO.get(origin_clean, {"iso3": origin_clean})
+        target_info = COUNTRY_INFO.get(target_clean, {"iso3": target_clean})
+        origin_iso3 = origin_info["iso3"]
+        target_iso3 = target_info["iso3"]
+
+        origin_api_iso3 = ISO2_TO_ISO3_API.get(origin_iso3, origin_iso3)
+        target_api_iso3 = ISO2_TO_ISO3_API.get(target_iso3, target_iso3)
+
+        domain = "lift-apicn.vfsglobal.com" if origin_api_iso3 in ["chn", "cn"] else "lift-api.vfsglobal.com"
+        headers = {
+            "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
+            "Accept": "application/json, text/plain, */*",
+            "Accept-Language": "en-US,en;q=0.9",
+            "route": f"{origin_api_iso3}/en/{target_api_iso3}",
+            "origin": "https://visa.vfsglobal.com",
+            "referer": "https://visa.vfsglobal.com/"
+        }
+
+        # 1. Fetch Centers
+        url_center = f"https://{domain}/master/center/{target_api_iso3}/{origin_api_iso3}/en-US"
+        try:
+            resp_center = _vfs_get(url_center, headers=headers)
+            if resp_center.status_code != 200:
+                raise RuntimeError(f"VFS master center API returned HTTP {resp_center.status_code}")
+            centers = resp_center.json()
+        except Exception as e:
+            raise RuntimeError(f"Failed to fetch VFS centers for {origin_api_iso3}->{target_api_iso3}: {e}")
+
+        if not isinstance(centers, list) or not centers:
+            raise RuntimeError(f"No centers returned for {origin_api_iso3}->{target_api_iso3}")
+
+        # Match center candidate
+        matched_centers = []
+        for c in centers:
+            iso_c = str(c.get("isoCode") or "").lower()
+            vac_c = str(c.get("vacCode") or "").lower()
+            name_c = str(c.get("centerName") or "").lower()
+            city_c = str(c.get("city") or "").lower()
+            if vac_clean in iso_c or vac_clean in vac_c or vac_clean in name_c or vac_clean in city_c:
+                matched_centers.append(c)
+
+        if not matched_centers:
+            matched_centers = centers
+
+        if len(matched_centers) == 1:
+            selected_center = matched_centers[0]
+        else:
+            selected_center = _select_candidate_with_llm(
+                matched_centers,
+                f"选择办理 {origin_api_iso3} 到 {target_api_iso3} 的 VAC 签证中心,目标中心编号/城市: {vac_clean}"
+            )
+
+        vac_code_fetched = selected_center.get("isoCode") or selected_center.get("vacCode") or vac_code.upper()
+        center_name_fetched = selected_center.get("centerName", "")
+        address_fetched = selected_center.get("address", "")
+
+        # 2. Fetch Visa Categories
+        url_cat = f"https://{domain}/master/visacategory/{target_api_iso3}/{origin_api_iso3}/{vac_code_fetched}/en-US"
+        try:
+            resp_cat = _vfs_get(url_cat, headers=headers)
+            if resp_cat.status_code != 200:
+                raise RuntimeError(f"VFS visa category API returned HTTP {resp_cat.status_code}, resp_cat.text is {resp_cat.text}")
+            categories = resp_cat.json()
+        except Exception as e:
+            raise RuntimeError(f"Failed to fetch VFS visa category for center {vac_code_fetched}: {e}")
+
+        if not isinstance(categories, list) or not categories:
+            raise RuntimeError(f"No visa categories returned for center {vac_code_fetched}")
+
+        if len(categories) == 1:
+            selected_cat = categories[0]
+        else:
+            matched_cats = [
+                cat for cat in categories
+                if visa_clean in str(cat.get("name", "")).lower() or visa_clean in str(cat.get("code", "")).lower()
+            ]
+            if len(matched_cats) == 1:
+                selected_cat = matched_cats[0]
+            elif len(matched_cats) > 1:
+                selected_cat = _select_candidate_with_llm(
+                    matched_cats,
+                    f"选择符合签证类型 '{visa_clean}' 的 Visa Category 类别"
+                )
+            else:
+                selected_cat = _select_candidate_with_llm(
+                    categories,
+                    f"选择符合签证类型 '{visa_clean}' 的 Visa Category 类别"
+                )
+
+        cat_code_fetched = selected_cat.get("visaCategoryCode") or selected_cat.get("code", "")
+        cat_name_fetched = selected_cat.get("name", "")
+
+        # 3. Fetch Visa Subcategories (Note: unauthenticated subvisacategory API may return HTTP 500 on VFS backend)
+        url_sub = f"https://{domain}/master/subvisacategory/{target_api_iso3}/{origin_api_iso3}/{vac_code_fetched}/{cat_code_fetched}/en-US"
+        subcat_code_fetched = "To"
+        subcat_name_fetched = "Tourist"
+        try:
+            resp_sub = _vfs_get(url_sub, headers=headers)
+            if resp_sub.status_code == 200 and isinstance(resp_sub.json(), list) and resp_sub.json():
+                subcategories = resp_sub.json()
+                if len(subcategories) == 1:
+                    selected_sub = subcategories[0]
+                else:
+                    selected_sub = _select_candidate_with_llm(
+                        subcategories,
+                        f"选择符合签证类型 '{visa_clean}' 的 Subvisa Category 子类别"
+                    )
+                subcat_code_fetched = selected_sub.get("subCategoryCode") or selected_sub.get("code", "To")
+                subcat_name_fetched = selected_sub.get("subCategoryName") or selected_sub.get("name", "Tourist")
+        except Exception as e:
+            print(f"[INFO] Subvisacategory query unauthenticated fallback: {e}")
+
+        return {
+            "routing_key": f"slot.{vac_clean}.{target_clean}.{visa_clean}",
+            "vac_code": vac_code_fetched,
+            "center_name": center_name_fetched,
+            "address": address_fetched,
+            "category_code": cat_code_fetched,
+            "category_name": cat_name_fetched,
+            "subcategory_code": subcat_code_fetched,
+            "subcategory_name": subcat_name_fetched
+        }
+
+    def fetch_free_config(self, origin, target, visa_type, vac_code=None):
+        origin_clean = origin.lower().strip()
+        target_clean = target.lower().strip()
+        vac_clean = (vac_code or "").lower().strip()
+        visa_clean = visa_type.lower().strip()
+
+        origin_info = COUNTRY_INFO.get(origin_clean, {"iso3": origin_clean, "name": origin.capitalize()})
+        target_info = COUNTRY_INFO.get(target_clean, {"iso3": target_clean, "name": target.capitalize()})
+
+        free_config = {
+            "mission_code": target_info["iso3"],
+            "mission_name": target_info["name"],
+            "country_code": origin_info["iso3"],
+            "country_name": origin_info["name"],
+            "culture_code": "en-US",
+            "language": "en",
+            "apt_configs": {}
+        }
+
+        vacs = self.metadata.get("vacs", {})
+        vac_key = "{}_{}_{}_{}".format(origin_clean, target_clean, vac_clean, visa_clean)
+        vac_info = vacs.get(vac_key, {})
+
+        if not vac_info and vac_clean:
+            for k, v in vacs.items():
+                if k.startswith("{}_{}_{}".format(origin_clean, target_clean, vac_clean)):
+                    vac_info = v
+                    break
+
+        # If vac_info NOT found in metadata, call external tool / API online!
+        if not vac_info and vac_clean:
+            vac_info = self.fetch_external_vac_info(origin_clean, target_clean, visa_clean, vac_clean)
+
+        if vac_info or vac_clean:
+            routing_key = vac_info.get("routing_key", "slot.{}.{}.{}".format(vac_clean, target_clean, visa_clean))
+            free_config["apt_configs"][routing_key] = {
+                "center_name": vac_info.get("center_name", ""),
+                "address": vac_info.get("address", ""),
+                "vac_code": vac_info.get("vac_code", ""),
+                "category_name": vac_info.get("category_name", ""),
+                "category_code": vac_info.get("category_code", ""),
+                "subcategory_name": vac_info.get("subcategory_name", ""),
+                "subcategory_code": vac_info.get("subcategory_code", "")
+            }
+
+        return free_config

+ 77 - 0
tests/test_visa_free_config.py

@@ -0,0 +1,77 @@
+# -*- coding: utf-8 -*-
+import pytest
+from app.visa_free_config_fetchers import get_fetcher
+from app.services.visa_free_config_service import fetch_visa_free_config
+
+def test_vfs_fetcher_free_config():
+    fetcher = get_fetcher("vfs")
+    cfg = fetcher.fetch_free_config(origin="ie", target="nl", visa_type="tourist", vac_code="dub")
+    assert cfg["mission_code"] == "nld"
+    assert "apt_configs" in cfg
+    routing_key = "slot.dub.nl.tourist"
+    assert routing_key in cfg["apt_configs"]
+    assert cfg["apt_configs"][routing_key]["vac_code"] == "NTDB"
+
+def test_bls_fetcher_free_config():
+    fetcher = get_fetcher("bls")
+    cfg = fetcher.fetch_free_config(origin="ie", target="es", visa_type="tourist", vac_code="dub")
+    assert cfg["domain"] == "ireland.blsspainglobal.com"
+
+def test_tls_fetcher_free_config():
+    fetcher = get_fetcher("tls")
+    cfg = fetcher.fetch_free_config(origin="gb", target="fr", visa_type="tourist", vac_code="lon")
+    assert cfg["location"] == "London"
+    assert cfg["site_id"] == "gbLON2fr"
+
+def test_tls_fetcher_auto_generate():
+    fetcher = get_fetcher("tls")
+    cfg = fetcher.fetch_free_config(origin="cn", target="fr", visa_type="tourist", vac_code="can")
+    assert cfg["tls_url"] == "https://visas-fr.tlscontact.com/en-us/country/cn/vac/cnCAN2fr"
+    assert cfg["site_id"] == "cnCAN2fr"
+    assert cfg["location"] == "Guangzhou"
+
+def test_pol_fetcher_auto_generate():
+    cfg = fetch_visa_free_config(provider="pol", origin="cn", target="pl", visa_type="tourist", vac="bjs")
+    assert cfg["service_type"] == "Wiza Schengen"
+    assert cfg["location"] == "Beijing"
+
+def test_de_fetcher_auto_generate():
+    cfg = fetch_visa_free_config(provider="de", origin="cn", target="de", visa_type="tourist", vac="bjs")
+    assert cfg["base_url"] == "https://cn-appointment.visametric.com"
+    assert cfg["consularid"] == 1
+
+def test_usa_fetcher_free_config():
+    cfg = fetch_visa_free_config(provider="usa", origin="cn", target="us", visa_type="tourist", vac="bjs")
+    assert cfg["usa_url"] == "https://www.usvisascheduling.com"
+    assert cfg["location"] == "BEIJING"
+
+from fastapi.testclient import TestClient
+from app.main import app
+from app.core.auth import get_current_user
+
+class DummyUser:
+    id = 1
+    role = "user"
+
+app.dependency_overrides[get_current_user] = lambda: DummyUser()
+client = TestClient(app)
+
+def test_visa_free_config_http_post():
+    response = client.post("/api/visa/free-config/fetch", json={
+        "provider": "tls",
+        "origin": "cn",
+        "target": "fr",
+        "visa_type": "tourist",
+        "vac": "can"
+    })
+    assert response.status_code == 200
+    body = response.json()
+    assert body["code"] == 0
+    assert body["data"]["tls_url"] == "https://visas-fr.tlscontact.com/en-us/country/cn/vac/cnCAN2fr"
+
+def test_visa_free_config_http_get():
+    response = client.get("/api/visa/free-config/fetch?provider=usa&origin=cn&target=us&visa_type=tourist&vac=bjs")
+    assert response.status_code == 200
+    body = response.json()
+    assert body["code"] == 0
+    assert body["data"]["usa_url"] == "https://www.usvisascheduling.com"