---
aliases:
cssclasses:
tags:
  - "#django"
  - "#python"
  - "#mysql"
kanban-plugin:
title:
source:
author:
published:
created:
description:
link:
---
#django #python #mysql #mariadb #project #tiktok 

```table-of-contents
title: 
style: nestedList # TOC style (nestedList|nestedOrderedList|inlineFirstLevel)
minLevel: 0 # Include headings from the specified level
maxLevel: 0 # Include headings up to the specified level
include: 
exclude: 
includeLinks: true # Make headings clickable
hideWhenEmpty: false # Hide TOC if no headings are found
debugInConsole: false # Print debug info in Obsidian console
```

## TikTok PM - Python Django 项目
### 一期规划
- [x] 如果数据从JSON导入，那么 source ID不为空，在admin页面里source id需要变成read-only不可编辑 ✅ 2025-11-21
- [x] description, description1, description2 这些字段需要用富文本框进行编辑和显示 ✅ 2025-11-24
- [x] 在product image和product variation section， 能根据zipline_url直接显示图片缩略图 ✅ 2025-11-21
- [x] 点击图片可以方法看原始尺寸 ✅ 2025-11-21
- [ ] 调整zipline_url编辑框尺寸
- [x] 缺少specification字段 ✅ 2025-11-21
- [ ] spcifications字段可以根据JSON内容，分别提供编辑框编辑JSON里每一个name,value字段, 默认折叠这个section不显示
- [x] created_at和updated_at都没有值，在没有使用ORM之前该字段没有长度限制，现在是6。如何修改可以让这两个字段自动带上日期时间戳？ ✅ 2025-11-21
- [x] 修改过滤条件按，在product list页面可以根据store name进行过滤，去除现有的一些过滤条件比如available, in stock, created at etc. ✅ 2025-11-24
- [x] 增加prodcut_reviews table. ✅ 2025-11-21
- [x] 在product list页面可以批量删除product,包含关联的product_images, product_videos, product_variations, product_reviews ✅ 2025-11-21
- [ ] 在product list页面可以高亮final price在10~50之间的产品，整条记录高亮，以区别其他的产品，10~50这个区间可以通过过滤条件来修改？
- [ ] 按不同颜色显示不同sold数量的商品
- [ ] 在base info section显示第一张product image
- [x] product list页面，显示每个产品的第一张图片的缩略图， 点击可以打开放大。 ✅ 2025-11-21
- [x] 将整个项目push到Github个人账户下 ✅ 2025-11-22
- [x] 把下载的JSON存放在某个特定目录下 data\json ✅ 2025-11-24
- [x] 根据decription detail生成HTML文件存放在data\html下 ✅ 2025-11-24
- [x] 在产品详情页面增加了view html链接，可以直接打开descrtpion detail html ✅ 2025-11-24
- [ ] 继续优化product fetch页面，可以根据不同的抓取方式发送不同的request
	- [x] TikTok Shop collect by URL, ✅ 2025-11-24
	- [ ] TikTok Shop Discover by keywords
	- [ ] TikTok Shop Discover by profile url
	- [ ] TikTok Shop Discover by category
	- [ ] TikTok Shop Discover by shop
- [x] 抓取数据默认设置一次性抓取上限值，放在settings里可以配置
- [ ] 用AI设计来分析抓取的数据，并在superset里实现
- [ ] 在products表里记录snapshot name这样可以回溯原始的JSON
- [ ] 研究Djang可否直接把model导出成JSON（单个产品）以便于被n8n读取

### 二期规划
- [ ] 通过淘宝开放平台拍立淘API来实现，图找商品的功能， 目前看起来浏览器插件可完成
- [x] 在应用里实现按产品source ID来调用Bright Data API获取JSON， 并导入数据库 ✅ 2025-11-24

## 常用命令

### 开发环境：

```
#创建Django Admin登录超级用户
python manage.py createsuperuser

python manage.py makemigrations

python manage.py migrate

python manage.py import_json_data

```

### 生产环境


```
docker compose exec web python manage.py createsuperuser

docker compose exec web python manage.py makemigrations

docker compose exec web python manage.py migrate

docker compose exec web python manage.py import_json_data

```





## 0. 项目规划
1. **项目初始化:** `django-admin startproject tiktok_pm` & `python manage.py startapp products`
    
2. **配置数据库:** 在 `settings.py` 中配置 MySQL 连接。
    
3. **定义模型:** 在 `products/models.py` 中编写 ORM 模型 (参考 2.1)。
    
4. **运行迁移:** `python manage.py makemigrations` & `python manage.py migrate`
    
5. **注册 Admin:** 在 `products/admin.py` 中注册模型并定制搜索/过滤/列表展示。
    
6. **安装 DRF:** `pip install djangorestframework`
    
7. **编写 API:** 在 `products/serializers.py` 和 `products/views.py` 中编写 DRF 组件。
    
8. **部署:** 使用 Gunicorn/uWSGI + Nginx/Apache 进行生产环境部署。

## 1. 项目准备与软件安装

在开始 Django 项目之前，您需要准备 **Python** 环境和 **MySQL** 数据库，并安装必要的 Python 库。

### 1.1. 核心软件安装

|**软件**|**用途**|**备注**|
|---|---|---|
|**Python 3.8+**|编程语言和运行环境|确保安装了较新的版本。|
|**pip**|Python 包管理器|通常随 Python 一起安装。|
|**MySQL Server**|数据库系统|存储所有产品数据 (与您提供的 SQL 文件保持一致)。|
|**Virtual Environment (推荐)**|隔离项目依赖|确保每个项目有独立的依赖环境，避免冲突|
### 1.2. Python 依赖库安装

我们需要 Django 及其配套的数据库驱动和 RESTful API 框架。

1. **打开您的命令行工具** (如 Terminal, CMD, 或 PowerShell)。
    
2. **创建并激活虚拟环境 (强烈推荐):**
    
    Bash
    
    ```
    # 1. 创建名为 'venv' 的虚拟环境
    python -m venv venv
    
    # 2. 激活虚拟环境 (Windows)
    venv\Scripts\activate
    
    # 3. 激活虚拟环境 (macOS/Linux)
    source venv/bin/activate
    ```
    
    _(激活后，您会在命令行前看到 `(venv)` 标识。)_
    
3. **安装核心依赖库:**
    
    Bash
    
    ```
    pip install Django djangorestframework mysqlclient
    ```
    
    - **`Django`**: 核心 Web 框架。
        
    - **`djangorestframework`**: 用于构建 API 接口 (方便 n8n 集成)。
        
    - **`mysqlclient`**: Python 连接 MySQL 数据库的驱动。

## 2. 项目初始化与配置

Django 采用“项目 (Project)”和“应用 (App)”的两级结构。

### 2.1. 创建 Django 项目 (Project)

项目是整个应用的总配置和环境设置。

1. **在命令行中执行以下命令创建项目：**
    
    Bash
    
    ```
    # 命名为 tiktok_pm_project
    django-admin startproject tiktok_pm_project .
    ```
    
    _(注意末尾的 `.` 表示在当前目录下创建，结构更清晰。)_
    
    执行后，您的目录结构如下：
    
    ```
    tiktok_pm_project/
    ├── manage.py
    └── tiktok_pm_project/
        ├── __init__.py
        ├── asgi.py
        ├── settings.py    # 核心配置文件
        ├── urls.py        # 根路由配置
        └── wsgi.py
    ```
    

### 2.2. 创建 Django 应用 (App)

应用是实现具体功能 (如产品管理) 的模块。

1. **在命令行中创建应用：**
    
    Bash
    
    ```
    # 命名为 products，专门处理产品相关逻辑
    python manage.py startapp products
    ```
    
    您的目录结构更新为：
    
    ```
    tiktok_pm_project/
    ├── manage.py
    ├── products/              # 新建的应用目录
    │   ├── migrations/
    │   ├── __init__.py
    │   ├── admin.py           # 配置管理后台
    │   ├── apps.py
    │   ├── models.py          # 定义数据库模型 (ORM)
    │   ├── tests.py
    │   └── views.py           # 编写业务逻辑 (CRUD)
    └── tiktok_pm_project/
        # ... (项目配置文件)
    ```
    

### 2.3. 基础配置 (`settings.py`)

打开 `tiktok_pm_project/settings.py` 文件，进行以下关键配置：

#### **A. 注册应用和框架**

找到 `INSTALLED_APPS` 列表，添加您创建的 `products` 应用和 `djangorestframework`。

Python

```
# tiktok_pm_project/settings.py

INSTALLED_APPS = [
    # Django 默认自带
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    
    # 第三方框架
    'rest_framework',         # DRF (用于API)
    
    # 您的应用
    'products',               # 注册您的产品应用
]
```

#### **B. 配置数据库 (MySQL)**

找到 `DATABASES` 配置项，替换为您的 MySQL 连接信息。

Python

```
# tiktok_pm_project/settings.py

DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.mysql',  # 数据库引擎
        'NAME': 'tiktok_product_db',          # 您的数据库名称 (需提前创建)
        'USER': 'your_mysql_user',            # 您的MySQL用户名
        'PASSWORD': 'your_mysql_password',    # 您的MySQL密码
        'HOST': '127.0.0.1',                  # 数据库地址
        'PORT': '3306',                       # 数据库端口
        'OPTIONS': {
            'init_command': "SET sql_mode='STRICT_TRANS_TABLES'",
            'charset': 'utf8mb4',             # 确保支持emoji和复杂字符
        }
    }
}
```

### 2.4. 首次迁移与创建管理员

1. **应用配置的迁移:**
    
    Bash
    
    ```
    python manage.py migrate
    ```
    
    _(这会创建 Django 自身需要的用户、会话等基础表。)_
    
2. **创建超级管理员账户 (用于登录 Admin 后台):**
    
    Bash
    
    ```
    python manage.py createsuperuser
    ```
    
    _(输入用户名、邮箱和密码。)_
    ``` bash
    #superuser
    
    username = admin
    password = Abcd_1234
    ```

### 2.5. 运行项目

1. **启动开发服务器:**
    
    Bash
    
    ```
    python manage.py runserver
    ```
    
1. **访问 Admin 后台:** 在浏览器中打开 **`http://127.0.0.1:8000/admin/`**，使用您刚刚创建的超级管理员账户登录。


## 3. 定义模型

