# 批量上传小说

一次创建小说并导入卷与章节，或为已有小说分批续传。

`POST /api/v2/upload`

这是推荐的小说导入接口。上传前先取得平台 ID，可选取得作者 ID。
每次请求只能选择以下一种模式：

1. **新建小说**：提供 `novel` 对象，不提供 `novelId`。成功返回 `201`。
2. **已有小说续传**：提供 `novelId`，不提供 `novel`。成功返回 `200`。

不带查询参数。新建模式允许只建元信息；续传模式至少包含一个卷或一个章节。

### 8.1 新建小说并上传卷、章节

```json
{
  "novel": {
    "platformId": 1,
    "authorId": 2,
    "title": "示例小说",
    "summary": "小说简介",
    "coverUrl": null,
    "status": "ONGOING"
  },
  "volumes": [
    { "order": 1, "title": "第一卷 启程" },
    { "order": 2, "title": "第二卷 远方" }
  ],
  "chapters": [
    {
      "order": 1,
      "volumeOrder": null,
      "title": "序章",
      "content": "未分卷的序章正文。"
    },
    {
      "order": 2,
      "volumeOrder": 1,
      "title": "第一章 出发",
      "content": "第一段。\n\n第二段。"
    },
    {
      "order": 3,
      "volumeOrder": 2,
      "title": "第二章 远行",
      "content": "第二卷中的章节正文。"
    }
  ]
}
```

### 8.2 给已有小说分批续传

```json
{
  "novelId": 10,
  "volumes": [
    { "order": 3, "title": "第三卷 归途" }
  ],
  "chapters": [
    {
      "order": 4,
      "volumeOrder": 3,
      "title": "第三章 归来",
      "content": "新章节正文。"
    },
    {
      "order": 5,
      "volumeOrder": 1,
      "title": "第一卷补记",
      "content": "引用此前已经存在的第一卷。"
    }
  ]
}
```

续传只修改本批次列出的卷、章节，不清空其他数据，不修改小说元信息。
要修改小说元信息，另行使用 `PUT /api/v2/novelinfo?id=10`。

### 8.3 顶层字段

| 请求键 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `novel` | object / null | 新建模式必填 | 第 5.4 节定义的小说写入对象；与非空 `novelId` 互斥。 |
| `novelId` | integer / null | 续传模式必填 | 已存在的小说 ID；不存在返回 404。与非空 `novel` 互斥。 |
| `volumes` | array / null | 否 | 本批次要新增/覆盖的卷，最多 200 项；省略/null/空数组均表示本批不写入卷。 |
| `chapters` | array / null | 否 | 本批次要新增/覆盖的章节，最多 1000 项；省略/null/空数组均表示本批不写入章节。 |

### 8.4 volumes 数组每项的所有键

| 请求键 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `order` | integer | 是 | 小说内卷序号；同一批次不能重复，不要求数组按序排列。 |
| `title` | string | 是 | 卷名，去除首尾空白后不能为空。 |

这里不提交 `novelId`、`id` 或时间字段；小说由顶层确定。
卷按 `(novelId, order)` 新增或覆盖，已有卷保留 ID、创建时间和章节关联。

### 8.5 chapters 数组每项的所有键

| 请求键 | 类型 | 必填 | 默认/约束 |
| --- | --- | --- | --- |
| `order` | integer | 是 | 全书章节序号；同一批次不能重复，跨卷不重置，不要求连续或数组排序。 |
| `volumeOrder` | integer / null | 否 | **卷序号，不是卷 ID**；引用本批次创建的卷，或同一本小说此前已经存在的卷。省略/null 会将章节设为未分卷。 |
| `title` | string | 是 | 章节标题，去除首尾空白后不能为空。 |
| `content` | string | 是 | 非全空白的纯文本正文，原始换行和空白保留。 |

批量上传不接受单章接口的 `volumeId`，用 `volumeOrder` 避免新卷尚无数据库 ID 的问题。
卷先写入，章节后写入；数组顺序不影响关联。卷引用找不到返回 400，整个事务回滚。
同一小说允许未分卷章节与分卷章节混用，卷可以暂时没有章节。

### 8.6 成功响应的所有键

新建返回 `201`，同时带 `Location: /api/v2/novelinfo?id=10`；续传返回 `200`。

```json
{
  "novel": {
    "id": 10,
    "platformId": 1,
    "authorId": 2,
    "title": "示例小说",
    "summary": "小说简介",
    "coverUrl": null,
    "status": "ONGOING",
    "createdAt": "2026-10-29T04:00:00Z",
    "updatedAt": "2026-10-29T04:00:00Z"
  },
  "volumes": [
    {
      "id": 20,
      "novelId": 10,
      "order": 1,
      "title": "第一卷 启程",
      "createdAt": "2026-10-29T04:00:00Z",
      "updatedAt": "2026-10-29T04:00:00Z"
    }
  ],
  "chapters": [
    {
      "id": 30,
      "order": 2,
      "volumeId": 20,
      "updatedAt": "2026-10-29T04:00:00Z"
    }
  ],
  "chapterCount": 1
}
```

以上用一卷一章展示响应结构，并非 8.1 完整请求的逐项响应。

| 响应键 | 类型 | 说明 |
| --- | --- | --- |
| `novel` | object | 完整小说元信息，所有键见第 5 节。 |
| `volumes` | array | **本批次显式写入**的卷，结构见第 6 节，按请求数组顺序返回；未写卷时为 `[]`。仅被章节引用的旧卷不会额外出现在这里。 |
| `chapters` | array | 本批次章节回执，按请求数组顺序返回；未写章节时为 `[]`。不重复返回正文，完整章节用 GET /api/v2/content 获取。 |
| `chapters[].id` | integer | 章节数据库 ID。 |
| `chapters[].order` | integer | 全书章节序号。 |
| `chapters[].volumeId` | integer / null | 解析后的数据库卷 ID，未分卷为 null。 |
| `chapters[].updatedAt` | string | 本次写入的服务端更新时间。 |
| `chapterCount` | integer | 本批次成功写入的章节条数，**不是全书章节总数**。新增和覆盖都计数。 |

### 8.7 事务、覆盖与重试规则

- 每批次一个数据库事务，任何关联或数据库错误都会撤销本批新小说、卷和章节的全部修改，不会部分成功。
- 卷按 `(novelId, order)`、章节按 `(novelId, order)` 覆盖。既有章节保留 ID，替换标题、正文与卷关联。
- 卷序号和章节序号是两套独立编号。序号是定位键，提交新序号不会移动/重编号旧记录，而是创建另一条。
- 同一批内部的重复卷序号或重复章节序号直接返回 400，避免隐式“最后一条覆盖”。
- **首次创建不按标题去重，也没有幂等键。重复发送带 `novel` 的请求会创建多部小说。**
- 成功取得 `novel.id` 后，后续请求统一改用 `novelId`。相同续传数据可以重试，记录 ID 保持不变，更新时间会更新。
- 如首次请求超时或连接中断，提交结果可能不明；先查询并核对，不要盲目再次用 `novel` 新建。
- 上传卷/章节不刷新小说的 `updatedAt`，该字段仅反映小说元信息写入时间。
- 多批之间不是同一个事务：某批失败不会撤销此前成功的批次。8 MiB/1000 章以内按需拆分，分批进度由客户端记录。
