owned-account-pool-cli 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # 自有账号池 CLI
2
2
 
3
- 面向 `https://handover.luckyjun.top` 的账号池与任务登记工具。支持交互登录、选择客户和账号、录入帖子与独立评论、编辑原稿、提交任务和失败恢复。
3
+ 面向 `https://handover.luckyjun.top` 的账号池与任务登记工具。支持交互登录、选择客户和账号、录入帖子与独立评论、上传图片、编辑原稿、提交任务和失败恢复。
4
4
 
5
5
  ## 安装
6
6
 
@@ -24,7 +24,7 @@ owned-pool post create
24
24
  owned-pool comment create
25
25
  ```
26
26
 
27
- 命令会依次收集缺失的账号密码、客户、帖子用途、回复目标、社区、账号来源、权重阈值、账号、标题、正文、留存要求以及保存方式。正文支持直接输入、UTF-8 文件和系统编辑器,备注可留空。
27
+ 命令会依次收集缺失的账号密码、客户、帖子用途、回复目标、社区、账号来源、权重阈值、账号、标题、图片、正文、留存要求以及保存方式。正文支持直接输入、UTF-8 文件和系统编辑器,备注可留空。
28
28
 
29
29
  评论使用自有账号,回复社区通过服务端解析目标 URL 获得。账号选择保留服务端资格限制。草稿保存与提交通过 `--draft`、`--submit` 选择,也可在终端回答。
30
30
 
@@ -72,7 +72,23 @@ owned-pool tasks edit 123 --body-file ./updated.md --draft
72
72
  owned-pool tasks submit 123
73
73
  ```
74
74
 
75
- 编辑保留已有素材及可编辑的跟帖,写入携带任务版本。新增录入支持文本原稿,图片可通过系统网页补充。
75
+ 编辑保留已有素材及可编辑的跟帖,写入携带任务版本。
76
+
77
+ ## 图片上传
78
+
79
+ 从 0.2.0 起,录入与编辑命令支持重复传入 `--image`,按传入顺序追加图片。交互模式会询问是否添加图片,随后逐个输入文件路径。
80
+
81
+ ```sh
82
+ owned-pool post create --image ./cover.png --image "./产品展示.jpg"
83
+ owned-pool comment create --image ./reference.webp
84
+ owned-pool tasks edit 123 --image ./extra.png --draft --non-interactive
85
+ ```
86
+
87
+ 上传遵循当前网页的限制,支持静态 JPEG、PNG、WebP,单张最多 5 MiB,每个任务最多 10 张,包含已有素材。图片帖的正文可留空。非交互模式省略 `--image` 时继续处理现有内容。
88
+
89
+ CLI 在写入前检查全部图片,先保存原稿,再逐张以 multipart 上传 `file` 和最新 `version`。每张成功上传的素材立即保存到任务,完整上传后才执行 `--submit`。编辑时图片追加到已有素材末尾。
90
+
91
+ 恢复文件会记录图片路径、内容摘要和已完成的素材 ID。遇到上传响应丢失,恢复时读取任务并下载新增原图核对摘要,确认成功后继续后续图片。出现并发修改或摘要不一致时返回错误供核对。待上传的本地图片需保持原路径和内容。
76
92
 
77
93
  写入失败时返回任务 ID 和本地恢复文件路径。该文件包含原稿内容,保存在用户目录 `.owned-account-pool/recovery`。使用返回的恢复命令继续操作,成功后自动清理。
78
94
 
@@ -110,4 +126,4 @@ node bin/owned-pool.js --help
110
126
  npm pack --dry-run
