- Phase 0: 实现服务端回复导出策略矩阵引擎 (replyexport.EvaluatePolicy)、数据库迁移 (2026-09-11-message-export-policy.sql)、SSE done 事件挂载与能力探测 API - Phase 1: 构建前端 ReplyDocumentIR 语法树解析与渲染管道,支持 Markdown / DOCX 文件下载及带格式富文本剪贴板复制 - Phase 2: 实现私密自由文档 (freeform_documents) 领域服务、PostgreSQL 乐观锁版本管理、ACL 隐私隔离、一键共享与右侧栏编辑联动 - 专项修复: 1. 修复后台 API 路由注册并加入双向拉取同步,确保私密保存实时进入我的私密列表 2. 实现轻量回复专属 DOCX 生成与静默下载逻辑 (downloadDocxReply) 3. 将 LaTeX 公式转为标准 MathML 矢量标记,支持粘贴到 Microsoft Word / WPS 自动识别为原生公式
774 lines
22 KiB
Go
774 lines
22 KiB
Go
package freedoc
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
"chat-agent-system/internal/ctxmgr"
|
|
"chat-agent-system/internal/replyexport"
|
|
)
|
|
|
|
type Service struct {
|
|
pool *pgxpool.Pool
|
|
ctxMgr *ctxmgr.ContextManager
|
|
|
|
mu sync.RWMutex
|
|
memDocs map[string]*FreeformDocument
|
|
memVersions map[string][]*FreeformDocumentVersion
|
|
memAudit []map[string]any
|
|
}
|
|
|
|
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),
|
|
}
|
|
}
|
|
|
|
func computeSHA256(text string) string {
|
|
sum := sha256.Sum256([]byte(text))
|
|
return hex.EncodeToString(sum[:])
|
|
}
|
|
|
|
// CreateFromMessage 从单条智能体回复保存为私密文档(幂等)
|
|
func (s *Service) CreateFromMessage(ctx context.Context, userID int, req CreateFromMessageRequest) (*FreeformDocDetail, bool, error) {
|
|
if req.MessageID <= 0 {
|
|
return nil, false, fmt.Errorf("%w: invalid message_id", ErrInvalidParam)
|
|
}
|
|
|
|
// 1. 获取源消息
|
|
if s.ctxMgr == nil {
|
|
return nil, false, errors.New("ctxMgr is not configured")
|
|
}
|
|
msg, err := s.ctxMgr.GetMessageByID(ctx, req.MessageID)
|
|
if err != nil {
|
|
return nil, false, fmt.Errorf("lookup message: %w", err)
|
|
}
|
|
if msg == nil {
|
|
return nil, false, ErrNotFound
|
|
}
|
|
|
|
// 2. 校验导出资格红线:必须是助手且非纯卡片且 export_policy != 'none'
|
|
if msg.Role != "assistant" || msg.ContentKind == "card_only" || msg.ExportPolicy == "none" || len(msg.Content) == 0 {
|
|
return nil, false, ErrMessageNotExportable
|
|
}
|
|
|
|
content := msg.Content
|
|
contentHash := computeSHA256(content)
|
|
if req.ExpectedSHA256 != "" && req.ExpectedSHA256 != contentHash {
|
|
return nil, false, ErrConflict
|
|
}
|
|
|
|
title := req.Title
|
|
if title == "" {
|
|
title = replyexport.DeriveTitle(content, time.Now())
|
|
}
|
|
|
|
projectID := req.ProjectID
|
|
if projectID <= 0 {
|
|
projectID = 1 // 兜底默认项目
|
|
}
|
|
|
|
// 3. 幂等性检查:若该用户针对该消息已生成且未删除,直接返回
|
|
if s.pool != nil {
|
|
var existingDoc FreeformDocument
|
|
row := s.pool.QueryRow(ctx, `
|
|
SELECT doc_id, project_id, owner_user_id, source_message_id, source_content_sha256,
|
|
title, governance_mode, space, current_version_id, current_sha256,
|
|
is_deleted, deleted_at, created_at, updated_at
|
|
FROM freeform_documents
|
|
WHERE owner_user_id = $1 AND source_message_id = $2 AND is_deleted = FALSE
|
|
LIMIT 1
|
|
`, userID, req.MessageID)
|
|
if err := row.Scan(
|
|
&existingDoc.DocID, &existingDoc.ProjectID, &existingDoc.OwnerUserID, &existingDoc.SourceMessageID,
|
|
&existingDoc.SourceContentSHA256, &existingDoc.Title, &existingDoc.GovernanceMode,
|
|
&existingDoc.Space, &existingDoc.CurrentVersionID, &existingDoc.CurrentSHA256,
|
|
&existingDoc.IsDeleted, &existingDoc.DeletedAt, &existingDoc.CreatedAt, &existingDoc.UpdatedAt,
|
|
); err == nil {
|
|
// 获取当前版本的 markdown
|
|
var curContent string
|
|
_ = s.pool.QueryRow(ctx, `
|
|
SELECT content_markdown FROM freeform_document_versions
|
|
WHERE doc_id = $1 AND version_no = $2
|
|
`, existingDoc.DocID, existingDoc.CurrentVersionID).Scan(&curContent)
|
|
return &FreeformDocDetail{
|
|
Document: existingDoc,
|
|
Content: curContent,
|
|
}, true, nil
|
|
}
|
|
|
|
docID := fmt.Sprintf("doc-free-%d-%d", time.Now().UnixNano(), userID)
|
|
tx, err := s.pool.Begin(ctx)
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
|
|
now := time.Now()
|
|
_, err = tx.Exec(ctx, `
|
|
INSERT INTO freeform_documents (
|
|
doc_id, project_id, owner_user_id, source_message_id, source_content_sha256,
|
|
title, governance_mode, space, current_version_id, current_sha256,
|
|
is_deleted, created_at, updated_at
|
|
) VALUES ($1, $2, $3, $4, $5, $6, 'freeform', 'my_private', 1, $7, FALSE, $8, $8)
|
|
`, docID, projectID, userID, req.MessageID, contentHash, title, contentHash, now)
|
|
if err != nil {
|
|
return nil, false, 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, false, 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_from_message', TRUE, $3)
|
|
`, docID, userID, now)
|
|
if err != nil {
|
|
return nil, false, fmt.Errorf("insert audit: %w", err)
|
|
}
|
|
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return nil, false, err
|
|
}
|
|
|
|
createdDoc := FreeformDocument{
|
|
DocID: docID,
|
|
ProjectID: projectID,
|
|
OwnerUserID: userID,
|
|
SourceMessageID: &req.MessageID,
|
|
SourceContentSHA256: contentHash,
|
|
Title: title,
|
|
GovernanceMode: "freeform",
|
|
Space: "my_private",
|
|
CurrentVersionID: 1,
|
|
CurrentSHA256: contentHash,
|
|
IsDeleted: false,
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}
|
|
return &FreeformDocDetail{
|
|
Document: createdDoc,
|
|
Content: content,
|
|
}, false, nil
|
|
}
|
|
|
|
// 内存备用实现
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
for _, d := range s.memDocs {
|
|
if d.OwnerUserID == userID && d.SourceMessageID != nil && *d.SourceMessageID == req.MessageID && !d.IsDeleted {
|
|
versions := s.memVersions[d.DocID]
|
|
content := ""
|
|
if len(versions) > 0 {
|
|
content = versions[len(versions)-1].ContentMarkdown
|
|
}
|
|
return &FreeformDocDetail{
|
|
Document: *d,
|
|
Content: content,
|
|
}, true, nil
|
|
}
|
|
}
|
|
|
|
docID := fmt.Sprintf("doc-free-%d-%d", time.Now().UnixNano(), userID)
|
|
now := time.Now()
|
|
newDoc := &FreeformDocument{
|
|
DocID: docID,
|
|
ProjectID: projectID,
|
|
OwnerUserID: userID,
|
|
SourceMessageID: &req.MessageID,
|
|
SourceContentSHA256: contentHash,
|
|
Title: title,
|
|
GovernanceMode: "freeform",
|
|
Space: "my_private",
|
|
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,
|
|
},
|
|
}
|
|
return &FreeformDocDetail{
|
|
Document: *newDoc,
|
|
Content: content,
|
|
}, false, nil
|
|
}
|
|
|
|
// GetDocument 获取文档详情及内容,严格执行私密与共享访问控制
|
|
func (s *Service) GetDocument(ctx context.Context, userID int, docID string) (*FreeformDocDetail, error) {
|
|
if docID == "" {
|
|
return nil, ErrNotFound
|
|
}
|
|
|
|
if s.pool != nil {
|
|
var doc FreeformDocument
|
|
row := s.pool.QueryRow(ctx, `
|
|
SELECT doc_id, project_id, owner_user_id, source_message_id, source_content_sha256,
|
|
title, governance_mode, space, current_version_id, current_sha256,
|
|
is_deleted, deleted_at, created_at, updated_at
|
|
FROM freeform_documents
|
|
WHERE doc_id = $1
|
|
`, docID)
|
|
if err := row.Scan(
|
|
&doc.DocID, &doc.ProjectID, &doc.OwnerUserID, &doc.SourceMessageID,
|
|
&doc.SourceContentSHA256, &doc.Title, &doc.GovernanceMode,
|
|
&doc.Space, &doc.CurrentVersionID, &doc.CurrentSHA256,
|
|
&doc.IsDeleted, &doc.DeletedAt, &doc.CreatedAt, &doc.UpdatedAt,
|
|
); err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, ErrNotFound
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
if doc.IsDeleted {
|
|
return nil, ErrNotFound
|
|
}
|
|
|
|
// 私密隔离红线:my_private 只有 owner 可读
|
|
if doc.Space == "my_private" && doc.OwnerUserID != userID {
|
|
_, _ = s.pool.Exec(ctx, `
|
|
INSERT INTO document_access_audit (doc_id, user_id, action, success)
|
|
VALUES ($1, $2, 'read_denied', FALSE)
|
|
`, docID, userID)
|
|
return nil, ErrForbidden
|
|
}
|
|
|
|
var content string
|
|
err := s.pool.QueryRow(ctx, `
|
|
SELECT content_markdown FROM freeform_document_versions
|
|
WHERE doc_id = $1 AND version_no = $2
|
|
`, doc.DocID, doc.CurrentVersionID).Scan(&content)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read version: %w", err)
|
|
}
|
|
|
|
_, _ = s.pool.Exec(ctx, `
|
|
INSERT INTO document_access_audit (doc_id, user_id, action, success)
|
|
VALUES ($1, $2, 'read', TRUE)
|
|
`, docID, userID)
|
|
|
|
return &FreeformDocDetail{
|
|
Document: doc,
|
|
Content: content,
|
|
}, nil
|
|
}
|
|
|
|
// 内存模式
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
doc, ok := s.memDocs[docID]
|
|
if !ok || doc.IsDeleted {
|
|
return nil, ErrNotFound
|
|
}
|
|
if doc.Space == "my_private" && doc.OwnerUserID != userID {
|
|
return nil, ErrForbidden
|
|
}
|
|
|
|
versions := s.memVersions[docID]
|
|
content := ""
|
|
for _, v := range versions {
|
|
if v.VersionNo == doc.CurrentVersionID {
|
|
content = v.ContentMarkdown
|
|
break
|
|
}
|
|
}
|
|
|
|
return &FreeformDocDetail{
|
|
Document: *doc,
|
|
Content: content,
|
|
}, nil
|
|
}
|
|
|
|
// ListDocuments 按空间与权限列出自由文档概要
|
|
func (s *Service) ListDocuments(ctx context.Context, userID int, projectID int, space string) ([]FreeformDocSummary, error) {
|
|
if space != "my_private" && space != "project_shared" && space != "trash" {
|
|
space = "my_private"
|
|
}
|
|
|
|
if s.pool != nil {
|
|
var rows pgx.Rows
|
|
var err error
|
|
if space == "my_private" {
|
|
// 我的私密:属于当前用户
|
|
if projectID > 0 {
|
|
rows, err = s.pool.Query(ctx, `
|
|
SELECT doc_id, project_id, owner_user_id, title, space, current_version_id, current_sha256, created_at, updated_at
|
|
FROM freeform_documents
|
|
WHERE owner_user_id = $1 AND space = 'my_private' AND project_id = $2 AND is_deleted = FALSE
|
|
ORDER BY updated_at DESC
|
|
`, userID, projectID)
|
|
} else {
|
|
rows, err = s.pool.Query(ctx, `
|
|
SELECT doc_id, project_id, owner_user_id, title, space, current_version_id, current_sha256, created_at, updated_at
|
|
FROM freeform_documents
|
|
WHERE owner_user_id = $1 AND space = 'my_private' AND is_deleted = FALSE
|
|
ORDER BY updated_at DESC
|
|
`, userID)
|
|
}
|
|
} else if space == "project_shared" {
|
|
// 项目共享参考:属于当前项目的所有公开参考
|
|
rows, err = s.pool.Query(ctx, `
|
|
SELECT doc_id, project_id, owner_user_id, title, space, current_version_id, current_sha256, created_at, updated_at
|
|
FROM freeform_documents
|
|
WHERE project_id = $1 AND space = 'project_shared' AND is_deleted = FALSE
|
|
ORDER BY updated_at DESC
|
|
`, projectID)
|
|
} else {
|
|
// 回收站:当前用户删除的
|
|
rows, err = s.pool.Query(ctx, `
|
|
SELECT doc_id, project_id, owner_user_id, title, space, current_version_id, current_sha256, created_at, updated_at
|
|
FROM freeform_documents
|
|
WHERE owner_user_id = $1 AND is_deleted = TRUE
|
|
ORDER BY deleted_at DESC
|
|
`, userID)
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var results []FreeformDocSummary
|
|
for rows.Next() {
|
|
var sm FreeformDocSummary
|
|
if err := rows.Scan(
|
|
&sm.DocID, &sm.ProjectID, &sm.OwnerUserID, &sm.Title, &sm.Space,
|
|
&sm.CurrentVersionID, &sm.CurrentSHA256, &sm.CreatedAt, &sm.UpdatedAt,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
results = append(results, sm)
|
|
}
|
|
return results, nil
|
|
}
|
|
|
|
// 内存模式
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
var results []FreeformDocSummary
|
|
for _, d := range s.memDocs {
|
|
if space == "my_private" {
|
|
if d.OwnerUserID == userID && d.Space == "my_private" && !d.IsDeleted {
|
|
if projectID <= 0 || d.ProjectID == projectID {
|
|
results = append(results, FreeformDocSummary{
|
|
DocID: d.DocID,
|
|
ProjectID: d.ProjectID,
|
|
OwnerUserID: d.OwnerUserID,
|
|
Title: d.Title,
|
|
Space: d.Space,
|
|
CurrentVersionID: d.CurrentVersionID,
|
|
CurrentSHA256: d.CurrentSHA256,
|
|
CreatedAt: d.CreatedAt,
|
|
UpdatedAt: d.UpdatedAt,
|
|
})
|
|
}
|
|
}
|
|
} else if space == "project_shared" {
|
|
if d.ProjectID == projectID && d.Space == "project_shared" && !d.IsDeleted {
|
|
results = append(results, FreeformDocSummary{
|
|
DocID: d.DocID,
|
|
ProjectID: d.ProjectID,
|
|
OwnerUserID: d.OwnerUserID,
|
|
Title: d.Title,
|
|
Space: d.Space,
|
|
CurrentVersionID: d.CurrentVersionID,
|
|
CurrentSHA256: d.CurrentSHA256,
|
|
CreatedAt: d.CreatedAt,
|
|
UpdatedAt: d.UpdatedAt,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
return results, nil
|
|
}
|
|
|
|
// UpdateDocument 更新文档正文或标题,支持 If-Match 乐观并发控制
|
|
func (s *Service) UpdateDocument(ctx context.Context, userID int, docID string, req UpdateFreeformDocRequest) (*FreeformDocDetail, error) {
|
|
if docID == "" {
|
|
return nil, ErrNotFound
|
|
}
|
|
|
|
if s.pool != nil {
|
|
tx, err := s.pool.Begin(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
|
|
var doc FreeformDocument
|
|
row := tx.QueryRow(ctx, `
|
|
SELECT doc_id, project_id, owner_user_id, source_message_id, source_content_sha256,
|
|
title, governance_mode, space, current_version_id, current_sha256,
|
|
is_deleted, created_at, updated_at
|
|
FROM freeform_documents
|
|
WHERE doc_id = $1 FOR UPDATE
|
|
`, docID)
|
|
if err := row.Scan(
|
|
&doc.DocID, &doc.ProjectID, &doc.OwnerUserID, &doc.SourceMessageID,
|
|
&doc.SourceContentSHA256, &doc.Title, &doc.GovernanceMode,
|
|
&doc.Space, &doc.CurrentVersionID, &doc.CurrentSHA256,
|
|
&doc.IsDeleted, &doc.CreatedAt, &doc.UpdatedAt,
|
|
); err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, ErrNotFound
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
if doc.IsDeleted {
|
|
return nil, ErrNotFound
|
|
}
|
|
|
|
// 私密文档非本人严禁修改
|
|
if doc.Space == "my_private" && doc.OwnerUserID != userID {
|
|
return nil, ErrForbidden
|
|
}
|
|
|
|
// 乐观并发冲突检验
|
|
if req.ExpectedVersion != nil && *req.ExpectedVersion != doc.CurrentVersionID {
|
|
return nil, ErrConflict
|
|
}
|
|
if req.ExpectedSHA256 != nil && *req.ExpectedSHA256 != "" && *req.ExpectedSHA256 != doc.CurrentSHA256 {
|
|
return nil, ErrConflict
|
|
}
|
|
|
|
now := time.Now()
|
|
newTitle := doc.Title
|
|
if req.Title != nil && *req.Title != "" {
|
|
newTitle = *req.Title
|
|
}
|
|
|
|
curContent := ""
|
|
_ = tx.QueryRow(ctx, `SELECT content_markdown FROM freeform_document_versions WHERE doc_id = $1 AND version_no = $2`, doc.DocID, doc.CurrentVersionID).Scan(&curContent)
|
|
|
|
newContent := curContent
|
|
if req.ContentMarkdown != nil {
|
|
newContent = *req.ContentMarkdown
|
|
}
|
|
|
|
newHash := computeSHA256(newContent)
|
|
if newHash != doc.CurrentSHA256 {
|
|
// 内容变更,生成新版本
|
|
nextVersion := doc.CurrentVersionID + 1
|
|
summary := req.ChangeSummary
|
|
if summary == "" {
|
|
summary = "编辑修改正文"
|
|
}
|
|
_, 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, $2, $3, $4, $5, $6, $7)
|
|
`, doc.DocID, nextVersion, newHash, newContent, userID, summary, now)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("insert version: %w", err)
|
|
}
|
|
|
|
_, err = tx.Exec(ctx, `
|
|
UPDATE freeform_documents
|
|
SET title = $1, current_version_id = $2, current_sha256 = $3, updated_at = $4
|
|
WHERE doc_id = $5
|
|
`, newTitle, nextVersion, newHash, now, doc.DocID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("update doc: %w", err)
|
|
}
|
|
|
|
doc.CurrentVersionID = nextVersion
|
|
doc.CurrentSHA256 = newHash
|
|
} else if newTitle != doc.Title {
|
|
// 仅标题变更
|
|
_, err = tx.Exec(ctx, `
|
|
UPDATE freeform_documents
|
|
SET title = $1, updated_at = $2
|
|
WHERE doc_id = $3
|
|
`, newTitle, now, doc.DocID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("update doc title: %w", err)
|
|
}
|
|
}
|
|
|
|
doc.Title = newTitle
|
|
doc.UpdatedAt = now
|
|
|
|
_, _ = tx.Exec(ctx, `
|
|
INSERT INTO document_access_audit (doc_id, user_id, action, success, created_at)
|
|
VALUES ($1, $2, 'update', TRUE, $3)
|
|
`, doc.DocID, userID, now)
|
|
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &FreeformDocDetail{
|
|
Document: doc,
|
|
Content: newContent,
|
|
}, nil
|
|
}
|
|
|
|
// 内存模式
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
doc, ok := s.memDocs[docID]
|
|
if !ok || doc.IsDeleted {
|
|
return nil, ErrNotFound
|
|
}
|
|
if doc.Space == "my_private" && doc.OwnerUserID != userID {
|
|
return nil, ErrForbidden
|
|
}
|
|
if req.ExpectedVersion != nil && *req.ExpectedVersion != doc.CurrentVersionID {
|
|
return nil, ErrConflict
|
|
}
|
|
if req.ExpectedSHA256 != nil && *req.ExpectedSHA256 != "" && *req.ExpectedSHA256 != doc.CurrentSHA256 {
|
|
return nil, ErrConflict
|
|
}
|
|
|
|
now := time.Now()
|
|
if req.Title != nil && *req.Title != "" {
|
|
doc.Title = *req.Title
|
|
}
|
|
|
|
curContent := ""
|
|
versions := s.memVersions[docID]
|
|
if len(versions) > 0 {
|
|
curContent = versions[len(versions)-1].ContentMarkdown
|
|
}
|
|
|
|
newContent := curContent
|
|
if req.ContentMarkdown != nil {
|
|
newContent = *req.ContentMarkdown
|
|
}
|
|
|
|
newHash := computeSHA256(newContent)
|
|
if newHash != doc.CurrentSHA256 {
|
|
nextVersion := doc.CurrentVersionID + 1
|
|
doc.CurrentVersionID = nextVersion
|
|
doc.CurrentSHA256 = newHash
|
|
s.memVersions[docID] = append(s.memVersions[docID], &FreeformDocumentVersion{
|
|
ID: int64(len(s.memVersions[docID]) + 1),
|
|
DocID: docID,
|
|
VersionNo: nextVersion,
|
|
ContentSHA256: newHash,
|
|
ContentMarkdown: newContent,
|
|
CreatedBy: userID,
|
|
ChangeSummary: req.ChangeSummary,
|
|
CreatedAt: now,
|
|
})
|
|
}
|
|
doc.UpdatedAt = now
|
|
return &FreeformDocDetail{
|
|
Document: *doc,
|
|
Content: newContent,
|
|
}, nil
|
|
}
|
|
|
|
// ShareDocument 将私密文档共享至项目参考库(仅 owner 可操作)
|
|
func (s *Service) ShareDocument(ctx context.Context, userID int, docID string) (*FreeformDocument, error) {
|
|
if docID == "" {
|
|
return nil, ErrNotFound
|
|
}
|
|
|
|
if s.pool != nil {
|
|
var doc FreeformDocument
|
|
row := s.pool.QueryRow(ctx, `
|
|
SELECT doc_id, project_id, owner_user_id, space, is_deleted
|
|
FROM freeform_documents WHERE doc_id = $1
|
|
`, docID)
|
|
if err := row.Scan(&doc.DocID, &doc.ProjectID, &doc.OwnerUserID, &doc.Space, &doc.IsDeleted); err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, ErrNotFound
|
|
}
|
|
return nil, err
|
|
}
|
|
if doc.IsDeleted {
|
|
return nil, ErrNotFound
|
|
}
|
|
if doc.OwnerUserID != userID {
|
|
return nil, ErrForbidden
|
|
}
|
|
|
|
now := time.Now()
|
|
_, err := s.pool.Exec(ctx, `
|
|
UPDATE freeform_documents
|
|
SET space = 'project_shared', updated_at = $1
|
|
WHERE doc_id = $2
|
|
`, now, docID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
_, _ = s.pool.Exec(ctx, `
|
|
INSERT INTO document_access_audit (doc_id, user_id, action, success, created_at)
|
|
VALUES ($1, $2, 'share', TRUE, $3)
|
|
`, docID, userID, now)
|
|
|
|
doc.Space = "project_shared"
|
|
doc.UpdatedAt = now
|
|
return &doc, nil
|
|
}
|
|
|
|
// 内存模式
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
doc, ok := s.memDocs[docID]
|
|
if !ok || doc.IsDeleted {
|
|
return nil, ErrNotFound
|
|
}
|
|
if doc.OwnerUserID != userID {
|
|
return nil, ErrForbidden
|
|
}
|
|
doc.Space = "project_shared"
|
|
doc.UpdatedAt = time.Now()
|
|
return doc, nil
|
|
}
|
|
|
|
// UnshareDocument 撤回共享,恢复为个人私密
|
|
func (s *Service) UnshareDocument(ctx context.Context, userID int, docID string) (*FreeformDocument, error) {
|
|
if docID == "" {
|
|
return nil, ErrNotFound
|
|
}
|
|
|
|
if s.pool != nil {
|
|
var doc FreeformDocument
|
|
row := s.pool.QueryRow(ctx, `
|
|
SELECT doc_id, project_id, owner_user_id, space, is_deleted
|
|
FROM freeform_documents WHERE doc_id = $1
|
|
`, docID)
|
|
if err := row.Scan(&doc.DocID, &doc.ProjectID, &doc.OwnerUserID, &doc.Space, &doc.IsDeleted); err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, ErrNotFound
|
|
}
|
|
return nil, err
|
|
}
|
|
if doc.IsDeleted {
|
|
return nil, ErrNotFound
|
|
}
|
|
if doc.OwnerUserID != userID {
|
|
return nil, ErrForbidden
|
|
}
|
|
|
|
now := time.Now()
|
|
_, err := s.pool.Exec(ctx, `
|
|
UPDATE freeform_documents
|
|
SET space = 'my_private', updated_at = $1
|
|
WHERE doc_id = $2
|
|
`, now, docID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
_, _ = s.pool.Exec(ctx, `
|
|
INSERT INTO document_access_audit (doc_id, user_id, action, success, created_at)
|
|
VALUES ($1, $2, 'unshare', TRUE, $3)
|
|
`, docID, userID, now)
|
|
|
|
doc.Space = "my_private"
|
|
doc.UpdatedAt = now
|
|
return &doc, nil
|
|
}
|
|
|
|
// 内存模式
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
doc, ok := s.memDocs[docID]
|
|
if !ok || doc.IsDeleted {
|
|
return nil, ErrNotFound
|
|
}
|
|
if doc.OwnerUserID != userID {
|
|
return nil, ErrForbidden
|
|
}
|
|
doc.Space = "my_private"
|
|
doc.UpdatedAt = time.Now()
|
|
return doc, nil
|
|
}
|
|
|
|
// DeleteDocument 软删除文档(仅 owner 可操作)
|
|
func (s *Service) DeleteDocument(ctx context.Context, userID int, docID string) error {
|
|
if docID == "" {
|
|
return ErrNotFound
|
|
}
|
|
|
|
if s.pool != nil {
|
|
var doc FreeformDocument
|
|
row := s.pool.QueryRow(ctx, `
|
|
SELECT doc_id, owner_user_id, is_deleted
|
|
FROM freeform_documents WHERE doc_id = $1
|
|
`, docID)
|
|
if err := row.Scan(&doc.DocID, &doc.OwnerUserID, &doc.IsDeleted); err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return ErrNotFound
|
|
}
|
|
return err
|
|
}
|
|
if doc.IsDeleted {
|
|
return nil
|
|
}
|
|
if doc.OwnerUserID != userID {
|
|
return ErrForbidden
|
|
}
|
|
|
|
now := time.Now()
|
|
_, err := s.pool.Exec(ctx, `
|
|
UPDATE freeform_documents
|
|
SET is_deleted = TRUE, deleted_at = $1, space = 'trash'
|
|
WHERE doc_id = $2
|
|
`, now, docID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
_, _ = s.pool.Exec(ctx, `
|
|
INSERT INTO document_access_audit (doc_id, user_id, action, success, created_at)
|
|
VALUES ($1, $2, 'delete', TRUE, $3)
|
|
`, docID, userID, now)
|
|
|
|
return nil
|
|
}
|
|
|
|
// 内存模式
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
doc, ok := s.memDocs[docID]
|
|
if !ok || doc.IsDeleted {
|
|
return ErrNotFound
|
|
}
|
|
if doc.OwnerUserID != userID {
|
|
return ErrForbidden
|
|
}
|
|
doc.IsDeleted = true
|
|
now := time.Now()
|
|
doc.DeletedAt = &now
|
|
doc.Space = "trash"
|
|
return nil
|
|
}
|