#!/usr/bin/env python
"""
AI-Ad-Agent API Proxy Server
解决局域网浏览器访问时的 CORS 跨域问题
所有前端请求通过此代理转发到后端服务

v2.0 新增:
- 用户认证 (Excel 数据库)
- 内容记录 CRUD API
- 按用户隔离数据
"""
import http.server
import sqlite3
import urllib.request
import urllib.error
import urllib.parse
import json
import re
import sys
import os
import socket
import time
import threading
import hashlib
import hmac
import uuid
from datetime import datetime, timedelta
from socketserver import ThreadingMixIn

PORT = 9000
PROXY_ROUTES = {
    '/api/ollama/':           'http://127.0.0.1:11434',
    '/api/comfyui/':          'http://127.0.0.1:8188',
    '/api/iopaint/':          'http://127.0.0.1:8080',
    '/api/gptsovits/':        'http://127.0.0.1:9880',
}

# 特殊路由：数字人 v2（完整路径转发）
FULL_PATH_ROUTES = {
    '/api/digital-human': 'http://127.0.0.1:8766',  # v2 Duix+GPT-SoVITS+RMBG
}

# ===== 后端健康检查配置 =====
BACKEND_CHECKS = {
    'Ollama':       ('http://127.0.0.1:11434/api/tags', 'GET', 2),
    'ComfyUI':      ('http://127.0.0.1:8188/system_stats', 'GET', 3),
    'IOPaint':      ('http://127.0.0.1:8080/', 'HEAD', 2),
    'GPT-SoVITS':  ('http://127.0.0.1:9880/docs', 'GET', 8),
    '数字人v2':     ('http://127.0.0.1:8766/api/digital-human/v2/status', 'GET', 8),
    'Duix视频合成': ('http://127.0.0.1:8383/', 'GET', 5),
    'Duix.TTS':     ('http://127.0.0.1:18180/', 'GET', 5),
    'Duix.ASR':     ('http://127.0.0.1:10095/', 'GET', 5),
}
_health_cache = {}
_health_cache_time = 0
_health_cache_lock = threading.Lock()

# ===== 用户认证配置 =====
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
DATA_DIR = os.path.join(BASE_DIR, "data")
os.makedirs(DATA_DIR, exist_ok=True)

# Session token 管理（内存 + 文件持久化）
TOKEN_SECRET = "zhengfangyuan_ad_agent_2026"
TOKEN_EXPIRE_HOURS = 24
TOKENS_FILE = os.path.join(DATA_DIR, "tokens.json")
_active_tokens = {}  # token -> {username, display_name, role, expires_at}
_tokens_lock = threading.Lock()


def _save_tokens_to_file():
    """将当前 token 持久化到文件"""
    try:
        with _tokens_lock:
            # 将 datetime 转为 ISO 字符串以便 JSON 序列化
            data = {}
            for t, info in _active_tokens.items():
                data[t] = {
                    "username": info["username"],
                    "display_name": info["display_name"],
                    "role": info["role"],
                    "expires_at": info["expires_at"].isoformat()
                }
        with open(TOKENS_FILE, 'w', encoding='utf-8') as f:
            json.dump(data, f, ensure_ascii=False)
    except Exception:
        pass  # 持久化失败不影响主流程


def _load_tokens_from_file():
    """从文件恢复 token（服务器重启后恢复登录状态）"""
    global _active_tokens
    try:
        if not os.path.exists(TOKENS_FILE):
            return
        with open(TOKENS_FILE, 'r', encoding='utf-8') as f:
            data = json.load(f)
        now = datetime.now()
        restored = {}
        for t, info in data.items():
            expires_at = datetime.fromisoformat(info["expires_at"])
            if now > expires_at:
                continue  # 跳过已过期的 token
            restored[t] = {
                "username": info["username"],
                "display_name": info["display_name"],
                "role": info["role"],
                "expires_at": expires_at
            }
        with _tokens_lock:
            _active_tokens = restored
        if restored:
            print(f"[Auth] 已从文件恢复 {len(restored)} 个有效 token")
    except Exception as e:
        print(f"[Auth] 恢复 token 失败: {e}")


# 启动时加载持久化的 token
_load_tokens_from_file()

# 引入用户管理模块
sys.path.insert(0, BASE_DIR)
try:
    from user_manager import verify_user
except ImportError:
    def verify_user(username, password):
        return None


def generate_token(username: str, display_name: str, role: str) -> str:
    """生成 session token"""
    raw = f"{username}:{uuid.uuid4().hex}:{time.time()}"
    sig = hmac.new(TOKEN_SECRET.encode(), raw.encode(), hashlib.sha256).hexdigest()[:32]
    token = f"{sig}.{raw}"
    with _tokens_lock:
        _active_tokens[token] = {
            "username": username,
            "display_name": display_name,
            "role": role,
            "expires_at": datetime.now() + timedelta(hours=TOKEN_EXPIRE_HOURS)
        }
    # 清理过期 token 并持久化
    _clean_expired_tokens()
    _save_tokens_to_file()
    return token


def validate_token(token: str):
    """验证 token，返回用户信息或 None"""
    if not token:
        return None
    with _tokens_lock:
        info = _active_tokens.get(token)
        if not info:
            return None
        if datetime.now() > info["expires_at"]:
            del _active_tokens[token]
            return None
        return {
            "username": info["username"],
            "display_name": info["display_name"],
            "role": info["role"],
        }


def invalidate_token(token: str):
    """使 token 失效"""
    with _tokens_lock:
        _active_tokens.pop(token, None)
    _save_tokens_to_file()


def _clean_expired_tokens():
    """清理过期 token"""
    now = datetime.now()
    expired = [t for t, i in _active_tokens.items() if now > i["expires_at"]]
    if expired:
        for t in expired:
            del _active_tokens[t]
        _save_tokens_to_file()


# ===== 内容记录管理 =====
def get_records_file(username: str) -> str:
    """获取用户记录文件路径"""
    return os.path.join(DATA_DIR, f"{username}_records.json")


def load_records(username: str) -> list:
    """加载用户记录"""
    fpath = get_records_file(username)
    if not os.path.exists(fpath):
        return []
    try:
        with open(fpath, 'r', encoding='utf-8') as f:
            return json.load(f)
    except (json.JSONDecodeError, IOError):
        return []


def save_records(username: str, records: list):
    """保存用户记录"""
    fpath = get_records_file(username)
    with open(fpath, 'w', encoding='utf-8') as f:
        json.dump(records, f, ensure_ascii=False, indent=2)


