diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c6e320a..31696e3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,6 +36,15 @@ jobs: - name: Install dependencies run: npm ci + # src/generated is committed so review sees what a spec change does to + # the CLI; this is what keeps it from drifting from spec/openapi.yaml. + - name: Generated code matches spec/openapi.yaml + shell: bash + run: | + npm run generate + git diff --exit-code --stat -- src/generated + test -z "$(git status --porcelain -- src/generated)" + - name: Typecheck run: npm run typecheck diff --git a/.github/workflows/spec-sync.yml b/.github/workflows/spec-sync.yml new file mode 100644 index 0000000..ca859e1 --- /dev/null +++ b/.github/workflows/spec-sync.yml @@ -0,0 +1,127 @@ +name: Sync openapi.yaml + +# Once a day the published openapi.yaml is fetched. When it differs from +# spec/openapi.yaml, src/generated is regenerated from it and a pull request +# opened, so what API v1 now takes reaches the CLI through review rather than +# through a user's error. +# +# The commands are not generated: which ones exist stays the CLI's decision. +# What follows the spec is what a request may carry — paths, parameters, the +# values a flag takes — so the pull request is where someone sees what moved. +# +# A pull request is opened even when the CLI no longer builds or passes against +# the new spec, and says so. That is the case that needs a person most: an +# operation the CLI calls was removed or renamed, or a value it sends is no +# longer taken. The run then fails as well, so it does not pass unnoticed. +on: + schedule: + - cron: '17 1 * * *' # 09:17 Asia/Shanghai + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + +concurrency: + group: spec-sync + cancel-in-progress: false + +jobs: + sync: + name: Fetch openapi.yaml and open a pull request + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22.x + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Fetch openapi.yaml + shell: bash + run: | + cp spec/openapi.yaml "${RUNNER_TEMP}/openapi.before.yaml" + node scripts/fetch-spec.mjs + + - name: Regenerate src/generated + id: generate + continue-on-error: true + shell: bash + run: npm run generate 2>&1 | tee "${RUNNER_TEMP}/generate.log" + + - name: Check the CLI against the new spec + id: check + if: steps.generate.outcome == 'success' + continue-on-error: true + shell: bash + run: | + npm run check 2>&1 | tee "${RUNNER_TEMP}/check.log" + npm test 2>&1 | tail -n 60 | tee -a "${RUNNER_TEMP}/check.log" + + - name: Describe the change + id: describe + shell: bash + env: + GENERATED: ${{ steps.generate.outcome }} + CHECKED: ${{ steps.check.outcome }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + # A change to an operation the CLI calls is a feat and names it, so the + # changelog says what moved; anything else is a chore and releases nothing. + title="$(node scripts/spec-diff.mjs --title "${RUNNER_TEMP}/openapi.before.yaml" spec/openapi.yaml)" || + title='feat: follow openapi.yaml' + delimiter="EOF_$(openssl rand -hex 8)" + { + echo "title=${title}" + echo "body<<${delimiter}" + echo 'The published openapi.yaml changed, and `src/generated` was regenerated from it.' + echo + node scripts/spec-diff.mjs "${RUNNER_TEMP}/openapi.before.yaml" spec/openapi.yaml || + echo '- The two copies could not be compared; read the diff of `spec/openapi.yaml`.' + echo + if [ "${GENERATED}" != 'success' ]; then + echo '**`npm run generate` failed**, so `src/generated` still describes the old spec:' + echo + echo '```' + tail -n 30 "${RUNNER_TEMP}/generate.log" + echo '```' + elif [ "${CHECKED}" != 'success' ]; then + echo '**The CLI does not pass against the new spec.** Fix it on this branch before merging:' + echo + echo '```' + grep -E 'error|✖|not ok|AssertionError|expected|actual' "${RUNNER_TEMP}/check.log" | head -n 40 || true + echo '```' + else + echo '`npm run check` and `npm test` pass against the new spec.' + fi + echo + echo "Run: ${RUN_URL}" + echo "${delimiter}" + } >> "${GITHUB_OUTPUT}" + + - name: Open or update the pull request + uses: peter-evans/create-pull-request@v7 + with: + # A pull request opened with GITHUB_TOKEN does not trigger other + # workflows, so CI would not run on it. The PAT release-please uses + # (contents and pull-requests write) fixes that, as it does there; + # without it the pull request still opens and simply shows no checks. + token: ${{ secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }} + branch: chore/sync-openapi + commit-message: ${{ steps.describe.outputs.title }} + title: ${{ steps.describe.outputs.title }} + body: ${{ steps.describe.outputs.body }} + labels: openapi-sync + delete-branch: true + + - name: Fail the run when the CLI does not follow the new spec + if: steps.generate.outcome != 'success' || steps.check.outcome == 'failure' + run: | + echo '::error::the CLI does not build or pass against the new openapi.yaml; see the pull request' + exit 1 diff --git a/.oxfmtrc.json b/.oxfmtrc.json index acd506f..29f399f 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -3,5 +3,5 @@ "printWidth": 120, "singleQuote": true, "trailingComma": "none", - "ignorePatterns": ["CHANGELOG.md", "package-lock.json"] + "ignorePatterns": ["CHANGELOG.md", "package-lock.json", "spec/openapi.yaml"] } diff --git a/.oxlintrc.json b/.oxlintrc.json index 0f76de8..ea8884f 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -12,6 +12,6 @@ "unicorn/consistent-function-scoping": "off", "unicorn/no-array-sort": "off" }, - "ignorePatterns": ["dist"], + "ignorePatterns": ["dist", "src/generated"], "env": { "builtin": true } } diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 447b96a..bdc77eb 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -17,11 +17,22 @@ says what the CLI does; this says how, for whoever changes it next. `node:http` are enough. The install is the package and nothing else. - **The server owns its vocabulary.** Field types, scenes, operators and aggregation functions are the API's to define and to refuse. The CLI checks - shapes and passes words through. + shapes and passes words through; the words its own flags offer are read from + the openapi.yaml the API publishes, never kept by hand. +- **openapi.yaml says what a request may carry.** Which commands exist is the + CLI's decision; which operation sits at which path, which parameters it has + and what values they take is the spec's, read from a table generated from it + rather than written down a second time. ## Module map ``` +spec/openapi.yaml the published API v1 spec, verbatim; spec-sync keeps it current +scripts/ + fetch-spec.mjs downloads it + generate.mjs spec/openapi.yaml -> src/generated/operations.ts; the one place that reads OpenAPI + spec-diff.mjs what changed between two copies, for the spec-sync pull request + src/ cli-bin.ts #!/usr/bin/env node — runs runCli, writes the result, sets the exit code cli.ts runCli: find the command, split the line, dispatch; the --all paging loop @@ -37,6 +48,12 @@ src/ local.ts the auth and config commands, in the table so they have help local.ts runs the auth and config commands (they never reach the API) options.ts the shared option grammar, and the parsers for --filter, --sort, --metric + spec.ts call(): an operation as a request, path and query checked against the spec; + the values a flag takes + testing/ + conformance.ts the tests' check of a request against the spec; not shipped + generated/ + operations.ts every operation and the request-side schemas, generated; never edited by hand payload.ts the shape check on a form or table payload http.ts JinshujuHttpClient: auth header, deadline, retry, query serialization auth.ts OAuth with PKCE on a loopback port; refresh; revoke @@ -51,8 +68,9 @@ src/ ``` Dependencies point downward: `cli.ts` knows everything, `commands/` knows -`options`, `http` and `render` types, `http.ts` knows `config`, `auth` and -`errors`, and `errors.ts`, `values.ts`, `version.ts` know nothing. +`options`, `spec`, `http` and `render` types, `spec.ts` knows the generated +table and `errors`, `http.ts` knows `config`, `auth` and `errors`, and +`errors.ts`, `values.ts`, `version.ts` know nothing. ## A request, end to end @@ -69,7 +87,8 @@ Dependencies point downward: `cli.ts` knows everything, `commands/` knows and `--json` reads inline JSON, `@file` or `-` for stdin right there. 4. `auth` and `config` go to `local.ts`. Everything else goes to `runRemote`, which builds the `HttpClient` from config and either calls the command's - `run` or sends its `request`. + `run` or sends its `request`. Either way the request was made by + `spec.call`, which refuses a parameter or a value the spec would. 5. `JinshujuHttpClient.request` adds the credential, a `User-Agent` and a deadline, serializes the query, and sends. A 401 on a stored OAuth session refreshes the token and tries once more. A retryable status is tried again @@ -92,7 +111,7 @@ A `Command` is data, not a class: | `path` | the words that select it: `['entry', 'list']` | | `summary`, `description`, `examples`, `payload` | what help shows; nothing else is | | `args`, `options` | what it accepts; anything not listed is refused | -| `request(input)` | the one request the command is, for the commands that are one | +| `request(input)` | the one request the command is, for the commands that are one, made with `call` | | `run(input, client)` | for the commands that are more than one request, or must decide how many; returns what to print | | `paginate` | which keys hold the rows and the cursor, so `--all` can follow it | | `select`, `render` | narrow the payload; reshape it for text only | @@ -105,9 +124,60 @@ request needs, and for `entry import --wait`. Each of those says in its error exactly what did and did not happen, because "nothing happened" would invite the caller to do it again. -Two command files decide **what** is sent; nothing in them decides **how**. -Query strings, credentials, timeouts, retries and error shapes are the -client's business, which is why `HttpRequest.query` is values and not a string. +The command files decide **what** is sent; nothing in them decides **how**. +Paths and the spelling of a parameter are the spec's, through `call`; query +strings, credentials, timeouts, retries and error shapes are the client's, +which is why `HttpRequest.query` is values and not a string. + +## The spec + +`spec/openapi.yaml` is the published spec, copied verbatim. `npm run generate` +reads it with `scripts/generate.mjs` and writes `src/generated/operations.ts`: +every operation's method, path, parameters and request body, and the named +schemas those refer to, cut down to their constraints. Descriptions, examples +and responses stay behind. The output is committed so a spec change reaches +review as a change to what the CLI may send, and CI regenerates it to prove the +two agree. A schema keyword the generator does not know stops it rather than +being dropped: the tests hold requests to what it keeps, and a dropped keyword +would let one through that the spec refuses. + +A command names an operation, never a path: `call('listEntries', { path, +query, body })`. A form and a table are two operations in the spec and one +command here, and `onContainer` picks between them by `--form` or `--table`. +`call` checks path and query against the operation before anything is sent — +a parameter it does not have, a required one left out, a value not of its +type — and spells a list the way the parameter says it goes on the wire. The flags +that offer a fixed set of values take them from the spec too, through +`choices` and `bodyChoices`, so a value the API learns reaches the CLI with the +next sync instead of being refused by a copy. + +A body goes as given. API v1 validates every request against the openapi.yaml +it runs — the only copy never out of date — and its 400 already says, by +pointer, what is wrong. A check here would save that round trip, but an +installed CLI keeps the spec it shipped with until someone upgrades it, and +from then on it would refuse each field type, key and value the API learns: an +old client is always stricter than the API it talks to. Path and query are +different. They come from the CLI's own flags, a new parameter needs a new +release anyway, and they are held only to their type, never to a range or a +list that may grow. + +The tests hold every request the command tests send, and every example in the +help, to the spec, the body included, with `src/testing/conformance.ts`, which +ships with nothing. That is where a body the CLI puts together itself is +checked, and how a `form edit` example sending an `exam_setting` key the API +has no use for was found. + +When the spec changes under the CLI, the failure is loud: an operation the CLI +calls that was removed or renamed no longer typechecks, and a value the CLI +sends that is no longer taken fails a test. + +`.github/workflows/spec-sync.yml` fetches the published spec every day. When it +differs, it regenerates, runs `check` and `test` against it, and opens a pull +request on `chore/sync-openapi` listing the operations and schemas that moved +and which of them the CLI calls. Its title comes from the same comparison: a +change to an operation the CLI calls is a `feat` naming those operations, and +releases; anything else is a `chore`, and does not. The pull request is opened even when the CLI no +longer passes, and says so: that is the case that needs a person. ## Errors and exit codes @@ -159,15 +229,16 @@ character. `npm test` builds and runs `node --test` over `dist/`. Most tests call `runCli` with a mock `HttpClient` and assert on the request that would have been sent and the result that came back: no network, no config file, no -process state. The HTTP client and OAuth are tested against `node:http` +process state. The mock also holds every request to the spec — an operation it +has, a body it takes — and the suite fails if one was not. The HTTP client and OAuth are tested against `node:http` servers on a loopback port. A temporary config path is passed everywhere so the suite never reads the developer's own credentials. ## Tooling and release -- `npm run check` is typecheck, `oxlint` and `oxfmt --check`; CI runs the same - on Node 20 and 22, then packs the tarball, installs it and starts both - binaries. +- `npm run check` is typecheck, `oxlint` and `oxfmt --check`; CI regenerates + `src/generated` and fails on any difference, runs the same checks on Node 20 + and 22, then packs the tarball, installs it and starts both binaries. - Commits are conventional commits, and PRs are squash-merged so each lands as one. release-please keeps a release PR open from `feat` and `fix` commits; merging it tags, releases and publishes to npm through OIDC trusted diff --git a/README.md b/README.md index 644507c..61b268b 100644 --- a/README.md +++ b/README.md @@ -415,4 +415,10 @@ npm install npm test npm run check # typecheck + oxlint + oxfmt --check, what CI runs npm run fmt # format in place with oxfmt +npm run generate # src/generated from spec/openapi.yaml +npm run spec:update # fetch the published openapi.yaml, then generate ``` + +What a request may carry — paths, parameters, the values a flag takes — follows +`spec/openapi.yaml`, a copy of the published API v1 spec. A daily workflow +fetches it and opens a pull request when it changes. diff --git a/package-lock.json b/package-lock.json index d69697c..3932103 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,8 @@ "@types/node": "^22.10.0", "oxfmt": "^0.70.0", "oxlint": "^1.85.0", - "typescript": "^5.9.3" + "typescript": "^5.9.3", + "yaml": "^2.9.1" }, "engines": { "node": ">=20" @@ -857,6 +858,22 @@ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "dev": true, "license": "MIT" + }, + "node_modules/yaml": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz", + "integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } } } } diff --git a/package.json b/package.json index a762d87..ddd51bc 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,8 @@ "files": [ "dist", "!dist/**/*.test.js", - "!dist/**/*.test.d.ts" + "!dist/**/*.test.d.ts", + "!dist/testing" ], "type": "module", "publishConfig": { @@ -34,13 +35,16 @@ "lint": "oxlint", "fmt": "oxfmt", "fmt:check": "oxfmt --check", - "check": "npm run typecheck && npm run lint && npm run fmt:check" + "check": "npm run typecheck && npm run lint && npm run fmt:check", + "generate": "node scripts/generate.mjs && oxfmt src/generated", + "spec:update": "node scripts/fetch-spec.mjs && npm run generate" }, "devDependencies": { "@types/node": "^22.10.0", "oxfmt": "^0.70.0", "oxlint": "^1.85.0", - "typescript": "^5.9.3" + "typescript": "^5.9.3", + "yaml": "^2.9.1" }, "engines": { "node": ">=20" diff --git a/scripts/fetch-spec.mjs b/scripts/fetch-spec.mjs new file mode 100644 index 0000000..5b0093c --- /dev/null +++ b/scripts/fetch-spec.mjs @@ -0,0 +1,18 @@ +// Downloads the published openapi.yaml into spec/openapi.yaml, verbatim. The +// spec-sync workflow runs it daily, and `npm run spec:update` by hand. +import { writeFileSync } from 'node:fs'; + +const url = process.env.JINSHUJU_OPENAPI_URL ?? 'https://jinshuju.net/api/v1/openapi.yaml'; +const response = await fetch(url, { headers: { Accept: 'application/yaml, text/yaml, */*' } }); +if (!response.ok) { + console.error(`GET ${url} answered ${response.status}`); + process.exit(1); +} +const text = await response.text(); +// A login page or an error page answered with 200 must not replace the spec. +if (!text.startsWith('openapi:')) { + console.error(`GET ${url} did not answer an OpenAPI document`); + process.exit(1); +} +writeFileSync(new URL('../spec/openapi.yaml', import.meta.url), text); +console.log(`spec/openapi.yaml updated from ${url} (${text.length} bytes)`); diff --git a/scripts/generate.mjs b/scripts/generate.mjs new file mode 100644 index 0000000..4b38717 --- /dev/null +++ b/scripts/generate.mjs @@ -0,0 +1,189 @@ +/** + * spec/openapi.yaml -> src/generated/operations.ts + * + * The one place that reads OpenAPI. What it writes is what API v1 says each + * operation takes — method, path, parameters, request body — and nothing a + * reader of the output needs: descriptions, examples and responses stay + * behind. Which commands exist is still the CLI's to decide; this decides what + * a request may carry. + * + * The output is committed, so a change to openapi.yaml reaches review as a + * change to what the CLI may send, and CI regenerates it to prove the two + * agree. + * + * Usage: npm run generate + */ +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { parse } from 'yaml'; + +const root = new URL('..', import.meta.url); +const spec = parse(readFileSync(new URL('spec/openapi.yaml', root), 'utf8')); + +const METHODS = ['get', 'post', 'put', 'patch', 'delete']; +const SCHEMA_REF = '#/components/schemas/'; + +/** + * The schema keywords the tests know how to check (src/testing/conformance.ts), + * by what they hold. A keyword missing from both lists stops the generator: + * dropping one it did not understand would let the tests pass a request the + * spec refuses. + */ +const KEYWORDS = { + value: ['type', 'enum', 'const', 'required', 'dependentRequired', 'pattern'], + number: ['minimum', 'maximum', 'minLength', 'maxLength', 'minItems', 'maxItems'], + schema: ['items', 'not', 'if', 'then', 'else', 'contains'], + schemas: ['allOf', 'oneOf', 'anyOf'] +}; +/** + * Words for people and for code generators, which constrain nothing; `format`, + * which only the server asserts; and `discriminator`, whose oneOf beside it + * already says which shape a value fits. + */ +const IGNORED = new Set([ + 'description', + 'title', + 'summary', + 'example', + 'examples', + 'default', + 'format', + 'contentMediaType', + 'discriminator', + 'deprecated', + 'externalDocs', + 'xml' +]); + +function fail(message) { + console.error(`generate: ${message}`); + process.exit(1); +} + +/** `#/components/parameters/PageLimit` and the like. */ +function lookup(ref) { + if (!ref.startsWith('#/')) fail(`external $ref is not supported: ${ref}`); + let node = spec; + for (const part of ref.slice(2).split('/')) { + node = node?.[part.replace(/~1/g, '/').replace(/~0/g, '~')]; + if (node === undefined) fail(`unresolved $ref: ${ref}`); + } + return node; +} + +/** The named schemas the output refers to, filled in as they are met. */ +const named = new Map(); + +/** A schema cut down to its constraints. A named one stays a reference, which is what keeps the output small. */ +function constraints(schema, where) { + if (schema === true || schema === false) return schema; + if (!schema || typeof schema !== 'object' || Array.isArray(schema)) fail(`${where}: not a schema`); + + if (typeof schema.$ref === 'string') { + if (!schema.$ref.startsWith(SCHEMA_REF)) return constraints(lookup(schema.$ref), schema.$ref); + const name = schema.$ref.slice(SCHEMA_REF.length); + if (!named.has(name)) { + named.set(name, undefined); + named.set(name, constraints(lookup(schema.$ref), name)); + } + const siblings = Object.keys(schema).filter((key) => key !== '$ref' && !IGNORED.has(key)); + if (siblings.length > 0) fail(`${where}: keywords beside a $ref are not supported (${siblings.join(', ')})`); + return { $ref: name }; + } + + const out = {}; + for (const [key, value] of Object.entries(schema)) { + if (IGNORED.has(key) || key.startsWith('x-')) continue; + if (KEYWORDS.value.includes(key) || KEYWORDS.number.includes(key)) out[key] = value; + else if (KEYWORDS.schema.includes(key)) out[key] = constraints(value, `${where}/${key}`); + else if (KEYWORDS.schemas.includes(key)) + out[key] = value.map((item, i) => constraints(item, `${where}/${key}/${i}`)); + else if (key === 'properties') { + out.properties = Object.fromEntries( + Object.entries(value).map(([name, property]) => [name, constraints(property, `${where}/properties/${name}`)]) + ); + } else if (key === 'additionalProperties') { + out.additionalProperties = + typeof value === 'boolean' ? value : constraints(value, `${where}/additionalProperties`); + } else fail(`${where}: schema keyword "${key}" is not supported; teach src/testing/conformance.ts, then here`); + } + return out; +} + +function parameter(raw, where) { + const param = raw.$ref ? lookup(raw.$ref) : raw; + if (!param.schema) fail(`${where}: parameter ${param.name} has no schema`); + const out = { name: param.name, in: param.in }; + if (param.required) out.required = true; + if (param.style !== undefined) out.style = param.style; + if (param.explode !== undefined) out.explode = param.explode; + out.schema = constraints(param.schema, `${where}/${param.name}`); + return out; +} + +const operations = {}; +for (const [path, item] of Object.entries(spec.paths ?? {})) { + for (const method of METHODS) { + const op = item[method]; + if (!op) continue; + const id = op.operationId; + if (!id) fail(`${method.toUpperCase()} ${path} has no operationId`); + if (operations[id]) fail(`operationId ${id} is used twice`); + const where = `${id} (${method.toUpperCase()} ${path})`; + + // An operation's own parameter replaces the path's of the same name and place. + const byKey = new Map(); + for (const raw of [...(item.parameters ?? []), ...(op.parameters ?? [])]) { + const param = parameter(raw, where); + byKey.set(`${param.in}:${param.name}`, param); + } + const parameters = [...byKey.values()]; + for (const [, name] of path.matchAll(/\{([^}]+)\}/g)) { + if (!parameters.some((param) => param.in === 'path' && param.name === name)) { + fail(`${where}: {${name}} is in the path but not among its parameters`); + } + } + + const operation = { method: method.toUpperCase(), path, parameters }; + if (op.requestBody) { + const body = op.requestBody.$ref ? lookup(op.requestBody.$ref) : op.requestBody; + operation.body = { + ...(body.required ? { required: true } : {}), + content: Object.fromEntries( + Object.entries(body.content ?? {}).map(([type, media]) => [ + type, + constraints(media.schema ?? {}, `${where} body ${type}`) + ]) + ) + }; + } + operations[id] = operation; + } +} + +const server = spec.servers?.[0]?.url; +if (!server) fail('servers[0].url is missing, and the base path comes from it'); +const basePath = new URL(server).pathname.replace(/\/$/, ''); + +const sorted = (record) => Object.fromEntries(Object.entries(record).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))); +const ids = Object.keys(operations).sort(); + +const output = `// Generated by scripts/generate.mjs from spec/openapi.yaml (${spec.info.title} ${spec.info.version}). +// Do not edit: change the spec and run \`npm run generate\`. + +import type { Operation, Schema } from '../spec.js'; + +/** The path every operation's path is relative to, from \`servers\`. */ +export const BASE_PATH = ${JSON.stringify(basePath)}; + +export type OperationId = +${ids.map((id) => ` | ${JSON.stringify(id)}`).join('\n')}; + +export const OPERATIONS: { readonly [id in OperationId]: Operation } = ${JSON.stringify(sorted(operations))}; + +/** The named schemas a parameter or a request body refers to. */ +export const SCHEMAS: { readonly [name: string]: Schema } = ${JSON.stringify(sorted(Object.fromEntries(named)))}; +`; + +mkdirSync(new URL('src/generated/', root), { recursive: true }); +writeFileSync(new URL('src/generated/operations.ts', root), output); +console.log(`src/generated/operations.ts: ${ids.length} operations, ${named.size} schemas`); diff --git a/scripts/spec-diff.mjs b/scripts/spec-diff.mjs new file mode 100644 index 0000000..5c79e13 --- /dev/null +++ b/scripts/spec-diff.mjs @@ -0,0 +1,167 @@ +/** + * What changed between two copies of openapi.yaml, for the spec-sync pull + * request: which operations were added, removed or changed in what a request + * may carry, which of them the CLI calls, and a conventional-commit title that + * says so. + * + * Only the request side counts, as it does for src/generated: an operation + * changed when its method, path, parameters or body changed, or any schema + * they refer to, however deep. Prose and responses do not count. + * + * Usage: + * node scripts/spec-diff.mjs markdown + * node scripts/spec-diff.mjs --title the title + */ +import { readFileSync, readdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { parse } from 'yaml'; + +const args = process.argv.slice(2); +const titleOnly = args[0] === '--title'; +const [before, after] = (titleOnly ? args.slice(1) : args).map((file) => parse(readFileSync(file, 'utf8'))); +if (!before || !after) { + console.error('usage: node scripts/spec-diff.mjs [--title] '); + process.exit(1); +} + +const METHODS = ['get', 'post', 'put', 'patch', 'delete']; +const PROSE = new Set(['description', 'summary', 'title', 'example', 'examples', 'externalDocs']); +const SCHEMA_REF = '#/components/schemas/'; + +function withoutProse(node) { + if (Array.isArray(node)) return node.map(withoutProse); + if (!node || typeof node !== 'object') return node; + return Object.fromEntries( + Object.entries(node) + .filter(([key]) => !PROSE.has(key)) + .map(([key, value]) => [key, withoutProse(value)]) + ); +} + +function lookup(spec, ref) { + return ref + .slice(2) + .split('/') + .reduce((node, part) => node?.[part.replace(/~1/g, '/').replace(/~0/g, '~')], spec); +} + +/** A parameter or body with the references to other parameters and bodies followed; schemas stay named. */ +function expanded(spec, node) { + if (Array.isArray(node)) return node.map((item) => expanded(spec, item)); + if (!node || typeof node !== 'object') return node; + if (typeof node.$ref === 'string' && !node.$ref.startsWith(SCHEMA_REF)) + return expanded(spec, lookup(spec, node.$ref)); + return Object.fromEntries(Object.entries(node).map(([key, value]) => [key, expanded(spec, value)])); +} + +/** Every named schema a node refers to, and those refer to, and so on. */ +function reach(spec, node, found = new Set()) { + if (Array.isArray(node)) node.forEach((item) => reach(spec, item, found)); + else if (node && typeof node === 'object') { + if (typeof node.$ref === 'string' && node.$ref.startsWith(SCHEMA_REF)) { + const name = node.$ref.slice(SCHEMA_REF.length); + if (!found.has(name)) { + found.add(name); + reach(spec, spec.components?.schemas?.[name], found); + } + } + for (const value of Object.values(node)) reach(spec, value, found); + } + return found; +} + +function operations(spec) { + const out = new Map(); + for (const [path, item] of Object.entries(spec.paths ?? {})) { + for (const method of METHODS) { + const op = item[method]; + if (!op?.operationId) continue; + const shape = expanded(spec, { + method, + path, + parameters: [...(item.parameters ?? []), ...(op.parameters ?? [])], + body: op.requestBody + }); + out.set(op.operationId, { own: JSON.stringify(withoutProse(shape)), schemas: reach(spec, shape) }); + } + } + return out; +} + +function schemas(spec) { + return new Map( + Object.entries(spec.components?.schemas ?? {}).map(([name, schema]) => [name, JSON.stringify(withoutProse(schema))]) + ); +} + +const oldOps = operations(before); +const newOps = operations(after); +const oldSchemas = schemas(before); +const newSchemas = schemas(after); +const schemaChanged = (name) => oldSchemas.get(name) !== newSchemas.get(name); + +const sorted = (items) => [...items].sort(); +const added = sorted([...newOps.keys()].filter((id) => !oldOps.has(id))); +const removed = sorted([...oldOps.keys()].filter((id) => !newOps.has(id))); +const changed = sorted( + [...newOps.keys()].filter((id) => { + const old = oldOps.get(id); + const now = newOps.get(id); + return old && (old.own !== now.own || [...now.schemas].some(schemaChanged)); + }) +); + +/** The operation ids the CLI's own code names, read off its source. */ +function usedByCli(ids) { + const used = new Set(); + const walk = (dir) => { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const file = join(dir, entry.name); + if (entry.isDirectory()) { + if (entry.name !== 'generated') walk(file); + } else if (file.endsWith('.ts') && !file.endsWith('.test.ts')) { + for (const [, id] of readFileSync(file, 'utf8').matchAll(/'([A-Za-z]+)'/g)) if (ids.has(id)) used.add(id); + } + } + }; + walk(new URL('../src', import.meta.url).pathname); + return used; +} + +const used = usedByCli(new Set([...oldOps.keys(), ...newOps.keys()])); +const affected = [...removed, ...changed].filter((id) => used.has(id)); + +/** + * A change to an operation the CLI calls is a feat, and releases: what the CLI + * may send moved. Anything else is a chore, so a sync that touches nothing the + * CLI calls neither releases nor adds a line to the changelog. + */ +function title() { + if (affected.length > 0) { + const named = affected.slice(0, 3).join(', '); + const more = affected.length > 3 ? ` and ${affected.length - 3} more` : ''; + return `feat: follow openapi.yaml changes to ${named}${more}`; + } + if (added.length + removed.length + changed.length > 0) { + return 'chore: sync openapi.yaml, no operation the CLI calls changed'; + } + return 'chore: sync openapi.yaml, prose only'; +} + +if (titleOnly) { + console.log(title()); +} else { + const mark = (id) => (used.has(id) ? `**\`${id}\`** (called by the CLI)` : `\`${id}\``); + const lines = []; + const section = (heading, items, format = (item) => `\`${item}\``) => { + if (items.length > 0) lines.push(`- ${heading}: ${items.map(format).join(', ')}`); + }; + section('Operations added', added, mark); + section('Operations removed', removed, mark); + section('Operations changed', changed, mark); + section( + 'Schemas changed', + sorted([...newSchemas.keys()].filter((name) => oldSchemas.has(name) && schemaChanged(name))) + ); + console.log(lines.length > 0 ? lines.join('\n') : '- Only prose changed: descriptions, summaries or examples.'); +} diff --git a/spec/openapi.yaml b/spec/openapi.yaml new file mode 100644 index 0000000..b3a4c0a --- /dev/null +++ b/spec/openapi.yaml @@ -0,0 +1,16448 @@ +openapi: 3.1.0 +info: + title: 金数据 API v1 + version: '1.0' + description: | + 金数据(Jinshuju)对外开放接口 v1,用于读写表单、表格、数据、视图、评论、对外查询等资源。 + 产品侧的说明见 [金数据开放平台](https://open.jinshuju.net/api_v1)。 + + ## 请求约定 + - 基础地址:`https://jinshuju.net/api/v1`,所有路径都相对于它。 + - 请求头:`Content-Type: application/json`、`Accept: application/json`、`Authorization`(见下)。 + - 更新接口以 `PATCH` 为准(只修改出现的键),同路径的 `PUT` 是兼容别名,按 `PATCH` 处理和校验;「数据」资源的 `PUT`(整体覆盖)与 `PATCH`(增量更新)语义不同,已分别列出。 + - 响应统一为 JSON,并带 `Access-Control-Allow-Origin: *`。 + - 字段定义相关的 schema(`FieldInput` 及各 `Input`、`FieldUpdateInput`、`FormCreateInput`、`TableCreateInput` 等)按字段 `type` 分支并带条件约束。 + + ## 认证 + - **Bearer Token(推荐)**:`Authorization: Bearer ACCESS_TOKEN`。支持三类令牌: + - 个人 Access Token:以该用户身份访问其创建或被共享的表单; + - 企业 Access Token(企业版套餐):以企业账户范围访问全部表单,此时接口没有「当前用户」,创建类操作记在企业账户负责人名下; + - OAuth 2.0 访问令牌:按令牌 scope 限制可调用的接口,scope 不足返回 `403`。每个接口的说明中标注了所需 scope + (`forms` / `form_setting` / `read_entries` / `write_entries` / `read_contacts` / `users` / `public`)。 + - **HTTP Basic(旧版,将下线)**:`api_key:api_secret` 做 Base64 后放入 `Authorization: Basic ...`。 + - 令牌无效或已撤销返回 `401`;当前套餐不含 API v1 能力返回 `402`。 + + ## 速率限制 + 按企业账户(或令牌)每小时计数,额度由套餐决定(默认 120 次/小时)。超限返回 `429`, + 响应头带 `Retry-After`、`X-RateLimit-Limit`、`X-RateLimit-Remaining`、`X-RateLimit-Reset`。 + + ## 错误格式 + 业务错误统一返回 `{ "error_description": "..." }`,HTTP 状态码表达错误类别: + `400` 参数或数据校验失败、`401` 未认证、`402` 套餐不支持、`403` 无权限 / 配额已满 / scope 不足、 + `404` 资源不存在或不可访问、`422` 批量操作全部失败、`429` 超过速率限制。 + + ## 分页 + 列表接口返回 `{ total, count, data, next }`。`next` 为 `null` 表示已到末页;把它原样回传即可取下一页。 + `skip` 可以指定起始行(0 起、最大 10000),用于并行切片读取;`skip` 与 `next` 不能同时传。 + 表单、表格、数据列表以及「我填写的」列表可用 `limit` 指定每页条数(缺省 50,最大 1000);视图数据列表固定每页 50 条。 + 小金商户的订单 / 退款 / 提现列表按页码分页:返回 `page_info`(total、page、per_page、total_pages)与记录数组,`limit` 缺省且最大 50;还有下一页时才带 `next`(下一页页码),原样回传即可。 + + ## 时间与时区 + `created_at` / `updated_at` 等系统时间以 UTC ISO 8601 返回(如 `2021-09-28T02:59:42.539Z`); + 日期字段的值按字段精度以北京时间格式化(如 `2012-12-25` 或 `2012-12-25 12:13`),写入时同样按北京时间解析。 + + ## 输入校验 + 服务端按本文档校验每个请求的路径参数、查询参数和 JSON 请求体(上传接口的 multipart 请求体除外)。 + - 不合法的请求直接返回 `400`,请求体为 `{ error_description, errors: [{ pointer, message, code }] }`;非 `application/json` 的请求体返回 `415`。 + - 请求体 schema 中未列出的键视为错误(数据写入的字段值除外,由表单字段定义决定)。 +servers: +- url: https://jinshuju.net/api/v1 + description: 生产环境 +security: +- bearerAuth: [] +- basicAuth: [] +tags: +- name: Forms + description: 表单:列表、结构、创建与编辑,以及字段规则、协作者、复制、移动等附属操作。 +- name: Form Settings + description: 表单设置:提交设置、访问策略、Webhook、主题、关闭规则、考试 / 测评场景设置。 +- name: Entries + description: 表单数据:列表、查询、新增、更新、删除与批量操作。 +- name: Tables + description: 数据表格(kind=table):结构与数据,字段类型限于表格支持的列类型。 +- name: Views + description: 表单 / 表格视图(视图筛选、排序、列配置)及按视图读取数据。 +- name: Comments + description: 数据评论。 +- name: Fill + description: 填写链接:以填写者身份读取填写页(`/f/`、`/v/`)并按它提交数据。 +- name: OpenSearch + description: 对外查询。 +- name: Folders + description: 文件夹。 +- name: Predefined Choices Sets + description: 预置选项集。 +- name: Templates + description: 模板使用。 +- name: Contacts + description: 联系人。 +- name: Account + description: 当前用户与企业账户:成员、发票、消费记录、短信签名审核状态。 +- name: Trade + description: 小金商户:商户与入驻进度、收款订单、交易统计、退款与提现。 +- name: Uploads + description: 附件、表单图片、导入文件的上传。 +- name: Utilities + description: 工具类接口。 +paths: + "/forms": + get: + tags: + - Forms + summary: 获取表单列表 + operationId: listForms + description: | + 返回当前身份可见的表单(`kind=form`,不含表格与快速支付)。个人令牌返回该用户创建及被共享的表单;企业令牌返回企业下全部表单。 + 默认按 id 倒序(即创建时间倒序),`next` 是下一页第一条的 id;传 `sort` 时按指定字段排序,`next` 变为 `row:<偏移量>`。 + 每页默认 50 条,可用 `limit` 调整(最大 1000)。 + - 每行带 `entries_count`(数据量)与 `last_entry_created_at`(最近一次收到数据的时间),可配合 `filters` / `sort` 使用。 + - `include_transactions=true` 时,已绑定收款账户的表单附带 `transactions` 收款统计。 + + OAuth 令牌需要 scope:forms + parameters: + - $ref: '#/components/parameters/FormsQ' + - $ref: '#/components/parameters/FormsFilters' + - $ref: '#/components/parameters/FormsSort' + - $ref: '#/components/parameters/FormsNext' + - $ref: '#/components/parameters/FormsSkip' + - $ref: '#/components/parameters/PageLimit' + - $ref: '#/components/parameters/IncludeTransactions' + responses: + '200': + description: 表单列表 + content: + application/json: + schema: + $ref: '#/components/schemas/FormListResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '402': + $ref: '#/components/responses/PaymentRequired' + '403': + $ref: '#/components/responses/Forbidden' + '429': + $ref: '#/components/responses/TooManyRequests' + post: + tags: + - Forms + summary: 创建表单 + operationId: createForm + description: | + 用字段定义列表创建一个表单,返回完整的表单结构。`name` 与 `fields` 必填。 + - `scene` 决定表单场景;考试 / 测评题型(如 `SingleSelect`、`FillInBlank`)只能在支持的场景下使用。 + - `layout=card`(分页式)不支持 PageBreak、Matrix*、TableField、GoodsField、ESignatureField、FormAssociation、MultipleBlanksField 及各类 Widget 字段。 + - `folder_token` 指定的文件夹必须可管理且 kind 为 form,否则分别返回 404 / 400。 + - `setting` 只接受白名单内的键(见 `FormSettingWriteInput`),`setting.notification_rules` 会整体替换由 API 创建的通知规则。 + - 返回的表单结构带 `url`(对外填写地址)。 + + OAuth 令牌需要 scope:forms + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/FormCreateInput' + responses: + '201': + description: 创建成功,返回表单结构 + content: + application/json: + schema: + $ref: '#/components/schemas/Form' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '402': + $ref: '#/components/responses/PaymentRequired' + '403': + $ref: '#/components/responses/Forbidden' + '404': + description: folder_token 指定的文件夹不存在或不可管理 + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '429': + $ref: '#/components/responses/TooManyRequests' + "/forms/{form_token}": + parameters: + - $ref: '#/components/parameters/FormToken' + get: + tags: + - Forms + summary: 获取表单结构 + operationId: getForm + description: | + 返回表单名称、对外填写地址(`url`)、描述、字段定义与设置。`fields` 中每个元素是 `{ "": {...} }` 的单键对象,按字段顺序排列。 + 以下内容在对应参数为 `true` 时附带: + - `include_analytics`:每个可分析字段的定义中附带 `analytics`; + - `include_theme`:附带 `theme`(表单主题); + - `include_field_rules`:附带 `field_rules`(字段显示规则); + - `include_extended_attributes`:附带 `extended_attributes`(数据来源链接); + - `include_transactions`:表单绑定了收款账户时附带 `transactions`(收款统计)。 + + OAuth 令牌需要 scope:forms + responses: + '200': + description: 表单结构 + content: + application/json: + schema: + $ref: '#/components/schemas/FormDetail' + '401': + $ref: '#/components/responses/Unauthorized' + '402': + $ref: '#/components/responses/PaymentRequired' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '429': + $ref: '#/components/responses/TooManyRequests' + parameters: + - name: include_analytics + in: query + required: false + description: 是否在每个可分析字段上附带 `analytics`(可用的聚合函数、能否分组、数据概况形态),缺省为 false。 + schema: + type: boolean + default: false + - name: include_theme + in: query + required: false + description: 为 `true` 时附带 `theme`(表单主题)。 + schema: + type: boolean + default: false + - name: include_field_rules + in: query + required: false + description: 为 `true` 时附带 `field_rules`(字段显示规则,同 `GET /forms/{form_token}/field_rules` 的 `data`)。 + schema: + type: boolean + default: false + - name: include_extended_attributes + in: query + required: false + description: 为 `true` 时附带 `extended_attributes`(数据来源链接,同 `GET /forms/{form_token}/extended_attributes` 的 `data`)。 + schema: + type: boolean + default: false + - $ref: '#/components/parameters/IncludeTransactions' + patch: + tags: + - Forms + summary: 编辑表单 + operationId: updateForm + description: | + 增量编辑表单,也接受 `PUT`。请求体至少包含 `name` / `description` / `fields` / `setting` / `field_rules` 之一,否则返回 400。 + - `fields` 按 `add` / `remove` / `update` / `update_choices` 四类操作增量修改,未提及的字段、选项与 api_code 全部保留; + `update[].type` 可原地切换字段类型,会清空数据的切换必须带 `confirm_data_loss: true`。 + - `field_rules` 以 `GET /forms/{form_token}/field_rules` 返回的 `index` 定位规则做 `add` / `remove` / `update`。 + - `setting` 与创建时相同,只接受白名单内的键。 + 返回更新后的完整表单结构。 + + OAuth 令牌需要 scope:forms + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/FormUpdateInput' + responses: + '200': + description: 更新成功,返回表单结构 + content: + application/json: + schema: + $ref: '#/components/schemas/Form' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '402': + $ref: '#/components/responses/PaymentRequired' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '429': + $ref: '#/components/responses/TooManyRequests' + post: + tags: + - Entries + summary: 新增数据(旧路径) + operationId: createEntryLegacy + deprecated: true + description: | + `POST /forms/{form_token}/entries` 的旧别名,行为完全相同。新接入请使用新路径。 + + OAuth 令牌需要 scope:write_entries + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/EntryInput' + responses: + '201': + description: 创建成功 + content: + application/json: + schema: + $ref: '#/components/schemas/Entry' + '400': + $ref: '#/components/responses/EntryValidationFailed' + '401': + $ref: '#/components/responses/Unauthorized' + '402': + $ref: '#/components/responses/PaymentRequired' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '429': + $ref: '#/components/responses/TooManyRequests' + "/forms/{form_token}/fields/check": + get: + tags: + - Forms + summary: 检查字段是否已有数据 + description: | + 删除字段或选项前使用:批量检查字段(或字段的单个选项)是否已有提交的数据。删除仍有数据的字段或选项会连同这些数据一起删除且无法恢复。 + 与网页编辑器删除前的检查结果一致。 + - 一次编辑涉及的所有删除目标放在同一次调用的 `checks` 里(最多 100 个,可跨多个字段);`results` 与 `checks` 顺序一一对应。 + - 某个目标无法检查(如字段不存在)时,该行返回 `unavailable` 说明原因,其余目标照常返回。 + - `checks` 缺失、为空、超过 100 个、不是 JSON 数组或其中某项不是对象时返回 400。 + + OAuth 令牌需要 scope:forms。 + operationId: checkFormFieldData + parameters: + - $ref: '#/components/parameters/FormToken' + - $ref: '#/components/parameters/FieldDataChecks' + responses: + '200': + description: 逐项检查结果 + content: + application/json: + schema: + $ref: '#/components/schemas/FieldDataCheckResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '402': + $ref: '#/components/responses/PaymentRequired' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '429': + $ref: '#/components/responses/TooManyRequests' + "/forms/{form_token}/fields/preview_convert": + get: + tags: + - Forms + summary: 预览字段类型转换 + description: | + 修改字段类型(PATCH /forms/{form_token} 中字段的 `type`)前使用:不修改任何数据,按字段当前的数据统计转换后保留与清空的条数。 + 类型转换在原字段上进行(api_code 与数据列不变),因此受影响的只有数据;与网页编辑器转换前的预览结果一致。 + - 一次编辑涉及的所有转换放在同一次调用的 `checks` 里(最多 20 个);`results` 与 `checks` 顺序一一对应。 + - `supported=false` 表示该字段不支持转换为目标类型,实际编辑也会被拒绝。 + - 多行文本转换为单选 / 多选等选择类字段时,额外返回将从已有数据生成的选项数 `choice_count`,以及是否超过选项上限 `choice_limit_exceeded`(超过时转换会被拒绝,而不是截断)。 + - 某项无法预览(字段不存在、`target_type` 不是该表单可用的类型等)时,该行返回 `unavailable` 说明原因,其余项照常返回。 + - `checks` 缺失、为空、超过 20 个、不是 JSON 数组或其中某项不是对象时返回 400。 + + OAuth 令牌需要 scope:forms。 + operationId: previewFormFieldTypeChange + parameters: + - $ref: '#/components/parameters/FormToken' + - $ref: '#/components/parameters/FieldTypeChangeChecks' + responses: + '200': + description: 逐项预览结果 + content: + application/json: + schema: + $ref: '#/components/schemas/FieldTypeChangePreviewResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '402': + $ref: '#/components/responses/PaymentRequired' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '429': + $ref: '#/components/responses/TooManyRequests' + "/forms/{form_token}/entries": + parameters: + - $ref: '#/components/parameters/FormToken' + get: + tags: + - Entries + summary: 获取数据列表 + operationId: listEntries + description: | + 分页返回表单数据,每页缺省 50 条,可用 `limit` 指定(最大 1000)。默认按序号升序,`next` 为下一页第一条的序号。 + - `filters` / `keyword` 触发条件查询,`created_at` 只返回该时间及之后创建的数据(并改为按创建时间排序); + 这两种情况以及传 `sort` 时,`next` 变为行偏移量。 + - `fields` 只返回指定字段(序号始终返回)。 + - 每条数据的键为字段 api_code(配置了 api_code_alias 的字段以别名为键),值的形态见 `EntryData`。 + + OAuth 令牌需要 scope:read_entries + parameters: + - $ref: '#/components/parameters/EntriesFields' + - $ref: '#/components/parameters/EntriesFilters' + - $ref: '#/components/parameters/EntriesKeyword' + - $ref: '#/components/parameters/EntriesSort' + - $ref: '#/components/parameters/EntriesCreatedAt' + - $ref: '#/components/parameters/EntriesNext' + - $ref: '#/components/parameters/EntriesSkip' + - $ref: '#/components/parameters/EntriesIncludeLabels' + - $ref: '#/components/parameters/PageLimit' + responses: + '200': + description: 数据列表 + content: + application/json: + schema: + $ref: '#/components/schemas/EntryListResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '402': + $ref: '#/components/responses/PaymentRequired' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '429': + $ref: '#/components/responses/TooManyRequests' + post: + tags: + - Entries + summary: 新增数据 + operationId: createEntry + description: | + 以字段 api_code(或 api_code_alias)为键提交一条数据。未知键被忽略;有效键为空时返回 400,`error_description` 以 `Entry attributes cannot be empty` 开头,并逐个说明被丢弃的键(不是该表单的字段,或是不可写入的签名 / 计算字段)。 + - 表单的「字段规则」与字段校验依然生效,校验失败返回 400,`error_description` 是 `{ "": ["错误"] }` 序列化后的 JSON 字符串。 + - 手机字段跳过短信验证;签名字段、计算字段不可写入。 + - 表单数据量达到套餐上限时返回 403。 + - 附件字段接受多种写法,见 `EntryInput`。 + + OAuth 令牌需要 scope:write_entries + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/EntryInput' + example: + field_1: 张三 + field_2: '13800000000' + field_3: + - 选项 A + - 选项 B + field_4: '2024-05-01' + responses: + '201': + description: 创建成功,返回该条数据 + content: + application/json: + schema: + $ref: '#/components/schemas/Entry' + '400': + $ref: '#/components/responses/EntryValidationFailed' + '401': + $ref: '#/components/responses/Unauthorized' + '402': + $ref: '#/components/responses/PaymentRequired' + '403': + description: 表单数据量已达上限(`Form has reached entries limit`),或 OAuth scope 不足 + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + $ref: '#/components/responses/NotFound' + '429': + $ref: '#/components/responses/TooManyRequests' + "/forms/{form_token}/entries/count": + parameters: + - $ref: '#/components/parameters/FormToken' + get: + tags: + - Entries + summary: 统计数据条数 + operationId: countEntries + description: | + 返回满足筛选条件的数据条数,不返回数据本身。`filters` / `keyword` 与 `GET /forms/{form_token}/entries` 相同;都不传时返回表单全部数据条数。 + 协作者只统计其角色可见范围内的数据。 + + OAuth 令牌需要 scope:read_entries + parameters: + - $ref: '#/components/parameters/EntriesFilters' + - $ref: '#/components/parameters/EntriesKeyword' + responses: + '200': + description: 数据条数 + content: + application/json: + schema: + $ref: '#/components/schemas/EntryCountResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '402': + $ref: '#/components/responses/PaymentRequired' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '429': + $ref: '#/components/responses/TooManyRequests' + "/forms/{form_token}/entries/aggregate": + parameters: + - $ref: '#/components/parameters/FormToken' + get: + tags: + - Entries + summary: 聚合统计数据 + operationId: aggregateEntries + description: | + 在服务端对满足筛选条件的数据做整列聚合(求和、平均、计数、分位数等),可按 1~2 个字段分组。响应大小只取决于指标与分组数,与数据量无关。 + - `metrics` 必填,1~20 项;每个字段可用的函数见 `GET /forms/{form_token}?include_analytics=true` 返回的 `analytics.agg_funcs`。 + - `dimensions` 可选,最多 2 项;只有 `analytics.groupable` 为 true 的字段(单值的选项类字段和日期字段)可以分组,日期字段必须指定 `bucket`。 + - 不传 `dimensions` 时 `rows` 只有一行;分组时每组一行,最多 `limit` 组,`row_count` 为分组总数,超出时 `truncated` 为 true。 + - `filters` / `keyword` 与数据列表相同,但不支持 `creator_id`、`serial_number` 与关联字段条件(返回 400)。 + - 字段不存在、无权读取、函数或分组方式不适用于该字段时返回 400,并说明可用的字段 / 函数 / 分组方式。 + + OAuth 令牌需要 scope:read_entries + parameters: + - $ref: '#/components/parameters/AggregateMetrics' + - $ref: '#/components/parameters/AggregateDimensions' + - $ref: '#/components/parameters/AggregateLimit' + - $ref: '#/components/parameters/EntriesFilters' + - $ref: '#/components/parameters/EntriesKeyword' + responses: + '200': + description: 聚合结果 + content: + application/json: + schema: + $ref: '#/components/schemas/EntryAggregateResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '402': + $ref: '#/components/responses/PaymentRequired' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '429': + $ref: '#/components/responses/TooManyRequests' + "/forms/{form_token}/entries/summary": + parameters: + - $ref: '#/components/parameters/FormToken' + get: + tags: + - Entries + summary: 数据概况 + operationId: summarizeEntries + description: | + 一次读取表单数据的整体概况:总览(匹配条数、今日新增)加上每个可分析字段的分布或数值统计。响应只有几 KB,与数据量无关。 + - 每个字段按 `kind` 返回统一结构:选项类给出各选项计数与占比,矩阵类按题目分组,数字给出最小 / 最大 / 求和 / 平均,日期给出最早 / 最晚,文本给出去重数。 + - 默认包含全部可分析字段(`analytics.summarisable` 为 true 的字段,另含 `created_at`),最多 60 个;超出的字段列在 `omitted_fields` 中,`truncated` 为 true。 + - `fields` 指定的字段不可分析或无权读取时返回 400。 + - `filters` / `keyword` 与数据列表相同,但不支持 `creator_id`、`serial_number` 与关联字段条件(返回 400)。 + + OAuth 令牌需要 scope:read_entries + parameters: + - $ref: '#/components/parameters/SummaryFields' + - $ref: '#/components/parameters/SummaryIncludeOverview' + - $ref: '#/components/parameters/EntriesFilters' + - $ref: '#/components/parameters/EntriesKeyword' + responses: + '200': + description: 数据概况 + content: + application/json: + schema: + $ref: '#/components/schemas/EntrySummaryResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '402': + $ref: '#/components/responses/PaymentRequired' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '429': + $ref: '#/components/responses/TooManyRequests' + "/forms/{form_token}/views/{view_token}/entries/count": + parameters: + - $ref: '#/components/parameters/FormToken' + - $ref: '#/components/parameters/ViewToken' + get: + tags: + - Views + summary: 统计视图数据条数 + operationId: countFormViewEntries + description: | + 与 `GET /forms/{form_token}/entries/count` 相同,只统计该视图筛选出的数据:先套用视图的筛选条件,`filters` / `keyword` 在此基础上进一步缩小范围。视图的列和排序不影响结果;视图没有筛选条件时等同于整张表单。 + 只能访问调用者可见的视图,否则 404(View cannot be found)。 + + OAuth 令牌需要 scope:read_entries + parameters: + - $ref: '#/components/parameters/EntriesFilters' + - $ref: '#/components/parameters/EntriesKeyword' + responses: + '200': + description: 数据条数 + content: + application/json: + schema: + $ref: '#/components/schemas/EntryCountResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '402': + $ref: '#/components/responses/PaymentRequired' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '429': + $ref: '#/components/responses/TooManyRequests' + "/forms/{form_token}/views/{view_token}/entries/aggregate": + parameters: + - $ref: '#/components/parameters/FormToken' + - $ref: '#/components/parameters/ViewToken' + get: + tags: + - Views + summary: 聚合统计视图数据 + operationId: aggregateFormViewEntries + description: | + 与 `GET /forms/{form_token}/entries/aggregate` 相同,只统计该视图筛选出的数据:先套用视图的筛选条件,`filters` / `keyword` 在此基础上进一步缩小范围。视图的列和排序不影响结果;视图没有筛选条件时等同于整张表单。 + 只能访问调用者可见的视图,否则 404(View cannot be found)。 + + OAuth 令牌需要 scope:read_entries + parameters: + - $ref: '#/components/parameters/AggregateMetrics' + - $ref: '#/components/parameters/AggregateDimensions' + - $ref: '#/components/parameters/AggregateLimit' + - $ref: '#/components/parameters/EntriesFilters' + - $ref: '#/components/parameters/EntriesKeyword' + responses: + '200': + description: 聚合结果 + content: + application/json: + schema: + $ref: '#/components/schemas/EntryAggregateResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '402': + $ref: '#/components/responses/PaymentRequired' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '429': + $ref: '#/components/responses/TooManyRequests' + "/forms/{form_token}/views/{view_token}/entries/summary": + parameters: + - $ref: '#/components/parameters/FormToken' + - $ref: '#/components/parameters/ViewToken' + get: + tags: + - Views + summary: 视图数据概况 + operationId: summarizeFormViewEntries + description: | + 与 `GET /forms/{form_token}/entries/summary` 相同,只统计该视图筛选出的数据:先套用视图的筛选条件,`filters` / `keyword` 在此基础上进一步缩小范围。视图的列和排序不影响结果;视图没有筛选条件时等同于整张表单。 + 只能访问调用者可见的视图,否则 404(View cannot be found)。 + + OAuth 令牌需要 scope:read_entries + parameters: + - $ref: '#/components/parameters/SummaryFields' + - $ref: '#/components/parameters/SummaryIncludeOverview' + - $ref: '#/components/parameters/EntriesFilters' + - $ref: '#/components/parameters/EntriesKeyword' + responses: + '200': + description: 数据概况 + content: + application/json: + schema: + $ref: '#/components/schemas/EntrySummaryResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '402': + $ref: '#/components/responses/PaymentRequired' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '429': + $ref: '#/components/responses/TooManyRequests' + "/forms/{form_token}/entries/batch": + parameters: + - $ref: '#/components/parameters/FormToken' + post: + tags: + - Entries + summary: 批量新增数据 + operationId: createEntriesBatch + description: | + 一次最多提交 200 条数据。逐条校验,合法的批量写入,非法的记录在 `errors` 中(`index` 为请求数组下标)。 + 至少一条成功返回 201,全部失败返回 422;`entries` 为空或超过 200 条返回 400。 + 提交前会按条数检查数据量上限,超限返回 403。批量写入不会触发单条新增的回调,但会创建一条「导入」活动记录并回填计算字段。 + + OAuth 令牌需要 scope:write_entries + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - entries + properties: + entries: + type: array + minItems: 1 + maxItems: 200 + items: + $ref: '#/components/schemas/EntryInput' + additionalProperties: false + responses: + '201': + description: 至少一条创建成功 + content: + application/json: + schema: + $ref: '#/components/schemas/BatchCreateResult' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '402': + $ref: '#/components/responses/PaymentRequired' + '403': + description: 表单数据量已达上限,或 OAuth scope 不足 + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + $ref: '#/components/responses/NotFound' + '422': + description: 全部条目创建失败 + content: + application/json: + schema: + $ref: '#/components/schemas/BatchCreateResult' + '429': + $ref: '#/components/responses/TooManyRequests' + patch: + tags: + - Entries + summary: 批量更新数据 + operationId: patchEntriesBatch + description: | + 按序号批量增量更新,一次最多 200 行。每行 `serial_number` 必填,`entry` 为要修改的字段(未提及的字段保持不变)。 + 逐行校验,失败的行记录在 `failed_rows`(`reason` 为 `invalid_serial_number` / `invalid_attributes` 等)。 + 至少一行成功返回 200,全部失败返回 422;`entries` 为空或超过 200 行返回 400。 + 批量更新不处理附件生命周期,因此**不能写附件字段**(含表格字段中的附件列),需要逐条更新。 + + OAuth 令牌需要 scope:write_entries + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - entries + properties: + entries: + type: array + minItems: 1 + maxItems: 200 + items: + type: object + required: + - serial_number + - entry + properties: + serial_number: + type: integer + description: 要更新的数据序号 + entry: + $ref: '#/components/schemas/EntryInput' + additionalProperties: false + additionalProperties: false + responses: + '200': + description: 至少一行更新成功 + content: + application/json: + schema: + $ref: '#/components/schemas/BatchPatchResult' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '402': + $ref: '#/components/responses/PaymentRequired' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '422': + description: 全部行更新失败 + content: + application/json: + schema: + $ref: '#/components/schemas/BatchPatchResult' + '429': + $ref: '#/components/responses/TooManyRequests' + "/forms/{form_token}/entries/{serial_number}": + parameters: + - $ref: '#/components/parameters/FormToken' + - $ref: '#/components/parameters/SerialNumber' + get: + tags: + - Entries + summary: 获取单条数据 + operationId: getEntry + description: | + 按序号返回一条数据。`fields` 可限定返回的字段。 + + OAuth 令牌需要 scope:read_entries + parameters: + - $ref: '#/components/parameters/EntriesFields' + - $ref: '#/components/parameters/EntriesIncludeLabels' + responses: + '200': + description: 单条数据 + content: + application/json: + schema: + $ref: '#/components/schemas/Entry' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '402': + $ref: '#/components/responses/PaymentRequired' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '429': + $ref: '#/components/responses/TooManyRequests' + put: + tags: + - Entries + summary: 覆盖更新数据 + operationId: replaceEntry + description: | + **整体覆盖**:请求体中未出现的可写字段会被清空,只保留本次提交的值。需要增量修改请用 `PATCH`。 + 校验失败返回 400,`error_description` 为字段错误的 JSON 字符串。 + + OAuth 令牌需要 scope:write_entries + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/EntryInput' + responses: + '200': + description: 更新成功,返回该条数据 + content: + application/json: + schema: + $ref: '#/components/schemas/Entry' + '400': + $ref: '#/components/responses/EntryValidationFailed' + '401': + $ref: '#/components/responses/Unauthorized' + '402': + $ref: '#/components/responses/PaymentRequired' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '429': + $ref: '#/components/responses/TooManyRequests' + patch: + tags: + - Entries + summary: 增量更新数据 + operationId: updateEntry + description: | + **增量更新**:只修改请求体中出现的字段,其余字段保持原值。附件字段是整组覆盖,要保留已有文件需把读到的地址或附件 id 一并回传。 + 校验失败返回 400,`error_description` 为字段错误的 JSON 字符串。 + + OAuth 令牌需要 scope:write_entries + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/EntryInput' + responses: + '200': + description: 更新成功,返回该条数据 + content: + application/json: + schema: + $ref: '#/components/schemas/Entry' + '400': + $ref: '#/components/responses/EntryValidationFailed' + '401': + $ref: '#/components/responses/Unauthorized' + '402': + $ref: '#/components/responses/PaymentRequired' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '429': + $ref: '#/components/responses/TooManyRequests' + delete: + tags: + - Entries + summary: 删除数据 + operationId: deleteEntry + description: | + 删除一条数据并记录删除活动。成功返回被删除数据的序号;删除失败(如表单状态不允许)返回 400。 + + OAuth 令牌需要 scope:write_entries + responses: + '200': + description: 删除成功 + content: + application/json: + schema: + type: object + required: + - serial_number + properties: + serial_number: + type: integer + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '402': + $ref: '#/components/responses/PaymentRequired' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '429': + $ref: '#/components/responses/TooManyRequests' + post: + tags: + - Entries + summary: 增量更新数据(POST) + operationId: updateEntryViaPost + description: | + 与 `PATCH` 同路径的 POST 别名,语义为增量更新,供不便发送 PATCH 的客户端使用。 + + OAuth 令牌需要 scope:write_entries + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/EntryInput' + responses: + '200': + description: 更新成功,返回该条数据 + content: + application/json: + schema: + $ref: '#/components/schemas/Entry' + '400': + $ref: '#/components/responses/EntryValidationFailed' + '401': + $ref: '#/components/responses/Unauthorized' + '402': + $ref: '#/components/responses/PaymentRequired' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '429': + $ref: '#/components/responses/TooManyRequests' + "/forms/{form_token}/entries/{serial_number}/comments": + parameters: + - $ref: '#/components/parameters/FormToken' + - $ref: '#/components/parameters/SerialNumber' + get: + tags: + - Comments + summary: 列出数据评论 + description: 按创建时间升序返回该条数据的全部评论,含创建者、被提及的用户与附件。 调用者若为协作者,需要对该表单具备数据读取权限且该条数据在其数据范围内,否则返回 400。 OAuth 令牌需要 scope:read_entries。 + operationId: listEntryComments + responses: + '200': + description: 评论列表 + content: + application/json: + schema: + type: object + required: + - count + - data + properties: + count: + type: integer + description: 评论数量 + data: + type: array + items: + $ref: '#/components/schemas/EntryComment' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '402': + $ref: '#/components/responses/PaymentRequired' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + post: + tags: + - Comments + summary: 创建数据评论 + description: 在该条数据下创建评论,评论者为当前调用者,content 会去除首尾空白。content 为空或超过 2000 字返回 400。 content 中可用 @[](24 位十六进制用户 id)提及用户,被提及者必须是该表单的协作者,否则返回 400 并且不创建评论。 parent_id 用于回复,父评论必须属于同一条数据,否则返回 400。通过本接口无法附加文件。 调用者若为协作者,需要具备数据读取权限且该条数据在其数据范围内,否则返回 400。 OAuth 令牌需要 scope:read_entries。 + operationId: createEntryComment + requestBody: + required: true + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/EntryCommentInput' + - type: object + required: + - content + responses: + '201': + description: 创建成功,返回评论 + content: + application/json: + schema: + $ref: '#/components/schemas/EntryComment' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '402': + $ref: '#/components/responses/PaymentRequired' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + "/forms/{form_token}/entries/{serial_number}/comments/{comment_id}": + parameters: + - $ref: '#/components/parameters/FormToken' + - $ref: '#/components/parameters/SerialNumber' + - name: comment_id + in: path + required: true + description: 评论 id + schema: + type: string + patch: + tags: + - Comments + summary: 更新数据评论 + description: 修改评论内容,也接受 PUT。只有评论作者本人可以编辑,表单管理员也不能编辑他人评论,否则返回 400。 content 规则与创建相同(去除首尾空白、最长 2000 字、提及者必须是协作者);评论已有的附件保持不变。 评论不属于该条数据时返回 404(comment cannot be found)。 OAuth 令牌需要 scope:read_entries。 + operationId: updateEntryComment + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - content + properties: + content: + type: string + maxLength: 2000 + description: 新的评论内容,可包含 @[] 提及 + additionalProperties: false + responses: + '200': + description: 更新成功,返回评论 + content: + application/json: + schema: + $ref: '#/components/schemas/EntryComment' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '402': + $ref: '#/components/responses/PaymentRequired' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + delete: + tags: + - Comments + summary: 删除数据评论 + description: 删除评论。评论作者本人、表单创建者或管理员角色的协作者可以删除,其他协作者返回 400。 已删除或不属于该条数据的评论返回 404(comment cannot be found)。 OAuth 令牌需要 scope:read_entries。 + operationId: deleteEntryComment + responses: + '200': + description: 删除成功 + content: + application/json: + schema: + type: object + required: + - id + properties: + id: + type: string + description: 被删除评论的 id + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '402': + $ref: '#/components/responses/PaymentRequired' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + "/forms/{form_token}/setting": + get: + tags: + - Form Settings + summary: 获取表单设置 + description: | + 返回表单的提交后设置、收集规则(关闭规则、填写频率)、访问策略、微信限制与 Webhook 设置。 + OAuth 令牌需要 scope:form_setting。 + 仅当表单选择了微信公众号(selected_weixin_appid 存在)时才返回 `weixin` 字段。 + operationId: getFormSetting + parameters: + - $ref: '#/components/parameters/FormToken' + responses: + '200': + description: 表单设置 + content: + application/json: + schema: + $ref: '#/components/schemas/FormSetting' + '401': + $ref: '#/components/responses/Unauthorized' + '402': + $ref: '#/components/responses/PaymentRequired' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + patch: + tags: + - Form Settings + summary: 更新表单设置 + description: | + 增量更新表单设置,只写入请求中出现的键;同时接受 PUT。 + OAuth 令牌需要 scope:form_setting。 + - `submission_region_limited` / `submission_regions` 为付费功能,在此接口只读;写入请用「编辑表单」接口的 `setting`。 + - `manually_close_rule`、`by_time_range_close_rule`、`by_entries_close_rule`、`fill_frequency` 为嵌套对象,服务端以 nested attributes 方式创建或更新。 + - 保存成功返回 `{ "status": "ok" }`;模型校验失败返回 400,响应体为 `{ "errors": "...", "status": "failed" }`(与通用错误结构不同)。 + operationId: updateFormSetting + parameters: + - $ref: '#/components/parameters/FormToken' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/FormSettingInput' + responses: + '200': + description: 保存成功 + content: + application/json: + schema: + $ref: '#/components/schemas/FormSettingSaveOk' + '400': + $ref: '#/components/responses/FormSettingBadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '402': + $ref: '#/components/responses/PaymentRequired' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + "/forms/{form_token}/manually_close_rule": + get: + tags: + - Form Settings + summary: 获取手动关闭状态 + description: | + 返回表单是否被手动关闭收集。未创建过手动关闭规则时返回 `{ "closed": false }`。 + OAuth 令牌需要 scope:form_setting。 + operationId: getManuallyCloseRule + parameters: + - $ref: '#/components/parameters/FormToken' + responses: + '200': + description: 手动关闭规则 + content: + application/json: + schema: + $ref: '#/components/schemas/ManuallyCloseRule' + '401': + $ref: '#/components/responses/Unauthorized' + '402': + $ref: '#/components/responses/PaymentRequired' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + patch: + tags: + - Form Settings + summary: 手动开启或关闭收集 + description: | + 通过 `open` 手动开启(true)或关闭(false)表单收集;同时接受 PUT。 + 不存在手动关闭规则时会创建,存在则更新。手动关闭状态发生变化时,服务端会删除该表单的其他关闭规则(按时间、按数据量等)。 + OAuth 令牌需要 scope:form_setting。 + 缺少 `open` 参数时返回 400(响应体以服务端实现为准);保存失败返回 400,响应体为 `{ "errors": "...", "status": "failed" }`。 + operationId: updateManuallyCloseRule + parameters: + - $ref: '#/components/parameters/FormToken' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/FormManuallyCloseRuleInput' + responses: + '200': + description: 更新后的手动关闭规则 + content: + application/json: + schema: + $ref: '#/components/schemas/ManuallyCloseRule' + '400': + $ref: '#/components/responses/FormSettingBadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '402': + $ref: '#/components/responses/PaymentRequired' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + "/forms/{form_token}/access_policy": + patch: + tags: + - Form Settings + summary: 更新填写频率限制 + description: | + 更新表单的填写频率限制(fill_frequency);同时接受 PUT。 + 该接口只接受 `fill_frequency` 一个键,其余访问策略字段请使用 PATCH /forms/{form_token}/setting。 + OAuth 令牌需要 scope:form_setting。 + 保存成功返回 `{ "status": "ok" }`;校验失败返回 400,响应体为 `{ "errors": "...", "status": "failed" }`(与通用错误结构不同)。 + operationId: updateAccessPolicy + parameters: + - $ref: '#/components/parameters/FormToken' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/AccessPolicyInput' + responses: + '200': + description: 保存成功 + content: + application/json: + schema: + $ref: '#/components/schemas/FormSettingSaveOk' + '400': + $ref: '#/components/responses/FormSettingBadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '402': + $ref: '#/components/responses/PaymentRequired' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + "/forms/{form_token}/webhook": + post: + tags: + - Form Settings + summary: 设置 Webhook + description: | + 设置表单的数据推送地址 `entry_post_url`,并同时开启新数据推送(post_new_entry = true)。 + OAuth 令牌需要 scope:form_setting。 + 需要套餐支持 Webhook,否则返回 402 且响应体为空。 + 服务端不检查保存结果,直接回显设置中的 `entry_post_url`(URL 格式不合法时是否已持久化以服务端实现为准)。 + operationId: createFormWebhook + parameters: + - $ref: '#/components/parameters/FormToken' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/WebhookInput' + responses: + '200': + description: 当前的推送地址 + content: + application/json: + schema: + $ref: '#/components/schemas/FormWebhook' + '401': + $ref: '#/components/responses/Unauthorized' + '402': + description: 套餐不支持 API v1 访问(返回 error_description),或套餐不支持 Webhook(响应体为空) + content: + application/json: + schema: + type: object + properties: + error_description: + type: string + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + delete: + tags: + - Form Settings + summary: 删除 Webhook + description: | + 移除表单设置中的 `entry_post_url`。成功返回 200,响应体为空。 + OAuth 令牌需要 scope:form_setting。 + operationId: deleteFormWebhook + parameters: + - $ref: '#/components/parameters/FormToken' + responses: + '200': + description: 已删除,响应体为空 + '401': + $ref: '#/components/responses/Unauthorized' + '402': + $ref: '#/components/responses/PaymentRequired' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + "/forms/{form_token}/theme": + patch: + tags: + - Form Settings + summary: 更新表单主题 + description: | + 增量修改表单主题(配色、背景、头图、字体、表单容器、提交按钮);同时接受 PUT。 + - 请求体必须至少包含 primary_color / secondary_color / wallpaper / header / typography / form_container / submit_button 之一(`generate_header_image` 会被转换为 header.header_image_url,因此也算),否则 400。 + - 头图来源优先级:header_image_upload_token > header_image_base64 > header_image_url > header_image_attachment_id;设置头图后若未指定 header.type,则自动设为 image。 + - 背景图来源优先级:background_image_upload_token > background_image_url > background_image_attachment_id。设置或移除背景图时会同时去掉主题模板自带的预设背景图,背景色保留。 + - `header_image_attachment_id` / `background_image_attachment_id` 填「上传表单图片」(image_type 为 header / wallpaper)返回的 `attachment_id`;传空字符串 `""` 移除当前图片。 + - `header.carousel_images` 整体替换轮播图,最多 5 张,不能为空;每项需提供 image_upload_token / image_url / attachment_id 之一,attachment_id 必须是该表单已有的轮播图;传入后若未指定 header.type,则自动设为 carousel。 + - `text_style` 与 `typography.*` 中的键使用下划线(如 font_size),服务端转换为 CSS 连字符键并与现有样式合并;返回值中为连字符键。 + - `generate_header_image` 调用 AI 生成头图,消耗 AI 点数;点数不足返回 402,生成失败返回 400。 + - 图片 upload_token 即「上传表单图片」使用的 `form_image_token`,其记录的图片用途需匹配(header / wallpaper / carousel),且必须已完成上传。 + + OAuth 令牌需要 scope:form_setting + operationId: updateFormTheme + parameters: + - $ref: '#/components/parameters/FormToken' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ThemeInput' + responses: + '200': + description: 保存后的主题 + content: + application/json: + schema: + $ref: '#/components/schemas/FormTheme' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '402': + $ref: '#/components/responses/PaymentRequired' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + "/forms/{form_token}/exam_setting": + get: + tags: + - Form Settings + summary: 获取考试设置 + description: | + 返回考试场景表单的交卷后展示、限时答题、考前须知与分数区间评语设置。 + 仅考试场景(scene = exam)表单可用,否则返回 400。表单尚无考试设置记录时返回空对象 `{}`。 + OAuth 令牌需要 scope:form_setting。 + operationId: getExamSetting + parameters: + - $ref: '#/components/parameters/FormToken' + responses: + '200': + description: 考试设置(可能为空对象) + content: + application/json: + schema: + $ref: '#/components/schemas/ExamSetting' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '402': + $ref: '#/components/responses/PaymentRequired' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + patch: + tags: + - Form Settings + summary: 更新考试设置 + description: | + 增量更新考试场景表单的设置;同时接受 PUT。仅考试场景表单可用,否则返回 400。 + OAuth 令牌需要 scope:form_setting。 + - 套餐不支持"提交后显示成绩"时,`notice_after_filling_mode` 会被强制写为 none,且 `success_message_rich_text` 被忽略。 + - `interval_comments` 整体替换现有区间评语;仅在(本次传入或已保存的)`notice_after_filling_mode` 为 grade / explaination / answer 时生效;传空数组不会关闭区间评语。 + - 区间评语需满足 end_point ≥ start_point 且互不重叠,否则 400。 + - 表单已有必填题目时传 `show_timeout: true` 返回 400;开启限时后题目字段不能再设必填(考生信息字段的必填不受影响),要开限时先取消题目必填。 + - `notice_after_filling_mode` 不为 none 时,服务端会清空表单设置中的 success_redirect_url / success_redirect_fields。 + - 请求体不含任何可识别键时视为无操作,直接返回当前设置。 + operationId: updateExamSetting + parameters: + - $ref: '#/components/parameters/FormToken' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ExamSettingInput' + responses: + '200': + description: 保存后的考试设置 + content: + application/json: + schema: + $ref: '#/components/schemas/ExamSetting' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '402': + $ref: '#/components/responses/PaymentRequired' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + "/forms/{form_token}/evaluation_setting": + get: + tags: + - Form Settings + summary: 获取测评设置 + description: | + 返回测评场景表单的报告展示、维度(指标)设置与总分区间评语。 + 仅测评场景(scene = evaluation)表单可用,否则返回 400。表单尚无测评设置记录时返回空对象 `{}`。 + OAuth 令牌需要 scope:form_setting。 + operationId: getEvaluationSetting + parameters: + - $ref: '#/components/parameters/FormToken' + responses: + '200': + description: 测评设置(可能为空对象) + content: + application/json: + schema: + $ref: '#/components/schemas/EvaluationSetting' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '402': + $ref: '#/components/responses/PaymentRequired' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + patch: + tags: + - Form Settings + summary: 更新测评设置 + description: | + 增量更新测评场景表单的设置;同时接受 PUT。仅测评场景表单可用,否则返回 400。 + OAuth 令牌需要 scope:form_setting。 + - 套餐不支持"提交后显示测评报告"时,`notice_after_filling_mode` 会被强制写为 none。 + - `evaluation_comments` 整体替换总分区间评语,传空数组则关闭区间评语;仅在(本次传入或已保存的)`notice_after_filling_mode` 为 reports / customize 时生效。 + - 按分数区间的评语必须同时给出 start_point 与 end_point;在表单编辑器中按条件编写的评语会以 `id` + `conditions` 读回,更新时必须按原 id 传回(只能改 comment / retry,不能改条件或加区间),遗漏或改动均返回 400。 + - `indicator_setting.indicators` 整体替换维度;传回 `api_code` 可保留已有维度身份。维度只能绑定表单上可见的可计分题目,可用 `field_api_codes` 或 `field_cids` 引用,引用未知字段返回 400。 + - `notice_after_filling_mode` 不为 none 时,服务端会清空表单设置中的 success_redirect_url / success_redirect_fields。 + - 请求体不含任何可识别键时视为无操作,直接返回当前设置。 + operationId: updateEvaluationSetting + parameters: + - $ref: '#/components/parameters/FormToken' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/EvaluationSettingInput' + responses: + '200': + description: 保存后的测评设置 + content: + application/json: + schema: + $ref: '#/components/schemas/EvaluationSetting' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '402': + $ref: '#/components/responses/PaymentRequired' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + "/forms/{form_token}/field_rules": + get: + tags: + - Forms + summary: 获取字段显示规则 + description: | + 返回表单的有效字段显示规则(逻辑跳转)。`index` 为 0 起始的位置,PATCH /forms/{form_token} 的 field_rules 更新与删除按此 index 定位。 + OAuth 令牌需要 scope:forms。 + operationId: listFieldRules + parameters: + - $ref: '#/components/parameters/FormToken' + responses: + '200': + description: 字段规则列表 + content: + application/json: + schema: + type: object + required: + - count + - data + properties: + count: + type: integer + description: 规则数量 + data: + type: array + items: + $ref: '#/components/schemas/FieldRule' + '401': + $ref: '#/components/responses/Unauthorized' + '402': + $ref: '#/components/responses/PaymentRequired' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + "/forms/{form_token}/extended_attributes": + get: + tags: + - Forms + summary: 获取数据来源链接 + description: | + 返回表单已生成的"跟踪数据来源"扩展属性(按创建时间升序),每条附带携带该值的填写链接。 + OAuth 令牌需要 scope:forms。 + operationId: listExtendedAttributes + parameters: + - $ref: '#/components/parameters/FormToken' + responses: + '200': + description: 扩展属性列表 + content: + application/json: + schema: + type: object + required: + - count + - data + properties: + count: + type: integer + data: + type: array + items: + $ref: '#/components/schemas/ExtendedAttribute' + '401': + $ref: '#/components/responses/Unauthorized' + '402': + $ref: '#/components/responses/PaymentRequired' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + "/forms/{form_token}/cooperators": + get: + tags: + - Forms + summary: 获取表单协作者 + description: | + 返回表单的协作者及其角色名称,包含从文件夹继承的角色;同一用户通过多条路径获得角色时只返回距离最近的一条。 + OAuth 令牌需要 scope:forms。 + operationId: listFormCooperators + parameters: + - $ref: '#/components/parameters/FormToken' + responses: + '200': + description: 协作者列表 + content: + application/json: + schema: + type: object + required: + - count + - data + properties: + count: + type: integer + data: + type: array + items: + $ref: '#/components/schemas/Cooperator' + '401': + $ref: '#/components/responses/Unauthorized' + '402': + $ref: '#/components/responses/PaymentRequired' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + "/forms/{form_token}/copy": + post: + tags: + - Forms + summary: 复制表单 + description: | + 复制表单并返回新表单(与 GET /forms/{form_token} 相同结构)。新表单的创建者为当前调用用户(账户级令牌为账户所有者)。 + OAuth 令牌需要 scope:forms。 + - 源表单允许公开复制(can_duplicated_in_public)时,不要求调用者对源表单有访问权限;否则无权限返回 404。 + - 源表单被冻结、或源表单创建者账号被禁用时返回 403,响应体为 `{ "errors": ..., "status_code": "form_forbidden" }`(与通用错误结构不同)。 + - `folder_token` 接受文件夹 token 或 id;文件夹不存在或无管理权限返回 404。 + - 复制失败(模型校验不通过)返回 400。 + operationId: copyForm + parameters: + - $ref: '#/components/parameters/FormToken' + requestBody: + required: false + content: + application/json: + schema: + $ref: '#/components/schemas/CopyFormInput' + responses: + '200': + description: 新建的表单 + content: + application/json: + schema: + $ref: '#/components/schemas/Form' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '402': + $ref: '#/components/responses/PaymentRequired' + '403': + description: OAuth scope 不足(error_description 结构),或源表单/创建者被冻结(errors + status_code 结构) + content: + application/json: + schema: + oneOf: + - type: object + required: + - error_description + properties: + error_description: + type: string + - $ref: '#/components/schemas/FormFrozenError' + '404': + $ref: '#/components/responses/NotFound' + "/forms/{form_token}/folder": + patch: + tags: + - Forms + summary: 移动表单到文件夹 + description: | + 将表单移入指定文件夹,或移出文件夹(`folder_token` 传 null);同时接受 PUT。移动仅影响当前调用用户的文件夹归属。 + OAuth 令牌需要 scope:forms。 + - 请求体必须包含 `folder_token` 键(值可为 null),否则 400。 + - `folder_token` 只接受文件夹 token(不接受 id);文件夹不存在或当前用户无管理权限返回 404。 + - 文件夹类型(form / table)必须与表单类型一致,否则 400。 + - 移动表格请使用 `PATCH /tables/{table_token}/folder`。 + operationId: moveFormToFolder + parameters: + - $ref: '#/components/parameters/FormToken' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/FormFolderMoveInput' + responses: + '200': + description: 移动结果 + content: + application/json: + schema: + $ref: '#/components/schemas/FormFolderMoveResult' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '402': + $ref: '#/components/responses/PaymentRequired' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + "/forms/{form_token}/weixin_oauth_session": + post: + tags: + - Forms + summary: 微信授权换 openid + description: | + 用微信网页授权返回的 `code`,通过表单所选公众号换取用户 openid。 + 表单微信授权 scope 为 userinfo 时,额外返回 `unionid`(微信有返回时)与 `profile`。 + OAuth 令牌需要 scope:write_entries。 + - 表单未配置微信公众号或公众号账号不存在时返回 400;缺少 `code` 返回 400。 + - 微信侧换取失败(code 无效等)或获取用户信息失败时返回 502。 + operationId: createWeixinOauthSession + parameters: + - $ref: '#/components/parameters/FormToken' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/FormWeixinOauthSessionInput' + responses: + '200': + description: 授权结果 + content: + application/json: + schema: + $ref: '#/components/schemas/FormWeixinOauthSession' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '402': + $ref: '#/components/responses/PaymentRequired' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '502': + description: 微信 OAuth 或用户信息接口调用失败 + content: + application/json: + schema: + type: object + required: + - error_description + properties: + error_description: + type: string + description: 微信返回的 errmsg,或 "WeChat OAuth failed" / "WeChat userinfo failed" + "/forms/{form_token}/views": + get: + tags: + - Views + summary: 列出表单视图 + description: 返回当前调用者在该表单上可见的视图:自己创建的视图,以及其他协作者设为 cooperators 可见的视图; 其他用户的 personal 视图不会返回。排序遵循调用者自己保存的视图顺序,未排序的视图按 position、created_at 追加在后。 OAuth 令牌需要 scope:forms。 + operationId: listFormViews + parameters: + - $ref: '#/components/parameters/FormToken' + responses: + '200': + description: 视图列表 + content: + application/json: + schema: + type: object + required: + - count + - data + properties: + count: + type: integer + description: 返回的视图数量 + data: + type: array + items: + $ref: '#/components/schemas/FormView' + '401': + $ref: '#/components/responses/Unauthorized' + '402': + $ref: '#/components/responses/PaymentRequired' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + post: + tags: + - Views + summary: 创建表单视图 + description: 为表单创建一个新视图,创建者为当前调用者,visibility 未传时默认为 cooperators,position 为当前可见视图的最大值加 1。 每个用户在同一表单最多 100 个视图,达到上限返回 422(View limit reached)。 view_type 为 kanban 且未传 kanban_group_by_api_code 时,服务端自动选择分组字段;未传 kanban_hidden_groups 时默认隐藏 __unset__ 分组; prefer_columns 为空时使用表单默认列(看板视图最多 5 列,含 serial_number)。 filter 不合法(不是数组、字段不存在、运算符不支持、值不合法)返回 422。模型校验失败(如 name 为空或超过 120 字、分组字段不支持看板分组)返回 422。 表单视图(view_type 为 form):仅表格支持,否则 422;title 缺省为表格名称,未传 field_overrides 时按当前列顺序勾选全部可填写的列,published 缺省 false(未发布时填写链接不可用)。title / description / published / field_overrides 只适用于表单视图,用在其他类型上返回 422。创建、修改、删除表单视图(包括把视图切换为或切换出表单视图)需要调用者在该表格上拥有可新增数据的预置角色,否则 403;企业令牌不受此限制。填写项的列不存在、不能作为填写项或设置了该列类型不支持的校验时返回 422,错误信息带列 api_code。 OAuth 令牌需要 scope:forms。 + operationId: createFormView + parameters: + - $ref: '#/components/parameters/FormToken' + requestBody: + required: true + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/FormViewInput' + - type: object + required: + - name + responses: + '201': + description: 创建成功,返回视图 + content: + application/json: + schema: + $ref: '#/components/schemas/FormView' + '401': + $ref: '#/components/responses/Unauthorized' + '402': + $ref: '#/components/responses/PaymentRequired' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '422': + description: 请求无法处理(视图数量达到上限、筛选条件不合法、模型校验失败、不允许删除等),响应体为 { error_description } + content: + application/json: + schema: + $ref: '#/components/schemas/ViewUnprocessableError' + "/forms/{form_token}/views/{view_token}": + parameters: + - $ref: '#/components/parameters/FormToken' + - name: view_token + in: path + required: true + description: 视图 token + schema: + type: string + get: + tags: + - Views + summary: 获取表单视图 + description: 返回单个视图。只能读取调用者可见的视图(自己创建的或 cooperators 可见的),其他用户的 personal 视图返回 404(View cannot be found)。 OAuth 令牌需要 scope:forms。 + operationId: getFormView + responses: + '200': + description: 视图详情 + content: + application/json: + schema: + $ref: '#/components/schemas/FormView' + '401': + $ref: '#/components/responses/Unauthorized' + '402': + $ref: '#/components/responses/PaymentRequired' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + patch: + tags: + - Views + summary: 更新表单视图 + description: 更新视图配置,也接受 PUT。只更新请求中出现的键:filter 传空数组会清空筛选,不传则保持不变; kanban_group_by_api_code 变化且未同时传 kanban_group_order / kanban_hidden_groups 时,这两个列表重置为默认值。 协作者可以修改 cooperators 可见视图的配置,但只有视图创建者可以切换 visibility,否则返回 403; view_scope 为 form 的视图不允许切换 visibility。filter 不合法或模型校验失败返回 422。 field_overrides 整体替换填写项配置;表单视图的权限要求与参数限制同创建。 OAuth 令牌需要 scope:forms。 + operationId: updateFormView + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/FormViewInput' + responses: + '200': + description: 更新成功,返回视图 + content: + application/json: + schema: + $ref: '#/components/schemas/FormView' + '401': + $ref: '#/components/responses/Unauthorized' + '402': + $ref: '#/components/responses/PaymentRequired' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '422': + description: 请求无法处理(视图数量达到上限、筛选条件不合法、模型校验失败、不允许删除等),响应体为 { error_description } + content: + application/json: + schema: + $ref: '#/components/schemas/ViewUnprocessableError' + delete: + tags: + - Views + summary: 删除表单视图 + description: 删除视图。预置视图(predefined)和 view_scope 为 form 的视图不能删除,删除后调用者将不再有任何可见视图时也会拒绝,均返回 422。 删除表单视图需要与创建相同的权限(否则 403),删除后其填写链接立即失效,不可恢复。 OAuth 令牌需要 scope:forms。 + operationId: deleteFormView + responses: + '200': + description: 删除成功 + content: + application/json: + schema: + type: object + required: + - token + properties: + token: + type: string + description: 被删除视图的 token + '401': + $ref: '#/components/responses/Unauthorized' + '402': + $ref: '#/components/responses/PaymentRequired' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '422': + description: 请求无法处理(视图数量达到上限、筛选条件不合法、模型校验失败、不允许删除等),响应体为 { error_description } + content: + application/json: + schema: + $ref: '#/components/schemas/ViewUnprocessableError' + "/forms/{form_token}/views/{view_token}/entries": + get: + tags: + - Views + summary: 列出视图数据 + description: 按视图的筛选条件和排序规则返回表单数据,每页最多 50 条。返回字段限定为视图 prefer_columns 中的字段(始终包含 serial_number), prefer_columns 为空时返回全部可推送字段;排序按视图 sort 规则,未包含 serial_number 时追加 serial_number 升序。 分页使用 next 参数(行偏移量),最后一页 next 为 null。视图筛选中存在查询引擎不支持的运算符时返回 400。 只能访问调用者可见的视图,否则 404(View cannot be found)。 OAuth 令牌需要 scope:read_entries。 + operationId: listFormViewEntries + parameters: + - $ref: '#/components/parameters/FormToken' + - name: view_token + in: path + required: true + description: 视图 token + schema: + type: string + - name: next + in: query + required: false + description: 上一页响应中的 next 值(行偏移量,从 0 开始),负数按 0 处理 + schema: + type: integer + minimum: 0 + - $ref: '#/components/parameters/EntriesIncludeLabels' + responses: + '200': + description: 数据列表 + content: + application/json: + schema: + $ref: '#/components/schemas/EntryListResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '402': + $ref: '#/components/responses/PaymentRequired' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + "/forms/{form_token}/entry_attachments": + post: + tags: + - Uploads + summary: 上传数据附件 + operationId: uploadEntryAttachment + security: + - {} + - bearerAuth: [] + - basicAuth: [] + description: | + 为表单的附件字段(或表格字段中的附件列)预先上传一个文件,返回附件 ID;之后在创建 / 更新数据时把该 ID(或上传令牌)填入对应字段即可完成绑定。上传后未被任何数据引用的附件处于未使用状态。 + 鉴权方式二选一: + 1. 常规 API 凭证(HTTP Basic 的 api_key / api_secret,或 Bearer 令牌)。 + 2. 一次性上传令牌 `attachment_token`(代替常规凭证):令牌以 `entry_att_tok_` 开头,30 分钟内有效、只能使用一次,并绑定到表单、字段、列与文件元数据。请求中带有 `attachment_token` 时不再校验常规凭证,以令牌所属用户的身份访问;令牌无效、过期、已使用、所属用户已被删除,或与请求的表单 / 字段 / 列不一致时返回 400 `attachment_token is invalid or expired; prepare upload again`;上传文件的文件名、Content-Type、字节数与令牌不一致时同样返回 400。 + 没有凭证也没有 `attachment_token` 时返回 401。 + 文件内容二选一:`multipart/form-data` 的 `file` 字段;或 JSON / 表单中的 `base64`(亦可用 `data`)字段传 Base64 字符串(可带 `data:;base64,` 前缀),配合 `file_name`(亦可用 `filename`)与 `content_type`。Base64 方式解码后不能超过 5 MB,超过返回 400 `attachment exceeds 5MB limit`,解码失败返回 400 `base64 is invalid`;两者都没有时返回 400 `file or base64 is required`。 + `field_api_code` 必须指向附件字段或表格字段;指向表格字段时必须同时传 `dimension_api_code` 且指向该表格中的附件列,否则返回 400;字段或列不存在返回 404。 + 文件大小受字段(或列)设置的单文件上限限制,超出时返回 422,`error_description` 为具体校验信息(如 `文件超过了10 MB`);上传到对象存储失败时返回 422 `上传失败,请重试`(以服务端文案为准)。 + 表单不存在或当前身份无权访问时返回 404 `form cannot be found`。 + + OAuth 令牌需要 scope:write_entries + parameters: + - $ref: '#/components/parameters/FormToken' + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: + - field_api_code + properties: + field_api_code: + type: string + description: 附件字段的 api_code;表格字段中的附件列则传表格字段的 api_code + dimension_api_code: + type: string + description: 表格字段中附件列的 api_code,`field_api_code` 为表格字段时必填 + attachment_token: + type: string + description: 一次性上传令牌,以 `entry_att_tok_` 开头;传入时代替常规凭证 + file: + type: string + description: 要上传的文件;与 `base64` 二选一 + contentMediaType: application/octet-stream + base64: + type: string + description: 文件内容的 Base64 字符串,可带 data URI 前缀;与 `file` 二选一 + data: + type: string + description: 与 `base64` 等价的别名 + file_name: + type: string + description: Base64 方式的文件名,缺省为 `attachment` + filename: + type: string + description: 与 `file_name` 等价的别名 + content_type: + type: string + description: Base64 方式的 MIME 类型,缺省取 data URI 前缀,否则为 `application/octet-stream` + additionalProperties: false + application/json: + schema: + $ref: '#/components/schemas/EntryAttachmentBase64Input' + responses: + '201': + description: 上传成功 + content: + application/json: + schema: + $ref: '#/components/schemas/UploadedEntryAttachment' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '402': + $ref: '#/components/responses/PaymentRequired' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '422': + description: 附件未通过校验(如超过字段的单文件大小上限)或存储失败 + content: + application/json: + schema: + type: object + required: + - error_description + properties: + error_description: + type: string + description: 校验失败原因 + "/forms/{form_token}/import_files": + post: + tags: + - Uploads + summary: 上传导入文件 + operationId: uploadImportFile + security: + - {} + - bearerAuth: [] + - basicAuth: [] + description: | + 上传一个用于批量导入数据的表格文件(`.xlsx` / `.xls` / `.csv`)到指定表单,返回导入附件 ID;随后把它作为 `attachment_id` 交给「从已上传的表格文件导入数据」(`POST /forms/{form_token}/entry_imports`)发起导入。 + 鉴权方式二选一: + 1. 常规 API 凭证(HTTP Basic 的 api_key / api_secret,或 Bearer 令牌)。 + 2. 一次性上传令牌 `import_file_token`(代替常规凭证):令牌以 `import_file_tok_` 开头,30 分钟内有效、只能使用一次,并绑定到表单与文件元数据。请求中带有 `import_file_token` 时不再校验常规凭证,以令牌所属用户的身份访问;上传文件的原始文件名、Content-Type、字节数必须与令牌记录的一致,否则返回 400。令牌不存在、已过期、已使用,或所属用户已被删除时返回 400 `import_file_token is invalid or expired; prepare upload again`;令牌属于另一个表单时返回 400 `import_file_token was issued for another form; prepare upload again`。 + 没有凭证也没有 `import_file_token` 时返回 401。 + 请求必须为 `multipart/form-data`,字段 `file` 携带文件;缺少文件返回 400 `file is required`。 + 文件大小受账号套餐的 Excel 导入上限(单位 MB)限制,扩展名须为 xls / xlsx / csv;不满足时返回 422,`error_description` 为具体校验信息(使用上传令牌时令牌保持可用,可重新上传)。 + 表单不存在或当前身份无权访问时返回 404 `form cannot be found`。 + + OAuth 令牌需要 scope:write_entries + parameters: + - $ref: '#/components/parameters/FormToken' + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: + - file + properties: + import_file_token: + type: string + description: 一次性上传令牌,以 `import_file_tok_` 开头;传入时代替常规凭证 + file: + type: string + description: 待导入的 xlsx / xls / csv 文件;使用上传令牌时文件名、Content-Type、大小须与令牌记录的一致 + contentMediaType: application/octet-stream + additionalProperties: false + responses: + '201': + description: 上传成功 + content: + application/json: + schema: + $ref: '#/components/schemas/UploadedImportFile' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '402': + $ref: '#/components/responses/PaymentRequired' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '422': + description: 文件未通过校验(超过套餐导入上限或扩展名不支持) + content: + application/json: + schema: + type: object + required: + - error_description + properties: + error_description: + type: string + description: 校验失败原因 + "/forms/{form_token}/entry_imports": + post: + tags: + - Entries + summary: 从已上传的表格文件导入数据 + operationId: createEntryImport + description: | + 把「上传导入文件」得到的表格文件逐行追加为该表单的数据。先上传文件拿到导入附件 ID(`attachment_id`),再在这里说明哪一列写入哪个字段。 + 文件、套餐导入上限、表头行、列映射等能预先判断的问题都在创建任务之前校验,被拒绝的导入不会写入任何数据;错误信息会带上这份表格的实际布局(行列范围与表头内容),便于据此修正后重试。 + 校验通过后返回 `201`,数据在后台写入:`201` 表示任务已开始而非已完成,用「获取数据导入任务」轮询结果。 + - `attachment_id` 必须是当前用户为该表单上传的导入文件,否则返回 400(找不到该文件,需重新上传);文件不是 xls / xlsx / csv 或无法读取时返回 400。 + - `columns` 不能为空;每项用 `field_api_code` 指定目标字段,并用 `column_label`(表头文字,需与表头单元格去掉首尾空格后完全一致)或 `sheet_column_index`(列号)指定来源列,两者都给时以 `sheet_column_index` 为准。 + - 字段必须是该表单可导入的字段;同一字段不能对应多列;表头中有多列同名时必须改用 `sheet_column_index`;列号必须在表格的列范围内。 + - `header_row_index` 缺省为表格的第一行,且其下方必须还有数据行。 + - 表单的必填字段都必须有对应的列,否则返回 400 并列出缺少的字段。 + - `unique_field_code` 指定一个字段做去重:文件中该字段的值已存在于表单数据时,这一行会被跳过(计入 `skip_count`)。 + - 表单不存在或当前身份无权访问时返回 404 `form cannot be found`。 + + OAuth 令牌需要 scope:write_entries + parameters: + - $ref: '#/components/parameters/FormToken' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/EntryImportInput' + responses: + '201': + description: 导入任务已创建,数据在后台写入 + content: + application/json: + schema: + $ref: '#/components/schemas/EntryImportCreated' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '402': + $ref: '#/components/responses/PaymentRequired' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '429': + $ref: '#/components/responses/TooManyRequests' + "/forms/{form_token}/entry_imports/{id}": + get: + tags: + - Entries + summary: 获取数据导入任务 + operationId: getEntryImport + description: | + 返回一个数据导入任务的进度与结果。`status` 为 `pending` / `running` 时任务仍在进行,`processed_rows` 与 `total_rows` 可用于展示进度; + `success` / `failed` / `cancelled` 为终态,`imported_count` / `skip_count` / `invalid_count` 为写入、跳过、校验失败的行数,`warnings` 为逐行提示,`error_message` 为任务失败原因。 + 任务只能通过它导入的表单读取:id 不存在、不是合法 id 或属于其他表单时返回 404 `Import job not found for this form — check the id, and that it belongs to this form.`。 + 表单不存在或当前身份无权访问时返回 404 `form cannot be found`。 + + OAuth 令牌需要 scope:write_entries + parameters: + - $ref: '#/components/parameters/FormToken' + - $ref: '#/components/parameters/EntryImportId' + responses: + '200': + description: 导入任务 + content: + application/json: + schema: + $ref: '#/components/schemas/EntryImportJob' + '401': + $ref: '#/components/responses/Unauthorized' + '402': + $ref: '#/components/responses/PaymentRequired' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '429': + $ref: '#/components/responses/TooManyRequests' + "/form_image_attachments": + post: + tags: + - Uploads + summary: 上传表单图片 + operationId: uploadFormImage + security: + - {} + - bearerAuth: [] + - basicAuth: [] + description: | + 上传一张属于表单本身的图片(图片选项图、表头图、背景图、表头轮播图的一帧、表单的对外查询页头图,或表单描述 / 字段描述里的插图),返回图片附件 ID。图片此时尚未绑定到任何表单,之后在表单或主题中引用它时才生效。 + 鉴权方式二选一: + 1. 常规 API 凭证(HTTP Basic 的 api_key / api_secret,或 Bearer 令牌),此时必须用 `image_type` 说明图片用途,且只能是 `header`(表头图)、`wallpaper`(背景图)、`opensearch_header`(对外查询页头图)或 `description`(描述插图):返回的 `attachment_id` 交给「更新表单主题」的 `header.header_image_attachment_id` / `wallpaper.background_image_attachment_id`,或「更新对外查询」的 `header.background_image.attachment_id`;`description` 另外返回图片地址 `url`,写进表单 `description` 或字段 `notes` 的 ``,保存表单时图片即归属该表单(每张表单最多 30 张),7 天内没被任何表单用上的会被清理。`image_type` 缺失或不在 `field_choice` / `header` / `wallpaper` / `carousel` / `opensearch_header` / `description` 之内时返回 400 `image_type must be one of: field_choice, header, wallpaper, carousel, opensearch_header, description.`;`field_choice` / `carousel` 没有接受附件 ID 的地方,只能配合上传令牌上传,否则返回 400。 + 2. 一次性上传令牌 `form_image_token`(代替常规凭证):令牌以 `form_img_tok_` 开头,30 分钟内有效、只能使用一次,已记录图片用途(`field_choice` / `header` / `wallpaper` / `carousel` / `opensearch_header` / `description`,此时忽略 `image_type`)与文件元数据;上传文件的原始文件名、Content-Type、字节数必须与令牌记录的一致,否则返回 400。上传成功后不要直接使用返回的 `attachment_id`,而是在「创建表单」/「编辑表单」的选项 `image_upload_token`,或「更新表单主题」的 `header.header_image_upload_token` / `wallpaper.background_image_upload_token` / `header.carousel_images[].image_upload_token`,或「更新对外查询」的 `header.background_image.image_upload_token` 中引用同一个令牌;用途为 `description` 时没有引用令牌的地方,直接用返回的 `url`。令牌不存在、已过期、已使用,或所属用户已被删除时返回 400 `form_image_token is invalid or expired; prepare upload again`。 + 没有凭证也没有 `form_image_token` 时返回 401。请求必须为 `multipart/form-data`,字段 `file` 携带图片二进制;缺少文件返回 400 `file is required`。 + + OAuth 令牌需要 scope:`image_type`(或令牌记录的用途)为 field_choice、opensearch_header、description 时需要 forms,其余需要 form_setting + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: + - file + properties: + image_type: + type: string + enum: + - field_choice + - header + - wallpaper + - carousel + - opensearch_header + - description + description: 图片用途;不带上传令牌时必填且只能为 header / wallpaper / opensearch_header / description,带上传令牌时忽略 + form_image_token: + type: string + description: 一次性上传令牌,以 `form_img_tok_` 开头;传入时代替常规凭证 + file: + type: string + description: 图片文件;使用上传令牌时文件名、Content-Type、大小须与令牌记录的一致 + contentMediaType: application/octet-stream + additionalProperties: false + responses: + '201': + description: 上传成功 + content: + application/json: + schema: + $ref: '#/components/schemas/UploadedFormImage' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '402': + $ref: '#/components/responses/PaymentRequired' + '403': + $ref: '#/components/responses/Forbidden' + '429': + $ref: '#/components/responses/TooManyRequests' + "/tables": + get: + tags: + - Tables + summary: 获取表格列表 + operationId: listTables + description: | + 返回当前身份可见的数据表格(`kind=table`)。分页、`q`、`filters`、`sort`、`next`、`skip`、`limit` 的语义与表单列表相同,每页默认 50 条,最大 1000。 + + OAuth 令牌需要 scope:forms + parameters: + - $ref: '#/components/parameters/FormsQ' + - $ref: '#/components/parameters/FormsFilters' + - $ref: '#/components/parameters/FormsSort' + - $ref: '#/components/parameters/FormsNext' + - $ref: '#/components/parameters/FormsSkip' + - $ref: '#/components/parameters/PageLimit' + responses: + '200': + description: 表格列表 + content: + application/json: + schema: + $ref: '#/components/schemas/TableListResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '402': + $ref: '#/components/responses/PaymentRequired' + '403': + $ref: '#/components/responses/Forbidden' + '429': + $ref: '#/components/responses/TooManyRequests' + post: + tags: + - Tables + summary: 创建表格 + operationId: createTable + description: | + 创建一个数据表格。与创建表单相同,但: + - 字段类型只能是表格支持的列类型:`TextArea`、`RadioButton`、`CheckBox`、`BooleanField`、`MobileField`、`NumberField`、`DateTimeField`、`EmailField`、`LinkField`、`AttachmentField`、`FormulaField`、`RelationField`; + - 附件列不接受数量 / 大小上限等限制属性,数字 / 计算列的精度受限,违反时返回 400 并列出问题列; + - 选项列不会生成「其他」选项;`folder_token` 必须是 table 文件夹;不支持 `layout`。 + - `with_default_entries: true` 时创建后预置 3 条空白数据(与产品内新建表格一致),缺省不预置。 + 返回包含字段与视图的表格结构。 + + OAuth 令牌需要 scope:forms + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/TableCreateInput' + responses: + '201': + description: 创建成功 + content: + application/json: + schema: + $ref: '#/components/schemas/Table' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '402': + $ref: '#/components/responses/PaymentRequired' + '403': + $ref: '#/components/responses/Forbidden' + '404': + description: folder_token 指定的文件夹不存在或不可管理 + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '429': + $ref: '#/components/responses/TooManyRequests' + "/tables/{table_token}": + parameters: + - $ref: '#/components/parameters/TableToken' + get: + tags: + - Tables + summary: 获取表格结构 + operationId: getTable + description: | + 返回表格的基本信息、字段定义与当前身份可见的视图列表。token 对应的不是表格(如是普通表单)时返回 404 `table cannot be found`。 + + OAuth 令牌需要 scope:forms + responses: + '200': + description: 表格结构 + content: + application/json: + schema: + $ref: '#/components/schemas/Table' + '401': + $ref: '#/components/responses/Unauthorized' + '402': + $ref: '#/components/responses/PaymentRequired' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '429': + $ref: '#/components/responses/TooManyRequests' + parameters: + - name: include_analytics + in: query + required: false + description: 是否在每个可分析字段上附带 `analytics`(可用的聚合函数、能否分组、数据概况形态),缺省为 false。 + schema: + type: boolean + default: false + patch: + tags: + - Tables + summary: 编辑表格 + operationId: updateTable + description: | + 增量编辑表格,也接受 `PUT`。请求体与编辑表单相同(`kind` 会被忽略);`fields.add` 与 `fields.update` 中的附件列不接受限制属性,`fields.add` 的类型必须是表格支持的列类型。 + + OAuth 令牌需要 scope:forms + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/FormUpdateInput' + responses: + '200': + description: 更新成功 + content: + application/json: + schema: + $ref: '#/components/schemas/Table' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '402': + $ref: '#/components/responses/PaymentRequired' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '429': + $ref: '#/components/responses/TooManyRequests' + delete: + tags: + - Tables + summary: 删除表格 + operationId: deleteTable + description: | + 删除表格及其数据,成功返回表格 token。 + + OAuth 令牌需要 scope:forms + responses: + '200': + description: 删除成功 + content: + application/json: + schema: + type: object + required: + - token + properties: + token: + type: string + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '402': + $ref: '#/components/responses/PaymentRequired' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '429': + $ref: '#/components/responses/TooManyRequests' + "/tables/{table_token}/fields/check": + get: + tags: + - Tables + summary: 检查表格列是否已有数据 + description: | + 与 GET /forms/{form_token}/fields/check 相同,作用于数据表格的列。`table_token` 指向的不是数据表格时返回 404(`table cannot be found`)。 + + OAuth 令牌需要 scope:forms。 + operationId: checkTableFieldData + parameters: + - $ref: '#/components/parameters/TableToken' + - $ref: '#/components/parameters/FieldDataChecks' + responses: + '200': + description: 逐项检查结果 + content: + application/json: + schema: + $ref: '#/components/schemas/FieldDataCheckResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '402': + $ref: '#/components/responses/PaymentRequired' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '429': + $ref: '#/components/responses/TooManyRequests' + "/tables/{table_token}/fields/preview_convert": + get: + tags: + - Tables + summary: 预览表格列类型转换 + description: | + 与 GET /forms/{form_token}/fields/preview_convert 相同,作用于数据表格的列;`target_type` 只接受表格支持的列类型(见 `GET /field_types?kind=table`)。 + `table_token` 指向的不是数据表格时返回 404(`table cannot be found`)。 + + OAuth 令牌需要 scope:forms。 + operationId: previewTableFieldTypeChange + parameters: + - $ref: '#/components/parameters/TableToken' + - $ref: '#/components/parameters/FieldTypeChangeChecks' + responses: + '200': + description: 逐项预览结果 + content: + application/json: + schema: + $ref: '#/components/schemas/FieldTypeChangePreviewResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '402': + $ref: '#/components/responses/PaymentRequired' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '429': + $ref: '#/components/responses/TooManyRequests' + "/tables/{table_token}/entries": + parameters: + - $ref: '#/components/parameters/TableToken' + get: + tags: + - Tables + summary: 获取表格数据列表 + operationId: listTableEntries + description: | + 与 `GET /forms/{form_token}/entries` 完全相同,仅要求目标是表格(否则 404 `table cannot be found`)。 + + OAuth 令牌需要 scope:read_entries + parameters: + - $ref: '#/components/parameters/EntriesFields' + - $ref: '#/components/parameters/EntriesFilters' + - $ref: '#/components/parameters/EntriesKeyword' + - $ref: '#/components/parameters/EntriesSort' + - $ref: '#/components/parameters/EntriesCreatedAt' + - $ref: '#/components/parameters/EntriesNext' + - $ref: '#/components/parameters/EntriesSkip' + - $ref: '#/components/parameters/EntriesIncludeLabels' + - $ref: '#/components/parameters/PageLimit' + responses: + '200': + description: 数据列表 + content: + application/json: + schema: + $ref: '#/components/schemas/EntryListResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '402': + $ref: '#/components/responses/PaymentRequired' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '429': + $ref: '#/components/responses/TooManyRequests' + post: + tags: + - Tables + summary: 新增表格数据 + operationId: createTableEntry + description: | + 与 `POST /forms/{form_token}/entries` 相同。 + + OAuth 令牌需要 scope:write_entries + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/EntryInput' + responses: + '201': + description: 创建成功 + content: + application/json: + schema: + $ref: '#/components/schemas/Entry' + '400': + $ref: '#/components/responses/EntryValidationFailed' + '401': + $ref: '#/components/responses/Unauthorized' + '402': + $ref: '#/components/responses/PaymentRequired' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '429': + $ref: '#/components/responses/TooManyRequests' + "/tables/{table_token}/entries/count": + parameters: + - $ref: '#/components/parameters/TableToken' + get: + tags: + - Tables + summary: 统计表格数据条数 + operationId: countTableEntries + description: | + 与 `GET /forms/{form_token}/entries/count` 完全相同,仅要求目标是表格(否则 404 `table cannot be found`)。 + + OAuth 令牌需要 scope:read_entries + parameters: + - $ref: '#/components/parameters/EntriesFilters' + - $ref: '#/components/parameters/EntriesKeyword' + responses: + '200': + description: 数据条数 + content: + application/json: + schema: + $ref: '#/components/schemas/EntryCountResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '402': + $ref: '#/components/responses/PaymentRequired' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '429': + $ref: '#/components/responses/TooManyRequests' + "/tables/{table_token}/entries/aggregate": + parameters: + - $ref: '#/components/parameters/TableToken' + get: + tags: + - Tables + summary: 聚合统计表格数据 + operationId: aggregateTableEntries + description: | + 与 `GET /forms/{form_token}/entries/aggregate` 完全相同,仅要求目标是表格(否则 404 `table cannot be found`)。 + 字段可用的函数与分组方式见 `GET /tables/{table_token}?include_analytics=true`。 + + OAuth 令牌需要 scope:read_entries + parameters: + - $ref: '#/components/parameters/AggregateMetrics' + - $ref: '#/components/parameters/AggregateDimensions' + - $ref: '#/components/parameters/AggregateLimit' + - $ref: '#/components/parameters/EntriesFilters' + - $ref: '#/components/parameters/EntriesKeyword' + responses: + '200': + description: 聚合结果 + content: + application/json: + schema: + $ref: '#/components/schemas/EntryAggregateResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '402': + $ref: '#/components/responses/PaymentRequired' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '429': + $ref: '#/components/responses/TooManyRequests' + "/tables/{table_token}/entries/summary": + parameters: + - $ref: '#/components/parameters/TableToken' + get: + tags: + - Tables + summary: 表格数据概况 + operationId: summarizeTableEntries + description: | + 与 `GET /forms/{form_token}/entries/summary` 完全相同,仅要求目标是表格(否则 404 `table cannot be found`)。 + + OAuth 令牌需要 scope:read_entries + parameters: + - $ref: '#/components/parameters/SummaryFields' + - $ref: '#/components/parameters/SummaryIncludeOverview' + - $ref: '#/components/parameters/EntriesFilters' + - $ref: '#/components/parameters/EntriesKeyword' + responses: + '200': + description: 数据概况 + content: + application/json: + schema: + $ref: '#/components/schemas/EntrySummaryResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '402': + $ref: '#/components/responses/PaymentRequired' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '429': + $ref: '#/components/responses/TooManyRequests' + "/tables/{table_token}/views/{view_token}/entries/count": + parameters: + - $ref: '#/components/parameters/TableToken' + - $ref: '#/components/parameters/ViewToken' + get: + tags: + - Tables + summary: 统计视图数据条数 + operationId: countTableViewEntries + description: | + 与 `GET /forms/{form_token}/views/{view_token}/entries/count` 完全相同,仅要求目标是表格(否则 404 `table cannot be found`)。 + + OAuth 令牌需要 scope:read_entries + parameters: + - $ref: '#/components/parameters/EntriesFilters' + - $ref: '#/components/parameters/EntriesKeyword' + responses: + '200': + description: 数据条数 + content: + application/json: + schema: + $ref: '#/components/schemas/EntryCountResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '402': + $ref: '#/components/responses/PaymentRequired' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '429': + $ref: '#/components/responses/TooManyRequests' + "/tables/{table_token}/views/{view_token}/entries/aggregate": + parameters: + - $ref: '#/components/parameters/TableToken' + - $ref: '#/components/parameters/ViewToken' + get: + tags: + - Tables + summary: 聚合统计视图数据 + operationId: aggregateTableViewEntries + description: | + 与 `GET /forms/{form_token}/views/{view_token}/entries/aggregate` 完全相同,仅要求目标是表格(否则 404 `table cannot be found`)。 + + OAuth 令牌需要 scope:read_entries + parameters: + - $ref: '#/components/parameters/AggregateMetrics' + - $ref: '#/components/parameters/AggregateDimensions' + - $ref: '#/components/parameters/AggregateLimit' + - $ref: '#/components/parameters/EntriesFilters' + - $ref: '#/components/parameters/EntriesKeyword' + responses: + '200': + description: 聚合结果 + content: + application/json: + schema: + $ref: '#/components/schemas/EntryAggregateResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '402': + $ref: '#/components/responses/PaymentRequired' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '429': + $ref: '#/components/responses/TooManyRequests' + "/tables/{table_token}/views/{view_token}/entries/summary": + parameters: + - $ref: '#/components/parameters/TableToken' + - $ref: '#/components/parameters/ViewToken' + get: + tags: + - Tables + summary: 视图数据概况 + operationId: summarizeTableViewEntries + description: | + 与 `GET /forms/{form_token}/views/{view_token}/entries/summary` 完全相同,仅要求目标是表格(否则 404 `table cannot be found`)。 + + OAuth 令牌需要 scope:read_entries + parameters: + - $ref: '#/components/parameters/SummaryFields' + - $ref: '#/components/parameters/SummaryIncludeOverview' + - $ref: '#/components/parameters/EntriesFilters' + - $ref: '#/components/parameters/EntriesKeyword' + responses: + '200': + description: 数据概况 + content: + application/json: + schema: + $ref: '#/components/schemas/EntrySummaryResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '402': + $ref: '#/components/responses/PaymentRequired' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '429': + $ref: '#/components/responses/TooManyRequests' + "/tables/{table_token}/entries/{serial_number}": + parameters: + - $ref: '#/components/parameters/TableToken' + - $ref: '#/components/parameters/SerialNumber' + get: + tags: + - Tables + summary: 获取单条表格数据 + operationId: getTableEntry + description: | + 与 `GET /forms/{form_token}/entries/{serial_number}` 相同。 + + OAuth 令牌需要 scope:read_entries + parameters: + - $ref: '#/components/parameters/EntriesFields' + - $ref: '#/components/parameters/EntriesIncludeLabels' + responses: + '200': + description: 单条数据 + content: + application/json: + schema: + $ref: '#/components/schemas/Entry' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '402': + $ref: '#/components/responses/PaymentRequired' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '429': + $ref: '#/components/responses/TooManyRequests' + put: + tags: + - Tables + summary: 覆盖更新表格数据 + operationId: replaceTableEntry + description: | + 与 `PUT /forms/{form_token}/entries/{serial_number}` 相同(整体覆盖)。 + + OAuth 令牌需要 scope:write_entries + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/EntryInput' + responses: + '200': + description: 更新成功 + content: + application/json: + schema: + $ref: '#/components/schemas/Entry' + '400': + $ref: '#/components/responses/EntryValidationFailed' + '401': + $ref: '#/components/responses/Unauthorized' + '402': + $ref: '#/components/responses/PaymentRequired' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '429': + $ref: '#/components/responses/TooManyRequests' + patch: + tags: + - Tables + summary: 增量更新表格数据 + operationId: updateTableEntry + description: | + 与 `PATCH /forms/{form_token}/entries/{serial_number}` 相同(增量更新)。 + + OAuth 令牌需要 scope:write_entries + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/EntryInput' + responses: + '200': + description: 更新成功 + content: + application/json: + schema: + $ref: '#/components/schemas/Entry' + '400': + $ref: '#/components/responses/EntryValidationFailed' + '401': + $ref: '#/components/responses/Unauthorized' + '402': + $ref: '#/components/responses/PaymentRequired' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '429': + $ref: '#/components/responses/TooManyRequests' + delete: + tags: + - Tables + summary: 删除表格数据 + operationId: deleteTableEntry + description: | + 与 `DELETE /forms/{form_token}/entries/{serial_number}` 相同。 + + OAuth 令牌需要 scope:write_entries + responses: + '200': + description: 删除成功 + content: + application/json: + schema: + type: object + required: + - serial_number + properties: + serial_number: + type: integer + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '402': + $ref: '#/components/responses/PaymentRequired' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '429': + $ref: '#/components/responses/TooManyRequests' + post: + tags: + - Tables + summary: 增量更新表格数据(POST) + operationId: updateTableEntryViaPost + description: | + 与 `PATCH` 同路径的 POST 别名,语义为增量更新。 + + OAuth 令牌需要 scope:write_entries + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/EntryInput' + responses: + '200': + description: 更新成功 + content: + application/json: + schema: + $ref: '#/components/schemas/Entry' + '400': + $ref: '#/components/responses/EntryValidationFailed' + '401': + $ref: '#/components/responses/Unauthorized' + '402': + $ref: '#/components/responses/PaymentRequired' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '429': + $ref: '#/components/responses/TooManyRequests' + "/tables/{table_token}/entries/{serial_number}/comments": + parameters: + - $ref: '#/components/parameters/TableToken' + - $ref: '#/components/parameters/SerialNumber' + get: + tags: + - Comments + summary: 列出表格数据评论 + description: 按创建时间升序返回表格中该条数据的全部评论,规则与表单数据评论相同。token 对应的不是表格时返回 404(table cannot be found)。 OAuth 令牌需要 scope:read_entries。 + operationId: listTableEntryComments + responses: + '200': + description: 评论列表 + content: + application/json: + schema: + type: object + required: + - count + - data + properties: + count: + type: integer + description: 评论数量 + data: + type: array + items: + $ref: '#/components/schemas/EntryComment' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '402': + $ref: '#/components/responses/PaymentRequired' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + post: + tags: + - Comments + summary: 创建表格数据评论 + description: 在表格的该条数据下创建评论,参数与限制与表单数据评论相同(content 必填、最长 2000 字、提及者必须是协作者、parent_id 须属于同一条数据)。 token 对应的不是表格时返回 404(table cannot be found)。 OAuth 令牌需要 scope:read_entries。 + operationId: createTableEntryComment + requestBody: + required: true + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/EntryCommentInput' + - type: object + required: + - content + responses: + '201': + description: 创建成功,返回评论 + content: + application/json: + schema: + $ref: '#/components/schemas/EntryComment' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '402': + $ref: '#/components/responses/PaymentRequired' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + "/tables/{table_token}/entries/{serial_number}/comments/{comment_id}": + parameters: + - $ref: '#/components/parameters/TableToken' + - $ref: '#/components/parameters/SerialNumber' + - name: comment_id + in: path + required: true + description: 评论 id + schema: + type: string + patch: + tags: + - Comments + summary: 更新表格数据评论 + description: 修改表格数据评论内容,也接受 PUT。只有评论作者本人可以编辑,否则返回 400;评论不属于该条数据时返回 404。 token 对应的不是表格时返回 404(table cannot be found)。 OAuth 令牌需要 scope:read_entries。 + operationId: updateTableEntryComment + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - content + properties: + content: + type: string + maxLength: 2000 + description: 新的评论内容,可包含 @[] 提及 + additionalProperties: false + responses: + '200': + description: 更新成功,返回评论 + content: + application/json: + schema: + $ref: '#/components/schemas/EntryComment' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '402': + $ref: '#/components/responses/PaymentRequired' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + delete: + tags: + - Comments + summary: 删除表格数据评论 + description: 删除表格数据评论。评论作者本人、表格创建者或管理员角色的协作者可以删除,其他协作者返回 400;已删除的评论返回 404。 token 对应的不是表格时返回 404(table cannot be found)。 OAuth 令牌需要 scope:read_entries。 + operationId: deleteTableEntryComment + responses: + '200': + description: 删除成功 + content: + application/json: + schema: + type: object + required: + - id + properties: + id: + type: string + description: 被删除评论的 id + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '402': + $ref: '#/components/responses/PaymentRequired' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + "/tables/{table_token}/views": + get: + tags: + - Views + summary: 列出表格视图 + description: 返回当前调用者在该表格上可见的视图,规则与表单视图列表相同。token 对应的不是表格(kind 不为 table)时返回 404(table cannot be found)。 OAuth 令牌需要 scope:forms。 + operationId: listTableViews + parameters: + - $ref: '#/components/parameters/TableToken' + responses: + '200': + description: 视图列表 + content: + application/json: + schema: + type: object + required: + - count + - data + properties: + count: + type: integer + description: 返回的视图数量 + data: + type: array + items: + $ref: '#/components/schemas/FormView' + '401': + $ref: '#/components/responses/Unauthorized' + '402': + $ref: '#/components/responses/PaymentRequired' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + post: + tags: + - Views + summary: 创建表格视图 + description: 为表格创建视图,参数与限制与创建表单视图相同(每用户每表格最多 100 个视图、name 最长 120 字、filter 不合法返回 422)。 token 对应的不是表格时返回 404(table cannot be found)。 view_type 可为 form(表单视图,对外填写页),规则见创建表单视图。 OAuth 令牌需要 scope:forms。 + operationId: createTableView + parameters: + - $ref: '#/components/parameters/TableToken' + requestBody: + required: true + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/FormViewInput' + - type: object + required: + - name + responses: + '201': + description: 创建成功,返回视图 + content: + application/json: + schema: + $ref: '#/components/schemas/FormView' + '401': + $ref: '#/components/responses/Unauthorized' + '402': + $ref: '#/components/responses/PaymentRequired' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '422': + description: 请求无法处理(视图数量达到上限、筛选条件不合法、模型校验失败、不允许删除等),响应体为 { error_description } + content: + application/json: + schema: + $ref: '#/components/schemas/ViewUnprocessableError' + "/tables/{table_token}/views/{view_token}": + parameters: + - $ref: '#/components/parameters/TableToken' + - name: view_token + in: path + required: true + description: 视图 token + schema: + type: string + get: + tags: + - Views + summary: 获取表格视图 + description: 返回单个表格视图,只能读取调用者可见的视图。token 对应的不是表格时返回 404(table cannot be found)。 OAuth 令牌需要 scope:forms。 + operationId: getTableView + responses: + '200': + description: 视图详情 + content: + application/json: + schema: + $ref: '#/components/schemas/FormView' + '401': + $ref: '#/components/responses/Unauthorized' + '402': + $ref: '#/components/responses/PaymentRequired' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + patch: + tags: + - Views + summary: 更新表格视图 + description: 更新表格视图配置,也接受 PUT,规则与更新表单视图相同:只有创建者可切换 visibility(否则 403),filter 或模型校验失败返回 422。 token 对应的不是表格时返回 404(table cannot be found)。 OAuth 令牌需要 scope:forms。 + operationId: updateTableView + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/FormViewInput' + responses: + '200': + description: 更新成功,返回视图 + content: + application/json: + schema: + $ref: '#/components/schemas/FormView' + '401': + $ref: '#/components/responses/Unauthorized' + '402': + $ref: '#/components/responses/PaymentRequired' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '422': + description: 请求无法处理(视图数量达到上限、筛选条件不合法、模型校验失败、不允许删除等),响应体为 { error_description } + content: + application/json: + schema: + $ref: '#/components/schemas/ViewUnprocessableError' + delete: + tags: + - Views + summary: 删除表格视图 + description: 删除表格视图。预置视图、view_scope 为 form 的视图,以及调用者最后一个可见视图不能删除,返回 422。 token 对应的不是表格时返回 404(table cannot be found)。 删除表单视图需要可新增数据的预置角色(否则 403),删除后其填写链接立即失效,不可恢复。 OAuth 令牌需要 scope:forms。 + operationId: deleteTableView + responses: + '200': + description: 删除成功 + content: + application/json: + schema: + type: object + required: + - token + properties: + token: + type: string + description: 被删除视图的 token + '401': + $ref: '#/components/responses/Unauthorized' + '402': + $ref: '#/components/responses/PaymentRequired' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '422': + description: 请求无法处理(视图数量达到上限、筛选条件不合法、模型校验失败、不允许删除等),响应体为 { error_description } + content: + application/json: + schema: + $ref: '#/components/schemas/ViewUnprocessableError' + "/tables/{table_token}/views/{view_token}/entries": + get: + tags: + - Views + summary: 列出表格视图数据 + description: 按表格视图的筛选与排序返回数据,每页最多 50 条,分页与字段规则与表单视图数据相同。 token 对应的不是表格时返回 404(table cannot be found)。 OAuth 令牌需要 scope:read_entries。 + operationId: listTableViewEntries + parameters: + - $ref: '#/components/parameters/TableToken' + - name: view_token + in: path + required: true + description: 视图 token + schema: + type: string + - name: next + in: query + required: false + description: 上一页响应中的 next 值(行偏移量,从 0 开始),负数按 0 处理 + schema: + type: integer + minimum: 0 + - $ref: '#/components/parameters/EntriesIncludeLabels' + responses: + '200': + description: 数据列表 + content: + application/json: + schema: + $ref: '#/components/schemas/EntryListResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '402': + $ref: '#/components/responses/PaymentRequired' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + "/opensearch/queries": + get: + tags: + - OpenSearch + summary: 列出对外查询 + description: 返回当前调用者创建的对外查询(账户级令牌视为账户所有者),按创建时间倒序,只含摘要字段。 传 form_token 时只返回该表单下的查询;form_token 不存在或调用者无权访问时返回 404,而不是忽略该参数。 OAuth 令牌需要 scope:forms。 + operationId: listOpensearchQueries + parameters: + - name: form_token + in: query + required: false + description: 按表单 token 过滤 + schema: + type: string + responses: + '200': + description: 对外查询列表 + content: + application/json: + schema: + type: object + required: + - count + - data + properties: + count: + type: integer + description: 返回的查询数量 + data: + type: array + items: + $ref: '#/components/schemas/OpensearchQuerySummary' + '401': + $ref: '#/components/responses/Unauthorized' + '402': + $ref: '#/components/responses/PaymentRequired' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + post: + tags: + - OpenSearch + summary: 创建对外查询 + description: 为 form_token 指定的表单创建对外查询,创建者为当前调用者。调用者必须能管理该表单:无权访问返回 404,能读取但不能管理返回 403; 当前套餐不含对外查询功能返回 402。 search_field_rules 中引用的字段必须是该表单可查询的字段,display_field_rules 中的必须是可展示的字段,否则返回 400 并列出可用的 api_code; search_field_rules / display_field_rules 传入时不能为空数组;scope_conditions 需要套餐支持限定可查询数据范围。 保存后异步生成预览图。 OAuth 令牌需要 scope:forms。 + operationId: createOpensearchQuery + requestBody: + required: true + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/OpensearchQueryInput' + - type: object + required: + - form_token + - name + - display_field_rules + properties: + form_token: + type: string + description: 所属表单的 token(也可作为 query 参数传递) + responses: + '201': + description: 创建成功,返回对外查询详情 + content: + application/json: + schema: + $ref: '#/components/schemas/OpensearchQuery' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '402': + $ref: '#/components/responses/PaymentRequired' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + "/opensearch/queries/{query_token}": + parameters: + - name: query_token + in: path + required: true + description: 对外查询 token(也接受 id) + schema: + type: string + get: + tags: + - OpenSearch + summary: 获取对外查询 + description: 返回对外查询详情,含查询字段规则与展示字段规则。只能读取调用者自己创建的查询,其他用户的查询返回 404(opensearch query cannot be found)。 OAuth 令牌需要 scope:forms。 + operationId: getOpensearchQuery + responses: + '200': + description: 对外查询详情 + content: + application/json: + schema: + $ref: '#/components/schemas/OpensearchQuery' + '401': + $ref: '#/components/responses/Unauthorized' + '402': + $ref: '#/components/responses/PaymentRequired' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + patch: + tags: + - OpenSearch + summary: 更新对外查询 + description: 更新调用者自己创建的对外查询,也接受 PUT,只更新请求中出现的键。所属表单已被删除时返回 400。 字段规则校验与创建相同:引用了不可查询 / 不可展示的字段、规则为空数组、name 为空等均返回 400。 更新不再检查表单管理权限与对外查询套餐功能。 OAuth 令牌需要 scope:forms。 + operationId: updateOpensearchQuery + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/OpensearchQueryInput' + responses: + '200': + description: 更新成功,返回对外查询详情 + content: + application/json: + schema: + $ref: '#/components/schemas/OpensearchQuery' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '402': + $ref: '#/components/responses/PaymentRequired' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + "/opensearch/query_suggestions": + get: + tags: + - OpenSearch + summary: 对外查询字段建议 + description: 返回某表单上可用于对外查询的查询字段与展示字段,以及推荐的字段 api_code,用于构造 search_field_rules / display_field_rules。 调用者必须能管理该表单:无权访问返回 404,能读取但不能管理返回 403。不检查对外查询套餐功能。 OAuth 令牌需要 scope:forms。 + operationId: listOpensearchQuerySuggestions + parameters: + - name: form_token + in: query + required: true + description: 表单 token + schema: + type: string + responses: + '200': + description: 字段建议 + content: + application/json: + schema: + type: object + required: + - form_token + - search_fields + - display_fields + - recommend_search_fields + - recommend_display_fields + properties: + form_token: + type: string + description: 表单 token + search_fields: + type: array + description: 可作为查询条件的字段 + items: + $ref: '#/components/schemas/OpensearchQuerySuggestion' + display_fields: + type: array + description: 可作为展示字段的字段 + items: + $ref: '#/components/schemas/OpensearchDisplayQuerySuggestion' + recommend_search_fields: + type: array + description: 推荐的查询字段 api_code(姓名 + 手机,无手机字段时为空) + items: + type: string + recommend_display_fields: + type: array + description: 推荐的展示字段 api_code(排除隐私字段、仅展示字段与微信字段) + items: + type: string + '401': + $ref: '#/components/responses/Unauthorized' + '402': + $ref: '#/components/responses/PaymentRequired' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + "/folders": + get: + tags: + - Folders + summary: 获取文件夹列表 + operationId: listFolders + description: | + 列出当前用户拥有管理权限的文件夹(表单文件夹与表格文件夹都在内,用 `kind` 区分),按创建时间倒序。使用账号级凭证时返回账号所有者的文件夹。 + `limit` 缺省 50,取值会被限定在 1 到 100 之间;不支持翻页,`total` 为全部数量,`count` 为本次返回数量。 + + OAuth 令牌需要 scope:forms + parameters: + - name: limit + in: query + required: false + description: 返回数量上限,缺省 50,最大 100 + schema: + type: integer + minimum: 1 + maximum: 100 + default: 50 + responses: + '200': + description: 文件夹列表 + content: + application/json: + schema: + type: object + required: + - total + - count + - data + properties: + total: + type: integer + description: 可管理的文件夹总数 + count: + type: integer + description: 本次返回的数量 + data: + type: array + items: + $ref: '#/components/schemas/Folder' + '401': + $ref: '#/components/responses/Unauthorized' + '402': + $ref: '#/components/responses/PaymentRequired' + '403': + $ref: '#/components/responses/Forbidden' + post: + tags: + - Folders + summary: 创建文件夹 + operationId: createFolder + description: | + 以当前用户身份创建一个文件夹,创建者自动成为文件夹管理者。`kind` 决定文件夹放表单(`form`,缺省)还是表格(`table`)。 + `name` 为空时返回 400 `Name cannot be empty`;`kind` 不是 form / table 时返回 400 `Kind must be one of: form, table`;模型校验失败时返回 400,`error_description` 为校验信息。 + + OAuth 令牌需要 scope:forms + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/FolderInput' + responses: + '201': + description: 创建成功 + content: + application/json: + schema: + $ref: '#/components/schemas/Folder' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '402': + $ref: '#/components/responses/PaymentRequired' + '403': + $ref: '#/components/responses/Forbidden' + "/predefined_choices_sets": + get: + tags: + - Predefined Choices Sets + summary: 获取预置选项集 + operationId: listPredefinedChoicesSets + description: | + 列出当前账号自定义(type 为 custom)的预置选项集,仅返回与当前请求语言一致的记录(以服务端实现为准);系统内置的全局选项集不会返回。 + OAuth 令牌需要 scope:form_setting。 + responses: + '200': + description: 选项集数组 + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/PredefinedChoicesSet' + '401': + $ref: '#/components/responses/Unauthorized' + '402': + $ref: '#/components/responses/PaymentRequired' + '403': + $ref: '#/components/responses/Forbidden' + post: + tags: + - Predefined Choices Sets + summary: 创建预置选项集 + operationId: createPredefinedChoicesSet + description: | + 在当前账号下创建一个自定义预置选项集。按 `label` 查找:账号内已有同名选项集时会直接覆盖其 `choices`(相当于更新),否则新建。 + OAuth 令牌需要 scope:form_setting。 + 请求体必须包裹在 `predefined_choices_set` 键下,缺少该键时返回 400。`choices` 中的空字符串会被过滤;`label` 与过滤后的 `choices` 都不能为空,否则返回 400,响应体为 `{ errors, status: "failed" }`。 + 成功时返回 200(不是 201)。 + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - predefined_choices_set + properties: + predefined_choices_set: + $ref: '#/components/schemas/PredefinedChoicesSetInput' + additionalProperties: false + responses: + '200': + description: 创建(或按 label 覆盖)成功 + content: + application/json: + schema: + $ref: '#/components/schemas/PredefinedChoicesSet' + '400': + description: 校验失败(label 或 choices 为空) + content: + application/json: + schema: + $ref: '#/components/schemas/PredefinedChoicesSetError' + '401': + $ref: '#/components/responses/Unauthorized' + '402': + $ref: '#/components/responses/PaymentRequired' + '403': + $ref: '#/components/responses/Forbidden' + "/predefined_choices_sets/{id}": + patch: + tags: + - Predefined Choices Sets + summary: 更新预置选项集 + operationId: updatePredefinedChoicesSet + description: | + 更新当前账号的一个自定义预置选项集的 `label` 和 / 或 `choices`。也接受 PUT 方法,行为相同。 + OAuth 令牌需要 scope:form_setting。 + 只能更新 type 为 custom 且属于当前账号的选项集:全局选项集或其他账号的选项集返回 403 `{ errors: "forbidden", status: "failed" }`;ID 不存在返回 404 `{ errors: "not found", status: "failed" }`;校验失败返回 400 `{ errors, status: "failed" }`。 + parameters: + - name: id + in: path + required: true + description: 选项集 ID(24 位十六进制) + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - predefined_choices_set + properties: + predefined_choices_set: + $ref: '#/components/schemas/PredefinedChoicesSetInput' + additionalProperties: false + responses: + '200': + description: 更新成功 + content: + application/json: + schema: + $ref: '#/components/schemas/PredefinedChoicesSet' + '400': + description: 校验失败(label 或 choices 为空) + content: + application/json: + schema: + $ref: '#/components/schemas/PredefinedChoicesSetError' + '401': + $ref: '#/components/responses/Unauthorized' + '402': + $ref: '#/components/responses/PaymentRequired' + '403': + description: 无权操作该选项集(全局选项集或其他账号的选项集),或 OAuth scope 不足 + content: + application/json: + schema: + oneOf: + - $ref: '#/components/schemas/PredefinedChoicesSetError' + - type: object + required: + - error_description + properties: + error_description: + type: string + '404': + description: 选项集不存在 + content: + application/json: + schema: + $ref: '#/components/schemas/PredefinedChoicesSetError' + delete: + tags: + - Predefined Choices Sets + summary: 删除预置选项集 + operationId: deletePredefinedChoicesSet + description: | + 删除当前账号的一个自定义预置选项集,成功时返回 200 且无响应体。 + OAuth 令牌需要 scope:form_setting。 + 只能删除 type 为 custom 且属于当前账号的选项集:全局选项集或其他账号的选项集返回 403 `{ errors: "forbidden", status: "failed" }`;ID 不存在返回 404 `{ errors: "not found", status: "failed" }`。 + parameters: + - name: id + in: path + required: true + description: 选项集 ID(24 位十六进制) + schema: + type: string + responses: + '200': + description: 删除成功,无响应体 + '401': + $ref: '#/components/responses/Unauthorized' + '402': + $ref: '#/components/responses/PaymentRequired' + '403': + description: 无权操作该选项集(全局选项集或其他账号的选项集),或 OAuth scope 不足 + content: + application/json: + schema: + oneOf: + - $ref: '#/components/schemas/PredefinedChoicesSetError' + - type: object + required: + - error_description + properties: + error_description: + type: string + '404': + description: 选项集不存在 + content: + application/json: + schema: + $ref: '#/components/schemas/PredefinedChoicesSetError' + "/template_usages": + post: + tags: + - Templates + summary: 用模板创建表单 + operationId: createTemplateUsage + description: | + 以当前用户身份选用一个模板市场的模板,复制出一张新表单,返回新表单的 token 与编辑页地址。 + OAuth 令牌需要 scope:forms。 + 模板通过 `id`(或 `template_id`)指定,接受模板 token 或 24 位十六进制 ID。可选参数:`tags`(标签,数组或逗号分隔字符串)、`tag_id` / `folder_id`(创建后归入的标签 / 文件夹)、`scene`(目标场景;模板字段超出该场景允许范围时回退为 form)、`frm`(来源标记,影响记录的 created_from)、`create_source`、`group_id`。 + 付费相关的分支: + - 付费模板且当前用户未购买:返回 200,响应体为 `{ template_url }`(模板购买页地址),不创建表单。 + - 模板使用了当前套餐不可用的付费功能:管理员且可开启功能试用时返回 403 `{ error: "CAN_FEATURE_TRIAL", target_plan, target_trial_type, ... }`;否则返回 403 `{ error: "PAID_FEATURE_REQUIRED", target_plan, template_url }`;来自渠道合作账号返回 403 `{ error: "UNAVAILABLE_TO_TRIAL_OR_UPGRADE" }`。 + - 传 `form_app_trial_alert` 且模板绑定了需购买 / 试用的表单应用时,返回 200 `{ error: "CANNOT_FORM_APP_TRIAL" | "FORM_APP_TRIAL_REQUIRED", ... }`,可用 `confirm_unbind_form_app` / `confirm_form_app_trial` 确认后重试。 + 表单创建失败(含内容风控未通过)返回 400 `{ error }`。模板不存在时返回 404,响应为 HTML 页面而非 JSON。 + `mobile_editor_forbidden` 仅在移动端请求且新表单为卡片布局但当前套餐不能使用移动端编辑器时出现。 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/TemplateUsageInput' + responses: + '200': + description: 创建成功;或因付费 / 表单应用限制未创建,响应体含 `template_url` 或 `error` + content: + application/json: + schema: + anyOf: + - type: object + required: + - success_url + - form_token + properties: + success_url: + type: string + description: 新表单编辑页的站内路径(如 `/forms/{token}/edit`) + form_token: + type: string + description: 新表单 token + mobile_editor_forbidden: + type: boolean + description: 移动端请求且当前套餐不能用移动端编辑器编辑卡片布局表单时为 true + - type: object + required: + - template_url + properties: + template_url: + type: string + description: 付费模板未购买时的模板购买页地址 + - type: object + required: + - error + properties: + error: + type: string + enum: + - CANNOT_FORM_APP_TRIAL + - FORM_APP_TRIAL_REQUIRED + cannot_trial_form_apps: + type: array + items: + type: string + description: 需购买的表单应用名称 + need_trial_form_apps: + type: array + items: + type: string + description: 需试用的表单应用名称 + expire_days: + type: integer + description: 表单应用试用剩余天数 + template_url: + type: string + '400': + description: 表单创建失败 + content: + application/json: + schema: + type: object + required: + - error + properties: + error: + type: string + description: 失败原因(如 `模板选用失败!...`) + '401': + $ref: '#/components/responses/Unauthorized' + '402': + $ref: '#/components/responses/PaymentRequired' + '403': + description: 模板含当前套餐不可用的付费功能,或 OAuth scope 不足 + content: + application/json: + schema: + oneOf: + - type: object + required: + - error + properties: + error: + type: string + enum: + - PAID_FEATURE_REQUIRED + - CAN_FEATURE_TRIAL + - UNAVAILABLE_TO_TRIAL_OR_UPGRADE + target_plan: + type: string + description: 使用该模板所需的最低套餐代码 + target_trial_type: + type: string + description: 可开启的功能试用类型 + in_trial_form_apps: + type: array + items: + type: string + current_trial_type: + type: string + current_trial_end_date: + type: string + template_url: + type: string + - type: object + required: + - error_description + properties: + error_description: + type: string + '404': + description: 模板不存在(返回 HTML 页面) + content: + text/html: + schema: + type: string + "/contacts": + get: + tags: + - Contacts + summary: 获取联系人列表 + operationId: listContacts + description: | + 分页列出当前用户的联系人,按 ID 倒序,每页固定 50 条。使用账号级凭证时返回账号所有者的联系人。 + OAuth 令牌需要 scope:read_contacts。 + 游标翻页:响应中的 `next` 为下一页第一条联系人的 ID,把它作为查询参数 `next` 传入即可获取下一页;没有更多数据时 `next` 为 null。 + parameters: + - name: next + in: query + required: false + description: 上一页响应返回的 `next` 游标(联系人 ID) + schema: + type: string + responses: + '200': + description: 联系人列表 + content: + application/json: + schema: + type: object + required: + - total + - count + - data + - next + properties: + total: + type: integer + description: 联系人总数 + count: + type: integer + description: 本页数量 + data: + type: array + items: + $ref: '#/components/schemas/Contact' + next: + type: + - string + - 'null' + description: 下一页游标,无更多数据时为 null + '401': + $ref: '#/components/responses/Unauthorized' + '402': + $ref: '#/components/responses/PaymentRequired' + '403': + $ref: '#/components/responses/Forbidden' + "/me": + get: + tags: + - Account + summary: 获取当前用户 + operationId: getCurrentUser + description: | + 返回当前凭证对应的用户信息。使用账号级凭证(无具体用户)时返回账号所有者。 + OAuth 令牌需要 scope:users。 + responses: + '200': + description: 当前用户 + content: + application/json: + schema: + $ref: '#/components/schemas/CurrentUser' + '401': + $ref: '#/components/responses/Unauthorized' + '402': + $ref: '#/components/responses/PaymentRequired' + '403': + $ref: '#/components/responses/Forbidden' + "/billing_account": + get: + tags: + - Account + summary: 获取当前账号 + operationId: getBillingAccount + description: | + 返回当前凭证所属账号的信息:套餐、当月用量与配额、功能试用状态,以及已绑定的收款商户 / 微信支付 / 支付宝账户。 + OAuth 令牌需要 scope:users。 + `usage` 在账号尚无当月用量记录时为 null;每项增值服务的配额为无限时只返回 `unit`、`unlimited: true`、`consumed_quota`。 + responses: + '200': + description: 账号信息 + content: + application/json: + schema: + $ref: '#/components/schemas/BillingAccount' + '401': + $ref: '#/components/responses/Unauthorized' + '402': + $ref: '#/components/responses/PaymentRequired' + '403': + $ref: '#/components/responses/Forbidden' + "/billing_account/users": + get: + tags: + - Account + summary: 获取账号成员 + operationId: listBillingAccountUsers + description: | + 列出当前账号下的成员(含已停用成员),先按状态升序(active 在前)、再按创建时间倒序。 + OAuth 令牌需要 scope:users。 + `limit` 缺省 50,取值会被限定在 1 到 100 之间;不支持翻页,`total` 为成员总数,`count` 为本次返回数量。 + parameters: + - name: limit + in: query + required: false + description: 返回数量上限,缺省 50,最大 100 + schema: + type: integer + minimum: 1 + maximum: 100 + default: 50 + responses: + '200': + description: 成员列表 + content: + application/json: + schema: + type: object + required: + - total + - count + - data + properties: + total: + type: integer + description: 成员总数 + count: + type: integer + description: 本次返回的数量 + data: + type: array + items: + $ref: '#/components/schemas/BillingAccountUser' + '401': + $ref: '#/components/responses/Unauthorized' + '402': + $ref: '#/components/responses/PaymentRequired' + '403': + $ref: '#/components/responses/Forbidden' + "/billing_account/invoices": + get: + tags: + - Account + summary: 获取账号发票 + operationId: listBillingAccountInvoices + description: | + 列出当前账号申请过的电子发票(按申请时间倒序,一次全部返回,不分页),以及可开票但尚未开票的消费金额。 + 只有账号管理员可以读取:非管理员成员返回 403(`Only an account admin can read this.`);使用账号级凭证(无具体用户)时视为账号所有者。 + OAuth 令牌需要 scope:users。 + responses: + '200': + description: 发票列表与未开票金额 + content: + application/json: + schema: + $ref: '#/components/schemas/BillingAccountInvoicesResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '402': + $ref: '#/components/responses/PaymentRequired' + '403': + $ref: '#/components/responses/Forbidden' + '429': + $ref: '#/components/responses/TooManyRequests' + "/billing_account/payment_histories": + get: + tags: + - Account + summary: 获取账号消费记录 + operationId: listBillingAccountPaymentHistories + description: | + 列出当前账号在金数据的充值与消费记录(购买套餐、短信、AI 点数、附件空间等账户余额流水),按时间倒序。 + 这里是账号自己花了什么,不含收款表单收到的款项。 + 只有账号管理员可以读取:非管理员成员返回 403(`Only an account admin can read this.`);使用账号级凭证(无具体用户)时视为账号所有者。 + 不支持翻页:`limit` 缺省 50,取值会被限定在 1 到 1000 之间;`total_records` 为满足条件的记录总数,`returned_records` 为本次返回数量。 + `include_balance=true` 时附带 `current_balance`(账户余额与各项配额余量);本次有返回记录时附带 `statistics`(只统计本次返回的记录)。 + OAuth 令牌需要 scope:users。 + parameters: + - name: start_date + in: query + required: false + description: 起始日期(含当天),按北京时间解析并取当天 00:00,如 `2024-01-01` + schema: + type: string + examples: + firstDay: + value: '2024-01-01' + - name: end_date + in: query + required: false + description: 结束日期(含当天),按北京时间解析并取当天 23:59:59,如 `2024-12-31` + schema: + type: string + examples: + lastDay: + value: '2024-12-31' + - name: verb + in: query + required: false + description: | + 只返回该类型的记录,值为记录的 `verb`。可能出现的类型: + `charge`(充值)、`coupons`(代金券)、`plan_charge`(购买套餐)、`plan_convert`(转换套餐)、`plan_for_free`、`convert_to_pro2`、`expand_entries_charge`、 + `sms_charge`(购买短信)、`active_mail_charge`(购买邮件)、`ai_points_charge`(购买 AI 点数)、`storage_quota_charge`(购买附件空间)、`audio_quota_charge`(购买语音时长)、 + `sub_account_charge` / `sub_account_for_free`(企业成员)、`submitter_charge` / `submitter_for_free`、`weixin_account_charge`(购买微信公众号)、 + `app_charge` / `app_upgrade_charge` / `app_package_charge`(应用)、`template_charge`(模板)、`form_application_charge` / `form_application_upgrade_charge`、 + `cname_protocol_charge`、`hide_report_form_charge`、`show_alipay_charge`、`gd_portal_charge` / `gd_portal_customer_charge`、`other_charge`。 + 传入其他值时结果为空。 + schema: + type: string + examples: + smsCharge: + value: sms_charge + - name: limit + in: query + required: false + description: 返回数量上限,缺省 50,最大 1000(超过按 1000 处理,小于 1 按 1 处理) + schema: + type: integer + default: 50 + - name: include_balance + in: query + required: false + description: 为 `true` 时附带 `current_balance`。缺省为 false + schema: + type: boolean + default: false + responses: + '200': + description: 消费记录 + content: + application/json: + schema: + $ref: '#/components/schemas/BillingAccountPaymentHistoriesResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '402': + $ref: '#/components/responses/PaymentRequired' + '403': + $ref: '#/components/responses/Forbidden' + '429': + $ref: '#/components/responses/TooManyRequests' + "/trade/merchant": + get: + tags: + - Trade + summary: 获取小金商户信息 + operationId: getTradeMerchant + description: | + 返回当前账号的小金商户(由金数据代收款的商户)及其入驻进度:商户资料、主体与联系信息、余额、结算信息、支付渠道授权情况、冻结状态、已绑定的收款表单,以及下一步操作的入口地址。 + - 账号还没有小金商户时同样返回 200:`merchant_stage` 说明入驻申请走到了哪一步,`merchant` / `subject` / `contact` / `balance` / `settlement` / `freeze` 为 null,`channels` 为空数组。 + - 通过自有微信支付 / 支付宝商户收款(直连)的账号在金数据没有费率、结算账户、余额和入驻状态,这里不会给出这些信息。 + - 银行卡号、手机号、邮箱、证件号等敏感字段按产品「资金账户信息」页的规则打码返回:保留首尾各 4 位(手机号保留前 3 位、后 4 位),中间以 `*` 代替。 + - `payment_form_tokens` 只含当前身份可访问的收款表单(含快速支付);使用账号级凭证时按账号所有者计算。 + - `balance` 在余额查询失败时为 null,此时不代表余额为 0。 + + OAuth 令牌需要 scope:users + responses: + '200': + description: 小金商户信息与入驻进度 + content: + application/json: + schema: + $ref: '#/components/schemas/TradeMerchant' + '401': + $ref: '#/components/responses/Unauthorized' + '402': + $ref: '#/components/responses/PaymentRequired' + '403': + $ref: '#/components/responses/Forbidden' + '429': + $ref: '#/components/responses/TooManyRequests' + "/trade/transactions": + get: + tags: + - Trade + summary: 获取收款订单列表 + operationId: listTradeTransactions + description: | + 列出小金商户的收款订单(客户在收款表单上支付产生的订单),按下单时间倒序。只含小金商户的订单:直连收款的订单、模拟支付的订单,以及快速支付中未支付的记录都不在其中。 + - 日期范围按下单时间筛选,按北京时间计,`start_date` / `end_date` 均包含当天且必须同时传。不传时默认最近 90 天;传了 `order_no` 且不传日期时不限时间。响应的 `queried_range` 给出实际使用的范围(不限时间时两端为 null)。 + - 明细上限:日期范围最多 90 天,且匹配的订单最多 200 条。超出任一上限时不返回明细:`transactions` 为 null,`detail_limit` 说明原因,`stats` 给出该范围的交易金额、成交量与订单量,请缩小范围后再查。 + - 分页:`limit` 为每页条数(缺省且最大 50),`next` 为页码。还有下一页时响应带 `next`(下一页的页码),原样回传即可;`page_info.total` 为匹配的订单总数。 + - 账号没有小金商户时返回 404。 + + OAuth 令牌需要 scope:users + parameters: + - $ref: '#/components/parameters/TradeStartDate' + - $ref: '#/components/parameters/TradeEndDate' + - $ref: '#/components/parameters/TradePaidStatus' + - $ref: '#/components/parameters/TradePaymentChannel' + - $ref: '#/components/parameters/TradeFormToken' + - name: order_no + in: query + required: false + description: 按单号精确查找,匹配交易单号(`trade_no`)、商户订单号(`merchant_out_trade_no`)或金数据单号(`out_trade_no`)之一。不传日期时不限时间。 + schema: + type: string + - $ref: '#/components/parameters/TradePageLimit' + - $ref: '#/components/parameters/TradePageNext' + responses: + '200': + description: 收款订单列表 + content: + application/json: + schema: + $ref: '#/components/schemas/TradeTransactionListResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '402': + $ref: '#/components/responses/PaymentRequired' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/MerchantNotFound' + '429': + $ref: '#/components/responses/TooManyRequests' + "/trade/transactions/aggregate": + get: + tags: + - Trade + summary: 统计收款订单 + operationId: aggregateTradeTransactions + description: | + 统计一段日期范围内小金商户收款订单的交易金额、成交量与订单量,可按一个维度分组。口径与「获取收款订单列表」一致(只含小金商户的订单,不含模拟支付与快速支付中未支付的记录);统计覆盖整个范围,不受明细上限约束。 + - 日期范围按下单时间筛选,按北京时间计,两端包含当天且必须同时传;不传时默认最近 30 天。范围最多 366 天,超出返回 400。 + - `transactions_amount`(交易金额):已支付类订单的金额减去各自的成功退款,计入订单下单的那一天;已退款、未支付的订单贡献 0。`transactions_count`(成交量):已支付、部分退款、退款中的订单数。`orders_count`(订单量):全部匹配的订单数,含未支付。 + - `group_by=time` 按时间分组:范围不足 90 天按日、90 天及以上按周、365 天及以上按月,`granularity` 给出粒度,没有订单的时段补零。`paid_status` / `payment_channel` 每个取值一组,`payment_channel` 为 null 的一组是尚未发起支付的订单。各组在相同筛选下累加等于总计。 + - 账号没有小金商户时返回 404。 + + OAuth 令牌需要 scope:users + parameters: + - $ref: '#/components/parameters/TradeStartDate' + - $ref: '#/components/parameters/TradeEndDate' + - name: group_by + in: query + required: false + description: 分组维度,一次只能按一个维度分组;缺省不分组,只返回总计。其他取值返回 400。 + schema: + type: string + enum: + - time + - paid_status + - payment_channel + - $ref: '#/components/parameters/TradePaidStatus' + - $ref: '#/components/parameters/TradePaymentChannel' + - $ref: '#/components/parameters/TradeFormToken' + responses: + '200': + description: 收款订单统计 + content: + application/json: + schema: + $ref: '#/components/schemas/TradeTransactionAggregateResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '402': + $ref: '#/components/responses/PaymentRequired' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/MerchantNotFound' + '429': + $ref: '#/components/responses/TooManyRequests' + "/trade/refunds": + get: + tags: + - Trade + summary: 获取退款列表 + operationId: listTradeRefunds + description: | + 列出小金商户向客户发起的退款,并给出统计。退款按发起时间筛选和倒序排列,而不是按订单的下单时间或退款到账时间。只有小金商户有退款记录,直连收款的账号在这里没有数据。 + - 日期范围按北京时间计,两端包含当天且必须同时传。不传时默认最近 90 天;传了 `refund_no` 且不传日期时不限时间。`queried_range` 给出实际使用的范围。 + - `include` 决定返回内容:`both`(缺省)同时返回 `stats` 与 `refunds`,`stats` 只返回统计,`records` 只返回记录。 + - 明细上限与「获取收款订单列表」相同(最多 90 天、200 条):超出时 `refunds` 为 null,`detail_limit` 说明原因,`stats` 照常返回。 + - `stats`:全部匹配退款的条数与金额、按状态拆分的条数与金额、退款成功涉及的订单数(去重),以及按退款发起日期的趋势(有日期范围时才有;不足 90 天按日、90 天及以上按周、365 天及以上按月,无退款的时段补零)。趋势中每个时段的订单数在该时段内去重,各时段之和可能大于总的订单数。 + - 分页同「获取收款订单列表」;`include=stats` 时不返回 `next`。 + - 账号没有小金商户时返回 404。 + + OAuth 令牌需要 scope:users + parameters: + - $ref: '#/components/parameters/TradeStartDate' + - $ref: '#/components/parameters/TradeEndDate' + - name: status + in: query + required: false + description: 只保留该状态的退款,记录、统计与趋势都按它筛选:`processing`(处理中)、`success`(退款成功)、`error`(退款失败)。其他取值返回 400。 + schema: + type: string + enum: + - processing + - success + - error + - name: refund_no + in: query + required: false + description: 按退款流水号(`refund_no`)精确查找。不传日期时不限时间。 + schema: + type: string + - $ref: '#/components/parameters/TradeInclude' + - $ref: '#/components/parameters/TradePageLimit' + - $ref: '#/components/parameters/TradePageNext' + responses: + '200': + description: 退款列表与统计 + content: + application/json: + schema: + $ref: '#/components/schemas/TradeRefundListResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '402': + $ref: '#/components/responses/PaymentRequired' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/MerchantNotFound' + '429': + $ref: '#/components/responses/TooManyRequests' + "/trade/payouts": + get: + tags: + - Trade + summary: 获取提现列表 + operationId: listTradePayouts + description: | + 列出小金商户从金数据余额提现到自有银行账户的记录,并给出统计。提现按申请时间筛选和倒序排列;每条记录带本次提现覆盖的结算日期范围,但不能按它筛选。只有小金商户有提现记录,直连收款的账号在这里没有数据。 + - 日期范围按北京时间计,两端包含当天且必须同时传;不传时默认最近 90 天。`queried_range` 给出实际使用的范围。 + - `include` 决定返回内容:`both`(缺省)同时返回 `stats` 与 `payouts`,`stats` 只返回统计,`records` 只返回记录。 + - 明细上限与「获取收款订单列表」相同(最多 90 天、200 条):超出时 `payouts` 为 null,`detail_limit` 说明原因,`stats` 照常返回。 + - `stats`:全部匹配提现的条数与金额,以及按状态拆分的条数与金额。 + - 分页同「获取收款订单列表」;`include=stats` 时不返回 `next`。 + - 待提现余额与结算周期见「获取小金商户信息」。账号没有小金商户时返回 404。 + + OAuth 令牌需要 scope:users + parameters: + - $ref: '#/components/parameters/TradeStartDate' + - $ref: '#/components/parameters/TradeEndDate' + - name: status + in: query + required: false + description: 只保留该状态的提现,记录与统计都按它筛选:`processing`(处理中)、`success`(提现成功)、`error`(提现失败)。其他取值返回 400。 + schema: + type: string + enum: + - processing + - success + - error + - $ref: '#/components/parameters/TradeInclude' + - $ref: '#/components/parameters/TradePageLimit' + - $ref: '#/components/parameters/TradePageNext' + responses: + '200': + description: 提现列表与统计 + content: + application/json: + schema: + $ref: '#/components/schemas/TradePayoutListResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '402': + $ref: '#/components/responses/PaymentRequired' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/MerchantNotFound' + '429': + $ref: '#/components/responses/TooManyRequests' + "/sms/audit_statuses": + get: + tags: + - Account + summary: 获取短信签名审核状态 + operationId: listSmsAuditStatuses + description: | + 列出当前用户申请的短信签名及各签名下的短信模板,带审核状态与未通过原因,按签名创建时间倒序。 + 只返回当前用户自己的签名,不含账号内其他成员的;使用账号级凭证时返回账号所有者的签名。任何成员都可调用,不要求管理员。 + + OAuth 令牌需要 scope:users + responses: + '200': + description: 短信签名与模板的审核状态 + content: + application/json: + schema: + $ref: '#/components/schemas/SMSAuditStatusResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '402': + $ref: '#/components/responses/PaymentRequired' + '403': + $ref: '#/components/responses/Forbidden' + '429': + $ref: '#/components/responses/TooManyRequests' + "/jamr_v": + get: + tags: + - Utilities + summary: 校验跳转参数 + operationId: verifyRedirectParams + security: [] + description: | + 供「提交成功后跳转到外部地址」的接收方校验跳转参数未被篡改。此接口不需要任何 API 凭证。 + 表单开启提交成功跳转并配置了「跳转时携带的字段」后,金数据在跳转 URL 中附加所选字段的值以及签名参数 `jamr_h`(对表单 token、数据 token 和 10 分钟有效期签名)。接收方把收到的 `jamr_h` 与全部字段参数原样转发到本接口:服务端解签后找到对应表单与数据,逐一比对配置中每个字段的当前值与传入参数值(数组值须整体相等,其他值按字符串比较)。 + 全部一致返回 200 `{ status: "SUCCESS", message: "验证成功" }`;`jamr_h` 签名无效或已过期返回 400 `{ status: "FAIL", message: "无效Token或者过期" }`;表单 / 数据不存在、字段值不一致或缺少某个字段参数返回 400 `{ status: "FAIL", message: "验证失败" }`。文案随请求语言变化。 + parameters: + - name: jamr_h + in: query + required: true + description: 跳转 URL 中携带的签名参数 + schema: + type: string + - name: serial_number + in: query + required: false + description: 跳转 URL 中携带的数据序号;仅当表单配置了携带该字段时出现 + schema: + type: string + responses: + '200': + description: 校验通过 + content: + application/json: + schema: + type: object + required: + - status + - message + properties: + status: + type: string + enum: + - SUCCESS + message: + type: string + description: 如 `验证成功` + '400': + description: 签名无效 / 过期,或字段值不一致 + content: + application/json: + schema: + type: object + required: + - status + - message + properties: + status: + type: string + enum: + - FAIL + message: + type: string + description: "`无效Token或者过期` 或 `验证失败`" + "/field_types": + get: + tags: + - Forms + summary: 获取字段类型列表 + description: | + 返回创建 / 编辑表单(或表格)时可用的全部字段类型,以及每种类型接受的校验开关与专属设置,用于构造 `fields` 中的 `type` 与类型专属参数。 + 与创建接口实际接受的类型和参数保持一致。 + + OAuth 令牌需要 scope:forms。 + operationId: listFieldTypes + parameters: + - $ref: '#/components/parameters/FieldTypeKind' + responses: + '200': + description: 字段类型列表 + content: + application/json: + schema: + $ref: '#/components/schemas/FieldTypeListResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '402': + $ref: '#/components/responses/PaymentRequired' + '403': + $ref: '#/components/responses/Forbidden' + '429': + $ref: '#/components/responses/TooManyRequests' + "/field_types/{type}": + parameters: + - name: type + in: path + required: true + description: 字段类型名(区分大小写),取值见 `GET /field_types` 返回的 `type`,如 `TextField`、`RadioButton`。 + schema: + type: string + pattern: "^[A-Za-z]+$" + example: NumberField + get: + tags: + - Forms + summary: 获取单个字段类型 + description: | + 返回一种字段类型接受的校验开关与专属设置,结构同 `GET /field_types` 列表中的一项。 + 类型名不存在,或不属于 `kind` 对应的可用类型(如 `kind=table` 时查询表格不支持的类型)时返回 404,并在 `error_description` 中列出可用类型。 + + OAuth 令牌需要 scope:forms。 + operationId: getFieldType + parameters: + - $ref: '#/components/parameters/FieldTypeKind' + responses: + '200': + description: 字段类型说明 + content: + application/json: + schema: + $ref: '#/components/schemas/FieldTypeDescription' + '401': + $ref: '#/components/responses/Unauthorized' + '402': + $ref: '#/components/responses/PaymentRequired' + '403': + $ref: '#/components/responses/Forbidden' + '404': + description: 类型名不存在或不属于 `kind` 对应的可用类型 + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + example: + error_description: 'Invalid field type: Nope. Allowed types: TextField, TextArea, NumberField, ...' + '429': + $ref: '#/components/responses/TooManyRequests' + "/entries/count": + get: + tags: + - Entries + summary: 批量统计多个表单的数据条数 + operationId: countEntriesAcrossForms + description: | + 一次统计最多 10 个表单 / 表格各自满足条件的数据条数,以及它们的合计。 + - 无法访问的 token(不存在、不属于当前身份)不会让整个请求失败,而是在该行返回 `unavailable` 说明原因;合计只累加可统计的行。 + - 各表单的字段 api_code 含义不同,因此 `filters` 只能使用 `created_at` / `updated_at` / `creator_id` 三个与表单无关的条件,其他字段返回 400。 + - 不支持 `keyword`(传入返回 400);需要按关键字查多个表单时使用 `GET /entries/search`。 + + OAuth 令牌需要 scope:read_entries + parameters: + - name: form_tokens + in: query + required: false + description: '必填。要统计的表单或表格 token(也接受 id),逗号分隔(或 `form_tokens[]=` 重复传),去重后 1~10 个;为空返回 400,超过 10 个返回 400。 + + ' + schema: + type: string + example: abc123,def456 + - name: filters + in: query + required: false + description: '筛选条件,JSON 数组,写法同 `GET /forms/{form_token}/entries` 的 `filters`,但 `field` 只能是 `created_at` / `updated_at` / `creator_id`。对每个表单分别生效。 + + ' + schema: + type: string + example: '[{"field":"created_at","operator":"within_last","value":{"unit":"day","n":7}}]' + responses: + '200': + description: 各表单的数据条数与合计 + content: + application/json: + schema: + $ref: '#/components/schemas/EntryCountBatchResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '402': + $ref: '#/components/responses/PaymentRequired' + '403': + $ref: '#/components/responses/Forbidden' + '429': + $ref: '#/components/responses/TooManyRequests' + "/entries/search": + get: + tags: + - Entries + summary: 跨表单搜索数据 + operationId: searchEntriesAcrossForms + description: | + 用同一个关键字在多个表单 / 表格中做全文搜索,按表单返回匹配条数与部分数据序号(每个表单最多 50 个),不返回数据内容。 + - 搜索范围:传 `form_tokens` 时为这些表单;不传时为当前身份可访问的全部表单与表格(企业 Access Token 为企业账户下全部表单与表格)。 + 两种方式都再经 `filters` 过滤;未在 `filters` 中指定 `entries_count` 时默认只搜索有数据的表单(`entries_count > 0`)。 + - 范围内最多 10 个表单,超过时返回 400,需用 `filters` 缩小范围或直接指定 `form_tokens`。 + - 没有匹配的表单不出现在结果中;无法访问、角色不允许读取数据、搜索超时或搜索服务未响应的表单会列出并带 `unavailable` 说明原因(此时不代表没有匹配)。 + + OAuth 令牌需要 scope:read_entries + parameters: + - name: keyword + in: query + required: true + description: 搜索关键字(最长 200 字符),为空返回 400。 + schema: + type: string + maxLength: 200 + - name: form_tokens + in: query + required: false + description: 要搜索的表单或表格 token(也接受 id),逗号分隔(或 `form_tokens[]=` 重复传),去重后最多 10 个。不传时搜索全部可访问的表单。 + schema: + type: string + example: abc123,def456 + - name: filters + in: query + required: false + description: | + 按表单属性筛选搜索范围,JSON 数组,写法与可用字段同 `GET /forms` 的 `filters`(`form_name` / `created_at` / `last_entry_created_at` / `entries_count`),多个条件为「且」关系。 + 未指定 `entries_count` 条件时自动追加 `entries_count > 0`。 + schema: + type: string + example: '[{"field":"last_entry_created_at","operator":"within_last","value":{"unit":"day","n":30}}]' + responses: + '200': + description: 各表单的匹配情况 + content: + application/json: + schema: + $ref: '#/components/schemas/EntrySearchResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '402': + $ref: '#/components/responses/PaymentRequired' + '403': + $ref: '#/components/responses/Forbidden' + '429': + $ref: '#/components/responses/TooManyRequests' + "/entries/stats": + get: + tags: + - Entries + summary: 按日期范围统计各表单提交量 + operationId: getEntryStats + description: | + 统计当前身份可访问的全部表单 / 表格(企业 Access Token 为企业账户下全部表单与表格)在日期范围内收到的提交次数:返回总数,以及有提交的表单按提交次数降序排列的列表。 + - 日期按北京时间计,`from` / `to` 均包含当天。 + - 统计的是提交发生时的次数:导入的数据计在导入当天,删除的数据不会扣减,因此与按数据 `created_at` 统计的结果不一定一致。 + - 列表最多 `limit` 行,超出时 `truncated` 为 true,`total` 与 `forms_with_data` 仍覆盖全部表单。 + - 传 `by`(`day` / `week` / `month`)时响应另带 `buckets`:范围内每个时间段各一份同样结构的统计(没有提交的时间段也返回),周从周一起算,首尾两段按范围裁剪。范围最多覆盖 92 天 / 53 周 / 24 个月,超过返回 400 并提示改用更粗的粒度或去掉 `by`。 + + OAuth 令牌需要 scope:read_entries + parameters: + - name: from + in: query + required: true + description: 起始日期(含),`YYYY-MM-DD`。缺失或无法解析时返回 400。 + schema: + type: string + example: '2026-09-01' + - name: to + in: query + required: false + description: 结束日期(含),`YYYY-MM-DD`,缺省为今天。早于 `from` 或无法解析时返回 400。 + schema: + type: string + example: '2026-09-07' + - name: kind + in: query + required: false + description: 只统计表单(`form`)或表格(`table`),缺省两者都统计。其他取值返回 400。 + schema: + type: string + enum: + - form + - table + - name: by + in: query + required: false + description: 按 `day` / `week` / `month` 拆分统计,响应带 `buckets`;其他取值返回 400。 + schema: + type: string + enum: + - day + - week + - month + - name: limit + in: query + required: false + description: 列表最多返回的表单数,最大 100;缺省 100,传 `by` 时每个时间段缺省 10。超过 100 按 100 处理,小于 1 按缺省处理。 + schema: + type: integer + responses: + '200': + description: 提交量统计 + content: + application/json: + schema: + $ref: '#/components/schemas/EntryStatsResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '402': + $ref: '#/components/responses/PaymentRequired' + '403': + $ref: '#/components/responses/Forbidden' + '429': + $ref: '#/components/responses/TooManyRequests' + "/my/forms": + get: + tags: + - Forms + summary: 获取我填写过的表单 + operationId: listMyFilledForms + description: | + 返回当前用户作为填写者提交过数据的表单(不含快速支付),按最近一次提交时间倒序。这些表单不必是当前用户创建或被共享的; + 每行给出的是当前用户自己的提交次数与最近提交时间,而不是表单的全部数据量。已删除的表单不出现在 `data` 中(`total` 仍计入)。 + - 分页:`next` 为行偏移量,原样回传取下一页;`skip` 与 `next` 不能同时传。每页默认 50 条,可用 `limit` 调整(最大 1000)。 + - 企业 Access Token 没有「当前用户」,调用返回 400。 + + OAuth 令牌需要 scope:forms + parameters: + - $ref: '#/components/parameters/PageLimit' + - $ref: '#/components/parameters/OffsetNext' + - $ref: '#/components/parameters/EntriesSkip' + responses: + '200': + description: 填写过的表单列表 + content: + application/json: + schema: + $ref: '#/components/schemas/MyFilledFormListResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '402': + $ref: '#/components/responses/PaymentRequired' + '403': + $ref: '#/components/responses/Forbidden' + '429': + $ref: '#/components/responses/TooManyRequests' + "/my/forms/{form_token}/entries": + get: + tags: + - Entries + summary: 获取我在某表单提交的数据 + operationId: listMySubmittedEntries + description: | + 返回当前用户在指定表单中自己提交的数据,按提交时间倒序。表单不必是当前用户创建或被共享的:只返回本人提交的数据, + 且只包含表单对填写者公开的字段(隐藏字段既不返回,也不能用于筛选或搜索)。没有提交过时返回空列表。 + - `fields` 只保留指定的公开字段,未知或非公开的 api_code 直接略过(不报错)。 + - `filters` 语义与数据列表相同,但不能按 `creator_id` 筛选(结果已限定为本人),也不能筛选非公开字段,否则返回 400。 + - `keyword` 只在本人的数据中搜索,不受表单数据量上限限制。 + - 分页:`next` 为行偏移量;`skip` 与 `next` 不能同时传。每页默认 50 条,可用 `limit` 调整(最大 1000)。 + - 企业 Access Token 没有「当前用户」,调用返回 400;表单不存在返回 404。 + + OAuth 令牌需要 scope:read_entries + parameters: + - name: form_token + in: path + required: true + description: 表单 token(`GET /my/forms` 返回的 `token`),也接受 24 位十六进制的表单 id。 + schema: + type: string + - name: fields + in: query + required: false + description: 只返回指定字段。逗号分隔字符串或数组参数(`fields[]=`),值为字段 api_code;未知或非公开字段被略过。序号始终返回。 + schema: + type: string + example: field_1,field_2 + - $ref: '#/components/parameters/EntriesFilters' + - $ref: '#/components/parameters/EntriesKeyword' + - $ref: '#/components/parameters/EntriesIncludeLabels' + - $ref: '#/components/parameters/PageLimit' + - $ref: '#/components/parameters/OffsetNext' + - $ref: '#/components/parameters/EntriesSkip' + responses: + '200': + description: 本人提交的数据列表 + content: + application/json: + schema: + $ref: '#/components/schemas/MySubmittedEntryListResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '402': + $ref: '#/components/responses/PaymentRequired' + '403': + $ref: '#/components/responses/Forbidden' + '404': + description: 表单不存在 + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + example: + error_description: Form not found — check the token. + '429': + $ref: '#/components/responses/TooManyRequests' + "/my/search": + get: + tags: + - Entries + summary: 在我提交的数据中搜索 + operationId: searchMySubmittedEntries + description: | + 用同一个关键字搜索当前用户在各表单中自己提交的数据(只搜索表单对填写者公开的字段),返回每个命中表单的命中数与序号。 + - `keyword` 必填,为空返回 400。 + - 默认搜索当前用户填写过的全部表单(不含快速支付);`form_tokens` 可限定为其中一部分,不在填写记录中的 token 会被忽略。 + 参与搜索的表单超过 20 个时返回 400,需用 `form_tokens` 缩小范围。 + - 没有命中的表单不出现在结果中;某个表单无法搜索时(如关键字过长)该表单返回 `unavailable` 说明,其余表单照常返回。 + - 每个表单最多返回 50 个序号,`matched` 为该表单的实际命中总数。 + - 企业 Access Token 没有「当前用户」,调用返回 400。 + + OAuth 令牌需要 scope:read_entries + parameters: + - name: keyword + in: query + required: true + description: 搜索关键字(最长 200 字符),在表单公开的可搜索字段中模糊匹配。 + schema: + type: string + minLength: 1 + - name: form_tokens + in: query + required: false + description: 只搜索这些表单。逗号分隔的表单 token,或数组参数(`form_tokens[]=`)。 + schema: + type: string + example: aBc123,dEf456 + responses: + '200': + description: 各表单的命中情况 + content: + application/json: + schema: + $ref: '#/components/schemas/MySearchResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '402': + $ref: '#/components/responses/PaymentRequired' + '403': + $ref: '#/components/responses/Forbidden' + '429': + $ref: '#/components/responses/TooManyRequests' + "/fill/f/{form_token}": + get: + tags: + - Fill + summary: 读取表单填写页 + operationId: getFillFormPage + description: | + 按填写链接(`/f/`)读取填写页:标题、说明、要填的字段,以及当前用户此刻能不能提交。 + 调用者以拿到链接的填写者身份读取,不要求对表单或表格有任何权限;返回的只有填写页上看得到的内容。 + - `fields` 按填写页顺序列出公开字段,形态同读取表单时的 `fields`;表单视图的列名、说明、必填已按视图设置生效。 + - `status.key` 为 `open` 才能提交;其他取值(如 `not_opened`、`closed`、`password_required`、`unauthorized`、`submission_prevented`、 + `fill_frequency_limited`:当前用户已填满填写页允许的次数)的说明在 `status.message`;未开放或按时间关闭时 `status.open_info` 给出开放时间。 + - `blockers` 列出只有填写者本人在页面上才能完成的要求(验证码、短信验证、微信身份、付款等);不为空时提交会被拒绝,需在浏览器中填写。 + - `manageable` 只在当前用户本来就能读取背后的表单或表格时返回,给出它的 token 与名称。 + - 企业 Access Token 没有「当前用户」,调用返回 400。 + + OAuth 令牌需要 scope:submit_entries + parameters: + - name: form_token + in: path + required: true + description: 填写链接 `/f/` 中的 token。 + schema: + type: string + responses: + '200': + description: 填写页 + content: + application/json: + schema: + $ref: '#/components/schemas/FillPage' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '402': + $ref: '#/components/responses/PaymentRequired' + '403': + $ref: '#/components/responses/Forbidden' + '404': + description: 填写页不存在 + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + example: + error_description: Fill page not found — check the link. + '429': + $ref: '#/components/responses/TooManyRequests' + "/fill/f/{form_token}/entries": + post: + tags: + - Fill + summary: 按表单填写页提交数据 + operationId: createFillFormEntry + description: | + 以当前用户的身份,按填写页提交一条数据,与在页面上点「提交」一致:只接受填写页上可见的字段,按填写页的必填设置校验, + 提交人记为当前用户(会出现在「我提交的」里),表单视图提交的数据记录来自哪个视图。 + - 填写页未开放返回 403,`status` 为原因;`blockers` 不为空返回 422 并列出原因。 + - 字段值不合法返回 422,`errors` 按字段 api_code 给出说明。 + - 每个用户每小时最多提交 30 次,超出返回 429。 + - 企业 Access Token 没有「当前用户」,调用返回 400。 + + OAuth 令牌需要 scope:submit_entries + parameters: + - name: form_token + in: path + required: true + description: 填写链接 `/f/` 中的 token。 + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/FillEntryRequest' + responses: + '201': + description: 已提交 + content: + application/json: + schema: + $ref: '#/components/schemas/FillEntryCreated' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '402': + $ref: '#/components/responses/PaymentRequired' + '403': + description: 填写页未开放 + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: 填写页不存在 + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '422': + description: 填写页有 API 代替不了的要求,或字段值不合法 + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '429': + $ref: '#/components/responses/TooManyRequests' + "/fill/v/{view_token}": + get: + tags: + - Fill + summary: 读取表单视图填写页 + operationId: getFillViewPage + description: | + 按填写链接(`/v/`)读取填写页:标题、说明、要填的字段,以及当前用户此刻能不能提交。 + 调用者以拿到链接的填写者身份读取,不要求对表单或表格有任何权限;返回的只有填写页上看得到的内容。 + - `fields` 按填写页顺序列出公开字段,形态同读取表单时的 `fields`;表单视图的列名、说明、必填已按视图设置生效。 + - `status.key` 为 `open` 才能提交;其他取值(如 `not_opened`、`closed`、`password_required`、`unauthorized`、`submission_prevented`、 + `fill_frequency_limited`:当前用户已填满填写页允许的次数)的说明在 `status.message`;未开放或按时间关闭时 `status.open_info` 给出开放时间。 + - `blockers` 列出只有填写者本人在页面上才能完成的要求(验证码、短信验证、微信身份、付款等);不为空时提交会被拒绝,需在浏览器中填写。 + - `manageable` 只在当前用户本来就能读取背后的表单或表格时返回,给出它的 token 与名称。 + - 企业 Access Token 没有「当前用户」,调用返回 400。 + + OAuth 令牌需要 scope:submit_entries + parameters: + - name: view_token + in: path + required: true + description: 填写链接 `/v/` 中的 token。 + schema: + type: string + responses: + '200': + description: 填写页 + content: + application/json: + schema: + $ref: '#/components/schemas/FillPage' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '402': + $ref: '#/components/responses/PaymentRequired' + '403': + $ref: '#/components/responses/Forbidden' + '404': + description: 填写页不存在 + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + example: + error_description: Fill page not found — check the link. + '429': + $ref: '#/components/responses/TooManyRequests' + "/fill/v/{view_token}/entries": + post: + tags: + - Fill + summary: 按表单视图填写页提交数据 + operationId: createFillViewEntry + description: | + 以当前用户的身份,按填写页提交一条数据,与在页面上点「提交」一致:只接受填写页上可见的字段,按填写页的必填设置校验, + 提交人记为当前用户(会出现在「我提交的」里),表单视图提交的数据记录来自哪个视图。 + - 填写页未开放返回 403,`status` 为原因;`blockers` 不为空返回 422 并列出原因。 + - 字段值不合法返回 422,`errors` 按字段 api_code 给出说明。 + - 每个用户每小时最多提交 30 次,超出返回 429。 + - 企业 Access Token 没有「当前用户」,调用返回 400。 + + OAuth 令牌需要 scope:submit_entries + parameters: + - name: view_token + in: path + required: true + description: 填写链接 `/v/` 中的 token。 + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/FillEntryRequest' + responses: + '201': + description: 已提交 + content: + application/json: + schema: + $ref: '#/components/schemas/FillEntryCreated' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '402': + $ref: '#/components/responses/PaymentRequired' + '403': + description: 填写页未开放 + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: 填写页不存在 + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '422': + description: 填写页有 API 代替不了的要求,或字段值不合法 + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '429': + $ref: '#/components/responses/TooManyRequests' + "/tables/{table_token}/folder": + patch: + tags: + - Tables + summary: 移动表格到文件夹 + description: | + 将表格移入指定文件夹,或移出文件夹(`folder_token` 传 null);同时接受 PUT。移动仅影响当前调用用户的文件夹归属。 + OAuth 令牌需要 scope:forms。 + - 请求体必须包含 `folder_token` 键(值可为 null),否则 400。 + - `folder_token` 只接受文件夹 token(不接受 id);文件夹不存在或当前用户无管理权限返回 404。 + - 目标文件夹的 kind 必须是 table,否则 400。 + - token 对应的不是表格时返回 404 `table cannot be found`。 + operationId: moveTableToFolder + parameters: + - $ref: '#/components/parameters/TableToken' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/FormFolderMoveInput' + responses: + '200': + description: 移动结果 + content: + application/json: + schema: + $ref: '#/components/schemas/TableFolderMoveResult' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '402': + $ref: '#/components/responses/PaymentRequired' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + description: | + `Authorization: Bearer ACCESS_TOKEN`。接受个人 Access Token、企业 Access Token(企业版)以及 OAuth 2.0 访问令牌。 + OAuth 令牌按 scope 限制可用接口:`forms`(表单 / 表格 / 视图 / 文件夹 / 对外查询 / 模板)、`form_setting`(表单设置类、预置选项集)、 + `read_entries` / `write_entries`(数据、评论、附件上传、微信授权会话)、`read_contacts`(联系人)、`users`(当前用户 / 企业账户及其成员、发票、消费记录 / 小金商户与交易 / 短信签名审核状态)、`public`(其余)。 + basicAuth: + type: http + scheme: basic + description: '旧版认证:`api_key:api_secret` 经 Base64 编码后放入 `Authorization: Basic ...`。不再向新账号发放,后续下线。' + parameters: + FormToken: + name: form_token + in: path + required: true + description: 表单 token(`GET /forms` 返回的 `token`),也接受 24 位十六进制的表单 id。 + schema: + type: string + TableToken: + name: table_token + in: path + required: true + description: 表格 token(`GET /tables` 返回的 `token`),也接受 24 位十六进制的表格 id。 + schema: + type: string + ViewToken: + name: view_token + in: path + required: true + description: 视图 token(`GET /forms/{form_token}/views` 返回的 `token`) + schema: + type: string + SerialNumber: + name: serial_number + in: path + required: true + description: 数据序号 + schema: + type: integer + FormsQ: + name: q + in: query + required: false + description: 按名称模糊匹配(不区分大小写,同时匹配对外名称与站内名称)。可传多个值(`q[]=A&q[]=B`),匹配名称包含其中任一的表单。等价于 `filters=[{"field":"form_name","operator":"like","value":...}]`。 + schema: + type: string + FormsFilters: + name: filters + in: query + required: false + description: | + 筛选条件,JSON 数组,多个条件为「且」关系。可筛选字段与操作符: + | field | 类型 | 操作符 | + |---|---|---| + | `form_name` | 文本 | `eq` `ne` `any_in` `none_in` `like` `not_like` | + | `created_at` | 时间 | `eq` `ne` `gte` `gt` `lte` `lt` `within_last` | + | `last_entry_created_at` | 时间 | 同上(从未收到数据的表单没有该值) | + | `entries_count` | 整数 | `eq` `ne` `gte` `gt` `lte` `lt` | + + `within_last` 的值形如 `{"unit":"day","n":30}`(unit 可为 minute / day / week / month),表示从现在起往前的窗口。 + 示例:`[{"field":"last_entry_created_at","operator":"within_last","value":{"unit":"day","n":7}}]` + schema: + type: string + example: '[{"field":"entries_count","operator":"gte","value":1}]' + FormsNext: + name: next + in: query + required: false + description: 上一页响应中的 `next`,原样回传以取下一页。不能与 `skip` 同时传。 + schema: + type: string + FormsSkip: + name: skip + in: query + required: false + description: 起始行偏移(0 起,最大 10000),用于并行切片读取;返回的 `next` 仍可继续翻页。不能与 `next` 同时传。 + schema: + type: integer + minimum: 0 + maximum: 10000 + EntriesFields: + name: fields + in: query + required: false + description: 只返回指定字段。JSON 数组、逗号分隔字符串或重复参数均可,值为字段 api_code;序号始终返回。含未知 api_code 时返回 400 并列出可用字段。 + schema: + type: string + example: field_1,field_2 + EntriesFilters: + name: filters + in: query + required: false + description: | + 筛选条件,JSON 数组,每项为 `{"field": "", "operator": "", "value": ...}`(`field` 也可写作 `api_code` / `trigger`),多个条件为「且」关系。 + - 操作符全集:`eq` `ne` `any_in` `none_in` `between` `not_between` `null` `not_null` `gte` `gt` `lte` `lt` `like` `not_like` `within_last`, + 别名 `equal` / `not_equal` / `start`(=gte)/ `end`(=lte)。每种字段类型只接受其中一部分,不支持时返回 400 并列出可用操作符。 + - 选项类字段(单选 / 多选 / 下拉 / 多级下拉)的 `value` 可以是选项 api_code 或选项显示值;多级下拉可用 `一级-二级` 路径。 + - `between` / `not_between` 的 `value` 为二元数组 `[min, max]`;矩阵 / 表格字段需写成 `{"<列 api_code>": [min, max]}`。 + - `within_last` 仅日期字段可用,`value` 形如 `{"unit":"day","n":30}`(unit 可为 minute / day / week / month)。 + - `creator_id` 可作为特殊字段用 `eq` 筛选提交人(值为用户 id),且只能出现一次。 + - `serial_number` 可作为特殊字段用 `eq` / `any_in` 按序号筛选,多个条件取交集。 + - 关联字段(RelationField)的 `any_in` / `none_in` 的 `value` 为关联表格中的数据序号;序号不存在时返回 400。 + - 含 `created_at` 条件时结果改为按创建时间排序。 + schema: + type: string + example: '[{"field":"field_3","operator":"any_in","value":["选项 A","选项 B"]}]' + EntriesKeyword: + name: keyword + in: query + required: false + description: 全文关键字(最长 200 字符),在所有可搜索字段中模糊匹配。数据量超过 99999 条且未启用分析引擎的表单不支持全文搜索(返回 400),除非同时用 `creator_id` 限定提交人。 + schema: + type: string + maxLength: 200 + EntriesSort: + name: sort + in: query + required: false + description: | + 排序规则,JSON 数组,每项 `{"api_code": "", "order": "asc"|"desc"}`,按顺序生效,末尾自动追加按序号升序。 + 只有可排序字段可用,否则返回 400 并列出可排序字段。传 `sort` 后 `next` 变为行偏移量。 + schema: + type: string + example: '[{"api_code":"created_at","order":"desc"}]' + EntriesCreatedAt: + name: created_at + in: query + required: false + description: 只返回在该时间及之后创建的数据(按北京时间解析,如 `2024-01-01` 或 `2024-01-01 08:00:00`),并改为按创建时间升序。格式无法解析时返回 400。与 `filters` / `keyword` 同时传时忽略。 + schema: + type: string + EntriesNext: + name: next + in: query + required: false + description: 上一页响应中的 `next`。默认排序下是序号游标(返回序号 ≥ 该值的数据);条件查询 / 按创建时间 / 显式排序时是行偏移量。不能与 `skip` 同时传。也接受字符串:以整数开头时取该整数,否则视为 0,即从第一页开始。 + schema: + type: + - integer + - string + EntriesSkip: + name: skip + in: query + required: false + description: 起始行偏移(0 起,最大 10000),用于并行切片读取。不能与 `next` 同时传。 + schema: + type: integer + minimum: 0 + maximum: 10000 + FieldDataChecks: + name: checks + in: query + required: true + description: | + 要检查的目标,JSON 数组(1~100 项),每项一个字段或一个选项: + | 键 | 类型 | 说明 | + |---|---|---| + | `field_api_code` | string | 必填。要检查的字段 api_code;检查表格字段 / 矩阵字段的某一列时传该列的 api_code,并同时传 `parent_field` | + | `choice_value` | string | 可选。只检查某个选项时传该选项的 api_code;不传则检查整个字段是否有数据 | + | `choice_type` | string | 可选,配合 `choice_value` 使用:`choice`(默认)、矩阵字段的 `statement` / `dimension`、多级下拉的 `level_1` ~ `level_4` | + | `parent_field` | string | 可选。检查矩阵 / 表格字段的某一列时,父字段的 api_code | + | `check_extended_text` | boolean | 可选。为 `true` 时检查该选项的「其他」补充填写内容是否有数据 | + | `goods_item_dimension_options` | object | 可选。检查商品字段某个规格时,传规格维度与选项的对应关系 | + schema: + type: string + example: '[{"field_api_code":"field_1"},{"field_api_code":"field_3","choice_value":"Yb2d"}]' + FieldTypeChangeChecks: + name: checks + in: query + required: true + description: | + 要预览的类型转换,JSON 数组(1~20 项),每项一个字段: + | 键 | 类型 | 说明 | + |---|---|---| + | `field_api_code` | string | 必填。要转换的字段 api_code | + | `target_type` | string | 必填。目标字段类型,取值见 `GET /field_types`(表格见 `GET /field_types?kind=table`) | + | `target_precision` | string | 可选。目标为 `DateTimeField` 时,编辑将使用的日期精度(`month` / `day` / `minute` / `second`),使预览与实际转换一致;其他情况忽略 | + schema: + type: string + example: '[{"field_api_code":"field_1","target_type":"RadioButton"}]' + FieldTypeKind: + name: kind + in: query + required: false + description: 传 `table` 时按数据表格可用的列类型回答;不传或其他值按表单可用的字段类型回答。 + schema: + type: string + example: table + AggregateMetrics: + name: metrics + in: query + required: false + description: | + 必填。要计算的指标,JSON 数组,1~20 项,每项 `{"func": "<函数>", "field": ""}`,缺失或为空返回 400。 + 可用函数随字段而定(以字段的 `analytics.agg_funcs` 为准): + | 字段 | 函数 | + |---|---| + | 全部字段 | `count` `count_non_empty` `count_empty` | + | 文本类 | 另加 `count_unique` | + | 数字类 | 另加 `count_unique` `sum` `avg` `min` `max` `stddev` `median` `p25` `p75` `p90` | + | 日期类 | 另加 `count_unique` `min` `max` | + schema: + type: string + example: '[{"func":"sum","field":"field_5"},{"func":"count","field":"field_1"}]' + AggregateDimensions: + name: dimensions + in: query + required: false + description: | + 分组字段,JSON 数组,最多 2 项,每项 `{"field": "", "bucket": "<粒度>"}`。 + 只有 `analytics.groupable` 为 true 的字段可以分组;日期字段必须带 `bucket`(`day` / `week` / `month`,按北京时间),非日期字段不能带 `bucket`。 + schema: + type: string + example: '[{"field":"field_3"},{"field":"created_at","bucket":"month"}]' + AggregateLimit: + name: limit + in: query + required: false + description: 分组时最多返回的组数,缺省 20,最大 200(超过按 200 处理,小于 1 按缺省处理)。不分组时忽略。 + schema: + type: integer + default: 20 + SummaryFields: + name: fields + in: query + required: false + description: 只概括指定字段,值为字段 api_code,逗号分隔(或 `fields[]=` 重复传),按传入顺序返回。缺省为全部可分析字段。字段不可分析或无权读取时返回 400 并列出可分析字段。 + schema: + type: string + example: field_1,field_3 + SummaryIncludeOverview: + name: include_overview + in: query + required: false + description: 是否返回 `overview`(匹配条数与今日新增),缺省为 true。 + schema: + type: boolean + default: true + EntriesIncludeLabels: + name: include_labels + in: query + required: false + description: | + 为 true 时,每条数据中表单字段的值改为 `{"label": "<字段标题>", "value": <原值>}`,免去再读一次表单结构。 + 系统键(如 `serial_number`、`creator_id`、`token`)与考试 / 测评的 `_extra_value` 保持原样。缺省为 false。 + schema: + type: boolean + default: false + PageLimit: + name: limit + in: query + required: false + description: 每页条数,缺省 50,最大 1000(超过按 1000 处理)。传 0 时只返回 `total`,`data` 为空。 + schema: + type: integer + default: 50 + OffsetNext: + name: next + in: query + required: false + description: 上一页响应中的 `next`(行偏移量,从 0 开始),原样回传以取下一页。不能与 `skip` 同时传。 + schema: + type: integer + minimum: 0 + FormsSort: + name: sort + in: query + required: false + description: | + 排序规则,JSON 数组,每项 `{"field": "<字段>", "order": "asc"|"desc"}`(order 缺省 asc),按顺序生效。 + 可排序字段:`created_at`、`last_entry_created_at`、`entries_count`;其他字段、非法 order 或无法解析的 JSON 返回 400。 + 传 `sort` 后 `next` 变为 `row:<偏移量>` 形式的游标,只能用于同样 `sort` 的请求;值相同的行在翻页间可能出现顺序变化。 + schema: + type: string + example: '[{"field":"last_entry_created_at","order":"desc"}]' + IncludeTransactions: + name: include_transactions + in: query + required: false + description: 为 `true` 时,已绑定收款账户(商户号 / 微信支付 / 支付宝)的表单附带 `transactions` 收款统计;未绑定的表单没有该键。缺省不附带。 + schema: + type: boolean + default: false + EntryImportId: + name: id + in: path + required: true + description: 导入任务 ID(创建导入时返回的 `job_id`) + schema: + type: string + TradeStartDate: + name: start_date + in: query + required: false + description: 起始日期(含),`YYYY-MM-DD`,按北京时间计。必须与 `end_date` 同时传,且不能晚于 `end_date`;只传其中一个或无法解析时返回 400。缺省范围见各接口说明。 + schema: + type: string + example: '2026-09-01' + TradeEndDate: + name: end_date + in: query + required: false + description: 结束日期(含),`YYYY-MM-DD`,按北京时间计。必须与 `start_date` 同时传。 + schema: + type: string + example: '2026-09-30' + TradePaidStatus: + name: paid_status + in: query + required: false + description: 只保留这些支付状态的订单,逗号分隔(或 `paid_status[]=` 重复传):`paid`(已支付)、`unpaid`(未支付)、`refunded`(已退款)、`refunding`(退款中)、`partial_refunded`(部分退款)。含未知取值时返回 400;缺省不限状态(含未支付)。 + explode: false + schema: + type: array + items: + type: string + enum: + - paid + - unpaid + - refunded + - refunding + - partial_refunded + example: + - paid + - refunding + TradePaymentChannel: + name: payment_channel + in: query + required: false + description: 只保留通过该渠道支付的订单:`wxpay`(微信支付)、`alipay`(支付宝)。其他取值返回 400。 + schema: + type: string + enum: + - wxpay + - alipay + TradeFormToken: + name: form_token + in: query + required: false + description: 只保留该收款表单的订单。表单 token(也接受 24 位十六进制的表单 id),可从 `GET /trade/merchant` 的 `payment_form_tokens` 取得;表单不存在或当前身份不可访问时返回 400。 + schema: + type: string + TradeInclude: + name: include + in: query + required: false + description: 返回内容:`both`(缺省)统计与记录都返回,`stats` 只返回统计,`records` 只返回记录。其他取值返回 400。 + schema: + type: string + enum: + - both + - stats + - records + default: both + TradePageLimit: + name: limit + in: query + required: false + description: 每页条数,缺省且最大为 50(超过按 50 处理,小于 1 按缺省处理)。 + schema: + type: integer + default: 50 + TradePageNext: + name: next + in: query + required: false + description: 上一页响应中的 `next`(页码,从 1 开始),原样回传以取下一页;缺省取第 1 页。 + schema: + type: integer + responses: + BadRequest: + description: 请求参数或数据不合法 + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + FormSettingBadRequest: + description: 请求不符合本文档时为通用 error_description 结构;模型校验失败时为 errors/status 结构 + content: + application/json: + schema: + oneOf: + - $ref: '#/components/schemas/Error' + - $ref: '#/components/schemas/FormSettingSaveFailed' + Unauthorized: + description: 未认证:令牌无效或已撤销,或 Basic 凭据错误 + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + example: + error_description: Invalid or revoked token + PaymentRequired: + description: 当前套餐不包含 API v1 能力 + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + Forbidden: + description: 无权限:OAuth 令牌 scope 不足,或操作被业务规则拒绝 + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + example: + error_description: OAuth token scope is insufficient + NotFound: + description: 资源不存在或当前身份不可访问(个人令牌只能访问自己创建或被共享的表单) + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + example: + error_description: form cannot be found + TooManyRequests: + description: 超过速率限制 + headers: + Retry-After: + description: 多少秒后可重试 + schema: + type: integer + X-RateLimit-Limit: + description: 当前周期(1 小时)额度 + schema: + type: integer + X-RateLimit-Remaining: + description: 当前周期剩余次数 + schema: + type: integer + X-RateLimit-Reset: + description: 额度重置的 Unix 时间戳 + schema: + type: integer + content: + application/json: + schema: + $ref: '#/components/schemas/RateLimitError' + EntryValidationFailed: + description: '数据校验失败或参数不合法。字段校验失败时 `error_description` 是 `{ "": ["错误信息"] }` 序列化后的 JSON 字符串' + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + example: + error_description: '{"field_2":["不能为空"]}' + MerchantNotFound: + description: 当前账号没有小金商户,因此没有小金商户的订单、退款与提现;入驻进度见 `GET /trade/merchant` + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + schemas: + Error: + type: object + required: + - error_description + properties: + error_description: + type: string + description: 人可读的错误说明 + RateLimitError: + type: object + properties: + error: + type: string + examples: + - rate_limit_exceeded + error_description: + type: string + retry_after: + type: integer + description: 多少秒后可重试 + limit: + type: integer + remaining: + type: integer + reset_at: + type: string + format: date-time + plan: + type: string + description: 当前套餐代码 + upgrade_url: + type: string + format: uri + Creator: + type: object + properties: + name: + type: + - string + - 'null' + description: 创建者姓名(无姓名时为昵称) + FormSummary: + type: object + required: + - name + - token + - tags + - created_at + - entries_count + - last_entry_created_at + - creator + properties: + name: + type: string + description: 表单名称(对外名称) + token: + type: string + tags: + type: array + items: + type: string + description: 当前身份为该表单打的标签(企业令牌为所有成员的标签合集) + created_at: + type: string + format: date-time + entries_count: + type: integer + description: 表单数据量 + last_entry_created_at: + type: + - string + - 'null' + format: date-time + description: 最近一次收到数据的时间(UTC);从未收到数据时为 null + creator: + $ref: '#/components/schemas/Creator' + transactions: + $ref: '#/components/schemas/FormTransactions' + FormListResponse: + type: object + required: + - total + - count + - data + - next + properties: + total: + type: integer + description: 满足条件的表单总数 + count: + type: integer + description: 本页条数 + data: + type: array + items: + $ref: '#/components/schemas/FormSummary' + next: + type: + - string + - 'null' + description: 下一页游标:默认排序下为表单 id,传 `sort` 时为 `row:<偏移量>`;末页为 null + Form: + type: object + required: + - name + - token + - url + - description + - fields + - setting + properties: + name: + type: string + token: + type: string + url: + type: string + format: uri + description: 表单对外填写地址(按企业账户的自定义域名 / 子域名生成,不能由 token 推算) + description: + type: + - string + - 'null' + description: 表单描述(可能是 HTML 富文本) + fields: + type: array + description: 字段列表,按表单中的顺序排列;每个元素是以字段 api_code 为唯一键的对象 + items: + $ref: '#/components/schemas/FieldMap' + setting: + description: 表单设置;表单尚无设置时为空对象 + allOf: + - $ref: '#/components/schemas/FormSetting' + FieldMap: + type: object + description: 单键对象:键为字段 api_code,值为字段定义 + minProperties: 1 + maxProperties: 1 + additionalProperties: + $ref: '#/components/schemas/FieldDefinition' + examples: + - field_1: + label: 姓名 + api_code_alias: + type: single_line_text + notes: '' + private: false + validation: + required: true + FieldDefinition: + type: object + description: | + 字段定义。所有字段都有 `label` / `api_code_alias` / `type` / `notes` / `private` / `validation`,其余属性按 `type` 出现。 + + `type` 取值与字段的对应关系: + | type | 字段 | + |---|---| + | `single_line_text` | 单行文字 | + | `paragraph_text` | 多行文字 | + | `name` / `id_card` / `mobile` / `email` / `phone` / `link` / `address` / `geo_location` | 姓名 / 身份证 / 手机 / 邮箱 / 电话 / 网址 / 地址 / 地理位置 | + | `single_choice` / `multiple_choice` / `drop_down` / `cascade_drop_down` | 单选 / 多选 / 下拉 / 多级下拉(颜色标记也是 `drop_down`) | + | `image_radio_button` / `image_check_box` | 图片单选 / 图片多选 | + | `number` / `formula` / `rating` / `nps` / `boolean` | 数字 / 计算 / 评分 / NPS / 是否 | + | `date_time` / `date` / `time` | 日期 / 日期(旧)/ 时间 | + | `attachment` / `audio` / `e_signature` | 上传文件 / 语音 / 电子签名 | + | `likert` / `matrix` / `matrix_scale` / `table` / `multiple_blanks` / `sort` | 矩阵单选 / 矩阵填空 / 矩阵量表 / 表格 / 横向填空 / 排序 | + | `goods` / `reservation` | 商品 / 预约 | + | `form_association` / `reversed_association` | 表单关联 / 反向关联 | + required: + - label + - type + - notes + - private + - validation + properties: + label: + type: string + description: 字段标题 + api_code_alias: + type: + - string + - 'null' + description: 字段 api_code 别名;配置后数据读写都以别名为键 + type: + type: string + description: 字段类型,取值见上表 + notes: + type: + - string + - 'null' + description: 字段说明(可能是 HTML 富文本) + private: + type: boolean + description: 是否为隐藏字段 + validation: + $ref: '#/components/schemas/FieldValidation' + choices: + type: array + description: 选项(单选 / 多选 / 下拉 / 多级下拉 / 图片选择 / 矩阵单选 / 排序) + items: + $ref: '#/components/schemas/Choice' + allow_other: + type: boolean + description: 是否含「其他」选项(选项类字段) + statements: + type: array + description: 题目(矩阵单选 / 矩阵填空 / 矩阵量表) + items: + $ref: '#/components/schemas/Statement' + dimensions: + type: array + description: 子字段(矩阵填空 / 矩阵量表 / 表格 / 横向填空),每项是以子字段 api_code 为键的单键对象,子字段只含通用属性 + items: + $ref: '#/components/schemas/FieldMap' + placeholder: + type: + - string + - 'null' + description: 横向填空的题干,空格以 `${field_x}$` 占位 + max_file_quantity: + type: integer + description: 附件字段:最多上传的文件数 + media_type: + type: object + description: 附件字段:允许的文件类型 + properties: + type: + type: string + description: "`unlimited` 或具体类型分组" + value: + type: array + items: + type: string + max_duration: + type: + - integer + - 'null' + description: 语音字段:最长时长(秒) + precision: + description: 日期字段为精度枚举(`month` / `day` / `minute` / `second` 等);数字 / 计算字段为小数位数 + oneOf: + - type: string + - type: integer + display_precision: + type: integer + description: 数字 / 计算字段:显示用小数位数 + display_as_percentage: + type: boolean + thousands_separator: + type: boolean + currency_symbol: + type: string + currency_symbol_position: + type: string + enum: + - prefix + - suffix + formula: + type: + - string + - 'null' + description: 计算字段:服务端计算用的公式 + formula_display: + type: + - string + - 'null' + description: 计算字段:管理员可读的公式 + goods_items: + type: array + description: 商品字段:商品项目 + items: + $ref: '#/components/schemas/GoodsItem' + reservation_items: + type: array + description: 预约字段:预约项目 + items: + $ref: '#/components/schemas/ReservationItem' + associated_form_token: + type: + - string + - 'null' + description: 表单关联:被关联表单的 token + associated_field_api_code: + type: string + description: 表单关联:只有一个关联键时给出 + associated_field_api_codes: + type: array + items: + type: string + description: 表单关联:全部关联键 api_code + display_field_settings: + type: array + description: 表单关联:展示字段设置 + items: + $ref: '#/components/schemas/DisplayFieldSetting' + allow_reverse_association: + type: boolean + description: 表单关联:是否在被关联表单上显示反向关联 + reverse_display_field_api_codes: + type: array + items: + type: string + description: 表单关联:反向关联展示的字段 api_code,开启反向关联时给出 + rating_type: + type: string + description: 评分字段:外观(如 `star`) + rating_max: + type: integer + description: 评分字段:量级 + range_max: + type: + - integer + - 'null' + minimum_ratings_display_text: + type: + - string + - 'null' + description: NPS 字段:最低分文案 + maximum_ratings_display_text: + type: + - string + - 'null' + description: NPS 字段:最高分文案 + highlight_color: + type: + - string + - 'null' + description: NPS 字段:高亮颜色 + include_second: + type: boolean + description: 时间字段:是否精确到秒(仅为 true 时出现) + analytics: + $ref: '#/components/schemas/FieldAnalytics' + additionalProperties: true + FieldValidation: + type: object + description: 字段校验规则,只有生效的规则才会出现 + properties: + required: + type: boolean + description: 必填 + unique: + type: boolean + description: 不能与已有数据重复 + white_list_limit: + type: boolean + description: 开启了白名单限制 + access_control_list_limit: + type: boolean + description: 开启了白名单 / 黑名单限制 + access_control_type: + type: string + enum: + - white + - black + description: 名单类型 + white_list_values: + type: array + items: + type: string + description: 白名单取值(单行文字 / 手机) + acl_values: + type: array + items: + type: string + description: 名单取值(单行文字 / 手机) + min_selected: + type: integer + description: 最少选几项(多选 / 图片多选 / 排序) + max_selected: + type: integer + description: 最多选几项(多选 / 图片多选 / 排序) + minimum_length: + type: integer + description: 最少字数(文本类) + maximum_length: + type: integer + description: 最多字数(文本类) + min: + type: number + description: 最小值(数字) + max: + type: number + description: 最大值(数字) + start: + type: string + description: 起始日期(日期字段) + end: + type: string + description: 结束日期(日期字段) + additionalProperties: true + Choice: + type: object + required: + - name + - value + properties: + name: + type: string + description: 选项显示值 + value: + type: string + description: 选项 api_code(写入数据时可用它或显示值) + hidden: + type: boolean + quota: + type: + - integer + - 'null' + description: 选项名额(未设置或设置了隐藏名额时不出现) + operand_value: + type: number + description: 选项赋值 + allow_extended_text: + type: boolean + description: 选中后可补充文本 + extended_text_required: + type: boolean + is_other: + type: boolean + description: 是否为「其他」选项 + image_url: + type: + - string + - 'null' + description: 图片选项的图片地址 + large_path: + type: + - string + - 'null' + description: 图片选项的大图地址 + sub_choices: + type: array + description: 下级选项(多级下拉) + items: + $ref: '#/components/schemas/Choice' + additionalProperties: true + Statement: + type: object + required: + - name + - value + properties: + name: + type: string + description: 题目文字 + value: + type: string + description: 题目 api_code + GoodsItem: + type: object + properties: + name: + type: string + value: + type: string + description: 商品 api_code + description: + type: + - string + - 'null' + inventory: + type: + - integer + - 'null' + hidden: + type: boolean + price: + type: + - number + - 'null' + description: 无规格时的价格;有规格时为 null + original_price: + type: + - number + - 'null' + skus: + type: array + items: + $ref: '#/components/schemas/GoodsSku' + GoodsSku: + type: object + properties: + spec: + type: object + additionalProperties: + type: string + description: 规格,键为规格维度名、值为规格选项 + price: + type: + - number + - 'null' + original_price: + type: + - number + - 'null' + inventory: + type: + - integer + - 'null' + ReservationItem: + type: object + required: + - name + - value + - hidden + properties: + name: + type: string + value: + type: string + description: 预约项目 api_code + hidden: + type: boolean + DisplayFieldSetting: + type: object + properties: + api_code: + type: string + public_display: + type: boolean + privacy_safe: + type: boolean + additionalProperties: true + EntryData: + type: object + description: | + 一条数据。键为字段 api_code(配置了 api_code_alias 的字段以别名为键),另有以下系统键: + `token`、`serial_number`、`created_at`、`updated_at`、`creator_name`(提交人姓名)、`color_mark`(颜色标记)、 + `info_filling_duration`(填写时长,秒)、`info_platform`、`info_os`、`info_browser`、`info_region`(`{country, province, city}`)、`info_remote_ip`, + 以及按表单能力出现的 `gen_code`(确认码)、`total_price` / `preferential_price`(有商品时)、`x_field_weixin_*`(微信信息)、`referral_link` 等。 + 考试 / 测评题目还会多出 `_extra_value: { correct_answer, score }`;反向关联字段多出 `_entries`,关联字段多出 `_related_entries`。 + + 各类型字段值的形态: + | 字段 | 值 | + |---|---| + | 单行 / 多行文字、姓名、手机、邮箱、电话、网址、身份证 | 字符串 | + | 单选、下拉、图片单选 | 选项显示值(字符串) | + | 多选、图片多选 | 选项显示值数组 | + | 多级下拉 | `{"level_1": "...", "level_2": "..."}` | + | 数字、评分、NPS、计算 | 数字(评分可能为字符串) | + | 是否 | 布尔 | + | 日期 | 按精度格式化的北京时间字符串,如 `2021-09-23` | + | 时间 | `HH:MM` 或 `HH:MM:SS` | + | 上传文件 | 下载地址数组(带签名,10 年有效) | + | 电子签名 | 下载地址字符串 | + | 语音 | `{"url": "...", "audio_text": "..."}` | + | 地址 | `{"province","city","district","street"}` | + | 地理位置 | `{"longitude","latitude","address"}` | + | 商品 | `[{"name","number","price","original_price","inventory","layout","spec"?}]` | + | 矩阵填空 / 矩阵量表 | `[{"statement": "题目", "dimensions": {"列名": 值}}]` | + | 矩阵单选 | `[{"statement": "题目", "choice": "选项"}]`(多选样式为 `choices` 数组) | + | 表格 | `[{"列名": 值}]`(每行一个对象,空值省略) | + | 横向填空 | `{"<子字段 api_code>": "..."}` | + | 排序 | `[{"rank": 1, "choice": "选项"}]` | + | 预约 | `[{"item_name","scheduled_label","number", ...}]` | + | 表单关联 | 关联数据的显示值字符串 | + | 关联(表格) | 关联表格中被关联数据的序号数组,如 `[1, 3]`;另附 `_related_entries`:`[{"serial_number": 1, "value": "..."}]`,value 为该数据第一列的值 | + 请求带 `include_labels=true` 时,表单字段的值包装为 `{label, value}`。 + required: + - token + - serial_number + properties: + token: + type: string + description: 数据唯一标识 + serial_number: + type: integer + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + creator_name: + type: string + color_mark: + type: string + info_filling_duration: + type: number + info_platform: + type: string + info_os: + type: string + info_browser: + type: string + info_region: + type: object + properties: + country: + type: string + province: + type: string + city: + type: string + info_remote_ip: + type: string + additionalProperties: true + examples: + - token: kjFelJqi + serial_number: 36 + field_1: 张三 + field_3: + - 选项 A + - 选项 B + field_9: 198 + field_12: '2021-09-23' + creator_name: '' + created_at: '2021-09-28T02:59:42.539Z' + updated_at: '2021-09-28T02:59:42.539Z' + info_filling_duration: 193.51 + info_remote_ip: 127.0.0.1 + Entry: + type: object + required: + - form + - form_name + - entry + properties: + form: + type: string + description: 表单 token + form_name: + type: string + entry: + $ref: '#/components/schemas/EntryData' + url: + type: string + format: uri + description: 该条数据在金数据数据页中的打开地址(表单为数据页、表格为表格页,定位到该条序号),给用户核对用。只在获取、新增、更新单条数据的响应中出现;列表与批量接口不返回;快捷收款表单的数据没有该键。 + examples: + - form: kjFelJqi + form_name: 报名表 + entry: + token: kjFelJqi + serial_number: 36 + field_1: 张三 + url: https://next.jinshuju.net/forms/kjFelJqi/entries?serial_number=36 + EntryListResponse: + type: object + required: + - total + - count + - data + - next + properties: + total: + type: integer + description: 满足条件的数据总数 + count: + type: integer + description: 本页条数 + data: + type: array + items: + $ref: '#/components/schemas/EntryData' + next: + type: + - integer + - 'null' + description: 下一页游标(默认排序下为序号,其他情况为行偏移量);末页为 null + EntryInput: + type: object + description: | + 写入数据时的字段值,键为字段 api_code 或 api_code_alias。未知键与不可写字段(签名、计算)被忽略。 + 选项类字段可传选项 api_code 或显示值,多级下拉与选项补充文本见下表;数字字段接受数字或数字字符串。 + + | 字段 | 写法 | + |---|---| + | 单行 / 多行文字、姓名、手机、邮箱、电话、网址、身份证 | 字符串 | + | 单选、下拉 | `"选项 api_code"` 或 `"选项显示值"`;补充文本用 `"_extended_text": {"<选项>": "补充"}`,「其他」文本用 `"_other": "..."` | + | 多选 | 数组,元素同上 | + | 图片单选 / 图片多选 | 选项 api_code(字符串 / 数组) | + | 多级下拉 | `{"level_1": "...", "level_2": "..."}`(api_code 或显示值) | + | 数字、评分、NPS | 数字 | + | 是否 | 布尔 | + | 日期 | `"2012-12-25"` 或 `"2012-12-25 12:13:15"`(北京时间) | + | 时间 | `{"hour": "12", "minute": "15"}` | + | 地址 | `{"province","city","district","street"}` | + | 地理位置 | `{"longitude","latitude","address"}` | + | 上传文件(可多个) | 数组,元素可为:已上传附件的 id;本表单已有附件的下载地址(用于保留);`{"base64": "...", "file_name": "a.png", "content_type": "image/png"}`(≤ 5MB,`base64` 也可为 data URI);`{"attachment_token": "..."}`(预上传令牌) | + | 商品 | `[{"value": "商品 api_code", "number": 2, "specification": {"规格维度": "选项"}?}]` | + | 矩阵填空 / 矩阵量表 | `{"<题目 api_code>": {"<列 api_code>": 值}}` | + | 矩阵单选 | `{"<题目 api_code>": "选项 api_code"}` | + | 表格 | `[{"<列 api_code>": 值}, ...]`,附件列写法同上传文件 | + | 横向填空 | `{"<子字段 api_code>": "..."}` | + | 排序 | 有序数组 `["选项 api_code", ...]` | + | 表单关联 | 关联数据的 token / id,或用关联键匹配的显示值(多个关联键传数组) | + | 预约 | `[{"api_code": "项目", "scheduled_at": "2021-04-26", "end_at"?: "...", "number": 1}]` | + | 颜色标记 | `"color_mark": "<颜色 api_code 或名称>"` | + | 国际手机号 | 另传 `"_country_id"` / `"_country_code"` | + additionalProperties: true + examples: + - field_1: 张三 + field_2: '13800000000' + field_3: + - 选项 A + - 选项 B + field_4: '2024-05-01' + field_5: + - 5f3a1b2c4d5e6f7a8b9c0d1e + - base64: iVBORw0KGgo... + file_name: photo.png + content_type: image/png + BatchCreateResult: + type: object + required: + - created_count + - errors + properties: + created_count: + type: integer + errors: + type: array + items: + type: object + required: + - index + - reason + properties: + index: + type: integer + description: 请求数组中的下标 + reason: + type: string + BatchPatchResult: + type: object + required: + - updated_count + - failed_rows + properties: + updated_count: + type: integer + failed_rows: + type: array + items: + type: object + properties: + serial_number: + type: + - integer + - 'null' + reason: + type: string + description: 如 `invalid_serial_number`、`invalid_attributes` + errors: + type: array + items: + type: string + TableSummary: + type: object + required: + - name + - token + - kind + - url + - created_at + - last_entry_created_at + - creator + - entries_count + properties: + name: + type: string + token: + type: string + kind: + type: string + enum: + - table + url: + type: string + format: uri + description: 表格在产品中的地址 + created_at: + type: string + format: date-time + last_entry_created_at: + type: + - string + - 'null' + format: date-time + creator: + $ref: '#/components/schemas/Creator' + entries_count: + type: integer + TableListResponse: + type: object + required: + - total + - count + - data + - next + properties: + total: + type: integer + count: + type: integer + data: + type: array + items: + $ref: '#/components/schemas/TableSummary' + next: + type: + - string + - 'null' + Table: + allOf: + - $ref: '#/components/schemas/TableSummary' + - type: object + required: + - fields + - views + properties: + fields: + type: array + items: + $ref: '#/components/schemas/FieldMap' + views: + type: array + description: 当前身份可见的视图,按顺序排列 + items: + $ref: '#/components/schemas/FormView' + FormCreateInput: + type: object + title: FormCreateInput + additionalProperties: false + required: + - name + - fields + properties: + name: + type: string + description: 表单名称 + description: + type: string + description: 表单描述 + fields: + type: array + minItems: 1 + items: + $ref: '#/components/schemas/FieldInput' + setting: + $ref: '#/components/schemas/FormSettingWriteInput' + folder_token: + type: string + description: 放入的文件夹 token(或 id),必须可管理且 kind 为 form + scene: + type: string + enum: + - form + - survey + - registry + - vote + - exam + - reservation + - customer_acquisition + - evaluation + - online_payment + - quickpay + description: 表单场景,缺省为普通表单 + layout: + type: string + enum: + - classic + - card + description: 布局,仅 survey / form / exam / evaluation 场景有效;card 为分页式 + allOf: + - $ref: '#/components/schemas/FormCreateConstraints' + TableCreateInput: + type: object + title: TableCreateInput + additionalProperties: false + required: + - name + - fields + properties: + name: + type: string + description: 表格名称 + description: + type: string + description: 表格描述 + fields: + type: array + minItems: 1 + items: + $ref: '#/components/schemas/TableColumnInput' + description: 列定义 + setting: + $ref: '#/components/schemas/FormSettingWriteInput' + folder_token: + type: string + description: 放入的 table 文件夹 token + with_default_entries: + type: boolean + default: false + description: 为 true 时创建后预置 3 条空白数据 + scene: + type: string + enum: + - form + - survey + - registry + - vote + - exam + - reservation + - customer_acquisition + - evaluation + - online_payment + - quickpay + FieldInput: + type: object + description: 表单字段定义,按 type 分支;各分支只接受该类型的属性 + required: + - type + - label + properties: + type: + type: string + enum: + - TextField + - TextArea + - NumberField + - EmailField + - MobileField + - IdCardField + - NameField + - RadioButton + - CheckBox + - DropDown + - DateTimeField + - RatingField + - TableField + - TelephoneField + - AddressField + - AttachmentField + - GeoField + - LinkField + - NpsField + - TimeField + - SortField + - ESignatureField + - AudioField + - CascadeDropDown + - LikertField + - MatrixField + - MatrixScaleField + - GoodsField + - FormulaField + - ReservationField + - FormAssociation + - ImageRadioButton + - ImageCheckBox + - SectionBreak + - PageBreak + - WidgetButton + - WidgetContact + - WidgetMap + - WidgetMarquee + - SingleSelect + - MultiSelect + - ImageSingleSelect + - ImageMultiSelect + - TrueOrFalse + - DropDownSelect + - FillInBlank + - ShortAnswer + - FillInNumber + - Rating + - Nps + - Department + - Grade + label: + type: string + description: 字段标题 + discriminator: + propertyName: type + mapping: + TextField: "#/components/schemas/TextFieldInput" + TextArea: "#/components/schemas/TextAreaInput" + NumberField: "#/components/schemas/NumberFieldInput" + EmailField: "#/components/schemas/EmailFieldInput" + MobileField: "#/components/schemas/MobileFieldInput" + IdCardField: "#/components/schemas/IdCardFieldInput" + NameField: "#/components/schemas/NameFieldInput" + RadioButton: "#/components/schemas/RadioButtonInput" + CheckBox: "#/components/schemas/CheckBoxInput" + DropDown: "#/components/schemas/DropDownInput" + DateTimeField: "#/components/schemas/DateTimeFieldInput" + RatingField: "#/components/schemas/RatingFieldInput" + TableField: "#/components/schemas/TableFieldInput" + TelephoneField: "#/components/schemas/TelephoneFieldInput" + AddressField: "#/components/schemas/AddressFieldInput" + AttachmentField: "#/components/schemas/AttachmentFieldInput" + GeoField: "#/components/schemas/GeoFieldInput" + LinkField: "#/components/schemas/LinkFieldInput" + NpsField: "#/components/schemas/NpsFieldInput" + TimeField: "#/components/schemas/TimeFieldInput" + SortField: "#/components/schemas/SortFieldInput" + ESignatureField: "#/components/schemas/ESignatureFieldInput" + AudioField: "#/components/schemas/AudioFieldInput" + CascadeDropDown: "#/components/schemas/CascadeDropDownInput" + LikertField: "#/components/schemas/LikertFieldInput" + MatrixField: "#/components/schemas/MatrixFieldInput" + MatrixScaleField: "#/components/schemas/MatrixScaleFieldInput" + GoodsField: "#/components/schemas/GoodsFieldInput" + FormulaField: "#/components/schemas/FormulaFieldInput" + ReservationField: "#/components/schemas/ReservationFieldInput" + FormAssociation: "#/components/schemas/FormAssociationInput" + ImageRadioButton: "#/components/schemas/ImageRadioButtonInput" + ImageCheckBox: "#/components/schemas/ImageCheckBoxInput" + SectionBreak: "#/components/schemas/SectionBreakInput" + PageBreak: "#/components/schemas/PageBreakInput" + WidgetButton: "#/components/schemas/WidgetButtonInput" + WidgetContact: "#/components/schemas/WidgetContactInput" + WidgetMap: "#/components/schemas/WidgetMapInput" + WidgetMarquee: "#/components/schemas/WidgetMarqueeInput" + SingleSelect: "#/components/schemas/SingleSelectInput" + MultiSelect: "#/components/schemas/MultiSelectInput" + ImageSingleSelect: "#/components/schemas/ImageSingleSelectInput" + ImageMultiSelect: "#/components/schemas/ImageMultiSelectInput" + TrueOrFalse: "#/components/schemas/TrueOrFalseInput" + DropDownSelect: "#/components/schemas/DropDownSelectInput" + FillInBlank: "#/components/schemas/FillInBlankInput" + ShortAnswer: "#/components/schemas/ShortAnswerInput" + FillInNumber: "#/components/schemas/FillInNumberInput" + Rating: "#/components/schemas/RatingInput" + Nps: "#/components/schemas/NpsInput" + Department: "#/components/schemas/DepartmentInput" + Grade: "#/components/schemas/GradeInput" + oneOf: + - $ref: '#/components/schemas/TextFieldInput' + - $ref: '#/components/schemas/TextAreaInput' + - $ref: '#/components/schemas/NumberFieldInput' + - $ref: '#/components/schemas/EmailFieldInput' + - $ref: '#/components/schemas/MobileFieldInput' + - $ref: '#/components/schemas/IdCardFieldInput' + - $ref: '#/components/schemas/NameFieldInput' + - $ref: '#/components/schemas/RadioButtonInput' + - $ref: '#/components/schemas/CheckBoxInput' + - $ref: '#/components/schemas/DropDownInput' + - $ref: '#/components/schemas/DateTimeFieldInput' + - $ref: '#/components/schemas/RatingFieldInput' + - $ref: '#/components/schemas/TableFieldInput' + - $ref: '#/components/schemas/TelephoneFieldInput' + - $ref: '#/components/schemas/AddressFieldInput' + - $ref: '#/components/schemas/AttachmentFieldInput' + - $ref: '#/components/schemas/GeoFieldInput' + - $ref: '#/components/schemas/LinkFieldInput' + - $ref: '#/components/schemas/NpsFieldInput' + - $ref: '#/components/schemas/TimeFieldInput' + - $ref: '#/components/schemas/SortFieldInput' + - $ref: '#/components/schemas/ESignatureFieldInput' + - $ref: '#/components/schemas/AudioFieldInput' + - $ref: '#/components/schemas/CascadeDropDownInput' + - $ref: '#/components/schemas/LikertFieldInput' + - $ref: '#/components/schemas/MatrixFieldInput' + - $ref: '#/components/schemas/MatrixScaleFieldInput' + - $ref: '#/components/schemas/GoodsFieldInput' + - $ref: '#/components/schemas/FormulaFieldInput' + - $ref: '#/components/schemas/ReservationFieldInput' + - $ref: '#/components/schemas/FormAssociationInput' + - $ref: '#/components/schemas/ImageRadioButtonInput' + - $ref: '#/components/schemas/ImageCheckBoxInput' + - $ref: '#/components/schemas/SectionBreakInput' + - $ref: '#/components/schemas/PageBreakInput' + - $ref: '#/components/schemas/WidgetButtonInput' + - $ref: '#/components/schemas/WidgetContactInput' + - $ref: '#/components/schemas/WidgetMapInput' + - $ref: '#/components/schemas/WidgetMarqueeInput' + - $ref: '#/components/schemas/SingleSelectInput' + - $ref: '#/components/schemas/MultiSelectInput' + - $ref: '#/components/schemas/ImageSingleSelectInput' + - $ref: '#/components/schemas/ImageMultiSelectInput' + - $ref: '#/components/schemas/TrueOrFalseInput' + - $ref: '#/components/schemas/DropDownSelectInput' + - $ref: '#/components/schemas/FillInBlankInput' + - $ref: '#/components/schemas/ShortAnswerInput' + - $ref: '#/components/schemas/FillInNumberInput' + - $ref: '#/components/schemas/RatingInput' + - $ref: '#/components/schemas/NpsInput' + - $ref: '#/components/schemas/DepartmentInput' + - $ref: '#/components/schemas/GradeInput' + title: FieldInput + ChoiceInput: + type: object + title: ChoiceInput + additionalProperties: false + description: 选项;value 与 name 等价,任填其一 + anyOf: + - required: + - value + - required: + - name + properties: + value: + type: string + description: 选项显示值 + name: + type: string + description: 与 value 等价 + api_code: + type: string + description: 指定选项 api_code;缺省由服务端生成,编辑时用于保留身份 + quota: + type: integer + minimum: 1 + description: 名额上限;不限名额时省略 + operand_value: + type: number + description: 选项赋值 + selected: + type: boolean + description: 默认选中 + is_other: + type: boolean + description: 设为其他选项(选中后出现填写框),文字照原样保留;文字正好是「其他」「其它」的不用设。只用于单选 / 多选 / 下拉 + DimensionInput: + type: object + title: DimensionInput + additionalProperties: false + description: 矩阵 / 表格的列 + required: + - type + - label + properties: + type: + type: string + enum: + - TextField + - TextArea + - MobileField + - CheckBox + - DropDown + - NumberField + - DateTimeField + - TimeField + - AttachmentField + - IdCardField + - EmailField + description: 列类型 + label: + type: string + pattern: "\\S" + description: 列名 + api_code: + type: string + cid: + type: string + required: + type: boolean + description: 必填 + choices: + type: array + items: + $ref: '#/components/schemas/ChoiceInput' + description: 选项类列的选项 + StatementInput: + type: object + title: StatementInput + additionalProperties: false + required: + - label + properties: + label: + type: string + pattern: "\\S" + description: 题目文字 + api_code: + type: string + description: 编辑时用于保留身份 + GoodsItemInput: + type: object + title: GoodsItemInput + additionalProperties: false + description: 商品项目 + required: + - name + - layout + properties: + name: + type: string + description: 商品名 + layout: + type: string + enum: + - without_images + - price_only + - images + - text_options + description: 布局 + api_code: + type: string + description: 编辑时用于保留身份;缺省按 name 匹配已有项目 + description: + type: string + description: 商品说明 + position: + type: integer + minimum: 0 + hidden: + type: boolean + price: + type: number + minimum: 0 + original_price: + type: number + minimum: 0 + description: 划线价 + predefined_number: + type: integer + minimum: 0 + description: 默认数量 + fixed_number: + type: boolean + description: 数量不可修改 + inventory: + type: integer + minimum: 0 + description: 库存;有规格时写在各 sku 上 + inventory_reset: + type: boolean + min_purchase_quantity: + type: integer + minimum: 0 + max_purchase_quantity: + type: integer + minimum: 0 + dimensions: + type: array + minItems: 1 + items: + type: object + additionalProperties: false + required: + - label + - options + properties: + api_code: + type: string + description: 编辑时用于保留身份 + label: + type: string + description: 维度名,如「尺寸」 + options: + type: array + minItems: 1 + items: + type: object + additionalProperties: false + required: + - label + properties: + label: + type: string + value: + type: string + description: 缺省自动生成;customized 表示自定义金额选项 + description: 规格维度 + skus: + type: array + minItems: 1 + items: + type: object + additionalProperties: false + required: + - specification + properties: + specification: + type: object + additionalProperties: + type: string + description: 键为维度 api_code 或名称,值为选项 value 或名称 + price: + type: number + minimum: 0 + original_price: + type: number + minimum: 0 + inventory: + type: integer + minimum: 0 + inventory_reset: + type: boolean + min_purchase_quantity: + type: integer + minimum: 0 + max_purchase_quantity: + type: integer + minimum: 0 + description: 规格组合,specification 须为每个维度各指定一个选项 + image_urls: + type: array + items: + type: string + format: uri + description: layout=images 的商品图地址 + image_base64s: + type: array + items: + type: string + description: layout=images 的 Base64 商品图 + dependentRequired: + dimensions: + - skus + skus: + - dimensions + allOf: + - description: price_only / text_options 布局必须带规格 + if: + properties: + layout: + enum: + - price_only + - text_options + then: + required: + - dimensions + - skus + - description: images 布局至少一张图 + if: + properties: + layout: + const: images + then: + anyOf: + - required: + - image_urls + properties: + image_urls: + minItems: 1 + - required: + - image_base64s + properties: + image_base64s: + minItems: 1 + ReservationItemInput: + type: object + title: ReservationItemInput + additionalProperties: false + description: 预约项目 + required: + - name + properties: + name: + type: string + api_code: + type: string + description: 编辑时用于保留身份;缺省按 name 匹配已有项目 + description: + type: string + position: + type: integer + minimum: 0 + hidden: + type: boolean + selected: + type: boolean + payable: + type: boolean + description: 需要付费(依赖套餐与各时段的 price) + start_date: + type: string + format: date + end_date: + type: string + format: date + quota_setting: + type: object + description: 名额设置,按 type 分支 + required: + - type + properties: + type: + type: string + enum: + - by_day_repeat_weekly + - by_time_range_repeat_daily + discriminator: + propertyName: type + mapping: + by_day_repeat_weekly: "#/components/schemas/ReservationByDayQuotaInput" + by_time_range_repeat_daily: "#/components/schemas/ReservationByTimeRangeQuotaInput" + oneOf: + - $ref: '#/components/schemas/ReservationByDayQuotaInput' + - $ref: '#/components/schemas/ReservationByTimeRangeQuotaInput' + FormSettingWriteInput: + type: object + description: | + 创建 / 编辑表单时的 `setting`,只接受以下键,其他键忽略。写入的是「结果状态」:省略的键保持原值,传 `null` 表示清除。 + - 只传 `success_message` 会自动把 `success_message_style` 设为 `text`;只传 `success_message_rich_text` 则设为 `rich_text`。 + - `submission_region_limited=true` 需要套餐包含「填写地域限制」,且 `submission_regions` 至少一项。 + - `merchant_account_id`(小金商户)不能与 `wxpay_account_id` / `alipay_account_id` 同时绑定;新传入的支付账户必须属于当前企业账户,绑定后 `payment_way` 自动为 `third_party`。 + - `notification_rules` 整体替换由 API 创建的通知规则(传 `[]` 即清空),不影响桌面端创建的规则。 + properties: + success_message: + type: string + success_message_rich_text: + type: string + success_message_style: + type: string + enum: + - text + - rich_text + entry_submit_mode: + type: string + success_redirect_url: + type: string + success_redirect_fields: + type: array + items: + type: string + open_entry_action: + type: string + open_entry_message: + type: string + open_entry_cancel_reservation: + type: boolean + show_serial_number_on_success: + type: boolean + show_submit_again: + type: boolean + hide_login_and_signup: + type: boolean + show_close_count_down: + type: boolean + show_form_before_open: + type: boolean + manually_close_rule: + type: + - object + - 'null' + properties: + closed: + type: boolean + additionalProperties: false + by_time_range_close_rule: + type: + - object + - 'null' + properties: + start_time: + type: string + format: date-time + end_time: + type: string + format: date-time + additionalProperties: false + by_entries_close_rule: + type: + - object + - 'null' + properties: + limit: + type: integer + additionalProperties: false + fill_frequency: + type: + - object + - 'null' + description: 只接受 `condition` `fill_type` `cycle_period` `limited_time` `cycles_per_period` `limited_field_api_codes` + properties: + fill_type: + type: string + enum: + - unlimited + - once + - repeatable + - repeatable_by_day + - custom_repeatable + condition: + type: string + enum: + - by_weixin + - by_ip + - by_device + - by_fields + cycle_period: + type: string + enum: + - every_hour + - every_day + - every_week + - every_month + cycles_per_period: + type: integer + minimum: 1 + maximum: 1000 + limited_time: + type: integer + limited_field_api_codes: + type: array + items: + type: string + additionalProperties: false + show_field_number: + type: boolean + password_required: + type: boolean + access_password: + type: string + allowed_audience: + type: string + weixin_limited: + type: boolean + submission_region_limited: + type: boolean + submission_regions: + type: array + items: + type: string + merchant_account_id: + type: + - string + - 'null' + wxpay_account_id: + type: + - string + - 'null' + alipay_account_id: + type: + - string + - 'null' + notification_rules: + type: array + items: + $ref: '#/components/schemas/NotificationRuleInput' + daily_repeat_close_rule: + $ref: '#/components/schemas/DailyRepeatCloseRuleInput' + additionalProperties: false + NotificationRuleInput: + type: object + required: + - approach + - url + properties: + approach: + type: string + enum: + - WXWORK + - DING_TALK + - WEBHOOK + description: 通知渠道(大小写不敏感) + url: + type: string + format: uri + description: 机器人 / Webhook 地址 + content: + type: string + description: 通知内容模板 + trigger_scope: + type: string + default: all_new + mentioned_mobile_list: + type: array + items: + type: string + enabled: + type: boolean + default: true + additionalProperties: false + FormUpdateInput: + type: object + description: 至少包含 `name` / `description` / `fields` / `setting` / `field_rules` 之一 + properties: + name: + type: string + description: + type: + - string + - 'null' + setting: + $ref: '#/components/schemas/FormSettingWriteInput' + fields: + type: object + description: 字段增量操作,至少一个子键非空才生效 + properties: + add: + type: array + description: 新增字段;可带 `position`(0 起)指定插入位置,缺省追加到末尾 + items: + anyOf: + - $ref: '#/components/schemas/FieldInput' + - $ref: '#/components/schemas/TableColumnInput' + remove: + type: array + description: 要删除的字段 api_code;不存在时返回 400 + items: + type: string + update: + type: array + items: + $ref: '#/components/schemas/FieldUpdateInput' + update_choices: + type: array + items: + $ref: '#/components/schemas/ChoiceUpdateInput' + additionalProperties: false + field_rules: + $ref: '#/components/schemas/FieldRuleOperations' + additionalProperties: false + FieldUpdateInput: + type: object + title: FieldUpdateInput + additionalProperties: false + description: 按 api_code 定位已有字段做修改,只处理出现的键;类型专属属性写在不适用的类型上会被忽略 + required: + - api_code + properties: + api_code: + type: string + description: 要修改的字段 api_code + type: + type: string + enum: + - TextField + - TextArea + - NumberField + - EmailField + - MobileField + - IdCardField + - NameField + - RadioButton + - CheckBox + - DropDown + - DateTimeField + - RatingField + - TableField + - TelephoneField + - AddressField + - AttachmentField + - GeoField + - LinkField + - NpsField + - TimeField + - SortField + - ESignatureField + - AudioField + - CascadeDropDown + - LikertField + - MatrixField + - MatrixScaleField + - GoodsField + - FormulaField + - ReservationField + - FormAssociation + - ImageRadioButton + - ImageCheckBox + - SectionBreak + - PageBreak + - WidgetButton + - WidgetContact + - WidgetMap + - WidgetMarquee + description: 切换为该类型:保留 api_code 与数据并迁移值;会清空数据的切换需 confirm_data_loss + confirm_data_loss: + type: boolean + description: 确认接受类型切换清空的数据 + label: + type: string + description: 字段标题 + notes: + type: + - string + - 'null' + description: 字段说明 + private: + type: boolean + description: 隐藏字段;隐藏字段不加必填校验 + required: + type: boolean + description: 必填 + unique: + type: boolean + description: 不允许重复;仅 TextField / NameField / EmailField / MobileField / TelephoneField / IdCardField / LinkField / FormAssociation / TextArea / NumberField 生效 + other_choice_required: + type: boolean + description: 选了「其他」必须填写;仅 RadioButton / CheckBox / DropDown 生效 + position: + type: integer + minimum: 0 + description: 字段在表单中的目标位置(0 起);缺省追加到末尾 + rating_max: + type: integer + minimum: 1 + choices: + type: array + items: + $ref: '#/components/schemas/CascadeChoiceInput' + description: 仅 CascadeDropDown,或文本字段转选择类型时接受;其他情况用 update_choices + dimensions: + type: array + items: + $ref: '#/components/schemas/DimensionInput' + description: 整体替换 + statements: + type: array + items: + $ref: '#/components/schemas/StatementInput' + description: 整体替换,带 api_code 以保留身份 + goods_items: + type: array + items: + $ref: '#/components/schemas/GoodsItemInput' + description: 整体替换,带 api_code 以保留身份 + reservation_items: + type: array + items: + $ref: '#/components/schemas/ReservationItemInput' + description: 整体替换,带 api_code 以保留身份 + display_field_api_codes: + type: array + maxItems: 20 + items: + type: string + answers: + type: array + minItems: 1 + items: + $ref: '#/components/schemas/AnswerInput' + description: 整体替换答案库 + answer_setting_mode: + type: string + enum: + - absolute + - partial_absolute + - relative + answer_explanation: + type: string + calculable: + type: boolean + description: 选项赋值开关;开启后每个(叶子)选项必须带 operand_value + choices_layout: + type: string + description: 选项排列方式 + predefined_value: + description: 默认值;文本类为字符串,DateTimeField 为符合精度的日期字符串或 today / yesterday / tomorrow,TimeField 为 {"hour", "minute"} 对象,AddressField 为地址对象 + placeholder: + type: string + description: 占位提示文字 + sms_verification: + type: boolean + description: 开启短信验证(同时自动开启图形验证码) + range_min: + type: number + description: 最小值(含) + range_max: + type: number + description: 最大值(含) + precision: + anyOf: + - type: integer + minimum: 0 + maximum: 14 + - type: string + enum: + - month + - day + - minute + - second + description: NumberField / FormulaField 为小数位数,DateTimeField 为精度枚举 + display_precision: + type: integer + minimum: 0 + description: 显示用小数位数 + display_as_percentage: + type: boolean + description: 按百分比显示;与 thousands_separator 互斥 + thousands_separator: + type: boolean + description: 显示千分位分隔符;与 display_as_percentage 互斥 + currency_symbol: + type: string + description: 货币符号 + currency_symbol_position: + type: string + enum: + - prefix + - suffix + description: 货币符号位置 + changeable: + type: object + description: AddressField 各级是否允许修改,键为 province / city / district / street,值为布尔 + additionalProperties: + type: boolean + enable_auto_position: + type: boolean + description: 首次打开时按设备定位自动填充地址 + max_size: + type: integer + minimum: 1 + maximum: 500 + description: 单文件大小上限(MB) + max_file_quantity: + type: integer + minimum: 1 + description: 最多上传的文件数 + media_type: + type: object + additionalProperties: false + description: 允许的文件类型;type 为预设分组,type=custom 时必须用 value 列出扩展名 + properties: + type: + type: string + enum: + - unlimited + - doc + - image + - video + - audio + - compress + - custom + value: + type: array + items: + type: string + description: 小写扩展名,不含点 + if: + properties: + type: + const: custom + required: + - type + then: + required: + - value + properties: + value: + minItems: 1 + mobile_camera_only: + type: boolean + description: 移动端只能拍照上传,不能从相册选择 + minimum_ratings_display_text: + type: string + description: 最低分文案 + maximum_ratings_display_text: + type: string + description: 最高分文案 + include_second: + type: boolean + description: 精确到秒 + random_choices: + type: boolean + description: 每次打开随机排列选项 + max_duration: + type: integer + minimum: 1 + maximum: 5 + description: 最长录音时长(分钟) + levels: + type: integer + minimum: 2 + description: 多级下拉的层级数,每条选项路径都必须到达该深度 + choice_filterable: + type: boolean + description: 允许筛选选项 + horizontal_on_mobile: + type: boolean + description: 移动端横向排列 + likert_choice_style: + type: string + enum: + - single + - multiple + description: 每个题目可选一项(single)或多项(multiple) + minimum_length: + type: integer + minimum: 0 + description: likert_choice_style=multiple 时每题最少选几项 + maximum_length: + type: integer + minimum: 0 + description: likert_choice_style=multiple 时每题最多选几项 + advanced_feature: + type: boolean + description: 开启高级功能 + unit: + type: string + maxLength: 2 + description: 数量单位,最多 2 个字 + collapse_on_mobile: + type: boolean + description: 移动端折叠商品列表 + columns_on_mobile: + type: integer + minimum: 1 + description: 移动端每行商品数 + formula_display: + type: string + description: 公式 HTML,字段引用写成 或 + result_display_type: + type: string + enum: + - string + - numeric + - datetime + description: 结果显示方式 + result_display_as_date: + type: boolean + description: result_display_type=datetime 的旧写法 + icon_type: + type: string + enum: + - none + - number-2 + - dollar1 + - cny1 + - formula-2 + - date-2 + - sum + - text + - text2 + description: 字段图标 + allow_multiple_items: + type: boolean + description: 一次提交可预约多个项目 + item_allow_multiple_reservations: + type: boolean + description: 同一项目可预约多个时段 + allow_multiple_number: + type: boolean + description: 每个时段可预约多个名额 + use_daily_time_range: + type: boolean + description: 启用按日时段表 + associated_form_token: + type: string + description: 被关联表单的 token + associated_field_api_codes: + type: array + minItems: 1 + maxItems: 4 + items: + type: string + description: 关联键字段的 api_code + privacy_safe: + type: boolean + description: 关联字段全局开启隐私保护显示 + allow_reverse_association: + type: boolean + description: 在被关联表单上显示反向关联 + reverse_display_field_api_codes: + type: array + items: + type: string + description: 反向关联展示的字段 api_code + related_form_token: + type: string + description: 关联的表格 token;只能是另一张表格,且需要它的管理权限 + paired: + type: boolean + description: 双向关联,缺省开启;开启时在关联的表格中自动创建反向列 + related_label: + type: string + description: 反向列名称,只在创建反向列时使用,缺省取本表格名称 + show_split_line: + type: boolean + description: 显示分割线 + show_part_description: + type: boolean + description: 描述过长时折叠显示 + disable_previous_page: + type: boolean + description: 隐藏「上一页」按钮 + previous_page_text: + type: string + description: "「上一页」按钮文字" + next_page_text: + type: string + description: "「下一页」按钮文字" + count_down: + type: integer + minimum: 0 + description: 锁定「下一页」的秒数,0 表示不锁 + buttons_number: + type: integer + minimum: 1 + description: 按钮数量,须与 buttons 数组长度一致 + fixed_to_bottom: + type: boolean + description: 按钮栏固定在页面底部 + widget_max_images_number: + type: integer + minimum: 1 + description: 最多展示的图片数,须不小于 widget_images 的长度 + phones: + type: array + description: 联系电话 + items: + type: object + required: + - phone + additionalProperties: false + properties: + phone: + type: string + nav_address: + type: string + description: 导航地址文字 + longitude: + type: number + description: 经度 + latitude: + type: number + description: 纬度 + value: + type: string + description: 显示值 + localizable_on_mobile: + type: boolean + description: 移动端允许跳转定位 + lines: + type: integer + minimum: 1 + description: 滚动行数 + entries_init_number: + type: integer + minimum: 0 + description: 起始展示的提交数 + entries_number_visible: + type: boolean + description: 显示提交数 + marquee_data_type: + type: string + enum: + - fake + - real + description: 数据来源:fake 为生成,real 为真实数据 + allOf: + - description: display_as_percentage 与 thousands_separator 不能同时为 true + not: + properties: + display_as_percentage: + const: true + thousands_separator: + const: true + required: + - display_as_percentage + - thousands_separator + - description: minimum_length / maximum_length 只在 likert_choice_style=multiple 时有意义 + if: + not: + properties: + likert_choice_style: + const: multiple + required: + - likert_choice_style + then: + not: + anyOf: + - required: + - minimum_length + - required: + - maximum_length + - description: calculable=true 时每个选项都要有 operand_value + if: + properties: + calculable: + const: true + required: + - calculable + then: + properties: + choices: + items: + required: + - operand_value + ChoiceUpdateInput: + type: object + required: + - field_api_code + description: 对某个选项类字段(或矩阵 / 表格中的选项列)的选项做增量修改;字段不存在或不支持选项时返回 400 + properties: + field_api_code: + type: string + add: + type: array + items: + $ref: '#/components/schemas/ChoiceInput' + remove: + type: array + description: '要删除的选项 api_code(或显示值),也可写成 `{"api_code": "..."}`' + items: + oneOf: + - type: string + - type: object + properties: + api_code: + type: string + update: + type: array + items: + type: object + required: + - api_code + properties: + api_code: + type: string + value: + type: string + description: 新显示值 + operand_value: + type: number + selected: + type: boolean + additionalProperties: false + additionalProperties: false + FieldRuleOperations: + type: object + description: 字段规则增量操作,`remove` / `update` 以 `GET /forms/{form_token}/field_rules` 返回的 0 起 `index` 定位;同一 index 不能同时删除和更新 + properties: + add: + type: array + items: + $ref: '#/components/schemas/FieldRuleWriteInput' + remove: + type: array + items: + type: integer + update: + type: array + items: + $ref: '#/components/schemas/FieldRuleWriteInput' + additionalProperties: false + FieldRuleWriteInput: + type: object + properties: + index: + type: integer + description: 仅 update 操作使用:要修改的规则下标(0 起) + targets: + type: array + items: + type: string + description: 受影响字段的 api_code;`targets_display_mode=abort` 时可省略 + targets_display_mode: + type: string + enum: + - show + - abort + description: "`show` / `abort`,默认 show" + operator: + type: string + enum: + - or + - and + description: 条件之间的关系,默认 or + conditions: + type: array + items: + type: object + required: + - trigger + properties: + trigger: + type: string + description: 触发字段 api_code + comparator: + type: string + description: 比较符,缺省取该字段支持的第一个;不支持时返回 400 并列出可用比较符 + value: + description: 比较值 + additionalProperties: false + additionalProperties: false + FormSettingSaveOk: + type: object + required: + - status + properties: + status: + type: string + enum: + - ok + FormSettingSaveFailed: + type: object + description: 表单设置类接口保存失败时的响应体 + required: + - errors + - status + properties: + errors: + type: string + description: 校验错误信息,多条以逗号分隔 + status: + type: string + enum: + - failed + FormFrozenError: + type: object + description: 表单或其创建者被冻结时的 403 响应体 + required: + - errors + - status_code + properties: + errors: + description: 冻结原因;表单被冻结时为字符串数组,创建者账号被禁用时为字符串 + oneOf: + - type: string + - type: array + items: + type: string + status_code: + type: string + enum: + - form_forbidden + ManuallyCloseRule: + type: object + required: + - closed + properties: + closed: + type: boolean + description: 是否已手动关闭收集 + FormManuallyCloseRuleInput: + type: object + required: + - open + properties: + open: + description: true 开启收集,false 关闭收集;接受布尔值或 "true" / "false" 字符串 + oneOf: + - type: boolean + - type: string + enum: + - 'true' + - 'false' + additionalProperties: false + FormFillFrequency: + type: object + description: 填写频率限制(读取) + required: + - fill_type + - condition + - cycle_period + - cycles_per_period + - limited_time + - limited_field_api_codes + properties: + fill_type: + type: string + description: 限制类型;历史数据可能为空字符串 + enum: + - unlimited + - once + - repeatable + - repeatable_by_day + - custom_repeatable + - '' + condition: + type: string + description: 判定同一填写者的依据;不限制时为空字符串。未设置时服务端按登录用户判定 + enum: + - by_weixin + - by_ip + - by_device + - by_fields + - '' + cycle_period: + type: string + description: 周期单位;仅 repeatable / custom_repeatable 有值,否则为空字符串 + enum: + - every_hour + - every_day + - every_week + - every_month + - '' + cycles_per_period: + type: integer + description: 每几个周期(仅 custom_repeatable 生效,1 ~ 1000) + limited_time: + type: + - integer + - 'null' + description: 每周期允许填写次数 + limited_field_api_codes: + type: array + description: condition 为 by_fields 时按哪些字段判定 + items: + type: string + FormFillFrequencyInput: + type: object + description: 填写频率限制(写入) + properties: + fill_type: + type: string + enum: + - unlimited + - once + - repeatable + - repeatable_by_day + - custom_repeatable + description: 限制类型;custom_repeatable 为付费功能 + condition: + type: string + enum: + - by_weixin + - by_ip + - by_device + - by_fields + description: 判定依据;by_fields 为付费功能。fill_type 为 unlimited 时会被清空 + cycle_period: + type: string + enum: + - every_hour + - every_day + - every_week + - every_month + description: 周期单位;仅 repeatable / custom_repeatable 保留 + cycles_per_period: + type: integer + minimum: 1 + maximum: 1000 + description: 每几个周期;仅 custom_repeatable 保留,其余类型重置为 1 + limited_time: + type: integer + minimum: 1 + description: 每周期允许次数;repeatable / custom_repeatable 时必须大于 0 + limited_field_api_codes: + type: array + description: condition 为 by_fields 时必填且不能为空 + items: + type: string + FormSetting: + type: object + description: 表单设置(GET /forms/{form_token}/setting 的响应) + required: + - success_message + - success_message_rich_text + - success_message_style + - entry_submit_mode + - success_redirect_url + - success_redirect_fields + - show_entry_on_success + - show_entry_on_success_button_text + - edit_entry_on_success + - show_serial_number_on_success + - show_submit_again + - hide_login_and_signup + - show_close_count_down + - show_form_before_open + - manually_close_rule + - by_time_range_close_rule + - by_entries_close_rule + - fill_frequency + - password_required + - access_password + - allowed_audience + - weixin_limited + - weixin_collect_way + - submission_region_limited + - submission_regions + - entry_post_url + - post_new_entry + - post_updated_entry + - daily_repeat_close_rule + properties: + success_message: + type: + - string + - 'null' + description: 提交成功文案(纯文本) + success_message_rich_text: + type: + - string + - 'null' + description: 提交成功富文本 + success_message_style: + type: + - string + - 'null' + enum: + - text + - rich_text + - distribution_poster + - certificate + - + description: 提交成功页样式 + entry_submit_mode: + type: string + enum: + - show_message + - redirect + - reports + - exam_score + description: 提交后行为 + success_redirect_url: + type: + - string + - 'null' + description: 提交后跳转地址 + success_redirect_fields: + type: array + description: 跳转时附带的字段 api_code + items: + type: string + show_entry_on_success: + type: + - boolean + - 'null' + show_entry_on_success_button_text: + type: + - string + - 'null' + edit_entry_on_success: + type: + - boolean + - 'null' + show_serial_number_on_success: + type: + - boolean + - 'null' + show_submit_again: + type: + - boolean + - 'null' + hide_login_and_signup: + type: + - boolean + - 'null' + show_close_count_down: + type: + - boolean + - 'null' + description: 关闭前显示倒计时 + show_form_before_open: + type: + - boolean + - 'null' + description: 开放前是否展示表单 + manually_close_rule: + type: + - object + - 'null' + description: 手动关闭规则;未设置时为 null + properties: + closed: + type: boolean + by_time_range_close_rule: + type: + - object + - 'null' + description: 按时间段开放规则;未设置时为 null + properties: + start_time: + type: + - string + - 'null' + format: date-time + end_time: + type: + - string + - 'null' + format: date-time + by_entries_close_rule: + type: + - object + - 'null' + description: 按数据量关闭规则;未设置时为 null + properties: + limit: + type: + - integer + - 'null' + fill_frequency: + description: 填写频率限制;未设置时为 null + oneOf: + - type: 'null' + - $ref: '#/components/schemas/FormFillFrequency' + password_required: + type: + - boolean + - 'null' + description: 是否需要密码填写 + access_password: + type: + - string + - 'null' + description: 填写密码(明文回显) + allowed_audience: + type: string + enum: + - public + - internal + - private + - weixin_qiye_followers_only + - weixin_followers_only + - submitter_only + - gd_user_only + description: 允许填写的人群 + weixin_limited: + type: + - boolean + - 'null' + description: 仅限微信内填写 + weixin_collect_way: + type: string + enum: + - not_collect + - goldendata + - own + - own_weixin_qiye + - yamcha + description: 微信信息收集方式 + submission_region_limited: + type: + - boolean + - 'null' + description: 是否限制填写地区(只读) + submission_regions: + type: array + description: 允许填写的地区(只读) + items: + type: string + entry_post_url: + type: + - string + - 'null' + description: Webhook 推送地址 + post_new_entry: + type: + - boolean + - 'null' + description: 新数据是否推送 + post_updated_entry: + type: + - boolean + - 'null' + description: 数据更新是否推送 + weixin: + type: object + description: 表单所选微信公众号信息;仅在配置了公众号时返回 + required: + - appid + - scope + properties: + appid: + type: string + scope: + type: string + enum: + - base + - userinfo + description: 微信网页授权 scope + component_appid: + type: string + description: 开放平台第三方 appid;不存在时省略 + daily_repeat_close_rule: + $ref: '#/components/schemas/DailyRepeatCloseRule' + FormSettingInput: + type: object + description: PATCH /forms/{form_token}/setting 的请求体;只写入出现的键 + properties: + success_message: + type: string + maxLength: 2000 + success_message_rich_text: + type: string + description: 富文本;内嵌 iframe 域名需在白名单内 + success_message_style: + type: string + enum: + - text + - rich_text + - distribution_poster + - certificate + description: distribution_poster 需开启表单分销,certificate 需开启证书 + enable_qrcode: + type: boolean + description: 提交成功页是否显示二维码(不在 GET 响应中回显) + entry_submit_mode: + type: string + enum: + - show_message + - redirect + - reports + - exam_score + success_redirect_url: + type: string + description: entry_submit_mode 为 redirect 时必须是合法链接 + success_redirect_fields: + type: array + description: 跳转附带的字段 api_code,数量受套餐配额限制 + items: + type: string + show_entry_on_success: + type: boolean + show_entry_on_success_button_text: + type: string + edit_entry_on_success: + type: boolean + show_serial_number_on_success: + type: boolean + show_submit_again: + type: boolean + hide_login_and_signup: + type: boolean + show_close_count_down: + type: boolean + show_form_before_open: + type: boolean + show_field_number: + type: boolean + description: 是否显示题号(不在 GET 响应中回显) + password_required: + type: boolean + description: 为 true 时必须同时有 access_password;为 false 时服务端清空密码 + access_password: + type: string + description: 仅允许 ASCII 字母、数字与常见符号 + allowed_audience: + type: string + enum: + - public + - internal + - private + - weixin_qiye_followers_only + - weixin_followers_only + - submitter_only + - gd_user_only + weixin_limited: + type: boolean + entry_post_url: + type: string + description: Webhook 推送地址,需为合法链接 + post_new_entry: + type: boolean + post_updated_entry: + type: boolean + manually_close_rule: + type: object + description: 手动关闭规则;closed 变化时服务端会删除其他关闭规则 + properties: + closed: + type: boolean + additionalProperties: false + by_time_range_close_rule: + type: object + description: 按时间段开放;start_time / end_time 至少一个,且 end_time 不早于 start_time + properties: + start_time: + type: + - string + - 'null' + format: date-time + end_time: + type: + - string + - 'null' + format: date-time + additionalProperties: false + by_entries_close_rule: + type: object + description: 收集到指定数量后关闭 + properties: + limit: + type: integer + additionalProperties: false + fill_frequency: + $ref: '#/components/schemas/FormFillFrequencyInput' + additionalProperties: false + AccessPolicyInput: + type: object + description: PATCH /forms/{form_token}/access_policy 的请求体 + properties: + fill_frequency: + $ref: '#/components/schemas/FormFillFrequencyInput' + additionalProperties: false + WebhookInput: + type: object + properties: + entry_post_url: + type: string + description: 数据推送地址 + additionalProperties: false + FormWebhook: + type: object + required: + - entry_post_url + properties: + entry_post_url: + type: + - string + - 'null' + FieldRule: + type: object + description: 一条字段显示规则 + required: + - index + - targets + - targets_display_mode + - operator + - conditions + properties: + index: + type: integer + description: 0 起始位置,用于 PATCH /forms/{form_token} 更新或删除规则 + targets: + type: array + description: 受控字段的 api_code;targets_display_mode 为 abort 时固定为 ["DYNAMIC_TARGETS"] + items: + type: string + targets_display_mode: + type: string + enum: + - show + - abort + description: show 满足条件时显示目标字段;abort 满足条件时终止填写 + operator: + type: string + enum: + - or + - and + description: 多个条件之间的逻辑关系 + conditions: + type: array + items: + type: object + required: + - trigger + - comparator + - value + properties: + trigger: + type: string + description: 触发字段 api_code + comparator: + type: string + enum: + - equal + - none_in + - between + - like + - not_like + description: equal 表示"包含任一"(历史原因) + value: + description: 条件值:选项 api_code 数组、数值范围 [min, max] 或文本,取决于触发字段类型 + ExtendedAttribute: + type: object + required: + - value + - url + - cname + - created_at + properties: + value: + type: string + description: 扩展属性值 + url: + type: string + format: uri + description: 携带该值的填写链接 + cname: + type: boolean + description: 链接是否使用自定义域名 + created_at: + type: + - string + - 'null' + format: date-time + IntervalComment: + type: object + description: 一条分数区间评语;值为 null 的键会被省略 + properties: + start_point: + type: number + description: 区间起点(含) + end_point: + type: number + description: 区间终点(含),不小于 start_point + comment: + type: string + description: 评语内容(富文本) + retry: + type: boolean + description: 是否允许重新作答 + FormIntervalCommentInput: + type: object + properties: + start_point: + type: number + end_point: + type: number + description: 不小于 start_point + comment: + type: string + retry: + type: boolean + ExamSetting: + type: object + description: 考试设置;表单尚无考试设置记录时为空对象。值为 null 的键会被省略 + properties: + notice_after_filling_mode: + type: string + enum: + - appreciation + - grade + - explaination + - none + - answer + description: 交卷后展示内容 + show_timeout: + type: boolean + description: 是否限时答题 + limited_time: + type: integer + description: 限时分钟数 + need_attention: + type: string + description: 考前须知 + interval_comments: + type: array + description: 分数区间评语;仅在已开启区间评语时返回 + items: + $ref: '#/components/schemas/IntervalComment' + ExamSettingInput: + type: object + description: PATCH /forms/{form_token}/exam_setting 的请求体;只写入出现的键 + properties: + notice_after_filling_mode: + type: string + enum: + - appreciation + - grade + - explaination + - none + - answer + description: 套餐不支持显示成绩时会被强制写为 none + show_timeout: + type: boolean + description: 开启限时答题。与考试题目字段的 required 互斥:表单已有必填题目时传 true 返回 400;开启后题目字段不能再设必填(考生信息字段的必填不受影响) + limited_time: + type: integer + minimum: 1 + maximum: 9999 + description: 限时分钟数 + need_attention: + type: string + success_message_rich_text: + type: string + description: 交卷后提示富文本;仅套餐支持显示成绩时写入 + interval_comments: + type: array + description: 整体替换区间评语;需互不重叠。仅 notice_after_filling_mode 为 grade / explaination / answer 时生效,空数组被忽略 + items: + $ref: '#/components/schemas/FormIntervalCommentInput' + additionalProperties: false + EvaluationComment: + type: object + description: | + 一条总分区间评语。按分数区间编写的评语返回 start_point / end_point; + 在表单编辑器中按条件编写的评语则返回 id 与 conditions(不含 start_point / end_point)。值为 null 的键会被省略 + properties: + start_point: + type: number + end_point: + type: number + comment: + type: string + retry: + type: boolean + id: + type: string + description: 按条件编写的评语的 id;更新时按此 id 传回 + conditions: + type: array + description: 按条件编写的评语的判定条件 + items: + type: object + properties: + trigger: + type: string + description: 条件字段 api_code + operator: + type: string + description: 比较操作符 + value: + description: 条件值 + group_index: + type: integer + FormEvaluationCommentInput: + type: object + description: 按区间的评语需同时给出 start_point 与 end_point;按条件编写的评语按读回的 id 传回,只能修改 comment / retry + properties: + id: + type: string + description: 按条件编写的评语的 id + conditions: + type: array + description: 可原样传回,不可改动 + items: + type: object + properties: + trigger: + type: string + operator: + type: string + value: + description: 条件值 + group_index: + type: integer + start_point: + type: number + end_point: + type: number + comment: + type: string + retry: + type: boolean + EvaluationSetting: + type: object + description: 测评设置;表单尚无测评设置记录时为空对象。值为 null 的键会被省略 + properties: + notice_after_filling_mode: + type: string + enum: + - reports + - customize + - none + description: 提交后展示内容 + show_report_score: + type: boolean + description: 报告中显示总分 + show_report_radar: + type: boolean + description: 报告中显示雷达图 + show_indicator_comments: + type: boolean + description: 报告中显示维度评语 + show_comments: + type: boolean + description: 报告中显示总分评语 + need_attention: + type: string + description: 测评须知 + evaluation_comments: + type: array + description: 总分区间评语;仅在已开启时返回 + items: + $ref: '#/components/schemas/EvaluationComment' + indicator_setting: + type: object + description: 维度设置;仅在存在维度时返回 + properties: + indicators_scoring_mode: + type: string + enum: + - summation + - average + - customization + description: 维度计分方式 + indicators: + type: array + items: + type: object + properties: + api_code: + type: string + description: 维度标识,数据中的维度得分存于 indicator_ + name: + type: string + field_api_codes: + type: array + items: + type: string + standard_score: + type: number + description: 标准分;为 null 时省略 + indicator_comments: + type: array + items: + $ref: '#/components/schemas/IntervalComment' + EvaluationSettingInput: + type: object + description: PATCH /forms/{form_token}/evaluation_setting 的请求体;只写入出现的键 + properties: + notice_after_filling_mode: + type: string + enum: + - reports + - customize + - none + description: 套餐不支持测评报告时会被强制写为 none + show_report_score: + type: boolean + show_report_radar: + type: boolean + show_indicator_comments: + type: boolean + show_comments: + type: boolean + need_attention: + type: string + success_message_rich_text: + type: string + evaluation_comments: + type: array + description: 整体替换总分区间评语;空数组关闭区间评语。区间评语需互不重叠 + items: + $ref: '#/components/schemas/FormEvaluationCommentInput' + indicator_setting: + type: object + description: 维度设置;indicators 整体替换 + properties: + enabled: + type: boolean + default: true + indicators_scoring_mode: + type: string + enum: + - summation + - average + - customization + default: summation + indicators: + type: array + maxItems: 200 + items: + type: object + required: + - name + properties: + api_code: + type: string + description: 传回已有维度的 api_code 以保留其身份;新维度可省略 + name: + type: string + maxLength: 20 + standard_score: + type: number + field_api_codes: + type: array + description: 绑定的题目 api_code,必须是表单上可见的可计分题目 + items: + type: string + field_cids: + type: array + description: 也可用题目 cid 引用(可为数字或字符串) + items: + oneOf: + - type: string + - type: integer + indicator_comments: + type: array + maxItems: 20 + description: 维度区间评语(不支持 retry) + items: + type: object + properties: + start_point: + type: number + end_point: + type: number + comment: + type: string + additionalProperties: false + additionalProperties: false + additionalProperties: false + additionalProperties: false + Cooperator: + type: object + required: + - user_id + - name + - role + properties: + user_id: + type: string + description: 用户 id + name: + type: string + description: 用户显示名 + role: + type: + - string + - 'null' + description: 角色名称 + FormWeixinOauthSessionInput: + type: object + required: + - code + properties: + code: + type: string + description: 微信网页授权回调得到的 code + additionalProperties: false + FormWeixinOauthSession: + type: object + required: + - openid + properties: + openid: + type: string + unionid: + type: string + description: 仅 userinfo scope 且微信返回时存在 + profile: + type: object + description: 仅 userinfo scope 时存在 + properties: + nickname: + type: + - string + - 'null' + headimgurl: + type: + - string + - 'null' + sex: + type: integer + description: 微信性别值,0 未知 / 1 男 / 2 女 + country: + type: + - string + - 'null' + province: + type: + - string + - 'null' + city: + type: + - string + - 'null' + ThemeInput: + type: object + description: PATCH /forms/{form_token}/theme 的请求体;至少包含一个主题操作键 + properties: + primary_color: + type: string + description: 主色,如 "#FBBF24" + secondary_color: + type: string + description: 辅色 + wallpaper: + type: object + description: 页面背景 + properties: + background_color: + type: string + background_image_url: + type: string + description: 服务端抓取该 URL 作为背景图 + background_image_upload_token: + type: string + description: "「上传表单图片」使用的 `form_image_token`,图片用途须为 wallpaper;优先于 background_image_url" + background_image_attachment_id: + type: + - string + - 'null' + description: "「上传表单图片」(image_type=wallpaper)返回的 attachment_id;传空字符串移除当前背景图。优先级低于 background_image_upload_token / background_image_url" + additionalProperties: false + header: + type: object + description: 表头 + properties: + type: + type: string + enum: + - none + - text + - image + - carousel + text: + type: string + description: 表头文字 + background_color: + type: string + text_style: + type: object + description: 文字样式,键使用下划线(如 font_size、text_align),与现有样式合并 + additionalProperties: + type: string + header_image_url: + type: string + description: 服务端抓取该 URL 作为头图 + header_image_base64: + type: string + description: base64 编码的头图;优先于 header_image_url + header_image_upload_token: + type: string + description: "「上传表单图片」使用的 `form_image_token`,图片用途须为 header;优先级最高" + carousel_images: + type: array + minItems: 1 + maxItems: 5 + description: 轮播图,整体替换,按数组顺序展示 + items: + type: object + description: 需提供 image_upload_token / image_url / attachment_id 之一 + properties: + image_upload_token: + type: string + description: "「上传表单图片」使用的 `form_image_token`,图片用途须为 carousel" + image_url: + type: string + attachment_id: + type: string + description: 保留该表单已有的轮播图 + additionalProperties: false + header_image_attachment_id: + type: + - string + - 'null' + description: "「上传表单图片」(image_type=header)返回的 attachment_id;传空字符串移除当前头图。优先级最低;未指定 header.type 时自动设为 image" + additionalProperties: false + typography: + type: object + description: 字体样式;各项键使用下划线并与现有样式合并 + properties: + form_header: + type: object + additionalProperties: + type: string + field_label: + type: object + additionalProperties: + type: string + choice_style: + type: object + additionalProperties: + type: string + additionalProperties: false + form_container: + type: object + properties: + background_color: + type: string + border_color: + type: string + description: 表单容器边框颜色,如 "#E5E7EB" + border_width: + type: string + description: 表单容器边框宽度,如 "1px" + additionalProperties: false + submit_button: + type: object + properties: + background_color: + type: string + color: + type: string + font_size: + type: string + description: 如 "16px" + additionalProperties: false + generate_header_image: + type: object + description: 调用 AI 生成头图并设置为 header 图片;消耗 AI 点数 + properties: + prompt: + type: string + description: 生成提示词;省略时由 AI 根据表单内容生成 + additionalProperties: false + additionalProperties: false + FormTheme: + type: object + description: 主题更新后的返回 + required: + - form_token + - primary_color + - secondary_color + - wallpaper + - header + - typography + - form_container + - submit_button + properties: + form_token: + type: string + primary_color: + type: + - string + - 'null' + secondary_color: + type: + - string + - 'null' + wallpaper: + type: object + properties: + background_color: + type: + - string + - 'null' + has_background_image: + type: boolean + background_image_url: + type: + - string + - 'null' + header: + type: object + properties: + type: + type: + - string + - 'null' + enum: + - none + - text + - image + - carousel + - + text: + type: + - string + - 'null' + text_style: + type: + - object + - 'null' + description: CSS 连字符键 + additionalProperties: true + background_color: + type: + - string + - 'null' + has_header_image: + type: boolean + header_image_url: + type: + - string + - 'null' + carousel_images: + type: array + items: + type: object + required: + - attachment_id + - image_url + properties: + attachment_id: + type: string + image_url: + type: string + typography: + type: object + properties: + form_header: + type: + - object + - 'null' + additionalProperties: true + field_label: + type: + - object + - 'null' + additionalProperties: true + choice_style: + type: + - object + - 'null' + additionalProperties: true + field_space: + type: + - string + - 'null' + form_container: + type: object + properties: + background_color: + type: + - string + - 'null' + max_width: + type: + - string + - 'null' + border_color: + type: + - string + - 'null' + border_width: + type: + - string + - 'null' + shadow: + type: boolean + indent_on_large_phone: + type: boolean + submit_button: + type: object + properties: + background_color: + type: + - string + - 'null' + color: + type: + - string + - 'null' + font_size: + type: + - string + - 'null' + shadow: + type: boolean + hidden: + type: boolean + FormFolderMoveInput: + type: object + required: + - folder_token + properties: + folder_token: + type: + - string + - 'null' + description: 目标文件夹 token;null 表示移出文件夹 + additionalProperties: false + FormFolderMoveResult: + type: object + required: + - form_token + - folder_token + properties: + form_token: + type: string + folder_token: + type: + - string + - 'null' + description: 移动后所在文件夹 token;移出时为 null + CopyFormInput: + type: object + properties: + name: + type: string + description: 新表单名称;省略时沿用服务端默认命名 + folder_token: + type: string + description: 目标文件夹 token 或 id;省略则不放入文件夹 + additionalProperties: false + UploadedFormImage: + type: object + required: + - attachment_id + properties: + attachment_id: + type: string + description: 图片附件 ID(见「上传表单图片」) + url: + type: string + description: 图片地址,仅 `image_type=description` 时返回;写进表单描述或字段描述的 `` + UploadedEntryAttachment: + type: object + required: + - id + - file_name + - content_type + - file_size + - download_url + properties: + id: + type: string + description: 附件 ID,创建 / 更新数据时填入附件字段 + file_name: + type: string + description: 文件名 + content_type: + type: string + description: MIME 类型 + file_size: + type: integer + description: 文件字节数 + download_url: + type: string + description: 下载地址(长期有效) + preview_url: + type: string + description: 图片预览地址,仅图片附件返回 + UploadedImportFile: + type: object + required: + - id + - file_name + - content_type + - file_size + properties: + id: + type: string + description: 导入附件 ID,作为 `attachment_id` 交给「从已上传的表格文件导入数据」 + file_name: + type: string + description: 文件名 + content_type: + type: + - string + - 'null' + description: MIME 类型 + file_size: + type: integer + description: 文件字节数 + EntryAttachmentBase64Input: + type: object + required: + - field_api_code + - base64 + properties: + field_api_code: + type: string + description: 附件字段的 api_code;表格字段中的附件列则传表格字段的 api_code + dimension_api_code: + type: string + description: 表格字段中附件列的 api_code,`field_api_code` 为表格字段时必填 + attachment_token: + type: string + description: 一次性上传令牌,以 `entry_att_tok_` 开头;传入时代替常规凭证 + base64: + type: string + description: 文件内容的 Base64 字符串,可带 `data:;base64,` 前缀,解码后不超过 5 MB;亦可用 `data` 键 + data: + type: string + description: 与 `base64` 等价的别名 + file_name: + type: string + description: 文件名,缺省为 `attachment`;亦可用 `filename` 键 + filename: + type: string + description: 与 `file_name` 等价的别名 + content_type: + type: string + description: MIME 类型,缺省取 data URI 前缀,否则为 `application/octet-stream` + additionalProperties: false + Folder: + type: object + required: + - token + - name + - kind + - created_at + properties: + token: + type: string + description: 文件夹 token + name: + type: string + description: 文件夹名称 + kind: + type: + - string + - 'null' + enum: + - form + - table + - quickpay + - + description: 文件夹类型:`form` 放表单、`table` 放表格;早期创建的表单文件夹可能为 null,按 `form` 对待 + created_at: + type: string + format: date-time + description: 创建时间(UTC) + FolderInput: + type: object + required: + - name + properties: + name: + type: string + minLength: 1 + description: 文件夹名称,不能为空 + kind: + type: + - string + - 'null' + enum: + - form + - table + - + default: form + description: 文件夹类型;一个文件夹只能放一类内容,表格只能放进 `table` 文件夹。缺省为 `form` + additionalProperties: false + PredefinedChoicesSet: + type: object + required: + - id + - label + - type + - choices + properties: + id: + type: string + description: 选项集 ID + label: + type: string + description: 选项集名称 + type: + type: string + enum: + - global + - custom + description: 选项集类型;本接口只返回账号自定义的 custom + choices: + type: array + description: 选项列表 + items: + type: object + required: + - label + properties: + label: + type: string + description: 选项文本 + PredefinedChoicesSetInput: + type: object + required: + - label + - choices + properties: + label: + type: string + description: 选项集名称,不能为空 + choices: + type: array + description: 选项文本列表,空字符串会被过滤,过滤后不能为空 + items: + type: string + additionalProperties: false + PredefinedChoicesSetError: + type: object + required: + - errors + - status + properties: + errors: + type: string + description: 错误信息,多条校验信息以 `; ` 分隔 + status: + type: string + enum: + - failed + TemplateUsageInput: + type: object + required: + - id + properties: + id: + type: string + description: 模板 token 或 24 位十六进制 ID;亦可用 `template_id` 键 + template_id: + type: string + description: 与 `id` 等价的别名 + tags: + description: 给新表单打的标签,数组或逗号分隔字符串 + oneOf: + - type: array + items: + type: string + - type: string + tag_id: + type: string + description: 创建后归入的标签 ID + folder_id: + type: string + description: 创建后归入的文件夹 ID + scene: + type: string + description: 目标场景 key;模板字段超出该场景允许范围时回退为 form + frm: + type: string + description: 来源标记;以 `spread` 开头或为 `scene_creation_modal` 时影响记录的 created_from + create_source: + type: string + description: 创建来源标记(以服务端实现为准) + group_id: + type: string + description: 附加到 `success_url` 的分组参数 + form_app_trial_alert: + type: boolean + description: 为 true 时先检查模板绑定的表单应用是否需购买 / 试用,需要时返回 200 且带 `error` + confirm_unbind_form_app: + type: boolean + description: 确认解绑需购买的表单应用后继续创建 + confirm_form_app_trial: + type: boolean + description: 确认开启表单应用试用后继续创建 + Contact: + type: object + required: + - id + - name + - emails + - phones + properties: + id: + type: string + description: 联系人 ID + name: + type: + - string + - 'null' + description: 姓名 + emails: + type: array + description: 邮箱列表 + items: + type: string + phones: + type: array + description: 手机号列表 + items: + type: string + CurrentUser: + type: object + required: + - id + - name + - email + - mobile + - role + - billing_account_id + - created_at + properties: + id: + type: string + description: 用户 ID + name: + type: + - string + - 'null' + description: 显示名称 + email: + type: + - string + - 'null' + description: 邮箱 + mobile: + type: + - string + - 'null' + description: 手机号 + role: + type: string + enum: + - owner + - admin + - worker + - outworker + description: 在账号中的角色 + billing_account_id: + type: string + description: 所属账号 ID + created_at: + type: string + format: date-time + description: 注册时间(UTC) + BillingAccountUser: + type: object + required: + - id + - name + - email + - mobile + - role + - status + - created_at + properties: + id: + type: string + description: 用户 ID + name: + type: + - string + - 'null' + description: 显示名称 + email: + type: + - string + - 'null' + description: 邮箱 + mobile: + type: + - string + - 'null' + description: 手机号 + role: + type: string + enum: + - owner + - admin + - worker + - outworker + description: 在账号中的角色 + status: + type: string + enum: + - active + - disabled + description: 成员状态 + created_at: + type: string + format: date-time + description: 加入时间(UTC) + BillingAccount: + type: object + required: + - id + - name + - subdomain + - users_count + - plan + - usage + - feature_trial + - merchant_accounts + - wxpay_accounts + - alipay_accounts + properties: + id: + type: string + description: 账号 ID + name: + type: + - string + - 'null' + description: 账号名称 + subdomain: + type: + - string + - 'null' + description: 自定义子域名 + users_count: + type: integer + description: 活跃成员数 + plan: + type: object + required: + - code + - name + - end_date + - org_plan + - expired + properties: + code: + type: string + description: 套餐代码(如 free、pro2、ent2、e10、e20、e30、en_cor) + name: + type: string + description: 套餐名称 + end_date: + type: string + description: 套餐到期日(YYYY-MM-DD),无到期日时为空字符串 + org_plan: + type: boolean + description: 是否企业套餐 + expired: + type: boolean + description: 企业套餐是否已到期 + usage: + type: + - object + - 'null' + description: 当月用量;键为增值服务代码(sms、active_mail、entry_quota、storage_quota、ai_points、audio_quota、entry_transaction_quota、merchant_entry_transaction_quota) + required: + - next_reset_date + properties: + next_reset_date: + type: string + description: 下次用量重置日(YYYY-MM-DD) + additionalProperties: + $ref: '#/components/schemas/BillingAccountUsageItem' + feature_trial: + type: object + required: + - in_use + - expired + - label + - end_date + properties: + in_use: + type: boolean + description: 是否正在功能试用中 + expired: + type: boolean + description: 功能试用是否已结束 + label: + type: + - string + - 'null' + description: 试用套餐名称 + end_date: + type: string + description: 试用截止日(YYYY-MM-DD),未试用过时为空字符串 + merchant_accounts: + type: array + description: 已绑定的收款商户 + items: + type: object + required: + - id + - name + - merchant_no + - channels + properties: + id: + type: string + name: + type: + - string + - 'null' + merchant_no: + type: + - string + - 'null' + description: 商户号 + channels: + type: array + description: 已开通的支付渠道(wxpay / alipay) + items: + type: string + wxpay_accounts: + type: array + description: 已绑定的微信支付账户 + items: + type: object + required: + - id + - mch_id + - mch_name + properties: + id: + type: string + mch_id: + type: + - string + - 'null' + mch_name: + type: + - string + - 'null' + alipay_accounts: + type: array + description: 已绑定的支付宝账户 + items: + type: object + required: + - id + - email + - account_type + properties: + id: + type: string + email: + type: + - string + - 'null' + account_type: + type: + - string + - 'null' + enum: + - create_direct_pay_by_user + - create_partner_trade_by_buyer + - trade_create_by_buyer + - fast_instant_trade_pay + - + BillingAccountUsageItem: + type: object + description: 单项增值服务的配额与用量;配额无限时只有 unit、unlimited、consumed_quota + required: + - unit + - consumed_quota + properties: + unit: + type: string + enum: + - item + - mb + - second + - cent + description: 计量单位 + unlimited: + type: boolean + description: 配额无限时为 true + total_quota: + type: integer + description: 本月总配额(套餐赠送 + 已购) + total_balance: + type: integer + description: 剩余总额度 + month_balance: + type: integer + description: 本月套餐赠送额度的剩余 + consumed_quota: + type: integer + description: 本月已消耗 + ViewUnprocessableError: + type: object + required: + - error_description + properties: + error_description: + type: string + description: 错误说明,多个校验错误以逗号拼接 + FormViewSortRule: + type: object + required: + - api_code + properties: + api_code: + type: string + description: 排序字段 api_code + order: + type: string + enum: + - asc + - desc + default: asc + description: 排序方向 + FormViewFilterCondition: + type: object + required: + - field + - operator + properties: + field: + type: string + description: 字段 api_code(也接受 api_code / trigger 作为键名) + operator: + type: string + description: 运算符,与数据列表 filters 相同:eq、ne、any_in、none_in、between、not_between、null、not_null、gte、gt、lte、lt、like、not_like、match、within_last; 别名 start / end / equal / not_equal 分别等价于 gte / lte / eq / ne。每种字段支持的子集以服务端为准。 选项类字段的 eq / ne 保存时会转为 any_in / none_in 且值转为数组。 + value: + description: '条件值,类型随字段与运算符而定:between 为两元素数组;within_last 为 {"unit": "day", "n": 30}(unit 可为 minute / day / week / month); 选项类字段可传选项 api_code 或选项文本,"null" 表示未填写;null / not_null 无需 value。' + FormView: + type: object + required: + - token + - name + - view_type + - position + - predefined + - visibility + - view_scope + - prefer_columns + - sorted_columns + - kanban_group_by_api_code + - kanban_card_title_api_code + - kanban_group_order + - kanban_hidden_groups + - grid_frozen_boundary_api_code + - sort + - filter + - created_at + - updated_at + properties: + token: + type: string + description: 视图 token + name: + type: string + description: 视图名称 + view_type: + type: string + enum: + - grid + - kanban + - stats + - form + description: 视图类型;form 为表单视图(对外填写页,仅表格支持) + position: + type: integer + description: 排序位置 + predefined: + type: boolean + description: 是否为预置视图(不可删除) + visibility: + type: string + enum: + - cooperators + - personal + description: 可见性,cooperators 对全部协作者可见,personal 仅创建者可见 + view_scope: + type: string + enum: + - user + - form + description: 视图归属,form 表示属于表单本身(不可删除、不可切换可见性) + prefer_columns: + type: array + description: 显示的字段 api_code 列表 + items: + type: string + sorted_columns: + type: array + description: 列显示顺序,按 position 升序 + items: + type: object + required: + - api_code + - position + properties: + api_code: + type: string + description: 字段 api_code + position: + type: integer + description: 列位置,从 1 开始 + kanban_group_by_api_code: + type: + - string + - 'null' + description: 看板分组字段 api_code + kanban_card_title_api_code: + type: + - string + - 'null' + description: 看板卡片标题字段 api_code,未设置时由服务端根据 prefer_columns 推算 + kanban_group_order: + type: array + description: 看板分组显示顺序(分组选项 api_code,__unset__ 表示未设置分组) + items: + type: string + kanban_hidden_groups: + type: array + description: 隐藏的看板分组(同 kanban_group_order 的取值) + items: + type: string + grid_frozen_boundary_api_code: + type: + - string + - 'null' + description: 表格视图冻结列边界字段 api_code + sort: + type: array + description: 排序规则 + items: + type: object + required: + - api_code + - order + properties: + api_code: + type: string + description: 排序字段 api_code + order: + type: string + enum: + - asc + - desc + description: 排序方向 + filter: + type: array + description: 筛选条件,无筛选时为空数组 + items: + $ref: '#/components/schemas/FormViewFilterCondition' + created_at: + type: string + format: date-time + description: 创建时间 + updated_at: + type: string + format: date-time + description: 更新时间 + title: + type: + - string + - 'null' + description: 填写页标题(仅表单视图返回) + description: + type: + - string + - 'null' + description: 填写页描述(仅表单视图返回) + published: + type: boolean + description: 是否已发布;未发布时填写链接不可用(仅表单视图返回) + fill_url: + type: string + format: uri + description: 对外填写链接(仅表单视图返回) + fill_items: + type: array + description: 填写页上勾选的填写项,按填写页顺序,值为生效值(仅表单视图返回) + items: + $ref: '#/components/schemas/FormViewFillItem' + FormViewInput: + type: object + description: 创建 / 更新视图的请求体,只有出现的键会被更新 + properties: + name: + type: string + maxLength: 120 + description: 视图名称,不能为空 + view_type: + type: string + enum: + - grid + - kanban + - stats + - form + description: 视图类型,创建时默认 grid;form 为表单视图(对外填写页),仅表格支持 + visibility: + type: string + enum: + - cooperators + - personal + description: 可见性,创建时默认 cooperators;更新时只有创建者可切换 + prefer_columns: + type: array + description: 显示的字段 api_code 列表 + items: + type: string + kanban_group_by_api_code: + type: string + description: 看板分组字段 api_code,必须是支持看板分组的字段 + kanban_card_title_api_code: + type: string + description: 看板卡片标题字段 api_code + kanban_group_order: + type: array + description: 看板分组显示顺序,元素须为非空且不重复的字符串 + items: + type: string + kanban_hidden_groups: + type: array + description: 隐藏的看板分组,元素须为非空且不重复的字符串;传空数组表示显示全部分组 + items: + type: string + sort: + type: array + description: 排序规则,整体替换 + items: + $ref: '#/components/schemas/FormViewSortRule' + sorted_columns: + type: array + description: 列显示顺序,整体替换,position 按数组顺序从 1 生成 + items: + type: object + required: + - api_code + properties: + api_code: + type: string + description: 字段 api_code + additionalProperties: false + filter: + type: array + description: 筛选条件数组,整体替换;传空数组清空筛选;不是数组时返回 422 + items: + $ref: '#/components/schemas/FormViewFilterCondition' + title: + type: string + maxLength: 120 + description: 填写页标题,不能为空;创建时缺省为表格名称。仅表单视图(view_type=form)可用,用在其他类型上返回 422。 + description: + type: + - string + - 'null' + description: 填写页描述。仅表单视图(view_type=form)可用,用在其他类型上返回 422。 + published: + type: boolean + description: 是否发布填写页,创建时缺省 false。仅表单视图(view_type=form)可用,用在其他类型上返回 422。 + field_overrides: + type: array + description: 填写项配置,整体替换;创建时不传则按当前列顺序勾选全部可填写的列。仅表单视图(view_type=form)可用,用在其他类型上返回 422。 + items: + $ref: '#/components/schemas/FormViewFieldOverrideInput' + cid: + type: string + description: 客户端标识,随实时变更广播透传(以服务端实现为准) + additionalProperties: false + EntryCommentMention: + type: object + required: + - user_id + - user_name + properties: + user_id: + type: string + description: 被提及用户 id + user_name: + type: string + description: 被提及用户在提及时的显示名 + EntryCommentAttachment: + type: object + required: + - id + - file_name + - content_type + - file_size + - url + properties: + id: + type: string + description: 附件 id + file_name: + type: string + description: 文件名 + content_type: + type: + - string + - 'null' + description: MIME 类型 + file_size: + type: integer + description: 文件大小(字节) + url: + type: string + description: 下载地址(私有链接,带时效签名) + EntryComment: + type: object + description: 数据评论。每条评论最多 3 个附件,单个不超过 5 MB,允许的扩展名:jpg、jpeg、png、bmp、webp、heic、doc、docx、xls、xlsx、csv、ppt、pptx、pdf、txt、mp4、mov。 + required: + - id + - created_at + - updated_at + properties: + id: + type: string + description: 评论 id + content: + type: string + description: 评论内容,提及以 @[] 形式出现;仅有附件时可能缺省 + creator: + type: object + description: 评论者;评论者信息缺失时缺省 + required: + - id + - name + properties: + id: + type: string + description: 用户 id + name: + type: string + description: 用户显示名,账号已删除时为「账号已注销」类占位文案 + parent_id: + type: string + description: 被回复的评论 id,非回复时缺省 + created_at: + type: string + format: date-time + description: 创建时间 + updated_at: + type: string + format: date-time + description: 更新时间 + mentions: + type: array + description: 被提及的协作者,无提及时缺省 + items: + $ref: '#/components/schemas/EntryCommentMention' + attachments: + type: array + description: 附件,按上传时间升序,无附件时缺省 + items: + $ref: '#/components/schemas/EntryCommentAttachment' + EntryCommentInput: + type: object + properties: + content: + type: string + maxLength: 2000 + description: 评论内容,去除首尾空白后不能为空;用 @[] 提及协作者(user_id 为 24 位十六进制) + parent_id: + type: string + description: 回复的父评论 id,必须属于同一条数据;仅创建时读取 + additionalProperties: false + OpensearchSearchFieldSetting: + type: object + required: + - field_api_code + properties: + field_api_code: + type: string + description: 查询字段 api_code,必须是表单可查询的字段 + field_label: + type: + - string + - 'null' + description: 对外显示的字段标签,为空时用字段原标签 + fuzzy: + type: boolean + default: false + description: 是否模糊匹配 + sms_verification: + type: boolean + default: false + description: 是否需要短信验证(仅对手机字段生效) + OpensearchSearchFieldRule: + type: object + required: + - search_field_settings + properties: + operand: + type: string + enum: + - and + - or + default: and + description: 规则内各查询字段的组合方式 + search_field_settings: + type: array + minItems: 1 + description: 查询字段设置,不能为空 + items: + $ref: '#/components/schemas/OpensearchSearchFieldSetting' + OpensearchDisplayFieldRule: + type: object + required: + - field_api_code + properties: + field_api_code: + type: string + description: 展示字段 api_code,必须是表单可展示的字段 + field_label: + type: + - string + - 'null' + description: 对外显示的字段标签,为空时用字段原标签 + editable: + type: boolean + default: false + description: 查询者是否可在结果页编辑该字段 + protected: + type: boolean + default: false + description: 是否隐私保护(脱敏显示,仅对支持隐私保护的字段生效) + highlight: + type: + - object + - 'null' + description: 高亮设置,未设置时为 null + properties: + color: + type: + - string + - 'null' + description: 高亮颜色 + position: + type: integer + default: 999 + description: 高亮排序位置 + OpensearchQuerySummary: + type: object + required: + - token + - url + - name + - enabled + - form_token + - created_at + - searches_count + - views_count + properties: + token: + type: string + description: 对外查询 token + url: + type: string + description: 对外查询公开访问地址(/os/{token},使用账户子域名或自定义域名) + name: + type: string + description: 名称 + enabled: + type: boolean + description: 是否启用 + form_token: + type: + - string + - 'null' + description: 所属表单 token,表单已删除时为 null + created_at: + type: string + format: date-time + description: 创建时间 + searches_count: + type: integer + description: 累计查询次数 + views_count: + type: integer + description: 累计访问次数 + OpensearchQuery: + allOf: + - $ref: '#/components/schemas/OpensearchQuerySummary' + - type: object + required: + - description + - status + - search_field_rules + - display_field_rules + - messages + - default_messages + - search_button + - allow_to_export_results + - header + - updated_at + properties: + description: + type: + - string + - 'null' + description: 说明文字 + status: + type: string + enum: + - active + - entries_count_limited + - form_not_found + description: 状态:active 可用;entries_count_limited 表单数据量超过套餐对外查询上限;form_not_found 表单已删除 + search_field_rules: + type: array + description: 查询字段规则 + items: + allOf: + - $ref: '#/components/schemas/OpensearchSearchFieldRule' + - type: object + required: + - operand + display_field_rules: + type: array + description: 展示字段规则 + items: + allOf: + - $ref: '#/components/schemas/OpensearchDisplayFieldRule' + - type: object + required: + - field_label + - editable + - protected + - highlight + messages: + type: + - object + - 'null' + description: 已保存的提示文案原始值,未自定义时为空对象或 null + properties: + has_result: + type: string + description: 查到结果时的提示 + no_result: + type: string + description: 未查到结果时的提示 + default_messages: + type: object + description: 合并产品默认文案后的提示文案(按表单语言) + required: + - has_result + - no_result + properties: + has_result: + type: string + description: 查到结果时的提示 + no_result: + type: string + description: 未查到结果时的提示 + search_button: + type: object + description: 查询按钮设置,未设置时为空对象 + properties: + text: + type: string + description: 按钮文字 + color: + type: string + description: 按钮颜色 + allow_to_export_results: + type: boolean + description: 是否允许查询者导出结果 + header: + type: object + description: 页头设置 + required: + - background_image + properties: + background_image: + type: + - object + - 'null' + description: 页头背景图,未设置时为 null + required: + - attachment_id + - position_x + - position_y + - image_url + properties: + attachment_id: + type: string + description: 背景图附件 id + position_x: + type: + - number + - 'null' + description: 背景图横向位置 + position_y: + type: + - number + - 'null' + description: 背景图纵向位置 + image_url: + type: + - string + - 'null' + description: 背景图地址 + updated_at: + type: string + format: date-time + description: 更新时间 + OpensearchQueryInput: + type: object + description: 创建 / 更新对外查询的请求体,只有出现的键会被更新 + properties: + form_token: + type: string + description: 仅创建时使用:所属表单的 token(也可作为 query 参数传递) + name: + type: string + description: 名称,不能为空 + description: + type: string + description: 说明文字 + enabled: + type: boolean + description: 是否启用 + search_field_rules: + type: array + minItems: 1 + description: 查询字段规则,整体替换;传入时不能为空数组,每条规则的 search_field_settings 不能为空 + items: + $ref: '#/components/schemas/OpensearchSearchFieldRule' + display_field_rules: + type: array + minItems: 1 + description: 展示字段规则,整体替换;传入时不能为空数组 + items: + $ref: '#/components/schemas/OpensearchDisplayFieldRule' + search_button: + type: object + description: 查询按钮设置,只读取 text 与 color + properties: + text: + type: string + description: 按钮文字 + color: + type: string + description: 按钮颜色 + additionalProperties: false + messages: + type: object + description: 提示文案,只读取 has_result 与 no_result;缺省的键使用产品默认文案 + properties: + has_result: + type: string + description: 查到结果时的提示 + no_result: + type: string + description: 未查到结果时的提示 + additionalProperties: false + allow_to_export_results: + type: boolean + description: 是否允许查询者导出结果 + header: + type: object + description: 页头设置,只读取 background_image 的 attachment_id、image_upload_token、position_x、position_y(以服务端实现为准) + properties: + background_image: + type: object + properties: + attachment_id: + type: string + description: 背景图附件 id,由「上传表单图片」(`POST /form_image_attachments`,`image_type=opensearch_header`)返回 + image_upload_token: + type: string + description: 代替 attachment_id:以 `form_image_token`(用途为 opensearch_header)上传后,直接传这个令牌。令牌须已上传、未过期、属于调用者且用途为 opensearch_header,否则返回 400 + position_x: + type: number + description: 背景图横向位置 + position_y: + type: number + description: 背景图纵向位置 + additionalProperties: false + additionalProperties: false + extra_configurable_setting: + type: object + description: 额外设置(以服务端实现为准) + properties: + show_exam_correct_answer: + type: boolean + description: 是否显示考试正确答案 + show_exam_explanation: + type: boolean + description: 是否显示考试解析 + additionalProperties: false + scope_conditions: + type: array + description: 限定可查询数据范围的条件,需要套餐支持,否则返回 400;每项只读取 trigger、operator、value、group_index、scope_attr + items: + type: object + properties: + trigger: + type: string + description: 触发字段 api_code + operator: + type: string + description: 运算符(以服务端实现为准) + value: + description: 条件值 + group_index: + type: integer + description: 条件分组序号,同组为 and,不同组为 or + scope_attr: + type: string + description: 条件作用的属性(以服务端实现为准) + additionalProperties: false + additionalProperties: false + OpensearchQuerySuggestion: + type: object + required: + - api_code + - label + - type + - privacy_protectable + - for_associated + properties: + api_code: + type: string + description: 字段 api_code + label: + type: string + description: 字段标签 + type: + type: string + description: 字段类型名,如 TextField、MobileField + privacy_protectable: + type: boolean + description: 是否支持隐私保护 + for_associated: + type: + - boolean + - 'null' + description: 是否为关联表单展开出来的字段,非关联字段为 null + OpensearchDisplayQuerySuggestion: + allOf: + - $ref: '#/components/schemas/OpensearchQuerySuggestion' + - type: object + required: + - editable + - scorable + properties: + editable: + type: boolean + description: 是否可设置为查询者可编辑 + scorable: + type: boolean + description: 是否为可计分字段 + TextFieldInput: + type: object + title: TextFieldInput + description: TextField 字段 + additionalProperties: false + required: + - type + - label + properties: + type: + const: TextField + label: + type: string + description: 字段标题 + cid: + type: string + description: 本次请求内的临时标识,供公式等引用 + notes: + type: string + description: 字段说明 + private: + type: boolean + description: 隐藏字段;隐藏字段不加必填校验 + required: + type: boolean + description: 必填 + position: + type: integer + minimum: 0 + description: 字段在表单中的目标位置(0 起);缺省追加到末尾 + unique: + type: boolean + description: 不允许与已有数据重复 + predefined_value: + type: string + description: 默认值 + placeholder: + type: string + description: 占位提示文字 + TextAreaInput: + type: object + title: TextAreaInput + description: TextArea 字段 + additionalProperties: false + required: + - type + - label + properties: + type: + const: TextArea + label: + type: string + description: 字段标题 + cid: + type: string + description: 本次请求内的临时标识,供公式等引用 + notes: + type: string + description: 字段说明 + private: + type: boolean + description: 隐藏字段;隐藏字段不加必填校验 + required: + type: boolean + description: 必填 + position: + type: integer + minimum: 0 + description: 字段在表单中的目标位置(0 起);缺省追加到末尾 + unique: + type: boolean + description: 不允许与已有数据重复 + predefined_value: + type: string + description: 默认值 + placeholder: + type: string + description: 占位提示文字 + NumberFieldInput: + type: object + title: NumberFieldInput + description: NumberField 字段 + additionalProperties: false + required: + - type + - label + properties: + type: + const: NumberField + label: + type: string + description: 字段标题 + cid: + type: string + description: 本次请求内的临时标识,供公式等引用 + notes: + type: string + description: 字段说明 + private: + type: boolean + description: 隐藏字段;隐藏字段不加必填校验 + required: + type: boolean + description: 必填 + position: + type: integer + minimum: 0 + description: 字段在表单中的目标位置(0 起);缺省追加到末尾 + unique: + type: boolean + description: 不允许与已有数据重复 + predefined_value: + type: + - number + - string + description: 默认值 + placeholder: + type: string + description: 占位提示文字 + range_min: + type: number + description: 最小值(含) + range_max: + type: number + description: 最大值(含) + precision: + type: integer + minimum: 0 + maximum: 14 + description: 小数位数 + display_precision: + type: integer + minimum: 0 + description: 显示用小数位数 + display_as_percentage: + type: boolean + description: 按百分比显示;与 thousands_separator 互斥 + thousands_separator: + type: boolean + description: 显示千分位分隔符;与 display_as_percentage 互斥 + currency_symbol: + type: string + description: 货币符号 + currency_symbol_position: + type: string + enum: + - prefix + - suffix + description: 货币符号位置 + allOf: + - description: display_as_percentage 与 thousands_separator 不能同时为 true + not: + properties: + display_as_percentage: + const: true + thousands_separator: + const: true + required: + - display_as_percentage + - thousands_separator + EmailFieldInput: + type: object + title: EmailFieldInput + description: EmailField 字段 + additionalProperties: false + required: + - type + - label + properties: + type: + const: EmailField + label: + type: string + description: 字段标题 + cid: + type: string + description: 本次请求内的临时标识,供公式等引用 + notes: + type: string + description: 字段说明 + private: + type: boolean + description: 隐藏字段;隐藏字段不加必填校验 + required: + type: boolean + description: 必填 + position: + type: integer + minimum: 0 + description: 字段在表单中的目标位置(0 起);缺省追加到末尾 + unique: + type: boolean + description: 不允许与已有数据重复 + placeholder: + type: string + description: 占位提示文字 + MobileFieldInput: + type: object + title: MobileFieldInput + description: MobileField 字段 + additionalProperties: false + required: + - type + - label + properties: + type: + const: MobileField + label: + type: string + description: 字段标题 + cid: + type: string + description: 本次请求内的临时标识,供公式等引用 + notes: + type: string + description: 字段说明 + private: + type: boolean + description: 隐藏字段;隐藏字段不加必填校验 + required: + type: boolean + description: 必填 + position: + type: integer + minimum: 0 + description: 字段在表单中的目标位置(0 起);缺省追加到末尾 + unique: + type: boolean + description: 不允许与已有数据重复 + predefined_value: + type: string + description: 默认值 + placeholder: + type: string + description: 占位提示文字 + sms_verification: + type: boolean + description: 开启短信验证(同时自动开启图形验证码) + IdCardFieldInput: + type: object + title: IdCardFieldInput + description: IdCardField 字段 + additionalProperties: false + required: + - type + - label + properties: + type: + const: IdCardField + label: + type: string + description: 字段标题 + cid: + type: string + description: 本次请求内的临时标识,供公式等引用 + notes: + type: string + description: 字段说明 + private: + type: boolean + description: 隐藏字段;隐藏字段不加必填校验 + required: + type: boolean + description: 必填 + position: + type: integer + minimum: 0 + description: 字段在表单中的目标位置(0 起);缺省追加到末尾 + unique: + type: boolean + description: 不允许与已有数据重复 + NameFieldInput: + type: object + title: NameFieldInput + description: NameField 字段 + additionalProperties: false + required: + - type + - label + properties: + type: + const: NameField + label: + type: string + description: 字段标题 + cid: + type: string + description: 本次请求内的临时标识,供公式等引用 + notes: + type: string + description: 字段说明 + private: + type: boolean + description: 隐藏字段;隐藏字段不加必填校验 + required: + type: boolean + description: 必填 + position: + type: integer + minimum: 0 + description: 字段在表单中的目标位置(0 起);缺省追加到末尾 + unique: + type: boolean + description: 不允许与已有数据重复 + RadioButtonInput: + type: object + title: RadioButtonInput + description: RadioButton 字段 + additionalProperties: false + required: + - type + - label + properties: + type: + const: RadioButton + label: + type: string + description: 字段标题 + cid: + type: string + description: 本次请求内的临时标识,供公式等引用 + notes: + type: string + description: 字段说明 + private: + type: boolean + description: 隐藏字段;隐藏字段不加必填校验 + required: + type: boolean + description: 必填 + position: + type: integer + minimum: 0 + description: 字段在表单中的目标位置(0 起);缺省追加到末尾 + other_choice_required: + type: boolean + description: 选了「其他」必须填写补充文本 + choices: + type: array + minItems: 1 + items: + $ref: '#/components/schemas/ChoiceInput' + description: 选项 + calculable: + type: boolean + description: 选项赋值开关;开启后每个(叶子)选项必须带 operand_value + choices_layout: + type: string + description: 选项排列方式 + allOf: + - description: calculable=true 时每个选项都要有 operand_value + if: + properties: + calculable: + const: true + required: + - calculable + then: + properties: + choices: + items: + required: + - operand_value + CheckBoxInput: + type: object + title: CheckBoxInput + description: CheckBox 字段 + additionalProperties: false + required: + - type + - label + properties: + type: + const: CheckBox + label: + type: string + description: 字段标题 + cid: + type: string + description: 本次请求内的临时标识,供公式等引用 + notes: + type: string + description: 字段说明 + private: + type: boolean + description: 隐藏字段;隐藏字段不加必填校验 + required: + type: boolean + description: 必填 + position: + type: integer + minimum: 0 + description: 字段在表单中的目标位置(0 起);缺省追加到末尾 + other_choice_required: + type: boolean + description: 选了「其他」必须填写补充文本 + choices: + type: array + minItems: 1 + items: + $ref: '#/components/schemas/ChoiceInput' + description: 选项 + calculable: + type: boolean + description: 选项赋值开关;开启后每个(叶子)选项必须带 operand_value + choices_layout: + type: string + description: 选项排列方式 + allOf: + - description: calculable=true 时每个选项都要有 operand_value + if: + properties: + calculable: + const: true + required: + - calculable + then: + properties: + choices: + items: + required: + - operand_value + DropDownInput: + type: object + title: DropDownInput + description: DropDown 字段 + additionalProperties: false + required: + - type + - label + properties: + type: + const: DropDown + label: + type: string + description: 字段标题 + cid: + type: string + description: 本次请求内的临时标识,供公式等引用 + notes: + type: string + description: 字段说明 + private: + type: boolean + description: 隐藏字段;隐藏字段不加必填校验 + required: + type: boolean + description: 必填 + position: + type: integer + minimum: 0 + description: 字段在表单中的目标位置(0 起);缺省追加到末尾 + other_choice_required: + type: boolean + description: 选了「其他」必须填写补充文本 + choices: + type: array + minItems: 1 + items: + $ref: '#/components/schemas/ChoiceInput' + description: 选项 + calculable: + type: boolean + description: 选项赋值开关;开启后每个(叶子)选项必须带 operand_value + allOf: + - description: calculable=true 时每个选项都要有 operand_value + if: + properties: + calculable: + const: true + required: + - calculable + then: + properties: + choices: + items: + required: + - operand_value + DateTimeFieldInput: + type: object + title: DateTimeFieldInput + description: DateTimeField 字段 + additionalProperties: false + required: + - type + - label + properties: + type: + const: DateTimeField + label: + type: string + description: 字段标题 + cid: + type: string + description: 本次请求内的临时标识,供公式等引用 + notes: + type: string + description: 字段说明 + private: + type: boolean + description: 隐藏字段;隐藏字段不加必填校验 + required: + type: boolean + description: 必填 + position: + type: integer + minimum: 0 + description: 字段在表单中的目标位置(0 起);缺省追加到末尾 + predefined_value: + type: string + description: 默认值 + precision: + type: string + enum: + - month + - day + - minute + - second + description: 日期精度,缺省 day + RatingFieldInput: + type: object + title: RatingFieldInput + description: RatingField 字段 + additionalProperties: false + required: + - type + - label + properties: + type: + const: RatingField + label: + type: string + description: 字段标题 + cid: + type: string + description: 本次请求内的临时标识,供公式等引用 + notes: + type: string + description: 字段说明 + private: + type: boolean + description: 隐藏字段;隐藏字段不加必填校验 + required: + type: boolean + description: 必填 + position: + type: integer + minimum: 0 + description: 字段在表单中的目标位置(0 起);缺省追加到末尾 + rating_max: + type: integer + minimum: 1 + description: 评分量级(如 3 / 5 / 10),缺省 5 + TableFieldInput: + type: object + title: TableFieldInput + description: TableField 字段 + additionalProperties: false + required: + - type + - label + properties: + type: + const: TableField + label: + type: string + description: 字段标题 + cid: + type: string + description: 本次请求内的临时标识,供公式等引用 + notes: + type: string + description: 字段说明 + private: + type: boolean + description: 隐藏字段;隐藏字段不加必填校验 + required: + type: boolean + description: 必填 + position: + type: integer + minimum: 0 + description: 字段在表单中的目标位置(0 起);缺省追加到末尾 + dimensions: + type: array + minItems: 1 + items: + $ref: '#/components/schemas/DimensionInput' + description: 子字段(列) + init_row_length: + type: integer + minimum: 1 + description: 初始行数,缺省 3 + TelephoneFieldInput: + type: object + title: TelephoneFieldInput + description: TelephoneField 字段 + additionalProperties: false + required: + - type + - label + properties: + type: + const: TelephoneField + label: + type: string + description: 字段标题 + cid: + type: string + description: 本次请求内的临时标识,供公式等引用 + notes: + type: string + description: 字段说明 + private: + type: boolean + description: 隐藏字段;隐藏字段不加必填校验 + required: + type: boolean + description: 必填 + position: + type: integer + minimum: 0 + description: 字段在表单中的目标位置(0 起);缺省追加到末尾 + unique: + type: boolean + description: 不允许与已有数据重复 + predefined_value: + type: string + description: 默认值 + placeholder: + type: string + description: 占位提示文字 + AddressFieldInput: + type: object + title: AddressFieldInput + description: AddressField 字段 + additionalProperties: false + required: + - type + - label + properties: + type: + const: AddressField + label: + type: string + description: 字段标题 + cid: + type: string + description: 本次请求内的临时标识,供公式等引用 + notes: + type: string + description: 字段说明 + private: + type: boolean + description: 隐藏字段;隐藏字段不加必填校验 + required: + type: boolean + description: 必填 + position: + type: integer + minimum: 0 + description: 字段在表单中的目标位置(0 起);缺省追加到末尾 + predefined_value: + type: object + description: 默认地址,键为 province / city / district / street + changeable: + type: object + description: AddressField 各级是否允许修改,键为 province / city / district / street,值为布尔 + additionalProperties: + type: boolean + enable_auto_position: + type: boolean + description: 首次打开时按设备定位自动填充地址 + AttachmentFieldInput: + type: object + title: AttachmentFieldInput + description: AttachmentField 字段 + additionalProperties: false + required: + - type + - label + properties: + type: + const: AttachmentField + label: + type: string + description: 字段标题 + cid: + type: string + description: 本次请求内的临时标识,供公式等引用 + notes: + type: string + description: 字段说明 + private: + type: boolean + description: 隐藏字段;隐藏字段不加必填校验 + required: + type: boolean + description: 必填 + position: + type: integer + minimum: 0 + description: 字段在表单中的目标位置(0 起);缺省追加到末尾 + max_size: + type: integer + minimum: 1 + maximum: 500 + description: 单文件大小上限(MB) + max_file_quantity: + type: integer + minimum: 1 + description: 最多上传的文件数 + media_type: + type: object + additionalProperties: false + description: 允许的文件类型;type 为预设分组,type=custom 时必须用 value 列出扩展名 + properties: + type: + type: string + enum: + - unlimited + - doc + - image + - video + - audio + - compress + - custom + value: + type: array + items: + type: string + description: 小写扩展名,不含点 + if: + properties: + type: + const: custom + required: + - type + then: + required: + - value + properties: + value: + minItems: 1 + mobile_camera_only: + type: boolean + description: 移动端只能拍照上传,不能从相册选择 + GeoFieldInput: + type: object + title: GeoFieldInput + description: GeoField 字段 + additionalProperties: false + required: + - type + - label + properties: + type: + const: GeoField + label: + type: string + description: 字段标题 + cid: + type: string + description: 本次请求内的临时标识,供公式等引用 + notes: + type: string + description: 字段说明 + private: + type: boolean + description: 隐藏字段;隐藏字段不加必填校验 + required: + type: boolean + description: 必填 + position: + type: integer + minimum: 0 + description: 字段在表单中的目标位置(0 起);缺省追加到末尾 + LinkFieldInput: + type: object + title: LinkFieldInput + description: LinkField 字段 + additionalProperties: false + required: + - type + - label + properties: + type: + const: LinkField + label: + type: string + description: 字段标题 + cid: + type: string + description: 本次请求内的临时标识,供公式等引用 + notes: + type: string + description: 字段说明 + private: + type: boolean + description: 隐藏字段;隐藏字段不加必填校验 + required: + type: boolean + description: 必填 + position: + type: integer + minimum: 0 + description: 字段在表单中的目标位置(0 起);缺省追加到末尾 + unique: + type: boolean + description: 不允许与已有数据重复 + predefined_value: + type: string + description: 默认值 + placeholder: + type: string + description: 占位提示文字 + NpsFieldInput: + type: object + title: NpsFieldInput + description: NpsField 字段 + additionalProperties: false + required: + - type + - label + properties: + type: + const: NpsField + label: + type: string + description: 字段标题 + cid: + type: string + description: 本次请求内的临时标识,供公式等引用 + notes: + type: string + description: 字段说明 + private: + type: boolean + description: 隐藏字段;隐藏字段不加必填校验 + required: + type: boolean + description: 必填 + position: + type: integer + minimum: 0 + description: 字段在表单中的目标位置(0 起);缺省追加到末尾 + minimum_ratings_display_text: + type: string + description: 最低分文案 + maximum_ratings_display_text: + type: string + description: 最高分文案 + TimeFieldInput: + type: object + title: TimeFieldInput + description: TimeField 字段 + additionalProperties: false + required: + - type + - label + properties: + type: + const: TimeField + label: + type: string + description: 字段标题 + cid: + type: string + description: 本次请求内的临时标识,供公式等引用 + notes: + type: string + description: 字段说明 + private: + type: boolean + description: 隐藏字段;隐藏字段不加必填校验 + required: + type: boolean + description: 必填 + position: + type: integer + minimum: 0 + description: 字段在表单中的目标位置(0 起);缺省追加到末尾 + predefined_value: + type: object + description: '默认时间,如 {"hour": 9, "minute": 30}' + properties: + hour: + type: integer + minute: + type: integer + second: + type: integer + include_second: + type: boolean + description: 精确到秒 + SortFieldInput: + type: object + title: SortFieldInput + description: SortField 字段 + additionalProperties: false + required: + - type + - label + properties: + type: + const: SortField + label: + type: string + description: 字段标题 + cid: + type: string + description: 本次请求内的临时标识,供公式等引用 + notes: + type: string + description: 字段说明 + private: + type: boolean + description: 隐藏字段;隐藏字段不加必填校验 + required: + type: boolean + description: 必填 + position: + type: integer + minimum: 0 + description: 字段在表单中的目标位置(0 起);缺省追加到末尾 + choices: + type: array + minItems: 1 + items: + $ref: '#/components/schemas/ChoiceInput' + description: 选项 + random_choices: + type: boolean + description: 每次打开随机排列选项 + ESignatureFieldInput: + type: object + title: ESignatureFieldInput + description: ESignatureField 字段 + additionalProperties: false + required: + - type + - label + properties: + type: + const: ESignatureField + label: + type: string + description: 字段标题 + cid: + type: string + description: 本次请求内的临时标识,供公式等引用 + notes: + type: string + description: 字段说明 + private: + type: boolean + description: 隐藏字段;隐藏字段不加必填校验 + required: + type: boolean + description: 必填 + position: + type: integer + minimum: 0 + description: 字段在表单中的目标位置(0 起);缺省追加到末尾 + AudioFieldInput: + type: object + title: AudioFieldInput + description: AudioField 字段 + additionalProperties: false + required: + - type + - label + properties: + type: + const: AudioField + label: + type: string + description: 字段标题 + cid: + type: string + description: 本次请求内的临时标识,供公式等引用 + notes: + type: string + description: 字段说明 + private: + type: boolean + description: 隐藏字段;隐藏字段不加必填校验 + required: + type: boolean + description: 必填 + position: + type: integer + minimum: 0 + description: 字段在表单中的目标位置(0 起);缺省追加到末尾 + max_duration: + type: integer + minimum: 1 + maximum: 5 + description: 最长录音时长(分钟) + CascadeDropDownInput: + type: object + title: CascadeDropDownInput + description: CascadeDropDown 字段 + additionalProperties: false + required: + - type + - label + properties: + type: + const: CascadeDropDown + label: + type: string + description: 字段标题 + cid: + type: string + description: 本次请求内的临时标识,供公式等引用 + notes: + type: string + description: 字段说明 + private: + type: boolean + description: 隐藏字段;隐藏字段不加必填校验 + required: + type: boolean + description: 必填 + position: + type: integer + minimum: 0 + description: 字段在表单中的目标位置(0 起);缺省追加到末尾 + choices: + type: array + minItems: 1 + items: + $ref: '#/components/schemas/CascadeChoiceInput' + description: 选项 + levels: + type: integer + minimum: 2 + description: 多级下拉的层级数,每条选项路径都必须到达该深度 + choice_filterable: + type: boolean + description: 允许筛选选项 + random_choices: + type: boolean + description: 每次打开随机排列选项 + calculable: + type: boolean + description: 选项赋值开关;开启后每个(叶子)选项必须带 operand_value + LikertFieldInput: + type: object + title: LikertFieldInput + description: LikertField 字段 + additionalProperties: false + required: + - type + - label + properties: + type: + const: LikertField + label: + type: string + description: 字段标题 + cid: + type: string + description: 本次请求内的临时标识,供公式等引用 + notes: + type: string + description: 字段说明 + private: + type: boolean + description: 隐藏字段;隐藏字段不加必填校验 + required: + type: boolean + description: 必填 + position: + type: integer + minimum: 0 + description: 字段在表单中的目标位置(0 起);缺省追加到末尾 + choices: + type: array + minItems: 1 + items: + $ref: '#/components/schemas/ChoiceInput' + description: 选项 + statements: + type: array + minItems: 1 + items: + $ref: '#/components/schemas/StatementInput' + description: 题目 + horizontal_on_mobile: + type: boolean + description: 移动端横向排列 + likert_choice_style: + type: string + enum: + - single + - multiple + description: 每个题目可选一项(single)或多项(multiple) + minimum_length: + type: integer + minimum: 0 + description: likert_choice_style=multiple 时每题最少选几项 + maximum_length: + type: integer + minimum: 0 + description: likert_choice_style=multiple 时每题最多选几项 + calculable: + type: boolean + description: 选项赋值开关;开启后每个(叶子)选项必须带 operand_value + allOf: + - description: minimum_length / maximum_length 只在 likert_choice_style=multiple 时有意义 + if: + not: + properties: + likert_choice_style: + const: multiple + required: + - likert_choice_style + then: + not: + anyOf: + - required: + - minimum_length + - required: + - maximum_length + - description: calculable=true 时每个选项都要有 operand_value + if: + properties: + calculable: + const: true + required: + - calculable + then: + properties: + choices: + items: + required: + - operand_value + MatrixFieldInput: + type: object + title: MatrixFieldInput + description: MatrixField 字段 + additionalProperties: false + required: + - type + - label + properties: + type: + const: MatrixField + label: + type: string + description: 字段标题 + cid: + type: string + description: 本次请求内的临时标识,供公式等引用 + notes: + type: string + description: 字段说明 + private: + type: boolean + description: 隐藏字段;隐藏字段不加必填校验 + required: + type: boolean + description: 必填 + position: + type: integer + minimum: 0 + description: 字段在表单中的目标位置(0 起);缺省追加到末尾 + dimensions: + type: array + minItems: 1 + items: + $ref: '#/components/schemas/DimensionInput' + description: 子字段(列) + statements: + type: array + minItems: 1 + items: + $ref: '#/components/schemas/StatementInput' + description: 题目 + horizontal_on_mobile: + type: boolean + description: 移动端横向排列 + advanced_feature: + type: boolean + description: 开启高级功能 + MatrixScaleFieldInput: + type: object + title: MatrixScaleFieldInput + description: MatrixScaleField 字段 + additionalProperties: false + required: + - type + - label + properties: + type: + const: MatrixScaleField + label: + type: string + description: 字段标题 + cid: + type: string + description: 本次请求内的临时标识,供公式等引用 + notes: + type: string + description: 字段说明 + private: + type: boolean + description: 隐藏字段;隐藏字段不加必填校验 + required: + type: boolean + description: 必填 + position: + type: integer + minimum: 0 + description: 字段在表单中的目标位置(0 起);缺省追加到末尾 + rating_max: + type: integer + minimum: 1 + description: 评分量级(如 3 / 5 / 10),缺省 5 + statements: + type: array + minItems: 1 + items: + $ref: '#/components/schemas/StatementInput' + description: 题目 + horizontal_on_mobile: + type: boolean + description: 移动端横向排列 + GoodsFieldInput: + type: object + title: GoodsFieldInput + description: GoodsField 字段 + additionalProperties: false + required: + - type + - label + properties: + type: + const: GoodsField + label: + type: string + description: 字段标题 + cid: + type: string + description: 本次请求内的临时标识,供公式等引用 + notes: + type: string + description: 字段说明 + private: + type: boolean + description: 隐藏字段;隐藏字段不加必填校验 + required: + type: boolean + description: 必填 + position: + type: integer + minimum: 0 + description: 字段在表单中的目标位置(0 起);缺省追加到末尾 + goods_items: + type: array + minItems: 1 + items: + $ref: '#/components/schemas/GoodsItemInput' + description: 商品项目 + unit: + type: string + maxLength: 2 + description: 数量单位,最多 2 个字 + collapse_on_mobile: + type: boolean + description: 移动端折叠商品列表 + columns_on_mobile: + type: integer + minimum: 1 + description: 移动端每行商品数 + FormulaFieldInput: + type: object + title: FormulaFieldInput + description: FormulaField 字段 + additionalProperties: false + required: + - type + - label + properties: + type: + const: FormulaField + label: + type: string + description: 字段标题 + cid: + type: string + description: 本次请求内的临时标识,供公式等引用 + notes: + type: string + description: 字段说明 + private: + type: boolean + description: 隐藏字段;隐藏字段不加必填校验 + required: + type: boolean + description: 必填 + position: + type: integer + minimum: 0 + description: 字段在表单中的目标位置(0 起);缺省追加到末尾 + formula_display: + type: string + description: 公式 HTML,字段引用写成 或 + result_display_type: + type: string + enum: + - string + - numeric + - datetime + description: 结果显示方式 + result_display_as_date: + type: boolean + description: result_display_type=datetime 的旧写法 + precision: + type: integer + minimum: 0 + maximum: 14 + description: 小数位数 + display_precision: + type: integer + minimum: 0 + description: 显示用小数位数 + display_as_percentage: + type: boolean + description: 按百分比显示;与 thousands_separator 互斥 + thousands_separator: + type: boolean + description: 显示千分位分隔符;与 display_as_percentage 互斥 + currency_symbol: + type: string + description: 货币符号 + currency_symbol_position: + type: string + enum: + - prefix + - suffix + description: 货币符号位置 + icon_type: + type: string + enum: + - none + - formula-2 + - date-2 + - number-2 + - dollar1 + - cny1 + - sum + - text + - text2 + description: 字段图标 + allOf: + - description: display_as_percentage 与 thousands_separator 不能同时为 true + not: + properties: + display_as_percentage: + const: true + thousands_separator: + const: true + required: + - display_as_percentage + - thousands_separator + ReservationFieldInput: + type: object + title: ReservationFieldInput + description: ReservationField 字段 + additionalProperties: false + required: + - type + - label + properties: + type: + const: ReservationField + label: + type: string + description: 字段标题 + cid: + type: string + description: 本次请求内的临时标识,供公式等引用 + notes: + type: string + description: 字段说明 + private: + type: boolean + description: 隐藏字段;隐藏字段不加必填校验 + required: + type: boolean + description: 必填 + position: + type: integer + minimum: 0 + description: 字段在表单中的目标位置(0 起);缺省追加到末尾 + reservation_items: + type: array + minItems: 1 + items: + $ref: '#/components/schemas/ReservationItemInput' + description: 预约项目 + allow_multiple_items: + type: boolean + description: 一次提交可预约多个项目 + item_allow_multiple_reservations: + type: boolean + description: 同一项目可预约多个时段 + allow_multiple_number: + type: boolean + description: 每个时段可预约多个名额 + use_daily_time_range: + type: boolean + description: 启用按日时段表 + FormAssociationInput: + type: object + title: FormAssociationInput + description: FormAssociation 字段 + additionalProperties: false + required: + - type + - label + - associated_form_token + - associated_field_api_codes + properties: + type: + const: FormAssociation + label: + type: string + description: 字段标题 + cid: + type: string + description: 本次请求内的临时标识,供公式等引用 + notes: + type: string + description: 字段说明 + private: + type: boolean + description: 隐藏字段;隐藏字段不加必填校验 + required: + type: boolean + description: 必填 + position: + type: integer + minimum: 0 + description: 字段在表单中的目标位置(0 起);缺省追加到末尾 + unique: + type: boolean + description: 不允许与已有数据重复 + display_field_api_codes: + type: array + maxItems: 20 + items: + type: string + description: 展示字段的 api_code + associated_form_token: + type: string + description: 被关联表单的 token + associated_field_api_codes: + type: array + minItems: 1 + maxItems: 4 + items: + type: string + description: 关联键字段的 api_code + privacy_safe: + type: boolean + description: 关联字段全局开启隐私保护显示 + allow_reverse_association: + type: boolean + description: 在被关联表单上显示反向关联 + reverse_display_field_api_codes: + type: array + items: + type: string + description: 反向关联展示的字段 api_code + ImageRadioButtonInput: + type: object + title: ImageRadioButtonInput + description: ImageRadioButton 字段 + additionalProperties: false + required: + - type + - label + properties: + type: + const: ImageRadioButton + label: + type: string + description: 字段标题 + cid: + type: string + description: 本次请求内的临时标识,供公式等引用 + notes: + type: string + description: 字段说明 + private: + type: boolean + description: 隐藏字段;隐藏字段不加必填校验 + required: + type: boolean + description: 必填 + position: + type: integer + minimum: 0 + description: 字段在表单中的目标位置(0 起);缺省追加到末尾 + choices: + type: array + minItems: 1 + items: + $ref: '#/components/schemas/ImageChoiceInput' + description: 选项 + calculable: + type: boolean + description: 选项赋值开关;开启后每个(叶子)选项必须带 operand_value + choices_layout: + type: string + description: 选项排列方式 + allOf: + - description: calculable=true 时每个选项都要有 operand_value + if: + properties: + calculable: + const: true + required: + - calculable + then: + properties: + choices: + items: + required: + - operand_value + ImageCheckBoxInput: + type: object + title: ImageCheckBoxInput + description: ImageCheckBox 字段 + additionalProperties: false + required: + - type + - label + properties: + type: + const: ImageCheckBox + label: + type: string + description: 字段标题 + cid: + type: string + description: 本次请求内的临时标识,供公式等引用 + notes: + type: string + description: 字段说明 + private: + type: boolean + description: 隐藏字段;隐藏字段不加必填校验 + required: + type: boolean + description: 必填 + position: + type: integer + minimum: 0 + description: 字段在表单中的目标位置(0 起);缺省追加到末尾 + choices: + type: array + minItems: 1 + items: + $ref: '#/components/schemas/ImageChoiceInput' + description: 选项 + calculable: + type: boolean + description: 选项赋值开关;开启后每个(叶子)选项必须带 operand_value + choices_layout: + type: string + description: 选项排列方式 + allOf: + - description: calculable=true 时每个选项都要有 operand_value + if: + properties: + calculable: + const: true + required: + - calculable + then: + properties: + choices: + items: + required: + - operand_value + SectionBreakInput: + type: object + title: SectionBreakInput + description: SectionBreak 字段 + additionalProperties: false + required: + - type + - label + properties: + type: + const: SectionBreak + label: + type: string + description: 字段标题 + cid: + type: string + description: 本次请求内的临时标识,供公式等引用 + notes: + type: string + description: 字段说明 + private: + type: boolean + description: 隐藏字段;隐藏字段不加必填校验 + required: + type: boolean + description: 必填 + position: + type: integer + minimum: 0 + description: 字段在表单中的目标位置(0 起);缺省追加到末尾 + show_split_line: + type: boolean + description: 显示分割线 + show_part_description: + type: boolean + description: 描述过长时折叠显示 + PageBreakInput: + type: object + title: PageBreakInput + description: PageBreak 字段 + additionalProperties: false + required: + - type + - label + properties: + type: + const: PageBreak + label: + type: string + description: 字段标题 + cid: + type: string + description: 本次请求内的临时标识,供公式等引用 + notes: + type: string + description: 字段说明 + private: + type: boolean + description: 隐藏字段;隐藏字段不加必填校验 + required: + type: boolean + description: 必填 + position: + type: integer + minimum: 0 + description: 字段在表单中的目标位置(0 起);缺省追加到末尾 + disable_previous_page: + type: boolean + description: 隐藏「上一页」按钮 + previous_page_text: + type: string + description: "「上一页」按钮文字" + next_page_text: + type: string + description: "「下一页」按钮文字" + count_down: + type: integer + minimum: 0 + description: 锁定「下一页」的秒数,0 表示不锁 + WidgetButtonInput: + type: object + title: WidgetButtonInput + description: WidgetButton 字段 + additionalProperties: false + required: + - type + - label + properties: + type: + const: WidgetButton + label: + type: string + description: 字段标题 + cid: + type: string + description: 本次请求内的临时标识,供公式等引用 + notes: + type: string + description: 字段说明 + private: + type: boolean + description: 隐藏字段;隐藏字段不加必填校验 + required: + type: boolean + description: 必填 + position: + type: integer + minimum: 0 + description: 字段在表单中的目标位置(0 起);缺省追加到末尾 + buttons: + type: array + minItems: 1 + items: + $ref: '#/components/schemas/WidgetButtonItemInput' + description: 按钮 + buttons_number: + type: integer + minimum: 1 + description: 按钮数量,须与 buttons 数组长度一致 + fixed_to_bottom: + type: boolean + description: 按钮栏固定在页面底部 + WidgetContactInput: + type: object + title: WidgetContactInput + description: WidgetContact 字段 + additionalProperties: false + required: + - type + - label + properties: + type: + const: WidgetContact + label: + type: string + description: 字段标题 + cid: + type: string + description: 本次请求内的临时标识,供公式等引用 + notes: + type: string + description: 字段说明 + private: + type: boolean + description: 隐藏字段;隐藏字段不加必填校验 + required: + type: boolean + description: 必填 + position: + type: integer + minimum: 0 + description: 字段在表单中的目标位置(0 起);缺省追加到末尾 + widget_images: + type: array + items: + $ref: '#/components/schemas/WidgetImageInput' + description: 图片,每项提供 image_url / image_base64 / image_upload_token 之一 + widget_max_images_number: + type: integer + minimum: 1 + description: 最多展示的图片数,须不小于 widget_images 的长度 + phones: + type: array + description: 联系电话 + items: + type: object + required: + - phone + additionalProperties: false + properties: + phone: + type: string + WidgetMapInput: + type: object + title: WidgetMapInput + description: WidgetMap 字段 + additionalProperties: false + required: + - type + - label + properties: + type: + const: WidgetMap + label: + type: string + description: 字段标题 + cid: + type: string + description: 本次请求内的临时标识,供公式等引用 + notes: + type: string + description: 字段说明 + private: + type: boolean + description: 隐藏字段;隐藏字段不加必填校验 + required: + type: boolean + description: 必填 + position: + type: integer + minimum: 0 + description: 字段在表单中的目标位置(0 起);缺省追加到末尾 + nav_address: + type: string + description: 导航地址文字 + longitude: + type: number + description: 经度 + latitude: + type: number + description: 纬度 + value: + type: string + description: 显示值 + localizable_on_mobile: + type: boolean + description: 移动端允许跳转定位 + WidgetMarqueeInput: + type: object + title: WidgetMarqueeInput + description: WidgetMarquee 字段 + additionalProperties: false + required: + - type + - label + properties: + type: + const: WidgetMarquee + label: + type: string + description: 字段标题 + cid: + type: string + description: 本次请求内的临时标识,供公式等引用 + notes: + type: string + description: 字段说明 + private: + type: boolean + description: 隐藏字段;隐藏字段不加必填校验 + required: + type: boolean + description: 必填 + position: + type: integer + minimum: 0 + description: 字段在表单中的目标位置(0 起);缺省追加到末尾 + lines: + type: integer + minimum: 1 + description: 滚动行数 + entries_init_number: + type: integer + minimum: 0 + description: 起始展示的提交数 + entries_number_visible: + type: boolean + description: 显示提交数 + marquee_data_type: + type: string + enum: + - fake + - real + description: 数据来源:fake 为生成,real 为真实数据 + SingleSelectInput: + type: object + title: SingleSelectInput + description: SingleSelect 题型,以 RadioButton 存储,仅 exam / evaluation 场景可用 + additionalProperties: false + required: + - type + - label + - choices + - answers + properties: + type: + const: SingleSelect + label: + type: string + description: 字段标题 + cid: + type: string + description: 本次请求内的临时标识,供公式等引用 + notes: + type: string + description: 字段说明 + private: + type: boolean + description: 隐藏字段;隐藏字段不加必填校验 + required: + type: boolean + description: 必填 + position: + type: integer + minimum: 0 + description: 字段在表单中的目标位置(0 起);缺省追加到末尾 + other_choice_required: + type: boolean + description: 选了「其他」必须填写补充文本 + choices: + type: array + minItems: 1 + items: + $ref: '#/components/schemas/ChoiceInput' + description: 选项 + calculable: + type: boolean + description: 选项赋值开关;开启后每个(叶子)选项必须带 operand_value + choices_layout: + type: string + description: 选项排列方式 + answers: + type: array + minItems: 1 + items: + $ref: '#/components/schemas/AnswerInput' + description: 正确答案与分值;选择题的 value 为选项显示值 + answer_setting_mode: + type: string + enum: + - absolute + - relative + description: 评分模式,缺省 absolute(全对才得分) + answer_explanation: + type: string + description: 答案解析 + allOf: + - description: calculable=true 时每个选项都要有 operand_value + if: + properties: + calculable: + const: true + required: + - calculable + then: + properties: + choices: + items: + required: + - operand_value + - description: absolute 模式只有一条答案 + if: + anyOf: + - not: + required: + - answer_setting_mode + - properties: + answer_setting_mode: + const: absolute + then: + properties: + answers: + maxItems: 1 + MultiSelectInput: + type: object + title: MultiSelectInput + description: MultiSelect 题型,以 CheckBox 存储,仅 exam / evaluation 场景可用 + additionalProperties: false + required: + - type + - label + - choices + - answers + properties: + type: + const: MultiSelect + label: + type: string + description: 字段标题 + cid: + type: string + description: 本次请求内的临时标识,供公式等引用 + notes: + type: string + description: 字段说明 + private: + type: boolean + description: 隐藏字段;隐藏字段不加必填校验 + required: + type: boolean + description: 必填 + position: + type: integer + minimum: 0 + description: 字段在表单中的目标位置(0 起);缺省追加到末尾 + other_choice_required: + type: boolean + description: 选了「其他」必须填写补充文本 + choices: + type: array + minItems: 1 + items: + $ref: '#/components/schemas/ChoiceInput' + description: 选项 + calculable: + type: boolean + description: 选项赋值开关;开启后每个(叶子)选项必须带 operand_value + choices_layout: + type: string + description: 选项排列方式 + answers: + type: array + minItems: 1 + items: + $ref: '#/components/schemas/AnswerInput' + description: 正确答案与分值;选择题的 value 为选项显示值 + answer_setting_mode: + type: string + enum: + - absolute + - partial_absolute + - relative + description: 评分模式,缺省 absolute(全对才得分) + answer_explanation: + type: string + description: 答案解析 + allOf: + - description: calculable=true 时每个选项都要有 operand_value + if: + properties: + calculable: + const: true + required: + - calculable + then: + properties: + choices: + items: + required: + - operand_value + - description: absolute 模式只有一条答案 + if: + anyOf: + - not: + required: + - answer_setting_mode + - properties: + answer_setting_mode: + const: absolute + then: + properties: + answers: + maxItems: 1 + - description: 多选题 absolute 模式的答案 value 是选项显示值数组 + if: + anyOf: + - not: + required: + - answer_setting_mode + - properties: + answer_setting_mode: + const: absolute + then: + properties: + answers: + items: + properties: + value: + type: array + items: + type: string + minItems: 1 + ImageSingleSelectInput: + type: object + title: ImageSingleSelectInput + description: ImageSingleSelect 题型,以 ImageRadioButton 存储,仅 exam / evaluation 场景可用 + additionalProperties: false + required: + - type + - label + - choices + - answers + properties: + type: + const: ImageSingleSelect + label: + type: string + description: 字段标题 + cid: + type: string + description: 本次请求内的临时标识,供公式等引用 + notes: + type: string + description: 字段说明 + private: + type: boolean + description: 隐藏字段;隐藏字段不加必填校验 + required: + type: boolean + description: 必填 + position: + type: integer + minimum: 0 + description: 字段在表单中的目标位置(0 起);缺省追加到末尾 + choices: + type: array + minItems: 1 + items: + $ref: '#/components/schemas/ImageChoiceInput' + description: 选项 + calculable: + type: boolean + description: 选项赋值开关;开启后每个(叶子)选项必须带 operand_value + choices_layout: + type: string + description: 选项排列方式 + answers: + type: array + minItems: 1 + items: + $ref: '#/components/schemas/AnswerInput' + description: 正确答案与分值;选择题的 value 为选项显示值 + answer_setting_mode: + type: string + enum: + - absolute + - relative + description: 评分模式,缺省 absolute(全对才得分) + answer_explanation: + type: string + description: 答案解析 + allOf: + - description: calculable=true 时每个选项都要有 operand_value + if: + properties: + calculable: + const: true + required: + - calculable + then: + properties: + choices: + items: + required: + - operand_value + - description: absolute 模式只有一条答案 + if: + anyOf: + - not: + required: + - answer_setting_mode + - properties: + answer_setting_mode: + const: absolute + then: + properties: + answers: + maxItems: 1 + ImageMultiSelectInput: + type: object + title: ImageMultiSelectInput + description: ImageMultiSelect 题型,以 ImageCheckBox 存储,仅 exam / evaluation 场景可用 + additionalProperties: false + required: + - type + - label + - choices + - answers + properties: + type: + const: ImageMultiSelect + label: + type: string + description: 字段标题 + cid: + type: string + description: 本次请求内的临时标识,供公式等引用 + notes: + type: string + description: 字段说明 + private: + type: boolean + description: 隐藏字段;隐藏字段不加必填校验 + required: + type: boolean + description: 必填 + position: + type: integer + minimum: 0 + description: 字段在表单中的目标位置(0 起);缺省追加到末尾 + choices: + type: array + minItems: 1 + items: + $ref: '#/components/schemas/ImageChoiceInput' + description: 选项 + calculable: + type: boolean + description: 选项赋值开关;开启后每个(叶子)选项必须带 operand_value + choices_layout: + type: string + description: 选项排列方式 + answers: + type: array + minItems: 1 + items: + $ref: '#/components/schemas/AnswerInput' + description: 正确答案与分值;选择题的 value 为选项显示值 + answer_setting_mode: + type: string + enum: + - absolute + - partial_absolute + - relative + description: 评分模式,缺省 absolute(全对才得分) + answer_explanation: + type: string + description: 答案解析 + allOf: + - description: calculable=true 时每个选项都要有 operand_value + if: + properties: + calculable: + const: true + required: + - calculable + then: + properties: + choices: + items: + required: + - operand_value + - description: absolute 模式只有一条答案 + if: + anyOf: + - not: + required: + - answer_setting_mode + - properties: + answer_setting_mode: + const: absolute + then: + properties: + answers: + maxItems: 1 + - description: 多选题 absolute 模式的答案 value 是选项显示值数组 + if: + anyOf: + - not: + required: + - answer_setting_mode + - properties: + answer_setting_mode: + const: absolute + then: + properties: + answers: + items: + properties: + value: + type: array + items: + type: string + minItems: 1 + TrueOrFalseInput: + type: object + title: TrueOrFalseInput + description: TrueOrFalse 题型,以 RadioButton 存储,仅 exam 场景可用 + additionalProperties: false + required: + - type + - label + - choices + - answers + properties: + type: + const: TrueOrFalse + label: + type: string + description: 字段标题 + cid: + type: string + description: 本次请求内的临时标识,供公式等引用 + notes: + type: string + description: 字段说明 + private: + type: boolean + description: 隐藏字段;隐藏字段不加必填校验 + required: + type: boolean + description: 必填 + position: + type: integer + minimum: 0 + description: 字段在表单中的目标位置(0 起);缺省追加到末尾 + other_choice_required: + type: boolean + description: 选了「其他」必须填写补充文本 + choices: + type: array + minItems: 1 + items: + $ref: '#/components/schemas/ChoiceInput' + description: 选项 + calculable: + type: boolean + description: 选项赋值开关;开启后每个(叶子)选项必须带 operand_value + choices_layout: + type: string + description: 选项排列方式 + answers: + type: array + minItems: 1 + items: + $ref: '#/components/schemas/AnswerInput' + description: 正确答案与分值;选择题的 value 为选项显示值 + answer_setting_mode: + type: string + enum: + - absolute + - relative + description: 评分模式,缺省 absolute(全对才得分) + answer_explanation: + type: string + description: 答案解析 + allOf: + - description: calculable=true 时每个选项都要有 operand_value + if: + properties: + calculable: + const: true + required: + - calculable + then: + properties: + choices: + items: + required: + - operand_value + - description: absolute 模式只有一条答案 + if: + anyOf: + - not: + required: + - answer_setting_mode + - properties: + answer_setting_mode: + const: absolute + then: + properties: + answers: + maxItems: 1 + DropDownSelectInput: + type: object + title: DropDownSelectInput + description: DropDownSelect 题型,以 DropDown 存储,仅 exam / evaluation 场景可用 + additionalProperties: false + required: + - type + - label + - choices + - answers + properties: + type: + const: DropDownSelect + label: + type: string + description: 字段标题 + cid: + type: string + description: 本次请求内的临时标识,供公式等引用 + notes: + type: string + description: 字段说明 + private: + type: boolean + description: 隐藏字段;隐藏字段不加必填校验 + required: + type: boolean + description: 必填 + position: + type: integer + minimum: 0 + description: 字段在表单中的目标位置(0 起);缺省追加到末尾 + other_choice_required: + type: boolean + description: 选了「其他」必须填写补充文本 + choices: + type: array + minItems: 1 + items: + $ref: '#/components/schemas/ChoiceInput' + description: 选项 + calculable: + type: boolean + description: 选项赋值开关;开启后每个(叶子)选项必须带 operand_value + answers: + type: array + minItems: 1 + items: + $ref: '#/components/schemas/AnswerInput' + description: 正确答案与分值;选择题的 value 为选项显示值 + answer_setting_mode: + type: string + enum: + - absolute + - relative + description: 评分模式,缺省 absolute(全对才得分) + answer_explanation: + type: string + description: 答案解析 + allOf: + - description: calculable=true 时每个选项都要有 operand_value + if: + properties: + calculable: + const: true + required: + - calculable + then: + properties: + choices: + items: + required: + - operand_value + - description: absolute 模式只有一条答案 + if: + anyOf: + - not: + required: + - answer_setting_mode + - properties: + answer_setting_mode: + const: absolute + then: + properties: + answers: + maxItems: 1 + FillInBlankInput: + type: object + title: FillInBlankInput + description: FillInBlank 题型,以 TextField 存储,仅 exam 场景可用 + additionalProperties: false + required: + - type + - label + - answers + properties: + type: + const: FillInBlank + label: + type: string + description: 字段标题 + cid: + type: string + description: 本次请求内的临时标识,供公式等引用 + notes: + type: string + description: 字段说明 + private: + type: boolean + description: 隐藏字段;隐藏字段不加必填校验 + required: + type: boolean + description: 必填 + position: + type: integer + minimum: 0 + description: 字段在表单中的目标位置(0 起);缺省追加到末尾 + unique: + type: boolean + description: 不允许与已有数据重复 + predefined_value: + type: string + description: 默认值 + placeholder: + type: string + description: 占位提示文字 + answers: + type: array + minItems: 1 + items: + $ref: '#/components/schemas/AnswerInput' + description: 正确答案与分值;选择题的 value 为选项显示值 + answer_setting_mode: + type: string + enum: + - absolute + - relative + description: 评分模式,缺省 absolute(全对才得分) + answer_explanation: + type: string + description: 答案解析 + allOf: + - description: absolute 模式只有一条答案 + if: + anyOf: + - not: + required: + - answer_setting_mode + - properties: + answer_setting_mode: + const: absolute + then: + properties: + answers: + maxItems: 1 + ShortAnswerInput: + type: object + title: ShortAnswerInput + description: ShortAnswer 题型,以 TextArea 存储,仅 exam 场景可用 + additionalProperties: false + required: + - type + - label + - answers + properties: + type: + const: ShortAnswer + label: + type: string + description: 字段标题 + cid: + type: string + description: 本次请求内的临时标识,供公式等引用 + notes: + type: string + description: 字段说明 + private: + type: boolean + description: 隐藏字段;隐藏字段不加必填校验 + required: + type: boolean + description: 必填 + position: + type: integer + minimum: 0 + description: 字段在表单中的目标位置(0 起);缺省追加到末尾 + unique: + type: boolean + description: 不允许与已有数据重复 + predefined_value: + type: string + description: 默认值 + placeholder: + type: string + description: 占位提示文字 + answers: + type: array + minItems: 1 + items: + $ref: '#/components/schemas/AnswerInput' + description: 正确答案与分值;选择题的 value 为选项显示值 + answer_setting_mode: + type: string + enum: + - absolute + - relative + description: 评分模式,缺省 absolute(全对才得分) + answer_explanation: + type: string + description: 答案解析 + allOf: + - description: absolute 模式只有一条答案 + if: + anyOf: + - not: + required: + - answer_setting_mode + - properties: + answer_setting_mode: + const: absolute + then: + properties: + answers: + maxItems: 1 + FillInNumberInput: + type: object + title: FillInNumberInput + description: FillInNumber 题型,以 NumberField 存储,仅 exam 场景可用 + additionalProperties: false + required: + - type + - label + - answers + properties: + type: + const: FillInNumber + label: + type: string + description: 字段标题 + cid: + type: string + description: 本次请求内的临时标识,供公式等引用 + notes: + type: string + description: 字段说明 + private: + type: boolean + description: 隐藏字段;隐藏字段不加必填校验 + required: + type: boolean + description: 必填 + position: + type: integer + minimum: 0 + description: 字段在表单中的目标位置(0 起);缺省追加到末尾 + unique: + type: boolean + description: 不允许与已有数据重复 + predefined_value: + type: + - number + - string + description: 默认值 + placeholder: + type: string + description: 占位提示文字 + range_min: + type: number + description: 最小值(含) + range_max: + type: number + description: 最大值(含) + precision: + type: integer + minimum: 0 + maximum: 14 + description: 小数位数 + display_precision: + type: integer + minimum: 0 + description: 显示用小数位数 + display_as_percentage: + type: boolean + description: 按百分比显示;与 thousands_separator 互斥 + thousands_separator: + type: boolean + description: 显示千分位分隔符;与 display_as_percentage 互斥 + currency_symbol: + type: string + description: 货币符号 + currency_symbol_position: + type: string + enum: + - prefix + - suffix + description: 货币符号位置 + answers: + type: array + minItems: 1 + items: + $ref: '#/components/schemas/AnswerInput' + description: 正确答案与分值;选择题的 value 为选项显示值 + answer_setting_mode: + type: string + enum: + - absolute + - relative + description: 评分模式,缺省 absolute(全对才得分) + answer_explanation: + type: string + description: 答案解析 + allOf: + - description: display_as_percentage 与 thousands_separator 不能同时为 true + not: + properties: + display_as_percentage: + const: true + thousands_separator: + const: true + required: + - display_as_percentage + - thousands_separator + - description: absolute 模式只有一条答案 + if: + anyOf: + - not: + required: + - answer_setting_mode + - properties: + answer_setting_mode: + const: absolute + then: + properties: + answers: + maxItems: 1 + RatingInput: + type: object + title: RatingInput + description: Rating 题型,以 RatingField 存储,仅 evaluation 场景可用 + additionalProperties: false + required: + - type + - label + - answers + properties: + type: + const: Rating + label: + type: string + description: 字段标题 + cid: + type: string + description: 本次请求内的临时标识,供公式等引用 + notes: + type: string + description: 字段说明 + private: + type: boolean + description: 隐藏字段;隐藏字段不加必填校验 + required: + type: boolean + description: 必填 + position: + type: integer + minimum: 0 + description: 字段在表单中的目标位置(0 起);缺省追加到末尾 + rating_max: + type: integer + minimum: 1 + description: 评分量级(如 3 / 5 / 10),缺省 5 + answers: + type: array + minItems: 1 + items: + $ref: '#/components/schemas/AnswerInput' + description: 正确答案与分值;选择题的 value 为选项显示值 + answer_setting_mode: + type: string + enum: + - absolute + - relative + description: 评分模式,缺省 absolute(全对才得分) + answer_explanation: + type: string + description: 答案解析 + allOf: + - description: absolute 模式只有一条答案 + if: + anyOf: + - not: + required: + - answer_setting_mode + - properties: + answer_setting_mode: + const: absolute + then: + properties: + answers: + maxItems: 1 + NpsInput: + type: object + title: NpsInput + description: Nps 题型,以 NpsField 存储,仅 evaluation 场景可用 + additionalProperties: false + required: + - type + - label + - answers + properties: + type: + const: Nps + label: + type: string + description: 字段标题 + cid: + type: string + description: 本次请求内的临时标识,供公式等引用 + notes: + type: string + description: 字段说明 + private: + type: boolean + description: 隐藏字段;隐藏字段不加必填校验 + required: + type: boolean + description: 必填 + position: + type: integer + minimum: 0 + description: 字段在表单中的目标位置(0 起);缺省追加到末尾 + minimum_ratings_display_text: + type: string + description: 最低分文案 + maximum_ratings_display_text: + type: string + description: 最高分文案 + answers: + type: array + minItems: 1 + items: + $ref: '#/components/schemas/AnswerInput' + description: 正确答案与分值;选择题的 value 为选项显示值 + answer_setting_mode: + type: string + enum: + - absolute + - relative + description: 评分模式,缺省 absolute(全对才得分) + answer_explanation: + type: string + description: 答案解析 + allOf: + - description: absolute 模式只有一条答案 + if: + anyOf: + - not: + required: + - answer_setting_mode + - properties: + answer_setting_mode: + const: absolute + then: + properties: + answers: + maxItems: 1 + DepartmentInput: + type: object + title: DepartmentInput + description: Department 题型,以 RadioButton 存储 + additionalProperties: false + required: + - type + - label + properties: + type: + const: Department + label: + type: string + description: 字段标题 + cid: + type: string + description: 本次请求内的临时标识,供公式等引用 + notes: + type: string + description: 字段说明 + private: + type: boolean + description: 隐藏字段;隐藏字段不加必填校验 + required: + type: boolean + description: 必填 + position: + type: integer + minimum: 0 + description: 字段在表单中的目标位置(0 起);缺省追加到末尾 + other_choice_required: + type: boolean + description: 选了「其他」必须填写补充文本 + choices: + type: array + minItems: 1 + items: + $ref: '#/components/schemas/ChoiceInput' + description: 选项 + calculable: + type: boolean + description: 选项赋值开关;开启后每个(叶子)选项必须带 operand_value + choices_layout: + type: string + description: 选项排列方式 + allOf: + - description: calculable=true 时每个选项都要有 operand_value + if: + properties: + calculable: + const: true + required: + - calculable + then: + properties: + choices: + items: + required: + - operand_value + GradeInput: + type: object + title: GradeInput + description: Grade 题型,以 RadioButton 存储 + additionalProperties: false + required: + - type + - label + properties: + type: + const: Grade + label: + type: string + description: 字段标题 + cid: + type: string + description: 本次请求内的临时标识,供公式等引用 + notes: + type: string + description: 字段说明 + private: + type: boolean + description: 隐藏字段;隐藏字段不加必填校验 + required: + type: boolean + description: 必填 + position: + type: integer + minimum: 0 + description: 字段在表单中的目标位置(0 起);缺省追加到末尾 + other_choice_required: + type: boolean + description: 选了「其他」必须填写补充文本 + choices: + type: array + minItems: 1 + items: + $ref: '#/components/schemas/ChoiceInput' + description: 选项 + calculable: + type: boolean + description: 选项赋值开关;开启后每个(叶子)选项必须带 operand_value + choices_layout: + type: string + description: 选项排列方式 + allOf: + - description: calculable=true 时每个选项都要有 operand_value + if: + properties: + calculable: + const: true + required: + - calculable + then: + properties: + choices: + items: + required: + - operand_value + BooleanFieldInput: + type: object + title: BooleanFieldInput + description: BooleanField 字段 + additionalProperties: false + required: + - type + - label + properties: + type: + const: BooleanField + label: + type: string + description: 字段标题 + cid: + type: string + description: 本次请求内的临时标识,供公式等引用 + notes: + type: string + description: 字段说明 + private: + type: boolean + description: 隐藏字段;隐藏字段不加必填校验 + required: + type: boolean + description: 必填 + position: + type: integer + minimum: 0 + description: 字段在表单中的目标位置(0 起);缺省追加到末尾 + TableColumnInput: + type: object + description: 表格列定义,type 限于表格支持的列类型 + required: + - type + - label + properties: + type: + type: string + enum: + - TextArea + - RadioButton + - CheckBox + - BooleanField + - MobileField + - NumberField + - DateTimeField + - EmailField + - LinkField + - AttachmentField + - FormulaField + - RelationField + label: + type: string + description: 字段标题 + discriminator: + propertyName: type + mapping: + TextArea: "#/components/schemas/TextAreaInput" + RadioButton: "#/components/schemas/RadioButtonInput" + CheckBox: "#/components/schemas/CheckBoxInput" + BooleanField: "#/components/schemas/BooleanFieldInput" + MobileField: "#/components/schemas/MobileFieldInput" + NumberField: "#/components/schemas/NumberFieldInput" + DateTimeField: "#/components/schemas/DateTimeFieldInput" + EmailField: "#/components/schemas/EmailFieldInput" + LinkField: "#/components/schemas/LinkFieldInput" + AttachmentField: "#/components/schemas/AttachmentFieldInput" + FormulaField: "#/components/schemas/FormulaFieldInput" + RelationField: "#/components/schemas/RelationFieldInput" + oneOf: + - $ref: '#/components/schemas/TextAreaInput' + - $ref: '#/components/schemas/RadioButtonInput' + - $ref: '#/components/schemas/CheckBoxInput' + - $ref: '#/components/schemas/BooleanFieldInput' + - $ref: '#/components/schemas/MobileFieldInput' + - $ref: '#/components/schemas/NumberFieldInput' + - $ref: '#/components/schemas/DateTimeFieldInput' + - $ref: '#/components/schemas/EmailFieldInput' + - $ref: '#/components/schemas/LinkFieldInput' + - $ref: '#/components/schemas/AttachmentFieldInput' + - $ref: '#/components/schemas/FormulaFieldInput' + - $ref: '#/components/schemas/RelationFieldInput' + title: TableColumnInput + ImageChoiceInput: + type: object + title: ImageChoiceInput + additionalProperties: false + description: 图片选项;value 为文字标签,且必须提供 image_url / image_base64 / image_upload_token 之一 + allOf: + - anyOf: + - required: + - value + - required: + - name + - anyOf: + - required: + - image_url + - required: + - image_base64 + - required: + - image_upload_token + properties: + value: + type: string + description: 选项显示值 + name: + type: string + description: 与 value 等价 + api_code: + type: string + description: 指定选项 api_code;缺省由服务端生成,编辑时用于保留身份 + operand_value: + type: number + description: 选项赋值 + selected: + type: boolean + description: 默认选中 + image_url: + type: string + format: uri + description: 公网可访问的图片地址 + image_base64: + type: string + description: Base64 图片,仅适合很小的图 + image_upload_token: + type: string + description: "「上传表单图片」使用的 form_image_token,图片用途为 field_choice" + CascadeChoiceInput: + type: object + title: CascadeChoiceInput + additionalProperties: false + description: 多级下拉选项;sub_choices 为下一级,每条路径的深度须等于 levels + anyOf: + - required: + - value + - required: + - name + properties: + value: + type: string + description: 选项显示值 + name: + type: string + description: 与 value 等价 + api_code: + type: string + description: 指定选项 api_code;缺省由服务端生成,编辑时用于保留身份 + quota: + type: integer + minimum: 1 + description: 名额上限;不限名额时省略 + operand_value: + type: number + description: 选项赋值 + selected: + type: boolean + description: 默认选中 + sub_choices: + type: array + items: + $ref: '#/components/schemas/CascadeChoiceInput' + description: 下级选项 + AnswerInput: + type: object + title: AnswerInput + additionalProperties: false + required: + - value + - score + properties: + value: + description: 正确答案:选择题为选项显示值(多选 absolute 模式为数组),填空为期望文本,数字题为期望数字 + score: + type: number + description: 分值 + ReservationByDayQuotaInput: + type: object + title: ReservationByDayQuotaInput + additionalProperties: false + required: + - type + properties: + type: + const: by_day_repeat_weekly + available_days_of_week: + type: array + items: + type: string + enum: + - sunday + - monday + - tuesday + - wednesday + - thursday + - friday + - saturday + description: 可预约的星期,小写英文星期名 + show_left_quota: + type: boolean + description: 显示剩余名额 + start_time_offset: + type: object + additionalProperties: false + properties: + offset_number: + type: integer + minimum: 0 + unit: + type: string + enum: + - day + - hour + description: 最早可提前多久预约 + end_time_offset: + type: object + additionalProperties: false + properties: + offset_number: + type: integer + minimum: 0 + unit: + type: string + enum: + - day + - hour + description: 最晚可提前多久预约 + daily_time_range_quotas: + type: array + items: + type: object + additionalProperties: false + properties: + for_wday: + type: string + enum: + - sunday + - monday + - tuesday + - wednesday + - thursday + - friday + - saturday + description: 星期,小写英文星期名 + quota: + type: integer + minimum: 0 + description: 名额 + price: + type: number + minimum: 0 + description: payable 时的价格 + position: + type: integer + minimum: 0 + daily_time_range_token: + type: string + description: 每个星期日的名额 + non_reservable_rules: + type: array + items: + type: object + additionalProperties: false + properties: + start_time: + type: string + end_time: + type: string + description: 不可预约的时间段 + ReservationByTimeRangeQuotaInput: + type: object + title: ReservationByTimeRangeQuotaInput + additionalProperties: false + required: + - type + properties: + type: + const: by_time_range_repeat_daily + available_days_of_week: + type: array + items: + type: string + enum: + - sunday + - monday + - tuesday + - wednesday + - thursday + - friday + - saturday + description: 可预约的星期,小写英文星期名 + show_left_quota: + type: boolean + description: 显示剩余名额 + start_time_offset: + type: object + additionalProperties: false + properties: + offset_number: + type: integer + minimum: 0 + unit: + type: string + enum: + - day + - hour + description: 最早可提前多久预约 + end_time_offset: + type: object + additionalProperties: false + properties: + offset_number: + type: integer + minimum: 0 + unit: + type: string + enum: + - day + - hour + description: 最晚可提前多久预约 + daily_time_range_quotas: + type: array + items: + type: object + additionalProperties: false + required: + - start_time + - end_time + properties: + for_wday: + type: string + enum: + - sunday + - monday + - tuesday + - wednesday + - thursday + - friday + - saturday + description: 星期,小写英文星期名 + quota: + type: integer + minimum: 0 + description: 名额 + price: + type: number + minimum: 0 + description: payable 时的价格 + position: + type: integer + minimum: 0 + daily_time_range_token: + type: string + start_time: + type: object + additionalProperties: false + properties: + hour: + type: integer + minimum: 0 + maximum: 23 + minute: + type: integer + minimum: 0 + maximum: 59 + description: 开始时间 + end_time: + type: object + additionalProperties: false + properties: + hour: + type: integer + minimum: 0 + maximum: 23 + minute: + type: integer + minimum: 0 + maximum: 59 + description: 结束时间 + description: 每个时段的名额 + non_reservable_rules: + type: array + items: + type: object + additionalProperties: false + properties: + start_time: + type: string + end_time: + type: string + description: 不可预约的时间段 + time_range_mode: + type: string + enum: + - same_by_wday + - different_by_wday + description: same_by_wday 每天相同时段;different_by_wday 每段须带 for_wday + if: + properties: + time_range_mode: + const: different_by_wday + required: + - time_range_mode + then: + properties: + daily_time_range_quotas: + items: + required: + - for_wday + WidgetImageInput: + type: object + title: WidgetImageInput + additionalProperties: false + anyOf: + - required: + - image_url + - required: + - image_base64 + - required: + - image_upload_token + properties: + image_url: + type: string + format: uri + image_base64: + type: string + image_upload_token: + type: string + FormCreateConstraints: + title: FormCreateConstraints + description: 创建表单时字段与场景 / 布局的组合约束 + allOf: + - description: card 布局不能包含分页式无法渲染的字段类型 + if: + properties: + layout: + const: card + required: + - layout + then: + properties: + fields: + items: + properties: + type: + not: + enum: + - PageBreak + - GoodsField + - MultipleBlanksField + - WidgetContact + - WidgetVideo + - WidgetButton + - description: SingleSelect / MultiSelect / ImageSingleSelect / ImageMultiSelect / DropDownSelect 只能用在 exam / evaluation 场景 + if: + properties: + fields: + type: array + contains: + properties: + type: + enum: + - SingleSelect + - MultiSelect + - ImageSingleSelect + - ImageMultiSelect + - DropDownSelect + required: + - type + required: + - fields + then: + properties: + scene: + enum: + - exam + - evaluation + required: + - scene + - description: TrueOrFalse / FillInBlank / ShortAnswer / FillInNumber 只能用在 exam 场景 + if: + properties: + fields: + type: array + contains: + properties: + type: + enum: + - TrueOrFalse + - FillInBlank + - ShortAnswer + - FillInNumber + required: + - type + required: + - fields + then: + properties: + scene: + enum: + - exam + required: + - scene + - description: Rating / Nps 只能用在 evaluation 场景 + if: + properties: + fields: + type: array + contains: + properties: + type: + enum: + - Rating + - Nps + required: + - type + required: + - fields + then: + properties: + scene: + enum: + - evaluation + required: + - scene + WidgetButtonItemInput: + type: object + title: WidgetButtonItemInput + additionalProperties: false + required: + - text + properties: + text: + type: string + description: 按钮文字 + position: + type: integer + minimum: 0 + link_type: + type: string + enum: + - normal + - miniapp + - phone + - qrcode + description: 链接类型,缺省 normal + link_attrs: + type: object + description: '链接属性,如 {"url": "https://..."}' + url: + type: string + format: uri + description: link_attrs 缺省时的链接地址 + FieldDataCheckResponse: + type: object + required: + - form_token + - results + properties: + form_token: + type: string + description: 表单(或表格)token + results: + type: array + description: 检查结果,与请求中的 `checks` 顺序一一对应 + items: + oneOf: + - $ref: '#/components/schemas/FieldDataCheckResult' + - $ref: '#/components/schemas/FieldCheckUnavailable' + FieldDataCheckResult: + type: object + required: + - field_api_code + - field_label + - has_data + properties: + field_api_code: + type: string + description: 字段 api_code + field_label: + type: string + description: 字段标题 + has_data: + type: boolean + description: 字段(传了 `choice_value` 时为该选项)是否已有提交的数据 + choice_value: + type: string + description: 请求中传入的 `choice_value`,未传时不返回 + FieldCheckUnavailable: + type: object + description: 无法检查 / 预览的一项 + required: + - field_api_code + - unavailable + properties: + field_api_code: + type: + - string + - 'null' + description: 请求中传入的 `field_api_code` + unavailable: + type: string + description: 无法检查的原因 + examples: + - 'Field not found: field_9. Read the form''s fields to see the current ones.' + FieldTypeChangePreviewResponse: + type: object + required: + - form_token + - results + properties: + form_token: + type: string + description: 表单(或表格)token + results: + type: array + description: 预览结果,与请求中的 `checks` 顺序一一对应 + items: + oneOf: + - $ref: '#/components/schemas/FieldTypeChangePreviewResult' + - $ref: '#/components/schemas/FieldCheckUnavailable' + FieldTypeChangePreviewResult: + type: object + required: + - field_api_code + - field_label + - source_type + - target_type + - supported + properties: + field_api_code: + type: string + description: 字段 api_code + field_label: + type: string + description: 字段标题 + source_type: + type: string + description: 字段当前的类型 + examples: + - TextField + target_type: + type: string + description: 目标类型 + examples: + - RadioButton + supported: + type: boolean + description: 是否支持该转换。为 `false` 时不返回下列计数,实际编辑也会被拒绝 + retained_count: + type: integer + description: 转换后保留值的数据条数(`supported=true` 时返回) + cleared_count: + type: integer + description: 转换后被清空值的数据条数(`supported=true` 时返回);为 0 表示转换无损 + choice_count: + type: integer + description: 多行文本转换为选择类字段时,将从已有数据生成的选项数;其他转换不返回 + choice_limit_exceeded: + type: boolean + description: 多行文本转换为选择类字段时,生成的选项数是否超过上限(超过时转换会被拒绝);其他转换不返回 + FieldTypeListResponse: + type: object + required: + - data + properties: + data: + type: array + items: + $ref: '#/components/schemas/FieldTypeDescription' + FieldTypeDescription: + type: object + required: + - type + - takes_choices + - flags + - settings + properties: + type: + type: string + description: 字段类型名,即 `fields[].type` 的取值 + examples: + - RadioButton + base_type: + type: string + description: 该类型基于的基础字段类型(如考试题型 `SingleSelect` 基于 `RadioButton`,能力与之相同);与 `type` 相同时不返回 + examples: + - RadioButton + scorable: + type: boolean + const: true + description: 考试计分题型时返回 `true`,其他类型不返回 + takes_choices: + type: boolean + description: 是否接受 `choices`(选项) + flags: + type: array + description: 支持的校验开关,即 `fields[]` 中可用的布尔键 + items: + type: string + enum: + - required + - unique + - other_choice_required + settings: + type: array + description: 该类型接受的专属设置键(写在 `fields[]` 上) + items: + type: string + examples: + - - calculable + - choices_layout + summary: + type: string + description: 这种字段是什么、怎么写的一段说明。`kind=table` 时不返回 + field_example: + type: object + description: 写进 `fields[]` 的一个完整示例。只在 `GET /field_types/{type}` 返回,`kind=table` 时不返回 + note: + type: string + description: 示例表达不了、容易写错的地方。只在 `GET /field_types/{type}` 返回,没有时不返回 + EntryCountResponse: + type: object + required: + - matched_entries + properties: + matched_entries: + type: integer + description: 满足条件的数据条数 + examples: + - matched_entries: 1284 + EntryAggregateColumn: + type: object + description: 结果列的说明。分组列在前(`kind` 为 `dimension`),指标列在后(`kind` 为 `metric`),顺序与请求一致。 + required: + - kind + - field + - label + properties: + kind: + type: string + enum: + - dimension + - metric + field: + type: string + description: 字段 api_code + label: + type: string + description: 字段标题 + func: + type: string + description: 聚合函数(仅指标列) + bucket: + type: string + enum: + - day + - week + - month + description: 日期分组粒度(仅按日期分组的列) + EntryAggregateResponse: + type: object + required: + - columns + - rows + - matched_entries + - row_count + - truncated + - tz + properties: + columns: + type: array + items: + $ref: '#/components/schemas/EntryAggregateColumn' + rows: + type: array + description: | + 每行的值与 `columns` 一一对应。 + - 分组列:选项字段为 `{"api_code", "label"}`,其他为字符串(日期分组为该时间段的标识);该字段为空的数据单独成组,值为 `null`。 + - 指标列:数字;日期字段的 `min` / `max` 为北京时间的 ISO 8601 时间;没有值时为 `null`。 + items: + type: array + items: + type: + - number + - string + - object + - 'null' + properties: + api_code: + type: string + label: + type: string + matched_entries: + type: integer + description: 参与统计的数据条数(满足筛选条件的数据数,不是组数) + row_count: + type: integer + description: 分组总数(不分组时为 1);可能大于 `rows` 的行数 + truncated: + type: boolean + description: 分组数超过 `limit`,`rows` 只含部分分组 + tz: + type: string + description: 日期分组与时间值使用的时区 + examples: + - Asia/Shanghai + examples: + - columns: + - kind: dimension + field: field_3 + label: 城市 + - kind: metric + func: sum + field: field_5 + label: 金额 + rows: + - - api_code: bj + label: 北京 + - 1520.5 + - - + - 300 + matched_entries: 42 + row_count: 2 + truncated: false + tz: Asia/Shanghai + EntrySummaryBucket: + type: object + required: + - api_code + - label + - count + - ratio + properties: + api_code: + type: + - string + - 'null' + description: 选项 api_code;按取值分组、没有对应选项的字段(如地址、商品、NPS)为 null + label: + type: string + description: 选项显示值或分组取值 + count: + type: integer + ratio: + type: number + description: 占已填写数据的比例(0~1,保留 4 位小数) + EntrySummaryField: + type: object + description: | + 单个字段的概况。所有字段都有 `api_code` / `label` / `kind` / `answered` / `null_count`,其余属性按 `kind` 出现: + | kind | 属性 | + |---|---| + | `choice` | `buckets` | + | `matrix` | `series` | + | `number` | `stats` | + | `date` | `earliest` / `latest` | + | `text` | `count_unique` | + | `geo` | 无 | + required: + - api_code + - label + - kind + - answered + - null_count + properties: + api_code: + type: string + label: + type: string + kind: + type: string + enum: + - choice + - matrix + - number + - date + - text + - geo + answered: + type: integer + description: 已填写该字段的数据条数 + null_count: + type: integer + description: 未填写该字段的数据条数 + buckets: + type: array + items: + $ref: '#/components/schemas/EntrySummaryBucket' + series: + type: array + description: 矩阵类字段按题目给出的分布 + items: + type: object + required: + - api_code + - label + - answered + - buckets + properties: + api_code: + type: string + description: 题目 api_code + label: + type: string + answered: + type: integer + buckets: + type: array + items: + $ref: '#/components/schemas/EntrySummaryBucket' + stats: + type: object + description: 数值统计;没有数据时为空对象 + properties: + min: + type: number + max: + type: number + sum: + type: number + avg: + type: number + earliest: + type: + - string + - 'null' + format: date-time + description: 最早的日期值(北京时间 ISO 8601) + latest: + type: + - string + - 'null' + format: date-time + description: 最晚的日期值(北京时间 ISO 8601) + count_unique: + type: + - integer + - 'null' + description: 不同取值的个数 + EntrySummaryResponse: + type: object + required: + - fields + - truncated + - omitted_fields + - tz + properties: + overview: + type: object + description: 总览;`include_overview=false` 时不返回 + required: + - matched_entries + - today_entries + properties: + matched_entries: + type: integer + description: 满足筛选条件的数据条数 + today_entries: + type: integer + description: 其中今天(北京时间)创建的数据条数 + fields: + type: array + items: + $ref: '#/components/schemas/EntrySummaryField' + truncated: + type: boolean + description: 可分析字段超过 60 个,部分字段未返回 + omitted_fields: + type: array + description: 因超过 60 个上限而未返回的字段 api_code + items: + type: string + tz: + type: string + description: 日期值与「今天」使用的时区 + examples: + - Asia/Shanghai + examples: + - overview: + matched_entries: 42 + today_entries: 3 + fields: + - api_code: field_3 + label: 城市 + kind: choice + answered: 40 + null_count: 2 + buckets: + - api_code: bj + label: 北京 + count: 25 + ratio: 0.625 + - api_code: sh + label: 上海 + count: 15 + ratio: 0.375 + - api_code: field_5 + label: 金额 + kind: number + answered: 42 + null_count: 0 + stats: + min: 10 + max: 200 + sum: 1820.5 + avg: 43.35 + truncated: false + omitted_fields: [] + tz: Asia/Shanghai + EntryFormUnavailable: + type: object + description: 未能统计 / 搜索的表单 + required: + - form_token + - unavailable + properties: + form_token: + type: string + description: 请求中的 token 或表单 token + unavailable: + type: string + description: 未能统计 / 搜索的原因 + EntryCountBatchResponse: + type: object + required: + - matched_entries + - forms + properties: + matched_entries: + type: integer + description: 各可统计表单的条数合计 + forms: + type: array + description: 按请求中 token 的顺序(去重后)逐个返回 + items: + oneOf: + - type: object + required: + - form_token + - name + - matched_entries + properties: + form_token: + type: string + name: + type: string + matched_entries: + type: integer + - $ref: '#/components/schemas/EntryFormUnavailable' + examples: + - matched_entries: 130 + forms: + - form_token: abc123 + name: 报名表 + matched_entries: 130 + - form_token: zzz999 + unavailable: Form not found or access denied — check the token, and note it may belong to a different account than the one this credential belongs to. + EntrySearchResponse: + type: object + required: + - total + - forms + properties: + total: + type: integer + description: 各表单匹配条数合计 + forms: + type: array + description: 有匹配的表单,以及未能搜索的表单;没有匹配的表单不列出 + items: + oneOf: + - type: object + required: + - form_token + - name + - matched + - serial_numbers + properties: + form_token: + type: string + name: + type: string + matched: + type: integer + description: 匹配的数据条数 + serial_numbers: + type: array + description: 部分匹配数据的序号,最多 50 个 + items: + type: integer + - $ref: '#/components/schemas/EntryFormUnavailable' + examples: + - total: 3 + forms: + - form_token: abc123 + name: 报名表 + matched: 3 + serial_numbers: + - 12 + - 57 + - 88 + EntryStatsResponse: + type: object + required: + - from + - to + - tz + - total + - forms_with_data + - count + - data + - truncated + properties: + from: + type: string + format: date + to: + type: string + format: date + tz: + type: string + examples: + - Asia/Shanghai + total: + type: integer + description: 范围内全部表单的提交次数合计 + forms_with_data: + type: integer + description: 范围内有提交的表单数 + count: + type: integer + description: "`data` 的行数" + data: + type: array + description: 有提交的表单,按提交次数降序 + items: + $ref: '#/components/schemas/EntryStatsFormRow' + truncated: + type: boolean + description: 有提交的表单多于 `limit`,`data` 只含前 `limit` 个 + buckets: + type: array + description: 只在传 `by` 时出现;范围内每个时间段一项,按时间升序 + items: + $ref: '#/components/schemas/EntryStatsBucket' + examples: + - from: '2026-09-01' + to: '2026-09-07' + tz: Asia/Shanghai + total: 210 + forms_with_data: 2 + count: 2 + data: + - token: abc123 + name: 报名表 + kind: form + fill_count: 180 + - token: def456 + name: 客户台账 + kind: table + fill_count: 30 + truncated: false + EntryStatsFormRow: + type: object + required: + - token + - name + - kind + - fill_count + properties: + token: + type: string + name: + type: string + kind: + type: string + enum: + - form + - table + fill_count: + type: integer + description: 时间段内的提交次数 + EntryStatsBucket: + type: object + description: 一个时间段的提交量统计,结构与总体统计相同 + required: + - from + - to + - total + - forms_with_data + - count + - data + - truncated + properties: + from: + type: string + format: date + description: 时间段起始日(含),首段按范围裁剪 + to: + type: string + format: date + description: 时间段结束日(含),末段按范围裁剪 + total: + type: integer + forms_with_data: + type: integer + count: + type: integer + data: + type: array + description: 该时间段内有提交的表单,按提交次数降序,最多 `limit` 行 + items: + $ref: '#/components/schemas/EntryStatsFormRow' + truncated: + type: boolean + FieldAnalytics: + type: object + description: 字段可做的统计分析,仅在 `include_analytics=true` 时出现在可分析字段上 + required: + - agg_funcs + - summary_kind + - groupable + - buckets + - default_agg + - summarisable + properties: + agg_funcs: + type: array + description: 聚合统计接口的 `metrics` 可对该字段使用的函数 + items: + type: string + summary_kind: + type: string + description: 数据概况中该字段的 `kind` + enum: + - choice + - matrix + - number + - date + - text + - geo + groupable: + type: boolean + description: 能否作为聚合统计的 `dimensions` + buckets: + type: array + description: 作为分组字段时可用的 `bucket`(仅日期字段非空) + items: + type: string + enum: + - day + - week + - month + default_agg: + type: string + description: 建议的默认聚合函数(数字为 `sum`,其他为 `count`) + summarisable: + type: boolean + description: 数据概况是否包含该字段 + MyFilledForm: + type: object + required: + - token + - name + - description + - kind + - scene + - submitted_entries_count + - last_submitted_at + properties: + token: + type: string + description: 表单 token + name: + type: string + description: 表单名称(对外名称) + description: + type: + - string + - 'null' + description: 表单描述(可能是 HTML 富文本) + kind: + type: string + description: 表单类型,`form` 或 `table` + examples: + - form + scene: + type: + - string + - 'null' + description: 表单场景 + submitted_entries_count: + type: integer + description: 当前用户在该表单提交的数据条数 + last_submitted_at: + type: + - string + - 'null' + format: date-time + description: 当前用户最近一次提交的时间(UTC) + MyFilledFormListResponse: + type: object + required: + - total + - count + - data + - next + properties: + total: + type: integer + description: 填写过的表单总数 + count: + type: integer + description: 本页条数 + data: + type: array + items: + $ref: '#/components/schemas/MyFilledForm' + next: + type: + - integer + - 'null' + description: 下一页的行偏移量;末页为 null + MySubmittedEntryListResponse: + type: object + required: + - total + - count + - data + - next + properties: + total: + type: integer + description: 满足条件的本人数据总数 + count: + type: integer + description: 本页条数 + data: + type: array + description: 本人提交的数据,只含表单对填写者公开的字段;值的形态见 `EntryData` + items: + $ref: '#/components/schemas/EntryData' + next: + type: + - integer + - 'null' + description: 下一页的行偏移量;末页为 null + FillPage: + type: object + required: + - kind + - token + - title + - fill_url + - status + - blockers + - fields + properties: + kind: + type: string + enum: + - form + - form_view + description: "`form` 来自 `/f/` 链接,`form_view` 来自 `/v/` 链接(表格的表单视图)" + token: + type: string + description: 链接中的 token + title: + type: string + description: 填写页标题;表单视图为视图标题 + description: + type: + - string + - 'null' + fill_url: + type: string + description: 填写页地址 + status: + type: object + required: + - key + properties: + key: + type: string + description: "`open` 表示当前用户可以提交" + message: + type: + - string + - 'null' + description: 不能提交时给用户看的说明 + open_info: + type: array + description: 未开放或按时间关闭时的开放时间说明 + items: + type: string + blockers: + type: array + description: 只有填写者本人在页面上才能完成的要求;不为空时只能在浏览器中填写 + items: + type: object + required: + - key + - message + properties: + key: + type: string + enum: + - captcha + - sms_verification + - weixin + - ip_or_device_limit + - region_limit + - payment + - portal + - exam + - required_attachment + message: + type: string + fields: + type: array + description: 填写页上的字段,按填写顺序 + items: + $ref: '#/components/schemas/FieldMap' + manageable: + type: object + description: 仅当当前用户本来就能读取背后的表单或表格时返回 + properties: + kind: + type: string + enum: + - form + - table + token: + type: string + name: + type: string + FillEntryRequest: + type: object + required: + - entry + properties: + entry: + type: object + description: 字段 api_code 到值的映射,值的写法同新增数据;填写页上不可见的字段会被忽略 + password: + type: string + description: 填写页设置了访问密码时,由填写者提供的密码 + FillEntryCreated: + type: object + required: + - serial_number + properties: + serial_number: + type: integer + description: 数据序号 + entry_token: + type: string + created_at: + type: string + format: date-time + MySearchFormResult: + type: object + required: + - form_token + properties: + form_token: + type: string + description: 表单 token + name: + type: + - string + - 'null' + description: 表单名称;`unavailable` 时不返回 + matched: + type: integer + description: 该表单中命中的本人数据条数;`unavailable` 时不返回 + serial_numbers: + type: array + maxItems: 50 + description: 命中数据的序号,最多 50 个;`unavailable` 时不返回 + items: + type: integer + unavailable: + type: string + description: 该表单无法搜索的原因;出现时没有 `name` / `matched` / `serial_numbers` + MySearchResponse: + type: object + required: + - total + - forms + properties: + total: + type: integer + description: 所有表单的命中总数 + forms: + type: array + description: 有命中或无法搜索的表单;没有命中的表单不出现 + items: + $ref: '#/components/schemas/MySearchFormResult' + TableFolderMoveResult: + type: object + required: + - table_token + - folder_token + properties: + table_token: + type: string + folder_token: + type: + - string + - 'null' + description: 移动后所在文件夹 token;移出时为 null + FormTransactions: + type: object + description: | + 收款统计,按交易状态分组(`include_transactions=true` 且表单绑定了收款账户时出现)。目前只有 `paid` 一组: + 交易状态为已支付或退款中的数据,按订单总额累计(不扣除退款);已退款与部分退款的数据不计入。这不是净收入报表。 + 金额为 0 不代表不是收款表单。 + required: + - paid + properties: + paid: + type: object + required: + - amount + - count + properties: + amount: + type: number + description: 该组数据的订单总额之和(元) + count: + type: integer + description: 该组数据的条数 + FormDetail: + description: 表单结构,以及按 `include_*` 参数附带的内容 + allOf: + - $ref: '#/components/schemas/Form' + - type: object + properties: + theme: + type: object + description: 表单主题(`include_theme=true` 时出现);结构同 `FormTheme`,但不含 `form_token`。表单没有主题时为空对象 + additionalProperties: true + field_rules: + type: array + description: 字段显示规则(`include_field_rules=true` 时出现),同 `GET /forms/{form_token}/field_rules` 的 `data` + items: + $ref: '#/components/schemas/FieldRule' + extended_attributes: + type: array + description: 数据来源链接(`include_extended_attributes=true` 时出现),同 `GET /forms/{form_token}/extended_attributes` 的 `data` + items: + $ref: '#/components/schemas/ExtendedAttribute' + transactions: + $ref: '#/components/schemas/FormTransactions' + FormViewFillItem: + type: object + description: 表单视图填写页上的一个填写项(生效值) + required: + - api_code + - label + - notes + - required + properties: + api_code: + type: string + description: 列的 api_code + label: + type: + - string + - 'null' + description: 填写页上显示的标题(未单独设置时为列标题) + notes: + type: + - string + - 'null' + description: 填写页上显示的说明(未单独设置时为列说明) + required: + type: boolean + description: 是否必填 + FormViewFieldOverrideInput: + type: object + description: 表单视图的一个填写项配置 + required: + - field_api_code + properties: + field_api_code: + type: string + description: 列的 api_code;列必须存在且可作为填写项,同一请求中不能重复 + label: + type: + - string + - 'null' + description: 填写页上的标题;为空时沿用列标题 + notes: + type: + - string + - 'null' + description: 填写页上的说明;为空时沿用列说明 + private: + type: + - boolean + - 'null' + description: 为 true 时不出现在填写页;不传时沿用列自身设置 + position: + type: + - integer + - 'null' + description: 在填写页中的顺序;不传时按数组顺序 + validations: + type: array + description: 该填写项的校验,目前用 `presence` 表示必填;必须是该列类型支持的校验(如是否列、计算列不能设为必填),否则 422 + items: + type: string + examples: + - presence + additionalProperties: false + EntryImportInput: + type: object + description: POST /forms/{form_token}/entry_imports 的请求体 + required: + - attachment_id + - columns + properties: + attachment_id: + type: string + description: "「上传导入文件」返回的导入附件 `id`" + columns: + type: array + minItems: 1 + description: 列映射;每项把表格中的一列写入一个字段 + items: + $ref: '#/components/schemas/EntryImportColumnInput' + header_row_index: + type: + - integer + - string + - 'null' + pattern: "^[0-9]+$" + description: 表头所在行号(与表格行号一致,从 1 起);缺省为表格第一行,其下方必须还有数据行 + examples: + - 1 + unique_field_code: + type: + - string + - 'null' + description: 去重字段的 api_code;文件中该字段的值已存在于表单数据时跳过这一行 + additionalProperties: false + EntryImportColumnInput: + type: object + required: + - field_api_code + properties: + field_api_code: + type: string + description: 目标字段的 api_code,必须是该表单可导入的字段 + column_label: + type: string + description: 来源列的表头文字,按去掉首尾空格后完全相等匹配;与 `sheet_column_index` 至少给一个 + sheet_column_index: + type: + - integer + - string + pattern: "^[0-9]+$" + description: 来源列的列号(与表格列号一致,从 1 起);表头缺失或重名时使用,优先于 `column_label` + additionalProperties: false + examples: + - field_api_code: field_1 + column_label: 姓名 + - field_api_code: field_2 + sheet_column_index: 3 + EntryImportCreated: + type: object + required: + - job_id + - status + properties: + job_id: + type: string + description: 导入任务 ID,用于「获取数据导入任务」 + status: + type: string + description: 任务状态;刚创建时为 `pending` + enum: + - pending + - running + - success + - failed + - cancelled + EntryImportJob: + type: object + required: + - job_id + - status + - total_rows + - processed_rows + - imported_count + - skip_count + - invalid_count + - warnings + - error_message + - started_at + - finished_at + properties: + job_id: + type: string + status: + type: string + description: "`pending` 排队中、`running` 进行中;`success` / `failed` / `cancelled` 为终态" + enum: + - pending + - running + - success + - failed + - cancelled + total_rows: + type: + - integer + - 'null' + description: 待导入的数据行数;开始执行前为 null + processed_rows: + type: integer + description: 已处理的行数 + imported_count: + type: + - integer + - 'null' + description: 成功写入的行数;完成前可能为 null + skip_count: + type: + - integer + - 'null' + description: 被跳过的行数(如按 `unique_field_code` 去重) + invalid_count: + type: + - integer + - 'null' + description: 未通过字段校验的行数 + warnings: + type: array + description: 逐行提示信息 + items: + type: string + error_message: + type: + - string + - 'null' + description: 任务失败原因 + started_at: + type: + - string + - 'null' + format: date-time + description: 开始执行时间(UTC) + finished_at: + type: + - string + - 'null' + format: date-time + description: 结束时间(UTC) + TimeOfDay: + type: object + required: + - hour + - minute + properties: + hour: + type: integer + minimum: 0 + maximum: 23 + minute: + type: integer + minimum: 0 + maximum: 59 + additionalProperties: false + DailyRepeatCloseRuleInput: + type: + - object + - 'null' + description: | + 每日开放填写时间:表单每天只在这些时间段内接受提交(如 11:00-12:40、17:00-18:30),可与 `by_time_range_close_rule`、`by_entries_close_rule` 同时存在。 + 传 `time_periods` 整体替换现有时间段;传 `{ "enabled": false }` 删除该规则。每个时间段的 `end_time` 不能早于 `start_time`,否则返回 400。 + properties: + enabled: + type: boolean + description: 传 false 删除该规则;传 `time_periods` 时省略 + time_periods: + type: array + description: 每项一个每日开放时间段 + items: + type: object + required: + - start_time + - end_time + properties: + start_time: + $ref: '#/components/schemas/TimeOfDay' + end_time: + $ref: '#/components/schemas/TimeOfDay' + additionalProperties: false + additionalProperties: false + examples: + - time_periods: + - start_time: + hour: 11 + minute: 0 + end_time: + hour: 12 + minute: 40 + - enabled: false + DailyRepeatCloseRule: + type: + - object + - 'null' + description: 每日开放填写时间;未设置时为 null + required: + - time_periods + properties: + time_periods: + type: array + items: + type: object + required: + - start_time + - end_time + properties: + start_time: + type: object + properties: + hour: + type: integer + minute: + type: integer + end_time: + type: object + properties: + hour: + type: integer + minute: + type: integer + TradePageInfo: + type: object + required: + - total + - page + - per_page + - total_pages + properties: + total: + type: integer + description: 匹配的记录总数(不只是本页) + page: + type: integer + description: 当前页码(从 1 开始) + per_page: + type: integer + description: 每页条数 + total_pages: + type: integer + description: 总页数 + TradeQueriedRange: + type: object + description: 实际使用的日期范围(北京时间,两端包含当天)。不限时间查询时两端为 null。 + required: + - start_date + - end_date + properties: + start_date: + type: + - string + - 'null' + format: date + end_date: + type: + - string + - 'null' + format: date + TradeTransaction: + type: object + required: + - trade_no + - merchant_out_trade_no + - out_trade_no + - amount + - paid_status + - paid_status_label + - transaction_fee + - payment_channel + - form_name + - form_token + - ordered_at + - paid_at + properties: + trade_no: + type: + - string + - 'null' + description: 交易单号 + merchant_out_trade_no: + type: + - string + - 'null' + description: 商户订单号 + out_trade_no: + type: + - string + - 'null' + description: 金数据单号 + amount: + type: number + description: 订单金额(元) + paid_status: + type: string + enum: + - paid + - unpaid + - refunded + - refunding + - partial_refunded + description: 支付状态 + paid_status_label: + type: + - string + - 'null' + description: 支付状态的中文名(已支付 / 未支付 / 已退款 / 退款中 / 部分退款) + transaction_fee: + type: + - number + - 'null' + description: 手续费(元) + payment_channel: + type: + - string + - 'null' + enum: + - wxpay + - alipay + - + description: 支付渠道;尚未发起支付时为 null + form_name: + type: + - string + - 'null' + description: 收款表单名称 + form_token: + type: + - string + - 'null' + description: 收款表单 token;表单已不存在时为 null + ordered_at: + type: + - string + - 'null' + format: date-time + description: 下单时间 + paid_at: + type: + - string + - 'null' + format: date-time + description: 支付时间;未支付时为 null + TradeTransactionMetrics: + type: object + required: + - transactions_amount + - transactions_count + - orders_count + properties: + transactions_amount: + type: number + description: 交易金额(元):已支付类订单的金额减去各自的成功退款 + transactions_count: + type: integer + description: 成交量:已支付、部分退款、退款中的订单数 + orders_count: + type: integer + description: 订单量:全部匹配的订单数,含未支付 + TradeTransactionListResponse: + type: object + required: + - page_info + - queried_range + - transactions + properties: + page_info: + $ref: '#/components/schemas/TradePageInfo' + queried_range: + $ref: '#/components/schemas/TradeQueriedRange' + transactions: + type: + - array + - 'null' + description: 本页订单,按下单时间倒序;超出明细上限时为 null + items: + $ref: '#/components/schemas/TradeTransaction' + next: + type: string + description: 下一页的页码,原样回传 `next` 参数即可;没有下一页时不返回 + detail_limit: + type: string + description: 超出明细上限的原因;未超出时不返回 + stats: + $ref: '#/components/schemas/TradeTransactionMetrics' + description: 超出明细上限时给出该范围的统计;未超出时不返回 + examples: + - page_info: + total: 2 + page: 1 + per_page: 50 + total_pages: 1 + queried_range: + start_date: '2026-09-01' + end_date: '2026-09-30' + transactions: + - trade_no: '2026090112345678' + merchant_out_trade_no: MO202609010001 + out_trade_no: 68b5a1c2d3e4f5a6b7c8d9e0 + amount: 99.0 + paid_status: paid + paid_status_label: 已支付 + transaction_fee: 1.48 + payment_channel: wxpay + form_name: 活动报名 + form_token: abc123 + ordered_at: '2026-09-01T02:10:00Z' + paid_at: '2026-09-01T02:10:30Z' + TradeTransactionGroup: + type: object + description: 一个分组的统计。按时间分组时带 `date`;按支付状态分组时带 `paid_status` 与 `label`;按支付渠道分组时带 `payment_channel` 与 `label`。 + required: + - transactions_amount + - transactions_count + - orders_count + properties: + date: + type: string + format: date + description: 时段起始日期:按日为当天,按周为周一,按月为当月 1 日(首个时段为范围起始日) + paid_status: + type: string + enum: + - paid + - unpaid + - refunded + - refunding + - partial_refunded + payment_channel: + type: + - string + - 'null' + enum: + - wxpay + - alipay + - + description: 支付渠道;null 表示尚未发起支付的订单 + label: + type: + - string + - 'null' + description: 分组取值的中文名 + transactions_amount: + type: number + transactions_count: + type: integer + orders_count: + type: integer + TradeTransactionAggregateResponse: + type: object + required: + - queried_range + - transactions_amount + - transactions_count + - orders_count + properties: + queried_range: + $ref: '#/components/schemas/TradeQueriedRange' + transactions_amount: + type: number + description: 交易金额(元):已支付类订单的金额减去各自的成功退款 + transactions_count: + type: integer + description: 成交量:已支付、部分退款、退款中的订单数 + orders_count: + type: integer + description: 订单量:全部匹配的订单数,含未支付 + group_by: + type: string + enum: + - time + - paid_status + - payment_channel + description: 使用的分组维度;未分组时不返回 + granularity: + type: string + enum: + - day + - week + - month + description: 时间分组的粒度;`group_by=time` 时才返回 + groups: + type: array + description: 各分组的统计;未分组时不返回 + items: + $ref: '#/components/schemas/TradeTransactionGroup' + examples: + - queried_range: + start_date: '2026-09-01' + end_date: '2026-09-07' + transactions_amount: 1980.0 + transactions_count: 20 + orders_count: 23 + group_by: payment_channel + groups: + - payment_channel: wxpay + label: 微信 + transactions_amount: 1500.0 + transactions_count: 15 + orders_count: 15 + - payment_channel: alipay + label: 支付宝 + transactions_amount: 480.0 + transactions_count: 5 + orders_count: 5 + - payment_channel: + label: + transactions_amount: 0.0 + transactions_count: 0 + orders_count: 3 + TradeAmountStats: + type: object + required: + - records_count + - amount + - by_status + properties: + records_count: + type: integer + description: 匹配的记录数 + amount: + type: number + description: 金额合计(元) + by_status: + type: array + description: 按状态拆分的条数与金额,只含有记录的状态 + items: + type: object + required: + - status + - records_count + - amount + properties: + status: + type: string + enum: + - processing + - success + - error + records_count: + type: integer + amount: + type: number + description: 该状态的金额合计(元) + TradeRefundStats: + allOf: + - $ref: '#/components/schemas/TradeAmountStats' + - type: object + required: + - refunded_orders_count + properties: + refunded_orders_count: + type: integer + description: 退款成功涉及的订单数,同一订单多次退款只算一个 + granularity: + type: string + enum: + - day + - week + - month + description: 趋势的时段粒度;有日期范围时才返回 + trend: + type: array + description: 按退款发起日期的趋势,无退款的时段补零;有日期范围时才返回 + items: + allOf: + - $ref: '#/components/schemas/TradeAmountStats' + - type: object + required: + - date + - refunded_orders_count + properties: + date: + type: string + format: date + description: 时段起始日期:按日为当天,按周为周一,按月为当月 1 日(首个时段为范围起始日) + refunded_orders_count: + type: integer + description: 该时段内退款成功涉及的订单数(在时段内去重) + TradeRefund: + type: object + required: + - refund_no + - trade_no + - amount + - status + - status_description + - reason + - created_at + - refunded_at + - refund_transaction_fee + - order + properties: + refund_no: + type: + - string + - 'null' + description: 退款流水号 + trade_no: + type: + - string + - 'null' + description: 所退订单的交易单号 + amount: + type: number + description: 退款金额(元) + status: + type: string + enum: + - processing + - success + - error + description: 退款状态:processing 处理中、success 退款成功、error 退款失败 + status_description: + type: + - string + - 'null' + description: 状态说明,退款失败时为失败原因 + reason: + type: + - string + - 'null' + description: 发起退款时填写的原因 + created_at: + type: + - string + - 'null' + format: date-time + description: 退款发起时间 + refunded_at: + type: + - string + - 'null' + format: date-time + description: 退款完成时间;未完成时为 null + refund_transaction_fee: + type: + - number + - 'null' + description: 随退款退回的手续费(元) + order: + description: 所退的订单及其收款表单;找不到关联订单时为 null + anyOf: + - $ref: '#/components/schemas/TradeTransaction' + - type: 'null' + TradeRefundListResponse: + type: object + required: + - page_info + - queried_range + - refunds + properties: + page_info: + $ref: '#/components/schemas/TradePageInfo' + queried_range: + $ref: '#/components/schemas/TradeQueriedRange' + refunds: + type: + - array + - 'null' + description: 本页退款,按发起时间倒序;`include=stats` 或超出明细上限时为 null + items: + $ref: '#/components/schemas/TradeRefund' + stats: + $ref: '#/components/schemas/TradeRefundStats' + description: 全部匹配退款的统计;`include=records` 且未超出明细上限时不返回 + next: + type: string + description: 下一页的页码,原样回传 `next` 参数即可;没有下一页时不返回 + detail_limit: + type: string + description: 超出明细上限的原因;未超出时不返回 + examples: + - page_info: + total: 1 + page: 1 + per_page: 50 + total_pages: 1 + queried_range: + start_date: '2026-09-01' + end_date: '2026-09-30' + refunds: + - refund_no: RF2026090100001 + trade_no: '2026090112345678' + amount: 99.0 + status: success + status_description: + reason: 客户申请退款 + created_at: '2026-09-02T01:00:00Z' + refunded_at: '2026-09-02T01:00:20Z' + refund_transaction_fee: 1.48 + order: + trade_no: '2026090112345678' + merchant_out_trade_no: MO202609010001 + out_trade_no: 68b5a1c2d3e4f5a6b7c8d9e0 + amount: 99.0 + paid_status: refunded + paid_status_label: 已退款 + transaction_fee: 1.48 + payment_channel: wxpay + form_name: 活动报名 + form_token: abc123 + ordered_at: '2026-09-01T02:10:00Z' + paid_at: '2026-09-01T02:10:30Z' + stats: + records_count: 1 + amount: 99.0 + by_status: + - status: success + records_count: 1 + amount: 99.0 + refunded_orders_count: 1 + granularity: day + trend: + - date: '2026-09-01' + records_count: 0 + amount: 0.0 + by_status: [] + refunded_orders_count: 0 + - date: '2026-09-02' + records_count: 1 + amount: 99.0 + by_status: + - status: success + records_count: 1 + amount: 99.0 + refunded_orders_count: 1 + TradePayout: + type: object + required: + - amount + - status + - status_description + - created_at + - settlement_start_date + - settlement_end_date + - bank_name + - bank_card_no + - payout_notes + - operation_notes + - creator_name + properties: + amount: + type: + - number + - 'null' + description: 提现金额(元) + status: + type: string + enum: + - processing + - success + - error + description: 提现状态:processing 处理中、success 提现成功、error 提现失败 + status_description: + type: + - string + - 'null' + description: 状态说明,提现失败时为失败原因 + created_at: + type: + - string + - 'null' + format: date-time + description: 申请时间 + settlement_start_date: + type: + - string + - 'null' + format: date + description: 本次提现覆盖的结算起始日期 + settlement_end_date: + type: + - string + - 'null' + format: date + description: 本次提现覆盖的结算结束日期 + bank_name: + type: + - string + - 'null' + description: 收款银行 + bank_card_no: + type: + - string + - 'null' + description: 收款银行卡号(打码) + payout_notes: + type: + - string + - 'null' + description: 提现附言 + operation_notes: + type: + - string + - 'null' + description: 操作备注 + creator_name: + type: + - string + - 'null' + description: 发起人姓名;无姓名时为其手机号或邮箱 + TradePayoutListResponse: + type: object + required: + - page_info + - queried_range + - payouts + properties: + page_info: + $ref: '#/components/schemas/TradePageInfo' + queried_range: + $ref: '#/components/schemas/TradeQueriedRange' + payouts: + type: + - array + - 'null' + description: 本页提现,按申请时间倒序;`include=stats` 或超出明细上限时为 null + items: + $ref: '#/components/schemas/TradePayout' + stats: + $ref: '#/components/schemas/TradeAmountStats' + description: 全部匹配提现的统计;`include=records` 且未超出明细上限时不返回 + next: + type: string + description: 下一页的页码,原样回传 `next` 参数即可;没有下一页时不返回 + detail_limit: + type: string + description: 超出明细上限的原因;未超出时不返回 + examples: + - page_info: + total: 1 + page: 1 + per_page: 50 + total_pages: 1 + queried_range: + start_date: '2026-07-03' + end_date: '2026-09-30' + payouts: + - amount: 1500.0 + status: success + status_description: + created_at: '2026-09-10T03:00:00Z' + settlement_start_date: '2026-09-01' + settlement_end_date: '2026-09-09' + bank_name: 招商银行 + bank_card_no: 6225********1234 + payout_notes: 9 月上旬结算 + operation_notes: + creator_name: 张三 + stats: + records_count: 1 + amount: 1500.0 + by_status: + - status: success + records_count: 1 + amount: 1500.0 + TradeMerchant: + type: object + required: + - merchant + - subject + - contact + - balance + - settlement + - channels + - freeze + - applied_at + - merchant_stage + - payment_form_tokens + - transactions_amount + - rejected_reason + - guidance + properties: + merchant: + type: + - object + - 'null' + description: 商户资料;没有小金商户时为 null + required: + - merchant_no + - merchant_name + - merchant_type + - created_at + - industry + - address + - avatar_url + properties: + merchant_no: + type: + - string + - 'null' + description: 商户号 + merchant_name: + type: + - string + - 'null' + description: 商户名称 + merchant_type: + type: string + enum: + - business + - micro + description: 商户类型:business 企业、micro 小微 + created_at: + type: + - string + - 'null' + format: date-time + description: 商户开通时间 + industry: + type: + - string + - 'null' + description: 经营类目 + address: + type: string + description: 经营地址(地区与详细地址拼接) + avatar_url: + type: + - string + - 'null' + description: 商户头像地址 + subject: + type: + - object + - 'null' + description: 主体信息;没有小金商户时为 null + required: + - subject_type + - subject_name + - license_no + - license_image_url + - legal_name + - legal_licence_no + properties: + subject_type: + type: + - string + - 'null' + description: 主体类型代码,如 `ENTERPRISE`(企业)、`INDIVIDUAL`(个体工商户)、`INSTITUTION`(事业单位 / 政府机关)、`COMMUNITY`(社会组织)、`PEOPLE_RUN_NON_ENTERPRISE`(民办非企业)、`PERSON`(个人,小微商户) + subject_name: + type: + - string + - 'null' + description: 主体名称 + license_no: + type: + - string + - 'null' + description: 营业执照编号 + license_image_url: + type: + - string + - 'null' + description: 营业执照图片地址 + legal_name: + type: + - string + - 'null' + description: 法人姓名 + legal_licence_no: + type: + - string + - 'null' + description: 法人证件号(打码) + contact: + type: + - object + - 'null' + description: 联系信息;没有小金商户时为 null + required: + - customer_service_phone + - customer_service_phone_under_review + - manager_name + - manager_email + - manager_phone + properties: + customer_service_phone: + type: + - string + - 'null' + description: 客服电话(打码) + customer_service_phone_under_review: + type: boolean + description: 客服电话的变更申请是否仍在审核中 + manager_name: + type: + - string + - 'null' + description: 联系人姓名 + manager_email: + type: + - string + - 'null' + description: 联系人邮箱(打码) + manager_phone: + type: + - string + - 'null' + description: 联系人手机号(打码) + balance: + type: + - object + - 'null' + description: 余额;没有小金商户或余额查询失败时为 null(查询失败不代表余额为 0) + required: + - settleable_amount + - unsettled_amount + properties: + settleable_amount: + type: + - number + - 'null' + description: 可提现金额(元) + unsettled_amount: + type: + - number + - 'null' + description: 待结算金额(元) + settlement: + type: + - object + - 'null' + description: 结算信息;没有小金商户时为 null + required: + - fee_rate + - method + - bank_name + - bank_account_name + - bank_card_no + - latest_settleable_date + properties: + fee_rate: + type: number + description: 手续费率,百分数(1.49 表示 1.49%) + method: + type: string + description: 结算方式,如 `D+1 自助结算` + bank_name: + type: + - string + - 'null' + description: 结算银行 + bank_account_name: + type: + - string + - 'null' + description: 结算账户名(即主体名称) + bank_card_no: + type: + - string + - 'null' + description: 结算银行卡号(打码) + latest_settleable_date: + type: + - string + - 'null' + format: date + description: 当前提现最晚可覆盖到的结算日期 + channels: + type: array + description: 商户开通的支付渠道及授权情况;没有小金商户时为空数组 + items: + type: object + required: + - channel + - authorized + - authorize_state + - channel_merchant_no + properties: + channel: + type: + - string + - 'null' + enum: + - wxpay + - alipay + - + description: 支付渠道 + authorized: + type: boolean + description: 渠道是否已授权;未授权的微信渠道收不到微信支付的款 + authorize_state: + type: + - string + - 'null' + enum: + - AUTHORIZE_STATE_UNAUTHORIZED + - AUTHORIZE_STATE_AUTHORIZED + - CLOSED + - SMID_NOT_EXIST + - + description: 渠道授权状态代码 + channel_merchant_no: + type: + - string + - 'null' + description: 渠道商户号 + freeze: + type: + - object + - 'null' + description: 冻结状态;没有小金商户时为 null + required: + - pay_frozen + - pay_frozen_reason + - payout_frozen + - payout_frozen_reason + - yeepay_status + - yeepay_status_reasons + properties: + pay_frozen: + type: boolean + description: 收款是否被冻结 + pay_frozen_reason: + type: + - string + - 'null' + description: 收款冻结 / 解冻的说明 + payout_frozen: + type: boolean + description: 提现是否被冻结 + payout_frozen_reason: + type: + - string + - 'null' + description: 提现冻结 / 解冻的说明 + yeepay_status: + type: + - string + - 'null' + enum: + - ACTIVE + - FROZEN + - OPERATION_EXCEPTION + - + description: 支付服务商侧的商户状态:ACTIVE 正常、FROZEN 冻结、OPERATION_EXCEPTION 经营异常 + yeepay_status_reasons: + type: array + description: 支付服务商给出的状态原因代码(如 `SILENT_MERCHANT` 沉默商户) + items: + type: string + applied_at: + type: + - string + - 'null' + format: date-time + description: 入驻申请的提交时间;没有申请时为 null + merchant_stage: + type: string + enum: + - no_application + - processing + - rejected + - pay_frozen + - silent + - approved_no_form + - has_form_no_income + - payout_frozen + - receiving + description: | + 入驻进度:`no_application` 尚未申请;`processing` 已提交申请、审核中(商户尚未开通);`rejected` 申请被驳回(见 `rejected_reason`); + `pay_frozen` 收款被冻结;`silent` 被支付服务商判定为沉默商户、经营异常,完成解冻前不能收款;`approved_no_form` 商户已开通但还没有绑定收款表单; + `has_form_no_income` 已绑定收款表单但还没有成功收款;`payout_frozen` 可以收款但提现被冻结;`receiving` 正常收款中。 + payment_form_tokens: + type: array + description: 绑定到该商户、且当前身份可访问的收款表单 token(含快速支付) + items: + type: string + transactions_amount: + type: number + description: 累计交易金额(元):全部时间内已支付类订单的金额减去成功退款。按日期范围或分组统计请用 `GET /trade/transactions/aggregate` + rejected_reason: + type: + - string + - 'null' + description: 入驻申请被驳回的原因;`merchant_stage` 为 `rejected` 时才有值 + guidance: + type: object + description: 下一步操作的入口地址,不适用时为 null + required: + - register_url + - elec_sign_url + - wechat_verify_qr_url + properties: + register_url: + type: + - string + - 'null' + format: uri + description: 申请入驻 / 驳回后重新提交的页面地址;`merchant_stage` 为 `no_application` 或 `rejected` 时才有值 + elec_sign_url: + type: + - string + - 'null' + description: 电子签约地址;申请已通过审核、企业主体等待签约时才有值 + wechat_verify_qr_url: + type: + - string + - 'null' + description: 微信实名认证二维码地址;渠道已登记、等待微信认证时才有值 + examples: + - merchant: + subject: + contact: + balance: + settlement: + channels: [] + freeze: + applied_at: + merchant_stage: no_application + payment_form_tokens: [] + transactions_amount: 0.0 + rejected_reason: + guidance: + register_url: https://jinshuju.net/trade?openRegister=true + elec_sign_url: + wechat_verify_qr_url: + SMSAuditStatusResponse: + type: object + required: + - signatures + properties: + signatures: + type: array + description: 当前用户的短信签名,按创建时间倒序 + items: + $ref: '#/components/schemas/SMSSignatureAuditStatus' + examples: + - signatures: + - signature: 金数据 + status: SUCCESS + reason: + templates: + - content: 您的验证码是#code#,5 分钟内有效。 + status: SUCCESS + reason: + - content: 您报名的活动将于明天开始,请准时参加。 + status: FAIL + reason: 模板内容缺少签名对应的主体信息 + SMSSignatureAuditStatus: + type: object + required: + - signature + - status + - reason + - templates + properties: + signature: + type: string + description: 签名内容 + status: + type: string + enum: + - CHECKING + - SUCCESS + - FAIL + description: 审核状态:CHECKING 审核中、SUCCESS 已通过、FAIL 未通过 + reason: + type: + - string + - 'null' + description: 审核说明,未通过时为原因 + templates: + type: array + description: 该签名下的短信模板 + items: + $ref: '#/components/schemas/SMSTemplateAuditStatus' + SMSTemplateAuditStatus: + type: object + required: + - content + - status + - reason + properties: + content: + type: + - string + - 'null' + description: 模板内容 + status: + type: + - string + - 'null' + enum: + - CHECKING + - SUCCESS + - FAIL + - + description: 审核状态:CHECKING 审核中、SUCCESS 已通过、FAIL 未通过;尚未提交审核时为 null + reason: + type: + - string + - 'null' + description: 审核说明,未通过时为原因 + BillingAccountInvoice: + type: object + required: + - created_at + - amount + - organization + - dedicated_invoice + properties: + created_at: + type: string + format: date-time + description: 申请开票时间,ISO 8601,带时区偏移(北京时间,如 `2024-03-08T14:12:41+08:00`) + amount: + type: + - number + - 'null' + description: 开票金额(元,保留两位小数) + organization: + type: + - string + - 'null' + description: 发票抬头(单位名称);抬头已删除时为 null + dedicated_invoice: + type: boolean + description: 是否增值税专用发票 + examples: + - created_at: '2024-03-08T14:12:41+08:00' + amount: 3600.0 + organization: 某某科技有限公司 + dedicated_invoice: false + BillingAccountInvoicesResponse: + type: object + required: + - invoices + - uninvoiced_amount + properties: + invoices: + type: object + required: + - count + - records + properties: + count: + type: integer + description: 发票数量 + records: + type: array + description: 按申请时间倒序 + items: + $ref: '#/components/schemas/BillingAccountInvoice' + uninvoiced_amount: + type: object + description: 可开票但尚未开票的消费 + required: + - total + - transaction_count + properties: + total: + type: number + description: 未开票金额合计(元,保留两位小数) + transaction_count: + type: integer + description: 未开票的消费笔数 + BillingAccountPaymentHistory: + type: object + required: + - id + - created_at + - verb + - description + - amount + - balance + properties: + id: + type: string + description: 记录 ID + created_at: + type: string + format: date-time + description: 发生时间,ISO 8601,带时区偏移(北京时间,如 `2024-03-13T17:20:11+08:00`) + verb: + type: string + description: 记录类型,取值见 `verb` 参数 + description: + type: + - string + - 'null' + description: 记录说明,按记录类型生成(如所购套餐名称、短信条数、成员数量),没有说明时为 null + amount: + type: + - number + - 'null' + description: 金额(元,保留两位小数),充值为正、消费为负 + balance: + type: + - number + - 'null' + description: 该笔发生后的账户余额(元,保留两位小数) + examples: + - id: 65f1c2a3b4c5d6e7f8a9b0c1 + created_at: '2024-03-13T17:20:11+08:00' + verb: sms_charge + description: 购买短信 + amount: -100.0 + balance: 900.0 + BillingAccountBalance: + type: object + description: 账户余额与各项配额的剩余量(套餐内 + 已购) + required: + - amount + - entry_quota + - sms_count + - ai_points_count + - active_mail_count + - storage_quota_count + - audio_quota_count + - entry_transaction_quota_count + properties: + amount: + type: + - number + - 'null' + description: 账户余额(元,保留两位小数) + entry_quota: + type: integer + description: 本月剩余数据条数 + sms_count: + type: integer + description: 剩余短信条数 + ai_points_count: + type: integer + description: 剩余 AI 点数 + active_mail_count: + type: integer + description: 剩余邮件条数 + storage_quota_count: + type: number + description: 剩余附件空间(MB,可能带小数) + audio_quota_count: + type: integer + description: 剩余语音时长(分钟,按秒数四舍五入) + entry_transaction_quota_count: + type: + - number + - string + description: 剩余收款额度(元,保留两位小数);套餐不限额时为字符串 `无限额` + examples: + - amount: 900.0 + entry_quota: 9800 + sms_count: 1200 + ai_points_count: 500 + active_mail_count: 300 + storage_quota_count: 2048 + audio_quota_count: 60 + entry_transaction_quota_count: 无限额 + BillingAccountPaymentStatistics: + type: object + description: 对本次返回记录的汇总 + required: + - total_consumption + - total_recharge + - verb_distribution + properties: + total_consumption: + type: number + description: 消费合计(元,正数) + total_recharge: + type: number + description: 充值合计(元) + verb_distribution: + type: object + description: 各类型的记录数,键为 `verb` + additionalProperties: + type: integer + examples: + - total_consumption: 200.0 + total_recharge: 1000.0 + verb_distribution: + charge: 1 + sms_charge: 2 + BillingAccountPaymentHistoriesResponse: + type: object + required: + - payment_history + properties: + payment_history: + type: object + required: + - total_records + - returned_records + - records + properties: + total_records: + type: integer + description: 满足条件的记录总数 + returned_records: + type: integer + description: 本次返回的数量 + records: + type: array + description: 按发生时间倒序 + items: + $ref: '#/components/schemas/BillingAccountPaymentHistory' + current_balance: + $ref: '#/components/schemas/BillingAccountBalance' + description: 只在 `include_balance=true` 时出现 + statistics: + $ref: '#/components/schemas/BillingAccountPaymentStatistics' + description: 只在本次有返回记录时出现 + RelationFieldInput: + type: object + title: RelationFieldInput + description: RelationField 字段 + additionalProperties: false + required: + - type + - label + properties: + type: + const: RelationField + label: + type: string + description: 字段标题 + cid: + type: string + description: 本次请求内的临时标识,供公式等引用 + notes: + type: string + description: 字段说明 + private: + type: boolean + description: 隐藏字段;隐藏字段不加必填校验 + required: + type: boolean + description: 必填 + position: + type: integer + minimum: 0 + description: 字段在表单中的目标位置(0 起);缺省追加到末尾 + related_form_token: + type: string + description: 关联的表格 token;只能是另一张表格,且需要它的管理权限 + paired: + type: boolean + description: 双向关联,缺省开启;开启时在关联的表格中自动创建反向列 + related_label: + type: string + description: 反向列名称,只在创建反向列时使用,缺省取本表格名称 diff --git a/src/commands.test.ts b/src/commands.test.ts index 8fecc95..dbc07fc 100644 --- a/src/commands.test.ts +++ b/src/commands.test.ts @@ -1,4 +1,4 @@ -import test from 'node:test'; +import test, { after } from 'node:test'; import assert from 'node:assert/strict'; import { existsSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; @@ -8,6 +8,8 @@ import { join } from 'node:path'; import { runCli, type CliRuntime } from './cli.js'; import { awaitImport } from './commands/entry.js'; import { HttpError, TransportError, withQuery, type HttpRequest } from './http.js'; +import { unansweredLookups } from './spec.js'; +import { nonconformities } from './testing/conformance.js'; /** What the client would have put on the wire: the path with its query. */ const url = (request: HttpRequest): string => withQuery(request.path, request.query); @@ -24,13 +26,32 @@ function cli(args: string[], runtime: CliRuntime = {}) { return runCli([...args, '--config', NO_CONFIG], runtime); } -function createMockClient() { +/** + * What the suite sent that openapi.yaml would not take. `call` checks every + * request it builds; this catches one that was built some other way — a path + * written out by hand, a body added after — and so never was. + */ +const nonconforming: string[] = []; + +function conform(request: HttpRequest): void { + nonconforming.push(...nonconformities(request)); +} + +after(() => assert.deepEqual(nonconforming, [], 'requests openapi.yaml would refuse')); + +/** + * A client that answers every request, and keeps it. `lenient` is for the tests + * whose point is a request this copy of the spec does not describe, sent anyway + * because the API may have learned it since. + */ +function createMockClient({ lenient = false }: { lenient?: boolean } = {}) { const requests: HttpRequest[] = []; return { requests, client: { async request(request: HttpRequest): Promise { requests.push(request); + if (!lenient) conform(request); return { ok: true, request } as T; } } @@ -62,8 +83,10 @@ test('form create posts API v1 payload without injecting api_code', async () => test('form and table create check the shape of the payload, and leave the type vocabulary to the server', async () => { const env = { JINSHUJU_ACCESS_TOKEN: 'tok' }; - // A type this CLI has never heard of goes through: the server knows the list. - const unknownType = createMockClient(); + // A type this CLI's copy of the spec does not list goes through: the server + // may have learned it since this CLI was installed, and `field types`, which + // asks the server, would already be listing it. + const unknownType = createMockClient({ lenient: true }); const sent = await cli(['form', 'create', '--json', '{"name":"x","fields":[{"type":"HologramField","label":"h"}]}'], { env, client: unknownType.client @@ -409,7 +432,7 @@ test('account me asks who is signed in, not what the account holds', async () => assert.equal(url(mock.requests[0]), '/api/v1/me'); }); -test('merchant, transaction, refund and payout ask the 小金商户 endpoints, a list of statuses as one parameter each', async () => { +test('merchant, transaction, refund and payout ask the 小金商户 endpoints, a list of statuses comma-joined as the spec spells it', async () => { const mock = createMockClient(); const env = { JINSHUJU_API_KEY: 'key', JINSHUJU_API_SECRET: 'secret' }; @@ -440,7 +463,7 @@ test('merchant, transaction, refund and payout ask the 小金商户 endpoints, a assert.deepEqual(mock.requests.map(url), [ '/api/v1/trade/merchant', '/api/v1/trade/transactions?start_date=2026-09-01&end_date=2026-09-28' + - '&paid_status%5B%5D=refunded&paid_status%5B%5D=partial_refunded&form_token=Kp7mQ2&limit=20', + '&paid_status=refunded%2Cpartial_refunded&form_token=Kp7mQ2&limit=20', '/api/v1/trade/transactions/aggregate?group_by=payment_channel', '/api/v1/trade/refunds?status=success&include=stats', '/api/v1/trade/payouts?next=2' @@ -845,7 +868,7 @@ test('field verbs all ride on the container PATCH, each in its own operation', a env: WRITE_ENV, client: update.client }); - await cli(['field', 'update-choices', '--form', 'Kp7mQ2', 'field_7', '--json', '{"add":[{"label":"丙"}]}'], { + await cli(['field', 'update-choices', '--form', 'Kp7mQ2', 'field_7', '--json', '{"add":[{"name":"丙"}]}'], { env: WRITE_ENV, client: choices.client }); @@ -857,7 +880,7 @@ test('field verbs all ride on the container PATCH, each in its own operation', a assert.equal(update.requests[0].path, '/api/v1/tables/Vn4xR8'); assert.deepEqual(update.requests[0].body, { fields: { update: [{ required: true, api_code: 'field_3' }] } }); assert.deepEqual(choices.requests[0].body, { - fields: { update_choices: [{ add: [{ label: '丙' }], field_api_code: 'field_7' }] } + fields: { update_choices: [{ add: [{ name: '丙' }], field_api_code: 'field_7' }] } }); assert.deepEqual(remove.requests[0].body, { fields: { remove: ['field_9'] } }); }); @@ -1032,16 +1055,29 @@ test('table move and --with-default-entries reach their own endpoints', async () await cli(['table', 'move', 'Vn4xR8', '--folder', 'Nf7mDC'], { env: WRITE_ENV, client: moved.client }); await cli(['table', 'move', 'Vn4xR8'], { env: WRITE_ENV, client: rooted.client }); - await cli(['table', 'create', '--json', '{"name":"台账","fields":[]}', '--with-default-entries'], { - env: WRITE_ENV, - client: seeded.client - }); + await cli( + [ + 'table', + 'create', + '--json', + '{"name":"台账","fields":[{"type":"TextArea","label":"事项"}]}', + '--with-default-entries' + ], + { + env: WRITE_ENV, + client: seeded.client + } + ); assert.equal(moved.requests[0].method, 'PATCH'); assert.equal(moved.requests[0].path, '/api/v1/tables/Vn4xR8/folder'); assert.deepEqual(moved.requests[0].body, { folder_token: 'Nf7mDC' }); assert.deepEqual(rooted.requests[0].body, { folder_token: '' }); - assert.deepEqual(seeded.requests[0].body, { name: '台账', fields: [], with_default_entries: true }); + assert.deepEqual(seeded.requests[0].body, { + name: '台账', + fields: [{ type: 'TextArea', label: '事项' }], + with_default_entries: true + }); }); test('form get --include names the blocks, and setting is honoured by already being there', async () => { @@ -1221,6 +1257,7 @@ function uploadingClient(responses: Record) { client: { async request(request: HttpRequest): Promise { requests.push(request); + conform(request); const key = Object.keys(responses).find((path) => request.path.startsWith(path)); return (key ? responses[key] : { ok: true }) as T; } @@ -1458,15 +1495,15 @@ test('a settings block travels to its own endpoint, on create and on edit', asyn '--type', 'exam', '--json', - '{"name":"考试","fields":[{"type":"TextField","label":"姓名"}],"exam_setting":{"total_score":100}}' + '{"name":"考试","fields":[{"type":"TextField","label":"姓名"}],"exam_setting":{"limited_time":30}}' ], { env: WRITE_ENV, client: created.client } ); - await cli(['form', 'edit', 'Kp7mQ2', '--json', '{"exam_setting":{"total_score":90}}'], { + await cli(['form', 'edit', 'Kp7mQ2', '--json', '{"exam_setting":{"limited_time":45}}'], { env: WRITE_ENV, client: edited.client }); - await cli(['form', 'edit', 'Kp7mQ2', '--json', '{"name":"改名","exam_setting":{"total_score":90}}'], { + await cli(['form', 'edit', 'Kp7mQ2', '--json', '{"name":"改名","exam_setting":{"limited_time":45}}'], { env: WRITE_ENV, client: editedBoth.client }); @@ -1475,7 +1512,7 @@ test('a settings block travels to its own endpoint, on create and on edit', asyn assert.equal(created.requests[0].path, '/api/v1/forms'); assert.equal((created.requests[0].body as Record).exam_setting, undefined); assert.equal(created.requests[1].path, '/api/v1/forms/Kp7mQ2/exam_setting'); - assert.deepEqual(created.requests[1].body, { total_score: 100 }); + assert.deepEqual(created.requests[1].body, { limited_time: 30 }); // A block on its own never touches the generic update, which would drop it. assert.equal(edited.requests.length, 2); @@ -2349,3 +2386,154 @@ test('fill submit without --json is a usage error', async () => { assert.equal(result.exitCode, 2); assert.equal(mock.requests.length, 0); }); + +test('a flag combination the operation behind it cannot take is refused, saying why', async () => { + const env = { JINSHUJU_ACCESS_TOKEN: 'tok' }; + const mock = createMockClient(); + + const viewLimit = await cli(['entry', 'list', '--form', 'Kp7mQ2', '--view', 'aB3dE9', '--limit', '10'], { + env, + client: mock.client + }); + assert.equal(viewLimit.exitCode, 2); + assert.match(viewLimit.stderr, /--limit cannot be combined with --view/); + + const severalKeyword = await cli(['entry', 'count', '--form', 'Kp7mQ2', '--form', 'Vn4xR8', '--keyword', '张三'], { + env, + client: mock.client + }); + assert.equal(severalKeyword.exitCode, 2); + assert.match(severalKeyword.stderr, /--keyword .* counts one container/); + + const notSerial = await cli(['entry', 'get', '--form', 'Kp7mQ2', 'twelve'], { env, client: mock.client }); + assert.equal(notSerial.exitCode, 2); + assert.match(notSerial.stderr, /serial_number must be an integer/); + + assert.equal(mock.requests.length, 0); +}); + +test('what the API may have learned since this CLI was installed is sent, for the server to judge', async () => { + const env = { JINSHUJU_ACCESS_TOKEN: 'tok' }; + const mock = createMockClient({ lenient: true }); + + // Every CLI already installed keeps the spec it shipped with. A key, a field + // type, a longer list or a higher limit the API learns later must still get + // through; the API judges the body against the spec it runs, and its 400 + // says what is wrong just as precisely. + const sent = await Promise.all([ + cli(['form', 'create', '--json', '{"name":"x","fields":[{"type":"TextField","label":"a","future_option":true}]}'], { + env, + client: mock.client + }), + cli(['folder', 'list', '--limit', '500'], { env, client: mock.client }), + cli(['table', 'create', '--json', '{"name":"t","fields":[]}'], { env, client: mock.client }), + cli(['field', 'add', '--form', 'Kp7mQ2', '--json', '{"type":"TextField"}'], { env, client: mock.client }), + cli(['comment', 'create', '--form', 'Kp7mQ2', '--entry', '12', 'x'.repeat(2001)], { env, client: mock.client }) + ]); + + assert.deepEqual( + sent.map((result) => result.exitCode), + [0, 0, 0, 0, 0] + ); + assert.equal(mock.requests.length, 5); +}); + +test('every list of values a flag reads off the spec is there', () => { + // A lookup that comes back empty leaves its flag open rather than crashing + // every command as it loads; this is where a spec that dropped one fails. + assert.deepEqual(unansweredLookups(), []); +}); + +test('the values a flag takes are the ones openapi.yaml lists', async () => { + const env = { JINSHUJU_ACCESS_TOKEN: 'tok' }; + const mock = createMockClient(); + + // Each of these was missing from the copy the CLI used to keep. + await cli(['view', 'create', '--form', 'Kp7mQ2', '填写页', '--type', 'form'], { env, client: mock.client }); + await cli( + [ + 'form', + 'create', + '--json', + '{"name":"收款","fields":[{"type":"TextField","label":"姓名"}]}', + '--scene', + 'quickpay' + ], + { + env, + client: mock.client + } + ); + + assert.deepEqual( + mock.requests.map((request) => request.body), + [ + { view_type: 'form', name: '填写页' }, + { name: '收款', fields: [{ type: 'TextField', label: '姓名' }], scene: 'quickpay' } + ] + ); + const help = await cli(['table', 'create', '--help']); + assert.match(help.stdout, /RelationField/); +}); + +/** + * An example is what a caller copies, and an agent copies it exactly. One that + * the API refuses — an exam setting it has no key for — teaches the refusal. + */ +test('every example in the help sends requests openapi.yaml takes', async () => { + const { COMMANDS } = await import('./commands/index.js'); + // A file the example names is one this checkout does not have. + const readsAFile = (line: string) => /[\s=](@|\.\/)|\scat\s|\|/.test(line); + const answers = { + token: 'Kp7mQ2', + id: 'att_1', + attachment_id: 'img_1', + job_id: 'job_1', + status: 'success', + data: [] + }; + + let ran = 0; + for (const command of COMMANDS) { + if (command.path[0] === 'auth' || command.path[0] === 'config') continue; + for (const example of command.examples ?? []) { + if (readsAFile(example)) continue; + const words = shellWords(example).slice(1); + const mock = createMockClient(); + const answering = { + async request(request: HttpRequest): Promise { + await mock.client.request(request); + return answers as T; + } + }; + const result = await cli([...words, '--output', 'json'], { env: WRITE_ENV, client: answering }); + assert.equal(result.exitCode, 0, `${example}\n${result.stderr}`); + assert.ok(mock.requests.length > 0, example); + ran += 1; + } + } + assert.ok(ran > 30, `only ${ran} examples ran`); +}); + +/** A command line split the way a shell splits it, as far as the examples quote. */ +function shellWords(line: string): string[] { + const words: string[] = []; + let word: string | undefined; + let quote: string | undefined; + for (const char of line) { + if (quote) { + if (char === quote) quote = undefined; + else word = (word ?? '') + char; + } else if (char === "'" || char === '"') { + quote = char; + word ??= ''; + } else if (/\s/.test(char)) { + if (word !== undefined) words.push(word); + word = undefined; + } else { + word = (word ?? '') + char; + } + } + if (word !== undefined) words.push(word); + return words; +} diff --git a/src/commands/account.ts b/src/commands/account.ts index dec0478..c07fb2d 100644 --- a/src/commands/account.ts +++ b/src/commands/account.ts @@ -1,29 +1,30 @@ import { LIMIT_OPTION } from '../options.js'; -import { API, paging } from './shared.js'; +import { call } from '../spec.js'; +import { paging } from './shared.js'; import type { Command } from './types.js'; export const ACCOUNT: readonly Command[] = [ { path: ['account', 'get'], summary: 'Show the current account, plan and quota', - request: () => ({ method: 'GET', path: `${API}/billing_account` }) + request: () => call('getBillingAccount') }, { path: ['account', 'me'], summary: 'Show who is signed in: name, email, mobile and role in the account', - request: () => ({ method: 'GET', path: `${API}/me` }) + request: () => call('getCurrentUser') }, { path: ['account', 'member', 'list'], summary: 'List account members', options: [LIMIT_OPTION], - request: (input) => ({ method: 'GET', path: `${API}/billing_account/users`, query: paging(input) }) + request: (input) => call('listBillingAccountUsers', { query: paging(input) }) }, { path: ['account', 'invoice', 'list'], summary: 'List e-invoices, and the amount not invoiced yet', description: 'Account admins only, as in the web app; a member is refused with 403.', - request: () => ({ method: 'GET', path: `${API}/billing_account/invoices` }) + request: () => call('listBillingAccountInvoices') }, { path: ['account', 'payment', 'list'], @@ -43,16 +44,15 @@ export const ACCOUNT: readonly Command[] = [ { ...LIMIT_OPTION, description: 'Records to return (default 50, up to 1000)' }, { name: '--with-balance', type: 'boolean', description: 'Also return the current balance and quotas' } ], - request: (input) => ({ - method: 'GET', - path: `${API}/billing_account/payment_histories`, - query: { - start_date: input.options.from as string | undefined, - end_date: input.options.to as string | undefined, - verb: input.options.verb as string | undefined, - limit: input.options.limit === undefined ? undefined : String(input.options.limit), - include_balance: input.options.with_balance ? 'true' : undefined - } - }) + request: (input) => + call('listBillingAccountPaymentHistories', { + query: { + start_date: input.options.from as string | undefined, + end_date: input.options.to as string | undefined, + verb: input.options.verb as string | undefined, + limit: input.options.limit as number | undefined, + include_balance: input.options.with_balance ? true : undefined + } + }) } ]; diff --git a/src/commands/comment.ts b/src/commands/comment.ts index f8a0698..3a703c5 100644 --- a/src/commands/comment.ts +++ b/src/commands/comment.ts @@ -1,5 +1,7 @@ +import type { HttpRequest } from '../http.js'; import { CONTAINER_OPTIONS, UsageError, type OptionSpec } from '../options.js'; -import { YES_OPTION, confirmed, containerPath, given } from './shared.js'; +import type { CallParts, OperationId } from '../spec.js'; +import { YES_OPTION, confirmed, given, onContainer } from './shared.js'; import type { Command, CommandInput } from './types.js'; /** A comment lives under its entry, so every verb needs the entry as well. */ @@ -10,10 +12,10 @@ const ENTRY_OPTION: OptionSpec = { description: 'Entry serial number' }; -function commentsPath(input: CommandInput): string { +function onEntry(input: CommandInput, form: OperationId, table: OperationId, parts: CallParts = {}): HttpRequest { const serial = input.options.entry as string | undefined; if (!serial) throw new UsageError('--entry is required'); - return `${containerPath(input)}/entries/${serial}/comments`; + return onContainer(input, form, table, { ...parts, path: { ...parts.path, serial_number: serial } }); } export const COMMENT: readonly Command[] = [ @@ -24,7 +26,7 @@ export const COMMENT: readonly Command[] = [ ...CONTAINER_OPTIONS, { name: '--entry', type: 'string', placeholder: '', description: 'Entry serial number' } ], - request: (input) => ({ method: 'GET', path: commentsPath(input) }) + request: (input) => onEntry(input, 'listEntryComments', 'listTableEntryComments') }, { path: ['comment', 'create'], @@ -35,11 +37,10 @@ export const COMMENT: readonly Command[] = [ ENTRY_OPTION, { name: '--reply-to', type: 'string', placeholder: '', description: 'Reply under this comment' } ], - request: (input) => ({ - method: 'POST', - path: `${commentsPath(input)}`, - body: given({ content: input.args.content, parent_id: input.options.reply_to }) - }), + request: (input) => + onEntry(input, 'createEntryComment', 'createTableEntryComment', { + body: given({ content: input.args.content, parent_id: input.options.reply_to }) + }), examples: ['jinshuju comment create --form Kp7mQ2 --entry 12 "已联系,等回复"'] }, { @@ -50,11 +51,11 @@ export const COMMENT: readonly Command[] = [ { name: 'content', required: true, description: 'New comment text' } ], options: [...CONTAINER_OPTIONS, ENTRY_OPTION], - request: (input) => ({ - method: 'PATCH', - path: `${commentsPath(input)}/${input.args.comment}`, - body: { content: input.args.content } - }) + request: (input) => + onEntry(input, 'updateEntryComment', 'updateTableEntryComment', { + path: { comment_id: input.args.comment }, + body: { content: input.args.content } + }) }, { path: ['comment', 'delete'], @@ -63,7 +64,9 @@ export const COMMENT: readonly Command[] = [ options: [...CONTAINER_OPTIONS, ENTRY_OPTION, YES_OPTION], request: (input) => { confirmed(input, `Deleting comment ${input.args.comment}`); - return { method: 'DELETE', path: `${commentsPath(input)}/${input.args.comment}` }; + return onEntry(input, 'deleteEntryComment', 'deleteTableEntryComment', { + path: { comment_id: input.args.comment } + }); } } ]; diff --git a/src/commands/entry.ts b/src/commands/entry.ts index a1e0fde..6164343 100644 --- a/src/commands/entry.ts +++ b/src/commands/entry.ts @@ -18,13 +18,12 @@ import { type OptionSpec } from '../options.js'; import { - API, LISTING, YES_OPTION, confirmed, - containerPath, filters, given, + onContainer, paging, payload, refuseWithMine, @@ -34,7 +33,8 @@ import { import type { Command, CommandInput } from './types.js'; import { RefusedError } from '../errors.js'; import { progress } from '../progress.js'; -import { TransportError, type HttpClient } from '../http.js'; +import { TransportError, type HttpClient, type HttpRequest } from '../http.js'; +import { call, choices, type CallParts, type OperationId } from '../spec.js'; import { isRecord } from '../values.js'; import { basename } from 'node:path'; import { upload } from './upload.js'; @@ -47,8 +47,8 @@ function list(value: unknown): string | undefined { return values && values.length > 0 ? values.join(',') : undefined; } -function labels(input: CommandInput): string | undefined { - return input.options.labels ? 'true' : undefined; +function labels(input: CommandInput): boolean | undefined { + return input.options.labels ? true : undefined; } /** @@ -249,10 +249,7 @@ export async function awaitImport( for (;;) { let job: ImportJob; try { - job = await client.request({ - method: 'GET', - path: `${API}/forms/${token}/entry_imports/${jobId}` - }); + job = await client.request(call('getEntryImport', { path: { form_token: token, id: jobId } })); } catch (error) { throw recoverable(`the import started, but asking how it is going failed: ${(error as Error).message}`, error); } @@ -297,7 +294,8 @@ async function withUploads( watching.step(`uploading ${basename(file)}…`); const uploaded = await client.request<{ id: string }>( upload( - `${API}/forms/${token}/entry_attachments`, + 'uploadEntryAttachment', + { form_token: token }, file, dimension === undefined ? { field_api_code: field } : { field_api_code: field, dimension_api_code: dimension } ) @@ -349,19 +347,43 @@ const ANALYSIS_VIEW_OPTION: OptionSpec = { description: 'Only the entries this view shows; a filter narrows them further' }; +/** The four operations an analysis is: of a form or a table, whole or through one of its views. */ +type Analysis = { form: OperationId; table: OperationId; formView: OperationId; tableView: OperationId }; + +const COUNT: Analysis = { + form: 'countEntries', + table: 'countTableEntries', + formView: 'countFormViewEntries', + tableView: 'countTableViewEntries' +}; +const AGGREGATE: Analysis = { + form: 'aggregateEntries', + table: 'aggregateTableEntries', + formView: 'aggregateFormViewEntries', + tableView: 'aggregateTableViewEntries' +}; +const SUMMARY: Analysis = { + form: 'summarizeEntries', + table: 'summarizeTableEntries', + formView: 'summarizeFormViewEntries', + tableView: 'summarizeTableViewEntries' +}; + /** Through a view, an analysis covers only the entries that view shows. */ -function analysisPath(input: CommandInput, read: string): string { +function analysis(input: CommandInput, ops: Analysis, query: CallParts['query']): HttpRequest { const view = input.options.view as string | undefined; - return `${containerPath(input)}${view ? `/views/${view}` : ''}/entries/${read}`; + return view + ? onContainer(input, ops.formView, ops.tableView, { path: { view_token: view }, query }) + : onContainer(input, ops.form, ops.table, { query }); } /** * Batch writes are served under the forms path alone, and it takes a table's - * token just as well, so a table batches through the same URL. + * token just as well, so a table batches through the same operation. */ -function batchPath(input: CommandInput): string { +function batch(input: CommandInput, id: 'createEntriesBatch' | 'patchEntriesBatch', entries: unknown[]): HttpRequest { const { token } = resolveContainer(input.options); - return `${API}/forms/${token}/entries/batch`; + return call(id, { path: { form_token: token }, body: { entries } }); } export const ENTRY: readonly Command[] = [ @@ -386,9 +408,8 @@ export const ENTRY: readonly Command[] = [ if (input.options.mine) { refuseWithMine(input, ['view', 'sort']); const { token } = resolveContainer(input.options); - return { - method: 'GET', - path: `${API}/my/forms/${token}/entries`, + return call('listMySubmittedEntries', { + path: { form_token: token }, query: { filters: filters(input), keyword: input.options.keyword as string | undefined, @@ -396,7 +417,7 @@ export const ENTRY: readonly Command[] = [ include_labels: labels(input), ...paging(input) } - }; + }); } const view = input.options.view as string | undefined; if (view) { @@ -415,15 +436,16 @@ export const ENTRY: readonly Command[] = [ 'Change them with `view edit --columns`, or read the form without --view.' ); } - return { - method: 'GET', - path: `${containerPath(input)}/views/${view}/entries`, - query: { include_labels: labels(input), ...paging(input) } - }; + // A view is read fifty rows a page, and the API takes no page size for it. + if (input.options.limit !== undefined) { + throw new UsageError('--limit cannot be combined with --view: a view is read 50 rows a page'); + } + return onContainer(input, 'listFormViewEntries', 'listTableViewEntries', { + path: { view_token: view }, + query: { include_labels: labels(input), next: input.options.next as string | undefined } + }); } - return { - method: 'GET', - path: `${containerPath(input)}/entries`, + return onContainer(input, 'listEntries', 'listTableEntries', { query: { filters: filters(input), keyword: input.options.keyword as string | undefined, @@ -432,7 +454,7 @@ export const ENTRY: readonly Command[] = [ sort: sort(input, 'api_code'), ...paging(input) } - }; + }); }, paginate: LISTING, examples: [ @@ -458,12 +480,17 @@ export const ENTRY: readonly Command[] = [ ], request: (input) => { const { tokens } = resolveContainers(input.options, MAX_COUNTED_CONTAINERS); - const query = { filters: filters(input), keyword: input.options.keyword as string | undefined }; - if (tokens.length === 1) return { method: 'GET', path: analysisPath(input, 'count'), query }; + const keyword = input.options.keyword as string | undefined; + if (tokens.length === 1) return analysis(input, COUNT, { filters: filters(input), keyword }); if (input.options.view !== undefined) { throw new UsageError('--view belongs to one form or table, so it counts one container, not several'); } - return { method: 'GET', path: `${API}/entries/count`, query: { ...query, form_tokens: tokens.join(',') } }; + if (keyword !== undefined) { + throw new UsageError( + '--keyword searches the fields of one form or table, so it counts one container, not several' + ); + } + return call('countEntriesAcrossForms', { query: { filters: filters(input), form_tokens: tokens.join(',') } }); }, examples: [ "jinshuju entry count --form Kp7mQ2 --filter 'field_3 gte 80'", @@ -499,25 +526,21 @@ export const ENTRY: readonly Command[] = [ const { tokens } = namedContainers(input.options); if (input.options.mine) { refuseWithMine(input, ['table', 'scope_filter']); - return { - method: 'GET', - path: `${API}/my/search`, + return call('searchMySubmittedEntries', { query: { keyword: input.args.keyword, form_tokens: tokens.length > 0 ? tokens.join(',') : undefined } - }; + }); } const scope = (input.options.scope_filter as string[] | undefined) ?? []; - return { - method: 'GET', - path: `${API}/entries/search`, + return call('searchEntriesAcrossForms', { query: { keyword: input.args.keyword, form_tokens: tokens.length > 0 ? tokens.join(',') : undefined, filters: scope.length > 0 ? JSON.stringify(scope.map(parseFilter)) : undefined } - }; + }); }, examples: [ 'jinshuju entry search 某某公司', @@ -548,7 +571,7 @@ export const ENTRY: readonly Command[] = [ { name: '--kind', type: 'string', - choices: ['form', 'table'], + choices: choices('getEntryStats', 'kind'), placeholder: '', description: 'Count only forms, or only tables' }, @@ -561,7 +584,7 @@ export const ENTRY: readonly Command[] = [ { name: '--by', type: 'string', - choices: ['day', 'week', 'month'], + choices: choices('getEntryStats', 'by'), placeholder: '', description: 'Also split the range into buckets: every one, empty ones with total 0, --limit per bucket. ' + @@ -569,17 +592,16 @@ export const ENTRY: readonly Command[] = [ 'At most 92 days, 53 weeks or 24 months' } ], - request: (input) => ({ - method: 'GET', - path: `${API}/entries/stats`, - query: { - from: requiredOption(input, 'from'), - to: input.options.to as string | undefined, - kind: input.options.kind as string | undefined, - limit: input.options.limit === undefined ? undefined : String(input.options.limit), - by: input.options.by as string | undefined - } - }), + request: (input) => + call('getEntryStats', { + query: { + from: requiredOption(input, 'from'), + to: input.options.to as string | undefined, + kind: input.options.kind as string | undefined, + limit: input.options.limit as number | undefined, + by: input.options.by as string | undefined + } + }), examples: [ 'jinshuju entry stats --from 2026-09-01', 'jinshuju entry stats --from 2026-09-01 --to 2026-09-07 --kind form --limit 10', @@ -623,16 +645,12 @@ export const ENTRY: readonly Command[] = [ const metrics = (input.options.metric as string[] | undefined) ?? []; if (metrics.length === 0) throw new UsageError('--metric : is required, up to 20'); const dimensions = (input.options.by as string[] | undefined) ?? []; - return { - method: 'GET', - path: analysisPath(input, 'aggregate'), - query: { - metrics: JSON.stringify(metrics.map(parseMetric)), - dimensions: dimensions.length > 0 ? JSON.stringify(dimensions.map(parseDimension)) : undefined, - limit: input.options.limit === undefined ? undefined : String(input.options.limit), - filters: filters(input) - } - }; + return analysis(input, AGGREGATE, { + metrics: JSON.stringify(metrics.map(parseMetric)), + dimensions: dimensions.length > 0 ? JSON.stringify(dimensions.map(parseDimension)) : undefined, + limit: input.options.limit as number | undefined, + filters: filters(input) + }); }, render: aggregateTable, examples: [ @@ -663,15 +681,12 @@ export const ENTRY: readonly Command[] = [ FILTER_OPTION, FILTERS_OPTION ], - request: (input) => ({ - method: 'GET', - path: analysisPath(input, 'summary'), - query: { + request: (input) => + analysis(input, SUMMARY, { fields: list(input.options.fields), - include_overview: input.options.no_overview ? 'false' : undefined, + include_overview: input.options.no_overview ? false : undefined, filters: filters(input) - } - }), + }), examples: [ 'jinshuju entry summary --form Kp7mQ2', 'jinshuju entry summary --form Kp7mQ2 --view aB3dE9', @@ -690,15 +705,15 @@ export const ENTRY: readonly Command[] = [ options: [...CONTAINER_OPTIONS, JSON_OPTION, BATCH_OPTION, ATTACH_OPTION], run: async (input, client) => { const attached = attachments(input); - const batch = batchRows(input); + const rows = batchRows(input); if (attached.length === 0) { - if (batch) return client.request({ method: 'POST', path: batchPath(input), body: { entries: batch } }); - return client.request({ method: 'POST', path: `${containerPath(input)}/entries`, body: payload(input) }); + if (rows) return client.request(batch(input, 'createEntriesBatch', rows)); + return client.request(onContainer(input, 'createEntry', 'createTableEntry', { body: payload(input) })); } - if (batch !== undefined) throw new UsageError('--attach cannot be combined with --batch'); + if (rows !== undefined) throw new UsageError('--attach cannot be combined with --batch'); const body = await withUploads(input, client, attached); - return await client.request({ method: 'POST', path: `${containerPath(input)}/entries`, body }); + return await client.request(onContainer(input, 'createEntry', 'createTableEntry', { body })); }, examples: [ 'jinshuju entry create --form Kp7mQ2 --json \'{"field_1":"张三"}\'', @@ -733,13 +748,13 @@ export const ENTRY: readonly Command[] = [ // that arrived without one had no way to gain one. The upload happens first // and its id joins the patch, exactly as it does on create. run: async (input, client) => { - const batch = batchRows(input); + const rows = batchRows(input); const attached = attachments(input); - if (batch) { + if (rows) { if (input.args.serial) throw new UsageError('--batch carries its own serial numbers, so is not taken'); if (input.options.replace) throw new UsageError('--replace cannot be combined with --batch'); if (attached.length > 0) throw new UsageError('--attach cannot be combined with --batch'); - return client.request({ method: 'PATCH', path: batchPath(input), body: { entries: batch } }); + return client.request(batch(input, 'patchEntriesBatch', rows)); } if (!input.args.serial) { throw new UsageError( @@ -749,11 +764,12 @@ export const ENTRY: readonly Command[] = [ ); } const body = attached.length > 0 ? await withUploads(input, client, attached) : payload(input); - return client.request({ - method: input.options.replace ? 'PUT' : 'PATCH', - path: `${containerPath(input)}/entries/${input.args.serial}`, - body - }); + const path = { serial_number: input.args.serial }; + return client.request( + input.options.replace + ? onContainer(input, 'replaceEntry', 'replaceTableEntry', { path, body }) + : onContainer(input, 'updateEntry', 'updateTableEntry', { path, body }) + ); }, examples: [ 'jinshuju entry update --form Kp7mQ2 12 --json \'{"field_1":"李四"}\'', @@ -806,20 +822,21 @@ export const ENTRY: readonly Command[] = [ try { watching.step(`uploading ${basename(input.args.file as string)}…`); const uploaded = await client.request<{ id: string }>( - upload(`${API}/forms/${token}/import_files`, input.args.file as string) + upload('uploadImportFile', { form_token: token }, input.args.file as string) ); watching.step('starting the import…'); - const started = await client.request({ - method: 'POST', - path: `${API}/forms/${token}/entry_imports`, - body: given({ - attachment_id: uploaded.id, - columns: mappings.map(parseColumnMapping), - header_row_index: input.options.header_row, - unique_field_code: input.options.unique + const started = await client.request( + call('createEntryImport', { + path: { form_token: token }, + body: given({ + attachment_id: uploaded.id, + columns: mappings.map(parseColumnMapping), + header_row_index: input.options.header_row, + unique_field_code: input.options.unique + }) }) - }); + ); if (!input.options.wait) return started; return await awaitImport(client, token, started.job_id, watching); @@ -843,7 +860,7 @@ export const ENTRY: readonly Command[] = [ options: [...CONTAINER_OPTIONS], request: (input) => { const { token } = resolveContainer(input.options); - return { method: 'GET', path: `${API}/forms/${token}/entry_imports/${input.args.job}` }; + return call('getEntryImport', { path: { form_token: token, id: input.args.job } }); }, examples: ['jinshuju entry import-status --form Kp7mQ2 6ab12edb3134316548d106a9'] }, @@ -854,7 +871,7 @@ export const ENTRY: readonly Command[] = [ options: [...CONTAINER_OPTIONS, YES_OPTION], request: (input) => { confirmed(input, `Deleting entry ${input.args.serial}`); - return { method: 'DELETE', path: `${containerPath(input)}/entries/${input.args.serial}` }; + return onContainer(input, 'deleteEntry', 'deleteTableEntry', { path: { serial_number: input.args.serial } }); }, examples: ['jinshuju entry delete --form Kp7mQ2 12 --yes'] }, @@ -868,10 +885,10 @@ export const ENTRY: readonly Command[] = [ { name: '--fields', type: 'list', placeholder: '', description: 'Return only these fields' }, LABELS_OPTION ], - request: (input) => ({ - method: 'GET', - path: `${containerPath(input)}/entries/${input.args.serial}`, - query: { fields: list(input.options.fields), include_labels: labels(input) } - }) + request: (input) => + onContainer(input, 'getEntry', 'getTableEntry', { + path: { serial_number: input.args.serial }, + query: { fields: list(input.options.fields), include_labels: labels(input) } + }) } ]; diff --git a/src/commands/field.ts b/src/commands/field.ts index ed6a653..21be189 100644 --- a/src/commands/field.ts +++ b/src/commands/field.ts @@ -1,5 +1,6 @@ import { CONTAINER_OPTIONS, JSON_OPTION, UsageError, type OptionSpec } from '../options.js'; -import { API, YES_OPTION, confirmed, containerPath, one, payload, requiredOption } from './shared.js'; +import { call } from '../spec.js'; +import { YES_OPTION, confirmed, onContainer, one, payload, requiredOption } from './shared.js'; import type { Command, CommandInput } from './types.js'; /** One field, or a list of them — the same objects `fields` holds above. */ @@ -121,11 +122,12 @@ export const FIELD: readonly Command[] = [ description: `Only types of this scope: ${FIELD_SCOPES.join(', ')}` } ], - request: (input) => ({ - method: 'GET', - path: input.args.type ? `${API}/field_types/${input.args.type}` : `${API}/field_types`, - query: { kind: input.options.kind as string | undefined } - }), + request: (input) => { + const query = { kind: input.options.kind as string | undefined }; + return input.args.type + ? call('getFieldType', { path: { type: input.args.type }, query }) + : call('listFieldTypes', { query }); + }, select: (body, input) => { const rows = Array.isArray((body as { data?: unknown }).data) ? (body as { data: Record[] }).data @@ -146,7 +148,7 @@ export const FIELD: readonly Command[] = [ summary: 'List the fields of a form or table', description: 'Read out of the object structure, the same fields `form get` and `table get` return.', options: [...CONTAINER_OPTIONS], - request: (input) => ({ method: 'GET', path: containerPath(input) }), + request: (input) => onContainer(input, 'getForm', 'getTable'), select: (body) => ({ data: selectFields(body) }) }, { @@ -155,11 +157,8 @@ export const FIELD: readonly Command[] = [ description: 'One field object, or a list of them. Do not pass api_code: the backend generates it.', options: [...CONTAINER_OPTIONS, JSON_OPTION], payload: FIELD_PAYLOAD, - request: (input) => ({ - method: 'PATCH', - path: containerPath(input), - body: { fields: { add: one(payload(input)) } } - }), + request: (input) => + onContainer(input, 'updateForm', 'updateTable', { body: { fields: { add: one(payload(input)) } } }), select: (body, input) => { const labels = labelsOf(payload(input)); return touchedFields(body, (field) => labels.has(String(field.label))); @@ -172,13 +171,12 @@ export const FIELD: readonly Command[] = [ description: 'The patch is merged onto the field; the api_code comes from the argument, not the payload.', args: [{ name: 'api-code', required: true, description: 'Field api_code, e.g. field_3' }], options: [...CONTAINER_OPTIONS, JSON_OPTION], - request: (input) => ({ - method: 'PATCH', - path: containerPath(input), - body: { - fields: { update: [{ ...(payload(input) as Record), api_code: input.args['api-code'] }] } - } - }), + request: (input) => + onContainer(input, 'updateForm', 'updateTable', { + body: { + fields: { update: [{ ...(payload(input) as Record), api_code: input.args['api-code'] }] } + } + }), select: (body, input) => touchedFields(body, (field) => field.api_code === input.args['api-code']), examples: ['jinshuju field update --form Kp7mQ2 field_3 --json \'{"required":true}\''] }, @@ -188,15 +186,14 @@ export const FIELD: readonly Command[] = [ description: 'The payload is the choice operations the field takes, e.g. {add, update, remove}.', args: [{ name: 'api-code', required: true, description: 'Field api_code' }], options: [...CONTAINER_OPTIONS, JSON_OPTION], - request: (input) => ({ - method: 'PATCH', - path: containerPath(input), - body: { - fields: { - update_choices: [{ ...(payload(input) as Record), field_api_code: input.args['api-code'] }] + request: (input) => + onContainer(input, 'updateForm', 'updateTable', { + body: { + fields: { + update_choices: [{ ...(payload(input) as Record), field_api_code: input.args['api-code'] }] + } } - } - }), + }), select: (body, input) => touchedFields(body, (field) => field.api_code === input.args['api-code']) }, { @@ -208,11 +205,10 @@ export const FIELD: readonly Command[] = [ 'this cannot express — a matrix statement, a cascade level — goes through --json.', args: [{ name: 'target', required: false, variadic: true, description: 'field api_code, or api_code:choice' }], options: [...CONTAINER_OPTIONS, JSON_OPTION], - request: (input) => ({ - method: 'GET', - path: `${containerPath(input)}/fields/check`, - query: { checks: JSON.stringify(checks(input, parseCheckTarget)) } - }), + request: (input) => + onContainer(input, 'checkFormFieldData', 'checkTableFieldData', { + query: { checks: JSON.stringify(checks(input, parseCheckTarget)) } + }), examples: [ 'jinshuju field check --form Kp7mQ2 field_3 field_9', 'jinshuju field check --form Kp7mQ2 field_7:choice_1' @@ -256,11 +252,9 @@ export const FIELD: readonly Command[] = [ } ] : one(input.options.json); - return { - method: 'GET', - path: `${containerPath(input)}/fields/preview_convert`, + return onContainer(input, 'previewFormFieldTypeChange', 'previewTableFieldTypeChange', { query: { checks: JSON.stringify(inline) } - }; + }); }, examples: ['jinshuju field preview-convert --form Kp7mQ2 field_1 --to RadioButton'] }, @@ -272,7 +266,9 @@ export const FIELD: readonly Command[] = [ options: [...CONTAINER_OPTIONS, YES_OPTION], request: (input) => { confirmed(input, `Removing ${input.args['api-code']} and any answers it holds`); - return { method: 'PATCH', path: containerPath(input), body: { fields: { remove: [input.args['api-code']] } } }; + return onContainer(input, 'updateForm', 'updateTable', { + body: { fields: { remove: [input.args['api-code']] } } + }); }, examples: ['jinshuju field remove --form Kp7mQ2 field_9 --yes'] } diff --git a/src/commands/fill.ts b/src/commands/fill.ts index 946c693..94587c0 100644 --- a/src/commands/fill.ts +++ b/src/commands/fill.ts @@ -1,10 +1,18 @@ import { UsageError } from '../errors.js'; +import type { HttpRequest } from '../http.js'; import { JSON_OPTION } from '../options.js'; +import { call, type CallParts, type OperationId } from '../spec.js'; import type { ArgSpec, Command, CommandInput } from './types.js'; -// The two kinds of fill page: `/f/` for a form, `/v/` for a -// table's form view. The API path mirrors them, so a link is all it takes. -const ROUTES = ['f', 'v']; +/** + * The two kinds of fill page: `/f/` for a form, `/v/` for a + * table's form view. The API mirrors them as two operations each, so a link is + * all it takes to know which. + */ +const ROUTES: Record = { + f: { token: 'form_token', page: 'getFillFormPage', submit: 'createFillFormEntry' }, + v: { token: 'view_token', page: 'getFillViewPage', submit: 'createFillViewEntry' } +}; const PAGE_ARG: ArgSpec = { name: 'page', @@ -12,8 +20,8 @@ const PAGE_ARG: ArgSpec = { description: 'The fill link as shared, e.g. https://example.jinshuju.net/v/aB3dE9, or just its path: v/aB3dE9' }; -/** `/api/v1/fill//` for a fill link, or for the `f/…` / `v/…` path of one. */ -export function fillPagePath(input: CommandInput): string { +/** The page's or the submit's request for a fill link, or for the `f/…` / `v/…` path of one. */ +function fillPage(input: CommandInput, what: 'page' | 'submit', parts: CallParts = {}): HttpRequest { const page = input.args.page.trim(); let path = page; if (/^https?:\/\//i.test(page)) { @@ -24,10 +32,11 @@ export function fillPagePath(input: CommandInput): string { } } const [route, token] = path.split('/').filter(Boolean); - if (route === undefined || !ROUTES.includes(route) || !token) { + const kind = route !== undefined && Object.hasOwn(ROUTES, route) ? ROUTES[route] : undefined; + if (!kind || !token) { throw new UsageError(`Not a fill link: ${page}. A fill link ends in /f/ or /v/.`); } - return `/api/v1/fill/${route}/${encodeURIComponent(token)}`; + return call(kind[what], { ...parts, path: { [kind.token]: token } }); } export const FILL: readonly Command[] = [ @@ -41,7 +50,7 @@ export const FILL: readonly Command[] = [ 'not empty the page has to be filled in a browser. `manageable` appears only when you could already ' + 'read the form or table behind the page.', args: [PAGE_ARG], - request: (input) => ({ method: 'GET', path: fillPagePath(input) }), + request: (input) => fillPage(input, 'page'), examples: ['jinshuju fill get https://example.jinshuju.net/v/aB3dE9', 'jinshuju fill get f/Kp7mQ2'] }, { @@ -67,7 +76,7 @@ export const FILL: readonly Command[] = [ if (entry === undefined) throw new UsageError('--json is required: the values to submit, keyed by api_code'); const body: Record = { entry }; if (input.options.password !== undefined) body.password = input.options.password; - return { method: 'POST', path: `${fillPagePath(input)}/entries`, body }; + return fillPage(input, 'submit', { body }); }, examples: ['jinshuju fill submit https://example.jinshuju.net/v/aB3dE9 --json \'{"field_1":"张三"}\''] } diff --git a/src/commands/folder.ts b/src/commands/folder.ts index a9d9256..c48507a 100644 --- a/src/commands/folder.ts +++ b/src/commands/folder.ts @@ -1,5 +1,6 @@ import { LIMIT_OPTION } from '../options.js'; -import { API, paging } from './shared.js'; +import { bodyChoices, call } from '../spec.js'; +import { given, paging } from './shared.js'; import type { Command } from './types.js'; export const FOLDER: readonly Command[] = [ @@ -7,7 +8,7 @@ export const FOLDER: readonly Command[] = [ path: ['folder', 'list'], summary: 'List folders', options: [LIMIT_OPTION], - request: (input) => ({ method: 'GET', path: `${API}/folders`, query: paging(input) }) + request: (input) => call('listFolders', { query: paging(input) }) }, { path: ['folder', 'create'], @@ -18,16 +19,12 @@ export const FOLDER: readonly Command[] = [ { name: '--kind', type: 'string', - choices: ['form', 'table'], + choices: bodyChoices('createFolder', 'kind'), placeholder: '', description: 'What the folder holds (default form)' } ], - request: (input) => ({ - method: 'POST', - path: `${API}/folders`, - body: { name: input.args.name, kind: input.options.kind } - }), + request: (input) => call('createFolder', { body: given({ name: input.args.name, kind: input.options.kind }) }), examples: ['jinshuju folder create 2026年活动', 'jinshuju folder create 台账 --kind table'] } ]; diff --git a/src/commands/form.ts b/src/commands/form.ts index 9c7a768..c6786ec 100644 --- a/src/commands/form.ts +++ b/src/commands/form.ts @@ -9,7 +9,6 @@ import { type OptionSpec } from '../options.js'; import { - API, FOLDER_OPTION, LISTING, filters, @@ -23,6 +22,7 @@ import { } from './shared.js'; import type { Command, CommandInput } from './types.js'; import { validateContainerPayload } from '../payload.js'; +import { bodyChoices, call, type OperationId } from '../spec.js'; import { upload, uploadFormImage } from './upload.js'; /** @@ -92,16 +92,16 @@ const INCLUDE_OPTION: OptionSpec = { description: `Extra blocks to carry: ${Object.keys(FORM_INCLUDES).join(', ')}. The setting is always there` }; -function includes(input: CommandInput): Record { +function includes(input: CommandInput): Record { const asked = (input.options.include as string[] | undefined) ?? []; - const query: Record = {}; + const query: Record = {}; for (const name of asked) { const parameter = FORM_INCLUDES[name]; if (parameter === '') continue; if (!parameter) { throw new UsageError(`--include takes ${Object.keys(FORM_INCLUDES).join(', ')}, got ${JSON.stringify(name)}`); } - query[parameter] = 'true'; + query[parameter] = true; } return query; } @@ -115,16 +115,7 @@ function themeBody(input: CommandInput): Record { } /** The scenes a form can be created for, as the API names them. */ -const FORM_SCENES = [ - 'survey', - 'registry', - 'vote', - 'exam', - 'reservation', - 'customer_acquisition', - 'evaluation', - 'online_payment' -] as const; +const FORM_SCENES = bodyChoices('createForm', 'scene'); /** * A form's type picks both the scene it is created in and the settings block @@ -133,22 +124,29 @@ const FORM_SCENES = [ * drop the block on the floor, since the form update only reads name, * description, setting, fields and field_rules. */ -const FORM_TYPES: Record = { +const FORM_TYPES: Record = { normal: {}, - exam: { scene: 'exam', settingKey: 'exam_setting', path: 'exam_setting' }, - evaluation: { scene: 'evaluation', settingKey: 'evaluation_setting', path: 'evaluation_setting' } + exam: { scene: 'exam', settingKey: 'exam_setting', operation: 'updateExamSetting' }, + evaluation: { scene: 'evaluation', settingKey: 'evaluation_setting', operation: 'updateEvaluationSetting' } }; -/** Which settings block a payload carries, and where it has to be sent. */ -function settingsBlock(body: Record): { key: string; path: string; value: unknown } | undefined { - for (const { settingKey, path } of Object.values(FORM_TYPES)) { - if (settingKey && path && body[settingKey] !== undefined) { - return { key: settingKey, path, value: body[settingKey] }; +type SettingsBlock = { key: string; operation: OperationId; value: unknown }; + +/** Which settings block a payload carries, and which operation it has to be sent to. */ +function settingsBlock(body: Record): SettingsBlock | undefined { + for (const { settingKey, operation } of Object.values(FORM_TYPES)) { + if (settingKey && operation && body[settingKey] !== undefined) { + return { key: settingKey, operation, value: body[settingKey] }; } } return undefined; } +/** A settings block, sent to the form it belongs to. */ +function saveSettings(form: string, settings: SettingsBlock) { + return call(settings.operation, { path: { form_token: form }, body: settings.value }); +} + /** The scene a --type implies, refusing a --scene that contradicts it. */ function sceneFor(input: CommandInput): string | undefined { const type = (input.options.type as string | undefined) ?? 'normal'; @@ -160,10 +158,7 @@ function sceneFor(input: CommandInput): string | undefined { return implied ?? scene; } -function createFormBody(input: CommandInput): { - body: Record; - settings?: { key: string; path: string; value: unknown }; -} { +function createFormBody(input: CommandInput): { body: Record; settings?: SettingsBlock } { const payloadBody = { ...validateContainerPayload(payload(input), 'form') }; const settings = settingsBlock(payloadBody); if (settings) delete payloadBody[settings.key]; @@ -201,19 +196,17 @@ export const FORM: readonly Command[] = [ request: (input) => { if (input.options.mine) { refuseWithMine(input, ['name', 'with_transactions', 'filter', 'filters', 'sort']); - return { method: 'GET', path: `${API}/my/forms`, query: paging(input) }; + return call('listMyFilledForms', { query: paging(input) }); } - return { - method: 'GET', - path: `${API}/forms`, + return call('listForms', { query: { q: keywords(input.options.name), - include_transactions: input.options.with_transactions ? 'true' : undefined, + include_transactions: input.options.with_transactions ? true : undefined, filters: filters(input), sort: sort(input, 'field'), ...paging(input) } - }; + }); }, paginate: LISTING, examples: [ @@ -233,11 +226,7 @@ export const FORM: readonly Command[] = [ 'answers the same fields flattened, with api_code on each.', args: [{ name: 'form', required: true, description: 'Form token, six letters and digits, e.g. Kp7mQ2' }], options: [INCLUDE_OPTION], - request: (input) => ({ - method: 'GET', - path: `${API}/forms/${input.args.form}`, - query: includes(input) - }), + request: (input) => call('getForm', { path: { form_token: input.args.form }, query: includes(input) }), examples: ['jinshuju form get Kp7mQ2', 'jinshuju form get Kp7mQ2 --include theme,rules,analytics'] }, { @@ -264,12 +253,12 @@ export const FORM: readonly Command[] = [ type: 'string', choices: FORM_SCENES, placeholder: '', - description: `What the form is for: ${FORM_SCENES.join(', ')}` + description: FORM_SCENES ? `What the form is for: ${FORM_SCENES.join(', ')}` : 'What the form is for' }, { name: '--layout', type: 'string', - choices: ['classic', 'card'], + choices: bodyChoices('createForm', 'layout'), placeholder: '', description: 'classic shows every field at once, card one page at a time' }, @@ -277,15 +266,11 @@ export const FORM: readonly Command[] = [ ], run: async (input, client) => { const { body, settings } = createFormBody(input); - if (!settings) return client.request({ method: 'POST', path: `${API}/forms`, body }); + if (!settings) return client.request(call('createForm', { body })); - const form = await client.request<{ token: string }>({ method: 'POST', path: `${API}/forms`, body }); + const form = await client.request<{ token: string }>(call('createForm', { body })); try { - await client.request({ - method: 'PATCH', - path: `${API}/forms/${form.token}/${settings.path}`, - body: settings.value - }); + await client.request(saveSettings(form.token, settings)); } catch (error) { // The form exists; saying so beats an error that reads as though // nothing happened and inviting a second one to be created. @@ -295,7 +280,7 @@ export const FORM: readonly Command[] = [ { cause: error } ); } - return client.request({ method: 'GET', path: `${API}/forms/${form.token}` }); + return client.request(call('getForm', { path: { form_token: form.token } })); }, examples: [ 'jinshuju form create --json @form.json', @@ -313,24 +298,20 @@ export const FORM: readonly Command[] = [ args: [{ name: 'form', required: true, description: 'Form token' }], options: [JSON_OPTION], run: async (input, client) => { + const form = input.args.form as string; const body = { ...(payload(input) as Record) }; const settings = settingsBlock(body); - if (!settings) return client.request({ method: 'PATCH', path: `${API}/forms/${input.args.form}`, body }); + if (!settings) return client.request(call('updateForm', { path: { form_token: form }, body })); delete body[settings.key]; // The settings go first: they are the half that can be refused for what // the form is, so leading with them is what keeps a refusal from landing // after the rest was already written. - await client.request({ - method: 'PATCH', - path: `${API}/forms/${input.args.form}/${settings.path}`, - body: settings.value - }); - if (Object.keys(body).length === 0) - return client.request({ method: 'GET', path: `${API}/forms/${input.args.form}` }); + await client.request(saveSettings(form, settings)); + if (Object.keys(body).length === 0) return client.request(call('getForm', { path: { form_token: form } })); try { - return await client.request({ method: 'PATCH', path: `${API}/forms/${input.args.form}`, body }); + return await client.request(call('updateForm', { path: { form_token: form }, body })); } catch (error) { // Ordering cannot make two requests atomic; it only chooses which half // fails first. When the second one fails the first has landed, and an @@ -346,7 +327,7 @@ export const FORM: readonly Command[] = [ examples: [ 'jinshuju form edit Kp7mQ2 --json \'{"name":"2026 活动报名"}\'', 'jinshuju form edit Kp7mQ2 --json \'{"setting":{"daily_repeat_close_rule":{"time_periods":[{"start_time":{"hour":11,"minute":0},"end_time":{"hour":12,"minute":40}}]}}}\'', - 'jinshuju form edit Kp7mQ2 --json \'{"exam_setting":{"total_score":100}}\'' + 'jinshuju form edit Kp7mQ2 --json \'{"exam_setting":{"limited_time":30}}\'' ] }, { @@ -357,22 +338,22 @@ export const FORM: readonly Command[] = [ { name: '--name', type: 'string', placeholder: '', description: 'Name for the copy' }, FOLDER_OPTION ], - request: (input) => ({ - method: 'POST', - path: `${API}/forms/${input.args.form}/copy`, - body: given({ name: input.options.name, folder_token: input.options.folder }) - }) + request: (input) => + call('copyForm', { + path: { form_token: input.args.form }, + body: given({ name: input.options.name, folder_token: input.options.folder }) + }) }, { path: ['form', 'move'], summary: 'Move a form into a folder, or out of one', args: [{ name: 'form', required: true, description: 'Form token' }], options: [FOLDER_OPTION], - request: (input) => ({ - method: 'PATCH', - path: `${API}/forms/${input.args.form}/folder`, - body: { folder_token: (input.options.folder as string | undefined) ?? '' } - }), + request: (input) => + call('moveFormToFolder', { + path: { form_token: input.args.form }, + body: { folder_token: (input.options.folder as string | undefined) ?? '' } + }), examples: ['jinshuju form move Kp7mQ2 --folder Fd2xK8', 'jinshuju form move Kp7mQ2'] }, { @@ -398,8 +379,8 @@ export const FORM: readonly Command[] = [ run: async (input, client) => { const wallpaper = input.options.wallpaper as string | undefined; const header = input.options.header as string | undefined; - const path = `${API}/forms/${input.args.form}/theme`; - if (!wallpaper && !header) return client.request({ method: 'PATCH', path, body: themeBody(input) }); + const path = { form_token: input.args.form as string }; + if (!wallpaper && !header) return client.request(call('updateFormTheme', { path, body: themeBody(input) })); const body = themeBody(input) as Record>; if (wallpaper) { @@ -410,7 +391,7 @@ export const FORM: readonly Command[] = [ const image = await uploadFormImage(client, header, 'header'); body.header = { ...body.header, header_image_attachment_id: image }; } - return client.request({ method: 'PATCH', path, body }); + return client.request(call('updateFormTheme', { path, body })); }, examples: [ 'jinshuju form theme set Kp7mQ2 --primary-color "#1F6FEB"', @@ -425,15 +406,14 @@ export const FORM: readonly Command[] = [ 'The image belongs to the first form saved with a description that refers to it; one that no ' + 'form takes within 7 days is removed.', args: [{ name: 'file', required: true, description: 'Image file' }], - request: (input) => - upload(`${API}/form_image_attachments`, input.args.file as string, { image_type: 'description' }), + request: (input) => upload('uploadFormImage', {}, input.args.file as string, { image_type: 'description' }), examples: ['jinshuju form image upload ./venue-map.png'] }, { path: ['form', 'rule', 'get'], summary: 'Show the field display rules of a form', args: [{ name: 'form', required: true, description: 'Form token' }], - request: (input) => ({ method: 'GET', path: `${API}/forms/${input.args.form}/field_rules` }) + request: (input) => call('listFieldRules', { path: { form_token: input.args.form } }) }, { path: ['form', 'rule', 'edit'], @@ -441,16 +421,13 @@ export const FORM: readonly Command[] = [ description: 'The payload is {add, update, remove}; a rule is targeted by the index `form rule get` shows.', args: [{ name: 'form', required: true, description: 'Form token' }], options: [JSON_OPTION], - request: (input) => ({ - method: 'PATCH', - path: `${API}/forms/${input.args.form}`, - body: { field_rules: payload(input) } - }) + request: (input) => + call('updateForm', { path: { form_token: input.args.form }, body: { field_rules: payload(input) } }) }, { path: ['form', 'cooperator', 'list'], summary: 'List the cooperators of a form', args: [{ name: 'form', required: true, description: 'Form token' }], - request: (input) => ({ method: 'GET', path: `${API}/forms/${input.args.form}/cooperators` }) + request: (input) => call('listFormCooperators', { path: { form_token: input.args.form } }) } ]; diff --git a/src/commands/opensearch.ts b/src/commands/opensearch.ts index a727639..a0ca728 100644 --- a/src/commands/opensearch.ts +++ b/src/commands/opensearch.ts @@ -1,5 +1,6 @@ import { JSON_OPTION, UsageError } from '../options.js'; -import { API, overriding, payload } from './shared.js'; +import { call } from '../spec.js'; +import { overriding, payload } from './shared.js'; import type { Command } from './types.js'; import { uploadFormImage } from './upload.js'; @@ -8,17 +9,14 @@ export const OPENSEARCH: readonly Command[] = [ path: ['opensearch', 'list'], summary: 'List public queries', options: [{ name: '--form', type: 'string', placeholder: '', description: 'Only the queries of this form' }], - request: (input) => ({ - method: 'GET', - path: `${API}/opensearch/queries`, - query: { form_token: input.options.form as string | undefined } - }) + request: (input) => + call('listOpensearchQueries', { query: { form_token: input.options.form as string | undefined } }) }, { path: ['opensearch', 'get'], summary: 'Show a public query', args: [{ name: 'query', required: true, description: 'Public query token' }], - request: (input) => ({ method: 'GET', path: `${API}/opensearch/queries/${input.args.query}` }) + request: (input) => call('getOpensearchQuery', { path: { query_token: input.args.query } }) }, { path: ['opensearch', 'fields'], @@ -27,7 +25,7 @@ export const OPENSEARCH: readonly Command[] = [ request: (input) => { const form = input.options.form as string | undefined; if (!form) throw new UsageError('--form is required'); - return { method: 'GET', path: `${API}/opensearch/query_suggestions`, query: { form_token: form } }; + return call('listOpensearchQuerySuggestions', { query: { form_token: form } }); } }, { @@ -41,11 +39,9 @@ export const OPENSEARCH: readonly Command[] = [ request: (input) => { const form = input.options.form as string | undefined; if (!form) throw new UsageError('--form is required'); - return { - method: 'POST', - path: `${API}/opensearch/queries`, + return call('createOpensearchQuery', { body: overriding(payload(input) as Record, { form_token: form }) - }; + }); } }, { @@ -72,7 +68,7 @@ export const OPENSEARCH: readonly Command[] = [ const image = await uploadFormImage(client, header, 'opensearch_header'); body.header = { background_image: { attachment_id: image } }; } - return client.request({ method: 'PATCH', path: `${API}/opensearch/queries/${input.args.query}`, body }); + return client.request(call('updateOpensearchQuery', { path: { query_token: input.args.query }, body })); }, examples: ['jinshuju opensearch edit Qy7nR3 --disable', 'jinshuju opensearch edit Qy7nR3 --header ./banner.png'] } diff --git a/src/commands/shared.ts b/src/commands/shared.ts index 67dfaf6..999311c 100644 --- a/src/commands/shared.ts +++ b/src/commands/shared.ts @@ -1,3 +1,4 @@ +import type { HttpRequest } from '../http.js'; import { UsageError, parseFilter, @@ -7,16 +8,27 @@ import { type OptionSpec, type SortRule } from '../options.js'; +import { call, type CallParts, type OperationId } from '../spec.js'; import type { CommandInput, Pagination } from './types.js'; /** What more than one resource's commands build their requests from. */ -export const API = '/api/v1'; - -/** The path segment a container lives under. */ -export function containerPath(input: CommandInput): string { +/** + * The request for whichever container was named. openapi.yaml has a form's + * operation and a table's as two, under two paths; the CLI has one command and + * `--form` or `--table`, so the flag picks the operation and its token fills + * the path. + */ +export function onContainer( + input: CommandInput, + form: OperationId, + table: OperationId, + parts: CallParts = {} +): HttpRequest { const { token, kind } = resolveContainer(input.options); - return `${API}/${kind === 'table' ? 'tables' : 'forms'}/${token}`; + return kind === 'table' + ? call(table, { ...parts, path: { ...parts.path, table_token: token } }) + : call(form, { ...parts, path: { ...parts.path, form_token: token } }); } /** The conditions themselves. A read sends them as a query string, a write as a body. */ @@ -45,11 +57,8 @@ export function sort(input: CommandInput, key: 'api_code' | 'field'): string | u return JSON.stringify(rules.map(parseSort).map((rule: SortRule) => ({ [key]: rule.field, order: rule.order }))); } -export function paging(input: CommandInput): Record { - return { - limit: input.options.limit === undefined ? undefined : String(input.options.limit), - next: input.options.next as string | undefined - }; +export function paging(input: CommandInput): { limit?: number; next?: string } { + return { limit: input.options.limit as number | undefined, next: input.options.next as string | undefined }; } /** The sort rules as the API's own objects, for a body. */ diff --git a/src/commands/sms.ts b/src/commands/sms.ts index 9c864d3..057750a 100644 --- a/src/commands/sms.ts +++ b/src/commands/sms.ts @@ -1,4 +1,4 @@ -import { API } from './shared.js'; +import { call } from '../spec.js'; import type { Command } from './types.js'; export const SMS: readonly Command[] = [ @@ -6,6 +6,6 @@ export const SMS: readonly Command[] = [ path: ['sms', 'status'], summary: 'List SMS signatures and templates with their review status and why a review failed', description: 'Your own signatures and templates, not those of the rest of the account.', - request: () => ({ method: 'GET', path: `${API}/sms/audit_statuses` }) + request: () => call('listSmsAuditStatuses') } ]; diff --git a/src/commands/table.ts b/src/commands/table.ts index 19512b2..d5a2e7e 100644 --- a/src/commands/table.ts +++ b/src/commands/table.ts @@ -1,5 +1,6 @@ import { FILTER_OPTION, FILTERS_OPTION, JSON_OPTION, PAGINATION_OPTIONS, SORT_OPTION } from '../options.js'; -import { API, FOLDER_OPTION, LISTING, filters, keywords, overriding, paging, payload, sort } from './shared.js'; +import { bodyChoices, call } from '../spec.js'; +import { FOLDER_OPTION, LISTING, filters, keywords, overriding, paging, payload, sort } from './shared.js'; import type { Command } from './types.js'; import { validateContainerPayload } from '../payload.js'; @@ -8,19 +9,7 @@ import { validateContainerPayload } from '../payload.js'; * Worth naming in the help rather than leaving to a rejected create: the absence * of TextField is the one nobody guesses. */ -const TABLE_FIELD_TYPES = [ - 'TextArea', - 'RadioButton', - 'CheckBox', - 'BooleanField', - 'MobileField', - 'NumberField', - 'DateTimeField', - 'EmailField', - 'LinkField', - 'AttachmentField', - 'FormulaField' -] as const; +const TABLE_FIELD_TYPES = bodyChoices('createTable', 'fields[].type'); export const TABLE: readonly Command[] = [ { @@ -39,11 +28,15 @@ export const TABLE: readonly Command[] = [ SORT_OPTION, ...PAGINATION_OPTIONS ], - request: (input) => ({ - method: 'GET', - path: `${API}/tables`, - query: { q: keywords(input.options.name), filters: filters(input), sort: sort(input, 'field'), ...paging(input) } - }), + request: (input) => + call('listTables', { + query: { + q: keywords(input.options.name), + filters: filters(input), + sort: sort(input, 'field'), + ...paging(input) + } + }), paginate: LISTING, examples: ['jinshuju table list --name 台账', 'jinshuju entry list --table Vn4xR8'] }, @@ -51,7 +44,7 @@ export const TABLE: readonly Command[] = [ path: ['table', 'get'], summary: 'Show a table: columns, types, choices', args: [{ name: 'table', required: true, description: 'Table token, six letters and digits, e.g. Vn4xR8' }], - request: (input) => ({ method: 'GET', path: `${API}/tables/${input.args.table}` }), + request: (input) => call('getTable', { path: { table_token: input.args.table } }), examples: ['jinshuju table get Vn4xR8'] }, { @@ -59,7 +52,8 @@ export const TABLE: readonly Command[] = [ summary: 'Create a table', description: 'Column types use the API v1 names, but a table takes fewer of them than a form: ' + - `${TABLE_FIELD_TYPES.join(', ')}. There is no TextField — a single line of text is a ` + + `${TABLE_FIELD_TYPES?.join(', ') ?? 'see `jinshuju field types --kind table`'}. ` + + 'There is no TextField — a single line of text is a ' + 'TextArea here. Do not pass api_code: the backend generates it.', options: [ JSON_OPTION, @@ -70,14 +64,13 @@ export const TABLE: readonly Command[] = [ description: 'Seed a few blank rows, as the UI does. Leave it off when rows follow' } ], - request: (input) => ({ - method: 'POST', - path: `${API}/tables`, - body: overriding(validateContainerPayload(payload(input), 'table'), { - folder_token: input.options.folder, - with_default_entries: input.options.with_default_entries ? true : undefined - }) - }), + request: (input) => + call('createTable', { + body: overriding(validateContainerPayload(payload(input), 'table'), { + folder_token: input.options.folder, + with_default_entries: input.options.with_default_entries ? true : undefined + }) + }), examples: ['jinshuju table create --json @table.json'] }, { @@ -86,11 +79,11 @@ export const TABLE: readonly Command[] = [ description: 'The folder must be a table folder; a form folder cannot hold a table.', args: [{ name: 'table', required: true, description: 'Table token' }], options: [FOLDER_OPTION], - request: (input) => ({ - method: 'PATCH', - path: `${API}/tables/${input.args.table}/folder`, - body: { folder_token: (input.options.folder as string | undefined) ?? '' } - }), + request: (input) => + call('moveTableToFolder', { + path: { table_token: input.args.table }, + body: { folder_token: (input.options.folder as string | undefined) ?? '' } + }), examples: ['jinshuju table move Vn4xR8 --folder Nf7mDC', 'jinshuju table move Vn4xR8'] }, { @@ -101,6 +94,6 @@ export const TABLE: readonly Command[] = [ 'fields: {add, update, update_choices, remove}.', args: [{ name: 'table', required: true, description: 'Table token' }], options: [JSON_OPTION], - request: (input) => ({ method: 'PATCH', path: `${API}/tables/${input.args.table}`, body: payload(input) }) + request: (input) => call('updateTable', { path: { table_token: input.args.table }, body: payload(input) }) } ]; diff --git a/src/commands/trade.ts b/src/commands/trade.ts index b5e5ac8..15a29f6 100644 --- a/src/commands/trade.ts +++ b/src/commands/trade.ts @@ -1,5 +1,6 @@ import { PAGINATION_OPTIONS, type OptionSpec } from '../options.js'; -import { API, paging } from './shared.js'; +import { call, choices, type OperationId, type QueryValue } from '../spec.js'; +import { paging } from './shared.js'; import type { Command, CommandInput } from './types.js'; /** @@ -9,9 +10,6 @@ import type { Command, CommandInput } from './types.js'; * answering with zeroes. */ -const PAID_STATUSES = ['paid', 'unpaid', 'refunded', 'refunding', 'partial_refunded']; -const RECORD_STATUSES = ['processing', 'success', 'error']; - const DATE_OPTIONS: readonly OptionSpec[] = [ { name: '--from', @@ -22,7 +20,10 @@ const DATE_OPTIONS: readonly OptionSpec[] = [ { name: '--to', type: 'string', placeholder: '', description: 'Last day, inclusive; give it with --from' } ]; -/** The CLI spells a dimension as its flag is spelled; the API names it by its parameter. */ +/** + * The CLI spells a dimension as its flag is spelled; the API names it by its + * parameter, and the spec says which of those it groups by. + */ const GROUP_BY: Record = { time: 'time', 'paid-status': 'paid_status', channel: 'payment_channel' }; const ORDER_FILTER_OPTIONS: readonly OptionSpec[] = [ @@ -30,27 +31,41 @@ const ORDER_FILTER_OPTIONS: readonly OptionSpec[] = [ name: '--status', type: 'string', repeatable: true, - choices: PAID_STATUSES, + choices: choices('listTradeTransactions', 'paid_status'), placeholder: '', description: 'Keep only these payment statuses, repeatable. Omitted, every status, unpaid included' }, { name: '--channel', type: 'string', - choices: ['wxpay', 'alipay'], + choices: choices('listTradeTransactions', 'payment_channel'), placeholder: '', description: 'Keep only orders paid through this channel: wxpay (微信) or alipay (支付宝)' }, { name: '--form', type: 'string', placeholder: '', description: 'Keep only orders of this payment form' } ]; -const INCLUDE_OPTION: OptionSpec = { - name: '--include', - type: 'string', - choices: ['both', 'stats', 'records'], - placeholder: '', - description: 'both (default), stats for the statistics alone, or records for the rows alone' -}; +/** What a refund or payout listing carries, as the operation behind it allows. */ +function includeOption(id: OperationId): OptionSpec { + return { + name: '--include', + type: 'string', + choices: choices(id, 'include'), + placeholder: '', + description: 'both (default), stats for the statistics alone, or records for the rows alone' + }; +} + +/** Which state a refund or a payout is in, as the operation behind it names them. */ +function recordStatusOption(id: OperationId, what: string): OptionSpec { + return { + name: '--status', + type: 'string', + choices: choices(id, 'status'), + placeholder: '', + description: `Keep only ${what} in this state` + }; +} const DETAIL_LIMITS = 'Rows are capped at 90 days and 200 matching records per query; past either, no rows come back, ' + @@ -63,7 +78,7 @@ function dates(input: CommandInput): Record { }; } -function orderFilters(input: CommandInput): Record { +function orderFilters(input: CommandInput): Record { const statuses = (input.options.status as string[] | undefined) ?? []; return { paid_status: statuses.length > 0 ? statuses : undefined, @@ -79,7 +94,7 @@ export const TRADE: readonly Command[] = [ description: 'Answers without a merchant too: merchant_stage says where the application stands. Bank card, phone, ' + 'email and ID numbers come back masked, as the product shows them.', - request: () => ({ method: 'GET', path: `${API}/trade/merchant` }) + request: () => call('getTradeMerchant') }, { path: ['transaction', 'list'], @@ -99,16 +114,15 @@ export const TRADE: readonly Command[] = [ }, ...PAGINATION_OPTIONS ], - request: (input) => ({ - method: 'GET', - path: `${API}/trade/transactions`, - query: { - ...dates(input), - ...orderFilters(input), - order_no: input.options.order_no as string | undefined, - ...paging(input) - } - }), + request: (input) => + call('listTradeTransactions', { + query: { + ...dates(input), + ...orderFilters(input), + order_no: input.options.order_no as string | undefined, + ...paging(input) + } + }), paginate: { items: 'transactions', cursor: 'next' }, examples: [ 'jinshuju transaction list --from 2026-09-01 --to 2026-09-28', @@ -132,11 +146,10 @@ export const TRADE: readonly Command[] = [ }, ...ORDER_FILTER_OPTIONS ], - request: (input) => ({ - method: 'GET', - path: `${API}/trade/transactions/aggregate`, - query: { ...dates(input), group_by: GROUP_BY[input.options.group_by as string], ...orderFilters(input) } - }), + request: (input) => + call('aggregateTradeTransactions', { + query: { ...dates(input), group_by: GROUP_BY[input.options.group_by as string], ...orderFilters(input) } + }), examples: ['jinshuju transaction aggregate --from 2026-09-01 --to 2026-09-28 --group-by channel'] }, { @@ -145,28 +158,21 @@ export const TRADE: readonly Command[] = [ description: 'Defaults to the last 90 days; --refund-no alone searches all time. ' + DETAIL_LIMITS, options: [ ...DATE_OPTIONS, - { - name: '--status', - type: 'string', - choices: RECORD_STATUSES, - placeholder: '', - description: 'Keep only refunds in this state' - }, + recordStatusOption('listTradeRefunds', 'refunds'), { name: '--refund-no', type: 'string', placeholder: '', description: 'Exact match on 退款流水号' }, - INCLUDE_OPTION, + includeOption('listTradeRefunds'), ...PAGINATION_OPTIONS ], - request: (input) => ({ - method: 'GET', - path: `${API}/trade/refunds`, - query: { - ...dates(input), - status: input.options.status as string | undefined, - refund_no: input.options.refund_no as string | undefined, - include: input.options.include as string | undefined, - ...paging(input) - } - }), + request: (input) => + call('listTradeRefunds', { + query: { + ...dates(input), + status: input.options.status as string | undefined, + refund_no: input.options.refund_no as string | undefined, + include: input.options.include as string | undefined, + ...paging(input) + } + }), paginate: { items: 'refunds', cursor: 'next' } }, { @@ -175,26 +181,19 @@ export const TRADE: readonly Command[] = [ description: 'Defaults to the last 90 days. ' + DETAIL_LIMITS, options: [ ...DATE_OPTIONS, - { - name: '--status', - type: 'string', - choices: RECORD_STATUSES, - placeholder: '', - description: 'Keep only payouts in this state' - }, - INCLUDE_OPTION, + recordStatusOption('listTradePayouts', 'payouts'), + includeOption('listTradePayouts'), ...PAGINATION_OPTIONS ], - request: (input) => ({ - method: 'GET', - path: `${API}/trade/payouts`, - query: { - ...dates(input), - status: input.options.status as string | undefined, - include: input.options.include as string | undefined, - ...paging(input) - } - }), + request: (input) => + call('listTradePayouts', { + query: { + ...dates(input), + status: input.options.status as string | undefined, + include: input.options.include as string | undefined, + ...paging(input) + } + }), paginate: { items: 'payouts', cursor: 'next' } } ]; diff --git a/src/commands/upload.ts b/src/commands/upload.ts index b8adaee..e70e69a 100644 --- a/src/commands/upload.ts +++ b/src/commands/upload.ts @@ -1,7 +1,7 @@ import { UsageError } from '../options.js'; import type { HttpClient, HttpRequest } from '../http.js'; import { progress } from '../progress.js'; -import { API } from './shared.js'; +import { call, type OperationId } from '../spec.js'; import { readFileSync } from 'node:fs'; import { basename, extname } from 'node:path'; @@ -23,12 +23,24 @@ const UPLOAD_TYPES: Record = { '.pdf': 'application/pdf' }; +/** + * What the CLI uploads a form image as. A test holds the list to the spec's + * `image_type`, which is where it would otherwise drift. + */ +export const FORM_IMAGE_TYPES = ['description', 'header', 'wallpaper', 'opensearch_header'] as const; +export type FormImageType = (typeof FORM_IMAGE_TYPES)[number]; + /** * A file on disk, as multipart. The three endpoints that take one authenticate * like every other request, so there is no ticket to fetch first: the file goes * up in one call and comes back with an id to refer to it by. */ -export function upload(path: string, file: string, extra: Record = {}): HttpRequest { +export function upload( + id: OperationId, + path: Record, + file: string, + extra: Record = {} +): HttpRequest { const form = new FormData(); let bytes: Buffer; try { @@ -39,19 +51,19 @@ export function upload(path: string, file: string, extra: Record const type = UPLOAD_TYPES[extname(file).toLowerCase()]; form.append('file', new Blob([new Uint8Array(bytes)], type ? { type } : undefined), basename(file)); for (const [name, value] of Object.entries(extra)) form.append(name, value); - return { method: 'POST', path, form }; + return call(id, { path, form }); } /** * An image that a form's theme or one of its public queries refers to by id, uploaded * with `imageType` saying which (header, wallpaper, opensearch_header). */ -export async function uploadFormImage(client: HttpClient, file: string, imageType: string): Promise { +export async function uploadFormImage(client: HttpClient, file: string, imageType: FormImageType): Promise { const watching = progress(); watching.step(`uploading ${basename(file)}…`); try { const uploaded = await client.request<{ attachment_id: string }>( - upload(`${API}/form_image_attachments`, file, { image_type: imageType }) + upload('uploadFormImage', {}, file, { image_type: imageType }) ); return uploaded.attachment_id; } finally { diff --git a/src/commands/view.ts b/src/commands/view.ts index 2c3d095..d25af57 100644 --- a/src/commands/view.ts +++ b/src/commands/view.ts @@ -6,7 +6,8 @@ import { SORT_OPTION, type OptionSpec } from '../options.js'; -import { YES_OPTION, confirmed, containerPath, filterConditions, overriding, sortRules } from './shared.js'; +import { bodyChoices } from '../spec.js'; +import { YES_OPTION, confirmed, filterConditions, onContainer, overriding, sortRules } from './shared.js'; import type { Command, CommandInput } from './types.js'; /** What `view create` and `view edit` both take, beyond the name. */ @@ -14,7 +15,7 @@ const VIEW_OPTIONS: readonly OptionSpec[] = [ { name: '--type', type: 'string', - choices: ['grid', 'kanban', 'stats'], + choices: bodyChoices('createFormView', 'view_type'), placeholder: '', description: 'View type' }, @@ -40,14 +41,14 @@ export const VIEW: readonly Command[] = [ path: ['view', 'list'], summary: 'List the views of a form or table', options: [...CONTAINER_OPTIONS], - request: (input) => ({ method: 'GET', path: `${containerPath(input)}/views` }) + request: (input) => onContainer(input, 'listFormViews', 'listTableViews') }, { path: ['view', 'get'], summary: 'Show a view', args: [{ name: 'view', required: true, description: 'View token, six letters and digits, e.g. aB3dE9' }], options: [...CONTAINER_OPTIONS], - request: (input) => ({ method: 'GET', path: `${containerPath(input)}/views/${input.args.view}` }) + request: (input) => onContainer(input, 'getFormView', 'getTableView', { path: { view_token: input.args.view } }) }, { path: ['view', 'create'], @@ -57,11 +58,8 @@ export const VIEW: readonly Command[] = [ 'Anything without a flag of its own — kanban grouping, visibility — goes through --json.', args: [{ name: 'name', required: true, description: 'View name' }], options: [...CONTAINER_OPTIONS, ...VIEW_OPTIONS], - request: (input) => ({ - method: 'POST', - path: `${containerPath(input)}/views`, - body: { ...viewBody(input), name: input.args.name } - }), + request: (input) => + onContainer(input, 'createFormView', 'createTableView', { body: { ...viewBody(input), name: input.args.name } }), examples: ["jinshuju view create --form Kp7mQ2 高分 --filter 'field_3 gte 80' --sort created_at:desc"] }, { @@ -74,11 +72,11 @@ export const VIEW: readonly Command[] = [ { name: '--name', type: 'string', placeholder: '', description: 'Rename the view' }, ...VIEW_OPTIONS ], - request: (input) => ({ - method: 'PATCH', - path: `${containerPath(input)}/views/${input.args.view}`, - body: overriding(viewBody(input), { name: input.options.name }) - }) + request: (input) => + onContainer(input, 'updateFormView', 'updateTableView', { + path: { view_token: input.args.view }, + body: overriding(viewBody(input), { name: input.options.name }) + }) }, { path: ['view', 'delete'], @@ -87,7 +85,7 @@ export const VIEW: readonly Command[] = [ options: [...CONTAINER_OPTIONS, YES_OPTION], request: (input) => { confirmed(input, `Deleting view ${input.args.view}`); - return { method: 'DELETE', path: `${containerPath(input)}/views/${input.args.view}` }; + return onContainer(input, 'deleteFormView', 'deleteTableView', { path: { view_token: input.args.view } }); } } ]; diff --git a/src/generated/operations.ts b/src/generated/operations.ts new file mode 100644 index 0000000..25ae648 --- /dev/null +++ b/src/generated/operations.ts @@ -0,0 +1,3933 @@ +// Generated by scripts/generate.mjs from spec/openapi.yaml (金数据 API v1 1.0). +// Do not edit: change the spec and run `npm run generate`. + +import type { Operation, Schema } from '../spec.js'; + +/** The path every operation's path is relative to, from `servers`. */ +export const BASE_PATH = '/api/v1'; + +export type OperationId = + | 'aggregateEntries' + | 'aggregateFormViewEntries' + | 'aggregateTableEntries' + | 'aggregateTableViewEntries' + | 'aggregateTradeTransactions' + | 'checkFormFieldData' + | 'checkTableFieldData' + | 'copyForm' + | 'countEntries' + | 'countEntriesAcrossForms' + | 'countFormViewEntries' + | 'countTableEntries' + | 'countTableViewEntries' + | 'createEntriesBatch' + | 'createEntry' + | 'createEntryComment' + | 'createEntryImport' + | 'createEntryLegacy' + | 'createFillFormEntry' + | 'createFillViewEntry' + | 'createFolder' + | 'createForm' + | 'createFormView' + | 'createFormWebhook' + | 'createOpensearchQuery' + | 'createPredefinedChoicesSet' + | 'createTable' + | 'createTableEntry' + | 'createTableEntryComment' + | 'createTableView' + | 'createTemplateUsage' + | 'createWeixinOauthSession' + | 'deleteEntry' + | 'deleteEntryComment' + | 'deleteFormView' + | 'deleteFormWebhook' + | 'deletePredefinedChoicesSet' + | 'deleteTable' + | 'deleteTableEntry' + | 'deleteTableEntryComment' + | 'deleteTableView' + | 'getBillingAccount' + | 'getCurrentUser' + | 'getEntry' + | 'getEntryImport' + | 'getEntryStats' + | 'getEvaluationSetting' + | 'getExamSetting' + | 'getFieldType' + | 'getFillFormPage' + | 'getFillViewPage' + | 'getForm' + | 'getFormSetting' + | 'getFormView' + | 'getManuallyCloseRule' + | 'getOpensearchQuery' + | 'getTable' + | 'getTableEntry' + | 'getTableView' + | 'getTradeMerchant' + | 'listBillingAccountInvoices' + | 'listBillingAccountPaymentHistories' + | 'listBillingAccountUsers' + | 'listContacts' + | 'listEntries' + | 'listEntryComments' + | 'listExtendedAttributes' + | 'listFieldRules' + | 'listFieldTypes' + | 'listFolders' + | 'listFormCooperators' + | 'listFormViewEntries' + | 'listFormViews' + | 'listForms' + | 'listMyFilledForms' + | 'listMySubmittedEntries' + | 'listOpensearchQueries' + | 'listOpensearchQuerySuggestions' + | 'listPredefinedChoicesSets' + | 'listSmsAuditStatuses' + | 'listTableEntries' + | 'listTableEntryComments' + | 'listTableViewEntries' + | 'listTableViews' + | 'listTables' + | 'listTradePayouts' + | 'listTradeRefunds' + | 'listTradeTransactions' + | 'moveFormToFolder' + | 'moveTableToFolder' + | 'patchEntriesBatch' + | 'previewFormFieldTypeChange' + | 'previewTableFieldTypeChange' + | 'replaceEntry' + | 'replaceTableEntry' + | 'searchEntriesAcrossForms' + | 'searchMySubmittedEntries' + | 'summarizeEntries' + | 'summarizeFormViewEntries' + | 'summarizeTableEntries' + | 'summarizeTableViewEntries' + | 'updateAccessPolicy' + | 'updateEntry' + | 'updateEntryComment' + | 'updateEntryViaPost' + | 'updateEvaluationSetting' + | 'updateExamSetting' + | 'updateForm' + | 'updateFormSetting' + | 'updateFormTheme' + | 'updateFormView' + | 'updateManuallyCloseRule' + | 'updateOpensearchQuery' + | 'updatePredefinedChoicesSet' + | 'updateTable' + | 'updateTableEntry' + | 'updateTableEntryComment' + | 'updateTableEntryViaPost' + | 'updateTableView' + | 'uploadEntryAttachment' + | 'uploadFormImage' + | 'uploadImportFile' + | 'verifyRedirectParams'; + +export const OPERATIONS: { readonly [id in OperationId]: Operation } = { + aggregateEntries: { + method: 'GET', + path: '/forms/{form_token}/entries/aggregate', + parameters: [ + { name: 'form_token', in: 'path', required: true, schema: { type: 'string' } }, + { name: 'metrics', in: 'query', schema: { type: 'string' } }, + { name: 'dimensions', in: 'query', schema: { type: 'string' } }, + { name: 'limit', in: 'query', schema: { type: 'integer' } }, + { name: 'filters', in: 'query', schema: { type: 'string' } }, + { name: 'keyword', in: 'query', schema: { type: 'string', maxLength: 200 } } + ] + }, + aggregateFormViewEntries: { + method: 'GET', + path: '/forms/{form_token}/views/{view_token}/entries/aggregate', + parameters: [ + { name: 'form_token', in: 'path', required: true, schema: { type: 'string' } }, + { name: 'view_token', in: 'path', required: true, schema: { type: 'string' } }, + { name: 'metrics', in: 'query', schema: { type: 'string' } }, + { name: 'dimensions', in: 'query', schema: { type: 'string' } }, + { name: 'limit', in: 'query', schema: { type: 'integer' } }, + { name: 'filters', in: 'query', schema: { type: 'string' } }, + { name: 'keyword', in: 'query', schema: { type: 'string', maxLength: 200 } } + ] + }, + aggregateTableEntries: { + method: 'GET', + path: '/tables/{table_token}/entries/aggregate', + parameters: [ + { name: 'table_token', in: 'path', required: true, schema: { type: 'string' } }, + { name: 'metrics', in: 'query', schema: { type: 'string' } }, + { name: 'dimensions', in: 'query', schema: { type: 'string' } }, + { name: 'limit', in: 'query', schema: { type: 'integer' } }, + { name: 'filters', in: 'query', schema: { type: 'string' } }, + { name: 'keyword', in: 'query', schema: { type: 'string', maxLength: 200 } } + ] + }, + aggregateTableViewEntries: { + method: 'GET', + path: '/tables/{table_token}/views/{view_token}/entries/aggregate', + parameters: [ + { name: 'table_token', in: 'path', required: true, schema: { type: 'string' } }, + { name: 'view_token', in: 'path', required: true, schema: { type: 'string' } }, + { name: 'metrics', in: 'query', schema: { type: 'string' } }, + { name: 'dimensions', in: 'query', schema: { type: 'string' } }, + { name: 'limit', in: 'query', schema: { type: 'integer' } }, + { name: 'filters', in: 'query', schema: { type: 'string' } }, + { name: 'keyword', in: 'query', schema: { type: 'string', maxLength: 200 } } + ] + }, + aggregateTradeTransactions: { + method: 'GET', + path: '/trade/transactions/aggregate', + parameters: [ + { name: 'start_date', in: 'query', schema: { type: 'string' } }, + { name: 'end_date', in: 'query', schema: { type: 'string' } }, + { name: 'group_by', in: 'query', schema: { type: 'string', enum: ['time', 'paid_status', 'payment_channel'] } }, + { + name: 'paid_status', + in: 'query', + explode: false, + schema: { + type: 'array', + items: { type: 'string', enum: ['paid', 'unpaid', 'refunded', 'refunding', 'partial_refunded'] } + } + }, + { name: 'payment_channel', in: 'query', schema: { type: 'string', enum: ['wxpay', 'alipay'] } }, + { name: 'form_token', in: 'query', schema: { type: 'string' } } + ] + }, + checkFormFieldData: { + method: 'GET', + path: '/forms/{form_token}/fields/check', + parameters: [ + { name: 'form_token', in: 'path', required: true, schema: { type: 'string' } }, + { name: 'checks', in: 'query', required: true, schema: { type: 'string' } } + ] + }, + checkTableFieldData: { + method: 'GET', + path: '/tables/{table_token}/fields/check', + parameters: [ + { name: 'table_token', in: 'path', required: true, schema: { type: 'string' } }, + { name: 'checks', in: 'query', required: true, schema: { type: 'string' } } + ] + }, + copyForm: { + method: 'POST', + path: '/forms/{form_token}/copy', + parameters: [{ name: 'form_token', in: 'path', required: true, schema: { type: 'string' } }], + body: { content: { 'application/json': { $ref: 'CopyFormInput' } } } + }, + countEntries: { + method: 'GET', + path: '/forms/{form_token}/entries/count', + parameters: [ + { name: 'form_token', in: 'path', required: true, schema: { type: 'string' } }, + { name: 'filters', in: 'query', schema: { type: 'string' } }, + { name: 'keyword', in: 'query', schema: { type: 'string', maxLength: 200 } } + ] + }, + countEntriesAcrossForms: { + method: 'GET', + path: '/entries/count', + parameters: [ + { name: 'form_tokens', in: 'query', schema: { type: 'string' } }, + { name: 'filters', in: 'query', schema: { type: 'string' } } + ] + }, + countFormViewEntries: { + method: 'GET', + path: '/forms/{form_token}/views/{view_token}/entries/count', + parameters: [ + { name: 'form_token', in: 'path', required: true, schema: { type: 'string' } }, + { name: 'view_token', in: 'path', required: true, schema: { type: 'string' } }, + { name: 'filters', in: 'query', schema: { type: 'string' } }, + { name: 'keyword', in: 'query', schema: { type: 'string', maxLength: 200 } } + ] + }, + countTableEntries: { + method: 'GET', + path: '/tables/{table_token}/entries/count', + parameters: [ + { name: 'table_token', in: 'path', required: true, schema: { type: 'string' } }, + { name: 'filters', in: 'query', schema: { type: 'string' } }, + { name: 'keyword', in: 'query', schema: { type: 'string', maxLength: 200 } } + ] + }, + countTableViewEntries: { + method: 'GET', + path: '/tables/{table_token}/views/{view_token}/entries/count', + parameters: [ + { name: 'table_token', in: 'path', required: true, schema: { type: 'string' } }, + { name: 'view_token', in: 'path', required: true, schema: { type: 'string' } }, + { name: 'filters', in: 'query', schema: { type: 'string' } }, + { name: 'keyword', in: 'query', schema: { type: 'string', maxLength: 200 } } + ] + }, + createEntriesBatch: { + method: 'POST', + path: '/forms/{form_token}/entries/batch', + parameters: [{ name: 'form_token', in: 'path', required: true, schema: { type: 'string' } }], + body: { + required: true, + content: { + 'application/json': { + type: 'object', + required: ['entries'], + properties: { entries: { type: 'array', minItems: 1, maxItems: 200, items: { $ref: 'EntryInput' } } }, + additionalProperties: false + } + } + } + }, + createEntry: { + method: 'POST', + path: '/forms/{form_token}/entries', + parameters: [{ name: 'form_token', in: 'path', required: true, schema: { type: 'string' } }], + body: { required: true, content: { 'application/json': { $ref: 'EntryInput' } } } + }, + createEntryComment: { + method: 'POST', + path: '/forms/{form_token}/entries/{serial_number}/comments', + parameters: [ + { name: 'form_token', in: 'path', required: true, schema: { type: 'string' } }, + { name: 'serial_number', in: 'path', required: true, schema: { type: 'integer' } } + ], + body: { + required: true, + content: { + 'application/json': { allOf: [{ $ref: 'EntryCommentInput' }, { type: 'object', required: ['content'] }] } + } + } + }, + createEntryImport: { + method: 'POST', + path: '/forms/{form_token}/entry_imports', + parameters: [{ name: 'form_token', in: 'path', required: true, schema: { type: 'string' } }], + body: { required: true, content: { 'application/json': { $ref: 'EntryImportInput' } } } + }, + createEntryLegacy: { + method: 'POST', + path: '/forms/{form_token}', + parameters: [{ name: 'form_token', in: 'path', required: true, schema: { type: 'string' } }], + body: { required: true, content: { 'application/json': { $ref: 'EntryInput' } } } + }, + createFillFormEntry: { + method: 'POST', + path: '/fill/f/{form_token}/entries', + parameters: [{ name: 'form_token', in: 'path', required: true, schema: { type: 'string' } }], + body: { required: true, content: { 'application/json': { $ref: 'FillEntryRequest' } } } + }, + createFillViewEntry: { + method: 'POST', + path: '/fill/v/{view_token}/entries', + parameters: [{ name: 'view_token', in: 'path', required: true, schema: { type: 'string' } }], + body: { required: true, content: { 'application/json': { $ref: 'FillEntryRequest' } } } + }, + createFolder: { + method: 'POST', + path: '/folders', + parameters: [], + body: { required: true, content: { 'application/json': { $ref: 'FolderInput' } } } + }, + createForm: { + method: 'POST', + path: '/forms', + parameters: [], + body: { required: true, content: { 'application/json': { $ref: 'FormCreateInput' } } } + }, + createFormView: { + method: 'POST', + path: '/forms/{form_token}/views', + parameters: [{ name: 'form_token', in: 'path', required: true, schema: { type: 'string' } }], + body: { + required: true, + content: { 'application/json': { allOf: [{ $ref: 'FormViewInput' }, { type: 'object', required: ['name'] }] } } + } + }, + createFormWebhook: { + method: 'POST', + path: '/forms/{form_token}/webhook', + parameters: [{ name: 'form_token', in: 'path', required: true, schema: { type: 'string' } }], + body: { required: true, content: { 'application/json': { $ref: 'WebhookInput' } } } + }, + createOpensearchQuery: { + method: 'POST', + path: '/opensearch/queries', + parameters: [], + body: { + required: true, + content: { + 'application/json': { + allOf: [ + { $ref: 'OpensearchQueryInput' }, + { + type: 'object', + required: ['form_token', 'name', 'display_field_rules'], + properties: { form_token: { type: 'string' } } + } + ] + } + } + } + }, + createPredefinedChoicesSet: { + method: 'POST', + path: '/predefined_choices_sets', + parameters: [], + body: { + required: true, + content: { + 'application/json': { + type: 'object', + required: ['predefined_choices_set'], + properties: { predefined_choices_set: { $ref: 'PredefinedChoicesSetInput' } }, + additionalProperties: false + } + } + } + }, + createTable: { + method: 'POST', + path: '/tables', + parameters: [], + body: { required: true, content: { 'application/json': { $ref: 'TableCreateInput' } } } + }, + createTableEntry: { + method: 'POST', + path: '/tables/{table_token}/entries', + parameters: [{ name: 'table_token', in: 'path', required: true, schema: { type: 'string' } }], + body: { required: true, content: { 'application/json': { $ref: 'EntryInput' } } } + }, + createTableEntryComment: { + method: 'POST', + path: '/tables/{table_token}/entries/{serial_number}/comments', + parameters: [ + { name: 'table_token', in: 'path', required: true, schema: { type: 'string' } }, + { name: 'serial_number', in: 'path', required: true, schema: { type: 'integer' } } + ], + body: { + required: true, + content: { + 'application/json': { allOf: [{ $ref: 'EntryCommentInput' }, { type: 'object', required: ['content'] }] } + } + } + }, + createTableView: { + method: 'POST', + path: '/tables/{table_token}/views', + parameters: [{ name: 'table_token', in: 'path', required: true, schema: { type: 'string' } }], + body: { + required: true, + content: { 'application/json': { allOf: [{ $ref: 'FormViewInput' }, { type: 'object', required: ['name'] }] } } + } + }, + createTemplateUsage: { + method: 'POST', + path: '/template_usages', + parameters: [], + body: { required: true, content: { 'application/json': { $ref: 'TemplateUsageInput' } } } + }, + createWeixinOauthSession: { + method: 'POST', + path: '/forms/{form_token}/weixin_oauth_session', + parameters: [{ name: 'form_token', in: 'path', required: true, schema: { type: 'string' } }], + body: { required: true, content: { 'application/json': { $ref: 'FormWeixinOauthSessionInput' } } } + }, + deleteEntry: { + method: 'DELETE', + path: '/forms/{form_token}/entries/{serial_number}', + parameters: [ + { name: 'form_token', in: 'path', required: true, schema: { type: 'string' } }, + { name: 'serial_number', in: 'path', required: true, schema: { type: 'integer' } } + ] + }, + deleteEntryComment: { + method: 'DELETE', + path: '/forms/{form_token}/entries/{serial_number}/comments/{comment_id}', + parameters: [ + { name: 'form_token', in: 'path', required: true, schema: { type: 'string' } }, + { name: 'serial_number', in: 'path', required: true, schema: { type: 'integer' } }, + { name: 'comment_id', in: 'path', required: true, schema: { type: 'string' } } + ] + }, + deleteFormView: { + method: 'DELETE', + path: '/forms/{form_token}/views/{view_token}', + parameters: [ + { name: 'form_token', in: 'path', required: true, schema: { type: 'string' } }, + { name: 'view_token', in: 'path', required: true, schema: { type: 'string' } } + ] + }, + deleteFormWebhook: { + method: 'DELETE', + path: '/forms/{form_token}/webhook', + parameters: [{ name: 'form_token', in: 'path', required: true, schema: { type: 'string' } }] + }, + deletePredefinedChoicesSet: { + method: 'DELETE', + path: '/predefined_choices_sets/{id}', + parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }] + }, + deleteTable: { + method: 'DELETE', + path: '/tables/{table_token}', + parameters: [{ name: 'table_token', in: 'path', required: true, schema: { type: 'string' } }] + }, + deleteTableEntry: { + method: 'DELETE', + path: '/tables/{table_token}/entries/{serial_number}', + parameters: [ + { name: 'table_token', in: 'path', required: true, schema: { type: 'string' } }, + { name: 'serial_number', in: 'path', required: true, schema: { type: 'integer' } } + ] + }, + deleteTableEntryComment: { + method: 'DELETE', + path: '/tables/{table_token}/entries/{serial_number}/comments/{comment_id}', + parameters: [ + { name: 'table_token', in: 'path', required: true, schema: { type: 'string' } }, + { name: 'serial_number', in: 'path', required: true, schema: { type: 'integer' } }, + { name: 'comment_id', in: 'path', required: true, schema: { type: 'string' } } + ] + }, + deleteTableView: { + method: 'DELETE', + path: '/tables/{table_token}/views/{view_token}', + parameters: [ + { name: 'table_token', in: 'path', required: true, schema: { type: 'string' } }, + { name: 'view_token', in: 'path', required: true, schema: { type: 'string' } } + ] + }, + getBillingAccount: { method: 'GET', path: '/billing_account', parameters: [] }, + getCurrentUser: { method: 'GET', path: '/me', parameters: [] }, + getEntry: { + method: 'GET', + path: '/forms/{form_token}/entries/{serial_number}', + parameters: [ + { name: 'form_token', in: 'path', required: true, schema: { type: 'string' } }, + { name: 'serial_number', in: 'path', required: true, schema: { type: 'integer' } }, + { name: 'fields', in: 'query', schema: { type: 'string' } }, + { name: 'include_labels', in: 'query', schema: { type: 'boolean' } } + ] + }, + getEntryImport: { + method: 'GET', + path: '/forms/{form_token}/entry_imports/{id}', + parameters: [ + { name: 'form_token', in: 'path', required: true, schema: { type: 'string' } }, + { name: 'id', in: 'path', required: true, schema: { type: 'string' } } + ] + }, + getEntryStats: { + method: 'GET', + path: '/entries/stats', + parameters: [ + { name: 'from', in: 'query', required: true, schema: { type: 'string' } }, + { name: 'to', in: 'query', schema: { type: 'string' } }, + { name: 'kind', in: 'query', schema: { type: 'string', enum: ['form', 'table'] } }, + { name: 'by', in: 'query', schema: { type: 'string', enum: ['day', 'week', 'month'] } }, + { name: 'limit', in: 'query', schema: { type: 'integer' } } + ] + }, + getEvaluationSetting: { + method: 'GET', + path: '/forms/{form_token}/evaluation_setting', + parameters: [{ name: 'form_token', in: 'path', required: true, schema: { type: 'string' } }] + }, + getExamSetting: { + method: 'GET', + path: '/forms/{form_token}/exam_setting', + parameters: [{ name: 'form_token', in: 'path', required: true, schema: { type: 'string' } }] + }, + getFieldType: { + method: 'GET', + path: '/field_types/{type}', + parameters: [ + { name: 'type', in: 'path', required: true, schema: { type: 'string', pattern: '^[A-Za-z]+$' } }, + { name: 'kind', in: 'query', schema: { type: 'string' } } + ] + }, + getFillFormPage: { + method: 'GET', + path: '/fill/f/{form_token}', + parameters: [{ name: 'form_token', in: 'path', required: true, schema: { type: 'string' } }] + }, + getFillViewPage: { + method: 'GET', + path: '/fill/v/{view_token}', + parameters: [{ name: 'view_token', in: 'path', required: true, schema: { type: 'string' } }] + }, + getForm: { + method: 'GET', + path: '/forms/{form_token}', + parameters: [ + { name: 'form_token', in: 'path', required: true, schema: { type: 'string' } }, + { name: 'include_analytics', in: 'query', schema: { type: 'boolean' } }, + { name: 'include_theme', in: 'query', schema: { type: 'boolean' } }, + { name: 'include_field_rules', in: 'query', schema: { type: 'boolean' } }, + { name: 'include_extended_attributes', in: 'query', schema: { type: 'boolean' } }, + { name: 'include_transactions', in: 'query', schema: { type: 'boolean' } } + ] + }, + getFormSetting: { + method: 'GET', + path: '/forms/{form_token}/setting', + parameters: [{ name: 'form_token', in: 'path', required: true, schema: { type: 'string' } }] + }, + getFormView: { + method: 'GET', + path: '/forms/{form_token}/views/{view_token}', + parameters: [ + { name: 'form_token', in: 'path', required: true, schema: { type: 'string' } }, + { name: 'view_token', in: 'path', required: true, schema: { type: 'string' } } + ] + }, + getManuallyCloseRule: { + method: 'GET', + path: '/forms/{form_token}/manually_close_rule', + parameters: [{ name: 'form_token', in: 'path', required: true, schema: { type: 'string' } }] + }, + getOpensearchQuery: { + method: 'GET', + path: '/opensearch/queries/{query_token}', + parameters: [{ name: 'query_token', in: 'path', required: true, schema: { type: 'string' } }] + }, + getTable: { + method: 'GET', + path: '/tables/{table_token}', + parameters: [ + { name: 'table_token', in: 'path', required: true, schema: { type: 'string' } }, + { name: 'include_analytics', in: 'query', schema: { type: 'boolean' } } + ] + }, + getTableEntry: { + method: 'GET', + path: '/tables/{table_token}/entries/{serial_number}', + parameters: [ + { name: 'table_token', in: 'path', required: true, schema: { type: 'string' } }, + { name: 'serial_number', in: 'path', required: true, schema: { type: 'integer' } }, + { name: 'fields', in: 'query', schema: { type: 'string' } }, + { name: 'include_labels', in: 'query', schema: { type: 'boolean' } } + ] + }, + getTableView: { + method: 'GET', + path: '/tables/{table_token}/views/{view_token}', + parameters: [ + { name: 'table_token', in: 'path', required: true, schema: { type: 'string' } }, + { name: 'view_token', in: 'path', required: true, schema: { type: 'string' } } + ] + }, + getTradeMerchant: { method: 'GET', path: '/trade/merchant', parameters: [] }, + listBillingAccountInvoices: { method: 'GET', path: '/billing_account/invoices', parameters: [] }, + listBillingAccountPaymentHistories: { + method: 'GET', + path: '/billing_account/payment_histories', + parameters: [ + { name: 'start_date', in: 'query', schema: { type: 'string' } }, + { name: 'end_date', in: 'query', schema: { type: 'string' } }, + { name: 'verb', in: 'query', schema: { type: 'string' } }, + { name: 'limit', in: 'query', schema: { type: 'integer' } }, + { name: 'include_balance', in: 'query', schema: { type: 'boolean' } } + ] + }, + listBillingAccountUsers: { + method: 'GET', + path: '/billing_account/users', + parameters: [{ name: 'limit', in: 'query', schema: { type: 'integer', minimum: 1, maximum: 100 } }] + }, + listContacts: { + method: 'GET', + path: '/contacts', + parameters: [{ name: 'next', in: 'query', schema: { type: 'string' } }] + }, + listEntries: { + method: 'GET', + path: '/forms/{form_token}/entries', + parameters: [ + { name: 'form_token', in: 'path', required: true, schema: { type: 'string' } }, + { name: 'fields', in: 'query', schema: { type: 'string' } }, + { name: 'filters', in: 'query', schema: { type: 'string' } }, + { name: 'keyword', in: 'query', schema: { type: 'string', maxLength: 200 } }, + { name: 'sort', in: 'query', schema: { type: 'string' } }, + { name: 'created_at', in: 'query', schema: { type: 'string' } }, + { name: 'next', in: 'query', schema: { type: ['integer', 'string'] } }, + { name: 'skip', in: 'query', schema: { type: 'integer', minimum: 0, maximum: 10000 } }, + { name: 'include_labels', in: 'query', schema: { type: 'boolean' } }, + { name: 'limit', in: 'query', schema: { type: 'integer' } } + ] + }, + listEntryComments: { + method: 'GET', + path: '/forms/{form_token}/entries/{serial_number}/comments', + parameters: [ + { name: 'form_token', in: 'path', required: true, schema: { type: 'string' } }, + { name: 'serial_number', in: 'path', required: true, schema: { type: 'integer' } } + ] + }, + listExtendedAttributes: { + method: 'GET', + path: '/forms/{form_token}/extended_attributes', + parameters: [{ name: 'form_token', in: 'path', required: true, schema: { type: 'string' } }] + }, + listFieldRules: { + method: 'GET', + path: '/forms/{form_token}/field_rules', + parameters: [{ name: 'form_token', in: 'path', required: true, schema: { type: 'string' } }] + }, + listFieldTypes: { + method: 'GET', + path: '/field_types', + parameters: [{ name: 'kind', in: 'query', schema: { type: 'string' } }] + }, + listFolders: { + method: 'GET', + path: '/folders', + parameters: [{ name: 'limit', in: 'query', schema: { type: 'integer', minimum: 1, maximum: 100 } }] + }, + listFormCooperators: { + method: 'GET', + path: '/forms/{form_token}/cooperators', + parameters: [{ name: 'form_token', in: 'path', required: true, schema: { type: 'string' } }] + }, + listFormViewEntries: { + method: 'GET', + path: '/forms/{form_token}/views/{view_token}/entries', + parameters: [ + { name: 'form_token', in: 'path', required: true, schema: { type: 'string' } }, + { name: 'view_token', in: 'path', required: true, schema: { type: 'string' } }, + { name: 'next', in: 'query', schema: { type: 'integer', minimum: 0 } }, + { name: 'include_labels', in: 'query', schema: { type: 'boolean' } } + ] + }, + listFormViews: { + method: 'GET', + path: '/forms/{form_token}/views', + parameters: [{ name: 'form_token', in: 'path', required: true, schema: { type: 'string' } }] + }, + listForms: { + method: 'GET', + path: '/forms', + parameters: [ + { name: 'q', in: 'query', schema: { type: 'string' } }, + { name: 'filters', in: 'query', schema: { type: 'string' } }, + { name: 'sort', in: 'query', schema: { type: 'string' } }, + { name: 'next', in: 'query', schema: { type: 'string' } }, + { name: 'skip', in: 'query', schema: { type: 'integer', minimum: 0, maximum: 10000 } }, + { name: 'limit', in: 'query', schema: { type: 'integer' } }, + { name: 'include_transactions', in: 'query', schema: { type: 'boolean' } } + ] + }, + listMyFilledForms: { + method: 'GET', + path: '/my/forms', + parameters: [ + { name: 'limit', in: 'query', schema: { type: 'integer' } }, + { name: 'next', in: 'query', schema: { type: 'integer', minimum: 0 } }, + { name: 'skip', in: 'query', schema: { type: 'integer', minimum: 0, maximum: 10000 } } + ] + }, + listMySubmittedEntries: { + method: 'GET', + path: '/my/forms/{form_token}/entries', + parameters: [ + { name: 'form_token', in: 'path', required: true, schema: { type: 'string' } }, + { name: 'fields', in: 'query', schema: { type: 'string' } }, + { name: 'filters', in: 'query', schema: { type: 'string' } }, + { name: 'keyword', in: 'query', schema: { type: 'string', maxLength: 200 } }, + { name: 'include_labels', in: 'query', schema: { type: 'boolean' } }, + { name: 'limit', in: 'query', schema: { type: 'integer' } }, + { name: 'next', in: 'query', schema: { type: 'integer', minimum: 0 } }, + { name: 'skip', in: 'query', schema: { type: 'integer', minimum: 0, maximum: 10000 } } + ] + }, + listOpensearchQueries: { + method: 'GET', + path: '/opensearch/queries', + parameters: [{ name: 'form_token', in: 'query', schema: { type: 'string' } }] + }, + listOpensearchQuerySuggestions: { + method: 'GET', + path: '/opensearch/query_suggestions', + parameters: [{ name: 'form_token', in: 'query', required: true, schema: { type: 'string' } }] + }, + listPredefinedChoicesSets: { method: 'GET', path: '/predefined_choices_sets', parameters: [] }, + listSmsAuditStatuses: { method: 'GET', path: '/sms/audit_statuses', parameters: [] }, + listTableEntries: { + method: 'GET', + path: '/tables/{table_token}/entries', + parameters: [ + { name: 'table_token', in: 'path', required: true, schema: { type: 'string' } }, + { name: 'fields', in: 'query', schema: { type: 'string' } }, + { name: 'filters', in: 'query', schema: { type: 'string' } }, + { name: 'keyword', in: 'query', schema: { type: 'string', maxLength: 200 } }, + { name: 'sort', in: 'query', schema: { type: 'string' } }, + { name: 'created_at', in: 'query', schema: { type: 'string' } }, + { name: 'next', in: 'query', schema: { type: ['integer', 'string'] } }, + { name: 'skip', in: 'query', schema: { type: 'integer', minimum: 0, maximum: 10000 } }, + { name: 'include_labels', in: 'query', schema: { type: 'boolean' } }, + { name: 'limit', in: 'query', schema: { type: 'integer' } } + ] + }, + listTableEntryComments: { + method: 'GET', + path: '/tables/{table_token}/entries/{serial_number}/comments', + parameters: [ + { name: 'table_token', in: 'path', required: true, schema: { type: 'string' } }, + { name: 'serial_number', in: 'path', required: true, schema: { type: 'integer' } } + ] + }, + listTableViewEntries: { + method: 'GET', + path: '/tables/{table_token}/views/{view_token}/entries', + parameters: [ + { name: 'table_token', in: 'path', required: true, schema: { type: 'string' } }, + { name: 'view_token', in: 'path', required: true, schema: { type: 'string' } }, + { name: 'next', in: 'query', schema: { type: 'integer', minimum: 0 } }, + { name: 'include_labels', in: 'query', schema: { type: 'boolean' } } + ] + }, + listTableViews: { + method: 'GET', + path: '/tables/{table_token}/views', + parameters: [{ name: 'table_token', in: 'path', required: true, schema: { type: 'string' } }] + }, + listTables: { + method: 'GET', + path: '/tables', + parameters: [ + { name: 'q', in: 'query', schema: { type: 'string' } }, + { name: 'filters', in: 'query', schema: { type: 'string' } }, + { name: 'sort', in: 'query', schema: { type: 'string' } }, + { name: 'next', in: 'query', schema: { type: 'string' } }, + { name: 'skip', in: 'query', schema: { type: 'integer', minimum: 0, maximum: 10000 } }, + { name: 'limit', in: 'query', schema: { type: 'integer' } } + ] + }, + listTradePayouts: { + method: 'GET', + path: '/trade/payouts', + parameters: [ + { name: 'start_date', in: 'query', schema: { type: 'string' } }, + { name: 'end_date', in: 'query', schema: { type: 'string' } }, + { name: 'status', in: 'query', schema: { type: 'string', enum: ['processing', 'success', 'error'] } }, + { name: 'include', in: 'query', schema: { type: 'string', enum: ['both', 'stats', 'records'] } }, + { name: 'limit', in: 'query', schema: { type: 'integer' } }, + { name: 'next', in: 'query', schema: { type: 'integer' } } + ] + }, + listTradeRefunds: { + method: 'GET', + path: '/trade/refunds', + parameters: [ + { name: 'start_date', in: 'query', schema: { type: 'string' } }, + { name: 'end_date', in: 'query', schema: { type: 'string' } }, + { name: 'status', in: 'query', schema: { type: 'string', enum: ['processing', 'success', 'error'] } }, + { name: 'refund_no', in: 'query', schema: { type: 'string' } }, + { name: 'include', in: 'query', schema: { type: 'string', enum: ['both', 'stats', 'records'] } }, + { name: 'limit', in: 'query', schema: { type: 'integer' } }, + { name: 'next', in: 'query', schema: { type: 'integer' } } + ] + }, + listTradeTransactions: { + method: 'GET', + path: '/trade/transactions', + parameters: [ + { name: 'start_date', in: 'query', schema: { type: 'string' } }, + { name: 'end_date', in: 'query', schema: { type: 'string' } }, + { + name: 'paid_status', + in: 'query', + explode: false, + schema: { + type: 'array', + items: { type: 'string', enum: ['paid', 'unpaid', 'refunded', 'refunding', 'partial_refunded'] } + } + }, + { name: 'payment_channel', in: 'query', schema: { type: 'string', enum: ['wxpay', 'alipay'] } }, + { name: 'form_token', in: 'query', schema: { type: 'string' } }, + { name: 'order_no', in: 'query', schema: { type: 'string' } }, + { name: 'limit', in: 'query', schema: { type: 'integer' } }, + { name: 'next', in: 'query', schema: { type: 'integer' } } + ] + }, + moveFormToFolder: { + method: 'PATCH', + path: '/forms/{form_token}/folder', + parameters: [{ name: 'form_token', in: 'path', required: true, schema: { type: 'string' } }], + body: { required: true, content: { 'application/json': { $ref: 'FormFolderMoveInput' } } } + }, + moveTableToFolder: { + method: 'PATCH', + path: '/tables/{table_token}/folder', + parameters: [{ name: 'table_token', in: 'path', required: true, schema: { type: 'string' } }], + body: { required: true, content: { 'application/json': { $ref: 'FormFolderMoveInput' } } } + }, + patchEntriesBatch: { + method: 'PATCH', + path: '/forms/{form_token}/entries/batch', + parameters: [{ name: 'form_token', in: 'path', required: true, schema: { type: 'string' } }], + body: { + required: true, + content: { + 'application/json': { + type: 'object', + required: ['entries'], + properties: { + entries: { + type: 'array', + minItems: 1, + maxItems: 200, + items: { + type: 'object', + required: ['serial_number', 'entry'], + properties: { serial_number: { type: 'integer' }, entry: { $ref: 'EntryInput' } }, + additionalProperties: false + } + } + }, + additionalProperties: false + } + } + } + }, + previewFormFieldTypeChange: { + method: 'GET', + path: '/forms/{form_token}/fields/preview_convert', + parameters: [ + { name: 'form_token', in: 'path', required: true, schema: { type: 'string' } }, + { name: 'checks', in: 'query', required: true, schema: { type: 'string' } } + ] + }, + previewTableFieldTypeChange: { + method: 'GET', + path: '/tables/{table_token}/fields/preview_convert', + parameters: [ + { name: 'table_token', in: 'path', required: true, schema: { type: 'string' } }, + { name: 'checks', in: 'query', required: true, schema: { type: 'string' } } + ] + }, + replaceEntry: { + method: 'PUT', + path: '/forms/{form_token}/entries/{serial_number}', + parameters: [ + { name: 'form_token', in: 'path', required: true, schema: { type: 'string' } }, + { name: 'serial_number', in: 'path', required: true, schema: { type: 'integer' } } + ], + body: { required: true, content: { 'application/json': { $ref: 'EntryInput' } } } + }, + replaceTableEntry: { + method: 'PUT', + path: '/tables/{table_token}/entries/{serial_number}', + parameters: [ + { name: 'table_token', in: 'path', required: true, schema: { type: 'string' } }, + { name: 'serial_number', in: 'path', required: true, schema: { type: 'integer' } } + ], + body: { required: true, content: { 'application/json': { $ref: 'EntryInput' } } } + }, + searchEntriesAcrossForms: { + method: 'GET', + path: '/entries/search', + parameters: [ + { name: 'keyword', in: 'query', required: true, schema: { type: 'string', maxLength: 200 } }, + { name: 'form_tokens', in: 'query', schema: { type: 'string' } }, + { name: 'filters', in: 'query', schema: { type: 'string' } } + ] + }, + searchMySubmittedEntries: { + method: 'GET', + path: '/my/search', + parameters: [ + { name: 'keyword', in: 'query', required: true, schema: { type: 'string', minLength: 1 } }, + { name: 'form_tokens', in: 'query', schema: { type: 'string' } } + ] + }, + summarizeEntries: { + method: 'GET', + path: '/forms/{form_token}/entries/summary', + parameters: [ + { name: 'form_token', in: 'path', required: true, schema: { type: 'string' } }, + { name: 'fields', in: 'query', schema: { type: 'string' } }, + { name: 'include_overview', in: 'query', schema: { type: 'boolean' } }, + { name: 'filters', in: 'query', schema: { type: 'string' } }, + { name: 'keyword', in: 'query', schema: { type: 'string', maxLength: 200 } } + ] + }, + summarizeFormViewEntries: { + method: 'GET', + path: '/forms/{form_token}/views/{view_token}/entries/summary', + parameters: [ + { name: 'form_token', in: 'path', required: true, schema: { type: 'string' } }, + { name: 'view_token', in: 'path', required: true, schema: { type: 'string' } }, + { name: 'fields', in: 'query', schema: { type: 'string' } }, + { name: 'include_overview', in: 'query', schema: { type: 'boolean' } }, + { name: 'filters', in: 'query', schema: { type: 'string' } }, + { name: 'keyword', in: 'query', schema: { type: 'string', maxLength: 200 } } + ] + }, + summarizeTableEntries: { + method: 'GET', + path: '/tables/{table_token}/entries/summary', + parameters: [ + { name: 'table_token', in: 'path', required: true, schema: { type: 'string' } }, + { name: 'fields', in: 'query', schema: { type: 'string' } }, + { name: 'include_overview', in: 'query', schema: { type: 'boolean' } }, + { name: 'filters', in: 'query', schema: { type: 'string' } }, + { name: 'keyword', in: 'query', schema: { type: 'string', maxLength: 200 } } + ] + }, + summarizeTableViewEntries: { + method: 'GET', + path: '/tables/{table_token}/views/{view_token}/entries/summary', + parameters: [ + { name: 'table_token', in: 'path', required: true, schema: { type: 'string' } }, + { name: 'view_token', in: 'path', required: true, schema: { type: 'string' } }, + { name: 'fields', in: 'query', schema: { type: 'string' } }, + { name: 'include_overview', in: 'query', schema: { type: 'boolean' } }, + { name: 'filters', in: 'query', schema: { type: 'string' } }, + { name: 'keyword', in: 'query', schema: { type: 'string', maxLength: 200 } } + ] + }, + updateAccessPolicy: { + method: 'PATCH', + path: '/forms/{form_token}/access_policy', + parameters: [{ name: 'form_token', in: 'path', required: true, schema: { type: 'string' } }], + body: { required: true, content: { 'application/json': { $ref: 'AccessPolicyInput' } } } + }, + updateEntry: { + method: 'PATCH', + path: '/forms/{form_token}/entries/{serial_number}', + parameters: [ + { name: 'form_token', in: 'path', required: true, schema: { type: 'string' } }, + { name: 'serial_number', in: 'path', required: true, schema: { type: 'integer' } } + ], + body: { required: true, content: { 'application/json': { $ref: 'EntryInput' } } } + }, + updateEntryComment: { + method: 'PATCH', + path: '/forms/{form_token}/entries/{serial_number}/comments/{comment_id}', + parameters: [ + { name: 'form_token', in: 'path', required: true, schema: { type: 'string' } }, + { name: 'serial_number', in: 'path', required: true, schema: { type: 'integer' } }, + { name: 'comment_id', in: 'path', required: true, schema: { type: 'string' } } + ], + body: { + required: true, + content: { + 'application/json': { + type: 'object', + required: ['content'], + properties: { content: { type: 'string', maxLength: 2000 } }, + additionalProperties: false + } + } + } + }, + updateEntryViaPost: { + method: 'POST', + path: '/forms/{form_token}/entries/{serial_number}', + parameters: [ + { name: 'form_token', in: 'path', required: true, schema: { type: 'string' } }, + { name: 'serial_number', in: 'path', required: true, schema: { type: 'integer' } } + ], + body: { required: true, content: { 'application/json': { $ref: 'EntryInput' } } } + }, + updateEvaluationSetting: { + method: 'PATCH', + path: '/forms/{form_token}/evaluation_setting', + parameters: [{ name: 'form_token', in: 'path', required: true, schema: { type: 'string' } }], + body: { required: true, content: { 'application/json': { $ref: 'EvaluationSettingInput' } } } + }, + updateExamSetting: { + method: 'PATCH', + path: '/forms/{form_token}/exam_setting', + parameters: [{ name: 'form_token', in: 'path', required: true, schema: { type: 'string' } }], + body: { required: true, content: { 'application/json': { $ref: 'ExamSettingInput' } } } + }, + updateForm: { + method: 'PATCH', + path: '/forms/{form_token}', + parameters: [{ name: 'form_token', in: 'path', required: true, schema: { type: 'string' } }], + body: { required: true, content: { 'application/json': { $ref: 'FormUpdateInput' } } } + }, + updateFormSetting: { + method: 'PATCH', + path: '/forms/{form_token}/setting', + parameters: [{ name: 'form_token', in: 'path', required: true, schema: { type: 'string' } }], + body: { required: true, content: { 'application/json': { $ref: 'FormSettingInput' } } } + }, + updateFormTheme: { + method: 'PATCH', + path: '/forms/{form_token}/theme', + parameters: [{ name: 'form_token', in: 'path', required: true, schema: { type: 'string' } }], + body: { required: true, content: { 'application/json': { $ref: 'ThemeInput' } } } + }, + updateFormView: { + method: 'PATCH', + path: '/forms/{form_token}/views/{view_token}', + parameters: [ + { name: 'form_token', in: 'path', required: true, schema: { type: 'string' } }, + { name: 'view_token', in: 'path', required: true, schema: { type: 'string' } } + ], + body: { required: true, content: { 'application/json': { $ref: 'FormViewInput' } } } + }, + updateManuallyCloseRule: { + method: 'PATCH', + path: '/forms/{form_token}/manually_close_rule', + parameters: [{ name: 'form_token', in: 'path', required: true, schema: { type: 'string' } }], + body: { required: true, content: { 'application/json': { $ref: 'FormManuallyCloseRuleInput' } } } + }, + updateOpensearchQuery: { + method: 'PATCH', + path: '/opensearch/queries/{query_token}', + parameters: [{ name: 'query_token', in: 'path', required: true, schema: { type: 'string' } }], + body: { required: true, content: { 'application/json': { $ref: 'OpensearchQueryInput' } } } + }, + updatePredefinedChoicesSet: { + method: 'PATCH', + path: '/predefined_choices_sets/{id}', + parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }], + body: { + required: true, + content: { + 'application/json': { + type: 'object', + required: ['predefined_choices_set'], + properties: { predefined_choices_set: { $ref: 'PredefinedChoicesSetInput' } }, + additionalProperties: false + } + } + } + }, + updateTable: { + method: 'PATCH', + path: '/tables/{table_token}', + parameters: [{ name: 'table_token', in: 'path', required: true, schema: { type: 'string' } }], + body: { required: true, content: { 'application/json': { $ref: 'FormUpdateInput' } } } + }, + updateTableEntry: { + method: 'PATCH', + path: '/tables/{table_token}/entries/{serial_number}', + parameters: [ + { name: 'table_token', in: 'path', required: true, schema: { type: 'string' } }, + { name: 'serial_number', in: 'path', required: true, schema: { type: 'integer' } } + ], + body: { required: true, content: { 'application/json': { $ref: 'EntryInput' } } } + }, + updateTableEntryComment: { + method: 'PATCH', + path: '/tables/{table_token}/entries/{serial_number}/comments/{comment_id}', + parameters: [ + { name: 'table_token', in: 'path', required: true, schema: { type: 'string' } }, + { name: 'serial_number', in: 'path', required: true, schema: { type: 'integer' } }, + { name: 'comment_id', in: 'path', required: true, schema: { type: 'string' } } + ], + body: { + required: true, + content: { + 'application/json': { + type: 'object', + required: ['content'], + properties: { content: { type: 'string', maxLength: 2000 } }, + additionalProperties: false + } + } + } + }, + updateTableEntryViaPost: { + method: 'POST', + path: '/tables/{table_token}/entries/{serial_number}', + parameters: [ + { name: 'table_token', in: 'path', required: true, schema: { type: 'string' } }, + { name: 'serial_number', in: 'path', required: true, schema: { type: 'integer' } } + ], + body: { required: true, content: { 'application/json': { $ref: 'EntryInput' } } } + }, + updateTableView: { + method: 'PATCH', + path: '/tables/{table_token}/views/{view_token}', + parameters: [ + { name: 'table_token', in: 'path', required: true, schema: { type: 'string' } }, + { name: 'view_token', in: 'path', required: true, schema: { type: 'string' } } + ], + body: { required: true, content: { 'application/json': { $ref: 'FormViewInput' } } } + }, + uploadEntryAttachment: { + method: 'POST', + path: '/forms/{form_token}/entry_attachments', + parameters: [{ name: 'form_token', in: 'path', required: true, schema: { type: 'string' } }], + body: { + required: true, + content: { + 'multipart/form-data': { + type: 'object', + required: ['field_api_code'], + properties: { + field_api_code: { type: 'string' }, + dimension_api_code: { type: 'string' }, + attachment_token: { type: 'string' }, + file: { type: 'string' }, + base64: { type: 'string' }, + data: { type: 'string' }, + file_name: { type: 'string' }, + filename: { type: 'string' }, + content_type: { type: 'string' } + }, + additionalProperties: false + }, + 'application/json': { $ref: 'EntryAttachmentBase64Input' } + } + } + }, + uploadFormImage: { + method: 'POST', + path: '/form_image_attachments', + parameters: [], + body: { + required: true, + content: { + 'multipart/form-data': { + type: 'object', + required: ['file'], + properties: { + image_type: { + type: 'string', + enum: ['field_choice', 'header', 'wallpaper', 'carousel', 'opensearch_header', 'description'] + }, + form_image_token: { type: 'string' }, + file: { type: 'string' } + }, + additionalProperties: false + } + } + } + }, + uploadImportFile: { + method: 'POST', + path: '/forms/{form_token}/import_files', + parameters: [{ name: 'form_token', in: 'path', required: true, schema: { type: 'string' } }], + body: { + required: true, + content: { + 'multipart/form-data': { + type: 'object', + required: ['file'], + properties: { import_file_token: { type: 'string' }, file: { type: 'string' } }, + additionalProperties: false + } + } + } + }, + verifyRedirectParams: { + method: 'GET', + path: '/jamr_v', + parameters: [ + { name: 'jamr_h', in: 'query', required: true, schema: { type: 'string' } }, + { name: 'serial_number', in: 'query', schema: { type: 'string' } } + ] + } +}; + +/** The named schemas a parameter or a request body refers to. */ +export const SCHEMAS: { readonly [name: string]: Schema } = { + AccessPolicyInput: { + type: 'object', + properties: { fill_frequency: { $ref: 'FormFillFrequencyInput' } }, + additionalProperties: false + }, + AddressFieldInput: { + type: 'object', + additionalProperties: false, + required: ['type', 'label'], + properties: { + type: { const: 'AddressField' }, + label: { type: 'string' }, + cid: { type: 'string' }, + notes: { type: 'string' }, + private: { type: 'boolean' }, + required: { type: 'boolean' }, + position: { type: 'integer', minimum: 0 }, + predefined_value: { type: 'object' }, + changeable: { type: 'object', additionalProperties: { type: 'boolean' } }, + enable_auto_position: { type: 'boolean' } + } + }, + AnswerInput: { + type: 'object', + additionalProperties: false, + required: ['value', 'score'], + properties: { value: {}, score: { type: 'number' } } + }, + AttachmentFieldInput: { + type: 'object', + additionalProperties: false, + required: ['type', 'label'], + properties: { + type: { const: 'AttachmentField' }, + label: { type: 'string' }, + cid: { type: 'string' }, + notes: { type: 'string' }, + private: { type: 'boolean' }, + required: { type: 'boolean' }, + position: { type: 'integer', minimum: 0 }, + max_size: { type: 'integer', minimum: 1, maximum: 500 }, + max_file_quantity: { type: 'integer', minimum: 1 }, + media_type: { + type: 'object', + additionalProperties: false, + properties: { + type: { type: 'string', enum: ['unlimited', 'doc', 'image', 'video', 'audio', 'compress', 'custom'] }, + value: { type: 'array', items: { type: 'string' } } + }, + if: { properties: { type: { const: 'custom' } }, required: ['type'] }, + then: { required: ['value'], properties: { value: { minItems: 1 } } } + }, + mobile_camera_only: { type: 'boolean' } + } + }, + AudioFieldInput: { + type: 'object', + additionalProperties: false, + required: ['type', 'label'], + properties: { + type: { const: 'AudioField' }, + label: { type: 'string' }, + cid: { type: 'string' }, + notes: { type: 'string' }, + private: { type: 'boolean' }, + required: { type: 'boolean' }, + position: { type: 'integer', minimum: 0 }, + max_duration: { type: 'integer', minimum: 1, maximum: 5 } + } + }, + BooleanFieldInput: { + type: 'object', + additionalProperties: false, + required: ['type', 'label'], + properties: { + type: { const: 'BooleanField' }, + label: { type: 'string' }, + cid: { type: 'string' }, + notes: { type: 'string' }, + private: { type: 'boolean' }, + required: { type: 'boolean' }, + position: { type: 'integer', minimum: 0 } + } + }, + CascadeChoiceInput: { + type: 'object', + additionalProperties: false, + anyOf: [{ required: ['value'] }, { required: ['name'] }], + properties: { + value: { type: 'string' }, + name: { type: 'string' }, + api_code: { type: 'string' }, + quota: { type: 'integer', minimum: 1 }, + operand_value: { type: 'number' }, + selected: { type: 'boolean' }, + sub_choices: { type: 'array', items: { $ref: 'CascadeChoiceInput' } } + } + }, + CascadeDropDownInput: { + type: 'object', + additionalProperties: false, + required: ['type', 'label'], + properties: { + type: { const: 'CascadeDropDown' }, + label: { type: 'string' }, + cid: { type: 'string' }, + notes: { type: 'string' }, + private: { type: 'boolean' }, + required: { type: 'boolean' }, + position: { type: 'integer', minimum: 0 }, + choices: { type: 'array', minItems: 1, items: { $ref: 'CascadeChoiceInput' } }, + levels: { type: 'integer', minimum: 2 }, + choice_filterable: { type: 'boolean' }, + random_choices: { type: 'boolean' }, + calculable: { type: 'boolean' } + } + }, + CheckBoxInput: { + type: 'object', + additionalProperties: false, + required: ['type', 'label'], + properties: { + type: { const: 'CheckBox' }, + label: { type: 'string' }, + cid: { type: 'string' }, + notes: { type: 'string' }, + private: { type: 'boolean' }, + required: { type: 'boolean' }, + position: { type: 'integer', minimum: 0 }, + other_choice_required: { type: 'boolean' }, + choices: { type: 'array', minItems: 1, items: { $ref: 'ChoiceInput' } }, + calculable: { type: 'boolean' }, + choices_layout: { type: 'string' } + }, + allOf: [ + { + if: { properties: { calculable: { const: true } }, required: ['calculable'] }, + then: { properties: { choices: { items: { required: ['operand_value'] } } } } + } + ] + }, + ChoiceInput: { + type: 'object', + additionalProperties: false, + anyOf: [{ required: ['value'] }, { required: ['name'] }], + properties: { + value: { type: 'string' }, + name: { type: 'string' }, + api_code: { type: 'string' }, + quota: { type: 'integer', minimum: 1 }, + operand_value: { type: 'number' }, + selected: { type: 'boolean' }, + is_other: { type: 'boolean' } + } + }, + ChoiceUpdateInput: { + type: 'object', + required: ['field_api_code'], + properties: { + field_api_code: { type: 'string' }, + add: { type: 'array', items: { $ref: 'ChoiceInput' } }, + remove: { + type: 'array', + items: { oneOf: [{ type: 'string' }, { type: 'object', properties: { api_code: { type: 'string' } } }] } + }, + update: { + type: 'array', + items: { + type: 'object', + required: ['api_code'], + properties: { + api_code: { type: 'string' }, + value: { type: 'string' }, + operand_value: { type: 'number' }, + selected: { type: 'boolean' } + }, + additionalProperties: false + } + } + }, + additionalProperties: false + }, + CopyFormInput: { + type: 'object', + properties: { name: { type: 'string' }, folder_token: { type: 'string' } }, + additionalProperties: false + }, + DailyRepeatCloseRuleInput: { + type: ['object', 'null'], + properties: { + enabled: { type: 'boolean' }, + time_periods: { + type: 'array', + items: { + type: 'object', + required: ['start_time', 'end_time'], + properties: { start_time: { $ref: 'TimeOfDay' }, end_time: { $ref: 'TimeOfDay' } }, + additionalProperties: false + } + } + }, + additionalProperties: false + }, + DateTimeFieldInput: { + type: 'object', + additionalProperties: false, + required: ['type', 'label'], + properties: { + type: { const: 'DateTimeField' }, + label: { type: 'string' }, + cid: { type: 'string' }, + notes: { type: 'string' }, + private: { type: 'boolean' }, + required: { type: 'boolean' }, + position: { type: 'integer', minimum: 0 }, + predefined_value: { type: 'string' }, + precision: { type: 'string', enum: ['month', 'day', 'minute', 'second'] } + } + }, + DepartmentInput: { + type: 'object', + additionalProperties: false, + required: ['type', 'label'], + properties: { + type: { const: 'Department' }, + label: { type: 'string' }, + cid: { type: 'string' }, + notes: { type: 'string' }, + private: { type: 'boolean' }, + required: { type: 'boolean' }, + position: { type: 'integer', minimum: 0 }, + other_choice_required: { type: 'boolean' }, + choices: { type: 'array', minItems: 1, items: { $ref: 'ChoiceInput' } }, + calculable: { type: 'boolean' }, + choices_layout: { type: 'string' } + }, + allOf: [ + { + if: { properties: { calculable: { const: true } }, required: ['calculable'] }, + then: { properties: { choices: { items: { required: ['operand_value'] } } } } + } + ] + }, + DimensionInput: { + type: 'object', + additionalProperties: false, + required: ['type', 'label'], + properties: { + type: { + type: 'string', + enum: [ + 'TextField', + 'TextArea', + 'MobileField', + 'CheckBox', + 'DropDown', + 'NumberField', + 'DateTimeField', + 'TimeField', + 'AttachmentField', + 'IdCardField', + 'EmailField' + ] + }, + label: { type: 'string', pattern: '\\S' }, + api_code: { type: 'string' }, + cid: { type: 'string' }, + required: { type: 'boolean' }, + choices: { type: 'array', items: { $ref: 'ChoiceInput' } } + } + }, + DropDownInput: { + type: 'object', + additionalProperties: false, + required: ['type', 'label'], + properties: { + type: { const: 'DropDown' }, + label: { type: 'string' }, + cid: { type: 'string' }, + notes: { type: 'string' }, + private: { type: 'boolean' }, + required: { type: 'boolean' }, + position: { type: 'integer', minimum: 0 }, + other_choice_required: { type: 'boolean' }, + choices: { type: 'array', minItems: 1, items: { $ref: 'ChoiceInput' } }, + calculable: { type: 'boolean' } + }, + allOf: [ + { + if: { properties: { calculable: { const: true } }, required: ['calculable'] }, + then: { properties: { choices: { items: { required: ['operand_value'] } } } } + } + ] + }, + DropDownSelectInput: { + type: 'object', + additionalProperties: false, + required: ['type', 'label', 'choices', 'answers'], + properties: { + type: { const: 'DropDownSelect' }, + label: { type: 'string' }, + cid: { type: 'string' }, + notes: { type: 'string' }, + private: { type: 'boolean' }, + required: { type: 'boolean' }, + position: { type: 'integer', minimum: 0 }, + other_choice_required: { type: 'boolean' }, + choices: { type: 'array', minItems: 1, items: { $ref: 'ChoiceInput' } }, + calculable: { type: 'boolean' }, + answers: { type: 'array', minItems: 1, items: { $ref: 'AnswerInput' } }, + answer_setting_mode: { type: 'string', enum: ['absolute', 'relative'] }, + answer_explanation: { type: 'string' } + }, + allOf: [ + { + if: { properties: { calculable: { const: true } }, required: ['calculable'] }, + then: { properties: { choices: { items: { required: ['operand_value'] } } } } + }, + { + if: { + anyOf: [ + { not: { required: ['answer_setting_mode'] } }, + { properties: { answer_setting_mode: { const: 'absolute' } } } + ] + }, + then: { properties: { answers: { maxItems: 1 } } } + } + ] + }, + ESignatureFieldInput: { + type: 'object', + additionalProperties: false, + required: ['type', 'label'], + properties: { + type: { const: 'ESignatureField' }, + label: { type: 'string' }, + cid: { type: 'string' }, + notes: { type: 'string' }, + private: { type: 'boolean' }, + required: { type: 'boolean' }, + position: { type: 'integer', minimum: 0 } + } + }, + EmailFieldInput: { + type: 'object', + additionalProperties: false, + required: ['type', 'label'], + properties: { + type: { const: 'EmailField' }, + label: { type: 'string' }, + cid: { type: 'string' }, + notes: { type: 'string' }, + private: { type: 'boolean' }, + required: { type: 'boolean' }, + position: { type: 'integer', minimum: 0 }, + unique: { type: 'boolean' }, + placeholder: { type: 'string' } + } + }, + EntryAttachmentBase64Input: { + type: 'object', + required: ['field_api_code', 'base64'], + properties: { + field_api_code: { type: 'string' }, + dimension_api_code: { type: 'string' }, + attachment_token: { type: 'string' }, + base64: { type: 'string' }, + data: { type: 'string' }, + file_name: { type: 'string' }, + filename: { type: 'string' }, + content_type: { type: 'string' } + }, + additionalProperties: false + }, + EntryCommentInput: { + type: 'object', + properties: { content: { type: 'string', maxLength: 2000 }, parent_id: { type: 'string' } }, + additionalProperties: false + }, + EntryImportColumnInput: { + type: 'object', + required: ['field_api_code'], + properties: { + field_api_code: { type: 'string' }, + column_label: { type: 'string' }, + sheet_column_index: { type: ['integer', 'string'], pattern: '^[0-9]+$' } + }, + additionalProperties: false + }, + EntryImportInput: { + type: 'object', + required: ['attachment_id', 'columns'], + properties: { + attachment_id: { type: 'string' }, + columns: { type: 'array', minItems: 1, items: { $ref: 'EntryImportColumnInput' } }, + header_row_index: { type: ['integer', 'string', 'null'], pattern: '^[0-9]+$' }, + unique_field_code: { type: ['string', 'null'] } + }, + additionalProperties: false + }, + EntryInput: { type: 'object', additionalProperties: true }, + EvaluationSettingInput: { + type: 'object', + properties: { + notice_after_filling_mode: { type: 'string', enum: ['reports', 'customize', 'none'] }, + show_report_score: { type: 'boolean' }, + show_report_radar: { type: 'boolean' }, + show_indicator_comments: { type: 'boolean' }, + show_comments: { type: 'boolean' }, + need_attention: { type: 'string' }, + success_message_rich_text: { type: 'string' }, + evaluation_comments: { type: 'array', items: { $ref: 'FormEvaluationCommentInput' } }, + indicator_setting: { + type: 'object', + properties: { + enabled: { type: 'boolean' }, + indicators_scoring_mode: { type: 'string', enum: ['summation', 'average', 'customization'] }, + indicators: { + type: 'array', + maxItems: 200, + items: { + type: 'object', + required: ['name'], + properties: { + api_code: { type: 'string' }, + name: { type: 'string', maxLength: 20 }, + standard_score: { type: 'number' }, + field_api_codes: { type: 'array', items: { type: 'string' } }, + field_cids: { type: 'array', items: { oneOf: [{ type: 'string' }, { type: 'integer' }] } }, + indicator_comments: { + type: 'array', + maxItems: 20, + items: { + type: 'object', + properties: { + start_point: { type: 'number' }, + end_point: { type: 'number' }, + comment: { type: 'string' } + }, + additionalProperties: false + } + } + }, + additionalProperties: false + } + } + }, + additionalProperties: false + } + }, + additionalProperties: false + }, + ExamSettingInput: { + type: 'object', + properties: { + notice_after_filling_mode: { type: 'string', enum: ['appreciation', 'grade', 'explaination', 'none', 'answer'] }, + show_timeout: { type: 'boolean' }, + limited_time: { type: 'integer', minimum: 1, maximum: 9999 }, + need_attention: { type: 'string' }, + success_message_rich_text: { type: 'string' }, + interval_comments: { type: 'array', items: { $ref: 'FormIntervalCommentInput' } } + }, + additionalProperties: false + }, + FieldInput: { + type: 'object', + required: ['type', 'label'], + properties: { + type: { + type: 'string', + enum: [ + 'TextField', + 'TextArea', + 'NumberField', + 'EmailField', + 'MobileField', + 'IdCardField', + 'NameField', + 'RadioButton', + 'CheckBox', + 'DropDown', + 'DateTimeField', + 'RatingField', + 'TableField', + 'TelephoneField', + 'AddressField', + 'AttachmentField', + 'GeoField', + 'LinkField', + 'NpsField', + 'TimeField', + 'SortField', + 'ESignatureField', + 'AudioField', + 'CascadeDropDown', + 'LikertField', + 'MatrixField', + 'MatrixScaleField', + 'GoodsField', + 'FormulaField', + 'ReservationField', + 'FormAssociation', + 'ImageRadioButton', + 'ImageCheckBox', + 'SectionBreak', + 'PageBreak', + 'WidgetButton', + 'WidgetContact', + 'WidgetMap', + 'WidgetMarquee', + 'SingleSelect', + 'MultiSelect', + 'ImageSingleSelect', + 'ImageMultiSelect', + 'TrueOrFalse', + 'DropDownSelect', + 'FillInBlank', + 'ShortAnswer', + 'FillInNumber', + 'Rating', + 'Nps', + 'Department', + 'Grade' + ] + }, + label: { type: 'string' } + }, + oneOf: [ + { $ref: 'TextFieldInput' }, + { $ref: 'TextAreaInput' }, + { $ref: 'NumberFieldInput' }, + { $ref: 'EmailFieldInput' }, + { $ref: 'MobileFieldInput' }, + { $ref: 'IdCardFieldInput' }, + { $ref: 'NameFieldInput' }, + { $ref: 'RadioButtonInput' }, + { $ref: 'CheckBoxInput' }, + { $ref: 'DropDownInput' }, + { $ref: 'DateTimeFieldInput' }, + { $ref: 'RatingFieldInput' }, + { $ref: 'TableFieldInput' }, + { $ref: 'TelephoneFieldInput' }, + { $ref: 'AddressFieldInput' }, + { $ref: 'AttachmentFieldInput' }, + { $ref: 'GeoFieldInput' }, + { $ref: 'LinkFieldInput' }, + { $ref: 'NpsFieldInput' }, + { $ref: 'TimeFieldInput' }, + { $ref: 'SortFieldInput' }, + { $ref: 'ESignatureFieldInput' }, + { $ref: 'AudioFieldInput' }, + { $ref: 'CascadeDropDownInput' }, + { $ref: 'LikertFieldInput' }, + { $ref: 'MatrixFieldInput' }, + { $ref: 'MatrixScaleFieldInput' }, + { $ref: 'GoodsFieldInput' }, + { $ref: 'FormulaFieldInput' }, + { $ref: 'ReservationFieldInput' }, + { $ref: 'FormAssociationInput' }, + { $ref: 'ImageRadioButtonInput' }, + { $ref: 'ImageCheckBoxInput' }, + { $ref: 'SectionBreakInput' }, + { $ref: 'PageBreakInput' }, + { $ref: 'WidgetButtonInput' }, + { $ref: 'WidgetContactInput' }, + { $ref: 'WidgetMapInput' }, + { $ref: 'WidgetMarqueeInput' }, + { $ref: 'SingleSelectInput' }, + { $ref: 'MultiSelectInput' }, + { $ref: 'ImageSingleSelectInput' }, + { $ref: 'ImageMultiSelectInput' }, + { $ref: 'TrueOrFalseInput' }, + { $ref: 'DropDownSelectInput' }, + { $ref: 'FillInBlankInput' }, + { $ref: 'ShortAnswerInput' }, + { $ref: 'FillInNumberInput' }, + { $ref: 'RatingInput' }, + { $ref: 'NpsInput' }, + { $ref: 'DepartmentInput' }, + { $ref: 'GradeInput' } + ] + }, + FieldRuleOperations: { + type: 'object', + properties: { + add: { type: 'array', items: { $ref: 'FieldRuleWriteInput' } }, + remove: { type: 'array', items: { type: 'integer' } }, + update: { type: 'array', items: { $ref: 'FieldRuleWriteInput' } } + }, + additionalProperties: false + }, + FieldRuleWriteInput: { + type: 'object', + properties: { + index: { type: 'integer' }, + targets: { type: 'array', items: { type: 'string' } }, + targets_display_mode: { type: 'string', enum: ['show', 'abort'] }, + operator: { type: 'string', enum: ['or', 'and'] }, + conditions: { + type: 'array', + items: { + type: 'object', + required: ['trigger'], + properties: { trigger: { type: 'string' }, comparator: { type: 'string' }, value: {} }, + additionalProperties: false + } + } + }, + additionalProperties: false + }, + FieldUpdateInput: { + type: 'object', + additionalProperties: false, + required: ['api_code'], + properties: { + api_code: { type: 'string' }, + type: { + type: 'string', + enum: [ + 'TextField', + 'TextArea', + 'NumberField', + 'EmailField', + 'MobileField', + 'IdCardField', + 'NameField', + 'RadioButton', + 'CheckBox', + 'DropDown', + 'DateTimeField', + 'RatingField', + 'TableField', + 'TelephoneField', + 'AddressField', + 'AttachmentField', + 'GeoField', + 'LinkField', + 'NpsField', + 'TimeField', + 'SortField', + 'ESignatureField', + 'AudioField', + 'CascadeDropDown', + 'LikertField', + 'MatrixField', + 'MatrixScaleField', + 'GoodsField', + 'FormulaField', + 'ReservationField', + 'FormAssociation', + 'ImageRadioButton', + 'ImageCheckBox', + 'SectionBreak', + 'PageBreak', + 'WidgetButton', + 'WidgetContact', + 'WidgetMap', + 'WidgetMarquee' + ] + }, + confirm_data_loss: { type: 'boolean' }, + label: { type: 'string' }, + notes: { type: ['string', 'null'] }, + private: { type: 'boolean' }, + required: { type: 'boolean' }, + unique: { type: 'boolean' }, + other_choice_required: { type: 'boolean' }, + position: { type: 'integer', minimum: 0 }, + rating_max: { type: 'integer', minimum: 1 }, + choices: { type: 'array', items: { $ref: 'CascadeChoiceInput' } }, + dimensions: { type: 'array', items: { $ref: 'DimensionInput' } }, + statements: { type: 'array', items: { $ref: 'StatementInput' } }, + goods_items: { type: 'array', items: { $ref: 'GoodsItemInput' } }, + reservation_items: { type: 'array', items: { $ref: 'ReservationItemInput' } }, + display_field_api_codes: { type: 'array', maxItems: 20, items: { type: 'string' } }, + answers: { type: 'array', minItems: 1, items: { $ref: 'AnswerInput' } }, + answer_setting_mode: { type: 'string', enum: ['absolute', 'partial_absolute', 'relative'] }, + answer_explanation: { type: 'string' }, + calculable: { type: 'boolean' }, + choices_layout: { type: 'string' }, + predefined_value: {}, + placeholder: { type: 'string' }, + sms_verification: { type: 'boolean' }, + range_min: { type: 'number' }, + range_max: { type: 'number' }, + precision: { + anyOf: [ + { type: 'integer', minimum: 0, maximum: 14 }, + { type: 'string', enum: ['month', 'day', 'minute', 'second'] } + ] + }, + display_precision: { type: 'integer', minimum: 0 }, + display_as_percentage: { type: 'boolean' }, + thousands_separator: { type: 'boolean' }, + currency_symbol: { type: 'string' }, + currency_symbol_position: { type: 'string', enum: ['prefix', 'suffix'] }, + changeable: { type: 'object', additionalProperties: { type: 'boolean' } }, + enable_auto_position: { type: 'boolean' }, + max_size: { type: 'integer', minimum: 1, maximum: 500 }, + max_file_quantity: { type: 'integer', minimum: 1 }, + media_type: { + type: 'object', + additionalProperties: false, + properties: { + type: { type: 'string', enum: ['unlimited', 'doc', 'image', 'video', 'audio', 'compress', 'custom'] }, + value: { type: 'array', items: { type: 'string' } } + }, + if: { properties: { type: { const: 'custom' } }, required: ['type'] }, + then: { required: ['value'], properties: { value: { minItems: 1 } } } + }, + mobile_camera_only: { type: 'boolean' }, + minimum_ratings_display_text: { type: 'string' }, + maximum_ratings_display_text: { type: 'string' }, + include_second: { type: 'boolean' }, + random_choices: { type: 'boolean' }, + max_duration: { type: 'integer', minimum: 1, maximum: 5 }, + levels: { type: 'integer', minimum: 2 }, + choice_filterable: { type: 'boolean' }, + horizontal_on_mobile: { type: 'boolean' }, + likert_choice_style: { type: 'string', enum: ['single', 'multiple'] }, + minimum_length: { type: 'integer', minimum: 0 }, + maximum_length: { type: 'integer', minimum: 0 }, + advanced_feature: { type: 'boolean' }, + unit: { type: 'string', maxLength: 2 }, + collapse_on_mobile: { type: 'boolean' }, + columns_on_mobile: { type: 'integer', minimum: 1 }, + formula_display: { type: 'string' }, + result_display_type: { type: 'string', enum: ['string', 'numeric', 'datetime'] }, + result_display_as_date: { type: 'boolean' }, + icon_type: { + type: 'string', + enum: ['none', 'number-2', 'dollar1', 'cny1', 'formula-2', 'date-2', 'sum', 'text', 'text2'] + }, + allow_multiple_items: { type: 'boolean' }, + item_allow_multiple_reservations: { type: 'boolean' }, + allow_multiple_number: { type: 'boolean' }, + use_daily_time_range: { type: 'boolean' }, + associated_form_token: { type: 'string' }, + associated_field_api_codes: { type: 'array', minItems: 1, maxItems: 4, items: { type: 'string' } }, + privacy_safe: { type: 'boolean' }, + allow_reverse_association: { type: 'boolean' }, + reverse_display_field_api_codes: { type: 'array', items: { type: 'string' } }, + related_form_token: { type: 'string' }, + paired: { type: 'boolean' }, + related_label: { type: 'string' }, + show_split_line: { type: 'boolean' }, + show_part_description: { type: 'boolean' }, + disable_previous_page: { type: 'boolean' }, + previous_page_text: { type: 'string' }, + next_page_text: { type: 'string' }, + count_down: { type: 'integer', minimum: 0 }, + buttons_number: { type: 'integer', minimum: 1 }, + fixed_to_bottom: { type: 'boolean' }, + widget_max_images_number: { type: 'integer', minimum: 1 }, + phones: { + type: 'array', + items: { + type: 'object', + required: ['phone'], + additionalProperties: false, + properties: { phone: { type: 'string' } } + } + }, + nav_address: { type: 'string' }, + longitude: { type: 'number' }, + latitude: { type: 'number' }, + value: { type: 'string' }, + localizable_on_mobile: { type: 'boolean' }, + lines: { type: 'integer', minimum: 1 }, + entries_init_number: { type: 'integer', minimum: 0 }, + entries_number_visible: { type: 'boolean' }, + marquee_data_type: { type: 'string', enum: ['fake', 'real'] } + }, + allOf: [ + { + not: { + properties: { display_as_percentage: { const: true }, thousands_separator: { const: true } }, + required: ['display_as_percentage', 'thousands_separator'] + } + }, + { + if: { not: { properties: { likert_choice_style: { const: 'multiple' } }, required: ['likert_choice_style'] } }, + then: { not: { anyOf: [{ required: ['minimum_length'] }, { required: ['maximum_length'] }] } } + }, + { + if: { properties: { calculable: { const: true } }, required: ['calculable'] }, + then: { properties: { choices: { items: { required: ['operand_value'] } } } } + } + ] + }, + FillEntryRequest: { + type: 'object', + required: ['entry'], + properties: { entry: { type: 'object' }, password: { type: 'string' } } + }, + FillInBlankInput: { + type: 'object', + additionalProperties: false, + required: ['type', 'label', 'answers'], + properties: { + type: { const: 'FillInBlank' }, + label: { type: 'string' }, + cid: { type: 'string' }, + notes: { type: 'string' }, + private: { type: 'boolean' }, + required: { type: 'boolean' }, + position: { type: 'integer', minimum: 0 }, + unique: { type: 'boolean' }, + predefined_value: { type: 'string' }, + placeholder: { type: 'string' }, + answers: { type: 'array', minItems: 1, items: { $ref: 'AnswerInput' } }, + answer_setting_mode: { type: 'string', enum: ['absolute', 'relative'] }, + answer_explanation: { type: 'string' } + }, + allOf: [ + { + if: { + anyOf: [ + { not: { required: ['answer_setting_mode'] } }, + { properties: { answer_setting_mode: { const: 'absolute' } } } + ] + }, + then: { properties: { answers: { maxItems: 1 } } } + } + ] + }, + FillInNumberInput: { + type: 'object', + additionalProperties: false, + required: ['type', 'label', 'answers'], + properties: { + type: { const: 'FillInNumber' }, + label: { type: 'string' }, + cid: { type: 'string' }, + notes: { type: 'string' }, + private: { type: 'boolean' }, + required: { type: 'boolean' }, + position: { type: 'integer', minimum: 0 }, + unique: { type: 'boolean' }, + predefined_value: { type: ['number', 'string'] }, + placeholder: { type: 'string' }, + range_min: { type: 'number' }, + range_max: { type: 'number' }, + precision: { type: 'integer', minimum: 0, maximum: 14 }, + display_precision: { type: 'integer', minimum: 0 }, + display_as_percentage: { type: 'boolean' }, + thousands_separator: { type: 'boolean' }, + currency_symbol: { type: 'string' }, + currency_symbol_position: { type: 'string', enum: ['prefix', 'suffix'] }, + answers: { type: 'array', minItems: 1, items: { $ref: 'AnswerInput' } }, + answer_setting_mode: { type: 'string', enum: ['absolute', 'relative'] }, + answer_explanation: { type: 'string' } + }, + allOf: [ + { + not: { + properties: { display_as_percentage: { const: true }, thousands_separator: { const: true } }, + required: ['display_as_percentage', 'thousands_separator'] + } + }, + { + if: { + anyOf: [ + { not: { required: ['answer_setting_mode'] } }, + { properties: { answer_setting_mode: { const: 'absolute' } } } + ] + }, + then: { properties: { answers: { maxItems: 1 } } } + } + ] + }, + FolderInput: { + type: 'object', + required: ['name'], + properties: { + name: { type: 'string', minLength: 1 }, + kind: { type: ['string', 'null'], enum: ['form', 'table', null] } + }, + additionalProperties: false + }, + FormAssociationInput: { + type: 'object', + additionalProperties: false, + required: ['type', 'label', 'associated_form_token', 'associated_field_api_codes'], + properties: { + type: { const: 'FormAssociation' }, + label: { type: 'string' }, + cid: { type: 'string' }, + notes: { type: 'string' }, + private: { type: 'boolean' }, + required: { type: 'boolean' }, + position: { type: 'integer', minimum: 0 }, + unique: { type: 'boolean' }, + display_field_api_codes: { type: 'array', maxItems: 20, items: { type: 'string' } }, + associated_form_token: { type: 'string' }, + associated_field_api_codes: { type: 'array', minItems: 1, maxItems: 4, items: { type: 'string' } }, + privacy_safe: { type: 'boolean' }, + allow_reverse_association: { type: 'boolean' }, + reverse_display_field_api_codes: { type: 'array', items: { type: 'string' } } + } + }, + FormCreateConstraints: { + allOf: [ + { + if: { properties: { layout: { const: 'card' } }, required: ['layout'] }, + then: { + properties: { + fields: { + items: { + properties: { + type: { + not: { + enum: [ + 'PageBreak', + 'GoodsField', + 'MultipleBlanksField', + 'WidgetContact', + 'WidgetVideo', + 'WidgetButton' + ] + } + } + } + } + } + } + } + }, + { + if: { + properties: { + fields: { + type: 'array', + contains: { + properties: { + type: { + enum: ['SingleSelect', 'MultiSelect', 'ImageSingleSelect', 'ImageMultiSelect', 'DropDownSelect'] + } + }, + required: ['type'] + } + } + }, + required: ['fields'] + }, + then: { properties: { scene: { enum: ['exam', 'evaluation'] } }, required: ['scene'] } + }, + { + if: { + properties: { + fields: { + type: 'array', + contains: { + properties: { type: { enum: ['TrueOrFalse', 'FillInBlank', 'ShortAnswer', 'FillInNumber'] } }, + required: ['type'] + } + } + }, + required: ['fields'] + }, + then: { properties: { scene: { enum: ['exam'] } }, required: ['scene'] } + }, + { + if: { + properties: { + fields: { + type: 'array', + contains: { properties: { type: { enum: ['Rating', 'Nps'] } }, required: ['type'] } + } + }, + required: ['fields'] + }, + then: { properties: { scene: { enum: ['evaluation'] } }, required: ['scene'] } + } + ] + }, + FormCreateInput: { + type: 'object', + additionalProperties: false, + required: ['name', 'fields'], + properties: { + name: { type: 'string' }, + description: { type: 'string' }, + fields: { type: 'array', minItems: 1, items: { $ref: 'FieldInput' } }, + setting: { $ref: 'FormSettingWriteInput' }, + folder_token: { type: 'string' }, + scene: { + type: 'string', + enum: [ + 'form', + 'survey', + 'registry', + 'vote', + 'exam', + 'reservation', + 'customer_acquisition', + 'evaluation', + 'online_payment', + 'quickpay' + ] + }, + layout: { type: 'string', enum: ['classic', 'card'] } + }, + allOf: [{ $ref: 'FormCreateConstraints' }] + }, + FormEvaluationCommentInput: { + type: 'object', + properties: { + id: { type: 'string' }, + conditions: { + type: 'array', + items: { + type: 'object', + properties: { + trigger: { type: 'string' }, + operator: { type: 'string' }, + value: {}, + group_index: { type: 'integer' } + } + } + }, + start_point: { type: 'number' }, + end_point: { type: 'number' }, + comment: { type: 'string' }, + retry: { type: 'boolean' } + } + }, + FormFillFrequencyInput: { + type: 'object', + properties: { + fill_type: { + type: 'string', + enum: ['unlimited', 'once', 'repeatable', 'repeatable_by_day', 'custom_repeatable'] + }, + condition: { type: 'string', enum: ['by_weixin', 'by_ip', 'by_device', 'by_fields'] }, + cycle_period: { type: 'string', enum: ['every_hour', 'every_day', 'every_week', 'every_month'] }, + cycles_per_period: { type: 'integer', minimum: 1, maximum: 1000 }, + limited_time: { type: 'integer', minimum: 1 }, + limited_field_api_codes: { type: 'array', items: { type: 'string' } } + } + }, + FormFolderMoveInput: { + type: 'object', + required: ['folder_token'], + properties: { folder_token: { type: ['string', 'null'] } }, + additionalProperties: false + }, + FormIntervalCommentInput: { + type: 'object', + properties: { + start_point: { type: 'number' }, + end_point: { type: 'number' }, + comment: { type: 'string' }, + retry: { type: 'boolean' } + } + }, + FormManuallyCloseRuleInput: { + type: 'object', + required: ['open'], + properties: { open: { oneOf: [{ type: 'boolean' }, { type: 'string', enum: ['true', 'false'] }] } }, + additionalProperties: false + }, + FormSettingInput: { + type: 'object', + properties: { + success_message: { type: 'string', maxLength: 2000 }, + success_message_rich_text: { type: 'string' }, + success_message_style: { type: 'string', enum: ['text', 'rich_text', 'distribution_poster', 'certificate'] }, + enable_qrcode: { type: 'boolean' }, + entry_submit_mode: { type: 'string', enum: ['show_message', 'redirect', 'reports', 'exam_score'] }, + success_redirect_url: { type: 'string' }, + success_redirect_fields: { type: 'array', items: { type: 'string' } }, + show_entry_on_success: { type: 'boolean' }, + show_entry_on_success_button_text: { type: 'string' }, + edit_entry_on_success: { type: 'boolean' }, + show_serial_number_on_success: { type: 'boolean' }, + show_submit_again: { type: 'boolean' }, + hide_login_and_signup: { type: 'boolean' }, + show_close_count_down: { type: 'boolean' }, + show_form_before_open: { type: 'boolean' }, + show_field_number: { type: 'boolean' }, + password_required: { type: 'boolean' }, + access_password: { type: 'string' }, + allowed_audience: { + type: 'string', + enum: [ + 'public', + 'internal', + 'private', + 'weixin_qiye_followers_only', + 'weixin_followers_only', + 'submitter_only', + 'gd_user_only' + ] + }, + weixin_limited: { type: 'boolean' }, + entry_post_url: { type: 'string' }, + post_new_entry: { type: 'boolean' }, + post_updated_entry: { type: 'boolean' }, + manually_close_rule: { type: 'object', properties: { closed: { type: 'boolean' } }, additionalProperties: false }, + by_time_range_close_rule: { + type: 'object', + properties: { start_time: { type: ['string', 'null'] }, end_time: { type: ['string', 'null'] } }, + additionalProperties: false + }, + by_entries_close_rule: { + type: 'object', + properties: { limit: { type: 'integer' } }, + additionalProperties: false + }, + fill_frequency: { $ref: 'FormFillFrequencyInput' } + }, + additionalProperties: false + }, + FormSettingWriteInput: { + type: 'object', + properties: { + success_message: { type: 'string' }, + success_message_rich_text: { type: 'string' }, + success_message_style: { type: 'string', enum: ['text', 'rich_text'] }, + entry_submit_mode: { type: 'string' }, + success_redirect_url: { type: 'string' }, + success_redirect_fields: { type: 'array', items: { type: 'string' } }, + open_entry_action: { type: 'string' }, + open_entry_message: { type: 'string' }, + open_entry_cancel_reservation: { type: 'boolean' }, + show_serial_number_on_success: { type: 'boolean' }, + show_submit_again: { type: 'boolean' }, + hide_login_and_signup: { type: 'boolean' }, + show_close_count_down: { type: 'boolean' }, + show_form_before_open: { type: 'boolean' }, + manually_close_rule: { + type: ['object', 'null'], + properties: { closed: { type: 'boolean' } }, + additionalProperties: false + }, + by_time_range_close_rule: { + type: ['object', 'null'], + properties: { start_time: { type: 'string' }, end_time: { type: 'string' } }, + additionalProperties: false + }, + by_entries_close_rule: { + type: ['object', 'null'], + properties: { limit: { type: 'integer' } }, + additionalProperties: false + }, + fill_frequency: { + type: ['object', 'null'], + properties: { + fill_type: { + type: 'string', + enum: ['unlimited', 'once', 'repeatable', 'repeatable_by_day', 'custom_repeatable'] + }, + condition: { type: 'string', enum: ['by_weixin', 'by_ip', 'by_device', 'by_fields'] }, + cycle_period: { type: 'string', enum: ['every_hour', 'every_day', 'every_week', 'every_month'] }, + cycles_per_period: { type: 'integer', minimum: 1, maximum: 1000 }, + limited_time: { type: 'integer' }, + limited_field_api_codes: { type: 'array', items: { type: 'string' } } + }, + additionalProperties: false + }, + show_field_number: { type: 'boolean' }, + password_required: { type: 'boolean' }, + access_password: { type: 'string' }, + allowed_audience: { type: 'string' }, + weixin_limited: { type: 'boolean' }, + submission_region_limited: { type: 'boolean' }, + submission_regions: { type: 'array', items: { type: 'string' } }, + merchant_account_id: { type: ['string', 'null'] }, + wxpay_account_id: { type: ['string', 'null'] }, + alipay_account_id: { type: ['string', 'null'] }, + notification_rules: { type: 'array', items: { $ref: 'NotificationRuleInput' } }, + daily_repeat_close_rule: { $ref: 'DailyRepeatCloseRuleInput' } + }, + additionalProperties: false + }, + FormUpdateInput: { + type: 'object', + properties: { + name: { type: 'string' }, + description: { type: ['string', 'null'] }, + setting: { $ref: 'FormSettingWriteInput' }, + fields: { + type: 'object', + properties: { + add: { type: 'array', items: { anyOf: [{ $ref: 'FieldInput' }, { $ref: 'TableColumnInput' }] } }, + remove: { type: 'array', items: { type: 'string' } }, + update: { type: 'array', items: { $ref: 'FieldUpdateInput' } }, + update_choices: { type: 'array', items: { $ref: 'ChoiceUpdateInput' } } + }, + additionalProperties: false + }, + field_rules: { $ref: 'FieldRuleOperations' } + }, + additionalProperties: false + }, + FormViewFieldOverrideInput: { + type: 'object', + required: ['field_api_code'], + properties: { + field_api_code: { type: 'string' }, + label: { type: ['string', 'null'] }, + notes: { type: ['string', 'null'] }, + private: { type: ['boolean', 'null'] }, + position: { type: ['integer', 'null'] }, + validations: { type: 'array', items: { type: 'string' } } + }, + additionalProperties: false + }, + FormViewFilterCondition: { + type: 'object', + required: ['field', 'operator'], + properties: { field: { type: 'string' }, operator: { type: 'string' }, value: {} } + }, + FormViewInput: { + type: 'object', + properties: { + name: { type: 'string', maxLength: 120 }, + view_type: { type: 'string', enum: ['grid', 'kanban', 'stats', 'form'] }, + visibility: { type: 'string', enum: ['cooperators', 'personal'] }, + prefer_columns: { type: 'array', items: { type: 'string' } }, + kanban_group_by_api_code: { type: 'string' }, + kanban_card_title_api_code: { type: 'string' }, + kanban_group_order: { type: 'array', items: { type: 'string' } }, + kanban_hidden_groups: { type: 'array', items: { type: 'string' } }, + sort: { type: 'array', items: { $ref: 'FormViewSortRule' } }, + sorted_columns: { + type: 'array', + items: { + type: 'object', + required: ['api_code'], + properties: { api_code: { type: 'string' } }, + additionalProperties: false + } + }, + filter: { type: 'array', items: { $ref: 'FormViewFilterCondition' } }, + title: { type: 'string', maxLength: 120 }, + description: { type: ['string', 'null'] }, + published: { type: 'boolean' }, + field_overrides: { type: 'array', items: { $ref: 'FormViewFieldOverrideInput' } }, + cid: { type: 'string' } + }, + additionalProperties: false + }, + FormViewSortRule: { + type: 'object', + required: ['api_code'], + properties: { api_code: { type: 'string' }, order: { type: 'string', enum: ['asc', 'desc'] } } + }, + FormWeixinOauthSessionInput: { + type: 'object', + required: ['code'], + properties: { code: { type: 'string' } }, + additionalProperties: false + }, + FormulaFieldInput: { + type: 'object', + additionalProperties: false, + required: ['type', 'label'], + properties: { + type: { const: 'FormulaField' }, + label: { type: 'string' }, + cid: { type: 'string' }, + notes: { type: 'string' }, + private: { type: 'boolean' }, + required: { type: 'boolean' }, + position: { type: 'integer', minimum: 0 }, + formula_display: { type: 'string' }, + result_display_type: { type: 'string', enum: ['string', 'numeric', 'datetime'] }, + result_display_as_date: { type: 'boolean' }, + precision: { type: 'integer', minimum: 0, maximum: 14 }, + display_precision: { type: 'integer', minimum: 0 }, + display_as_percentage: { type: 'boolean' }, + thousands_separator: { type: 'boolean' }, + currency_symbol: { type: 'string' }, + currency_symbol_position: { type: 'string', enum: ['prefix', 'suffix'] }, + icon_type: { + type: 'string', + enum: ['none', 'formula-2', 'date-2', 'number-2', 'dollar1', 'cny1', 'sum', 'text', 'text2'] + } + }, + allOf: [ + { + not: { + properties: { display_as_percentage: { const: true }, thousands_separator: { const: true } }, + required: ['display_as_percentage', 'thousands_separator'] + } + } + ] + }, + GeoFieldInput: { + type: 'object', + additionalProperties: false, + required: ['type', 'label'], + properties: { + type: { const: 'GeoField' }, + label: { type: 'string' }, + cid: { type: 'string' }, + notes: { type: 'string' }, + private: { type: 'boolean' }, + required: { type: 'boolean' }, + position: { type: 'integer', minimum: 0 } + } + }, + GoodsFieldInput: { + type: 'object', + additionalProperties: false, + required: ['type', 'label'], + properties: { + type: { const: 'GoodsField' }, + label: { type: 'string' }, + cid: { type: 'string' }, + notes: { type: 'string' }, + private: { type: 'boolean' }, + required: { type: 'boolean' }, + position: { type: 'integer', minimum: 0 }, + goods_items: { type: 'array', minItems: 1, items: { $ref: 'GoodsItemInput' } }, + unit: { type: 'string', maxLength: 2 }, + collapse_on_mobile: { type: 'boolean' }, + columns_on_mobile: { type: 'integer', minimum: 1 } + } + }, + GoodsItemInput: { + type: 'object', + additionalProperties: false, + required: ['name', 'layout'], + properties: { + name: { type: 'string' }, + layout: { type: 'string', enum: ['without_images', 'price_only', 'images', 'text_options'] }, + api_code: { type: 'string' }, + description: { type: 'string' }, + position: { type: 'integer', minimum: 0 }, + hidden: { type: 'boolean' }, + price: { type: 'number', minimum: 0 }, + original_price: { type: 'number', minimum: 0 }, + predefined_number: { type: 'integer', minimum: 0 }, + fixed_number: { type: 'boolean' }, + inventory: { type: 'integer', minimum: 0 }, + inventory_reset: { type: 'boolean' }, + min_purchase_quantity: { type: 'integer', minimum: 0 }, + max_purchase_quantity: { type: 'integer', minimum: 0 }, + dimensions: { + type: 'array', + minItems: 1, + items: { + type: 'object', + additionalProperties: false, + required: ['label', 'options'], + properties: { + api_code: { type: 'string' }, + label: { type: 'string' }, + options: { + type: 'array', + minItems: 1, + items: { + type: 'object', + additionalProperties: false, + required: ['label'], + properties: { label: { type: 'string' }, value: { type: 'string' } } + } + } + } + } + }, + skus: { + type: 'array', + minItems: 1, + items: { + type: 'object', + additionalProperties: false, + required: ['specification'], + properties: { + specification: { type: 'object', additionalProperties: { type: 'string' } }, + price: { type: 'number', minimum: 0 }, + original_price: { type: 'number', minimum: 0 }, + inventory: { type: 'integer', minimum: 0 }, + inventory_reset: { type: 'boolean' }, + min_purchase_quantity: { type: 'integer', minimum: 0 }, + max_purchase_quantity: { type: 'integer', minimum: 0 } + } + } + }, + image_urls: { type: 'array', items: { type: 'string' } }, + image_base64s: { type: 'array', items: { type: 'string' } } + }, + dependentRequired: { dimensions: ['skus'], skus: ['dimensions'] }, + allOf: [ + { + if: { properties: { layout: { enum: ['price_only', 'text_options'] } } }, + then: { required: ['dimensions', 'skus'] } + }, + { + if: { properties: { layout: { const: 'images' } } }, + then: { + anyOf: [ + { required: ['image_urls'], properties: { image_urls: { minItems: 1 } } }, + { required: ['image_base64s'], properties: { image_base64s: { minItems: 1 } } } + ] + } + } + ] + }, + GradeInput: { + type: 'object', + additionalProperties: false, + required: ['type', 'label'], + properties: { + type: { const: 'Grade' }, + label: { type: 'string' }, + cid: { type: 'string' }, + notes: { type: 'string' }, + private: { type: 'boolean' }, + required: { type: 'boolean' }, + position: { type: 'integer', minimum: 0 }, + other_choice_required: { type: 'boolean' }, + choices: { type: 'array', minItems: 1, items: { $ref: 'ChoiceInput' } }, + calculable: { type: 'boolean' }, + choices_layout: { type: 'string' } + }, + allOf: [ + { + if: { properties: { calculable: { const: true } }, required: ['calculable'] }, + then: { properties: { choices: { items: { required: ['operand_value'] } } } } + } + ] + }, + IdCardFieldInput: { + type: 'object', + additionalProperties: false, + required: ['type', 'label'], + properties: { + type: { const: 'IdCardField' }, + label: { type: 'string' }, + cid: { type: 'string' }, + notes: { type: 'string' }, + private: { type: 'boolean' }, + required: { type: 'boolean' }, + position: { type: 'integer', minimum: 0 }, + unique: { type: 'boolean' } + } + }, + ImageCheckBoxInput: { + type: 'object', + additionalProperties: false, + required: ['type', 'label'], + properties: { + type: { const: 'ImageCheckBox' }, + label: { type: 'string' }, + cid: { type: 'string' }, + notes: { type: 'string' }, + private: { type: 'boolean' }, + required: { type: 'boolean' }, + position: { type: 'integer', minimum: 0 }, + choices: { type: 'array', minItems: 1, items: { $ref: 'ImageChoiceInput' } }, + calculable: { type: 'boolean' }, + choices_layout: { type: 'string' } + }, + allOf: [ + { + if: { properties: { calculable: { const: true } }, required: ['calculable'] }, + then: { properties: { choices: { items: { required: ['operand_value'] } } } } + } + ] + }, + ImageChoiceInput: { + type: 'object', + additionalProperties: false, + allOf: [ + { anyOf: [{ required: ['value'] }, { required: ['name'] }] }, + { anyOf: [{ required: ['image_url'] }, { required: ['image_base64'] }, { required: ['image_upload_token'] }] } + ], + properties: { + value: { type: 'string' }, + name: { type: 'string' }, + api_code: { type: 'string' }, + operand_value: { type: 'number' }, + selected: { type: 'boolean' }, + image_url: { type: 'string' }, + image_base64: { type: 'string' }, + image_upload_token: { type: 'string' } + } + }, + ImageMultiSelectInput: { + type: 'object', + additionalProperties: false, + required: ['type', 'label', 'choices', 'answers'], + properties: { + type: { const: 'ImageMultiSelect' }, + label: { type: 'string' }, + cid: { type: 'string' }, + notes: { type: 'string' }, + private: { type: 'boolean' }, + required: { type: 'boolean' }, + position: { type: 'integer', minimum: 0 }, + choices: { type: 'array', minItems: 1, items: { $ref: 'ImageChoiceInput' } }, + calculable: { type: 'boolean' }, + choices_layout: { type: 'string' }, + answers: { type: 'array', minItems: 1, items: { $ref: 'AnswerInput' } }, + answer_setting_mode: { type: 'string', enum: ['absolute', 'partial_absolute', 'relative'] }, + answer_explanation: { type: 'string' } + }, + allOf: [ + { + if: { properties: { calculable: { const: true } }, required: ['calculable'] }, + then: { properties: { choices: { items: { required: ['operand_value'] } } } } + }, + { + if: { + anyOf: [ + { not: { required: ['answer_setting_mode'] } }, + { properties: { answer_setting_mode: { const: 'absolute' } } } + ] + }, + then: { properties: { answers: { maxItems: 1 } } } + }, + { + if: { + anyOf: [ + { not: { required: ['answer_setting_mode'] } }, + { properties: { answer_setting_mode: { const: 'absolute' } } } + ] + }, + then: { + properties: { + answers: { items: { properties: { value: { type: 'array', items: { type: 'string' }, minItems: 1 } } } } + } + } + } + ] + }, + ImageRadioButtonInput: { + type: 'object', + additionalProperties: false, + required: ['type', 'label'], + properties: { + type: { const: 'ImageRadioButton' }, + label: { type: 'string' }, + cid: { type: 'string' }, + notes: { type: 'string' }, + private: { type: 'boolean' }, + required: { type: 'boolean' }, + position: { type: 'integer', minimum: 0 }, + choices: { type: 'array', minItems: 1, items: { $ref: 'ImageChoiceInput' } }, + calculable: { type: 'boolean' }, + choices_layout: { type: 'string' } + }, + allOf: [ + { + if: { properties: { calculable: { const: true } }, required: ['calculable'] }, + then: { properties: { choices: { items: { required: ['operand_value'] } } } } + } + ] + }, + ImageSingleSelectInput: { + type: 'object', + additionalProperties: false, + required: ['type', 'label', 'choices', 'answers'], + properties: { + type: { const: 'ImageSingleSelect' }, + label: { type: 'string' }, + cid: { type: 'string' }, + notes: { type: 'string' }, + private: { type: 'boolean' }, + required: { type: 'boolean' }, + position: { type: 'integer', minimum: 0 }, + choices: { type: 'array', minItems: 1, items: { $ref: 'ImageChoiceInput' } }, + calculable: { type: 'boolean' }, + choices_layout: { type: 'string' }, + answers: { type: 'array', minItems: 1, items: { $ref: 'AnswerInput' } }, + answer_setting_mode: { type: 'string', enum: ['absolute', 'relative'] }, + answer_explanation: { type: 'string' } + }, + allOf: [ + { + if: { properties: { calculable: { const: true } }, required: ['calculable'] }, + then: { properties: { choices: { items: { required: ['operand_value'] } } } } + }, + { + if: { + anyOf: [ + { not: { required: ['answer_setting_mode'] } }, + { properties: { answer_setting_mode: { const: 'absolute' } } } + ] + }, + then: { properties: { answers: { maxItems: 1 } } } + } + ] + }, + LikertFieldInput: { + type: 'object', + additionalProperties: false, + required: ['type', 'label'], + properties: { + type: { const: 'LikertField' }, + label: { type: 'string' }, + cid: { type: 'string' }, + notes: { type: 'string' }, + private: { type: 'boolean' }, + required: { type: 'boolean' }, + position: { type: 'integer', minimum: 0 }, + choices: { type: 'array', minItems: 1, items: { $ref: 'ChoiceInput' } }, + statements: { type: 'array', minItems: 1, items: { $ref: 'StatementInput' } }, + horizontal_on_mobile: { type: 'boolean' }, + likert_choice_style: { type: 'string', enum: ['single', 'multiple'] }, + minimum_length: { type: 'integer', minimum: 0 }, + maximum_length: { type: 'integer', minimum: 0 }, + calculable: { type: 'boolean' } + }, + allOf: [ + { + if: { not: { properties: { likert_choice_style: { const: 'multiple' } }, required: ['likert_choice_style'] } }, + then: { not: { anyOf: [{ required: ['minimum_length'] }, { required: ['maximum_length'] }] } } + }, + { + if: { properties: { calculable: { const: true } }, required: ['calculable'] }, + then: { properties: { choices: { items: { required: ['operand_value'] } } } } + } + ] + }, + LinkFieldInput: { + type: 'object', + additionalProperties: false, + required: ['type', 'label'], + properties: { + type: { const: 'LinkField' }, + label: { type: 'string' }, + cid: { type: 'string' }, + notes: { type: 'string' }, + private: { type: 'boolean' }, + required: { type: 'boolean' }, + position: { type: 'integer', minimum: 0 }, + unique: { type: 'boolean' }, + predefined_value: { type: 'string' }, + placeholder: { type: 'string' } + } + }, + MatrixFieldInput: { + type: 'object', + additionalProperties: false, + required: ['type', 'label'], + properties: { + type: { const: 'MatrixField' }, + label: { type: 'string' }, + cid: { type: 'string' }, + notes: { type: 'string' }, + private: { type: 'boolean' }, + required: { type: 'boolean' }, + position: { type: 'integer', minimum: 0 }, + dimensions: { type: 'array', minItems: 1, items: { $ref: 'DimensionInput' } }, + statements: { type: 'array', minItems: 1, items: { $ref: 'StatementInput' } }, + horizontal_on_mobile: { type: 'boolean' }, + advanced_feature: { type: 'boolean' } + } + }, + MatrixScaleFieldInput: { + type: 'object', + additionalProperties: false, + required: ['type', 'label'], + properties: { + type: { const: 'MatrixScaleField' }, + label: { type: 'string' }, + cid: { type: 'string' }, + notes: { type: 'string' }, + private: { type: 'boolean' }, + required: { type: 'boolean' }, + position: { type: 'integer', minimum: 0 }, + rating_max: { type: 'integer', minimum: 1 }, + statements: { type: 'array', minItems: 1, items: { $ref: 'StatementInput' } }, + horizontal_on_mobile: { type: 'boolean' } + } + }, + MobileFieldInput: { + type: 'object', + additionalProperties: false, + required: ['type', 'label'], + properties: { + type: { const: 'MobileField' }, + label: { type: 'string' }, + cid: { type: 'string' }, + notes: { type: 'string' }, + private: { type: 'boolean' }, + required: { type: 'boolean' }, + position: { type: 'integer', minimum: 0 }, + unique: { type: 'boolean' }, + predefined_value: { type: 'string' }, + placeholder: { type: 'string' }, + sms_verification: { type: 'boolean' } + } + }, + MultiSelectInput: { + type: 'object', + additionalProperties: false, + required: ['type', 'label', 'choices', 'answers'], + properties: { + type: { const: 'MultiSelect' }, + label: { type: 'string' }, + cid: { type: 'string' }, + notes: { type: 'string' }, + private: { type: 'boolean' }, + required: { type: 'boolean' }, + position: { type: 'integer', minimum: 0 }, + other_choice_required: { type: 'boolean' }, + choices: { type: 'array', minItems: 1, items: { $ref: 'ChoiceInput' } }, + calculable: { type: 'boolean' }, + choices_layout: { type: 'string' }, + answers: { type: 'array', minItems: 1, items: { $ref: 'AnswerInput' } }, + answer_setting_mode: { type: 'string', enum: ['absolute', 'partial_absolute', 'relative'] }, + answer_explanation: { type: 'string' } + }, + allOf: [ + { + if: { properties: { calculable: { const: true } }, required: ['calculable'] }, + then: { properties: { choices: { items: { required: ['operand_value'] } } } } + }, + { + if: { + anyOf: [ + { not: { required: ['answer_setting_mode'] } }, + { properties: { answer_setting_mode: { const: 'absolute' } } } + ] + }, + then: { properties: { answers: { maxItems: 1 } } } + }, + { + if: { + anyOf: [ + { not: { required: ['answer_setting_mode'] } }, + { properties: { answer_setting_mode: { const: 'absolute' } } } + ] + }, + then: { + properties: { + answers: { items: { properties: { value: { type: 'array', items: { type: 'string' }, minItems: 1 } } } } + } + } + } + ] + }, + NameFieldInput: { + type: 'object', + additionalProperties: false, + required: ['type', 'label'], + properties: { + type: { const: 'NameField' }, + label: { type: 'string' }, + cid: { type: 'string' }, + notes: { type: 'string' }, + private: { type: 'boolean' }, + required: { type: 'boolean' }, + position: { type: 'integer', minimum: 0 }, + unique: { type: 'boolean' } + } + }, + NotificationRuleInput: { + type: 'object', + required: ['approach', 'url'], + properties: { + approach: { type: 'string', enum: ['WXWORK', 'DING_TALK', 'WEBHOOK'] }, + url: { type: 'string' }, + content: { type: 'string' }, + trigger_scope: { type: 'string' }, + mentioned_mobile_list: { type: 'array', items: { type: 'string' } }, + enabled: { type: 'boolean' } + }, + additionalProperties: false + }, + NpsFieldInput: { + type: 'object', + additionalProperties: false, + required: ['type', 'label'], + properties: { + type: { const: 'NpsField' }, + label: { type: 'string' }, + cid: { type: 'string' }, + notes: { type: 'string' }, + private: { type: 'boolean' }, + required: { type: 'boolean' }, + position: { type: 'integer', minimum: 0 }, + minimum_ratings_display_text: { type: 'string' }, + maximum_ratings_display_text: { type: 'string' } + } + }, + NpsInput: { + type: 'object', + additionalProperties: false, + required: ['type', 'label', 'answers'], + properties: { + type: { const: 'Nps' }, + label: { type: 'string' }, + cid: { type: 'string' }, + notes: { type: 'string' }, + private: { type: 'boolean' }, + required: { type: 'boolean' }, + position: { type: 'integer', minimum: 0 }, + minimum_ratings_display_text: { type: 'string' }, + maximum_ratings_display_text: { type: 'string' }, + answers: { type: 'array', minItems: 1, items: { $ref: 'AnswerInput' } }, + answer_setting_mode: { type: 'string', enum: ['absolute', 'relative'] }, + answer_explanation: { type: 'string' } + }, + allOf: [ + { + if: { + anyOf: [ + { not: { required: ['answer_setting_mode'] } }, + { properties: { answer_setting_mode: { const: 'absolute' } } } + ] + }, + then: { properties: { answers: { maxItems: 1 } } } + } + ] + }, + NumberFieldInput: { + type: 'object', + additionalProperties: false, + required: ['type', 'label'], + properties: { + type: { const: 'NumberField' }, + label: { type: 'string' }, + cid: { type: 'string' }, + notes: { type: 'string' }, + private: { type: 'boolean' }, + required: { type: 'boolean' }, + position: { type: 'integer', minimum: 0 }, + unique: { type: 'boolean' }, + predefined_value: { type: ['number', 'string'] }, + placeholder: { type: 'string' }, + range_min: { type: 'number' }, + range_max: { type: 'number' }, + precision: { type: 'integer', minimum: 0, maximum: 14 }, + display_precision: { type: 'integer', minimum: 0 }, + display_as_percentage: { type: 'boolean' }, + thousands_separator: { type: 'boolean' }, + currency_symbol: { type: 'string' }, + currency_symbol_position: { type: 'string', enum: ['prefix', 'suffix'] } + }, + allOf: [ + { + not: { + properties: { display_as_percentage: { const: true }, thousands_separator: { const: true } }, + required: ['display_as_percentage', 'thousands_separator'] + } + } + ] + }, + OpensearchDisplayFieldRule: { + type: 'object', + required: ['field_api_code'], + properties: { + field_api_code: { type: 'string' }, + field_label: { type: ['string', 'null'] }, + editable: { type: 'boolean' }, + protected: { type: 'boolean' }, + highlight: { + type: ['object', 'null'], + properties: { color: { type: ['string', 'null'] }, position: { type: 'integer' } } + } + } + }, + OpensearchQueryInput: { + type: 'object', + properties: { + form_token: { type: 'string' }, + name: { type: 'string' }, + description: { type: 'string' }, + enabled: { type: 'boolean' }, + search_field_rules: { type: 'array', minItems: 1, items: { $ref: 'OpensearchSearchFieldRule' } }, + display_field_rules: { type: 'array', minItems: 1, items: { $ref: 'OpensearchDisplayFieldRule' } }, + search_button: { + type: 'object', + properties: { text: { type: 'string' }, color: { type: 'string' } }, + additionalProperties: false + }, + messages: { + type: 'object', + properties: { has_result: { type: 'string' }, no_result: { type: 'string' } }, + additionalProperties: false + }, + allow_to_export_results: { type: 'boolean' }, + header: { + type: 'object', + properties: { + background_image: { + type: 'object', + properties: { + attachment_id: { type: 'string' }, + image_upload_token: { type: 'string' }, + position_x: { type: 'number' }, + position_y: { type: 'number' } + }, + additionalProperties: false + } + }, + additionalProperties: false + }, + extra_configurable_setting: { + type: 'object', + properties: { show_exam_correct_answer: { type: 'boolean' }, show_exam_explanation: { type: 'boolean' } }, + additionalProperties: false + }, + scope_conditions: { + type: 'array', + items: { + type: 'object', + properties: { + trigger: { type: 'string' }, + operator: { type: 'string' }, + value: {}, + group_index: { type: 'integer' }, + scope_attr: { type: 'string' } + }, + additionalProperties: false + } + } + }, + additionalProperties: false + }, + OpensearchSearchFieldRule: { + type: 'object', + required: ['search_field_settings'], + properties: { + operand: { type: 'string', enum: ['and', 'or'] }, + search_field_settings: { type: 'array', minItems: 1, items: { $ref: 'OpensearchSearchFieldSetting' } } + } + }, + OpensearchSearchFieldSetting: { + type: 'object', + required: ['field_api_code'], + properties: { + field_api_code: { type: 'string' }, + field_label: { type: ['string', 'null'] }, + fuzzy: { type: 'boolean' }, + sms_verification: { type: 'boolean' } + } + }, + PageBreakInput: { + type: 'object', + additionalProperties: false, + required: ['type', 'label'], + properties: { + type: { const: 'PageBreak' }, + label: { type: 'string' }, + cid: { type: 'string' }, + notes: { type: 'string' }, + private: { type: 'boolean' }, + required: { type: 'boolean' }, + position: { type: 'integer', minimum: 0 }, + disable_previous_page: { type: 'boolean' }, + previous_page_text: { type: 'string' }, + next_page_text: { type: 'string' }, + count_down: { type: 'integer', minimum: 0 } + } + }, + PredefinedChoicesSetInput: { + type: 'object', + required: ['label', 'choices'], + properties: { label: { type: 'string' }, choices: { type: 'array', items: { type: 'string' } } }, + additionalProperties: false + }, + RadioButtonInput: { + type: 'object', + additionalProperties: false, + required: ['type', 'label'], + properties: { + type: { const: 'RadioButton' }, + label: { type: 'string' }, + cid: { type: 'string' }, + notes: { type: 'string' }, + private: { type: 'boolean' }, + required: { type: 'boolean' }, + position: { type: 'integer', minimum: 0 }, + other_choice_required: { type: 'boolean' }, + choices: { type: 'array', minItems: 1, items: { $ref: 'ChoiceInput' } }, + calculable: { type: 'boolean' }, + choices_layout: { type: 'string' } + }, + allOf: [ + { + if: { properties: { calculable: { const: true } }, required: ['calculable'] }, + then: { properties: { choices: { items: { required: ['operand_value'] } } } } + } + ] + }, + RatingFieldInput: { + type: 'object', + additionalProperties: false, + required: ['type', 'label'], + properties: { + type: { const: 'RatingField' }, + label: { type: 'string' }, + cid: { type: 'string' }, + notes: { type: 'string' }, + private: { type: 'boolean' }, + required: { type: 'boolean' }, + position: { type: 'integer', minimum: 0 }, + rating_max: { type: 'integer', minimum: 1 } + } + }, + RatingInput: { + type: 'object', + additionalProperties: false, + required: ['type', 'label', 'answers'], + properties: { + type: { const: 'Rating' }, + label: { type: 'string' }, + cid: { type: 'string' }, + notes: { type: 'string' }, + private: { type: 'boolean' }, + required: { type: 'boolean' }, + position: { type: 'integer', minimum: 0 }, + rating_max: { type: 'integer', minimum: 1 }, + answers: { type: 'array', minItems: 1, items: { $ref: 'AnswerInput' } }, + answer_setting_mode: { type: 'string', enum: ['absolute', 'relative'] }, + answer_explanation: { type: 'string' } + }, + allOf: [ + { + if: { + anyOf: [ + { not: { required: ['answer_setting_mode'] } }, + { properties: { answer_setting_mode: { const: 'absolute' } } } + ] + }, + then: { properties: { answers: { maxItems: 1 } } } + } + ] + }, + RelationFieldInput: { + type: 'object', + additionalProperties: false, + required: ['type', 'label'], + properties: { + type: { const: 'RelationField' }, + label: { type: 'string' }, + cid: { type: 'string' }, + notes: { type: 'string' }, + private: { type: 'boolean' }, + required: { type: 'boolean' }, + position: { type: 'integer', minimum: 0 }, + related_form_token: { type: 'string' }, + paired: { type: 'boolean' }, + related_label: { type: 'string' } + } + }, + ReservationByDayQuotaInput: { + type: 'object', + additionalProperties: false, + required: ['type'], + properties: { + type: { const: 'by_day_repeat_weekly' }, + available_days_of_week: { + type: 'array', + items: { type: 'string', enum: ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'] } + }, + show_left_quota: { type: 'boolean' }, + start_time_offset: { + type: 'object', + additionalProperties: false, + properties: { offset_number: { type: 'integer', minimum: 0 }, unit: { type: 'string', enum: ['day', 'hour'] } } + }, + end_time_offset: { + type: 'object', + additionalProperties: false, + properties: { offset_number: { type: 'integer', minimum: 0 }, unit: { type: 'string', enum: ['day', 'hour'] } } + }, + daily_time_range_quotas: { + type: 'array', + items: { + type: 'object', + additionalProperties: false, + properties: { + for_wday: { + type: 'string', + enum: ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'] + }, + quota: { type: 'integer', minimum: 0 }, + price: { type: 'number', minimum: 0 }, + position: { type: 'integer', minimum: 0 }, + daily_time_range_token: { type: 'string' } + } + } + }, + non_reservable_rules: { + type: 'array', + items: { + type: 'object', + additionalProperties: false, + properties: { start_time: { type: 'string' }, end_time: { type: 'string' } } + } + } + } + }, + ReservationByTimeRangeQuotaInput: { + type: 'object', + additionalProperties: false, + required: ['type'], + properties: { + type: { const: 'by_time_range_repeat_daily' }, + available_days_of_week: { + type: 'array', + items: { type: 'string', enum: ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'] } + }, + show_left_quota: { type: 'boolean' }, + start_time_offset: { + type: 'object', + additionalProperties: false, + properties: { offset_number: { type: 'integer', minimum: 0 }, unit: { type: 'string', enum: ['day', 'hour'] } } + }, + end_time_offset: { + type: 'object', + additionalProperties: false, + properties: { offset_number: { type: 'integer', minimum: 0 }, unit: { type: 'string', enum: ['day', 'hour'] } } + }, + daily_time_range_quotas: { + type: 'array', + items: { + type: 'object', + additionalProperties: false, + required: ['start_time', 'end_time'], + properties: { + for_wday: { + type: 'string', + enum: ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'] + }, + quota: { type: 'integer', minimum: 0 }, + price: { type: 'number', minimum: 0 }, + position: { type: 'integer', minimum: 0 }, + daily_time_range_token: { type: 'string' }, + start_time: { + type: 'object', + additionalProperties: false, + properties: { + hour: { type: 'integer', minimum: 0, maximum: 23 }, + minute: { type: 'integer', minimum: 0, maximum: 59 } + } + }, + end_time: { + type: 'object', + additionalProperties: false, + properties: { + hour: { type: 'integer', minimum: 0, maximum: 23 }, + minute: { type: 'integer', minimum: 0, maximum: 59 } + } + } + } + } + }, + non_reservable_rules: { + type: 'array', + items: { + type: 'object', + additionalProperties: false, + properties: { start_time: { type: 'string' }, end_time: { type: 'string' } } + } + }, + time_range_mode: { type: 'string', enum: ['same_by_wday', 'different_by_wday'] } + }, + if: { properties: { time_range_mode: { const: 'different_by_wday' } }, required: ['time_range_mode'] }, + then: { properties: { daily_time_range_quotas: { items: { required: ['for_wday'] } } } } + }, + ReservationFieldInput: { + type: 'object', + additionalProperties: false, + required: ['type', 'label'], + properties: { + type: { const: 'ReservationField' }, + label: { type: 'string' }, + cid: { type: 'string' }, + notes: { type: 'string' }, + private: { type: 'boolean' }, + required: { type: 'boolean' }, + position: { type: 'integer', minimum: 0 }, + reservation_items: { type: 'array', minItems: 1, items: { $ref: 'ReservationItemInput' } }, + allow_multiple_items: { type: 'boolean' }, + item_allow_multiple_reservations: { type: 'boolean' }, + allow_multiple_number: { type: 'boolean' }, + use_daily_time_range: { type: 'boolean' } + } + }, + ReservationItemInput: { + type: 'object', + additionalProperties: false, + required: ['name'], + properties: { + name: { type: 'string' }, + api_code: { type: 'string' }, + description: { type: 'string' }, + position: { type: 'integer', minimum: 0 }, + hidden: { type: 'boolean' }, + selected: { type: 'boolean' }, + payable: { type: 'boolean' }, + start_date: { type: 'string' }, + end_date: { type: 'string' }, + quota_setting: { + type: 'object', + required: ['type'], + properties: { type: { type: 'string', enum: ['by_day_repeat_weekly', 'by_time_range_repeat_daily'] } }, + oneOf: [{ $ref: 'ReservationByDayQuotaInput' }, { $ref: 'ReservationByTimeRangeQuotaInput' }] + } + } + }, + SectionBreakInput: { + type: 'object', + additionalProperties: false, + required: ['type', 'label'], + properties: { + type: { const: 'SectionBreak' }, + label: { type: 'string' }, + cid: { type: 'string' }, + notes: { type: 'string' }, + private: { type: 'boolean' }, + required: { type: 'boolean' }, + position: { type: 'integer', minimum: 0 }, + show_split_line: { type: 'boolean' }, + show_part_description: { type: 'boolean' } + } + }, + ShortAnswerInput: { + type: 'object', + additionalProperties: false, + required: ['type', 'label', 'answers'], + properties: { + type: { const: 'ShortAnswer' }, + label: { type: 'string' }, + cid: { type: 'string' }, + notes: { type: 'string' }, + private: { type: 'boolean' }, + required: { type: 'boolean' }, + position: { type: 'integer', minimum: 0 }, + unique: { type: 'boolean' }, + predefined_value: { type: 'string' }, + placeholder: { type: 'string' }, + answers: { type: 'array', minItems: 1, items: { $ref: 'AnswerInput' } }, + answer_setting_mode: { type: 'string', enum: ['absolute', 'relative'] }, + answer_explanation: { type: 'string' } + }, + allOf: [ + { + if: { + anyOf: [ + { not: { required: ['answer_setting_mode'] } }, + { properties: { answer_setting_mode: { const: 'absolute' } } } + ] + }, + then: { properties: { answers: { maxItems: 1 } } } + } + ] + }, + SingleSelectInput: { + type: 'object', + additionalProperties: false, + required: ['type', 'label', 'choices', 'answers'], + properties: { + type: { const: 'SingleSelect' }, + label: { type: 'string' }, + cid: { type: 'string' }, + notes: { type: 'string' }, + private: { type: 'boolean' }, + required: { type: 'boolean' }, + position: { type: 'integer', minimum: 0 }, + other_choice_required: { type: 'boolean' }, + choices: { type: 'array', minItems: 1, items: { $ref: 'ChoiceInput' } }, + calculable: { type: 'boolean' }, + choices_layout: { type: 'string' }, + answers: { type: 'array', minItems: 1, items: { $ref: 'AnswerInput' } }, + answer_setting_mode: { type: 'string', enum: ['absolute', 'relative'] }, + answer_explanation: { type: 'string' } + }, + allOf: [ + { + if: { properties: { calculable: { const: true } }, required: ['calculable'] }, + then: { properties: { choices: { items: { required: ['operand_value'] } } } } + }, + { + if: { + anyOf: [ + { not: { required: ['answer_setting_mode'] } }, + { properties: { answer_setting_mode: { const: 'absolute' } } } + ] + }, + then: { properties: { answers: { maxItems: 1 } } } + } + ] + }, + SortFieldInput: { + type: 'object', + additionalProperties: false, + required: ['type', 'label'], + properties: { + type: { const: 'SortField' }, + label: { type: 'string' }, + cid: { type: 'string' }, + notes: { type: 'string' }, + private: { type: 'boolean' }, + required: { type: 'boolean' }, + position: { type: 'integer', minimum: 0 }, + choices: { type: 'array', minItems: 1, items: { $ref: 'ChoiceInput' } }, + random_choices: { type: 'boolean' } + } + }, + StatementInput: { + type: 'object', + additionalProperties: false, + required: ['label'], + properties: { label: { type: 'string', pattern: '\\S' }, api_code: { type: 'string' } } + }, + TableColumnInput: { + type: 'object', + required: ['type', 'label'], + properties: { + type: { + type: 'string', + enum: [ + 'TextArea', + 'RadioButton', + 'CheckBox', + 'BooleanField', + 'MobileField', + 'NumberField', + 'DateTimeField', + 'EmailField', + 'LinkField', + 'AttachmentField', + 'FormulaField', + 'RelationField' + ] + }, + label: { type: 'string' } + }, + oneOf: [ + { $ref: 'TextAreaInput' }, + { $ref: 'RadioButtonInput' }, + { $ref: 'CheckBoxInput' }, + { $ref: 'BooleanFieldInput' }, + { $ref: 'MobileFieldInput' }, + { $ref: 'NumberFieldInput' }, + { $ref: 'DateTimeFieldInput' }, + { $ref: 'EmailFieldInput' }, + { $ref: 'LinkFieldInput' }, + { $ref: 'AttachmentFieldInput' }, + { $ref: 'FormulaFieldInput' }, + { $ref: 'RelationFieldInput' } + ] + }, + TableCreateInput: { + type: 'object', + additionalProperties: false, + required: ['name', 'fields'], + properties: { + name: { type: 'string' }, + description: { type: 'string' }, + fields: { type: 'array', minItems: 1, items: { $ref: 'TableColumnInput' } }, + setting: { $ref: 'FormSettingWriteInput' }, + folder_token: { type: 'string' }, + with_default_entries: { type: 'boolean' }, + scene: { + type: 'string', + enum: [ + 'form', + 'survey', + 'registry', + 'vote', + 'exam', + 'reservation', + 'customer_acquisition', + 'evaluation', + 'online_payment', + 'quickpay' + ] + } + } + }, + TableFieldInput: { + type: 'object', + additionalProperties: false, + required: ['type', 'label'], + properties: { + type: { const: 'TableField' }, + label: { type: 'string' }, + cid: { type: 'string' }, + notes: { type: 'string' }, + private: { type: 'boolean' }, + required: { type: 'boolean' }, + position: { type: 'integer', minimum: 0 }, + dimensions: { type: 'array', minItems: 1, items: { $ref: 'DimensionInput' } }, + init_row_length: { type: 'integer', minimum: 1 } + } + }, + TelephoneFieldInput: { + type: 'object', + additionalProperties: false, + required: ['type', 'label'], + properties: { + type: { const: 'TelephoneField' }, + label: { type: 'string' }, + cid: { type: 'string' }, + notes: { type: 'string' }, + private: { type: 'boolean' }, + required: { type: 'boolean' }, + position: { type: 'integer', minimum: 0 }, + unique: { type: 'boolean' }, + predefined_value: { type: 'string' }, + placeholder: { type: 'string' } + } + }, + TemplateUsageInput: { + type: 'object', + required: ['id'], + properties: { + id: { type: 'string' }, + template_id: { type: 'string' }, + tags: { oneOf: [{ type: 'array', items: { type: 'string' } }, { type: 'string' }] }, + tag_id: { type: 'string' }, + folder_id: { type: 'string' }, + scene: { type: 'string' }, + frm: { type: 'string' }, + create_source: { type: 'string' }, + group_id: { type: 'string' }, + form_app_trial_alert: { type: 'boolean' }, + confirm_unbind_form_app: { type: 'boolean' }, + confirm_form_app_trial: { type: 'boolean' } + } + }, + TextAreaInput: { + type: 'object', + additionalProperties: false, + required: ['type', 'label'], + properties: { + type: { const: 'TextArea' }, + label: { type: 'string' }, + cid: { type: 'string' }, + notes: { type: 'string' }, + private: { type: 'boolean' }, + required: { type: 'boolean' }, + position: { type: 'integer', minimum: 0 }, + unique: { type: 'boolean' }, + predefined_value: { type: 'string' }, + placeholder: { type: 'string' } + } + }, + TextFieldInput: { + type: 'object', + additionalProperties: false, + required: ['type', 'label'], + properties: { + type: { const: 'TextField' }, + label: { type: 'string' }, + cid: { type: 'string' }, + notes: { type: 'string' }, + private: { type: 'boolean' }, + required: { type: 'boolean' }, + position: { type: 'integer', minimum: 0 }, + unique: { type: 'boolean' }, + predefined_value: { type: 'string' }, + placeholder: { type: 'string' } + } + }, + ThemeInput: { + type: 'object', + properties: { + primary_color: { type: 'string' }, + secondary_color: { type: 'string' }, + wallpaper: { + type: 'object', + properties: { + background_color: { type: 'string' }, + background_image_url: { type: 'string' }, + background_image_upload_token: { type: 'string' }, + background_image_attachment_id: { type: ['string', 'null'] } + }, + additionalProperties: false + }, + header: { + type: 'object', + properties: { + type: { type: 'string', enum: ['none', 'text', 'image', 'carousel'] }, + text: { type: 'string' }, + background_color: { type: 'string' }, + text_style: { type: 'object', additionalProperties: { type: 'string' } }, + header_image_url: { type: 'string' }, + header_image_base64: { type: 'string' }, + header_image_upload_token: { type: 'string' }, + carousel_images: { + type: 'array', + minItems: 1, + maxItems: 5, + items: { + type: 'object', + properties: { + image_upload_token: { type: 'string' }, + image_url: { type: 'string' }, + attachment_id: { type: 'string' } + }, + additionalProperties: false + } + }, + header_image_attachment_id: { type: ['string', 'null'] } + }, + additionalProperties: false + }, + typography: { + type: 'object', + properties: { + form_header: { type: 'object', additionalProperties: { type: 'string' } }, + field_label: { type: 'object', additionalProperties: { type: 'string' } }, + choice_style: { type: 'object', additionalProperties: { type: 'string' } } + }, + additionalProperties: false + }, + form_container: { + type: 'object', + properties: { + background_color: { type: 'string' }, + border_color: { type: 'string' }, + border_width: { type: 'string' } + }, + additionalProperties: false + }, + submit_button: { + type: 'object', + properties: { background_color: { type: 'string' }, color: { type: 'string' }, font_size: { type: 'string' } }, + additionalProperties: false + }, + generate_header_image: { type: 'object', properties: { prompt: { type: 'string' } }, additionalProperties: false } + }, + additionalProperties: false + }, + TimeFieldInput: { + type: 'object', + additionalProperties: false, + required: ['type', 'label'], + properties: { + type: { const: 'TimeField' }, + label: { type: 'string' }, + cid: { type: 'string' }, + notes: { type: 'string' }, + private: { type: 'boolean' }, + required: { type: 'boolean' }, + position: { type: 'integer', minimum: 0 }, + predefined_value: { + type: 'object', + properties: { hour: { type: 'integer' }, minute: { type: 'integer' }, second: { type: 'integer' } } + }, + include_second: { type: 'boolean' } + } + }, + TimeOfDay: { + type: 'object', + required: ['hour', 'minute'], + properties: { + hour: { type: 'integer', minimum: 0, maximum: 23 }, + minute: { type: 'integer', minimum: 0, maximum: 59 } + }, + additionalProperties: false + }, + TrueOrFalseInput: { + type: 'object', + additionalProperties: false, + required: ['type', 'label', 'choices', 'answers'], + properties: { + type: { const: 'TrueOrFalse' }, + label: { type: 'string' }, + cid: { type: 'string' }, + notes: { type: 'string' }, + private: { type: 'boolean' }, + required: { type: 'boolean' }, + position: { type: 'integer', minimum: 0 }, + other_choice_required: { type: 'boolean' }, + choices: { type: 'array', minItems: 1, items: { $ref: 'ChoiceInput' } }, + calculable: { type: 'boolean' }, + choices_layout: { type: 'string' }, + answers: { type: 'array', minItems: 1, items: { $ref: 'AnswerInput' } }, + answer_setting_mode: { type: 'string', enum: ['absolute', 'relative'] }, + answer_explanation: { type: 'string' } + }, + allOf: [ + { + if: { properties: { calculable: { const: true } }, required: ['calculable'] }, + then: { properties: { choices: { items: { required: ['operand_value'] } } } } + }, + { + if: { + anyOf: [ + { not: { required: ['answer_setting_mode'] } }, + { properties: { answer_setting_mode: { const: 'absolute' } } } + ] + }, + then: { properties: { answers: { maxItems: 1 } } } + } + ] + }, + WebhookInput: { type: 'object', properties: { entry_post_url: { type: 'string' } }, additionalProperties: false }, + WidgetButtonInput: { + type: 'object', + additionalProperties: false, + required: ['type', 'label'], + properties: { + type: { const: 'WidgetButton' }, + label: { type: 'string' }, + cid: { type: 'string' }, + notes: { type: 'string' }, + private: { type: 'boolean' }, + required: { type: 'boolean' }, + position: { type: 'integer', minimum: 0 }, + buttons: { type: 'array', minItems: 1, items: { $ref: 'WidgetButtonItemInput' } }, + buttons_number: { type: 'integer', minimum: 1 }, + fixed_to_bottom: { type: 'boolean' } + } + }, + WidgetButtonItemInput: { + type: 'object', + additionalProperties: false, + required: ['text'], + properties: { + text: { type: 'string' }, + position: { type: 'integer', minimum: 0 }, + link_type: { type: 'string', enum: ['normal', 'miniapp', 'phone', 'qrcode'] }, + link_attrs: { type: 'object' }, + url: { type: 'string' } + } + }, + WidgetContactInput: { + type: 'object', + additionalProperties: false, + required: ['type', 'label'], + properties: { + type: { const: 'WidgetContact' }, + label: { type: 'string' }, + cid: { type: 'string' }, + notes: { type: 'string' }, + private: { type: 'boolean' }, + required: { type: 'boolean' }, + position: { type: 'integer', minimum: 0 }, + widget_images: { type: 'array', items: { $ref: 'WidgetImageInput' } }, + widget_max_images_number: { type: 'integer', minimum: 1 }, + phones: { + type: 'array', + items: { + type: 'object', + required: ['phone'], + additionalProperties: false, + properties: { phone: { type: 'string' } } + } + } + } + }, + WidgetImageInput: { + type: 'object', + additionalProperties: false, + anyOf: [{ required: ['image_url'] }, { required: ['image_base64'] }, { required: ['image_upload_token'] }], + properties: { + image_url: { type: 'string' }, + image_base64: { type: 'string' }, + image_upload_token: { type: 'string' } + } + }, + WidgetMapInput: { + type: 'object', + additionalProperties: false, + required: ['type', 'label'], + properties: { + type: { const: 'WidgetMap' }, + label: { type: 'string' }, + cid: { type: 'string' }, + notes: { type: 'string' }, + private: { type: 'boolean' }, + required: { type: 'boolean' }, + position: { type: 'integer', minimum: 0 }, + nav_address: { type: 'string' }, + longitude: { type: 'number' }, + latitude: { type: 'number' }, + value: { type: 'string' }, + localizable_on_mobile: { type: 'boolean' } + } + }, + WidgetMarqueeInput: { + type: 'object', + additionalProperties: false, + required: ['type', 'label'], + properties: { + type: { const: 'WidgetMarquee' }, + label: { type: 'string' }, + cid: { type: 'string' }, + notes: { type: 'string' }, + private: { type: 'boolean' }, + required: { type: 'boolean' }, + position: { type: 'integer', minimum: 0 }, + lines: { type: 'integer', minimum: 1 }, + entries_init_number: { type: 'integer', minimum: 0 }, + entries_number_visible: { type: 'boolean' }, + marquee_data_type: { type: 'string', enum: ['fake', 'real'] } + } + } +}; diff --git a/src/spec.test.ts b/src/spec.test.ts new file mode 100644 index 0000000..5353e68 --- /dev/null +++ b/src/spec.test.ts @@ -0,0 +1,135 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { UsageError } from './errors.js'; +import { withQuery } from './http.js'; +import { FORM_IMAGE_TYPES } from './commands/upload.js'; +import { BASE_PATH, bodyChoices, call, choices, operation, unansweredLookups, type Schema } from './spec.js'; +import { operationOf, validate } from './testing/conformance.js'; + +test('a request is the operation openapi.yaml names, under the base path, its path parameters encoded', () => { + const request = call('getEntry', { + path: { form_token: 'Kp7mQ2', serial_number: 12 }, + query: { include_labels: true } + }); + + assert.equal(BASE_PATH, '/api/v1'); + assert.equal(request.method, 'GET'); + assert.equal(withQuery(request.path, request.query), '/api/v1/forms/Kp7mQ2/entries/12?include_labels=true'); + assert.equal(call('getFillViewPage', { path: { view_token: 'a/b' } }).path, '/api/v1/fill/v/a%2Fb'); +}); + +test('a parameter the operation does not have is refused by name, before anything is sent', () => { + assert.throws( + () => call('listFormViewEntries', { path: { form_token: 'Kp7mQ2', view_token: 'aB3dE9' }, query: { limit: 10 } }), + (error: unknown) => error instanceof UsageError && /takes no limit parameter/.test(error.message) + ); + // A path parameter that is not there is this code's mistake, not the caller's. + assert.throws( + () => call('getForm', { path: { form_token: 'Kp7mQ2', view_token: 'x' } }), + /no path parameter view_token/ + ); + assert.throws(() => call('getForm'), /needs its path parameter form_token/); +}); + +test('a parameter is held to its type and to being there, and to nothing a later API could widen', () => { + const refused = (fn: () => unknown, message: RegExp) => + assert.throws(fn, (error: unknown) => error instanceof UsageError && message.test(error.message)); + + refused( + () => call('getEntry', { path: { form_token: 'Kp7mQ2', serial_number: 'abc' } }), + /^serial_number must be an integer, got "abc"$/ + ); + refused(() => call('listTradeTransactions', { query: { next: 'page-two' } }), /^next must be an integer/); + refused(() => call('getEntryStats'), /^from is required/); + + // A limit, a length or a list of values may grow in a later API; the server judges those. + assert.ok(call('listFolders', { query: { limit: 500 } })); + assert.ok(call('listTradeRefunds', { query: { status: 'reversed' } })); + assert.ok(call('listEntries', { path: { form_token: 'Kp7mQ2' }, query: { keyword: 'x'.repeat(201) } })); +}); + +test('a list goes on the wire as its parameter says: comma-joined when it does not explode, repeated otherwise', () => { + const statuses = call('listTradeTransactions', { query: { paid_status: ['paid', 'refunded'] } }); + assert.equal(withQuery(statuses.path, statuses.query), '/api/v1/trade/transactions?paid_status=paid%2Crefunded'); + + // `q` is a string in the schema, and the spec's prose lets it repeat. + const names = call('listForms', { query: { q: ['报名', '反馈'] } }); + assert.equal( + withQuery(names.path, names.query), + `/api/v1/forms?${new URLSearchParams([ + ['q[]', '报名'], + ['q[]', '反馈'] + ])}` + ); +}); + +test('a body goes as given, for the API to judge against the spec it runs', () => { + // A key this copy of the spec has never heard of may be one the API has + // learned since this CLI was installed; the request still leaves. + const body = { name: 'x', fields: [{ type: 'TextField', label: 'a', future_option: true }] }; + assert.deepEqual(call('createForm', { body }).body, body); + + assert.throws(() => call('getForm', { path: { form_token: 'Kp7mQ2' }, body: {} }), /takes no JSON body/); + assert.throws(() => call('createFolder'), /needs a request body/); +}); + +test('flags read their values off the spec rather than a copy of it', () => { + assert.deepEqual(choices('getEntryStats', 'by'), ['day', 'week', 'month']); + assert.ok(choices('listTradeTransactions', 'paid_status')?.includes('partial_refunded')); + // The copies these replaced had fallen behind: no form view, no form or quickpay scene. + assert.ok(bodyChoices('createFormView', 'view_type')?.includes('form')); + assert.ok(bodyChoices('createForm', 'scene')?.includes('quickpay')); + assert.ok(bodyChoices('createTable', 'fields[].type')?.includes('TextArea')); + assert.deepEqual(bodyChoices('createFolder', 'kind'), ['form', 'table']); + // A list the spec does not give leaves the flag open, and is noted for the test that loads the commands. + assert.equal(choices('listFolders', 'limit'), undefined); + assert.equal(bodyChoices('createFolder', 'colour'), undefined); + assert.deepEqual(unansweredLookups(), ['listFolders limit', 'createFolder colour']); +}); + +test('the image types the CLI uploads as are ones the spec lists', () => { + const multipart = operation('uploadFormImage').body?.content['multipart/form-data']; + const imageType = multipart?.properties?.image_type as Schema | undefined; + for (const type of FORM_IMAGE_TYPES) assert.ok(imageType?.enum?.includes(type), type); +}); + +test('a request is traced back to its operation, a literal segment before a parameter', () => { + assert.equal(operationOf({ method: 'GET', path: '/api/v1/forms/Kp7mQ2/entries/count' }), 'countEntries'); + assert.equal(operationOf({ method: 'GET', path: '/api/v1/forms/Kp7mQ2/entries/12' }), 'getEntry'); + assert.equal(operationOf({ method: 'PATCH', path: '/api/v1/forms/Kp7mQ2/entries/batch' }), 'patchEntriesBatch'); + assert.equal(operationOf({ method: 'GET', path: '/api/v1/nowhere' }), undefined); + assert.equal(operationOf({ method: 'GET', path: '/elsewhere/forms' }), undefined); +}); + +test('the check the tests hold requests to knows the keywords the generator keeps', () => { + const schema: Schema = { + type: 'object', + required: ['kind'], + additionalProperties: false, + properties: { + kind: { enum: ['a', 'b'] }, + size: { type: 'integer', minimum: 1 }, + tags: { type: 'array', maxItems: 2, items: { type: 'string', pattern: '^[a-z]+$' } }, + shape: { oneOf: [{ type: 'string' }, { type: 'object', required: ['id'] }] } + }, + if: { properties: { kind: { const: 'b' } } }, + // oxlint-disable-next-line unicorn/no-thenable -- a JSON Schema keyword, never awaited + then: { required: ['size'] } + }; + const messages = (value: unknown) => validate(schema, value).map((issue) => `${issue.pointer} ${issue.message}`); + + assert.deepEqual(messages({ kind: 'a' }), []); + assert.deepEqual(messages({ kind: 'b' }), ['/size is required']); + assert.deepEqual(messages({ kind: 'c', size: 0, extra: 1 }), [ + '/kind must be one of a, b', + '/size must be at least 1', + '/extra is not a key it takes' + ]); + assert.deepEqual(messages({ kind: 'a', tags: ['ok', 'No', 'x'] }), [ + '/tags must have at most 2 items', + '/tags/1 must match ^[a-z]+$' + ]); + assert.deepEqual(messages({ kind: 'a', shape: {} }), ['/shape/id is required']); + assert.deepEqual(messages([]), [' must be object']); +}); diff --git a/src/spec.ts b/src/spec.ts new file mode 100644 index 0000000..1e5c01f --- /dev/null +++ b/src/spec.ts @@ -0,0 +1,282 @@ +import { UsageError } from './errors.js'; +import { BASE_PATH, OPERATIONS, SCHEMAS, type OperationId } from './generated/operations.js'; +import type { HttpMethod, HttpRequest, QueryValues } from './http.js'; + +/** + * What API v1 takes, as openapi.yaml says it, and the one way a command turns + * an operation into a request. + * + * The commands decide what the CLI offers. Which operation lives at which + * path, which parameters it has and of what type, how a list goes on the wire, + * and which values a flag offers are the spec's, read from the generated table + * rather than written down a second time here. When the spec changes, + * `npm run spec:update` regenerates the table and the CLI follows. + */ + +export type { OperationId } from './generated/operations.js'; +export { BASE_PATH } from './generated/operations.js'; + +/** A JSON Schema cut down to the constraints scripts/generate.mjs keeps. */ +export interface Schema { + /** The name of a schema in SCHEMAS. */ + readonly $ref?: string; + readonly type?: string | readonly string[]; + readonly enum?: readonly unknown[]; + readonly const?: unknown; + readonly properties?: { readonly [name: string]: Schema | boolean }; + readonly required?: readonly string[]; + readonly dependentRequired?: { readonly [name: string]: readonly string[] }; + readonly additionalProperties?: Schema | boolean; + readonly items?: Schema | boolean; + readonly contains?: Schema | boolean; + readonly allOf?: readonly (Schema | boolean)[]; + readonly oneOf?: readonly (Schema | boolean)[]; + readonly anyOf?: readonly (Schema | boolean)[]; + readonly not?: Schema | boolean; + readonly if?: Schema | boolean; + readonly then?: Schema | boolean; + readonly else?: Schema | boolean; + readonly minimum?: number; + readonly maximum?: number; + readonly minLength?: number; + readonly maxLength?: number; + readonly minItems?: number; + readonly maxItems?: number; + readonly pattern?: string; +} + +export interface Parameter { + readonly name: string; + readonly in: string; + readonly required?: boolean; + readonly style?: string; + readonly explode?: boolean; + readonly schema: Schema; +} + +export interface Operation { + readonly method: HttpMethod; + /** Relative to BASE_PATH, with `{name}` for each path parameter. */ + readonly path: string; + readonly parameters: readonly Parameter[]; + readonly body?: { readonly required?: boolean; readonly content: { readonly [mediaType: string]: Schema } }; +} + +export function operation(id: OperationId): Operation { + return OPERATIONS[id]; +} + +// --- the request ----------------------------------------------------------- + +/** A query value as a command has it; `call` spells it for the wire. */ +export type QueryValue = string | number | boolean | readonly string[] | undefined; + +export interface CallParts { + readonly path?: Readonly>; + readonly query?: Readonly>; + readonly body?: unknown; + /** A multipart body, for the operations that take a file. */ + readonly form?: FormData; +} + +/** + * The request an operation is. + * + * Path and query come from the CLI's own flags and arguments, so they are held + * to the operation's parameters here: one it does not have, a required one + * left out, or a value that is not of its type is a usage error naming it. A + * new parameter needs a new flag and so a new release anyway, which is why + * this never makes an installed CLI stricter than a newer API. + * + * A body goes as given. It is the caller's payload as often as not, the API + * validates it against the openapi.yaml it runs — the only copy never out of + * date — and answers a 400 that says exactly what is wrong. A check here + * would save that round trip at the price of refusing, in every CLI already + * installed, each field type, key and value the API learns after it ships. + */ +export function call(id: OperationId, parts: CallParts = {}): HttpRequest { + const op = OPERATIONS[id]; + const request: HttpRequest = { + method: op.method, + path: BASE_PATH + fillPath(id, op, parts.path ?? {}) + }; + + const query = queryOf(id, op, parts.query ?? {}); + if (Object.keys(query).length > 0) request.query = query; + + if (parts.body !== undefined) { + if (!op.body?.content['application/json']) throw new Error(`${id} takes no JSON body`); + request.body = parts.body; + } + if (parts.form !== undefined) { + if (!op.body?.content['multipart/form-data']) throw new Error(`${id} takes no multipart body`); + request.form = parts.form; + } + if (op.body?.required && request.body === undefined && request.form === undefined) { + throw new Error(`${id} needs a request body`); + } + return request; +} + +function fillPath(id: OperationId, op: Operation, values: Readonly>) { + for (const name of Object.keys(values)) { + if (!op.parameters.some((param) => param.in === 'path' && param.name === name)) { + throw new Error(`${id} has no path parameter ${name}`); + } + } + return op.path.replace(/\{([^}]+)\}/g, (_, name: string) => { + const value = values[name]; + if (value === undefined || value === '') throw new Error(`${id} needs its path parameter ${name}`); + const param = op.parameters.find((candidate) => candidate.in === 'path' && candidate.name === name) as Parameter; + check(param, [String(value)]); + return encodeURIComponent(String(value)); + }); +} + +function queryOf(id: OperationId, op: Operation, values: Readonly>): QueryValues { + const query: Record = {}; + for (const [name, value] of Object.entries(values)) { + if (value === undefined) continue; + const param = op.parameters.find((candidate) => candidate.in === 'query' && candidate.name === name); + if (!param) { + throw new UsageError(`${op.method} ${BASE_PATH}${op.path} takes no ${name} parameter (${id} in openapi.yaml)`); + } + const texts = typeof value === 'object' ? [...value] : [String(value)]; + check(param, texts); + if (isArray(param.schema)) { + // A list on one parameter is `name=a,b` when the spec says it does not + // explode, and `name[]=a&name[]=b` otherwise, which is how Rails reads one. + query[name] = param.explode === false ? texts.join(',') : texts; + } else { + // The spec's own prose lets a scalar repeat — `q[]=A&q[]=B` — so a list + // is each of its values sent as the scalar. + query[name] = typeof value === 'object' ? texts : (texts[0] as string); + } + } + for (const param of op.parameters) { + if (param.in === 'query' && param.required && query[param.name] === undefined) { + throw new UsageError(`${param.name} is required (${id} in openapi.yaml)`); + } + } + return query; +} + +/** + * Each value of a path or query parameter, held to the type it declares: the + * one thing about it no later version of the API changes its mind on. A + * range, a length or a list of values may grow, so those are the server's. + */ +function check(param: Parameter, texts: readonly string[]): void { + const types = typesOf(isArray(param.schema) ? itemsOf(param.schema) : param.schema); + if (types.length === 0) return; + for (const text of texts) { + if (types.some((type) => reads(text, type))) continue; + throw new UsageError(`${param.name} must be ${types.map(article).join(' or ')}, got ${JSON.stringify(text)}`); + } +} + +/** Whether a string off the command line reads as a value of the type. */ +function reads(text: string, type: string): boolean { + switch (type) { + case 'integer': + return /^-?\d+$/.test(text); + case 'number': + return /^-?\d+(\.\d+)?$/.test(text); + case 'boolean': + return text === 'true' || text === 'false'; + case 'null': + return false; + default: + return true; + } +} + +function article(type: string): string { + return type === 'integer' || type === 'object' || type === 'array' ? `an ${type}` : `a ${type}`; +} + +function isArray(schema: Schema): boolean { + return typesOf(schema).includes('array'); +} + +function itemsOf(schema: Schema): Schema { + const items = resolved(schema).items; + return typeof items === 'object' ? items : {}; +} + +function typesOf(schema: Schema): readonly string[] { + const type = resolved(schema).type; + return type === undefined ? [] : typeof type === 'string' ? [type] : type; +} + +function resolved(schema: Schema): Schema { + let current = schema; + while (current.$ref !== undefined) { + const target = SCHEMAS[current.$ref]; + if (!target) throw new Error(`openapi.yaml has no schema ${current.$ref}`); + current = target; + } + return current; +} + +// --- the vocabulary flags are checked by -------------------------------------- + +/** + * Every lookup that found no list of values. These run as the commands load, + * so a spec that dropped one must not take every command down with it, --help + * included: the flag goes without a list and the server judges the value, and + * a test fails on this list instead, in the sync pull request that dropped it. + */ +const unanswered: string[] = []; + +export function unansweredLookups(): readonly string[] { + return unanswered; +} + +/** + * The values a query parameter takes, for the flag that feeds it. A flag + * listing them itself is a second copy, and the second copy was already behind + * once: a view type and two scenes the API had learned were refused by the CLI + * before they reached it. + */ +export function choices(id: OperationId, name: string): readonly string[] | undefined { + const param = OPERATIONS[id].parameters.find((candidate) => candidate.name === name); + return enumOf(param && (isArray(param.schema) ? itemsOf(param.schema) : param.schema), `${id} ${name}`); +} + +/** + * The values a property of a JSON body takes. `path` walks properties with + * dots and steps into a list's items with `[]`: `fields[].type`. + */ +export function bodyChoices(id: OperationId, path: string): readonly string[] | undefined { + let schema = OPERATIONS[id].body?.content['application/json']; + for (const step of path.split('.')) { + if (!schema) break; + const many = step.endsWith('[]'); + const property = propertyOf(schema, many ? step.slice(0, -2) : step); + schema = property && (many ? itemsOf(property) : property); + } + return enumOf(schema, `${id} ${path}`); +} + +/** A property as an object schema or any of its allOf parts declares it. */ +function propertyOf(schema: Schema, name: string): Schema | undefined { + const here = resolved(schema); + const own = here.properties?.[name]; + if (typeof own === 'object') return own; + for (const part of here.allOf ?? []) { + if (typeof part !== 'object') continue; + const found = propertyOf(part, name); + if (found) return found; + } + return undefined; +} + +function enumOf(schema: Schema | undefined, what: string): readonly string[] | undefined { + const values = (schema ? (resolved(schema).enum ?? []) : []).filter( + (value): value is string => typeof value === 'string' + ); + if (values.length > 0) return values; + unanswered.push(what); + return undefined; +} diff --git a/src/testing/conformance.ts b/src/testing/conformance.ts new file mode 100644 index 0000000..50665c1 --- /dev/null +++ b/src/testing/conformance.ts @@ -0,0 +1,189 @@ +import { BASE_PATH, OPERATIONS, SCHEMAS, type OperationId } from '../generated/operations.js'; +import type { HttpRequest } from '../http.js'; +import type { Schema } from '../spec.js'; + +/** + * Holds a request the CLI built to openapi.yaml, for the tests. It is never + * part of a command: the API checks every body against the spec it runs, and + * a copy checked here would refuse what the API has since learned. What the + * tests want to know is narrower — whether a body the CLI itself puts together, + * or a help example spells out, is one the spec takes — and this answers it. + * + * It knows the keywords scripts/generate.mjs keeps, and the generator stops on + * one it does not, so nothing in the spec is passed over here unnoticed. + */ + +/** What is wrong with a request, one line each; nothing when the spec takes it. */ +export function nonconformities(request: HttpRequest): string[] { + const id = operationOf(request); + if (!id) return [`${request.method} ${request.path}: openapi.yaml has no such operation`]; + const schema = OPERATIONS[id].body?.content['application/json']; + if (schema === undefined || request.body === undefined) return []; + // What the server reads is the JSON, where an undefined key is no key at all. + return validate(schema, JSON.parse(JSON.stringify(request.body))).map( + (issue) => `${id} body${issue.pointer} ${issue.message}, got ${JSON.stringify(issue.value)}` + ); +} + +/** + * The operation a request was sent to, read off its method and path, so a + * request is held to the spec without trusting how it was built. + */ +export function operationOf(request: Pick): OperationId | undefined { + if (!request.path.startsWith(BASE_PATH)) return undefined; + const path = request.path.slice(BASE_PATH.length); + const matches = (Object.keys(OPERATIONS) as OperationId[]).filter((id) => { + const op = OPERATIONS[id]; + if (op.method !== request.method) return false; + const pattern = op.path.replace(/[.*+?^$()|[\]\\]/g, '\\$&').replace(/\{[^}]+\}/g, '[^/]+'); + return new RegExp(`^${pattern}$`).test(path); + }); + // A literal segment beats a parameter: /entries/count is not /entries/{serial_number}. + return matches.sort((a, b) => literalLength(OPERATIONS[b].path) - literalLength(OPERATIONS[a].path))[0]; +} + +function literalLength(path: string): number { + return path.replace(/\{[^}]+\}/g, '').length; +} + +export interface Issue { + /** JSON Pointer to the value, `` for the value itself. */ + readonly pointer: string; + readonly message: string; + readonly value: unknown; +} + +/** The ways a value breaks a schema, first found first. */ +export function validate(schema: Schema | boolean, value: unknown, pointer = ''): Issue[] { + if (schema === true) return []; + if (schema === false) return [{ pointer, message: 'is not allowed here', value }]; + const s = resolved(schema); + const issues: Issue[] = []; + const issue = (message: string) => issues.push({ pointer, message, value }); + + if (s.type !== undefined) { + const types = typeof s.type === 'string' ? [s.type] : s.type; + if (!types.some((type) => isType(value, type))) { + issue(`must be ${types.join(' or ')}`); + return issues; + } + } + if (s.enum !== undefined && !s.enum.some((option) => same(option, value))) { + issue(`must be one of ${s.enum.map((option) => String(option)).join(', ')}`); + } + if (s.const !== undefined && !same(s.const, value)) issue(`must be ${JSON.stringify(s.const)}`); + + if (typeof value === 'number') { + if (s.minimum !== undefined && value < s.minimum) issue(`must be at least ${s.minimum}`); + if (s.maximum !== undefined && value > s.maximum) issue(`must be at most ${s.maximum}`); + } + if (typeof value === 'string') { + const length = [...value].length; + if (s.minLength !== undefined && length < s.minLength) issue(`must be at least ${s.minLength} long`); + if (s.maxLength !== undefined && length > s.maxLength) issue(`must be at most ${s.maxLength} long`); + if (s.pattern !== undefined && !new RegExp(s.pattern, 'u').test(value)) issue(`must match ${s.pattern}`); + } + if (Array.isArray(value)) { + if (s.minItems !== undefined && value.length < s.minItems) issue(`must have at least ${s.minItems} items`); + if (s.maxItems !== undefined && value.length > s.maxItems) issue(`must have at most ${s.maxItems} items`); + const items = s.items; + if (items !== undefined) + value.forEach((item, index) => issues.push(...validate(items, item, `${pointer}/${index}`))); + const contains = s.contains; + if (contains !== undefined && !value.some((item) => validate(contains, item).length === 0)) { + issue('must contain a matching item'); + } + } + if (isPlainObject(value)) issues.push(...objectIssues(s, value, pointer)); + + for (const part of s.allOf ?? []) issues.push(...validate(part, value, pointer)); + if (s.anyOf !== undefined && !s.anyOf.some((part) => validate(part, value, pointer).length === 0)) { + issues.push(...closest(s.anyOf, value, pointer)); + } + if (s.oneOf !== undefined) { + const matching = s.oneOf.filter((part) => validate(part, value, pointer).length === 0).length; + if (matching === 0) issues.push(...closest(s.oneOf, value, pointer)); + if (matching > 1) issue('matches more than one of the shapes it may take'); + } + if (s.not !== undefined && validate(s.not, value, pointer).length === 0) issue('is not allowed in this shape'); + if (s.if !== undefined) { + const branch = validate(s.if, value, pointer).length === 0 ? s.then : s.else; + if (branch !== undefined) issues.push(...validate(branch, value, pointer)); + } + return issues; +} + +function objectIssues(s: Schema, value: Record, pointer: string): Issue[] { + const issues: Issue[] = []; + const missing = (name: string, message: string) => + issues.push({ pointer: `${pointer}/${escape(name)}`, message, value: undefined }); + for (const name of s.required ?? []) if (!(name in value)) missing(name, 'is required'); + for (const [name, needs] of Object.entries(s.dependentRequired ?? {})) { + if (!(name in value)) continue; + for (const needed of needs) if (!(needed in value)) missing(needed, `is required with ${name}`); + } + for (const [name, item] of Object.entries(value)) { + const where = `${pointer}/${escape(name)}`; + const declared = s.properties?.[name]; + if (declared !== undefined) issues.push(...validate(declared, item, where)); + else if (s.additionalProperties === false) + issues.push({ pointer: where, message: 'is not a key it takes', value: item }); + else if (s.additionalProperties !== undefined) issues.push(...validate(s.additionalProperties, item, where)); + } + return issues; +} + +/** + * Why a value fits none of the shapes it may take, told by the one it came + * closest to. A shape it fails at its own level — a string where an object was + * wanted — is further off than one it fails only further in. + */ +function closest(parts: readonly (Schema | boolean)[], value: unknown, pointer: string): Issue[] { + const distance = (issues: Issue[]) => + issues.filter((issue) => issue.pointer === pointer).length * 1000 + issues.length; + const tries = parts.map((part) => validate(part, value, pointer)).sort((a, b) => distance(a) - distance(b)); + return tries[0] ?? []; +} + +function resolved(schema: Schema): Schema { + let current = schema; + while (current.$ref !== undefined) { + const target = SCHEMAS[current.$ref]; + if (!target) throw new Error(`openapi.yaml has no schema ${current.$ref}`); + current = target; + } + return current; +} + +function isType(value: unknown, type: string): boolean { + switch (type) { + case 'null': + return value === null; + case 'boolean': + return typeof value === 'boolean'; + case 'integer': + return typeof value === 'number' && Number.isInteger(value); + case 'number': + return typeof value === 'number' && Number.isFinite(value); + case 'string': + return typeof value === 'string'; + case 'array': + return Array.isArray(value); + case 'object': + return isPlainObject(value); + default: + return true; + } +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function same(a: unknown, b: unknown): boolean { + return a === b || JSON.stringify(a) === JSON.stringify(b); +} + +function escape(name: string): string { + return name.replace(/~/g, '~0').replace(/\//g, '~1'); +}