Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ api_key = ""
| `GET` | `/api/v1/tasks` | 查询任务列表。按创建时间倒序返回。 |
| `GET` | `/api/v1/tasks/{id}` | 查询单个任务详情。 |
| `GET` | `/api/v1/tasks/{id}/logs` | 分页读取任务日志。 |
| `GET` | `/api/v1/tasks/{id}/results` | 分页读取逐次提交结果。 |
| `POST` | `/api/v1/surveys/parse` | 解析问卷,不提交答案。 |
| `POST` | `/api/v1/configs` | 生成 V1 默认运行配置。 |
| `POST` | `/api/v1/submissions` | 同步提交一次,返回本次结果和耗时。 |
Expand All @@ -126,6 +127,8 @@ api_key = ""

单次提交可调用 Go SDK 的 `client.Submit(ctx, &surveycore.SubmissionRequest{Config: cfg})`,或把配置放在 `config` 字段中发送到 `POST /api/v1/submissions`。该接口同步返回 `attempt`、`provider`、`success`、`elapsed_seconds` 和 `error`,支持 Go 与 Python v1 配置。单次提交固定执行一次,平台失败时通过 `success: false` 和 `error` 返回结果。

批量运行可用 `for result, err := range client.RunResults(ctx, cfg)` 按完成顺序读取结果,`attempt` 从 1 开始。目标数表示尝试次数;每次失败保留结果并继续后续尝试,不自动重试。除第一次外,每次尝试开始前都会等待配置中的随机提交间隔。原生 Go 配置显式启用 `failStop` 时仍会在连续失败达到阈值后停止。HTTP 任务用 `GET /api/v1/tasks/{id}/results?after=0&limit=200` 读取同样的逐次结果。

## 错误响应