111
127
  ```
112
128
 
113
- 接口依据本目录 2026-11-11 的 API 文档及站点前端调用。测试覆盖模拟服务的认证、CSRF、录入、分页选择、版本冲突、恢复、更新提示和 CLI 入口。
129
+ 接口依据本目录 2026-11-11 的 API 文档及站点前端调用。测试覆盖模拟服务的认证、CSRF、录入、图片 multipart 上传、分页选择、版本冲突、恢复、更新提示和 CLI 入口。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "owned-account-pool-cli",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "自有账号池 CLI,交互登录、客户与账号选择、帖子和评论任务录入",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: owned-account-pool
3
- description: 使用 owned-pool CLI 查询自有账号池、客户和登记任务,录入或编辑帖子与评论原稿,提交给发布人员,并在 npm 包有新版本时提醒用户。适用于 handover.luckyjun.top 自有账号发布系统。
3
+ description: 使用 owned-pool CLI 查询自有账号池、客户和登记任务,录入或编辑帖子与评论原稿、上传图片、提交给发布人员,并在 npm 包有新版本时提醒用户。适用于 handover.luckyjun.top 自有账号发布系统。
4
4
  ---
5
5
 
6
6
  # 自有账号池 CLI
@@ -56,10 +56,22 @@ owned-pool comment create --client 12 --account account-opaque-id --target-url "
56
56
  - 留存 `--retention-hours` 取 `24`、`48`、`72`、`168`、`720` 小时。
57
57
  - `--draft` 保存草稿,`--submit` 保存后提交给发布人员。依据用户要求选择,缺少要求时询问。
58
58
  - 长正文、多行内容使用 UTF-8 的 `--body-file`。可选备注在非交互模式下省略即为空。
59
+ - 图片上传需要 CLI 0.2.0 起,使用可重复的 `--image <路径>`,参数顺序即追加顺序。支持静态 JPEG、PNG、WebP,每张最多 5 MiB,每个任务包含已有素材最多 10 张。图片帖可省略正文。
59
60
  - `--dry-run` 用于检查收集完成的录入方案。它包含登录和只读查询。
60
61
 
61
62
  成功结果包含 `task_id`、`status`、`version`。向用户汇报任务 ID 与状态。此工作流记录系统任务,Reddit 实际发布由发布人员执行。
62
63
 
64
+ ## 图片录入
65
+
66
+ 用户提供本地图片或可访问的附件路径时,把路径加入录入或编辑命令。需要图片且路径缺失时询问用户。交互命令会询问是否添加图片,非交互模式省略图片参数时沿用现有内容。多个文件依次使用 `--image`,含空格的路径加引号。
67
+
68
+ ```sh
69
+ owned-pool post create --image ./cover.png --image "./产品展示.jpg"
70
+ owned-pool tasks edit 123 --image ./extra.webp --draft --non-interactive --json
71
+ ```
72
+
73
+ CLI 先保存原稿,再按最新任务版本逐张上传并保存,全部成功后执行提交。上传结果的 `uploaded_assets` 包含本轮新增素材 ID,保留其原始类型。
74
+
63
75
  ## 编辑、提交与恢复
64
76
 
65
77
  ```sh
@@ -72,4 +84,6 @@ owned-pool tasks resume "恢复文件的完整路径" --non-interactive --json
72
84
 
73
85
  录入分为创建任务和保存原稿。写入中断时,错误中包含 `recovery_file` 和 `next_command`。优先恢复既有任务。创建请求结果未知时,先查 `tasks list`,找到对应任务,再使用 `tasks resume ... --task-id ID`。恢复文件保存在用户目录 `.owned-account-pool/recovery`,含录入正文,成功恢复后清理。
74
86
 
87
+ 图片上传中断也沿用恢复命令。保留待上传文件的原路径与内容,CLI 会核对内容摘要,复用已确认成功的图片。上传响应丢失时,CLI 根据任务新增素材及下载原图的摘要判断结果。`UPLOAD_CONFLICT` 表示任务变化或素材摘要不匹配,需要读取最新任务并核对,随后合并用户要求。
88
+
75
89
  本 CLI 覆盖账号查询与任务登记流程。客户绑定、账号资格等服务端配置问题按接口错误提示处理。
package/src/api.js CHANGED
@@ -19,17 +19,18 @@ export class ApiClient {
19
19
  this.loggedIn = false;
20
20
  }
21
21
 