### 3.1：保存 `products/models.py` 文件
``` python
from django.db import models  
  
# Create your models here.  
from django.db import models  
  
  
class Product(models.Model):  
    """  
    对应 tables.sql 中的 products 表。  
    存储 TikTok 平台产品的核心信息。  
    """  
    # 基础信息  
    # id BIGINT AUTO_INCREMENT PRIMARY KEY 已经由 Django 自动创建  
    source_id = models.CharField(  
        max_length=64,  
        unique=True,  
        db_index=True,  # 确保查询效率  
        verbose_name="TikTok Source ID"  
    )  
    url = models.TextField(blank=True, null=True)  
    title = models.TextField(blank=True, null=True)  
  
    # 描述字段 (使用 TextField 对应 MySQL 的 LONGTEXT/TEXT)    description = models.TextField(blank=True, null=True)  
    description_1 = models.TextField(blank=True, null=True)  
    description_2 = models.TextField(blank=True, null=True)  
    desc_detail = models.TextField(blank=True, null=True)  
    desc_detail_1 = models.TextField(blank=True, null=True)  
    desc_detail_2 = models.TextField(blank=True, null=True)  
  
    # 状态字段  
    available = models.BooleanField(blank=True, null=True)  
    In_stock = models.BooleanField(blank=True, null=True)  # 字段名与SQL文件保持一致  
  
    # 价格字段  
    currency = models.CharField(max_length=16, blank=True, null=True)  
    initial_price = models.DecimalField(max_digits=10, decimal_places=2, blank=True, null=True)  
    final_price = models.DecimalField(  
        max_digits=10,  
        decimal_places=2,  
        blank=True,  
        null=True,  
        db_index=True  # 对应 idx_price    )  
    discount_percent = models.DecimalField(max_digits=10, decimal_places=2, blank=True, null=True)  
  
    # 价格范围字段  
    initial_price_low = models.DecimalField(max_digits=10, decimal_places=2, blank=True, null=True)  
    initial_price_high = models.DecimalField(max_digits=10, decimal_places=2, blank=True, null=True)  
    final_price_low = models.DecimalField(max_digits=10, decimal_places=2, blank=True, null=True)  
    final_price_high = models.DecimalField(max_digits=10, decimal_places=2, blank=True, null=True)  
  
    # 销售和位置  
    sold = models.IntegerField(blank=True, null=True, db_index=True)  # 对应 idx_sold    position = models.IntegerField(blank=True, null=True)  
  
    # JSON 字段 (存储复杂的结构化数据)  
    colors = models.JSONField(blank=True, null=True)  
    sizes = models.JSONField(blank=True, null=True)  
    shipping_fee = models.JSONField(blank=True, null=True)  
    specifications = models.JSONField(blank=True, null=True)  
    videos = models.JSONField(blank=True, null=True)  
    related_videos = models.JSONField(blank=True, null=True)  
  
    # 视频链接  
    video_link = models.TextField(blank=True, null=True)  
  
    # 分类和卖家信息  
    category = models.CharField(max_length=255, blank=True, null=True, db_index=True)  # 对应 idx_category    category_url = models.TextField(blank=True, null=True)  
    seller_id = models.CharField(max_length=128, blank=True, null=True, db_index=True)  # 对应 idx_seller    store_name = models.CharField(max_length=255, blank=True, null=True)  
  
    # 评价和性能  
    prodct_rating = models.JSONField(blank=True, null=True)  
    promotion_items = models.JSONField(blank=True, null=True)  
    shop_performance_metrics = models.JSONField(blank=True, null=True)  
  
    # 导入时间戳和原始数据  
    timestamp = models.DateTimeField(blank=True, null=True)  
    input = models.JSONField(blank=True, null=True)  
    raw_json = models.JSONField(blank=True, null=True)  
  
    # Django 自动管理的时间戳  
    created_at = models.DateTimeField(auto_now_add=True)  
    updated_at = models.DateTimeField(auto_now=True)  
  
    class Meta:  
        # 定义模型的复数名称，让Admin后台显示更友好  
        verbose_name = "TikTok Product"  
        verbose_name_plural = "TikTok Products"  
        # 显式指定使用的表名（如果和模型名不一致，可以配置）  
        db_table = 'products'  
  
    def __str__(self):  
        """返回对象在Admin后台的显示名称"""  
        return f"{self.source_id}: {self.title[:50]}..."  
  
  
# ------------------------------------------------------------  
# 提示：其他关联表 (product_images, product_variations 等)  
# 需要您参照此格式，继续在 models.py 文件中创建，并设置 ForeignKey 关联。  
# ------------------------------------------------------------  
  
  
# ------------------------------------------------------------  
# 1. Table: product_images  
# ------------------------------------------------------------  
class ProductImage(models.Model):  
    product = models.ForeignKey(  
        Product,  
        on_delete=models.CASCADE,  # 对应 ON DELETE CASCADE        related_name='images',  # 方便从 Product 对象反向查询所有图片：product.images.all()  
        verbose_name="关联产品"  
    )  
    image_type = models.TextField(blank=True, null=True)  
    original_url = models.TextField(blank=True, null=True)  
    zipline_url = models.TextField(blank=True, null=True)  
  
    created_at = models.DateTimeField(auto_now_add=True)  
  
    class Meta:  
        db_table = 'product_images'  
        verbose_name_plural = "产品图片"  
  
    def __str__(self):  
        return f"Image for {self.product.source_id}"  
  
  
# ------------------------------------------------------------  
# 2. Table: product_videos  
# ------------------------------------------------------------  
class ProductVideo(models.Model):  
    product = models.ForeignKey(  
        Product,  
        on_delete=models.CASCADE,  # 对应 ON DELETE CASCADE        related_name='videos_list',  # 注意：避免与 Product.videos (JSONField) 字段名冲突  
        verbose_name="关联产品"  
    )  
    video_type = models.TextField(blank=True, null=True)  
    original_url = models.TextField(blank=True, null=True)  
    zipline_url = models.TextField(blank=True, null=True)  
  
    created_at = models.DateTimeField(auto_now_add=True)  
  
    class Meta:  
        db_table = 'product_videos'  
        verbose_name_plural = "产品视频"  
  
    def __str__(self):  
        return f"Video for {self.product.source_id}"  
  
  
# ------------------------------------------------------------  
# 3. Table: product_variations  
# ------------------------------------------------------------  
class ProductVariation(models.Model):  
    product = models.ForeignKey(  
        Product,  
        on_delete=models.CASCADE,  # 对应 ON DELETE CASCADE        related_name='variations',  # 方便从 Product 对象反向查询所有变体：product.variations.all()  
        verbose_name="关联产品"  
    )  
  
    sku = models.CharField(max_length=128, blank=True, null=True, db_index=True)  # 对应 idx_variation_sku    sku_sales_props = models.JSONField(blank=True, null=True)  
  
    stock = models.IntegerField(blank=True, null=True)  
    purchase_limit = models.IntegerField(blank=True, null=True)  
  
    initial_price = models.DecimalField(max_digits=10, decimal_places=2, blank=True, null=True)  
    final_price = models.DecimalField(max_digits=10, decimal_places=2, blank=True, null=True)  
    currency = models.CharField(max_length=16, blank=True, null=True)  
    discount_percent = models.DecimalField(max_digits=10, decimal_places=2, blank=True, null=True)  
  
    image_original_url = models.TextField(blank=True, null=True)  
    image_zipline_url = models.TextField(blank=True, null=True)  
  
    created_at = models.DateTimeField(auto_now_add=True)  
    # auto_now=True 对应 ON UPDATE CURRENT_TIMESTAMP    updated_at = models.DateTimeField(auto_now=True)  
  
    class Meta:  
        db_table = 'product_variations'  
        verbose_name_plural = "产品变体"  
  
    def __str__(self):  
        return f"Variation SKU: {self.sku} ({self.product.source_id})"

```

在 Django 中，要将您对 `models.py` 文件中 ORM 模型的修改同步到数据库，您必须使用 **数据库迁移 (Migrations)** 系统。

Django 会自动检测您对模型（例如添加、修改或删除字段）所做的更改，并为您生成必要的 SQL 语句。

对于您修改 `created_at = models.DateTimeField(auto_now_add=True)` 为 `created_at = models.DateTimeField(auto_now_add=True, null=True)` 的操作，需要执行以下两个步骤：

创建迁移文件 (Make Migrations)

这个命令会扫描您的 `products/models.py` 文件，检测到字段的变化，并在 `products/migrations/` 目录下创建一个新的 Python 文件，记录这个变更（即添加 `null=True` 属性）。

Bash

```
# 在项目的根目录下（manage.py 所在目录）执行
python manage.py makemigrations products
```

**执行结果示例：**

```
Migrations for 'products':
  products/migrations/000x_..._creatd_at_nullable.py # 文件名可能不同
    - Alter field created_at on product
```

执行迁移到数据库 (Migrate)

这个命令会读取上一步生成的迁移文件，并根据其中的指令，执行相应的 SQL 命令来修改您 MySQL 数据库中现有表的结构。

Bash

```
# 执行所有待处理的迁移
python manage.py migrate
```

**执行结果示例：**

```
Operations to perform:
  Apply all migrations: admin, auth, contenttypes, sessions, products
Running migrations:
  Applying products.000x_..._creatd_at_nullable... OK
```

### 3.2 总结

无论是修改字段类型、添加新字段，还是像您这样修改字段约束（例如从非空改为可空 `null=True`），都必须遵循这两步流程：

1. **`makemigrations <app_name>`**：记录模型更改。
    
2. **`migrate`**：应用更改到数据库。
    

**注意：** 您修改 `created_at` 字段并添加 `null=True` 后，如果该字段之前已经有数据，数据库本身可能不会立即受到影响（因为 `auto_now_add=True` 意味着它在创建时总会被赋值）。但将 `null=True` 添加到模型中是正确的做法，尤其是在您未来可能手动操作这个字段时，它告诉 Django 数据库该字段是可空的。

如果您的更改是添加了一个**非空**的新字段，Django 会在 `makemigrations` 步骤中询问您如何处理现有行的数据。

- 您可以提供一个默认值。
    
- 或者在迁移文件中手动设置 `default=` 参数。
## 4 运行迁移

您需要再次执行以下两个命令，将新定义的 ORM 模型转换为数据库表：

### 4.1： **创建新的迁移文件 (Migration):**

```
python manage.py makemigrations products
```

(Django 会检测到 `ProductImage`, `ProductVideo`, 和 `ProductVariation` 三个新模型。)_

### 4.2： **执行迁移到数据库:**
    
    
```
python manage.py migrate
```
    
(这会在您的 MySQL 数据库中创建 `product_images`, `product_videos`, 和 `product_variations` 三个新表，并设置好外键关系。)_


## 5 注册 Admin

Django Admin 是一个基于模型自动生成的管理界面，非常适合作为管理员工具（Admin Management Tool）。

您需要编辑 **`products/admin.py`** 文件。

### 5.1. 配置 Admin 后台

打开您的 **`products/admin.py`** 文件，并按以下步骤添加代码。

