feat(freedoc): CreateDocument with client_key idempotency
Support direct freeform create for upload fact-source registration while keeping source_content_sha256 as a content fingerprint only. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
package freedoc_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"chat-agent-system/internal/db"
|
||||
"chat-agent-system/internal/docrevision"
|
||||
"chat-agent-system/internal/freedoc"
|
||||
)
|
||||
|
||||
func TestCreateDocument_PG_ClientKeyIdempotentAndLedgerSurvivesNewService(t *testing.T) {
|
||||
dsn := db.SkipIfNoTestDatabaseURL(t)
|
||||
ctx := context.Background()
|
||||
pool, err := pgxpool.New(ctx, dsn)
|
||||
require.NoError(t, err)
|
||||
defer pool.Close()
|
||||
|
||||
var hasClientKey bool
|
||||
err = pool.QueryRow(ctx, `
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'freeform_documents' AND column_name = 'client_key'
|
||||
)
|
||||
`).Scan(&hasClientKey)
|
||||
if err != nil || !hasClientKey {
|
||||
t.Skip("freeform_documents.client_key missing; migrate 00029 required")
|
||||
}
|
||||
var hasVersions bool
|
||||
_ = pool.QueryRow(ctx, `
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM information_schema.tables WHERE table_name = 'document_versions'
|
||||
)
|
||||
`).Scan(&hasVersions)
|
||||
if !hasVersions {
|
||||
t.Skip("document_versions missing; migrate 00028 required")
|
||||
}
|
||||
|
||||
userID := 91001
|
||||
clientKey := fmt.Sprintf("doc-upload-pg-%d", time.Now().UnixNano())
|
||||
rev1 := docrevision.NewService(pool)
|
||||
svc1 := freedoc.NewService(pool, nil)
|
||||
svc1.SetDocRevision(rev1)
|
||||
|
||||
detail, err := svc1.CreateDocument(ctx, userID, freedoc.CreateFreeformDocRequest{
|
||||
ProjectID: 1,
|
||||
Title: "PG上传规格",
|
||||
ContentMarkdown: "# PG上传规格\n\n基线正文\n",
|
||||
Space: "my_private",
|
||||
ClientKey: clientKey,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
docID := detail.Document.DocID
|
||||
require.NotEmpty(t, docID)
|
||||
require.NotNil(t, detail.Document.ClientKey)
|
||||
assert.Equal(t, clientKey, *detail.Document.ClientKey)
|
||||
assert.NotContains(t, detail.Document.SourceContentSHA256, "doc-upload")
|
||||
assert.Len(t, detail.Document.SourceContentSHA256, 64)
|
||||
|
||||
// client_key 幂等:同一 owner+key 返回同一 doc
|
||||
again, err := svc1.CreateDocument(ctx, userID, freedoc.CreateFreeformDocRequest{
|
||||
ProjectID: 1,
|
||||
Title: "PG上传规格",
|
||||
ContentMarkdown: "# PG上传规格\n\n基线正文\n",
|
||||
ClientKey: clientKey,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, docID, again.Document.DocID)
|
||||
|
||||
st1, err := rev1.GetRevisionState(ctx, docID)
|
||||
require.NoError(t, err)
|
||||
assert.GreaterOrEqual(t, st1.CurrentVersionNo, 1)
|
||||
|
||||
patched := "# PG上传规格\n\n基线正文\n\nPG人工修订\n"
|
||||
_, err = rev1.CommitHumanRevision(ctx, docrevision.HumanRevisionRequest{
|
||||
DocID: docID, Markdown: patched,
|
||||
ExpectedHeadSnapshot: &st1.HeadSnapshotID, ExpectedRevisionNo: &st1.HeadRevisionNo,
|
||||
IdempotencyKey: fmt.Sprintf("pg-upload-patch-%d", time.Now().UnixNano()),
|
||||
ActorUserID: int64(userID), LifecycleState: "drafting", ProjectID: 1,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
_, err = svc1.UpdateDocument(ctx, userID, docID, freedoc.UpdateFreeformDocRequest{
|
||||
ContentMarkdown: &patched,
|
||||
ChangeSummary: "pg patch",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// 新 Service 实例:client_key 仍可解析,revision-state 与正文可恢复
|
||||
rev2 := docrevision.NewService(pool)
|
||||
svc2 := freedoc.NewService(pool, nil)
|
||||
svc2.SetDocRevision(rev2)
|
||||
|
||||
byKey, err := svc2.CreateDocument(ctx, userID, freedoc.CreateFreeformDocRequest{
|
||||
ProjectID: 1,
|
||||
Title: "ignored",
|
||||
ContentMarkdown: "# ignored\n",
|
||||
ClientKey: clientKey,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, docID, byKey.Document.DocID)
|
||||
|
||||
st2, err := rev2.GetRevisionState(ctx, docID)
|
||||
require.NoError(t, err, "revision-state must hydrate after new Service")
|
||||
assert.GreaterOrEqual(t, st2.HeadRevisionNo, 1)
|
||||
|
||||
got, err := svc2.GetDocument(ctx, userID, docID)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, got.Content, "PG人工修订")
|
||||
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(context.Background(), `DELETE FROM freeform_document_versions WHERE doc_id=$1`, docID)
|
||||
_, _ = pool.Exec(context.Background(), `DELETE FROM freeform_documents WHERE doc_id=$1`, docID)
|
||||
_, _ = pool.Exec(context.Background(), `DELETE FROM document_change_events WHERE doc_id=$1`, docID)
|
||||
_, _ = pool.Exec(context.Background(), `DELETE FROM document_versions WHERE doc_id=$1`, docID)
|
||||
_, _ = pool.Exec(context.Background(), `DELETE FROM document_snapshots WHERE doc_id=$1`, docID)
|
||||
_, _ = pool.Exec(context.Background(), `DELETE FROM document_access_audit WHERE doc_id=$1`, docID)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package freedoc_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"chat-agent-system/internal/docrevision"
|
||||
"chat-agent-system/internal/freedoc"
|
||||
)
|
||||
|
||||
func TestCreateDocument_ClientKeyIdempotentAndBootstrapsLedger(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
rev := docrevision.NewService(nil)
|
||||
svc := freedoc.NewService(nil, nil)
|
||||
svc.SetDocRevision(rev)
|
||||
|
||||
detail, err := svc.CreateDocument(ctx, 42, freedoc.CreateFreeformDocRequest{
|
||||
ProjectID: 7,
|
||||
Title: "超声探头规格.pdf",
|
||||
ContentMarkdown: "# 超声探头规格\n\n上传正文基线\n",
|
||||
Space: "my_private",
|
||||
ClientKey: "doc-upload-1789263145197-0",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, detail)
|
||||
assert.True(t, len(detail.Document.DocID) > 0)
|
||||
assert.True(t, detail.Document.DocID[:8] == "doc-free" || detail.Document.DocID[:9] == "doc-free-")
|
||||
require.NotNil(t, detail.Document.ClientKey)
|
||||
assert.Equal(t, "doc-upload-1789263145197-0", *detail.Document.ClientKey)
|
||||
assert.Equal(t, "freeform", detail.Document.GovernanceMode)
|
||||
// source_content_sha256 must remain content fingerprint — never client_key prefix
|
||||
assert.NotContains(t, detail.Document.SourceContentSHA256, "doc-upload")
|
||||
assert.NotContains(t, detail.Document.SourceContentSHA256, "client_key")
|
||||
assert.Len(t, detail.Document.SourceContentSHA256, 64)
|
||||
|
||||
st, err := rev.GetRevisionState(ctx, detail.Document.DocID)
|
||||
require.NoError(t, err)
|
||||
assert.GreaterOrEqual(t, st.CurrentVersionNo, 1)
|
||||
|
||||
again, err := svc.CreateDocument(ctx, 42, freedoc.CreateFreeformDocRequest{
|
||||
ProjectID: 7,
|
||||
Title: "超声探头规格.pdf",
|
||||
ContentMarkdown: "# 超声探头规格\n\n上传正文基线\n",
|
||||
ClientKey: "doc-upload-1789263145197-0",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, detail.Document.DocID, again.Document.DocID)
|
||||
|
||||
otherUser, err := svc.CreateDocument(ctx, 99, freedoc.CreateFreeformDocRequest{
|
||||
ProjectID: 7,
|
||||
Title: "他人同名键",
|
||||
ContentMarkdown: "# other\n",
|
||||
ClientKey: "doc-upload-1789263145197-0",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.NotEqual(t, detail.Document.DocID, otherUser.Document.DocID)
|
||||
}
|
||||
|
||||
func TestCreateDocument_RejectsEmptyContent(t *testing.T) {
|
||||
svc := freedoc.NewService(nil, nil)
|
||||
_, err := svc.CreateDocument(context.Background(), 1, freedoc.CreateFreeformDocRequest{
|
||||
ProjectID: 1, Title: "x", ContentMarkdown: " ",
|
||||
})
|
||||
assert.ErrorIs(t, err, freedoc.ErrInvalidParam)
|
||||
}
|
||||
|
||||
func TestCreateDocument_RequiresProjectIDAndPrivateSpace(t *testing.T) {
|
||||
svc := freedoc.NewService(nil, nil)
|
||||
_, err := svc.CreateDocument(context.Background(), 1, freedoc.CreateFreeformDocRequest{
|
||||
ProjectID: 0, Title: "x", ContentMarkdown: "# x\n",
|
||||
})
|
||||
assert.ErrorIs(t, err, freedoc.ErrInvalidParam)
|
||||
assert.Contains(t, err.Error(), "project_id")
|
||||
|
||||
_, err = svc.CreateDocument(context.Background(), 1, freedoc.CreateFreeformDocRequest{
|
||||
ProjectID: 1, Title: "x", ContentMarkdown: "# x\n", Space: "project_shared",
|
||||
})
|
||||
assert.ErrorIs(t, err, freedoc.ErrInvalidParam)
|
||||
assert.Contains(t, err.Error(), "my_private")
|
||||
}
|
||||
+212
-7
@@ -6,14 +6,15 @@ import (
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"chat-agent-system/internal/ctxmgr"
|
||||
"chat-agent-system/internal/docrevision"
|
||||
"chat-agent-system/internal/replyexport"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
@@ -25,15 +26,18 @@ type Service struct {
|
||||
memDocs map[string]*FreeformDocument
|
||||
memVersions map[string][]*FreeformDocumentVersion
|
||||
memAudit []map[string]any
|
||||
// memClientKeys mirrors PG uq_freeform_owner_client_key (owner|client_key → doc_id)
|
||||
memClientKeys map[string]string
|
||||
}
|
||||
|
||||
func NewService(pool *pgxpool.Pool, ctxMgr *ctxmgr.ContextManager) *Service {
|
||||
return &Service{
|
||||
pool: pool,
|
||||
ctxMgr: ctxMgr,
|
||||
memDocs: make(map[string]*FreeformDocument),
|
||||
memVersions: make(map[string][]*FreeformDocumentVersion),
|
||||
memAudit: make([]map[string]any, 0),
|
||||
pool: pool,
|
||||
ctxMgr: ctxMgr,
|
||||
memDocs: make(map[string]*FreeformDocument),
|
||||
memVersions: make(map[string][]*FreeformDocumentVersion),
|
||||
memAudit: make([]map[string]any, 0),
|
||||
memClientKeys: make(map[string]string),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -232,6 +236,207 @@ func (s *Service) CreateFromMessage(ctx context.Context, userID int, req CreateF
|
||||
}, false, nil
|
||||
}
|
||||
|
||||
func clientKeyLookup(userID int, clientKey string) string {
|
||||
return fmt.Sprintf("%d|%s", userID, strings.TrimSpace(clientKey))
|
||||
}
|
||||
|
||||
func (s *Service) bootstrapLedger(ctx context.Context, userID int, detail *FreeformDocDetail) {
|
||||
if s == nil || s.docRev == nil || detail == nil {
|
||||
return
|
||||
}
|
||||
md := detail.Content
|
||||
if strings.TrimSpace(md) == "" {
|
||||
return
|
||||
}
|
||||
_, _ = s.docRev.EnsureBootstrap(ctx, docrevision.BootstrapRequest{
|
||||
DocID: detail.Document.DocID,
|
||||
Kind: "freeform",
|
||||
Markdown: md,
|
||||
ProjectID: int64(detail.Document.ProjectID),
|
||||
LifecycleState: "drafting",
|
||||
Actor: docrevision.ActorSnapshot{
|
||||
DisplayName: "freeform-owner",
|
||||
Role: "owner",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) findByClientKeyPG(ctx context.Context, userID int, clientKey string) (*FreeformDocDetail, error) {
|
||||
var docID string
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT doc_id FROM freeform_documents
|
||||
WHERE owner_user_id = $1 AND client_key = $2 AND is_deleted = FALSE
|
||||
LIMIT 1
|
||||
`, userID, clientKey).Scan(&docID)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return s.GetDocument(ctx, userID, docID)
|
||||
}
|
||||
|
||||
// CreateDocument 直接创建自由文档(上传附件 / 自定义正文的服务端事实源)。
|
||||
// client_key 持久化于 freeform_documents.client_key,与 owner 唯一;禁止污染 source_content_sha256。
|
||||
// 创建后立即 EnsureBootstrap 版本账本,使 revision-state 可读、PATCH 可持久化。
|
||||
func (s *Service) CreateDocument(ctx context.Context, userID int, req CreateFreeformDocRequest) (*FreeformDocDetail, error) {
|
||||
title := strings.TrimSpace(req.Title)
|
||||
content := req.ContentMarkdown
|
||||
if title == "" {
|
||||
return nil, fmt.Errorf("%w: title required", ErrInvalidParam)
|
||||
}
|
||||
if strings.TrimSpace(content) == "" {
|
||||
return nil, fmt.Errorf("%w: content_markdown required", ErrInvalidParam)
|
||||
}
|
||||
space := strings.TrimSpace(req.Space)
|
||||
if space == "" {
|
||||
space = "my_private"
|
||||
}
|
||||
// Direct create is upload/private registration only; project_shared must go through ShareDocument.
|
||||
if space != "my_private" {
|
||||
return nil, fmt.Errorf("%w: direct create only allows my_private; use share API", ErrInvalidParam)
|
||||
}
|
||||
projectID := req.ProjectID
|
||||
if projectID <= 0 {
|
||||
return nil, fmt.Errorf("%w: project_id required", ErrInvalidParam)
|
||||
}
|
||||
clientKey := strings.TrimSpace(req.ClientKey)
|
||||
var clientKeyPtr *string
|
||||
if clientKey != "" {
|
||||
clientKeyPtr = &clientKey
|
||||
}
|
||||
|
||||
if clientKey != "" {
|
||||
if s.pool != nil {
|
||||
existing, err := s.findByClientKeyPG(ctx, userID, clientKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if existing != nil {
|
||||
return existing, nil
|
||||
}
|
||||
} else {
|
||||
s.mu.RLock()
|
||||
existingID := s.memClientKeys[clientKeyLookup(userID, clientKey)]
|
||||
s.mu.RUnlock()
|
||||
if existingID != "" {
|
||||
return s.GetDocument(ctx, userID, existingID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
contentHash := computeSHA256(content)
|
||||
docID := fmt.Sprintf("doc-free-%d-%d", time.Now().UnixNano(), userID)
|
||||
now := time.Now()
|
||||
|
||||
if s.pool != nil {
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
_, err = tx.Exec(ctx, `
|
||||
INSERT INTO freeform_documents (
|
||||
doc_id, project_id, owner_user_id, source_message_id, source_content_sha256,
|
||||
client_key, title, governance_mode, space, legacy_version_id, current_sha256,
|
||||
is_deleted, created_at, updated_at
|
||||
) VALUES ($1, $2, $3, NULL, $4, $5, $6, 'freeform', $7, 1, $4, FALSE, $8, $8)
|
||||
`, docID, projectID, userID, contentHash, clientKeyPtr, title, space, now)
|
||||
if err != nil {
|
||||
// Unique race: another request inserted the same owner+client_key
|
||||
if clientKey != "" {
|
||||
if existing, findErr := s.findByClientKeyPG(ctx, userID, clientKey); findErr == nil && existing != nil {
|
||||
return existing, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("insert freeform_doc: %w", err)
|
||||
}
|
||||
_, err = tx.Exec(ctx, `
|
||||
INSERT INTO freeform_document_versions (
|
||||
doc_id, version_no, content_sha256, content_markdown, created_by, change_summary, created_at
|
||||
) VALUES ($1, 1, $2, $3, $4, '上传附件注册', $5)
|
||||
`, docID, contentHash, content, userID, now)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("insert version: %w", err)
|
||||
}
|
||||
_, err = tx.Exec(ctx, `
|
||||
INSERT INTO document_access_audit (doc_id, user_id, action, success, created_at)
|
||||
VALUES ($1, $2, 'create_upload', TRUE, $3)
|
||||
`, docID, userID, now)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("insert audit: %w", err)
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
detail := &FreeformDocDetail{
|
||||
Document: FreeformDocument{
|
||||
DocID: docID,
|
||||
ProjectID: projectID,
|
||||
OwnerUserID: userID,
|
||||
SourceContentSHA256: contentHash,
|
||||
ClientKey: clientKeyPtr,
|
||||
Title: title,
|
||||
GovernanceMode: "freeform",
|
||||
Space: space,
|
||||
CurrentVersionID: 1,
|
||||
CurrentSHA256: contentHash,
|
||||
IsDeleted: false,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
},
|
||||
Content: content,
|
||||
}
|
||||
s.bootstrapLedger(ctx, userID, detail)
|
||||
return detail, nil
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if clientKey != "" {
|
||||
if existingID := s.memClientKeys[clientKeyLookup(userID, clientKey)]; existingID != "" {
|
||||
doc := s.memDocs[existingID]
|
||||
versions := s.memVersions[existingID]
|
||||
cur := ""
|
||||
if len(versions) > 0 {
|
||||
cur = versions[len(versions)-1].ContentMarkdown
|
||||
}
|
||||
if doc != nil {
|
||||
return &FreeformDocDetail{Document: *doc, Content: cur}, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
newDoc := &FreeformDocument{
|
||||
DocID: docID,
|
||||
ProjectID: projectID,
|
||||
OwnerUserID: userID,
|
||||
SourceContentSHA256: contentHash,
|
||||
ClientKey: clientKeyPtr,
|
||||
Title: title,
|
||||
GovernanceMode: "freeform",
|
||||
Space: space,
|
||||
CurrentVersionID: 1,
|
||||
CurrentSHA256: contentHash,
|
||||
IsDeleted: false,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
s.memDocs[docID] = newDoc
|
||||
s.memVersions[docID] = []*FreeformDocumentVersion{{
|
||||
ID: 1, DocID: docID, VersionNo: 1, ContentSHA256: contentHash,
|
||||
ContentMarkdown: content, CreatedBy: userID, ChangeSummary: "上传附件注册", CreatedAt: now,
|
||||
}}
|
||||
if clientKey != "" {
|
||||
s.memClientKeys[clientKeyLookup(userID, clientKey)] = docID
|
||||
}
|
||||
detail := &FreeformDocDetail{Document: *newDoc, Content: content}
|
||||
s.bootstrapLedger(ctx, userID, detail)
|
||||
return detail, nil
|
||||
}
|
||||
|
||||
// GetDocument 获取文档详情及内容,严格执行私密与共享访问控制
|
||||
func (s *Service) GetDocument(ctx context.Context, userID int, docID string) (*FreeformDocDetail, error) {
|
||||
if docID == "" {
|
||||
|
||||
@@ -6,12 +6,12 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"chat-agent-system/internal/config"
|
||||
"chat-agent-system/internal/ctxmgr"
|
||||
"chat-agent-system/internal/freedoc"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestFreeformDoc_DatabaseIntegration(t *testing.T) {
|
||||
|
||||
@@ -4,10 +4,10 @@ import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"chat-agent-system/internal/ctxmgr"
|
||||
"chat-agent-system/internal/freedoc"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestFreeformDocService_LifecycleAndACL(t *testing.T) {
|
||||
|
||||
+18
-15
@@ -15,20 +15,21 @@ var (
|
||||
|
||||
// FreeformDocument 自由私密/共享文档元数据
|
||||
type FreeformDocument struct {
|
||||
DocID string `json:"doc_id"`
|
||||
ProjectID int `json:"project_id"`
|
||||
OwnerUserID int `json:"owner_user_id"`
|
||||
SourceMessageID *int64 `json:"source_message_id,omitempty"`
|
||||
SourceContentSHA256 string `json:"source_content_sha256"`
|
||||
Title string `json:"title"`
|
||||
GovernanceMode string `json:"governance_mode"` // 恒为 'freeform'
|
||||
Space string `json:"space"` // 'my_private' | 'project_shared' | 'trash'
|
||||
CurrentVersionID int `json:"current_version_id"`
|
||||
CurrentSHA256 string `json:"current_sha256"`
|
||||
IsDeleted bool `json:"is_deleted"`
|
||||
DeletedAt *time.Time `json:"deleted_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DocID string `json:"doc_id"`
|
||||
ProjectID int `json:"project_id"`
|
||||
OwnerUserID int `json:"owner_user_id"`
|
||||
SourceMessageID *int64 `json:"source_message_id,omitempty"`
|
||||
SourceContentSHA256 string `json:"source_content_sha256"`
|
||||
ClientKey *string `json:"client_key,omitempty"`
|
||||
Title string `json:"title"`
|
||||
GovernanceMode string `json:"governance_mode"` // 恒为 'freeform'
|
||||
Space string `json:"space"` // 'my_private' | 'project_shared' | 'trash'
|
||||
CurrentVersionID int `json:"current_version_id"`
|
||||
CurrentSHA256 string `json:"current_sha256"`
|
||||
IsDeleted bool `json:"is_deleted"`
|
||||
DeletedAt *time.Time `json:"deleted_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// FreeformDocumentVersion 自由文档历史版本快照
|
||||
@@ -70,12 +71,14 @@ type CreateFromMessageRequest struct {
|
||||
ExpectedSHA256 string `json:"expected_sha256,omitempty"`
|
||||
}
|
||||
|
||||
// CreateFreeformDocRequest 直接创建空白/自定义自由文档请求
|
||||
// CreateFreeformDocRequest 直接创建空白/自定义自由文档请求(上传附件即时注册事实源)
|
||||
type CreateFreeformDocRequest struct {
|
||||
ProjectID int `json:"project_id"`
|
||||
Title string `json:"title"`
|
||||
ContentMarkdown string `json:"content_markdown"`
|
||||
Space string `json:"space,omitempty"` // 默认 'my_private'
|
||||
// ClientKey 可选幂等键(如 doc-upload-{ts}-{idx}),同用户重复提交返回已有文档
|
||||
ClientKey string `json:"client_key,omitempty"`
|
||||
}
|
||||
|
||||
// UpdateFreeformDocRequest 更新自由文档请求
|
||||
|
||||
Reference in New Issue
Block a user