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
19 changes: 18 additions & 1 deletion .github/workflows/go.yml
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ jobs:
run: go build ./...

verify-linux-amd64:
name: Verify Linux amd64
name: Verify (ubuntu-latest)
runs-on: ubuntu-latest
steps:
- name: Checkout
Expand Down Expand Up @@ -107,3 +107,20 @@ jobs:
run: go mod tidy -diff
- name: Check whitespace
run: git diff --check

live-parser-regression:
name: Live Parser Regression
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: ${{ env.GO_VERSION }}
cache: true
- name: Parse authorized surveys on all providers
env:
SURVEYCORE_LIVE_PARSE: "1"
run: go test -count=1 -timeout=3m ./CI/live_tests -run '^TestLiveProviderParse$' -v
36 changes: 36 additions & 0 deletions CI/live_tests/provider_submission_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,42 @@ var (
sensitiveFieldValue = regexp.MustCompile(`(?i)(access[_-]?token|refresh[_-]?token|api[_-]?key|password|passwd)(\s*[=:]\s*|\s+)[^\s,;}&]+`)
)

func TestLiveProviderParse(t *testing.T) {
if os.Getenv("SURVEYCORE_LIVE_PARSE") != "1" {
t.Skip("set SURVEYCORE_LIVE_PARSE=1 to enable live parser checks")
}
for _, survey := range []struct {
provider string
url string
parseError string
}{
{model.ProviderWJX, "https://v.wjx.cn/vm/ei3sVrE.aspx", ""},
// This published fixture includes rating types the parser explicitly rejects.
{model.ProviderQQ, "https://wj.qq.com/s2/26070328/fa89/", "腾讯问卷当前版本暂不支持量表、矩阵量表题"},
{model.ProviderCredamo, "https://www.credamo.com/s/A73QR3ano", ""},
} {
t.Run(survey.provider, func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
defer cancel()
if survey.parseError != "" {
_, err := surveycore.New().Parse(ctx, survey.url)
if !errors.Is(err, surveycore.ErrParseFailed) || !strings.Contains(err.Error(), survey.parseError) {
t.Fatalf("expected the fixture's unsupported-type error, got %v", err)
}
t.Log("live questions fetched; unsupported rating types correctly reported")
return
}
config := defaultConfigWithRetry(t, ctx, surveycore.New(), survey.url)
if config.SurveySource.Provider != survey.provider || config.SurveyDefinition.Provider != survey.provider {
t.Fatalf("provider mismatch: source=%q definition=%q", config.SurveySource.Provider, config.SurveyDefinition.Provider)
}
if strings.TrimSpace(config.Title) == "" || len(config.SurveyDefinition.Questions) == 0 || len(config.AnswerPlan.Strategies) == 0 {
t.Fatal("live parser returned an empty title, questions, or answer strategies")
}
})
}
}