``` python
from django.contrib import admin  
  
# Register your models here.  
  
from django.contrib import admin  
# --- 导入富文本字段 ---from tinymce.widgets import TinyMCE  
# --- 导入 forms 模块 ---from django import forms  
# --- 新增导入：用于渲染HTML ---  
from django.utils.safestring import mark_safe  
  
from .models import (  
    Product,  
    ProductImage,  
    ProductVideo,  
    ProductVariation  
)  
  
# ----------------------------------------------------------------------  
# 核心类: 定义富文本表单 (用于 ProductAdmin)# ----------------------------------------------------------------------  
class ProductAdminForm(forms.ModelForm):  
    """  
    定义 Product 模型的自定义表单，指定字段使用 TinyMCE 插件。  
    """    class Meta:  
        model = Product  
        fields = '__all__'  
        # 将描述字段指定为 TinyMCE 富文本 widget        widgets = {  
            'description': TinyMCE(attrs={'cols': 80, 'rows': 20}),  
            'description_1': TinyMCE(attrs={'cols': 80, 'rows': 15}),  
            'description_2': TinyMCE(attrs={'cols': 80, 'rows': 10}),  
        }  
  
# ----------------------------------------------------------------------  
# 辅助类: 用于在 Product 详情页内嵌显示关联信息  
# ----------------------------------------------------------------------  
  
# 1. 产品图片 (ProductImages)class ProductImageInline(admin.TabularInline):  
    """在 Product 编辑页内嵌展示产品图片"""  
    model = ProductImage  
    # 指定显示的字段  
    fields = ('image_type', 'original_url', 'zipline_url', 'image_preview', 'created_at')  
    readonly_fields = ('image_preview', 'created_at',)  
    extra = 1  # 额外显示一行空白表单供新增  
  
    def image_preview(self, obj):  
        if obj.zipline_url:  
            return mark_safe(f'<img src="{obj.zipline_url}" style="max-width: 100px; max-height: 100px;" />')  
        return "No Image"  
  
    image_preview.short_description = 'Preview'  
  
  
# 2. 产品视频 (ProductVideos)class ProductVideoInline(admin.TabularInline):  
    """在 Product 编辑页内嵌展示产品视频"""  
    model = ProductVideo  
    fields = ('video_type', 'original_url', 'zipline_url', 'created_at')  
    readonly_fields = ('created_at',)  
    extra = 1  
  
  
# 3. 产品变体 (ProductVariations)class ProductVariationInline(admin.TabularInline):  
    """在 Product 编辑页内嵌展示产品变体/SKU"""  
    model = ProductVariation  
    # fields 列表添加 'image_preview'    fields = (  
        'sku', 'stock', 'purchase_limit', 'final_price', 'currency',  
        'image_original_url', 'image_preview', 'updated_at' # <-- 添加 image_preview    )  
    # readonly_fields 列表添加 'image_preview'    readonly_fields = ('image_preview', 'updated_at',)  
    extra = 1  
  
    def image_preview(self, obj):  
        """根据 image_zipline_url 生成图片预览"""  
        if obj.image_zipline_url:  
            return mark_safe(f'<img src="{obj.image_zipline_url}" style="max-width: 100px; max-height: 100px;" />')  
        return "No Image"  
    image_preview.short_description = 'Preview'  
    # 可以通过设置 max_num 来限制变体的数量，这里不设置，保持可拓展性  
  
  
# ----------------------------------------------------------------------  
# 核心类: 定制 Product 模型的管理界面 (CRUD)# ----------------------------------------------------------------------  
  
@admin.register(Product)  
class ProductAdmin(admin.ModelAdmin):  
    # --- 新增: 引用自定义表单 ---    form = ProductAdminForm  
  
    # =========================================================  
    # 列表页定制 (list view)    # =========================================================  
    # 1. 列表显示的字段 (要求 3.2: 查询)  
    list_display = (  
        'source_id',  
        'title_short',  
        'store_name',  
        'final_price',  
        'sold',  
        'available',  
        'In_stock',  
        'created_at'  
    )  
  
    # 2. 链接到编辑页的字段  
    list_display_links = ('source_id', 'title_short')  
  
    # 3. 快速关键词搜索 (要求 3.8: 快速搜索)  
    # 配置搜索栏将根据哪些字段进行模糊查询 (LIKE)    search_fields = (  
        'source_id',  
        'title',  
        'store_name',  
        'category',  
        'seller_id'  
    )  
  
    # 4. 多条件过滤搜索 (要求 3.9: 多条件过滤)  
    # 配置侧边栏过滤器，允许用户根据这些字段筛选数据  
    list_filter = (  
        'available',  
        'In_stock',  
        'category',  
        'currency',  
        'created_at',  
        'final_price'  # 价格范围过滤可能需要安装第三方库，这里先用默认过滤器  
    )  
  
    # 5. 列表页可编辑字段  
    list_editable = ('available', 'In_stock',)  
  
    # 6. 每页显示数量  
    list_per_page = 25  
  
    # 7. 优化显示 title 字段  
    def title_short(self, obj):  
        """截取 title 字段，使其在列表页显示更简洁"""  
        return obj.title[:50] + '...' if obj.title and len(obj.title) > 50 else obj.title  
  
    title_short.short_description = 'Title'  # 定义列的标题  
  
    # =========================================================  
    # 详情页定制 (Change/Add view)    # =========================================================  
    # 1. 字段分组显示  
    fieldsets = (  
        ('Product Base Info', {  
            'fields':(  
                ('source_id', 'title', 'url', 'category', 'category_url', 'position'),  
                ('colors', 'sizes', 'shipping_fee', 'specifications'),  
            )  
        }),  
        ('Sell Status', {  
            'fields': (  
                ('available', 'In_stock'),  
                ('sold',)  
            ),  
        }),  
        ('Price Settings', {  
            # 'classes': ('collapse',), # 默认折叠该部分  
            'fields': (  
                ('currency', 'initial_price', 'final_price', 'discount_percent'),  
                ('initial_price_low', 'initial_price_high'),  
                ('final_price_low', 'final_price_high'),  
            ),  
        }),  
        ('Seller Info', {  
            'fields': ('seller_id', 'store_name', 'shop_performance_metrics'),  
        }),  
        ('Descriptions', {  
            'fields': ('description', 'description_1', 'description_2'),  
            # 备注: 此处需要集成富文本编辑器 (如 TinyMCE) 来保留格式  
        }),  
        ('JSON Raw Data', {  
            'classes': ('collapse',),  # 默认折叠，减少页面冗余  
            'fields': ('input', 'raw_json'),  
        }),  
    )  
  
    # 2. 内联关联模型 (显示关联的图片、视频、变体)  
    inlines = [  
        ProductVariationInline,  
        ProductImageInline,  
        ProductVideoInline  
    ]  
  
    # 3. 不允许修改的字段  
    readonly_fields = ('source_id', 'created_at', 'updated_at')  
  
  
# ----------------------------------------------------------------------  
# 注册其他独立关联模型 (如果需要独立管理和搜索)  
# ----------------------------------------------------------------------  
  
# 尽管 ProductVariation 已内嵌，但最好也注册独立管理  
@admin.register(ProductVariation)  
class ProductVariationAdmin(admin.ModelAdmin):  
    list_display = ('sku', 'product', 'stock', 'final_price', 'updated_at')  
    list_filter = ('stock', 'final_price')  
    search_fields = ('sku', 'product__source_id')  # 支持跨模型搜索  
    raw_id_fields = ('product',)  # 使用ID输入框选择产品，提升性能
```


## 6. 富文本编辑器集成

要实现 `description` 字段的富文本编辑和显示，我们通常使用第三方库。这里推荐使用 **`django-tinymce`**，它易于安装和集成。

### 6.1: 安装 `django-tinymce`

在您的虚拟环境中，使用 pip 安装：

Bash

```
pip install django-tinymce
```

### 6.2: 注册应用

打开您的项目配置文件 **`tiktok_pm_project/settings.py`**，并在 `INSTALLED_APPS` 中添加 `'tinymce'`：

Python

``` python
# tiktok_pm_project/settings.py

INSTALLED_APPS = [
    # ... 其他应用
    'rest_framework',
    
    # 富文本编辑器
    'tinymce',         # <-- 新增
    
    'products',
]
```

### 6.3: 修改 `ProductAdmin`

我们需要从 `tinymce` 导入专用的表单字段，并覆盖 `ProductAdmin` 的表单属性。

打开 **`products/admin.py`**，进行以下修改：

Python

``` python
# products/admin.py

from django.contrib import admin
# --- 导入富文本字段 ---
from tinymce.widgets import TinyMCE 
# --- 导入 forms 模块 ---
from django import forms
from .models import (
    Product, 
    ProductImage, 
    ProductVideo, 
    ProductVariation
)

# ... (ProductImageInline, ProductVideoInline, ProductVariationInline 保持不变)

# ----------------------------------------------------------------------
# 核心类: 定义富文本表单 (用于 ProductAdmin)
# ----------------------------------------------------------------------

class ProductAdminForm(forms.ModelForm):
    """
    定义 Product 模型的自定义表单，指定字段使用 TinyMCE 插件。
    """
    class Meta:
        model = Product
        fields = '__all__'
        # 将描述字段指定为 TinyMCE 富文本 widget
        widgets = {
            'description': TinyMCE(attrs={'cols': 80, 'rows': 20}),
            'description_1': TinyMCE(attrs={'cols': 80, 'rows': 15}),
            'description_2': TinyMCE(attrs={'cols': 80, 'rows': 10}),
        }


@admin.register(Product)
class ProductAdmin(admin.ModelAdmin):
    # --- 新增: 引用自定义表单 ---
    form = ProductAdminForm 
    
    # =========================================================
    # ... (list_display, search_fields, list_filter 等保持不变)
    # =========================================================
    
    # ... (fieldsets, inlines, readonly_fields 等保持不变)

# ... (ProductVariationAdmin 保持不变)
```


## 7. 实现 API 接口

现在您的模型和管理后台已经非常完善了。接下来，我们来实现 **Django REST Framework (DRF)** 接口，以便满足 n8n 等第三方应用调用和融入自动化的要求.

### 7.1: 配置 URL

打开您的项目根目录下的 **`tiktok_pm_project/urls.py`** 文件，添加 DRF 的配置：

Python

``` python
# tiktok_pm_project/urls.py

from django.contrib import admin
from django.urls import path, include 

urlpatterns = [
    path('admin/', admin.site.urls),
    # 将所有 API 路由包含进来
    path('api/', include('products.urls')),  # <-- 新增 API 入口
    # 如果需要，可以添加 DRF 默认的登录/认证页面
    path('api-auth/', include('rest_framework.urls', namespace='rest_framework')), 
]
```

### 7.2: 创建应用 URL 文件

在您的 `products` 应用目录下创建一个新文件 **`products/urls.py`**，用于定义 API 路由：

Python

``` python
# products/urls.py

from django.urls import path, include
from rest_framework.routers import DefaultRouter
from . import views

# 使用 DefaultRouter 自动生成标准的 CRUD 路由 (GET/POST/PUT/DELETE)
router = DefaultRouter()
# 注册 ProductViewSet，生成 /products/ 和 /products/{id}/ 路由
router.register(r'products', views.ProductViewSet) 
# 注册 ProductVariationViewSet，生成 /variations/ 和 /variations/{id}/ 路由
router.register(r'variations', views.ProductVariationViewSet)

# DRF 最佳实践：使用 ViewSet 和 Router 自动构建 API
urlpatterns = [
    path('', include(router.urls)),
]
```

### 7.3: 定义序列化器 (Serializers)

序列化器用于将 Django 模型实例（Python 对象）转换为 JSON/XML 格式，以便通过 API 传输。

创建新文件 **`products/serializers.py`**：

Python

``` python
# products/serializers.py

from rest_framework import serializers
from .models import Product, ProductVariation, ProductImage, ProductVideo

# --- 辅助序列化器 (用于嵌套展示) ---

class ProductImageSerializer(serializers.ModelSerializer):
    class Meta:
        model = ProductImage
        fields = ['id', 'image_type', 'original_url', 'zipline_url']
        read_only_fields = ['id']

class ProductVideoSerializer(serializers.ModelSerializer):
    class Meta:
        model = ProductVideo
        fields = ['id', 'video_type', 'original_url', 'zipline_url']
        read_only_fields = ['id']

class ProductVariationSerializer(serializers.ModelSerializer):
    class Meta:
        model = ProductVariation
        # 排除 product_id 字段，因为它会被 ProductSerializer 自动处理
        exclude = ['product', 'created_at'] 


# --- 核心产品序列化器 (用于 Product API) ---

class ProductSerializer(serializers.ModelSerializer):
    # 将关联模型嵌套进来，方便 n8n 一次性获取所有信息
    images = ProductImageSerializer(many=True, read_only=True) # related_name='images'
    variations = ProductVariationSerializer(many=True, read_only=True) # related_name='variations'
    videos_list = ProductVideoSerializer(many=True, read_only=True) # related_name='videos_list'
    
    class Meta:
        model = Product
        # 排除 'input', 'raw_json' 等大型或内部字段，简化常用 API 响应
        # 如果需要，可以创建 ProductDetailSerializer 来包含它们
        exclude = ['raw_json', 'input', 'timestamp'] 
        
        # 确保 created_at, updated_at, source_id, seller_id 等关键字段可读
        read_only_fields = ['id', 'created_at', 'updated_at']

```

### 7.4: 定义视图集 (ViewSets)

视图集定义了 API 的业务逻辑（CRUD）。

打开 **`products/views.py`**：

Python

