#!/usr/bin/env python
# @Project ：django_base_ai
# @File    : file_list.py
# @Author  : cx
# @Time    : 11/2/2025
# @Desc    : 文件上传、列表、Office 解析、UEditor 等。
import base64
import datetime
import hashlib
import io
import json
import logging
import os
import random
import re
import uuid
import zipfile
import xml.etree.ElementTree as ET
from urllib.parse import quote

from django.conf import settings
from django.contrib.auth.models import AnonymousUser
from django.core.files.uploadedfile import InMemoryUploadedFile
from django.http import HttpResponse, StreamingHttpResponse
from django.views.decorators.csrf import csrf_exempt
from rest_framework import serializers
from rest_framework.decorators import action
from rest_framework.permissions import (
    AllowAny,
    IsAuthenticated,  # 判断用户是否登录
)

from django_base_ai import dispatch
from django_base_ai.system.models import ChunkUploadSession, FileList
from django_base_ai.system.views.ueditor_settings import ueditor_settings, ueditor_upload_settings
from django_base_ai.utils.json_response import DetailResponse, ErrorResponse
from django_base_ai.utils.serializers import CustomModelSerializer
from django_base_ai.utils.string_util import format_bytes
from django_base_ai.utils.validator import CustomValidationError
from django_base_ai.utils.office_extract import (
    extract_docx_text as extract_docx_text_impl,
    extract_pptx_text as extract_pptx_text_impl,
    extract_xlsx_text as extract_xlsx_text_impl,
    get_file_stream_from_request,
)
from django_base_ai.utils.viewset import CustomModelViewSet
from django_base_ai.utils.xss_cleaned import cleaned_html_file

logger = logging.getLogger(__name__)


def _safe_file_url(instance):
    """将 file_url 转为对外安全的 Web 路径，不暴露物理路径。"""
    if not instance.file_url and not getattr(instance, "url", None):
        return ""
    # 已是完整 http(s) 的保持原样
    raw = (instance.file_url or "").strip()
    if raw.startswith("http://") or raw.startswith("https://"):
        return raw
    # 优先用 FileField url 得到相对路径，再拼成 /media/xxx
    if getattr(instance, "url", None) and instance.url:
        path = f"{settings.MEDIA_URL}{instance.url}".replace("//", "/")
        return f"/{path}" if not path.startswith("/") else path
    # 从物理路径中截取 media 及之后部分，返回 /media/...
    if "media" in raw:
        after_media = raw.split("media", 1)[1].replace("\\", "/").strip("/")
        return f"/media/{after_media}"
    return raw if raw.startswith("/") else f"/{raw}"