func TestLiveProviderSubmission(t *testing.T) {
if os.Getenv("SURVEYCORE_LIVE") != "1" {
t.Skip("set SURVEYCORE_LIVE=1 to enable authorized live submissions")
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,10 @@ api_key = ""
| `POST` | `/api/v1/proxy/bonus` | 领取代理额度。 |
| `POST` | `/api/v1/proxy/redeem` | 兑换代理卡。 |

## Python 配置导入

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

## 错误响应

API 错误统一返回稳定错误码、用户消息和调试详情:
Expand Down
67 changes: 66 additions & 1 deletion api/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ paths:
'200': { description: Tasks ordered by creation time, content: { application/json: { schema: { $ref: '#/components/schemas/TaskList' } } } }
post:
operationId: createTask
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/RunRequest' } } } }
requestBody: { required: true, content: { application/json: { schema: { oneOf: [{ $ref: '#/components/schemas/RunRequest' }, { $ref: '#/components/schemas/PythonConfig' }] } } } }
responses:
'202': { description: Task accepted, content: { application/json: { schema: { $ref: '#/components/schemas/Task' } } } }
'400': { $ref: '#/components/responses/InvalidJSON' }
Expand Down Expand Up @@ -167,6 +167,71 @@ components:
answers: { $ref: '#/components/schemas/AnswerPlan' }
reverseFill: { $ref: '#/components/schemas/ReverseFillPlan' }
psychometrics: { $ref: '#/components/schemas/PsychometricPolicy' }
userAgent: { $ref: '#/components/schemas/UserAgentSettings' }
UserAgentSettings:
type: object
additionalProperties: false
properties:
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.
type: object
additionalProperties: false
required: [schema_version, url]
properties:
schema_version: { type: integer, const: 1 }
url: { type: string, minLength: 1 }
provider: { type: [string, 'null'], enum: [wjx, qq, credamo, null] }
target: { type: integer, minimum: 1, default: 1 }
concurrency: { type: integer, minimum: 1, default: 1 }
questions: { type: array, items: { $ref: '#/components/schemas/PythonQuestionConfig' } }
answer_duration: { $ref: '#/components/schemas/IntegerPair', default: [60, 120] }
submit_interval: { $ref: '#/components/schemas/IntegerPair', default: [0, 0] }
answer_datetime_window: { $ref: '#/components/schemas/StringPair' }
random_user_agent: { type: boolean, default: false }
user_agent_ratios: { type: object, additionalProperties: { type: integer } }
reliability_mode: { type: boolean, default: true }
target_alpha: { type: number, default: 0.85 }
answer_rules: { type: array, items: { $ref: '#/components/schemas/ConsistencyRule' } }
PythonWeights:
anyOf:
- { type: 'null' }
- { type: number }
- type: array
items:
anyOf:
- { type: [number, 'null'] }
- { type: array, items: { type: [number, 'null'] } }
PythonQuestionConfig:
type: object
additionalProperties: false
required: [question_type]
properties:
question_type: { type: string, minLength: 1 }
probabilities: { $ref: '#/components/schemas/PythonWeights' }
custom_weights: { $ref: '#/components/schemas/PythonWeights' }
texts: { type: [array, 'null'], items: { type: string } }
rows: { type: integer }
option_count: { type: integer }
distribution_mode: { type: string }
question_num: { type: [integer, 'null'] }
question_title: { type: [string, 'null'] }
survey_provider: { type: string }
provider_question_id: { type: [string, 'null'] }
provider_page_id: { type: [string, 'null'] }
option_fill_texts: { type: [array, 'null'], items: { type: [string, 'null'] } }
fillable_option_indices: { type: [array, 'null'], items: { type: integer } }
attached_option_selects: { type: array, items: { $ref: '#/components/schemas/AttachedOptionSelect' } }
is_location: { type: boolean }
location_parts: { type: array, items: { type: string } }
multi_text_blank_modes: { type: array, items: { type: string } }
multi_text_blank_ai_flags: { type: array, items: { type: boolean } }
multi_text_blank_int_ranges: { type: array, items: { type: array, items: { type: integer } } }
text_random_mode: { type: string }
text_random_int_range: { type: array, items: { type: integer } }
dimension: { type: [string, 'null'] }
psycho_bias: { type: string }
SurveySource:
type: object
additionalProperties: false
Expand Down
4 changes: 3 additions & 1 deletion docs/sdk/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,9 @@ POST /api/v1/tasks
Content-Type: application/json
```

请求体必须使用 `RunRequest` 的嵌套结构。建议先获取默认配置,再修改目标数和并发数:
请求体接受 `RunRequest` 的嵌套结构,或 `surveycore-py` 导出的 `schema_version: 1` 配置。Python 配置会转换为 `RunRequest` 后创建任务,任务响应中的 `config` 使用 Go 的嵌套结构。字段映射见 [Python 配置导入](./schemas#python-配置导入)。

使用嵌套结构时,建议先获取默认配置,再修改目标数和并发数:

```json
{
Expand Down
2 changes: 1 addition & 1 deletion docs/sdk/errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ outline: deep

## 注意事项

- JSON 接口会拒绝未知字段。V1 任务请求必须使用 `source`、`definition`、`execution`、`answers`、`reverseFill` 和 `psychometrics` 结构。
- JSON 接口会拒绝未知字段。V1 任务请求接受嵌套的 `RunRequest` 或 `schema_version: 1` 的 Python 配置;两种格式不能混用。Python 配置版本、字段类型、范围或权重无效时返回 `400 invalid_json`。
- 二维码上传字段名是 `file`。
- `202` 不代表提交成功。继续查询任务状态和日志。
- 任务运行期配置错误会写入任务的 `error` 字段,并把状态改成 `failed`。
21 changes: 21 additions & 0 deletions docs/sdk/examples.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,27 @@ task = request_json("POST", "/tasks", json=config)
print(task["id"])
```

### 使用 surveycore-py 配置创建任务

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

config = RunConfig(
url="https://www.wjx.cn/vm/example.aspx",
target=3,
concurrency=2,
questions=[QuestionConfig(question_type="single", probabilities=[0, 1])],
)
response = httpx.post(
"http://127.0.0.1:19178/api/v1/tasks",
json=serialize_config(config),
timeout=30,
)
response.raise_for_status()
print(response.json()["id"])
```

### 上传二维码

JavaScript:
Expand Down
20 changes: 20 additions & 0 deletions docs/sdk/schemas.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ outline: deep
| `answers.questions` | `QuestionStrategy[]` | 每道题的答案策略。 |
| `reverseFill` | `ReverseFillPlan` | Excel 反填配置。 |
| `psychometrics` | `PsychometricPolicy` | 信效度配置。 |
| `userAgent` | `object` | 可选随机 User-Agent 配置,包含 `enabled` 和 `ratios`(`wechat`、`mobile`、`pc` 权重)。 |

## QuestionStrategy

Expand Down Expand Up @@ -139,3 +140,22 @@ outline: deep
## 本地配置文件

`configs/example.json` 使用 `schemaVersion: 2` 的 `ConfigDocument`。它包含 `survey`、`execution`、`network`、`answers`、`reverseFill` 和 `psychometrics` 六个区段。该文件格式与 REST `RunRequest` 不是同一个 JSON 外壳。

## Python 配置导入

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

| Python 字段 | Go 字段 |
|---|---|
| `url` / `provider` | `source.url` / `source.provider`;省略或为 null 的 provider 由运行层识别。 |
| `target` / `concurrency` | `execution.target` / `execution.threads`。 |
| `answer_duration` / `submit_interval` / `answer_datetime_window` | 对应 `execution` 中的三个时间范围;保留原始范围,包括 `[0, 0]`。 |
| `questions` / `answer_rules` | `answers.questions` / `answers.rules`;省略或为 null 的题号按配置顺序从 1 编号。 |
| `random_user_agent` / `user_agent_ratios` | `userAgent.enabled` / `userAgent.ratios`,本地 v2 文件保存在 `network`。 |
| `reliability_mode` / `target_alpha` | `psychometrics.enabled` / `psychometrics.targetAlpha`。 |

题目中的 `probabilities` 和 `custom_weights` 接受 Python 的数字、数组、矩阵数组或 null,转换为 Go 的 `WeightTable`。选择题的 null 或 `-1` 表示随机;多选权重保留 0–100 数值。支持其余 Go 已有的题目策略字段,并拒绝未知字段。

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

这一兼容入口用于配置导入。任务仍使用 Go 的重试、间隔和结果返回规则;Python 的逐次提交结果接口另行对齐。
3 changes: 2 additions & 1 deletion internal/service/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,9 +69,10 @@ func (m *Manager) Create(ctx context.Context, cfg *model.RunRequest) (*Task, err
m.tasks[id] = task
m.runtimes[id] = &taskRuntime{cancel: cancel}
m.wg.Add(1)
snapshot := cloneTask(task)
m.mu.Unlock()
go func() { defer m.wg.Done(); m.run(runCtx, id) }()
return cloneTask(task), nil
return snapshot, nil
}

func (m *Manager) run(ctx context.Context, id string) {
Expand Down
31 changes: 29 additions & 2 deletions pkg/restapi/server.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package restapi

import (
"bytes"
"encoding/json"
"errors"
"fmt"
Expand All @@ -13,6 +14,7 @@ import (
surveyio "github.com/SurveyController/SurveyCore/internal/io"
"github.com/SurveyController/SurveyCore/internal/service"
"github.com/SurveyController/SurveyCore/pkg/surveycore"
surveyconfig "github.com/SurveyController/SurveyCore/pkg/surveycore/config"
"github.com/SurveyController/SurveyCore/pkg/surveycore/model"
"github.com/SurveyController/SurveyCore/pkg/surveycore/proxy"
)
Expand Down Expand Up @@ -126,8 +128,13 @@ func (s *Server) config(w http.ResponseWriter, r *http.Request) {
write(w, 200, v)
}
func (s *Server) create(w http.ResponseWriter, r *http.Request) {
var cfg model.RunRequest
if err := decode(r, &cfg); err != nil {
var body json.RawMessage
if err := decode(r, &body); err != nil {
fail(w, 400, "invalid_json", err)
return
}
cfg, err := decodeTaskConfig(body)
if err != nil {
fail(w, 400, "invalid_json", err)
return
}
Expand All @@ -138,6 +145,26 @@ func (s *Server) create(w http.ResponseWriter, r *http.Request) {
}
write(w, 202, t)
}

func decodeTaskConfig(body []byte) (model.RunRequest, error) {
var payload map[string]any
payloadDecoder := json.NewDecoder(bytes.NewReader(body))
payloadDecoder.UseNumber()
if err := payloadDecoder.Decode(&payload); err != nil {
return model.RunRequest{}, err
}
if payload == nil {
return model.RunRequest{}, errors.New("task config must be a JSON object")
}
if _, ok := payload["schema_version"]; ok {
return surveyconfig.DeserializeRunRequest(payload)
}
var config model.RunRequest
decoder := json.NewDecoder(bytes.NewReader(body))
decoder.DisallowUnknownFields()
err := decoder.Decode(&config)
return config, err
}
func (s *Server) list(w http.ResponseWriter, _ *http.Request) {
write(w, 200, map[string]any{"tasks": s.manager.List()})
}
Expand Down
50 changes: 50 additions & 0 deletions pkg/restapi/server_test.go
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
package restapi

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

"github.com/SurveyController/SurveyCore/internal/service"
)

func TestV1VersionUsesConfiguredBuildVersion(t *testing.T) {
Expand Down Expand Up @@ -36,6 +39,53 @@ func TestV1VersionUsesConfiguredBuildVersion(t *testing.T) {
}
}

func TestV1CreateTaskAcceptsPythonConfig(t *testing.T) {
s, err := New(Config{DBPath: t.TempDir() + "/tasks.db"})
if err != nil {
t.Fatal(err)
}
defer s.Close()
// A cancelled context exercises task creation and persistence without upstream requests.
ctx, cancel := context.WithCancel(context.Background())
cancel()
body := `{"schema_version":1,"url":"https://www.wjx.cn/vm/demo.aspx","target":3,"concurrency":2,"random_user_agent":true,"user_agent_ratios":{"pc":100},"questions":[{"question_type":"single","probabilities":[0,1]}]}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/tasks", strings.NewReader(body)).WithContext(ctx)
rec := httptest.NewRecorder()
s.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusAccepted {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
var task service.Task
if err := json.Unmarshal(rec.Body.Bytes(), &task); err != nil {
t.Fatal(err)
}
if task.Config.ExecutionPlan.Threads != 2 || task.Config.Target != 3 || task.Config.UserAgent == nil || !task.Config.UserAgent.Enabled || task.Config.AnswerPlan.Strategies[0].Probabilities.Options[1] != 1 {
t.Fatalf("task config = %#v", task.Config)
}
}

func TestV1CreateTaskRejectsInvalidPythonConfig(t *testing.T) {
s, err := New(Config{DBPath: t.TempDir() + "/tasks.db"})
if err != nil {
t.Fatal(err)
}
defer s.Close()
for _, body := range []string{
`{"schema_version":2,"url":"x"}`,
`{"schema_version":1,"url":"x","concurrency":0}`,
`{"schema_version":1,"url":"x","execution":{}}`,
`{"schema_version":1,"url":"x","questions":[{"question_type":"single","unknown":1}]}`,
`{"schema_version":1,"url":"x"} {}`,
`null`,
} {
rec := httptest.NewRecorder()
s.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/api/v1/tasks", strings.NewReader(body)))
if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), `"code":"invalid_json"`) {
t.Fatalf("body=%s status=%d error=%s", body, rec.Code, rec.Body.String())
}
}
}

func TestV1HealthAndNotFound(t *testing.T) {
dir := t.TempDir()
s, err := New(Config{DBPath: dir + "\\test.db"})
Expand Down
Loading
Loading