``` python
# products/views.py

from rest_framework import viewsets
from rest_framework.permissions import IsAuthenticated
from .models import Product, ProductVariation
from .serializers import ProductSerializer, ProductVariationSerializer
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework import filters

class ProductViewSet(viewsets.ModelViewSet):
    """
    提供 Product 资源的 CRUD 操作 API。
    实现：快速搜索 (要求 3.8)，多条件过滤 (要求 3.9)
    """
    queryset = Product.objects.all().order_by('-updated_at')
    serializer_class = ProductSerializer
    
    # 限制只有认证用户才能访问 API
    # permission_classes = [IsAuthenticated] 

    # 启用过滤和搜索后端
    filter_backends = [DjangoFilterBackend, filters.SearchFilter]
    
    # 启用字段过滤（多条件过滤）
    filterset_fields = [
        'available', 
        'In_stock', 
        'category', 
        'seller_id', 
        'final_price',
    ]

    # 启用快速搜索 (要求 3.8)
    search_fields = [
        '=source_id', # 精确匹配
        'title', 
        'store_name', 
        'description'
    ]
    
    # 可选：自定义查询集以提高性能
    def get_queryset(self):
        # 预加载关联数据以解决 N+1 查询问题
        return Product.objects.all().select_related().prefetch_related(
            'images', 'variations', 'videos_list'
        ).order_by('-updated_at')


class ProductVariationViewSet(viewsets.ModelViewSet):
    """
    提供 ProductVariation 资源的 CRUD 操作 API。
    """
    queryset = ProductVariation.objects.all()
    serializer_class = ProductVariationSerializer
    
    # 启用字段过滤
    filter_backends = [DjangoFilterBackend, filters.SearchFilter]
    filterset_fields = ['product', 'sku', 'stock']
    search_fields = ['=sku', 'product__source_id']

```

### 7.5: 安装依赖

为实现高级过滤（要求 3.9），您需要安装 `django-filter` 库：

Bash

```
pip install django-filter
```

并在 **`tiktok_pm_project/settings.py`** 中注册：

Python

``` python
# tiktok_pm_project/settings.py

INSTALLED_APPS = [
    # ...
    'rest_framework',         
    'tinymce',         
    'products',      
    
    # API 过滤
    'django_filters', # <-- 新增
]

# 配置 DRF 使用 DjangoFilter
REST_FRAMEWORK = {
    'DEFAULT_FILTER_BACKENDS': ['django_filters.rest_framework.DjangoFilterBackend']
}
```

现在，您的 Web Application 已经具备了完整的 Admin 后台管理界面和可用于自动化的 **RESTful API** 接口，完全满足了您的所有要求！

**API 示例路由：**

- **查询所有产品:** `GET /api/products/`
    
- **关键词搜索 (n8n 调用):** `GET /api/products/?search=关键词`
    
- **多条件过滤:** `GET /api/products/?category=服饰&In_stock=true`
    
- **创建产品:** `POST /api/products/` (发送 JSON 数据)


## 8. 模态框图片放大实现方案

我们需要创建两个文件：

1. **`admin_image_modal.css`**: 定义模态框的样式。
    
2. **`admin_image_modal.js`**: 编写 JavaScript 逻辑，捕获点击事件并显示模态框。
    

### 步骤 A: 创建静态文件目录结构

首先，在您的 `products` 应用目录下创建静态文件所需的路径：

``` bash
products/
└── static/
    └── admin/
        └── css/
            └── admin_image_modal.css  # <-- CSS 文件
        └── js/
            └── admin_image_modal.js   # <-- JS 文件
```

### 步骤 B: 编写 CSS (`admin_image_modal.css`)

将以下代码粘贴到 **`products/static/admin/css/admin_image_modal.css`** 中。

CSS

``` css
/* 模态框背景，覆盖整个屏幕 */
.image-modal {
    display: none; /* 默认隐藏 */
    position: fixed;
    z-index: 9999; /* 确保在最上层 */
    left: 0;
    top: 0;
    width: 100%;
    height: 100%;
    overflow: auto;
    background-color: rgba(0, 0, 0, 0.9); /* 黑色半透明背景 */
}

/* 模态框内容：图片居中 */
.image-modal-content {
    margin: auto;
    display: block;
    width: 80%;
    max-width: 900px;
    max-height: 90vh; /* 限制最大高度为视口高度的 90% */
    object-fit: contain; /* 确保图片在框内完整显示 */
    position: relative;
    top: 50%;
    transform: translateY(-50%);
}

/* 关闭按钮 */
.image-modal-close {
    position: absolute;
    top: 15px;
    right: 35px;
    color: #f1f1f1;
    font-size: 40px;
    font-weight: bold;
    transition: 0.3s;
    cursor: pointer;
    z-index: 10000;
}

.image-modal-close:hover,
.image-modal-close:focus {
    color: #bbb;
    text-decoration: none;
}
```

### 步骤 C: 编写 JavaScript (`admin_image_modal.js`)

将以下代码粘贴到 **`products/static/admin/js/admin_image_modal.js`** 中。

JavaScript

``` javascript
/* products/static/admin/js/admin_image_modal.js - 最终版本 */  
// 我们不再依赖 IIFE 的参数映射，而是直接使用 django.jQuery// 使用 setTimeout 确保我们的代码在 Admin 的其余 JS 之后运行  
setTimeout(function() {  
    // 再次检查，确保 django.jQuery 确实存在  
    if (typeof django === 'undefined' || typeof django.jQuery === 'undefined') {  
        console.error("致命错误：无法找到 django.jQuery。图片放大功能失效。");  
        return;  
    }  
  
    const $ = django.jQuery; // 在这里定义一个局部变量 $ 方便后续代码书写  
  
    $(document).ready(function() {  
        // 1. 在页面底部注入模态框 HTML 结构  
        $('body').append(`  
            <div id="productImageModal" class="image-modal">                <span class="image-modal-close">&times;</span>                <img class="image-modal-content" id="imgModalContent">            </div>        `);  
  
        var modal = $('#productImageModal');  
        var modalImg = $('#imgModalContent');  
        var closeModal = $('.image-modal-close');  
  
        // 2. 捕获图片点击事件  
        // 使用 #content-main 作为父元素监听事件，阻止事件冒泡干扰 Admin 内部逻辑  
        $('#content-main').on('click', '.image-clickable', function(e) {  
            e.preventDefault();  
            e.stopPropagation();  
  
            var clickedImg = $(this);  
            var largeSrc = clickedImg.data('large-url');  
  
            if (largeSrc) {  
                modal.css('display', 'block');  
                modalImg.attr('src', largeSrc);  
                modal.focus();  
            }  
        });  
  
        // 3. 监听关闭事件  
        closeModal.on('click', function() {  
            modal.css('display', 'none');  
        });  
  
        // 点击模态框背景关闭  
        modal.on('click', function(e) {  
            if ($(e.target).hasClass('image-modal')) {  
                modal.css('display', 'none');  
            }  
        });  
  
        // 键盘 Esc 键关闭  
        $(document).on('keydown', function(e) {  
            if (e.key === "Escape" && modal.css('display') === 'block') {  
                modal.css('display', 'none');  
            }  
        });  
    });  
}, 0); // 使用 setTimeout 确保代码在浏览器 Event Loop 的下一个 tick 执行
```

### 步骤 D: 修改 Admin Python 代码 (`products/admin.py`)

我们需要进行两处修改：

1. 在 `ProductAdmin` 类中添加 `Media` 内部类，引入新的 CSS 和 JS 文件。
    
2. 修改 `image_preview` 方法，使其不再生成 `<a>` 标签，而是生成带有 `data-large-url` 属性和 `image-clickable` class 的 `<img>` 标签。
    

Python

``` python
# products/admin.py

from django.contrib import admin
from tinymce.widgets import TinyMCE 
from django import forms
from django.utils.safestring import mark_safe
# ... 导入保持不变

# ----------------------------------------------------------------------
# 1. 修改 ProductImageInline
# ----------------------------------------------------------------------
class ProductImageInline(admin.TabularInline):
    # ... 保持不变 ...

    def image_preview(self, obj):
        """生成图片预览，并添加 JavaScript 点击事件支持"""
        if obj.zipline_url:
            # 不再使用 <a> 标签，而是使用带 data 属性的 <img> 标签
            return mark_safe(f'''
                <img 
                    src="{obj.zipline_url}" 
                    data-large-url="{obj.zipline_url}" 
                    class="image-clickable" 
                    style="max-width: 100px; max-height: 100px; cursor: pointer;" 
                    title="点击查看大图"
                />
            ''')
        return "No Image"
    image_preview.short_description = '预览'


# ----------------------------------------------------------------------
# 2. 修改 ProductVariationInline
# ----------------------------------------------------------------------
class ProductVariationInline(admin.TabularInline):
    # ... 保持不变 ...
    
    def image_preview(self, obj):
        """根据 image_zipline_url 生成图片预览，并添加 JavaScript 点击事件支持"""
        if obj.image_zipline_url:
            # 不再使用 <a> 标签
            return mark_safe(f'''
                <img 
                    src="{obj.image_zipline_url}" 
                    data-large-url="{obj.image_zipline_url}" 
                    class="image-clickable"
                    style="max-width: 100px; max-height: 100px; cursor: pointer;" 
                    title="点击查看大图"
                />
            ''')
        return "No Image"
    image_preview.short_description = '预览'


# ----------------------------------------------------------------------
# 3. 核心类: 定制 Product 模型的管理界面，注入 CSS/JS
# ----------------------------------------------------------------------

# ... ProductAdminForm 保持不变 ...


@admin.register(Product)
class ProductAdmin(admin.ModelAdmin):
    form = ProductAdminForm
    
    # ... 其他配置保持不变 ...
    
    class Media:
        """
        引入自定义的静态文件。
        注意：Django Admin 默认会加载 jQuery。
        """
        css = {
            'all': ('admin/css/admin_image_modal.css',)
        }
        js = (
            'admin/js/admin_image_modal.js',
        )

# ... ProductVariationAdmin 保持不变 ...
```

### 步骤 E: 运行验证

1. **收集静态文件:** 由于您添加了新的静态文件，如果您的项目部署在生产环境中，需要运行：
    
    Bash
    
    ``` python
    python manage.py collectstatic
    ```
    
    _（在开发环境中通常不需要，但这是一个好习惯。）_
    
2. **启动服务器:**
    
    Bash
    
    ``` python
    python manage.py runserver
    ```
    
3. **测试:** 登录 Admin 后台，进入产品编辑页面，点击产品图片预览。图片现在应该会在一个覆盖整个屏幕的模态框中放大显示。您可以点击右上角的 `&times;` 或按 `Esc` 键来关闭模态框。
    

这样，您就实现了简洁且用户体验更好的图片放大功能。

## 9. 新增 `product_reviews` 表是一个关键的步骤，用于存储产品的评价数据。

我们将按照标准的 Django 流程来完成这个任务：**创建模型**、**配置关联**、**数据库迁移**，并将其集成到 **Admin 后台**。

### 9.1 整体步骤概览

1. **`products/models.py`**: 定义新的 `ProductReview` 模型。
    
2. **数据库迁移**: 运行 `makemigrations` 和 `migrate` 创建新表。
    
3. **`products/admin.py`**: 注册 `ProductReview` 模型，并将其内联到 `Product` 编辑页。
    

---

### 9.2: 创建 `ProductReview` 模型

打开您的 **`products/models.py`** 文件，添加以下 `ProductReview` 模型定义。

 📄 `products/models.py`

Python