22
- async request(method, path, { query = {}, body, timeout = this.timeout } = {}) {
22
+ async request(method, path, { query = {}, body, responseType = 'json', timeout = this.timeout } = {}) {
23
23
  assert(path.startsWith('/api/'), 'INVALID_PATH', '接口路径须以 /api/ 开头。');
24
24
  const url = new URL(path, this.baseUrl);
25
25
  assert(url.origin === this.baseUrl, 'INVALID_PATH', '接口路径必须属于当前服务。');
26
26
  for (const [key, value] of Object.entries(query)) {
27
27
  if (value !== undefined && value !== null && value !== '') url.searchParams.set(key, value);
28
28
  }
29
- const headers = { Accept: 'application/json' };
29
+ const multipart = body instanceof FormData;
30
+ const headers = { Accept: responseType === 'buffer' ? '*/*' : 'application/json' };
30
31
  const cookie = await this.jar.getCookieString(url.href);
31
32
  if (cookie) headers.Cookie = cookie;
32
- if (body !== undefined) headers['Content-Type'] = 'application/json';
33
+ if (body !== undefined && !multipart) headers['Content-Type'] = 'application/json';
33
34
  if (!['GET', 'HEAD', 'OPTIONS'].includes(method) && this.csrf) {
34
35
  headers['X-CSRF-Token'] = this.csrf;
35
36
  }
@@ -37,12 +38,13 @@ export class ApiClient {
37
38
  let text;
38
39
  try {
39
40
  response = await this.fetchImpl(url, {
40
- method, headers, body: body === undefined ? undefined : JSON.stringify(body),
41
+ method, headers, body: multipart ? body : body === undefined ? undefined : JSON.stringify(body),
41
42
  redirect: 'manual', signal: AbortSignal.timeout(timeout),
42
43
  });
43
44
  for (const cookieValue of response.headers.getSetCookie()) {
44
45
  await this.jar.setCookie(cookieValue, url.href);
45
46
  }
47
+ if (response.ok && responseType === 'buffer') return Buffer.from(await response.arrayBuffer());
46
48
  text = await response.text();
47
49
  } catch (error) {
48
50
  const code = ['TimeoutError', 'AbortError'].includes(error.name) ? 'REQUEST_TIMEOUT' : 'NETWORK_ERROR';
package/src/cli.js CHANGED
@@ -56,6 +56,7 @@ function authenticated(command, handler) {
56
56
  function contentOptions(command) {
57
57
  return command.option('--body <text>', '正文 Markdown')
58
58
  .option('--body-file <file>', '从 UTF-8 文件读取正文')
59
+ .option('--image <file>', '添加图片,支持重复传入,按参数顺序上传', (file, files = []) => [...files, file])
59
60
  .option('--notes <text>', '内部备注,可传空字符串')
60
61
  .option('--retention-hours <hours>', '留存小时数 24/48/72/168/720')
61
62
  .option('--draft', '保存为草稿')
package/src/images.js ADDED
@@ -0,0 +1,77 @@
1
+ import { open } from 'node:fs/promises';
2
+ import { basename, resolve } from 'node:path';
3
+ import { createHash } from 'node:crypto';
4
+ import { assert } from './errors.js';
5
+
6
+ export const MAX_IMAGES = 10;
7
+ export const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
8
+
9
+ export function imageHash(bytes) {
10
+ return createHash('sha256').update(bytes).digest('hex');
11
+ }
12
+
13
+ function imageType(bytes) {
14
+ if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return 'image/jpeg';
15
+ if (bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))) {
16
+ for (let offset = 8; offset + 12 <= bytes.length;) {
17
+ const length = bytes.readUInt32BE(offset);
18
+ const type = bytes.toString('ascii', offset + 4, offset + 8);
19
+ assert(type !== 'acTL', 'INVALID_IMAGE', '请使用静态 PNG 图片。');
20
+ offset += length + 12;
21
+ }
22
+ return 'image/png';
23
+ }
24
+ if (bytes.toString('ascii', 0, 4) === 'RIFF' && bytes.toString('ascii', 8, 12) === 'WEBP') {
25
+ for (let offset = 12; offset + 8 <= bytes.length;) {
26
+ const type = bytes.toString('ascii', offset, offset + 4);
27
+ const length = bytes.readUInt32LE(offset + 4);
28
+ assert(type !== 'ANIM' && type !== 'ANMF' &&
29
+ !(type === 'VP8X' && length > 0 && (bytes[offset + 8] & 2)),
30
+ 'INVALID_IMAGE', '请使用静态 WebP 图片。');
31
+ offset += 8 + length + length % 2;
32
+ }
33
+ return 'image/webp';
34
+ }
35
+ assert(false, 'INVALID_IMAGE', '图片格式须为 JPEG、PNG 或 WebP。');
36
+ }
37
+
38
+ export async function readImage(file) {
39
+ const path = resolve(file);
40
+ const handle = await open(path, 'r');
41
+ let bytes;
42
+ try {
43
+ const stat = await handle.stat();
44
+ assert(stat.isFile(), 'INVALID_IMAGE', '图片路径须指向文件。', { path });
45
+ assert(stat.size > 0 && stat.size <= MAX_IMAGE_BYTES, 'INVALID_IMAGE', '单张图片须大于 0 且不超过 5 MiB。', { path });
46
+ bytes = await handle.readFile();
47
+ } finally {
48
+ await handle.close();
49
+ }
50
+ assert(bytes.length > 0 && bytes.length <= MAX_IMAGE_BYTES, 'INVALID_IMAGE', '单张图片须大于 0 且不超过 5 MiB。', { path });
51
+ return { path, name: basename(path), type: imageType(bytes), size: bytes.length, sha256: imageHash(bytes), bytes };
52
+ }
53
+
54
+ export async function prepareImages(questions, files, existingCount = 0) {
55
+ let paths = files || [];
56
+ if (!files && questions.interactive && existingCount < MAX_IMAGES) {
57
+ const add = await questions.choice('images', '是否添加图片', undefined, [
58
+ { name: '暂不添加', value: false }, { name: '添加本地图片', value: true },
59
+ ]);
60
+ if (add) {
61
+ paths = [];
62
+ while (paths.length + existingCount < MAX_IMAGES) {
63
+ const path = await questions.text('image', paths.length ? '下一张图片路径(留空结束)' : '图片文件路径',
64
+ undefined, { optional: paths.length > 0 });
65
+ if (!path.trim()) break;
66
+ paths.push(path.trim().replace(/^"|"$/g, ''));
67
+ }
68
+ }
69
+ }
70
+ assert(paths.length + existingCount <= MAX_IMAGES, 'TOO_MANY_IMAGES', '每个任务最多 10 张图片,包含现有图片。');
71
+ const images = [];
72
+ for (const path of paths) {
73
+ const { bytes, ...image } = await readImage(path);
74
+ images.push(image);
75
+ }
76
+ return images;
77
+ }
package/src/prompts.js CHANGED
@@ -69,10 +69,11 @@ export class Questions {
69
69
  }, this.context);
70
70
  }
71
71
 
72
- async body(value, file, defaultValue) {
72
+ async body(value, file, defaultValue, { optional = false } = {}) {
73
73
  assert(!(value !== undefined && file), 'INVALID_INPUT', '--body 与 --body-file 只能选择一项。');
74
74
  if (file) value = await readFile(file, 'utf8');
75
- if (value !== undefined) return this.text('body', '正文', value, { max: 40000 });
75
+ if (value !== undefined) return this.text('body', '正文', value, { max: 40000, optional });
76
+ if (optional && !this.interactive) return defaultValue ?? '';
76
77
  this.requireInteractive('body', '正文(--body 或 --body-file)');
77
78
  const mode = await this.choice('body-mode', '正文输入方式', undefined, [
78
79
  { name: '输入一段文字', value: 'input' },
@@ -81,15 +82,16 @@ export class Questions {
81
82
  ]);
82
83
  if (mode === 'file') {
83
84
  const path = await this.text('body-file', '正文文件路径', undefined);
84
- return this.body(undefined, path.trim().replace(/^"|"$/g, ''));
85
+ return this.body(undefined, path.trim().replace(/^"|"$/g, ''), defaultValue, { optional });
85
86
  }
86
87
  if (mode === 'editor') {
87
88
  return editor({
88
89
  message: '编辑正文并保存退出', default: defaultValue ?? '', postfix: '.md',
89
- validate: content => content.trim() && content.length <= 40000 ? true : '正文须为 1–40000 个字符。',
90
+ validate: content => (optional || content.trim()) && content.length <= 40000
91
+ ? true : '请填写正文,最多 40000 个字符。',
90
92
  }, this.context);
91
93
  }
92
- return this.text('body', '正文', undefined, { max: 40000, defaultValue });
94
+ return this.text('body', optional ? '正文(图片帖可留空)' : '正文', undefined, { max: 40000, defaultValue, optional });
93
95
  }
94
96
  }
95
97
 
package/src/tasks.js CHANGED
@@ -6,6 +6,7 @@ import { isDeepStrictEqual } from 'node:util';
6
6
  import { allPages, itemsOf } from './api.js';
7
7
  import { CliError, assert } from './errors.js';
8
8
  import { normalizeSubreddit, positiveInteger } from './prompts.js';
9
+ import { imageHash, MAX_IMAGES, prepareImages, readImage } from './images.js';
9
10
 
10
11
  const PURPOSES = [
11
12
  { name: '外部原创', value: 'external_original' },
@@ -96,7 +97,10 @@ export async function prepareCreate(api, questions, kind, options) {
96
97
  const candidate = await chooseCandidate(api, questions, options, client.id, kind, target?.subreddit);
97
98
  const title = kind === 'post'
98
99
  ? await questions.text('title', '帖子标题', options.title, { max: 300 }) : '';
99
- const body = await questions.body(options.body, options.bodyFile);
100
+ const images = await prepareImages(questions, options.image);
101
+ const body = await questions.body(options.body, options.bodyFile, undefined, {
102
+ optional: kind === 'post' && images.length > 0,
103
+ });
100
104
  const notes = await questions.text('notes', '内部备注(可留空)', options.notes, { optional: true, max: 5000 });
101
105
  const targetContext = kind === 'comment'
102
106
  ? await questions.text('target-context', '目标原文摘录(可留空)', options.targetContext, { optional: true, max: 20000 }) : '';
@@ -104,7 +108,7 @@ export async function prepareCreate(api, questions, kind, options) {
104
108
  const mode = await submissionChoice(questions, options);
105
109
  const accountMode = kind === 'comment' ? { account_mode: 'owned' } : {};
106
110
  return {
107
- schema: 1, base_url: api.baseUrl, task_id: null, expected_version: null, mode,
111
+ schema: 1, base_url: api.baseUrl, task_id: null, expected_version: null, mode, images,
108
112
  create: {
109
113
  client_id: positiveInteger(client.id, '客户 ID'), kind, parent_task_id: null,
110
114
  follow_op: false, post_purpose: purpose, ...accountMode,
@@ -134,11 +138,14 @@ export async function prepareEdit(api, questions, id, options) {
134
138
  const task = await getTask(api, id);
135
139
  assert(['draft', 'returned'].includes(task.status), 'INVALID_TASK_STATE', '原稿编辑适用于 draft 或 returned 任务。');
136
140
  const content = { title: '', body: '', notes: '', target_context: '', assets: [], ...task.source };
141
+ const images = await prepareImages(questions, options.image, content.assets.length);
137
142
  if (task.kind === 'post' && (options.title !== undefined || !content.title)) {
138
143
  content.title = await questions.text('title', '帖子标题', options.title, { max: 300 });
139
144
  }
140
- if (options.body !== undefined || options.bodyFile || !content.body || questions.interactive) {
141
- content.body = await questions.body(options.body, options.bodyFile, content.body);
145
+ if (options.body !== undefined || options.bodyFile || !content.body || questions.interactive && !options.image?.length) {
146
+ content.body = await questions.body(options.body, options.bodyFile, content.body, {
147
+ optional: task.kind === 'post' && content.assets.length + images.length > 0,
148
+ });
142
149
  }
143
150
  content.notes = await questions.text('notes', '内部备注(可留空)', options.notes, {
144
151
  optional: true, max: 5000, defaultValue: content.notes,
@@ -158,7 +165,7 @@ export async function prepareEdit(api, questions, id, options) {
158
165
  chosenCandidates = [await chooseCandidate(api, questions, options, task.client_id, task.kind, subreddit)];
159
166
  }
160
167
  return {
161
- schema: 1, base_url: api.baseUrl, task_id: task.id, expected_version: task.version,
168
+ schema: 1, base_url: api.baseUrl, task_id: task.id, expected_version: task.version, images,
162
169
  mode: await submissionChoice(questions, options),
163
170
  registration: {
164
171
  content,
@@ -196,6 +203,73 @@ function registrationMatches(task, registration) {
196
203
  });
197
204
  }
198
205
 
206
+ function addedImage(task, plan) {
207
+ const previous = plan.registration.content.assets;
208
+ const current = task.source?.assets;
209
+ assert(Array.isArray(current) && current.length === previous.length + 1 &&
210
+ isDeepStrictEqual(current.slice(0, -1), previous) &&
211
+ registrationMatches({ ...task, source: { ...task.source, assets: previous } }, plan.registration),
212
+ 'UPLOAD_CONFLICT', '任务素材或原稿发生了其他变化,请查询任务并核对恢复文件。');
213
+ const id = current.at(-1);
214
+ assert((typeof id === 'string' && id.length > 0 || typeof id === 'number' && Number.isFinite(id)) &&
215
+ !previous.includes(id), 'INVALID_RESPONSE', '上传响应缺少新增图片的素材 ID。');
216
+ return id;
217
+ }
218
+
219
+ function rememberImage(plan, image, task, assetId) {
220
+ image.asset_id = assetId;
221
+ delete image.attempt;
222
+ plan.registration.content.assets = [...task.source.assets];
223
+ plan.expected_version = task.version;
224
+ }
225
+
226
+ async function reconcileUpload(api, plan, task, recoveryPath) {
227
+ const pending = (plan.images || []).filter(image => image.attempt);
228
+ assert(pending.length <= 1, 'INVALID_RECOVERY', '恢复文件包含多个未确认的上传请求。');
229
+ if (!pending.length) return;
230
+ const image = pending[0];
231
+ if (task.version === image.attempt.version && registrationMatches(task, plan.registration)) {
232
+ delete image.attempt;
233
+ } else {
234
+ const id = addedImage(task, plan);
235
+ const bytes = await api.request('GET', `/api/tasks/${task.id}/assets/${encodeURIComponent(String(id))}`, {
236
+ query: { download: true }, responseType: 'buffer',
237
+ });
238
+ assert(imageHash(bytes) === image.sha256, 'UPLOAD_CONFLICT',
239
+ '新增素材与待恢复图片的内容不一致,请查询任务并核对上传结果。');
240
+ rememberImage(plan, image, task, id);
241
+ }
242
+ await writeRecovery(recoveryPath, plan);
243
+ }
244
+
245
+ async function checkedImage(image) {
246
+ const current = await readImage(image.path);
247
+ assert(current.sha256 === image.sha256, 'IMAGE_CHANGED',
248
+ '图片文件在录入后发生了变化,请恢复原文件,或通过 tasks edit 使用新文件。', { path: image.path });
249
+ return current;
250
+ }
251
+
252
+ async function uploadImages(api, plan, task, recoveryPath) {
253
+ for (const image of plan.images || []) {
254
+ if (image.asset_id !== undefined) continue;
255
+ assert(['draft', 'returned'].includes(task.status), 'INVALID_TASK_STATE', '当前任务状态不支持原稿图片上传。');
256
+ assert(task.version === plan.expected_version && registrationMatches(task, plan.registration),
257
+ 'VERSION_CONFLICT', '任务已变化,请核对原稿后继续上传。');
258
+ const file = await checkedImage(image);
259
+ const form = new FormData();
260
+ form.set('file', new Blob([file.bytes], { type: file.type }), file.name);
261
+ form.set('version', String(task.version));
262
+ image.attempt = { version: task.version };
263
+ await writeRecovery(recoveryPath, plan);
264
+ const uploaded = taskOf(await api.request('POST', `/api/tasks/${task.id}/assets`, { body: form }));
265
+ const id = addedImage(uploaded, plan);
266
+ rememberImage(plan, image, uploaded, id);
267
+ await writeRecovery(recoveryPath, plan);
268
+ task = uploaded;
269
+ }
270
+ return task;
271
+ }
272
+
199
273
  export async function submitTask(api, task, dryRun = false) {
200
274
  assert(['draft', 'returned'].includes(task.status), 'INVALID_TASK_STATE', '可提交的原稿状态为 draft 或 returned。');
201
275
  const body = {
@@ -209,6 +283,12 @@ export async function submitTask(api, task, dryRun = false) {
209
283
 
210
284
  export async function executePlan(api, plan, { recoveryPath, recoveryDir, resume = false, dryRun = false } = {}) {
211
285
  if (dryRun) return { dry_run: true, plan };
286
+ const pendingImages = (plan.images || []).filter(image => image.asset_id === undefined);
287
+ assert(plan.registration.content.assets.length + pendingImages.length <= MAX_IMAGES,
288
+ 'TOO_MANY_IMAGES', '每个任务最多 10 张图片,包含现有图片。');
289
+ for (const image of pendingImages) {
290
+ if (!image.attempt) await checkedImage(image);
291
+ }
212
292
  const directory = recoveryDir || join(homedir(), '.owned-account-pool', 'recovery');
213
293
  await mkdir(directory, { recursive: true, mode: 0o700 });
214
294
  const path = recoveryPath || join(directory, `${randomUUID()}.recovery.json`);
@@ -228,6 +308,7 @@ export async function executePlan(api, plan, { recoveryPath, recoveryDir, resume
228
308
  assert(task.client_id === plan.create.client_id && task.kind === plan.create.kind,
229
309
  'RECOVERY_MISMATCH', '恢复任务的客户或类型与原录入内容不一致。');
230
310
  }
311
+ await reconcileUpload(api, plan, task, path);
231
312
  const alreadySaved = registrationMatches(task, plan.registration);
232
313
  if (!alreadySaved) {
233
314
  assert(['draft', 'returned'].includes(task.status), 'INVALID_TASK_STATE', '任务当前状态不支持保存原稿。');
@@ -238,7 +319,11 @@ export async function executePlan(api, plan, { recoveryPath, recoveryDir, resume
238
319
  }));
239
320
  plan.expected_version = task.version;
240
321
  await writeRecovery(path, plan);
322
+ } else {
323
+ plan.expected_version = task.version;
324
+ await writeRecovery(path, plan);
241
325
  }
326
+ task = await uploadImages(api, plan, task, path);
242
327
  if (plan.mode === 'submit' && ['draft', 'returned'].includes(task.status)) {
243
328
  task = await submitTask(api, task);
244
329
  }
@@ -254,21 +339,25 @@ export async function executePlan(api, plan, { recoveryPath, recoveryDir, resume
254
339
  return { task_id: task.id, status: task.status, version: task.version,
255
340
  recovery_file: path, warning: '操作成功,恢复文件清理失败,可手动删除。' };
256
341
  }
257
- return { task_id: task.id, status: task.status, version: task.version };
342
+ return { task_id: task.id, status: task.status, version: task.version,
343
+ ...((plan.images || []).length ? { uploaded_assets: plan.images.map(image => image.asset_id) } : {}) };
258
344
  }
259
345
 
260
346
  export async function resumePlan(api, questions, file, options) {
261
347
  const path = resolve(file);
262
348
  const plan = JSON.parse(await readFile(path, 'utf8'));
263
349
  assert(plan.schema === 1 && plan.registration?.content && Array.isArray(plan.registration.candidates) &&
264
- Array.isArray(plan.registration.followups) && ['draft', 'submit'].includes(plan.mode),
350
+ Array.isArray(plan.registration.content.assets) && Array.isArray(plan.registration.followups) &&
351
+ (plan.images === undefined || Array.isArray(plan.images) && plan.images.every(image =>
352
+ typeof image.path === 'string' && /^[a-f0-9]{64}$/.test(image.sha256))) &&
353
+ ['draft', 'submit'].includes(plan.mode),
265
354
  'INVALID_RECOVERY', '恢复文件格式无效。');
266
355
  assert(plan.base_url === api.baseUrl, 'RECOVERY_MISMATCH', '恢复文件所属服务与当前 --base-url 不一致。');
267
356
  if (!plan.task_id) {
268
357
  plan.task_id = positiveInteger(await questions.text('task-id',
269
358
  '创建结果未知,请查询任务列表并填写对应任务 ID', options.taskId), '任务 ID');
270
359
  const task = await getTask(api, plan.task_id);
271
- assert(!task.source?.body || registrationMatches(task, plan.registration),
360
+ assert(!(task.source?.body || task.source?.assets?.length) || registrationMatches(task, plan.registration),
272
361
  'RECOVERY_MISMATCH', '指定任务已有其他正文,请核对任务 ID 并通过 tasks edit 合并修改。');
273
362
  plan.expected_version = task.version;
274
363
  }