Просмотр исходного кода

feat: 添加发布代理功能支持神龙代理服务

- 在发布任务中新增发布代理配置选项,支持按城市选择代理IP
- 添加系统设置页面用于配置发布代理的城市IP列表API
- 修改数据库schema,在publish_tasks表中添加publish_proxy字段
- 扩展Python发布服务以支持代理配置,包括神龙代理的解析和测试
- 更新所有平台适配器将代理配置传递给Python发布服务
- 在前端发布页面添加发布代理开关和城市选择功能
Ethanfly 13 часов назад
Родитель
Сommit
61d79db727

+ 1 - 0
client/src/components.d.ts

@@ -34,6 +34,7 @@ declare module 'vue' {
     ElIcon: typeof import('element-plus/es')['ElIcon']
     ElImage: typeof import('element-plus/es')['ElImage']
     ElInput: typeof import('element-plus/es')['ElInput']
+    ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
     ElMain: typeof import('element-plus/es')['ElMain']
     ElMenu: typeof import('element-plus/es')['ElMenu']
     ElMenuItem: typeof import('element-plus/es')['ElMenuItem']

+ 78 - 0
client/src/views/Publish/index.vue

@@ -172,6 +172,16 @@
             placeholder="选择时间(留空则立即发布)"
           />
         </el-form-item>
+
+        <el-form-item label="发布代理">
+          <el-switch v-model="createForm.usePublishProxy" />
+        </el-form-item>
+
+        <el-form-item v-if="createForm.usePublishProxy" label="代理城市">
+          <el-select v-model="createForm.publishProxyCity" placeholder="选择城市" style="width: 100%">
+            <el-option v-for="city in publishProxyCities" :key="city" :label="city" :value="city" />
+          </el-select>
+        </el-form-item>
       </el-form>
       
       <template #footer>
@@ -329,6 +339,16 @@
             placeholder="选择时间(留空则立即发布)"
           />
         </el-form-item>
+
+        <el-form-item label="发布代理">
+          <el-switch v-model="editForm.usePublishProxy" />
+        </el-form-item>
+
+        <el-form-item v-if="editForm.usePublishProxy" label="代理城市">
+          <el-select v-model="editForm.publishProxyCity" placeholder="选择城市" style="width: 100%">
+            <el-option v-for="city in publishProxyCities" :key="city" :label="city" :value="city" />
+          </el-select>
+        </el-form-item>
       </el-form>
       
       <template #footer>
@@ -384,6 +404,8 @@ const pagination = reactive({
   total: 0,
 });
 
+const publishProxyCities = ref<string[]>([]);
+
 const createForm = reactive({
   videoFile: null as File | null,
   title: '',
@@ -391,6 +413,8 @@ const createForm = reactive({
   tags: [] as string[],
   targetAccounts: [] as number[],
   scheduledAt: null as Date | null,
+  usePublishProxy: false,
+  publishProxyCity: '',
 });
 
 const editForm = reactive({
@@ -402,8 +426,27 @@ const editForm = reactive({
   tags: [] as string[],
   targetAccounts: [] as number[],
   scheduledAt: null as Date | null,
+  usePublishProxy: false,
+  publishProxyCity: '',
 });
 
+async function loadSystemConfig() {
+  try {
+    await loadPublishProxyCities();
+  } catch {
+    publishProxyCities.value = [];
+  }
+}
+
+async function loadPublishProxyCities() {
+  try {
+    const res = await request.get('/api/system/publish-proxy/cities');
+    publishProxyCities.value = Array.isArray(res?.cities) ? res.cities : [];
+  } catch {
+    publishProxyCities.value = [];
+  }
+}
+
 function getPlatformName(platform: PlatformType) {
   return PLATFORMS[platform]?.name || platform;
 }
@@ -534,6 +577,11 @@ async function handleCreate() {
     ElMessage.warning('请填写完整信息');
     return;
   }
+
+  if (createForm.usePublishProxy && publishProxyCities.value.length > 0 && !createForm.publishProxyCity) {
+    ElMessage.warning('请选择代理城市');
+    return;
+  }
   
   submitting.value = true;
   try {
@@ -556,6 +604,13 @@ async function handleCreate() {
       tags: createForm.tags,
       targetAccounts: createForm.targetAccounts,
       scheduledAt: createForm.scheduledAt ? createForm.scheduledAt.toISOString() : null,
+      publishProxy: createForm.usePublishProxy
+        ? {
+            enabled: true,
+            provider: 'shenlong',
+            city: createForm.publishProxyCity || undefined,
+          }
+        : null,
     });
     
     ElMessage.success('发布任务创建成功');
@@ -568,6 +623,8 @@ async function handleCreate() {
     createForm.tags = [];
     createForm.targetAccounts = [];
     createForm.scheduledAt = null;
+    createForm.usePublishProxy = false;
+    createForm.publishProxyCity = '';
     
     loadTasks();
   } catch {
@@ -602,6 +659,8 @@ function openEditDialog() {
   editForm.tags = [...(currentTask.value.tags || [])];
   editForm.targetAccounts = [...(currentTask.value.targetAccounts || [])];
   editForm.scheduledAt = null;
+  editForm.usePublishProxy = Boolean(currentTask.value.publishProxy?.enabled);
+  editForm.publishProxyCity = String(currentTask.value.publishProxy?.city || '');
   
   showDetailDialog.value = false;
   showEditDialog.value = true;
@@ -619,6 +678,11 @@ async function handleRepublish() {
     ElMessage.warning('请填写完整信息');
     return;
   }
+
+  if (editForm.usePublishProxy && publishProxyCities.value.length > 0 && !editForm.publishProxyCity) {
+    ElMessage.warning('请选择代理城市');
+    return;
+  }
   
   submitting.value = true;
   try {
@@ -644,6 +708,13 @@ async function handleRepublish() {
       tags: editForm.tags,
       targetAccounts: editForm.targetAccounts,
       scheduledAt: editForm.scheduledAt ? editForm.scheduledAt.toISOString() : null,
+      publishProxy: editForm.usePublishProxy
+        ? {
+            enabled: true,
+            provider: 'shenlong',
+            city: editForm.publishProxyCity || undefined,
+          }
+        : null,
     });
     
     ElMessage.success('新发布任务已创建');
@@ -707,6 +778,7 @@ watch(() => taskStore.tasks, (newTasks, oldTasks) => {
 onMounted(() => {
   loadTasks();
   loadAccounts();
+  loadSystemConfig();
 });
 </script>
 
@@ -741,6 +813,12 @@ onMounted(() => {
   }
 }
 
+.form-tip {
+  margin-left: 12px;
+  color: $text-secondary;
+  font-size: 13px;
+}
+
 .publish-results {
   margin-top: 20px;
   

+ 42 - 0
client/src/views/Settings/index.vue

@@ -25,6 +25,22 @@
           </el-form>
         </div>
       </el-tab-pane>
+
+      <el-tab-pane label="发布代理" name="publish-proxy">
+        <div class="page-card">
+          <el-form :model="publishProxy" label-width="150px">
+            <el-form-item label="城市IP列表API">
+              <el-input v-model="publishProxy.cityApiUrl" placeholder="填写返回城市+代理列表的 API 地址" style="width: 100%" />
+              <span class="form-tip">发布时按该 API 动态生成城市列表与代理IP,不再额外配置</span>
+            </el-form-item>
+
+            <el-form-item>
+              <el-button type="primary" @click="savePublishProxy">保存配置</el-button>
+              <el-button @click="loadPublishProxy">刷新</el-button>
+            </el-form-item>
+          </el-form>
+        </div>
+      </el-tab-pane>
       
       <el-tab-pane label="用户管理" name="users">
         <div class="page-card">
@@ -110,6 +126,10 @@ const settings = reactive({
   defaultUserRole: 'operator',
 });
 
+const publishProxy = reactive({
+  cityApiUrl: '',
+});
+
 const users = ref<User[]>([]);
 const showAddUserDialog = ref(false);
 
@@ -149,6 +169,27 @@ async function loadSettings() {
   }
 }
 
+async function loadPublishProxy() {
+  try {
+    const config = await request.get('/api/system/publish-proxy');
+    publishProxy.cityApiUrl = String(config.cityApiUrl || '');
+  } catch {
+    // 错误已处理
+  }
+}
+
+async function savePublishProxy() {
+  try {
+    await request.put('/api/system/publish-proxy', {
+      cityApiUrl: publishProxy.cityApiUrl,
+    });
+    ElMessage.success('发布代理配置已保存');
+    loadPublishProxy();
+  } catch {
+    // 错误已处理
+  }
+}
+
 async function saveSettings() {
   try {
     await request.put('/api/system/config', settings);
@@ -196,6 +237,7 @@ async function deleteUser(id: number) {
 
 onMounted(() => {
   loadSettings();
+  loadPublishProxy();
   loadUsers();
   loadSystemStatus();
 });

+ 3 - 0
database/migrations/add_publish_proxy_to_publish_tasks.sql

@@ -0,0 +1,3 @@
+ALTER TABLE publish_tasks
+  ADD COLUMN publish_proxy JSON NULL AFTER platform_configs;
+

+ 1 - 0
database/schema.sql

@@ -99,6 +99,7 @@ CREATE TABLE IF NOT EXISTS publish_tasks (
     tags JSON,
     target_accounts JSON,
     platform_configs JSON,
+    publish_proxy JSON,
     status ENUM('pending','processing','completed','failed','cancelled') DEFAULT 'pending',
     scheduled_at TIMESTAMP NULL,
     published_at TIMESTAMP NULL,

BIN
server/python/__pycache__/app.cpython-313.pyc


+ 143 - 0
server/python/app.py

@@ -16,6 +16,9 @@ import asyncio
 import os
 import sys
 import argparse
+import random
+import re
+import time
 
 # 禁用输出缓冲,确保 print 立即输出
 os.environ['PYTHONUNBUFFERED'] = '1'
@@ -96,6 +99,134 @@ def parse_datetime(date_str: str):
     return None
 
 
+def _extract_ip_ports(text: str):
+    if not text:
+        return []
+    matches = re.findall(r'\b(?:\d{1,3}\.){3}\d{1,3}:\d{2,5}\b', text)
+    seen = set()
+    results = []
+    for m in matches:
+        if m in seen:
+            continue
+        seen.add(m)
+        results.append(m)
+    return results
+
+
+def _mask_ip_port(ip_port: str) -> str:
+    try:
+        host, port = ip_port.split(':', 1)
+        parts = host.split('.')
+        if len(parts) == 4:
+            return f"{parts[0]}.{parts[1]}.{parts[2]}.***:{port}"
+    except Exception:
+        pass
+    return '***'
+
+
+def _build_requests_proxy_meta(host: str, port: int, username: str = '', password: str = '') -> str:
+    host = str(host).strip()
+    port = int(port)
+    if username and password:
+        return f"http://{username}:{password}@{host}:{port}"
+    return f"http://{host}:{port}"
+
+
+def _test_proxy_connectivity(test_url: str, host: str, port: int, username: str = '', password: str = '', timeout: int = 10) -> bool:
+    proxy_meta = _build_requests_proxy_meta(host, port, username, password)
+    proxies = {"http": proxy_meta, "https": proxy_meta}
+    start = int(round(time.time() * 1000))
+    try:
+        resp = requests.get(test_url, proxies=proxies, timeout=timeout)
+        _ = resp.text
+        cost = int(round(time.time() * 1000)) - start
+        print(f"[Proxy] test ok: {_mask_ip_port(host + ':' + str(port))} cost={cost}ms", flush=True)
+        return True
+    except Exception as e:
+        print(f"[Proxy] test failed: {_mask_ip_port(host + ':' + str(port))} err={type(e).__name__}", flush=True)
+        return False
+
+
+def _resolve_shenlong_proxy(proxy_payload: dict) -> dict:
+    api_url = str(proxy_payload.get('apiUrl') or '').strip()
+    test_url = 'http://myip.ipip.net'
+    city = str(proxy_payload.get('city') or '').strip()
+
+    if not api_url:
+        raise Exception('缺少城市IP列表API地址')
+
+    params = {}
+    if city:
+        params['city'] = city
+
+    resp = requests.get(api_url, params=params, timeout=15)
+    if resp.status_code >= 400:
+        raise Exception(f"代理提取失败: HTTP {resp.status_code}")
+
+    content_type = (resp.headers.get('content-type') or '').lower()
+    raw_text = resp.text or ''
+
+    payload = None
+    if 'application/json' in content_type or raw_text.strip().startswith('{') or raw_text.strip().startswith('['):
+        try:
+            payload = resp.json()
+        except Exception:
+            payload = None
+
+    def collect_ip_ports(data_list, city_filter: str):
+        ips = []
+        for item in data_list:
+            if isinstance(item, str):
+                for ip_port in _extract_ip_ports(item):
+                    ips.append(ip_port)
+                continue
+            if not isinstance(item, dict):
+                continue
+            item_city = str(item.get('city') or item.get('area') or '').strip()
+            if city_filter and item_city and item_city != city_filter:
+                continue
+            ip = str(item.get('ip') or '').strip()
+            port = str(item.get('port') or '').strip()
+            if ip and port:
+                ips.append(f"{ip}:{port}")
+        return ips
+
+    ip_ports = []
+    if payload is not None:
+        if isinstance(payload, dict) and isinstance(payload.get('data'), list):
+            ip_ports = collect_ip_ports(payload.get('data'), city)
+            if city and not ip_ports:
+                ip_ports = collect_ip_ports(payload.get('data'), '')
+        elif isinstance(payload, list):
+            ip_ports = collect_ip_ports(payload, city)
+            if city and not ip_ports:
+                ip_ports = collect_ip_ports(payload, '')
+    else:
+        ip_ports = _extract_ip_ports(raw_text)
+
+    if not ip_ports:
+        raise Exception('代理提取结果为空')
+
+    random.shuffle(ip_ports)
+    candidates = ip_ports[: min(10, len(ip_ports))]
+
+    print(f"[Proxy] shenlong resolved: city={city or '-'} candidates={len(candidates)}/{len(ip_ports)}", flush=True)
+
+    for ip_port in candidates:
+        try:
+            host, port_str = ip_port.split(':', 1)
+            port = int(port_str)
+        except Exception:
+            continue
+
+        if _test_proxy_connectivity(test_url, host, port, timeout=10):
+            return {
+                'server': f"http://{host}:{port}",
+            }
+
+    raise Exception('未找到可用代理IP')
+
+
 def validate_video_file(video_path: str) -> bool:
     """验证视频文件是否有效"""
     if not video_path:
@@ -350,6 +481,12 @@ def publish_video():
         # 获取对应平台的发布器
         PublisherClass = get_publisher(platform)
         publisher = PublisherClass(headless=HEADLESS_MODE)
+
+        proxy_payload = data.get('proxy')
+        if isinstance(proxy_payload, dict) and proxy_payload.get('enabled'):
+            provider = str(proxy_payload.get('provider') or 'shenlong').strip().lower()
+            if provider == 'shenlong':
+                publisher.proxy_config = _resolve_shenlong_proxy(proxy_payload)
         
         # 执行发布
         result = asyncio.run(publisher.run(cookie_str, params))
@@ -474,6 +611,12 @@ def publish_ai_assisted():
         # 获取对应平台的发布器
         PublisherClass = get_publisher(platform)
         publisher = PublisherClass(headless=headless)  # 使用请求参数中的 headless 值
+
+        proxy_payload = data.get('proxy')
+        if isinstance(proxy_payload, dict) and proxy_payload.get('enabled'):
+            provider = str(proxy_payload.get('provider') or 'shenlong').strip().lower()
+            if provider == 'shenlong':
+                publisher.proxy_config = _resolve_shenlong_proxy(proxy_payload)
         try:
             publisher.user_id = int(data.get("user_id")) if data.get("user_id") is not None else None
         except Exception:

BIN
server/python/platforms/__pycache__/base.cpython-313.pyc


+ 8 - 2
server/python/platforms/base.py

@@ -177,6 +177,7 @@ class BasePublisher(ABC):
         self.user_id: Optional[int] = None
         self.publish_task_id: Optional[int] = None
         self.publish_account_id: Optional[int] = None
+        self.proxy_config: Optional[Dict[str, Any]] = None
     
     def set_progress_callback(self, callback: Callable[[int, str], None]):
         """设置进度回调"""
@@ -217,11 +218,16 @@ class BasePublisher(ABC):
         """将 cookie 列表转换为字符串"""
         return '; '.join([f"{c['name']}={c['value']}" for c in cookies])
     
-    async def init_browser(self, storage_state: str = None):
+    async def init_browser(self, storage_state: str = None, proxy_config: Dict[str, Any] = None):
         """初始化浏览器"""
         print(f"[{self.platform_name}] init_browser: headless={self.headless}", flush=True)
         playwright = await async_playwright().start()
-        self.browser = await playwright.chromium.launch(headless=self.headless)
+
+        proxy = proxy_config or self.proxy_config
+        if proxy and isinstance(proxy, dict) and proxy.get('server'):
+            self.browser = await playwright.chromium.launch(headless=self.headless, proxy=proxy)
+        else:
+            self.browser = await playwright.chromium.launch(headless=self.headless)
         
         if storage_state and os.path.exists(storage_state):
             self.context = await self.browser.new_context(storage_state=storage_state)

+ 5 - 0
server/src/automation/platforms/baijiahao.ts

@@ -287,6 +287,7 @@ export class BaijiahaoAdapter extends BasePlatformAdapter {
         : undefined;
 
       // 使用 AI 辅助发布接口
+      const extra = (params.extra || {}) as Record<string, unknown>;
       const response = await fetch(`${PYTHON_PUBLISH_SERVICE_URL}/publish/ai-assisted`, {
         method: 'POST',
         headers: {
@@ -295,6 +296,10 @@ export class BaijiahaoAdapter extends BasePlatformAdapter {
         body: JSON.stringify({
           platform: 'baijiahao',
           cookie: cookieStr,
+          user_id: (extra as any).userId,
+          publish_task_id: (extra as any).publishTaskId,
+          publish_account_id: (extra as any).publishAccountId,
+          proxy: (extra as any).publishProxy || null,
           title: params.title,
           description: params.description || params.title,
           video_path: absoluteVideoPath,

+ 5 - 0
server/src/automation/platforms/bilibili.ts

@@ -181,6 +181,7 @@ export class BilibiliAdapter extends BasePlatformAdapter {
         : undefined;
 
       // 使用 AI 辅助发布接口
+      const extra = (params.extra || {}) as Record<string, unknown>;
       const response = await fetch(`${PYTHON_PUBLISH_SERVICE_URL}/publish/ai-assisted`, {
         method: 'POST',
         headers: {
@@ -189,6 +190,10 @@ export class BilibiliAdapter extends BasePlatformAdapter {
         body: JSON.stringify({
           platform: 'bilibili',
           cookie: cookieStr,
+          user_id: (extra as any).userId,
+          publish_task_id: (extra as any).publishTaskId,
+          publish_account_id: (extra as any).publishAccountId,
+          proxy: (extra as any).publishProxy || null,
           title: params.title,
           description: params.description || params.title,
           video_path: absoluteVideoPath,

+ 14 - 13
server/src/automation/platforms/douyin.ts

@@ -79,12 +79,12 @@ export class DouyinAdapter extends BasePlatformAdapter {
         // 在判断登录成功之前,先检查是否有二次校验弹框
         // 抖音二次校验弹框特征:标题"身份验证"、选项包含"接收短信验证码"等
         const hasSecondaryVerification = await this.checkSecondaryVerification();
-        
+
         if (hasSecondaryVerification) {
           logger.info('[Douyin] Secondary verification detected, waiting for user to complete');
-          return { 
-            status: 'scanned', 
-            message: '需要二次校验,请在手机上完成身份验证' 
+          return {
+            status: 'scanned',
+            message: '需要二次校验,请在手机上完成身份验证'
           };
         }
 
@@ -103,9 +103,9 @@ export class DouyinAdapter extends BasePlatformAdapter {
       const hasSecondaryVerification = await this.checkSecondaryVerification();
       if (hasSecondaryVerification) {
         logger.info('[Douyin] Secondary verification detected after scan');
-        return { 
-          status: 'scanned', 
-          message: '需要二次校验,请在手机上完成身份验证' 
+        return {
+          status: 'scanned',
+          message: '需要二次校验,请在手机上完成身份验证'
         };
       }
 
@@ -177,8 +177,8 @@ export class DouyinAdapter extends BasePlatformAdapter {
 
       // 5. 检查页面内容中是否包含二次校验关键文本
       const pageContent = await this.page.content().catch(() => '');
-      if (pageContent.includes('为保障账号安全') && 
-          (pageContent.includes('身份验证') || pageContent.includes('完成身份验证'))) {
+      if (pageContent.includes('为保障账号安全') &&
+        (pageContent.includes('身份验证') || pageContent.includes('完成身份验证'))) {
         logger.info('[Douyin] Found secondary verification text in page content');
         return true;
       }
@@ -924,6 +924,7 @@ export class DouyinAdapter extends BasePlatformAdapter {
           user_id: extra.userId,
           publish_task_id: extra.publishTaskId,
           publish_account_id: extra.publishAccountId,
+          proxy: (extra as any).publishProxy || null,
           title: params.title,
           description: params.description || params.title,
           video_path: absoluteVideoPath,
@@ -951,7 +952,7 @@ export class DouyinAdapter extends BasePlatformAdapter {
       if (result.screenshot_base64) {
         logger.info('[Douyin Python] Got screenshot, analyzing with AI...');
         const { aiService } = await import('../../ai/index.js');
-        
+
         if (aiService.isAvailable()) {
           const aiStatus = await aiService.analyzePublishStatus(result.screenshot_base64, 'douyin');
           logger.info(`[Douyin Python] AI analysis: status=${aiStatus.status}, confidence=${aiStatus.confidence}%`);
@@ -969,7 +970,7 @@ export class DouyinAdapter extends BasePlatformAdapter {
           // AI 检测到需要验证码
           if (aiStatus.status === 'need_captcha') {
             logger.info(`[Douyin Python] AI detected captcha: ${aiStatus.captchaDescription}`);
-            
+
             // 如果有验证码回调,尝试处理
             if (onCaptchaRequired) {
               onProgress?.(50, `AI 检测到验证码: ${aiStatus.captchaDescription || '请输入验证码'}`);
@@ -1046,11 +1047,11 @@ export class DouyinAdapter extends BasePlatformAdapter {
           errorMessage: `CAPTCHA_REQUIRED:检测到${pythonResult.captchaType}验证码,需要使用有头浏览器完成验证`,
         };
       }
-      
+
       if (pythonResult.success) {
         return pythonResult;
       }
-      
+
       return {
         success: false,
         errorMessage: pythonResult.errorMessage || '发布失败',

+ 5 - 0
server/src/automation/platforms/kuaishou.ts

@@ -169,6 +169,7 @@ export class KuaishouAdapter extends BasePlatformAdapter {
         : undefined;
 
       // 使用 AI 辅助发布接口
+      const extra = (params.extra || {}) as Record<string, unknown>;
       const response = await fetch(`${PYTHON_PUBLISH_SERVICE_URL}/publish/ai-assisted`, {
         method: 'POST',
         headers: {
@@ -177,6 +178,10 @@ export class KuaishouAdapter extends BasePlatformAdapter {
         body: JSON.stringify({
           platform: 'kuaishou',
           cookie: cookies,
+          user_id: (extra as any).userId,
+          publish_task_id: (extra as any).publishTaskId,
+          publish_account_id: (extra as any).publishAccountId,
+          proxy: (extra as any).publishProxy || null,
           title: params.title,
           description: params.description || params.title,
           video_path: absoluteVideoPath,

+ 64 - 59
server/src/automation/platforms/weixin.ts

@@ -27,30 +27,30 @@ export class WeixinAdapter extends BasePlatformAdapter {
   readonly platform: PlatformType = 'weixin_video';
   readonly loginUrl = 'https://channels.weixin.qq.com/platform';
   readonly publishUrl = 'https://channels.weixin.qq.com/platform/post/create';
-  
+
   protected getCookieDomain(): string {
     return '.weixin.qq.com';
   }
-  
+
   async getQRCode(): Promise<QRCodeInfo> {
     try {
       await this.initBrowser();
-      
+
       if (!this.page) throw new Error('Page not initialized');
-      
+
       // 访问登录页面
       await this.page.goto('https://channels.weixin.qq.com/platform/login-for-iframe?dark_mode=true&host_type=1');
-      
+
       // 点击二维码切换
       await this.page.locator('.qrcode').click();
-      
+
       // 获取二维码
       const qrcodeImg = await this.page.locator('img.qrcode').getAttribute('src');
-      
+
       if (!qrcodeImg) {
         throw new Error('Failed to get QR code');
       }
-      
+
       return {
         qrcodeUrl: qrcodeImg,
         qrcodeKey: `weixin_${Date.now()}`,
@@ -61,49 +61,49 @@ export class WeixinAdapter extends BasePlatformAdapter {
       throw error;
     }
   }
-  
+
   async checkQRCodeStatus(qrcodeKey: string): Promise<LoginStatusResult> {
     try {
       if (!this.page) {
         return { status: 'expired', message: '二维码已过期' };
       }
-      
+
       // 检查是否扫码成功
       const maskDiv = this.page.locator('.mask').first();
       const className = await maskDiv.getAttribute('class');
-      
+
       if (className && className.includes('show')) {
         // 等待登录完成
         await this.page.waitForTimeout(3000);
-        
+
         const cookies = await this.getCookies();
         if (cookies && cookies.length > 10) {
           await this.closeBrowser();
           return { status: 'success', message: '登录成功', cookies };
         }
       }
-      
+
       return { status: 'waiting', message: '等待扫码' };
     } catch (error) {
       logger.error('Weixin checkQRCodeStatus error:', error);
       return { status: 'error', message: '检查状态失败' };
     }
   }
-  
+
   async checkLoginStatus(cookies: string): Promise<boolean> {
     try {
       await this.initBrowser();
       await this.setCookies(cookies);
-      
+
       if (!this.page) throw new Error('Page not initialized');
-      
+
       await this.page.goto(this.publishUrl);
       await this.page.waitForLoadState('networkidle');
-      
+
       // 检查是否需要登录
       const needLogin = await this.page.$('div.title-name:has-text("视频号小店")');
       await this.closeBrowser();
-      
+
       return !needLogin;
     } catch (error) {
       logger.error('Weixin checkLoginStatus error:', error);
@@ -111,15 +111,15 @@ export class WeixinAdapter extends BasePlatformAdapter {
       return false;
     }
   }
-  
+
   /**
    * 关闭页面上可能存在的弹窗对话框
    */
   private async closeModalDialogs(): Promise<boolean> {
     if (!this.page) return false;
-    
+
     let closedAny = false;
-    
+
     try {
       const modalSelectors = [
         // 微信视频号常见弹窗关闭按钮
@@ -136,7 +136,7 @@ export class WeixinAdapter extends BasePlatformAdapter {
         '.close-btn',
         '.icon-close',
       ];
-      
+
       for (const selector of modalSelectors) {
         try {
           const closeBtn = this.page.locator(selector).first();
@@ -150,7 +150,7 @@ export class WeixinAdapter extends BasePlatformAdapter {
           // 忽略错误,继续尝试下一个选择器
         }
       }
-      
+
       // 尝试按 ESC 键关闭弹窗
       if (!closedAny) {
         const hasModal = await this.page.locator('[class*="dialog"], [class*="modal"], [role="dialog"]').count();
@@ -161,15 +161,15 @@ export class WeixinAdapter extends BasePlatformAdapter {
           closedAny = true;
         }
       }
-      
+
       if (closedAny) {
         logger.info('[Weixin] Successfully closed modal dialog');
       }
-      
+
     } catch (error) {
       logger.warn('[Weixin] Error closing modal:', error);
     }
-    
+
     return closedAny;
   }
 
@@ -177,18 +177,18 @@ export class WeixinAdapter extends BasePlatformAdapter {
     try {
       await this.initBrowser();
       await this.setCookies(cookies);
-      
+
       if (!this.page) throw new Error('Page not initialized');
-      
+
       // 访问视频号创作者平台首页
       await this.page.goto('https://channels.weixin.qq.com/platform/home');
       await this.page.waitForLoadState('networkidle');
       await this.page.waitForTimeout(2000);
-      
+
       // 从页面提取账号信息
       const accountData = await this.page.evaluate(() => {
         const result: { accountId?: string; accountName?: string; avatarUrl?: string; fansCount?: number; worksCount?: number } = {};
-        
+
         try {
           // ===== 1. 优先使用精确选择器获取视频号 ID =====
           // 方法1: 通过 #finder-uid-copy 的 data-clipboard-text 属性获取
@@ -206,7 +206,7 @@ export class WeixinAdapter extends BasePlatformAdapter {
               }
             }
           }
-          
+
           // 方法2: 通过 .finder-uniq-id 选择器获取
           if (!result.accountId) {
             const finderUniqIdEl = document.querySelector('.finder-uniq-id');
@@ -224,7 +224,7 @@ export class WeixinAdapter extends BasePlatformAdapter {
               }
             }
           }
-          
+
           // 方法3: 从页面文本中正则匹配
           if (!result.accountId) {
             const bodyText = document.body.innerText || '';
@@ -241,10 +241,10 @@ export class WeixinAdapter extends BasePlatformAdapter {
               }
             }
           }
-          
+
           // ===== 2. 获取账号名称 =====
-          const nicknameEl = document.querySelector('h2.finder-nickname') || 
-                            document.querySelector('.finder-nickname');
+          const nicknameEl = document.querySelector('h2.finder-nickname') ||
+            document.querySelector('.finder-nickname');
           if (nicknameEl) {
             const text = nicknameEl.textContent?.trim();
             if (text && text.length >= 2 && text.length <= 30) {
@@ -252,7 +252,7 @@ export class WeixinAdapter extends BasePlatformAdapter {
               console.log('[WeixinVideo] Found name:', result.accountName);
             }
           }
-          
+
           // ===== 3. 获取头像 =====
           const avatarEl = document.querySelector('img.avatar') as HTMLImageElement;
           if (avatarEl?.src && avatarEl.src.startsWith('http')) {
@@ -263,7 +263,7 @@ export class WeixinAdapter extends BasePlatformAdapter {
               result.avatarUrl = altAvatarEl.src;
             }
           }
-          
+
           // ===== 4. 获取视频数和关注者数 =====
           const contentInfo = document.querySelector('.finder-content-info');
           if (contentInfo) {
@@ -281,7 +281,7 @@ export class WeixinAdapter extends BasePlatformAdapter {
               }
             });
           }
-          
+
           // 备选:从页面整体文本中匹配
           if (result.fansCount === undefined || result.worksCount === undefined) {
             const bodyText = document.body.innerText || '';
@@ -305,22 +305,22 @@ export class WeixinAdapter extends BasePlatformAdapter {
         } catch (e) {
           console.error('[WeixinVideo] Extract error:', e);
         }
-        
+
         return result;
       });
-      
+
       logger.info('[Weixin] Extracted account data:', accountData);
-      
+
       // 如果首页没有获取到视频号 ID,尝试访问账号设置页面
       let finalAccountId = accountData.accountId;
       if (!finalAccountId || finalAccountId.length < 10) {
         logger.info('[Weixin] Finder ID not found on home page, trying account settings page...');
-        
+
         try {
           await this.page.goto('https://channels.weixin.qq.com/platform/account');
           await this.page.waitForLoadState('networkidle');
           await this.page.waitForTimeout(2000);
-          
+
           const settingsId = await this.page.evaluate(() => {
             const bodyText = document.body.innerText || '';
             const patterns = [
@@ -336,7 +336,7 @@ export class WeixinAdapter extends BasePlatformAdapter {
             }
             return null;
           });
-          
+
           if (settingsId) {
             finalAccountId = settingsId;
             logger.info('[Weixin] Found finder ID from settings page:', finalAccountId);
@@ -345,9 +345,9 @@ export class WeixinAdapter extends BasePlatformAdapter {
           logger.warn('[Weixin] Failed to fetch from settings page:', e);
         }
       }
-      
+
       await this.closeBrowser();
-      
+
       return {
         accountId: finalAccountId || `weixin_video_${Date.now()}`,
         accountName: accountData.accountName || '视频号账号',
@@ -367,7 +367,7 @@ export class WeixinAdapter extends BasePlatformAdapter {
       };
     }
   }
-  
+
   /**
    * 检查 Python 发布服务是否可用
    */
@@ -409,6 +409,7 @@ export class WeixinAdapter extends BasePlatformAdapter {
         : undefined;
 
       // 使用 AI 辅助发布接口
+      const extra = (params.extra || {}) as Record<string, unknown>;
       const response = await fetch(`${PYTHON_PUBLISH_SERVICE_URL}/publish/ai-assisted`, {
         method: 'POST',
         headers: {
@@ -417,6 +418,10 @@ export class WeixinAdapter extends BasePlatformAdapter {
         body: JSON.stringify({
           platform: 'weixin',
           cookie: cookies,
+          user_id: (extra as any).userId,
+          publish_task_id: (extra as any).publishTaskId,
+          publish_account_id: (extra as any).publishAccountId,
+          proxy: (extra as any).publishProxy || null,
           title: params.title,
           description: params.description || params.title,
           video_path: absoluteVideoPath,
@@ -439,7 +444,7 @@ export class WeixinAdapter extends BasePlatformAdapter {
       throw error;
     }
   }
-  
+
   async publishVideo(
     cookies: string,
     params: PublishParams,
@@ -460,7 +465,7 @@ export class WeixinAdapter extends BasePlatformAdapter {
     logger.info('[Weixin] Using Python service for publishing');
     try {
       const result = await this.publishVideoViaPython(cookies, params, onProgress);
-      
+
       // 检查是否需要验证码
       if (!result.success && result.errorMessage?.includes('验证码')) {
         logger.info('[Weixin] Python detected captcha, need headful browser');
@@ -469,7 +474,7 @@ export class WeixinAdapter extends BasePlatformAdapter {
           errorMessage: `CAPTCHA_REQUIRED:${result.errorMessage}`,
         };
       }
-      
+
       return result;
     } catch (pythonError) {
       logger.error('[Weixin] Python publish failed:', pythonError);
@@ -1005,13 +1010,13 @@ export class WeixinAdapter extends BasePlatformAdapter {
     }
     ========== Playwright 方式已注释结束 ========== */
   }
-  
+
   /**
    * 通过 Python API 获取评论
    */
   private async getCommentsViaPython(cookies: string, videoId: string): Promise<CommentData[]> {
     logger.info('[Weixin] Getting comments via Python API...');
-    
+
     const response = await fetch(`${PYTHON_PUBLISH_SERVICE_URL}/comments`, {
       method: 'POST',
       headers: {
@@ -1023,17 +1028,17 @@ export class WeixinAdapter extends BasePlatformAdapter {
         work_id: videoId,
       }),
     });
-    
+
     if (!response.ok) {
       throw new Error(`Python API returned ${response.status}`);
     }
-    
+
     const result = await response.json();
-    
+
     if (!result.success) {
       throw new Error(result.error || 'Failed to get comments');
     }
-    
+
     // 转换数据格式
     return (result.comments || []).map((comment: {
       comment_id: string;
@@ -1067,16 +1072,16 @@ export class WeixinAdapter extends BasePlatformAdapter {
         logger.warn('[Weixin] Python API getComments failed:', pythonError);
       }
     }
-    
+
     logger.warn('Weixin getComments - Python API not available');
     return [];
   }
-  
+
   async replyComment(cookies: string, videoId: string, commentId: string, content: string): Promise<boolean> {
     logger.warn('Weixin replyComment not implemented');
     return false;
   }
-  
+
   async getAnalytics(cookies: string, dateRange?: DateRange): Promise<AnalyticsData> {
     logger.warn('Weixin getAnalytics not implemented');
     return {

+ 49 - 48
server/src/automation/platforms/xiaohongshu.ts

@@ -146,9 +146,9 @@ export class XiaohongshuAdapter extends BasePlatformAdapter {
    */
   private async closeModalDialogs(): Promise<boolean> {
     if (!this.page) return false;
-    
+
     let closedAny = false;
-    
+
     try {
       // 检查并关闭 Element UI / Vue 弹窗
       const modalSelectors = [
@@ -170,7 +170,7 @@ export class XiaohongshuAdapter extends BasePlatformAdapter {
         // 遮罩层(点击遮罩关闭)
         '.el-overlay[style*="display: none"]',
       ];
-      
+
       for (const selector of modalSelectors) {
         try {
           const closeBtn = this.page.locator(selector).first();
@@ -184,7 +184,7 @@ export class XiaohongshuAdapter extends BasePlatformAdapter {
           // 忽略错误,继续尝试下一个选择器
         }
       }
-      
+
       // 尝试按 ESC 键关闭弹窗
       if (!closedAny) {
         const hasModal = await this.page.locator('.el-overlay-dialog, [role="dialog"]').count();
@@ -192,21 +192,21 @@ export class XiaohongshuAdapter extends BasePlatformAdapter {
           logger.info('[Xiaohongshu] Trying ESC key to close modal...');
           await this.page.keyboard.press('Escape');
           await this.page.waitForTimeout(500);
-          
+
           // 检查是否关闭成功
           const stillHasModal = await this.page.locator('.el-overlay-dialog, [role="dialog"]').count();
           closedAny = stillHasModal < hasModal;
         }
       }
-      
+
       if (closedAny) {
         logger.info('[Xiaohongshu] Successfully closed modal dialog');
       }
-      
+
     } catch (error) {
       logger.warn('[Xiaohongshu] Error closing modal:', error);
     }
-    
+
     return closedAny;
   }
 
@@ -285,10 +285,10 @@ export class XiaohongshuAdapter extends BasePlatformAdapter {
           if (url.includes('edith.xiaohongshu.com') && url.includes('/creator/note/user/posted')) {
             const data = await response.json();
             logger.info(`[Xiaohongshu API] Posted notes (edith):`, JSON.stringify(data).slice(0, 800));
-            
+
             if (data?.data?.tags && Array.isArray(data.data.tags)) {
               // 从 tags 数组中找到 "所有笔记" 的 notes_count
-              const allNotesTag = data.data.tags.find((tag: { id?: string; name?: string; notes_count?: number }) => 
+              const allNotesTag = data.data.tags.find((tag: { id?: string; name?: string; notes_count?: number }) =>
                 tag.id?.includes('note_time') || tag.name === '所有笔记'
               );
               if (allNotesTag?.notes_count !== undefined) {
@@ -366,34 +366,34 @@ export class XiaohongshuAdapter extends BasePlatformAdapter {
       if (fansCount === 0 || worksCount === 0) {
         const statsData = await this.page.evaluate(() => {
           const result = { fans: 0, notes: 0, name: '', avatar: '' };
-          
+
           // 获取页面文本
           const allText = document.body.innerText;
-          
+
           // 尝试匹配粉丝数
           const fansMatch = allText.match(/粉丝[::\s]*(\d+(?:\.\d+)?[万亿]?)|(\d+(?:\.\d+)?[万亿]?)\s*粉丝/);
           if (fansMatch) {
             const numStr = fansMatch[1] || fansMatch[2];
             result.fans = Math.floor(parseFloat(numStr) * (numStr.includes('万') ? 10000 : numStr.includes('亿') ? 100000000 : 1));
           }
-          
+
           // 尝试匹配笔记数
           const notesMatch = allText.match(/笔记[::\s]*(\d+)|(\d+)\s*篇?笔记|共\s*(\d+)\s*篇/);
           if (notesMatch) {
             result.notes = parseInt(notesMatch[1] || notesMatch[2] || notesMatch[3]);
           }
-          
+
           // 获取用户名
           const nameEl = document.querySelector('[class*="nickname"], [class*="user-name"], [class*="creator-name"]');
           if (nameEl) result.name = nameEl.textContent?.trim() || '';
-          
+
           // 获取头像
           const avatarEl = document.querySelector('[class*="avatar"] img, [class*="user-avatar"] img');
           if (avatarEl) result.avatar = (avatarEl as HTMLImageElement).src || '';
-          
+
           return result;
         });
-        
+
         if (fansCount === 0 && statsData.fans > 0) fansCount = statsData.fans;
         if (worksCount === 0 && statsData.notes > 0) worksCount = statsData.notes;
         if ((!accountName || accountName === '小红书账号') && statsData.name) accountName = statsData.name;
@@ -459,7 +459,7 @@ export class XiaohongshuAdapter extends BasePlatformAdapter {
     try {
       // 准备 cookie 字符串
       let cookieStr = cookies;
-      
+
       // 如果 cookies 是 JSON 数组格式,转换为字符串格式
       try {
         const cookieArray = JSON.parse(cookies);
@@ -473,11 +473,11 @@ export class XiaohongshuAdapter extends BasePlatformAdapter {
       onProgress?.(10, '正在上传视频...');
 
       // 将相对路径转换为绝对路径
-      const absoluteVideoPath = path.isAbsolute(params.videoPath) 
-        ? params.videoPath 
+      const absoluteVideoPath = path.isAbsolute(params.videoPath)
+        ? params.videoPath
         : path.resolve(SERVER_ROOT, params.videoPath);
-      
-      const absoluteCoverPath = params.coverPath 
+
+      const absoluteCoverPath = params.coverPath
         ? (path.isAbsolute(params.coverPath) ? params.coverPath : path.resolve(SERVER_ROOT, params.coverPath))
         : undefined;
 
@@ -511,6 +511,7 @@ export class XiaohongshuAdapter extends BasePlatformAdapter {
           user_id: (params.extra as any)?.userId,
           publish_task_id: (params.extra as any)?.publishTaskId,
           publish_account_id: (params.extra as any)?.publishAccountId,
+          proxy: (params.extra as any)?.publishProxy || null,
           return_screenshot: true,
         }),
         signal: AbortSignal.timeout(300000), // 5分钟超时
@@ -551,7 +552,7 @@ export class XiaohongshuAdapter extends BasePlatformAdapter {
     logger.info('[Xiaohongshu] Using Python API service for publishing');
     try {
       const result = await this.publishVideoViaApi(cookies, params, onProgress);
-      
+
       // 检查是否需要验证码
       if (!result.success && result.errorMessage?.includes('验证码')) {
         logger.info('[Xiaohongshu] Python detected captcha, need headful browser');
@@ -560,7 +561,7 @@ export class XiaohongshuAdapter extends BasePlatformAdapter {
           errorMessage: `CAPTCHA_REQUIRED:${result.errorMessage}`,
         };
       }
-      
+
       return result;
     } catch (apiError) {
       logger.error('[Xiaohongshu] Python API publish failed:', apiError);
@@ -1279,7 +1280,7 @@ export class XiaohongshuAdapter extends BasePlatformAdapter {
    */
   private async getCommentsViaPython(cookies: string, videoId: string): Promise<CommentData[]> {
     logger.info('[Xiaohongshu] Getting comments via Python API...');
-    
+
     const response = await fetch(`${XHS_PYTHON_SERVICE_URL}/comments`, {
       method: 'POST',
       headers: {
@@ -1291,17 +1292,17 @@ export class XiaohongshuAdapter extends BasePlatformAdapter {
         work_id: videoId,
       }),
     });
-    
+
     if (!response.ok) {
       throw new Error(`Python API returned ${response.status}`);
     }
-    
+
     const result = await response.json();
-    
+
     if (!result.success) {
       throw new Error(result.error || 'Failed to get comments');
     }
-    
+
     // 转换数据格式
     return (result.comments || []).map((comment: {
       comment_id: string;
@@ -1364,7 +1365,7 @@ export class XiaohongshuAdapter extends BasePlatformAdapter {
         logger.warn('[Xiaohongshu] Python API getComments failed, falling back to Playwright:', pythonError);
       }
     }
-    
+
     // 回退到 Playwright 方式
     try {
       await this.initBrowser({ headless: true });
@@ -1398,7 +1399,7 @@ export class XiaohongshuAdapter extends BasePlatformAdapter {
               });
             }
           }
-        } catch {}
+        } catch { }
       });
 
       // 访问评论管理页面
@@ -1513,7 +1514,7 @@ export class XiaohongshuAdapter extends BasePlatformAdapter {
           await this.page.screenshot({ path: screenshotPath, fullPage: true });
           logger.info(`[Xiaohongshu Delete] Page screenshot: ${screenshotPath}`);
         }
-      } catch {}
+      } catch { }
 
       // 在笔记管理页面找到对应的笔记行
       // 页面结构:
@@ -1521,23 +1522,23 @@ export class XiaohongshuAdapter extends BasePlatformAdapter {
       // - 笔记ID在 data-impression 属性的 JSON 中: noteId: "xxx"
       // - 删除按钮是 span.control.data-del 内的 <span>删除</span>
       let deleteClicked = false;
-      
+
       // 方式1: 通过 data-impression 属性找到对应笔记,然后点击其删除按钮
       logger.info(`[Xiaohongshu Delete] Looking for note with ID: ${noteId}`);
-      
+
       // 查找所有笔记卡片
       const noteCards = this.page.locator('div.note');
       const noteCount = await noteCards.count();
       logger.info(`[Xiaohongshu Delete] Found ${noteCount} note cards`);
-      
+
       for (let i = 0; i < noteCount; i++) {
         const card = noteCards.nth(i);
         const impression = await card.getAttribute('data-impression').catch(() => '');
-        
+
         // 检查 data-impression 中是否包含目标 noteId
         if (impression && impression.includes(noteId)) {
           logger.info(`[Xiaohongshu Delete] Found target note at index ${i}`);
-          
+
           // 在该笔记卡片内查找删除按钮 (span.data-del)
           const deleteBtn = card.locator('span.data-del, span.control.data-del').first();
           if (await deleteBtn.count() > 0) {
@@ -1556,15 +1557,15 @@ export class XiaohongshuAdapter extends BasePlatformAdapter {
           // 查找所有 div.note 元素
           const notes = document.querySelectorAll('div.note');
           console.log(`[XHS Delete] Found ${notes.length} note elements`);
-          
+
           for (const note of notes) {
             const impression = note.getAttribute('data-impression') || '';
             if (impression.includes(nid)) {
               console.log(`[XHS Delete] Found note with ID ${nid}`);
-              
+
               // 查找删除按钮
-              const deleteBtn = note.querySelector('span.data-del') || 
-                               note.querySelector('.control.data-del');
+              const deleteBtn = note.querySelector('span.data-del') ||
+                note.querySelector('.control.data-del');
               if (deleteBtn) {
                 console.log(`[XHS Delete] Clicking delete button`);
                 (deleteBtn as HTMLElement).click();
@@ -1572,23 +1573,23 @@ export class XiaohongshuAdapter extends BasePlatformAdapter {
               }
             }
           }
-          
+
           return false;
         }, noteId);
-        
+
         if (deleteClicked) {
           logger.info('[Xiaohongshu Delete] Delete button clicked via evaluate');
         }
       }
-      
+
       // 方式3: 如果还没找到,尝试点击第一个可见的删除按钮
       if (!deleteClicked) {
         logger.info('[Xiaohongshu Delete] Trying to click first visible delete button...');
-        
+
         const allDeleteBtns = this.page.locator('span.data-del');
         const btnCount = await allDeleteBtns.count();
         logger.info(`[Xiaohongshu Delete] Found ${btnCount} delete buttons on page`);
-        
+
         for (let i = 0; i < btnCount; i++) {
           const btn = allDeleteBtns.nth(i);
           if (await btn.isVisible().catch(() => false)) {
@@ -1608,7 +1609,7 @@ export class XiaohongshuAdapter extends BasePlatformAdapter {
             await this.page.screenshot({ path: screenshotPath, fullPage: true });
             logger.info(`[Xiaohongshu Delete] No delete button found, screenshot: ${screenshotPath}`);
           }
-        } catch {}
+        } catch { }
         throw new Error('未找到删除按钮');
       }
 
@@ -1727,7 +1728,7 @@ export class XiaohongshuAdapter extends BasePlatformAdapter {
               analytics.sharesCount = d.collect_count || analytics.sharesCount;
             }
           }
-        } catch {}
+        } catch { }
       });
 
       // 访问数据中心

+ 4 - 0
server/src/models/entities/PublishTask.ts

@@ -7,6 +7,7 @@ import {
   JoinColumn,
 } from 'typeorm';
 import type { PublishTaskStatus, PlatformPublishConfig } from '@media-manager/shared';
+import type { PublishProxyConfig } from '@media-manager/shared';
 import { User } from './User.js';
 import { PublishResult } from './PublishResult.js';
 
@@ -42,6 +43,9 @@ export class PublishTask {
   @Column({ type: 'json', nullable: true, name: 'platform_configs' })
   platformConfigs!: PlatformPublishConfig[] | null;
 
+  @Column({ type: 'json', nullable: true, name: 'publish_proxy' })
+  publishProxy!: PublishProxyConfig | null;
+
   @Column({
     type: 'enum',
     enum: ['pending', 'processing', 'completed', 'failed', 'cancelled'],

+ 24 - 0
server/src/models/index.ts

@@ -67,10 +67,34 @@ async function ensureMysqlSessionTimezone(): Promise<void> {
   }
 }
 
+async function ensurePublishTasksPublishProxyColumn(): Promise<void> {
+  try {
+    const rows: Array<{ cnt: number }> = await AppDataSource.query(
+      `SELECT COUNT(*) AS cnt
+       FROM information_schema.COLUMNS
+       WHERE TABLE_SCHEMA = ?
+         AND TABLE_NAME = 'publish_tasks'
+         AND COLUMN_NAME = 'publish_proxy'`,
+      [config.database.database]
+    );
+
+    const cnt = Number(rows?.[0]?.cnt || 0);
+    if (cnt > 0) return;
+
+    await AppDataSource.query(
+      `ALTER TABLE publish_tasks
+       ADD COLUMN publish_proxy JSON NULL AFTER platform_configs`
+    );
+  } catch {
+    return;
+  }
+}
+
 export async function initDatabase(): Promise<DataSource> {
   if (!AppDataSource.isInitialized) {
     await AppDataSource.initialize();
     await ensureMysqlSessionTimezone();
+    await ensurePublishTasksPublishProxyColumn();
   }
   return AppDataSource;
 }

+ 1 - 0
server/src/routes/publish.ts

@@ -48,6 +48,7 @@ router.post(
     body('videoPath').notEmpty().withMessage('视频路径不能为空'),
     body('title').notEmpty().withMessage('标题不能为空'),
     body('targetAccounts').isArray({ min: 1 }).withMessage('至少选择一个目标账号'),
+    body('publishProxy').optional().isObject(),
     validateRequest,
   ],
   asyncHandler(async (req, res) => {

+ 33 - 0
server/src/routes/system.ts

@@ -33,6 +33,39 @@ router.put(
   })
 );
 
+router.get(
+  '/publish-proxy',
+  authenticate,
+  authorize('admin'),
+  asyncHandler(async (_req, res) => {
+    const config = await systemService.getPublishProxyAdminConfig();
+    res.json({ success: true, data: config });
+  })
+);
+
+router.get(
+  '/publish-proxy/cities',
+  authenticate,
+  asyncHandler(async (_req, res) => {
+    const cities = await systemService.getPublishProxyCitiesFromApi();
+    res.json({ success: true, data: { cities } });
+  })
+);
+
+router.put(
+  '/publish-proxy',
+  authenticate,
+  authorize('admin'),
+  [
+    body('cityApiUrl').optional().isString(),
+    validateRequest,
+  ],
+  asyncHandler(async (req, res) => {
+    await systemService.updatePublishProxyAdminConfig(req.body);
+    res.json({ success: true, message: '发布代理配置已更新' });
+  })
+);
+
 // 获取系统状态(需要管理员权限)
 router.get(
   '/status',

+ 67 - 1
server/src/services/PublishService.ts

@@ -1,4 +1,4 @@
-import { AppDataSource, PublishTask, PublishResult, PlatformAccount } from '../models/index.js';
+import { AppDataSource, PublishTask, PublishResult, PlatformAccount, SystemConfig } from '../models/index.js';
 import { AppError } from '../middleware/error.js';
 import { ERROR_CODES, HTTP_STATUS, WS_EVENTS } from '@media-manager/shared';
 import type {
@@ -7,6 +7,7 @@ import type {
   CreatePublishTaskRequest,
   PaginatedData,
   PlatformType,
+  PublishProxyConfig,
 } from '@media-manager/shared';
 import { wsManager } from '../websocket/index.js';
 import { DouyinAdapter } from '../automation/platforms/douyin.js';
@@ -21,6 +22,7 @@ import path from 'path';
 import { config } from '../config/index.js';
 import { CookieManager } from '../automation/cookie.js';
 import { taskQueueService } from './TaskQueueService.js';
+import { In } from 'typeorm';
 
 interface GetTasksParams {
   page: number;
@@ -32,6 +34,7 @@ export class PublishService {
   private taskRepository = AppDataSource.getRepository(PublishTask);
   private resultRepository = AppDataSource.getRepository(PublishResult);
   private accountRepository = AppDataSource.getRepository(PlatformAccount);
+  private systemConfigRepository = AppDataSource.getRepository(SystemConfig);
 
   // 平台适配器映射
   private adapters: Map<PlatformType, BasePlatformAdapter> = new Map();
@@ -130,6 +133,7 @@ export class PublishService {
       tags: data.tags || null,
       targetAccounts: validAccountIds, // 只保存有效的账号 ID
       platformConfigs: data.platformConfigs || null,
+      publishProxy: data.publishProxy || null,
       status: 'pending', // 初始状态为 pending,任务队列执行时再更新为 processing
       scheduledAt: data.scheduledAt ? new Date(data.scheduledAt) : null,
     });
@@ -183,6 +187,36 @@ export class PublishService {
     let failCount = 0;
     const totalAccounts = results.length;
 
+    let publishProxyExtra: Awaited<ReturnType<PublishService['buildPublishProxyExtra']>> = null;
+    try {
+      publishProxyExtra = await this.buildPublishProxyExtra(task.publishProxy);
+    } catch (error) {
+      const errorMessage = error instanceof Error ? error.message : '发布代理配置错误';
+      logger.error(`[PublishService] publish proxy config error: ${errorMessage}`);
+
+      for (const r of results) {
+        await this.resultRepository.update(r.id, {
+          status: 'failed',
+          errorMessage,
+        });
+      }
+
+      await this.taskRepository.update(taskId, {
+        status: 'failed',
+        publishedAt: new Date(),
+      });
+
+      wsManager.sendToUser(userId, WS_EVENTS.TASK_STATUS_CHANGED, {
+        taskId,
+        status: 'failed',
+        successCount: 0,
+        failCount: totalAccounts,
+      });
+
+      onProgress?.(100, `发布失败: ${errorMessage}`);
+      return;
+    }
+
     // 构建视频文件的完整路径
     let videoPath = task.videoPath || '';
 
@@ -319,6 +353,7 @@ export class PublishService {
               userId,
               publishTaskId: taskId,
               publishAccountId: account.id,
+              publishProxy: publishProxyExtra,
             },
           },
           (progress, message) => {
@@ -526,6 +561,8 @@ export class PublishService {
       }
     }
 
+    const publishProxyExtra = await this.buildPublishProxyExtra(task.publishProxy);
+
     // 6. 调用 Python API(有头浏览器模式)
     const PYTHON_SERVICE_URL = process.env.PYTHON_PUBLISH_SERVICE_URL || 'http://localhost:5005';
 
@@ -561,6 +598,7 @@ export class PublishService {
           user_id: userId,
           publish_task_id: taskId,
           publish_account_id: accountId,
+          proxy: publishProxyExtra,
           title: task.title,
           description: task.description || task.title,
           video_path: absoluteVideoPath,
@@ -667,6 +705,7 @@ export class PublishService {
       tags: task.tags || [],
       targetAccounts: task.targetAccounts || [],
       platformConfigs: task.platformConfigs || [],
+      publishProxy: task.publishProxy,
       status: task.status,
       scheduledAt: task.scheduledAt?.toISOString() || null,
       publishedAt: task.publishedAt?.toISOString() || null,
@@ -675,6 +714,33 @@ export class PublishService {
     };
   }
 
+  private async buildPublishProxyExtra(publishProxy: PublishProxyConfig | null | undefined): Promise<null | {
+    enabled: boolean;
+    provider: 'shenlong';
+    city: string;
+    apiUrl: string;
+  }> {
+    if (!publishProxy?.enabled) return null;
+
+    const provider = publishProxy.provider || 'shenlong';
+    if (provider !== 'shenlong') return null;
+
+    const rows = await this.systemConfigRepository.find({
+      where: { configKey: 'publish_proxy_city_api_url' },
+    });
+    const cityApiUrl = String(rows?.[0]?.configValue || '').trim();
+    if (!cityApiUrl) {
+      return null;
+    }
+
+    return {
+      enabled: true,
+      provider: 'shenlong',
+      city: String(publishProxy.city || '').trim(),
+      apiUrl: cityApiUrl,
+    };
+  }
+
   private formatTaskDetail(task: PublishTask): PublishTaskDetail {
     return {
       ...this.formatTask(task),

+ 95 - 0
server/src/services/SystemService.ts

@@ -8,6 +8,15 @@ interface UpdateConfigParams {
   defaultUserRole?: string;
 }
 
+export interface PublishProxyAdminConfig {
+  enabled: boolean;
+  cityApiUrl: string;
+}
+
+export interface UpdatePublishProxyAdminConfig {
+  cityApiUrl?: string;
+}
+
 interface SystemStatus {
   database: 'connected' | 'disconnected';
   redis: 'connected' | 'disconnected';
@@ -27,12 +36,18 @@ export class SystemService {
   async getPublicConfig(): Promise<SystemConfigType> {
     const configs = await this.configRepository.find();
     const configMap = new Map(configs.map(c => [c.configKey, c.configValue]));
+    const cityApiUrl = (configMap.get('publish_proxy_city_api_url') || '').trim();
 
     return {
       allowRegistration: configMap.get('allow_registration') === 'true',
       defaultUserRole: configMap.get('default_user_role') || 'operator',
       maxUploadSize: 4096 * 1024 * 1024, // 4GB
       supportedPlatforms: AVAILABLE_PLATFORM_TYPES,
+      publishProxy: {
+        enabled: Boolean(cityApiUrl),
+        provider: 'shenlong',
+        cities: [],
+      },
     };
   }
 
@@ -45,6 +60,48 @@ export class SystemService {
     }
   }
 
+  async getPublishProxyAdminConfig(): Promise<PublishProxyAdminConfig> {
+    const configs = await this.configRepository.find();
+    const configMap = new Map(configs.map(c => [c.configKey, c.configValue]));
+
+    const cityApiUrl = (configMap.get('publish_proxy_city_api_url') || '').trim();
+
+    return {
+      enabled: Boolean(cityApiUrl),
+      cityApiUrl,
+    };
+  }
+
+  async updatePublishProxyAdminConfig(params: UpdatePublishProxyAdminConfig): Promise<void> {
+    if (params.cityApiUrl !== undefined) {
+      await this.setConfig('publish_proxy_city_api_url', String(params.cityApiUrl || '').trim());
+    }
+  }
+
+  async getPublishProxyCitiesFromApi(): Promise<string[]> {
+    const configs = await this.configRepository.find({
+      where: { configKey: 'publish_proxy_city_api_url' },
+    });
+    const cityApiUrl = (configs?.[0]?.configValue || '').trim();
+    if (!cityApiUrl) return [];
+
+    let responseText = '';
+    try {
+      const response = await fetch(cityApiUrl, { signal: AbortSignal.timeout(15000) });
+      responseText = await response.text();
+    } catch {
+      return [];
+    }
+
+    try {
+      const parsed = JSON.parse(responseText);
+      const cities = this.extractCitiesFromProxyApiResponse(parsed);
+      return Array.from(new Set(cities)).filter(Boolean).sort((a, b) => a.localeCompare(b, 'zh-CN'));
+    } catch {
+      return [];
+    }
+  }
+
   async getSystemStatus(): Promise<SystemStatus> {
     const [totalUsers, totalAccounts, totalTasks] = await Promise.all([
       this.userRepository.count(),
@@ -71,4 +128,42 @@ export class SystemService {
       await this.configRepository.save({ configKey: key, configValue: value });
     }
   }
+
+  private extractCitiesFromProxyApiResponse(payload: any): string[] {
+    const result: string[] = [];
+
+    const pushCity = (value: any) => {
+      const city = String(value || '').trim();
+      if (city) result.push(city);
+    };
+
+    if (Array.isArray(payload)) {
+      for (const item of payload) {
+        if (typeof item === 'string') continue;
+        if (item && typeof item === 'object') {
+          pushCity((item as any).city ?? (item as any).name ?? (item as any).fullname);
+        }
+      }
+      return result;
+    }
+
+    if (!payload || typeof payload !== 'object') return result;
+
+    if (Array.isArray((payload as any).cities)) {
+      for (const c of (payload as any).cities) pushCity(c);
+      return result;
+    }
+
+    const data = (payload as any).data;
+    if (Array.isArray(data)) {
+      for (const item of data) {
+        if (typeof item === 'string') continue;
+        if (item && typeof item === 'object') {
+          pushCity((item as any).city ?? (item as any).name ?? (item as any).fullname);
+        }
+      }
+    }
+
+    return result;
+  }
 }

+ 5 - 0
shared/src/types/api.ts

@@ -77,4 +77,9 @@ export interface SystemConfig {
   defaultUserRole: string;
   maxUploadSize: number;
   supportedPlatforms: string[];
+  publishProxy?: {
+    enabled: boolean;
+    provider: 'shenlong';
+    cities: string[];
+  };
 }

+ 10 - 0
shared/src/types/publish.ts

@@ -23,6 +23,14 @@ export interface PlatformPublishConfig {
   extra?: Record<string, unknown>;
 }
 
+export type PublishProxyProvider = 'shenlong';
+
+export interface PublishProxyConfig {
+  enabled: boolean;
+  provider?: PublishProxyProvider;
+  city?: string;
+}
+
 /**
  * 发布任务
  */
@@ -37,6 +45,7 @@ export interface PublishTask {
   tags: string[];
   targetAccounts: number[];
   platformConfigs: PlatformPublishConfig[];
+  publishProxy?: PublishProxyConfig | null;
   status: PublishTaskStatus;
   scheduledAt: string | null;
   publishedAt: string | null;
@@ -71,6 +80,7 @@ export interface CreatePublishTaskRequest {
   targetAccounts: number[];
   platformConfigs?: PlatformPublishConfig[];
   scheduledAt?: string;
+  publishProxy?: PublishProxyConfig | null;
 }
 
 /**