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

@@ -210,6 +210,10 @@ func (s *Service) UserCanvasProjectSummaries(userID string) ([]UserDataSummary,
return s.canvasDomain().UserCanvasProjectSummaries(userID)
}
func (s *Service) UserCanvasProjectMetadata(userID string, id string) (*model.CanvasProject, error) {
return s.canvasDomain().UserCanvasProjectMetadata(userID, id)
}
func (s *Service) UserCanvasProject(userID string, id string) (json.RawMessage, error) {
return s.canvasDomain().UserCanvasProject(userID, id)
}

View File

@@ -4,6 +4,7 @@ import (
"bytes"
"encoding/json"
"errors"
"io"
"net/http"
"strings"
"time"
@@ -169,19 +170,99 @@ func buildCanvasSnapshot(before *model.CanvasProject, after model.CanvasProject,
// Row metadata is authoritative, including changes made by project association operations.
func canvasProjectPayload(project model.CanvasProject) (json.RawMessage, error) {
var payload map[string]json.RawMessage
if err := json.Unmarshal([]byte(project.PayloadJSON), &payload); err != nil {
return nil, err
metadata := []struct {
key string
value any
}{
{key: "id", value: project.ID},
{key: "title", value: project.Title},
{key: "projectId", value: project.ProjectID},
{key: "revision", value: project.Revision},
{key: "createdAt", value: project.CreatedAt},
{key: "updatedAt", value: project.UpdatedAt},
}
if payload == nil {
return nil, kernel.BadAuthRequest("画布数据格式错误")
}
for key, value := range map[string]any{"id": project.ID, "title": project.Title, "projectId": project.ProjectID, "revision": project.Revision, "createdAt": project.CreatedAt, "updatedAt": project.UpdatedAt} {
encoded, err := json.Marshal(value)
replacements := make(map[string]json.RawMessage, len(metadata))
for _, item := range metadata {
encoded, err := json.Marshal(item.value)
if err != nil {
return nil, err
}
payload[key] = encoded
replacements[item.key] = encoded
}
return json.Marshal(payload)
decoder := json.NewDecoder(strings.NewReader(project.PayloadJSON))
opening, err := decoder.Token()
if err != nil {
return nil, err
}
if opening != json.Delim('{') {
return nil, kernel.BadAuthRequest("画布数据格式错误")
}
result := make([]byte, 0, len(project.PayloadJSON)+128)
result = append(result, '{')
written := make(map[string]struct{}, len(replacements))
first := true
appendField := func(key string, value json.RawMessage) error {
if !first {
result = append(result, ',')
}
first = false
encodedKey, err := json.Marshal(key)
if err != nil {
return err
}
result = append(result, encodedKey...)
result = append(result, ':')
result = append(result, value...)
return nil
}
for decoder.More() {
token, err := decoder.Token()
if err != nil {
return nil, err
}
key, ok := token.(string)
if !ok {
return nil, kernel.BadAuthRequest("画布数据格式错误")
}
var value json.RawMessage
if err := decoder.Decode(&value); err != nil {
return nil, err
}
if replacement, exists := replacements[key]; exists {
if _, duplicate := written[key]; duplicate {
continue
}
value = replacement
written[key] = struct{}{}
}
if err := appendField(key, value); err != nil {
return nil, err
}
}
closing, err := decoder.Token()
if err != nil {
return nil, err
}
if closing != json.Delim('}') {
return nil, kernel.BadAuthRequest("画布数据格式错误")
}
var trailing json.RawMessage
if err := decoder.Decode(&trailing); err != io.EOF {
if err == nil {
return nil, kernel.BadAuthRequest("画布数据格式错误")
}
return nil, err
}
for _, item := range metadata {
if _, exists := written[item.key]; exists {
continue
}
if err := appendField(item.key, replacements[item.key]); err != nil {
return nil, err
}
}
result = append(result, '}')
return json.RawMessage(result), nil
}

View File

@@ -4,13 +4,55 @@ import (
"encoding/json"
"errors"
"net/http"
"reflect"
"strings"
"testing"
"time"
"infinite-canvas/backend/internal/kernel"
"infinite-canvas/backend/internal/model"
)
func TestCanvasProjectPayloadReplacesMetadataAndPreservesDocument(t *testing.T) {
project := model.CanvasProject{
ID: "canvas-authoritative",
Title: "Current title",
ProjectID: "project-current",
Revision: 9,
CreatedAt: time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC),
UpdatedAt: time.Date(2026, 2, 3, 4, 5, 6, 0, time.UTC),
PayloadJSON: `{"id":"stale","title":"stale","revision":1,"nodes":[ { "id": "node-1", "metadata": {"content":"large"} } ],"connections":[],"custom":{"preserve":true}}`,
}
raw, err := canvasProjectPayload(project)
if err != nil {
t.Fatal(err)
}
var actual map[string]json.RawMessage
if err := json.Unmarshal(raw, &actual); err != nil {
t.Fatal(err)
}
for key, want := range map[string]any{
"id": project.ID, "title": project.Title, "projectId": project.ProjectID,
"revision": project.Revision, "createdAt": project.CreatedAt, "updatedAt": project.UpdatedAt,
} {
var got any
if err := json.Unmarshal(actual[key], &got); err != nil {
t.Fatal(err)
}
encodedWant, _ := json.Marshal(want)
var decodedWant any
_ = json.Unmarshal(encodedWant, &decodedWant)
if !reflect.DeepEqual(got, decodedWant) {
t.Errorf("%s = %#v, want %#v", key, got, decodedWant)
}
}
for _, key := range []string{"nodes", "connections", "custom"} {
if len(actual[key]) == 0 || !json.Valid(actual[key]) {
t.Errorf("original %s payload was not preserved: %s", key, actual[key])
}
}
}
func TestCanvasHistoryRestore(t *testing.T) {
svc := newCanvasHistoryTestService(t)
actor := &model.User{ID: "owner"}

View File

@@ -218,6 +218,10 @@ func (s *Service) UserCanvasProjectSummaries(userID string) ([]UserDataSummary,
return result, nil
}
func (s *Service) UserCanvasProjectMetadata(userID string, id string) (*model.CanvasProject, error) {
return s.repo.CanvasProjectMetadataForUser(userID, id)
}
func (s *Service) UserCanvasProject(userID string, id string) (json.RawMessage, error) {
project, err := s.repo.CanvasProjectForUser(userID, id)
if err != nil {

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)
}
}

View File

@@ -0,0 +1,74 @@
package model
import (
"time"
)
// Canvas 数据模型扩展
// Canvas 画布
type Canvas struct {
ID string `json:"id" gorm:"primaryKey"`
UserID string `json:"userId" gorm:"index"`
Name string `json:"name"`
Description string `json:"description"`
Metadata map[string]interface{} `json:"metadata" gorm:"type:jsonb"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
// CanvasNode 画布节点
type CanvasNode struct {
ID string `json:"id" gorm:"primaryKey"`
CanvasID string `json:"canvasId" gorm:"index"`
Type string `json:"type"`
Content string `json:"content" gorm:"type:text"`
Position *CanvasPosition `json:"position" gorm:"type:jsonb"`
Size *CanvasSize `json:"size" gorm:"type:jsonb"`
ParentID *string `json:"parentId" gorm:"index"`
Children []string `json:"children" gorm:"type:jsonb"`
Metadata map[string]interface{} `json:"metadata" gorm:"type:jsonb"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
// CanvasPosition 节点位置
type CanvasPosition struct {
X float64 `json:"x"`
Y float64 `json:"y"`
}
// CanvasSize 节点尺寸
type CanvasSize struct {
Width float64 `json:"width"`
Height float64 `json:"height"`
}
// CanvasNodeUpdate 节点更新请求
type CanvasNodeUpdate struct {
Content *string `json:"content,omitempty"`
Position *CanvasPosition `json:"position,omitempty"`
Size *CanvasSize `json:"size,omitempty"`
ParentID *string `json:"parentId,omitempty"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
}
// CanvasRelationship 节点关系
type CanvasRelationship struct {
From string `json:"from"`
To string `json:"to"`
Type string `json:"type"` // parent, reference, dependency, flow
}
// Approval 审批请求
type Approval struct {
ID string `json:"id" gorm:"primaryKey"`
UserID string `json:"userId" gorm:"index"`
RunID string `json:"runId" gorm:"index"`
ToolName string `json:"toolName"`
Data map[string]any `json:"data" gorm:"type:jsonb"`
Status string `json:"status"` // pending, approved, rejected
Decision string `json:"decision"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}

View File

@@ -0,0 +1,276 @@
package repository
import (
"errors"
"fmt"
"time"
"infinite-canvas/backend/internal/model"
)
// Canvas 数据访问层扩展
// Canvas 获取画布
func (r *Repository) Canvas(userID, canvasID string) (model.Canvas, error) {
var canvas model.Canvas
err := r.db.Where("user_id = ? AND id = ?", userID, canvasID).First(&canvas).Error
if err != nil {
return canvas, fmt.Errorf("get canvas: %w", err)
}
return canvas, nil
}
// GetCanvas is the explicit repository name used by Agent policy builders.
func (r *Repository) GetCanvas(userID, canvasID string) (model.Canvas, error) {
return r.Canvas(userID, canvasID)
}
func (r *Repository) CanvasNodes(userID, canvasID string) ([]model.CanvasNode, error) {
var nodes []model.CanvasNode
err := r.db.Where("canvas_id IN (SELECT id FROM canvases WHERE user_id = ? AND id = ?)", userID, canvasID).
Find(&nodes).Error
if err != nil {
return nil, fmt.Errorf("get canvas nodes: %w", err)
}
return nodes, nil
}
// CanvasNode 获取单个节点
func (r *Repository) CanvasNode(userID, canvasID, nodeID string) (model.CanvasNode, error) {
var node model.CanvasNode
err := r.db.Where("id = ? AND canvas_id IN (SELECT id FROM canvases WHERE user_id = ? AND id = ?)",
nodeID, userID, canvasID).First(&node).Error
if err != nil {
return node, fmt.Errorf("get canvas node: %w", err)
}
return node, nil
}
// CreateCanvasNode 创建节点
func (r *Repository) CreateCanvasNode(userID, canvasID string, node model.CanvasNode) (model.CanvasNode, error) {
// 验证画布存在且属于用户
var canvas model.Canvas
err := r.db.Where("user_id = ? AND id = ?", userID, canvasID).First(&canvas).Error
if err != nil {
return node, fmt.Errorf("canvas not found: %w", err)
}
// 生成节点 ID
if node.ID == "" {
node.ID = generateNodeID()
}
node.CanvasID = canvasID
// 创建节点
err = r.db.Create(&node).Error
if err != nil {
return node, fmt.Errorf("create canvas node: %w", err)
}
return node, nil
}
// UpdateCanvasNode 更新节点
func (r *Repository) UpdateCanvasNode(userID, canvasID, nodeID string, update model.CanvasNodeUpdate) (model.CanvasNode, error) {
// 获取现有节点
node, err := r.CanvasNode(userID, canvasID, nodeID)
if err != nil {
return node, err
}
// 应用更新
if update.Content != nil {
node.Content = *update.Content
}
if update.Position != nil {
node.Position = update.Position
}
if update.Size != nil {
node.Size = update.Size
}
if update.ParentID != nil {
node.ParentID = update.ParentID
}
if update.Metadata != nil {
if node.Metadata == nil {
node.Metadata = make(map[string]interface{})
}
for k, v := range update.Metadata {
node.Metadata[k] = v
}
}
// 保存
err = r.db.Save(&node).Error
if err != nil {
return node, fmt.Errorf("update canvas node: %w", err)
}
return node, nil
}
// DeleteCanvasNode 删除节点,可选递归删除子节点。
func (r *Repository) DeleteCanvasNode(userID, canvasID, nodeID string, recursive ...bool) error {
// 验证节点存在且属于用户
_, err := r.CanvasNode(userID, canvasID, nodeID)
if err != nil {
return err
}
if len(recursive) > 0 && recursive[0] {
var children []model.CanvasNode
if err := r.db.Where("canvas_id = ? AND parent_id = ?", canvasID, nodeID).Find(&children).Error; err != nil {
return fmt.Errorf("list child nodes: %w", err)
}
for _, child := range children {
if err := r.DeleteCanvasNode(userID, canvasID, child.ID, true); err != nil {
return err
}
}
}
// 删除节点
if err := r.db.Where("id = ? AND canvas_id = ?", nodeID, canvasID).Delete(&model.CanvasNode{}).Error; err != nil {
return fmt.Errorf("delete canvas node: %w", err)
}
return nil
}
// GetCanvasNodeChildren 返回指定父节点的直接子节点。
func (r *Repository) GetCanvasNodeChildren(userID, canvasID, parentID string) ([]model.CanvasNode, error) {
if _, err := r.CanvasNode(userID, canvasID, parentID); err != nil {
return nil, err
}
var children []model.CanvasNode
if err := r.db.Where("canvas_id = ? AND parent_id = ?", canvasID, parentID).Find(&children).Error; err != nil {
return nil, fmt.Errorf("get canvas node children: %w", err)
}
return children, nil
}
// CanvasNodes 获取画布所有节点
func (r *Repository) CreateCanvasRelationship(userID, canvasID string, rel model.CanvasRelationship) error {
// 验证节点存在
_, err := r.CanvasNode(userID, canvasID, rel.From)
if err != nil {
return fmt.Errorf("source node not found: %w", err)
}
_, err = r.CanvasNode(userID, canvasID, rel.To)
if err != nil {
return fmt.Errorf("target node not found: %w", err)
}
// 存储关系到 from 节点的 metadata
fromNode, err := r.CanvasNode(userID, canvasID, rel.From)
if err != nil {
return err
}
if fromNode.Metadata == nil {
fromNode.Metadata = make(map[string]interface{})
}
relationshipsKey := "relationships"
relationships, ok := fromNode.Metadata[relationshipsKey].([]interface{})
if !ok {
relationships = []interface{}{}
}
// 检查关系是否已存在
for _, r := range relationships {
if relMap, ok := r.(map[string]interface{}); ok {
if relMap["to"] == rel.To && relMap["type"] == rel.Type {
return nil // 关系已存在
}
}
}
// 添加新关系
relationships = append(relationships, map[string]interface{}{
"to": rel.To,
"type": rel.Type,
})
fromNode.Metadata[relationshipsKey] = relationships
// 保存
err = r.db.Save(&fromNode).Error
if err != nil {
return fmt.Errorf("save relationship: %w", err)
}
return nil
}
// DeleteCanvasRelationship 删除节点关系
func (r *Repository) DeleteCanvasRelationship(userID, canvasID, from, to, relType string) error {
fromNode, err := r.CanvasNode(userID, canvasID, from)
if err != nil {
return err
}
if fromNode.Metadata == nil {
return errors.New("relationship not found")
}
relationshipsKey := "relationships"
relationships, ok := fromNode.Metadata[relationshipsKey].([]interface{})
if !ok {
return errors.New("relationship not found")
}
// 过滤掉要删除的关系
filtered := make([]interface{}, 0)
found := false
for _, r := range relationships {
if relMap, ok := r.(map[string]interface{}); ok {
if relMap["to"] == to && relMap["type"] == relType {
found = true
continue
}
}
filtered = append(filtered, r)
}
if !found {
return errors.New("relationship not found")
}
fromNode.Metadata[relationshipsKey] = filtered
// 保存
err = r.db.Save(&fromNode).Error
if err != nil {
return fmt.Errorf("delete relationship: %w", err)
}
return nil
}
// CreateApproval 创建审批
func (r *Repository) CreateApproval(approval *model.Approval) error {
return r.db.Create(approval).Error
}
// GetApproval 获取审批
func (r *Repository) GetApproval(approvalID string) (*model.Approval, error) {
var approval model.Approval
err := r.db.Where("id = ?", approvalID).First(&approval).Error
if err != nil {
return nil, err
}
return &approval, nil
}
// UpdateApprovalDecision 更新审批决策
func (r *Repository) UpdateApprovalDecision(approvalID, decision string) error {
return r.db.Model(&model.Approval{}).
Where("id = ?", approvalID).
Updates(map[string]interface{}{
"status": "completed",
"decision": decision,
}).Error
}
// generateNodeID 生成节点 ID(简化实现)
func generateNodeID() string {
return fmt.Sprintf("node_%d", time.Now().UnixNano())
}

View File

@@ -13,8 +13,8 @@ import (
"gorm.io/gorm/clause"
)
var ErrCanvasHistoryResourceMissing = errors.New("canvas history resource missing")
var ErrCanvasHistoryResourceReferenced = errors.New("resource referenced by canvas history")
var ErrCanvasHistoryResourceMissing = errors.New("画布历史引用的资源不存在")
var ErrCanvasHistoryResourceReferenced = errors.New("资源仍被画布历史引用,无法删除")
type CanvasHistoryResourceMissingError struct {
ResourceIDs []string

View File

@@ -0,0 +1,15 @@
package repository
import "infinite-canvas/backend/internal/model"
// CanvasProjectMetadataForUser reads only fields needed to validate a cached
// canvas representation, avoiding the large JSON body when the caller's ETag
// is still current.
func (r *Repository) CanvasProjectMetadataForUser(userID string, id string) (*model.CanvasProject, error) {
var project model.CanvasProject
if err := r.db.Select("id", "user_id", "project_id", "title", "revision", "created_at", "updated_at").
First(&project, "id = ? AND user_id = ?", id, userID).Error; err != nil {
return nil, err
}
return &project, nil
}