feat(fs): add pipelined multipart upload#2723
Conversation
- Reassemble concurrently uploaded chunks into a sequential stream through a ring file, bounding disk usage to slots*chunkSize per session - Park writers up to a deadline when their slot is busy instead of rejecting instantly, so flow control does not surface as connection errors in browsers - Record a per-chunk CRC32 table for re-fill verification and keep it readable after close - Propagate cancellation to blocked readers via CloseWithError so drivers treat aborts like canceled requests - Cover ordering, backpressure, idempotent resends and close/abort wake-ups with race-enabled tests
- Start the driver upload at session init over a sequential stream backed by the window, so client-to-server and server-to-storage transfers run concurrently - Attach client-provided hashes to the stream so drivers can attempt rapid upload before any chunk arrives, and absorb chunks racing pipeline completion idempotently - Keep only metadata and chunk CRCs after a failed attempt: re-sending chunk 0 re-fills a fresh window, and content changes between attempts are rejected - Resume receiving sessions only when client hashes prove the same file; failed_retriable sessions resume unconditionally - Reclaim sessions with a sliding-TTL GC and sweep orphaned ring files at startup - Cover the state machine with race-enabled tests over a stubbed storage layer
- Add multipart_enabled and multipart_chunk_size (MB) as public traffic settings - Validate the chunk size on save (integer within 1-90) via the setting item hook
- Add /api/fs/multipart init/chunk/complete/status/abort endpoints with headers aligned with /fs/put - Gate init behind the FsUp permission checks and reuse the client upload rate limiter for chunk uploads - Drain the request body before answering chunk requests on every path, so browsers do not see early responses as network errors - Start the multipart session GC when the router is initialized to reclaim ring files orphaned by a previous run
| multipart := g.Group("/multipart") | ||
| multipart.POST("/init", middlewares.FsUp, handles.MultipartInit) | ||
| multipart.PUT("/chunk", uploadLimiter, handles.MultipartChunk) | ||
| multipart.POST("/complete", handles.MultipartComplete) | ||
| multipart.GET("/status", handles.MultipartStatus) | ||
| multipart.POST("/abort", handles.MultipartAbort) |
There was a problem hiding this comment.
麻烦在GitHub上Comment一下新增的API文档,方便后面加到Apifox里面。
| conf.MultipartChunkSize: func(item *model.SettingItem) error { | ||
| size, err := strconv.Atoi(strings.TrimSpace(item.Value)) | ||
| if err != nil || size < 1 || size > 90 { | ||
| // deliberately a plain error: SaveSettings formats hook errors | ||
| // with %+v, which would dump a full stack trace into the UI | ||
| // notification for stack-carrying errors | ||
| return fmt.Errorf("multipart chunk size must be an integer between 1 and 90 (MB), got %q", item.Value) | ||
| } | ||
| item.Value = strconv.Itoa(size) | ||
| return nil | ||
| }, |
There was a problem hiding this comment.
个人认为OpenList作为一个自部署项目,不应该对这个分片大小做严格的限制。让用户自己决定分片大小,框架只需要保证合理性即可。
There was a problem hiding this comment.
同意这个理念,已在 b638510 调整:
- 管理端设置:校验放宽为"≥1 的整数",设置项 Help 文案同步更新;
- 其他:init 请求的
X-Chunk-Sizeheader 是客户端可控的;服务端每会话缓冲 8 个分片的窗口,如果对客户端建议值也不设上限,任意登录用户可以在 init 时索要超大分片,直接放大服务端磁盘占用。因此 handler 将客户端建议值卡到 [1MB, 管理员设置值],即客户端不能越过管理员定的块大小。
- The overwrite flag was stored on the session but never read: the handler performs the pre-check and op.Put owns the overwrite semantics
- Validate the setting as a positive integer only; self-hosted admins decide the ceiling themselves instead of an arbitrary 90MB cap - Keep clamping the client-suggested X-Chunk-Size to the admin value: the server buffers a window of 8 chunks per session, so an unbounded client suggestion would translate directly into server-side disk usage
60cd884 to
b638510
Compare
- Fold the two-step clamp into one branch: the ceiling is already floored, so a client suggestion just lowers the size with a 1MB floor
Multipart API 文档此处附上 OpenAPI 3.0openapi: 3.0.3
info:
title: OpenList Multipart Upload API
version: "1.0"
description: >-
Chunked, resumable, pipelined upload. Every endpoint requires a logged-in
user (Authorization header). The HTTP status is always 200: the status
codes documented under Responses refer to the "code" field of the JSON
envelope {code, message, data}.
security:
- bearerAuth: []
paths:
/api/fs/multipart/init:
post:
summary: Init multipart upload
description: >-
Create an upload session and immediately start the server-to-driver
upload pipeline, or resume an existing one. Session reuse rules (same
user + path + size): a live "receiving" session is resumed only when
the supplied file hashes match the ones it was created with — without
hashes, path+size cannot prove file identity, so no resume; a
"failed_retriable" session is always reused (its buffered data was
already discarded, refill is guarded by per-chunk CRC); any other
existing session is superseded and cleaned up. Supplied file hashes
travel ahead of the file data, so drivers with server-side
deduplication can finish instantly (rapid upload) without receiving
any chunk.
parameters:
- {name: File-Path, in: header, required: true, schema: {type: string}, example: "/uploads/movie.mkv", description: "URL-encoded destination path (same as /fs/put)"}
- {name: X-File-Size, in: header, required: true, schema: {type: integer, format: int64, minimum: 1}, description: "file size in bytes; upload empty files via /fs/put"}
- {name: X-Chunk-Size, in: header, required: false, schema: {type: integer, format: int64}, description: "suggested chunk bytes, clamped to [1MB, admin setting]; the effective value is returned as chunk_size"}
- {name: Overwrite, in: header, required: false, schema: {type: boolean, default: true}, description: "when false, reject if the target already exists"}
- {name: X-File-Md5, in: header, required: false, schema: {type: string}, description: "MD5 of the file; enables rapid upload and identifies the file for resume"}
- {name: X-File-Sha1, in: header, required: false, schema: {type: string}, description: "SHA1 of the file; enables rapid upload and identifies the file for resume"}
- {name: X-File-Sha256, in: header, required: false, schema: {type: string}, description: "SHA256 of the file; enables rapid upload and identifies the file for resume"}
- {name: Last-Modified, in: header, required: false, schema: {type: integer, format: int64}, description: "file modification time (Unix timestamp in milliseconds)"}
- {name: Content-Type, in: header, required: false, schema: {type: string}, description: "file MIME type, inferred from the extension when omitted"}
responses:
"200":
x-apifox-name: Success
description: "Session created or resumed; data is the session snapshot plus resumed"
content:
application/json:
schema:
allOf:
- $ref: "#/components/schemas/Envelope"
- type: object
properties:
data:
allOf:
- $ref: "#/components/schemas/SessionSnapshot"
- type: object
properties:
resumed: {type: boolean, description: "true when an existing session was reused; skip the chunk ranges listed in received"}
example:
code: 200
message: success
data:
upload_id: 3f2c9e2a-6c4b-4d2e-9a2b-1f0e8c7d5a41
state: receiving
attempt: 0
path: /uploads/movie.mkv
size: 734003200
chunk_size: 10485760
total_chunks: 70
received: [[0, 2]]
received_bytes: 31457280
frontier: 3
storage_progress: 4.29
resumed: true
"400":
x-apifox-name: Bad Request
description: "Invalid File-Path, X-File-Size or X-Chunk-Size"
content:
application/json:
schema: {$ref: "#/components/schemas/Envelope"}
example:
code: 400
message: multipart upload requires a valid X-File-Size header
data: null
"401":
x-apifox-name: Unauthorized
description: "Not logged in or the token has expired"
content:
application/json:
schema: {$ref: "#/components/schemas/Envelope"}
example:
code: 401
message: token is expired
data: null
"403":
x-apifox-name: Forbidden
description: "Multipart upload disabled, no write permission on the destination, or Overwrite is false and the target already exists"
content:
application/json:
schema: {$ref: "#/components/schemas/Envelope"}
example:
code: 403
message: permission denied
data: null
"405":
x-apifox-name: Upload Not Supported
description: "The target storage does not support upload"
content:
application/json:
schema: {$ref: "#/components/schemas/Envelope"}
example:
code: 405
message: upload not supported
data: null
"500":
x-apifox-name: Storage Error
description: "Failed to resolve the target storage"
content:
application/json:
schema: {$ref: "#/components/schemas/Envelope"}
example:
code: 500
message: "storage not found; rawPath: /uploads"
data: null
/api/fs/multipart/chunk:
put:
summary: Upload one chunk
description: >-
Send one chunk as the raw request body. Chunks are idempotent and may
be sent concurrently and out of order, but only within the receiving
window [frontier, frontier+8), which slides forward as the driver
consumes data. Every chunk except the last must be exactly chunk_size
bytes; the last one is the remainder. When the window is full the
request parks on the server for up to 10 seconds waiting for a free
slot before answering 429. If the session already completed (e.g.
rapid upload) the chunk is absorbed idempotently and the returned
snapshot has state=completed — stop sending the remaining chunks. The
server always consumes the request body before answering, so browsers
never see an early response.
parameters:
- {name: X-Upload-Id, in: header, required: true, schema: {type: string}, description: "session id returned by init"}
- {name: X-Chunk-Index, in: header, required: true, schema: {type: integer, minimum: 0}, description: "0-based chunk index"}
requestBody:
required: true
content:
application/octet-stream:
schema: {type: string, format: binary}
responses:
"200":
x-apifox-name: Success
description: "Chunk stored (or absorbed idempotently); data is the latest session snapshot"
content:
application/json:
schema: {$ref: "#/components/schemas/SnapshotResp"}
example:
code: 200
message: success
data:
upload_id: 3f2c9e2a-6c4b-4d2e-9a2b-1f0e8c7d5a41
state: receiving
attempt: 0
path: /uploads/movie.mkv
size: 734003200
chunk_size: 10485760
total_chunks: 70
received: [[0, 11]]
received_bytes: 125829120
frontier: 9
storage_progress: 12.86
"400":
x-apifox-name: Bad Chunk or Failed Session
description: "Invalid X-Chunk-Index, wrong chunk length, a refilled chunk differs from the previous attempt, or the session already failed (data carries state and error)"
content:
application/json:
schema: {$ref: "#/components/schemas/SnapshotResp"}
example:
code: 400
message: "upload attempt failed, resend from chunk 0 to retry: context deadline exceeded"
data:
upload_id: 3f2c9e2a-6c4b-4d2e-9a2b-1f0e8c7d5a41
state: failed_retriable
attempt: 0
path: /uploads/movie.mkv
size: 734003200
chunk_size: 10485760
total_chunks: 70
received: []
received_bytes: 0
frontier: 0
storage_progress: 0
error: context deadline exceeded
"401":
x-apifox-name: Unauthorized
description: "Not logged in or the token has expired"
content:
application/json:
schema: {$ref: "#/components/schemas/Envelope"}
example:
code: 401
message: token is expired
data: null
"403":
x-apifox-name: Not Owner
description: "The session belongs to another user (data carries no session info)"
content:
application/json:
schema: {$ref: "#/components/schemas/Envelope"}
example:
code: 403
message: multipart upload session belongs to another user
data: null
"404":
x-apifox-name: Session Not Found
description: "Session not found: expired, reclaimed after completion, or the server restarted (data carries no session info)"
content:
application/json:
schema: {$ref: "#/components/schemas/Envelope"}
example:
code: 404
message: multipart upload session not found
data: null
"409":
x-apifox-name: Chunk In Flight
description: "The same chunk is being uploaded on another connection; retry shortly (data carries the latest snapshot)"
content:
application/json:
schema: {$ref: "#/components/schemas/SnapshotResp"}
example:
code: 409
message: chunk is being uploaded by another request
data:
upload_id: 3f2c9e2a-6c4b-4d2e-9a2b-1f0e8c7d5a41
state: receiving
attempt: 0
path: /uploads/movie.mkv
size: 734003200
chunk_size: 10485760
total_chunks: 70
received: [[0, 11]]
received_bytes: 125829120
frontier: 9
storage_progress: 12.86
"429":
x-apifox-name: Window Full
description: "Receiving window still full after parking; flow control, not an error — resend the chunk after a short delay (data carries the latest snapshot)"
content:
application/json:
schema: {$ref: "#/components/schemas/SnapshotResp"}
example:
code: 429
message: chunk is out of the receiving window
data:
upload_id: 3f2c9e2a-6c4b-4d2e-9a2b-1f0e8c7d5a41
state: receiving
attempt: 0
path: /uploads/movie.mkv
size: 734003200
chunk_size: 10485760
total_chunks: 70
received: [[0, 16]]
received_bytes: 178257920
frontier: 9
storage_progress: 12.86
/api/fs/multipart/complete:
post:
summary: Complete multipart upload
description: >-
Join point: blocks until the upload pipeline reaches a terminal state,
mirroring how /fs/put only answers once the driver upload finished.
Rejected immediately while chunks are still missing, so an incomplete
upload never holds the connection open. On success the session is
reclaimed right away. If a CDN cuts the connection early, poll /status
until a terminal state.
parameters:
- {name: X-Upload-Id, in: header, required: true, schema: {type: string}, description: "session id returned by init"}
responses:
"200":
x-apifox-name: Success
description: "Upload finished; data.state is completed and the session is reclaimed"
content:
application/json:
schema: {$ref: "#/components/schemas/SnapshotResp"}
example:
code: 200
message: success
data:
upload_id: 3f2c9e2a-6c4b-4d2e-9a2b-1f0e8c7d5a41
state: completed
attempt: 0
path: /uploads/movie.mkv
size: 734003200
chunk_size: 10485760
total_chunks: 70
received: [[0, 69]]
received_bytes: 734003200
frontier: 70
storage_progress: 100
"400":
x-apifox-name: Incomplete or Failed
description: "Chunks still missing (returned immediately), or the pipeline failed (data carries state and error)"
content:
application/json:
schema: {$ref: "#/components/schemas/SnapshotResp"}
example:
code: 400
message: "cannot complete: 723517440 of 734003200 bytes received"
data:
upload_id: 3f2c9e2a-6c4b-4d2e-9a2b-1f0e8c7d5a41
state: receiving
attempt: 0
path: /uploads/movie.mkv
size: 734003200
chunk_size: 10485760
total_chunks: 70
received: [[0, 65], [67, 69]]
received_bytes: 723517440
frontier: 66
storage_progress: 94.29
"401":
x-apifox-name: Unauthorized
description: "Not logged in or the token has expired"
content:
application/json:
schema: {$ref: "#/components/schemas/Envelope"}
example:
code: 401
message: token is expired
data: null
"403":
x-apifox-name: Not Owner
description: "The session belongs to another user (data carries no session info)"
content:
application/json:
schema: {$ref: "#/components/schemas/Envelope"}
example:
code: 403
message: multipart upload session belongs to another user
data: null
"404":
x-apifox-name: Session Not Found
description: "Session not found: expired, reclaimed after completion, or the server restarted (data carries no session info)"
content:
application/json:
schema: {$ref: "#/components/schemas/Envelope"}
example:
code: 404
message: multipart upload session not found
data: null
/api/fs/multipart/status:
get:
summary: Query upload session
description: >-
Two valid parameter combinations: pass upload_id alone, or pass path
and size together to discover a resumable live session of the current
user (e.g. after a page refresh, or to poll the verdict when complete
was cut off by a CDN). Each parameter is optional on its own, but one
of the two combinations is required.
parameters:
- {name: upload_id, in: query, required: false, schema: {type: string}, description: "session id; use alone"}
- {name: path, in: query, required: false, schema: {type: string}, description: "URL-encoded destination path; use together with size"}
- {name: size, in: query, required: false, schema: {type: integer, format: int64}, description: "file size in bytes; use together with path"}
responses:
"200":
x-apifox-name: Success
description: "Session found; data is the latest session snapshot"
content:
application/json:
schema: {$ref: "#/components/schemas/SnapshotResp"}
example:
code: 200
message: success
data:
upload_id: 3f2c9e2a-6c4b-4d2e-9a2b-1f0e8c7d5a41
state: receiving
attempt: 0
path: /uploads/movie.mkv
size: 734003200
chunk_size: 10485760
total_chunks: 70
received: [[0, 8], [10, 11]]
received_bytes: 115343360
frontier: 9
storage_progress: 12.86
"400":
x-apifox-name: Bad Query
description: "Invalid parameter combination: pass upload_id alone, or path and size together"
content:
application/json:
schema: {$ref: "#/components/schemas/Envelope"}
example:
code: 400
message: "status lookup requires upload_id, or path and size"
data: null
"401":
x-apifox-name: Unauthorized
description: "Not logged in or the token has expired"
content:
application/json:
schema: {$ref: "#/components/schemas/Envelope"}
example:
code: 401
message: token is expired
data: null
"403":
x-apifox-name: Forbidden
description: "Path outside your allowed root, or the session belongs to another user"
content:
application/json:
schema: {$ref: "#/components/schemas/Envelope"}
example:
code: 403
message: multipart upload session belongs to another user
data: null
"404":
x-apifox-name: Session Not Found
description: "No live session matches the query"
content:
application/json:
schema: {$ref: "#/components/schemas/Envelope"}
example:
code: 404
message: multipart upload session not found
data: null
/api/fs/multipart/abort:
post:
summary: Abort multipart upload
description: >-
Cancel the pipeline (the driver handles it as a request cancellation —
the local driver, for example, removes the unfinished destination
file), discard all buffered chunks and remove the session.
parameters:
- {name: X-Upload-Id, in: header, required: true, schema: {type: string}, description: "session id returned by init"}
responses:
"200":
x-apifox-name: Success
description: "Session cancelled and reclaimed"
content:
application/json:
schema: {$ref: "#/components/schemas/Envelope"}
example:
code: 200
message: success
data: null
"401":
x-apifox-name: Unauthorized
description: "Not logged in or the token has expired"
content:
application/json:
schema: {$ref: "#/components/schemas/Envelope"}
example:
code: 401
message: token is expired
data: null
"403":
x-apifox-name: Not Owner
description: "The session belongs to another user"
content:
application/json:
schema: {$ref: "#/components/schemas/Envelope"}
example:
code: 403
message: multipart upload session belongs to another user
data: null
"404":
x-apifox-name: Session Not Found
description: "Session not found: expired, reclaimed after completion, or the server restarted"
content:
application/json:
schema: {$ref: "#/components/schemas/Envelope"}
example:
code: 404
message: multipart upload session not found
data: null
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
description: "OpenList login token in the Authorization header"
schemas:
Envelope:
type: object
required: [code, message]
properties:
code: {type: integer, description: "business code (200 = success)"}
message: {type: string, description: "success, or the error message"}
data: {type: object, nullable: true}
SessionSnapshot:
type: object
properties:
upload_id: {type: string, description: "session id (UUID)"}
state:
type: string
enum: [receiving, completed, failed_retriable, failed_permanent, aborted]
attempt: {type: integer, description: "refill attempt counter, starts at 0"}
path: {type: string, description: "destination path"}
size: {type: integer, format: int64, description: "file size in bytes"}
chunk_size: {type: integer, format: int64, description: "effective chunk size; the client must slice with this value"}
total_chunks: {type: integer}
received:
type: array
description: "inclusive index ranges already received (skip them on resume)"
items:
type: array
items: {type: integer}
minItems: 2
maxItems: 2
received_bytes: {type: integer, format: int64, description: "bytes received, including data already consumed by the driver"}
frontier: {type: integer, description: "next chunk index the driver will consume"}
storage_progress: {type: number, description: "server-to-storage upload progress, 0-100"}
error: {type: string, description: "failure reason; present only in failed states"}
SnapshotResp:
allOf:
- $ref: "#/components/schemas/Envelope"
- type: object
properties:
data: {$ref: "#/components/schemas/SessionSnapshot"}所有端点挂载于 端点总览
除 init 外,所有端点校验会话归属(创建者 user id),非本人返回 403;所有端点均可能返回 401(未登录 / token 失效)。 POST /init
响应 会话复用规则(同一用户 + 同一path + 同一size):
PUT /chunk
请求体( 行为要点:
POST /complete
阻塞等待流水线终态(与
GET /status两种合法参数组合:
POST /abort
取消流水线(驱动侧按请求取消处理,例如 local 驱动会删除未完成的目标文件)、删除缓冲分片、移除会话。
SessionSnapshot 结构
失败重试协议会话 生命周期会话仅存内存(服务重启后失效,暂存文件启动时自动清扫);30 分钟滑动无活动过期; |
|
在 Apifox 起了个空项目导入上面的 OpenAPI ,效果在这里。 |
Summary / 摘要
实现网页端分块上传(multipart upload),解决经 CDN 反代(如 Cloudflare 免费计划 100MB 请求体上限)时无法上传大文件的问题,并为上传链路补上断点续传与失败重试能力。
用户可感知的变化:
Multipart:客户端按分片并发上传,单个请求体可控制在 CDN 限制之内(分片大小可配,默认 10MB)multipart_enabled(功能开关)、multipart_chunk_size(分片大小 MB,保存时校验 1–90)实现要点:
op.Put启动驱动上传,客户端→服务端与服务端→存储两段并行,分别占用服务器下行/上行带宽FileStreamer,与现有/fs/put直传对驱动完全同构,87 个驱动无需任何适配(已在本地/S3/WebDAV/百度/123 五种驱动形态上实测)关于与 As-Task 的关系:本 PR 中 multipart 有意不支持"添加为任务"。As-Task 的语义是"先收完、入队、稍后传",必然要求文件全量落盘,与流水线形态(收片即传、磁盘占用窗口封顶、秒传免传后续分片)在根本上互斥;且 multipart 会话自身已具备任务队列的核心价值(进度可查、失败可断点重试)。若社区确有"multipart + As-Task"的需求(全量落盘后再推远端,形态接近 #1877 的 store-and-forward 方案),可以作为后续独立 PR 在本 PR 的会话框架上补充,实现难度低于本 PR。
兼容性说明:不改动任何既有端点与驱动接口;新增
/api/fs/multipart/{init,chunk,complete,status,abort}五个端点(header 风格与/fs/put对齐);新增两个 PUBLIC 设置项;会话仅存内存(与 Upload 任务不持久化的既有决策对齐),重启后未完成会话失效、暂存自动清扫。/ 此 PR 包含破坏性变更。
/ 此 PR 修改了公开 API、配置、存储格式或迁移行为。
/ 此 PR 需要关联仓库同步修改。
Related repository PRs / 关联仓库 PR:
Related Issues / 关联 Issue
Relates to #460
说明:#460 同时提出了网页端与 WebDAV 两个方向的分块上传,本 PR 覆盖网页端;WebDAV 分块(如 Nextcloud 兼容协议)不在本 PR 范围内,但服务端重组器与协议无关,后续可将 WebDAV/Nextcloud 入口作为它的另一个前端接入。
致谢:感谢 #1877(@dnslin)对该方向的探索,以及 @KirCute 在该 PR 评审中提出的两点要求——"开始上传时即构建 FileStream 调用驱动(两段带宽并行)"与"首个请求携带文件哈希触发秒传、命中后免传后续分片"。本 PR 为独立的从零实现,上述两点均为本设计的出发点并已实现(哈希放在 init 请求而非首个分片,语义等价且更早:秒传判定可在任何分片到达前完成)。
Testing / 测试
测试平台:macOS arm64,Go 1.26.4。
go test ./...本机环境因缺少 macFUSE 头文件无法构建 internal/fuse(存量平台问题,与本 PR 无关),故未跑全仓测试;实际执行为下述包级测试与主二进制构建。
自动化测试:
go test -race -count=1 ./internal/multipart/:32 个用例全部通过,覆盖乱序/并发写入、背压停靠与唤醒、幂等重发、秒传与在途分片的竞态(含确定性竞态构造与变异验证)、窗口关闭与完成裁决之间的间隙、CRC 重灌一致性、会话状态机全迁移、GC 过期与阻塞读者唤醒gofmt -l对全部改动文件零违规;go build .主二进制构建通过跨驱动实测:
浏览器手动验证(配合前端配套分支联调):覆盖已有文件 × 尝试秒传四种组合、刷新页面后重拖续传、3×3 大文件并发上传、失败行重试按钮断点续传、管理端分片大小非法值拒绝提示。
Checklist / 检查清单
/ 我已阅读 CONTRIBUTING。
/ 我确认此贡献符合仓库许可证、贡献规范和行为准则。
gofmt,go fmt, orprettierwhere applicable./ 我已按适用情况使用
gofmt、go fmt或prettier格式化变更代码。/ 我已在适用情况下请求相关维护者或代码所有者审查。
AI Disclosure / AI 使用声明
/ 此 PR 包含 AI 辅助内容。
Tools used / 使用工具:
Usage scope / 使用范围:
Code generation / 代码生成
Refactoring / 重构
Documentation / 文档
Tests / 测试
Translation / 翻译
Review assistance / 审查辅助
I have reviewed and validated all AI-assisted content included in this PR.
/ 我已审核并验证此 PR 中的所有 AI 辅助内容。
I have ensured that all AI-assisted commits include
Co-Authored-Byattribution./ 我已确保所有 AI 辅助提交都包含
Co-Authored-By归属信息。I can reproduce all AI-assisted content included in this PR without any AI tools.
/ 我可以在没有任何 AI 工具的情况下重现此 PR 中包含的所有 AI 辅助内容。