def add_record(username: str, record: dict) -> dict:
    """添加一条记录"""
    record["id"] = uuid.uuid4().hex[:12]
    record["username"] = username
    record["created_at"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
    records = load_records(username)
    records.insert(0, record)  # 最新在前
    # 限制最多 500 条
    if len(records) > 500:
        records = records[:500]
    save_records(username, records)
    return record


def get_records_by_type(username: str, record_type: str, page: int = 1, page_size: int = 20) -> dict:
    """获取指定类型的记录列表（分页）"""
    records = load_records(username)
    filtered = [r for r in records if r.get("type") == record_type]
    total = len(filtered)
    start = (page - 1) * page_size
    end = start + page_size
    return {
        "records": filtered[start:end],
        "total": total,
        "page": page,
        "page_size": page_size,
        "total_pages": max(1, (total + page_size - 1) // page_size),
    }


def get_record_by_id(username: str, record_id: str) -> dict:
    """获取单条记录"""
    records = load_records(username)
    for r in records:
        if r.get("id") == record_id:
            return r
    return None


def delete_record(username: str, record_id: str) -> bool:
    """删除记录"""
    records = load_records(username)
    new_records = [r for r in records if r.get("id") != record_id]
    if len(new_records) == len(records):
        return False
    save_records(username, new_records)
    return True


def check_backend_health(name, url, method='GET', timeout=2):
    """检查单个后端服务健康状态"""
    try:
        if method == 'GET':
            req = urllib.request.Request(url)
        elif method == 'HEAD':
            req = urllib.request.Request(url, method='HEAD')
        else:
            req = urllib.request.Request(url, method=method)
        with urllib.request.urlopen(req, timeout=timeout) as resp:
            import sys
            print(f"[HEALTH] {name} OK: {resp.status}", file=sys.stderr)
            return resp.status < 500
    except urllib.error.HTTPError as he:
        # 4xx 错误（如 404）说明服务在线，只是路径不对
        import sys
        ok = he.code < 500
        print(f"[HEALTH] {name} {'OK' if ok else 'FAIL'} (HTTP {he.code})", file=sys.stderr)
        return ok
    except urllib.error.URLError as ue:
        # 连接被拒绝/关闭 — 对 WebSocket 服务用 TCP 检查
        import sys
        from urllib.parse import urlparse
        parsed = urlparse(url)
        host = parsed.hostname or '127.0.0.1'
        port = parsed.port or (443 if parsed.scheme == 'https' else 80)
        try:
            import socket
            sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
            sock.settimeout(timeout)
            result = sock.connect_ex((host, port))
            sock.close()
            if result == 0:
                print(f"[HEALTH] {name} OK (TCP connect to {host}:{port})", file=sys.stderr)
                return True
            else:
                print(f"[HEALTH] {name} FAIL: TCP connect failed to {host}:{port}", file=sys.stderr)
                return False
        except Exception as e2:
            print(f"[HEALTH] {name} FAIL: {type(e2).__name__}: {e2}", file=sys.stderr)
            return False
    except Exception as e:
        # 其他异常（如 RemoteDisconnected）也尝试 TCP 回退
        import sys
        from urllib.parse import urlparse
        parsed = urlparse(url)
        host = parsed.hostname or '127.0.0.1'
        port = parsed.port or (443 if parsed.scheme == 'https' else 80)
        try:
            import socket
            sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
            sock.settimeout(timeout)
            result = sock.connect_ex((host, port))
            sock.close()
            if result == 0:
                print(f"[HEALTH] {name} OK (TCP fallback after {type(e).__name__})", file=sys.stderr)
                return True
            else:
                print(f"[HEALTH] {name} FAIL: {type(e).__name__}: {e}", file=sys.stderr)
                return False
        except Exception as e2:
            print(f"[HEALTH] {name} FAIL: {type(e).__name__} + {type(e2).__name__}", file=sys.stderr)
            return False


def get_all_backend_health():
    """获取所有后端服务健康状态（并发检查，带缓存）"""
    global _health_cache, _health_cache_time
    now = time.time()
    
    with _health_cache_lock:
        if now - _health_cache_time < 5:
            return _health_cache.copy()
    
    result = {}
    from concurrent.futures import ThreadPoolExecutor, as_completed
    with ThreadPoolExecutor(max_workers=8) as executor:
        future_to_name = {
            executor.submit(check_backend_health, name, url, method, timeout): name
            for name, (url, method, timeout) in BACKEND_CHECKS.items()
        }
        for future in as_completed(future_to_name):
            name = future_to_name[future]
            try:
                result[name] = future.result()
            except Exception:
                result[name] = False
    
    with _health_cache_lock:
        _health_cache = result
        _health_cache_time = now
    
    return result


class ProxyHandler(http.server.SimpleHTTPRequestHandler):
    
    def __init__(self, *args, **kwargs):
        super().__init__(*args, directory=BASE_DIR, **kwargs)
    
    def _serve_static_file(self):
        """静态文件服务，添加 Cache-Control: no-cache 确保浏览器加载最新版本"""
        path = self.translate_path(self.path)
        if not os.path.exists(path) or os.path.isdir(path):
            return super().do_GET()
        try:
            fs = os.fstat
            ctype = self.guess_type(path)
            with open(path, 'rb') as f:
                self.send_response(200)
                self.send_header('Content-type', ctype)
                self.send_header('Content-Length', str(fs(os.open(path, os.O_RDONLY)).st_size))
                self.send_header('Cache-Control', 'no-cache, no-store, must-revalidate')
                self.send_header('Pragma', 'no-cache')
                self.send_header('Expires', '0')
                self._send_cors_headers()
                self.end_headers()
                self.wfile.write(f.read())
        except Exception:
            return super().do_GET()
    
    def do_GET(self):
        try:
            # 静态文件服务 — 添加 Cache-Control 防止浏览器缓存旧版 JS/CSS/HTML
            if not self.path.startswith('/api/'):
                return self._serve_static_file()
            # 内置 API
            if self.path == '/api/health':
                return self._handle_health()
            if self.path == '/api/start-services':
                return self._handle_start_services()
            if self.path == '/api/restart-services':
                return self._handle_restart_services()
            if self.path == '/api/auth/check':
                return self._handle_auth_check()
            if self.path.startswith('/api/records'):
                return self._handle_records_get()
            # Knowledge Management API (GET)
            if self.path.startswith('/api/knowledge/list'):
                return self._handle_knowledge_list()
            if self.path.startswith('/api/knowledge/search'):
                return self._handle_knowledge_search()
            if self.path.startswith('/api/knowledge/preview'):
                return self._handle_knowledge_preview()
            if self.path.startswith('/api/knowledge/download'):
                return self._handle_knowledge_download()
            if self.path == '/api/users/list':
                return self._handle_users_list()
            # 语音文件服务
            if self.path.startswith('/api/voice/file/'):
                return self._handle_voice_file()
            # ComfyUI 状态查询
            if self.path.startswith('/api/comfyui/status'):
                return self._handle_comfyui_status()
            # API 代理（数字人路由需认证，但状态查询及媒体文件豁免）
            if self.path.startswith('/api/digital-human'):
                clean_path = self.path.split('?')[0]
                if clean_path in ('/api/digital-human/v2/status', '/api/digital-human/status'):
                    self._proxy_request('GET')
                    return
                if any(x in clean_path for x in ('/v2/video/', '/v2/voice/audio/', '/v2/bg/')):
                    self._proxy_request('GET')
                    return
                user = self._require_auth()
                if not user:
                    return
                self._proxy_request('GET', user=user)
            else:
                self._proxy_request('GET')
        except Exception as _e:
            import traceback, os
            err_msg = traceback.format_exc()
            try:
                log_path = os.path.join(BASE_DIR, 'proxy_exception.log')
                with open(log_path, 'a', encoding='utf-8') as _f:
                    _f.write(err_msg + '\n' + '='*50 + '\n')
            except Exception:
                pass
            try:
                self.send_response(500)
                self._send_cors_headers()
                self.send_header('Content-Type', 'application/json; charset=utf-8')
                self.end_headers()
                self.wfile.write(json.dumps({'error': str(_e), 'traceback': err_msg}).encode())
            except Exception:
                pass
    
    def do_POST(self):
        # 内置 API
        if self.path == '/api/start-services':
            return self._handle_start_services()
        if self.path == '/api/restart-services':
            return self._handle_restart_services()

        if self.path == '/api/auth/login':
            return self._handle_login()
        
        if self.path == '/api/auth/logout':
            return self._handle_logout()
        
        if self.path == '/api/auth/change-pwd':
            return self._handle_change_password()
        
        if self.path == '/api/records/save':
            return self._handle_records_save()
        
        if self.path == '/api/users/add':
            return self._handle_users_add()
        
        # IOPaint 专用端点
        if self.path == '/api/iopaint/remove_watermark':
            return self._handle_iopaint_remove_watermark()
        
        if self.path == '/api/iopaint/detect_and_inpaint':
            return self._handle_iopaint_detect_and_inpaint()
        
        if self.path == '/api/iopaint/inpaint_mask':
            return self._handle_iopaint_inpaint_mask()
        
        if self.path == '/api/iopaint/brush_inpaint':
            return self._handle_iopaint_brush_inpaint()
        
        if self.path == '/api/iopaint/remove_background':
            return self._handle_iopaint_remove_background()
        
        if self.path == '/api/voice/save':
            return self._handle_voice_save()
        
        # Knowledge Management API (POST)
        if self.path == '/api/knowledge/upload':
            return self._handle_knowledge_upload()
        if self.path == '/api/knowledge/generate':
            return self._handle_knowledge_generate()
        
        # API 代理（数字人路由需认证）
        if self.path.startswith('/api/digital-human'):
            user = self._require_auth()
            if not user:
                return
            self._proxy_request('POST', user=user)
        else:
            self._proxy_request('POST')
    
    def do_DELETE(self):
        if self.path.startswith('/api/records/delete'):
            return self._handle_records_delete()
        
        # Knowledge Management API (DELETE)
        if self.path.startswith('/api/knowledge/delete'):
            return self._handle_knowledge_delete()
        
        if self.path.startswith('/api/users/delete'):
            return self._handle_users_delete()
        
        # 代理 DELETE 请求到后端服务（数字人/声音/视频删除等，需认证）
        if self.path.startswith('/api/digital-human'):
            user = self._require_auth()
            if not user:
                return
            return self._proxy_request('DELETE', user=user)
        elif self.path.startswith('/api/'):
            return self._proxy_request('DELETE')
        
        self.send_error(405)
    
    def do_OPTIONS(self):
        """CORS 预检请求"""
        self.send_response(200)
        self._send_cors_headers()
        self.end_headers()
    
    def _get_auth_user(self):
        """从请求头提取并验证 token，返回用户信息或 None。
        支持两种认证方式（Header 优先）：
        1. Authorization: Bearer <token> 请求头
        2. ?token=<token> URL 查询参数（用于 <video>/<audio> 等无法自定义请求头的元素）
        """
        # 方式1：Authorization 头
        auth_header = self.headers.get('Authorization', '')
        if auth_header.startswith('Bearer '):
            token = auth_header[7:]
            user = validate_token(token)
            if user:
                return user
        
        # 方式2：URL 查询参数 ?token=xxx（用于媒体元素）
        import urllib.parse
        parsed = urllib.parse.urlparse(self.path)
        qs = urllib.parse.parse_qs(parsed.query)
        query_token = qs.get('token', [None])[0]
        if query_token:
            return validate_token(query_token)
        
        return None
    
    def _require_auth(self):
        """要求认证，未认证返回 401"""
        user = self._get_auth_user()
        if not user:
            self.send_response(401)
            self._send_cors_headers()
            self.send_header('Content-Type', 'application/json; charset=utf-8')
            self.end_headers()
            self.wfile.write(json.dumps({
                "error": "未登录或登录已过期，请重新登录"
            }, ensure_ascii=False).encode())
            return None
        return user
    
    # ============ 认证 API ============
    
    def _handle_login(self):
        """POST /api/auth/login"""
        content_length = int(self.headers.get('Content-Length', 0))
        body = self.rfile.read(content_length) if content_length > 0 else b'{}'
        
        try:
            data = json.loads(body)
            username = data.get('username', '').strip()
            password = data.get('password', '')
        except json.JSONDecodeError:
            self._json_response(400, {"error": "请求格式错误"})
            return
        
        if not username or not password:
            self._json_response(400, {"error": "用户名和密码不能为空"})
            return
        
        user = verify_user(username, password)
        if not user:
            self._json_response(401, {"error": "用户名或密码错误"})
            return
        
        token = generate_token(user["username"], user["display_name"], user["role"])
        self._json_response(200, {
            "success": True,
            "token": token,
            "user": user,
        })
    
    def _handle_logout(self):
        """POST /api/auth/logout"""
        auth_header = self.headers.get('Authorization', '')
        if auth_header.startswith('Bearer '):
            invalidate_token(auth_header[7:])
        self._json_response(200, {"success": True, "message": "已登出"})
    
    def _handle_change_password(self):
        """POST /api/auth/change-pwd"""
        user = self._require_auth()
        if not user:
            return
        
        content_length = int(self.headers.get('Content-Length', 0))
        body = self.rfile.read(content_length) if content_length > 0 else b'{}'
        
        try:
            data = json.loads(body)
            old_password = data.get('old_password', '')
            new_password = data.get('new_password', '')
        except json.JSONDecodeError:
            self._json_response(400, {"error": "请求格式错误"})
            return
        
        if not old_password or not new_password:
            self._json_response(400, {"error": "旧密码和新密码不能为空"})
            return
        
        if len(new_password) < 4:
            self._json_response(400, {"error": "新密码长度至少4位"})
            return
        
        # 验证旧密码
        from user_manager import verify_user, find_user, reset_password
        full_user = find_user(user["username"])
        if not full_user or not verify_user(user["username"], old_password):
            self._json_response(401, {"error": "旧密码错误"})
            return
        
        # 修改密码
        if reset_password(user["username"], new_password):
            self._json_response(200, {"success": True, "message": "密码修改成功"})
        else:
            self._json_response(500, {"error": "密码修改失败"})
    
    def _handle_users_list(self):
        """GET /api/users/list — 仅管理员"""
        user = self._require_auth()
        if not user:
            return
        
        if user.get("role") != "admin":
            self._json_response(403, {"error": "仅管理员可查看用户列表"})
            return
        
        from user_manager import get_all_users
        users = get_all_users()
        # 返回安全信息（不包含密码哈希）
        safe_users = []
        for u in users:
            safe_users.append({
                "username": u["username"],
                "display_name": u["display_name"],
                "phone": u.get("phone", ""),
                "role": u["role"],
                "created_at": u["created_at"],
            })
        self._json_response(200, {"users": safe_users, "total": len(safe_users)})
    
    def _handle_users_add(self):
        """POST /api/users/add — 仅管理员"""
        user = self._require_auth()
        if not user:
            return
        
        if user.get("role") != "admin":
            self._json_response(403, {"error": "仅管理员可添加用户"})
            return
        
        content_length = int(self.headers.get('Content-Length', 0))
        body = self.rfile.read(content_length) if content_length > 0 else b'{}'
        
        try:
            data = json.loads(body)
            username = data.get('username', '').strip()
            password = data.get('password', '')
            display_name = data.get('display_name', username)
            phone = data.get('phone', '')
            role = data.get('role', 'user')
        except json.JSONDecodeError:
            self._json_response(400, {"error": "请求格式错误"})
            return
        
        if not username or not password:
            self._json_response(400, {"error": "用户名和密码不能为空"})
            return
        
        if len(password) < 4:
            self._json_response(400, {"error": "密码长度至少4位"})
            return
        
        from user_manager import find_user, add_user
        if find_user(username):
            self._json_response(409, {"error": f"用户 '{username}' 已存在"})
            return
        
        if add_user(username, password, display_name, phone, role):
            self._json_response(201, {"success": True, "message": f"用户 '{username}' 添加成功"})
        else:
            self._json_response(500, {"error": "添加用户失败"})
    
    def _handle_users_delete(self):
        """DELETE /api/users/delete?username=xxx — 仅管理员"""
        user = self._require_auth()
        if not user:
            return
        
        if user.get("role") != "admin":
            self._json_response(403, {"error": "仅管理员可删除用户"})
            return
        
        from urllib.parse import urlparse, parse_qs
        parsed = urlparse(self.path)
        params = parse_qs(parsed.query)
        target = params.get('username', [None])[0]
        
        if not target:
            self._json_response(400, {"error": "缺少 username 参数"})
            return
        
        if target == user["username"]:
            self._json_response(400, {"error": "不能删除自己"})
            return
        
        from user_manager import delete_user
        if delete_user(target):
            self._json_response(200, {"success": True, "message": f"用户 '{target}' 已删除"})
        else:
            self._json_response(404, {"error": f"用户 '{target}' 不存在或删除失败"})
    
    def _handle_auth_check(self):
        """GET /api/auth/check"""
        user = self._get_auth_user()
        if user:
            self._json_response(200, {"authenticated": True, "user": user})
        else:
            self._json_response(200, {"authenticated": False})
    
    # ============ 内容管理 API ============
    
    def _handle_records_get(self):
        """GET /api/records 和 /api/records/detail"""
        user = self._require_auth()
        if not user:
            return
        
        from urllib.parse import urlparse, parse_qs
        parsed = urlparse(self.path)
        params = parse_qs(parsed.query)
        
        # 获取单条详情
        if parsed.path == '/api/records/detail':
            record_id = params.get('id', [None])[0]
            if not record_id:
                self._json_response(400, {"error": "缺少 id 参数"})
                return
            record = get_record_by_id(user["username"], record_id)
            if not record:
                self._json_response(404, {"error": "记录不存在"})
                return
            self._json_response(200, {"record": record})
            return
        
        # 获取列表
        record_type = params.get('type', ['chat'])[0]
        page = int(params.get('page', ['1'])[0])
        page_size = int(params.get('page_size', ['20'])[0])
        
        result = get_records_by_type(user["username"], record_type, page, page_size)
        self._json_response(200, result)
    
    def _handle_records_save(self):
        """POST /api/records/save"""
        user = self._require_auth()
        if not user:
            return
        
        content_length = int(self.headers.get('Content-Length', 0))
        body = self.rfile.read(content_length) if content_length > 0 else b'{}'
        
        try:
            data = json.loads(body)
        except json.JSONDecodeError:
            self._json_response(400, {"error": "请求格式错误"})
            return
        
        record_type = data.get('type', 'chat')
        if record_type not in ('chat', 'knowledge', 'copywriting', 'script', 'image', 'voice', 'content'):
            self._json_response(400, {"error": "无效的记录类型"})
            return
        
        record = {
            "type": record_type,
            "title": data.get('title', '')[:100],
            "content": data.get('content', ''),
            "preview": data.get('preview', '')[:200],
            "metadata": data.get('metadata', {}),
        }
        
        saved = add_record(user["username"], record)
        self._json_response(201, {"success": True, "record": saved})
    
    def _handle_records_delete(self):
        """DELETE /api/records/delete?id=xxx"""
        user = self._require_auth()
        if not user:
            return
        
        from urllib.parse import urlparse, parse_qs
        parsed = urlparse(self.path)
        params = parse_qs(parsed.query)
        record_id = params.get('id', [None])[0]
        
        if not record_id:
            self._json_response(400, {"error": "缺少 id 参数"})
            return
        
        if delete_record(user["username"], record_id):
            self._json_response(200, {"success": True, "message": "已删除"})
        else:
            self._json_response(404, {"error": "记录不存在"})
    
    # ============ 工具方法 ============
    
    def _json_response(self, status, data):
        """发送 JSON 响应"""
        self.send_response(status)
        self._send_cors_headers()
        self.send_header('Content-Type', 'application/json; charset=utf-8')
        self.send_header('Cache-Control', 'no-cache, no-store, must-revalidate')
        self.end_headers()
        self.wfile.write(json.dumps(data, ensure_ascii=False).encode())
    
    def _handle_health(self):
        """健康检查 API"""
        backends = get_all_backend_health()
        all_healthy = all(backends.values())
        
        self.send_response(200 if all_healthy else 207)
        self._send_cors_headers()
        self.send_header('Content-Type', 'application/json; charset=utf-8')
        self.end_headers()
        self.wfile.write(json.dumps({
            'status': 'healthy' if all_healthy else 'degraded',
            'proxy': 'running',
            'uptime': round(time.time() - self.server.start_time, 0),
            'backends': backends,
        }, ensure_ascii=False).encode())
    
    def _handle_start_services(self):
        """一键启动所有后端服务（同时支持重启：创建 restart_signal.json 通知守护进程）"""
        import subprocess
        import json
        
        guardian_path = os.path.join(BASE_DIR, 'service_guardian.py')
        bat_path = os.path.join(BASE_DIR, 'start_all_services.bat')
        restart_signal = os.path.join(BASE_DIR, 'restart_signal.json')
        
        # 1. 创建重启信号文件（守护进程会轮询检测并执行重启）
        # 即使守护进程已在运行，它也会在下次检查时发现此信号
        try:
            signal_data = {
                'restart': True,
                'timestamp': time.time(),
                'requested_by': 'web_ui'
            }
            with open(restart_signal, 'w', encoding='utf-8') as f:
                json.dump(signal_data, f, ensure_ascii=False)
        except Exception as e:
            pass  # 信号文件创建失败不影响后续启动
        
        # 2. 启动守护进程（如果已在运行，PID 锁会阻止重复启动，无影响）
        if os.path.exists(guardian_path):
            python_exe = r"C:\Program Files\Python311\python.exe"
            try:
                subprocess.Popen(
                    ['cmd', '/c', 'start', 'Service-Guardian', python_exe, guardian_path],
                    cwd=BASE_DIR,
                    stdout=subprocess.DEVNULL,
                    stderr=subprocess.DEVNULL,
                    creationflags=subprocess.CREATE_NEW_PROCESS_GROUP
                )
                self._json_response(200, {
                    'success': True,
                    'message': '重启指令已发送！守护进程将在10秒内重启所有服务',
                    'hint': '重启过程中服务会短暂中断，请等待1-3分钟'
                })
                return
            except Exception as e:
                self._json_response(500, {'success': False, 'error': str(e)})
                return
        
        # 3. 降级：没有守护进程，直接启动
        if os.path.exists(bat_path):
            try:
                subprocess.Popen(
                    ['cmd', '/c', 'start', 'Service-Launcher', bat_path],
                    cwd=BASE_DIR,
                    stdout=subprocess.DEVNULL,
                    stderr=subprocess.DEVNULL,
                    creationflags=subprocess.CREATE_NEW_PROCESS_GROUP
                )
                self._json_response(200, {
                    'success': True,
                    'message': '启动脚本已执行，请查看弹出的命令窗口了解进度',
                    'hint': '启动完成后请点击"刷新状态"查看各服务状态'
                })
                return
            except Exception as e:
                self._json_response(500, {'success': False, 'error': str(e)})
                return
        
        self._json_response(500, {
            'success': False,
            'error': '启动脚本不存在，请通过桌面"启动工作台.bat"启动'
        })

    def _handle_restart_services(self):
        """一键重启所有后端服务（通过信号文件通知守护进程）"""
        import subprocess
        import json

        guardian_path = os.path.join(BASE_DIR, 'service_guardian.py')
        restart_signal = os.path.join(BASE_DIR, 'restart_signal.json')

        # 1. 写入重启信号文件（守护进程主循环每10秒检测一次）
        signal_data = {
            'restart': True,
            'timestamp': time.time(),
            'requested_by': 'web_ui'
        }
        try:
            with open(restart_signal, 'w', encoding='utf-8') as f:
                json.dump(signal_data, f, ensure_ascii=False)
        except Exception as e:
            self._json_response(500, {
                'success': False,
                'error': f'无法写入重启信号: {str(e)}'
            })
            return

        # 2. 确保守护进程正在运行（如果没在运行，启动它）
        #    守护进程的 PID 文件锁会防止重复启动
        if os.path.exists(guardian_path):
            python_exe = r"C:\Program Files\Python311\python.exe"
            try:
                subprocess.Popen(
                    ['cmd', '/c', 'start', 'Service-Guardian', python_exe, guardian_path],
                    cwd=BASE_DIR,
                    stdout=subprocess.DEVNULL,
                    stderr=subprocess.DEVNULL,
                    creationflags=subprocess.CREATE_NEW_PROCESS_GROUP
                )
            except Exception as e:
                pass  # 守护进程可能已经在运行，启动会静默失败

        self._json_response(200, {
            'success': True,
            'message': '重启指令已发送！守护进程将在10秒内重启所有服务',
            'hint': '重启过程中服务会短暂中断（约1-3分钟），请耐心等待'
        })

    # ===== ComfyUI 状态同步 =====
    
    def _handle_comfyui_status(self):
        """GET /api/comfyui/status?prompt_id=xxx
        实时查询 ComfyUI 中任务的状态：
        - queued: 排队中
        - running: 执行中，返回进度百分比
        - completed: 已完成，返回输出图片URL
        - error: 执行失败
        - not_found: prompt_id 不存在（已完成并被清理）
        """
        parsed = urllib.parse.urlparse(self.path)
        params = urllib.parse.parse_qs(parsed.query)
        prompt_id = params.get('prompt_id', [None])[0]
        
        if not prompt_id:
            self._json_response(400, {'error': '缺少 prompt_id 参数'})
            return
        
        comfyui_base = 'http://127.0.0.1:8188'
        
        try:
            # 1. 先查历史（已完成的任务）
            hist_req = urllib.request.Request(f'{comfyui_base}/history/{prompt_id}')
            with urllib.request.urlopen(hist_req, timeout=5) as resp:
                hist_data = json.loads(resp.read())
            
            if hist_data and prompt_id in hist_data:
                hist_entry = hist_data[prompt_id]
                outputs = hist_entry.get('outputs', {})
                status_data = hist_entry.get('status', {})
                
                # 检查是否有错误
                if status_data.get('status_str') == 'error':
                    self._json_response(200, {
                        'status': 'error',
                        'prompt_id': prompt_id,
                        'message': status_data.get('messages', [['执行失败']])[-1][0] if status_data.get('messages') else '未知错误',
                    })
                    return
                
                # 成功完成
                image_urls = []
                for node_id, output in outputs.items():
                    if output.get('images'):
                        for img in output['images']:
                            filename = img['filename']
                            subfolder = img.get('subfolder', '')
                            img_url = f'/api/comfyui/view?filename={urllib.parse.quote(filename)}&subfolder={urllib.parse.quote(subfolder)}&type=output'
                            image_urls.append({
                                'url': img_url,
                                'filename': filename,
                                'node_id': node_id,
                            })
                
                self._json_response(200, {
                    'status': 'completed',
                    'prompt_id': prompt_id,
                    'image_urls': image_urls,
                })
                return
            
            # 2. 不在历史中，查队列
            queue_req = urllib.request.Request(f'{comfyui_base}/queue')
            with urllib.request.urlopen(queue_req, timeout=5) as resp:
                queue_data = json.loads(resp.read())
            
            running = queue_data.get('queue_running', [])
            pending = queue_data.get('queue_pending', [])
            
            # 检查是否在运行中
            for item in running:
                if len(item) >= 2 and item[1] == prompt_id:
                    # 提取工作流的节点数，用于估算进度
                    node_count = len(item[1]) if len(item) >= 3 else 0
                    
                    # 尝试通过 prompt API 获取执行信息
                    try:
                        prompt_req = urllib.request.Request(f'{comfyui_base}/prompt')
                        with urllib.request.urlopen(prompt_req, timeout=5) as p_resp:
                            prompt_data = json.loads(p_resp.read())
                        exec_info = prompt_data.get('exec_info', {})
                        queue_remaining = exec_info.get('queue_remaining', 0)
                    except Exception:
                        queue_remaining = 0
                    
                    # 更诚实的进度估算：
                    # - queue_remaining > 0: 任务还在排队中，用队列位置估算
                    # - queue_remaining == 0: 任务正在执行，返回-1表示不确定进度（前端显示动画）
                    progress = -1
                    if queue_remaining > 0:
                        progress = max(5, min(90, 100 - queue_remaining * 5))
                    
                    self._json_response(200, {
                        'status': 'running',
                        'prompt_id': prompt_id,
                        'progress': progress,
                        'queue_position': 0,
                    })
                    return
            
            # 检查是否在排队中（只统计 pending，不包含 running/completed）
            for idx, item in enumerate(pending):
                if len(item) >= 2 and item[1] == prompt_id:
                    self._json_response(200, {
                        'status': 'queued',
                        'prompt_id': prompt_id,
                        'queue_position': idx + 1,  # 只算 pending 中的位置
                        'total_pending': len(pending),
                        'running_count': len(running),
                    })
                    return
            
            # 3. 都不在 — 可能已完成并被清理，或 prompt_id 无效
            self._json_response(200, {
                'status': 'not_found',
                'prompt_id': prompt_id,
                'message': '任务已完成或 prompt_id 无效，请检查结果',
            })
            
        except urllib.error.URLError as e:
            self._json_response(502, {
                'status': 'error',
                'prompt_id': prompt_id,
                'message': f'无法连接 ComfyUI: {e.reason}',
            })
        except Exception as e:
            self._json_response(500, {
                'status': 'error',
                'prompt_id': prompt_id,
                'message': str(e),
            })
    
    # ===== IOPaint 图像处理 =====
    
    def _parse_multipart_form(self):
        """解析 multipart/form-data 请求体，返回 (fields, files)"""
        content_type = self.headers.get('Content-Type', '')
        if 'multipart/form-data' not in content_type:
            return None, None
        
        content_length = int(self.headers.get('Content-Length', 0))
        if content_length == 0:
            return None, None
        
        body = self.rfile.read(content_length)
        
        # 解析 boundary
        import email.parser
        boundary = None
        for part in content_type.split(';'):
            part = part.strip()
            if part.startswith('boundary='):
                boundary = part[9:].strip('"')
                break
        
        if not boundary:
            return None, None
        
        boundary_bytes = boundary.encode('utf-8')
        parts = body.split(b'--' + boundary_bytes)
        
        fields = {}
        files = {}
        
        for part in parts:
            if not part or part == b'--' or part == b'--\r\n':
                continue
            
            # 分离头部和内容
            header_end = part.find(b'\r\n\r\n')
            if header_end == -1:
                continue
            
            headers_raw = part[:header_end].decode('utf-8', errors='ignore')
            content = part[header_end + 4:]
            # 去掉末尾的 \r\n
            if content.endswith(b'\r\n'):
                content = content[:-2]
            
            # 解析 Content-Disposition
            disp_match = re.search(r'Content-Disposition:\s*form-data;\s*name="([^"]*)"(?:\s*;\s*filename="([^"]*)")?', headers_raw)
            if not disp_match:
                continue
            
            field_name = disp_match.group(1)
            filename = disp_match.group(2)
            
            if filename:
                files[field_name] = (filename, content)
            else:
                fields[field_name] = content.decode('utf-8', errors='ignore')
        
        return fields, files
    
    def _handle_iopaint_remove_watermark(self):
        """去水印 - 直接使用智能检测+inpaint"""
        self._handle_iopaint_detect_and_inpaint()
    
    def _handle_iopaint_detect_and_inpaint(self):
        """v8.0: AI智能去水印 - 自动识别或根据描述精准去除
        
        策略:
        1. 无描述词: 使用cv2图像处理自动识别水印区域（快速<1s，不依赖Ollama）
        2. 有描述词: 使用Ollama视觉模型精准识别（30s超时，失败回退到自动检测）
        3. 最终回退: 四角+边缘区域inpaint
        """
        import base64
        from PIL import Image, ImageDraw
        import io
        import requests
        import json as json_mod
        
        try:
            fields, files = self._parse_multipart_form()
            
            if not files or 'image' not in files:
                self._json_response(400, {'error': '未上传图片'})
                return
            
            filename, image_data = files['image']
            description = fields.get('description', '').strip()
            
            img = Image.open(io.BytesIO(image_data))
            if img.mode != 'RGB':
                img = img.convert('RGB')
            
            img_w, img_h = img.size
            
            # === 辅助函数 ===
            def do_inpaint(mask_image, radius=3):
                """调用IOPaint inpaint API（使用更小的radius减少涂抹）"""
                buf = io.BytesIO()
                img.save(buf, format='PNG')
                img_b64 = base64.b64encode(buf.getvalue()).decode('utf-8')
                mask_buf = io.BytesIO()
                mask_image.save(mask_buf, format='PNG')
                mask_b64 = base64.b64encode(mask_buf.getvalue()).decode('utf-8')
                return requests.post(
                    'http://127.0.0.1:8080/api/v1/inpaint',
                    json={"image": img_b64, "mask": mask_b64, "cv2_flag": "INPAINT_NS", "cv2_radius": radius},
                    timeout=120
                )
            
            def send_result(resp):
                """发送inpaint结果"""
                if resp.status_code == 200:
                    result_img = resp.content
                    self.send_response(200)
                    self._send_cors_headers()
                    self.send_header('Content-Type', 'image/png')
                    self.send_header('Content-Length', len(result_img))
                    self.end_headers()
                    self.wfile.write(result_img)
                    return True
                else:
                    error_msg = resp.text[:200] if resp.text else str(resp.status_code)
                    print(f"[IOPaint] inpaint失败({resp.status_code}): {error_msg}", flush=True)
                    return False
            
            def fallback_inpaint():
                """保守回退 - 处理边缘窄条带区域"""
                w, h = img.size
                mask = Image.new('L', img.size, 0)
                mask_draw = ImageDraw.Draw(mask)
                # 边缘区域（水印常见位置）
                corner_w = int(w * 0.18)
                corner_h = int(h * 0.18)
                bottom_h = int(h * 0.10)
                top_h = int(h * 0.08)
                mask_draw.rectangle([0, 0, corner_w, corner_h], fill=255)
                mask_draw.rectangle([w - corner_w, 0, w, corner_h], fill=255)
                mask_draw.rectangle([0, h - corner_h, corner_w, h], fill=255)
                mask_draw.rectangle([w - corner_w, h - corner_h, w, h], fill=255)
                mask_draw.rectangle([0, h - bottom_h, w, h], fill=255)
                mask_draw.rectangle([0, 0, w, top_h], fill=255)
                print(f"[Fallback] 边缘条带inpaint ({w}x{h})", flush=True)
                resp = do_inpaint(mask)
                if send_result(resp):
                    print(f"[Fallback] 去水印完成", flush=True)
                else:
                    self._json_response(500, {'error': f'IOPaint去水印失败: {resp.status_code}'})
            
            def auto_detect_watermark():
                """自动检测水印区域 - 增强版：更精准的边缘检测 + 扩大检测范围
                """
                try:
                    import cv2
                    import numpy as np
                    
                    img_array = np.array(img)
                    gray = cv2.cvtColor(img_array, cv2.COLOR_RGB2GRAY)
                    h, w = gray.shape
                    
                    # 1. 更敏感的边缘检测
                    edges = cv2.Canny(gray, 30, 120)
                    
                    # 2. 适中的膨胀
                    kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (9, 9))
                    edges_dilated = cv2.dilate(edges, kernel, iterations=2)
                    
                    mask_array = np.zeros_like(gray)
                    
                    # 3. 扩大检测区域
                    edge_regions = [
                        ('top', 0, int(h * 0.12), 0, w, 0.03),
                        ('bottom', int(h * 0.80), h, 0, w, 0.03),
                        ('left', 0, h, 0, int(w * 0.15), 0.03),
                        ('right', 0, h, int(w * 0.85), w, 0.03),
                    ]
                    
                    for name, y1, y2, x1, x2, threshold in edge_regions:
                        region = edges_dilated[y1:y2, x1:x2]
                        if region.size == 0:
                            continue
                        density = np.sum(region > 0) / region.size
                        if density > threshold:
                            mask_array[y1:y2, x1:x2] = cv2.bitwise_or(mask_array[y1:y2, x1:x2], region)
                            print(f"[Auto] {name}边缘检测到文字/水印 密度={density:.2%}", flush=True)
                    
                    # 4. 四角区域检测
                    corner_w = int(w * 0.20)
                    corner_h = int(h * 0.20)
                    corners = [
                        (0, 0, corner_w, corner_h),
                        (w - corner_w, 0, w, corner_h),
                        (0, h - corner_h, corner_w, h),
                        (w - corner_w, h - corner_h, w, h),
                    ]
                    for cx1, cy1, cx2, cy2 in corners:
                        region = edges_dilated[cy1:cy2, cx1:cx2]
                        if region.size == 0:
                            continue
                        density = np.sum(region > 0) / region.size
                        if density > 0.06:
                            mask_array[cy1:cy2, cx1:cx2] = cv2.bitwise_or(mask_array[cy1:cy2, cx1:cx2], region)
                            print(f"[Auto] 角落水印: ({cx1},{cy1})-({cx2},{cy2}) 密度={density:.2%}", flush=True)
                    
                    # 5. 连通域过滤
                    num_labels, labels, stats, centroids = cv2.connectedComponentsWithStats(mask_array, connectivity=8)
                    refined_mask = np.zeros_like(mask_array)
                    min_area = 15
                    max_area = int(h * w * 0.08)
                    for i in range(1, num_labels):
                        area = stats[i, cv2.CC_STAT_AREA]
                        if min_area <= area <= max_area:
                            refined_mask[labels == i] = 255
                    
                    # 6. 适当膨胀让mask覆盖完整文字
                    refined_mask = cv2.dilate(refined_mask, kernel, iterations=2)
                    
                    if np.sum(refined_mask > 0) > 0:
                        mask_image = Image.fromarray(refined_mask, mode='L')
                        print(f"[Auto] 检测到水印区域，执行inpaint...", flush=True)
                        resp = do_inpaint(mask_image, radius=5)
                        if send_result(resp):
                            print(f"[Auto] 自动去水印完成", flush=True)
                            return True
                        else:
                            print(f"[Auto] inpaint失败，回退", flush=True)
                            fallback_inpaint()
                            return True
                    else:
                        print(f"[Auto] 未检测到明显水印，使用边缘回退", flush=True)
                        fallback_inpaint()
                        return True
                        
                except Exception as e:
                    print(f"[Auto] 自动检测异常: {e}，回退", flush=True)
                    import traceback
                    traceback.print_exc()
                    fallback_inpaint()
                    return True
            
            # === 主逻辑 ===
            
            # 策略1: 无描述词 → 自动检测（不依赖Ollama，快速）
            if not description:
                print(f"[v8.0] 无描述词，自动检测水印 ({img_w}x{img_h})", flush=True)
                auto_detect_watermark()
                return
            
            # 策略2: 有描述词 → Ollama vision精准识别（30s超时）
            print(f"[v8.0] 有描述词，Ollama vision识别: {description[:60]}...", flush=True)
            
            img_base64 = base64.b64encode(image_data).decode()
            
            vision_prompt = f"""You are a watermark detection expert. Look at this image ({img_w}x{img_h} pixels) and find: {description}.

A watermark is any text, logo, icon, pattern, or semi-transparent overlay that was added to the original image. Common locations: bottom-right, bottom-left, top-right, top-left corners, or centered.

Return ONLY a valid JSON object (no markdown, no explanation) with the bounding box in pixel coordinates:
{{"found": true, "x": <int>, "y": <int>, "width": <int>, "height": <int>}}

If you find multiple watermarks, return the bounding box that covers ALL of them combined.
If absolutely no watermark exists, return: {{"found": false}}

Coordinates must be within the image boundaries (0-{img_w-1}, 0-{img_h-1})."""
            
            try:
                vision_resp = requests.post(
                    'http://127.0.0.1:11434/api/generate',
                    json={
                        'model': 'qwen2.5vl:7b',
                        'prompt': vision_prompt,
                        'images': [img_base64],
                        'stream': False,
                        'options': {'temperature': 0.1, 'num_predict': 256},
                    },
                    timeout=30  # v8.0: 缩短到30s，减少代理被杀风险
                )
                
                if vision_resp.status_code != 200:
                    print(f"[Detect] Ollama失败({vision_resp.status_code})，回退到自动检测", flush=True)
                    auto_detect_watermark()
                    return
                
                vision_result = vision_resp.json().get('response', '{}')
                print(f"[Detect] Ollama返回: {vision_result[:200]}", flush=True)
                
                # 解析bbox
                cleaned = vision_result.strip()
                if cleaned.startswith('```'):
                    cleaned = re.sub(r'^```(?:json)?\s*\n?', '', cleaned)
                    cleaned = re.sub(r'\n?```\s*$', '', cleaned)
                
                json_match = re.search(r'\{[\s\S]*?\}', cleaned)
                bbox = {"found": False}
                if json_match:
                    try:
                        bbox = json_mod.loads(json_match.group())
                        if not all(k in bbox for k in ['found']):
                            bbox = {"found": False}
                        if bbox.get('found') and not all(k in bbox for k in ['x', 'y', 'width', 'height']):
                            bbox = {"found": False}
                    except (json_mod.JSONDecodeError, ValueError):
                        bbox = {"found": False}
                
                if not bbox.get('found'):
                    print(f"[Detect] 未识别到水印，回退到自动检测", flush=True)
                    auto_detect_watermark()
                    return
                
                x, y, w, h = int(bbox['x']), int(bbox['y']), int(bbox['width']), int(bbox['height'])
                pad_w, pad_h = int(w * 0.2), int(h * 0.2)
                x = max(0, x - pad_w)
                y = max(0, y - pad_h)
                w = min(img.width - x, w + pad_w * 2)
                h = min(img.height - y, h + pad_h * 2)
                
                print(f"[Detect] 水印区域: x={x}, y={y}, w={w}, h={h}", flush=True)
                
                mask = Image.new('L', img.size, 0)
                mask_draw = ImageDraw.Draw(mask)
                mask_draw.rectangle([x, y, x + w, y + h], fill=255)
                
                resp = do_inpaint(mask)
                if send_result(resp):
                    print(f"[Detect] 精准去水印完成", flush=True)
                    return
                else:
                    print(f"[Detect] inpaint失败，回退", flush=True)
                    fallback_inpaint()
                    return
                    
            except requests.exceptions.Timeout:
                print(f"[Detect] Ollama超时(30s)，回退到自动检测", flush=True)
                auto_detect_watermark()
            except Exception as e:
                print(f"[Detect] 检测失败: {e}，回退到自动检测", flush=True)
                import traceback
                traceback.print_exc()
                auto_detect_watermark()
            
        except Exception as e:
            print(f"[IOPaint] 去水印失败: {e}", flush=True)
            import traceback
            traceback.print_exc()
            try:
                self._json_response(500, {'error': f'去水印失败: {str(e)}'})
            except:
                pass
    
    def _handle_iopaint_inpaint_mask(self):
        """局部修复 - 接受图片和用户圈选的Mask进行精准去水印
        前端传递: image (原始图) + mask (用户绘制的修复区域，白色=修复，黑色=保留)
        """
        try:
            fields, files = self._parse_multipart_form()
            
            if not files or 'image' not in files:
                self._json_response(400, {'error': '未上传图片'})
                return
            
            image_data = files['image']
            if isinstance(image_data, tuple):
                _, image_data = image_data
            
            # 解析 mask（用户圈选的修复区域）
            mask_data = None
            if 'mask' in files:
                mask_data = files['mask']
                if isinstance(mask_data, tuple):
                    _, mask_data = mask_data
            
            # 如果没有 mask，使用保守的全图清理策略
            if mask_data is None:
                # 返回全图轻度修复
                mask_data = files.get('mask', (None, None))
                if isinstance(mask_data, tuple):
                    mask_data = mask_data[1] if len(mask_data) > 1 else None
            
            import base64
            from PIL import Image
            import io
            import requests
            
            # 处理原图
            img = Image.open(io.BytesIO(image_data))
            if img.mode != 'RGB':
                img = img.convert('RGB')
            buf = io.BytesIO()
            img.save(buf, format='PNG')
            img_base64 = base64.b64encode(buf.getvalue()).decode('utf-8')
            
            # 处理 Mask
            if mask_data:
                mask_img = Image.open(io.BytesIO(mask_data))
                if mask_img.mode != 'L':
                    mask_img = mask_img.convert('L')
                # 确保 mask 尺寸和原图一致
                if mask_img.size != img.size:
                    mask_img = mask_img.resize(img.size, Image.NEAREST)
                mask_buf = io.BytesIO()
                mask_img.save(mask_buf, format='PNG')
                mask_base64 = base64.b64encode(mask_buf.getvalue()).decode('utf-8')
            else:
                # 无 mask → 创建保守的轻度全图mask（让cv2做智能处理）
                mask = Image.new('L', img.size, 255)
                mask_buf = io.BytesIO()
                mask.save(mask_buf, format='PNG')
                mask_base64 = base64.b64encode(mask_buf.getvalue()).decode('utf-8')
            
            inpaint_payload = {
                "image": img_base64,
                "mask": mask_base64,
                "cv2_flag": "INPAINT_NS",
                "cv2_radius": 3,
            }
            
            resp = requests.post(
                'http://127.0.0.1:8080/api/v1/inpaint',
                json=inpaint_payload,
                timeout=120
            )
            
            if resp.status_code == 200:
                result_img = resp.content
                self.send_response(200)
                self._send_cors_headers()
                self.send_header('Content-Type', 'image/png')
                self.send_header('Content-Length', len(result_img))
                self.end_headers()
                self.wfile.write(result_img)
            else:
                print(f"[IOPaint] 局部Inpaint 失败({resp.status_code}): {resp.text[:200]}", flush=True)
                self._json_response(500, {'error': f'IOPaint 局部修复失败: {resp.status_code}'})
                
        except Exception as e:
            print(f"[IOPaint] 局部修复失败: {e}", flush=True)
            self._json_response(500, {'error': f'局部修复失败: {str(e)}'})
    
    def _handle_iopaint_brush_inpaint(self):
        """v9.0: 画笔涂抹去水印 - 接受图片+用户涂抹mask，智能修复
        处理流程:
        1. 接收图片 + 用户涂抹的mask（白色=水印区域）
        2. mask膨胀 + 边缘羽化，确保完整覆盖水印
        3. 仅在被mask标记的区域执行IOPaint inpaint
        4. 图片其余内容100%保持不变
        """
        try:
            fields, files = self._parse_multipart_form()
            
            if not files or 'image' not in files:
                self._json_response(400, {'error': '未上传图片'})
                return
            
            image_data = files['image']
            if isinstance(image_data, tuple):
                _, image_data = image_data
            
            mask_data = None
            if 'mask' in files:
                mask_data = files['mask']
                if isinstance(mask_data, tuple):
                    _, mask_data = mask_data
            
            if mask_data is None:
                self._json_response(400, {'error': '未提供画笔mask'})
                return
            
            import base64
            from PIL import Image, ImageFilter
            import io
            import requests
            
            # 处理原图
            img = Image.open(io.BytesIO(image_data))
            if img.mode != 'RGB':
                img = img.convert('RGB')
            img_w, img_h = img.size
            buf = io.BytesIO()
            img.save(buf, format='PNG')
            img_base64 = base64.b64encode(buf.getvalue()).decode('utf-8')
            
            # 处理 Mask
            mask_img = Image.open(io.BytesIO(mask_data))
            if mask_img.mode != 'L':
                mask_img = mask_img.convert('L')
            if mask_img.size != img.size:
                mask_img = mask_img.resize(img.size, Image.NEAREST)
            
            # 计算mask覆盖率
            import numpy as np
            mask_arr = np.array(mask_img)
            mask_pixels = np.sum(mask_arr > 0)
            mask_ratio = mask_pixels / (img_w * img_h)
            
            print(f"[BrushInpaint] Mask覆盖率: {mask_ratio:.2%} ({mask_pixels}px / {img_w}x{img_h})", flush=True)
            
            # 智能mask处理（v12.0 改进：精准修复，不改变图片内容）:
            # 1. 轻微膨胀: 仅1-2px，确保完整覆盖水印边缘
            # 2. 轻量羽化: 保留边缘细节，避免过度修复
            # 3. 使用INPAINT_TELEA: 基于Navier-Stokes，更好保留边缘结构
            dilate_px = max(1, int(min(img_w, img_h) * 0.001))  # 约0.1%图片尺寸（更精准）
            
            if mask_pixels > 0:
                # 使用cv2进行膨胀（更好的形态学处理）
                try:
                    import cv2
                    kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (dilate_px*2+1, dilate_px*2+1))
                    dilated = cv2.dilate(mask_arr, kernel, iterations=1)
                    
                    # 羽化: 对膨胀后的mask做高斯模糊
                    feather_radius = max(1, dilate_px)
                    dilated = cv2.GaussianBlur(dilated, (feather_radius*2+1, feather_radius*2+1), 0)
                    
                    # 阈值化：模糊后的像素>20视为修复区域（保留软边缘）
                    _, dilated = cv2.threshold(dilated, 20, 255, cv2.THRESH_BINARY)
                    
                    mask_img = Image.fromarray(dilated, mode='L')
                    print(f"[BrushInpaint] mask膨胀{dilate_px}px + 羽化{feather_radius}px", flush=True)
                except ImportError:
                    # 纯PIL回退
                    mask_img = mask_img.filter(ImageFilter.MaxFilter(dilate_px*2+1))
                    mask_img = mask_img.filter(ImageFilter.GaussianBlur(dilate_px))
                    print(f"[BrushInpaint] PIL fallback: MaxFilter({dilate_px*2+1}) + GaussianBlur({dilate_px})", flush=True)
            
            mask_buf = io.BytesIO()
            mask_img.save(mask_buf, format='PNG')
            mask_base64 = base64.b64encode(mask_buf.getvalue()).decode('utf-8')
            
            # 调用IOPaint inpaint - 仅修复mask区域
            # cv2_flag: INPAINT_TELEA 基于Navier-Stokes，更好保留边缘结构
            # cv2_radius: 保持小半径3，依赖精准mask定位
            inpaint_payload = {
                "image": img_base64,
                "mask": mask_base64,
                "cv2_flag": "INPAINT_TELEA",
                "cv2_radius": 3,
            }
            
            print(f"[BrushInpaint] 发送IOPaint请求 (原图{img_w}x{img_h}, mask覆盖率{mask_ratio:.1%})", flush=True)
            resp = requests.post(
                'http://127.0.0.1:8080/api/v1/inpaint',
                json=inpaint_payload,
                timeout=120
            )
            
            if resp.status_code == 200:
                result_img = resp.content
                self.send_response(200)
                self._send_cors_headers()
                self.send_header('Content-Type', 'image/png')
                self.send_header('Content-Length', len(result_img))
                self.end_headers()
                self.wfile.write(result_img)
                print(f"[BrushInpaint] 去水印完成 (仅修复涂抹区域)", flush=True)
            else:
                print(f"[BrushInpaint] IOPaint失败({resp.status_code}): {resp.text[:200]}", flush=True)
                self._json_response(500, {'error': f'IOPaint修复失败: {resp.status_code}'})
                
        except Exception as e:
            print(f"[BrushInpaint] 失败: {e}", flush=True)
            import traceback
            traceback.print_exc()
            self._json_response(500, {'error': f'去水印失败: {str(e)}'})
    
    def _handle_iopaint_remove_background(self):
        """抠图 - 使用 IOPaint RemoveBG 插件 (briaai/RMBG-1.4)"""
        try:
            fields, files = self._parse_multipart_form()
            
            if not files or 'image' not in files:
                self._json_response(400, {'error': '未上传图片'})
                return
            
            filename, image_data = files['image']
            
            import base64
            import requests
            
            # 将图片转为 base64，调用 IOPaint RemoveBG 插件
            img_base64 = base64.b64encode(image_data).decode('utf-8')
            
            plugin_payload = {
                "name": "RemoveBG",
                "image": img_base64,
            }
            
            # 先尝试 IOPaint RemoveBG 插件
            resp = requests.post(
                'http://127.0.0.1:8080/api/v1/run_plugin_gen_image',
                json=plugin_payload,
                timeout=120
            )
            
            if resp.status_code == 200:
                result_data = resp.content
            else:
                # IOPaint 插件失败，回退到本地 rembg
                print(f"[IOPaint] RemoveBG插件失败({resp.status_code}): {resp.text[:200]}, 回退到本地rembg", flush=True)
                try:
                    from rembg import remove
                    result_data = remove(image_data, force_return_bytes=True)
                except ImportError:
                    self._json_response(500, {'error': '抠图服务不可用：IOPaint RemoveBG插件和rembg库均不可用'})
                    return
                except Exception as e2:
                    self._json_response(500, {'error': f'抠图失败（IOPaint: {resp.status_code}, rembg: {str(e2)}）'})
                    return
            
            self.send_response(200)
            self._send_cors_headers()
            self.send_header('Content-Type', 'image/png')
            self.send_header('Content-Length', len(result_data))
            self.end_headers()
            self.wfile.write(result_data)
            
        except Exception as e:
            print(f"[IOPaint] 抠图失败: {e}", flush=True)
            self._json_response(500, {'error': f'抠图失败: {str(e)}'})
    
    def _handle_voice_save(self):
        """POST /api/voice/save - 保存 TTS 生成的音频文件"""
        user = self._require_auth()
        if not user:
            return
        
        try:
            fields, files = self._parse_multipart_form()
            
            if not files or 'audio' not in files:
                self._json_response(400, {'error': '未上传音频文件'})
                return
            
            filename, audio_data = files['audio']
            # 用时间戳生成唯一文件名
            import time
            ts = int(time.time())
            ext = os.path.splitext(filename)[1] or '.wav'
            save_filename = f"{user['username']}_{ts}{ext}"
            
            # 保存到用户目录
            voice_dir = os.path.join(DATA_DIR, 'voices', user['username'])
            os.makedirs(voice_dir, exist_ok=True)
            save_path = os.path.join(voice_dir, save_filename)
            
            with open(save_path, 'wb') as f:
                f.write(audio_data)
            
            # 返回访问 URL
            file_url = f"/api/voice/file/{user['username']}/{save_filename}"
            self._json_response(200, {
                'success': True,
                'url': file_url,
                'filename': save_filename,
                'size': len(audio_data)
            })
            print(f"[Voice] 保存音频: {save_path}", flush=True)
            
        except Exception as e:
            print(f"[Voice] 保存音频失败: {e}", flush=True)
            self._json_response(500, {'error': f'保存音频失败: {str(e)}'})
    
    def _handle_voice_file(self):
        """GET /api/voice/file/{username}/{filename} - 提供保存的音频文件"""
        try:
            # 解析路径: /api/voice/file/{username}/{filename}
            import urllib.parse
            path_parts = self.path.split('/')
            if len(path_parts) < 6:
                self.send_error(400, 'Invalid path')
                return
            
            username = path_parts[4]
            filename = '/'.join(path_parts[5:])  # 支持文件名中有 '/' 的情况（实际上不会有）
            filename = urllib.parse.unquote(filename)
            
            # 安全检查：防止路径遍历
            if '..' in filename or filename.startswith('/'):
                self.send_error(403, 'Forbidden')
                return
            
            file_path = os.path.join(DATA_DIR, 'voices', username, filename)
            if not os.path.isfile(file_path):
                self.send_error(404, 'File not found')
                return
            
            # 验证当前用户只能访问自己的文件（或管理员）
            user = self._get_auth_user()
            if user and (user['username'] == username or user.get('role') == 'admin'):
                # 允许访问
                pass
            else:
                # 未登录或访问其他用户文件
                self.send_response(403)
                self._send_cors_headers()
                self.send_header('Content-Type', 'application/json')
                self.end_headers()
                self.wfile.write(json.dumps({'error': '无权限访问此文件'}, ensure_ascii=False).encode())
                return
            
            # 发送文件
            ext = os.path.splitext(filename)[1].lower()
            mime_types = {
                '.wav': 'audio/wav',
                '.mp3': 'audio/mpeg',
                '.ogg': 'audio/ogg',
                '.flac': 'audio/flac',
            }
            content_type = mime_types.get(ext, 'application/octet-stream')
            
            with open(file_path, 'rb') as f:
                file_data = f.read()
            
            self.send_response(200)
            self._send_cors_headers()
            self.send_header('Content-Type', content_type)
            self.send_header('Content-Length', len(file_data))
            self.send_header('Accept-Ranges', 'bytes')
            self.end_headers()
            self.wfile.write(file_data)
            
        except Exception as e:
            print(f"[Voice] 提供音频文件失败: {e}", flush=True)
            self.send_error(500, str(e))
    
    def _send_cors_headers(self):
        self.send_header('Access-Control-Allow-Origin', '*')
        self.send_header('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS')
        self.send_header('Access-Control-Allow-Headers', 'Content-Type, Authorization, Content-Length, X-Requested-With')
        self.send_header('Access-Control-Max-Age', '86400')
    
    def _proxy_request(self, method, user=None):
        """代理请求到后端服务
        
        Args:
            method: HTTP 方法 (GET/POST/DELETE)
            user:   认证用户信息 dict (可选)，含 username/display_name/role
                    如果提供，会通过 X-Auth-User 头传给后端
        """
        backend_url = None
        
        for prefix, target in FULL_PATH_ROUTES.items():
            if self.path.startswith(prefix):
                # 数字人路由：自动插入 /v2（如果路径中没有的话）
                # 前端: /api/digital-human/xxx → 后端: /api/digital-human/v2/xxx
                if prefix == '/api/digital-human' and '/v2' not in self.path:
                    new_path = self.path.replace('/api/digital-human', '/api/digital-human/v2', 1)
                    backend_url = f"{target}{new_path}"
                else:
                    backend_url = f"{target}{self.path}"
                break
        
        if not backend_url:
            for prefix, target in PROXY_ROUTES.items():
                if self.path.startswith(prefix):
                    sub_path = self.path[len(prefix):]
                    backend_url = f"{target}/{sub_path}"
                    break
        
        if not backend_url:
            self.send_error(404, f"No route for {self.path}")
            return
        
        max_retries = 2
        last_error = None
        
        for attempt in range(max_retries + 1):
            try:
                content_length = int(self.headers.get('Content-Length', 0))
                body = self.rfile.read(content_length) if content_length > 0 else None
                
                # 转发关键请求头（Range 对视频播放seek至关重要）
                fwd_headers = {}
                for h in ['Content-Type', 'Authorization', 'Accept', 'User-Agent', 'Range']:
                    v = self.headers.get(h)
                    if v:
                        fwd_headers[h] = v
                
                # 注入用户身份（数字人后端用于数据隔离）
                if user:
                    fwd_headers['X-Auth-User'] = user.get('username', '')
                    fwd_headers['X-Auth-Role'] = user.get('role', 'user')
                
                req = urllib.request.Request(
                    backend_url,
                    data=body,
                    method=method,
                    headers=fwd_headers
                )
                
                # 根据路由类型设置超时：
                # - GPT-SoVITS 语音合成：长文本可达数分钟，300s
                # - 数字人生成(/generate, /generate-batch)：提交任务后台异步，但 GPT-SoVITS 是同步调用，需 300s
                # - 其他数字人 API（状态查询/列表等）：30s 足够
                # - 其他后端（ComfyUI, IOPaint 等）：120s
                if '/api/gptsovits' in self.path:
                    proxy_timeout = 600  # v7.3: 匹配 GPT-SoVITS 长文本合成最大超时
                elif '/api/digital-human' in self.path and ('generate' in self.path or 'train' in self.path or 'voice' in self.path or 'task' in self.path):
                    proxy_timeout = 600  # v8.3: Duix 视频生成可达3-5分钟，增加到600s
                elif '/api/comfyui' in self.path or '/api/iopaint' in self.path:
                    proxy_timeout = 120
                else:
                    proxy_timeout = 30
                
                with urllib.request.urlopen(req, timeout=proxy_timeout) as resp:
                    response_body = resp.read()
                    self.send_response(resp.status)
                    # 转发关键响应头（Range请求/视频播放需要Content-Range/Accept-Ranges）
                    for rh in ['Content-Type', 'Content-Length', 'Content-Range', 'Accept-Ranges',
                               'Content-Disposition', 'Cache-Control', 'ETag', 'Last-Modified']:
                        v = resp.headers.get(rh)
                        if v:
                            self.send_header(rh, v)
                    self._send_cors_headers()
                    # Content-Length: 确保用实际读取的 body 长度（可能处理过chunked transfer）
                    if 'Content-Length' not in {k for k, _ in resp.headers.items()}:
                        self.send_header('Content-Length', len(response_body))
                    self.end_headers()
                    self.wfile.write(response_body)
                    return
                    
            except urllib.error.HTTPError as e:
                self.send_response(e.code)
                self._send_cors_headers()
                self.end_headers()
                if e.fp:
                    self.wfile.write(e.fp.read())
                return
                
            except (urllib.error.URLError, ConnectionRefusedError, OSError) as e:
                last_error = e
                if attempt < max_retries:
                    time.sleep(0.5)
                    continue
            
            except Exception as e:
                last_error = e
                if attempt < max_retries:
                    time.sleep(0.5)
                    continue
        
        error_msg = str(last_error.reason) if hasattr(last_error, 'reason') else str(last_error)
        self.send_response(502)
        self._send_cors_headers()
        self.end_headers()
        self.wfile.write(json.dumps({
            'error': f'Backend unreachable: {error_msg}',
            'backend': backend_url,
        }).encode())
    
    def log_message(self, format, *args):
        """简化日志"""
        msg = args[0] if args else ''
        if '/api/' in str(msg):
            print(f"[PROXY] {msg}", flush=True)
        elif '200' not in str(msg):
            print(f"[PROXY] {msg}", flush=True)


    # ===== Knowledge Management API =====
    def _handle_knowledge_upload(self):
        """POST /api/knowledge/upload"""
        try:
            fields, files = self._parse_multipart_form()
            if not files or 'file' not in files:
                self._json_response(400, {"error": "No file"})
                return
            file_info = files['file']
            # v11.8: _parse_multipart_form returns tuple (filename, content)
            if isinstance(file_info, tuple):
                filename = file_info[0]
                file_data = file_info[1]
            else:
                # 兼容旧格式（dict）
                filename = file_info.get('filename', 'unknown')
                file_data = file_info.get('data', b'')
            import time, os
            timestamp = int(time.time())
            safe_filename = f"{timestamp}_{filename}"
            
            # 根据文件扩展名分类存储
            ext = os.path.splitext(filename)[1].lower()
            type_dirs = {
                '.jpg': 'images', '.jpeg': 'images', '.png': 'images', '.gif': 'images', '.bmp': 'images', '.webp': 'images',
                '.mp4': 'videos', '.avi': 'videos', '.mov': 'videos', '.wmv': 'videos', '.flv': 'videos',
                '.doc': 'docs', '.docx': 'docs', '.pdf': 'docs', '.txt': 'docs', '.md': 'docs',
                '.xls': 'sheets', '.xlsx': 'sheets', '.csv': 'sheets',
                '.ppt': 'slides', '.pptx': 'slides',
            }
            subdir = type_dirs.get(ext, 'misc')
            base_dir = f"D:/AI-Ad-Agent/portal/data/knowledge_uploads/{subdir}"
            os.makedirs(base_dir, exist_ok=True)
            filepath = f"{base_dir}/{safe_filename}"
            
            with open(filepath, 'wb') as f:
                f.write(file_data)
            db_path = "D:/AI-Ad-Agent/portal/data/knowledge.db"
            conn = sqlite3.connect(db_path)
            c = conn.cursor()
            category = fields.get('category', ['misc'])[0] if isinstance(fields.get('category'), list) else fields.get('category', 'misc')
            owner = fields.get('owner', [None])[0] if isinstance(fields.get('owner'), list) else fields.get('owner', None)
            c.execute('INSERT INTO files (filename, filepath, filetype, category, filesize, metadata, owner) VALUES (?,?,?,?,?,?,?)', 
                     (filename, filepath, filename.split('.')[-1], category, len(file_data), '', owner))
            file_id = c.lastrowid
            conn.commit()
            conn.close()
            self._json_response(200, {"success": True, "file_id": file_id})
        except Exception as e:
            self._json_response(500, {"error": str(e)})
    
    def _handle_knowledge_list(self):
        """GET /api/knowledge/list?category=xxx&owner=xxx"""
        try:
            from urllib.parse import urlparse, parse_qs
            parsed = urlparse(self.path)
            params = parse_qs(parsed.query)
            owner_filter = params.get('owner', [None])[0]
            category_filter = params.get('category', [None])[0]
            
            db_path = "D:/AI-Ad-Agent/portal/data/knowledge.db"
            conn = sqlite3.connect(db_path)
            c = conn.cursor()
            
            # 构建查询条件
            conditions = []
            values = []
            if owner_filter:
                conditions.append('owner=?')
                values.append(owner_filter)
            if category_filter:
                conditions.append('category=?')
                values.append(category_filter)
            
            if conditions:
                query = f"SELECT * FROM files WHERE {' AND '.join(conditions)} ORDER BY upload_time DESC"
            else:
                query = 'SELECT * FROM files ORDER BY upload_time DESC'
            
            c.execute(query, values)
            rows = c.fetchall()
            columns = [desc[0] for desc in c.description]
            # 安全序列化：把非 JSON 可序列化的值转成字符串
            safe_files = []
            for row in rows:
                safe_row = {}
                for col, val in zip(columns, row):
                    if isinstance(val, (str, int, float, bool)) or val is None:
                        safe_row[col] = val
                    else:
                        safe_row[col] = str(val)
                safe_files.append(safe_row)
            conn.close()
            self._json_response(200, {"success": True, "files": safe_files})
        except Exception as e:
            try:
                self._json_response(500, {"error": str(e)})
            except Exception:
                pass
    


    def _handle_knowledge_search(self):
        """GET /api/knowledge/search?q=xxx&category=yyy — 搜索知识库"""
        try:
            from urllib.parse import urlparse, parse_qs
            import sqlite3, json
            
            parsed = urlparse(self.path)
            params = parse_qs(parsed.query)
            query = params.get('q', [''])[0].strip()
            category_filter = params.get('category', [None])[0]
            owner_filter = params.get('owner', [None])[0]
            
            if not query:
                self._json_response(400, {"error": "Missing search query"})
                return
            
            db_path = "D:/AI-Ad-Agent/portal/data/knowledge.db"
            conn = sqlite3.connect(db_path)
            c = conn.cursor()
            
            # 只搜索必要字段，不读取 content 列（可能包含二进制数据）
            search_term = f"%{query}%"
            conditions = ['(filename LIKE ? OR metadata LIKE ? OR category LIKE ?)']
            values = [search_term, search_term, search_term]
            
            if category_filter:
                conditions.append('category=?')
                values.append(category_filter)
            if owner_filter:
                conditions.append('owner=?')
                values.append(owner_filter)
            
            # 只选择安全列，避免 content 列的二进制数据
            safe_columns = 'id, filename, filepath, filetype, category, upload_time, filesize, metadata, owner'
            sql = f"SELECT {safe_columns} FROM files WHERE {' AND '.join(conditions)} ORDER BY upload_time DESC LIMIT 50"
            c.execute(sql, values)
            rows = c.fetchall()
            columns = [desc[0] for desc in c.description]
            
            safe_files = []
            for row in rows:
                safe_row = {}
                for col, val in zip(columns, row):
                    if isinstance(val, (str, int, float, bool)) or val is None:
                        safe_row[col] = val
                    else:
                        safe_row[col] = str(val)
                safe_files.append(safe_row)
            conn.close()
            
            # 手动序列化，确保正确处理中文和特殊字符
            response_data = json.dumps({
                "success": True,
                "files": safe_files,
                "query": query,
                "total": len(safe_files)
            }, ensure_ascii=False).encode('utf-8')
            
            self.send_response(200)
            self.send_header('Content-Type', 'application/json; charset=utf-8')
            self.send_header('Content-Length', str(len(response_data)))
            self.end_headers()
            self.wfile.write(response_data)
        except Exception as e:
            import traceback
            traceback.print_exc()
            try:
                self._json_response(500, {"error": str(e)})
            except Exception:
                pass
    
    def _handle_knowledge_preview(self):
        """GET /api/knowledge/preview?id=xxx — 预览上传的文件（通过ID）"""
        try:
            from urllib.parse import urlparse, parse_qs
            parsed = urlparse(self.path)
            params = parse_qs(parsed.query)
            file_id = params.get('id', [None])[0]
            if not file_id:
                self._json_response(400, {"error": "Missing file id"})
                return
            
            # 从数据库读取文件路径
            db_path = "D:/AI-Ad-Agent/portal/data/knowledge.db"
            conn = sqlite3.connect(db_path)
            c = conn.cursor()
            c.execute('SELECT filepath, filename FROM files WHERE id = ?', (file_id,))
            row = c.fetchone()
            conn.close()
            
            if not row:
                self._json_response(404, {"error": "File not found in database"})
                return
            
            filepath = row[0]
            original_filename = row[1]
            
            # 安全校验：只允许预览 knowledge_uploads 目录下的文件
            allowed_dir = os.path.normpath("D:/AI-Ad-Agent/portal/data/knowledge_uploads")
            if not os.path.normpath(filepath).startswith(allowed_dir):
                self._json_response(403, {"error": "Access denied"})
                return
            
            if not os.path.exists(filepath):
                self._json_response(404, {"error": f"File not found on disk: {original_filename}"})
                return
            
            # 根据文件类型设置 Content-Type
            ext = os.path.splitext(filepath)[1].lower()
            mime_types = {
                '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png',
                '.gif': 'image/gif', '.bmp': 'image/bmp', '.webp': 'image/webp',
                '.mp4': 'video/mp4', '.avi': 'video/x-msvideo', '.mov': 'video/quicktime',
                '.pdf': 'application/pdf',
                '.doc': 'application/msword', '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
                '.xls': 'application/vnd.ms-excel', '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
                '.ppt': 'application/vnd.ms-powerpoint', '.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
                '.txt': 'text/plain', '.md': 'text/markdown',
            }
            content_type = mime_types.get(ext, 'application/octet-stream')
            
            with open(filepath, 'rb') as f:
                file_data = f.read()
            
            self.send_response(200)
            self.send_header('Content-Type', content_type)
            self.send_header('Content-Length', len(file_data))
            self.send_header('Cache-Control', 'public, max-age=3600')
            self.end_headers()
            self.wfile.write(file_data)
        except Exception as e:
            self._json_response(500, {"error": str(e)})
    
    def _handle_knowledge_download(self):
        """GET /api/knowledge/download?id=xxx — 下载上传的文件"""
        try:
            from urllib.parse import urlparse, parse_qs
            parsed = urlparse(self.path)
            params = parse_qs(parsed.query)
            file_id = params.get('id', [None])[0]
            if not file_id:
                self._json_response(400, {"error": "Missing file id"})
                return
            
            # 从数据库读取文件路径
            db_path = "D:/AI-Ad-Agent/portal/data/knowledge.db"
            conn = sqlite3.connect(db_path)
            c = conn.cursor()
            c.execute('SELECT filepath, filename FROM files WHERE id = ?', (file_id,))
            row = c.fetchone()
            conn.close()
            
            if not row:
                self._json_response(404, {"error": "File not found in database"})
                return
            
            filepath = row[0]
            original_filename = row[1]
            
            if not os.path.exists(filepath):
                self._json_response(404, {"error": f"File not found on disk: {original_filename}"})
                return
            
            # 读取文件并返回（设置 Content-Disposition 为 attachment）
            with open(filepath, 'rb') as f:
                file_data = f.read()
            
            self.send_response(200)
            self.send_header('Content-Type', 'application/octet-stream')
            self.send_header('Content-Disposition', f'attachment; filename="{original_filename}"')
            self.send_header('Content-Length', len(file_data))
            self.end_headers()
            self.wfile.write(file_data)
        except Exception as e:
            self._json_response(500, {"error": str(e)})
    

    def _handle_knowledge_delete(self):
        "DELETE /api/knowledge/delete?id=xxx"
        try:
            from urllib.parse import urlparse, parse_qs
            import os, sqlite3
            parsed = urlparse(self.path)
            params = parse_qs(parsed.query)
            file_id = params.get('id', [None])[0]
            if not file_id:
                self._json_response(400, {"error": "Missing id"})
                return
            
            db_path = "D:/AI-Ad-Agent/portal/data/knowledge.db"
            conn = sqlite3.connect(db_path)
            c = conn.cursor()
            
            # 检查文件是否存在及owner
            c.execute('SELECT filepath, owner FROM files WHERE id=?', (file_id,))
            row = c.fetchone()
            if not row:
                self._json_response(404, {"error": "File not found"})
                conn.close()
                return
            
            filepath, owner = row
            # TODO: 从请求头获取当前用户，这里简单跳过权限检查
            # 前端会发送 Authorization 头，但这里解析较复杂，暂时允许删除
            
            if filepath and os.path.exists(filepath):
                try:
                    os.remove(filepath)
                except Exception:
                    pass
            c.execute('DELETE FROM files WHERE id=?', (file_id,))
            conn.commit()
            conn.close()
            self._json_response(200, {"success": True})
        except Exception as e:
            self._json_response(500, {"error": str(e)})
    
    def _handle_knowledge_generate(self):
        """POST /api/knowledge/generate — 基于上传文件生成知识"""
        try:
            import json, os, sqlite3, requests
            from urllib.parse import urlparse, parse_qs
            
            # 尝试从POST body读取JSON参数
            file_id = None
            content_length = int(self.headers.get('Content-Length', 0))
            if content_length > 0:
                try:
                    post_data = self.rfile.read(content_length)
                    post_json = json.loads(post_data.decode('utf-8'))
                    file_id = post_json.get('file_id') or post_json.get('id')
                except Exception:
                    pass
            
            # 如果POST body没有，尝试从GET参数读取
            if not file_id:
                parsed = urlparse(self.path)
                params = parse_qs(parsed.query)
                file_id = params.get('id', [None])[0]
            
            if not file_id:
                self._json_response(400, {"error": "Missing file id"})
                return
            
            # 从数据库读取文件
            db_path = "D:/AI-Ad-Agent/portal/data/knowledge.db"
            conn = sqlite3.connect(db_path)
            c = conn.cursor()
            c.execute('SELECT filepath, filename, filetype, category FROM files WHERE id = ?', (file_id,))
            row = c.fetchone()
            conn.close()
            
            if not row:
                self._json_response(404, {"error": "File not found"})
                return
            
            filepath, filename, filetype, category = row
            
            if not os.path.exists(filepath):
                self._json_response(404, {"error": f"File not found on disk: {filename}"})
                return
            
            # 读取文件内容（根据类型）
            content_text = ""
            extraction_method = "未知"
            try:
                if filetype in ['txt', 'md', 'csv', 'json', 'xml', 'html', 'css', 'js', 'py']:
                    with open(filepath, 'r', encoding='utf-8', errors='ignore') as f:
                        content_text = f.read()[:5000]  # 前5000字符
                    extraction_method = "文本读取"
                
                elif filetype in ['docx']:
                    try:
                        from docx import Document
                        doc = Document(filepath)
                        full_text = '\n'.join([para.text for para in doc.paragraphs if para.text.strip()])
                        # 也提取表格内容
                        table_text = []
                        for table in doc.tables:
                            for row in table.rows:
                                for cell in row.cells:
                                    if cell.text.strip():
                                        table_text.append(cell.text.strip())
                        if table_text:
                            full_text += '\n\n【表格内容】\n' + '\n'.join(table_text)
                        content_text = full_text[:5000]
                        extraction_method = "python-docx提取"
                    except Exception as de:
                        content_text = f"[Word文档: {filename}] 提取失败: {str(de)}"
                
                elif filetype in ['doc']:
                    content_text = f"[旧版Word文档(.doc): {filename}] 建议使用.docx格式上传，或安装antiword工具"
                
                elif filetype in ['pdf']:
                    try:
                        import PyPDF2
                        with open(filepath, 'rb') as f:
                            reader = PyPDF2.PdfReader(f)
                            full_text = []
                            for i, page in enumerate(reader.pages):
                                if i >= 10:  # 最多读取前10页
                                    break
                                text = page.extract_text()
                                if text and text.strip():
                                    full_text.append(text)
                            content_text = '\n'.join(full_text)[:5000]
                        extraction_method = "PyPDF2提取"
                    except Exception as pe:
                        content_text = f"[PDF文档: {filename}] 提取失败: {str(pe)}"
                
                elif filetype in ['xlsx', 'xls']:
                    try:
                        import openpyxl
                        wb = openpyxl.load_workbook(filepath, read_only=True)
                        full_text = []
                        for sheet_name in wb.sheetnames[:3]:  # 最多读3个sheet
                            sheet = wb[sheet_name]
                            full_text.append(f"【Sheet: {sheet_name}】")
                            for row_idx, row in enumerate(sheet.iter_rows(values_only=True)):
                                if row_idx >= 50:  # 最多读50行
                                    break
                                row_text = ' | '.join([str(cell) if cell is not None else '' for cell in row])
                                if row_text.strip():
                                    full_text.append(row_text)
                        content_text = '\n'.join(full_text)[:5000]
                        wb.close()
                        extraction_method = "openpyxl提取"
                    except Exception as xe:
                        content_text = f"[Excel文档: {filename}] 提取失败: {str(xe)}"
                
                elif filetype in ['pptx']:
                    try:
                        from pptx import Presentation
                        prs = Presentation(filepath)
                        full_text = []
                        for i, slide in enumerate(prs.slides):
                            if i >= 20:  # 最多读20张幻灯片
                                break
                            slide_text = []
                            for shape in slide.shapes:
                                if hasattr(shape, "text") and shape.text and shape.text.strip():
                                    slide_text.append(shape.text.strip())
                            if slide_text:
                                full_text.append(f"【幻灯片 {i+1}】")
                                full_text.extend(slide_text)
                        content_text = '\n'.join(full_text)[:5000]
                        extraction_method = "python-pptx提取"
                    except Exception as ppe:
                        content_text = f"[PPT文档: {filename}] 提取失败: {str(ppe)}"
                
                elif filetype in ['ppt']:
                    content_text = f"[旧版PPT文档(.ppt): {filename}] 建议使用.pptx格式上传"
                
                else:
                    # 对于图片、视频等二进制文件，使用文件名和类型作为内容
                    content_text = f"文件名称: {filename}\n文件类型: {filetype}\n文件分类: {category}\n这是一个需要AI分析和提取知识的文件。"
                    extraction_method = "文件名推断"
                
                # 如果内容为空，给出提示
                if not content_text or not content_text.strip():
                    content_text = f"[文件: {filename}] 提取到空内容，文件可能只有图片/格式特殊"
                    
            except Exception as e:
                content_text = f"[读取文件失败: {str(e)}] {filename}"
                extraction_method = "读取失败"
            
            # 调用 Ollama 生成知识摘要
            ollama_url = "http://127.0.0.1:11434/api/generate"
            
            # 根据文件类型调整提示词
            if filetype in ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'mp4', 'avi', 'mov', 'wmv']:
                prompt = f"用户上传了一个{filetype}文件：{filename}。请根据文件名和分类（{category}），生成这个描述性知识条目（100字以内），包括：1. 可能的用途 2. 适用的广告场景 3. 相关知识要点"
            else:
                prompt = f"请对以下内容进行知识提取和摘要（200字以内），生成结构化的知识条目：\n\n{content_text[:2000]}"
            
            try:
                resp = requests.post(ollama_url, json={
                    "model": "qwen3:8b",
                    "prompt": prompt,
                    "stream": False,
                    "think": False,
                }, timeout=120)
                if resp.status_code == 200:
                    result = resp.json().get('response', '生成失败')
                else:
                    result = f"知识库条目（{filename}）：{content_text[:200]}"  # fallback
            except Exception as e:
                print(f"[Knowledge Generate] Ollama error: {e}")
                result = f"知识库条目（{filename}）：{content_text[:200]}"
            
            # 更新数据库 metadata 字段
            conn = sqlite3.connect(db_path)
            c = conn.cursor()
            c.execute('UPDATE files SET metadata = ? WHERE id = ?', (result, file_id))
            conn.commit()
            conn.close()
            
            self._json_response(200, {"success": True, "result": result})
        except Exception as e:
            import traceback
            traceback.print_exc()
            self._json_response(500, {"error": str(e)})

