feat(canvas): 条件读取与历史 - 减少画布同步传输并校准版本
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user