diff --git a/backend/config.dev.yaml b/backend/config.dev.yaml index 7fe7bc0..98ea7d0 100644 --- a/backend/config.dev.yaml +++ b/backend/config.dev.yaml @@ -18,6 +18,14 @@ database: max_connections: 10 min_connections: 2 +# ========== Redis配置 ========== +redis: + host: 127.0.0.1 + port: 6379 + password: "" + db: 0 + pool_size: 10 + # ========== 浏览器池配置 ========== browser_pool: idle_timeout: 1800 # 空闲超时(秒),已禁用自动清理,保持常驻 diff --git a/backend/config.prod.yaml b/backend/config.prod.yaml index b5e7770..618894c 100644 --- a/backend/config.prod.yaml +++ b/backend/config.prod.yaml @@ -18,6 +18,14 @@ database: max_connections: 20 min_connections: 5 +# ========== Redis配置 ========== +redis: + host: 8.140.194.184 + port: 6379 + password: "Redis@123456" + db: 0 + pool_size: 10 + # ========== 浏览器池配置 ========== browser_pool: idle_timeout: 1800 # 空闲超时(秒),已禁用自动清理,保持常驻 diff --git a/backend/config.py b/backend/config.py index c8e4e40..153d0b1 100644 --- a/backend/config.py +++ b/backend/config.py @@ -95,6 +95,16 @@ def load_config(env: str = None) -> Config: if os.getenv('DB_NAME'): config_dict.setdefault('database', {})['dbname'] = os.getenv('DB_NAME') + # Redis配置 + if os.getenv('REDIS_HOST'): + config_dict.setdefault('redis', {})['host'] = os.getenv('REDIS_HOST') + if os.getenv('REDIS_PORT'): + config_dict.setdefault('redis', {})['port'] = int(os.getenv('REDIS_PORT')) + if os.getenv('REDIS_PASSWORD'): + config_dict.setdefault('redis', {})['password'] = os.getenv('REDIS_PASSWORD') + if os.getenv('REDIS_DB'): + config_dict.setdefault('redis', {})['db'] = int(os.getenv('REDIS_DB')) + # 调度器配置 if os.getenv('SCHEDULER_ENABLED'): config_dict.setdefault('scheduler', {})['enabled'] = os.getenv('SCHEDULER_ENABLED').lower() == 'true' @@ -122,6 +132,7 @@ def load_config(env: str = None) -> Config: print(f"[配置] 已加载配置文件: {config_file}") print(f"[配置] 环境: {env}") print(f"[配置] 数据库: {config_dict.get('database', {}).get('host')}:{config_dict.get('database', {}).get('port')}") + print(f"[配置] Redis: {config_dict.get('redis', {}).get('host')}:{config_dict.get('redis', {}).get('port')}") print(f"[配置] 调度器: {'启用' if config_dict.get('scheduler', {}).get('enabled') else '禁用'}") return Config(config_dict) diff --git a/backend/damai_proxy_config.py b/backend/damai_proxy_config.py index e631df0..77d595e 100644 --- a/backend/damai_proxy_config.py +++ b/backend/damai_proxy_config.py @@ -50,13 +50,13 @@ PROXY_POOL = [ "server": "http://111.132.40.72:50002", "username": "ih3z07", "password": "078bt7o5", - "enabled": False + "enabled": True }, { "name": "天启03", "server": "http://210.51.27.194:50001", "username": "hb6su3", "password": "acv2ciow", - "enabled": True + "enabled": False } ] diff --git a/backend/main.py b/backend/main.py index 78254e5..18d9f5c 100644 --- a/backend/main.py +++ b/backend/main.py @@ -62,8 +62,11 @@ class ConnectionManager: async def connect(self, session_id: str, websocket: WebSocket): await websocket.accept() self.active_connections[session_id] = websocket - print(f"[WebSocket] 新连接: {session_id}", file=sys.stderr) + print(f"[WebSocket] ========== 新连接建立 ==========", file=sys.stderr) + print(f"[WebSocket] Session ID: {session_id}", file=sys.stderr) print(f"[WebSocket] 当前活跃连接数: {len(self.active_connections)}", file=sys.stderr) + print(f"[WebSocket] 连接时间: {__import__('datetime').datetime.now()}", file=sys.stderr) + print(f"[WebSocket] ===================================", file=sys.stderr) # 立即检查缓存消息(不等待) if session_id in self.pending_messages: @@ -91,22 +94,42 @@ class ConnectionManager: else: print(f"[WebSocket] 没有缓存消息: {session_id}", file=sys.stderr) - def disconnect(self, session_id: str): + def disconnect(self, session_id: str, reason: str = "未知原因"): + """断开WebSocket连接并记录原因""" if session_id in self.active_connections: del self.active_connections[session_id] - print(f"[WebSocket] 断开连接: {session_id}", file=sys.stderr) + print(f"[WebSocket] ========== 连接断开 ==========", file=sys.stderr) + print(f"[WebSocket] Session ID: {session_id}", file=sys.stderr) + print(f"[WebSocket] 断开原因: {reason}", file=sys.stderr) + print(f"[WebSocket] 剩余活跃连接数: {len(self.active_connections)}", file=sys.stderr) + print(f"[WebSocket] ===================================", file=sys.stderr) # 清理缓存消息 if session_id in self.pending_messages: + pending_count = len(self.pending_messages[session_id]) + if pending_count > 0: + print(f"[WebSocket] 清理 {pending_count} 条未发送的缓存消息", file=sys.stderr) del self.pending_messages[session_id] async def send_message(self, session_id: str, message: dict): + print(f"[WebSocket] ========== 尝试发送消息 ==========", file=sys.stderr) + print(f"[WebSocket] Session ID: {session_id}", file=sys.stderr) + print(f"[WebSocket] 消息类型: {message.get('type')}", file=sys.stderr) + print(f"[WebSocket] 当前活跃连接数: {len(self.active_connections)}", file=sys.stderr) + print(f"[WebSocket] 活跃连接session_ids: {list(self.active_connections.keys())}", file=sys.stderr) + print(f"[WebSocket] session_id在连接中: {session_id in self.active_connections}", file=sys.stderr) + print(f"[WebSocket] ===================================", file=sys.stderr) + if session_id in self.active_connections: try: await self.active_connections[session_id].send_json(message) print(f"[WebSocket] 发送消息到 {session_id}: {message.get('type')}", file=sys.stderr) except Exception as e: - print(f"[WebSocket] 发送消息失败 {session_id}: {str(e)}", file=sys.stderr) - self.disconnect(session_id) + print(f"[WebSocket] ========== 发送消息失败 ==========", file=sys.stderr) + print(f"[WebSocket] Session ID: {session_id}", file=sys.stderr) + print(f"[WebSocket] 失败原因: {str(e)}", file=sys.stderr) + print(f"[WebSocket] 消息类型: {message.get('type')}", file=sys.stderr) + print(f"[WebSocket] ===================================", file=sys.stderr) + self.disconnect(session_id, reason=f"发送消息失败: {str(e)}") else: # WebSocket还未连接,缓存消息 print(f"[WebSocket] 连接尚未建立,缓存消息: {session_id}", file=sys.stderr) @@ -811,6 +834,87 @@ async def save_bind_info(request: dict): data=None ) +@app.post("/api/xhs/save-login") +async def save_login(request: dict): + """ + 保存验证码登录的信息到Go后端 + 与扫码登录不同,验证码登录返回的是storage_state数据 + """ + try: + employee_id = request.get('employee_id') + storage_state = request.get('storage_state', {}) + storage_state_path = request.get('storage_state_path', '') + user_info = request.get('user_info', {}) # 新增: 获取用户信息 + + if not employee_id: + return BaseResponse( + code=1, + message="employee_id不能为空", + data=None + ) + + if not storage_state: + return BaseResponse( + code=1, + message="storage_state不能为空", + data=None + ) + + # 调用Go后端API保存 + config = get_config() + go_backend_url = config.get_str('go_backend.url', 'http://localhost:8080') + + # 从 storage_state 中提取 cookies + cookies_full = storage_state.get('cookies', []) + + # 构造保存数据 + save_data = { + "employee_id": employee_id, + "cookies_full": cookies_full, + "storage_state": storage_state, + "storage_state_path": storage_state_path, + "user_info": user_info # 新增: 传递用户信息 + } + + print(f"[保存验证码登录] employee_id={employee_id}, cookies数量={len(cookies_full)}, 用户={user_info.get('nickname', '未知')}", file=sys.stderr) + + import aiohttp + async with aiohttp.ClientSession() as session: + # 获取小程序传来的token + auth_header = request.get('Authorization', '') + + async with session.post( + f"{go_backend_url}/api/xhs/save-login", + json=save_data, + headers={'Authorization': auth_header} if auth_header else {} + ) as resp: + result = await resp.json() + + if resp.status == 200 and result.get('code') == 200: + print(f"[保存验证码登录] 保存成功", file=sys.stderr) + return BaseResponse( + code=0, + message="保存成功", + data=result.get('data') + ) + else: + print(f"[保存验证码登录] 保存失败: {result.get('message')}", file=sys.stderr) + return BaseResponse( + code=1, + message=result.get('message', '保存失败'), + data=None + ) + + except Exception as e: + print(f"[保存验证码登录] 异常: {str(e)}", file=sys.stderr) + import traceback + traceback.print_exc() + return BaseResponse( + code=1, + message=f"保存失败: {str(e)}", + data=None + ) + @app.post("/api/xhs/qrcode/cancel") async def cancel_qrcode_login(request: dict): """ @@ -1324,6 +1428,7 @@ async def upload_images(files: List[UploadFile] = File(...)): async def handle_send_code_ws(session_id: str, phone: str, country_code: str, login_page: str, websocket: WebSocket): """ 异步处理WebSocket发送验证码请求 + 返回: (result, service_instance) - result是结果字典,service_instance是XHSLoginService实例 """ try: print(f"[WebSocket-SendCode] 开始处理: session={session_id}, phone={phone}", file=sys.stderr) @@ -1343,6 +1448,13 @@ async def handle_send_code_ws(session_id: str, phone: str, country_code: str, lo session_id=session_id ) + # 将service实例存储到浏览器池,供后续验证码验证使用 + if session_id in browser_pool.temp_browsers: + browser_pool.temp_browsers[session_id]['service'] = request_login_service + print(f"[WebSocket-SendCode] 已存储service实例: {session_id}", file=sys.stderr) + else: + print(f"[WebSocket-SendCode] 警告: session_id {session_id} 不在temp_browsers中", file=sys.stderr) + # 检查是否需要验证(发送验证码时触发风控) if result.get("need_captcha"): print(f"[WebSocket-SendCode] 检测到风控,需要扫码", file=sys.stderr) @@ -1353,7 +1465,8 @@ async def handle_send_code_ws(session_id: str, phone: str, country_code: str, lo "message": result.get("message", "需要扫码验证") }) print(f"[WebSocket-SendCode] 已推送风控信息", file=sys.stderr) - return + # 返回service实例,供外部启动监听任务 + return result, request_login_service if result["success"]: print(f"[WebSocket-SendCode] 验证码发送成功", file=sys.stderr) @@ -1369,6 +1482,9 @@ async def handle_send_code_ws(session_id: str, phone: str, country_code: str, lo "success": False, "message": result.get("error", "发送验证码失败") }) + + return result, request_login_service + except Exception as e: print(f"[WebSocket-SendCode] 异常: {str(e)}", file=sys.stderr) import traceback @@ -1381,6 +1497,7 @@ async def handle_send_code_ws(session_id: str, phone: str, country_code: str, lo }) except: pass + return {"success": False, "error": str(e)}, None async def handle_verify_code_ws(session_id: str, phone: str, code: str, country_code: str, login_page: str, websocket: WebSocket): """ @@ -1404,7 +1521,7 @@ async def handle_verify_code_ws(session_id: str, phone: str, code: str, country_ request_login_service = browser_data['service'] # 调用登录服务验证登录 - result = await request_login_service.login_with_code( + result = await request_login_service.login( phone=phone, code=code, country_code=country_code, @@ -1483,6 +1600,43 @@ async def websocket_login(websocket: WebSocket, session_id: str): """ await ws_manager.connect(session_id, websocket) + # 启动Redis订阅任务 + import asyncio + import redis.asyncio as aioredis + import json + from config import get_config + + config = get_config() + redis_host = config.get_str('redis.host', 'localhost') + redis_port = config.get_int('redis.port', 6379) + redis_password = config.get_str('redis.password', '') + + # 创建Redis订阅客户端 + redis_url = f"redis://:{redis_password}@{redis_host}:{redis_port}" if redis_password else f"redis://{redis_host}:{redis_port}" + redis_client = await aioredis.from_url(redis_url, decode_responses=True) + pubsub = redis_client.pubsub() + channel = f"ws_message:{session_id}" + await pubsub.subscribe(channel) + print(f"[WebSocket] 已订阅Redis频道: {channel}", file=sys.stderr) + + # 启动后台任务监听Redis消息 + async def redis_subscriber(): + try: + async for message in pubsub.listen(): + if message['type'] == 'message': + try: + data = json.loads(message['data']) + print(f"[WebSocket] 从Redis收到消息: {data}", file=sys.stderr) + await websocket.send_json(data) + print(f"[WebSocket] 已转发消息到前端: {session_id}", file=sys.stderr) + except Exception as e: + print(f"[WebSocket] 处理Redis消息失败: {str(e)}", file=sys.stderr) + except Exception as e: + print(f"[WebSocket] Redis订阅异常: {str(e)}", file=sys.stderr) + + # 在后台启动Redis监听 + redis_task = asyncio.create_task(redis_subscriber()) + try: # 保持连接,等待消息或断开 while True: @@ -1496,7 +1650,6 @@ async def websocket_login(websocket: WebSocket, session_id: str): else: # 尝试解析JSON消息 try: - import json msg = json.loads(data) msg_type = msg.get('type', 'unknown') print(f"[WebSocket] 解析消息类型: {msg_type}", file=sys.stderr) @@ -1519,8 +1672,15 @@ async def websocket_login(websocket: WebSocket, session_id: str): login_page = msg.get('login_page', 'creator') print(f"[WebSocket] 收到发送验证码请求: phone={phone}", file=sys.stderr) - # 启动异步任务处理发送验证码 - asyncio.create_task(handle_send_code_ws(session_id, phone, country_code, login_page, websocket)) + # 直接处理发送验证码(不使用create_task) + result, service_instance = await handle_send_code_ws(session_id, phone, country_code, login_page, websocket) + + # 如果需要扫码,在当前协程中启动监听 + if result.get("need_captcha") and service_instance: + print(f"[WebSocket] 在主协程中启动扫码监听: {session_id}", file=sys.stderr) + # 使用create_task在后台监听,但不阻塞当前消息循环 + asyncio.create_task(service_instance._monitor_qrcode_scan(session_id)) + print(f"[WebSocket] 已启动扫码监听任务", file=sys.stderr) # 处理验证码验证消息 elif msg_type == 'verify_code': @@ -1536,12 +1696,38 @@ async def websocket_login(websocket: WebSocket, session_id: str): except json.JSONDecodeError: print(f"[WebSocket] 无法解析为JSON: {data}", file=sys.stderr) - except WebSocketDisconnect: - ws_manager.disconnect(session_id) - print(f"[WebSocket] 客户端断开: {session_id}", file=sys.stderr) + except WebSocketDisconnect as e: + reason = f"客户端主动断开连接 (code: {e.code if hasattr(e, 'code') else 'unknown'})" + ws_manager.disconnect(session_id, reason=reason) except Exception as e: - ws_manager.disconnect(session_id) - print(f"[WebSocket] 连接异常 {session_id}: {str(e)}", file=sys.stderr) + reason = f"连接异常: {type(e).__name__} - {str(e)}" + ws_manager.disconnect(session_id, reason=reason) + finally: + # 清理Redis订阅 + try: + redis_task.cancel() + await pubsub.unsubscribe(channel) + await pubsub.close() + await redis_client.close() + print(f"[WebSocket] 已取消Redis订阅: {channel}", file=sys.stderr) + except: + pass + + # 释放浏览器实例 + try: + # 检查是否有临时浏览器需要释放 + if session_id in browser_pool.temp_browsers: + print(f"[WebSocket] 检测到未释放的临时浏览器,开始清理: {session_id}", file=sys.stderr) + await browser_pool.release_temp_browser(session_id) + print(f"[WebSocket] 已释放临时浏览器: {session_id}", file=sys.stderr) + + # 检查是否有扫码页面需要释放 + if session_id in browser_pool.qrcode_pages: + print(f"[WebSocket] 检测到未释放的扫码页面,开始清理: {session_id}", file=sys.stderr) + await browser_pool.release_qrcode_page(session_id) + print(f"[WebSocket] 已释放扫码页面: {session_id}", file=sys.stderr) + except Exception as e: + print(f"[WebSocket] 释放浏览器异常: {str(e)}", file=sys.stderr) if __name__ == "__main__": import uvicorn diff --git a/backend/xhs_login.py b/backend/xhs_login.py index 530a60e..2e4e307 100644 --- a/backend/xhs_login.py +++ b/backend/xhs_login.py @@ -576,13 +576,12 @@ class XHSLoginService: # 尝试查找二维码图片元素 qrcode_selectors = [ - '.qrcode-img', # 根据您提供的HTML + '.qrcode-img', # 小红书风控二维码的特定class 'img.qrcode-img', - '.qrcode-container img', - 'img[src*="data:image"]', # base64图片 - 'img[src*="qrcode"]', - 'img[alt*="二维码"]', - 'img[alt*="qrcode"]', + '.qrcode-container img', # 二维码容器内的图片 + '.verify-captcha img', # 验证弹窗内的图片 + 'img[alt*="二维码"]', # alt属性包含"二维码" + 'img[alt*="qrcode"]', # alt属性包含"qrcode" ] for selector in qrcode_selectors: @@ -643,27 +642,37 @@ class XHSLoginService: 后台监听扫码后的页面跳转和二维码失效 通过监听小红书API https://edith.xiaohongshu.com/api/redcaptcha/v2/qr/status/query 来精准判断二维码状态: - - status=1: 未过期,等待扫码 - - status=5: 已扫码,等待确认 - - 其他: 失效或已完成 + - status=1: 正常,等待扫码 + - status=2: 扫码完成,待APP确认 + - status=5: 二维码已过期/失效 Args: session_id: 会话 ID """ try: logger.info(f"[WebSocket] 开始监听扫码状态: {session_id}") + + # 等待1秒,确保WebSocket连接完全建立 + logger.info(f"[WebSocket] 等待WebSocket连接建立...") + await asyncio.sleep(1.0) + logger.info(f"[WebSocket] 等待完成,开始监听") if not self.page: logger.error(f"[WebSocket] 页面对象不存在: {session_id}") return # 用于存储最新的二维码状态 - latest_qr_status = {"status": 1, "scanned": False} + latest_qr_status = {"status": 1} # 标记是否已推送失效消息 expired_notified = False + # 标记是否已推送扫码成功消息 + scan_success_notified = False + # 记录上次推送的状态,避免重复推送 + last_notified_status = None # 设置响应监听,拦截二维码状态查询API async def handle_qr_status_response(response): + nonlocal last_notified_status try: if '/api/redcaptcha/v2/qr/status/query' in response.url: json_data = await response.json() @@ -671,11 +680,56 @@ class XHSLoginService: status = json_data['data'].get('status') latest_qr_status['status'] = status - if status == 5: - latest_qr_status['scanned'] = True - logger.info(f"[WebSocket] 检测到二维码已扫描,等待确认: status={status}") - elif status == 1: - logger.debug(f"[WebSocket] 二维码未过期,等待扫码: status={status}") + # 推送状态变化给前端 + if status != last_notified_status: + status_message = { + 1: "等待扫码", + 2: "扫码完成,请在APP中确认", + 5: "二维码已过期" + }.get(status, f"二维码状态: {status}") + + try: + # 使用Redis发布消息,避免事件循环隔离问题 + import redis + import json as json_lib + from config import get_config + + config = get_config() + redis_host = config.get_str('redis.host', 'localhost') + redis_port = config.get_int('redis.port', 6379) + redis_password = config.get_str('redis.password', '') + + redis_client = redis.Redis( + host=redis_host, + port=redis_port, + password=redis_password if redis_password else None, + decode_responses=True + ) + + message = { + "type": "qrcode_status", + "status": status, + "message": status_message + } + + # 发布到Redis频道 + channel = f"ws_message:{session_id}" + redis_client.publish(channel, json_lib.dumps(message)) + logger.info(f"[WebSocket] 已通过Redis推送二维码状态: status={status}, channel={channel}") + last_notified_status = status + + redis_client.close() + except Exception as ws_error: + logger.error(f"[WebSocket] 推送状态失败: {str(ws_error)}") + import traceback + traceback.print_exc() + + if status == 1: + logger.debug(f"[WebSocket] 二维码正常,等待扫码: status={status}") + elif status == 2: + logger.info(f"[WebSocket] 检测到扫码完成,等待APP确认: status={status}") + elif status == 5: + logger.warning(f"[WebSocket] 检测到二维码已过期: status={status}") else: logger.info(f"[WebSocket] 二维码状态: status={status}") except Exception as e: @@ -694,88 +748,104 @@ class XHSLoginService: # 1. 检测是否跳转回首页(不再是captcha/verify页) if 'captcha' not in current_url.lower() and 'verify' not in current_url.lower(): + # 如果已经推送过扫码成功消息,跳过 + if scan_success_notified: + continue + # 检查是否跳转到小红书首页 if 'xiaohongshu.com' in current_url: logger.success(f"[WebSocket] 检测到扫码完成,页面跳转回: {current_url}") + # 等待500ms确保WebSocket连接完全建立 + await asyncio.sleep(0.5) + # 通过WebSocket推送扫码成功消息 try: - from main import ws_manager - await ws_manager.send_message(session_id, { + # 使用Redis发布消息 + import redis + import json as json_lib + from config import get_config + + config = get_config() + redis_host = config.get_str('redis.host', 'localhost') + redis_port = config.get_int('redis.port', 6379) + redis_password = config.get_str('redis.password', '') + + redis_client = redis.Redis( + host=redis_host, + port=redis_port, + password=redis_password if redis_password else None, + decode_responses=True + ) + + message = { "type": "qrcode_scan_success", "message": "扫码验证完成,请重新发送验证码" - }) - logger.success(f"[WebSocket] 已推送扫码成功消息: {session_id}") + } + + channel = f"ws_message:{session_id}" + redis_client.publish(channel, json_lib.dumps(message)) + logger.success(f"[WebSocket] 已通过Redis推送扫码成功消息: channel={channel}") + scan_success_notified = True + + redis_client.close() except Exception as ws_error: logger.error(f"[WebSocket] 推送消息失败: {str(ws_error)}") - - break + import traceback + traceback.print_exc() + + # 不退出监听,继续等待用户后续操作 + logger.info(f"[WebSocket] 扫码成功,保持监听状态") # 2. 检测二维码是否失效(通过API状态判断) if 'captcha' in current_url.lower() or 'verify' in current_url.lower(): - # 如果已经推送过失效消息,跳过后续检测 + # 如果已经推送过失效消息,跳过后续检测 if expired_notified: continue - # 如果状态不是1和5,说明二维码可能已失效 - if latest_qr_status['status'] not in [1, 5]: - logger.warning(f"[WebSocket] 检测到二维码失效: status={latest_qr_status['status']}") + # 如果状态是5,说明二维码已过期 + if latest_qr_status['status'] == 5: + logger.warning(f"[WebSocket] API检测到二维码过期: status=5") + + # 等待500ms确保WebSocket连接完全建立 + await asyncio.sleep(0.5) # 通过WebSocket推送失效消息 try: - from main import ws_manager - await ws_manager.send_message(session_id, { + # 使用Redis发布消息 + import redis + import json as json_lib + from config import get_config + + config = get_config() + redis_host = config.get_str('redis.host', 'localhost') + redis_port = config.get_int('redis.port', 6379) + redis_password = config.get_str('redis.password', '') + + redis_client = redis.Redis( + host=redis_host, + port=redis_port, + password=redis_password if redis_password else None, + decode_responses=True + ) + + message = { "type": "qrcode_expired", "message": "二维码已失效,请重新发送验证码" - }) - logger.success(f"[WebSocket] 已推送二维码失效消息: {session_id}") - expired_notified = True # 标记已推送 + } + + channel = f"ws_message:{session_id}" + redis_client.publish(channel, json_lib.dumps(message)) + logger.success(f"[WebSocket] 已通过Redis推送二维码失效消息: channel={channel}") + expired_notified = True + + redis_client.close() except Exception as ws_error: logger.error(f"[WebSocket] 推送消息失败: {str(ws_error)}") - - break # 退出监听循环 + import traceback + traceback.print_exc() - # 备用方案:检查页面文本(以防API未返回) - try: - expired_selectors = [ - 'text="已过期"', - 'text="二维码已失效"', - 'text="二维码过期"', - ] - - for selector in expired_selectors: - expired_elem = await self.page.query_selector(selector) - if expired_elem: - is_visible = await expired_elem.is_visible() - if is_visible: - # 进一步检查元素文本内容 - text_content = await expired_elem.text_content() - # 只在明确显示"已过期"或"已失效"时才认为失效,忽略"二维码X分钟失效"这种提示 - if text_content and ('已过期' in text_content or '已失效' in text_content): - logger.warning(f"[WebSocket] DOM检测到二维码失效: {selector}, 文本: {text_content}") - - # 通过WebSocket推送失效消息 - try: - from main import ws_manager - await ws_manager.send_message(session_id, { - "type": "qrcode_expired", - "message": "二维码已失效,请重新发送验证码" - }) - logger.success(f"[WebSocket] 已推送二维码失效消息: {session_id}") - expired_notified = True # 标记已推送 - except Exception as ws_error: - logger.error(f"[WebSocket] 推送消息失败: {str(ws_error)}") - - # 退出所有循环 - break - - # 如果检测到失效,退出外层循环 - if expired_notified: - break - - except Exception as e: - # 页面可能已关闭,忽略错误 - pass + # 不退出监听,继续等待用户重新操作 # 每30秒打印一次状态 if i > 0 and i % 60 == 0: @@ -783,21 +853,11 @@ class XHSLoginService: except Exception as e: logger.error(f"[WebSocket] 监听异常: {str(e)}") - break + # 不退出,继续监听 - # 超时5分钟未扫码,通知前端关闭弹窗 - logger.warning(f"[WebSocket] 扫码监听超时(5分钟): {session_id}") - try: - from main import ws_manager - await ws_manager.send_message(session_id, { - "type": "qrcode_expired", - "message": "二维码已超时,请重新发送验证码" - }) - logger.success(f"[WebSocket] 已推送超时消息: {session_id}") - except Exception as ws_error: - logger.error(f"[WebSocket] 推送消息失败: {str(ws_error)}") - - logger.info(f"[WebSocket] 监听任务结束: {session_id}") + # 超时5分钟,通知前端(但不退出监听) + logger.warning(f"[WebSocket] 监听已运行5分钟: {session_id}") + logger.info(f"[WebSocket] 监听仍将继续,直到用户关闭页面") except Exception as e: logger.error(f"[WebSocket] 监听任务异常: {str(e)}") @@ -850,6 +910,26 @@ class XHSLoginService: logger.warning(f"[页面导航] 导航超时,但尝试继续: {str(e)}") logger.info(f"[页面导航] 当前URL: {current_url}") + # 检测小红书反爬JSON页面 + await asyncio.sleep(0.5) # 等待页面内容加载 + try: + page_content = await self.page.content() + # 检查页面是否只返回JSON(小红书的检测机制) + if page_content and len(page_content) < 500: # JSON页面通常很短 + # 尝试解析JSON + if '{"code"' in page_content and '"success":true' in page_content: + logger.warning("="*50) + logger.warning("⚠️ 检测到小红书反爬JSON页面") + logger.warning(f"页面内容: {page_content[:200]}") + logger.warning("="*50) + # 抛出异常,让外层处理 + raise Exception("ANTI_CRAWL_JSON") + except Exception as e: + if "ANTI_CRAWL_JSON" in str(e): + raise # 重新抛出,让外层捕获 + # 其他异常忽略,继续执行 + pass + # 等待二维码API请求(最多等待timeout秒) wait_count = 0 max_wait = timeout * 10 # 每次等待0.1秒 @@ -919,12 +999,30 @@ class XHSLoginService: else: # 页面变了,重新访问登录页 logger.success(f"[预热] 页面已变更 ({current_url}),重新访问{page_name}登录页...") - await self._navigate_with_qrcode_listener(login_url) + try: + await self._navigate_with_qrcode_listener(login_url) + except Exception as e: + if "ANTI_CRAWL_JSON" in str(e): + logger.error("⚠️ 检测到小红书反爬检测,请稍后再试") + return { + "success": False, + "error": "当前IP被小红书检测,请等待5分钟后再试" + } + raise else: # 未预热或不是池模式,使用监听机制访问页面 logger.debug(f"正在访问{page_name}登录页...") - await self._navigate_with_qrcode_listener(login_url) + try: + await self._navigate_with_qrcode_listener(login_url) + except Exception as e: + if "ANTI_CRAWL_JSON" in str(e): + logger.error("⚠️ 检测到小红书反爬检测,请稍后再试") + return { + "success": False, + "error": "当前IP被小红书检测,请等待5分钟后再试" + } + raise logger.success(f"✅ 已进入{page_name}登录页面") @@ -951,9 +1049,8 @@ class XHSLoginService: logger.info(f"二维码数据长度: {len(qrcode_data)} 字符") logger.info("返回二维码给前端,等待用户扫码后重新调用接口") - # 启动后台任务监听页面跳转,扫码完成后通知前端 - asyncio.create_task(self._monitor_qrcode_scan(session_id)) - logger.info(f"[WebSocket] 已启动扫码监听任务: {session_id}") + # 不再在这里启动监听任务,由main.py中的WebSocket端点启动 + # asyncio.create_task(self._monitor_qrcode_scan(session_id)) return { "success": False, @@ -1517,14 +1614,14 @@ class XHSLoginService: logger.success(f"✅ 检测到登录成功,用户: {user_me_data.get('nickname')}") # 通过WebSocket推送登录成功消息 - if session_id: + if self.session_id: try: from main import ws_manager - await ws_manager.send_message(session_id, { + await ws_manager.send_message(self.session_id, { "type": "login_success", "user_info": user_me_data }) - logger.info(f"[WebSocket] 已推送登录成功消息: {session_id}") + logger.info(f"[WebSocket] 已推送登录成功消息: {self.session_id}") except Exception as ws_error: logger.error(f"[WebSocket] 推送消息失败: {str(ws_error)}") except Exception as e: @@ -1599,7 +1696,7 @@ class XHSLoginService: "need_captcha": True, "captcha_type": "qrcode", "qrcode_image": qrcode_data, - "session_id": session_id, # 返回session_id,供后续轮询使用 + "session_id": self.session_id, # 返回session_id,供后续轮询使用 "message": "需要扫码验证,请使用小红书APP扫描二维码" } else: @@ -1626,40 +1723,114 @@ class XHSLoginService: logger.info(f"最终URL: {self.page.url}") logger.info("="*50) - # 2. 即使URL没变,也要检测页面上是否出现二维码弹窗 - logger.info("检测页面上是否出现扫码验证...") - qrcode_selectors = [ - '.qrcode-img', - 'img.qrcode-img', - '.qrcode-container img', - 'img[src*="data:image"]', - 'img[src*="qrcode"]', - 'img[alt*="二维码"]', - 'img[alt*="qrcode"]', - ] - - for selector in qrcode_selectors: + # 2. 只有在未检测到风控且未登录成功时,才检测页面上是否出现二维码弹窗 + current_url = self.page.url + # 如果已经跳转到成功页面,不再检测二维码 + if 'explore' in current_url or 'creator' in current_url or 'xiaohongshu.com' in current_url: + if 'login' not in current_url: + logger.info("已跳转到登录成功页面,跳过二维码检测") + else: + logger.info("仍在登录页,检测页面上是否出现扫码验证...") + # 先检测提示文本 + try: + tip_elem = await self.page.query_selector('.tip') + if tip_elem: + tip_text = await tip_elem.inner_text() + logger.info(f"检测到提示文本: {tip_text}") + if '扫码' in tip_text or '二维码' in tip_text: + logger.warning("⚠️ 确认检测到扫码验证提示") + except Exception as e: + logger.debug(f"检测提示文本失败: {str(e)}") + + qrcode_selectors = [ + '.qrcode-img', # 小红书风控二维码的特定class + 'img.qrcode-img', + '.qrcode-container img', # 二维码容器内的图片 + '.qrcode .qrcode-img', # 二维码容器下的二维码图片 + '.verify-captcha img', # 验证弹窗内的图片 + '.login-container .qrcode-img', # 登录容器内的二维码 + 'img[alt*="二维码"]', # alt属性包含"二维码" + 'img[alt*="qrcode"]', # alt属性包含"qrcode" + ] + + for selector in qrcode_selectors: + try: + qrcode_elem = await self.page.query_selector(selector) + if qrcode_elem: + logger.info(f"检测到符合选择器的元素: {selector},尝试提取二维码...") + qrcode_data = await self.extract_verification_qrcode() + if qrcode_data: + logger.warning(f"⚠️ 确认检测到风控二维码: {selector}") + logger.success("✅ 成功提取扫码验证二维码,返回给前端") + # 注意:不移除API监听,保持session_id对应的浏览器继续运行 + return { + "success": False, + "need_captcha": True, + "captcha_type": "qrcode", + "qrcode_image": qrcode_data, + "session_id": self.session_id, # 返回session_id,供后续轮询使用 + "message": "需要扫码验证,请使用小红书APP扫描二维码" + } + else: + logger.debug(f"选择器 {selector} 匹配到元素但无法提取二维码,可能不是风控二维码") + break + except Exception as e: + logger.debug(f"选择器 {selector} 检测失败: {str(e)}") + continue + + logger.info("未检测到扫码验证") + else: + logger.info("仍在登录页,检测页面上是否出现扫码验证...") + # 先检测提示文本 try: - qrcode_elem = await self.page.query_selector(selector) - if qrcode_elem: - logger.warning(f"⚠️ 检测到页面上出现二维码: {selector}") - qrcode_data = await self.extract_verification_qrcode() - if qrcode_data: - logger.success("✅ 成功提取扫码验证二维码,返回给前端") - # 注意:不移除API监听,保持session_id对应的浏览器继续运行 - return { - "success": False, - "need_captcha": True, - "captcha_type": "qrcode", - "qrcode_image": qrcode_data, - "session_id": session_id, # 返回session_id,供后续轮询使用 - "message": "需要扫码验证,请使用小红书APP扫描二维码" - } - break - except Exception: - continue + tip_elem = await self.page.query_selector('.tip') + if tip_elem: + tip_text = await tip_elem.inner_text() + logger.info(f"检测到提示文本: {tip_text}") + if '扫码' in tip_text or '二维码' in tip_text: + logger.warning("⚠️ 确认检测到扫码验证提示") + except Exception as e: + logger.debug(f"检测提示文本失败: {str(e)}") + + qrcode_selectors = [ + '.qrcode-img', # 小红书风控二维码的特定class + 'img.qrcode-img', + '.qrcode-container img', # 二维码容器内的图片 + '.qrcode .qrcode-img', # 二维码容器下的二维码图片 + '.verify-captcha img', # 验证弹窗内的图片 + '.login-container .qrcode-img', # 登录容器内的二维码 + 'img[alt*="二维码"]', # alt属性包含"二维码" + 'img[alt*="qrcode"]', # alt属性包含"qrcode" + ] + + for selector in qrcode_selectors: + try: + qrcode_elem = await self.page.query_selector(selector) + if qrcode_elem: + logger.info(f"检测到符合选择器的元素: {selector},尝试提取二维码...") + qrcode_data = await self.extract_verification_qrcode() + if qrcode_data: + logger.warning(f"⚠️ 确认检测到风控二维码: {selector}") + logger.success("✅ 成功提取扫码验证二维码,返回给前端") + # 注意:不移除API监听,保持session_id对应的浏览器继续运行 + return { + "success": False, + "need_captcha": True, + "captcha_type": "qrcode", + "qrcode_image": qrcode_data, + "session_id": self.session_id, # 返回session_id,供后续轮询使用 + "message": "需要扫码验证,请使用小红书APP扫描二维码" + } + else: + logger.debug(f"选择器 {selector} 匹配到元素但无法提取二维码,可能不是风控二维码") + break + except Exception as e: + logger.debug(f"选择器 {selector} 检测失败: {str(e)}") + continue + + logger.info("未检测到扫码验证") - logger.info("未检测到扫码验证,继续等待登录...") + logger.info("继续等待登录...") # 等待URL跳转或API响应(最多30秒) logger.info("[登录检测] 等待扫码完成或登录跳转...") @@ -1908,6 +2079,7 @@ class XHSLoginService: "localStorage": localStorage_data, # API 返回:localStorage数据 "sessionStorage": sessionStorage_data, # API 返回:sessionStorage数据 "url": current_url, + "storage_state": storage_state_data, # 新增:Playwright storage_state对象 "storage_state_path": storage_state_path # 新增:storage_state文件路径 } @@ -3162,6 +3334,26 @@ class XHSLoginService: current_url = self.page.url logger.success(f"[扫码登录] 页面加载完成, 当前URL: {current_url}") + # 检测小红书反爬JSON页面 + await asyncio.sleep(0.5) # 等待页面内容加载 + try: + page_content = await self.page.content() + # 检查页面是否只返回JSON(小红书的检测机制) + if page_content and len(page_content) < 500: # JSON页面通常很短 + # 尝试解析JSON + if '{"code"' in page_content and '"success":true' in page_content: + logger.warning("="*50) + logger.warning("⚠️ 检测到小红书反爬JSON页面") + logger.warning(f"页面内容: {page_content[:200]}") + logger.warning("="*50) + return { + "success": False, + "error": "当前IP被小红书检测,请等待5分钟后再试" + } + except Exception as e: + # 其他异常忽略,继续执行 + pass + # 检查是否跳转到验证码页面 if '/website-login/captcha' in current_url or 'verifyUuid=' in current_url: logger.warning(f"[扫码登录] 检测到风控验证页面,尝试等待或跳过...") diff --git a/go_backend/controller/employee_controller.go b/go_backend/controller/employee_controller.go index 1d4079c..b7edf14 100644 --- a/go_backend/controller/employee_controller.go +++ b/go_backend/controller/employee_controller.go @@ -781,6 +781,31 @@ func (ctrl *EmployeeController) SaveQRCodeLogin(c *gin.Context) { common.SuccessWithMessage(c, "绑定成功", nil) } +// SaveLogin 保存验证码登录的绑定信息 +func (ctrl *EmployeeController) SaveLogin(c *gin.Context) { + var req struct { + EmployeeID int `json:"employee_id" binding:"required"` + CookiesFull []interface{} `json:"cookies_full"` + StorageState map[string]interface{} `json:"storage_state"` + StorageStatePath string `json:"storage_state_path"` + UserInfo map[string]interface{} `json:"user_info"` // 新增: 用户信息 + } + + if err := c.ShouldBindJSON(&req); err != nil { + common.Error(c, common.CodeInvalidParams, "参数错误") + return + } + + // 调用service层保存 + err := ctrl.service.SaveLogin(req.EmployeeID, req.CookiesFull, req.StorageState, req.StorageStatePath, req.UserInfo) + if err != nil { + common.Error(c, common.CodeInternalError, err.Error()) + return + } + + common.SuccessWithMessage(c, "绑定成功", nil) +} + // StartQRCodeLogin 启动扫码登录,转发到Python服务 func (ctrl *EmployeeController) StartQRCodeLogin(c *gin.Context) { employeeID := c.GetInt("employee_id") diff --git a/go_backend/router/router.go b/go_backend/router/router.go index 7b78a55..814c936 100644 --- a/go_backend/router/router.go +++ b/go_backend/router/router.go @@ -130,6 +130,8 @@ func SetupRouter(r *gin.Engine) { // 保存扫码登录的绑定信息 xhs.POST("/save-qrcode-login", xhsCtrl.SaveQRCodeLogin) + // 保存验证码登录的绑定信息 + xhs.POST("/save-login", xhsCtrl.SaveLogin) } } } diff --git a/go_backend/service/employee_service.go b/go_backend/service/employee_service.go index 8363bb6..785ea4a 100644 --- a/go_backend/service/employee_service.go +++ b/go_backend/service/employee_service.go @@ -2341,6 +2341,151 @@ func (s *EmployeeService) SaveQRCodeLogin(employeeID int, cookiesFull []interfac return nil } +// SaveLogin 保存验证码登录的信息 +func (s *EmployeeService) SaveLogin(employeeID int, cookiesFull []interface{}, storageState map[string]interface{}, storageStatePath string, userInfo map[string]interface{}) error { + ctx := context.Background() + + // 查询用户信息 + var employee models.User + if err := database.DB.First(&employee, employeeID).Error; err != nil { + return fmt.Errorf("获取用户信息失败: %w", err) + } + + // 优先使用 storage_state,如果没有则降级使用 cookies_full + var loginStateJSON string + + if len(storageState) > 0 { + // 新版:使用 Playwright 的 storage_state + storageStateBytes, err := json.Marshal(storageState) + if err == nil { + loginStateJSON = string(storageStateBytes) + log.Printf("验证码登录 - 用户%d - StorageState长度: %d", employeeID, len(loginStateJSON)) + } else { + log.Printf("验证码登录 - 用户%d - 序列化storage_state失败: %v", employeeID, err) + } + } else if len(cookiesFull) > 0 { + // 降级:使用旧版本的 cookies_full + log.Printf("验证码登录 - 用户%d - 警告: 未找到storage_state,降级使用cookies", employeeID) + cookiesBytes, err := json.Marshal(cookiesFull) + if err == nil { + loginStateJSON = string(cookiesBytes) + log.Printf("验证码登录 - 用户%d - Cookie长度: %d", employeeID, len(loginStateJSON)) + } + } + + if loginStateJSON == "" { + log.Printf("验证码登录 - 用户%d - 错误: 未能获取到任何登录数据", employeeID) + return errors.New("登录成功但未能获取到登录数据,请重试") + } + + // 从 userInfo 提取小红书账号信息 + xhsNickname := "小红书用户" // 默认值 + xhsPhone := "" // red_id + xhsUserId := "" // user_id + + if len(userInfo) > 0 { + // 优先使用 nickname + if nickname, ok := userInfo["nickname"].(string); ok && nickname != "" { + xhsNickname = nickname + } + // 提取 red_id 作为 xhs_phone + if redID, ok := userInfo["red_id"].(string); ok && redID != "" { + xhsPhone = redID + } + // 提取 user_id + if userID, ok := userInfo["user_id"].(string); ok && userID != "" { + xhsUserId = userID + } + log.Printf("验证码登录 - 用户%d - 提取的用户信息: nickname=%s, red_id=%s, user_id=%s", employeeID, xhsNickname, xhsPhone, xhsUserId) + } else { + log.Printf("验证码登录 - 用户%d - 警告: userInfo为空,使用默认值", employeeID) + } + + now := time.Now() + + // 开启事务 + tx := database.DB.Begin() + defer func() { + if r := recover(); r != nil { + tx.Rollback() + } + }() + + // 创建或更新 ai_authors 表的小红书账号记录 + log.Printf("验证码登录 - 用户%d - 开始创建或更新作者记录", employeeID) + + author := models.Author{ + EnterpriseID: employee.EnterpriseID, + CreatedUserID: employeeID, + Phone: employee.Phone, + AuthorName: xhsNickname, + XHSCookie: loginStateJSON, + XHSPhone: xhsPhone, + XHSAccount: xhsNickname, + BoundAt: &now, + Channel: 1, // 1=小红书 + Status: "active", + } + + // 查询是否已存在记录 + var existingAuthor models.Author + err := database.DB.Where("created_user_id = ? AND enterprise_id = ? AND channel = 1", + employeeID, employee.EnterpriseID).First(&existingAuthor).Error + + if err == gorm.ErrRecordNotFound { + // 创建新记录 + if err := tx.Create(&author).Error; err != nil { + tx.Rollback() + log.Printf("验证码登录 - 用户%d - 创建作者记录失败: %v", employeeID, err) + return fmt.Errorf("创建作者记录失败: %w", err) + } + log.Printf("验证码登录 - 用户%d - 创建作者记录成功", employeeID) + } else { + // 更新现有记录 + if err := tx.Model(&models.Author{}).Where( + "created_user_id = ? AND enterprise_id = ? AND channel = 1", + employeeID, employee.EnterpriseID, + ).Updates(map[string]interface{}{ + "author_name": xhsNickname, + "xhs_cookie": loginStateJSON, + "xhs_phone": xhsPhone, + "xhs_account": xhsNickname, + "bound_at": &now, + "status": "active", + "phone": employee.Phone, + }).Error; err != nil { + tx.Rollback() + log.Printf("验证码登录 - 用户%d - 更新作者记录失败: %v", employeeID, err) + return fmt.Errorf("更新作者记录失败: %w", err) + } + log.Printf("验证码登录 - 用户%d - 更新作者记录成功", employeeID) + } + + // 更新 ai_users 表的绑定标识 + if err := tx.Model(&employee).Update("is_bound_xhs", 1).Error; err != nil { + tx.Rollback() + log.Printf("验证码登录 - 用户%d - 更新用户绑定标识失败: %v", employeeID, err) + return fmt.Errorf("更新用户绑定标识失败: %w", err) + } + + log.Printf("验证码登录 - 用户%d - 数据库更新成功", employeeID) + + // 提交事务 + if err := tx.Commit().Error; err != nil { + log.Printf("验证码登录 - 用户%d - 事务提交失败: %v", employeeID, err) + return fmt.Errorf("提交事务失败: %w", err) + } + + // 清除相关缓存 + cacheService := NewCacheService() + if err := cacheService.ClearUserRelatedCache(ctx, employeeID); err != nil { + log.Printf("清除缓存失败: %v", err) + } + + log.Printf("验证码登录 - 用户%d - 绑定成功", employeeID) + return nil +} + // StartQRCodeLogin 启动扫码登录,转发到Python服务 func (s *EmployeeService) StartQRCodeLogin(employeeID int) (map[string]interface{}, error) { log.Printf("[启动扫码登录] 用户ID: %d", employeeID) diff --git a/miniprogram/miniprogram/config/api.ts b/miniprogram/miniprogram/config/api.ts index 2794a90..f54b478 100644 --- a/miniprogram/miniprogram/config/api.ts +++ b/miniprogram/miniprogram/config/api.ts @@ -15,6 +15,7 @@ type EnvType = 'dev' | 'test' | 'prod'; interface EnvConfig { baseURL: string; // 主服务地址 pythonURL?: string; // Python服务地址(可选) + websocketURL?: string; // WebSocket服务地址(可选,默认使用pythonURL) timeout: number; // 请求超时时间 } @@ -27,6 +28,7 @@ const API_CONFIG: Record = { dev: { baseURL: 'http://localhost:8080', // 本地Go服务 pythonURL: 'http://localhost:8000', // 本地Python服务 + websocketURL: 'ws://localhost:8000', // 本地WebSocket服务 timeout: 90000 }, @@ -34,6 +36,7 @@ const API_CONFIG: Record = { test: { baseURL: 'https://lehang.tech', // 测试服务器Go服务 pythonURL: 'https://lehang.tech', // 测试服务器Python服务 + websocketURL: 'wss://lehang.tech', // 测试服务器WebSocket服务 timeout: 90000 }, @@ -41,6 +44,7 @@ const API_CONFIG: Record = { prod: { baseURL: 'https://lehang.tech', // 生产环境Go服务 pythonURL: 'https://lehang.tech', // 生产环境Python服务 + websocketURL: 'wss://lehang.tech', // 生产环境WebSocket服务 timeout: 90000 } }; @@ -90,6 +94,9 @@ console.log(`[API Config] 主服务: ${currentConfig().baseURL}`); if (currentConfig().pythonURL) { console.log(`[API Config] Python服务: ${currentConfig().pythonURL}`); } +if (currentConfig().websocketURL) { + console.log(`[API Config] WebSocket服务: ${currentConfig().websocketURL}`); +} // API端点 export const API = { @@ -100,6 +107,16 @@ export const API = { get pythonURL(): string | undefined { return currentConfig().pythonURL; }, + get websocketURL(): string { + // 如果配置了websocketURL则使用,否则从pythonURL或baseURL自动转换 + const config = currentConfig(); + if (config.websocketURL) { + return config.websocketURL; + } + // 降级:从pythonURL或baseURL自动转换 + const httpURL = config.pythonURL || config.baseURL; + return httpURL.replace('http://', 'ws://').replace('https://', 'wss://'); + }, get timeout(): number { return currentConfig().timeout; }, diff --git a/miniprogram/miniprogram/pages/profile/platform-bind/platform-bind.ts b/miniprogram/miniprogram/pages/profile/platform-bind/platform-bind.ts index ea62a0f..f754706 100644 --- a/miniprogram/miniprogram/pages/profile/platform-bind/platform-bind.ts +++ b/miniprogram/miniprogram/pages/profile/platform-bind/platform-bind.ts @@ -1,6 +1,6 @@ // pages/profile/platform-bind/platform-bind.ts import { EmployeeService } from '../../../services/employee'; -import { API } from '../../../config/api'; +import { API, isDevelopment } from '../../../config/api'; Page({ data: { @@ -18,10 +18,15 @@ Page({ pollTimer: null as any, // 轮询定时器 pollCount: 0, // 轮询次数 socketTask: null as any, // WebSocket连接 + socketConnected: false, // WebSocket连接状态 + socketReadyState: -1, // WebSocket ReadyState (0=CONNECTING, 1=OPEN, 2=CLOSING, 3=CLOSED) // 验证码相关 needCaptcha: false, // 是否需要验证码 captchaType: '', // 验证码类型 qrcodeImage: '', // 二维码图片base64 + captchaTitle: '', // 二维码标题提示 + qrcodeStatus: 0, // 二维码状态 (1=等待扫码, 2=已扫码, 4=验证成功, 5=已过期) + qrcodeStatusText: '', // 二维码状态文本 sessionId: '', // 发送验证码时返回的session_id,用于复用浏览器 // 登录方式 loginType: 'phone' as 'phone' | 'qrcode', // phone: 手机号登录, qrcode: 扫码登录 @@ -34,23 +39,23 @@ Page({ qrId: '', // 二维码ID qrCode: '', // 二维码code qrcodeError: '', // 二维码加载错误提示 - qrcodeLoading: false // 二维码是否正在加载 + qrcodeLoading: false, // 二维码是否正在加载 + isDevelopment: isDevelopment(), // 是否开发环境 + isGettingCode: false // 是否正在获取验证码(防抖) }, onLoad() { console.log('小红书绑定页面加载'); - - // 页面加载时就生成session_id并建立WebSocket连接 - const sessionId = `xhs_login_${Date.now().toString(36)}${Math.random().toString(36).substr(2, 9)}`; - console.log('[页面加载] 生成session_id:', sessionId); - this.setData({ sessionId }); - - // 建立WebSocket连接 - console.log('[页面加载] 建立WebSocket连接...'); - this.connectWebSocket(sessionId); + // 不再在页面加载时建立连接,等待用户输入手机号并点击获取验证码 + console.log('[页面加载] 等待用户输入手机号...'); }, onUnload() { + console.log('[页面生命周期] onUnload - 页面卸载'); + + // 清理sessionId,阻止重连 + this.setData({ sessionId: '' }); + if (this.data.countdownTimer) { clearInterval(this.data.countdownTimer); } @@ -59,8 +64,69 @@ Page({ clearInterval(this.data.pollTimer); } // 关闭WebSocket连接 - if (this.data.socketTask) { - this.data.socketTask.close(); + console.log('[页面生命周期] 关闭WebSocket连接'); + this.closeWebSocket(); + }, + + onHide() { + console.log('[页面生命周期] onHide - 页面隐藏'); + console.log('[页面生命周期] 当前登录方式:', this.data.loginType); + console.log('[页面生命周期] 是否正在等待登录结果:', (this.data as any).waitingLoginResult); + console.log('[页面生命周期] 是否需要验证码:', this.data.needCaptcha); + + // 临时标记为隐藏状态,阻止重连 + this.setData({ pageHidden: true } as any); + + // 如果正在等待登录结果,不关闭WebSocket + if ((this.data as any).waitingLoginResult) { + console.log('[页面生命周期] 正在等待登录结果,保持WebSocket连接'); + return; + } + + // 如果是扫码登录模式,不关闭WebSocket(用户可能切到小红书APP扫码) + if (this.data.loginType === 'qrcode') { + console.log('[页面生命周期] 扫码登录模式,保持WebSocket连接(用户可能在扫码)'); + return; + } + + // 如果出现风控验证码弹窗,不关闭WebSocket(用户可能切到小红书APP扫码) + if (this.data.needCaptcha) { + console.log('[页面生命周期] 风控验证中,保持WebSocket连接(用户可能在扫码)'); + return; + } + + console.log('[页面生命周期] 关闭WebSocket连接'); + // 页面隐藏时也关闭WebSocket连接 + this.closeWebSocket(); + }, + + onShow() { + console.log('[页面生命周期] onShow - 页面显示'); + + // 清除隐藏标记 + this.setData({ pageHidden: false } as any); + + // 检查WebSocket连接状态 + const socketTask = this.data.socketTask; + if (socketTask) { + try { + const readyState = (socketTask as any).readyState; + // readyState: 0=CONNECTING, 1=OPEN, 2=CLOSING, 3=CLOSED + if (readyState === 0 || readyState === 1) { + console.log('[页面生命周期] WebSocket连接已存在且正常,无需重建'); + return; + } + console.log(`[页面生命周期] WebSocket连接状态异常: ${readyState},准备重建`); + } catch (e) { + console.log('[页面生命周期] 检查WebSocket状态失败:', e); + } + } + + // 页面显示时重新建立连接 + const sessionId = this.data.sessionId; + if (sessionId) { + console.log('[页面生命周期] 重新建立WebSocket连接:', sessionId); + this.connectWebSocket(sessionId); } }, @@ -73,9 +139,19 @@ Page({ // 手机号输入 onPhoneInput(e: any) { + const phone = e.detail.value; this.setData({ - phone: e.detail.value + phone: phone }); + + // 不再在输入手机号时建立连接,等待点击获取验证码 + if (phone.length === 11) { + const sessionId = `xhs_login_${phone}`; + console.log('[手机号输入] 手机号已输入完成:', phone); + console.log('[手机号输入] 生成session_id:', sessionId); + // 只更新session_id,不建立连接 + this.setData({ sessionId }); + } }, // 验证码输入 @@ -87,11 +163,17 @@ Page({ // 获取验证码 async getVerifyCode() { + // 防抖:如果正在获取中,直接返回 + if (this.data.isGettingCode) { + console.log('[发送验证码] 正在处理中,忽略重复点击'); + return; + } + if (this.data.countdown > 0) { return; } - const { phone, countryCodes, countryCodeIndex, sessionId, socketTask } = this.data; + const { phone, countryCodes, countryCodeIndex } = this.data; if (phone.length !== 11) { wx.showToast({ title: '请输入正确的手机号', @@ -101,26 +183,96 @@ Page({ return; } + // 设置防抖标记 + this.setData({ isGettingCode: true }); + + // 立即显示加载动画 + wx.showToast({ + title: '正在连接...', + icon: 'loading', + duration: 20000, + mask: true + }); + try { - const countryCode = countryCodes[countryCodeIndex]; - console.log('[发送验证码] 开始,手机号:', phone, '区号:', countryCode); - console.log('[发送验证码] 使用现有session_id:', sessionId); + // 使用手机号生成session_id + const sessionId = `xhs_login_${phone}`; - // 检查WebSocket连接 + console.log('[发送验证码] 手机号:', phone); + console.log('[发送验证码] session_id:', sessionId); + + // 更新session_id + this.setData({ sessionId }); + + // 检查是否已经有连接 + const socketTask = this.data.socketTask; if (!socketTask) { - console.error('[发送验证码] WebSocket连接不存在,重新建立...'); + // 没有连接,建立新连接 + console.log('[发送验证码] 没有WebSocket连接,建立连接...'); this.connectWebSocket(sessionId); - await new Promise(resolve => setTimeout(resolve, 500)); + + // 等待连接建立 + console.log('[发送验证码] 等待连接建立...'); + await new Promise(resolve => setTimeout(resolve, 2000)); + } else { + // 已有连接,检查状态 + const readyState = (socketTask as any).readyState; + console.log('[发送验证码] 已有连接,状态:', readyState); + + // readyState: 0=CONNECTING, 1=OPEN, 2=CLOSING, 3=CLOSED + if (readyState === 3 || readyState === 2) { + // 连接已关闭或正在关闭,重新建立 + console.log('[发送验证码] 连接已关闭,重新建立...'); + this.connectWebSocket(sessionId); + await new Promise(resolve => setTimeout(resolve, 2000)); + } else if (readyState === 0) { + // 连接建立中,等待 + console.log('[发送验证码] 连接建立中,等待...'); + await new Promise(resolve => setTimeout(resolve, 1000)); + } + // readyState === 1 连接已打开,直接使用 } - // 通过WebSocket发送send_code消息 - console.log('[发送验证码] 通过WebSocket发送请求...'); - const currentSocketTask = this.data.socketTask; - if (!currentSocketTask) { - throw new Error('WebSocket连接未建立'); + const countryCode = countryCodes[countryCodeIndex]; + + // 再次检查WebSocket连接 + const finalSocketTask = this.data.socketTask; + if (!finalSocketTask) { + console.log('[发送验证码] WebSocket连接不存在,异常情况!'); + throw new Error('WebSocket连接未建立,请刷新页面'); } - currentSocketTask.send({ + // 检查连接状态 + const readyState = (finalSocketTask as any).readyState; + console.log('[发送验证码] 最终WebSocket连接状态:', readyState); + + // readyState: 0=CONNECTING, 1=OPEN, 2=CLOSING, 3=CLOSED + if (readyState !== 1) { + // 如果连接还在建立中,再等待一下 + if (readyState === 0) { + console.log('[发送验证码] 连接建立中,等待打开...'); + await new Promise(resolve => setTimeout(resolve, 1000)); + + const newReadyState = (finalSocketTask as any).readyState; + console.log('[发送验证码] 等待后的连接状态:', newReadyState); + + if (newReadyState !== 1) { + throw new Error('连接建立失败,请重试'); + } + } else { + throw new Error('连接已关闭,请重试'); + } + } + + // 更新加载提示 + wx.showToast({ + title: '正在发送验证码...', + icon: 'loading', + duration: 20000, + mask: true + }); + + finalSocketTask.send({ data: JSON.stringify({ type: 'send_code', phone: phone, @@ -129,14 +281,11 @@ Page({ }), success: () => { console.log('[发送验证码] WebSocket消息发送成功'); - wx.showToast({ - title: '正在发送验证码...', - icon: 'loading', - duration: 20000 - }); }, fail: (err: any) => { console.error('[发送验证码] WebSocket消息发送失败:', err); + // 清除防抖标记 + this.setData({ isGettingCode: false }); wx.showToast({ title: '发送失败,请重试', icon: 'none', @@ -147,6 +296,8 @@ Page({ } catch (error: any) { console.error('[发送验证码] 异常:', error); + // 清除防抖标记 + this.setData({ isGettingCode: false }); wx.showToast({ title: error.message || '发送失败,请重试', icon: 'none', @@ -232,6 +383,17 @@ Page({ throw new Error('WebSocket连接已断开,请重新发送验证码'); } + // 检查连接状态 + const readyState = (socketTask as any).readyState; + console.log('[绑定账号] WebSocket连接状态:', readyState); + + if (readyState !== 1) { + throw new Error('WebSocket连接已关闭,请重新发送验证码'); + } + + // 标记正在等待登录结果,防止onHide关闭连接 + this.setData({ waitingLoginResult: true } as any); + const countryCode = countryCodes[countryCodeIndex]; wx.showToast({ @@ -253,6 +415,8 @@ Page({ }, fail: (err: any) => { console.error('[绑定账号] WebSocket消息发送失败:', err); + // 清除等待标记 + this.setData({ waitingLoginResult: false } as any); wx.showToast({ title: '发送失败,请重试', icon: 'none', @@ -263,6 +427,8 @@ Page({ } catch (error: any) { console.error('[绑定账号] 异常:', error); + // 清除等待标记 + this.setData({ waitingLoginResult: false } as any); wx.showToast({ title: error.message || '绑定失败,请重试', icon: 'none', @@ -892,25 +1058,102 @@ Page({ } }, + // 保存验证码登录信息 + async saveLoginInfo(loginData: any) { + try { + console.log('开始保存验证码登录信息...', loginData); + + // 获取用户token + const token = wx.getStorageSync('token'); + if (!token) { + throw new Error('未登录,请先登录'); + } + + const saveResult: any = await new Promise((resolve, reject) => { + wx.request({ + url: `${API.baseURL}/api/xhs/save-login`, + method: 'POST', + header: { + 'Content-Type': 'application/json' + }, + data: { + employee_id: (wx.getStorageSync('employeeInfo') || {}).id, + storage_state: loginData.storage_state || {}, + storage_state_path: loginData.storage_state_path || '', + user_info: loginData.user_info || {} // 新增: 传递用户信息 + }, + success: (res) => resolve(res.data), + fail: (err) => reject(err) + }); + }); + + console.log('保存结果:', saveResult); + + if (saveResult.code === 0 || saveResult.code === 200) { + // 更新本地缓存 + const bindings = wx.getStorageSync('socialBindings') || {}; + + bindings.xiaohongshu = { + phone: this.data.phone, + xhs_account: '小红书用户', + bindTime: new Date().getTime(), + cookieExpired: false + }; + + wx.setStorageSync('socialBindings', bindings); + console.log('[验证码登录] 绑定成功,已更新本地缓存', bindings.xiaohongshu); + } else { + throw new Error(saveResult.message || '保存失败'); + } + } catch (error: any) { + console.error('保存验证码登录信息失败:', error); + throw error; // 抛出错误让调用方处理 + } + }, + // 建立WebSocket连接 connectWebSocket(sessionId: string) { + console.log('[WebSocket] ========== connectWebSocket被调用 =========='); + console.log('[WebSocket] 调用栈:', new Error().stack?.split('\n').slice(1, 4).join('\n')); + console.log('[WebSocket] SessionID:', sessionId); + console.log('[WebSocket] 当前needCaptcha状态:', this.data.needCaptcha); + console.log('[WebSocket] ==========================================='); + + // 清除正常关闭标记,新连接默认为非正常关闭 + // 重置重连计数,允许新连接重连 + this.setData({ + normalClose: false, + reconnectCount: 0 + } as any); + // 关闭旧连接 if (this.data.socketTask) { try { + console.log('[WebSocket] 检测到旧连接,准备关闭...'); + // 标记为正常关闭,不触发重连 + this.setData({ + normalClose: true, + reconnectCount: 999 + } as any); this.data.socketTask.close(); + console.log('[WebSocket] 已调用close(),等待关闭...'); } catch (e) { console.log('[WebSocket] 关闭旧连接失败(可能已关闭):', e); + } finally { + // 重置标记,允许新连接重连 + this.setData({ + normalClose: false, + reconnectCount: 0 + } as any); } } - // 获取Python服务地址(WebSocket端点在Python后端) - const pythonURL = API.pythonURL || API.baseURL; - // 将http/https转为ws/wss - const wsURL = pythonURL.replace('http://', 'ws://').replace('https://', 'wss://'); + // 获取WebSocket服务地址(使用配置化地址) + const wsURL = API.websocketURL; const url = `${wsURL}/ws/login/${sessionId}`; console.log('[WebSocket] 开始连接:', url); - console.log('[WebSocket] Python服务地址:', pythonURL); + console.log('[WebSocket] WebSocket服务地址:', wsURL); // 小程序环境检查 if (url.includes('localhost') || url.includes('127.0.0.1')) { @@ -942,9 +1185,20 @@ Page({ // 监听连接打开 socketTask.onOpen(() => { - console.log('[WebSocket] 连接已打开'); + console.log('[WebSocket] ========================================'); + console.log('[WebSocket] 连接已成功打开!'); + console.log('[WebSocket] Session ID:', sessionId); + console.log('[WebSocket] 连接URL:', url); + console.log('[WebSocket] 时间:', new Date().toLocaleString()); + console.log('[WebSocket] ========================================'); socketConnected = true; + // 更新页面状态 + this.setData({ + socketConnected: true, + socketReadyState: 1 // OPEN + }); + // 重置重连计数 this.setData({ reconnectCount: 0 } as any); @@ -975,8 +1229,46 @@ Page({ try { const data = JSON.parse(res.data as string); + // 处理二维码状态消息 + if (data.type === 'qrcode_status') { + console.log('📡 二维码状态变化:', `status=${data.status}`, data.message); + + // 更新二维码弹窗的提示文字和状态 + if (this.data.needCaptcha) { + // 根据状态显示不同的提示 + let captchaTitle = '扫码验证'; + let qrcodeStatusText = ''; + + if (data.status === 1) { + captchaTitle = '请使用小红书APP扫码'; + qrcodeStatusText = '等待扫码...'; + } else if (data.status === 2) { + captchaTitle = '扫码完成,请在APP中确认'; + qrcodeStatusText = '请在小红书APP中点击确认'; + } else if (data.status === 4) { + captchaTitle = '验证成功'; + qrcodeStatusText = '验证完成,正在处理...'; + } else if (data.status === 5) { + captchaTitle = '二维码已过期'; + qrcodeStatusText = '二维码已过期,请重新获取'; + } else { + captchaTitle = data.message || '扫码验证'; + qrcodeStatusText = data.message || ''; + } + + // 更新弹窗标题和状态 + this.setData({ + captchaTitle: captchaTitle, + qrcodeStatus: data.status, + qrcodeStatusText: qrcodeStatusText + } as any); + + console.log(`[二维码状态] 已更新提示: ${captchaTitle}`); + console.log(`[二维码状态] 状态文本: ${qrcodeStatusText}`); + } + } // 处理扫码成功消息(发送验证码阶段的风控) - if (data.type === 'qrcode_scan_success') { + else if (data.type === 'qrcode_scan_success') { console.log('✅ 扫码验证完成!', data.message); // 关闭验证码弹窗 @@ -1019,6 +1311,9 @@ Page({ } // 处理登录成功消息(点击登录按钮阶段的风控) else if (data.type === 'login_success') { + // 清除等待标记 + this.setData({ waitingLoginResult: false } as any); + // 判断是扫码验证成功还是真正的登录成功 if (data.storage_state) { // 真正的登录成功,包含 storage_state @@ -1034,27 +1329,46 @@ Page({ // 关闭WebSocket this.closeWebSocket(); - // 显示绑定成功动画 - this.setData({ - showSuccess: true + // 显示保存中 + wx.showLoading({ + title: '正在保存登录信息...', + mask: true }); - setTimeout(() => { + // 保存登录信息到数据库 + this.saveLoginInfo(data).then(() => { + wx.hideLoading(); + + // 显示绑定成功动画 this.setData({ - showSuccess: false + showSuccess: true }); - // 跳转回上一页 - wx.navigateBack({ - delta: 1, - success: () => { - console.log('✅ 跳转回上一页'); - }, - fail: (err) => { - console.error('⚠️ 跳转失败:', err); - } + setTimeout(() => { + this.setData({ + showSuccess: false + }); + + // 跳转回上一页 + wx.navigateBack({ + delta: 1, + success: () => { + console.log('✅ 跳转回上一页'); + }, + fail: (err) => { + console.error('⚠️ 跳转失败:', err); + } + }); + }, 2000); + }).catch((err) => { + wx.hideLoading(); + console.error('保存登录信息失败:', err); + wx.showToast({ + title: err.message || '保存失败,请重试', + icon: 'none', + duration: 3000 }); - }, 2000); + }); } else { // 扫码验证成功,但还需要继续登录 console.log('✅ 扫码验证成功!', data.user_info); @@ -1101,6 +1415,9 @@ Page({ else if (data.type === 'code_sent') { console.log('[WebSocket] 验证码发送结果:', data); + // 清除防抖标记 + this.setData({ isGettingCode: false }); + wx.hideToast(); if (data.success) { @@ -1124,6 +1441,9 @@ Page({ else if (data.type === 'login_result') { console.log('[WebSocket] 登录结果:', data); + // 清除等待标记 + this.setData({ waitingLoginResult: false } as any); + wx.hideToast(); if (!data.success) { @@ -1141,44 +1461,77 @@ Page({ // 监听错误 socketTask.onError((err) => { - console.error('[WebSocket] 连接错误:', err); + console.error('========== WebSocket连接错误 ==========') + console.error('[WebSocket] Session ID:', sessionId); + console.error('[WebSocket] 错误信息:', err); + console.error('[WebSocket] 错误类型:', err.errMsg || '未知'); + console.error('===========================================') socketConnected = false; - + + // 更新页面状态 + this.setData({ + socketConnected: false, + socketReadyState: 3 // CLOSED + }); + // 记录错误,准备重连 console.log('[WebSocket] 将在关闭后尝试重连'); }); // 监听关闭 - socketTask.onClose(() => { - console.log('[WebSocket] 连接关闭'); + socketTask.onClose((res: any) => { + console.log('========== WebSocket连接关闭 ==========') + console.log('[WebSocket] Session ID:', sessionId); + console.log('[WebSocket] 关闭原因:', res.reason || '未提供原因'); + console.log('[WebSocket] 关闭代码:', res.code || '未知'); + console.log('[WebSocket] 是否正常关闭:', res.code === 1000 ? '是' : '否'); + console.log('[WebSocket] 是否主动关闭:', (this.data as any).normalClose ? '是' : '否'); + console.log('=========================================') socketConnected = false; - + + // 更新页面状态 + this.setData({ + socketConnected: false, + socketReadyState: 3 // CLOSED + }); + // 清理ping定时器 if ((this.data as any).pingTimer) { clearInterval((this.data as any).pingTimer); } - // 检查是否需要重连(只有弹窗还在时才重连) - if (this.data.needCaptcha && sessionId) { - // 增加重连计数 - const reconnectCount = (this.data as any).reconnectCount || 0; + // 判断是否是正常关闭 + const isNormalClose = (this.data as any).normalClose || res.code === 1000; + + // 清除正常关闭标记 + this.setData({ normalClose: false } as any); + + // 检查是否需要重连(只要页面还在且未隐藏就重连) + // 增加重连计数 + const reconnectCount = (this.data as any).reconnectCount || 0; + + // 最多重连5次 + if (reconnectCount < 5) { + const delay = Math.min(1000 * Math.pow(2, reconnectCount), 10000); // 指数退避: 1s, 2s, 4s, 8s, 10s + console.log(`[WebSocket] 将在${delay}ms后进行第${reconnectCount + 1}次重连`); - // 最多重连3次 - if (reconnectCount < 3) { - const delay = Math.min(1000 * Math.pow(2, reconnectCount), 5000); // 指数退避: 1s, 2s, 4s - console.log(`[WebSocket] 将在${delay}ms后进行第${reconnectCount + 1}次重连`); - - setTimeout(() => { - if (this.data.needCaptcha) { // 再次检查弹窗是否还在 - console.log('[WebSocket] 开始重连...'); - this.setData({ reconnectCount: reconnectCount + 1 } as any); - this.connectWebSocket(sessionId); - } - }, delay); - } else { - console.log('[WebSocket] 已达到最大重连次数,停止重连'); + setTimeout(() => { + // 检查页面是否还在且未隐藏(通过sessionId存在和pageHidden来判断) + if (this.data.sessionId && !(this.data as any).pageHidden) { + console.log('[WebSocket] 开始重连...'); + this.setData({ reconnectCount: reconnectCount + 1 } as any); + this.connectWebSocket(this.data.sessionId); + } else { + console.log('[WebSocket] 页面已退出或隐藏,取消重连'); + } + }, delay); + } else { + console.log('[WebSocket] 已达到最大重连次数,停止重连'); + + // 只有非正常关闭才提示用户 + if (!isNormalClose) { wx.showToast({ - title: 'WebSocket连接失败,请重新发送验证码', + title: 'WebSocket连接断开,请刷新页面', icon: 'none', duration: 3000 }); @@ -1193,8 +1546,11 @@ Page({ closeWebSocket() { console.log('[WebSocket] 开始关闭连接'); - // 重置重连计数(主动关闭不需要重连) - this.setData({ reconnectCount: 999 } as any); // 设置为很大的数阻止重连 + // 标记为主动关闭,不需要重连和提示 + this.setData({ + reconnectCount: 999, // 阻止重连 + normalClose: true // 标记为正常关闭 + } as any); // 清理ping定时器 if ((this.data as any).pingTimer) { @@ -1236,6 +1592,23 @@ Page({ } else { console.log('[WebSocket] 没有活跃的WebSocket连接'); } + + // 重置连接状态显示 + this.setData({ + socketConnected: false, + socketReadyState: -1 + }); + }, + + // 强制关闭连接(手动按钮) + forceCloseWebSocket() { + console.log('[WebSocket] 用户手动关闭连接'); + this.closeWebSocket(); + wx.showToast({ + title: '已关闭连接', + icon: 'success', + duration: 1000 + }); }, // 保存二维码 @@ -1317,6 +1690,18 @@ Page({ }); }, + // 关闭验证码弹窗 + closeCaptcha() { + console.log('[关闭弹窗] 用户手动关闭二维码弹窗'); + this.setData({ + needCaptcha: false, + qrcodeImage: '', + captchaTitle: '', + qrcodeStatus: 0, + qrcodeStatusText: '' + }); + }, + // 测试WebSocket连接 testWebSocket() { console.log('[测试] 开始测试WebSocket...'); @@ -1334,9 +1719,8 @@ Page({ } } - // 获取WebSocket地址 - const pythonURL = API.pythonURL || API.baseURL; - const wsURL = pythonURL.replace('http://', 'ws://').replace('https://', 'wss://'); + // 获取WebSocket地址(使用配置化地址) + const wsURL = API.websocketURL; const url = `${wsURL}/ws/login/${testSessionId}`; console.log('[测试] WebSocket地址:', url); @@ -1421,5 +1805,80 @@ Page({ }); this.setData({ socketTask }); + }, + + // ========== 调试工具方法 ========== + + /** + * 调试:显示指定状态的二维码 + */ + debugShowQRCode(e: any) { + const status = parseInt(e.currentTarget.dataset.status); + console.log(`[调试工具] 显示状态1${status}的二维码`); + + // 生成模拟二维码图片(使用纯色图片代替) + const colors: { [key: number]: string } = { + 1: '#2196F3', // 蓝色 - 等待扫码 + 2: '#4CAF50', // 绿色 - 已扫码 + 4: '#FF9800', // 橙色 - 验证成功 + 5: '#F44336' // 红色 - 已过期 + }; + const color = colors[status] || '#999'; + + // 生成SVG二维码图片 + const svgQRCode = `data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='200' height='200'%3E%3Crect width='200' height='200' fill='white'/%3E%3Crect x='10' y='10' width='180' height='180' fill='${encodeURIComponent(color)}'/%3E%3Ctext x='100' y='100' text-anchor='middle' dominant-baseline='middle' font-size='24' fill='white' font-weight='bold'%3E状态${status}%3C/text%3E%3C/svg%3E`; + + // 根据状态设置标题和文本 + let captchaTitle = ''; + let qrcodeStatusText = ''; + + switch(status) { + case 1: + captchaTitle = '请使用小红书APP扫码'; + qrcodeStatusText = '等待扫码...'; + break; + case 2: + captchaTitle = '扫码完成,请在APP中确认'; + qrcodeStatusText = '请在小红书APP中点击确认'; + break; + case 4: + captchaTitle = '验证成功'; + qrcodeStatusText = '验证完成,正在处理...'; + break; + case 5: + captchaTitle = '二维码已过期'; + qrcodeStatusText = '二维码已过期,请重新获取'; + break; + } + + // 显示二维码弹窗 + this.setData({ + needCaptcha: true, + qrcodeImage: svgQRCode, + captchaTitle: captchaTitle, + qrcodeStatus: status, + qrcodeStatusText: qrcodeStatusText, + captchaType: 'qrcode' + }); + + wx.showToast({ + title: `已切换至状态1${status}`, + icon: 'none', + duration: 1500 + }); + }, + + /** + * 调试:关闭二维码弹窗 + */ + debugCloseQRCode() { + console.log('[调试工具] 关闭二维码弹窗'); + this.setData({ + needCaptcha: false, + qrcodeImage: '', + captchaTitle: '', + qrcodeStatus: 0, + qrcodeStatusText: '' + }); } }); diff --git a/miniprogram/miniprogram/pages/profile/platform-bind/platform-bind.wxml b/miniprogram/miniprogram/pages/profile/platform-bind/platform-bind.wxml index f2e3a23..ced1dc5 100644 --- a/miniprogram/miniprogram/pages/profile/platform-bind/platform-bind.wxml +++ b/miniprogram/miniprogram/pages/profile/platform-bind/platform-bind.wxml @@ -1,10 +1,17 @@ - - - + + + 🛠️ 调试工具 + + + + + + + - + @@ -80,12 +87,45 @@ {{loadingText}} - - - 请使用小红书APP扫描二维码 - - 扫码后即可继续绑定流程 - + + + + + + + + + + + + + + + + + + + × + + + + + + + {{captchaTitle || '请使用小红书APP扫码'}} + 在APP中确认完成验证 + 长按二维码可保存 + 请关闭后重新获取 + + + + + + + diff --git a/miniprogram/miniprogram/pages/profile/platform-bind/platform-bind.wxss b/miniprogram/miniprogram/pages/profile/platform-bind/platform-bind.wxss index 93dc03b..fa8b587 100644 --- a/miniprogram/miniprogram/pages/profile/platform-bind/platform-bind.wxss +++ b/miniprogram/miniprogram/pages/profile/platform-bind/platform-bind.wxss @@ -214,67 +214,191 @@ page { width: 100%; } -/* 二维码验证区域 */ -.qrcode-section { - width: 100%; +/* ========== 二维码弹窗(扁平化设计) ========== */ + +/* 蒙层 */ +.qrcode-modal { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: 999; display: flex; flex-direction: column; align-items: center; - padding: 48rpx 0; - margin-bottom: 48rpx; + justify-content: center; } -.qrcode-title { - font-size: 32rpx; - font-weight: 500; - color: #333; - margin-bottom: 48rpx; - text-align: center; +.qrcode-mask { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.6); } -.qrcode { - width: 500rpx; - height: 500rpx; - border: 2rpx solid #E5E5E5; - border-radius: 16rpx; - padding: 32rpx; +/* 卡片 */ +.qrcode-card { + position: relative; + z-index: 1; + width: 600rpx; background: #fff; - box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.08); - margin-bottom: 32rpx; + border-radius: 24rpx; + padding: 60rpx 40rpx; + display: flex; + flex-direction: column; + align-items: center; } -.qrcode-hint { - font-size: 26rpx; - color: #999; - text-align: center; - line-height: 1.6; - margin-bottom: 24rpx; -} - -/* 保存二维码按钮 */ -.save-qrcode-btn { - width: 100%; +/* 外部关闭按钮 */ +.close-btn-outer { + position: relative; + z-index: 1; + width: 80rpx; height: 80rpx; - background: linear-gradient(135deg, #FF2442 0%, #FF4F6A 100%); - border-radius: 12rpx; - font-size: 30rpx; - font-weight: 500; - color: #fff; - border: none; + margin-top: 40rpx; display: flex; align-items: center; justify-content: center; - box-shadow: 0 8rpx 16rpx rgba(255, 36, 66, 0.3); - transition: all 0.3s; + background: rgba(255, 255, 255, 0.9); + border-radius: 50%; + transition: all 0.2s; } -.save-qrcode-btn::after { - border: none; +.close-btn-outer:active { + background: rgba(255, 255, 255, 0.7); + transform: scale(0.95); } -.save-qrcode-btn:active { - opacity: 0.8; - transform: scale(0.98); +.close-icon { + font-size: 40rpx; + color: #666; + line-height: 1; +} + +/* 二维码区域 */ +.qr-box { + position: relative; + width: 420rpx; + height: 420rpx; + margin-bottom: 36rpx; +} + +.qr-img { + width: 100%; + height: 100%; + padding: 24rpx; + background: #fff; + border: 2rpx solid #eee; + border-radius: 16rpx; + box-sizing: border-box; +} + +/* 状态透明层(已扫码/已过期) */ +.scan-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + border-radius: 16rpx; + display: flex; + align-items: center; + justify-content: center; + animation: fadeIn 0.3s ease; +} + +.scan-overlay.success { + background: rgba(82, 196, 26, 0.15); +} + +.scan-overlay.error { + background: rgba(255, 77, 79, 0.15); +} + +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +/* 状态图标 */ +.scan-icon { + width: 120rpx; + height: 120rpx; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + animation: scaleIn 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275); +} + +.scan-overlay.success .scan-icon { + background: #52C41A; + box-shadow: 0 8rpx 24rpx rgba(82, 196, 26, 0.4); +} + +.scan-overlay.error .scan-icon { + background: #FF4D4F; + box-shadow: 0 8rpx 24rpx rgba(255, 77, 79, 0.4); +} + +@keyframes scaleIn { + from { + transform: scale(0); + opacity: 0; + } + to { + transform: scale(1); + opacity: 1; + } +} + +.icon-checkmark { + font-size: 72rpx; + color: #fff; + font-weight: bold; + line-height: 1; +} + +.icon-cross { + font-size: 80rpx; + color: #fff; + font-weight: 300; + line-height: 1; + display: flex; + align-items: center; + justify-content: center; +} + +/* 提示文本 */ +.qr-tips { + display: flex; + flex-direction: column; + align-items: center; + gap: 12rpx; +} + +.tip-main { + font-size: 32rpx; + font-weight: 500; + color: #333; + text-align: center; +} + +.tip-sub { + font-size: 26rpx; + color: #999; + text-align: center; +} + +.tip-sub.error { + color: #FF4D4F; } .bind-form { diff --git a/验证.txt b/验证.txt index c6e79f2..3bfc85b 100644 --- a/验证.txt +++ b/验证.txt @@ -1 +1 @@ -https://www.xiaohongshu.com/website-login/captcha?redirectPath=https%3A%2F%2Fwww.xiaohongshu.com%2Fexplore%3FexSource%3D&verifyUuid=e3d62847-8664-4bd0-8c7c-9a9032d6eaff&verifyType=124&verifyBiz=461 \ No newline at end of file + \ No newline at end of file