from __future__ import annotations import mimetypes import threading from pathlib import Path from typing import Any import requests from django.conf import settings from django.db.models import Count, Q from django.http import FileResponse, Http404, HttpResponse from django.utils import timezone from django.utils.decorators import method_decorator from django.views.decorators.csrf import csrf_exempt from rest_framework import status from rest_framework.response import Response from rest_framework.views import APIView from .dataset_nonempty import ( comment_columns_for_api, detail_columns_for_api, merged_columns_for_api, search_columns_for_api, ) from .export_job import build_csv_bytes, build_json_bytes, build_xlsx_bytes from .row_serialize import ( comment_row_to_dict, detail_row_to_dict, merged_row_to_dict, search_row_to_dict, ) from .brief_pack import build_brief_pack_zip_bytes from .strategy_draft import build_strategy_draft_markdown from .ingest import ingest_job_full from .jd_runner import ( build_competitor_brief_for_job, get_default_report_config, merge_llm_supplement_with_rules_report, regenerate_competitor_report, write_competitor_analysis_markdown, ) from .llm_generate import ( generate_competitor_report_markdown_llm, generate_strategy_draft_markdown_llm, ) from .md_document_export import markdown_to_docx_bytes, markdown_to_pdf_bytes from .models import ( JdJobCommentRow, JdJobDetailRow, JdJobMergedRow, JdJobSearchRow, JdProduct, JdProductSnapshot, JobStatus, PipelineJob, ) from .serializers import ( CreatePipelineJobSerializer, JdProductDetailSerializer, JdProductListSerializer, JdProductSnapshotBriefSerializer, JdProductSnapshotDetailSerializer, JobReportConfigPatchSerializer, PipelineJobSerializer, RegenerateReportRequestSerializer, StrategyDraftRequestSerializer, ) from .tasks import execute_job # 在线预览最大字节(超出则截断并提示下载) _PREVIEW_MAX_BYTES = 2 * 1024 * 1024 # 允许下载的相对文件名(均在 run_dir 下) _DOWNLOAD_NAMES = frozenset( { "merged", "pc_search", "comments", "detail_ware", "report", } ) def _jd_data_root() -> Path: root = (settings.LOW_GI_PROJECT_ROOT or "").strip() if not root: raise RuntimeError("LOW_GI_PROJECT_ROOT 未配置") return (Path(root) / "data" / "JD").resolve() def _safe_file_for_job(run_dir_str: str, name: str) -> Path: if name not in _DOWNLOAD_NAMES: raise Http404("unknown file") base = Path(run_dir_str).resolve() jd_root = _jd_data_root().resolve() try: base.relative_to(jd_root) except ValueError: raise Http404("invalid run_dir") mapping = { "merged": "keyword_pipeline_merged.csv", "pc_search": "pc_search_export.csv", "comments": "comments_flat.csv", "detail_ware": "detail_ware_export.csv", "report": "competitor_analysis.md", } f = base / mapping[name] if not f.is_file(): raise Http404("file not found") return f def _job_run_dir_usable(job: PipelineJob) -> bool: """成功或已终止但已写入 run_dir 时,可预览/下载批次文件。""" return bool((job.run_dir or "").strip()) and job.status in ( JobStatus.SUCCESS, JobStatus.CANCELLED, ) @method_decorator(csrf_exempt, name="dispatch") class JobListCreateView(APIView): def get(self, request): qs = PipelineJob.objects.all()[:200] return Response(PipelineJobSerializer(qs, many=True).data) def post(self, request): if not (settings.LOW_GI_PROJECT_ROOT or "").strip(): return Response( {"detail": "请先在 market_assistant/.env 中配置 LOW_GI_PROJECT_ROOT"}, status=status.HTTP_503_SERVICE_UNAVAILABLE, ) ser = CreatePipelineJobSerializer(data=request.data) ser.is_valid(raise_exception=True) data = ser.validated_data job = PipelineJob.objects.create( platform=data["platform"], keyword=data["keyword"], max_skus=data.get("max_skus"), page_start=data.get("page_start"), page_to=data.get("page_to"), pipeline_run_dir=data.get("pipeline_run_dir") or "", cookie_file_path=data.get("cookie_file_path") or "", cookie_text=data.get("cookie_text") or "", pvid=data.get("pvid") or "", request_delay=data.get("request_delay") or "", list_pages=data.get("list_pages") or "", scenario_filter_enabled=data.get("scenario_filter_enabled"), report_config=data.get("report_config") or {}, status=JobStatus.PENDING, ) t = threading.Thread(target=execute_job, args=(job.id,), daemon=True) t.start() return Response( PipelineJobSerializer(job).data, status=status.HTTP_201_CREATED, ) @method_decorator(csrf_exempt, name="dispatch") class JobDetailView(APIView): def get(self, request, pk: int): job = PipelineJob.objects.filter(pk=pk).first() if not job: raise Http404() return Response(PipelineJobSerializer(job).data) def patch(self, request, pk: int): job = PipelineJob.objects.filter(pk=pk).first() if not job: raise Http404() ser = JobReportConfigPatchSerializer(data=request.data) ser.is_valid(raise_exception=True) job.report_config = ser.validated_data["report_config"] job.save(update_fields=["report_config", "updated_at"]) return Response(PipelineJobSerializer(job).data) @method_decorator(csrf_exempt, name="dispatch") class JobCancelView(APIView): """ 终止:将 ``cancellation_requested`` 置位后,执行线程会尽快 ``terminate`` 采集子进程 (效果接近在终端对脚本按 Ctrl+C),并保留已写入运行目录的文件。 """ def post(self, request, pk: int): job = PipelineJob.objects.filter(pk=pk).first() if not job: raise Http404() if job.status not in (JobStatus.PENDING, JobStatus.RUNNING): return Response( {"detail": "仅待执行或执行中的任务可终止"}, status=status.HTTP_400_BAD_REQUEST, ) job.cancellation_requested = True job.save(update_fields=["cancellation_requested", "updated_at"]) return Response(PipelineJobSerializer(job).data) class ReportConfigDefaultsView(APIView): """返回 ``jd_competitor_report`` 中与脚本常量一致的默认报告调参 JSON。""" def get(self, request): try: return Response(get_default_report_config()) except FileNotFoundError as e: return Response( {"detail": str(e)}, status=status.HTTP_503_SERVICE_UNAVAILABLE, ) class JobDownloadView(APIView): def get(self, request, pk: int): job = PipelineJob.objects.filter(pk=pk).first() if not job or not _job_run_dir_usable(job): raise Http404() name = (request.query_params.get("name") or "").strip().lower() path = _safe_file_for_job(job.run_dir, name) return FileResponse( path.open("rb"), as_attachment=True, filename=path.name, ) class JobPreviewView(APIView): """浏览器内联查看产出(CSV / Markdown 文本),大文件截断。""" def get(self, request, pk: int): job = PipelineJob.objects.filter(pk=pk).first() if not job or not _job_run_dir_usable(job): raise Http404() name = (request.query_params.get("name") or "").strip().lower() fpath = _safe_file_for_job(job.run_dir, name) raw = fpath.read_bytes() truncated = len(raw) > _PREVIEW_MAX_BYTES if truncated: raw = raw[:_PREVIEW_MAX_BYTES] text = raw.decode("utf-8-sig", errors="replace") if truncated: text += "\n\n... [内容已截断,完整文件请使用下载]\n" if name == "report": ctype = "text/markdown; charset=utf-8" else: ctype = "text/csv; charset=utf-8" resp = HttpResponse(text, content_type=ctype) resp["X-Preview-Truncated"] = "1" if truncated else "0" resp["X-Preview-Filename"] = fpath.name return resp @method_decorator(csrf_exempt, name="dispatch") class JobRegenerateReportView(APIView): """基于任务已有 ``run_dir`` 内 CSV 重新生成 ``competitor_analysis.md``(不重新爬取)。""" def post(self, request, pk: int): if not (settings.LOW_GI_PROJECT_ROOT or "").strip(): return Response( {"detail": "请先在 market_assistant/.env 中配置 LOW_GI_PROJECT_ROOT"}, status=status.HTTP_503_SERVICE_UNAVAILABLE, ) job = PipelineJob.objects.filter(pk=pk).first() if not job: raise Http404() if job.status != JobStatus.SUCCESS or not (job.run_dir or "").strip(): return Response( {"detail": "仅可对已成功且已写入 run_dir 的任务重新生成报告"}, status=status.HTTP_400_BAD_REQUEST, ) ser = RegenerateReportRequestSerializer(data=request.data or {}) ser.is_valid(raise_exception=True) generator = ser.validated_data.get("generator") or "rules" rc = job.report_config if isinstance(job.report_config, dict) else None if generator == "llm": try: regenerate_competitor_report( job.run_dir, job.keyword, report_config=rc ) rules_md = ( Path(job.run_dir) / "competitor_analysis.md" ).read_text(encoding="utf-8") brief = build_competitor_brief_for_job( job.run_dir, job.keyword, report_config=rc ) md = generate_competitor_report_markdown_llm(brief, job.keyword) md = merge_llm_supplement_with_rules_report(md, rules_md) write_competitor_analysis_markdown(job.run_dir, md) except FileNotFoundError as e: return Response({"detail": str(e)}, status=status.HTTP_400_BAD_REQUEST) except ValueError as e: return Response({"detail": str(e)}, status=status.HTTP_503_SERVICE_UNAVAILABLE) except requests.RequestException as e: return Response( {"detail": f"大模型网关错误:{e}"}, status=status.HTTP_502_BAD_GATEWAY, ) else: try: regenerate_competitor_report(job.run_dir, job.keyword, report_config=rc) except FileNotFoundError as e: return Response({"detail": str(e)}, status=status.HTTP_400_BAD_REQUEST) except ValueError as e: return Response({"detail": str(e)}, status=status.HTTP_400_BAD_REQUEST) return Response(PipelineJobSerializer(job).data) class JobCompetitorBriefView(APIView): """单次任务的结构化竞品摘要(JSON,与 ``competitor_analysis.md`` 统计口径一致,规则驱动无 LLM)。""" def get(self, request, pk: int): if not (settings.LOW_GI_PROJECT_ROOT or "").strip(): return Response( {"detail": "请先在 market_assistant/.env 中配置 LOW_GI_PROJECT_ROOT"}, status=status.HTTP_503_SERVICE_UNAVAILABLE, ) job = PipelineJob.objects.filter(pk=pk).first() if not job: raise Http404() if job.status != JobStatus.SUCCESS or not (job.run_dir or "").strip(): return Response( {"detail": "仅可对已成功且含 run_dir 的任务获取竞品摘要"}, status=status.HTTP_400_BAD_REQUEST, ) try: data = build_competitor_brief_for_job( job.run_dir, job.keyword, report_config=job.report_config if isinstance(job.report_config, dict) else None, ) except FileNotFoundError as e: return Response({"detail": str(e)}, status=status.HTTP_400_BAD_REQUEST) except ValueError as e: return Response({"detail": str(e)}, status=status.HTTP_400_BAD_REQUEST) return Response(data) class JobCompetitorBriefPackView(APIView): """ZIP:完整 Markdown 报告 + 结构化 JSON + 要点摘录 Markdown + 说明文本。""" def get(self, request, pk: int): if not (settings.LOW_GI_PROJECT_ROOT or "").strip(): return Response( {"detail": "请先在 market_assistant/.env 中配置 LOW_GI_PROJECT_ROOT"}, status=status.HTTP_503_SERVICE_UNAVAILABLE, ) job = PipelineJob.objects.filter(pk=pk).first() if not job: raise Http404() if job.status != JobStatus.SUCCESS or not (job.run_dir or "").strip(): return Response( {"detail": "仅可对已成功且含 run_dir 的任务导出简报包"}, status=status.HTTP_400_BAD_REQUEST, ) try: brief = build_competitor_brief_for_job( job.run_dir, job.keyword, report_config=job.report_config if isinstance(job.report_config, dict) else None, ) zip_bytes = build_brief_pack_zip_bytes(Path(job.run_dir), brief) except FileNotFoundError as e: return Response({"detail": str(e)}, status=status.HTTP_400_BAD_REQUEST) except ValueError as e: return Response({"detail": str(e)}, status=status.HTTP_400_BAD_REQUEST) filename_ascii = f"job_{pk}_competitor_brief_pack.zip" resp = HttpResponse(zip_bytes, content_type="application/zip") resp["Content-Disposition"] = f'attachment; filename="{filename_ascii}"' return resp @method_decorator(csrf_exempt, name="dispatch") class JobStrategyDraftView(APIView): """ 市场策略制定 Markdown:策略框架 + 附录;默认规则生成,可选 ``generator=llm``(``AI_crawler.chat_completion_text``)。 """ def post(self, request, pk: int): if not (settings.LOW_GI_PROJECT_ROOT or "").strip(): return Response( {"detail": "请先在 market_assistant/.env 中配置 LOW_GI_PROJECT_ROOT"}, status=status.HTTP_503_SERVICE_UNAVAILABLE, ) job = PipelineJob.objects.filter(pk=pk).first() if not job: raise Http404() if job.status != JobStatus.SUCCESS or not (job.run_dir or "").strip(): return Response( {"detail": "仅可对已成功且含 run_dir 的任务生成策略制定稿"}, status=status.HTTP_400_BAD_REQUEST, ) ser = StrategyDraftRequestSerializer(data=request.data or {}) ser.is_valid(raise_exception=True) vd = ser.validated_data notes = (vd.get("business_notes") or "").strip() strategy_decisions = { "product_role": vd.get("product_role") or "", "time_horizon": vd.get("time_horizon") or "", "success_criteria": vd.get("success_criteria") or "", "non_goals": vd.get("non_goals") or "", "battlefield_one_line": vd.get("battlefield_one_line") or "", "positioning_choice": vd.get("positioning_choice") or "", "competitive_stance": vd.get("competitive_stance") or "", "pillar_product": vd.get("pillar_product") or "", "pillar_price": vd.get("pillar_price") or "", "pillar_channel": vd.get("pillar_channel") or "", "pillar_comm": vd.get("pillar_comm") or "", "ack_risk_keywords": bool(vd.get("ack_risk_keywords")), "ack_risk_price": bool(vd.get("ack_risk_price")), "ack_risk_concentration": bool(vd.get("ack_risk_concentration")), } try: brief = build_competitor_brief_for_job( job.run_dir, job.keyword, report_config=job.report_config if isinstance(job.report_config, dict) else None, ) except FileNotFoundError as e: return Response({"detail": str(e)}, status=status.HTTP_400_BAD_REQUEST) except ValueError as e: return Response({"detail": str(e)}, status=status.HTTP_400_BAD_REQUEST) gen_at = timezone.now().isoformat() generator = (vd.get("generator") or "rules").strip() try: if generator == "llm": md = generate_strategy_draft_markdown_llm( job_id=job.id, keyword=job.keyword, brief=brief, business_notes=notes, generated_at_iso=gen_at, strategy_decisions=strategy_decisions, ) src = "llm_text_ai_crawler_v1" else: md = build_strategy_draft_markdown( job_id=job.id, keyword=job.keyword, brief=brief, business_notes=notes, generated_at_iso=gen_at, strategy_decisions=strategy_decisions, ) src = "structured_summary_rules_v1" except ValueError as e: return Response({"detail": str(e)}, status=status.HTTP_503_SERVICE_UNAVAILABLE) except requests.RequestException as e: return Response( {"detail": f"大模型网关错误:{e}"}, status=status.HTTP_502_BAD_GATEWAY, ) return Response( { "schema_version": 1, "job_id": job.id, "keyword": job.keyword, "generated_at": gen_at, "source": src, "markdown": md, } ) @method_decorator(csrf_exempt, name="dispatch") class JobExportDocumentView(APIView): """ 将 Markdown 导出为 Word(.docx)或简易 PDF。 - GET:``kind=report``,读取 ``run_dir/competitor_analysis.md``。 - POST:``kind=strategy``,请求体 JSON 字段 ``markdown`` 为策略稿正文(与前端 sessionStorage 一致)。 PDF 依赖本机中文字体或环境变量 ``MA_PDF_FONT`` 指向 .ttf。 """ def get(self, request, pk: int): if not (settings.LOW_GI_PROJECT_ROOT or "").strip(): return Response( {"detail": "请先在 market_assistant/.env 中配置 LOW_GI_PROJECT_ROOT"}, status=status.HTTP_503_SERVICE_UNAVAILABLE, ) job = PipelineJob.objects.filter(pk=pk).first() if not job: raise Http404() if not _job_run_dir_usable(job): return Response( {"detail": "仅可对已成功或已终止且含 run_dir 的任务导出"}, status=status.HTTP_400_BAD_REQUEST, ) fmt = (request.query_params.get("fmt") or "docx").strip().lower() kind = (request.query_params.get("kind") or "report").strip().lower() if kind != "report": return Response( {"detail": "GET 仅支持 kind=report;策略稿请用 POST 提交 markdown"}, status=status.HTTP_400_BAD_REQUEST, ) if fmt not in ("docx", "pdf"): return Response( {"detail": "fmt 须为 docx 或 pdf"}, status=status.HTTP_400_BAD_REQUEST, ) path = Path(job.run_dir) / "competitor_analysis.md" if not path.is_file(): return Response( {"detail": "报告文件不存在,请先在「报告生成」重新生成"}, status=status.HTTP_404_NOT_FOUND, ) md = path.read_text(encoding="utf-8") asset_root = Path(job.run_dir).resolve() try: if fmt == "docx": data = markdown_to_docx_bytes(md, asset_root=asset_root) ct = "application/vnd.openxmlformats-officedocument.wordprocessingml.document" fn = f"job_{pk}_competitor_report.docx" else: data = markdown_to_pdf_bytes(md, asset_root=asset_root) ct = "application/pdf" fn = f"job_{pk}_competitor_report.pdf" except ValueError as e: return Response({"detail": str(e)}, status=status.HTTP_503_SERVICE_UNAVAILABLE) resp = HttpResponse(data, content_type=ct) resp["Content-Disposition"] = f'attachment; filename="{fn}"' return resp def post(self, request, pk: int): if not (settings.LOW_GI_PROJECT_ROOT or "").strip(): return Response( {"detail": "请先在 market_assistant/.env 中配置 LOW_GI_PROJECT_ROOT"}, status=status.HTTP_503_SERVICE_UNAVAILABLE, ) job = PipelineJob.objects.filter(pk=pk).first() if not job: raise Http404() if not _job_run_dir_usable(job): return Response( {"detail": "仅可对已成功或已终止且含 run_dir 的任务导出"}, status=status.HTTP_400_BAD_REQUEST, ) body = request.data if isinstance(request.data, dict) else {} kind = (body.get("kind") or "strategy").strip().lower() fmt = (body.get("fmt") or "docx").strip().lower() md = (body.get("markdown") or "").strip() if kind != "strategy": return Response( {"detail": "POST 仅支持 kind=strategy"}, status=status.HTTP_400_BAD_REQUEST, ) if not md: return Response( {"detail": "markdown 不能为空"}, status=status.HTTP_400_BAD_REQUEST, ) if fmt not in ("docx", "pdf"): return Response( {"detail": "fmt 须为 docx 或 pdf"}, status=status.HTTP_400_BAD_REQUEST, ) try: if fmt == "docx": data = markdown_to_docx_bytes(md) ct = "application/vnd.openxmlformats-officedocument.wordprocessingml.document" fn = f"job_{pk}_strategy_draft.docx" else: data = markdown_to_pdf_bytes(md) ct = "application/pdf" fn = f"job_{pk}_strategy_draft.pdf" except ValueError as e: return Response({"detail": str(e)}, status=status.HTTP_503_SERVICE_UNAVAILABLE) resp = HttpResponse(data, content_type=ct) resp["Content-Disposition"] = f'attachment; filename="{fn}"' return resp class JobReportAssetView(APIView): """安全读取 ``run_dir/report_assets/*`` 下的 PNG 等(供 Markdown 预览插图)。""" def get(self, request, pk: int): job = PipelineJob.objects.filter(pk=pk).first() if not job or not _job_run_dir_usable(job): raise Http404() rel = (request.query_params.get("path") or "").strip().replace("\\", "/") if not rel or ".." in Path(rel).parts: return Response( {"detail": "path 非法"}, status=status.HTTP_400_BAD_REQUEST, ) base = Path(job.run_dir).resolve() assets_root = (base / "report_assets").resolve() target = (base / rel).resolve() try: target.relative_to(assets_root) except ValueError: raise Http404() if not target.is_file(): raise Http404() ctype, _ = mimetypes.guess_type(str(target)) return FileResponse( target.open("rb"), content_type=ctype or "application/octet-stream", ) def _dataset_job(pk: int) -> PipelineJob: job = PipelineJob.objects.filter(pk=pk).first() if not job: raise Http404() return job def _read_page_params(request) -> tuple[int, int]: page_size = min(max(int(request.query_params.get("page_size", 50)), 1), 200) page = max(int(request.query_params.get("page", 1)), 1) return page, page_size class JobDatasetSummaryView(APIView): """任务在库中的搜索/详情/评价行数(入库后可用)。""" def get(self, request, pk: int): job = _dataset_job(pk) return Response( { "job_id": job.id, "keyword": job.keyword, "status": job.status, "search_rows": JdJobSearchRow.objects.filter(job=job).count(), "detail_rows": JdJobDetailRow.objects.filter(job=job).count(), "comment_rows": JdJobCommentRow.objects.filter(job=job).count(), "merged_rows": JdJobMergedRow.objects.filter(job=job).count(), "search_columns": search_columns_for_api(job), "detail_columns": detail_columns_for_api(job), "comment_columns": comment_columns_for_api(job), "merged_columns": merged_columns_for_api(job), } ) class JobDatasetSearchView(APIView): def get(self, request, pk: int): job = _dataset_job(pk) page, page_size = _read_page_params(request) qs = JdJobSearchRow.objects.filter(job=job) total = qs.count() start = (page - 1) * page_size rows = qs.order_by("row_index")[start : start + page_size] return Response( { "total": total, "page": page, "page_size": page_size, "results": [search_row_to_dict(r) for r in rows], } ) class JobDatasetDetailView(APIView): def get(self, request, pk: int): job = _dataset_job(pk) page, page_size = _read_page_params(request) qs = JdJobDetailRow.objects.filter(job=job) total = qs.count() start = (page - 1) * page_size rows = qs.order_by("row_index")[start : start + page_size] return Response( { "total": total, "page": page, "page_size": page_size, "results": [detail_row_to_dict(r) for r in rows], } ) class JobDatasetCommentsView(APIView): def get(self, request, pk: int): job = _dataset_job(pk) page, page_size = _read_page_params(request) sku_id = (request.query_params.get("sku_id") or "").strip() qs = JdJobCommentRow.objects.filter(job=job) if sku_id: qs = qs.filter(sku_id=sku_id) total = qs.count() start = (page - 1) * page_size rows = qs.order_by("row_index")[start : start + page_size] return Response( { "total": total, "page": page, "page_size": page_size, "sku_filter": sku_id or None, "results": [comment_row_to_dict(r) for r in rows], } ) class JobDatasetMergedView(APIView): def get(self, request, pk: int): job = _dataset_job(pk) page, page_size = _read_page_params(request) qs = JdJobMergedRow.objects.filter(job=job) total = qs.count() start = (page - 1) * page_size rows = qs.order_by("row_index")[start : start + page_size] return Response( { "total": total, "page": page, "page_size": page_size, "results": [merged_row_to_dict(r) for r in rows], } ) class JobDatasetExportView(APIView): """下载:kind=search|detail|comments|merged|all,export_fmt=json|csv|xlsx。 ``merged``:库内合并宽表行(与 lean 合并 CSV 列一致,入库后导出)。 注意:勿使用查询参数名 ``format``,DRF 会将其用于内容协商,非 json 时易在进视图前 404。 """ def get(self, request, pk: int): job = _dataset_job(pk) kind = (request.query_params.get("kind") or "search").strip().lower() fmt = (request.query_params.get("export_fmt") or "json").strip().lower() if kind not in ("search", "detail", "comments", "all", "merged"): return Response( {"detail": "kind 须为 search / detail / comments / all / merged"}, status=status.HTTP_400_BAD_REQUEST, ) if fmt not in ("json", "csv", "xlsx"): return Response( {"detail": "export_fmt 须为 json / csv / xlsx"}, status=status.HTTP_400_BAD_REQUEST, ) try: if fmt == "json": data, filename = build_json_bytes(job=job, kind=kind) resp = HttpResponse(data, content_type="application/json; charset=utf-8") elif fmt == "csv": data, filename = build_csv_bytes(job=job, kind=kind) resp = HttpResponse(data, content_type="text/csv; charset=utf-8") else: data, filename = build_xlsx_bytes(job=job, kind=kind) resp = HttpResponse( data, content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", ) except ValueError as e: return Response({"detail": str(e)}, status=status.HTTP_400_BAD_REQUEST) resp["Content-Disposition"] = f'attachment; filename="{filename}"' return resp class JdProductListView(APIView): """已入库 SKU 分页列表;支持按标题/SKU/品牌模糊搜、按合并表中的 pipeline_keyword 精确筛。""" def get(self, request): limit = min(max(int(request.query_params.get("limit", 50)), 1), 200) offset = max(int(request.query_params.get("offset", 0)), 0) q = (request.query_params.get("q") or "").strip() kw = (request.query_params.get("keyword") or "").strip() qs = JdProduct.objects.annotate(snapshot_count=Count("snapshots")) if q: qs = qs.filter( Q(sku_id__icontains=q) | Q(title__icontains=q) | Q(detail_brand__icontains=q) ) if kw: qs = qs.filter(current_payload__pipeline_keyword=kw) total = qs.count() page = qs.order_by("-updated_at")[offset : offset + limit] return Response( { "total": total, "limit": limit, "offset": offset, "results": JdProductListSerializer(page, many=True).data, } ) class JdProductDetailView(APIView): def get(self, request, sku_id: str): platform = (request.query_params.get("platform") or "jd").strip() or "jd" obj = ( JdProduct.objects.annotate(snapshot_count=Count("snapshots")) .filter(platform=platform, sku_id=sku_id) .first() ) if not obj: raise Http404() return Response(JdProductDetailSerializer(obj).data) class JdProductSnapshotListView(APIView): """某 SKU 的历史快照列表(不含整包 payload,便于时间线)。""" def get(self, request, sku_id: str): platform = (request.query_params.get("platform") or "jd").strip() or "jd" product = JdProduct.objects.filter(platform=platform, sku_id=sku_id).first() if not product: raise Http404() snaps = ( product.snapshots.select_related("job") .order_by("-captured_at") .all() ) return Response( { "platform": platform, "sku_id": sku_id, "count": snaps.count(), "results": JdProductSnapshotBriefSerializer(snaps, many=True).data, } ) class JdProductSnapshotDetailView(APIView): """单条快照完整 payload,用于历史回放与字段级对比。""" def get(self, request, pk: int): snap = ( JdProductSnapshot.objects.select_related("product", "job") .filter(pk=pk) .first() ) if not snap: raise Http404() return Response(JdProductSnapshotDetailSerializer(snap).data) @method_decorator(csrf_exempt, name="dispatch") class JobImportMergedView(APIView): """将指定任务目录下搜索/详情/评价 CSV 与合并表重新写入数据库(幂等:先清空该任务三类行再全量插入)。""" def post(self, request, pk: int): job = PipelineJob.objects.filter(pk=pk).first() if not job: raise Http404() if job.status != JobStatus.SUCCESS or not (job.run_dir or "").strip(): return Response( {"detail": "仅可对已成功且含 run_dir 的任务执行入库"}, status=status.HTTP_400_BAD_REQUEST, ) try: stats = ingest_job_full(job) except FileNotFoundError as e: return Response({"detail": str(e)}, status=status.HTTP_400_BAD_REQUEST) return Response(stats)