jerry vor 4 Monaten
Ursprung
Commit
f8207e11b9

+ 62 - 0
config/accounts.json

@@ -0,0 +1,62 @@
+{
+    "ie_nl": [
+        {
+            "id": 423,
+            "lock_until": 0,
+            "password": "VS@OjoMs3f",
+            "username": "ie_nl_quqc36@gmail-app.com"
+        },
+        {
+            "id": 568,
+            "lock_until": 0,
+            "password": "0z@VzYvt9i",
+            "username": "ie_nl_vxzo9t@gmail-app.com"
+        },
+        {
+            "id": 569,
+            "lock_until": 0,
+            "password": "9@5J9UMxwK",
+            "username": "ie_nl_cluyap@gmail-app.com"
+        },
+        {
+            "id": 570,
+            "lock_until": 0,
+            "password": "Ry5Ts7RA@R",
+            "username": "ie_nl_yk3mah@gmail-app.com"
+        },
+        {
+            "id": 571,
+            "lock_until": 0,
+            "password": "Yw6La2Ig@C",
+            "username": "ie_nl_cx2v7z@gmail-app.com"
+        },
+        {
+            "id": 572,
+            "lock_until": 0,
+            "password": "vDrmFxv@1V",
+            "username": "ie_nl_y118ln@gmail-app.com"
+        },
+        {
+            "id": 573,
+            "lock_until": 0,
+            "password": "VaZ0@0I8vS",
+            "username": "ie_nl_x0rh02@gmail-app.com"
+        }
+    ],
+    "gb_fr": [
+        {
+            "id": 0,
+            "username":"arket_zz@163.com",
+            "password": "Visafly@111",
+            "lock_until": 0
+        }
+    ],
+    "ie_es": [
+        {
+            "id": 0,
+            "username":"arket_zz@163.com",
+            "password": "dx4ua@!.X.i8Xn8",
+            "lock_until": 0
+        }
+    ]
+}

+ 1 - 0
config/bindings.json

@@ -0,0 +1 @@
+[]

+ 112 - 0
config/groups.json

@@ -0,0 +1,112 @@
+[
+    {
+        "identifier": "VFS_DUBLIN_NL",
+        "enable": true,
+        "need_account": true,
+        "account_pool": "ie_nl",
+        "need_proxy": true,
+        "proxy_pool": "global_proxy",
+        "target_instances": 1,
+        "account_login_interval": 30,
+        "query_wait": {
+            "mode": 2,
+            "fixed_wait": 10,
+            "random_min": 60,
+            "random_max": 300
+        },
+        "plugin_config": {
+            "lib_path": "plugins",
+            "plugin_name": "vfs_plugin",
+            "plugin_bin": "vfs_plugin.py",
+            "plugin_proto": "IVSPlg"
+        },
+        "free_config": {
+            "verbose": 0,
+            "missionCode": "nld",
+            "missionName": "Netherlands",
+            "countryCode": "irl",
+            "countryName": "Ireland",
+            "cultureCode": "en-US",
+            "language": "en",
+            "website": "https://visa.vfsglobal.com/irl/en/nld/login",
+            "appointmentType": [
+                {
+                    "id": 573,
+                    "routingKey": "slot.dub.nl.tourist",
+                    "centerName": "Netherlands Visa Application Center - Dublin",
+                    "city": "Dublin",
+                    "cityCode": "dub",
+                    "address": "Cunningham House, 130 Francis Street, Dublin 8  D08 H48R",
+                    "vacCode": "NTDB",
+                    "categoryName": "All Short stay Categories",
+                    "categoryCode": "TA",
+                    "subcategoryName": "Tourist",
+                    "subcategoryCode": "To",
+                    "fee": null,
+                    "currency": null
+                }
+            ]
+        }
+    },
+    {
+        "identifier": "BLS_DUBLIN_ES",
+        "enable": false,
+        "need_account": true,
+        "account_pool": "ie_es",
+        "need_proxy": true,
+        "proxy_pool": "local",
+        "target_instances": 1,
+        "query_wait": {
+            "mode": 2,
+            "random_min": 60,
+            "random_max": 300
+        },
+        "plugin_config": {
+            "lib_path": "plugins",
+            "plugin_name": "bls_plugin",
+            "plugin_bin": "bls_plugin.py",
+            "plugin_proto": "IVSPlg"
+        },
+        "free_config": {
+            "domain": "ireland.blsspainglobal.com", 
+            "ocr_service_url": "http://127.0.0.1:8085/predict/vfcode?model=pytorch", 
+            
+            "location": "Dublin",
+            "jurisdiction": null,
+            "visaType": "Schengen Visa/ Short Term Visa",
+            "visaSubType": "Tourist Visa",
+            "appointmentCategory": "Normal"
+        }
+    },
+    {
+        "identifier": "TLS_LONDON_FR",
+        "enable": false,
+        "need_account": true,
+        "account_pool": "gb_fr",
+        "need_proxy": true,
+        "proxy_pool": "global_proxy",
+        "target_instances": 1,
+        "query_wait": {
+            "mode": 2,
+            "random_min": 60,
+            "random_max": 300
+        },
+        "plugin_config": {
+            "lib_path": "plugins",
+            "plugin_name": "tls_plugin",
+            "plugin_bin": "tls_plugin.py",
+            "plugin_proto": "IVSPlg"
+        },
+        "free_config": {
+            "verbose": 0,
+            "embassy_code": "gbLON2fr",
+            "country_code": "gb",
+            "mission_code": "fr",
+            "city": "london",
+            "capsolver_key": "CAP-5441DD341DD3CC2FAEF0BE6FE493EE9A",
+            "interest_month": "01-2026",
+            "target_labels": ["", "pta"],
+            "website": "https://visas-fr.tlscontact.com/country/gb/vac/gbLON2fr/"
+        }
+    }
+]

+ 141 - 0
config/proxies.json

@@ -0,0 +1,141 @@
+{
+    "global_proxy": [
+        {
+            "id": 100003,
+            "ip": "82.152.15.37",
+            "lock_until": 0,
+            "password": "idzlar",
+            "port": 7778,
+            "scheme": "http",
+            "username": "4scepfs03sh920"
+        },
+        {
+            "id": 100005,
+            "ip": "82.152.19.246",
+            "lock_until": 0,
+            "password": "idzlar",
+            "port": 7778,
+            "scheme": "http",
+            "username": "4scepfmv4gp9c"
+        },
+        {
+            "id": 100014,
+            "ip": "82.152.15.33",
+            "lock_until": 0,
+            "password": "idzlar",
+            "port": 7778,
+            "scheme": "http",
+            "username": "4scepfpclja918"
+        },
+        {
+            "id": 100018,
+            "ip": "82.152.19.4",
+            "lock_until": 0,
+            "password": "idzlar",
+            "port": 7778,
+            "scheme": "http",
+            "username": "4scepfmv4gp914"
+        },
+        {
+            "id": 100019,
+            "ip": "82.152.15.8",
+            "lock_until": 0,
+            "password": "idzlar",
+            "port": 7778,
+            "scheme": "http",
+            "username": "4scepfmv4gp913"
+        },
+        {
+            "id": 100020,
+            "ip": "82.152.19.141",
+            "lock_until": 0,
+            "password": "idzlar",
+            "port": 7778,
+            "scheme": "http",
+            "username": "4scepfmv4gp912"
+        },
+        {
+            "id": 100023,
+            "ip": "82.152.19.2",
+            "lock_until": 0,
+            "password": "idzlar",
+            "port": 7778,
+            "scheme": "http",
+            "username": "4scepfmv4gp9e"
+        },
+        {
+            "id": 100025,
+            "ip": "163.5.40.236",
+            "lock_until": 0,
+            "password": "idzlar",
+            "port": 7778,
+            "scheme": "http",
+            "username": "4scekff9u6j9a"
+        },
+        {
+            "id": 100026,
+            "ip": "45.196.65.44",
+            "lock_until": 0,
+            "password": "idzlar",
+            "port": 7778,
+            "scheme": "http",
+            "username": "4sj3agscn8592cf"
+        },
+        {
+            "id": 100027,
+            "ip": "154.82.173.108",
+            "lock_until": 0,
+            "password": "M8v0m9b1p4b3",
+            "port": 9856,
+            "scheme": "http",
+            "username": "l9w3z3c4B4O2"
+        },
+        {
+            "id": 100028,
+            "ip": "45.196.65.25",
+            "lock_until": 0,
+            "password": "idzlar",
+            "port": 7778,
+            "scheme": "http",
+            "username": "4sio3rdic7m9184"
+        },
+        {
+            "id": 100029,
+            "ip": "45.196.65.109",
+            "lock_until": 0,
+            "password": "idzlar",
+            "port": 7778,
+            "scheme": "http",
+            "username": "4sibp4qi26n93c"
+        }
+    ],
+    "ireland_proxies": [
+        {
+            "id": 100029,
+            "ip": "95.135.130.73",
+            "lock_until": 0,
+            "password": "RTBuPWx1CEr6DfD",
+            "port": 48306,
+            "scheme": "http",
+            "username": "9zMOkhCng5HG8SZ"
+        },
+        {
+            "id": 100029,
+            "ip": "95.135.130.76",
+            "lock_until": 0,
+            "password": "5i4lV3VjNwE4bkL",
+            "port": 41553,
+            "scheme": "http",
+            "username": "aNCaMFjblyODleO"
+        }
+    ],
+    "local": [
+        {
+            "id": 100029,
+            "ip": "127.0.0.1",
+            "lock_until": 0,
+            "port": 7890,
+            "scheme": "http"
+        }
+    ]
+}

+ 175 - 0
core/app_manager.py

@@ -0,0 +1,175 @@
+# core/app_manager.py
+import json
+import os
+import threading
+from typing import Dict, List, Optional
+
+# 引入核心组件
+from vs_types import GroupConfig, QueryWaitConfig, PluginConfig, QueryWaitMode
+from group_coordinator import GroupCoordinator
+from vs_log_macros import VSC_INFO, VSC_ERROR, VSC_WARN
+from core.plugin_reloader import reload_plugin_module
+
+class AppManager:
+    _instance = None
+    _lock = threading.Lock()
+
+    def __new__(cls):
+        with cls._lock:
+            if cls._instance is None:
+                cls._instance = super().__new__(cls)
+                cls._instance._init()
+            return cls._instance
+
+    @staticmethod
+    def Instance():
+        return AppManager()
+
+    def _init(self):
+        self.coordinators: Dict[str, GroupCoordinator] = {}
+        self.configs: Dict[str, GroupConfig] = {}
+        self.config_file = "config/groups.json"
+
+    def load_configs(self):
+        """读取并解析配置文件"""
+        if not os.path.exists(self.config_file):
+            VSC_ERROR("app_mgr", f"Config file not found: {self.config_file}")
+            return
+
+        try:
+            with open(self.config_file, 'r', encoding='utf-8') as f:
+                data = json.load(f)
+            
+            for item in data:
+                # JSON -> GroupConfig 转换
+                qw_data = item.get("query_wait", {})
+                qw_cfg = QueryWaitConfig(
+                    mode=QueryWaitMode(qw_data.get("mode", 0)),
+                    fixed_wait=qw_data.get("fixed_wait", 0),
+                    random_min=qw_data.get("random_min", 0),
+                    random_max=qw_data.get("random_max", 0)
+                )
+
+                plg_data = item.get("plugin_config", {})
+                plg_cfg = PluginConfig(
+                    lib_path=plg_data.get("lib_path", "plugins"),
+                    plugin_name=plg_data.get("plugin_name", ""),
+                    plugin_bin=plg_data.get("plugin_bin", ""),
+                    plugin_proto=plg_data.get("plugin_proto", "IVSPlg")
+                )
+
+                grp_cfg = GroupConfig(
+                    identifier=item["identifier"],
+                    enable=item.get("enable", False),
+                    need_account=item.get("need_account", False),
+                    account_pool=item.get("account_pool", ""),
+                    need_proxy=item.get("need_proxy", False),
+                    proxy_pool=item.get("proxy_pool", ""),
+                    target_instances=item.get("target_instances", 1),
+                    account_login_interval=item.get("account_login_interval", 0),
+                    query_wait=qw_cfg,
+                    plugin_config=plg_cfg,
+                    free_config=json.dumps(item.get("free_config", "{}"))
+                )
+                
+                self.configs[grp_cfg.identifier] = grp_cfg
+                
+            VSC_INFO("app_mgr", f"Loaded {len(self.configs)} group configurations.")
+            
+        except Exception as e:
+            VSC_ERROR("app_mgr", f"Failed to load configs: {e}")
+
+    def start_all(self):
+        """启动所有 enable=True 的组"""
+        for gid, cfg in self.configs.items():
+            if cfg.enable and gid not in self.coordinators:
+                self.start_group(gid)
+
+    def start_group(self, group_id: str) -> bool:
+        if group_id not in self.configs:
+            VSC_ERROR("app_mgr", f"Group {group_id} not found in config")
+            return False
+        
+        if group_id in self.coordinators:
+            VSC_WARN("app_mgr", f"Group {group_id} is already running")
+            return True
+
+        cfg = self.configs[group_id]
+        try:
+            coord = GroupCoordinator(cfg)
+            # 设置推送回调,这里可以连接到 WebSocket
+            coord.set_push_callback(lambda t, d, s: print(f"[{group_id} PUSH] {d.decode()}"))
+            
+            coord.start()
+            self.coordinators[group_id] = coord
+            VSC_INFO("app_mgr", f"Started group: {group_id}")
+            return True
+        except Exception as e:
+            VSC_ERROR("app_mgr", f"Failed to start group {group_id}: {e}")
+            return False
+
+    def stop_group(self, group_id: str) -> bool:
+        if group_id not in self.coordinators:
+            return False
+        
+        VSC_INFO("app_mgr", f"Stopping group: {group_id}...")
+        try:
+            self.coordinators[group_id].stop()
+            del self.coordinators[group_id]
+            VSC_INFO("app_mgr", f"Stopped group: {group_id}")
+            return True
+        except Exception as e:
+            VSC_ERROR("app_mgr", f"Error stopping group {group_id}: {e}")
+            return False
+
+    def restart_group(self, group_id: str) -> bool:
+        self.stop_group(group_id)
+        return self.start_group(group_id)
+
+    def ota_update_plugin(self, plugin_name: str) -> List[str]:
+        """
+        OTA 热更新流程:
+        1. 找到所有使用该插件的运行中组
+        2. 停止这些组
+        3. 清除 Python 模块缓存 (热重载)
+        4. 重新启动这些组
+        """
+        affected_groups = []
+        
+        # 1. 查找受影响的组
+        for gid, coord in self.coordinators.items():
+            # 注意:这里我们访问 coord 私有成员,实际工程中建议添加 getter
+            # 假设 config 存储在 m_cfg
+            if coord.m_cfg.plugin_config.plugin_name == plugin_name:
+                affected_groups.append(gid)
+        
+        VSC_INFO("app_mgr", f"OTA Update for '{plugin_name}'. Affected groups: {affected_groups}")
+        
+        # 2. 停止
+        for gid in affected_groups:
+            self.stop_group(gid)
+            
+        # 3. 卸载模块
+        reload_plugin_module(plugin_name)
+        
+        # 4. 重启
+        restarted = []
+        for gid in affected_groups:
+            if self.start_group(gid):
+                restarted.append(gid)
+                
+        return restarted
+
+    def get_status(self):
+        """获取所有组的状态"""
+        status_list = []
+        for gid, cfg in self.configs.items():
+            running = gid in self.coordinators
+            status_list.append({
+                "id": gid,
+                "plugin": cfg.plugin_config.plugin_name,
+                "running": running,
+                "instances": cfg.target_instances if running else 0,
+                "account_pool": cfg.account_pool
+            })
+        return status_list