class FileSerializer(CustomModelSerializer):
    url = serializers.SerializerMethodField(read_only=True)
    file_url = serializers.SerializerMethodField(read_only=True)

    def get_url(self, instance):
        if self.request.query_params.get("prefix"):
            if instance.file_url:
                return instance.file_url if instance.file_url.startswith("http") else f"/{instance.file_url}"
            return f"/{settings.MEDIA_URL}/{str(instance.url)}"
        return f"{instance.url.url}" or (f"{settings.MEDIA_URL}/{str(instance.url)}")

    def get_file_url(self, instance):
        return _safe_file_url(instance)

    def validate(self, attrs):
        get_dictionary_config = dispatch.get_dictionary_config()
        ext_str, ext = "", []
        for item in get_dictionary_config.get("file_type", {}).get("children", []):
            ext_str += item.get("value", "").lower() + ","
        ext = ext_str.split(",")
        print("ext==", ext)
        file = self.initial_data.get("file")
        fn, t = os.path.splitext(file.name)
        if t[1:].lower() not in ext:
            raise CustomValidationError("文件格式不合法")
        return attrs

    def create(self, validated_data):
        file_engine = dispatch.get_system_config_values("file_storage.file_engine") or "local"
        file_backup = dispatch.get_system_config_values("file_storage.file_backup")
        file = self.initial_data.get("file")
        file_size = file.size
        file.name = (
            file.name if len(file.name.split(".")) > 1 else str(uuid.uuid4()) + "." + file.content_type.split("/")[1]
        )

        # 检查是否为 HTML/HTM 文件
        file_extension = os.path.splitext(file.name)[1].lower()
        is_html_file = file_extension in [".html", ".htm"]

        if is_html_file:
            # 对于 HTML 文件，需要清理 XSS
            from django.core.files.base import ContentFile

            from django_base_ai.utils.xss_cleaned import cleaned_html_file

            # 保存原始文件的content_type
            original_content_type = file.content_type

            # 读取文件内容
            file_content = b""
            for chunk in file.chunks():
                file_content += chunk

            try:
                # 解码为字符串
                html_content = file_content.decode("utf-8")
                # 清理 XSS
                cleaned_content = cleaned_html_file(html_content)
                # 重新编码为字节
                cleaned_bytes = cleaned_content.encode("utf-8")

                # 创建清理后的文件对象
                cleaned_file = ContentFile(cleaned_bytes, name=file.name)
                # 为ContentFile添加content_type属性
                cleaned_file.content_type = original_content_type
                file = cleaned_file
                file_size = len(cleaned_bytes)
            except Exception:
                # 如果清理失败，使用原文件
                pass

        validated_data["name"] = file.name
        validated_data["size"] = file_size
        md5 = hashlib.md5()
        for chunk in file.chunks():
            md5.update(chunk)
        validated_data["md5sum"] = md5.hexdigest()
        validated_data["engine"] = file_engine
        validated_data["mime_type"] = file.content_type
        if file_backup:
            validated_data["url"] = file
        validated_data["url"] = file
        # 审计字段
        if self.request and self.request.user and not isinstance(self.request.user, AnonymousUser):
            try:
                request_user = self.request.user
                if hasattr(request_user, "dept") and request_user.dept:
                    validated_data["dept_belong_id"] = request_user.dept.id
                validated_data["creator"] = request_user.id
                validated_data["modifier"] = request_user.id
            except Exception as e:
                logger.warning(f"设置文件审计字段时出错: {e}")
        return super().create(validated_data)

    class Meta:
        model = FileList
        fields = "__all__"


class FileListInitSerializer(CustomModelSerializer):
    def save(self, **kwargs):
        instance = super().save(**kwargs)
        file_engine = dispatch.get_system_config_values("file_storage.file_engine") or "local"
        dispatch.get_system_config_values("file_storage.file_backup")
        file_url = f"/{settings.MEDIA_ROOT}{self.instance.file_url}"
        local_file_path = f"{settings.BASE_DIR}{file_url}"
        print("local_file_path=", local_file_path)
        with open(local_file_path, "br") as file:
            file_bytes_io = io.BytesIO(file.read())
            file_size = os.path.getsize(local_file_path)
            print("file_size==", file_size)
            instance.size = file_size
            instance.engine = file_engine
            instance.file_url = file_url
            instance.url = InMemoryUploadedFile(file_bytes_io, None, instance.name, None, file_size, None)
            instance.save()
            file_bytes_io.close()
        return instance

    class Meta:
        model = FileList
        fields = "__all__"


def file_iterator(filename, chunk_size=512):
    """read file"""
    with open(filename, "rb") as f:
        while True:
            c = f.read(chunk_size)
            if c:
                yield c
            else:
                break


rstr = r"[\/\\\:\*\?\"\<\>\|]"

# 断点续传：切片临时目录（相对 MEDIA_ROOT）
CHUNK_UPLOAD_DIR = "chunk_upload"