class ProxyServer(ThreadingMixIn, http.server.HTTPServer):
    """多线程代理服务器，带启动时间记录"""
    daemon_threads = True
    allow_reuse_address = True
    
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.start_time = time.time()



    

if __name__ == '__main__':
    os.chdir(BASE_DIR)
    
    print("=" * 55)
    print("  正方元智能广告工作台 - 代理服务器 v2.0")
    print(f"  端口: {PORT}")
    print(f"  静态目录: {os.getcwd()}")
    print(f"  用户认证: 已启用 (users.xlsx)")
    print("=" * 55)
    
    # 检测端口是否被占用
    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    try:
        sock.bind(('0.0.0.0', PORT))
        sock.close()
    except OSError:
        print(f"\n[警告] 端口 {PORT} 已被占用，尝试强制释放...")
        if sys.platform == 'win32':
            import subprocess
            result = subprocess.run(
                f'netstat -ano | findstr :{PORT}',
                shell=True, capture_output=True, text=True
            )
            for line in result.stdout.strip().split('\n'):
                parts = line.strip().split()
                if len(parts) >= 5 and parts[-1].isdigit():
                    subprocess.run(f'taskkill /F /PID {parts[-1]}', shell=True, capture_output=True)
                    print(f"  已终止 PID={parts[-1]}")
            time.sleep(1)
    
    server = ProxyServer(('0.0.0.0', PORT), ProxyHandler)
    print(f"\n代理服务器运行在 http://0.0.0.0:{PORT}")
    print(f"路由: {list(PROXY_ROUTES.keys())}")
    print(f"健康检查: http://127.0.0.1:{PORT}/api/health")
    print(f"登录接口: http://127.0.0.1:{PORT}/api/auth/login")
    
    try:
        server.serve_forever()
    except KeyboardInterrupt:
        print("\n正在关闭...")
        server.shutdown()
        print("代理服务器已停止")