``` python
from django.db import models
from django.db.models.functions import Now # 用于 db_default
# from django.utils import timezone # 暂时不需要 timezone.now

# ... (Product, ProductImage, ProductVideo, ProductVariation 模型定义)

# ----------------------------------------------------------------------
# 新增模型: ProductReview (用于存储产品评价)
# ----------------------------------------------------------------------

class ProductReview(models.Model):
    # 关联到 Product 表
    product = models.ForeignKey(
        'Product', 
        on_delete=models.CASCADE,
        related_name='reviews' # 定义反向关联名称
    )
    
    # 评价人信息
    reviewer_name = models.CharField(
        max_length=255, 
        blank=True, 
        null=True,
        verbose_name='评价人昵称'
    )
    
    # 评分 (TINYINT 对应 SmallIntegerField 或 IntegerField)
    rating = models.SmallIntegerField(
        blank=True, 
        null=True,
        verbose_name='评分 (1-5)'
    )
    
    # 评价内容 (TEXT 对应 TextField)
    review_text = models.TextField(
        blank=True, 
        null=True,
        verbose_name='评价内容'
    )
    
    # 评价日期
    review_date = models.DateTimeField(
        blank=True, 
        null=True,
        verbose_name='评价发生日期'
    )
    
    # 原始图片 URL (JSON 对应 JSONField)
    images = models.JSONField(
        blank=True, 
        null=True,
        verbose_name='原始图片JSON'
    )
    
    # Zipline 加速后图片 URL (JSON 对应 JSONField)
    zipline_images = models.JSONField(
        blank=True, 
        null=True,
        verbose_name='加速图片JSON'
    )
    
    # 创建时间 (对应 TIMESTAMP DEFAULT CURRENT_TIMESTAMP)
    # 采用之前针对 MySQL DDL 的解决方案：依赖数据库的 DEFAULT
    created_at = models.DateTimeField(
        blank=True, 
        null=True,
        db_default=Now() # 如果是 Django 4.2+，使用 Now()
        # 如果不是 4.2+，请用 models.DateTimeField(blank=True, null=True) 代替
    ) 

    class Meta:
        verbose_name = '产品评价'
        verbose_name_plural = '产品评价'
        db_table = 'product_reviews' # 确保表名与您 DDL 中的名称一致
        ordering = ['-created_at']

    def __str__(self):
        return f'{self.product.source_id} - {self.reviewer_name} ({self.rating}星)'
```

---

### 9.3: 执行数据库迁移

保存 `products/models.py` 后，执行以下两个命令来创建 `product_reviews` 表。

### 1. 创建迁移文件

Bash

``` bash
python manage.py makemigrations products
```

### 2. 应用迁移到数据库

Bash

``` bash
python manage.py migrate
```

_（执行成功后，`product_reviews` 表将在数据库中创建。）_

---

### 9.4: 配置 Admin 后台

我们将采用内联方式，让您可以在编辑 `Product` 时，直接查看和管理其关联的所有 `ProductReview`。

打开您的 **`products/admin.py`** 文件，进行以下修改：

📄 `products/admin.py`

#### 1. 新增 `ProductReviewInline` 辅助类

在 `ProductImageInline` 等内联类定义的下方，添加 `ProductReviewInline`：

Python

``` python
# products/admin.py

# ... (在 ProductVideoInline 和 ProductVariationInline 之后)

# ----------------------------------------------------------------------
# 4. 产品评价 (ProductReviews)
# ----------------------------------------------------------------------

class ProductReviewInline(admin.TabularInline):
    """在 Product 编辑页内嵌展示产品评价"""
    model = ProductReview
    # 注意：images 和 zipline_images 是 JSONField，如果内容过大，可以不显示或使用定制 widget
    fields = (
        'reviewer_name', 'rating', 'review_date', 'review_text', 
        'images', 'zipline_images', 'created_at'
    )
    readonly_fields = ('created_at',)
    extra = 0 # 评价通常是导入的，默认不提供额外空白行
    
    # 如果 review_text 过长，可以使用以下方式控制其显示：
    # formfield_overrides = {
    #     models.TextField: {'widget': forms.Textarea(attrs={'rows': 2, 'cols': 50})}
    # }
```

#### 2. 将 `ProductReviewInline` 添加到 `ProductAdmin`

在 `@admin.register(Product)` 下的 `ProductAdmin` 类中，将新的内联类添加到 `inlines` 列表中：

Python

``` python
# products/admin.py

@admin.register(Product)
class ProductAdmin(admin.ModelAdmin):
    # ... (其他配置保持不变)

    # 2. 内联关联模型 (显示关联的图片、视频、变体、评价)
    inlines = [
        ProductVariationInline, 
        ProductImageInline, 
        ProductVideoInline,
        ProductReviewInline,  # <-- 新增这一行
    ]

    # ... (其他配置保持不变)
```

### 9.5 运行验证

1. 启动开发服务器: `python manage.py runserver`。
    
2. 访问 Admin 后台，进入任何一个产品的编辑页。
    
3. 您应该能在页面底部看到一个新的可折叠的 **"产品评价 (Product reviews)"** 内联表单，您可以手动添加数据或查看通过导入工具创建的数据。
    

至此，您已成功将 `product_reviews` 表集成到您的 Django 项目中。


## 10 修改过滤条件

好的，这是一个清晰的管理界面定制要求。您希望在 `Product` 列表页的侧边栏过滤器中，用 **`store_name` (店铺名称)** 替换掉现有的 `available`, `In_stock`, 和 `created_at` 过滤条件。

这个修改只需要在 **`products/admin.py`** 文件中进行。

### 10.1 解决方案：修改 `ProductAdmin` 的 `list_filter`

打开您的 **`products/admin.py`** 文件，找到 `ProductAdmin` 类的定义，并修改 `list_filter` 属性如下：

Python

``` python
# products/admin.py

@admin.register(Product)
class ProductAdmin(admin.ModelAdmin):
    # =========================================================
    # 列表页定制 (list view)
    # =========================================================
    
    # 1. 列表显示的字段 (保持不变，如果需要)
    list_display = (
        'source_id',
        'title_short',
        'store_name', # 确认 'store_name' 在 list_display 中，方便查看
        'final_price',
        'sold',
        'available',
        'In_stock',
        'created_at'
    )
    
    # ... (其他设置如 search_fields 保持不变)

    # 4. 多条件过滤搜索 (要求 3.9: 多条件过滤)
    # 移除: 'available', 'In_stock', 'created_at'
    # 新增: 'store_name'
    list_filter = (
        'store_name',      # <-- 新增：根据店铺名称过滤
        'category',
        'currency', 
        'final_price',     # 价格过滤
    )

    # ... (其他设置保持不变)
```

### 10.2 为什么 `store_name` 适用作过滤条件？

`store_name` 是一个 `CharField`（或 `VARCHAR`），当它作为 `list_filter` 的过滤条件时，Django Admin 会自动创建一个侧边栏，列出数据库中出现过的所有不同的店铺名称，并允许用户点击进行筛选。这对于管理多个店铺的商品非常实用。

保存文件并重启您的开发服务器，即可在 Admin 后台的 `Product` 列表页右侧看到更新后的过滤侧边栏。


## 11. Product List页面每个产品添加缩略图
这是一个非常实用的需求，能极大提升产品列表页的可视化效果。我们将利用之前创建的 `ProductImage` 模型和图片放大功能来实现。

要实现在 **`Product` 列表页** 显示第一张图片的缩略图并支持点击放大，我们需要做两处核心修改：

1. **在 `products/admin.py` 中：**
    
    - 在 `ProductAdmin` 类中添加一个方法，用于获取并渲染产品的第一张图片。
        
    - 将该方法添加到 `list_display` 列表中。
        
2. **在 `products/models.py` 中：**
    
    - 优化 `Product` 模型，添加一个属性快速获取第一张图片 URL。
        

---

## 11.1: 优化 `products/models.py` (获取第一张图片)

为了效率，我们确保 `Product` 模型有一个简单的方法来获取其关联的缩略图 URL。

打开您的 **`products/models.py`** 文件，在 `Product` 模型的定义中添加一个属性：

Python

``` python
# products/models.py

# ... (其他导入保持不变)

class Product(models.Model):
    # ... (其他字段保持不变)
    
    # ... (Meta 和 __str__ 方法保持不变)

    # ------------------------------------------------------------
    # 新增属性：获取第一张图片的 Zipline URL (用于列表页展示)
    # ------------------------------------------------------------
    @property
    def first_image_zipline_url(self):
        """返回关联的第一张图片的加速 URL，如果存在的话。"""
        # 由于我们在 ProductImage 模型中使用了 related_name='product_images' (假设如此)
        # 我们按 id 或某个顺序字段获取第一张图片
        first_image = self.product_images.filter(image_type='main').order_by('id').first()
        if first_image and first_image.zipline_url:
            return first_image.zipline_url
        
        # 如果没有找到 'main' 类型的图片，尝试找任意一张
        first_image = self.product_images.order_by('id').first()
        if first_image and first_image.zipline_url:
            return first_image.zipline_url
            
        return None
        
# ... (其他模型保持不变)

# 确保 ProductImage 模型有一个正确的 related_name (检查 ProductImage 模型)
# class ProductImage(models.Model):
#    product = models.ForeignKey(..., related_name='product_images', ...)
```

**(⚠️ 重要提醒：)** 请检查您的 `ProductImage` 模型中，指向 `Product` 的外键是否使用了 `related_name='product_images'`。如果没有，请将其添加到 `ProductImage` 模型中：

Python

```
# products/models.py (在 ProductImage 模型中)
class ProductImage(models.Model):
    # ...
    product = models.ForeignKey(
        'Product', 
        on_delete=models.CASCADE,
        related_name='product_images' # <--- 确保有这个 related_name
    )
    # ...
```

---

## 11.2: 修改 `products/admin.py` (渲染列表页缩略图)

打开您的 **`products/admin.py`** 文件，进行以下修改：

📄 `products/admin.py`

#### 1. 新增方法 `product_thumbnail`

在 `ProductAdmin` 类中添加一个新方法，该方法将使用我们之前为内联表单设置的 **点击放大** 的 HTML 结构。

Python

``` python
# products/admin.py

from django.utils.safestring import mark_safe
# ... (确保顶部导入了 mark_safe)


@admin.register(Product)
class ProductAdmin(admin.ModelAdmin):
    # ... (其他设置保持不变)

    # ------------------------------------------------------------
    # 列表页自定义方法：产品缩略图（支持点击放大）
    # ------------------------------------------------------------
    def product_thumbnail(self, obj):
        """显示产品的第一张图片缩略图，并应用点击放大 JS 样式"""
        img_url = obj.first_image_zipline_url # 调用 models.py 中定义的属性
        
        if img_url:
            # 使用我们在 Admin 中定义的图片点击放大样式 (class="image-clickable")
            return mark_safe(f'''
                <img 
                    src="{img_url}" 
                    data-large-url="{img_url}" 
                    class="image-clickable" 
                    style="max-width: 60px; max-height: 60px; cursor: pointer;" 
                    title="点击查看大图"
                />
            ''')
        return "N/A"

    product_thumbnail.short_description = '图片' # 列表页列名
    product_thumbnail.allow_tags = True

    # ------------------------------------------------------------
    # 列表页显示字段 (list_display)
    # ------------------------------------------------------------
    list_display = (
        'product_thumbnail', # <-- 新增：将缩略图放在第一列
        'source_id',
        'title_short',
        'store_name',
        'final_price',
        'sold',
        'available',
        'In_stock',
        'created_at',
        'price_highlight_class'
    )
    
    # ... (其他设置保持不变)
```

#### 2. 将 `product_thumbnail` 添加到 `list_display`

确保在 `list_display` 列表中，您将 `'product_thumbnail'` 添加到了您希望显示的位置（通常是第一列）。

### 运行验证

1. **重启服务器:**
    
    Bash
    
    ``` bash
    python manage.py runserver
    ```
    
2. **访问 Admin 产品列表页：**
    
    - 现在列表的第一列应该显示一个 60x60px 的图片缩略图。
        
    - 点击缩略图，应该会触发您之前配置的 JS 模态框，显示放大后的图片。
        