class FileViewSet(CustomModelViewSet):
    """
    文件管理接口
    list:查询
    create:新增
    update:修改
    retrieve:单例
    destroy:删除
    """

    queryset = FileList.objects.all()
    serializer_class = FileSerializer
    filter_fields = ["name"]
    search_fields = ["name"]

    @csrf_exempt
    @action(methods=["GET", "POST"], detail=False, permission_classes=[])
    def ueditor(self, request):
        action = self.request.query_params.get("action", "")
        reponse_action = {
            "config": self.get_ueditor_settings,
            "uploadimage": self.upload_file,
            "uploadscrawl": self.upload_file,
            "uploadvideo": self.upload_file,
            "uploadfile": self.upload_file,
        }
        return reponse_action[action](request)

    def get_ueditor_settings(self, request):
        return HttpResponse(
            json.dumps(ueditor_upload_settings, ensure_ascii=False), content_type="application/javascript"
        )

    # 保存上传的文件
    def save_upload_file(self, file, file_path):
        # 检查是否为 HTML/HTM 文件
        file_extension = os.path.splitext(file_path)[1].lower()
        is_html_file = file_extension in [".html", ".htm"]

        if is_html_file:
            # 对于 HTML 文件，需要清理 XSS
            from django_base_ai.utils.xss_cleaned import cleaned_html_file

            # 读取文件内容
            file_content = b""
            for chunk in file.chunks():
                file_content += chunk

            try:
                # 解码为字符串
                html_content = file_content.decode("utf-8")
                # 清理 XSS
                cleaned_content = cleaned_html_file(html_content)
                # 重新编码为字节
                cleaned_bytes = cleaned_content.encode("utf-8")

                # 写入清理后的内容
                with open(file_path, "wb") as f:
                    f.write(cleaned_bytes)
            except Exception as e:
                return f"清理HTML文件XSS错误:{e}"
        else:
            # 非 HTML 文件，直接保存
            with open(file_path, "wb") as f:
                try:
                    for chunk in file.chunks():
                        f.write(chunk)
                except Exception as e:
                    return f"写入文件错误:{e}"

        return "SUCCESS"

    def get_path_format_vars(self):
        return {
            "year": datetime.datetime.now().strftime("%Y"),
            "month": datetime.datetime.now().strftime("%m"),
            "day": datetime.datetime.now().strftime("%d"),
            "date": datetime.datetime.now().strftime("%Y%m%d"),
            "time": datetime.datetime.now().strftime("%H%M%S"),
            "datetime": datetime.datetime.now().strftime("%Y%m%d%H%M%S"),
            "rnd": random.randrange(100, 999),
        }

    def get_output_path(self, path_format_var):
        """
        取得输出文件的路径
        :param path_format_var:
        :return:
        """
        file_name = (ueditor_settings["defaultPathFormat"] % path_format_var).replace("\\", "/")
        # 分解OutputPathFormat
        output_path = os.path.join(f"{settings.MEDIA_ROOT}", "ueditor", f"{self.request.user.id}")
        if not os.path.exists(output_path):
            os.makedirs(output_path)
        return (file_name, output_path)

    # 涂鸦功能上传处理
    def save_scrawl_file(self, request, file_path, file_name):

        instance = None
        try:
            content = request.data.get(ueditor_upload_settings.get("scrawlFieldName", "upfile"))
            f = open(os.path.join(settings.BASE_DIR, file_path, file_name), "wb")
            f.write(base64.b64decode(content))
            f.close()
            state = "SUCCESS"
            instance = FileList.save_file(request, file_path, file_name, mime_type="image/png")
        except Exception as e:
            state = f"写入图片文件错误:{e}"
        return state, instance

    def upload_file(self, request):
        """上传文件"""
        state = "SUCCESS"
        action = self.request.query_params.get("action")
        # 上传文件
        upload_field_name_list = {
            "uploadfile": "fileFieldName",
            "uploadimage": "imageFieldName",
            "uploadscrawl": "scrawlFieldName",
            "catchimage": "catcherFieldName",
            "uploadvideo": "videoFieldName",
        }
        upload_field_name = self.request.query_params.get(
            upload_field_name_list[action], ueditor_upload_settings.get(action, "upfile")
        )
        # 上传涂鸦，涂鸦是采用base64编码上传的，需要单独处理
        if action == "uploadscrawl":
            upload_file_name = "scrawl.png"
            upload_file_size = 0
        else:
            # 取得上传的文件
            file = request.FILES.get(upload_field_name, None)
            if file is None:
                return HttpResponse(json.dumps("{'state:'ERROR'}"), content_type="application/javascript")
            upload_file_name = file.name
            upload_file_size = file.size

        # 取得上传的文件的原始名称
        upload_original_name, upload_original_ext = os.path.splitext(upload_file_name)
        # 文件类型检验
        upload_allow_type = {
            "uploadfile": "fileAllowFiles",
            "uploadimage": "imageAllowFiles",
            "uploadvideo": "videoAllowFiles",
        }
        if action in upload_allow_type:
            allow_type = list(
                self.request.query_params.get(
                    upload_allow_type[action], ueditor_upload_settings.get(upload_allow_type[action], "")
                )
            )
            if upload_original_ext.lower() not in allow_type:
                state = f"服务器不允许上传{upload_original_ext}类型的文件。"
                return HttpResponse({"state": state}, content_type="application/javascript")

        # 大小检验
        upload_max_size = {
            "uploadfile": "filwMaxSize",
            "uploadimage": "imageMaxSize",
            "uploadscrawl": "scrawlMaxSize",
            "uploadvideo": "videoMaxSize",
        }
        max_size = int(
            self.request.query_params.get(
                upload_max_size[action], ueditor_upload_settings.get(upload_max_size[action], 0)
            )
        )
        if max_size != 0:
            if upload_file_size > max_size:
                state = f"上传文件大小不允许超过{format_bytes(max_size)}。"
                return HttpResponse({"state": state}, content_type="application/javascript")

        path_format_var = self.get_path_format_vars()
        path_format_var.update(
            {
                "basename": upload_original_name,
                "extname": upload_original_ext[1:],
                "filename": upload_file_name,
            }
        )
        # 取得输出文件的路径
        format_file_name, output_path = self.get_output_path(path_format_var)
        # 所有检测完成后写入文件
        file_instance = None
        if state == "SUCCESS":
            if action == "uploadscrawl":
                state, file_instance = self.save_scrawl_file(request, file_path=output_path, file_name=format_file_name)
            else:
                file = request.FILES.get(upload_field_name, None)
                # 保存到文件中，如果保存错误，需要返回ERROR
                state = self.save_upload_file(file, os.path.join(settings.BASE_DIR, output_path, format_file_name))
                # 保存到附件管理中
                file_instance = FileList.save_file(request, output_path, format_file_name, mime_type=file.content_type)

        # 返回数据
        return_info = {
            "url": file_instance.file_url
            if file_instance
            else os.path.join(output_path, format_file_name),  # 保存后的文件名称
            "original": upload_file_name,  # 原始文件名
            "type": upload_original_ext,
            "state": state,  # 上传状态，成功时返回SUCCESS,其他任何值将原样返回至图片上传框中
            "size": upload_file_size,
        }
        return HttpResponse(json.dumps(return_info, ensure_ascii=False), content_type="application/javascript")

    @action(methods=["GET"], detail=False)
    def download_file(self, request):
        """
        下载文件 需要验证用户权限
        """
        fid = request.GET.get("fid", 0)
        if not fid:
            return ErrorResponse(msg="参数错误请重试")
        file_list = FileList.objects.filter(id=fid).first()
        if not file_list:
            return ErrorResponse(msg="参数错误请重试")
        str(os.path.splitext(file_list.name)[-1]).upper()
        file_path = f"{settings.BASE_DIR}{file_list.file_url}"
        print("file_path=", file_path)
        response = StreamingHttpResponse(file_iterator(file_path))
        response["Content-Type"] = "application/octet-stream"
        response["Content-Disposition"] = 'attachment;filename="{}"'.format(
            quote(re.sub(rstr, "_", file_list.name)),
        )
        return response

    @csrf_exempt
    @action(methods=["post"], detail=False, permission_classes=[AllowAny], authentication_classes=[])
    def upload_allow_file(self, request, *args, **kwargs):
        fid = request.data.get("fid", "")
        file_names = []
        file_list_to_insert = list()
        files = request.FILES.getlist("file", [])
        get_dictionary_config = dispatch.get_dictionary_config()
        ext = (
            ",".join([item.get("value") for item in get_dictionary_config.get("file_type", {}).get("children")])
            .lower()
            .split(",")
        )
        for item in files:
            fn, t = os.path.splitext(item.name)
            if t[1:].lower() in ext:
                directory_path = os.path.join(f"{settings.BASE_DIR}/{settings.MEDIA_ROOT}", fid)
                if not os.path.exists(directory_path):
                    os.makedirs(directory_path)
                filename = f"{uuid.uuid4().hex}{t.lower()}"
                file_path = directory_path + "/" + filename
                md5 = hashlib.md5()
                with open(file_path, "wb") as f:
                    for c in item.chunks():
                        f.write(c)
                        md5.update(c)
                    f.close()
                file_names.append(f"{settings.MEDIA_URL}{filename}")
                file_list_to_insert.append(
                    FileList(
                        name=item.name,
                        file_url=f"{settings.MEDIA_URL}{filename}",
                        mime_type=item.content_type,
                        size=item.size,
                        md5sum=md5.hexdigest(),
                    )
                )
            if len(file_list_to_insert) > 0:
                FileList.objects.bulk_create(file_list_to_insert)
        return DetailResponse(file_names)

    # ---------- 断点续传 ----------

    @action(methods=["POST"], detail=False, permission_classes=[IsAuthenticated])
    def check_file_by_md5(self, request, *args, **kwargs):
        """
        步骤1：前端传文件 MD5，后端判断是否已存在（秒传）。
        请求体: md5（必填）, filename/name、total_size、chunk_total（可选）
        响应: exists=True 返回已有 file 信息；exists=False 创建上传会话并返回 upload_id。
        """
        data = request.data
        file_md5 = data.get("md5","").strip().lower()
        if not file_md5 or len(file_md5) != 32:
            return ErrorResponse(msg="请提供有效的 md5（32 位）")
        existing = FileList.objects.filter(md5sum=file_md5).first()
        if existing:
            file_url = existing.file_url or (
                f"/{settings.MEDIA_URL}{existing.url}".replace("//", "/") if existing.url else ""
            )
            return DetailResponse(
                data={
                    "exists": True,
                    "file_id": existing.id,
                    "file_url": file_url,
                    "name": existing.name,
                    "size": existing.size,
                },
                msg="文件已存在，可秒传",
            )
        upload_id = uuid.uuid4().hex
        filename = (data.get("filename") or data.get("name") or "").strip()
        try:
            total_size = int(data.get("total_size") or 0)
        except (TypeError, ValueError):
            total_size = 0
        try:
            chunk_total = int(data.get("chunk_total") or 0)
        except (TypeError, ValueError):
            chunk_total = 0
        session = ChunkUploadSession(
            upload_id=upload_id,
            file_md5=file_md5,
            filename=filename,
            total_size=total_size,
            chunk_total=chunk_total,
            uploaded_chunk_indices=[],
        )
        if request.user and not isinstance(request.user, AnonymousUser):
            try:
                session.creator = str(request.user.id)
                session.modifier = str(request.user.id)
                if getattr(request.user, "dept_id", None):
                    session.dept_belong_id = str(request.user.dept_id)
            except Exception as e:
                logger.warning("设置审计字段时出错: %s", e)
        session.save()
        return DetailResponse(
            data={"exists": False, "upload_id": upload_id},
            msg="文件不存在，请上传切片",
        )

    @action(methods=["POST"], detail=False, permission_classes=[IsAuthenticated])
    def upload_chunk(self, request, *args, **kwargs):
        """
        步骤2：上传单个切片。请求 multipart：upload_id、chunk_index（从 0 开始）、chunk_total、file 或 chunk。
        同一 chunk_index 重复上传会覆盖文件，会话中只记录一次（幂等）。
        """
        data = request.data
        upload_id = data.get("upload_id","").strip()
        if not upload_id:
            return ErrorResponse(msg="缺少 upload_id")
        if not re.match(r"^[a-zA-Z0-9\-]{1,64}$", upload_id):
            return ErrorResponse(msg="upload_id 仅允许字母、数字、短横线，长度 1~64")
        try:
            chunk_index = int(data.get("chunk_index"))
            chunk_total = int(data.get("chunk_total"))
        except (TypeError, ValueError):
            return ErrorResponse(msg="chunk_index / chunk_total 必须为整数")
        if chunk_index < 0 or chunk_total < 1 or chunk_index >= chunk_total:
            return ErrorResponse(msg="chunk_index 或 chunk_total 不合法")
        chunk_file = request.FILES.get("file") or request.FILES.get("chunk")
        if not chunk_file:
            return ErrorResponse(msg="请上传切片文件 file 或 chunk")
        chunk_dir = os.path.join(settings.BASE_DIR, settings.MEDIA_ROOT, CHUNK_UPLOAD_DIR, upload_id)
        try:
            os.makedirs(chunk_dir, exist_ok=True)
        except OSError as e:
            logger.exception("创建切片目录失败: %s", e)
            return ErrorResponse(msg="创建切片目录失败")
        chunk_path = os.path.join(chunk_dir, str(chunk_index))
        try:
            with open(chunk_path, "wb") as f:
                for chunk in chunk_file.chunks():
                    f.write(chunk)
        except OSError as e:
            logger.exception("写入切片失败: %s", e)
            return ErrorResponse(msg="写入切片失败")
        session = ChunkUploadSession.objects.filter(upload_id=upload_id).first()
        uploaded_count = 1
        if session:
            indices = sorted(set((session.uploaded_chunk_indices or []) + [chunk_index]))
            session.uploaded_chunk_indices = indices
            if session.chunk_total == 0:
                session.chunk_total = chunk_total
            session.save(update_fields=["uploaded_chunk_indices", "chunk_total", "update_datetime"])
            uploaded_count = len(indices)
        return DetailResponse(
            data={"chunk_index": chunk_index, "chunk_total": chunk_total, "uploaded_count": uploaded_count},
            msg="切片上传成功",
        )

    @action(methods=["POST"], detail=False, permission_classes=[IsAuthenticated])
    def get_uploaded_chunks(self, request, *args, **kwargs):
        """
        查询某次上传已完成的切片序号，用于断点续传（前端只传缺失的切片）。
        请求: upload_id（query 或 body）
        响应: uploaded_chunk_indices, chunk_total
        """
        upload_id = (request.data.get("upload_id") or request.query_params.get("upload_id") or "").strip()
        if not upload_id:
            return ErrorResponse(msg="请提供 upload_id")
        session = ChunkUploadSession.objects.filter(upload_id=upload_id).first()
        if session:
            return DetailResponse(
                data={
                    "uploaded_chunk_indices": list(session.uploaded_chunk_indices or []),
                    "chunk_total": session.chunk_total,
                },
                msg="ok",
            )
        chunk_dir = os.path.join(settings.BASE_DIR, settings.MEDIA_ROOT, CHUNK_UPLOAD_DIR, upload_id)
        if os.path.isdir(chunk_dir):
            indices = sorted([int(p) for p in os.listdir(chunk_dir) if p.isdigit()])
            return DetailResponse(
                data={"uploaded_chunk_indices": indices, "chunk_total": 0},
                msg="会话未入库，已从磁盘读取已上传切片",
            )
        return DetailResponse(
            data={"uploaded_chunk_indices": [], "chunk_total": 0},
            msg="未找到该 upload_id 的会话或切片目录",
        )

    def _cleanup_chunk_upload(self, upload_id, chunk_dir, chunk_indices, remove_final_path=None):
        """删除切片目录下文件、目录及该 upload_id 的会话记录；可选删除合并后的文件。"""
        if remove_final_path and os.path.isfile(remove_final_path):
            try:
                os.remove(remove_final_path)
            except OSError:
                pass
        for i in chunk_indices:
            try:
                os.remove(os.path.join(chunk_dir, str(i)))
            except OSError:
                pass
        try:
            os.rmdir(chunk_dir)
        except OSError:
            pass
        ChunkUploadSession.objects.filter(upload_id=upload_id).delete()

    @action(methods=["POST"], detail=False, permission_classes=[IsAuthenticated])
    def merge_chunks(self, request, *args, **kwargs):
        """
        步骤3：合并切片，校验整体 MD5 与可选 total_size，通过则入库并清理临时目录。
        请求体: upload_id, md5, filename 或 name；可选 total_size, mime_type 或 content_type
        """
        data = request.data
        upload_id = data.get("upload_id","").strip()
        file_md5 = data.get("md5","").strip().lower()
        filename = data.get("name","").strip()
        mime_type = data.get("mime_type","application/octet-stream").strip()
        if not upload_id or not file_md5 or len(file_md5) != 32:
            return ErrorResponse(msg="请提供 upload_id 与有效的 md5（32 位）")
        if not filename:
            return ErrorResponse(msg="请提供 filename")
        try:
            total_size = int(data.get("total_size",0))
        except (TypeError, ValueError):
            total_size = 0
        chunk_dir = os.path.join(settings.BASE_DIR, settings.MEDIA_ROOT, CHUNK_UPLOAD_DIR, upload_id)
        if not os.path.isdir(chunk_dir):
            return ErrorResponse(msg="未找到该 upload_id 的切片目录，请先上传切片")
        chunk_indices = sorted([int(p) for p in os.listdir(chunk_dir) if p.isdigit()])
        if not chunk_indices:
            return ErrorResponse(msg="切片目录为空")
        ext = os.path.splitext(filename)[1].lower() or ""
        files_subdir = os.path.join(settings.BASE_DIR, settings.MEDIA_ROOT, "files")
        final_name = file_md5 + ext
        final_path = os.path.join(files_subdir, final_name)
        try:
            os.makedirs(files_subdir, exist_ok=True)
        except OSError as e:
            logger.exception("创建 files 目录失败: %s", e)
            return ErrorResponse(msg="创建文件目录失败")
        md5_calc = hashlib.md5()
        size_sum = 0
        try:
            with open(final_path, "wb") as out:
                for i in chunk_indices:
                    chunk_path = os.path.join(chunk_dir, str(i))
                    if not os.path.isfile(chunk_path):
                        raise ValueError(f"缺少切片 {i}")
                    with open(chunk_path, "rb") as inc:
                        buf = inc.read()
                    out.write(buf)
                    md5_calc.update(buf)
                    size_sum += len(buf)
        except (OSError, ValueError) as e:
            self._cleanup_chunk_upload(upload_id, chunk_dir, chunk_indices, final_path)
            return ErrorResponse(msg=f"合并失败: {e}")
        merged_md5 = md5_calc.hexdigest().lower()
        if merged_md5 != file_md5:
            self._cleanup_chunk_upload(upload_id, chunk_dir, chunk_indices, final_path)
            return ErrorResponse(msg=f"文件 MD5 校验失败：期望 {file_md5}，实际 {merged_md5}")
        if total_size and size_sum != total_size:
            self._cleanup_chunk_upload(upload_id, chunk_dir, chunk_indices, final_path)
            return ErrorResponse(msg=f"文件大小不一致：期望 {total_size}，实际 {size_sum}")
        file_url = f"{settings.MEDIA_URL.rstrip('/')}/files/{final_name}".replace("//", "/")
        rel_path = f'files/{final_name}'
        instance = FileList(
            name=filename,
            file_url=file_url,
            url=rel_path,
            md5sum=merged_md5,
            size=size_sum,
            mime_type=mime_type,
            engine=dispatch.get_system_config_values("file_storage.file_engine") or "local",
        )
        if request.user and not isinstance(request.user, AnonymousUser):
            try:
                instance.creator = str(request.user.id)
                instance.modifier = str(request.user.id)
                if getattr(request.user, "dept_id", None):
                    instance.dept_belong_id = str(request.user.dept_id)
            except Exception as e:
                logger.warning("设置审计字段时出错: %s", e)
        instance.save()
        self._cleanup_chunk_upload(upload_id, chunk_dir, chunk_indices)
        return DetailResponse(
            data={
                "file_id": instance.id,
                "file_url": instance.file_url,
                "name": instance.name,
                "size": instance.size,
                "md5sum": instance.md5sum,
            },
            msg="合并并校验成功",
        )

    # ---------- Office 文件解压提取明文（docx/xlsx/pptx 本质为 ZIP+XML），用于提供给 AI ----------

    @action(methods=["GET", "POST"], detail=False, permission_classes=[IsAuthenticated])
    def extract_docx_text(self, request, *args, **kwargs):
        """
        解压 docx（ZIP+XML）并返回正文明文。
        请求: file_id（query/body）或 multipart 上传 file。
        响应: text 为提取的纯文本。
        """
        file_like, name = get_file_stream_from_request(request)
        if file_like is None:
            return ErrorResponse(msg="请提供 file_id 或上传 file")
        try:
            text = extract_docx_text_impl(file_like)
        except (zipfile.BadZipFile, ET.ParseError, KeyError) as e:
            logger.exception("extract_docx_text 失败: %s", e)
            return ErrorResponse(msg=f"文件不是有效的 docx 或解析失败: {e}")
        return DetailResponse(data={"text": text, "filename": name}, msg="ok")

    @action(methods=["GET", "POST"], detail=False, permission_classes=[IsAuthenticated])
    def extract_xlsx_text(self, request, *args, **kwargs):
        """
        解压 xlsx（ZIP+XML）并返回单元格等文本信息（明文）。
        请求: file_id 或上传 file。
        """
        file_like, name = get_file_stream_from_request(request)
        if file_like is None:
            return ErrorResponse(msg="请提供 file_id 或上传 file")
        try:
            text = extract_xlsx_text_impl(file_like)
        except (zipfile.BadZipFile, ET.ParseError, KeyError) as e:
            logger.exception("extract_xlsx_text 失败: %s", e)
            return ErrorResponse(msg=f"文件不是有效的 xlsx 或解析失败: {e}")
        return DetailResponse(data={"text": text, "filename": name}, msg="ok")

    @action(methods=["GET", "POST"], detail=False, permission_classes=[IsAuthenticated])
    def extract_pptx_text(self, request, *args, **kwargs):
        """
        解压 pptx（ZIP+XML）并返回幻灯片内文本（明文）。
        请求: file_id 或上传 file。
        """
        file_like, name = get_file_stream_from_request(request)
        if file_like is None:
            return ErrorResponse(msg="请提供 file_id 或上传 file")
        try:
            text = extract_pptx_text_impl(file_like)
        except (zipfile.BadZipFile, ET.ParseError, KeyError) as e:
            logger.exception("extract_pptx_text 失败: %s", e)
            return ErrorResponse(msg=f"文件不是有效的 pptx 或解析失败: {e}")
        return DetailResponse(data={"text": text, "filename": name}, msg="ok")

    @action(methods=["POST"], detail=False, permission_classes=[IsAuthenticated])
    def html_cleaned_xss(self, request, *args, **kwargs):
        """
        清理HTML内容中的XSS攻击
        """
        # 获取HTML内容
        html_content = request.data.get("html", "")

        # 检查输入是否为空
        if not html_content:
            return ErrorResponse(msg="HTML内容不能为空")

        # 如果传入的是字节类型，需要先解码
        if isinstance(html_content, bytes):
            html_content = html_content.decode("utf-8")

        # 清理 XSS
        cleaned_content = cleaned_html_file(html_content)

        # 返回清理后的内容
        return DetailResponse(
            data={
                "cleaned_html": cleaned_content,
                "original_length": len(html_content),
                "cleaned_length": len(cleaned_content),
            },
            msg="操作成功",
        )