+ 37 - 0
core/plugin_reloader.py

@@ -0,0 +1,37 @@
+# core/plugin_reloader.py
+import sys
+import importlib
+from vs_log_macros import VSC_INFO, VSC_WARN
+
+def reload_plugin_module(plugin_name: str):
+    """
+    强制卸载并重载插件模块,实现 OTA 热更新
+    """
+    # 1. 在 sys.modules 中查找相关模块
+    # 插件可能导入了其他子模块,这里简单处理:移除主插件模块
+    # 如果插件有 __init__.py 且是一个包,逻辑类似
+    
+    modules_to_remove = []
+    for mod_name in sys.modules.keys():
+        # 假设插件名就是模块名,或者模块名以插件名开头
+        # 例如 plugin_name="bls_plugin", 模块可能是 "plugins.bls_plugin"
+        if plugin_name in mod_name:
+            modules_to_remove.append(mod_name)
+    
+    if not modules_to_remove:
+        VSC_WARN("reloader", f"Plugin module '{plugin_name}' not found in memory. First load?")
+        return
+
+    # 2. 移除模块
+    for mod in modules_to_remove:
+        try:
+            del sys.modules[mod]
+            VSC_INFO("reloader", f"Unloaded module: {mod}")
+        except Exception as e:
+            VSC_WARN("reloader", f"Failed to unload {mod}: {e}")
+            
+    # 3. 强制垃圾回收 (可选,依赖引用计数)
+    import gc
+    gc.collect()
+    
+    VSC_INFO("reloader", f"Plugin '{plugin_name}' reloaded (ready for fresh import).")

BIN
data/ocr.pth


+ 72 - 32
group_coordinator.py

@@ -1,8 +1,9 @@
 # group_coordinator.py
-import threading
+import os
 import time
+import json
 import random
-import os
+import threading
 from typing import List, Optional
 from concurrent.futures import ThreadPoolExecutor, wait
 
@@ -14,6 +15,7 @@ from toolkit.account_manager import AccountManager # type: ignore
 from toolkit.proxy_manager import ProxyManager # type: ignore
 from toolkit.binding_manager import BindingManager # type: ignore
 from toolkit.thread_pool import ThreadPool # type: ignore
+from toolkit.vs_cloud_api import VSCloudApi
 from vs_log_macros import VSC_INFO, VSC_DEBUG, VSC_WARN, VSC_ERROR # type: ignore
 
 
@@ -327,49 +329,87 @@ class GroupCoordinator:
             return None
 
     def on_query_result(self, sptr: IVSPlg, query_result: VSQueryResult):
-        """
-        @brief 处理查询结果
-        *** 关键修改 ***
-        这里会阻塞直到所有发起的预订任务完成。这防止了 Monitor Loop 继续运行导致
-        同一个账号在抢票的同时又发起新的 Query。
-        """
-        VSC_INFO("coordinator", "[%s] Query result received. BLOCKING monitor loop for booking...", self.m_cfg.identifier)
-
-        # 获取所有当前健康的实例进行并发预订 (通常是当前找到票的这个实例,或者是组内所有实例)
-        # 策略:
-        # 1. 激进策略:所有实例一起抢 (可能导致互踢)
-        # 2. 保守策略:仅当前实例抢 (sptr)
-        # 这里使用保守策略,避免多实例同时操作一个 Pool 里的不同账号去抢同一个 Slot 导致资源竞争过大
-        # 如果你想多实例抢,可以使用 self.m_tasks 里的实例。
-        
-        # 即使只用当前实例,也放入 list 统一处理逻辑
-        instances_for_booking = [sptr] 
+        VSC_INFO("coordinator", "[%s] Query result received: %s. BLOCKING monitor loop for booking...", 
+                 self.m_cfg.identifier, str(query_result))
         
+        # 定义内部预订任务
         def book_task(inst: IVSPlg, result: VSQueryResult):
+            task_id = None
             try:
-                VSC_INFO("coordinator", "[%s] Starting book() procedure...", inst.get_group_id())
-                book_res = inst.book(result)
+                # 1. 获取对应的用户任务 (Pop Task)
+                booking_routing_key = f'auto.{result.routing_key}' if result.routing_key else "default"
+                
+                # 尝试获取任务
+                task = VSCloudApi.Instance().get_vas_task_pop(booking_routing_key)
+                
+                if not task:
+                    VSC_WARN("coordinator", "[%s] No pending user tasks found for key '%s'. Abandoning slot.", 
+                             inst.get_group_id(), booking_routing_key)
+                    return 
+
+                task_id = task['id']
+                user_input = task.get('user_inputs', {})
+                
+                VSC_INFO("coordinator", "[%s] Picked up Task ID %s for booking...", inst.get_group_id(), task_id)
+
+                # 2. 执行预订
+                # 注意:插件的 book 方法需要接收 user_input
+                book_res = inst.book(result, user_input)
+
+                # 3. 处理结果
                 if book_res.success:
                     VSC_INFO("coordinator", "[%s] Booking SUCCESS! Order: '%s'", 
                              inst.get_group_id(), book_res.order_id)
+                    
+                    # 推送通知
                     if hasattr(self, 'push_callback_') and self.push_callback_:
                         self.push_callback_(100, f"Booking Success: {book_res.order_id}".encode('utf-8'), 0) 
+                    
+                    # 4. 成功逻辑:更新任务状态为 grabbed
+                    # 构造历史记录详情对象
+                    history_detail = {
+                        "slot_date": book_res.book_date,
+                        "slot_time": book_res.book_time,
+                        "account": inst.config.account.username if inst.config else "unknown",
+                        "timestamp": int(time.time()),
+                        "payment_link": book_res.payment_link,
+                        "order_id": book_res.order_id
+                    }
+
+                    update_data = {
+                        "status": "grabbed",
+                        # 修改点:grabbed_history 是一个字符串列表
+                        # 我们将详情对象序列化为 JSON 字符串放入列表中
+                        "grabbed_history": [json.dumps(history_detail)]
+                    }
+                    
+                    VSCloudApi.Instance().update_vas_task(task_id, update_data)
+                    VSC_INFO("coordinator", "[%s] Task %s marked as GRABBED.", inst.get_group_id(), task_id)
+                    
+                    # 成功后 task_id 置空,防止 finally 块再次将其重置为 pending
+                    task_id = None 
+
                 else:
+                    # 失败逻辑
                     error = inst.get_last_error()
                     VSC_ERROR("coordinator", "[%s] Booking FAILED. Code=%d, Msg=%s", 
                               inst.get_group_id(), error.error_code, error.error_message)
+                    # task_id 仍然存在,将由 finally 块处理回滚
+
             except Exception as e:
                 VSC_ERROR("coordinator", "[%s] Exception during booking: %s", inst.get_group_id(), str(e))
-        
-        # 1. 提交任务到线程池并获取 Future 对象
+            
+            finally:
+                # 5. Return to Queue (回滚机制)
+                if task_id is not None:
+                    VSC_WARN("coordinator", "[%s] Returning Task %s to queue (status=pending).", inst.get_group_id(), task_id)
+                    try:
+                        VSCloudApi.Instance().update_vas_task(task_id, {"status": "pending"})
+                    except Exception as ex:
+                        VSC_ERROR("coordinator", "[%s] Failed to return task to queue: %s", inst.get_group_id(), str(ex))
+
         futures = []
-        for inst in instances_for_booking:
-            f = self.book_executor.enqueue(book_task, inst, query_result)
-            futures.append(f)
+        f = self.book_executor.enqueue(book_task, sptr, query_result)
+        futures.append(f)
         
-        # 2. === 阻塞等待 ===
-        # wait 会阻塞当前线程 (Monitor Thread),直到所有 future 完成
-        # 这样确保了抢票期间不会发起新的 Query
-        VSC_INFO("coordinator", "[%s] Waiting for booking tasks to complete...", self.m_cfg.identifier)
-        wait(futures)
-        VSC_INFO("coordinator", "[%s] Booking tasks completed. Resuming monitor loop.", self.m_cfg.identifier)
+        wait(futures)

+ 3 - 3
main.py