这样，您就利用了之前编写的 JS 模态框功能，完美地实现了列表页的图片展示和放大需求。


## 12 Ubuntu Server 生产环境 Docker 部署指南

### 前提条件

1. **Ubuntu Server:** 已安装 SSH 访问。
    
2. **MySQL Server:** 位于外部服务器，已配置防火墙和用户权限允许 Ubuntu Server 访问 3306 端口。
    
3. **Git 仓库:** 您的 Django 项目代码已推送到 GitHub（或 Gitee/GitLab 等）。
    

### 阶段一：本地（Windows）项目准备

确保您的项目文件结构和配置符合 Docker 部署要求。

#### 1. 创建 `.dockerignore` 文件

在项目根目录创建 `.dockerignore` 文件，排除不必要的或敏感文件，减小镜像体积：

代码段

``` bash
# .dockerignore
# 忽略 Git 自身文件
.git
.gitignore

# 忽略本地开发环境
__pycache__
*.pyc
venv/
.idea/
.vscode/

# 忽略本地数据库文件
db.sqlite3

# 忽略日志和本地媒体文件
logs/
mediafiles/

# 忽略敏感配置文件（如果 .env 包含在内）
# 注意：我们使用 .env 传参，确保它在本地不被提交到 Git，但会被 Docker Compose 使用
```

#### 2. 创建 Nginx 配置文件目录

在项目根目录创建 Nginx 文件夹及配置文件：

```
your_project_root/
└── nginx/
    └── nginx.conf  # <-- 新建此文件
```

**`nginx/nginx.conf` 内容：** (与之前提供的配置相同)

Nginx

``` python
# nginx/nginx.conf

upstream web {
    # web 是 docker-compose.yml 中定义的 Django 服务名
    server web:8000; 
}

server {
    listen 80;

    # 静态文件服务：由 Web 容器的 /app/staticfiles 提供
    location /static/ {
        alias /app/staticfiles/; 
    }

    # 媒体文件服务：由 Web 容器的 /app/mediafiles 提供
    location /media/ {
        alias /app/mediafiles/; 
    }

    # 其他请求转发给 Gunicorn (Web 服务)
    location / {
        proxy_pass http://web;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }
}
```

#### 3. 提交代码到 GitHub

确保将所有配置（包括 `Dockerfile`, `docker-compose.yml`, `nginx/nginx.conf` 等）推送到您的 GitHub 仓库：

Bash

```
git add .
git commit -m "Add Docker deployment files"
git push origin main
```

---

### 阶段二：服务器环境配置

以下步骤在您的 Ubuntu Server 上执行。

#### 1. 安装 Docker 和 Docker Compose

Bash

``` bash
# 步骤可能因 Ubuntu 版本略有不同，这是通用安装方法
sudo apt update
sudo apt install ca-certificates curl gnupg lsb-release

# 添加 Docker 官方 GPG 密钥
sudo mkdir -p /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg

# 添加 Docker 仓库
echo \
  "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \
  $(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null

sudo apt update
sudo apt install docker-ce docker-ce-cli containerd.io docker-compose-plugin

# 将当前用户添加到 docker 组，以便无需 sudo 即可运行 docker 命令
sudo usermod -aG docker $USER
# 重新登录或运行: newgrp docker
```

#### 2. 克隆项目和创建配置

**克隆项目：** 决定您的部署目录，例如 `/home/your_username/app/tiktok_pm`。

Bash

```
mkdir -p ~/app/
cd ~/app/
git clone [您的 GitHub 仓库 URL] tiktok_pm
cd tiktok_pm
```

**创建 `.env` 文件：** 此文件用于存储敏感信息，**不应提交到 Git**。

Bash

```
nano .env
```

**`.env` 文件内容：** 替换为您真实的生产配置。

代码段

```
# .env

# Django 配置
DJANGO_SECRET_KEY='YOUR_VERY_LONG_AND_SECURE_SECRET_KEY'
# 必须包含服务器的公网 IP 和域名
DJANGO_ALLOWED_HOSTS='your_domain.com,your_server_ip,localhost,127.0.0.1' 

# 外部 MySQL 数据库连接配置
MYSQL_HOST=YOUR_MYSQL_SERVER_IP # <-- 外部 MySQL IP
MYSQL_PORT=3306
MYSQL_DB_NAME=tiktok_pm_prod
MYSQL_USER=tiktok_user
MYSQL_PASSWORD=your_secure_db_password
```

### 阶段三：部署与运行

#### 1. 首次构建和启动服务

在项目根目录（包含 `docker-compose.yml`）下运行：

Bash

```
# 构建 web 镜像 (会自动安装依赖、收集静态文件)
# -d 表示在后台运行
docker compose up --build -d 
```

#### 2. 初始化数据库和创建用户

容器启动后，首次运行数据库迁移和创建超级用户：

Bash

```
# 1. 运行数据库迁移 (连接到外部 MySQL)
docker compose exec web python manage.py migrate

# 2. 创建超级管理员
docker compose exec web python manage.py createsuperuser

# 3. 检查容器状态docke
docker compose ps
```

您的应用现在应该可以通过 Nginx（即服务器的 80 端口）访问了。

---

## ♻️ 阶段四：版本更新和升级流程 (最佳实践)

这是在 Docker 环境下进行代码更新的标准流程，它确保了最小化停机时间和环境隔离。

### 1. 代码更新流程

在 Ubuntu 服务器上执行以下操作：

Bash

```
# 1. 进入项目目录
cd ~/app/tiktok_pm

# 2. 拉取最新代码
# 假设您只在 main 分支上部署
git pull origin main 
```

### 2. Docker 升级流程 (一键式)

Bash

```
# 3. 停止并移除旧容器，重建新容器
# --build: 强制 Docker 重新构建 web 镜像 (这样它会包含您的新代码、新依赖和新的 collectstatic)
# -d: 后台运行
docker compose up --build -d 
```

### 3. 数据库迁移 (如果模型有变动)

Bash

```
# 4. 运行数据库迁移
# 必须在新容器启动后执行
docker compose exec web python manage.py migrate
```

### 4. 清理旧镜像（可选）

为了节约服务器空间，您可以清除未被任何容器使用的旧 Docker 镜像：

Bash

```
docker image prune -f
```

### 最佳实践总结

- **原子性部署：** `docker compose up --build -d` 确保了所有依赖项和配置都会在新镜像中构建好，如果构建失败，旧服务不会受到影响。
    
- **零停机重启：** 虽然 Docker Compose 默认的重启不是完全的零停机（它会停止旧的再启动新的），但由于您的应用启动速度快，停机时间极短。如果追求绝对的零停机，需要使用更复杂的工具如 Kubernetes。
    
- **易于回滚：** 如果新版本出现问题，您只需要回退 Git 提交 (`git reset --hard <old_commit_hash>`)，然后再次运行 `docker compose up --build -d`，系统即可快速回到稳定状态。
    

这个 Docker 部署流程结合了版本控制的优势，为您提供了一个专业、稳定且易于维护的生产环境。

## 📂 项目完整目录结构

假设您的项目根目录是 `tiktok_pm`。

```
tiktok_pm/
├── tiktok_pm_project/         # Django 项目主配置目录
│   ├── __init__.py
│   ├── settings.py            # 核心配置，已修改以读取环境变量
│   ├── urls.py
│   └── wsgi.py
│
├── products/                  # 产品应用目录
│   ├── migrations/            # 数据库迁移文件目录
│   │   ├── 0001_initial.py
│   │   └── ...
│   ├── static/                # 静态文件目录
│   │   └── admin/             # 供 Admin 使用的自定义静态文件
│   │       ├── css/
│   │       │   ├── admin_image_modal.css      # 图片模态框样式 (已创建)
│   │       │   └── admin_price_highlight.css  # 列表高亮样式 (已创建)
│   │       └── js/
│   │           ├── admin_image_modal.js       # 图片模态框 JS (已修复)
│   │           └── admin_price_highlight.js   # 列表高亮 JS (已创建)
│   ├── __init__.py
│   ├── admin.py               # Admin 配置 (ProductAdmin, Inlines, 缩略图方法)
│   ├── apps.py
│   ├── models.py              # 模型定义 (Product, ProductImage, ProductReview 等)
│   └── views.py
│
├── nginx/                     # Docker Nginx 配置目录
│   └── nginx.conf             # Nginx 反向代理和静态文件服务配置
│
├── staticfiles/               # collectstatic 收集的静态文件目录 (Docker 构建时生成)
├── venv/                      # 虚拟环境目录 (应被 .gitignore 和 .dockerignore 忽略)
├── manage.py                  # Django 管理脚本
├── requirements.txt           # Python 依赖列表 (用于 Docker 构建)
├── .env                       # 环境变量文件 (外部 MySQL 连接信息、SECRET_KEY)
├── Dockerfile                 # Docker 镜像构建文件
├── docker-compose.yml         # Docker Compose 编排文件
├── .gitignore                 # Git 忽略文件列表
└── .dockerignore              # Docker 构建忽略文件列表
```

## 13. 实现异步产品数据导入功能

### 需求
现在我希望在现有的web application基础上开发一个新功能, 这是一个异步的调用第三方API来获取TikTok 产品数据(JSON)文件，并导入到现有的mysql数据库的tiktok_products数据表中。具体要求如下：

1. 我希望在tiktok_pm web applicaiton admin页面有一个新的页面 - product_fetch

2. 一开始，我可以在一个text box里输入多行的产品URL, 比如：

https://www.tiktok.com/view/product/1729762527861968902

https://www.tiktok.com/view/product/1731057176518889862

一行一个URL， 分别代表不同的产品。

3. 提交表单后，发送http request 给第三方API:
``` bash
curl -H "Authorization: Bearer <API key>" -H "Content-Type: application/json" -d '{"input":[{"url":"https://www.tiktok.com/view/product/1729762527861968902"},{"url":"https://www.tiktok.com/view/product/1731057176518889862"}]}' "https://api.brightdata.com/datasets/v3/trigger?dataset_id=gd_m45m1u911dsa4274pi&notify=false&include_errors=true"
```

4. 在这里，我们将调用bright data的 Asynchronous Requests 来实现，因为有可能数据很多，需要很长时间进行抓取，具体关于Asynchronous Requests的文档如下：

https://docs.brightdata.com/api-reference/rest-api/scraper/asynchronous-requests?_gl=1*1jguggx*_gcl_au*NzM2NjUwMTI5LjE3NjM0NzI3ODY.*_ga*MTA2MDMzMDE4MC4xNzYzMzQ2MDM2*_ga_KQX3XWKR2T*czE3NjM4MTQ1NDckbzExJGcxJHQxNzYzODE0ODI3JGo2MCRsMCRoMA..

5. 我的目的是获取调用API执行成功后的JSON到本地，并执行我自己编写的python脚本来将JSON数据导入数据库。

在您现有的 Django 架构中实现这个异步 TikTok 产品数据导入功能，最专业和稳健的方式是使用 **Django Admin 的自定义视图** 结合 **Django-Q 或 Celery** 来处理异步任务，确保 Web 请求不会超时。

由于您已经提供了一个 Python 导入脚本 (`import_products.py`)，我们将把该脚本的核心逻辑包装成一个 Django **管理命令** 或 **异步任务**。

### 🚀 总体架构设计

我们将采用以下三层架构实现需求：

1. **前端 (Admin View):** 创建一个自定义的 `product_fetch` 视图，用于接收 URL 列表。
    
2. **异步层 (Django-Q/Celery):** 使用一个异步任务队列（这里以 **Django-Q** 为例，它更轻量易用）来执行耗时的 Bright Data API 调用。
    
