Metadata-Version: 2.4
Name: django-log-archive
Version: 0.3.1
Summary: 日志归档通用扩展包：抽象归档模型、分块归档流水线、可扩展统计聚合、流式归档加解密
Author-email: rRR0VrFP <rrr0vrfp@qq.com>
Maintainer-email: rRR0VrFP <rrr0vrfp@qq.com>
License: MIT
Project-URL: homepage, https://gitee.com/rRR0VrFP/django-log-archive
Keywords: django,archive,log,pipeline,aggregate,encryption,crypto
Classifier: Development Status :: 5 - Production/Stable
Classifier: Framework :: Django
Classifier: Framework :: Django :: 4.2
Classifier: Framework :: Django :: 5.0
Classifier: Framework :: Django :: 5.1
Classifier: Framework :: Django :: 5.2
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: Django
Requires-Dist: pycryptodome
Requires-Dist: django-simpletask5
Requires-Dist: django-static-fontawesome
Dynamic: license-file

# django-log-archive

日志归档通用扩展包：抽象归档模型、分块归档流水线、可扩展统计聚合、流式归档加解密，以及可插拔的归档写入器 / 历史读取器。

![Version](https://img.shields.io/badge/version-0.3.1-blue)
![Django](https://img.shields.io/badge/Django-4.2%20%7C%205.0%20%7C%205.1%20%7C%205.2-green)
![License](https://img.shields.io/badge/License-MIT-green)

## 特性

- **抽象归档模型** — `ArchiveRecordBase` / `ArchiveStatsBase`，业务继承即得归档记录与按日统计表；抽象基类无实质 DDL，业务子类按需建表并声明统计唯一约束
- **分块归档流水线** — `ChunkedPipeline` / `PipelineStage` / `iter_batches`，大表按批次取数、写归档、统计、删记录；内置通用阶段
  `ArchiveExportStage`（按日整卷导出）/ `SourceCleanupStage`（归档后删源数据）与集成默认阶段的 `ArchivePipeline` 基类
- **原始日志保留策略** — `ArchivePipeline(retention_days=N)` 实现「按日归档、原始日志保存 N 天」：归档时打
  `archived_at` 标记而非立即删除，到期后自动清理；重复归档不会产生重复数据（`DJANGO_LOG_ARCHIVE_RETENTION_DAYS` 可配默认值）
- **可扩展统计聚合** — `BaseAggregate` + 内置 `CountAggregate` / `SuccessFailureAggregate` / `TokensAggregate`，`StatsAggregator` 驱动分组聚合；
  统计模型按 `group_fields` 建唯一约束，重复归档原子累加不覆盖（系统检查 `E001` 校验）
- **DateField / DateTimeField 按天归档** — 源日期字段支持 `DateField` 与 `DateTimeField`；
  `DateTimeField` 按 `settings.TIME_ZONE` 归一到本地日（跨零点记录归入正确日期），
  范围过滤使用本地日边界直接比较原字段，不依赖 `__date` / `CONVERT_TZ`，可命中索引
- **仅统计模式** — `ArchivePipeline(use_export=False)` / `archive --no-file` 只生成每日统计、不落盘归档文件
  （保留源数据；可配 `--retention-days` 使重复运行幂等）
- **流式归档加解密（默认写入器）** — 64KB 分块 + 临时文件，全程不加载文件到内存，适配单日 50GB 级归档；内置 4 种算法（GCM / CBC / 两种历史格式）
- **整卷归档** — 按日期写入归档文件：同一天只有一个归档文件，默认写入器输出整卷压缩 + 加密的单个标准信封；重复归档同一日期时读取既有内容 + 合并新记录 + 按当前 `writer` 整体重新编码为新文件（先落新文件再删旧文件）
- **归档文件检查** — `django_simple_archive` 管理命令：`list-archive-models` / `list-source-models` 列出归档与源模型、`check` 逐记录验证可读性与内容、`archive` 按源模型归档
- **后台归档任务** — 基于 django-simpletask5 的后台任务 + 核心 `ArchiveButtonAdminMixin`
  管理后台「归档」按钮（日期选择弹窗），业务侧配置 `archive_source_model` 即可一键异步归档；
  列表页模板**链式叠加**，可与 django-mptt（树形列表）、django-import-export 等 admin
  扩展共存（`ImportExportMixin` → 业务模板 → 核心归档模板 → mptt → admin）
- **定时归档** — example 内置 cronjob（每天 00:05 归档昨天），复用 django-simpletask5
  的 cron 调度器（`register_cronjob` + `django_simpletask_crontab`）与现有 worker，
  无需额外定时框架
- **流式压缩中间件** — 默认写入器**先压缩后加密**（信封头 `compress` 字段自描述，历史
  未压缩文件读取时自动跳过解压），`DJANGO_LOG_ARCHIVE_COMPRESSOR` 可配（默认 gzip /
  `None` 关闭），临时文件根目录 `DJANGO_LOG_ARCHIVE_TEMP_DIR` 可配；全程流式
- **归档导出与重建** — 管理命令 `export`（读取归档并导出明文 JSONL 到指定目录，
  如 OSS / 冷备盘）、`reencrypt`（按归档对象当前 `writer` 重新编码历史文件）、
  `normalize`（把历史归档文件归一化为当前目标格式）、`download`（同步生成明文 zip 压缩包）
- **按归档对象配置策略** — 归档模型可用 `writer` 指定写入器（默认整卷压缩 + 加密标准
  信封，可替换为业务自定义格式），用 `cipher` / `compressor` 配置默认写入器的算法与
  压缩，用 `ciphers` / `compressors` 读取本框架历史格式，用 `readers` 读取**非本框架
  生成**的历史归档（明文 / zip / tar / 第三方格式等）；`check` / `export` / `reencrypt` /
  `normalize` / `download` / 后台读取均按记录所属归档对象取策略
- **归档下载管理** — 核心数据模型 `ArchiveDownloadTask`（两级完成度进度 + 结果
  文件），同一核心执行器支撑 django-simpletask5 后台异步与同步命令；归档记录列表
  action「下载所选归档记录的明文压缩包」选定范围后台生成 zip，后台「归档下载
  任务」页实时看进度并下载
- **归档记录管理页** — `ArchiveRecordAdminMixin`：全字段只读 + 下载密文/明文 +
  异步检查；单文件读取走后台任务，避免同步接口超时
- **一键集成** — `django_log_archive/__init__.py` 声明 `app_requires`
  （`django_static_fontawesome`、`django_simpletask5`），配合 `django_app_requires`
  自动展开 `INSTALLED_APPS` 依赖

## 安装

```bash
pip install django-log-archive
```

在 `INSTALLED_APPS` 中注册：

```python
INSTALLED_APPS = [
    ...
    "django_log_archive",
    # 需要后台归档任务 / 下载管理（django-simpletask5 worker）时再加（依赖 Redis）：
    "django_simpletask5",
    # 归档按钮图标（fa-solid fa-box-archive）需要：
    "django_static_fontawesome",
]
```

也可通过 `django_app_requires` 自动展开（核心包在 `__init__.py` 声明了
`app_requires`，详见下文「业务集成方案」第 1 步）。

可选配置默认写入器、加密密钥、历史读取策略：

```python
DJANGO_LOG_ARCHIVE_ENCRYPTION_KEY = "自定义归档加密密钥"   # 缺省回退 Django SECRET_KEY

# 默认写入器（不配置即用内置标准写入器：整卷压缩 + 加密标准信封）
# DJANGO_LOG_ARCHIVE_WRITER = "myapp.archive.writers.LegacyZipWriter"

# 默认写入器使用的加密引擎与压缩器
DJANGO_LOG_ARCHIVE_CIPHER = "django_log_archive.crypto.aes256gcm.AES256GCMCipher"  # 默认
DJANGO_LOG_ARCHIVE_COMPRESSOR = "django_log_archive.compress.GzipCompressor"       # 默认开启
# DJANGO_LOG_ARCHIVE_COMPRESSOR = None   # 关闭压缩

# 读取本框架历史格式：历史解密器链（算法 + 密钥）、历史自定义压缩器
DJANGO_LOG_ARCHIVE_CIPHERS = [
    "django_log_archive.crypto.legacy_gcm_binary.LegacyGCMBinaryCipher",
    {"cipher": "django_log_archive.crypto.legacy_json_envelope.LegacyJSONEnvelopeCipher", "key": "旧密钥"},
]
DJANGO_LOG_ARCHIVE_COMPRESSORS = ["myapp.compress.ZstdCompressor"]

# 读取非本框架生成的历史归档（明文 / zip / tar / 第三方格式等）
# DJANGO_LOG_ARCHIVE_READERS = ["myapp.archive.readers.LegacyZipReader"]
```

## 配置项一览（含默认值）

### Django settings

| 配置项 | 默认值 | 说明 |
| --- | --- | --- |
| `DJANGO_LOG_ARCHIVE_ENCRYPTION_KEY` | 未设置 → 回退 `SECRET_KEY` | 归档加密原始密钥，实际密钥 = `sha256(raw_key)` |
| `DJANGO_LOG_ARCHIVE_CIPHER` | `django_log_archive.crypto.aes256gcm.AES256GCMCipher`（算法 `aes256-gcm`） | 默认写入器使用的加密引擎（类路径字符串） |
| `DJANGO_LOG_ARCHIVE_CIPHERS` | 空（`None`，仅用默认加密引擎解密） | 读取**本框架历史格式**时的解密器链，元素为类路径字符串或 `{"cipher": 类路径, "key": 旧密钥}` |
| `DJANGO_LOG_ARCHIVE_COMPRESSOR` | `django_log_archive.compress.GzipCompressor`（`gzip`，默认开启） | 默认写入器使用的流式压缩器（类路径字符串）；设 `None` 关闭压缩。**先压缩后加密**，信封头带 `compress` 字段，历史未压缩文件读取时按该字段自动跳过解压 |
| `DJANGO_LOG_ARCHIVE_COMPRESSORS` | 空（`None`，历史压缩器仅用内置注册表） | 历史解压器列表（类路径字符串），按 `algorithm` 匹配旧文件 `compress` 字段；用于自定义压缩器轮换后仍能解压历史文件 |
| `DJANGO_LOG_ARCHIVE_WRITER` | 空（使用默认标准写入器） | 全局归档写入器（类路径 / 类 / 实例 / 工厂）；缺省为整卷压缩 + 加密标准信封，归档模型可用 `writer` 覆盖 |
| `DJANGO_LOG_ARCHIVE_READERS` | 空（无额外历史读取器） | 全局历史归档读取器列表（类路径 / 类 / 实例 / 可调用对象），用于读取非本框架生成的历史归档；归档模型可用 `readers` 覆盖 |
| `DJANGO_LOG_ARCHIVE_TEMP_DIR` | `/tmp` | 归档临时文件根目录（压缩缓冲 / 解密输出 / check 临时文件），自动创建；不可写时回退系统临时目录 |
| `DJANGO_LOG_ARCHIVE_PROGRESS_INTERVAL` | `524288000`（500MB） | 归档大文件流式环节（压缩+加密 / 解密合并 / 上传）的进度日志阈值（字节），每处理该字节数输出一条 `logging` 日志；设 `0` / 负数 / `None` 关闭 |
| `DJANGO_LOG_ARCHIVE_RETENTION_DAYS` | 未设置（`None`，归档后立即删除源日志） | 原始日志保存天数（按日归档、到期清理）；需要源模型有 `archived_at` 字段 |
| `MEDIA_ROOT`（Django 内置） | 项目自身默认 | 归档文件实际落盘目录（归档文件存 `MEDIA_ROOT/archives/%Y/%m/`） |

### 归档模型声明（`ArchiveRecordBase` 子类类属性）

| 属性 | 默认值 | 说明 |
| --- | --- | --- |
| `source_model` | `None` | 源日志模型（类或 `"app_label.Model"`）；为 `None` 时不参与归档发现 |
| `serializer` | `None` | 序列化器 callable 或源模型方法名字符串；缺省调用源模型 `to_archive_line()` |
| `stats_model` | `None` | 统计模型（类或字符串）；`None` 则不生成统计；统计模型须对 `group_fields` 声明唯一约束 |
| `group_fields` | `()` | 统计分组字段（如 `("date",)`）；与统计模型的唯一约束字段保持一致 |
| `filename_prefix` | `"archive"` | 归档文件名前缀 |
| `group_field` | `"date"` | 按日归档使用的**源日志**日期字段名；支持 `DateField` / `DateTimeField`，与默认值不同时在此覆写（`run_archive` / 命令据此过滤日期范围） |
| `mark_field` | `"archived_at"` | 原始日志保留策略（`retention_days`）的归档标记字段名 |
| `writer` | 继承全局 `DJANGO_LOG_ARCHIVE_WRITER`（默认标准写入器） | 本归档对象的**写入器**（类路径 / 类 / 实例 / 工厂）；默认整卷压缩 + 加密标准信封，可替换为业务自定义格式 |
| `cipher` | 继承全局 `DJANGO_LOG_ARCHIVE_CIPHER` | 默认写入器使用的加密引擎（类路径字符串，必须是标准信封引擎；历史格式引擎只能放入 `ciphers`） |
| `compressor` | 继承全局 `DJANGO_LOG_ARCHIVE_COMPRESSOR` | 默认写入器使用的压缩器（类路径字符串 / `None` 关闭） |
| `ciphers` | 继承全局 `DJANGO_LOG_ARCHIVE_CIPHERS` | 读取**本框架历史格式**时的解密器链（旧算法 / 旧密钥） |
| `compressors` | 继承全局 `DJANGO_LOG_ARCHIVE_COMPRESSORS` | 读取**本框架历史格式**时的压缩器表（旧压缩器，按 `algorithm` 匹配）；未声明回落全局 |
| `readers` | 继承全局 `DJANGO_LOG_ARCHIVE_READERS` | 读取**非本框架生成**的历史归档（明文 / zip / tar / 第三方格式等）的读取器列表 |

> 策略属性用 `INHERIT` 哨兵表示「未声明、回落全局」，与「显式 `None` 关闭压缩」区分；
> `writer` / `readers` 未声明或为 `None` 时同样回落全局设置。
> `check` / `export` / `reencrypt` / `normalize` / `download` / 后台读取均按记录所属
> 归档对象构建策略。读取顺序：内置加解密链（`cipher` / `ciphers` / `compressor` /
> `compressors`）→ `readers`。写入统一走 `writer`，默认写入器由 `cipher` /
> `compressor` 决定算法与压缩。轮换算法/密钥/压缩器时，把旧配置留在 `ciphers` /
> `compressors` 即可继续读取本框架旧文件；完全外部的历史格式请用 `readers`。

```python
class ApiLogArchive(ArchiveRecordBase):
    source_model = ApiLog
    stats_model = ApiLogArchiveStats
    group_fields = ("date",)

    # 写入器：缺省为整卷压缩 + 加密标准信封；如需自定义写入格式再声明
    # writer = "myapp.archive.writers.LegacyZipWriter"

    # 默认写入器：CBC + 不压缩（须为标准信封引擎）
    cipher = "django_log_archive.crypto.aes256cbc.AES256CBCCipher"
    compressor = None
    # 读取本框架历史格式：旧 GCM 主密钥文件、旧 CBC 密钥文件、旧 zlib 压缩文件
    ciphers = [
        "django_log_archive.crypto.aes256gcm.AES256GCMCipher",
        {"cipher": "django_log_archive.crypto.aes256cbc.AES256CBCCipher", "key": "旧密钥"},
    ]
    compressors = [  # 自定义压缩器类路径，按 algorithm 匹配
        "myapp.compress.ZstdCompressor",
    ]
    # 读取完全外部的历史归档（不是本框架生成的）
    readers = ["myapp.archive.readers.LegacyZipReader"]
```

### 写入器与历史读取器

归档能力拆成两个可插拔协议：

| 协议 | 作用 | 默认实现 |
| --- | --- | --- |
| `ArchiveWriter` | 把明文 JSONL 编码为归档文件 | `CryptoArchiveWriter`：整卷压缩 + 加密标准信封 |
| `ArchiveReader` | 把归档文件解码为明文 JSONL | 无默认实例；读取时先走内置加解密链（本框架历史格式），再尝试业务声明的 `readers` |

选型速查：

| 需求 | 用哪个 |
| --- | --- |
| 继续用标准信封，只换算法 / 密钥 / 压缩器 | `cipher` / `compressor`（默认写入器） |
| 新归档写成 zip / tar / 业务自定义格式 | `writer` |
| 读取本框架历史文件（旧算法 / 旧密钥 / 旧压缩 / 旧信封头） | `ciphers` / `compressors` |
| 读取非本框架生成的历史文件（明文 / zip / tar / 第三方格式） | `readers` |

#### 写入器（writer）

归档模型用 `writer` 指定写入器。默认写入器由 `cipher` / `compressor` 决定算法与压缩；
`writer` 支持以下写法：

- 类路径字符串：`writer = "myapp.archive.writers.LegacyZipWriter"`
- 写入器类：`writer = LegacyZipWriter`
- 写入器实例：`writer = LegacyZipWriter(...)`
- 工厂函数：`writer = make_writer`，调用时传入归档模型（`make_writer(archive_model)`）

解析顺序：归档模型 `writer` → `DJANGO_LOG_ARCHIVE_WRITER` → 内置默认
`CryptoArchiveWriter`。自定义写入器需实现：

| 方法 | 是否必须 | 说明 |
| --- | --- | --- |
| `write(record, src_file, dst_file)` | 必须 | 把明文 JSONL 流编码写入 `dst_file`；`record` 为归档记录（含 `date` / `file` 等） |
| `matches(record, src_file)` | 可选 | 既有文件是否已是本写入器目标格式；默认 `False`（`normalize` 每次重写） |
| `describe(record)` | 可选 | 目标格式说明，用于 `normalize` / `reencrypt` 的 dry-run 展示 |

```python
import shutil
import zipfile

from django_log_archive.writers import ArchiveWriter

class LegacyZipWriter(ArchiveWriter):
    MEMBER = "data.jsonl"

    def write(self, record, src_file, dst_file):
        # record：归档记录（含 date / file 等）；src_file：明文 JSONL
        # dst_file 为二进制可写且可 seek 的临时文件
        with zipfile.ZipFile(dst_file, "w", zipfile.ZIP_DEFLATED) as zf:
            with zf.open(self.MEMBER, "w") as member:
                shutil.copyfileobj(src_file, member)

    def matches(self, record, src_file):
        # 已是目标格式返回 True，normalize 会跳过；不实现时默认 False（每次重写）
        try:
            with zipfile.ZipFile(src_file) as zf:
                return self.MEMBER in zf.namelist()
        except (zipfile.BadZipFile, OSError):
            return False

    def describe(self, record):
        return f"zip({self.MEMBER})"
```

写入器用于：归档流水线写入、同日再次归档时重新编码、`normalize` / `reencrypt` 的
目标格式。归档流水线新写入的文件名由框架统一生成为
`<prefix>_<date>_<random>.jsonl.enc`（`reencrypt` / `normalize` 重写已有记录时沿用原
文件名）；扩展名仅表示「归档文件」，**不代表内容格式**（例如自定义写入器可以在其中
写 zip / tar / 明文），读取一律通过 `writer.matches` / `readers` / 内容探测识别。

#### 历史读取器（readers）

本框架内置读取链只能读取自己的历史格式（旧算法 / 旧密钥 / 旧压缩 / 旧信封头）。
**并非由本框架生成**的历史归档（明文 JSONL、zip / tar 打包、第三方加密格式等）用
`readers` 读取：

自定义读取器需实现 `read(record, src_file, dst_file) -> bool`：成功把明文 JSONL
写入 `dst_file` 后返回 `True`；返回 `False` 或抛异常表示不匹配 / 解析失败。

```python
import shutil
import zipfile

from django_log_archive.readers import ArchiveReader

class LegacyZipReader(ArchiveReader):
    MEMBER = "data.jsonl"   # 与 LegacyZipWriter 写出的成员名一致

    def read(self, record, src_file, dst_file):
        # record.file.name / record.date 可用于选择解析方式
        # src_file 是归档文件，dst_file 用于写明文 JSONL
        with zipfile.ZipFile(src_file) as zf:
            with zf.open(self.MEMBER) as member:
                shutil.copyfileobj(member, dst_file)
        return True
```

解析顺序：归档模型 `readers` → `DJANGO_LOG_ARCHIVE_READERS` → 无（仅用内置链）。
`readers` 支持单个读取器或列表，元素同样可为类路径 / 类 / 实例 / 可调用对象
（`reader(record, src_file, dst_file) -> bool`）；`src_file` / `dst_file` 均为二进制流，
`src_file` 支持 `seek`（存储不支持随机读取时可先复制到临时文件），可用
`record.file.name` / `record.date` 选择解析方式；返回 `False` 或抛出异常会继续尝试
下一个读取器，每次尝试前 `dst_file` 会被清空。读取顺序：

```
内置加解密链（本框架历史格式） → readers 依次尝试 → 全部失败才报错
```

`readers` 会用于 `check`、`export`、`reencrypt`、`normalize`、`download`、后台明文
下载，以及同日再次归档时读取既有文件。典型迁移流程：

```python
class ApiCallLogArchive(ArchiveRecordBase):
    source_model = ApiCallLog
    stats_model = ApiCallLogArchiveStats
    group_fields = ("date",)
    # 新归档写成 zip 格式；不声明则用默认整卷压缩 + 加密标准信封
    writer = "myapp.archive.writers.LegacyZipWriter"
    # 读取外部历史归档；自定义 writer 也靠它反向读取自己写出的文件
    readers = ["myapp.archive.readers.LegacyZipReader"]
```

```bash
# 1) 先预览外部历史归档能否读取、哪些会归一化
python manage.py django_simple_archive normalize --model myapp.ApiCallLogArchive --dry-run
# 2) 归一化为当前 writer 目标格式（此处即 LegacyZipWriter；不声明 writer 则为标准信封）
python manage.py django_simple_archive normalize --model myapp.ApiCallLogArchive
# 3) 归一化后 check / export / download 均可正常使用
python manage.py django_simple_archive check --model myapp.ApiCallLogArchive
```

> 自定义写入器通常需要配套 `readers`，否则 `check` / `export` / `reencrypt` /
> `normalize` / `download` 与同日合并无法反向读取它写出的文件。
> 若外部历史归档与现有记录日期相同，归档流水线也会通过 `readers` 读取后合并。

公共入口：

| 函数 | 用途 |
| --- | --- |
| `get_writer_for(archive_model)` | 按归档模型解析写入器（模型声明 → 全局设置 → 默认） |
| `get_readers_for(archive_model)` | 按归档模型解析历史读取器列表（模型声明 → 全局设置） |
| `decode_stream(record, crypto, readers, src_file, dst_file)` | 先内置加解密链、后 `readers`，成功返回 `True` |

### 归档参数与适用场景

归档能力分布在**四类入口**上，各入口只接受自己那一层的参数——归档模型声明 /
`run_archive` / 低层 `ArchivePipeline` / `django_simple_archive` 命令不会、也不需要
接收全部参数。

#### A. 归档模型声明（`run_archive` 与命令自动读取）

`source_model` / `serializer` / `stats_model` / `group_fields` / `filename_prefix` /
`group_field` / `mark_field` 以及存储策略 `writer` / `readers` / `cipher` / `compressor` /
`ciphers` / `compressors` 均为 `ArchiveRecordBase` 子类的**类属性**（默认值见上
「归档模型声明」表）。`run_archive("app.Model", ...)` 与
`django_simple_archive archive` 通过 `source_model` 发现配置后**自动读取**，
调用处无需（也不能）重复传这些值；存储策略由各阶段 / 命令按模型自动构建。

#### B. `run_archive(...)` 函数参数（编程入口）

业务代码调用 `run_archive` 时，只传下述函数签名内的参数，其余配置来自 A 的模型声明：

```python
run_archive(source_model, since=None, until=None,
            chunk_size=2000, retention_days=None, use_export=True)
```

| 参数 | 默认值 | 说明 |
| --- | --- | --- |
| `source_model` | 必填 | 源日志模型（类或 `"app_label.Model"` 字符串）；须有归档模型声明了它 |
| `since` / `until` | `None`（归档全部） | 归档日期范围（含），`datetime.date` 或 `"YYYY-MM-DD"`；按源模型上的哪个字段过滤由 A 的 `group_field` 决定。`DateTimeField` 时按 `settings.TIME_ZONE` 解释为本地日边界（`since` 含当日 00:00，`until` 不含次日 00:00） |
| `chunk_size` | `2000` | 流水线批处理大小（取数 / 写归档 / 清理的事务粒度） |
| `retention_days` | `None` | 原始日志保留天数；`None` 时回退 `DJANGO_LOG_ARCHIVE_RETENTION_DAYS` 设置，仍为空则归档后立即删除；打标记字段见 A 的 `mark_field` |
| `use_export` | `True` | `False` = 仅生成每日统计、不生成归档文件（保留源数据；需声明 `stats_model`，可配 `retention_days` 实现幂等） |

> 例：`run_archive("myapp.ApiCallLog", since="2026-08-01", until="2026-08-31",
> retention_days=7)`——`serializer` / `stats_model` / `group_fields` /
> `group_field` 等全部取自 `ApiCallLogArchive` 上的声明。

#### C. `ArchivePipeline` 构造参数（低层，自组装流水线时使用）

业务一般用 B 或命令即可；只有需要自定义中间件组合时才直接构造 `ArchivePipeline`。
此时下表参数**都要显式传入**（不会按模型声明自动补齐）；但 `writer` / `readers` /
`cipher` / `compressor` / `ciphers` / `compressors` 等存储策略仍由各阶段按传入的
`archive_model` 自动读取。`since/until` 不是流水线参数——流水线只处理调用方给出的
id 集合，日期范围过滤在调用方（如 `run_archive`）完成：

| 参数 | 默认值 | 说明 |
| --- | --- | --- |
| `log_model` | 必填 | 源日志模型 |
| `archive_model` | 必填 | 归档模型 |
| `serializer` | `None` | 序列化器 callable 或源模型方法名字符串；缺省调用 `log_model.to_archive_line()` |
| `stats_model` | `None` | 统计模型；`None` 则不生成统计；统计模型须对 `group_fields` 声明唯一约束 |
| `group_fields` | `()` | 统计分组字段（如 `("date",)`）；与统计模型的唯一约束字段保持一致 |
| `filename_prefix` | `"archive"` | 归档文件名前缀 |
| `group_field` | `"date"` | 源模型上按日分桶的日期字段名 |
| `chunk_size` | `2000` | 批处理大小（取数 / 写归档 / 清理的事务粒度） |
| `flush_size` | `2000` | 导出阶段内存缓冲阈值：达到后把该日明文行溢写到临时文件；不决定归档文件生成时机（每个日期仍在 teardown 时整体编码一次） |
| `retention_days` | `None` | 原始日志保留天数；`None` 时回退 `DJANGO_LOG_ARCHIVE_RETENTION_DAYS`，仍为空则归档后立即删除 |
| `mark_field` | `"archived_at"` | 保留期归档标记字段（设 `retention_days` 才生效） |
| `use_export` | `True` | 是否生成归档文件；`False` 时仅聚合统计 |
| `use_cleanup` | `None`（自动） | 是否启用源数据清理阶段；自动值：启用 `use_export` 或设 `retention_days` 为 `True`，否则 `False` |
| `use_stats` | 由 `stats_model` 决定 | 是否启用统计聚合阶段；声明了 `stats_model` 时默认开启 |
| `stats_persist` | `None`（自动落库） | 统计结果落库回调；声明 `stats_model` 且启用统计时默认在清理源数据前持久化。仅在流水线成功时落库，失败则保留源数据 |

#### D. `django_simple_archive archive` 命令选项（运维入口）

命令同样只暴露自己的选项，见下「管理命令 `django_simple_archive` 选项」中的
`archive` 专属选项（`--chunk-size` / `--retention-days` / `--no-file` / `--mode` /
`--dry-run`）与公共筛选（`--since` / `--until` / `--model`）；模型声明中的
`serializer` / `group_fields` 等对命令透明，无需也无法通过命令行传入。

### 管理命令 `django_simple_archive` 选项

通用命令 `django_simple_archive`，核心包自带、无需业务编写命令。子命令：

| 子命令 | 说明 |
| --- | --- |
| `list-archive-models` | 列出全部归档模型（`ArchiveRecordBase` 子类）及其记录数 |
| `list-source-models` | 列出已声明流水线的源日志模型 |
| `check` | 按记录读取归档文件并校验内容一致性，全程流式 + 临时文件 |
| `archive` | 归档源日志（归档导出 + 聚合统计 + 清理源数据），支持 `--mode sync\|async` |
| `export` | 读取归档文件，导出明文 JSONL 到指定目录（如 OSS 挂载目录 / 冷备盘） |
| `reencrypt` | 历史归档按归档对象的**当前 `writer`** 重新编码（策略轮换后重建历史文件） |
| `normalize` | 把历史归档（旧算法 / 旧压缩 / 旧版信封头 / 外部格式）归一化为当前 `writer` 目标格式；已是目标格式的文件跳过 |
| `download` | 生成归档明文 **zip 压缩包**（同步执行，但创建匹配的下载管理数据记录） |

公共筛选选项（除 `archive` 外均适用；其中 `--id` 仅用于 `export` / `reencrypt` /
`normalize` / `download`——`check` 是整表逐条校验归档文件，按模型跑一遍即可，不需要也无法只校验单条记录，
故不支持 `--id`）：

| 选项 | 默认值 | 说明 |
| --- | --- | --- |
| `--model app_label.Model` | 全部模型 | 限定模型，可多次指定 |
| `--id N` | `None` | 只处理单条归档记录（主键） |
| `--since` / `--until` | `None`（全部） | 日期范围（含），`YYYY-MM-DD` |
| `--limit N` | `None`（不限制） | 每个归档模型最多处理多少条 |

`archive` 专属选项：

| 选项 | 默认值 | 说明 |
| --- | --- | --- |
| `--chunk-size N` | `2000` | 批处理大小 |
| `--retention-days N` | 取 `DJANGO_LOG_ARCHIVE_RETENTION_DAYS`，仍为空则立即删除 | 原始日志保留天数 |
| `--no-file` | `False` | 不生成归档文件，仅生成每日统计（保留源数据；需归档模型声明 `stats_model`，可再配 `--retention-days` 实现幂等） |
| `--mode sync\|async` | `sync` | 同步执行或创建后台任务 |
| `--dry-run` | `False` | 仅统计待归档数量，不实际执行 |

`export` 专属选项：`--output-dir PATH`（必填，明文导出目录，自动创建）。
`reencrypt` 专属选项：`--dry-run`（只校验能否读取并预览，不实际写回）。
`normalize` 专属选项：`--dry-run`（只报告将归一化 / 跳过的文件，不实际写回）。

`check` / `export` / `reencrypt` / `normalize` / `download` 另有分块读取选项 `--chunk-size N`
（默认 `2000`，每批从数据库读取的记录数，超大记录量下避免一次性载入全部归档记录），
与 `archive` 的批处理粒度一致；`--limit` 仍用于限制每模型处理条数。

### `ArchiveButtonAdminMixin` 属性

| 属性 | 默认值 | 说明 |
| --- | --- | --- |
| `archive_source_model` | `None`（缺省由归档模型声明推断） | 后台归档按钮的源日志模型 |
| `archive_form_class` | `django_log_archive.forms.ArchiveLogsForm` | 归档弹窗表单 |
| `archive_verbose_name` | `"归档日志"` | 弹窗标题 |
| `archive_url_name` | `None` → `<app_label>_<model>_archive` | 归档视图 URL 名称 |
| `change_list_template` | `django_log_archive/admin/change_list.html`（默认） | 列表页模板；业务自定义模板须 `{% extends %}` 核心模板以保留归档按钮（链式叠加，见下） |

后台「归档」按钮的列表页模板与静态资源均为核心包自带：

- 核心模板 `django_log_archive/admin/change_list.html`（按钮 + 弹窗 + 快捷日期），其
  父模板按 MRO 自动解析（见「列表页模板链式叠加」），静态样式/脚本抽为
  `static/django_log_archive/admin/archive.css` / `archive.js`，随 `collectstatic` 发布。

### 默认写入器内部参数（固定常量）

| 常量 | 默认值 | 说明 |
| --- | --- | --- |
| 分块大小 | `65536` 字节（64KB） | 流式加解密按块处理，不加载文件到内存 |
| 信封头读取上限 | `4096` 字节 | 解析第一行 JSON 头的最大长度 |
| nonce / IV | 16 字节 | AES-GCM 初始化向量 |
| GCM tag | 16 字节 | 认证标签长度 |
| 归档文件存储路径 | `archives/%Y/%m/<文件名>` | 按归档日期（年月）分目录 |

后台异步归档（`--mode async` / admin 按钮 / 定时归档）还需 django-simpletask5 的
配置（如 `DJANGO_SIMPLETASK_BROKER_URL`，默认指向 Redis），详见 django-simpletask5 文档。

## 快速开始（内置 demo + example）

仓库内置 `django_log_archive_demo`（演示项目）与 `django_log_archive_example`（验证应用）。

```bash
pip install -r requirements.txt        # 生产运行时依赖
pip install -r requirements.tests.txt  # demo/example 与测试所需（含上者），跑 demo/测试时装
python manage.py migrate
python manage.py generate_api_logs --days 3 --per-day 100   # 生成演示源日志
python manage.py django_simple_archive list-source-models    # 列出源日志模型
python manage.py django_simple_archive archive --until 2026-08-03   # 同步归档
python manage.py django_simple_archive archive --until 2026-08-03 --mode async   # 后台异步归档
python manage.py django_simple_archive archive --until 2026-08-03 --no-file   # 仅统计、不生成归档文件
python manage.py django_simple_archive list-archive-models   # 列出归档模型
python manage.py django_simple_archive check                 # 逐记录验证可读性与内容
python manage.py django_simple_archive export --output-dir /backup/plain   # 导出明文 JSONL（如 OSS）
python manage.py django_simple_archive reencrypt --since 2026-01-01        # 按当前 writer 重新编码
python manage.py django_simple_archive normalize --dry-run                 # 预览历史归档归一化
python manage.py django_simple_archive normalize --since 2026-01-01        # 归一化为当前目标格式
python manage.py django_simple_archive download --since 2026-08-01         # 生成明文 zip 压缩包（同步）
python manage.py runserver                                  # 管理后台 http://127.0.0.1:8000/admin/
```

管理后台中打开「API 调用日志」列表，点击右上角「归档」按钮，弹出日期选择
（起始/截止日期），提交后由 django-simpletask5 在后台执行归档。

另在「API 分类」列表页可看到**三者共存**效果：归档按钮 + import/export 按钮 +
mptt 树形缩进列表同屏显示（`ApiCategoryAdmin` 组合 `ImportExportMixin`、
`ArchiveButtonAdminMixin`、`MPTTModelAdmin`）。

如需本地跑通后台任务，需要 Redis（示例默认 `redis://:changeme@127.0.0.1:6390/0`）
并另开一个终端启动 worker：

```bash
python manage.py django_simpletask_executor --workers 1
```

运行验证测试：

```bash
python -m pytest            # 或 python manage.py test
```

## 测试结构

- `django_log_archive/tests/` — 通用测试（可随扩展包复用）：加密回环 / 历史格式读取 /
  算法链 / 按归档对象策略 / 写入器与读取器解析 / 密钥派生 / 信封头解析 / 流水线分块与阶段顺序
- `django_log_archive_example/tests/` — 示例相关测试：整卷归档流水线端到端 / 聚合统计 /
  生成与归档管理命令 / `django_simple_archive` 各分支（含 `normalize`）/ 自定义读写器与
  外部历史格式 / 后台任务与 admin 归档视图

`manage.py test` 与 `python -m pytest` 均可运行；pytest 默认附加覆盖率统计
（`django_log_archive` 覆盖率 ≥ 95%，见 `pyproject.toml`）。

## 项目结构

```
django-log-archive/
├── django_log_archive/              # 核心扩展包（打包发布）
│   ├── models.py                    # 抽象归档模型
│   ├── pipeline.py                  # 分块流水线框架（ChunkedPipeline / PipelineStage / iter_batches）
│   ├── stages.py                    # 通用归档阶段（ArchiveExportStage / SourceCleanupStage）与默认流水线 ArchivePipeline
│   ├── export.py                    # 明文导出 / 重新编码 / 归一化 / 单条记录读取
│   ├── download.py                  # 明文 zip / 单文件 / 检查下载执行器
│   ├── aggregates.py                # 统计聚合器
│   ├── checks.py                    # 归档模型发现 / 归档文件校验逻辑
│   ├── registry.py                  # 归档/源模型发现与流水线配置解析
│   ├── fields.py                    # DateField / DateTimeField 按天归一与范围过滤策略
│   ├── archive.py                   # 通用归档服务（run_archive）
│   ├── tasks.py                     # 无任务模型异步归档（submit_archive_task / ArchiveExecutionExecutor）
│   ├── admin.py                     # 后台归档按钮 mixin（ArchiveButtonAdminMixin）
│   ├── forms.py                     # 归档弹窗表单（ArchiveLogsForm）
│   ├── management/commands/         # django_simple_archive 综合管理命令
│   ├── templates/                   # 后台归档按钮核心模板（按钮 / 弹窗 / 快捷日期）
│   ├── static/                      # 归档按钮样式与脚本（archive.css / archive.js）
│   ├── crypto/                      # 流式加解密引擎（4 种算法，标准信封 + 历史格式兼容）
│   ├── writers.py                   # 归档写入器（默认 CryptoArchiveWriter + 自定义 writer 解析）
│   ├── readers.py                   # 历史归档读取器（读取非本框架生成的历史文件）
│   └── migrations/                  # 核心具体模型迁移（下载任务）
├── django_log_archive_demo/         # 演示项目（settings / urls / asgi / wsgi）
├── django_log_archive_example/      # 验证应用（具体模型 / 后台任务 / admin 归档按钮）
├── manage.py
├── pyproject.toml
└── README.md
```

## 模型

- `ArchiveRecordBase` — 归档记录基类（抽象）。字段：`date/year/month/day`、`created_at/updated_at`、
  `file`（归档文件；默认写入器把明文 JSONL 压缩 + 加密后写入，
  `upload_to=django_log_archives`）、`record_count/failed_count/file_size`。
- `ArchiveStatsBase` — 归档统计基类（抽象）。声明 `aggregates`（聚合器列表）并提供
  `build_aggregator(group_fields=())`；具体统计模型必须为归档模型的 `group_fields`
  声明唯一约束（如 `UniqueConstraint(fields=["date"], ...)`）。
- `ArchiveDownloadTask` — **归档下载管理**（核心具体模型，自带迁移）。字段：`mode`
  （`bundle` zip 明文压缩包 / `single` 单文件明文 / `check` 检查读取）、
  `archive_model_label`、`status`（pending / running / success / failed / canceled）与
  **两级完成度**：`record_total` / `record_done` / `record_failed`（记录级）+ `step_total`
  / `step_done`（步骤级）。每条记录固定 2 个子步骤（读取 → 写入产物）；
  `bundle`/`single` 生成结果文件时再加 1 个**收尾步骤**（`step_total = record_total × 2 + 1`），
  `check` 无收尾步（`step_total = record_total × 2`）+
  `current_record_id` / `current_step`（当前记录及其子步骤）；结果文件
  `result_file` / `result_size`、`result_message`（check 结果）、`error`、`execution_id`
  （simpletask5 任务）。`progress_percent()` 返回整体完成度百分比。
- `WithArchiveDateFields` / `WithArchiveTimestampFields` — 日期与时间戳混入。

业务应用继承抽象基类并覆写字段即可。示例见 `django_log_archive_example/models.py`：

```python
class ApiLogArchive(ArchiveRecordBase):
    source_model = ApiLog                 # 源日志模型（供 archive 命令与流水线使用）
    serializer = ApiLog.to_archive_line   # 序列化器：callable 或源模型方法名字符串
    stats_model = "django_log_archive_example.ApiLogArchiveStats"
    group_fields = ("date",)
    filename_prefix = "api_log"

    class Meta:
        verbose_name = "API 日志归档"


class ApiLogArchiveStats(ArchiveStatsBase):
    call_count = models.PositiveIntegerField(default=0)
    success_count = models.PositiveIntegerField(default=0)
    failed_count = models.PositiveIntegerField(default=0)
    prompt_tokens = models.BigIntegerField(default=0)
    completion_tokens = models.BigIntegerField(default=0)
    total_tokens = models.BigIntegerField(default=0)

    aggregates = [
        CountAggregate(output_field="call_count"),
        SuccessFailureAggregate(),
        TokensAggregate(),
    ]

    class Meta:
        constraints = [
            models.UniqueConstraint(
                fields=["date"], name="uniq_apilog_archive_stats_date"
            )
        ]
```

统计模型的唯一约束字段必须与归档模型的 `group_fields` 一致；默认按天即
`("date",)`，有额外分组时使用组合唯一约束。

源日志的日期字段也可以是 `DateTimeField`，只需把 `group_field` 指向该字段；归档记录 /
统计记录仍按「天」存储（`group_fields` 用 `date`），跨零点记录按
`settings.TIME_ZONE` 归入正确的本地日期：

```python
class EventLogArchive(ArchiveRecordBase):
    source_model = EventLog                 # 源日志模型
    serializer = EventLog.to_archive_line
    stats_model = "django_log_archive_example.EventLogArchiveStats"
    group_fields = ("date",)                # 归档 / 统计记录按天存储
    group_field = "add_time"                # 源模型上的 DateTimeField
    filename_prefix = "event_log"
```

example 中的 `EventLog` / `EventLogArchive` / `EventLogArchiveStats` 就是完整的
`DateTimeField` 接入示例（迁移 `0007_eventlog_*`）。

`django_simple_archive` 命令通过归档模型上声明的 `source_model` 等配置自动发现
源模型与流水线参数，无需业务编写命令或服务：`list-source-models` 列出已声明
`source_model` 的源模型，`archive --model app_label.Model` 可指定归档某个源模型。

## 流水线

`pipeline.py` 提供 `ChunkedPipeline`（分块批处理，`CHUNK_SIZE` 可配）、`PipelineStage`
（每个阶段实现 `run(input)`，前序输出作为本阶段输入）与 `iter_batches` 工具，用于
大表按批次取数、写归档、统计、删记录的流式处理。

`stages.py` 内置通用阶段，业务可「选择使用哪些中间件」：

- `ArchiveExportStage` — **按日整卷导出**：每天只有一个归档记录 / 一个归档文件，
  写入使用归档对象声明的 `writer`（默认整卷压缩 + 加密标准信封）。处理期间按日把
  明文行溢写到临时文件（`flush_size` 仅控制内存缓冲），teardown 时每个日期由 writer
  整体编码一次；重复归档同一日期时，先读取既有文件（内置加解密链 → `readers`）+
  合并同日重复记录 + 新增行，再由 writer 整体重新编码为新文件（先落新文件再删旧文件）。
  `normalize` 命令可把存量文件归一化为当前 `writer` 目标格式。
- `SourceCleanupStage` — 归档成功后删除已归档的源日志；`process_batch` 仅收集待删 id，
  teardown 逆序执行、导出阶段把归档文件落盘后才删除源日志，导出失败则跳过删除，
  避免数据丢失。
- `StatsAggregator` — 分组聚合统计（见下节）；`StatsPersistStage` 在导出落盘后、
  清理源数据前把统计结果持久化，流水线失败时跳过落库并保留源数据。

`ArchivePipeline` 是集成了标准默认阶段（`[SourceCleanupStage, ArchiveExportStage,
StatsAggregator]`）的基类，业务只需传模型与序列化器，可按需关闭默认阶段：
`use_cleanup` / `use_stats` / `use_export`。

```python
from django_log_archive.stages import ArchivePipeline

pipeline = ArchivePipeline(
    log_model=ApiLog,
    archive_model=ApiLogArchive,
    serializer=ApiLog.to_archive_line,        # 一行源日志 -> JSONL 行
    stats_model=ApiLogArchiveStats,
    group_fields=("date",),
    filename_prefix="api_log",
    chunk_size=2000,
)
context = pipeline.run(ids)   # context["stats"] 为分组聚合结果
```

`serializer` 缺省使用 `log.to_archive_line()`，也可传 callable 或模型方法名字符串。
如需自定义能力，可用 `ChunkedPipeline` 自行组装各阶段，选择自己需要的中间件。

### 仅统计：不生成归档文件

`use_export=False`（命令 `archive --no-file`）实现「只生成每日统计、不落盘归档
文件」：

- 导出阶段被移除，不写归档文件、不创建归档记录（需归档模型声明 `stats_model`，
  否则运行时报错）；
- 清理阶段默认同步关闭，源日志原样保留——未留存归档备份前不删除原始数据；
- 因此同一范围重复执行会把范围内全部行**再次累加**进统计（与既有「合并」语义
  一致）。若需重复运行幂等，请同时传 `retention_days=N`：统计后给源记录打上
  `archived_at` 标记并按保留期清理，后续运行自动跳过已标记行。注意两种模式的标记
  语义一致，请勿对同一源模型混用 `--no-file` 与常规归档（已标记的行不会再被归档）。

```python
pipeline = ArchivePipeline(
    log_model=ApiLog,
    archive_model=ApiLogArchive,
    stats_model=ApiLogArchiveStats,
    group_fields=("date",),
    use_export=False,          # 仅统计、不生成归档文件
    retention_days=7,          # 可选：统计后打标记并按 7 天保留期清理（幂等）
)
```

```bash
# 每天对昨天执行一次，只累加统计、保留源数据
python manage.py django_simple_archive archive --since 2026-08-02 --until 2026-08-02 --no-file
# 幂等版本：统计后标记已处理行并按时清理
python manage.py django_simple_archive archive --since 2026-08-02 --until 2026-08-02 --no-file --retention-days 7
```

### 按日归档、原始日志保存 N 天

默认行为是归档后立即删除源日志；需要「原始日志保存 N 天」时给 `ArchivePipeline`
传 `retention_days=N`：

- 归档时源记录打上 `archived_at` 标记（源模型需有该可空字段），而非立即删除；
- 之后每次归档运行时清理「已归档且归档时间早于 N 天前」的记录（`purge_expired`）；
- 导出阶段只处理未标记的行，保留期内重复归档不会把同一批数据写进归档文件。

```python
# 源模型需提供 archived_at 字段
class ApiLog(models.Model):
    ...
    archived_at = models.DateTimeField(null=True, blank=True, db_index=True)

pipeline = ArchivePipeline(
    log_model=ApiLog,
    archive_model=ApiLogArchive,
    serializer=ApiLog.to_archive_line,
    stats_model=ApiLogArchiveStats,
    group_fields=("date",),
    retention_days=7,          # 原始日志保存 7 天
    chunk_size=2000,
)
```

示例命令可用 `--retention-days` 覆盖，也可全局配置默认值：

```bash
python manage.py archive_api_logs --retention-days 7
DJANGO_LOG_ARCHIVE_RETENTION_DAYS=7 python manage.py django_simple_archive archive
```

`django-simpletask5` 后台任务执行器直接复用 `run_archive`，保留天数缺省取自该设置。

### DateTimeField 按天归档

源日志日期字段是 `DateTimeField` 时不需要先转成 `DateField`：归档模型把
`group_field` 指向该字段即可，`group_fields` 仍使用归档记录自己的 `date`（按天）：

```python
class EventLogArchive(ArchiveRecordBase):
    source_model = EventLog
    serializer = EventLog.to_archive_line
    stats_model = "myapp.EventLogArchiveStats"
    group_fields = ("date",)          # 归档 / 统计记录按天存储
    group_field = "add_time"          # 源模型上的 DateTimeField
    filename_prefix = "event_log"
```

- 归档分桶与每日统计均按 `settings.TIME_ZONE` 的本地日归一：例如 UTC
  `2026-01-01 18:00` 在上海时区归入 `2026-01-02`；
- `run_archive(..., since="2026-08-01", until="2026-08-31")` 与命令的
  `--since` / `--until` 仍传「日期」：`since` 含当日 00:00，`until` 到次日 00:00
  前（不含）；
- 范围过滤直接对 `DateTimeField` 使用 `__gte` / `__lt` 比较，不依赖 `__date` /
  `CONVERT_TZ`，可命中该字段索引。

```python
from django_log_archive.archive import run_archive

result = run_archive(
    "myapp.EventLog",
    since="2026-08-01",
    until="2026-08-31",
)
```

```bash
python manage.py django_simple_archive archive --model myapp.EventLog \
    --since 2026-08-01 --until 2026-08-31
```

直接使用 `ArchivePipeline` 时无须额外传日期归一参数：流水线会用 `group_field`
自动归一 `DateTimeField` 的统计分组。

## 聚合

`aggregates.py` 定义 `BaseAggregate`（`collect(row)` / `result()`）与内置聚合：
`CountAggregate`、`SuccessFailureAggregate`、`TokensAggregate`，由 `StatsAggregator`
驱动对结果集分组聚合。

### 统计唯一约束与原子累加

统计落库依赖统计模型对归档模型 `group_fields` 的**唯一约束**：

- 默认按天统计：`UniqueConstraint(fields=["date"], name="...")`；
- 额外分组：唯一约束字段与 `group_fields` 完全一致，如
  `("date", "other_group_field")`；`year/month/day` 与指标字段不加入唯一键；
- 首次创建由唯一索引保证只成功一次；已存在行使用数据库原子自增累加，重复归档
  不会覆盖既有统计；
- 核心包提供系统检查 `django_log_archive.E001`：统计模型缺少与 `group_fields`
  匹配的唯一约束时，启动检查直接报错；
- 同一批多个分组按稳定顺序写入，避免并发任务交叉更新不同分组。

```python
class ApiCallLogArchiveStats(ArchiveStatsBase):
    call_count = models.PositiveIntegerField(default=0)

    class Meta:
        constraints = [
            models.UniqueConstraint(
                fields=["date"], name="uniq_apicall_stats_date"
            )
        ]
```

## 加解密（crypto/，默认写入器底层）

框架位于 `django_log_archive/crypto/`，流式加解密、全部分块处理，支持大文件与
CephFS/S3 存储。内置四种算法，每种一个文件、一个算法名：

| 算法名 | 类 | 文件 | 说明 |
| --- | --- | --- | --- |
| `aes256-gcm` | `AES256GCMCipher` | `aes256gcm.py` | 标准信封 AES-256-GCM 流式（默认写入器引擎） |
| `aes256-cbc` | `AES256CBCCipher` | `aes256cbc.py` | 标准信封 AES-256-CBC + PKCS#7 流式 |
| `legacy-gcm-binary` | `LegacyGCMBinaryCipher` | `legacy_gcm_binary.py` | 历史格式：`nonce(16) + 密文 + tag(16)` 裸 GCM，无信封头 |
| `legacy-json-envelope` | `LegacyJSONEnvelopeCipher` | `legacy_json_envelope.py` | 历史格式：`{"nonce","tag","data"}` JSON envelope（仅解密） |

### 标准信封格式（默认写入器）

默认写入器 `CryptoArchiveWriter` 输出**单个**标准信封（`aes256-gcm` / `aes256-cbc`）：

```
{"version":1,"alg":"aes256-gcm","iv":"<base64>","len":<整段密文载荷字节数>,"key":"<密钥指纹>"[,"compress":"gzip"]}\n
<流式密文 64KB 分块>
<末尾算法尾部：GCM 的 16 字节 tag / CBC 无>
```

头部自描述算法名与初始化向量，正文流式处理；`len` 标记密文载荷长度（即从换行后到
文件末尾的字节数），`key` 为密钥指纹，`compress` 为压缩算法。整卷只有一个信封，
同日数据在一次归档中整体压缩 + 加密后写入。

**读取兼容**：内置读取链支持本框架历史文件——无 `len` / `key` / `compress` 的旧信封、
以及历史算法 / 旧密钥 / 旧压缩器。读取时先按归档模型声明的 `ciphers` 链（旧算法/旧密钥）
选解密器，再按 `compressors` 表（旧压缩器）或内置注册表按 `compress` 字段选解压器。
非本框架生成的历史归档由 `readers` 读取；`normalize` 可把以上历史文件统一重写为当前
`writer` 的目标格式。

**流式压缩**：启用压缩时（默认）在加密前对明文做 gzip 压缩（**先压缩后加密**，
密文不可压缩故必须压缩在前），信封头带 `compress` 字段；解密时按该字段自动解压，
历史未压缩文件（无该字段）自动跳过。压缩器可用
`DJANGO_LOG_ARCHIVE_COMPRESSOR` 配置（默认 `GzipCompressor`，`None` 关闭）；
归档模型也可用 `compressor` / `compressors` 按对象覆盖/保留历史。

### 密钥

```
key = sha256(raw_key)
raw_key 优先取 DJANGO_LOG_ARCHIVE_ENCRYPTION_KEY，缺省回退 Django SECRET_KEY。
```

每个引擎 = （算法 + 密钥）组合：构造时传 `key` 参数（已派生密钥）即产生不同密钥的
独立解密器，用于密钥轮换后解密历史文件。

### 使用

```python
from django_log_archive.crypto import get_crypto, get_crypto_for

crypto = get_crypto()                 # 全局策略
crypto = get_crypto_for(ApiLogArchive)  # 按归档模型声明的策略
crypto.encrypt_stream(src_file, dst_file)
crypto.decrypt_stream(src_file, dst_file)
crypto.encrypt_file(src_path, dst_path)
crypto.decrypt_file(src_path, dst_path)
```

全程流式处理（64KB 分块 + 临时文件），不加载文件内容到内存。各算法文件的
docstring 含完整格式说明与外部解密步骤。

> `crypto` 是默认写入器的底层实现。业务写入 / 读取归档时优先使用
> `get_writer_for(model)` / `get_readers_for(model)`（见上文「写入器与历史读取器」），
> 这样自定义 `writer` / `readers` 才能生效：

```python
from django_log_archive.crypto import get_crypto_for
from django_log_archive.readers import decode_stream, get_readers_for
from django_log_archive.writers import get_writer_for

writer = get_writer_for(ApiCallLogArchive)
writer.write(record, plain_file, dst_file)

crypto = get_crypto_for(ApiCallLogArchive)
ok = decode_stream(record, crypto, get_readers_for(ApiCallLogArchive), src_file, plain_file)
```

## 管理命令

综合归档管理命令 `django_simple_archive`，支持子命令 `list-archive-models` /
`list-source-models` / `check` / `archive` / `export` / `reencrypt` / `normalize` /
`download`。核心包自带，无需业务编写命令。

### 列出归档模型（供 check 使用）

```
python manage.py django_simple_archive list-archive-models
```

列出所有继承 `ArchiveRecordBase` 的具体归档模型及其记录数。

### 列出源日志模型（供 archive 使用）

```
python manage.py django_simple_archive list-source-models
```

列出所有在归档模型上声明了 `source_model` 的源日志模型及其对应归档模型。

### 校验归档文件可读性

```
python manage.py django_simple_archive check [--model app_label.Model]... [--since YYYY-MM-DD] [--until YYYY-MM-DD] [--limit N] [--chunk-size N]
```

- 缺省依次检查全部归档模型；`--model` 可多次指定，限定只检查指定模型；
- `--since` / `--until` 限定检查日期范围（全部或指定范围）；
- 每个记录分步输出检查过程：读取记录 → 发现文件 → 复制文件到临时文件（进度）→
  读取临时文件（进度）→ 检查内容提取统计字段（进度）→ 本记录完成情况；
- 复制/读取/检查全程流式（64KB 分块）与临时文件，不加载文件内容到内存，适配
  单日 50GB 级归档；进度按 5% 粒度回显（文件过小时自动省略）；
- 统计字段含记录总数、有效 JSON 行、成功/失败数、prompt/completion/total tokens，
  输出模型与总体汇总（读取成功/失败/输出字节/有效记录/成功率；默认写入器场景即
  解密成功 / 解密字节）。

### 归档（同步 / 异步）

```
python manage.py django_simple_archive archive [--model app_label.Model]... [--since YYYY-MM-DD] [--until YYYY-MM-DD] [--chunk-size N] [--mode sync|async] [--dry-run] [--no-file] [--retention-days N]
```

- `--model` 可多次指定，限定只归档指定源日志模型（源模型需在归档模型上声明
  `source_model`）；缺省归档全部已声明的源模型；
- `--mode sync`（默认）：当前进程内直接执行归档（归档导出 + 聚合统计 + 清理源数据）；
- `--mode async`：创建后台归档任务（`django_log_archive.tasks.submit_archive_task`，
  不绑定业务任务模型），由 django-simpletask5 的 `django_simpletask_executor`
  worker 在后台执行；
- `--since` / `--until` 限定归档日期范围（均为含，缺省归档全部）；
- `--dry-run` 仅统计待归档数量，不实际执行；
- `--no-file` 不生成归档文件、仅生成每日统计（保留源数据；需归档模型声明
  `stats_model`），语义见「仅统计：不生成归档文件」；
- `--retention-days N` 原始日志保存 N 天（默认取 `DJANGO_LOG_ARCHIVE_RETENTION_DAYS`）。

### 导出明文（export）

```
python manage.py django_simple_archive export --output-dir /backup/plain [--model app_label.Model]... [--id N] [--since YYYY-MM-DD] [--until YYYY-MM-DD] [--limit N] [--chunk-size N]
```

- `--output-dir` 必填：明文导出目录（如 OSS 挂载目录 / 冷备盘），自动创建；
- 每个归档记录经内置加解密链 + `readers` 读取后，以明文 JSONL 写出为 `xxx.jsonl`
  （默认写入器的归档文件名为 `xxx.jsonl.enc`），**不修改源数据**；
- 单条读取失败时清理残留文件、记录错误并继续处理其余记录；
- 全程流式 + 临时文件，不加载文件内容到内存。

### 重新编码（reencrypt）

```
python manage.py django_simple_archive reencrypt [--model app_label.Model]... [--id N] [--since YYYY-MM-DD] [--until YYYY-MM-DD] [--limit N] [--dry-run] [--chunk-size N]
```

- 用于**策略轮换**后把历史归档重建为当前目标格式：读取走内置加解密链
  （`ciphers` / `compressors`）与 `readers`，写入使用该归档对象声明的 `writer`
  （默认 `cipher` + `compressor` 的标准信封）；
- 先落新文件再删旧文件，避免数据丢失；
- `--dry-run` 只校验能否读取并预览，不实际写回归档文件；
- 与 `normalize` 的区别：`reencrypt` 对范围内**全部**记录重写；`normalize` 只重写
  `writer.matches` 判定**不是目标格式**的文件，已达标文件跳过。

### 归一化历史格式（normalize）

```
python manage.py django_simple_archive normalize [--model app_label.Model]... [--id N] [--since YYYY-MM-DD] [--until YYYY-MM-DD] [--limit N] [--dry-run] [--chunk-size N]
```

- 目标格式：归档对象当前声明的 `writer`（默认整卷压缩 + 加密标准信封）；
- 读取历史：内置加解密链（`ciphers` / `compressors`）→ `readers`（非本框架格式）；
- `writer.matches` 判定已是目标格式的文件自动跳过（`dry_run` 也会报告跳过数量）；
- 用于一次性把存量归档统一为当前目标格式。

### 生成明文压缩包（download）

```
python manage.py django_simple_archive download [--model app_label.Model]... [--id N] [--since YYYY-MM-DD] [--until YYYY-MM-DD] [--limit N] [--chunk-size N]
```

- 对指定范围的归档记录逐个读取（边读取边写入），生成明文 **zip 压缩包**；
- 记录按 `--chunk-size`（默认 2000）分批预取，超大范围下不会一次载入全部归档记录；
- **同步执行**，不经过 django-simpletask5 后台队列；但每个模型**创建一条匹配的
  下载管理数据记录**（`ArchiveDownloadTask`，含两级完成度进度），结果 zip 作为
  该记录的结果文件保存，可在后台「归档下载任务」页查看进度并下载——与后台异步
  bundle 下载完全一致的数据模型。

## 归档下载管理（明文压缩包 / 单文件 / 异步检查）

下载管理围绕核心数据模型 `ArchiveDownloadTask`（含两级完成度进度字段），同一套
核心执行器同时支撑**后台异步**（django-simpletask5 worker）与**同步命令**：

- **两级进度**：`record_total` / `record_done`（记录级）+ `step_total` / `step_done`
  （步骤级）。处理一条归档记录固定 2 个子步骤（**读取 → 写入产物**）；生成
  zip / 明文文件（`bundle` / `single`）时再加 1 个**收尾步骤**，因此
  `step_total = record_total × 2 + 1`，而 `check` 检查读取无收尾步
  （`step_total = record_total × 2`）；收尾步骤同样计入总数与已完成数；另有
  `current_record_id` / `current_step` 指示当前处理到哪条记录、已完成几个子步骤；
- **三种模式**：`bundle`（zip 明文压缩包）/ `single`（单文件明文）/ `check`（检查
  读取，结果写入 `result_message`）；读取统一走该归档对象的内置加解密链 + `readers`；
- **两条路径共用**：`django_log_archive.download.run_download_task` 是核心执行器，
  后台由 `ArchiveDownloadExecutor`（worker）调用，命令与业务代码可直接同步调用；
- 全程流式（64KB 分块 + 临时文件），zip 内逐条记录边读取边写入压缩条目。

后台异步入口：

```python
from django_log_archive.tasks import submit_download_task

task = submit_download_task(
    "myapp.ApiCallLogArchive",  # 归档模型（类或 "app_label.Model"）
    mode="bundle",              # bundle / single / check
    ids=[1001, 1002],           # 归档记录主键列表
)
# task.record_total / task.step_total 创建时即预置，可在 admin 查看进度
```

后台 worker 执行后更新该记录的 `status`（pending → running → success / failed）、
两级进度与结果文件（`result_file`，后台「归档下载任务」页提供下载链接，页面在
pending / running 时每 3 秒自动刷新）。

### 归档记录管理页（`ArchiveRecordAdminMixin`）

归档记录 ModelAdmin 继承核心 mixin 即可获得「全字段只读 + 下载密文/明文 + 检查」：

```python
# myapp/admin.py
from django.contrib import admin
from django_log_archive.admin import ArchiveRecordAdminMixin

from .models import ApiCallLogArchive


@admin.register(ApiCallLogArchive)
class ApiCallLogArchiveAdmin(ArchiveRecordAdminMixin, admin.ModelAdmin):
    list_display = ("id", "date", "record_count", "failed_count", "file_size")
```

- `has_change_permission` 恒为 `False`：变更页只读（可查看 / 删除，不可编辑）；
- 归档文件字段渲染「下载密文 / 下载明文」按钮：密文即归档文件原样下载（同步；
  自定义 `writer` 场景下即自定义格式原文件）；**明文走后台异步**（单文件读取可能
  耗时，同步接口无法在可接受时间内完成），提交 single 下载任务并跳转到
  「归档下载任务」页；
- 右上角 object-tools「检查」按钮提交异步 check 任务，结果写入下载管理记录的
  `result_message`；
- 列表页提供 action「**下载所选归档记录的明文压缩包**」：勾选记录范围 → 提交
  bundle 任务 → 后台生成明文 zip → 在「归档下载任务」页下载。

## 后台归档任务（django-simpletask5）

核心 `django_log_archive.tasks` 提供无任务模型的异步归档：`submit_archive_task`
直接创建 `TaskExecution`（参数存于 `context`，`executor_class` 指向核心通用执行器
`ArchiveExecutionExecutor`）并发布消息，worker 调用执行器完成归档并更新任务状态——
**业务无需定义任何 django-simpletask5 `Task` 子类**。

管理后台「API 调用日志」列表页的「归档」按钮（位于「添加」按钮之前）由核心
`ArchiveButtonAdminMixin` 提供，弹出起始/截止日期选择弹窗，提交后创建后台任务
并在列表顶部提示；任务执行状态可在 django-simpletask5 自带的「任务执行」管理页
查看（执行器 / 状态 / 重试等）。

业务侧复用归档按钮只需在 ModelAdmin 上继承 mixin 并配置源日志模型：

```python
from django_log_archive.admin import ArchiveButtonAdminMixin


@admin.register(ApiLog)
class ApiLogAdmin(ArchiveButtonAdminMixin, admin.ModelAdmin):
    archive_source_model = ApiLog                 # 源日志模型（也可由归档模型声明推断）
    archive_verbose_name = "归档 API 调用日志"     # 可选：弹窗标题
    # 可选：archive_form_class（缺省核心 ArchiveLogsForm）、archive_url_name
```

**列表页模板链式叠加**：mixin 默认声明 `change_list_template =
django_log_archive/admin/change_list.html`（核心归档模板，自带按钮 + 弹窗 + JS）。
业务自定义模板优先、核心模板缺省兜底：业务若因其它需求重载 `change_list.html`，只需
让自己的模板以核心模板为父模板，并在需要叠加内容的 `block` 里用 `{{ block.super }}`
保留归档按钮；多个业务模板可逐层 `{% extends %}` 相互扩展（内容逐层叠加），互不覆盖：

```html
{# myapp/templates/myapp/admin/change_list.html #}
{% extends "django_log_archive/admin/change_list.html" %}
{% block object-tools-items %}
  <li><a href="#" class="my-custom-btn">自定义按钮</a></li>
  {{ block.super }}   {# 保留归档按钮 #}
{% endblock %}
```

```python
class ApiLogAdmin(ArchiveButtonAdminMixin, admin.ModelAdmin):
    change_list_template = "myapp/admin/change_list.html"   # 自定义模板照常设置
```

**核心模板的父模板自动解析**：`{% extends %}` 只能有一个父模板，而 mptt / grappelli
等第三方扩展也会声明自己的 `change_list_template`（MRO 里只有一个能胜出）。mixin 通过
`archive_base_change_list_template` 上下文变量决定核心模板的父模板——
`_get_archive_base_change_list_template()` 沿 MRO 查找**第一个非本 mixin**（也非当前
生效模板）的 `change_list_template`：继承 `MPTTModelAdmin` 时自动命中
`admin/mptt_change_list.html`（树形列表），grappelli 等其它自定义 base admin 同理
自动命中，无需逐个适配；找不到则回退 `admin/change_list.html`。

**与 import-export / mptt 三者共存（example 内置演示）**：`django_log_archive_example`
新增 `ApiCategory`（MPTT 树形模型）与 `ApiCategoryAdmin`，在同一列表页同时展示
归档按钮 + import/export 按钮 + mptt 树形缩进列表。mixin 顺序固定为
`ImportExportMixin` 在最前（它会把 `change_list_template` 作为
`base_change_list_template` 包装成 import-export 模板）、`ArchiveButtonAdminMixin`
居中、`MPTTModelAdmin` 殿后，渲染链为
「import/export → 业务模板 → 核心归档模板 → mptt → admin」：

```python
from import_export import resources
from import_export.admin import ImportExportMixin
from mptt.admin import MPTTModelAdmin

from django_log_archive.admin import ArchiveButtonAdminMixin
from .models import ApiCategory, ApiLog


class ApiCategoryResource(resources.ModelResource):
    class Meta:
        model = ApiCategory
        fields = ("id", "name", "parent")


@admin.register(ApiCategory)
class ApiCategoryAdmin(
    ImportExportMixin,
    ArchiveButtonAdminMixin,
    MPTTModelAdmin,
):
    resource_classes = [ApiCategoryResource]
    archive_source_model = ApiLog        # 归档按钮点击即归档 ApiLog
    list_display = ("name", "parent")    # mptt 自动对首字段做树形缩进
```

注意：demo 项目的 `INSTALLED_APPS` 需加入 `mptt` 与 `import_export`（第三方模板靠
`AppDirectoriesFinder` 从这两者加载）。

后台依赖 Redis（消息队列 + 分布式锁），启动 worker：

```bash
python manage.py django_simpletask_executor --workers 1
```

### 定时归档（example 内置示例）

example 通过 django-simpletask5 的 cron 调度器注册了「每天 00:05 归档昨天」的
cronjob（`django_log_archive_example/cronjobs.py`）：

- `DailyApiLogArchiveExecutor` — 执行时动态计算「昨天」（`timezone.localdate() - 1day`），
  调用核心通用归档服务 `run_archive` 并更新执行状态；「昨天」不写入 cronjob 静态
  context，也可用 context 的 `data` 覆盖（`since` / `until` / `chunk_size` /
  `retention_days`）；
- `register_example_cronjobs()` — 在 `AppConfig.ready()` 中调用，向
  django-simpletask5 内存注册表写入 cronjob（cron 表达式 `5 0 * * *`）；调度器每轮
  tick 自动 `sync_cronjobs()` 同步到数据库（`django_simpletask5_cronjob` 表，也可
  用 `django_simpletask_sync_cronjobs` 命令手动同步）。

运行定时归档需要 Redis，并常驻两个进程（调度器 + worker）：

```bash
python manage.py django_simpletask_crontab                        # 定时调度器
python manage.py django_simpletask_executor --workers 1           # 任务 worker
```

cronjob 可在后台「任务计划（CronJob）」管理页查看 / 启停；业务侧复用只需在
自己的 app 中 `register_cronjob(...)` 指向一个执行器，并在 `AppConfig.ready()`
中调用即可。若不想常驻调度器进程，也可改用 OS crontab 直接调用
`django_simple_archive archive --until $(date -d yesterday +%F)`。

## 业务集成方案（以 example 为例）

`django_log_archive_example` 是一份可直接对照的完整业务实现：源模型（ApiLog）、
归档模型（ApiLogArchive）、统计模型（ApiLogArchiveStats）、admin 归档按钮、业务
归档服务（`archive.py`）、定时归档（`cronjobs.py`）齐全。下面按步骤说明如何把
归档能力接入你自己的业务 app（下文以 `myapp` 为例）。

### 1. 安装与配置

```bash
pip install django-log-archive
```

```python
INSTALLED_APPS = [
    ...
    "django_log_archive",
    # 需要后台归档任务 / 下载管理（django-simpletask5 worker）时再加（依赖 Redis）：
    "django_simpletask5",
    # 归档按钮图标（fa-solid fa-box-archive）需要：
    "django_static_fontawesome",
]
```

也可用 `django_app_requires` 自动展开依赖（核心包在
`django_log_archive/__init__.py` 声明了 `app_requires`，含 `django_static_fontawesome`
与 `django_simpletask5`）：

```python
# settings.py
INSTALLED_APPS = [...]
from django_app_requires import patch_all
patch_all()          # 自动把 app_requires 依赖展开进 INSTALLED_APPS
```

```python
DJANGO_LOG_ARCHIVE_ENCRYPTION_KEY = "自定义归档加密密钥"   # 缺省回退 Django SECRET_KEY
DJANGO_LOG_ARCHIVE_CIPHER = "django_log_archive.crypto.aes256gcm.AES256GCMCipher"  # 默认
# DJANGO_LOG_ARCHIVE_COMPRESSOR = "django_log_archive.compress.GzipCompressor"  # 默认开启压缩
# DJANGO_LOG_ARCHIVE_COMPRESSOR = None   # 关闭压缩（历史未压缩文件仍可正常读取）
# DJANGO_LOG_ARCHIVE_TEMP_DIR = "/mnt/big-disk/tmp"   # 归档临时文件根目录（默认 /tmp）
# DJANGO_LOG_ARCHIVE_PROGRESS_INTERVAL = 500 * 1024 * 1024   # 流式环节进度日志阈值（默认 500MB，0 关闭）
# DJANGO_LOG_ARCHIVE_WRITER = "myapp.archive.writers.LegacyZipWriter"   # 自定义写入器
# DJANGO_LOG_ARCHIVE_READERS = ["myapp.archive.readers.LegacyZipReader"]  # 外部历史读取器
```

### 2. 定义源日志模型

源模型需有按日归档的日期字段（example 用 `date`），如需「原始日志保留 N 天」
策略则加可空 `archived_at` 字段，并提供一行转 JSON 的序列化方法：

```python
# myapp/models.py
import json


class ApiCallLog(models.Model):
    date = models.DateField(db_index=True)
    is_success = models.BooleanField(default=True)
    total_tokens = models.PositiveIntegerField(default=0)
    payload = models.TextField(blank=True, default="")
    archived_at = models.DateTimeField(null=True, blank=True, db_index=True)  # 可选：保留策略

    def to_archive_line(self):
        return json.dumps({
            "id": self.pk,
            "date": self.date.isoformat(),
            "is_success": self.is_success,
            "total_tokens": self.total_tokens,
            "payload": self.payload,
        }, ensure_ascii=False)
```

日期字段用 `DateField` 或 `DateTimeField` 均可；`DateTimeField` 会按
`settings.TIME_ZONE` 归一到本地日（见上文「DateTimeField 按天归档」），归档范围
`since` / `until` 仍按日期指定。

### 3. 定义归档模型与统计模型（核心一步）

继承 `ArchiveRecordBase` 的归档模型是**整个集成的心脏**：所有流水线配置都声明在
上面（`django_simple_archive` 命令、admin 按钮、`run_archive` 均据此自动发现与
执行），业务无需编写命令或服务：

```python
# myapp/models.py
from django_log_archive.aggregates import CountAggregate, SuccessFailureAggregate
from django_log_archive.models import ArchiveRecordBase, ArchiveStatsBase


class ApiCallLogArchive(ArchiveRecordBase):
    source_model = ApiCallLog               # 源日志模型（类或 "app_label.Model"）
    serializer = ApiCallLog.to_archive_line # 序列化器：callable 或方法名字符串
    stats_model = "myapp.ApiCallLogArchiveStats"  # 统计模型，可省略
    group_fields = ("date",)                # 统计分组字段
    filename_prefix = "api_call"            # 归档文件名前缀

    class Meta:
        verbose_name = "API 调用日志归档"


class ApiCallLogArchiveStats(ArchiveStatsBase):
    call_count = models.PositiveIntegerField(default=0)
    success_count = models.PositiveIntegerField(default=0)
    failed_count = models.PositiveIntegerField(default=0)

    aggregates = [
        CountAggregate(output_field="call_count"),
        SuccessFailureAggregate(),
    ]

    class Meta:
        constraints = [
            models.UniqueConstraint(
                fields=["date"], name="uniq_apicall_stats_date"
            )
        ]
```

统计模型的唯一约束字段必须与归档模型的 `group_fields` 完全一致，否则启动检查
会报 `django_log_archive.E001`。

完成后执行 `python manage.py makemigrations myapp && python manage.py migrate`，
并可用核心命令验证配置已生效：

```bash
python manage.py django_simple_archive list-source-models    # 应看到 myapp.ApiCallLog
python manage.py django_simple_archive list-archive-models   # 应看到 myapp.ApiCallLogArchive
```

### 3.1 接入外部历史归档 / 自定义写入格式（可选）

如果存量归档**不是本框架生成**的（明文、zip / tar、第三方加密等），在归档模型上声明
`readers`；如果希望新归档写成业务自己的格式，声明 `writer`：

```python
class ApiCallLogArchive(ArchiveRecordBase):
    source_model = ApiCallLog
    stats_model = ApiCallLogArchiveStats
    group_fields = ("date",)

    # 读取外部历史归档（按顺序尝试）
    readers = ["myapp.archive.readers.LegacyZipReader"]
    # 自定义写入格式（不声明则使用默认整卷压缩 + 加密标准信封）
    # writer = "myapp.archive.writers.LegacyZipWriter"
```

推荐先预览再归一化，最后用 `check` / `export` 验证：

```bash
python manage.py django_simple_archive normalize --model myapp.ApiCallLogArchive --dry-run
python manage.py django_simple_archive normalize --model myapp.ApiCallLogArchive
python manage.py django_simple_archive check --model myapp.ApiCallLogArchive
python manage.py django_simple_archive export --model myapp.ApiCallLogArchive --output-dir /backup/plain
```

`normalize` 会把外部历史归档读取后按当前 `writer` 重写；同日再次归档时也会通过
`readers` 读取既有文件并合并。自定义 `writer` 需配套 `readers` 才能被后续 check /
export / normalize 等反向读取。

### 4. 归档触发方式（任选其一或组合）

**A. 管理命令（无需任何代码）** — 核心自带，同步/异步/校验：

```bash
python manage.py django_simple_archive archive --until 2026-08-03            # 同步
python manage.py django_simple_archive archive --until 2026-08-03 --dry-run  # 预统计
python manage.py django_simple_archive archive --until 2026-08-03 --mode async  # 后台任务
python manage.py django_simple_archive archive --until 2026-08-03 --no-file  # 仅统计、不生成归档文件
python manage.py django_simple_archive check                                 # 校验归档文件
```

**B. 业务代码调用 `run_archive`** — 在视图/脚本/管理命令里直接归档（example 的
`django_log_archive_example/archive.py` 就是给它包了一层「固定归档 ApiLog」的便捷
签名，非必需）：

```python
from django_log_archive.archive import run_archive

result = run_archive(
    "myapp.ApiCallLog",        # 源模型类或 "app_label.Model" 字符串
    since="2026-08-01",
    until="2026-08-31",
    chunk_size=2000,
    retention_days=7,
)
# result == {"source_count": ..., "archive_count": ..., "stats_count": ..., "remaining": ...}
# 其中 archive_count / stats_count 均为本次运行的增量（本次实际写入的归档记录数 /
# 本次产生统计的分组数），remaining 为运行后剩余源日志数。

# 仅生成每日统计、不生成归档文件（保留源数据）
stats_result = run_archive(
    "myapp.ApiCallLog",
    since="2026-08-01",
    until="2026-08-31",
    use_export=False,
)
# stats_result["archive_count"] == 0，其余源日志原样保留
```

**C. 管理后台「归档」按钮** — ModelAdmin 继承核心 mixin 即可，弹出日期选择弹窗，
提交后由 worker 异步执行（example 的 `admin.py`）：

```python
# myapp/admin.py
from django.contrib import admin
from django_log_archive.admin import ArchiveButtonAdminMixin

from .models import ApiCallLog


@admin.register(ApiCallLog)
class ApiCallLogAdmin(ArchiveButtonAdminMixin, admin.ModelAdmin):
    archive_source_model = ApiCallLog            # 也可省略，由归档模型声明推断
    archive_verbose_name = "归档 API 调用日志"
```

**D. 定时归档** — 复用 django-simpletask5 cron 调度器，每天 00:05 自动归档昨天
（参照 example 的 `cronjobs.py`，执行时动态计算「昨天」）：

```python
# myapp/cronjobs.py
import json
from datetime import timedelta
from django.utils import timezone
from django_log_archive.archive import run_archive


class DailyApiCallLogArchiveExecutor:
    timeout_seconds = 3600

    def execute(self, execution, task=None):
        yesterday = timezone.localdate() - timedelta(days=1)
        result = run_archive("myapp.ApiCallLog", since=yesterday, until=yesterday)
        execution.status = "success"
        execution.finished_at = timezone.now()
        execution.save(update_fields=["status", "finished_at", "updated_at"])
        return json.dumps({"success": True, "date": yesterday.isoformat(), **result}, ensure_ascii=False)


def register_myapp_cronjobs():
    from django_simpletask5.core.cronjob_registry import register_cronjob
    register_cronjob(
        uid="myapp_daily_api_call_log_archive",
        display_name="每日归档昨日 API 调用日志",
        cron_expression="5 0 * * *",
        executor_class="myapp.cronjobs.DailyApiCallLogArchiveExecutor",
    )
```

```python
# myapp/apps.py
class MyappConfig(AppConfig):
    ...
    def ready(self):
        from .cronjobs import register_myapp_cronjobs
        register_myapp_cronjobs()
```

常驻运行：`python manage.py django_simpletask_crontab`（调度器）+
`python manage.py django_simpletask_executor --workers 1`（worker）。

### 5. 归档记录管理页（只读 + 下载密文/明文 + 检查）

归档记录的 ModelAdmin 继承 `ArchiveRecordAdminMixin`，变更页全字段只读，
归档文件字段变为「下载密文 / 下载明文」按钮，右上角多一个「检查」按钮，列表页
多一个「下载所选归档记录的明文压缩包」action（详见上文「归档记录管理页」）：

```python
# myapp/admin.py
from django.contrib import admin
from django_log_archive.admin import ArchiveRecordAdminMixin

from .models import ApiCallLogArchive


@admin.register(ApiCallLogArchive)
class ApiCallLogArchiveAdmin(ArchiveRecordAdminMixin, admin.ModelAdmin):
    list_display = ("id", "date", "record_count", "failed_count", "file_size")
```

### 6. 归档下载管理（明文压缩包 / 单文件 / 异步检查）

核心自带 `ArchiveDownloadTask` 下载管理模型与后台「归档下载任务」管理页
（进度条 + 结果文件下载 + 自动刷新），无需业务注册。业务侧两种接入方式：

**A. 后台异步（推荐）** — 记录列表 action / 记录页「下载明文」/「检查」按钮都会
自动创建下载任务并由 django-simpletask5 worker 执行（见上文「归档下载管理」）；
业务代码也可直接调用：

```python
from django_log_archive.tasks import submit_download_task

task = submit_download_task("myapp.ApiCallLogArchive", mode="bundle", ids=[1001, 1002])
```

**B. 同步命令** — `django_simple_archive download` 在进程内同步执行，但同样创建
匹配的下载管理数据记录：

```bash
python manage.py django_simple_archive download --since 2026-08-01 --until 2026-08-31
```

### 7. 原始日志保留策略

默认归档后立即删除源日志。需要「原始日志保存 N 天」时传 `retention_days=N`（源
模型需有 `archived_at` 字段）：归档打标记而非删除，之后每次归档清理到期记录，
重复归档不产生重复数据。全局默认值可配：

```bash
DJANGO_LOG_ARCHIVE_RETENTION_DAYS=7 python manage.py django_simple_archive archive
```

### 8. 运维与校验

- **校验归档文件** — `django_simple_archive check` 逐记录复制/读取/比对内容，全程
  流式 + 临时文件，`--model` / `--since` / `--until` / `--limit` / `--chunk-size`
  可限定范围抽查（`--chunk-size` 控制每批读取的归档记录数，默认 2000）；
- **导出明文（如 OSS / 冷备盘）** — `django_simple_archive export --output-dir ...`
  读取并导出明文 JSONL（`xxx.jsonl`），不修改源数据，单条失败自动跳过；
- **密钥 / 算法 / 压缩器轮换** — `django_simple_archive reencrypt` 按归档对象声明的
  当前 `writer` 重新编码：读取走 `ciphers` / `compressors` 与 `readers`，写入默认使用
  当前 `cipher` + `compressor` 的标准信封，先落新文件再删旧文件；
- **历史格式归一化** — `django_simple_archive normalize` 把存量旧格式 / 无 `len` /
  旧策略 / 外部格式文件统一重写为当前 `writer` 目标格式，已达标文件自动跳过；
- **归档可观测** — 归档记录（`ApiLogArchive`）含日期、记录数、失败数、文件大小；
  统计模型（`ApiLogArchiveStats`）按 `group_fields` 聚合，可在 admin 或报表中展示；
- **下载管理** — 明文 zip / 单文件 / 检查统一走 `ArchiveDownloadTask`，后台
  「归档下载任务」页查看两级进度并下载结果。

## 开发与打包

```bash
pip install -r requirements.txt        # 生产运行时依赖（Django + pycryptodome + simpletask5 + …）
pip install -r requirements.tests.txt  # demo/example + 验证测试依赖（含上者），跑 demo/测试时安装
python -m pytest                       # 运行验证测试（覆盖率 ≥ 95%）
python manage.py test                  # Django 自带测试运行器
python -m ruff check .                 # 代码检查
python -m build                        # 构建 sdist + wheel
```

打包仅发布 `django_log_archive` 核心包（`pyproject.toml` 中已排除 demo / example）。

## 更新记录

### v0.3.1

1. 【新增】**大文件处理进度可观测**：归档（压缩 + 加密）、导出 / 校验的解密合并、S3 等存储上传三个环节，按已处理字节数周期性输出进度日志（默认每 500MB，可用 `DJANGO_LOG_ARCHIVE_PROGRESS_INTERVAL` 调整，设为 `0` / 负数 / `None` 关闭），解决长耗时归档期间无输出、疑似卡死的问题。
2. 【新增】**`check` 报告实际读取器**：逐条校验时输出真正命中的解密器 / 历史读取器名称（如 `AES256GCMCipher(aes256-gcm)`），便于确认历史文件实际由哪条链路读出。
3. 【新增】**`check` 核对统计一致性**：除内容自洽外，比对文件实际行数与归档记录 `record_count`、实际密文大小与 `file_size`，不一致时判为失败并给出差异明细。
4. 【新增】**无法读取历史数据时输出特征**：`check` / `normalize` / `reencrypt` / `export` 在文件无法被内置加解密链与历史读取器解码时，输出历史数据主要特征（文件大小、格式线索、标准信封头字段、头部字节的十六进制与可打印预览），供业务侧进一步排查；输出长度严格受限，避免超大 / 二进制文件产生海量日志。
5. 【新增】**命令输出读取 / 写入策略概览**：`check` / `export` / `reencrypt` / `normalize` 逐模型输出当前读取策略（算法、压缩、历史读取器）；`archive` 输出目标归档模型、是否生成文件与源日志保留天数；`download` 输出匹配记录数。
6. 【新增】**中断兜底清理临时文件**：所有管理子命令统一在 `Ctrl+C` / `SIGTERM` / 异常退出时清理本次创建的具名临时文件（含复制副本、解密中间明文、导出缓冲、zip 缓冲等），避免中断后遗留 GB 级临时文件；嵌套调用只清理各自作用域内新建的文件。
7. 【优化】**归档格式**：默认写入器在写入上传前显式读取文件大小，不再依赖写入器游标位置，兼容自定义写入器。
8. 【修正】**后台归档权限按源日志模型校验**：归档会删除源日志，权限改为校验源日志模型的修改权限，避免用户凭无关模型的权限触发其本无权的源日志被删除。
9. 【修正】**下载结果不再暴露明文直链**：后台「下载结果文件」改为经权限校验的下载入口，不再直接给出 media URL；明文下载同样要求相应查看权限。
10. 【修正】**下载任务重复触发幂等**：同一任务被并发或重复执行时只真正运行一次，避免进度错乱、重复生成结果文件；执行方异常中断后可由重试接管。
11. 【修正】**批量校验更稳定**：修复大批量 `check` 时的资源占用、统计异常与格式误判问题。
12. 【修正】**历史归档读取更健壮**：兼容字段顺序不同、特殊 nonce 等历史文件；无认证算法用错密钥解出的乱码会被识别为读取失败，而非当作有效数据继续。
13. 【修正】**合并归档按实际内容重算记录数**：同日再次归档时 `record_count` 以合并后的真实行数为准，不再累加可能失真的旧计数。

### v0.3.0

1. 【变更】**默认归档格式统一**：默认写入器输出「整卷压缩 + 加密」的单个标准归档文件；同一天再次归档会自动合并并按当前 `writer` 重写。业务可通过 `writer` 自定义写入格式。
2. 【新增】**写入器 / 读取器可插拔**：归档模型可用 `writer` 指定写入器（类路径 / 类 / 实例 / 工厂），用 `readers` 读取非本框架生成的历史归档（明文 / zip / tar / 第三方格式）；未声明时回落全局设置。
3. 【新增】**按归档对象配置存储与读取策略**：`cipher` / `compressor` 控制默认写入器的算法与压缩，`ciphers` / `compressors` 读取本框架历史文件；`check` / `export` / `reencrypt` / `normalize` / `download` 与后台读取均按记录所属归档对象生效。
4. 【新增】**`normalize` 子命令**：将旧格式、旧算法、旧压缩或外部格式的历史归档统一重写为当前 `writer` 目标格式，支持 `--dry-run` 与模型 / 日期 / 条数筛选；`reencrypt` 则重写范围内全部记录。
5. 【修正】**归档格式校验更严格**：读取与归一化时严格匹配算法、密钥指纹与压缩算法，避免密钥 / 压缩器轮换后误读或无法解压。
6. 【兼容】**历史归档继续可读**：兼容无长度 / 密钥 / 压缩标记的旧文件、旧算法、旧密钥与旧压缩器。
7. 【新增】**`DateTimeField` 按天归档**：源日期字段为 `DateTimeField` 时无需先转 `DateField`；按 `settings.TIME_ZONE` 归入本地日，归档与统计口径一致。
8. 【优化】**日期范围查询可命中索引**：`since` / `until` 使用本地日边界直接比较原始日期字段，避免全表扫描。
9. 【示例】**内置 `DateTimeField` 接入示例**：example 提供 `EventLog` / `EventLogArchive` / `EventLogArchiveStats` 完整示例，可直接对照接入。
10. 【新增】**统计唯一约束与原子累加**：统计模型按 `group_fields` 建唯一约束；重复归档使用数据库原子累加，不再覆盖既有统计。
11. 【修正】**统计先落库、后清理源数据**：任一步失败都会保留源数据，避免统计未写入却已删除 / 标记源日志。
12. 【修正】**统计批量原子写入**：同一批统计在同一事务内写入，任一分组失败则全部回滚，避免部分写入后重复累加。
13. 【修正】**截断 / 损坏归档不再静默通过**：`check` / `export` 会明确报错，不再漏数据。
14. 【修正】**同日归档合并更稳健**：零字节、缺失文件等异常记录不再中断归档，合并后会清理失效文件与多余记录。
15. 【修正】**导出 / 下载不覆盖既有文件**：同名明文自动追加 `_1` / `_2` 后缀；失败不破坏既有导出文件，并清理临时残留。
16. 【修正】**归档校验更严格且内存有界**：`check` 提示文件行数与记录数不一致；超长行、非对象 JSON、非数值字段均安全处理。
17. 【修正】**配置 / 参数错误提前报错**：源模型缺少日期字段 / 标记字段、分组字段空值、日期参数非法、模型无法解析时，在删除源数据前给出明确错误；归档弹窗校验日期范围。
18. 【修正】**后台下载权限与只读页边界**：下载 action 校验权限；无 fieldsets 的只读变更页不再报错。
19. 【优化】**聚合计数支持自定义主键**：内置聚合默认按 `pk` 计数，源模型使用自定义主键时不再报错。
20. 【优化】**低层流水线默认落库统计**：直接使用 `ArchivePipeline` 并声明 `stats_model` 时，默认在清理源数据前持久化统计，可用 `stats_persist` 自定义。

> 升级提示：0.3.0 起统计模型必须为归档模型的 `group_fields` 声明唯一约束。已有统计表补充约束前请先清理历史重复行，否则迁移会失败；启动检查会提示缺失的约束。

### v0.2.0

1. 【新增】**只出统计、不落盘**：`django_simple_archive archive --no-file` 或 `ArchivePipeline(use_export=False)` 只生成每日聚合统计、不写归档文件；源日志默认原样保留，需要定时清理时可搭配 `--retention-days`，重复运行幂等。
2. 【新增】**源日志字段名可声明**：归档模型可声明 `group_field`（按日归档的源日志日期字段，默认 `date`）与 `mark_field`（保留策略的标记字段，默认 `archived_at`）；`run_archive` 与管理命令均据此过滤。
3. 【修正】**启用历史解密链后同日合并不再丢数据**：合并重复归档记录时新归档行会完整保留，`check` 也能正常发现异常。
4. 【修正】**运行计数口径修正**：`run_archive` 返回的 `archive_count` / `stats_count` 改为本次运行增量（本次实际写入的归档记录数 / 产生统计的分组数），重复归档不再多报。
5. 【修正】**dry-run 与预统计口径一致**：待归档数量会剔除已打标记的行、识别自定义日期字段，不再把运行时会跳过的行计入。
6. 【修正】**配置错误提前校验**：声明了 `stats_model` 却漏配 `group_fields` 时，在删除源数据前即报错，避免任务失败后源数据已丢失。
7. 【修正】**下载任务进度显示修正**：后台进度页不再显示错误的步骤数量。
8. 【优化】**大表读取 / 清理全程分块**：校验、导出、重新编码、下载也按批处理，超大归档下内存有界。
9. 【新增】**下载命令支持 `--chunk-size`**：`django_simple_archive download` 新增 `--chunk-size`（默认 2000），与 `check` / `export` / `reencrypt` 口径一致。
10. 【优化】**归档表 / 源表查询索引**：归档模型、统计模型与下载任务表自动为常用日期字段建索引；示例源表给出复合索引示范，业务可按自己的过滤 / 排序字段补齐。

### v0.1.0

1. 【新增】**日志归档开箱即用**：业务表只需继承归档 / 统计基类并声明 `source_model`、序列化器等少量配置，即可获得按日归档 + 统计能力，无需自己设计归档表与写入逻辑。
2. 【新增】**海量数据也稳妥**：归档按批次流式处理、内存占用恒定，单日 GB 级日志也能平稳归档；同一天的数据在多次运行间自动合并，不会重复归档。
3. 【新增】**默认归档即加密存储**：默认写入器采用流式加密（AES-256-GCM / CBC，并兼容历史算法与旧密钥），密钥或算法轮换后旧文件仍可正常读取。
4. 【新增】**省空间不牺牲兼容**：默认先压缩后加密，文件自带压缩标记；早期未压缩归档在读取时自动识别，全程不加载整份文件到内存。
5. 【新增】**原始日志可留可删**：归档后默认立即清理源日志释放空间；需要回溯时开启「保留 N 天」，到期自动清理，同一批数据不会因再次运行而被重复归档。
6. 【新增】**失败不丢数据**：归档过程中任何一步出错，源日志都会原样保留，修复后直接重跑即可。
7. 【新增】**统计始终准确**：同一天在后续归档运行中补充的数据，统计会自动累加，与归档文件内容一致。
8. 【新增】**后台一键归档**：为源日志的 ModelAdmin 挂一个 mixin，列表页即有「归档」按钮 + 日期范围弹窗 + 异步后台任务，并能与 mptt / django-import-export / grappelli 等 admin 扩展共存。
9. 【新增】**明文随时可取**：后台可把指定归档记录批量下载为明文 zip、单文件明文，或发起读取检查并查看结果；下载任务显示实时进度，反复重跑不会留下冗余文件。
10. 【新增】**一条命令管理全部归档**：内置 `django_simple_archive` 管理命令，支持列出归档 / 源模型、逐条校验、归档、导出明文 JSONL、重新编码历史归档、生成明文 zip。
11. 【文档】**随包附赠可运行演示**：内置 demo 与 example 应用（含每日定时归档、归档按钮与三方 admin 共存的完整示例），可直接对照接入。
