feat(canvas): 条件读取与历史 - 减少画布同步传输并校准版本

This commit is contained in:
ddcat666
2026-09-29 08:26:29 +08:00
parent bcccddb4e9
commit 6ad1adc197
17 changed files with 783 additions and 25 deletions

View File

@@ -1,6 +1,8 @@
package handler
import (
"bytes"
"compress/gzip"
"encoding/json"
"errors"
"fmt"
@@ -535,12 +537,25 @@ func RegisterUserDataRoutes(r *gin.RouterGroup, svc *service.Service) {
failService(c, err)
return
}
metadata, err := svc.UserCanvasProjectMetadata(user.ID, c.Param("id"))
if err != nil {
fail(c, http.StatusNotFound, err)
return
}
etag := canvasProjectResponseETag(metadata)
c.Header("ETag", etag)
c.Header("Cache-Control", "private, no-cache")
addVaryHeader(c, "Accept-Encoding")
if ifNoneMatch(c.GetHeader("If-None-Match"), etag) {
c.Status(http.StatusNotModified)
return
}
project, err := svc.UserCanvasProject(user.ID, c.Param("id"))
if err != nil {
fail(c, http.StatusNotFound, err)
return
}
ok(c, gin.H{"project": project})
okCanvasProject(c, project)
})
r.GET("/canvas-projects/:id/history", func(c *gin.Context) {
user, err := currentUser(c, svc)
@@ -673,6 +688,75 @@ func hasUserAssetPageFilters(c *gin.Context) bool {
return false
}
func okCanvasProject(c *gin.Context, project json.RawMessage) {
payload, err := json.Marshal(gin.H{
"code": service.CodeOK,
"data": gin.H{"project": project},
"msg": "ok",
})
if err != nil {
failInternal(c, http.StatusInternalServerError, err)
return
}
if len(payload) >= 1024 && acceptsGzip(c.GetHeader("Accept-Encoding")) {
var compressed bytes.Buffer
writer, err := gzip.NewWriterLevel(&compressed, gzip.BestSpeed)
if err != nil {
failInternal(c, http.StatusInternalServerError, err)
return
}
_, writeErr := writer.Write(payload)
closeErr := writer.Close()
if writeErr != nil || closeErr != nil {
failInternal(c, http.StatusInternalServerError, errors.Join(writeErr, closeErr))
return
}
c.Header("Content-Encoding", "gzip")
c.Header("Content-Length", strconv.Itoa(compressed.Len()))
c.Data(http.StatusOK, "application/json; charset=utf-8", compressed.Bytes())
return
}
c.Data(http.StatusOK, "application/json; charset=utf-8", payload)
}
func acceptsGzip(header string) bool {
wildcardAccepted := false
for _, item := range strings.Split(header, ",") {
parts := strings.Split(item, ";")
encoding := strings.TrimSpace(strings.ToLower(parts[0]))
accepted := true
for _, parameter := range parts[1:] {
name, value, found := strings.Cut(strings.TrimSpace(parameter), "=")
if found && strings.EqualFold(strings.TrimSpace(name), "q") {
quality, err := strconv.ParseFloat(strings.TrimSpace(value), 64)
accepted = err == nil && quality > 0
}
}
if encoding == "gzip" {
return accepted
}
if encoding == "*" {
wildcardAccepted = accepted
}
}
return wildcardAccepted
}
func addVaryHeader(c *gin.Context, value string) {
for _, existing := range c.Writer.Header().Values("Vary") {
for _, token := range strings.Split(existing, ",") {
if strings.EqualFold(strings.TrimSpace(token), value) {
return
}
}
}
c.Header("Vary", strings.Join(append(c.Writer.Header().Values("Vary"), value), ", "))
}
func canvasProjectResponseETag(project *model.CanvasProject) string {
return `W/` + strconv.Quote(fmt.Sprintf("canvas-%d", project.Revision))
}
func resourceResponseETag(resource *model.Resource) string {
value := strings.Trim(strings.TrimSpace(resource.ETag), `"`)
if value == "" {
@@ -682,8 +766,16 @@ func resourceResponseETag(resource *model.Resource) string {
}
func ifNoneMatch(header string, etag string) bool {
normalize := func(value string) string {
value = strings.TrimSpace(value)
if len(value) >= 2 && strings.EqualFold(value[:2], "W/") {
return strings.TrimSpace(value[2:])
}
return value
}
etag = normalize(etag)
for _, candidate := range strings.Split(header, ",") {
candidate = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(candidate), "W/"))
candidate = normalize(candidate)
if candidate == "*" || candidate == etag {
return true
}

View File

@@ -1,8 +1,16 @@
package handler
import (
"bytes"
"compress/gzip"
"encoding/json"
"io"
"net/http/httptest"
"strings"
"testing"
"time"
"infinite-canvas/backend/internal/model"
"infinite-canvas/backend/internal/service"
"github.com/gin-gonic/gin"
@@ -30,3 +38,64 @@ func TestUserDataIncrementalRoutesAreRegistered(t *testing.T) {
}
}
}
func TestCanvasProjectResponseETagChangesWithRevisionMetadata(t *testing.T) {
project := &model.CanvasProject{Revision: 7, UpdatedAt: time.Date(2026, 9, 28, 12, 0, 0, 123000000, time.UTC)}
first := canvasProjectResponseETag(project)
if first != `W/"canvas-7"` {
t.Fatalf("unexpected canvas ETag: %s", first)
}
if !ifNoneMatch(`"canvas-7"`, first) || !ifNoneMatch(first, first) {
t.Fatal("strong and weak validators should match for conditional GET")
}
project.Revision++
if first == canvasProjectResponseETag(project) {
t.Fatal("revision change must invalidate the canvas ETag")
}
}
func TestAcceptsGzipHonorsQualityAndWildcard(t *testing.T) {
for _, test := range []struct {
header string
want bool
}{
{header: "gzip, br", want: true},
{header: "gzip;q=0, *;q=1", want: false},
{header: "br, *;q=0.5", want: true},
{header: "br, gzip;q=0.2", want: true},
{header: "br, gzip;q=0", want: false},
} {
if got := acceptsGzip(test.header); got != test.want {
t.Errorf("acceptsGzip(%q) = %v, want %v", test.header, got, test.want)
}
}
}
func TestOkCanvasProjectCompressesLargeResponses(t *testing.T) {
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
ctx.Request = httptest.NewRequest("GET", "/api/canvas-projects/canvas", nil)
ctx.Request.Header.Set("Accept-Encoding", "gzip")
project := json.RawMessage(`{"nodes":["` + strings.Repeat("x", 2048) + `"]}`)
okCanvasProject(ctx, project)
if got := recorder.Header().Get("Content-Encoding"); got != "gzip" {
t.Fatalf("Content-Encoding = %q, want gzip", got)
}
reader, err := gzip.NewReader(bytes.NewReader(recorder.Body.Bytes()))
if err != nil {
t.Fatal(err)
}
body, err := io.ReadAll(reader)
if err != nil {
t.Fatal(err)
}
if err := reader.Close(); err != nil {
t.Fatal(err)
}
if !json.Valid(body) {
t.Fatalf("decompressed response is not valid JSON: %s", body)
}
}