Files

1 line
43 KiB
JSON

{"repo": "boots-coder/foundgeo", "files": {"backend/app/routers/settings.py": {"a01cdb9ae2d2a23a538538335d762cf1d9a38266": {"t": 1785746911.0811052, "n": 18061, "h": "51eb8910c49da049", "c": "\"\"\"设置路由 - 完整可写配置中心。\n\nGET /api/settings/runtime → 当前配置 (敏感字段已 mask)\nPUT /api/settings/runtime → 写入配置 (空字符串 = 删除该 key)\nPOST /api/settings/test/llm → 用当前 LLM 配置 ping 一次\nPOST /api/settings/test/publisher/{platform} → 测试发布平台凭证\nPOST /api/settings/test/monitor/{platform} → 测试 AI 监测平台凭证\n\n兼容旧版:\nGET /api/settings/platforms → 平台已配置状态列表\nGET /api/settings/llm → LLM 配置概览\n\"\"\"\nfrom __future__ import annotations\n\nimport logging\nfrom typing import Any\n\nfrom fastapi import APIRouter\nfrom pydantic import BaseModel\n\nfrom ..config import get_settings, reload_settings\nfrom ..schemas import err, ok\nfrom ..services.runtime_config import (\n ALLOWED_KEYS,\n SECRET_KEYS,\n clean_cookie_value,\n clean_token_value,\n delete_runtime_keys,\n load_runtime_config,\n mask_secret,\n save_runtime_config,\n)\n\nlogger = logging.getLogger(\"geo.routers.settings\")\nrouter = APIRouter()\n\nPLATFORMS = (\n \"zhihu\",\n \"wechat_mp\",\n \"toutiao\",\n \"sohu\",\n \"baijiahao\",\n \"wordpress\",\n)\n\n# 字段元数据 — 前端用此渲染表单\nFIELD_SCHEMA: list[dict[str, Any]] = [\n # === 通用 ===\n {\n \"key\": \"GEO_USE_MOCK\",\n \"label\": \"Mock 模式\",\n \"type\": \"boolean\",\n \"category\": \"general\",\n \"help\": \"开启后所有 LLM/分发/监测走 mock 实现, 无需真实 key。修改后需重启生效。\",\n },\n {\n \"key\": \"GEO_LOG_LEVEL\",\n \"label\": \"日志级别\",\n \"type\": \"select\",\n \"options\": [\"DEBUG\", \"INFO\", \"WARNING\", \"ERROR\"],\n \"category\": \"general\",\n },\n # === LLM ===\n {\n \"key\": \"LLM_PROVIDER\",\n \"label\": \"LLM 服务商\",\n \"type\": \"select\",\n \"options\": [\"mock\", \"openai\", \"deepseek\", \"moonshot\", \"anthropic\"],\n \"category\": \"llm\",\n \"help\": \"推荐 deepseek (中文友好, ¥1/百万 tokens)\",\n },\n {\n \"key\": \"LLM_MODEL\",\n \"label\": \"模型名\",\n \"type\": \"text\",\n \"placeholder\": \"deepseek-chat / gpt-4o-mini / claude-opus-4-6 ...\",\n \"category\": \"llm\",\n },\n {\n \"key\": \"DEEPSEEK_API_KEY\",\n \"label\": \"DeepSeek API Key\",\n \"type\": \"password\",\n \"category\": \"llm\",\n \"help\": \"申请: https://platform.deepseek.com/api_keys\",\n },\n {\n \"key\": \"OPENAI_API_KEY\",\n \"label\": \"OpenAI API Key\",\n \"type\": \"password\",\n \"category\": \"llm\",\n \"help\": \"申请: https://platform.openai.com/api-keys (M4 ChatGPT 监测也用此)\",\n },\n {\n \"key\": \"OPENAI_MONITOR_MODEL\",\n \"label\": \"ChatGPT 监测模型\",\n \"type\": \"text\",\n \"placeholder\": \"gpt-4o-mini\",\n \"category\": \"llm\",\n },\n {\n \"key\": \"ANTHROPIC_API_KEY\",\n \"label\": \"Anthropic API Key\",\n \"type\": \"password\",\n \"category\": \"llm\",\n \"help\": \"申请: https://console.anthropic.com/settings/keys\",\n },\n {\n \"key\": \"KIMI_API_KEY\",\n \"label\": \"Moonshot/Kimi API Key\",\n \"type\": \"password\",\n \"category\": \"llm\",\n \"help\": \"申请: https://platform.moonshot.cn/console/api-keys (M4 Kimi 监测也用此)\",\n },\n # === 分发: WordPress ===\n {\n \"key\": \"WORDPRESS_URL\",\n \"label\": \"WordPress 站点 URL\",\n \"type\": \"text\",\n \"placeholder\": \"https://your-site.com\",\n \"category\": \"publisher\",\n \"platform\": \"wordpress\",\n },\n {\n \"key\": \"WORDPRESS_USERNAME\",\n \"label\": \"WordPress 登录名\",\n \"type\": \"text\",\n \"category\": \"publisher\",\n \"platform\": \"wordpress\",\n },\n {\n \"key\": \"WORDPRESS_APP_PASSWORD\",\n \"label\": \"WordPress App Password\",\n \"type\": \"password\",\n \"category\": \"publisher\",\n \"platform\": \"wordpress\",\n \"help\": \"WP 后台 → 用户 → 个人资料 → 应用程序密码\",\n },\n # === 分发: 微信公众号 ===\n {\n \"key\": \"WECHAT_MP_APPID\",\n \"label\": \"微信公众号 AppID (服务号)\",\n \"type\": \"text\",\n \"category\": \"publisher\",\n \"platform\": \"wechat_mp\",\n \"help\": \"服务号官方 API 模式, 需要 IP 白名单\",\n },\n {\n \"key\": \"WECHAT_MP_SECRET\",\n \"label\": \"微信公众号 Secret\",\n \"type\": \"password\",\n \"category\": \"publisher\",\n \"platform\": \"wechat_mp\",\n },\n {\n \"key\": \"WECHAT_MP_COOKIE\",\n \"label\": \"微信公众号 Cookie (订阅号)\",\n \"type\": \"textarea\",\n \"category\": \"publisher\",\n \"platform\": \"wechat_mp\",\n \"help\": \"Web cookie 模式, 1-2 天过期, 适合无认证账号\",\n },\n # === 分发: 第三方平台 cookie ===\n {\n \"key\": \"ZHIHU_COOKIE\",\n \"label\": \"知乎 Cookie\",\n \"type\": \"textarea\",\n \"category\": \"publisher\",\n \"platform\": \"zhihu\",\n \"help\": \"登录 zhihu.com → F12 → Application → Cookies, 复制全部为 name=value; ... 字符串。约 30 天过期\",\n },\n {\n \"key\": \"SOHU_COOKIE\",\n \"label\": \"搜狐号 Cookie\",\n \"type\": \"textarea\",\n \"category\": \"publisher\",\n \"platform\": \"sohu\",\n \"help\": \"约 7 天过期\",\n },\n {\n \"key\": \"TOUTIAO_COOKIE\",\n \"label\": \"头条号 Cookie\",\n \"type\": \"textarea\",\n \"category\": \"publisher\",\n \"platform\": \"toutiao\",\n \"help\": \"必须包含 csrftoken, 约 7 天过期\",\n },\n {\n \"key\": \"BAIJIAHAO_COOKIE\",\n \"label\": \"百家号 Cookie\",\n \"type\": \"textarea\",\n \"category\": \"publisher\",\n \"platform\": \"baijiahao\",\n \"help\": \"约 7 天过期\",\n },\n # === AI 监测 ===\n {\n \"key\": \"DOUBAO_REFRESH_TOKEN\",\n \"label\": \"豆包 sessionid\",\n \"type\": \"password\",\n \"category\": \"monitor\",\n \"platform\": \"doubao\",\n \"help\": (\n \"⚠️ 不是字节火山引擎的 LLM API key, 是 www.doubao.com 浏览器登录态 sessionid。\\n\"\n \"获取步骤:\\n\"\n \"1) 浏览器打开 https://www.doubao.com 并登录\\n\"\n \"2) F12 → Application (Chrome) / 存储 (Firefox) → 左侧 Cookies → 选 https://www.doubao.com\\n\"\n \"3) 找 Name 为 sessionid 的那行, 复制 Value (40+ 字符的 hex 串)\\n\"\n \"4) 粘贴到此输入框\\n\"\n \"\\n\"\n \"也可以直接粘贴整段 cookie 字符串 (含 sessionid=xxx;...), 系统会自动抠出。\\n\"\n \"服务端只校验 sessionid, 字节家 mssdk 反爬的 msToken/a_bogus 我们用伪值绕过, 与浏览器一致。\"\n ),\n },\n {\n \"key\": \"KIMI_BEARER\",\n \"label\": \"Kimi Web Bearer (推荐)\",\n \"type\": \"textarea\",\n \"category\": \"monitor\",\n \"platform\": \"kimi\",\n \"help\": (\n \"⚠️ 不是 platform.moonshot.cn 的 API Key, 是 www.kimi.com 网页版登录态 JWT。\\n\"\n \"获取步骤:\\n\"\n \"1) 浏览器打开 https://www.kimi.com 并登录\\n\"\n \"2) F12 → Network → 随便发一句话, 找到 .../ChatService/Chat 请求\\n\"\n \"3) Request Headers 里复制 authorization 的值, 去掉前缀 'Bearer ' 后粘贴到此\\n\"\n \" (或 Application → Cookies → 找 kimi-auth, 复制 value)\\n\"\n \"\\n\"\n \"有效期约 30 天, JWT 内已包含 device_id/ssid/traffic_id, 无需再单独配置。\\n\"\n \"优先级: KIMI_API_KEY > KIMI_BEARER > KIMI_COOKIE。\"\n ),\n },\n {\n \"key\": \"KIMI_COOKIE\",\n \"label\": \"Kimi Web Cookie (备选)\",\n \"type\": \"textarea\",\n \"category\": \"monitor\",\n \"platform\": \"kimi\",\n \"help\": \"整段 Cookie 字符串, 系统会自动从 kimi-auth 抠 JWT。已填 KIMI_BEARER 时可留空。\",\n },\n {\n \"key\": \"DEEPSEEK_REFRESH_TOKEN\",\n \"label\": \"DeepSeek Refresh Token\",\n \"type\": \"password\",\n \"category\": \"monitor\",\n \"platform\": \"deepseek\",\n \"help\": (\n \"⚠️ 不是 platform.deepseek.com 的 LLM API Key, 而是 chat.deepseek.com 网页版的账号级 token。\\n\"\n \"获取步骤:\\n\"\n \"1) 浏览器打开 https://chat.deepseek.com 并登录\\n\"\n \"2) 按 F12 打开开发者工具 → 切到 Application (Chrome) 或 存储 (Firefox) 标签\\n\"\n \"3) 左侧展开 Local Storage → 选中 https://chat.deepseek.com\\n\"\n \"4) 右侧找到 key 为 userToken 的一行, 复制 value 字段里的整段 JSON (形如 {\\\"value\\\":\\\"RWXtg...\\\"} )\\n\"\n \"5) 粘贴到此输入框, 点「从粘贴提取 token」按钮自动解析\\n\"\n \"\\n\"\n \"替代方案: 随便在网页上发一条消息, 在 Network 里抓到任意请求, 复制 curl → 粘贴过来点按钮, \"\n \"会自动从 Authorization: Bearer ... 里抽出 token。\"\n ),\n },\n]\n\n\n# ============================================================\n# 新版: 完整可写配置\n# ============================================================\n\n\n@router.get(\"/settings/schema\")\ndef get_schema() -> dict:\n \"\"\"返回字段元数据, 前端用于动态渲染表单。\"\"\"\n return ok(\n {\n \"fields\": FIELD_SCHEMA,\n \"categories\": [\n {\"key\": \"general\", \"label\": \"通用\"},\n {\"key\": \"llm\", \"label\": \"LLM 服务\"},\n {\"key\": \"publisher\", \"label\": \"分发平台\"},\n {\"key\": \"monitor\", \"label\": \"AI 监测平台\"},\n ],\n }\n )\n\n\n@router.get(\"/settings/runtime\")\ndef get_runtime_config() -> dict:\n \"\"\"读取当前生效配置, 敏感字段已掩码 (sk-1***xxxx)。\n\n 返回的字段同时包含来自 .env 的值和来自 runtime_config.json 的覆盖。\n \"\"\"\n settings = get_settings()\n overlay = load_runtime_config()\n out: dict[str, Any] = {}\n for key in ALLOWED_KEYS:\n value = getattr(settings, key, None)\n if key in SECRET_KEYS:\n out[key] = {\n \"configured\": bool(value),\n \"masked\": mask_secret(value),\n \"from_runtime\": key in overlay,\n }\n else:\n out[key] = {\n \"value\": value,\n \"from_runtime\": key in overlay,\n }\n return ok(out)\n\n\nclass RuntimeConfigBody(BaseModel):\n updates: dict[str, Any]\n\n\n@router.put(\"/settings/runtime\")\ndef put_runtime_config(body: RuntimeConfigBody) -> dict:\n \"\"\"写入运行时配置。\n\n 规则:\n - 仅 ALLOWED_KEYS 内的字段会被保存\n - 敏感字段空字符串 = 不修改 (而不是清空)\n - 非敏感字段空字符串 = 清空 (恢复 .env / 默认)\n - 写入后立即调用 reload_settings(), 工厂会拿到新值\n \"\"\"\n cleaned: dict[str, Any] = {}\n for k, v in body.updates.items():\n if k not in ALLOWED_KEYS:\n continue\n # 敏感字段: 空字符串视为\"不修改\", 跳过\n if k in SECRET_KEYS and isinstance(v, str) and v.strip() == \"\":\n continue\n # 布尔字段\n if k == \"GEO_USE_MOCK\":\n v = bool(v)\n cleaned[k] = v\n\n save_runtime_config(cleaned)\n reload_settings() # 让下次 get_settings() 拿到新值\n return ok({\"updated\": list(cleaned.keys()), \"count\": len(cleaned)})\n\n\n@router.delete(\"/settings/runtime/{key}\")\ndef delete_runtime_key(key: str) -> dict:\n \"\"\"从 runtime_config.json 中清除指定字段, 恢复 .env / 默认值。\"\"\"\n if key not in ALLOWED_KEYS:\n return err(1001, f\"未知或受保护的字段: {key}\")\n removed = delete_runtime_keys([key])\n if not removed:\n return err(1002, f\"字段 {key} 不在 runtime 配置中 (本来就用 .env / 默认值)\")\n reload_settings()\n return ok({\"deleted\": removed})\n\n\nclass CookieCleanBody(BaseModel):\n raw: str\n\n\n@router.post(\"/settings/clean-cookie\")\ndef clean_cookie(body: CookieCleanBody) -> dict:\n \"\"\"工具接口: 把用户粘的 curl / -b / Cookie header 提取为干净 cookie 字符串。\n\n 前端可在用户粘贴时实时调用, 帮助预览将要保存的值。\n \"\"\"\n return ok({\"cookie\": clean_cookie_value(body.raw)})\n\n\n@router.post(\"/settings/clean-token\")\ndef clean_token(body: CookieCleanBody) -> dict:\n \"\"\"工具接口: 把用户粘的 curl / Authorization header / localStorage JSON 提取为\n 干净的 Bearer token 字符串。专门服务 DeepSeek refresh_token 这类字段。\n\n 支持的输入:\n - 完整 curl: 自动抓 ``-H 'authorization: Bearer XXX'``\n - 单独 header 行: ``Authorization: Bearer XXX``\n - localStorage JSON: ``{\"value\":\"XXX\",\"__version\":1}``\n - 直接粘的纯 token\n \"\"\"\n return ok({\"token\": clean_token_value(body.raw)})\n\n\n# ============================================================\n# 测试连接\n# ============================================================\n\n\n@router.post(\"/settings/test/llm\")\ndef test_llm() -> dict:\n \"\"\"用当前 LLM 配置发一次 ping prompt, 返回 OK / 错误信息。\"\"\"\n try:\n from ..services.llm_client import get_llm_client\n\n llm = get_llm_client()\n text = llm.generate_sync(\"请用一句话说: 测试连通\", max_tokens=50)\n return ok(\n {\n \"ok\": True,\n \"preview\": text[:120],\n }\n )\n except Exception as e:\n return ok({\"ok\": False, \"error\": str(e)[:300]})\n\n\n@router.post(\"/settings/test/publisher/{platform}\")\ndef test_publisher(platform: str) -> dict:\n \"\"\"干跑测试: 仅校验凭证 (实例化 + cookie 加载), 不真正发文章。\"\"\"\n try:\n from ..services.publishers import get_publisher\n from ..services.publishers.base import PublishError\n\n pub = get_publisher(platform)\n # 各 publisher 的轻量探测: 调用 require_cookie / 读 settings\n # 若没 cookie 会立刻抛 PublishError\n if hasattr(pub, \"settings\"):\n # WordPress / WeChat 模式 A 走 settings 字段\n if platform == \"wordpress\":\n if not pub.settings.WORDPRESS_URL:\n raise PublishError(\"WORDPRESS_URL 未配置\")\n if not pub.settings.WORDPRESS_USERNAME or not pub.settings.WORDPRESS_APP_PASSWORD:\n raise PublishError(\"WORDPRESS_USERNAME / APP_PASSWORD 未配置\")\n return ok({\"ok\": True, \"message\": \"WordPress 凭证已配置 (建议手动发一篇草稿验证)\"})\n if platform == \"wechat_mp\":\n has_official = bool(pub.settings.WECHAT_MP_APPID and pub.settings.WECHAT_MP_SECRET)\n has_cookie = bool(pub.settings.WECHAT_MP_COOKIE)\n if not has_official and not has_cookie:\n raise PublishError(\"微信公众号未配置 (需要 AppID+Secret 或 Cookie)\")\n mode = \"official_api\" if has_official else \"web_cookie\"\n return ok({\"ok\": True, \"message\": f\"已配置, 模式={mode}\"})\n # cookie-only 平台\n cookie_env_map = {\n \"zhihu\": \"ZHIHU_COOKIE\",\n \"sohu\": \"SOHU_COOKIE\",\n \"toutiao\": \"TOUTIAO_COOKIE\",\n \"baijiahao\": \"BAIJIAHAO_COOKIE\",\n }\n env_name = cookie_env_map.get(platform)\n if env_name:\n cookie = pub.load_cookie_string(env_name)\n if not cookie:\n raise PublishError(f\"{env_name} 未配置\")\n return ok(\n {\n \"ok\": True,\n \"message\": f\"{platform} cookie 已配置 (长度 {len(cookie)} 字符), \"\n f\"建议手动发一篇草稿验证\",\n }\n )\n return ok({\"ok\": True, \"message\": \"已配置\"})\n except Exception as e:\n return ok({\"ok\": False, \"error\": str(e)[:300]})\n\n\n@router.post(\"/settings/test/monitor/{platform}\")\ndef test_monitor(platform: str) -> dict:\n try:\n from ..services.ai_monitors import get_ai_monitor\n from ..services.ai_monitors.base import MonitorError\n\n mon = get_ai_monitor(platform)\n s = mon.settings\n if platform == \"chatgpt\":\n if not s.OPENAI_API_KEY:\n raise MonitorError(\"OPENAI_API_KEY 未配置\")\n return ok({\"ok\": True, \"message\": \"ChatGPT 已配置\"})\n if platform == \"doubao\":\n if not (s.DOUBAO_REFRESH_TOKEN or s.DOUBAO_COOKIE):\n raise MonitorError(\"DOUBAO_REFRESH_TOKEN 未配置 (或在 DOUBAO_COOKIE 中含 sessionid=)\")\n # 走真实链路: 调 /passport/account/info/v2 验证 sessionid 是否有效\n try:\n alive = mon.check_token_alive() # type: ignore[attr-defined]\n except MonitorError as e:\n return ok({\"ok\": False, \"error\": str(e)[:300]})\n if not alive:\n return ok({\"ok\": False, \"error\": \"豆包 sessionid 校验未通过 (响应中无 user_id)\"})\n return ok({\"ok\": True, \"message\": \"豆包 sessionid 有效, 反扒绕过成功\"})\n if platform == \"kimi\":\n has_api = bool(s.KIMI_API_KEY)\n has_bearer = bool(s.KIMI_BEARER)\n has_cookie = bool(s.KIMI_COOKIE)\n if not (has_api or has_bearer or has_cookie):\n raise MonitorError(\n \"Kimi 未配置 (需要 KIMI_API_KEY / KIMI_BEARER / KIMI_COOKIE 任一)\"\n )\n if has_api:\n return ok({\"ok\": True, \"message\": \"已配置, 模式=api_key\"})\n # 走 web 链路: 真请求一发, 验证 bearer 是否过期\n try:\n r = mon.query(\"你好\", brand_keywords=None) # type: ignore[attr-defined]\n except MonitorError as e:\n return ok({\"ok\": False, \"error\": str(e)[:300]})\n mode = \"web_bearer\" if has_bearer else \"web_cookie\"\n return ok({\n \"ok\": True,\n \"message\": f\"已配置, 模式={mode}, 实测对话成功 ({len(r.get('raw_answer',''))} 字)\",\n })\n if platform == \"deepseek\":\n if not s.DEEPSEEK_REFRESH_TOKEN:\n raise MonitorError(\"DEEPSEEK_REFRESH_TOKEN 未配置\")\n # 走真实链路: refresh → access_token, 验证 token 有效\n try:\n access = mon._acquire_access_token(s.DEEPSEEK_REFRESH_TOKEN) # type: ignore[attr-defined]\n except MonitorError as e:\n return ok({\"ok\": False, \"error\": str(e)[:300]})\n return ok({\"ok\": True, \"message\": f\"DeepSeek refresh_token 有效, access_token={access[:8]}***\"})\n return ok({\"ok\": True, \"message\": \"已配置\"})\n except Exception as e:\n return ok({\"ok\": False, \"error\": str(e)[:300]})\n\n\n# ============================================================\n# 旧版兼容 (前端旧代码可能仍在用)\n# ============================================================\n\n\n@router.get(\"/settings/platforms\")\ndef list_platforms() -> dict:\n settings = get_settings()\n cookie_map = {\n \"zhihu\": settings.ZHIHU_COOKIE,\n \"sohu\": settings.SOHU_COOKIE,\n \"toutiao\": settings.TOUTIAO_COOKIE,\n \"wechat_mp\": settings.WECHAT_MP_APPID or settings.WECHAT_MP_COOKIE,\n \"wordpress\": settings.WORDPRESS_URL,\n \"baijiahao\": settings.BAIJIAHAO_COOKIE,\n }\n out = []\n for p in PLATFORMS:\n out.append(\n {\n \"platform\": p,\n \"configured\": bool(cookie_map.get(p)),\n \"last_used\": None,\n }\n )\n return ok(out)\n\n\nclass PlatformConfig(BaseModel):\n platform: str\n credentials: dict[str, Any] = {}\n\n\n@router.put(\"/settings/platforms\")\ndef update_platform(body: PlatformConfig) -> dict:\n return ok({\"platform\": body.platform, \"configured\": True})\n\n\n@router.get(\"/settings/llm\")\ndef get_llm_settings() -> dict:\n settings = get_settings()\n return ok(\n {\n \"provider\": settings.LLM_PROVIDER,\n \"model\": settings.LLM_MODEL,\n \"configured\": settings.GEO_USE_MOCK\n or bool(\n settings.OPENAI_API_KEY\n or settings.DEEPSEEK_API_KEY\n or settings.ANTHROPIC_API_KEY\n or settings.KIMI_API_KEY\n ),\n }\n )\n"}, "77059b10884379142122d948cbef708dcec137fc": {"t": 1785768835.2922685, "n": 18061, "h": "51eb8910c49da049", "c": "\"\"\"设置路由 - 完整可写配置中心。\n\nGET /api/settings/runtime → 当前配置 (敏感字段已 mask)\nPUT /api/settings/runtime → 写入配置 (空字符串 = 删除该 key)\nPOST /api/settings/test/llm → 用当前 LLM 配置 ping 一次\nPOST /api/settings/test/publisher/{platform} → 测试发布平台凭证\nPOST /api/settings/test/monitor/{platform} → 测试 AI 监测平台凭证\n\n兼容旧版:\nGET /api/settings/platforms → 平台已配置状态列表\nGET /api/settings/llm → LLM 配置概览\n\"\"\"\nfrom __future__ import annotations\n\nimport logging\nfrom typing import Any\n\nfrom fastapi import APIRouter\nfrom pydantic import BaseModel\n\nfrom ..config import get_settings, reload_settings\nfrom ..schemas import err, ok\nfrom ..services.runtime_config import (\n ALLOWED_KEYS,\n SECRET_KEYS,\n clean_cookie_value,\n clean_token_value,\n delete_runtime_keys,\n load_runtime_config,\n mask_secret,\n save_runtime_config,\n)\n\nlogger = logging.getLogger(\"geo.routers.settings\")\nrouter = APIRouter()\n\nPLATFORMS = (\n \"zhihu\",\n \"wechat_mp\",\n \"toutiao\",\n \"sohu\",\n \"baijiahao\",\n \"wordpress\",\n)\n\n# 字段元数据 — 前端用此渲染表单\nFIELD_SCHEMA: list[dict[str, Any]] = [\n # === 通用 ===\n {\n \"key\": \"GEO_USE_MOCK\",\n \"label\": \"Mock 模式\",\n \"type\": \"boolean\",\n \"category\": \"general\",\n \"help\": \"开启后所有 LLM/分发/监测走 mock 实现, 无需真实 key。修改后需重启生效。\",\n },\n {\n \"key\": \"GEO_LOG_LEVEL\",\n \"label\": \"日志级别\",\n \"type\": \"select\",\n \"options\": [\"DEBUG\", \"INFO\", \"WARNING\", \"ERROR\"],\n \"category\": \"general\",\n },\n # === LLM ===\n {\n \"key\": \"LLM_PROVIDER\",\n \"label\": \"LLM 服务商\",\n \"type\": \"select\",\n \"options\": [\"mock\", \"openai\", \"deepseek\", \"moonshot\", \"anthropic\"],\n \"category\": \"llm\",\n \"help\": \"推荐 deepseek (中文友好, ¥1/百万 tokens)\",\n },\n {\n \"key\": \"LLM_MODEL\",\n \"label\": \"模型名\",\n \"type\": \"text\",\n \"placeholder\": \"deepseek-chat / gpt-4o-mini / claude-opus-4-6 ...\",\n \"category\": \"llm\",\n },\n {\n \"key\": \"DEEPSEEK_API_KEY\",\n \"label\": \"DeepSeek API Key\",\n \"type\": \"password\",\n \"category\": \"llm\",\n \"help\": \"申请: https://platform.deepseek.com/api_keys\",\n },\n {\n \"key\": \"OPENAI_API_KEY\",\n \"label\": \"OpenAI API Key\",\n \"type\": \"password\",\n \"category\": \"llm\",\n \"help\": \"申请: https://platform.openai.com/api-keys (M4 ChatGPT 监测也用此)\",\n },\n {\n \"key\": \"OPENAI_MONITOR_MODEL\",\n \"label\": \"ChatGPT 监测模型\",\n \"type\": \"text\",\n \"placeholder\": \"gpt-4o-mini\",\n \"category\": \"llm\",\n },\n {\n \"key\": \"ANTHROPIC_API_KEY\",\n \"label\": \"Anthropic API Key\",\n \"type\": \"password\",\n \"category\": \"llm\",\n \"help\": \"申请: https://console.anthropic.com/settings/keys\",\n },\n {\n \"key\": \"KIMI_API_KEY\",\n \"label\": \"Moonshot/Kimi API Key\",\n \"type\": \"password\",\n \"category\": \"llm\",\n \"help\": \"申请: https://platform.moonshot.cn/console/api-keys (M4 Kimi 监测也用此)\",\n },\n # === 分发: WordPress ===\n {\n \"key\": \"WORDPRESS_URL\",\n \"label\": \"WordPress 站点 URL\",\n \"type\": \"text\",\n \"placeholder\": \"https://your-site.com\",\n \"category\": \"publisher\",\n \"platform\": \"wordpress\",\n },\n {\n \"key\": \"WORDPRESS_USERNAME\",\n \"label\": \"WordPress 登录名\",\n \"type\": \"text\",\n \"category\": \"publisher\",\n \"platform\": \"wordpress\",\n },\n {\n \"key\": \"WORDPRESS_APP_PASSWORD\",\n \"label\": \"WordPress App Password\",\n \"type\": \"password\",\n \"category\": \"publisher\",\n \"platform\": \"wordpress\",\n \"help\": \"WP 后台 → 用户 → 个人资料 → 应用程序密码\",\n },\n # === 分发: 微信公众号 ===\n {\n \"key\": \"WECHAT_MP_APPID\",\n \"label\": \"微信公众号 AppID (服务号)\",\n \"type\": \"text\",\n \"category\": \"publisher\",\n \"platform\": \"wechat_mp\",\n \"help\": \"服务号官方 API 模式, 需要 IP 白名单\",\n },\n {\n \"key\": \"WECHAT_MP_SECRET\",\n \"label\": \"微信公众号 Secret\",\n \"type\": \"password\",\n \"category\": \"publisher\",\n \"platform\": \"wechat_mp\",\n },\n {\n \"key\": \"WECHAT_MP_COOKIE\",\n \"label\": \"微信公众号 Cookie (订阅号)\",\n \"type\": \"textarea\",\n \"category\": \"publisher\",\n \"platform\": \"wechat_mp\",\n \"help\": \"Web cookie 模式, 1-2 天过期, 适合无认证账号\",\n },\n # === 分发: 第三方平台 cookie ===\n {\n \"key\": \"ZHIHU_COOKIE\",\n \"label\": \"知乎 Cookie\",\n \"type\": \"textarea\",\n \"category\": \"publisher\",\n \"platform\": \"zhihu\",\n \"help\": \"登录 zhihu.com → F12 → Application → Cookies, 复制全部为 name=value; ... 字符串。约 30 天过期\",\n },\n {\n \"key\": \"SOHU_COOKIE\",\n \"label\": \"搜狐号 Cookie\",\n \"type\": \"textarea\",\n \"category\": \"publisher\",\n \"platform\": \"sohu\",\n \"help\": \"约 7 天过期\",\n },\n {\n \"key\": \"TOUTIAO_COOKIE\",\n \"label\": \"头条号 Cookie\",\n \"type\": \"textarea\",\n \"category\": \"publisher\",\n \"platform\": \"toutiao\",\n \"help\": \"必须包含 csrftoken, 约 7 天过期\",\n },\n {\n \"key\": \"BAIJIAHAO_COOKIE\",\n \"label\": \"百家号 Cookie\",\n \"type\": \"textarea\",\n \"category\": \"publisher\",\n \"platform\": \"baijiahao\",\n \"help\": \"约 7 天过期\",\n },\n # === AI 监测 ===\n {\n \"key\": \"DOUBAO_REFRESH_TOKEN\",\n \"label\": \"豆包 sessionid\",\n \"type\": \"password\",\n \"category\": \"monitor\",\n \"platform\": \"doubao\",\n \"help\": (\n \"⚠️ 不是字节火山引擎的 LLM API key, 是 www.doubao.com 浏览器登录态 sessionid。\\n\"\n \"获取步骤:\\n\"\n \"1) 浏览器打开 https://www.doubao.com 并登录\\n\"\n \"2) F12 → Application (Chrome) / 存储 (Firefox) → 左侧 Cookies → 选 https://www.doubao.com\\n\"\n \"3) 找 Name 为 sessionid 的那行, 复制 Value (40+ 字符的 hex 串)\\n\"\n \"4) 粘贴到此输入框\\n\"\n \"\\n\"\n \"也可以直接粘贴整段 cookie 字符串 (含 sessionid=xxx;...), 系统会自动抠出。\\n\"\n \"服务端只校验 sessionid, 字节家 mssdk 反爬的 msToken/a_bogus 我们用伪值绕过, 与浏览器一致。\"\n ),\n },\n {\n \"key\": \"KIMI_BEARER\",\n \"label\": \"Kimi Web Bearer (推荐)\",\n \"type\": \"textarea\",\n \"category\": \"monitor\",\n \"platform\": \"kimi\",\n \"help\": (\n \"⚠️ 不是 platform.moonshot.cn 的 API Key, 是 www.kimi.com 网页版登录态 JWT。\\n\"\n \"获取步骤:\\n\"\n \"1) 浏览器打开 https://www.kimi.com 并登录\\n\"\n \"2) F12 → Network → 随便发一句话, 找到 .../ChatService/Chat 请求\\n\"\n \"3) Request Headers 里复制 authorization 的值, 去掉前缀 'Bearer ' 后粘贴到此\\n\"\n \" (或 Application → Cookies → 找 kimi-auth, 复制 value)\\n\"\n \"\\n\"\n \"有效期约 30 天, JWT 内已包含 device_id/ssid/traffic_id, 无需再单独配置。\\n\"\n \"优先级: KIMI_API_KEY > KIMI_BEARER > KIMI_COOKIE。\"\n ),\n },\n {\n \"key\": \"KIMI_COOKIE\",\n \"label\": \"Kimi Web Cookie (备选)\",\n \"type\": \"textarea\",\n \"category\": \"monitor\",\n \"platform\": \"kimi\",\n \"help\": \"整段 Cookie 字符串, 系统会自动从 kimi-auth 抠 JWT。已填 KIMI_BEARER 时可留空。\",\n },\n {\n \"key\": \"DEEPSEEK_REFRESH_TOKEN\",\n \"label\": \"DeepSeek Refresh Token\",\n \"type\": \"password\",\n \"category\": \"monitor\",\n \"platform\": \"deepseek\",\n \"help\": (\n \"⚠️ 不是 platform.deepseek.com 的 LLM API Key, 而是 chat.deepseek.com 网页版的账号级 token。\\n\"\n \"获取步骤:\\n\"\n \"1) 浏览器打开 https://chat.deepseek.com 并登录\\n\"\n \"2) 按 F12 打开开发者工具 → 切到 Application (Chrome) 或 存储 (Firefox) 标签\\n\"\n \"3) 左侧展开 Local Storage → 选中 https://chat.deepseek.com\\n\"\n \"4) 右侧找到 key 为 userToken 的一行, 复制 value 字段里的整段 JSON (形如 {\\\"value\\\":\\\"RWXtg...\\\"} )\\n\"\n \"5) 粘贴到此输入框, 点「从粘贴提取 token」按钮自动解析\\n\"\n \"\\n\"\n \"替代方案: 随便在网页上发一条消息, 在 Network 里抓到任意请求, 复制 curl → 粘贴过来点按钮, \"\n \"会自动从 Authorization: Bearer ... 里抽出 token。\"\n ),\n },\n]\n\n\n# ============================================================\n# 新版: 完整可写配置\n# ============================================================\n\n\n@router.get(\"/settings/schema\")\ndef get_schema() -> dict:\n \"\"\"返回字段元数据, 前端用于动态渲染表单。\"\"\"\n return ok(\n {\n \"fields\": FIELD_SCHEMA,\n \"categories\": [\n {\"key\": \"general\", \"label\": \"通用\"},\n {\"key\": \"llm\", \"label\": \"LLM 服务\"},\n {\"key\": \"publisher\", \"label\": \"分发平台\"},\n {\"key\": \"monitor\", \"label\": \"AI 监测平台\"},\n ],\n }\n )\n\n\n@router.get(\"/settings/runtime\")\ndef get_runtime_config() -> dict:\n \"\"\"读取当前生效配置, 敏感字段已掩码 (sk-1***xxxx)。\n\n 返回的字段同时包含来自 .env 的值和来自 runtime_config.json 的覆盖。\n \"\"\"\n settings = get_settings()\n overlay = load_runtime_config()\n out: dict[str, Any] = {}\n for key in ALLOWED_KEYS:\n value = getattr(settings, key, None)\n if key in SECRET_KEYS:\n out[key] = {\n \"configured\": bool(value),\n \"masked\": mask_secret(value),\n \"from_runtime\": key in overlay,\n }\n else:\n out[key] = {\n \"value\": value,\n \"from_runtime\": key in overlay,\n }\n return ok(out)\n\n\nclass RuntimeConfigBody(BaseModel):\n updates: dict[str, Any]\n\n\n@router.put(\"/settings/runtime\")\ndef put_runtime_config(body: RuntimeConfigBody) -> dict:\n \"\"\"写入运行时配置。\n\n 规则:\n - 仅 ALLOWED_KEYS 内的字段会被保存\n - 敏感字段空字符串 = 不修改 (而不是清空)\n - 非敏感字段空字符串 = 清空 (恢复 .env / 默认)\n - 写入后立即调用 reload_settings(), 工厂会拿到新值\n \"\"\"\n cleaned: dict[str, Any] = {}\n for k, v in body.updates.items():\n if k not in ALLOWED_KEYS:\n continue\n # 敏感字段: 空字符串视为\"不修改\", 跳过\n if k in SECRET_KEYS and isinstance(v, str) and v.strip() == \"\":\n continue\n # 布尔字段\n if k == \"GEO_USE_MOCK\":\n v = bool(v)\n cleaned[k] = v\n\n save_runtime_config(cleaned)\n reload_settings() # 让下次 get_settings() 拿到新值\n return ok({\"updated\": list(cleaned.keys()), \"count\": len(cleaned)})\n\n\n@router.delete(\"/settings/runtime/{key}\")\ndef delete_runtime_key(key: str) -> dict:\n \"\"\"从 runtime_config.json 中清除指定字段, 恢复 .env / 默认值。\"\"\"\n if key not in ALLOWED_KEYS:\n return err(1001, f\"未知或受保护的字段: {key}\")\n removed = delete_runtime_keys([key])\n if not removed:\n return err(1002, f\"字段 {key} 不在 runtime 配置中 (本来就用 .env / 默认值)\")\n reload_settings()\n return ok({\"deleted\": removed})\n\n\nclass CookieCleanBody(BaseModel):\n raw: str\n\n\n@router.post(\"/settings/clean-cookie\")\ndef clean_cookie(body: CookieCleanBody) -> dict:\n \"\"\"工具接口: 把用户粘的 curl / -b / Cookie header 提取为干净 cookie 字符串。\n\n 前端可在用户粘贴时实时调用, 帮助预览将要保存的值。\n \"\"\"\n return ok({\"cookie\": clean_cookie_value(body.raw)})\n\n\n@router.post(\"/settings/clean-token\")\ndef clean_token(body: CookieCleanBody) -> dict:\n \"\"\"工具接口: 把用户粘的 curl / Authorization header / localStorage JSON 提取为\n 干净的 Bearer token 字符串。专门服务 DeepSeek refresh_token 这类字段。\n\n 支持的输入:\n - 完整 curl: 自动抓 ``-H 'authorization: Bearer XXX'``\n - 单独 header 行: ``Authorization: Bearer XXX``\n - localStorage JSON: ``{\"value\":\"XXX\",\"__version\":1}``\n - 直接粘的纯 token\n \"\"\"\n return ok({\"token\": clean_token_value(body.raw)})\n\n\n# ============================================================\n# 测试连接\n# ============================================================\n\n\n@router.post(\"/settings/test/llm\")\ndef test_llm() -> dict:\n \"\"\"用当前 LLM 配置发一次 ping prompt, 返回 OK / 错误信息。\"\"\"\n try:\n from ..services.llm_client import get_llm_client\n\n llm = get_llm_client()\n text = llm.generate_sync(\"请用一句话说: 测试连通\", max_tokens=50)\n return ok(\n {\n \"ok\": True,\n \"preview\": text[:120],\n }\n )\n except Exception as e:\n return ok({\"ok\": False, \"error\": str(e)[:300]})\n\n\n@router.post(\"/settings/test/publisher/{platform}\")\ndef test_publisher(platform: str) -> dict:\n \"\"\"干跑测试: 仅校验凭证 (实例化 + cookie 加载), 不真正发文章。\"\"\"\n try:\n from ..services.publishers import get_publisher\n from ..services.publishers.base import PublishError\n\n pub = get_publisher(platform)\n # 各 publisher 的轻量探测: 调用 require_cookie / 读 settings\n # 若没 cookie 会立刻抛 PublishError\n if hasattr(pub, \"settings\"):\n # WordPress / WeChat 模式 A 走 settings 字段\n if platform == \"wordpress\":\n if not pub.settings.WORDPRESS_URL:\n raise PublishError(\"WORDPRESS_URL 未配置\")\n if not pub.settings.WORDPRESS_USERNAME or not pub.settings.WORDPRESS_APP_PASSWORD:\n raise PublishError(\"WORDPRESS_USERNAME / APP_PASSWORD 未配置\")\n return ok({\"ok\": True, \"message\": \"WordPress 凭证已配置 (建议手动发一篇草稿验证)\"})\n if platform == \"wechat_mp\":\n has_official = bool(pub.settings.WECHAT_MP_APPID and pub.settings.WECHAT_MP_SECRET)\n has_cookie = bool(pub.settings.WECHAT_MP_COOKIE)\n if not has_official and not has_cookie:\n raise PublishError(\"微信公众号未配置 (需要 AppID+Secret 或 Cookie)\")\n mode = \"official_api\" if has_official else \"web_cookie\"\n return ok({\"ok\": True, \"message\": f\"已配置, 模式={mode}\"})\n # cookie-only 平台\n cookie_env_map = {\n \"zhihu\": \"ZHIHU_COOKIE\",\n \"sohu\": \"SOHU_COOKIE\",\n \"toutiao\": \"TOUTIAO_COOKIE\",\n \"baijiahao\": \"BAIJIAHAO_COOKIE\",\n }\n env_name = cookie_env_map.get(platform)\n if env_name:\n cookie = pub.load_cookie_string(env_name)\n if not cookie:\n raise PublishError(f\"{env_name} 未配置\")\n return ok(\n {\n \"ok\": True,\n \"message\": f\"{platform} cookie 已配置 (长度 {len(cookie)} 字符), \"\n f\"建议手动发一篇草稿验证\",\n }\n )\n return ok({\"ok\": True, \"message\": \"已配置\"})\n except Exception as e:\n return ok({\"ok\": False, \"error\": str(e)[:300]})\n\n\n@router.post(\"/settings/test/monitor/{platform}\")\ndef test_monitor(platform: str) -> dict:\n try:\n from ..services.ai_monitors import get_ai_monitor\n from ..services.ai_monitors.base import MonitorError\n\n mon = get_ai_monitor(platform)\n s = mon.settings\n if platform == \"chatgpt\":\n if not s.OPENAI_API_KEY:\n raise MonitorError(\"OPENAI_API_KEY 未配置\")\n return ok({\"ok\": True, \"message\": \"ChatGPT 已配置\"})\n if platform == \"doubao\":\n if not (s.DOUBAO_REFRESH_TOKEN or s.DOUBAO_COOKIE):\n raise MonitorError(\"DOUBAO_REFRESH_TOKEN 未配置 (或在 DOUBAO_COOKIE 中含 sessionid=)\")\n # 走真实链路: 调 /passport/account/info/v2 验证 sessionid 是否有效\n try:\n alive = mon.check_token_alive() # type: ignore[attr-defined]\n except MonitorError as e:\n return ok({\"ok\": False, \"error\": str(e)[:300]})\n if not alive:\n return ok({\"ok\": False, \"error\": \"豆包 sessionid 校验未通过 (响应中无 user_id)\"})\n return ok({\"ok\": True, \"message\": \"豆包 sessionid 有效, 反扒绕过成功\"})\n if platform == \"kimi\":\n has_api = bool(s.KIMI_API_KEY)\n has_bearer = bool(s.KIMI_BEARER)\n has_cookie = bool(s.KIMI_COOKIE)\n if not (has_api or has_bearer or has_cookie):\n raise MonitorError(\n \"Kimi 未配置 (需要 KIMI_API_KEY / KIMI_BEARER / KIMI_COOKIE 任一)\"\n )\n if has_api:\n return ok({\"ok\": True, \"message\": \"已配置, 模式=api_key\"})\n # 走 web 链路: 真请求一发, 验证 bearer 是否过期\n try:\n r = mon.query(\"你好\", brand_keywords=None) # type: ignore[attr-defined]\n except MonitorError as e:\n return ok({\"ok\": False, \"error\": str(e)[:300]})\n mode = \"web_bearer\" if has_bearer else \"web_cookie\"\n return ok({\n \"ok\": True,\n \"message\": f\"已配置, 模式={mode}, 实测对话成功 ({len(r.get('raw_answer',''))} 字)\",\n })\n if platform == \"deepseek\":\n if not s.DEEPSEEK_REFRESH_TOKEN:\n raise MonitorError(\"DEEPSEEK_REFRESH_TOKEN 未配置\")\n # 走真实链路: refresh → access_token, 验证 token 有效\n try:\n access = mon._acquire_access_token(s.DEEPSEEK_REFRESH_TOKEN) # type: ignore[attr-defined]\n except MonitorError as e:\n return ok({\"ok\": False, \"error\": str(e)[:300]})\n return ok({\"ok\": True, \"message\": f\"DeepSeek refresh_token 有效, access_token={access[:8]}***\"})\n return ok({\"ok\": True, \"message\": \"已配置\"})\n except Exception as e:\n return ok({\"ok\": False, \"error\": str(e)[:300]})\n\n\n# ============================================================\n# 旧版兼容 (前端旧代码可能仍在用)\n# ============================================================\n\n\n@router.get(\"/settings/platforms\")\ndef list_platforms() -> dict:\n settings = get_settings()\n cookie_map = {\n \"zhihu\": settings.ZHIHU_COOKIE,\n \"sohu\": settings.SOHU_COOKIE,\n \"toutiao\": settings.TOUTIAO_COOKIE,\n \"wechat_mp\": settings.WECHAT_MP_APPID or settings.WECHAT_MP_COOKIE,\n \"wordpress\": settings.WORDPRESS_URL,\n \"baijiahao\": settings.BAIJIAHAO_COOKIE,\n }\n out = []\n for p in PLATFORMS:\n out.append(\n {\n \"platform\": p,\n \"configured\": bool(cookie_map.get(p)),\n \"last_used\": None,\n }\n )\n return ok(out)\n\n\nclass PlatformConfig(BaseModel):\n platform: str\n credentials: dict[str, Any] = {}\n\n\n@router.put(\"/settings/platforms\")\ndef update_platform(body: PlatformConfig) -> dict:\n return ok({\"platform\": body.platform, \"configured\": True})\n\n\n@router.get(\"/settings/llm\")\ndef get_llm_settings() -> dict:\n settings = get_settings()\n return ok(\n {\n \"provider\": settings.LLM_PROVIDER,\n \"model\": settings.LLM_MODEL,\n \"configured\": settings.GEO_USE_MOCK\n or bool(\n settings.OPENAI_API_KEY\n or settings.DEEPSEEK_API_KEY\n or settings.ANTHROPIC_API_KEY\n or settings.KIMI_API_KEY\n ),\n }\n )\n"}}}}