3. **核心逻辑 (Python Script Wrapper):** 编写一个任务函数，负责：
    
    - 触发 Bright Data 异步请求。
        
    - 轮询 Bright Data 的完成状态并下载 JSON 结果。
        
    - 调用您现有的 `import_products.py` 脚本逻辑，将数据导入 MySQL。
        

---

### 📋 步骤一：环境准备和配置

首先，我们需要确保 Django 应用可以处理异步任务和外部请求。

#### 1. 安装依赖

在您的 `requirements.txt` 中添加以下库（如果尚未添加）：

```
# ... 其他依赖
django-q # 用于异步任务队列
requests # 用于 HTTP 请求 (已在您的脚本中)
pymysql # 用于 MySQL 连接 (已在您的脚本中)
```

然后，在您的 Dockerfile 中确保这些依赖会被安装，并在服务器上重新构建：`docker compose up --build -d`。

#### 2. 配置 Django-Q

在您的 `tiktok_pm_project/settings.py` 中添加 Django-Q 配置，并使用 Redis 或您的数据库作为任务后端（假设使用数据库）：

Python

```
# tiktok_pm_project/settings.py

# ... (在 DATABASES 配置之后添加)

Q_CLUSTER = {
    # 推荐使用数据库后端，如果 Redis 可用则更推荐 Redis
    'name': 'DjangORM',
    'workers': 4, # 根据服务器性能设置工作进程数
    'timeout': 360,
    'retry': 120,
    'queue_limit': 50,
    'bulk': 10,
    'orm': 'default', # 使用默认数据库连接
}
```

#### 3. 运行迁移

在服务器上运行 Django 迁移以创建 Django-Q 所需的表：

Bash

``` bash
docker compose exec web python manage.py migrate
```

---

### 📋 步骤二：编写异步任务 (`tasks.py`)

我们将把 Bright Data 逻辑和数据导入逻辑放在 `products` 应用内的 `tasks.py` 文件中。

#### 1. 创建 `products/tasks.py`

创建 `products/tasks.py` 文件，包含 API 调用和数据下载逻辑。

Python

``` python
# products/tasks.py
import json
import time
import requests
from django_q.tasks import async_task
from django.conf import settings # 用于访问 Bright Data 配置
from .importer_wrapper import import_json_data_to_db # 导入导入函数

# --------------------------
# Bright Data Config (应从 settings.py 读取)
# --------------------------
# 假设您将这些配置添加到 settings.py 或 .env 中
BRIGHT_DATA_API_KEY = "Bearer <API key>" # 替换为您的密钥
BRIGHT_DATA_TRIGGER_URL = "https://api.brightdata.com/datasets/v3/trigger"
BRIGHT_DATA_DOWNLOAD_URL = "https://api.brightdata.com/datasets/v3/datasets/{dataset_id}/data"
DATASET_ID = "gd_m45m1u911dsa4274pi"
NOTIFICATION_EMAIL = "your@email.com" # 用于接收完成通知

# --------------------------
# 任务函数
# --------------------------

def trigger_bright_data_task(urls_list):
    """
    1. 将 URL 列表转换为 Bright Data 所需的 JSON 格式。
    2. 触发异步 API 调用。
    3. 返回 task_id
    """
    
    # 构造请求体
    input_data = [{"url": url.strip()} for url in urls_list if url.strip()]
    
    payload = {
        "input": input_data,
    }
    
    # 构造请求参数
    params = {
        "dataset_id": DATASET_ID,
        "notify": "true", # 允许通知
        "notify_emails": NOTIFICATION_EMAIL,
        "include_errors": "true",
    }
    
    headers = {
        "Authorization": BRIGHT_DATA_API_KEY,
        "Content-Type": "application/json"
    }
    
    print(f"Triggering Bright Data for {len(input_data)} URLs...")
    
    try:
        response = requests.post(
            BRIGHT_DATA_TRIGGER_URL,
            headers=headers,
            params=params,
            data=json.dumps(payload),
            timeout=60 # 设置超时时间
        )
        response.raise_for_status() # 检查 HTTP 错误
        
        data = response.json()
        
        # Bright Data 成功触发后返回 dataset_id 和 job_id
        if 'job_id' in data:
            job_id = data['job_id']
            # 异步调用轮询任务，检查结果
            async_task('products.tasks.poll_bright_data_result', job_id)
            return job_id
        
        print("❌ Bright Data API response error:", data)
        return None

    except requests.exceptions.RequestException as e:
        print(f"❌ Bright Data API request failed: {e}")
        return None

def poll_bright_data_result(job_id):
    """
    轮询 Bright Data API，直到任务完成并下载数据。
    """
    
    dataset_id = DATASET_ID 
    
    # 轮询状态的 API (需要查看 Bright Data 文档确认准确的轮询 URL)
    status_url = f"https://api.brightdata.com/datasets/v3/jobs/{job_id}/status"
    
    headers = {"Authorization": BRIGHT_DATA_API_KEY}
    
    print(f"Start polling Bright Data job_id: {job_id}")
    
    while True:
        try:
            time.sleep(30) # 每 30 秒轮询一次
            
            status_resp = requests.get(status_url, headers=headers, timeout=60)
            status_resp.raise_for_status()
            status_data = status_resp.json()
            
            job_status = status_data.get('status')
            
            print(f"Job {job_id} status: {job_status}")
            
            if job_status == 'completed' or job_status == 'finished':
                # 任务完成，下载数据
                download_url = BRIGHT_DATA_DOWNLOAD_URL.format(dataset_id=dataset_id)
                download_resp = requests.get(
                    download_url,
                    headers=headers,
                    params={'job_id': job_id, 'file_type': 'json'}, # 确保下载 JSON
                    stream=True,
                    timeout=300
                )
                download_resp.raise_for_status()

                # 处理下载的 JSON 数据流
                downloaded_products = download_resp.json()
                
                # 导入数据到数据库
                print(f"✅ Download complete. Found {len(downloaded_products)} products.")
                import_json_data_to_db(downloaded_products) 
                return f"Job {job_id} completed. Imported {len(downloaded_products)} products."
            
            elif job_status in ['failed', 'error']:
                print(f"❌ Job {job_id} failed with status: {job_status}")
                return f"Job {job_id} failed."

        except requests.exceptions.RequestException as e:
            print(f"❌ Polling request failed: {e}. Retrying...")
            continue
        except Exception as e:
            print(f"❌ General error during polling: {e}")
            return f"Job {job_id} encountered a general error."

# ----------------------------------------------------------------------
# 包装您的导入脚本的核心逻辑 (假设您的脚本位于 products/importer_wrapper.py)
# ----------------------------------------------------------------------

def import_json_data_to_db(products_list):
    """
    调用您的 import_products.py 脚本中的核心导入逻辑。
    """
    # 假设您的 import_products.py 已被重构为一个模块，并且 expose 了 import_json_data 函数
    # 由于您提供的是一个完整的脚本，我们需要将它的逻辑提取并包装：
    
    # 假设 import_products.py 的所有函数 (insert_product, insert_images, etc.) 
    # 被复制到 products/importer_core.py 中，并在主函数中调用它们。

    # **重要：** 这一部分需要您将 import_products.py 中的逻辑重构为一个可导入的函数
    # 暂时使用一个占位符，模拟数据导入成功
    
    # 请手动将 import_products.py 中的所有函数复制到一个新文件 products/importer_core.py 中
    
    from .importer_core import import_products_from_list
    
    print(f"Starting database import for {len(products_list)} items...")
    
    # 假设 import_products_from_list 接受连接配置和产品列表
    # 注意：需要从 settings.py 获取 MYSQL_CONFIG
    
    # 由于您没有提供 settings.py 中数据库配置的统一入口，我们暂时使用硬编码的连接配置
    # ！！请务必将您的 MYSQL_CONFIG 移入 Django settings.py 或 .env 文件！！
    MYSQL_CONFIG = {
        "host": "192.168.3.17",
        "user": "root",
        "password": "abcd1234",
        "db": "tiktok_products",
        "charset": "utf8mb4",
        "cursorclass": pymysql.cursors.DictCursor,
    }
    
    try:
        # import_products_from_list(MYSQL_CONFIG, products_list)
        pass # 实际调用导入函数
        print("Database import complete.")
    except Exception as e:
        print(f"Database import failed: {e}")
    

```

### 📋 步骤三：创建 Admin 自定义视图

现在，我们在 Django Admin 中创建一个自定义页面来承载表单。

#### 1. 创建 `products/views.py`

在 `products` 应用中添加一个视图函数来渲染表单并处理提交。

Python

``` python
# products/views.py
from django.shortcuts import render
from django.contrib.admin.views.decorators import staff_member_required
from django.http import HttpResponseRedirect
from django.urls import reverse
from .tasks import trigger_bright_data_task # 导入触发函数

@staff_member_required
def product_fetch_view(request):
    context = {
        'title': 'TikTok 产品数据抓取与导入',
        'site_header': 'TikTok PM 后台管理',
    }

    if request.method == 'POST':
        # 1. 从表单中获取多行 URL
        urls_raw = request.POST.get('urls_input', '')
        urls_list = [url.strip() for url in urls_raw.split('\n') if url.strip()]
        
        if not urls_list:
            context['message'] = "错误：请输入至少一个产品 URL。"
            context['status'] = "error"
            return render(request, 'admin/product_fetch.html', context)
        
        # 2. 触发异步任务 (trigger_bright_data_task 将会调用 async_task)
        job_id = trigger_bright_data_task(urls_list)
        
        if job_id:
            context['message'] = f"✅ 已成功触发 Bright Data 任务，Job ID: {job_id}。请耐心等待异步导入结果。"
            context['status'] = "success"
        else:
            context['message'] = "❌ 触发 Bright Data 任务失败，请检查 API 密钥和网络连接。"
            context['status'] = "error"

    return render(request, 'admin/product_fetch.html', context)
```

#### 2. 配置 Admin URL

在 `products` 应用中创建 `admin.py` 或修改现有的 `tiktok_pm_project/urls.py` 来添加这个新路径。

**在 `products/admin.py` 中添加自定义 URL：**

Python

``` python
# products/admin.py
from django.contrib import admin
from django.urls import path
from .views import product_fetch_view

class ProductAdmin(admin.ModelAdmin):
    # ... 其他 admin 配置

    def get_urls(self):
        urls = super().get_urls()
        custom_urls = [
            # 新增 /admin/product_fetch/ 路径
            path('product_fetch/', self.admin_site.admin_view(product_fetch_view), name='product_fetch'),
        ]
        return custom_urls + urls

# admin.site.register(Product, ProductAdmin) # 确保 Product 模型已注册
```

#### 3. 创建 Admin 模板

Django Admin 的自定义视图需要一个模板。在您的项目模板目录下创建 `templates/admin/product_fetch.html`。

HTML

``` html
{% extends 'admin/base_site.html' %}
{% load i18n admin_static %}

{% block content %}
<div id="content-main">
    <h1>{% trans "TikTok 产品数据抓取与导入" %}</h1>
    <p>请输入需要抓取的产品 URL，一行一个。</p>

    {% if message %}
    <div class="messagelist">
        <p class="{% if status == 'success' %}success{% else %}error{% endif %}">
            {{ message }}
        </p>
    </div>
    {% endif %}

    <form action="." method="post">{% csrf_token %}
        <div class="form-row">
            <label class="required" for="id_urls_input">产品 URL 列表:</label>
            <textarea name="urls_input" id="id_urls_input" rows="10" cols="80" required></textarea>
        </div>
        
        <div class="submit-row">
            <input type="submit" value="开始异步抓取并导入" class="default" name="_start">
        </div>
    </form>
</div>
{% endblock %}
```

---

### 📋 步骤四：运行工作进程

因为使用了 Django-Q，您需要在后台运行一个工作进程，来执行 `poll_bright_data_result` 这样的异步任务。