请求级错误统一返回稳定错误码、用户消息和调试详情:
Expand Down
32 changes: 27 additions & 5 deletions api/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,17 @@ paths:
'400': { $ref: '#/components/responses/InvalidQuery' }
'404': { $ref: '#/components/responses/NotFound' }
'500': { $ref: '#/components/responses/InternalError' }
/api/v1/tasks/{id}/results:
parameters: [{ $ref: '#/components/parameters/TaskID' }]
get:
operationId: listTaskResults
description: Completed attempts in completion order. Use next_cursor as the next after value; has_more only describes stored results, not task completion. Results survive service restarts.
parameters: [{ $ref: '#/components/parameters/After' }, { $ref: '#/components/parameters/Limit' }]
responses:
'200': { description: Cursor-paginated per-attempt results, content: { application/json: { schema: { $ref: '#/components/schemas/TaskResultPage' } } } }
'400': { $ref: '#/components/responses/InvalidQuery' }
'404': { $ref: '#/components/responses/NotFound' }
'500': { $ref: '#/components/responses/InternalError' }
/api/v1/qrcode/decode:
post:
operationId: decodeQRCode
Expand Down Expand Up @@ -182,10 +193,10 @@ components:
additionalProperties: false
required: [attempt, provider, success, elapsed_seconds, error]
properties:
attempt: { type: integer, minimum: 0, description: Standalone Submit uses zero }
attempt: { type: integer, minimum: 0, description: Standalone Submit uses zero; batch attempts start at one and are returned in completion order }
provider: { $ref: '#/components/schemas/Provider' }
success: { type: boolean }
elapsed_seconds: { type: number, minimum: 0 }
elapsed_seconds: { type: number, minimum: 0, description: Standalone Submit includes preparation; batch results exclude shared preparation and submit intervals }
error: { type: [string, 'null'] }
RunRequest:
type: object
Expand All @@ -206,7 +217,7 @@ components:
enabled: { type: boolean }
ratios: { type: object, additionalProperties: { type: integer } }
PythonConfig:
description: Imports surveycore-py schema_version 1. Task responses use RunRequest; scheduling and results retain Go semantics.
description: Imports surveycore-py schema_version 1. Task responses use RunRequest. Target counts attempts, failures continue without automatic retries, and results use completion order.
type: object
additionalProperties: false
required: [schema_version, url]
Expand Down Expand Up @@ -275,9 +286,11 @@ components:
additionalProperties: false
required: [target, threads, submitInterval, answerDuration, failStop, pauseOnAliyunCaptcha]
properties:
target: { type: integer, minimum: 1 }
target: { type: integer, minimum: 1, description: Number of attempts; each failed attempt counts once without automatic retries }
threads: { type: integer, minimum: 1 }
submitInterval: { $ref: '#/components/schemas/IntegerPair' }
submitInterval:
$ref: '#/components/schemas/IntegerPair'
description: Random seconds waited inside a concurrency slot before each attempt after the first, including new workers
answerDuration: { $ref: '#/components/schemas/IntegerPair' }
answerDatetimeWindow: { $ref: '#/components/schemas/StringPair' }
failStop: { type: boolean }
Expand Down Expand Up @@ -494,6 +507,7 @@ components:
current: { type: integer }
total: { type: integer }
time: { type: string, format: date-time }
result: { $ref: '#/components/schemas/SubmissionResult' }
Task:
type: object
additionalProperties: false
Expand Down Expand Up @@ -538,6 +552,14 @@ components:
logs: { type: array, items: { $ref: '#/components/schemas/TaskLog' } }
next_cursor: { type: integer }
has_more: { type: boolean }
TaskResultPage:
type: object
additionalProperties: false
required: [results, next_cursor, has_more]
properties:
results: { type: array, items: { $ref: '#/components/schemas/SubmissionResult' } }
next_cursor: { type: integer, minimum: 0, description: Opaque log ID cursor; an empty page preserves the input cursor }
has_more: { type: boolean, description: Whether more completed results are currently stored }
ProxyExtractRequest:
type: object
additionalProperties: false
Expand Down
29 changes: 28 additions & 1 deletion docs/sdk/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,9 @@ Content-Type: application/json
}
```

`202` 只代表任务已经创建。
`202` 代表任务已经创建。任务独立于创建请求运行,响应结束或客户端断开不会取消已创建的任务。使用停止接口取消任务,服务关闭时也会停止运行中的任务。

目标数表示尝试次数,失败保留结果并继续后续尝试,不自动重试。第 2 次起,每次尝试开始前在并发槽内等待配置中的随机提交间隔。显式启用 `execution.failStop` 时,连续失败达到阈值可提前结束。存在单次失败时最终状态为 `failed`,已完成次数见汇总和逐次结果。

## 查询任务

Expand All @@ -230,6 +232,8 @@ POST /api/v1/tasks/{id}/stop

返回停止后的 `Task`。已结束的任务保持原状态。

停止操作会取消运行中的平台请求。工作线程退出后更新 `finished_at` 和汇总;取消中的请求不计为失败,也不产生逐次结果。

## 读取任务日志

```http
Expand All @@ -256,6 +260,29 @@ GET /api/v1/tasks/{id}/logs?after=0&limit=200
}
```

## 读取逐次提交结果

```http
GET /api/v1/tasks/{id}/results?after=0&limit=200
```

`after` 为非负游标,初次传 0,后续使用上一页的 `next_cursor`。游标使用底层日志 ID,不能用尝试编号推算。`limit` 默认 200,0 使用默认值,超过 1000 按 1000 处理;负数或非法参数返回 `400 invalid_query`,任务不存在返回 `404 not_found`。

```json
{
"results": [
{"attempt": 2, "provider": "wjx", "success": true, "elapsed_seconds": 0.8, "error": null},
{"attempt": 1, "provider": "wjx", "success": false, "elapsed_seconds": 1.2, "error": "提交失败"}
],
"next_cursor": 16,
"has_more": false
}
```

结果按完成记录顺序返回,`attempt` 从 1 开始,因此并发运行时编号可能乱序。每次失败单独保留 `error`。批量耗时不含公共准备和提交间隔。结果保存在 SQLite 任务日志中,服务重启后仍可读取;升级前未记录的逐次结果不会补生成。

空页返回 `results: []` 并保留请求游标。`has_more` 只表示当前还有已存储结果;任务运行期间为 false 时,可沿用游标继续轮询,并通过任务状态判断是否结束。任务日志接口中的完成事件也包含 `event.result`。

## 解析二维码

```http
Expand Down
3 changes: 2 additions & 1 deletion docs/sdk/errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ outline: deep
| `400` | `invalid_query` | 查询参数不是非负整数。 |
| `404` | `not_found` | 任务不存在。 |
| `422` | `validation_error` | 单次提交前置参数无效、任务保存失败或二维码内容不是有效问卷链接。 |
| `500` | `internal_error` | 停止任务或读取日志时发生内部错误。 |
| `500` | `internal_error` | 停止任务、读取日志或逐次结果时发生内部错误。 |
| `502` | `upstream_error` | 问卷或官方代理上游请求失败。 |

