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
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ api_key = ""
| `GET` | `/api/v1/tasks/{id}/logs` | 分页读取任务日志。 |
| `POST` | `/api/v1/surveys/parse` | 解析问卷,不提交答案。 |
| `POST` | `/api/v1/configs` | 生成 V1 默认运行配置。 |
| `POST` | `/api/v1/submissions` | 同步提交一次,返回本次结果和耗时。 |
| `POST` | `/api/v1/tasks` | 创建异步提交任务。 |
| `POST` | `/api/v1/tasks/{id}/stop` | 停止任务。 |
| `POST` | `/api/v1/qrcode/decode` | 从二维码图片中解析问卷链接。 |
Expand All @@ -123,9 +124,11 @@ api_key = ""

`POST /api/v1/tasks` 也接受 `surveycore-py` 的 `serialize_config()` 导出对象(`schema_version: 1`)。Go 本地配置可用 `config.Load()` 读取同一格式。支持范围和字段映射见 [数据结构](docs/sdk/schemas.md#python-配置导入)。

单次提交可调用 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` 返回结果。

## 错误响应

API 错误统一返回稳定错误码、用户消息和调试详情:
请求级错误统一返回稳定错误码、用户消息和调试详情:

```json
{
Expand Down
31 changes: 31 additions & 0 deletions api/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,14 @@ paths:
'200': { description: Default run configuration, content: { application/json: { schema: { $ref: '#/components/schemas/RunRequest' } } } }
'400': { $ref: '#/components/responses/InvalidJSON' }
'502': { $ref: '#/components/responses/UpstreamError' }
/api/v1/submissions:
post:
operationId: submitOnce
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/SubmissionRequest' } } } }
responses:
'200': { description: One attempt completed; inspect success and error for the platform outcome, content: { application/json: { schema: { $ref: '#/components/schemas/SubmissionResult' } } } }
'400': { $ref: '#/components/responses/InvalidJSON' }
'422': { $ref: '#/components/responses/ValidationError' }
/api/v1/tasks:
get:
operationId: listTasks
Expand Down Expand Up @@ -156,6 +164,29 @@ components:
required: [url]
properties: { url: { type: string, format: uri } }
Provider: { type: string, enum: [wjx, qq, credamo] }
SubmissionRequest:
type: object
additionalProperties: false
required: [config]
properties:
config:
oneOf:
- { $ref: '#/components/schemas/RunRequest' }
- { $ref: '#/components/schemas/PythonConfig' }
survey:
anyOf:
- { $ref: '#/components/schemas/SurveyDefinition' }
- { type: 'null' }
SubmissionResult:
type: object
additionalProperties: false
required: [attempt, provider, success, elapsed_seconds, error]
properties:
attempt: { type: integer, minimum: 0, description: Standalone Submit uses zero }
provider: { $ref: '#/components/schemas/Provider' }
success: { type: boolean }
elapsed_seconds: { type: number, minimum: 0 }
error: { type: [string, 'null'] }
RunRequest:
type: object
additionalProperties: false
Expand Down
35 changes: 35 additions & 0 deletions docs/sdk/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,41 @@ Content-Type: application/json

完整字段见 [数据结构](./schemas#runrequest)。

## 单次提交

```http
POST /api/v1/submissions
Content-Type: application/json
```

`config` 接受 Go 的 `RunRequest` 或 Python 的 `schema_version: 1` 配置。可选的 `survey` 使用 `/api/v1/surveys/parse` 返回的结构,提供时覆盖配置中的问卷快照,平台必须一致。

```json
{
"config": {
"schema_version": 1,
"url": "https://www.wjx.cn/vm/example.aspx",
"questions": [
{"question_type": "single", "probabilities": [0, 1]}
]
}
}
```

接口同步执行一次并返回 `SubmissionResult`,不创建持久化任务。目标数和并发数固定为 1,不等待任务提交间隔,不自动重试提交。省略题目策略时自动生成默认策略。耗时包含本次解析、准备和平台请求。

```json
{
"attempt": 0,
"provider": "wjx",
"success": true,
"elapsed_seconds": 0.125,
"error": null
}
```

开始执行后的平台、解析或准备失败返回 `200`,结果为 `success: false`,`error` 包含失败详情。JSON 格式或字段错误返回 `400 invalid_json`;缺少链接、不支持的平台或平台不一致返回 `422 validation_error`。客户端需检查 `success` 判断提交结果。

## 创建任务

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

Expand All @@ -36,6 +36,7 @@ outline: deep
|---|---|
| `POST /api/v1/surveys/parse` | `400`、`502` |
| `POST /api/v1/configs` | `400`、`502` |
| `POST /api/v1/submissions` | `400`、`422`;执行阶段失败通过 `200` 响应中的 `success` 和 `error` 表达。 |
| `POST /api/v1/tasks` | `400`、`422` |
| `GET /api/v1/tasks/{id}` | `404` |
| `POST /api/v1/tasks/{id}/stop` | `404`、`500` |
Expand All @@ -49,3 +50,4 @@ outline: deep
- 二维码上传字段名是 `file`。
- `202` 不代表提交成功。继续查询任务状态和日志。
- 任务运行期配置错误会写入任务的 `error` 字段,并把状态改成 `failed`。
- 单次提交的 `200` 响应需检查 `success`。Go SDK 的单次提交在执行失败时同时返回结果和带分类的错误,可用 `ClassifySubmissionError()` 区分 `config`、`unsupported`、`parse`、`run` 和 `canceled`,或用 `errors.Is` 检查现有公开错误和 context 取消原因。
32 changes: 32 additions & 0 deletions docs/sdk/examples.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,21 @@ func main() {

仓库中的 `pkg/surveycore/example_test.go` 会编译这些公开调用。

### Go 单次提交

```go
client := surveycore.New()
result, err := client.Submit(ctx, &surveycore.SubmissionRequest{Config: cfg})
if err != nil {
log.Printf("提交错误类别=%s: %v", surveycore.ClassifySubmissionError(err), err)
}
if result != nil {
log.Printf("成功=%t 耗时=%.3f秒", result.Success, result.ElapsedSeconds)
}
```

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

## REST API

### JavaScript
Expand Down Expand Up @@ -136,6 +151,23 @@ response.raise_for_status()
print(response.json()["id"])
```

### 使用 surveycore-py 配置单次提交

```python
import httpx
from surveycore import RunConfig, serialize_config

config = RunConfig(url="https://www.wjx.cn/vm/example.aspx")
response = httpx.post(
"http://127.0.0.1:19178/api/v1/submissions",
json={"config": serialize_config(config)},
timeout=120,
)
response.raise_for_status()
result = response.json()
print(result["success"], result["elapsed_seconds"], result["error"])
```

### 上传二维码

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

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

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

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

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

Expand Down Expand Up @@ -67,6 +69,7 @@ Accept: application/json
| `GET` | `/api/v1/version` | 读取版本。 |
| `POST` | `/api/v1/surveys/parse` | 解析问卷。 |
| `POST` | `/api/v1/configs` | 生成默认运行配置。 |
| `POST` | `/api/v1/submissions` | 同步提交一次并返回结果。 |
| `POST` | `/api/v1/tasks` | 创建任务。 |
| `GET` | `/api/v1/tasks` | 查询任务列表。 |
| `GET` | `/api/v1/tasks/{id}` | 查询任务。 |
Expand Down
27 changes: 26 additions & 1 deletion docs/sdk/schemas.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,31 @@ outline: deep

`QuestionMeta` 包含题号、题型、选项、分页、必填状态、显示逻辑、跳题逻辑、媒体和平台原始 ID。字段名以 `POST /api/v1/surveys/parse` 返回值为准。

## SubmissionRequest

单次提交使用 `{ "config": ..., "survey": ... }`。Go SDK 类型为 `surveycore.SubmissionRequest`。

| 字段 | 类型 | 说明 |
|---|---|---|
| `config` | `RunRequest` 或 Python v1 配置 | 必填。Go SDK 中为 `*model.RunRequest`,Python 配置可先用 `config.DeserializeRunRequest()` 导入。 |
| `survey` | `SurveyDefinition` 或 null | 可选问卷快照,覆盖 `config.definition`。省略时使用配置快照或平台解析结果。 |

`surveycore.SubmissionRequest` 是高层 SDK 输入。`model.SubmissionRequest` 仍是实验性的底层平台输入,使用 `Source`、`Definition` 和 `Context`。

## SubmissionResult

字段与 Python `SubmissionResult` 对齐。Go SDK 使用 `surveycore.SubmissionResult`,共享模型位于 `model.SubmissionResult`。

| 字段 | 类型 | 说明 |
|---|---|---|
| `attempt` | `integer` | 独立 `Submit` 调用固定为 0。 |
| `provider` | `string` | `wjx`、`qq` 或 `credamo`。 |
| `success` | `boolean` | 平台是否确认本次提交成功。 |
| `elapsed_seconds` | `number` | 本次调用耗时,单位秒,包含解析与准备。 |
| `error` | `string` 或 null | 成功时显式返回 null,失败时返回详情。 |

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

## Task

| 字段 | 类型 | 说明 |
Expand All @@ -143,7 +168,7 @@ outline: deep

## Python 配置导入

`POST /api/v1/tasks`、`config.DeserializeRunRequest()` 和 `config.Load()` 接受 `surveycore-py` 的 `serialize_config()` 输出(`schema_version: 1`)。`config.DeserializePythonConfig()` 可先转换为本地 `ConfigDocument`。保存时仍写入 Go 的 v2 格式。
`POST /api/v1/tasks`、`POST /api/v1/submissions` 的 `config` 字段、`config.DeserializeRunRequest()` 和 `config.Load()` 接受 `surveycore-py` 的 `serialize_config()` 输出(`schema_version: 1`)。`config.DeserializePythonConfig()` 可先转换为本地 `ConfigDocument`。保存时仍写入 Go 的 v2 格式。

| Python 字段 | Go 字段 |
|---|---|
Expand Down
4 changes: 4 additions & 0 deletions internal/service/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,10 @@ func (m *Manager) Logs(id string, after int64, limit int) (*TaskLogPage, error)
func (m *Manager) Parse(ctx context.Context, url string) (*model.SurveyDefinition, error) {
return m.client.Parse(ctx, url)
}

func (m *Manager) Submit(ctx context.Context, request *surveycore.SubmissionRequest) (*surveycore.SubmissionResult, error) {
return m.client.Submit(ctx, request)
}
func (m *Manager) DefaultConfig(ctx context.Context, url string) (*model.RunRequest, error) {
return m.client.DefaultConfig(ctx, url)
}
Expand Down
23 changes: 23 additions & 0 deletions pkg/restapi/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ func (s *Server) routes() http.Handler {
mux.HandleFunc("GET /api/v1/version", s.version)
mux.HandleFunc("POST /api/v1/surveys/parse", s.parse)
mux.HandleFunc("POST /api/v1/configs", s.config)
mux.HandleFunc("POST /api/v1/submissions", s.submit)
mux.HandleFunc("POST /api/v1/tasks", s.create)
mux.HandleFunc("GET /api/v1/tasks", s.list)
mux.HandleFunc("GET /api/v1/tasks/{id}", s.get)
Expand Down Expand Up @@ -146,6 +147,28 @@ func (s *Server) create(w http.ResponseWriter, r *http.Request) {
write(w, 202, t)
}

func (s *Server) submit(w http.ResponseWriter, r *http.Request) {
var request struct {
Config json.RawMessage `json:"config"`
Survey *model.SurveyDefinition `json:"survey,omitempty"`
}
if err := decode(r, &request); err != nil {
fail(w, 400, "invalid_json", err)
return
}
config, err := decodeTaskConfig(request.Config)
if err != nil {
fail(w, 400, "invalid_json", err)
return
}
result, err := s.manager.Submit(r.Context(), &surveycore.SubmissionRequest{Config: &config, Survey: request.Survey})
if result == nil {
fail(w, 422, "validation_error", err)
return
}
write(w, 200, result)
}

func decodeTaskConfig(body []byte) (model.RunRequest, error) {
var payload map[string]any
payloadDecoder := json.NewDecoder(bytes.NewReader(body))
Expand Down
114 changes: 114 additions & 0 deletions pkg/restapi/submissions_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
package restapi

import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"

"github.com/SurveyController/SurveyCore/internal/service"
"github.com/SurveyController/SurveyCore/pkg/surveycore"
"github.com/SurveyController/SurveyCore/pkg/surveycore/model"
)

type submissionTestTransport func(*http.Request) (*http.Response, error)

func (fn submissionTestTransport) RoundTrip(request *http.Request) (*http.Response, error) {
return fn(request)
}

func submissionTestServer(t *testing.T, success bool) (*Server, *atomic.Int32) {
t.Helper()
s, err := New(Config{DBPath: t.TempDir() + "/submissions.db"})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = s.Close() })
posts := &atomic.Int32{}
transport := submissionTestTransport(func(request *http.Request) (*http.Response, error) {
body := `<html><head><title>Submission</title></head><body><div id="divQuestion"><fieldset><div topic="1" id="div1" type="3"><div class="topichtml">Choice</div><div class="ui-controlgroup"><div><span class="label">A</span></div><div><span class="label">B</span></div></div></div></fieldset></div></body></html>`
if request.Method == http.MethodPost {
posts.Add(1)
if success {
body = "10"
} else {
body = "1〒1〒失败"
}
}
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(body)), Request: request}, nil
})
s.manager = service.NewManager(s.store, surveycore.New(surveycore.WithHTTPClient(&http.Client{Transport: transport})))
return s, posts
}

func TestV1SingleSubmissionAcceptsBothConfigFormats(t *testing.T) {
native, err := json.Marshal(map[string]any{"config": model.RunRequest{
SurveySource: model.SurveySource{URL: "https://www.wjx.cn/vm/demo.aspx"},
ExecutionPlan: model.ExecutionPlan{Target: 9, Threads: 4, SubmitInterval: [2]int{600, 600}},
}})
if err != nil {
t.Fatal(err)
}
for _, body := range []string{
string(native),
`{"config":{"schema_version":1,"url":"https://www.wjx.cn/vm/demo.aspx","target":9,"concurrency":4,"submit_interval":[600,600],"questions":[{"question_type":"single","probabilities":[0,1]}]}}`,
} {
s, posts := submissionTestServer(t, true)
rec := httptest.NewRecorder()
s.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/api/v1/submissions", strings.NewReader(body)))
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
var result surveycore.SubmissionResult
if err := json.Unmarshal(rec.Body.Bytes(), &result); err != nil {
t.Fatal(err)
}
if !result.Success || result.Attempt != 0 || result.Provider != "wjx" || result.Error != nil || posts.Load() != 1 || len(s.manager.List()) != 0 || !strings.Contains(rec.Body.String(), `"error":null`) {
t.Fatalf("result=%#v posts=%d tasks=%d", result, posts.Load(), len(s.manager.List()))
}
}
}

func TestV1SingleSubmissionReturnsPlatformFailureInResult(t *testing.T) {
s, posts := submissionTestServer(t, false)
body := `{"config":{"schema_version":1,"url":"https://www.wjx.cn/vm/demo.aspx"}}`
rec := httptest.NewRecorder()
s.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/api/v1/submissions", strings.NewReader(body)))
var result surveycore.SubmissionResult
if err := json.Unmarshal(rec.Body.Bytes(), &result); err != nil {
t.Fatal(err)
}
if rec.Code != http.StatusOK || result.Success || result.Error == nil || posts.Load() != 1 || result.ElapsedSeconds < 0 {
t.Fatalf("status=%d result=%#v posts=%d", rec.Code, result, posts.Load())
}
}

func TestV1SingleSubmissionRejectsInvalidRequestsWithoutNetwork(t *testing.T) {
s, posts := submissionTestServer(t, true)
for _, test := range []struct {
body string
status int
}{
{`{}`, 400}, {`null`, 400}, {`{"config":null}`, 400},
{`{"config":{"unknown":1}}`, 400},
{`{"config":{"schema_version":1,"url":"x"},"unknown":1}`, 400},
{`{"config":{"schema_version":1,"url":"x"},"survey":{"unknown":1}}`, 400},
{`{"config":{"schema_version":1,"url":"x"}} {}`, 400},
{`{"config":{}}`, 422},
{`{"config":{"schema_version":1,"url":"https://example.invalid/survey"}}`, 422},
{`{"config":{"schema_version":1,"url":"https://www.wjx.cn/vm/demo.aspx","provider":"qq"}}`, 422},
{`{"config":{"schema_version":1,"url":"https://www.wjx.cn/vm/demo.aspx"},"survey":{"provider":"qq"}}`, 422},
} {
rec := httptest.NewRecorder()
s.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/api/v1/submissions", strings.NewReader(test.body)))
if rec.Code != test.status {
t.Fatalf("request=%s status=%d body=%s", test.body, rec.Code, rec.Body.String())
}
}
if posts.Load() != 0 || len(s.manager.List()) != 0 {
t.Fatal("invalid requests created submissions or tasks")
}
}
Loading
Loading