From 422dca361c64c8d7261340f8c98ca726c3c8c241 Mon Sep 17 00:00:00 2001 From: LING71671 <1739677116@qq.com> Date: Wed, 11 Nov 2026 22:34:12 +0800 Subject: [PATCH] feat: align single submission requests and results with Python --- README.md | 5 +- api/openapi.yaml | 31 ++++ docs/sdk/api.md | 35 +++++ docs/sdk/errors.md | 4 +- docs/sdk/examples.md | 32 ++++ docs/sdk/index.md | 5 +- docs/sdk/schemas.md | 27 +++- internal/service/manager.go | 4 + pkg/restapi/server.go | 23 +++ pkg/restapi/submissions_test.go | 114 ++++++++++++++ pkg/surveycore/doc.go | 2 +- pkg/surveycore/errors.go | 5 + pkg/surveycore/example_test.go | 12 ++ pkg/surveycore/model/submission_types.go | 11 ++ pkg/surveycore/runtime/submit.go | 91 +++++++++++ pkg/surveycore/runtime/submit_test.go | 143 ++++++++++++++++++ pkg/surveycore/submit.go | 38 +++++ pkg/surveycore/submit_test.go | 119 +++++++++++++++ .../testdata/submission-results.json | 16 ++ tests/consumer/sdk_test.go | 6 + 20 files changed, 718 insertions(+), 5 deletions(-) create mode 100644 pkg/restapi/submissions_test.go create mode 100644 pkg/surveycore/model/submission_types.go create mode 100644 pkg/surveycore/runtime/submit.go create mode 100644 pkg/surveycore/runtime/submit_test.go create mode 100644 pkg/surveycore/submit.go create mode 100644 pkg/surveycore/submit_test.go create mode 100644 pkg/surveycore/testdata/submission-results.json diff --git a/README.md b/README.md index 8090eb4..2b7cf4e 100644 --- a/README.md +++ b/README.md @@ -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` | 从二维码图片中解析问卷链接。 | @@ -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 { diff --git a/api/openapi.yaml b/api/openapi.yaml index a0eb0b3..acceb7c 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -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 @@ -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 diff --git a/docs/sdk/api.md b/docs/sdk/api.md index cab556f..2114821 100644 --- a/docs/sdk/api.md +++ b/docs/sdk/api.md @@ -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 diff --git a/docs/sdk/errors.md b/docs/sdk/errors.md index 05dfc00..bc25874 100644 --- a/docs/sdk/errors.md +++ b/docs/sdk/errors.md @@ -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` | 问卷或官方代理上游请求失败。 | @@ -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` | @@ -49,3 +50,4 @@ outline: deep - 二维码上传字段名是 `file`。 - `202` 不代表提交成功。继续查询任务状态和日志。 - 任务运行期配置错误会写入任务的 `error` 字段,并把状态改成 `failed`。 +- 单次提交的 `200` 响应需检查 `success`。Go SDK 的单次提交在执行失败时同时返回结果和带分类的错误,可用 `ClassifySubmissionError()` 区分 `config`、`unsupported`、`parse`、`run` 和 `canceled`,或用 `errors.Is` 检查现有公开错误和 context 取消原因。 diff --git a/docs/sdk/examples.md b/docs/sdk/examples.md index 31e25c0..8d7a060 100644 --- a/docs/sdk/examples.md +++ b/docs/sdk/examples.md @@ -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 @@ -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: diff --git a/docs/sdk/index.md b/docs/sdk/index.md index 9c3e3e0..63e8de1 100644 --- a/docs/sdk/index.md +++ b/docs/sdk/index.md @@ -14,7 +14,9 @@ go get github.com/SurveyController/SurveyCore@ `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` 获取稳定分类。 @@ -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}` | 查询任务。 | diff --git a/docs/sdk/schemas.md b/docs/sdk/schemas.md index c2d2aca..6cc9bb9 100644 --- a/docs/sdk/schemas.md +++ b/docs/sdk/schemas.md @@ -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 | 字段 | 类型 | 说明 | @@ -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 字段 | |---|---| diff --git a/internal/service/manager.go b/internal/service/manager.go index af8875d..3cf5d6a 100644 --- a/internal/service/manager.go +++ b/internal/service/manager.go @@ -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) } diff --git a/pkg/restapi/server.go b/pkg/restapi/server.go index 05e2851..8647ea7 100644 --- a/pkg/restapi/server.go +++ b/pkg/restapi/server.go @@ -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) @@ -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)) diff --git a/pkg/restapi/submissions_test.go b/pkg/restapi/submissions_test.go new file mode 100644 index 0000000..4e26478 --- /dev/null +++ b/pkg/restapi/submissions_test.go @@ -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 := `Submission
Choice
A
B
` + 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") + } +} diff --git a/pkg/surveycore/doc.go b/pkg/surveycore/doc.go index 52e5d8c..f5092a3 100644 --- a/pkg/surveycore/doc.go +++ b/pkg/surveycore/doc.go @@ -1,6 +1,6 @@ // Package surveycore provides the stable, high-level SurveyCore SDK. // -// Parse, DefaultConfig, Run, and RunWithEvents honor context cancellation. +// Parse, DefaultConfig, Submit, Run, and RunWithEvents honor context cancellation. // Cancellation stops scheduling new submissions and waits for active provider // calls to return. Advanced controls live in the experimental runtime, proxy, // and config subpackages. diff --git a/pkg/surveycore/errors.go b/pkg/surveycore/errors.go index 396a8a2..d28ad13 100644 --- a/pkg/surveycore/errors.go +++ b/pkg/surveycore/errors.go @@ -25,3 +25,8 @@ const ( func ClassifyRunError(err error) ErrorKind { return ErrorKind(surveyRuntime.ClassifyRunError(err)) } + +// ClassifySubmissionError returns the same stable failure categories used by Run. +func ClassifySubmissionError(err error) ErrorKind { + return ClassifyRunError(err) +} diff --git a/pkg/surveycore/example_test.go b/pkg/surveycore/example_test.go index 5e67dd2..ddea3d7 100644 --- a/pkg/surveycore/example_test.go +++ b/pkg/surveycore/example_test.go @@ -31,3 +31,15 @@ func ExampleClassifyRunError() { // true // config } + +func ExampleClient_Submit() { + client := surveycore.New() + result, err := client.Submit(context.Background(), &surveycore.SubmissionRequest{ + Config: &model.RunRequest{SurveySource: model.SurveySource{URL: "https://www.wjx.cn/vm/example.aspx"}}, + }) + if err != nil { + fmt.Println(surveycore.ClassifySubmissionError(err)) + return + } + fmt.Println(result.Success, result.ElapsedSeconds) +} diff --git a/pkg/surveycore/model/submission_types.go b/pkg/surveycore/model/submission_types.go new file mode 100644 index 0000000..b1ae78d --- /dev/null +++ b/pkg/surveycore/model/submission_types.go @@ -0,0 +1,11 @@ +package model + +// SubmissionResult describes one submission attempt, matching surveycore-py. +// Standalone Submit calls use attempt 0. Error is null on success. +type SubmissionResult struct { + Attempt int `json:"attempt"` + Provider string `json:"provider"` + Success bool `json:"success"` + ElapsedSeconds float64 `json:"elapsed_seconds"` + Error *string `json:"error"` +} diff --git a/pkg/surveycore/runtime/submit.go b/pkg/surveycore/runtime/submit.go new file mode 100644 index 0000000..436f449 --- /dev/null +++ b/pkg/surveycore/runtime/submit.go @@ -0,0 +1,91 @@ +package runtime + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/SurveyController/SurveyCore/pkg/surveycore/model" +) + +// Submit executes exactly one submission without task-level retries or intervals. +// A valid request returns a result even when execution fails; err retains the +// typed cause for errors.Is and ClassifyRunError. +func (c *Client) Submit(ctx context.Context, config *RunRequest) (*model.SubmissionResult, error) { + started := time.Now() + provider, err := submissionProvider(config) + if err != nil { + return nil, err + } + cfg := cloneRunRequest(config) + cfg.SurveySource.Provider = provider + cfg.ExecutionPlan.Target = 1 + cfg.ExecutionPlan.Threads = 1 + cfg.ExecutionPlan.SubmitInterval = [2]int{} + cfg.ExecutionPlan.FailStop = false + cfg.ExecutionPlan.PauseOnAliyunCaptcha = false + cfg.ReverseFillPlan.Threads = 1 + options := ExecutionOptionsFromConfig(&cfg) + options.MaxRetries = 0 + var runResult *RunResult + if err = ctx.Err(); err == nil { + if len(cfg.AnswerPlan.Strategies) == 0 { + if len(cfg.SurveyDefinition.Questions) == 0 { + var definition *SurveyDefinition + definition, err = c.Parse(ctx, cfg.SurveySource.URL) + if err == nil { + cfg.SurveyDefinition = *definition + } + } + if err == nil { + ensureQuestionStrategies(&cfg) + } + } + if err == nil { + runResult, err = c.RunWithExecutionOptions(ctx, &cfg, nil, options) + } + } + if err != nil && ctx.Err() != nil { + err = ctx.Err() + } + result := &model.SubmissionResult{ + Provider: provider, + Success: err == nil && runResult != nil && runResult.Success == 1 && runResult.Fail == 0 && !runResult.Stopped, + ElapsedSeconds: time.Since(started).Seconds(), + } + if !result.Success { + if err == nil { + err = fmt.Errorf("%w: 平台未确认提交成功", ErrRunFailed) + } + message := err.Error() + result.Error = &message + } + return result, err +} + +// Submit uses a default client for one submission. +func Submit(ctx context.Context, config *RunRequest) (*model.SubmissionResult, error) { + return New().Submit(ctx, config) +} + +func submissionProvider(config *RunRequest) (string, error) { + if config == nil || strings.TrimSpace(config.SurveySource.URL) == "" { + return "", fmt.Errorf("%w: 必须提供问卷配置和链接", ErrInvalidConfig) + } + detected := detectProvider(config.SurveySource.URL) + provider := config.SurveySource.Provider + if provider == "" { + provider = detected + } + if provider != model.ProviderWJX && provider != model.ProviderQQ && provider != model.ProviderCredamo { + return "", fmt.Errorf("%w: 不支持的问卷平台:%s", ErrUnsupportedOperation, provider) + } + if detected != "" && provider != detected { + return "", fmt.Errorf("%w: 问卷链接平台与配置不一致:%s != %s", ErrUnsupportedOperation, provider, detected) + } + if config.SurveyDefinition.Provider != "" && config.SurveyDefinition.Provider != provider { + return "", fmt.Errorf("%w: 问卷快照平台与配置不一致", ErrInvalidConfig) + } + return provider, nil +} diff --git a/pkg/surveycore/runtime/submit_test.go b/pkg/surveycore/runtime/submit_test.go new file mode 100644 index 0000000..e2b19a7 --- /dev/null +++ b/pkg/surveycore/runtime/submit_test.go @@ -0,0 +1,143 @@ +package runtime + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/SurveyController/SurveyCore/pkg/surveycore/model" +) + +type submitRoundTripFunc func(*http.Request) (*http.Response, error) + +func (fn submitRoundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) { + return fn(request) +} + +func TestSubmitExecutesOneAttemptOnEachProvider(t *testing.T) { + for _, provider := range []string{ProviderWJX, ProviderQQ, ProviderCredamo} { + t.Run(provider, func(t *testing.T) { + var server *httptest.Server + var client *http.Client + var surveyURL, submitPath string + switch provider { + case ProviderWJX: + server = newWJXCoreTestServer(t, true) + client = rewriteWJXHTTPClient(server.URL) + surveyURL, submitPath = "https://www.wjx.cn/vm/demo.aspx", "/joinnew/processjq.ashx" + case ProviderQQ: + server = newTencentCoreTestServer(t) + client = rewriteTencentHTTPClient(server.URL) + surveyURL, submitPath = "https://wj.qq.com/s2/123/hashvalue/", "/api/v2/respondent/surveys/123/answers" + case ProviderCredamo: + server = newCredamoTestServer(t, true) + client = server.Client() + surveyURL, submitPath = server.URL+"/s/demo_", "/v1/survey/answer/noauth/save" + } + defer server.Close() + var submissions atomic.Int32 + next := client.Transport + client.Transport = submitRoundTripFunc(func(request *http.Request) (*http.Response, error) { + if request.URL.Path == submitPath { + submissions.Add(1) + if !strings.Contains(request.UserAgent(), "Windows NT") { + t.Errorf("configured user-agent was lost: %q", request.UserAgent()) + } + } + return next.RoundTrip(request) + }) + config := &RunRequest{ + SurveySource: SurveySource{URL: surveyURL}, + ExecutionPlan: ExecutionPlan{Target: 8, Threads: 4, SubmitInterval: [2]int{600, 600}}, + ReverseFillPlan: ReverseFillPlan{Threads: 7}, + PsychometricPolicy: PsychometricPolicy{Enabled: true, TargetAlpha: 0.85}, + UserAgent: &model.UserAgentSettings{Enabled: true, Ratios: map[string]int{"pc": 100}}, + } + original, _ := json.Marshal(config) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + result, err := New(WithHTTPClient(client)).Submit(ctx, config) + if err != nil { + t.Fatal(err) + } + if !result.Success || result.Provider != provider || result.Attempt != 0 || result.Error != nil || result.ElapsedSeconds < 0 || submissions.Load() != 1 { + t.Fatalf("result=%#v submissions=%d", result, submissions.Load()) + } + current, _ := json.Marshal(config) + if string(original) != string(current) { + t.Fatal("Submit modified the caller's configuration") + } + }) + } +} + +func TestSubmitFailureDoesNotRetryAndRetainsTypedError(t *testing.T) { + server := newWJXCoreTestServer(t, false) + defer server.Close() + client := rewriteWJXHTTPClient(server.URL) + var submissions atomic.Int32 + next := client.Transport + client.Transport = submitRoundTripFunc(func(request *http.Request) (*http.Response, error) { + if request.Method == http.MethodPost { + submissions.Add(1) + } + return next.RoundTrip(request) + }) + result, err := New(WithHTTPClient(client)).Submit(context.Background(), &RunRequest{ + SurveySource: SurveySource{URL: "https://www.wjx.cn/vm/demo.aspx"}, + ExecutionPlan: ExecutionPlan{Target: 9, Threads: 9}, + PsychometricPolicy: PsychometricPolicy{Enabled: true, TargetAlpha: 0.85}, + }) + if !errors.Is(err, ErrRunFailed) || ClassifyRunError(err) != ErrorKindRun { + t.Fatalf("error lost its category: %v", err) + } + if result == nil || result.Success || result.Error == nil || *result.Error != err.Error() || submissions.Load() != 1 { + t.Fatalf("result=%#v submissions=%d", result, submissions.Load()) + } +} + +func TestSubmitPreservesCancellation(t *testing.T) { + server := newWJXCoreTestServer(t, true) + defer server.Close() + for _, preCancelled := range []bool{true, false} { + ctx, cancel := context.WithCancel(context.Background()) + client := rewriteWJXHTTPClient(server.URL) + var submissions atomic.Int32 + next := client.Transport + client.Transport = submitRoundTripFunc(func(request *http.Request) (*http.Response, error) { + if preCancelled { + t.Error("a pre-cancelled Submit performed a request") + } + if request.Method == http.MethodPost { + submissions.Add(1) + cancel() + return nil, context.Canceled + } + return next.RoundTrip(request) + }) + if preCancelled { + cancel() + } + result, err := New(WithHTTPClient(client)).Submit(ctx, &RunRequest{SurveySource: SurveySource{URL: "https://www.wjx.cn/vm/demo.aspx"}}) + cancel() + if !errors.Is(err, context.Canceled) || ClassifyRunError(err) != ErrorKindCanceled || result == nil || result.Success || result.Error == nil || submissions.Load() > 1 { + t.Fatalf("preCancelled=%t result=%#v error=%v submissions=%d", preCancelled, result, err, submissions.Load()) + } + } +} + +func TestSubmitParseFailureIncludesAttemptMetadata(t *testing.T) { + client := &http.Client{Transport: submitRoundTripFunc(func(*http.Request) (*http.Response, error) { + return nil, errors.New("parse transport failed") + })} + result, err := New(WithHTTPClient(client)).Submit(context.Background(), &RunRequest{SurveySource: SurveySource{URL: "https://www.wjx.cn/vm/demo.aspx"}}) + if !errors.Is(err, ErrParseFailed) || result == nil || result.Success || result.Error == nil || result.Provider != ProviderWJX { + t.Fatalf("result=%#v error=%v", result, err) + } +} diff --git a/pkg/surveycore/submit.go b/pkg/surveycore/submit.go new file mode 100644 index 0000000..ac5d8b4 --- /dev/null +++ b/pkg/surveycore/submit.go @@ -0,0 +1,38 @@ +package surveycore + +import ( + "context" + "fmt" + + "github.com/SurveyController/SurveyCore/pkg/surveycore/model" +) + +// SubmissionRequest contains a run configuration and an optional parsed survey. +// Config target and concurrency do not change Submit's one-attempt behavior. +// This differs from model.SubmissionRequest, the experimental provider input. +type SubmissionRequest struct { + Config *model.RunRequest `json:"config"` + Survey *model.SurveyDefinition `json:"survey,omitempty"` +} + +// SubmissionResult reports one attempt's outcome, duration, and optional error. +type SubmissionResult = model.SubmissionResult + +// Submit executes one submission. Missing configurations or incompatible providers +// return a nil result and a typed error before any network request. Failures return +// both an unsuccessful result and the typed cause, including context cancellation. +func (client *Client) Submit(ctx context.Context, request *SubmissionRequest) (*SubmissionResult, error) { + if request == nil || request.Config == nil { + return nil, fmt.Errorf("%w: 配置不能为空", ErrInvalidConfig) + } + cfg := *request.Config + if request.Survey != nil { + cfg.SurveyDefinition = model.CloneSurveyDefinition(*request.Survey) + } + return client.inner.Submit(ctx, &cfg) +} + +// Submit uses a default Client to execute one submission. +func Submit(ctx context.Context, request *SubmissionRequest) (*SubmissionResult, error) { + return New().Submit(ctx, request) +} diff --git a/pkg/surveycore/submit_test.go b/pkg/surveycore/submit_test.go new file mode 100644 index 0000000..66b7ffe --- /dev/null +++ b/pkg/surveycore/submit_test.go @@ -0,0 +1,119 @@ +package surveycore_test + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "os" + "reflect" + "strings" + "testing" + "time" + + "github.com/SurveyController/SurveyCore/pkg/surveycore" + "github.com/SurveyController/SurveyCore/pkg/surveycore/model" +) + +type submissionTransport func(*http.Request) (*http.Response, error) + +func (fn submissionTransport) RoundTrip(request *http.Request) (*http.Response, error) { + return fn(request) +} + +func TestSubmitRejectsInvalidRequestsBeforeNetwork(t *testing.T) { + client := surveycore.New(surveycore.WithHTTPClient(&http.Client{Transport: submissionTransport(func(*http.Request) (*http.Response, error) { + t.Fatal("invalid request reached the transport") + return nil, nil + })})) + for _, test := range []struct { + request *surveycore.SubmissionRequest + kind surveycore.ErrorKind + }{ + {nil, surveycore.ErrorKindConfig}, + {&surveycore.SubmissionRequest{}, surveycore.ErrorKindConfig}, + {&surveycore.SubmissionRequest{Config: &model.RunRequest{}}, surveycore.ErrorKindConfig}, + {&surveycore.SubmissionRequest{Config: &model.RunRequest{SurveySource: model.SurveySource{URL: "https://example.invalid/survey"}}}, surveycore.ErrorKindUnsupported}, + {&surveycore.SubmissionRequest{Config: &model.RunRequest{SurveySource: model.SurveySource{URL: "https://www.wjx.cn/vm/demo.aspx", Provider: "qq"}}}, surveycore.ErrorKindUnsupported}, + {&surveycore.SubmissionRequest{Config: &model.RunRequest{SurveySource: model.SurveySource{URL: "https://www.wjx.cn/vm/demo.aspx"}}, Survey: &model.SurveyDefinition{Provider: "qq"}}, surveycore.ErrorKindConfig}, + } { + result, err := client.Submit(context.Background(), test.request) + if result != nil || err == nil || surveycore.ClassifySubmissionError(err) != test.kind { + t.Fatalf("request=%#v result=%#v error=%v", test.request, result, err) + } + } + if result, err := surveycore.Submit(context.Background(), nil); result != nil || !errors.Is(err, surveycore.ErrInvalidConfig) { + t.Fatalf("package-level Submit result=%#v error=%v", result, err) + } +} + +func TestSubmitUsesSurveySnapshotAndKeepsCallerState(t *testing.T) { + posts := 0 + transport := submissionTransport(func(request *http.Request) (*http.Response, error) { + body := `Live title
Choice
A
B
` + if request.Method == http.MethodPost { + posts++ + if err := request.ParseForm(); err != nil { + t.Fatal(err) + } + if request.Form.Get("submitdata") != "1$2" { + t.Fatalf("survey snapshot was not applied: %q", request.Form.Get("submitdata")) + } + body = "10" + } + return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(body)), Request: request}, nil + }) + forced := 1 + request := &surveycore.SubmissionRequest{ + Config: &model.RunRequest{SurveySource: model.SurveySource{URL: "https://www.wjx.cn/vm/demo.aspx"}, ExecutionPlan: model.ExecutionPlan{Target: 20, Threads: 8}}, + Survey: &model.SurveyDefinition{Provider: "wjx", Title: "Snapshot", Questions: []model.QuestionMeta{{Num: 1, TypeCode: "3", Options: 2, ForcedOptionIdx: &forced}}}, + } + original, _ := json.Marshal(request) + client := surveycore.New(surveycore.WithHTTPClient(&http.Client{Transport: transport})) + result, err := client.Submit(context.Background(), request) + if err != nil || !result.Success || posts != 1 { + t.Fatalf("result=%#v error=%v posts=%d", result, err, posts) + } + current, _ := json.Marshal(request) + if string(original) != string(current) { + t.Fatal("caller config or snapshot was modified") + } +} + +func TestSubmissionResultMatchesPythonFixture(t *testing.T) { + // Produced with dataclasses.asdict on surveycore-py 411ef6d SubmissionResult. + data, err := os.ReadFile("testdata/submission-results.json") + if err != nil { + t.Fatal(err) + } + var results []surveycore.SubmissionResult + if err := json.Unmarshal(data, &results); err != nil { + t.Fatal(err) + } + encoded, err := json.Marshal(results) + if err != nil { + t.Fatal(err) + } + var expected, actual any + if err := json.Unmarshal(data, &expected); err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(encoded, &actual); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(expected, actual) { + t.Fatalf("Go result JSON differs from Python: %s", encoded) + } +} + +func TestSubmitPreservesExpiredDeadline(t *testing.T) { + ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(-time.Second)) + defer cancel() + result, err := surveycore.Submit(ctx, &surveycore.SubmissionRequest{Config: &model.RunRequest{ + SurveySource: model.SurveySource{URL: "https://www.wjx.cn/vm/demo.aspx"}, + }}) + if !errors.Is(err, context.DeadlineExceeded) || surveycore.ClassifySubmissionError(err) != surveycore.ErrorKindCanceled || result == nil || result.Success || result.Error == nil { + t.Fatalf("result=%#v error=%v", result, err) + } +} diff --git a/pkg/surveycore/testdata/submission-results.json b/pkg/surveycore/testdata/submission-results.json new file mode 100644 index 0000000..8d1b3a1 --- /dev/null +++ b/pkg/surveycore/testdata/submission-results.json @@ -0,0 +1,16 @@ +[ + { + "attempt": 0, + "provider": "wjx", + "success": true, + "elapsed_seconds": 0.125, + "error": null + }, + { + "attempt": 0, + "provider": "qq", + "success": false, + "elapsed_seconds": 0.25, + "error": "平台未确认提交成功" + } +] diff --git a/tests/consumer/sdk_test.go b/tests/consumer/sdk_test.go index 866bbe0..4131145 100644 --- a/tests/consumer/sdk_test.go +++ b/tests/consumer/sdk_test.go @@ -2,6 +2,7 @@ package consumer_test import ( "context" + "errors" "testing" "github.com/SurveyController/SurveyCore/pkg/surveycore" @@ -46,6 +47,11 @@ func TestPublicPackagesCompileForExternalConsumer(t *testing.T) { if imported.ExecutionPlan.Threads != 2 { t.Fatalf("Python concurrency = %d", imported.ExecutionPlan.Threads) } + var submission *surveycore.SubmissionResult + submission, err = client.Submit(context.Background(), &surveycore.SubmissionRequest{}) + if submission != nil || !errors.Is(err, surveycore.ErrInvalidConfig) || surveycore.ClassifySubmissionError(err) != surveycore.ErrorKindConfig { + t.Fatalf("single submission contract: result=%#v error=%v", submission, err) + } pool := proxy.NewPool(proxy.PoolOptions{}) if pool == nil {