## 接口错误速查
Expand All @@ -41,6 +41,7 @@ outline: deep
| `GET /api/v1/tasks/{id}` | `404` |
| `POST /api/v1/tasks/{id}/stop` | `404`、`500` |
| `GET /api/v1/tasks/{id}/logs` | `400`、`404`、`500` |
| `GET /api/v1/tasks/{id}/results` | `400`、`404`、`500` |
| `POST /api/v1/qrcode/decode` | `400`、`422` |
| `/api/v1/proxy/*` | `400`、`502` |

Expand Down
26 changes: 26 additions & 0 deletions docs/sdk/examples.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,26 @@ if result != nil {

该调用固定执行一次。已有问卷快照可通过 `Survey` 传入。原始 `cfg` 中的目标数和并发数保持原值。

### Go 逐次提交结果

```go
cfg.ExecutionPlan.Target = 3
cfg.ExecutionPlan.Threads = 2
cfg.ExecutionPlan.FailStop = false
for result, err := range client.RunResults(ctx, cfg) {
if err != nil {
log.Printf("运行结束: %v", err)
break
}
log.Printf("尝试=%d 成功=%t 耗时=%.3f秒", result.Attempt, result.Success, result.ElapsedSeconds)
if result.Error != nil {
log.Print(*result.Error)
}
}
```

结果按完成顺序返回。单次失败后继续尝试;准备或上下文错误终止迭代。消费方提前 `break` 时,SDK 会取消未完成请求并等待工作线程退出。

## REST API

### JavaScript
Expand Down Expand Up @@ -97,8 +117,14 @@ async function createTaskFromURL(url) {
async function getTask(taskID) {
return requestJSON(`/tasks/${taskID}`);
}

async function readTaskResults(taskID, after = 0) {
return requestJSON(`/tasks/${taskID}/results?after=${after}&limit=200`);
}
```

将返回的 `next_cursor` 用作下次 `after`。`has_more` 仅表示当前还有已存储的结果;任务仍在运行时,即使它为 false,也应保留游标继续轮询,并结合任务状态判断是否结束。

### Python

```python
Expand Down
8 changes: 7 additions & 1 deletion docs/sdk/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,14 @@ go get github.com/SurveyController/SurveyCore@<commit>

`pkg/surveycore` 只负责解析、默认配置和运行。公共结构在 `pkg/surveycore/model`。低层执行、代理和本地配置能力分别在实验性的 `pkg/surveycore/runtime`、`pkg/surveycore/proxy`、`pkg/surveycore/config`。

`Parse`、`DefaultConfig`、`Submit`、`Run` 和 `RunWithEvents` 接受 `context.Context`。取消后不再安排新提交,并等待已开始的平台请求返回。事件回调可能来自工作 goroutine,回调实现必须自行保证并发安全。
`Parse`、`DefaultConfig`、`Submit`、`Run`、`RunWithEvents` 和 `RunResults` 接受 `context.Context`。取消后不再安排新提交,并等待已开始的平台请求返回。事件回调可能来自工作 goroutine,回调实现必须自行保证并发安全。

`Submit` 使用 `surveycore.SubmissionRequest`,其中 `Config` 是 `*model.RunRequest`,`Survey` 是可选的 `*model.SurveyDefinition`。它固定提交一次并返回 `SubmissionResult`;失败时同时返回可用 `errors.Is` 或 `ClassifySubmissionError` 判断的错误。参数中的目标数、并发数和提交间隔不会触发额外提交。

`RunResults` 返回 `iter.Seq2[SubmissionResult, error]`,按完成顺序产生结果,尝试编号从 1 开始。单次失败保存在结果的 `error` 中,迭代继续;准备失败或调用方取消通过迭代错误返回。提前 `break` 会取消未完成的请求并等待工作线程退出。`Run` 和 `RunWithEvents` 返回汇总,存在失败时在运行结束后返回首个错误;`RunWithEvents` 的 `event.Result` 包含逐次结果。

批量运行以 `target` 为尝试次数,受 `threads` 并发限制,不自动重试。第 2 次起,每次尝试开始前均在并发槽内等待 `submitInterval` 范围内的随机秒数。显式 `failStop`、停止操作或取消可以使运行提前结束。Python v1 配置导入时 `failStop` 为 false。

错误可用 `errors.Is` 判断 `ErrInvalidConfig`、`ErrParseFailed`、`ErrPrepareConfigFailed`、`ErrRunFailed` 和 `ErrUnsupportedOperation`,也可用 `ClassifyRunError` 获取稳定分类。

## 文档结构
Expand Down Expand Up @@ -60,6 +64,7 @@ Accept: application/json
4. 调 `POST /api/v1/tasks` 创建任务。
5. 调 `GET /api/v1/tasks/{id}` 查询状态。
6. 调 `GET /api/v1/tasks/{id}/logs` 读取日志。
7. 调 `GET /api/v1/tasks/{id}/results` 分页读取逐次提交结果。

## 接口清单

Expand All @@ -75,6 +80,7 @@ Accept: application/json
| `GET` | `/api/v1/tasks/{id}` | 查询任务。 |
| `POST` | `/api/v1/tasks/{id}/stop` | 停止任务。 |
| `GET` | `/api/v1/tasks/{id}/logs` | 读取任务日志。 |
| `GET` | `/api/v1/tasks/{id}/results` | 读取逐次提交结果。 |
| `POST` | `/api/v1/qrcode/decode` | 解析二维码。 |
| `POST` | `/api/v1/proxy/session` | 激活官方代理会话。 |
| `GET` | `/api/v1/proxy/usage` | 查询官方代理余量。 |
Expand Down
24 changes: 19 additions & 5 deletions docs/sdk/schemas.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,9 @@ outline: deep
| `source.url` | `string` | 问卷链接。创建任务时不能为空。 |
| `source.provider` | `string` | `wjx`、`qq` 或 `credamo`。 |
| `definition` | `SurveyDefinition` | 解析得到的问卷快照。 |
| `execution.target` | `integer` | 目标提交份数。 |
| `execution.target` | `integer` | 目标尝试次数,失败占一次,不自动重试。 |
| `execution.threads` | `integer` | 并发数。 |
| `execution.submitInterval` | `[integer, integer]` | 每个工作线程两次提交之间的随机等待秒数。 |
| `execution.submitInterval` | `[integer, integer]` | 第 2 次起,每次尝试开始前在并发槽内随机等待的秒数范围,包括新工作线程的首次提交。 |
| `execution.answerDuration` | `[integer, integer]` | 作答时长范围,单位秒。 |
| `execution.answerDatetimeWindow` | `[string, string]` | 可选作答时间窗口。 |
| `execution.failStop` | `boolean` | 连续失败达到阈值时停止。 |
Expand Down Expand Up @@ -140,14 +140,28 @@ outline: deep

| 字段 | 类型 | 说明 |
|---|---|---|
| `attempt` | `integer` | 独立 `Submit` 调用固定为 0。 |
| `attempt` | `integer` | 独立 `Submit` 调用固定为 0;批量运行从 1 开始编号,结果按完成顺序返回。 |
| `provider` | `string` | `wjx`、`qq` 或 `credamo`。 |
| `success` | `boolean` | 平台是否确认本次提交成功。 |
| `elapsed_seconds` | `number` | 本次调用耗时,单位秒,包含解析与准备。 |
| `elapsed_seconds` | `number` | 单位秒。独立 `Submit` 包含解析与准备;批量结果仅计本次执行耗时,不含公共准备及提交间隔。 |
| `error` | `string` 或 null | 成功时显式返回 null,失败时返回详情。 |

Go 的 `Submit` 返回 `(result, error)`:缺少配置、链接或平台不兼容时 `result` 为 nil;执行失败时同时返回失败结果和带分类的错误。取消保留 `context.Canceled` 或 `context.DeadlineExceeded`。原始配置及问卷快照不会被修改。

`RunResults` 在单次失败时返回失败结果和 nil 迭代错误,继续后续尝试;准备失败或调用方取消返回迭代错误。取消中的请求及尚未开始的尝试不计入成功数、失败数或逐次结果。

## Event 与 TaskResultPage

`Event` 保留 `worker`、`message`、`success`、`fail`、`current`、`total` 和 `time`。每次尝试完成时,事件额外包含 `result: SubmissionResult`。普通进度事件省略 `result`;获取逐次结果时应筛选该字段。

`GET /api/v1/tasks/{id}/results` 返回:

| 字段 | 类型 | 说明 |
|---|---|---|
| `results` | `SubmissionResult[]` | 按完成记录顺序返回,空页为 `[]`。 |
| `next_cursor` | `integer` | 下次请求的 `after`,空页保留原游标。游标使用底层日志 ID,可能不连续。 |
| `has_more` | `boolean` | 当前是否还有未返回的已存储结果。任务结束状态由 `Task.status` 判断。 |

## Task

| 字段 | 类型 | 说明 |
Expand Down Expand Up @@ -183,4 +197,4 @@ Go 的 `Submit` 返回 `(result, error)`:缺少配置、链接或平台不兼

默认目标数和并发数都是 1,作答时长为 `[60, 120]`,间隔为 `[0, 0]`,信效度开关为 true、目标 alpha 为 0.85,随机 User-Agent 默认关闭。Python 序列化格式不包含 `answer_generator` 或题目 `ai_enabled`,导入会拒绝这些字段。

这一兼容入口用于配置导入。任务仍使用 Go 的重试、间隔和结果返回规则;Python 的逐次提交结果接口另行对齐。
批量运行已对齐 Python 的尝试次数、并发限制、失败后继续、无自动重试、尝试前间隔和按完成顺序返回结果的规则。Go SDK 使用 `RunResults` 迭代,HTTP 使用任务结果分页。
17 changes: 15 additions & 2 deletions internal/service/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@ func (m *Manager) Create(ctx context.Context, cfg *model.RunRequest) (*Task, err
if cfg == nil {
return nil, errors.New("配置不能为空")
}
if ctx.Err() != nil {
return nil, ctx.Err()
}
id, err := taskID()
if err != nil {
return nil, err
Expand All @@ -64,7 +67,8 @@ func (m *Manager) Create(ctx context.Context, cfg *model.RunRequest) (*Task, err
return nil, err
}
m.appendLog(id, TaskLog{Timestamp: now, Level: "INFO", Message: "任务已创建"})
runCtx, cancel := context.WithCancel(ctx)
// Async tasks outlive the HTTP request that created them. Stop owns cancellation.
runCtx, cancel := context.WithCancel(context.WithoutCancel(ctx))
m.mu.Lock()
m.tasks[id] = task
m.runtimes[id] = &taskRuntime{cancel: cancel}
Expand Down Expand Up @@ -145,7 +149,9 @@ func (m *Manager) Stop(id string) (*Task, error) {
t.StopMessage = "用户请求停止"
rt.cancel()
snap := cloneTask(t)
go m.store.SaveTask(snap)
if err := m.store.SaveTask(snap); err != nil {
return nil, err
}
return snap, nil
}
func (m *Manager) Logs(id string, after int64, limit int) (*TaskLogPage, error) {
Expand All @@ -154,6 +160,13 @@ func (m *Manager) Logs(id string, after int64, limit int) (*TaskLogPage, error)
}
return m.store.LoadLogs(id, after, limit)
}

func (m *Manager) Results(id string, after int64, limit int) (*TaskResultPage, error) {
if _, ok := m.Get(id); !ok {
return nil, ErrTaskNotFound
}
return m.store.LoadResults(id, after, limit)
}
func (m *Manager) Parse(ctx context.Context, url string) (*model.SurveyDefinition, error) {
return m.client.Parse(ctx, url)
}
Expand Down
Loading
Loading