@@ -199,7 +199,7 @@ def bls_test():
     # 这些字段会被 BlsPlugin 读取并用于填表和逻辑判断
     bls_config = {
         "domain": "ireland.blsspainglobal.com", # 目标域名
-        "ocr_service_url": "http://127.0.0.1:8085/predict/vfcode", # OCR 服务地址
+        "ocr_service_url": "http://127.0.0.1:8085/predict/vfcode?model=pytorch", # OCR 服务地址
         
         # 签证参数 (根据实际网站下拉框的值填写)
         "location": "Dublin",
@@ -245,7 +245,7 @@ def bls_test():
         need_account=True,
         account_pool="ie_es",           # 使用刚才注入的池子
         need_proxy=True,
-        proxy_pool="ireland_proxies",      # 使用默认代理池
+        proxy_pool="local",      # 使用默认代理池
         target_instances=1,             # 启动 1 个实例
         account_login_interval=5,       # 登录间隔/锁定时间
         query_wait=query_wait_config,
@@ -278,4 +278,4 @@ def bls_test():
         VSC_INFO("main", "Test Finished.")
 
 if __name__ == "__main__":
-    bls_test()
+    vfs_test()

+ 41 - 0
main_server.py

@@ -0,0 +1,41 @@
+# main_server.py
+import threading
+import time
+import os
+from core.app_manager import AppManager
+from web.server import run_web_server
+from vs_log_macros import VSC_INFO
+
+def main():
+    # 1. 确保目录存在
+    if not os.path.exists("config"): os.makedirs("config")
+    if not os.path.exists("plugins"): os.makedirs("plugins")
+    
+    # 2. 初始化 AppManager
+    manager = AppManager.Instance()
+    
+    # 3. 加载配置
+    VSC_INFO("main", "Loading configurations...")
+    manager.load_configs()
+    
+    # 4. 自动启动 enabled 的组
+    VSC_INFO("main", "Starting enabled groups...")
+    manager.start_all()
+    
+    # 5. 启动 Web Server (在主线程运行,或者新线程)
+    #由于 uvicorn.run 是阻塞的,我们直接在主线程跑 Web,
+    # 后台的 GroupCoordinators 已经在各自的线程里跑了。
+    VSC_INFO("main", "Starting Web API on port 8000...")
+    try:
+        run_web_server()
+    except KeyboardInterrupt:
+        pass
+    finally:
+        # 退出时清理
+        VSC_INFO("main", "Shutting down...")
+        # 停止所有组
+        for gid in list(manager.coordinators.keys()):
+            manager.stop_group(gid)
+
+if __name__ == "__main__":
+    main()

+ 81 - 60
plugins/bls_plugin.py

@@ -4,6 +4,7 @@ import time
 import json
 import random
 import string
+from pathlib import Path
 from urllib.parse import urlparse, parse_qs, urlencode
 from typing import Dict, List, Optional, Any
 
@@ -18,7 +19,7 @@ from vs_plg import IVSPlg, VSError # type: ignore
 from vs_types import VSPlgConfig, VSQueryResult, VSBookResult, AvailabilityStatus # type: ignore
 from vs_log_macros import VSC_INFO, VSC_ERROR, VSC_DEBUG, VSC_WARN # type: ignore
 from toolkit.vs_cloud_api import VSCloudApi # type: ignore
-
+from utils.browser_util import get_browser
 
 class BlsPlugin(IVSPlg):
     """
@@ -37,7 +38,8 @@ class BlsPlugin(IVSPlg):
         self.is_healthy = True
         
         # OCR 服务地址默认值
-        self.ocr_service_url = "http://127.0.0.1:8085/predict/vfcode"
+        self.ocr_service_url = "http://127.0.0.1:8085/predict/vfcode?model=pytorch"
+        self.browser = get_browser()
 
     def get_group_id(self) -> str:
         return self.group_id
@@ -78,12 +80,14 @@ class BlsPlugin(IVSPlg):
             curl_options={const.CurlOpt.MAXAGE_CONN: 1800, const.CurlOpt.VERBOSE: False}
         )
         domain = self.free_config.get("domain")
-        if not domain: return False
+        if not domain:
+            return False
 
         # 1.1 获取登录页 & 解析参数
         url = f"https://{domain}/Global/account/login"
         resp = self._request("GET", url)
-        if not resp: return False
+        if not resp:
+            return False
         
         soup = BeautifulSoup(resp.text, 'html.parser')
         form_data = self._extract_hidden_fields(soup)
@@ -99,11 +103,12 @@ class BlsPlugin(IVSPlg):
                 form_data["Password"] = re.search(r'\d+', iid).group(0)
         
         # 解析 data 参数 (用于验证码)
-        data_val = self._extract_js_var(resp.text, "iframeOpenUrl", r"data=([^&]+)")
+        data_val = self._extract_js_var(resp.text, "iframeOpenUrl", r"data=([^']+)")
         
         # 1.2 处理验证码
-        captcha_token = self._solve_bls_captcha(data_val, 'Global/account/login')
-        if not captcha_token: return False
+        captcha_token = self._solve_bls_captcha(data_val)
+        if not captcha_token:
+            return False
         
         # 1.3 提交登录
         submit_url = f"https://{domain}/Global/account/loginsubmit"
@@ -114,7 +119,7 @@ class BlsPlugin(IVSPlg):
         if "UserIdKey" in form_data: payload[form_data["UserIdKey"]] = self.config.account.username
         if "PasswordKey" in form_data: payload[form_data["PasswordKey"]] = self.config.account.password
         
-        login_res = self._request("POST", submit_url, data=payload, headers={"Referer": url})
+        login_res = self._request("POST", submit_url, data=payload)
         if login_res and login_res.json().get('success'):
             VSC_INFO("bls_plg", "[%s] Login Successful", self.group_id)
             return True
@@ -136,13 +141,13 @@ class BlsPlugin(IVSPlg):
         if not resp: return res
         
         form_vtv = self._extract_hidden_fields(BeautifulSoup(resp.text, 'html.parser'))
-        captcha_token = self._solve_bls_captcha(referer='Global/bls/visatypeverification')
+        captcha_token = self._solve_bls_captcha()
         if not captcha_token: return res
         
         form_vtv['CaptchaData'] = captcha_token
         form_vtv["X-Requested-With"] = "XMLHttpRequest"
         
-        vtv_res = self._request("POST", f"https://{domain}/Global/bls/VisaTypeVerification", data=form_vtv, headers={"Referer": url_vtv})
+        vtv_res = self._request("POST", f"https://{domain}/Global/bls/VisaTypeVerification", data=form_vtv)
         if not vtv_res or not vtv_res.json().get('success'): return res
         
         # 2.2 签证类型选择 (VisaType)
@@ -158,7 +163,7 @@ class BlsPlugin(IVSPlg):
         vt_payload = self._construct_visatype_payload(resp_vt.text, BeautifulSoup(resp_vt.text, 'html.parser'))
         if not vt_payload: return res
         
-        vt_res = self._request("POST", f"https://{domain}/Global/bls/VisaType", data=vt_payload, headers={"Referer": url_vt})
+        vt_res = self._request("POST", f"https://{domain}/Global/bls/VisaType", data=vt_payload)
         if not vt_res or not vt_res.json().get('success'):
             if vt_res and not vt_res.json().get('available'):
                 res.success = True
@@ -221,7 +226,6 @@ class BlsPlugin(IVSPlg):
                 "content-type": f"multipart/form-data; boundary={boundary}",
                 "requestverificationtoken": req_token,
                 "x-requested-with": "XMLHttpRequest",
-                "Referer": url_ma
             }
             body = (f"--{boundary}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"photo.jpg\"\r\n"
                     f"Content-Type: image/jpeg\r\n\r\n").encode("utf-8") + photo_bytes + f"\r\n--{boundary}--\r\n".encode("utf-8")
@@ -233,7 +237,7 @@ class BlsPlugin(IVSPlg):
         # 3.3 邮箱 OTP 流程
         data_val = self._extract_js_var(resp_ma.text, "win.iframeOpenUrl", r"data=([^&]+)")
         # 发送 OTP
-        self._request("GET", f"https://{domain}/Global/blsappointment/SendAppointmentVerificationCode?code={data_val}", headers={"Referer": url_ma, "X-Requested-With": "XMLHttpRequest"})
+        self._request("GET", f"https://{domain}/Global/blsappointment/SendAppointmentVerificationCode?code={data_val}", headers={"X-Requested-With": "XMLHttpRequest"})
         
         # 读取 OTP (Wait 30s max)
         otp_code = self._read_otp_email(wait_sec=30)
@@ -243,7 +247,7 @@ class BlsPlugin(IVSPlg):
             
         # 验证 OTP
         verify_payload = {"Code": otp_code, "Value": ma_form.get('EmailCode'), "Id": ma_form.get('Id')}
-        v_res = self._request("POST", f"https://{domain}/Global/blsappointment/VerifyEmail", data=verify_payload, headers={"Referer": url_ma, "requestverificationtoken": req_token})
+        v_res = self._request("POST", f"https://{domain}/Global/blsappointment/VerifyEmail", data=verify_payload, headers={"requestverificationtoken": req_token})
         if not v_res or not v_res.json().get('success'): return res
         
         ma_form['EmailVerified'] = 'True'
@@ -264,7 +268,7 @@ class BlsPlugin(IVSPlg):
             "dataSource": ma_form.get("DataSource"),
             "missionId": ma_form.get("MissionId")
         }
-        slots_res = self._request("POST", slot_url, params=slot_params, headers={"Referer": url_ma, "requestverificationtoken": req_token})
+        slots_res = self._request("POST", slot_url, params=slot_params, headers={"requestverificationtoken": req_token})
         if not slots_res: return res
         
         slots_data = sorted(slots_res.json(), key=lambda x: -x["Count"]) # 选剩余最多的
@@ -280,11 +284,11 @@ class BlsPlugin(IVSPlg):
         ma_form[f'AppointmentSlot{slot_id}'] = target_time
 
         # 3.5 再次验证码 & 提交 ManageAppointment
-        captcha_token = self._solve_bls_captcha(data_val, f'Global/blsAppointment/ManageAppointment?{urlencode(self.book_params)}')
+        captcha_token = self._solve_bls_captcha(data_val)
         if not captcha_token: return res
         ma_form['CaptchaData'] = captcha_token
         
-        final_ma_res = self._request("POST", f"https://{domain}/Global/BLSAppointment/ManageAppointment", data=ma_form, headers={"Referer": url_ma})
+        final_ma_res = self._request("POST", f"https://{domain}/Global/BLSAppointment/ManageAppointment", data=ma_form)
         if not final_ma_res: return res
         
         appt_model_id = final_ma_res.json().get('model', {}).get('Id')
@@ -329,7 +333,7 @@ class BlsPlugin(IVSPlg):
             VSC_WARN("bls_plg", f"Request Error: {e}")
         return None
 
-    def _solve_bls_captcha(self, data='', referer='') -> Optional[str]:
+    def _solve_bls_captcha(self, data='') -> Optional[str]:
         """
         验证码处理:获取图片 -> 调用远程 OCR 服务 -> 提交验证
         """
@@ -337,56 +341,73 @@ class BlsPlugin(IVSPlg):
         url = f"https://{domain}/Global/NewCaptcha/GenerateCaptcha"
         if data: url = f"https://{domain}/Global/CaptchaPublic/GenerateCaptcha?data={data}"
         
-        resp = self._request("GET", url, headers={"Referer": f"https://{domain}/{referer}"})
-        if not resp: return None
-        
-        soup = BeautifulSoup(resp.text, 'html.parser')
+        resp = self._request("GET", url)
+        if not resp:
+            return None
         
-        # 1. 提取目标数字
-        target_text = soup.get_text() 
-        target_match = re.search(r'Select\s*(\d+)', target_text)
-        if not target_match: return None
-        target_num = target_match.group(1)
+        with open("tmp.html", 'w') as f:
+            f.write(resp.text)
         
         selected_ids = []
-        
-        # 2. 遍历图片并调用远程 OCR
-        imgs = soup.find_all('img', class_='captcha-img')
-        for img in imgs:
-            src = img.get('src', '')
-            if 'base64,' in src:
-                b64 = src.split('base64,')[1]
-                img_bytes = base64.b64decode(b64)
-                
-                # === 远程调用 Start ===
-                try:
-                    # 直接发送二进制 body
-                    ocr_resp = requests.post(
-                        self.ocr_service_url, 
-                        data=img_bytes, 
-                        headers={"Content-Type": "application/octet-stream"},
-                        timeout=5
-                    )
-                    if ocr_resp.status_code == 200:
-                        res_json = ocr_resp.json()
-                        ocr_res = res_json.get('data', '').replace('$', '')
-                        
-                        VSC_DEBUG("bls_plg", f"OCR: {ocr_res} (Target: {target_num})")
-                        
-                        if ocr_res == target_num:
-                            selected_ids.append(img.get('id'))
-                except Exception as e:
-                    VSC_WARN("bls_plg", f"OCR Service Failed: {e}")
-                # === 远程调用 End ===
-        
-        if not selected_ids: return None 
-        
+        html_file_path = Path("tmp.html").resolve()
+        file_url = f'file://{html_file_path}'
+        self.browser.get(file_url)
+        captions_ele = self.browser.ele('xpath://*[@id="captcha-main-div"]/div/div[1]', timeout=5)
+        if not captions_ele:
+            return None
+        caption_eles = captions_ele.children()
+        caption_text = ''
+        for caption in caption_eles:
+            if not caption.states.is_covered:
+                caption_text = caption.text
+
+        numbers = re.findall(r'\d+', caption_text)[0]
+        captcha_images_ele = self.browser.ele('xpath://*[@id="captcha-main-div"]/div/div[2]')
+        captcha_image_eles = captcha_images_ele.children()
+        rect_dict = {}
+        for captcha_image in captcha_image_eles:
+            img = captcha_image.ele('.captcha-img')
+            if img.states.has_rect:
+                rect_dict[img._backend_id] = img.states.has_rect
+        for captcha_image in captcha_image_eles:
+            img = captcha_image.ele('.captcha-img')
+            if img.states.has_rect and img.states.is_covered == False:
+                img_src = img.attr('src')
+                if img_src and img_src.startswith('data:image'):
+                    base64_data = re.sub('^data:image/.+;base64,', '', img_src)
+                    img_bytes = base64.b64decode(base64_data)
+                    
+                    # === 远程调用 Start ===
+                    try:
+                        # 直接发送二进制 body
+                        ocr_resp = requests.post(
+                            self.ocr_service_url, 
+                            data=img_bytes, 
+                            headers={"Content-Type": "application/octet-stream"},
+                            timeout=5
+                        )
+                        if ocr_resp.status_code == 200:
+                            res_json = ocr_resp.json()
+                            ocr_res = res_json.get('data', '').replace('$', '')[:3]
+                            
+                            VSC_INFO("bls_plg", f'ocr captcha id={captcha_image.attr("id")} result={ocr_res}, target={numbers}')
+                            
+                            if ocr_res == numbers:
+                                eid = captcha_image.attr('id')
+                                selected_ids.append(eid)
+                    except Exception as e:
+                        VSC_WARN("bls_plg", f"OCR Service Failed: {e}")
+        if not selected_ids:
+            return None 
+        VSC_INFO("bls_plg", f'select_ids={selected_ids}')
+        soup = BeautifulSoup(resp.text, 'html.parser')
+
         # 3. 提交选中结果
         form = self._extract_hidden_fields(soup)
         form['SelectedImages'] = ",".join(selected_ids)
         submit_url = f"https://{domain}/Global/{'CaptchaPublic' if data else 'NewCaptcha'}/SubmitCaptcha"
         
-        res = self._request("POST", submit_url, data=form, headers={"X-Requested-With": "XMLHttpRequest", "Referer": url})
+        res = self._request("POST", submit_url, data=form, headers={"X-Requested-With": "XMLHttpRequest"})
         if res and res.json().get('captcha'):
             return res.json()['captcha']
         return None

+ 1 - 0
plugins/concrete_plugin.py

@@ -1,6 +1,7 @@
 # plugins/concrete_plugin.py
 import time
 import random
+from typing import Dict, List, Optional, Any
 from vs_plg import IVSPlg, VSError # type: ignore
 from vs_types import VSPlgConfig, VSQueryResult, VSBookResult, AvailabilityStatus, QueryWaitMode # type: ignore
 from vs_log_macros import VSC_INFO, VSC_ERROR, VSC_DEBUG, VSC_WARN # type: ignore

+ 1 - 1
plugins/tls_plugin.py

@@ -286,7 +286,7 @@ class TlsPlugin(IVSPlg):
             
             res.success = True
             res.city = embassy['city']
-            res.visa_type = "Short Stay"
+            res.visa_type = "Tourist"
             res.availability_status = AvailabilityStatus.NoneAvailable
             
             if available:

+ 71 - 71
plugins/vfs_plugin.py

@@ -46,6 +46,17 @@ COUNTRY_MAP = {
 def get_country_iso3(name: str) -> str:
     return COUNTRY_MAP.get(name.lower(), "CHN")
 
+def get_alias_email(email: str, new_domain: str = "gmail-app.com") -> str:
+    """
+    将邮箱域名替换为指定域名(默认 gmail-app.com)
+    """
+    if "@" not in email:
+        raise ValueError(f"Invalid email: {email}")
+
+    local_part, _ = email.rsplit("@", 1)
+    return f"{local_part}@{new_domain}"
+
+
 # ----------------- VfsPlugin 类 -----------------
 
 class VfsPlugin(IVSPlg):
@@ -251,26 +262,25 @@ class VfsPlugin(IVSPlg):
                 curr = curr.replace(month=curr.month + 1)
         return months
 
-    def book(self, slot_info: VSQueryResult) -> VSBookResult:
+    def book(self, slot_info: VSQueryResult, user_inputs) -> VSBookResult:
         """
         执行完整的预约流程 (对应 C++ VFSApi::book)
         包含:上传文档 -> 添加申请人 -> OTP -> 选时间 -> 锁定 -> 支付
         """
-        res = VSBookResult()
+        user_email = user_inputs.get('email', 'get_visa_666@example.com')
+        user_inputs['alias_email'] = get_alias_email(user_email, new_domain="gmail-app.com")
         
-        # 1. 准备配置和用户信息
-        # 这里的 uinfo 实际上应该从 Coordinator 传入或从 Config 获取,这里沿用之前的 helper
-        uinfo = self._prepare_user_info()
+        res = VSBookResult()
+        # 定位 Appointment Config
+        slot_routing_key = slot_info.routing_key
         
         # C++ 中 from_date 是入参,对应 Python 的 slot_info.earliest_date
         from_date = slot_info.earliest_date if slot_info.earliest_date else datetime.now().strftime("%d/%m/%Y")
         
-        # 定位 Appointment Config
-        target_routing_key = slot_info.routing_key 
         apt_config = None
         appt_types = self.free_config.get("appointmentType", [])
         for apt in appt_types:
-            if apt.get("routing_key") == target_routing_key or len(appt_types) == 1:
+            if apt.get("routingKey") == slot_routing_key or len(appt_types) == 1:
                 apt_config = apt
                 break
         
@@ -292,12 +302,12 @@ class VfsPlugin(IVSPlg):
         if ocr_enabled:
             VSC_INFO("vfs_plg", "[%s] OCR Enabled, uploading documents...", self.group_id)
             upload_res = {}
-            if not self._upload_applicant_documents(apt_config, uinfo, upload_res):
+            if not self._upload_applicant_documents(apt_config, user_inputs, upload_res):
                 return res
-            # 回填上传结果到 uinfo,供 add_primary_applicant 使用
-            uinfo["applicant_image"] = upload_res.get("passportImageFilename")
-            uinfo["applicant_image_data"] = upload_res.get("passportImageFileBytes") # Base64
-            uinfo["guid"] = upload_res.get("uploadDocumentGUID")
+            # 回填上传结果到 user_inputs,供 add_primary_applicant 使用
+            user_inputs["applicant_image"] = upload_res.get("passportImageFilename")
+            user_inputs["applicant_image_data"] = upload_res.get("passportImageFileBytes") # Base64
+            user_inputs["guid"] = upload_res.get("uploadDocumentGUID")
 
         # ---------------- 需要提供申请号 (Cover Letter) ----------------
         enable_reference_number = sub_conf.get("enableReferenceNumber", False)
@@ -313,7 +323,7 @@ class VfsPlugin(IVSPlg):
         
         while add_primary_retry < MAX_RETRY:
             urn = [] # 清空
-            if self._add_primary_applicant(apt_config, uinfo, is_waitlist, ocr_enabled, enable_reference_number, urn):
+            if self._add_primary_applicant(apt_config, user_inputs, is_waitlist, ocr_enabled, enable_reference_number, urn):
                 success_add = True
                 break
             
@@ -364,11 +374,11 @@ class VfsPlugin(IVSPlg):
 
         # ---------------- 规则引擎与日期筛选 (核心步骤 3) ----------------
         # C++: RuleEngine rule_engine(rules);
-        rules_str = uinfo.get("rules", "")
+        rules_str = user_inputs.get("rules", "")
         rule_engine = RuleEngine(rules_str)
         
-        expected_start = uinfo.get("expected_submit_start", "")
-        expected_end = uinfo.get("expected_submit_end", "")
+        expected_start = user_inputs.get("expected_start_date", "")
+        expected_end = user_inputs.get("expected_end_date", "")
         rule_engine.set_date_range_start(expected_start)
         rule_engine.set_date_range_end(expected_end)
         
@@ -504,7 +514,9 @@ class VfsPlugin(IVSPlg):
             if payment_url:
                 res.payment_link = payment_url
                 # 保存 Session (C++ save_http_session)
-                self._save_http_session(payment_url)
+                saved_session = self._save_http_session(payment_url)
+                if saved_session:
+                    res.session_id = saved_session['session_id']
         else:
             res.message = "Booking confirmed (No payment required)"
 
@@ -558,10 +570,10 @@ class VfsPlugin(IVSPlg):
             
         return False
     
-    def _upload_applicant_documents(self, apt_config, uinfo, res_out: Dict) -> bool:
+    def _upload_applicant_documents(self, apt_config, user_inputs, res_out: Dict) -> bool:
         """上传护照图片"""
         url = "https://lift-api.vfsglobal.com/appointment/UploadApplicantDocument"
-        passport_url = uinfo.get("passport_image_url")
+        passport_url = user_inputs.get("passport_image_url")
         if not passport_url:
             self._set_error(9007, "Missing passport_image_url")
             return False
@@ -609,7 +621,7 @@ class VfsPlugin(IVSPlg):
         except:
             return False
         
-    def _add_primary_applicant(self, apt_config: Dict[str, Any], uinfo: Dict[str, Any], 
+    def _add_primary_applicant(self, apt_config: Dict[str, Any], user_inputs: Dict[str, Any], 
                              is_waitlist: bool, ocr_enabled: bool, enable_ref: bool, 
                              urn_out_list: List[str]) -> bool:
         """
@@ -622,12 +634,12 @@ class VfsPlugin(IVSPlg):
 
         # --- 辅助 Helper: 映射性别 ---
         # C++: male/Male -> 1, 否则 -> 2
-        gender_str = str(uinfo.get("gender", "")).lower()
+        gender_str = str(user_inputs.get("gender", "")).lower()
         gender_code = 1 if gender_str == "male" else 2
 
         # --- 辅助 Helper: 获取 Dial Code ---
         # C++ 逻辑处理了 int 和 string
-        raw_dial = uinfo.get("phone_country_code", "86")
+        raw_dial = user_inputs.get("phone_country_code", "86")
         dial_code = str(raw_dial)
 
         # --- 辅助 Helper: 格式化日期 ---
@@ -639,8 +651,8 @@ class VfsPlugin(IVSPlg):
             except:
                 return d_str # 原样返回
 
-        dob = _to_ddmmyyyy(str(uinfo.get("birthday", "")))
-        ppt_exp = _to_ddmmyyyy(str(uinfo.get("passport_expiry_date", "")))
+        dob = _to_ddmmyyyy(str(user_inputs.get("birthday", "")))
+        ppt_exp = _to_ddmmyyyy(str(user_inputs.get("passport_expiry_date", "")))
 
         # --- 构造单个 Applicant 对象 ---
         # 对应 C++ 中庞大的 applicant JSON 构建
@@ -650,31 +662,31 @@ class VfsPlugin(IVSPlg):
             "loginUser": self.config.account.username,
             
             # 基本信息 (全部大写)
-            "firstName": str(uinfo.get("first_name", "")).upper(),
+            "firstName": str(user_inputs.get("first_name", "")).upper(),
             "middleName": "",
-            "lastName": str(uinfo.get("last_name", "")).upper(),
+            "lastName": str(user_inputs.get("last_name", "")).upper(),
             "employerFirstName": "",
             "employerLastName": "",
             "salutation": "",
             "gender": gender_code,
             
             # 联系信息
-            "contactNumber": str(uinfo.get("phone_no", "")),
+            "contactNumber": str(user_inputs.get("phone", "")),
             "dialCode": dial_code,
             "employerContactNumber": "",
             "employerDialCode": "",
-            "emailId": str(uinfo.get("alias_email", "")).upper(),
+            "emailId": str(user_inputs.get("alias_email", "")).upper(),
             "employerEmailId": "",
             
             # 证件信息
-            "passportNumber": str(uinfo.get("passport_no", "")).upper(),
+            "passportNumber": str(user_inputs.get("passport_no", "")).upper(),
             "confirmPassportNumber": "", # 通常留空
             "passportExpirtyDate": ppt_exp, # 注意拼写 Expirty 是 VFS API 的特征
             "dateOfBirth": dob,
             "nationalId": None,
             
             # 国籍 (使用全局辅助函数 get_country_iso3)
-            "nationalityCode": get_country_iso3(str(uinfo.get("nationality", ""))),
+            "nationalityCode": get_country_iso3(str(user_inputs.get("nationality", ""))),
             
             # 地址与其它 (大部分为空)
             "state": None,
@@ -720,16 +732,16 @@ class VfsPlugin(IVSPlg):
 
         # --- 处理 Reference Number (Cover Letter) ---
         if enable_ref:
-            applicant["referenceNumber"] = str(uinfo.get("cover_letter", ""))
+            applicant["referenceNumber"] = str(user_inputs.get("cover_letter", ""))
         else:
             applicant["referenceNumber"] = None
 
         # --- 处理 OCR 数据 ---
         if ocr_enabled:
-            # 必须从 uinfo 获取上传后返回的 metadata
-            applicant["applicantImage"] = str(uinfo.get("applicant_image", ""))
-            applicant["applicantImageData"] = str(uinfo.get("applicant_image_data", ""))
-            applicant["GUID"] = str(uinfo.get("guid", ""))
+            # 必须从 user_inputs 获取上传后返回的 metadata
+            applicant["applicantImage"] = str(user_inputs.get("applicant_image", ""))
+            applicant["applicantImageData"] = str(user_inputs.get("applicant_image_data", ""))
+            applicant["GUID"] = str(user_inputs.get("guid", ""))
 
         # --- 构造最外层 Payload ---
         payload = {
@@ -1025,9 +1037,9 @@ class VfsPlugin(IVSPlg):
             proxy_str += f"{self.config.proxy.ip}:{self.config.proxy.port}"
 
         # 2. 提交任务
-        task_out = {}
         VSC_INFO("vfs_plg", "[%s] Submitting Turnstile task for %s...", self.group_id, website_url)
-        if not VSCloudApi.Instance().submit_anti_turnstile_task(proxy_str, website_url, task_out):
+        task_out = VSCloudApi.Instance().submit_anti_turnstile_task(proxy_str, website_url)
+        if not task_out:
             self._set_error(9002, "Failed to submit captcha task to Cloud API")
             return ""
         
@@ -1041,8 +1053,8 @@ class VfsPlugin(IVSPlg):
         start_time = time.time()
         
         while time.time() - start_time < timeout:
-            result_out = {}
-            if not VSCloudApi.Instance().get_anti_turnstile_result(task_id, result_out):
+            result_out = VSCloudApi.Instance().get_anti_turnstile_result(task_id)
+            if not result_out:
                 # 获取结果的网络请求失败,稍后重试
                 time.sleep(3)
                 continue
@@ -1416,21 +1428,19 @@ class VfsPlugin(IVSPlg):
             content_out = [] # 用于接收结果的容器
             
             # 对应 C++: expiry = 5 * 60 (300秒)
-            status = VSCloudApi.Instance().fetch_mail_content(
+            content_out = VSCloudApi.Instance().fetch_mail_content(
                 master_email,
                 sender,
                 recipient,
                 subject_keywords,
                 body_keywords,
                 formatted_utc_time,
-                300,
-                content_out
+                300
             )
 
-            if status and content_out:
-                content = content_out[0]
+            if content_out:
                 # 4. 正则匹配 6位数字 (对应 C++ std::regex otp_pattern(R"(\b\d{6}\b)"))
-                match = re.search(r'\b\d{6}\b', content)
+                match = re.search(r'\b\d{6}\b', content_out)
                 if match:
                     otp = match.group(0)
                     VSC_INFO("vfs_plg", "[%s] OTP code found: %s", self.group_id, otp)
@@ -1528,24 +1538,6 @@ class VfsPlugin(IVSPlg):
         except Exception as e:
             self._set_error(9001, f"OTP Login parse error: {str(e)}")
             return False
-
-    def _prepare_user_info(self) -> Dict:
-        return {
-            "first_name": "Jiarui", 
-            "last_name": "Hu", 
-            "passport_no": "E91829352",
-            "phone_country_code": 86,
-            "phone_no": "17386033452", 
-            "email": "arket_zz@163.com",
-            "alias_email": "arket_zz@gmail-app.com",
-            "birthday": "1990-01-01", 
-            "passport_expiry_date": "2030-01-01",
-            "nationality": "China", 
-            "gender": "Male",
-            "expected_submit_start": "2026-01-01",
-            "expected_submit_end": "2026-03-15",
-            "rules": "time_filter = AM,PM\nallowed_weekdays = 1,2,3,4,5,6,7"
-        }
         
     def _submit_no_addition_service(self, urn) -> bool:
         url = "https://lift-api.vfsglobal.com/vas/mapvas"
@@ -1673,15 +1665,23 @@ class VfsPlugin(IVSPlg):
             session_id = hashes.Hash(hashes.SHA256(), backend=default_backend())
             session_id.update(raw.encode())
             sid = session_id.finalize().hex()
+              
+            proxy_str = ""
+            if self.config.proxy.ip:
+                proxy_str = f"{self.config.proxy.scheme}://"
+            if self.config.proxy.username:
+                proxy_str += f"{self.config.proxy.username}:{self.config.proxy.password}@"
+            proxy_str += f"{self.config.proxy.ip}:{self.config.proxy.port}"
             
-            dummy_res = {}
-            # 代理可能是 None,处理一下
-            proxy_ip = self.config.proxy.ip if self.config.proxy else ""
-            
-            VSCloudApi.Instance().create_http_session(
-                sid, cookies_str, "", ua_str, proxy_ip, page_url, dummy_res
+            saved_session = VSCloudApi.Instance().create_http_session(
+                sid, cookies_str, "", ua_str, proxy_str, page_url
             )
-            VSC_INFO("vfs_plg", "[%s] Session saved. ID: %s", self.group_id, sid)
+            if saved_session:
+                VSC_INFO("vfs_plg", "[%s] Session saved. ID: %s", self.group_id, sid)
+            else:
+                VSC_ERROR("vfs_plg", "[%s] Session save failed. ID: %s", self.group_id, sid)
+            return saved_session
         except Exception as e:
             # 捕获异常,确保即使保存 Session 失败,也不影响主流程返回预订结果
             VSC_WARN("vfs_plg", "[%s] Failed to save session to cloud: %s", self.group_id, str(e))
+        return None

+ 152 - 28
predict_server.py

@@ -1,5 +1,7 @@
-# predict_server.py
 import os
+# 屏蔽 ONNX Runtime 的警告日志
+os.environ["ORT_LOGGING_LEVEL"] = "3"
+
 import json
 import string
 import socket
@@ -7,14 +9,78 @@ import traceback
 from http.server import BaseHTTPRequestHandler, HTTPServer
 from io import BytesIO
 from collections import OrderedDict
+from urllib.parse import urlparse, parse_qs
+
+# 图像处理依赖
+import cv2
+import numpy as np
+from PIL import Image
 
 # 深度学习依赖
 import torch
 from torch import nn
 from torchvision import transforms
-from PIL import Image
 
-# ================= 定义模型结构 (保持不变) =================
+# ddddocr 依赖
+try:
+    import ddddocr
+    HAS_DDDDOCR = True
+except ImportError:
+    print("[WARNING] ddddocr not installed. Run 'pip install ddddocr'")
+    HAS_DDDDOCR = False
+
+# ================= 核心优化:图像去噪 =================
+def advanced_denoise(image_bytes):
+    """
+    针对 BLS 验证码的去噪流程:
+    1. 转灰度
+    2. 中值滤波 (关键:去除椒盐噪点)
+    3. 自适应二值化 (剥离彩色背景)
+    4. 连通域过滤 (去除残留的微小噪点)
+    """
+    try:
+        # 1. 字节流转 OpenCV 格式
+        nparr = np.frombuffer(image_bytes, np.uint8)
+        img = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
+        
+        # 2. 灰度化
+        gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
+        
+        # 3. 中值滤波 (Median Blur) - 去除椒盐噪声的神器
+        # ksize=3 表示 3x3 区域,能过滤掉独立的黑点,保留较粗的笔画
+        gray_blur = cv2.medianBlur(gray, 3)
+        
+        # 4. 自适应二值化
+        # 使用 Gaussian 方法,BlockSize=11, C=2 经验参数
+        binary = cv2.adaptiveThreshold(
+            gray_blur, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
+            cv2.THRESH_BINARY, 11, 2
+        )
+        
+        # 5. 连通域降噪 (Contour Filter)
+        # 找到所有的黑色块(文字和残留噪点)
+        # 注意:OpenCV findContours 找的是白色块,所以先反转
+        contours, _ = cv2.findContours(255 - binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
+        
+        # 创建一个纯白背景
+        clean_img = np.ones(binary.shape, dtype="uint8") * 255
+        
+        for cnt in contours:
+            area = cv2.contourArea(cnt)
+            # 过滤逻辑:保留面积在 30 到 1000 像素之间的色块 (文字)
+            # 小于 30 的通常是残留噪点,大于 1000 的可能是边框
+            if 30 < area < 1000:
+                cv2.drawContours(clean_img, [cnt], -1, 0, -1) # 在白底上画黑色文字
+
+        # 6. 转回 PIL Image
+        return Image.fromarray(clean_img)
+        
+    except Exception as e:
+        print(f"[Denoise] Error: {e}")
+        # 出错时回退到原始图片
+        return Image.open(BytesIO(image_bytes))
+
+# ================= PyTorch 模型结构 (保持不变) =================
 class Model(nn.Module):
     def __init__(self, n_classes, input_shape=(3, 64, 128)):
         super(Model, self).__init__()
@@ -57,8 +123,8 @@ class Model(nn.Module):
         x = self.fc(x)
         return x
 
-# ================= 推理类 =================
-class DeployModel:
+# ================= 引擎1: PyTorch =================
+class PyTorchEngine:
     def __init__(self, model_path):
         self.num_classes = 12
         self.characters = '-' + string.digits + '$'
@@ -69,9 +135,11 @@ class DeployModel:
         if os.path.exists(model_path):
             self.model.load_state_dict(torch.load(model_path, map_location=torch.device('cpu')))
             self.model.eval()
-            print(f"Model loaded successfully from {model_path}")
+            print(f"[PyTorch] Model loaded successfully from {model_path}")
+            self.ready = True
         else:
-            raise FileNotFoundError(f"Model file not found: {model_path}")
+            print(f"[PyTorch] Warning: Model file not found at {model_path}")
+            self.ready = False
 
         self.transforms_func = transforms.Compose([
             transforms.Resize((self.hight, self.width)),
@@ -88,12 +156,15 @@ class DeployModel:
                 last = x
         s2 = ''.join([x for x in s if x != self.characters[0]])
         return s2
-    
+
     def inference_bytes(self, image_bytes):
+        if not self.ready:
+            return "Error: Model not loaded"
         try:
-            image = Image.open(BytesIO(image_bytes))
-            if image.mode == 'RGBA':
-                image = image.convert('RGB')
+            # 使用高级去噪
+            image = advanced_denoise(image_bytes)
+            image = image.convert('RGB')
+            
             if self.transforms_func is not None:
                 image = self.transforms_func(image)
             with torch.no_grad():
@@ -103,35 +174,82 @@ class DeployModel:
             predict_label = self.decode(output_argmax[0])
             return predict_label
         except Exception as e:
-            print(f"Inference error: {e}")
+            print(f"[PyTorch] Inference error: {e}")
+            return ""
+
+# ================= 引擎2: DDDDOCR (已优化) =================
+class DddOcrEngine:
+    def __init__(self):
+        if HAS_DDDDOCR:
+            # show_ad=False 关闭广告, beta=True 启用旧版模型(通常对纯数字更稳)
+            self.ocr = ddddocr.DdddOcr(show_ad=False, beta=True)
+            print("[DDDDOCR] Initialized successfully")
+            self.ready = True
+        else:
+            print("[DDDDOCR] Library missing")
+            self.ready = False
+
+    def inference_bytes(self, image_bytes):
+        if not self.ready:
+            return "Error: ddddocr not installed"
+        try:
+            # 1. 预处理:去噪、二值化、过滤
+            img_pil = advanced_denoise(image_bytes)
+            
+            # 2. 转 bytes 传给 ddddocr
+            img_byte_arr = BytesIO()
+            img_pil.save(img_byte_arr, format='PNG')
+            processed_bytes = img_byte_arr.getvalue()
+            
+            # 3. 识别
+            res = self.ocr.classification(processed_bytes)
+            return res
+        except Exception as e:
+            print(f"[DDDDOCR] Inference error: {e}")
             return ""
 
 # ================= HTTP 处理 =================
-# 全局模型实例
-deploy_model = None
+engines = {}
 
 class RequestHandler(BaseHTTPRequestHandler):
-    
     def _send_response(self, status, content_type, content):
         self.send_response(status)
         self.send_header('Content-type', content_type)
         self.end_headers()
         self.wfile.write(content)
 
+    def log_message(self, format, *args):
+        # 屏蔽 HTTP 请求日志,只打印识别结果
+        return
+
     def do_POST(self):
-        if self.path == '/predict/vfcode':
+        parsed_path = urlparse(self.path)
+        path = parsed_path.path
+        query_params = parse_qs(parsed_path.query)
+        
+        # 默认使用 ddddocr,因为加上去噪后效果通常好于未针对性训练的 pytorch 模型
+        model_type = query_params.get('model', ['ddddocr'])[0]
+
+        if path == '/predict/vfcode':
             try:
-                # 获取内容长度
                 content_length = int(self.headers.get('Content-Length', 0))
                 if content_length == 0:
                     self._send_response(400, 'application/json', json.dumps({'code': 400, 'msg': 'Empty body'}).encode())
                     return
 
-                # 直接读取 Raw Binary 数据 (简化通信,避免 multipart 解析问题)
                 file_content = self.rfile.read(content_length)
 
-                # 推理
-                result_string = deploy_model.inference_bytes(file_content)
+                result_string = ""
+                if model_type == 'ddddocr':
+                    if 'ddddocr' in engines:
+                        result_string = engines['ddddocr'].inference_bytes(file_content)
+                    else:
+                        result_string = "Error: ddddocr not available"
+                else:
+                    if 'pytorch' in engines:
+                        result_string = engines['pytorch'].inference_bytes(file_content)
+                    else:
+                        result_string = "Error: pytorch model not available"
                 
                 response = {
                     'data': result_string,
@@ -139,7 +257,9 @@ class RequestHandler(BaseHTTPRequestHandler):
                     'code': 200
                 }
                 self._send_response(200, 'application/json', json.dumps(response).encode())
-                print(f"Processed request. Result: {result_string}")
+                
+                # 打印简洁的识别日志
+                print(f"[{model_type}] Result: {result_string}")
 
             except Exception as e:
                 traceback.print_exc()
@@ -149,20 +269,24 @@ class RequestHandler(BaseHTTPRequestHandler):
             self._send_response(404, 'text/plain', b'Not Found')
 
 if __name__ == '__main__':
-    # 配置区
     MODEL_PATH = 'data/ocr.pth'
     PORT = 8085
     
-    # 启动
-    if not os.path.exists(MODEL_PATH):
-        print(f"[ERROR] 请确保模型文件存在: {MODEL_PATH}")
-        exit(1)
+    # 1. PyTorch
+    pytorch_engine = PyTorchEngine(MODEL_PATH)
+    if pytorch_engine.ready:
+        engines['pytorch'] = pytorch_engine
         
-    deploy_model = DeployModel(MODEL_PATH)
+    # 2. ddddocr
+    ddd_engine = DddOcrEngine()
+    if ddd_engine.ready:
+        engines['ddddocr'] = ddd_engine
     
-    server_address = ('0.0.0.0', PORT) # 监听所有接口
+    server_address = ('0.0.0.0', PORT)
     httpd = HTTPServer(server_address, RequestHandler)
     print(f'OCR Server running on port {PORT}...')
+    print(f'Active engines: {list(engines.keys())}')
+    
     try:
         httpd.serve_forever()
     except KeyboardInterrupt:

+ 0 - 52
test.py

@@ -1,52 +0,0 @@
-import requests
-
-# 目标 URL
-url = "https://ireland.blsspainglobal.com/Global/account/login"
-
-# 请求头
-headers = {
-  'accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7',
-  'accept-language': 'zh-CN,zh;q=0.9',
-  'upgrade-insecure-requests': '1',
-  'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36',
-  'Cookie': '.AspNetCore.Antiforgery.oY6EC-RUIyA=CfDJ8P1AkyeYcD1Ite9P68eByJroaIfpxao3RR7eDNqGHFCzilNlvksu3Lm1hSGPhPfSnGJdMIWBtHWKKamv82f-fWknhJkdW1gH5X_OEuMWGrlDGJiwOcCOmdSzaTH14Yh6lveEAlEaUhblp38cJ_gaUFc'
-}
-
-# 待测试的代理列表
-proxy_list = [
-    "9zMOkhCng5HG8SZ:RTBuPWx1CEr6DfD@95.135.130.73:48306",
-    "aNCaMFjblyODleO:5i4lV3VjNwE4bkL@95.135.130.76:41553"
-]
-
-print(f"{'Proxy IP':<30} | {'Status':<10} | {'Result'}")
-print("-" * 60)
-
-for proxy_str in proxy_list:
-    # 构造 requests 库需要的 proxy 字典格式
-    # 格式为: http://user:password@ip:port
-    proxies = {
-        "http": f"http://{proxy_str}",
-        "https": f"http://{proxy_str}"
-    }
-
-    try:
-        # 发送请求,设置超时时间为 10 秒,防止死等
-        response = requests.get(url, headers=headers, proxies=proxies, timeout=10)
-        
-        status_code = response.status_code
-        
-        if status_code == 200:
-            result = "✅ 正常 (Normal)"
-        elif status_code == 403:
-            result = "🚫 封禁 (Blocked 403)"
-        else:
-            result = f"⚠️ 其他 ({status_code})"
-            
-        print(f"{proxy_str.split('@')[1]:<30} | {status_code:<10} | {result}")
-
-    except requests.exceptions.ProxyError:
-        print(f"{proxy_str.split('@')[1]:<30} | Error      | ❌ 代理连接失败")
-    except requests.exceptions.ConnectTimeout:
-        print(f"{proxy_str.split('@')[1]:<30} | Timeout    | ❌ 连接超时")
-    except Exception as e:
-        print(f"{proxy_str.split('@')[1]:<30} | Error      | ❌ 发生错误: {str(e)[:20]}")

BIN
test/image.png


+ 78 - 0
test/image_ocr_test.py

@@ -0,0 +1,78 @@
+import requests
+import os
+import sys
+
+def test_ocr_service(image_path, server_url="http://127.0.0.1:8085/predict/vfcode"):
+    """
+    读取本地图片并发送给 OCR 服务进行识别
+    """
+    # 1. 检查文件是否存在
+    if not os.path.exists(image_path):
+        print(f"[错误] 找不到文件: {image_path}")
+        return
+
+    print(f"[INFO] 正在读取图片: {image_path}")
+    print(f"[INFO] 目标服务器: {server_url}")
+
+    try:
+        # 2. 读取图片二进制数据
+        with open(image_path, 'rb') as f:
+            image_data = f.read()
+
+        # 3. 发送 POST 请求
+        # 注意:这里直接发送二进制流 (data=image_data),配合之前的 predict_server.py 使用
+        # 如果服务端要求 multipart/form-data,则需要用 files={'file': f}
+        start_time = time.time()
+        response = requests.post(
+            server_url, 
+            data=image_data,
+            headers={"Content-Type": "application/octet-stream"}, # 标识为二进制流
+            timeout=10
+        )
+        cost_time = (time.time() - start_time) * 1000
+
+        # 4. 解析响应
+        if response.status_code == 200:
+            result = response.json()
+            ocr_text = result.get('data', '').replace('$', '') # 去除可能存在的结束符
+            
+            print("-" * 40)
+            print(f"✅ 识别成功")
+            print(f"📸 结果: {ocr_text}")
+            print(f"⏱️ 耗时: {cost_time:.2f} ms")
+            print(f"📩 原始响应: {result}")
+            print("-" * 40)
+        else:
+            print(f"[失败] 服务器返回状态码: {response.status_code}")
+            print(f"响应内容: {response.text}")
+
+    except requests.exceptions.ConnectionError:
+        print("[错误] 无法连接到服务器。请确认 predict_server.py 正在运行且端口正确。")
+    except Exception as e:
+        print(f"[错误] 发生异常: {e}")
+
+if __name__ == "__main__":
+    import time
+    
+    # --- 配置 ---
+    SERVER_URL = "http://127.0.0.1:8085/predict/vfcode"
+    
+    # 默认测试图片路径 (请修改为你本地实际存在的验证码图片路径)
+    # 你可以放一张图片在当前目录下,命名为 test.jpg
+    DEFAULT_IMAGE = "output.png" 
+
+    # 支持命令行参数: python test_ocr_client.py my_image.png
+    if len(sys.argv) > 1:
+        image_file = sys.argv[1]
+    else:
+        image_file = DEFAULT_IMAGE
+
+    # 如果默认图片不存在,尝试在 test 目录下找一个
+    if not os.path.exists(image_file):
+        if os.path.exists("test/562_xxxx.gif"): # 假设你的项目结构里有这个
+            image_file = "test/562_xxxx.gif"
+        elif os.path.exists("data/captcha.jpg"):
+            image_file = "data/captcha.jpg"
+
+    # 开始测试
+    test_ocr_service(image_file, SERVER_URL)

BIN
test/output.png


+ 7 - 0
test/test.py

@@ -0,0 +1,7 @@
+import cv2
+import numpy as np
+
+img = cv2.imread('image.png')
+# 使用中值滤波去噪,核大小为3
+dst = cv2.medianBlur(img, 3)
+cv2.imwrite('output.png', dst)

+ 97 - 74
toolkit/account_manager.py

@@ -1,106 +1,129 @@
-# toolkit/account_manager.py
 import threading
 import time
+import json
+import os
 import random
-from typing import Optional, Dict, Any
-from vs_log_macros import VSC_DEBUG, VSC_WARN, VSC_INFO # type: ignore
+from typing import Optional, Dict, Any, List
+from vs_log_macros import VSC_DEBUG, VSC_WARN, VSC_INFO, VSC_ERROR # type: ignore
 
 class AccountManager:
-    """模拟账户管理器,用于获取和锁定账户。"""
+    """
+    账户管理器 (仅本地配置文件模式)
+    读取 config/accounts.json
+    """
     _instance = None
-    _lock = threading.Lock()
+    _lock = threading.RLock()
 
     def __new__(cls):
         with cls._lock:
             if cls._instance is None:
                 cls._instance = super().__new__(cls)
-                cls._instance._accounts = {
-                    "ie_nl": [
-                        {
-                            "id": 580,
-                            "lock_until": 0,
-                            "password": "y1wm@hf0Bn",
-                            "username": "sg_fr_onoy9i@gmail-app.com"
-                        },
-                        {
-                            "id": 581,
-                            "lock_until": 0,
-                            "password": "p7@@0NnOR8",
-                            "username": "sg_fr_k6vuhb@gmail-app.com"
-                        },
-                        {
-                            "id": 582,
-                            "lock_until": 0,
-                            "password": "D@4OCh2j3I",
-                            "username": "sg_fr_cvq08b@gmail-app.com"
-                        },
-                        {
-                            "id": 583,
-                            "lock_until": 0,
-                            "password": "xT01gsHw@j",
-                            "username": "sg_fr_4cn9xj@gmail-app.com"
-                        },
-                        {
-                            "id": 584,
-                            "lock_until": 0,
-                            "password": "@aprZh7NbA",
-                            "username": "sg_fr_m32a2e@gmail-app.com"
-                        },
-                        {
-                            "id": 585,
-                            "lock_until": 0,
-                            "password": "SR2hnX@ho5",
-                            "username": "sg_fr_p0nz5l@gmail-app.com"
-                        }
-                    ],
-                    "gb_fr": [
-                        {
-                            "id": 0,
-                            "username":"arket_zz@163.com",
-                            "password": "Visafly@111",
-                            "lock_until": 0
-                        },
-                    ],
-                    "ie_es": [
-                        {
-                            "id": 0,
-                            "username":"arket_zz@163.com",
-                            "password": "dx4ua@!.X.i8Xn8",
-                            "lock_until": 0
-                        },
-                    ]
-                }
-                cls._instance._account_lock = threading.Lock() # 用于保护账户状态
+                cls._instance._init_data()
             return cls._instance
 
     @staticmethod
     def Instance():
         return AccountManager()
 
+    def _init_data(self):
+        self._accounts: Dict[str, List[Dict]] = {} # pool_name -> [account_dict]
+        self._account_lock = threading.RLock()
+        self._config_path = "config/accounts.json"
+        
+        # 初始化时加载
+        self.reload_config()
+
+    def reload_config(self):
+        """(重新)加载本地配置文件"""
+        if not os.path.exists(self._config_path):
+            VSC_WARN("acc_mgr", f"Config file not found: {self._config_path}. Account pools are empty.")
+            return
+
+        try:
+            with open(self._config_path, 'r', encoding='utf-8') as f:
+                data = json.load(f)
+            
+            count = 0
+            with self._account_lock:
+                self._accounts.clear()
+                for pool_name, acc_list in data.items():
+                    processed_list = []
+                    for acc in acc_list:
+                        # 确保必要字段存在,并初始化状态
+                        if "id" not in acc or "username" not in acc:
+                            continue
+                            
+                        acc.setdefault('lock_until', 0)
+                        # bound_data 用于存储绑定的预约人信息,配置文件里可以是 null
+                        acc.setdefault('bound_data', None) 
+                        
+                        processed_list.append(acc)
+                        count += 1
+                    
+                    self._accounts[pool_name] = processed_list
+                    
+            VSC_INFO("acc_mgr", f"Loaded {count} accounts from {self._config_path}")
+            
+        except json.JSONDecodeError:
+            VSC_ERROR("acc_mgr", f"Invalid JSON format in {self._config_path}")
+        except Exception as e:
+            VSC_ERROR("acc_mgr", f"Failed to load config: {e}")
+
     def get_next_account(self, pool_name: str) -> Optional[Dict[str, Any]]:
-        """从指定池中获取下一个可用账户。"""
+        """
+        获取下一个可用账号 (随机策略)
+        """
         with self._account_lock:
             accounts = self._accounts.get(pool_name, [])
+            if not accounts:
+                VSC_WARN("acc_mgr", "No accounts found for pool '%s'", pool_name)
+                return None
+
             now = time.time()
+            # 筛选未锁定的账号
+            available = [acc for acc in accounts if acc.get("lock_until", 0) <= now]
             
-            available_accounts = [acc for acc in accounts if acc["lock_until"] <= now]
-            if not available_accounts:
-                VSC_WARN("account_manager", "No available accounts in pool '%s'", pool_name)
+            if not available:
+                VSC_DEBUG("acc_mgr", "Pool '%s' has %d accounts but all are locked.", pool_name, len(accounts))
                 return None
+
+            # 随机选择
+            selected = random.choice(available)
             
-            # 简单地随机选择一个可用账户
-            account = random.choice(available_accounts)
-            VSC_DEBUG("account_manager", "Selected account ID %d from pool '%s'", account["id"], pool_name)
-            return account
+            VSC_DEBUG("acc_mgr", "Selected account %s (id=%s) from pool %s", 
+                      selected.get("username"), selected.get("id"), pool_name)
+            return selected
 
     def lock_account(self, pool_name: str, account_id: int, duration_seconds: int):
-        """锁定指定账户一段时间。"""
+        """
+        锁定账号一段时间
+        """
         with self._account_lock:
             accounts = self._accounts.get(pool_name, [])
             for acc in accounts:
                 if acc["id"] == account_id:
                     acc["lock_until"] = time.time() + duration_seconds
-                    VSC_INFO("account_manager", "Locked account ID %d in pool '%s' until %s", 
-                             account_id, pool_name, time.ctime(acc["lock_until"]))
+                    VSC_INFO("acc_mgr", "Locked account %s (id=%d) for %ds", 
+                             acc.get("username"), account_id, duration_seconds)
                     return
-            VSC_WARN("account_manager", "Account ID %d not found in pool '%s' for locking", account_id, pool_name)
+            VSC_WARN("acc_mgr", "Account %d not found in pool %s to lock", account_id, pool_name)
+
+    def remove_account(self, pool_name: str, account_id: int, reason: str = "success", extra_data: dict = None):
+        """
+        从内存中移除账号 (预订成功后不再使用)
+        注意:这不会修改磁盘上的 accounts.json 文件,重启程序后账号会恢复
+        """
+        with self._account_lock:
+            accounts = self._accounts.get(pool_name, [])
+            target_acc = None
+            for acc in accounts:
+                if acc["id"] == account_id:
+                    target_acc = acc
+                    break
+            
+            if target_acc:
+                accounts.remove(target_acc)
+                VSC_INFO("acc_mgr", "Removed account %s (id=%d) from memory pool %s. Reason: %s", 
+                         target_acc.get("username"), account_id, pool_name, reason)
+            else:
+                VSC_WARN("acc_mgr", "Attempt to remove non-existent account %d from pool %s", account_id, pool_name)

+ 81 - 21
toolkit/binding_manager.py

@@ -1,59 +1,119 @@
-# toolkit/binding_manager.py
 import threading
-from typing import List, Optional
-from vs_log_macros import VSC_DEBUG, VSC_INFO # type: ignore
+import json
+import os
+from typing import List, Optional, Tuple, Dict, Any
+from vs_log_macros import VSC_DEBUG, VSC_INFO, VSC_WARN, VSC_ERROR # type: ignore
 
 class BindingManager:
-    """模拟账户-代理绑定管理器。"""
+    """
+    绑定管理器 (支持从配置文件加载静态绑定 + 运行时动态绑定)
+    读取 config/bindings.json
+    """
     _instance = None
-    _lock = threading.Lock()
+    _lock = threading.RLock()
 
     def __new__(cls):
         with cls._lock:
             if cls._instance is None:
                 cls._instance = super().__new__(cls)
-                cls._instance._bindings = {} # { (account_pool, account_id): (proxy_pool, proxy_id, bind_type) }
-                cls._instance._binding_lock = threading.Lock()
+                cls._instance._init_data()
             return cls._instance
 
     @staticmethod
     def Instance():
         return BindingManager()
 
+    def _init_data(self):
+        # Key: (account_pool, account_id)
+        # Value: (proxy_pool, proxy_id, bind_type)
+        self._bindings: Dict[Tuple[str, int], Tuple[str, int, str]] = {}
+        self._binding_lock = threading.RLock()
+        self._config_path = "config/bindings.json"
+        
+        self.reload_config()
+
+    def reload_config(self):
+        """加载本地绑定配置 (通常用于静态绑定)"""
+        if not os.path.exists(self._config_path):
+            # 绑定文件不存在是正常的,可能全是动态绑定
+            VSC_DEBUG("binding_mgr", f"Config file not found: {self._config_path}. No static bindings loaded.")
+            return
+
+        try:
+            with open(self._config_path, 'r', encoding='utf-8') as f:
+                data = json.load(f)
+            
+            if not isinstance(data, list):
+                VSC_ERROR("binding_mgr", "Invalid JSON format: bindings must be a list")
+                return
+
+            count = 0
+            with self._binding_lock:
+                # 策略:不清除运行时生成的动态绑定,仅覆盖/添加配置文件中的静态绑定
+                # 如果需要重置,可以先 self._bindings.clear()
+                
+                for item in data:
+                    # 校验字段
+                    if not all(k in item for k in ("account_pool", "account_id", "proxy_pool", "proxy_id")):
+                        continue
+                    
+                    key = (item["account_pool"], item["account_id"])
+                    val = (item["proxy_pool"], item["proxy_id"], item.get("bind_type", "static"))
+                    
+                    self._bindings[key] = val
+                    count += 1
+                    
+            VSC_INFO("binding_mgr", f"Loaded {count} static bindings from {self._config_path}")
+            
+        except json.JSONDecodeError:
+            VSC_ERROR("binding_mgr", f"Invalid JSON format in {self._config_path}")
+        except Exception as e:
+            VSC_ERROR("binding_mgr", f"Failed to load bindings: {e}")
+
     def get_bounded_proxy_id(self, account_pool: str, account_id: int) -> Optional[int]:
-        """获取给定账户绑定的代理ID。"""
+        """获取给定账户绑定的代理ID"""
         with self._binding_lock:
             key = (account_pool, account_id)
             binding = self._bindings.get(key)
             if binding:
-                VSC_DEBUG("binding_manager", "Found binding for account %d: proxy %d", account_id, binding[1])
-                return binding[1]
-            VSC_DEBUG("binding_manager", "No binding found for account %d in pool %s", account_id, account_pool)
+                proxy_pool, proxy_id, b_type = binding
+                VSC_DEBUG("binding_mgr", "Found %s binding: Acc(%s:%d) -> Proxy(%s:%d)", 
+                          b_type, account_pool, account_id, proxy_pool, proxy_id)
+                return proxy_id
+            
+            VSC_DEBUG("binding_mgr", "No binding found for Acc(%s:%d)", account_pool, account_id)
             return None
     
     def get_bounded_proxies_ids(self, account_pool: str, proxy_pool: str) -> List[int]:
-        """获取所有在特定代理池中被账户绑定的代理ID列表。"""
+        """
+        获取所有在特定代理池中被账户绑定的代理ID列表。
+        用于 ProxyManager 过滤掉已被占用的代理。
+        """
         with self._binding_lock:
             bounded_ids = []
-            for (acc_pool, _), (p_pool, p_id, _) in self._bindings.items():
-                if acc_pool == account_pool and p_pool == proxy_pool:
-                    bounded_ids.append(p_id)
-            VSC_DEBUG("binding_manager", "Bounded proxy IDs in pool %s for acc pool %s: %s", proxy_pool, account_pool, bounded_ids)
+            for (acc_p, _), (px_p, px_id, _) in self._bindings.items():
+                if acc_p == account_pool and px_p == proxy_pool:
+                    bounded_ids.append(px_id)
+            
+            # VSC_DEBUG("binding_mgr", "Bounded IDs in %s (for %s): %s", proxy_pool, account_pool, bounded_ids)
             return bounded_ids
 
     def create_binding(self, account_pool: str, account_id: int, 
                        proxy_pool: str, proxy_id: int, bind_type: str):
-        """创建账户和代理的绑定。"""
+        """
+        创建绑定 (运行时调用,如动态绑定)
+        注意:运行时创建的绑定目前仅存在内存中,重启后丢失(除非写入文件,当前版本暂不实现回写)
+        """
         with self._binding_lock:
             key = (account_pool, account_id)
             self._bindings[key] = (proxy_pool, proxy_id, bind_type)
-            VSC_INFO("binding_manager", "Created binding: account %d (pool %s) -> proxy %d (pool %s) type %s",
-                     account_id, account_pool, proxy_id, proxy_pool, bind_type)
+            VSC_INFO("binding_mgr", "Created binding: Acc(%s:%d) -> Proxy(%s:%d) [%s]",
+                     account_pool, account_id, proxy_pool, proxy_id, bind_type)
 
     def remove_binding(self, account_pool: str, account_id: int):
-        """移除账户和代理的绑定。"""
+        """移除绑定"""
         with self._binding_lock:
             key = (account_pool, account_id)
             if key in self._bindings:
                 del self._bindings[key]
-                VSC_INFO("binding_manager", "Removed binding for account %d in pool %s", account_id, account_pool)
+                VSC_INFO("binding_mgr", "Removed binding for Acc(%s:%d)", account_pool, account_id)

+ 86 - 151
toolkit/proxy_manager.py

@@ -1,199 +1,134 @@
-# toolkit/proxy_manager.py
 import threading
 import time
+import json
+import os
 import random
 from typing import List, Optional, Dict, Any
-from vs_log_macros import VSC_DEBUG, VSC_WARN, VSC_INFO # type: ignore
+from vs_log_macros import VSC_DEBUG, VSC_WARN, VSC_INFO, VSC_ERROR # type: ignore
 
 class ProxyManager:
-    """模拟代理管理器,用于获取和锁定代理。"""
+    """
+    代理管理器 (仅本地配置文件模式)
+    读取 config/proxies.json
+    """
     _instance = None
-    _lock = threading.Lock()
+    _lock = threading.RLock()
 
     def __new__(cls):
         with cls._lock:
             if cls._instance is None:
                 cls._instance = super().__new__(cls)
-                cls._instance._proxies = {
-                    "global_proxy": [
-                        {
-                            "id": 100003,
-                            "ip": "82.152.15.37",
-                            "lock_until": 0,
-                            "password": "idzlar",
-                            "port": 7778,
-                            "scheme": "http",
-                            "username": "4scepfs03sh920"
-                        },
-                        {
-                            "id": 100005,
-                            "ip": "82.152.19.246",
-                            "lock_until": 0,
-                            "password": "idzlar",
-                            "port": 7778,
-                            "scheme": "http",
-                            "username": "4scepfmv4gp9c"
-                        },
-                        {
-                            "id": 100014,
-                            "ip": "82.152.15.33",
-                            "lock_until": 0,
-                            "password": "idzlar",
-                            "port": 7778,
-                            "scheme": "http",
-                            "username": "4scepfpclja918"
-                        },
-                        {
-                            "id": 100018,
-                            "ip": "82.152.19.4",
-                            "lock_until": 0,
-                            "password": "idzlar",
-                            "port": 7778,
-                            "scheme": "http",
-                            "username": "4scepfmv4gp914"
-                        },
-                        {
-                            "id": 100019,
-                            "ip": "82.152.15.8",
-                            "lock_until": 0,
-                            "password": "idzlar",
-                            "port": 7778,
-                            "scheme": "http",
-                            "username": "4scepfmv4gp913"
-                        },
-                        {
-                            "id": 100020,
-                            "ip": "82.152.19.141",
-                            "lock_until": 0,
-                            "password": "idzlar",
-                            "port": 7778,
-                            "scheme": "http",
-                            "username": "4scepfmv4gp912"
-                        },
-                        {
-                            "id": 100023,
-                            "ip": "82.152.19.2",
-                            "lock_until": 0,
-                            "password": "idzlar",
-                            "port": 7778,
-                            "scheme": "http",
-                            "username": "4scepfmv4gp9e"
-                        },
-                        {
-                            "id": 100025,
-                            "ip": "163.5.40.236",
-                            "lock_until": 0,
-                            "password": "idzlar",
-                            "port": 7778,
-                            "scheme": "http",
-                            "username": "4scekff9u6j9a"
-                        },
-                        {
-                            "id": 100026,
-                            "ip": "45.196.65.44",
-                            "lock_until": 0,
-                            "password": "idzlar",
-                            "port": 7778,
-                            "scheme": "http",
-                            "username": "4sj3agscn8592cf"
-                        },
-                        {
-                            "id": 100027,
-                            "ip": "154.82.173.108",
-                            "lock_until": 0,
-                            "password": "M8v0m9b1p4b3",
-                            "port": 9856,
-                            "scheme": "http",
-                            "username": "l9w3z3c4B4O2"
-                        },
-                        {
-                            "id": 100028,
-                            "ip": "45.196.65.25",
-                            "lock_until": 0,
-                            "password": "idzlar",
-                            "port": 7778,
-                            "scheme": "http",
-                            "username": "4sio3rdic7m9184"
-                        },
-                        {
-                            "id": 100029,
-                            "ip": "45.196.65.109",
-                            "lock_until": 0,
-                            "password": "idzlar",
-                            "port": 7778,
-                            "scheme": "http",
-                            "username": "4sibp4qi26n93c"
-                        }
-                    ],
-                    "ireland_proxies": [
-                        {
-                            "id": 100029,
-                            "ip": "95.135.130.73",
-                            "lock_until": 0,
-                            "password": "RTBuPWx1CEr6DfD",
-                            "port": 48306,
-                            "scheme": "http",
-                            "username": "9zMOkhCng5HG8SZ"
-                        },
-                        {
-                            "id": 100029,
-                            "ip": "95.135.130.76",
-                            "lock_until": 0,
-                            "password": "5i4lV3VjNwE4bkL",
-                            "port": 41553,
-                            "scheme": "http",
-                            "username": "aNCaMFjblyODleO"
-                        }
-                    ]
-                }
-                cls._instance._proxy_lock = threading.Lock() # 用于保护代理状态
+                cls._instance._init_data()
             return cls._instance
 
     @staticmethod
     def Instance():
         return ProxyManager()
 
+    def _init_data(self):
+        self._proxies: Dict[str, List[Dict]] = {} # pool_name -> [proxy_dict]
+        self._proxy_lock = threading.RLock()
+        self._config_path = "config/proxies.json"
+        
+        self.reload_config()
+
+    def reload_config(self):
+        """(重新)加载本地配置文件"""
+        if not os.path.exists(self._config_path):
+            VSC_WARN("proxy_mgr", f"Config file not found: {self._config_path}. Proxy pools are empty.")
+            return
+
+        try:
+            with open(self._config_path, 'r', encoding='utf-8') as f:
+                data = json.load(f)
+            
+            count = 0
+            with self._proxy_lock:
+                self._proxies.clear()
+                for pool_name, proxy_list in data.items():
+                    processed_list = []
+                    for p in proxy_list:
+                        # 校验必要字段
+                        if "id" not in p or "ip" not in p or "port" not in p:
+                            continue
+                        
+                        # 初始化状态
+                        p.setdefault('lock_until', 0)
+                        p.setdefault('scheme', 'http')
+                        p.setdefault('username', '')
+                        p.setdefault('password', '')
+                        
+                        processed_list.append(p)
+                        count += 1
+                    
+                    self._proxies[pool_name] = processed_list
+                    
+            VSC_INFO("proxy_mgr", f"Loaded {count} proxies from {self._config_path}")
+            
+        except json.JSONDecodeError:
+            VSC_ERROR("proxy_mgr", f"Invalid JSON format in {self._config_path}")
+        except Exception as e:
+            VSC_ERROR("proxy_mgr", f"Failed to load proxy config: {e}")
+
     def get_next_proxy(self, pool_name: str) -> Optional[Dict[str, Any]]:
-        """从指定池中获取下一个可用代理。"""
+        """
+        从指定池中获取下一个可用代理 (随机)
+        """
         with self._proxy_lock:
             proxies = self._proxies.get(pool_name, [])
-            now = time.time()
+            if not proxies:
+                VSC_WARN("proxy_mgr", "No proxies found in pool '%s'", pool_name)
+                return None
             
+            now = time.time()
             available_proxies = [p for p in proxies if p["lock_until"] <= now]
+            
             if not available_proxies:
-                VSC_WARN("proxy_manager", "No available proxies in pool '%s'", pool_name)
+                VSC_WARN("proxy_mgr", "Pool '%s' has proxies but all are locked/busy.", pool_name)
                 return None
             
             proxy = random.choice(available_proxies)
-            VSC_DEBUG("proxy_manager", "Selected proxy ID %d from pool '%s'", proxy["id"], pool_name)
+            VSC_DEBUG("proxy_mgr", "Selected proxy ID %d (%s) from pool '%s'", 
+                      proxy["id"], proxy["ip"], pool_name)
             return proxy
 
     def get_unbind_proxy(self, pool_name: str, bounded_ids: List[int]) -> Optional[Dict[str, Any]]:
-        """获取一个未绑定(且未锁定)的代理。"""
+        """
+        获取一个未绑定(且未锁定)的代理。
+        用于 GroupCoordinator 的 IP 绑定逻辑。
+        """
         with self._proxy_lock:
             proxies = self._proxies.get(pool_name, [])
+            if not proxies:
+                VSC_WARN("proxy_mgr", "No proxies found in pool '%s'", pool_name)
+                return None
+
             now = time.time()
             
-            unbound_and_available_proxies = [
+            # 筛选条件:1. 未锁定  2. ID 不在已绑定列表中
+            unbound_and_available = [
                 p for p in proxies 
                 if p["id"] not in bounded_ids and p["lock_until"] <= now
             ]
-            if not unbound_and_available_proxies:
-                VSC_WARN("proxy_manager", "No unbind and available proxies in pool '%s'", pool_name)
+            
+            if not unbound_and_available:
+                VSC_WARN("proxy_mgr", "No unbound and available proxies in pool '%s'", pool_name)
                 return None
             
-            proxy = random.choice(unbound_and_available_proxies)
-            VSC_DEBUG("proxy_manager", "Selected unbind proxy ID %d from pool '%s'", proxy["id"], pool_name)
+            proxy = random.choice(unbound_and_available)
+            VSC_DEBUG("proxy_mgr", "Selected unbound proxy ID %d from pool '%s'", proxy["id"], pool_name)
             return proxy
 
     def lock_proxy(self, pool_name: str, proxy_id: int, duration_seconds: int):
-        """锁定指定代理一段时间。"""
+        """
+        锁定指定代理一段时间 (例如请求过于频繁被 429)
+        """
         with self._proxy_lock:
             proxies = self._proxies.get(pool_name, [])
             for p in proxies:
                 if p["id"] == proxy_id:
                     p["lock_until"] = time.time() + duration_seconds
-                    VSC_INFO("proxy_manager", "Locked proxy ID %d in pool '%s' until %s",
-                             proxy_id, pool_name, time.ctime(p["lock_until"]))
+                    VSC_INFO("proxy_mgr", "Locked proxy ID %d in pool '%s' for %ds",
+                             proxy_id, pool_name, duration_seconds)
                     return
-            VSC_WARN("proxy_manager", "Proxy ID %d not found in pool '%s' for locking", proxy_id, pool_name)
+            VSC_WARN("proxy_mgr", "Proxy ID %d not found in pool '%s' for locking", proxy_id, pool_name)

+ 147 - 121
toolkit/vs_cloud_api.py

@@ -8,17 +8,17 @@ from vs_log_macros import VSC_ERROR, VSC_INFO, VSC_DEBUG # type: ignore
 
 class VSCloudApi:
     """
-    @brief VSCloudApi 的 Python 实现 (1:1 对应 C++ 版本)
-    用于对接 http://45.137.220.138:8888 的云端服务
+    @brief VSCloudApi 的 Python 实现
+    用于对接云端服务 (打码、邮件、Session存储、任务调度)
     """
     _instance = None
 
     def __new__(cls, *args, **kwargs):
         if cls._instance is None:
             cls._instance = super(VSCloudApi, cls).__new__(cls)
-            # 初始化默认配置,对应 C++ 构造函数参数
-            cls._instance.base_url = "http://45.137.220.138:8000"
-            cls._instance.api_token = "7x9EjFpmv7GjZc6AfVeqxuUBANpqkpkHAtxJM7CAW5oZhs0nEyCJBy39N4XXs5hgfYWXw3jFrcgXqQ42HAx9Qvwtk9vC2GvKBbWz"
+            # 初始化默认配置
+            cls._instance.base_url = "http://45.137.220.138:8888"
+            cls._instance.api_token = "Bearer tok_8cb26cf337cb405784eb346dfafb7f54"
             cls._instance.session = requests.Session()
         return cls._instance
 
@@ -33,59 +33,68 @@ class VSCloudApi:
             "Accept": "application/json, text/plain, */*"
         }
 
-    def _handle_request(self, method: str, endpoint: str, **kwargs) -> Optional[Any]:
-        url = f"{self.base_url}{endpoint}"
+    # =========================================================================
+    #  VAS Task Management (新增 API)
+    # =========================================================================
+
+    def get_vas_task_pop(self, routing_key: str) -> Optional[Dict]:
+        """
+        获取任务列表
+        API: GET /api/vas/task/list
+        """
+        url = f"{self.base_url}/api/vas/task/pop"
+        params = {
+            "queue_name": routing_key,
+        }
+
+        headers = self._get_headers()
+        
         try:
-            resp = self.session.request(method, url, timeout=30, **kwargs)
+            resp = self.session.get(url, params=params, headers=headers, timeout=30)
             if resp.status_code == 200:
-                try:
-                    return resp.json()
-                except json.JSONDecodeError:
-                    # 部分接口可能返回纯文本或空,视情况而定
-                    return resp.text
+                result = resp.json()
+                if result.get("code") == 0:
+                    return result.get("data", {})
+                else:
+                    VSC_ERROR("vs_cloud", f"get_vas_task_list biz error: {result.get('message')}")
             else:
-                VSC_ERROR("vs_cloud", f"Request failed: {method} {url} [{resp.status_code}] {resp.text}")
+                VSC_ERROR("vs_cloud", f"get_vas_task_list failed: {resp.status_code} {resp.text[:100]}")
         except Exception as e:
-            VSC_ERROR("vs_cloud", f"Request exception: {method} {url} - {str(e)}")
-        return None
-
-    def query_user_pending(self, tech_provider: str, pending_users_out: Dict[str, Any]) -> bool:
-        """查询待处理用户"""
-        params = {"tech_provider": tech_provider}
-        headers = self._get_headers()
-        # C++: headers = curl_slist_append(headers, auth.c_str());
-        
-        result = self._handle_request("GET", "/api/autobooking/pending", params=params, headers=headers)
-        if result is not None:
-            pending_users_out.update(result)
-            return True
-        return False
-
-    def query_user_info(self, uid: str, userinfo_out: Dict[str, Any]) -> bool:
-        """查询 VFS 用户信息"""
-        headers = self._get_headers()
-        result = self._handle_request("GET", f"/api/autobooking/{uid}", headers=headers)
-        if result is not None:
-            userinfo_out.update(result)
-            return True
+            VSC_ERROR("vs_cloud", f"get_vas_task_list exception: {e}")
         return False
 
-    def update_user_info(self, uid: str, uinfo: Dict[str, Any], updated_userinfo_out: Dict[str, Any]) -> bool:
-        """更新 VFS 用户信息"""
+    def update_vas_task(self, 
+                        task_id: int, 
+                        update_data: Dict[str, Any]) -> Optional[Dict]:
+        """
+        更新任务
+        API: POST /api/vas/task/update?id=1
+        Body: update_data (json)
+        """
+        url = f"{self.base_url}/api/vas/task/update"
+        params = {"id": task_id}
         headers = self._get_headers()
-        # C++: if (!uinfo.is_null()) put_data = uinfo.dump();
         
-        result = self._handle_request("PUT", f"/api/autobooking/{uid}", headers=headers, json=uinfo)
-        if result is not None:
-            updated_userinfo_out.update(result)
-            return True
-        return False
+        try:
+            resp = self.session.post(url, params=params, json=update_data, headers=headers, timeout=30)
+            if resp.status_code == 200:
+                result = resp.json()
+                if result.get("code") == 0:
+                    return result.get("data", {})
+                else:
+                    VSC_ERROR("vs_cloud", f"update_vas_task biz error: {result.get('message')}")
+            else:
+                VSC_ERROR("vs_cloud", f"update_vas_task failed: {resp.status_code} {resp.text[:100]}")
+        except Exception as e:
+            VSC_ERROR("vs_cloud", f"update_vas_task exception: {e}")
+                
+        return None
 
-    def submit_anti_turnstile_task(self, proxy: str, website_url: str, task_out: Dict[str, Any]) -> bool:
+    def submit_anti_turnstile_task(self, proxy: str, website_url: str) -> Optional[Dict]:
         """
         提交反 Turnstile 任务
-        注意 C++ 逻辑:args 字段是 JSON dump 后的字符串
         """
+        url = f"{self.base_url}/api/tasks"
         headers = self._get_headers()
         
         args = {
@@ -95,32 +104,46 @@ class VSCloudApi:
         
         payload = {
             "command": "AntiCloudflareTurnstileTask",
-            "args": json.dumps(args), # C++: data["args"] = args.dump();
+            "args": json.dumps(args), 
             "status": 0
         }
 
-        result = self._handle_request("POST", "/api/tasks", headers=headers, json=payload)
-        if result is not None:
-            task_out.update(result)
-            return True
-        return False
+        try:
+            resp = self.session.post(url, headers=headers, json=payload, timeout=30)
+            if resp.status_code == 200:
+                result = resp.json()
+                if result.get("code") == 0:
+                    return result.get("data", {})
+                else:
+                    VSC_ERROR("vs_cloud", f"update_vas_task biz error: {result.get('message')}")
+            else:
+                VSC_ERROR("vs_cloud", f"submit_anti_turnstile_task failed: {resp.status_code} {resp.text[:100]}")
+        except Exception as e:
+            VSC_ERROR("vs_cloud", f"submit_anti_turnstile_task exception: {e}")
+        return None
 
-    def get_anti_turnstile_result(self, task_id: str, result_out: Dict[str, Any]) -> bool:
+    def get_anti_turnstile_result(self, task_id: str) -> Optional[Dict]:
         """获取反 Turnstile 结果"""
+        url = f"{self.base_url}/api/tasks/{task_id}"
         headers = self._get_headers()
-        result = self._handle_request("GET", f"/api/tasks/{task_id}", headers=headers)
-        if result is not None:
-            result_out.update(result)
-            return True
-        return False
+        try:
+            resp = self.session.get(url, headers=headers, timeout=30)
+            if resp.status_code == 200:
+                result = resp.json()
+                if result.get("code") == 0:
+                    return result.get("data", {})
+                else:
+                    VSC_ERROR("vs_cloud", f"update_vas_task biz error: {result.get('message')}")
+            else:
+                VSC_ERROR("vs_cloud", f"get_anti_turnstile_result failed: {resp.status_code} {resp.text[:100]}")
+        except Exception as e:
+            VSC_ERROR("vs_cloud", f"get_anti_turnstile_result exception: {e}")
+        return None
     
     def submit_anticloudflare_task(self, proxy: str, website_url: str) -> Optional[Dict]:
         """
         提交 AntiCloudflareTask (用于 TLSContact 5s 盾)
         """
-        # 注意:这里路径根据之前的 C++ 移植经验是 /api/tasks,
-        # 如果你确定是 /common/api/tasks,请修改这里。为了兼容现有 base_url,我使用 /api/tasks
-        # url = f"https://{self._base_url}/common/api/tasks" 
         url = f"{self.base_url}/api/tasks" 
         
         args = {
@@ -128,20 +151,21 @@ class VSCloudApi:
             'websiteUrl': website_url
         }
         data = {
-            "command": "AntiCloudflareTask", # 关键:Command 名字不同
+            "command": "AntiCloudflareTask", 
             "args": json.dumps(args),
             "status": 0
         }
-        headers = {
-            'Authorization': self.api_token,
-            'Content-Type': 'application/json'
-        }
+        headers = self._get_headers()
         try:
             response = self.session.post(url, headers=headers, json=data, timeout=30)
-            if response.status_code != 200:
-                VSC_ERROR("vs_cloud", f"submit_anticloudflare_task failed: {response.status_code}")
-                return None
-            return response.json()
+            if response.status_code == 200:
+                result = response.json()
+                if result.get("code") == 0:
+                    return result.get("data", {})
+                else:
+                    VSC_ERROR("vs_cloud", f"update_vas_task biz error: {result.get('message')}")
+            else:
+                VSC_ERROR("vs_cloud", f"get_anti_turnstile_result failed: {response.status_code} {response.text[:100]}")
         except Exception as e:
             VSC_ERROR("vs_cloud", f"submit_anticloudflare_task exception: {e}")
         return None
@@ -151,26 +175,27 @@ class VSCloudApi:
         获取 AntiCloudflareTask 结果 (带轮询)
         """
         url = f"{self.base_url}/api/tasks/{task_id}"
-        headers = {
-            'Authorization': self.api_token
-        }
+        headers = self._get_headers()
         try:
             for attempt in range(max_retries):
                 response = self.session.get(url, headers=headers, timeout=30)
+                
                 if response.status_code == 200:
-                    data = response.json()
-                    # status 2 表示成功
-                    if data.get("status") == 2:
-                        return data
-                    elif data.get("status") == 3:
-                        VSC_ERROR("vs_cloud", f"AntiCloudflareTask failed: {data.get('result')}")
-                        return None
+                    result = response.json()
+                    if result.get("code") == 0:
+                        data = result.get("data", {})
+                        # status 2 表示成功
+                        if data.get("status") == 2:
+                            return data
+                        elif data.get("status") == 3:
+                            VSC_ERROR("vs_cloud", f"AntiCloudflareTask failed: {data.get('result')}")
+                            return None
+                        else:
+                            time.sleep(retry_interval)
                     else:
-                        # VSC_DEBUG("vs_cloud", f"Task {task_id} not ready, retrying...")
-                        time.sleep(retry_interval)
+                        VSC_ERROR("vs_cloud", f"update_vas_task biz error: {result.get('message')}")
                 else:
-                    VSC_ERROR("vs_cloud", f"Error getting task result: {response.text}")
-                    break
+                    VSC_ERROR("vs_cloud", f"get_anti_turnstile_result failed: {response.status_code} {response.text[:100]}")
             VSC_ERROR("vs_cloud", "Max retries reached, AntiCloudflareTask not completed.")
         except Exception as e:
             VSC_ERROR("vs_cloud", f"get_anticloudflare_result exception: {e}")
@@ -183,10 +208,10 @@ class VSCloudApi:
         local_storage: str, 
         user_agent: str, 
         proxy: str, 
-        page: str, 
-        created_session_out: Dict[str, Any]
-    ) -> bool:
+        page: str
+    ) -> Optional[Dict]:
         """创建 http session"""
+        url = f"{self.base_url}/api/http-session"
         headers = self._get_headers()
         
         payload = {
@@ -198,13 +223,19 @@ class VSCloudApi:
             "session_id": session_id
         }
         
-        result = self._handle_request("POST", "/api/http-session", headers=headers, json=payload)
-        if result is not None:
-            # result 可能是 None 如果解析失败,或者是一个 dict
-            if isinstance(result, dict):
-                created_session_out.update(result)
-            return True
-        return False
+        try:
+            resp = self.session.post(url, headers=headers, json=payload, timeout=30)
+            if resp.status_code == 200:
+                result = resp.json()
+                if result.get("code") == 0:
+                    return result.get("data", {})
+                else:
+                    VSC_ERROR("vs_cloud", f"update_vas_task biz error: {result.get('message')}")
+            else:
+                VSC_ERROR("vs_cloud", f"create_http_session failed: {resp.status_code} {resp.text[:100]}")
+        except Exception as e:
+            VSC_ERROR("vs_cloud", f"create_http_session exception: {e}")
+        return None
 
     def fetch_mail_content(
         self,
@@ -214,12 +245,10 @@ class VSCloudApi:
         subject_keywords: str,
         body_keywords: str,
         sent_date: str,
-        expiry: int,
-        content_out: list # Python string is immutable, use list to simulate pointer behavior like C++
-    ) -> bool:
+        expiry: int
+    ) -> Optional[str]:
         """
         获取邮件内容
-        C++ logic: query params in URL + POST method + empty body
         """
         params = {
             "email": email,
@@ -231,26 +260,23 @@ class VSCloudApi:
             "expiry": str(expiry)
         }
         
-        headers = self._get_headers()
-        # C++: curl_easy_setopt(curl, CURLOPT_POST, 1L); curl_easy_setopt(curl, CURLOPT_POSTFIELDS, "");
-        
-        # requests.post(url, params=params) 会把 params 拼接到 URL 后面
         url = f"{self.base_url}/api/email-authorizations/fetch"
+        headers = self._get_headers()
         try:
             resp = self.session.post(url, headers=headers, params=params, data="", timeout=30)
             if resp.status_code == 200:
-                # C++: content.assign(response_string.data(), response_string.length());
-                # 这里假设 content_out 是一个 list,我们将结果 append 进去或修改第一个元素
-                if isinstance(content_out, list):
-                    content_out.clear()
-                    content_out.append(resp.text)
-                return True
+                result = resp.json()
+                if result.get('code', 0) == 0:
+                    data = result.get('data', {})
+                    return data.get('body', "")
+                else:
+                    VSC_ERROR("vs_cloud", f"fetch_mail_content biz error: {result.get('message')}")
             else:
-                VSC_ERROR("vs_cloud", f"fetch_mail_content failed: {resp.status_code} {resp.text}")
+                VSC_ERROR("vs_cloud", f"fetch_mail_content failed: {resp.status_code} {resp.text[:100]}")
         except Exception as e:
             VSC_ERROR("vs_cloud", f"fetch_mail_content exception: {e}")
             
-        return False
+        return None
 
     def fetch_mail_content_from_top(
         self,
@@ -259,9 +285,8 @@ class VSCloudApi:
         recipient: str,
         subject_keywords: str,
         body_keywords: str,
-        top: int,
-        content_out: list
-    ) -> bool:
+        top: int
+    ) -> Optional[str]:
         """从顶部获取邮件内容"""
         params = {
             "email": email,
@@ -272,19 +297,20 @@ class VSCloudApi:
             "top": str(top)
         }
         
-        headers = self._get_headers()
         url = f"{self.base_url}/api/email-authorizations/fetch-top"
+        headers = self._get_headers()
         
         try:
             resp = self.session.post(url, headers=headers, params=params, data="", timeout=30)
             if resp.status_code == 200:
-                if isinstance(content_out, list):
-                    content_out.clear()
-                    content_out.append(resp.text)
-                return True
+                result = resp.json()
+                if result.get('code', 0) == 0:
+                    data = result.get('data', {})
+                    return data.get('body', "")
+                else:
+                    VSC_ERROR("vs_cloud", f"fetch_mail_content_from_top biz error: {result.get('message')}")
             else:
-                VSC_ERROR("vs_cloud", f"fetch_mail_content_from_top failed: {resp.status_code} {resp.text}")
+                VSC_ERROR("vs_cloud", f"fetch_mail_content_from_top failed: {resp.status_code} {resp.text[:100]}")
         except Exception as e:
             VSC_ERROR("vs_cloud", f"fetch_mail_content_from_top exception: {e}")
-            
-        return False
+        return None

+ 0 - 0
utils/__init__.py


+ 26 - 0
utils/browser_util.py

@@ -0,0 +1,26 @@
+from DrissionPage import ChromiumPage, ChromiumOptions
+
+
+def get_browser():
+    options = ChromiumOptions()
+    options.set_local_port(28888)
+    options.set_argument(arg='--disable-notifications', value=True)
+    options.set_argument(arg='--disable-extensions', value=False)
+    options.set_argument(arg='--disable-background-networking', value=True)
+    options.set_argument(arg='--disable-gpu', value=True)
+    options.set_argument(arg='--dns-prefetch-disable', value=True)
+    options.set_argument(arg='--disable-infobars', value=True)
+    options.set_argument(arg='--disable-dev-shm-usage', value=True)
+    options.set_argument(arg='--enable-automation', value=False)
+    options.set_argument(arg='--log-level', value='3')
+    options.set_argument(arg='--start-maximized', value=True)
+    options.set_argument(arg='--ignore-ssl-errors', value=True)
+    options.set_argument(arg='--no-sandbox')
+    options.headless(False)
+    options.incognito(False)
+    driver = ChromiumPage(addr_or_opts=options)
+    driver.set.window.max()
+    return driver
+
+def quit_browser(driver):
+    driver.quit()

+ 2 - 1
vs_plg.py

@@ -47,10 +47,11 @@ class IVSPlg(ABC):
         pass
 
     @abstractmethod
-    def book(self, slot_info: VSQueryResult) -> VSBookResult:
+    def book(self, slot_info: VSQueryResult, user_inputs) -> VSBookResult:
         """
         @brief 进行预约操作
         @param slot_info 查询得到的可用时段信息
+        @param user_inputs 用户输入的预约信息
         @return VSBookResult 预约结果
         """
         pass

+ 95 - 0
web/server.py

@@ -0,0 +1,95 @@
+# web/server.py
+import os
+from pathlib import Path
+from fastapi import FastAPI, HTTPException
+from fastapi.staticfiles import StaticFiles
+from fastapi.responses import FileResponse
+from pydantic import BaseModel
+from core.app_manager import AppManager
+from vs_log_macros import VSC_INFO, VSC_ERROR
+
+app = FastAPI(title="Visa Plugin Manager")
+
+# === 1. 路径计算 (关键修改) ===
+# 获取 web/server.py 所在的绝对路径目录
+CURRENT_DIR = Path(__file__).resolve().parent
+# 拼接出 static 目录: .../web/static
+STATIC_DIR = CURRENT_DIR / "static"
+# 拼接出 index.html 路径
+INDEX_FILE = STATIC_DIR / "index.html"
+
+VSC_INFO("web", f"Static Directory configured at: {STATIC_DIR}")
+VSC_INFO("web", f"Index File expected at: {INDEX_FILE}")
+
+# 确保目录存在
+if not STATIC_DIR.exists():
+    VSC_INFO("web", "Static directory missing, creating...")
+    STATIC_DIR.mkdir(parents=True, exist_ok=True)
+
+# === 2. 挂载静态文件 ===
+# 挂载 /static 路径,用于访问 CSS/JS 等资源 (虽然本例是CDN,但保留以备用)
+app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
+
+# === 3. 根路径路由 ===
+@app.get("/")
+async def read_index():
+    # 显式检查文件是否存在,方便调试
+    if not INDEX_FILE.exists():
+        return {
+            "error": "Index file not found",
+            "expected_path": str(INDEX_FILE),
+            "tip": "Please ensure 'index.html' exists in the 'web/static' folder."
+        }
+    return FileResponse(str(INDEX_FILE))
+
+# === 数据模型 ===
+class GroupControl(BaseModel):
+    group_id: str
+
+class OTARequest(BaseModel):
+    plugin_name: str
+
+# === API 接口 ===
+
+@app.get("/status")
+def get_status():
+    return {"data": AppManager.Instance().get_status()}
+
+@app.post("/reload_config")
+def reload_config():
+    AppManager.Instance().load_configs()
+    return {"message": "Configuration reloaded"}
+
+@app.post("/start")
+def start_group(payload: GroupControl):
+    if AppManager.Instance().start_group(payload.group_id):
+        return {"message": f"Group {payload.group_id} started"}
+    raise HTTPException(status_code=400, detail="Failed to start group")
+
+@app.post("/stop")
+def stop_group(payload: GroupControl):
+    if AppManager.Instance().stop_group(payload.group_id):
+        return {"message": f"Group {payload.group_id} stopped"}
+    raise HTTPException(status_code=400, detail="Group not running or failed to stop")
+
+@app.post("/restart")
+def restart_group(payload: GroupControl):
+    if AppManager.Instance().restart_group(payload.group_id):
+        return {"message": f"Group {payload.group_id} restarted"}
+    raise HTTPException(status_code=400, detail="Failed to restart")
+
+@app.post("/ota")
+def ota_update(payload: OTARequest):
+    try:
+        restarted = AppManager.Instance().ota_update_plugin(payload.plugin_name)
+        return {
+            "message": f"Plugin {payload.plugin_name} reloaded",
+            "restarted_groups": restarted
+        }
+    except Exception as e:
+        raise HTTPException(status_code=500, detail=str(e))
+
+def run_web_server(host="0.0.0.0", port=8000):
+    import uvicorn
+    # log_level warning 减少控制台刷屏
+    uvicorn.run(app, host=host, port=port, log_level="info")

+ 205 - 0
web/static/index.html

@@ -0,0 +1,205 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>Visa Plugin Manager</title>
+    <!-- 引入 Tailwind CSS -->
+    <script src="https://cdn.tailwindcss.com"></script>
+    <!-- 引入 Vue 3 -->
+    <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
+    <!-- 引入 Axios -->
+    <script src="https://unpkg.com/axios/dist/axios.min.js"></script>
+    <style>
+        [v-cloak] { display: none; }
+    </style>
+</head>
+<body class="bg-gray-100 min-h-screen font-sans">
+    <div id="app" v-cloak class="container mx-auto px-4 py-8">
+        
+        <!-- 头部 -->
+        <header class="flex justify-between items-center mb-8 bg-white p-6 rounded-lg shadow-md">
+            <div>
+                <h1 class="text-2xl font-bold text-gray-800">Visa Plugin Manager</h1>
+                <p class="text-gray-500 text-sm mt-1">Status Monitor & Control Panel</p>
+            </div>
+            <div class="flex gap-3">
+                <button @click="reloadConfig" :disabled="loading" class="bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded shadow transition flex items-center">
+                    <span v-if="loading">...</span>
+                    <span v-else>Reload Config</span>
+                </button>
+                <button @click="fetchStatus" :disabled="loading" class="bg-gray-200 hover:bg-gray-300 text-gray-700 px-4 py-2 rounded shadow transition">
+                    Refresh Status
+                </button>
+            </div>
+        </header>
+
+        <!-- OTA 面板 -->
+        <div class="mb-8 bg-white p-6 rounded-lg shadow-md">
+            <h2 class="text-lg font-semibold mb-4 text-gray-700 border-b pb-2">OTA Plugin Update</h2>
+            <div class="flex gap-4 items-end">
+                <div class="flex-1">
+                    <label class="block text-sm font-medium text-gray-700 mb-1">Plugin Name</label>
+                    <input v-model="otaPluginName" type="text" placeholder="e.g. bls_plugin" class="w-full border-gray-300 border rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-indigo-500">
+                    <p class="text-xs text-gray-500 mt-1">Make sure you have replaced the .py file in plugins/ directory first.</p>
+                </div>
+                <button @click="triggerOTA" :disabled="!otaPluginName || loading" class="bg-purple-600 hover:bg-purple-700 text-white px-6 py-2 rounded shadow transition mb-[2px]">
+                    Hot Reload Plugin
+                </button>
+            </div>
+        </div>
+
+        <!-- 任务组列表 -->
+        <div class="bg-white rounded-lg shadow-md overflow-hidden">
+            <div class="p-6 border-b border-gray-200 flex justify-between items-center">
+                <h2 class="text-lg font-semibold text-gray-700">Task Groups</h2>
+                <span class="text-sm bg-gray-100 text-gray-600 px-3 py-1 rounded-full">{{ groups.length }} Groups</span>
+            </div>
+            
+            <div class="overflow-x-auto">
+                <table class="w-full text-left border-collapse">
+                    <thead>
+                        <tr class="bg-gray-50 text-gray-600 text-sm uppercase tracking-wider">
+                            <th class="px-6 py-4 font-medium">Group ID</th>
+                            <th class="px-6 py-4 font-medium">Plugin</th>
+                            <th class="px-6 py-4 font-medium">Pool</th>
+                            <th class="px-6 py-4 font-medium text-center">Instances</th>
+                            <th class="px-6 py-4 font-medium text-center">Status</th>
+                            <th class="px-6 py-4 font-medium text-right">Actions</th>
+                        </tr>
+                    </thead>
+                    <tbody class="divide-y divide-gray-200">
+                        <tr v-for="g in groups" :key="g.id" class="hover:bg-gray-50 transition">
+                            <td class="px-6 py-4 font-medium text-gray-900">{{ g.id }}</td>
+                            <td class="px-6 py-4 text-gray-600 font-mono text-sm">{{ g.plugin }}</td>
+                            <td class="px-6 py-4 text-gray-600">{{ g.account_pool }}</td>
+                            <td class="px-6 py-4 text-center">
+                                <span class="inline-block px-3 py-1 bg-blue-100 text-blue-800 rounded-full text-xs font-bold">
+                                    {{ g.instances }}
+                                </span>
+                            </td>
+                            <td class="px-6 py-4 text-center">
+                                <span v-if="g.running" class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
+                                    <span class="w-2 h-2 mr-1 bg-green-500 rounded-full"></span>
+                                    Running
+                                </span>
+                                <span v-else class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">
+                                    <span class="w-2 h-2 mr-1 bg-red-500 rounded-full"></span>
+                                    Stopped
+                                </span>
+                            </td>
+                            <td class="px-6 py-4 text-right space-x-2">
+                                <button v-if="!g.running" @click="controlGroup(g.id, 'start')" class="text-green-600 hover:text-green-900 font-medium text-sm hover:underline">Start</button>
+                                <button v-if="g.running" @click="controlGroup(g.id, 'restart')" class="text-orange-600 hover:text-orange-900 font-medium text-sm hover:underline">Restart</button>
+                                <button v-if="g.running" @click="controlGroup(g.id, 'stop')" class="text-red-600 hover:text-red-900 font-medium text-sm hover:underline">Stop</button>
+                            </td>
+                        </tr>
+                        <tr v-if="groups.length === 0">
+                            <td colspan="6" class="px-6 py-8 text-center text-gray-500">No groups loaded. Check config/groups.json</td>
+                        </tr>
+                    </tbody>
+                </table>
+            </div>
+        </div>
+    </div>
+
+    <script>
+        const { createApp, ref, onMounted } = Vue;
+
+        createApp({
+            setup() {
+                const groups = ref([]);
+                const loading = ref(false);
+                const otaPluginName = ref("");
+
+                // 获取状态
+                const fetchStatus = async () => {
+                    try {
+                        loading.value = true;
+                        const res = await axios.get('/status');
+                        groups.value = res.data.data;
+                    } catch (err) {
+                        alert("Failed to fetch status: " + err.message);
+                    } finally {
+                        loading.value = false;
+                    }
+                };
+
+                // 统一的控制函数
+                const controlGroup = async (groupId, action) => {
+                    try {
+                        loading.value = true;
+                        // action: 'start', 'stop', 'restart'
+                        const res = await axios.post(`/${action}`, { group_id: groupId });
+                        // 操作后稍微等待一下再刷新,让后台状态变化
+                        setTimeout(() => {
+                            fetchStatus();
+                            alert(`${action.toUpperCase()} command sent.`);
+                        }, 500);
+                    } catch (err) {
+                        const msg = err.response?.data?.detail || err.message;
+                        alert(`Failed to ${action}: ${msg}`);
+                        loading.value = false;
+                    }
+                };
+
+                // 重载配置
+                const reloadConfig = async () => {
+                    if (!confirm("Are you sure to reload config? This won't stop running groups.")) return;
+                    try {
+                        loading.value = true;
+                        await axios.post('/reload_config');
+                        await fetchStatus();
+                        alert("Configuration reloaded.");
+                    } catch (err) {
+                        alert("Error: " + err.message);
+                    } finally {
+                        loading.value = false;
+                    }
+                };
+
+                // OTA 更新
+                const triggerOTA = async () => {
+                    if (!otaPluginName.value) return;
+                    if (!confirm(`Confirm hot reload for plugin '${otaPluginName.value}'? This will restart related groups.`)) return;
+                    
+                    try {
+                        loading.value = true;
+                        const res = await axios.post('/ota', { plugin_name: otaPluginName.value });
+                        
+                        let msg = res.data.message;
+                        if (res.data.restarted_groups.length > 0) {
+                            msg += `\nRestarted groups: ${res.data.restarted_groups.join(', ')}`;
+                        } else {
+                            msg += "\nNo active groups were using this plugin.";
+                        }
+                        
+                        await fetchStatus();
+                        alert(msg);
+                    } catch (err) {
+                        alert("OTA Error: " + err.message);
+                    } finally {
+                        loading.value = false;
+                    }
+                };
+
+                onMounted(() => {
+                    fetchStatus();
+                    // 自动刷新 (可选,每5秒)
+                    setInterval(fetchStatus, 5000);
+                });
+
+                return {
+                    groups,
+                    loading,
+                    otaPluginName,
+                    fetchStatus,
+                    controlGroup,
+                    reloadConfig,
+                    triggerOTA
+                };
+            }
+        }).mount('#app');
+    </script>
+</body>
+</html>