Files
WeiCHEN 1fc7d0d01f feat(export): 实现单次智能体回复导出、私密文档保存与隐私防泄露全套闭环 (Phase 0 - Phase 2)
- 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 自动识别为原生公式
2026-09-13 02:50:21 +00:00

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
}