#### 1. 修改 `docker-compose.yml`

在您的 `docker-compose.yml` 文件中，新增一个 `worker` 服务：

YAML

```
# docker-compose.yml

services:
  # ... (原有的 web 和 db 服务)

  worker:
    build: 
      context: .
      dockerfile: Dockerfile # 使用与 web 相同的镜像
    container_name: tiktok_pm_worker
    env_file:
      - .env
    command: python manage.py qcluster # 启动 Django-Q 进程
    depends_on:
      - web # 确保 web 应用启动后 worker 才启动
```

#### 2. 重启所有服务

在服务器上，确保您的代码已提交并拉取，然后启动所有服务：

Bash

```
docker compose up --build -d
```

现在，当您访问 `/admin/product_fetch/` 页面并提交表单时，Django-Q 就会将任务发送给 `worker` 容器，在后台完成 Bright Data 的调用、下载和 MySQL 导入。



## 14 添加自定义页面到Admin导航页

您的问题是如何在 Django Admin 的导航（Navigate）区域，即左侧菜单栏或顶部链接中，显示您自定义的 `product_fetch` 页面链接。

由于 `product_fetch` 是一个**自定义的视图**，而不是一个标准的模型管理页面，您需要通过自定义 **`admin.py`** 来将这个链接注入到 Admin 界面中。

以下是实现此目标的标准方法，需要修改您的 `products/admin.py` 文件：

### 🛠️ 步骤一：修改 `products/admin.py`

在您的 `ProductAdmin` 类中，我们将添加一个名为 `product_fetch_link` 的方法，并利用 Django Admin 的 `changelist_view` 上下文注入这个链接。

请更新您的 **`D:\Python\tiktok_pm\products\admin.py`** 文件，确保 `ProductAdmin` 类中包含以下逻辑：

Python

```
# D:\Python\tiktok_pm\products\admin.py

from django.contrib import admin
from django.urls import path
from django.utils.html import format_html
from . import models
from . import views # 确保导入您的 views 模块

# ... (如果 Product 模型没有定义，请确保您已经定义了)
# 例如: from .models import Product 


class ProductAdmin(admin.ModelAdmin):
    # ... (您原有的配置，例如 list_display, search_fields 等)

    # ----------------------------------------------------
    # 1. 注入自定义 URL (您之前已完成)
    # ----------------------------------------------------
    def get_urls(self):
        urls = super().get_urls()
        custom_urls = [
            path('product_fetch/', self.admin_site.admin_view(views.product_fetch_view), name='product_fetch'),
        ]
        return custom_urls + urls

    # ----------------------------------------------------
    # 2. 创建链接显示的自定义方法
    # ----------------------------------------------------
    # 这个方法用于在 Model 的 'change list' 页面顶部显示链接
    def product_fetch_link(self):
        # 构造反向解析的 URL 名称
        # 格式为: 'admin:<app_label>_<model_name>_<url_name>'
        url = reverse('admin:products_product_product_fetch') 
        
        # 使用 format_html 来生成链接 HTML
        return format_html(
            '<a class="button" href="{}">{}</a>',
            url,
            '批量抓取产品' # 这是链接上显示的文本
        )
    # 设置短描述，让它在 Admin 中显示得更友好
    product_fetch_link.short_description = '触发批量抓取'
    
    # ----------------------------------------------------
    # 3. 覆盖 changelist_view 方法，将链接注入到上下文
    # ----------------------------------------------------
    def changelist_view(self, request, extra_context=None):
        if extra_context is None:
            extra_context = {}
            
        # 将自定义链接添加到上下文，通常命名为 'custom_actions' 或类似名称
        extra_context['product_fetch_url'] = reverse('admin:products_product_product_fetch')
        
        # 返回原始的 changelist 视图
        return super().changelist_view(request, extra_context=extra_context)


# 注册 Admin 类
admin.site.register(models.Product, ProductAdmin)
```

### 🛠️ 步骤二：修改 `product_fetch.html` (显示导航菜单)

为了让您的自定义页面看起来像一个标准的 Admin 页面，并且顶部导航栏（面包屑）能正确显示，您需要确保在 **`product_fetch.html`** 中包含 `admin/change_form.html` 中常用的导航块。

请检查并确保您的 **`D:\Python\tiktok_pm\products\templates\admin\product_fetch.html`** 模板至少包含以下结构：

HTML

```
{% extends "admin/base_site.html" %}
{% load i18n admin_urls static %}

{# ---------------------------------------------------------------- #}
{# 🌟 确保 breadcrumbs 块被定义，用于显示导航路径 🌟 #}
{# ---------------------------------------------------------------- #}
{% block breadcrumbs %}
<div class="breadcrumbs">
    <a href="{% url 'admin:index' %}">{% translate 'Home' %}</a>
    &rsaquo; <a href="{% url 'admin:app_list' app_label='products' %}">{% translate 'Products' %}</a>
    &rsaquo; <a href="{% url 'admin:products_product_changelist' %}">{% translate 'Products' %}</a>
    &rsaquo; {% translate '批量抓取产品' %} {# 自定义页面的名称 #}
</div>
{% endblock %}

{% block content %}
<div id="content-main">
    {# 您的表单和内容放在这里 #}
    <form action="" method="post">
        {% csrf_token %}
        <fieldset class="module">
            <h2>{% translate "TikTok 产品数据抓取" %}</h2>
            </fieldset>
        
        <div class="submit-row">
            <input type="submit" value="{% translate '提交任务并开始抓取' %}" name="_start" />
        </div>
    </form>
</div>
{% endblock %}
```

### 结果：

完成上述步骤后：

1. 当您进入 **Products (产品)** 列表页 (`/admin/products/product/`) 时，您应该会在页面顶部或一个易于访问的位置看到一个名为 **"批量抓取产品"** 的按钮或链接（如果您在 `changelist_view` 中使用了一个自定义模板来显示它）。
    
2. 最重要的是，通过 `get_urls` 的设置，该页面现在是一个有效的 Admin 子页面，并且通过 `breadcrumbs` 块，它能正确地融入 Admin 的导航体系。
    

如果您希望它出现在 Admin 首页左侧的**应用列表**下方，那需要更复杂的定制，但这通常是通过在 **模型列表页（changelist）**上放置一个醒目的链接来实现的。

## 15 **创建自定义管理命令（Custom Management Command）**。

我想把我的importer_wrapper.py包装成一个python命令，可以通过类似python manage.py runserver这样的方式去执行手动导入JSON的动作。

  

我已经手动把一些JSON 放在data\json目录下，所以希望这个命令能调用imort_product_from_list这个方法执行手动导入JSON文件进到数据库。该如何实现？

您好！这是一个非常标准且推荐的 Django 实践：**创建自定义管理命令（Custom Management Command）**。

通过自定义管理命令，您可以将 `importer_wrapper.py` 中的逻辑包装起来，并通过 `python manage.py import_json_data` 这样的方式运行，就像运行 `runserver` 一样。

### 🛠️ 实现自定义管理命令的步骤

我们将创建一个名为 `import_json_data` 的管理命令，它将查找 `data/json` 目录下的 JSON 文件，并调用您的导入逻辑。

#### 步骤一：创建管理命令目录结构

在您的 `products` 应用目录下，创建以下目录和文件：

```
products/
├── management/
│   └── commands/
│       └── import_json_data.py  <-- 新文件
└── ...
```

#### 步骤二：编写 `import_json_data.py`

在这个文件中，您需要继承 `BaseCommand` 并实现 `handle()` 方法。

**文件：`products/management/commands/import_json_data.py`**

Python

``` python
import os  
import json  
from django.core.management.base import BaseCommand, CommandError  
from django.conf import settings  
from pathlib import Path  
  
# 假设您的导入逻辑在 products/importer_wrapper.py 文件中  
# 我们需要确保能正确导入它。  
from products.importer_wrapper import import_products_from_list  
  
  
class Command(BaseCommand):  
    # 命令行中使用的名称：python manage.py import_json_data  
    help = 'Imports product data from JSON files located in the MEDIA_ROOT/temp_json directory and moves them to MEDIA_ROOT/json upon success.'  
  
    def handle(self, *args, **options):  
        # 定义源目录和目标目录  
        source_subdir = 'temp_json'  
        target_subdir = 'json'  
  
        json_dir = Path(settings.MEDIA_ROOT) / source_subdir  
        target_dir = Path(settings.MEDIA_ROOT) / target_subdir  
  
        # 1. 检查源目录是否存在  
        if not json_dir.exists():  
            raise CommandError(f'JSON source directory does not exist: {json_dir}')  
  
        # 2. 确保目标目录存在，如果不存在则创建  
        target_dir.mkdir(parents=True, exist_ok=True)  
  
        self.stdout.write(self.style.NOTICE(f'Scanning directory: {json_dir}'))  
        self.stdout.write(self.style.NOTICE(f'Target directory for completed files: {target_dir}'))  
  
        success_count = 0  
        failure_count = 0  
  
        # 3. 遍历目录中的所有 JSON 文件  
        for filename in os.listdir(json_dir):  
            if filename.endswith('.json'):  
                file_path = json_dir / filename  
                self.stdout.write(f'Processing file: {filename}')  
  
                try:  
                    with open(file_path, 'r', encoding='utf-8') as f:  
                        data = json.load(f)  
  
                    # 检查 JSON 数据结构  
                    if not isinstance(data, list):  
                        data = [data]  
  
                    # 调用导入函数  
                    import_products_from_list(data)  
  
                    # ----------------------------------------------------  
                    # 🌟 关键步骤：文件移动 🌟  
                    # ----------------------------------------------------  
                    target_file_path = target_dir / filename  
  
                    # 使用 os.rename (或 shutil.move) 将文件移动到目标目录  
                    # os.rename 可以用于跨目录的文件移动  
                    os.rename(file_path, target_file_path)  
  
                    self.stdout.write(  
                        self.style.SUCCESS(f'Successfully imported and moved {filename} to {target_subdir}/'))  
                    success_count += 1  
  
                except json.JSONDecodeError:  
                    self.stderr.write(self.style.ERROR(f'Failed to decode JSON from {filename}. Skipping.'))  
                    failure_count += 1  
                except Exception as e:  
                    self.stderr.write(self.style.ERROR(f'Error importing {filename}: {e}'))  
                    # 如果导入失败，文件保留在 temp_json 目录  
                    failure_count += 1  
  
        self.stdout.write(self.style.SUCCESS(  
            f'\n--- Import Finished ---'  
        ))  
        self.stdout.write(f'Total files processed: {success_count + failure_count}')  
        self.stdout.write(self.style.SUCCESS(f'Successful imports and moves: {success_count}'))  
        self.stdout.write(self.style.ERROR(f'Failed imports (files kept in source): {failure_count}'))

```

#### 步骤三：验证 `settings.py`

请确保您的 `settings.py` 中 `MEDIA_ROOT` 配置正确，以便 `Path(settings.MEDIA_ROOT) / 'json'` 能指向 `data/json` 目录。

Python

```
# settings.py

from pathlib import Path
import os

# BASE_DIR = Path(__file__).resolve().parent.parent 

MEDIA_ROOT = os.path.join(BASE_DIR, 'data') # 确保指向项目根目录下的 data 文件夹
```

#### 步骤四：执行命令

在您的 Django 项目根目录下（即 `manage.py` 所在的目录），运行您的自定义命令：

Bash

```
# 确保在运行前，您的 Docker 或本地数据库服务已启动
python manage.py import_json_data
```

---

然后，用户可以通过以下方式运行：

Bash

``` bash

# 默认从 data/json 导入
python manage.py import_json_data

# 在生产环境下运行
docker compose exec web python manage.py import_json_data

```