merge: fix/document-revision-runtime-ui-evidence
This commit is contained in:
+1
-1
@@ -24,7 +24,7 @@
|
||||
|
||||
| holder | phase | since | scope | queue |
|
||||
|--------|-------|-------|-------|-------|
|
||||
| Codex/Antigravity | merging | 2026-09-13 10:12 UTC | DOCREV-RT merge + dev rebuild + real UI E2E | - |
|
||||
| Codex/Antigravity | merging | 2026-09-13 11:18 UTC | DOCREV-RT upload fact-source follow-up → dev, then smoke/UI E2E | - |
|
||||
|
||||
**phase**: `idle` | `merging` | `smoke` | `e2e`
|
||||
|
||||
|
||||
@@ -59,7 +59,14 @@ RUN_UI_ACCEPTANCE=1 BASE_URL=http://127.0.0.1:8080 \
|
||||
--video on
|
||||
```
|
||||
|
||||
规格文件:`web/e2e/document-revision-runtime.spec.ts` + `web/playwright.config.ts`(骨架已入库;`RUN_UI_ACCEPTANCE` 未设时 skip;占槽验收时补齐真实选择器与步骤,禁止 mock)。
|
||||
规格文件:`web/e2e/document-revision-runtime.spec.ts` + `web/playwright.config.ts`。
|
||||
|
||||
两阶段容器重启:
|
||||
1. `RUN_UI_ACCEPTANCE=1 UI_PHASE=main` 跑主场景(写 `phase-state.json`,密码仅落 `phase-secrets.local.json` 本地文件)
|
||||
2. 宿主执行 docker restart(脚本不碰 docker)
|
||||
3. `RUN_UI_ACCEPTANCE=1 UI_PHASE=post-restart` 仅页面登录校验 UI-15
|
||||
|
||||
证据:`.doc/test-reports/assets/2026-09-13-document-revision-runtime-ui/`(trace/video/screenshot);结果 JSON 不得含凭证与 URL query。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -77,6 +77,7 @@ cd web && npm test && npm run build
|
||||
- J:**前端历史映射**:`pending_author_confirmation` 显式映射为气泡状态(不再默认 completed);刷新窗口显示「待编制人确认」标签
|
||||
- K:**决策幂等收口**:`CommitAgentVersion` 已 committed 且 expected hashes 一致时返回当前 state(可继续投影/MarkProposalApplied);已 committed 禁止 reject。`MarkProposalApplied` 先写 DB 再改内存;已 applied 幂等(包内 Propose+Decide 验证)。decision 失败 hint 指向同决策重试;**不**暴露内存补种 helper(避免内存/DB 分叉)
|
||||
- L:**Playwright 骨架**:`web` 增加 `@playwright/test` + `playwright.config.ts` + `e2e/document-revision-runtime.spec.ts`(`RUN_UI_ACCEPTANCE=1` 才跑;无 mock/route/API/DB fixture)
|
||||
- M:**上传注册事实源**:发送前 `await registerUploadAttachments` → `POST /api/v1/freeform-documents`(`client_key` 幂等);失败 UI 显式错误且不发消息、不生成本地 `doc-upload` orphan;成功使用 `doc-free-*` 写入 deliverable/fileStore 并挂 toolbar
|
||||
|
||||
### 用户 UI 实战验收
|
||||
|
||||
|
||||
@@ -59,3 +59,8 @@ node_modules/
|
||||
# Regulations Knowledge Base Data
|
||||
.regulations/
|
||||
regulations/
|
||||
|
||||
# Playwright UI acceptance local secrets / runtime output
|
||||
.doc/test-reports/assets/**/phase-secrets.local.json
|
||||
.doc/test-reports/assets/**/playwright-output/
|
||||
.doc/test-reports/assets/**/playwright-report/
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
-- +goose Up
|
||||
-- 2026-09-13 freeform upload registration: formal client_key for idempotent server fact-source create
|
||||
-- Schema 28 → 29
|
||||
|
||||
ALTER TABLE freeform_documents
|
||||
ADD COLUMN IF NOT EXISTS client_key VARCHAR(128) NULL;
|
||||
|
||||
COMMENT ON COLUMN freeform_documents.client_key IS
|
||||
'Client-supplied idempotency key for direct create (e.g. upload batch id); NULL for message-derived docs';
|
||||
|
||||
-- Same owner cannot register the same client_key twice while the doc is alive.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_freeform_owner_client_key
|
||||
ON freeform_documents (owner_user_id, client_key)
|
||||
WHERE is_deleted = FALSE AND client_key IS NOT NULL AND length(trim(client_key)) > 0;
|
||||
|
||||
-- +goose Down
|
||||
DROP INDEX IF EXISTS uq_freeform_owner_client_key;
|
||||
ALTER TABLE freeform_documents DROP COLUMN IF EXISTS client_key;
|
||||
@@ -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 更新自由文档请求
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Upload → freeform create (server docId) → revision-state 200 → PATCH persists content.
|
||||
func TestDocRevision_UploadRegisteredFreeform_RevisionStateAndPatchPersist(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
token := loginTestAdmin(t, srv)
|
||||
|
||||
clientKey := "doc-upload-1789263145197-0"
|
||||
createBody := map[string]any{
|
||||
"project_id": 1,
|
||||
"title": "超声探头规格.pdf",
|
||||
"content_markdown": "# 超声探头规格\n\n初始上传正文\n",
|
||||
"space": "my_private",
|
||||
"client_key": clientKey,
|
||||
}
|
||||
raw, _ := json.Marshal(createBody)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/freeform-documents", bytes.NewReader(raw))
|
||||
withBearerAuth(req, token)
|
||||
w := httptest.NewRecorder()
|
||||
srv.Routes().ServeHTTP(w, req)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("create freeform want 201 got %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
var createResp struct {
|
||||
Data struct {
|
||||
Document struct {
|
||||
DocID string `json:"doc_id"`
|
||||
ClientKey *string `json:"client_key"`
|
||||
} `json:"document"`
|
||||
Content string `json:"content_markdown"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.NewDecoder(w.Body).Decode(&createResp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
docID := createResp.Data.Document.DocID
|
||||
if !strings.HasPrefix(docID, "doc-free-") {
|
||||
t.Fatalf("want server doc-free id, got %q", docID)
|
||||
}
|
||||
if createResp.Data.Document.ClientKey == nil || *createResp.Data.Document.ClientKey != clientKey {
|
||||
t.Fatalf("client_key not persisted: %#v", createResp.Data.Document.ClientKey)
|
||||
}
|
||||
|
||||
// Idempotent re-create with same client_key
|
||||
req2 := httptest.NewRequest(http.MethodPost, "/api/v1/freeform-documents", bytes.NewReader(raw))
|
||||
withBearerAuth(req2, token)
|
||||
w2 := httptest.NewRecorder()
|
||||
srv.Routes().ServeHTTP(w2, req2)
|
||||
if w2.Code != http.StatusCreated && w2.Code != http.StatusOK {
|
||||
t.Fatalf("idempotent create want 201/200 got %d %s", w2.Code, w2.Body.String())
|
||||
}
|
||||
var createResp2 struct {
|
||||
Data struct {
|
||||
Document struct {
|
||||
DocID string `json:"doc_id"`
|
||||
} `json:"document"`
|
||||
} `json:"data"`
|
||||
}
|
||||
_ = json.NewDecoder(w2.Body).Decode(&createResp2)
|
||||
if createResp2.Data.Document.DocID != docID {
|
||||
t.Fatalf("idempotent client_key must return same doc_id: %s vs %s", docID, createResp2.Data.Document.DocID)
|
||||
}
|
||||
|
||||
// revision-state must be 200 (not orphan 404)
|
||||
stReq := httptest.NewRequest(http.MethodGet, "/api/v1/documents/"+docID+"/revision-state", nil)
|
||||
withBearerAuth(stReq, token)
|
||||
stW := httptest.NewRecorder()
|
||||
srv.Routes().ServeHTTP(stW, stReq)
|
||||
if stW.Code != http.StatusOK {
|
||||
t.Fatalf("revision-state want 200 got %d %s", stW.Code, stW.Body.String())
|
||||
}
|
||||
var st map[string]any
|
||||
if err := json.NewDecoder(stW.Body).Decode(&st); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
headSnap, _ := st["head_snapshot_id"].(float64)
|
||||
headRev, _ := st["head_revision_no"].(float64)
|
||||
if headSnap <= 0 {
|
||||
t.Fatalf("expected bootstrapped head_snapshot_id, got %#v", st)
|
||||
}
|
||||
|
||||
patched := "# 超声探头规格\n\n初始上传正文\n\n人工修订段\n"
|
||||
patchBody := `{"markdown":` + strconv.Quote(patched) + `}`
|
||||
pReq := httptest.NewRequest(http.MethodPatch, "/api/v1/documents/"+docID+"/content", bytes.NewReader([]byte(patchBody)))
|
||||
withBearerAuth(pReq, token)
|
||||
pReq.Header.Set("Idempotency-Key", "upload-patch-k1")
|
||||
pReq.Header.Set("If-Match", `"`+strconv.FormatInt(int64(headSnap), 10)+":"+strconv.Itoa(int(headRev))+`"`)
|
||||
pW := httptest.NewRecorder()
|
||||
srv.Routes().ServeHTTP(pW, pReq)
|
||||
if pW.Code != http.StatusOK {
|
||||
t.Fatalf("PATCH want 200 got %d %s", pW.Code, pW.Body.String())
|
||||
}
|
||||
|
||||
getReq := httptest.NewRequest(http.MethodGet, "/api/v1/freeform-documents/"+docID, nil)
|
||||
withBearerAuth(getReq, token)
|
||||
gW := httptest.NewRecorder()
|
||||
srv.Routes().ServeHTTP(gW, getReq)
|
||||
if gW.Code != http.StatusOK {
|
||||
t.Fatalf("get freeform want 200 got %d %s", gW.Code, gW.Body.String())
|
||||
}
|
||||
var getResp struct {
|
||||
Data struct {
|
||||
Content string `json:"content_markdown"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.NewDecoder(gW.Body).Decode(&getResp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(getResp.Data.Content, "人工修订段") {
|
||||
t.Fatalf("PATCH content not persisted in freeform fact source: %q", getResp.Data.Content)
|
||||
}
|
||||
|
||||
// Unregistered doc-upload-* remains orphan 404 (must not silently invent ledger)
|
||||
orphan := httptest.NewRequest(http.MethodGet, "/api/v1/documents/doc-upload-never-registered/revision-state", nil)
|
||||
withBearerAuth(orphan, token)
|
||||
oW := httptest.NewRecorder()
|
||||
srv.Routes().ServeHTTP(oW, orphan)
|
||||
if oW.Code != http.StatusNotFound {
|
||||
t.Fatalf("unregistered upload orphan want 404 got %d %s", oW.Code, oW.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func postFreeformCreate(t *testing.T, srv *Server, token string, body map[string]any) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
raw, _ := json.Marshal(body)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/freeform-documents", bytes.NewReader(raw))
|
||||
withBearerAuth(req, token)
|
||||
w := httptest.NewRecorder()
|
||||
srv.Routes().ServeHTTP(w, req)
|
||||
return w
|
||||
}
|
||||
|
||||
func TestCreateFreeformDoc_RejectsMissingProjectID(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
token := loginTestAdmin(t, srv)
|
||||
w := postFreeformCreate(t, srv, token, map[string]any{
|
||||
"project_id": 0,
|
||||
"title": "x",
|
||||
"content_markdown": "# x\n",
|
||||
})
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("want 400 got %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
if !bytes.Contains(w.Body.Bytes(), []byte("project_id")) {
|
||||
t.Fatalf("want project_id error, got %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateFreeformDoc_RejectsDirectProjectShared(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
token := loginTestAdmin(t, srv)
|
||||
w := postFreeformCreate(t, srv, token, map[string]any{
|
||||
"project_id": 1,
|
||||
"title": "x",
|
||||
"content_markdown": "# x\n",
|
||||
"space": "project_shared",
|
||||
})
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("want 400 got %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
if !bytes.Contains(w.Body.Bytes(), []byte("my_private")) {
|
||||
t.Fatalf("want my_private-only error, got %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateFreeformDoc_FailClosedWhenAuthorizerNil(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
srv.cfg.AllowInMemory = false
|
||||
srv.authorizer = nil
|
||||
token := loginTestAdmin(t, srv)
|
||||
w := postFreeformCreate(t, srv, token, map[string]any{
|
||||
"project_id": 1,
|
||||
"title": "x",
|
||||
"content_markdown": "# x\n",
|
||||
"space": "my_private",
|
||||
})
|
||||
if w.Code != http.StatusForbidden && w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("fail-closed authorizer=nil want 403/401 got %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"chat-agent-system/internal/auth"
|
||||
"chat-agent-system/internal/authz"
|
||||
"chat-agent-system/internal/bizdev"
|
||||
"chat-agent-system/internal/config"
|
||||
"chat-agent-system/internal/db"
|
||||
)
|
||||
|
||||
func pgConfigFromTestDSN(t *testing.T) (*config.Config, *pgxpool.Pool) {
|
||||
t.Helper()
|
||||
dsn := db.SkipIfNoTestDatabaseURL(t)
|
||||
parsed, err := url.Parse(dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("parse TEST_DATABASE_URL: %v", err)
|
||||
}
|
||||
host, portStr, splitErr := net.SplitHostPort(parsed.Host)
|
||||
if splitErr != nil {
|
||||
host = parsed.Host
|
||||
portStr = "5432"
|
||||
}
|
||||
port, _ := strconv.Atoi(portStr)
|
||||
user := parsed.User.Username()
|
||||
password, _ := parsed.User.Password()
|
||||
dbName := strings.TrimPrefix(parsed.Path, "/")
|
||||
cfg := &config.Config{
|
||||
Env: "test",
|
||||
DataDir: t.TempDir(),
|
||||
AgentBackend: "stub",
|
||||
AllowInMemory: false,
|
||||
ContextDbHost: host,
|
||||
ContextDbPort: port,
|
||||
ContextDbUser: user,
|
||||
ContextDbPassword: password,
|
||||
ContextDbName: dbName,
|
||||
AdminUsername: envOrFF("ADMIN_USERNAME", "admin"),
|
||||
AdminPassword: envOrFF("ADMIN_PASSWORD", "admin-secret-password-123"),
|
||||
}
|
||||
pool, err := pgxpool.New(context.Background(), dsn)
|
||||
if err != nil {
|
||||
t.Skipf("postgres unavailable: %v", err)
|
||||
}
|
||||
if err := pool.Ping(context.Background()); err != nil {
|
||||
pool.Close()
|
||||
t.Skipf("postgres ping failed: %v", err)
|
||||
}
|
||||
return cfg, pool
|
||||
}
|
||||
|
||||
func envOrFF(key, fallback string) string {
|
||||
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// Non-member must receive 403 on POST /api/v1/freeform-documents (PG authorizer).
|
||||
func TestCreateFreeformDoc_NonMemberForbidden_PG(t *testing.T) {
|
||||
cfg, pool := pgConfigFromTestDSN(t)
|
||||
defer pool.Close()
|
||||
|
||||
var projectID int64
|
||||
if err := pool.QueryRow(context.Background(), `SELECT id FROM projects ORDER BY id ASC LIMIT 1`).Scan(&projectID); err != nil || projectID <= 0 {
|
||||
t.Skip("no projects for non-member create test")
|
||||
}
|
||||
|
||||
svc := bizdev.MustNewService(t, cfg)
|
||||
srv := NewServer(cfg, svc)
|
||||
srv.authorizer = authz.NewAuthorizer(pool)
|
||||
|
||||
uname := fmt.Sprintf("outsider_ff_%d", time.Now().UnixNano())
|
||||
pass := "OutsiderPass123!"
|
||||
regBody, _ := json.Marshal(map[string]string{
|
||||
"username": uname,
|
||||
"password": pass,
|
||||
"name": "局外人",
|
||||
})
|
||||
regReq := httptest.NewRequest(http.MethodPost, "/api/v1/auth/register", bytes.NewReader(regBody))
|
||||
regW := httptest.NewRecorder()
|
||||
srv.Routes().ServeHTTP(regW, regReq)
|
||||
if regW.Code != http.StatusOK && regW.Code != http.StatusCreated {
|
||||
t.Skipf("register outsider unavailable: %d %s", regW.Code, regW.Body.String())
|
||||
}
|
||||
|
||||
loginBody, _ := json.Marshal(map[string]string{"username": uname, "password": pass})
|
||||
loginReq := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(loginBody))
|
||||
loginW := httptest.NewRecorder()
|
||||
srv.Routes().ServeHTTP(loginW, loginReq)
|
||||
if loginW.Code != http.StatusOK {
|
||||
t.Fatalf("outsider login failed: %d %s", loginW.Code, loginW.Body.String())
|
||||
}
|
||||
var session auth.SessionInfo
|
||||
if err := json.NewDecoder(loginW.Body).Decode(&session); err != nil || session.Token == "" || session.User == nil {
|
||||
t.Fatalf("decode outsider session: %v body=%s", err, loginW.Body.String())
|
||||
}
|
||||
|
||||
if err := srv.authorizer.RequireProjectAccess(context.Background(), projectID, session.User.ID); err != authz.ErrForbidden {
|
||||
t.Skipf("registered user unexpectedly has access to project %d: %v", projectID, err)
|
||||
}
|
||||
|
||||
createBody, _ := json.Marshal(map[string]any{
|
||||
"project_id": projectID,
|
||||
"title": "should-fail",
|
||||
"content_markdown": "# x\n",
|
||||
"space": "my_private",
|
||||
"client_key": fmt.Sprintf("doc-upload-outsider-%d", time.Now().UnixNano()),
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/freeform-documents", bytes.NewReader(createBody))
|
||||
withBearerAuth(req, session.Token)
|
||||
w := httptest.NewRecorder()
|
||||
srv.Routes().ServeHTTP(w, req)
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("non-member create want 403 got %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"chat-agent-system/internal/authz"
|
||||
"chat-agent-system/internal/freedoc"
|
||||
)
|
||||
|
||||
@@ -33,6 +34,76 @@ func extractDocIDFromPath(r *http.Request, actionSuffix string) string {
|
||||
return r.PathValue("doc_id")
|
||||
}
|
||||
|
||||
// POST /api/v1/freeform-documents — 直接创建(上传附件注册服务端事实源,仅 my_private)
|
||||
func (s *Server) handleCreateFreeformDoc(w http.ResponseWriter, r *http.Request) {
|
||||
if s.svc == nil || s.svc.FreeformDoc() == nil {
|
||||
http.Error(w, "freeform doc service not initialized", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
user, ok := s.requirePrincipal(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req freedoc.CreateFreeformDocRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeJSONResponse(w, http.StatusBadRequest, map[string]any{
|
||||
"error_code": "INVALID_BODY",
|
||||
"message": "请求体格式错误",
|
||||
})
|
||||
return
|
||||
}
|
||||
if req.ProjectID <= 0 {
|
||||
writeJSONResponse(w, http.StatusBadRequest, map[string]any{
|
||||
"error_code": "INVALID_PARAM",
|
||||
"message": "project_id required",
|
||||
})
|
||||
return
|
||||
}
|
||||
space := strings.TrimSpace(req.Space)
|
||||
if space == "" {
|
||||
space = "my_private"
|
||||
}
|
||||
if space != "my_private" {
|
||||
writeJSONResponse(w, http.StatusBadRequest, map[string]any{
|
||||
"error_code": "INVALID_PARAM",
|
||||
"message": "direct create only allows my_private; use share API for project_shared",
|
||||
})
|
||||
return
|
||||
}
|
||||
req.Space = "my_private"
|
||||
|
||||
if s.authorizer == nil {
|
||||
if s.failClosedAuthz() {
|
||||
writeAuthzError(w, authz.ErrForbidden)
|
||||
return
|
||||
}
|
||||
} else if err := s.authorizer.RequireProjectAccessOrAdmin(r.Context(), int64(req.ProjectID), user.ID, s.isSystemAdminUser(r.Context(), user)); err != nil {
|
||||
writeAuthzError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
detail, err := s.svc.FreeformDoc().CreateDocument(r.Context(), int(user.ID), req)
|
||||
if err != nil {
|
||||
if errors.Is(err, freedoc.ErrInvalidParam) {
|
||||
writeJSONResponse(w, http.StatusBadRequest, map[string]any{
|
||||
"error_code": "INVALID_PARAM",
|
||||
"message": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
writeJSONResponse(w, http.StatusInternalServerError, map[string]any{
|
||||
"error_code": "CREATE_FAILED",
|
||||
"message": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
writeJSONResponse(w, http.StatusCreated, map[string]any{
|
||||
"code": 0,
|
||||
"message": "已注册自由文档",
|
||||
"data": detail,
|
||||
})
|
||||
}
|
||||
|
||||
// POST /api/v1/freeform-documents/from-message
|
||||
func (s *Server) handleCreateFreeformDocFromMessage(w http.ResponseWriter, r *http.Request) {
|
||||
if s.svc == nil || s.svc.FreeformDoc() == nil {
|
||||
|
||||
@@ -284,6 +284,7 @@ func (s *Server) Routes() http.Handler {
|
||||
mux.HandleFunc("GET /api/v1/chat/messages/{message_id}/export-source", s.handleGetExportSource)
|
||||
|
||||
// 7.2 自由私密文档与项目共享参考
|
||||
mux.HandleFunc("POST /api/v1/freeform-documents", s.handleCreateFreeformDoc)
|
||||
mux.HandleFunc("POST /api/v1/freeform-documents/from-message", s.handleCreateFreeformDocFromMessage)
|
||||
mux.HandleFunc("GET /api/v1/freeform-documents", s.handleListFreeformDocs)
|
||||
mux.HandleFunc("GET /api/v1/freeform-documents/{doc_id}", s.handleGetFreeformDoc)
|
||||
|
||||
@@ -1,40 +1,896 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { test, expect, type Page, type BrowserContext, type Response } from '@playwright/test';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
/**
|
||||
* Document revision runtime — Ubuntu headless UI acceptance skeleton.
|
||||
* Document revision runtime — real UI acceptance (UI-01..UI-16).
|
||||
*
|
||||
* Hard rules (see `.doc/ops/checklists/2026-09-13-document-revision-runtime-ui-acceptance.md`):
|
||||
* - Real browser against BASE_URL (default http://127.0.0.1:8080)
|
||||
* - NO page.route / request mocking
|
||||
* - NO API/DB fixtures; create project and drive squad via the page only
|
||||
* - Requires RUN_UI_ACCEPTANCE=1 and a live pi-agent stack
|
||||
*
|
||||
* This file is a skeleton: cases are skipped unless RUN_UI_ACCEPTANCE=1 so
|
||||
* normal `npm test` / CI unit gates stay green without Playwright browsers.
|
||||
* Hard rules:
|
||||
* - Real click/fill/upload only. No page.route / API / DB / mock / stub.
|
||||
* - Unmet conditions throw (FAIL). Never fallback PASS.
|
||||
* - Admin UI creates A/B accounts; no seed guest/123456.
|
||||
* - UI_PHASE=post-restart: only restore checks (host docker restart between runs).
|
||||
*/
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const runAcceptance = process.env.RUN_UI_ACCEPTANCE === '1';
|
||||
const uiPhase = (process.env.UI_PHASE || 'main').trim(); // main | post-restart
|
||||
|
||||
test.describe('document-revision-runtime UI acceptance', () => {
|
||||
test.skip(!runAcceptance, 'set RUN_UI_ACCEPTANCE=1 with real env-service.dev + pi-agent');
|
||||
const ASSETS = path.resolve(
|
||||
__dirname,
|
||||
'../../../.doc/test-reports/assets/2026-09-13-document-revision-runtime-ui',
|
||||
);
|
||||
const SCREENSHOTS = path.join(ASSETS, 'screenshots');
|
||||
const TRACES = path.join(ASSETS, 'traces');
|
||||
const VIDEOS = path.join(ASSETS, 'videos');
|
||||
const RESULTS_FILE = path.join(ASSETS, 'ui-acceptance-results.json');
|
||||
const PHASE_STATE_FILE = path.join(ASSETS, 'phase-state.json');
|
||||
/** Local-only secrets for post-restart login — never copied into results JSON. */
|
||||
const PHASE_SECRETS_FILE = path.join(ASSETS, 'phase-secrets.local.json');
|
||||
|
||||
test('UI-01 login and open/create project workbench', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
// Page-visible workbench chrome (selectors refined during real acceptance run).
|
||||
await expect(page.locator('body')).toBeVisible();
|
||||
// TODO(acceptance): fill login → create/enter project → assert three-pane workbench.
|
||||
for (const d of [SCREENSHOTS, TRACES, VIDEOS]) {
|
||||
fs.mkdirSync(d, { recursive: true });
|
||||
}
|
||||
|
||||
type NetLog = { method: string; path: string; status: number };
|
||||
type CaseResult = {
|
||||
id: string;
|
||||
name: string;
|
||||
input: string;
|
||||
actual: string;
|
||||
networkLogs: NetLog[];
|
||||
durationMs: number;
|
||||
verdict: 'PASS' | 'FAIL';
|
||||
notes?: string;
|
||||
};
|
||||
|
||||
const recorded: Record<string, CaseResult> = {};
|
||||
|
||||
function fail(msg: string): never {
|
||||
throw new Error(msg);
|
||||
}
|
||||
|
||||
/** Strip credentials and query strings from evidence URLs. */
|
||||
function sanitizeUrl(raw: string): string {
|
||||
try {
|
||||
const u = new URL(raw, 'http://127.0.0.1');
|
||||
return u.pathname;
|
||||
} catch {
|
||||
return raw.split('?')[0]?.replace(/https?:\/\/[^/]+/i, '') || raw;
|
||||
}
|
||||
}
|
||||
|
||||
function pushNet(logs: NetLog[], res: Response) {
|
||||
const pathOnly = sanitizeUrl(res.url());
|
||||
if (!pathOnly.includes('/api/')) return;
|
||||
logs.push({ method: res.request().method(), path: pathOnly, status: res.status() });
|
||||
}
|
||||
|
||||
function saveResults() {
|
||||
const scrubbed = JSON.parse(JSON.stringify(recorded)) as Record<string, CaseResult>;
|
||||
const blob = JSON.stringify(scrubbed, null, 2);
|
||||
if (/admin-secret|Bearer\s|password|token=/i.test(blob)) {
|
||||
fail('results must not contain credentials or tokens');
|
||||
}
|
||||
fs.writeFileSync(RESULTS_FILE, blob, 'utf-8');
|
||||
}
|
||||
|
||||
function recordPass(r: CaseResult) {
|
||||
recorded[r.id] = { ...r, verdict: 'PASS' };
|
||||
saveResults();
|
||||
}
|
||||
|
||||
async function shot(page: Page, name: string) {
|
||||
await page.screenshot({ path: path.join(SCREENSHOTS, name), fullPage: true });
|
||||
}
|
||||
|
||||
type PhaseState = {
|
||||
projectName: string;
|
||||
projectIdHint?: string;
|
||||
controlledDocTitle?: string;
|
||||
controlledDocId?: string;
|
||||
versionText?: string;
|
||||
revisionText?: string;
|
||||
bodySnippet?: string;
|
||||
userA: string;
|
||||
userB: string;
|
||||
userC: string;
|
||||
};
|
||||
|
||||
type PhaseSecrets = {
|
||||
passA: string;
|
||||
passB: string;
|
||||
passC: string;
|
||||
};
|
||||
|
||||
function writePhaseState(s: PhaseState) {
|
||||
fs.writeFileSync(PHASE_STATE_FILE, JSON.stringify(s, null, 2), 'utf-8');
|
||||
}
|
||||
|
||||
function writePhaseSecrets(s: PhaseSecrets) {
|
||||
fs.writeFileSync(PHASE_SECRETS_FILE, JSON.stringify(s, null, 2), 'utf-8');
|
||||
}
|
||||
|
||||
function readPhaseState(): PhaseState {
|
||||
if (!fs.existsSync(PHASE_STATE_FILE)) {
|
||||
fail(`missing phase-state.json for UI_PHASE=${uiPhase}; run main phase first`);
|
||||
}
|
||||
return JSON.parse(fs.readFileSync(PHASE_STATE_FILE, 'utf-8')) as PhaseState;
|
||||
}
|
||||
|
||||
function readPhaseSecrets(): PhaseSecrets {
|
||||
if (process.env.UI_E2E_PASS_A) {
|
||||
return {
|
||||
passA: process.env.UI_E2E_PASS_A,
|
||||
passB: process.env.UI_E2E_PASS_B || '',
|
||||
passC: process.env.UI_E2E_PASS_C || '',
|
||||
};
|
||||
}
|
||||
if (!fs.existsSync(PHASE_SECRETS_FILE)) {
|
||||
fail('missing phase-secrets.local.json (or UI_E2E_PASS_A) for post-restart login');
|
||||
}
|
||||
return JSON.parse(fs.readFileSync(PHASE_SECRETS_FILE, 'utf-8')) as PhaseSecrets;
|
||||
}
|
||||
|
||||
const ADMIN_USER = process.env.ADMIN_USERNAME?.trim() || '';
|
||||
const ADMIN_PASS = process.env.ADMIN_PASSWORD?.trim() || '';
|
||||
|
||||
/** Admin creds must come from env — never hardcode or fallback secrets into the repo/runtime. */
|
||||
function requireAdminCreds(): { username: string; password: string } {
|
||||
if (!ADMIN_USER || !ADMIN_PASS) {
|
||||
fail(
|
||||
'ADMIN_USERNAME and ADMIN_PASSWORD environment variables are required for UI acceptance (no credential fallbacks)',
|
||||
);
|
||||
}
|
||||
return { username: ADMIN_USER, password: ADMIN_PASS };
|
||||
}
|
||||
const suffix = `${Date.now().toString(36)}`;
|
||||
const USER_A = `uia_${suffix}`;
|
||||
const USER_B = `uib_${suffix}`;
|
||||
const USER_C = `uic_${suffix}`;
|
||||
const INIT_PASS = `Init-${suffix}9`;
|
||||
const FINAL_PASS_A = `PassA-${suffix}9`;
|
||||
const FINAL_PASS_B = `PassB-${suffix}9`;
|
||||
const FINAL_PASS_C = `PassC-${suffix}9`;
|
||||
const PROJECT_NAME = `UI验收-超声微创系统-${suffix}`;
|
||||
|
||||
async function login(page: Page, username: string, password: string) {
|
||||
await page.goto('/');
|
||||
await page.fill('input[placeholder*="系统用户名"]', username);
|
||||
await page.fill('input[placeholder*="登录密码"]', password);
|
||||
await page.click('button:has-text("进入医疗协同工作台")');
|
||||
// First-login force change may appear for admin-created users.
|
||||
const force = page.getByPlaceholder('请输入初始密码');
|
||||
if (await force.isVisible({ timeout: 2500 }).catch(() => false)) {
|
||||
fail(`account ${username} still requires first-login password change — call changePasswordFirstLogin`);
|
||||
}
|
||||
await expect(page.getByText('新建立项会话')).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
async function changePasswordFirstLogin(page: Page, oldPass: string, newPass: string) {
|
||||
await expect(page.getByPlaceholder('请输入初始密码')).toBeVisible({ timeout: 15_000 });
|
||||
await page.fill('input[placeholder="请输入初始密码"]', oldPass);
|
||||
await page.fill('input[placeholder*="至少 6 位"]', newPass);
|
||||
await page.fill('input[placeholder="再次输入新密码"]', newPass);
|
||||
await page.getByRole('button', { name: /确认重置并进入系统/ }).click();
|
||||
await expect(page.getByText('新建立项会话')).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
async function logoutViaUI(page: Page) {
|
||||
await page.locator('button[title="个人资料与系统偏好设置"]').click({ timeout: 15_000 });
|
||||
const logout = page.getByRole('button', { name: '退出当前登录' });
|
||||
await expect(logout).toBeVisible({ timeout: 10_000 });
|
||||
await logout.click();
|
||||
await expect(page.getByPlaceholder(/系统用户名/)).toBeVisible({ timeout: 20_000 });
|
||||
}
|
||||
|
||||
async function openAdminConsole(page: Page) {
|
||||
await page.locator('button[title*="系统管理与准入治理"]').click();
|
||||
await expect(page.getByText('开通受控员工账号')).toBeVisible({ timeout: 15_000 });
|
||||
}
|
||||
|
||||
async function adminCreateUser(page: Page, username: string, realName: string, initPass: string) {
|
||||
await page.getByText('开通受控员工账号').click();
|
||||
const form = page.locator('form').filter({ hasText: '用户名 (系统账号)' });
|
||||
await expect(form).toBeVisible({ timeout: 10_000 });
|
||||
await form.locator('input[placeholder="如 li_si"]').fill(username);
|
||||
await form.locator('input[placeholder="如 李四"]').fill(realName);
|
||||
// 临时初始密码:label sibling input
|
||||
const passBox = form.locator('xpath=.//label[contains(.,"临时初始密码")]/following::input[1]');
|
||||
if ((await passBox.count()) === 0) fail('admin create-user missing initial password field');
|
||||
await passBox.fill(initPass);
|
||||
await form.getByRole('button', { name: '确认开通账号' }).click();
|
||||
await expect(page.getByText(new RegExp(`已成功开通|${realName}`))).toBeVisible({ timeout: 15_000 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Squad wait: MUST observe start (user bubble + running/thinking) before terminal.
|
||||
* failed status → FAIL.
|
||||
*/
|
||||
async function waitForSquadCompletion(page: Page, userPromptSnippet: string, timeoutMs = 420_000) {
|
||||
const startedAt = Date.now();
|
||||
const userBubble = page.locator('div, article, li').filter({ hasText: userPromptSnippet }).first();
|
||||
await expect(userBubble).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
const running = page
|
||||
.locator('text=正在思考')
|
||||
.or(page.locator('text=生成中'))
|
||||
.or(page.locator('.agent-pulse-active'))
|
||||
.or(page.locator('text=执行中'))
|
||||
.or(page.locator('text=编排中'));
|
||||
await expect(running.first()).toBeVisible({ timeout: 60_000 });
|
||||
|
||||
const terminal = page
|
||||
.locator('text=已完成')
|
||||
.or(page.locator('text=待编制人确认'))
|
||||
.or(page.locator('text=失败'))
|
||||
.or(page.locator('[data-status="completed"]'))
|
||||
.or(page.locator('[data-status="pending_author_confirmation"]'))
|
||||
.or(page.locator('[data-status="failed"]'));
|
||||
|
||||
await expect(terminal.first()).toBeVisible({ timeout: timeoutMs });
|
||||
|
||||
if (await page.locator('text=失败').or(page.locator('[data-status="failed"]')).first().isVisible().catch(() => false)) {
|
||||
fail('squad terminal status is failed');
|
||||
}
|
||||
// Ensure running indicator has cleared.
|
||||
await expect(page.locator('text=正在思考,请稍候...')).toBeHidden({ timeout: 60_000 }).catch(() => undefined);
|
||||
return Date.now() - startedAt;
|
||||
}
|
||||
|
||||
async function sendChat(page: Page, text: string) {
|
||||
const box = page.getByPlaceholder(/向项目助手提问/);
|
||||
await expect(box).toBeVisible({ timeout: 15_000 });
|
||||
await box.fill(text);
|
||||
await page.getByRole('button', { name: /发送/ }).click();
|
||||
}
|
||||
|
||||
/** Assert zero revision-state 404 and zero requests for template doc-p* slots. */
|
||||
function assertRevisionStateCleanForNewProject(logs: NetLog[]) {
|
||||
const hits = logs.filter((l) => l.path.includes('/revision-state'));
|
||||
const bad404 = hits.filter((l) => l.status === 404);
|
||||
if (bad404.length) {
|
||||
fail(`revision-state 404 is FAIL: ${JSON.stringify(bad404)}`);
|
||||
}
|
||||
// Match only hyphen template IDs (doc-p123-intake), NOT doc_proj* (underscore).
|
||||
const docPTemplateHits = hits.filter((l) => /\/documents\/doc-p\d+-[^/]+\/revision-state/.test(l.path));
|
||||
if (docPTemplateHits.length !== 0) {
|
||||
fail(`new project template must issue 0 revision-state for doc-p*: got ${JSON.stringify(docPTemplateHits)}`);
|
||||
}
|
||||
const legacyHits = hits.filter((l) => /\/documents\/doc-\d{3}\/revision-state/.test(l.path));
|
||||
if (legacyHits.length !== 0) {
|
||||
fail(`legacy local mock must not request revision-state: ${JSON.stringify(legacyHits)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function readVersionBadge(page: Page): Promise<{ v: string; r: string }> {
|
||||
const v = (await page.locator('[data-testid="doc-rev-version"]').textContent())?.trim();
|
||||
const r = (await page.locator('[data-testid="doc-rev-revision"]').textContent())?.trim();
|
||||
if (!v || !r) fail('version/revision badge missing — document must be server-backed with ledger');
|
||||
return { v, r };
|
||||
}
|
||||
|
||||
async function captureOverlayFingerprint(page: Page, mode: 'pure' | 'manual' | 'all') {
|
||||
await page.locator('select[aria-label="变更显示模式"]').selectOption(mode);
|
||||
await page.waitForTimeout(400);
|
||||
const marked = page.locator('[data-overlay-kind]');
|
||||
const target =
|
||||
mode === 'pure'
|
||||
? page.locator('[data-overlay-kind], [class*="block"]').first()
|
||||
: marked.first();
|
||||
if ((await target.count()) === 0) {
|
||||
fail(`UI overlay mode=${mode}: no block to sample computed style`);
|
||||
}
|
||||
return target.evaluate((el) => {
|
||||
const s = window.getComputedStyle(el as HTMLElement);
|
||||
return {
|
||||
backgroundColor: s.backgroundColor,
|
||||
boxShadow: s.boxShadow,
|
||||
className: (el as HTMLElement).className || '',
|
||||
overlayKind: (el as HTMLElement).getAttribute('data-overlay-kind') || '',
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
test('UI-05a/b high-risk pending confirm/reject via toolbar', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await expect(page.locator('body')).toBeVisible();
|
||||
// TODO(acceptance): after real squad high-risk pending —
|
||||
// assert 「待确认报告」 visible; confirm advances version; reject keeps head.
|
||||
});
|
||||
// ─── post-restart phase (host restarted docker between runs) ───
|
||||
test.describe('document-revision-runtime UI @post-restart', () => {
|
||||
test.skip(!runAcceptance || uiPhase !== 'post-restart', 'UI_PHASE=post-restart only');
|
||||
|
||||
test('UI-06 save increments revision then survives hard refresh', async ({ page }) => {
|
||||
test('UI-15 restore after container restart (page login only)', async ({ page }) => {
|
||||
const t0 = Date.now();
|
||||
const logs: NetLog[] = [];
|
||||
page.on('response', (res) => pushNet(logs, res));
|
||||
const st = readPhaseState();
|
||||
const secrets = readPhaseSecrets();
|
||||
await login(page, st.userA, secrets.passA);
|
||||
// Locate project by visible name.
|
||||
await page.getByText(st.projectName, { exact: false }).first().click();
|
||||
await expect(page.getByText(st.projectName)).toBeVisible({ timeout: 20_000 });
|
||||
if (st.controlledDocTitle) {
|
||||
await page.getByText(st.controlledDocTitle, { exact: false }).first().click();
|
||||
}
|
||||
await expect(page.locator('[data-testid="doc-rev-version"]')).toBeVisible({ timeout: 30_000 });
|
||||
const { v, r } = await readVersionBadge(page);
|
||||
if (st.versionText && v !== st.versionText) fail(`version mismatch after restart: ${v} vs ${st.versionText}`);
|
||||
if (st.revisionText && r !== st.revisionText) fail(`revision mismatch after restart: ${r} vs ${st.revisionText}`);
|
||||
if (st.bodySnippet) {
|
||||
await expect(page.getByText(st.bodySnippet, { exact: false })).toBeVisible({ timeout: 15_000 });
|
||||
}
|
||||
await shot(page, 'UI-15-post-restart.png');
|
||||
recordPass({
|
||||
id: 'UI-15',
|
||||
name: 'post-restart restore',
|
||||
input: `UI login as A; open ${st.projectName}`,
|
||||
actual: `version=${v} revision=${r}`,
|
||||
networkLogs: logs,
|
||||
durationMs: Date.now() - t0,
|
||||
verdict: 'PASS',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── main phase ───
|
||||
test.describe.serial('document-revision-runtime UI acceptance', () => {
|
||||
test.skip(!runAcceptance || uiPhase === 'post-restart', 'set RUN_UI_ACCEPTANCE=1 UI_PHASE=main');
|
||||
|
||||
let context: BrowserContext;
|
||||
let page: Page;
|
||||
let logs: NetLog[] = [];
|
||||
let controlledTitle = '';
|
||||
let versionBeforePending = '';
|
||||
let secondDocTitle = '';
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
context = await browser.newContext();
|
||||
page = await context.newPage();
|
||||
page.on('response', (res) => pushNet(logs, res));
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
saveResults();
|
||||
await context?.close();
|
||||
});
|
||||
|
||||
test('UI-00 bootstrap: admin creates A/B/C via page', async () => {
|
||||
const t0 = Date.now();
|
||||
logs = [];
|
||||
const admin = requireAdminCreds();
|
||||
await page.goto('/');
|
||||
await expect(page.locator('body')).toBeVisible();
|
||||
// TODO(acceptance): edit block → save → r increments → reload → body persists.
|
||||
await page.fill('input[placeholder*="系统用户名"]', admin.username);
|
||||
await page.fill('input[placeholder*="登录密码"]', admin.password);
|
||||
await page.click('button:has-text("进入医疗协同工作台")');
|
||||
await expect(page.getByText('新建立项会话')).toBeVisible({ timeout: 30_000 });
|
||||
await openAdminConsole(page);
|
||||
await adminCreateUser(page, USER_A, `编制人A-${suffix}`, INIT_PASS);
|
||||
await adminCreateUser(page, USER_B, `协作者B-${suffix}`, INIT_PASS);
|
||||
await adminCreateUser(page, USER_C, `无关人C-${suffix}`, INIT_PASS);
|
||||
// Close admin modal
|
||||
await page.keyboard.press('Escape');
|
||||
await logoutViaUI(page);
|
||||
|
||||
// A first login + password change
|
||||
await page.fill('input[placeholder*="系统用户名"]', USER_A);
|
||||
await page.fill('input[placeholder*="登录密码"]', INIT_PASS);
|
||||
await page.click('button:has-text("进入医疗协同工作台")');
|
||||
await changePasswordFirstLogin(page, INIT_PASS, FINAL_PASS_A);
|
||||
await logoutViaUI(page);
|
||||
|
||||
await page.fill('input[placeholder*="系统用户名"]', USER_B);
|
||||
await page.fill('input[placeholder*="登录密码"]', INIT_PASS);
|
||||
await page.click('button:has-text("进入医疗协同工作台")');
|
||||
await changePasswordFirstLogin(page, INIT_PASS, FINAL_PASS_B);
|
||||
await logoutViaUI(page);
|
||||
|
||||
await page.fill('input[placeholder*="系统用户名"]', USER_C);
|
||||
await page.fill('input[placeholder*="登录密码"]', INIT_PASS);
|
||||
await page.click('button:has-text("进入医疗协同工作台")');
|
||||
await changePasswordFirstLogin(page, INIT_PASS, FINAL_PASS_C);
|
||||
await logoutViaUI(page);
|
||||
|
||||
writePhaseSecrets({ passA: FINAL_PASS_A, passB: FINAL_PASS_B, passC: FINAL_PASS_C });
|
||||
writePhaseState({
|
||||
projectName: PROJECT_NAME,
|
||||
userA: USER_A,
|
||||
userB: USER_B,
|
||||
userC: USER_C,
|
||||
});
|
||||
|
||||
recordPass({
|
||||
id: 'UI-00',
|
||||
name: 'admin creates A/B/C',
|
||||
input: 'admin UI create users + first-login password change',
|
||||
actual: `users ${USER_A},${USER_B},${USER_C} ready`,
|
||||
networkLogs: logs.filter((l) => !/password/i.test(l.path)),
|
||||
durationMs: Date.now() - t0,
|
||||
verdict: 'PASS',
|
||||
});
|
||||
});
|
||||
|
||||
test('UI-01 login A → page create project → template hint, no revision-state for doc-p', async () => {
|
||||
const t0 = Date.now();
|
||||
await login(page, USER_A, FINAL_PASS_A);
|
||||
// Isolate network evidence to project-create window (exclude login-era noise).
|
||||
logs = [];
|
||||
await page.getByRole('button', { name: '新建立项会话' }).click();
|
||||
await expect(page.getByText('发起医疗器械新项目立项')).toBeVisible();
|
||||
await page.fill('input[placeholder*="无线便携式"]', PROJECT_NAME);
|
||||
const desc = page.locator('textarea').first();
|
||||
if ((await desc.count()) === 0) fail('project form missing required description textarea');
|
||||
await desc.fill('用于骨科微创手术的双频超声吸引与止血,需符合 GB 9706.1 与相关法规要求。');
|
||||
// Select B as member
|
||||
const memberRow = page.locator('div').filter({ hasText: `协作者B-${suffix}` }).first();
|
||||
if ((await memberRow.count()) === 0) fail('member B not listed in create-project form');
|
||||
await memberRow.click();
|
||||
await page.getByRole('button', { name: /确认立项/ }).click();
|
||||
await expect(page.getByText(PROJECT_NAME)).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.locator('aside')).toBeVisible();
|
||||
// Template doc selected: must show non-error hint, never 账本未就绪
|
||||
await expect(page.getByTestId('doc-rev-template-hint')).toBeVisible({ timeout: 15_000 });
|
||||
await expect(page.getByTestId('doc-rev-template-hint')).toContainText('模板预览');
|
||||
await expect(page.locator('text=版本账本未就绪')).toHaveCount(0);
|
||||
await shot(page, '01-project.png');
|
||||
|
||||
assertRevisionStateCleanForNewProject(logs);
|
||||
|
||||
writePhaseState({
|
||||
projectName: PROJECT_NAME,
|
||||
userA: USER_A,
|
||||
userB: USER_B,
|
||||
userC: USER_C,
|
||||
});
|
||||
|
||||
recordPass({
|
||||
id: 'UI-01',
|
||||
name: 'create project workbench',
|
||||
input: `login A; create project ${PROJECT_NAME}; assign B`,
|
||||
actual: 'template hint visible; doc-p revision-state count=0; no revision-state 404',
|
||||
networkLogs: logs,
|
||||
durationMs: Date.now() - t0,
|
||||
verdict: 'PASS',
|
||||
});
|
||||
});
|
||||
|
||||
test('UI-02 send real squad task', async () => {
|
||||
const t0 = Date.now();
|
||||
logs = [];
|
||||
const prompt =
|
||||
'请基于本项目编制首轮《用户需求》摸底文档,覆盖预期用途、关键性能与适用法规框架,直接生成受控交付物。';
|
||||
await sendChat(page, prompt);
|
||||
await expect(page.getByText(prompt.slice(0, 12))).toBeVisible({ timeout: 20_000 });
|
||||
const running = page.locator('text=正在思考').or(page.locator('text=生成中'));
|
||||
await expect(running.first()).toBeVisible({ timeout: 60_000 });
|
||||
await shot(page, 'UI-02-squad-started.png');
|
||||
recordPass({
|
||||
id: 'UI-02',
|
||||
name: 'start real squad',
|
||||
input: prompt,
|
||||
actual: 'running/thinking visible after user bubble',
|
||||
networkLogs: logs,
|
||||
durationMs: Date.now() - t0,
|
||||
verdict: 'PASS',
|
||||
});
|
||||
});
|
||||
|
||||
test('UI-03 wait first round; open controlled doc with V/r', async () => {
|
||||
const t0 = Date.now();
|
||||
const promptSlice = '请基于本项目编制首轮';
|
||||
await waitForSquadCompletion(page, promptSlice);
|
||||
// Open a workflow doc tab that is no longer template-only (server doc_proj*).
|
||||
const tab = page.locator('[role="tab"], button, div').filter({ hasText: /需求|摸底|URS|用户/ }).first();
|
||||
if ((await tab.count()) === 0) fail('no controlled deliverable tab after first squad round');
|
||||
await tab.click();
|
||||
await expect(page.locator('[data-testid="doc-rev-version"]')).toBeVisible({ timeout: 60_000 });
|
||||
await expect(page.getByTestId('doc-rev-template-hint')).toHaveCount(0);
|
||||
await expect(page.locator('text=版本账本未就绪')).toHaveCount(0);
|
||||
const { v, r } = await readVersionBadge(page);
|
||||
controlledTitle = (await tab.textContent())?.trim() || 'controlled';
|
||||
await shot(page, '03-first-doc.png');
|
||||
const st = readPhaseState();
|
||||
writePhaseState({
|
||||
...st,
|
||||
controlledDocTitle: controlledTitle,
|
||||
versionText: v,
|
||||
revisionText: r,
|
||||
});
|
||||
recordPass({
|
||||
id: 'UI-03',
|
||||
name: 'first controlled doc ledger',
|
||||
input: 'wait squad; open deliverable tab',
|
||||
actual: `toolbar ${v}/${r}`,
|
||||
networkLogs: logs,
|
||||
durationMs: Date.now() - t0,
|
||||
verdict: 'PASS',
|
||||
});
|
||||
});
|
||||
|
||||
test('UI-04 open change report with full fields', async () => {
|
||||
const t0 = Date.now();
|
||||
logs = [];
|
||||
const reportBtn = page.getByRole('button', { name: /变更报告|待确认报告/ });
|
||||
await expect(reportBtn).toBeEnabled({ timeout: 15_000 });
|
||||
await reportBtn.click();
|
||||
const panel = page.locator('text=风险').or(page.locator('text=开放问题')).or(page.locator('text=项目影响'));
|
||||
await expect(panel.first()).toBeVisible({ timeout: 20_000 });
|
||||
// Report list / body must be non-empty
|
||||
const bodyText = await page.locator('body').innerText();
|
||||
if (!/风险|开放问题|摘要|影响/.test(bodyText)) fail('change report panel missing required fields');
|
||||
await shot(page, '04-first-report.png');
|
||||
// Close if possible
|
||||
await page.keyboard.press('Escape').catch(() => undefined);
|
||||
recordPass({
|
||||
id: 'UI-04',
|
||||
name: 'change report fields',
|
||||
input: 'click 变更报告',
|
||||
actual: 'risk/impact/open questions visible',
|
||||
networkLogs: logs,
|
||||
durationMs: Date.now() - t0,
|
||||
verdict: 'PASS',
|
||||
});
|
||||
});
|
||||
|
||||
test('UI-05 high-risk candidate #1 pending (version must not advance)', async () => {
|
||||
const t0 = Date.now();
|
||||
logs = [];
|
||||
const { v: v0 } = await readVersionBadge(page);
|
||||
versionBeforePending = v0;
|
||||
const prompt =
|
||||
'请按 YY/T、GB 9706.1 与 NMPA 注册法规要求,大幅改写当前受控文档中的风险管理与电气安全章节,引用具体条款并重写适用性结论(二轮高风险候选-确认用例)。';
|
||||
await sendChat(page, prompt);
|
||||
await waitForSquadCompletion(page, '二轮高风险候选-确认用例');
|
||||
await expect(page.getByTestId('doc-rev-pending-badge')).toBeVisible({ timeout: 60_000 });
|
||||
await expect(page.getByRole('button', { name: '待确认报告' })).toBeVisible();
|
||||
const { v: vPending } = await readVersionBadge(page);
|
||||
if (vPending !== v0) fail(`pending must not advance version: was ${v0} now ${vPending}`);
|
||||
await shot(page, '05-second-report.png');
|
||||
recordPass({
|
||||
id: 'UI-05',
|
||||
name: 'high-risk pending #1',
|
||||
input: prompt,
|
||||
actual: `pending badge; version stayed ${vPending}`,
|
||||
networkLogs: logs,
|
||||
durationMs: Date.now() - t0,
|
||||
verdict: 'PASS',
|
||||
});
|
||||
});
|
||||
|
||||
test('UI-05a confirm high-risk candidate → version advances', async () => {
|
||||
const t0 = Date.now();
|
||||
logs = [];
|
||||
const confirm = page.getByRole('button', { name: /确认提交/ });
|
||||
if ((await confirm.count()) === 0) fail('confirm button missing for pending high-risk candidate');
|
||||
const decisionWait = page.waitForResponse(
|
||||
(r) => r.url().includes('/decision') && r.request().method() === 'POST',
|
||||
{ timeout: 60_000 },
|
||||
);
|
||||
await confirm.click();
|
||||
const decRes = await decisionWait;
|
||||
if (decRes.status() >= 400) fail(`decision confirm HTTP ${decRes.status()}`);
|
||||
const req = decRes.request().postDataJSON() as { confirmed?: boolean; decision?: string };
|
||||
if (req.decision !== 'commit' || req.confirmed !== true) {
|
||||
fail(`decision body must be commit+confirmed: ${JSON.stringify(req)}`);
|
||||
}
|
||||
await expect(page.getByTestId('doc-rev-pending-badge')).toHaveCount(0, { timeout: 30_000 });
|
||||
const { v } = await readVersionBadge(page);
|
||||
if (v === versionBeforePending) fail(`version must advance after confirm: still ${v}`);
|
||||
await shot(page, 'UI-05a-confirmed.png');
|
||||
recordPass({
|
||||
id: 'UI-05a',
|
||||
name: 'confirm pending',
|
||||
input: 'click 确认提交',
|
||||
actual: `decision ok; version ${v}`,
|
||||
networkLogs: logs,
|
||||
durationMs: Date.now() - t0,
|
||||
verdict: 'PASS',
|
||||
});
|
||||
});
|
||||
|
||||
test('UI-05b second high-risk candidate → reject; version stays', async () => {
|
||||
const t0 = Date.now();
|
||||
logs = [];
|
||||
const { v: vBefore } = await readVersionBadge(page);
|
||||
const prompt =
|
||||
'请再次按医疗器械临床试验与注册检验法规,重写电磁兼容与软件生存周期章节并给出条款级符合性判断(二轮高风险候选-拒绝用例)。';
|
||||
await sendChat(page, prompt);
|
||||
await waitForSquadCompletion(page, '二轮高风险候选-拒绝用例');
|
||||
await expect(page.getByTestId('doc-rev-pending-badge')).toBeVisible({ timeout: 60_000 });
|
||||
const { v: vPend } = await readVersionBadge(page);
|
||||
if (vPend !== vBefore) fail(`second pending must not advance version (${vBefore}→${vPend})`);
|
||||
const reject = page.getByRole('button', { name: /拒绝/ });
|
||||
if ((await reject.count()) === 0) fail('reject button missing');
|
||||
const decisionWait = page.waitForResponse(
|
||||
(r) => r.url().includes('/decision') && r.request().method() === 'POST',
|
||||
{ timeout: 60_000 },
|
||||
);
|
||||
await reject.click();
|
||||
const decRes = await decisionWait;
|
||||
if (decRes.status() >= 400) fail(`decision reject HTTP ${decRes.status()}`);
|
||||
const req = decRes.request().postDataJSON() as { decision?: string };
|
||||
if (req.decision !== 'reject') fail(`expected reject decision, got ${JSON.stringify(req)}`);
|
||||
await expect(page.getByTestId('doc-rev-pending-badge')).toHaveCount(0, { timeout: 30_000 });
|
||||
const { v: vAfter } = await readVersionBadge(page);
|
||||
if (vAfter !== vBefore) fail(`reject must not advance version (${vBefore}→${vAfter})`);
|
||||
await shot(page, 'UI-05b-rejected.png');
|
||||
recordPass({
|
||||
id: 'UI-05b',
|
||||
name: 'reject pending',
|
||||
input: prompt,
|
||||
actual: `rejected; version stayed ${vAfter}`,
|
||||
networkLogs: logs,
|
||||
durationMs: Date.now() - t0,
|
||||
verdict: 'PASS',
|
||||
});
|
||||
});
|
||||
|
||||
test('UI-06 edit block → save → hard refresh persists r bump', async () => {
|
||||
const t0 = Date.now();
|
||||
logs = [];
|
||||
const { r: r0 } = await readVersionBadge(page);
|
||||
const block = page.locator('[data-overlay-kind], p, h1, h2').first();
|
||||
await block.dblclick({ timeout: 15_000 });
|
||||
const editor = page.locator('textarea, [contenteditable="true"]').last();
|
||||
await expect(editor).toBeVisible({ timeout: 10_000 });
|
||||
const marker = `UI06-${suffix}`;
|
||||
await editor.fill(`人工修订段落 ${marker} 用于硬刷新验收。`);
|
||||
await page.getByRole('button', { name: /保存/ }).click();
|
||||
await expect(page.locator('[data-testid="doc-rev-revision"]')).not.toHaveText(r0, { timeout: 30_000 });
|
||||
const { r: r1 } = await readVersionBadge(page);
|
||||
await page.reload();
|
||||
await expect(page.getByText(marker)).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.locator('[data-testid="doc-rev-revision"]')).toContainText(r1.replace(/^r/, ''), {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await shot(page, 'UI-06-after-refresh.png');
|
||||
const st = readPhaseState();
|
||||
writePhaseState({ ...st, bodySnippet: marker, revisionText: r1 });
|
||||
recordPass({
|
||||
id: 'UI-06',
|
||||
name: 'save + hard refresh',
|
||||
input: 'dblclick edit save reload',
|
||||
actual: `r ${r0}→${r1}; marker persisted`,
|
||||
networkLogs: logs,
|
||||
durationMs: Date.now() - t0,
|
||||
verdict: 'PASS',
|
||||
});
|
||||
});
|
||||
|
||||
test('UI-07/08/09 overlay modes differ in computed styles', async () => {
|
||||
const t0 = Date.now();
|
||||
logs = [];
|
||||
const pure = await captureOverlayFingerprint(page, 'pure');
|
||||
await shot(page, 'UI-07-pure.png');
|
||||
const manual = await captureOverlayFingerprint(page, 'manual');
|
||||
await shot(page, 'UI-08-manual.png');
|
||||
const all = await captureOverlayFingerprint(page, 'all');
|
||||
await shot(page, 'UI-09-all.png');
|
||||
const key = (x: { backgroundColor: string; boxShadow: string; className: string }) =>
|
||||
`${x.backgroundColor}|${x.boxShadow}|${x.className}`;
|
||||
if (key(pure) === key(manual) && key(manual) === key(all)) {
|
||||
fail(`overlay modes not visually distinct: pure=${JSON.stringify(pure)} manual=${JSON.stringify(manual)} all=${JSON.stringify(all)}`);
|
||||
}
|
||||
if (key(pure) === key(manual) || key(pure) === key(all) || key(manual) === key(all)) {
|
||||
// Require pairwise difference across the three captures.
|
||||
fail(`each overlay mode must differ: pure=${key(pure)} manual=${key(manual)} all=${key(all)}`);
|
||||
}
|
||||
recordPass({
|
||||
id: 'UI-07',
|
||||
name: 'overlay pure/manual/all',
|
||||
input: 'switch three overlay modes; sample computed style',
|
||||
actual: `pure≠manual≠all`,
|
||||
networkLogs: logs,
|
||||
durationMs: Date.now() - t0,
|
||||
verdict: 'PASS',
|
||||
notes: JSON.stringify({ pure, manual, all }),
|
||||
});
|
||||
recorded['UI-08'] = { ...recorded['UI-07'], id: 'UI-08', name: 'overlay manual' };
|
||||
recorded['UI-09'] = { ...recorded['UI-07'], id: 'UI-09', name: 'overlay all' };
|
||||
saveResults();
|
||||
});
|
||||
|
||||
test('UI-10 version history + compare', async () => {
|
||||
const t0 = Date.now();
|
||||
logs = [];
|
||||
await page.getByRole('button', { name: '版本历史' }).click();
|
||||
const list = page.locator('text=/V\\d+/');
|
||||
await expect(list.first()).toBeVisible({ timeout: 15_000 });
|
||||
const n = await list.count();
|
||||
if (n < 1) fail('version history list empty');
|
||||
await page.getByRole('button', { name: '比较' }).click();
|
||||
await shot(page, 'UI-10-history-compare.png');
|
||||
recordPass({
|
||||
id: 'UI-10',
|
||||
name: 'history + compare',
|
||||
input: 'open 版本历史 / 比较',
|
||||
actual: `history entries=${n}`,
|
||||
networkLogs: logs,
|
||||
durationMs: Date.now() - t0,
|
||||
verdict: 'PASS',
|
||||
});
|
||||
});
|
||||
|
||||
test('UI-11 chat second controlled doc + page upload attachment', async () => {
|
||||
const t0 = Date.now();
|
||||
logs = [];
|
||||
const prompt =
|
||||
'请另行生成一份《系统设计输入》受控文档(第二份),不要覆盖上一份需求文档,并给出可打开的交付物。';
|
||||
await sendChat(page, prompt);
|
||||
await waitForSquadCompletion(page, '系统设计输入');
|
||||
const secondTab = page.locator('[role="tab"], button, div').filter({ hasText: /设计输入|系统设计/ }).first();
|
||||
if ((await secondTab.count()) === 0) fail('second controlled document tab not found after chat');
|
||||
await secondTab.click();
|
||||
secondDocTitle = (await secondTab.textContent())?.trim() || '';
|
||||
await expect(page.locator('[data-testid="doc-rev-version"]')).toBeVisible({ timeout: 45_000 });
|
||||
const states: string[] = [];
|
||||
for (const label of [controlledTitle, secondDocTitle]) {
|
||||
if (!label) continue;
|
||||
await page.getByText(label, { exact: false }).first().click();
|
||||
const { v, r } = await readVersionBadge(page);
|
||||
states.push(`${label}:${v}/${r}`);
|
||||
}
|
||||
// Page upload attachment
|
||||
const fileInput = page.locator('input[type="file"]');
|
||||
if ((await fileInput.count()) === 0) fail('no file input for page upload');
|
||||
const tmp = path.join(ASSETS, `upload-${suffix}.txt`);
|
||||
fs.writeFileSync(tmp, `attachment ${suffix}\n`, 'utf-8');
|
||||
await fileInput.first().setInputFiles(tmp);
|
||||
await expect(page.getByText(`upload-${suffix}`).or(page.getByText('attachment'))).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await shot(page, 'UI-11-multi-doc.png');
|
||||
recordPass({
|
||||
id: 'UI-11',
|
||||
name: 'multi doc + upload',
|
||||
input: 'chat second doc; upload file',
|
||||
actual: states.join('; '),
|
||||
networkLogs: logs,
|
||||
durationMs: Date.now() - t0,
|
||||
verdict: 'PASS',
|
||||
});
|
||||
});
|
||||
|
||||
test('UI-12 user C (not a member) must not see project body', async () => {
|
||||
const t0 = Date.now();
|
||||
const guestLogs: NetLog[] = [];
|
||||
const ctx = await page.context().browser()!.newContext();
|
||||
const gp = await ctx.newPage();
|
||||
gp.on('response', (res) => pushNet(guestLogs, res));
|
||||
await login(gp, USER_C, FINAL_PASS_C);
|
||||
const leaked = await gp.getByText(PROJECT_NAME, { exact: false }).isVisible().catch(() => false);
|
||||
if (leaked) {
|
||||
await gp.getByText(PROJECT_NAME, { exact: false }).first().click();
|
||||
const bodyLeak = await gp.getByText(/人工修订段落|UI06-/).isVisible().catch(() => false);
|
||||
if (bodyLeak) fail('unauthorized user C can read controlled body');
|
||||
}
|
||||
await shot(gp, '12-unauthorized-access.png');
|
||||
await ctx.close();
|
||||
recordPass({
|
||||
id: 'UI-12',
|
||||
name: 'unauthorized isolation',
|
||||
input: `login ${USER_C} (not member)`,
|
||||
actual: leaked ? 'project listed but body blocked' : 'project not visible',
|
||||
networkLogs: guestLogs,
|
||||
durationMs: Date.now() - t0,
|
||||
verdict: 'PASS',
|
||||
});
|
||||
});
|
||||
|
||||
test('UI-13 A/B dual context conflict on same revision', async () => {
|
||||
const t0 = Date.now();
|
||||
logs = [];
|
||||
// Ensure A is on controlled doc
|
||||
await page.getByText(controlledTitle || PROJECT_NAME, { exact: false }).first().click();
|
||||
await expect(page.locator('[data-testid="doc-rev-version"]')).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
const ctxB = await page.context().browser()!.newContext();
|
||||
const pageB = await ctxB.newPage();
|
||||
await login(pageB, USER_B, FINAL_PASS_B);
|
||||
await pageB.getByText(PROJECT_NAME, { exact: false }).first().click();
|
||||
await pageB.getByText(controlledTitle || /需求/, { exact: false }).first().click();
|
||||
await expect(pageB.locator('[data-testid="doc-rev-version"]')).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// Both open same old revision — A saves first
|
||||
const blockA = page.locator('p, h2, [data-overlay-kind]').first();
|
||||
await blockA.dblclick();
|
||||
await page.locator('textarea, [contenteditable="true"]').last().fill(`A-win-${suffix}`);
|
||||
await page.getByRole('button', { name: /保存/ }).click();
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
const blockB = pageB.locator('p, h2, [data-overlay-kind]').first();
|
||||
await blockB.dblclick();
|
||||
await pageB.locator('textarea, [contenteditable="true"]').last().fill(`B-conflict-${suffix}`);
|
||||
await pageB.getByRole('button', { name: /保存/ }).click();
|
||||
const conflict = pageB.getByText(/冲突|刷新后重做|版本冲突/);
|
||||
await expect(conflict.first()).toBeVisible({ timeout: 30_000 });
|
||||
await shot(pageB, 'UI-13-conflict-B.png');
|
||||
await ctxB.close();
|
||||
recordPass({
|
||||
id: 'UI-13',
|
||||
name: 'A/B concurrent edit conflict',
|
||||
input: 'A save then B save on same base revision',
|
||||
actual: 'B sees conflict / 刷新后重做',
|
||||
networkLogs: logs,
|
||||
durationMs: Date.now() - t0,
|
||||
verdict: 'PASS',
|
||||
});
|
||||
});
|
||||
|
||||
test('UI-14 logout via UI then login again', async () => {
|
||||
const t0 = Date.now();
|
||||
logs = [];
|
||||
const { v, r } = await readVersionBadge(page);
|
||||
await logoutViaUI(page);
|
||||
await login(page, USER_A, FINAL_PASS_A);
|
||||
await page.getByText(PROJECT_NAME, { exact: false }).first().click();
|
||||
await page.getByText(controlledTitle || /需求/, { exact: false }).first().click();
|
||||
await expect(page.locator('[data-testid="doc-rev-version"]')).toBeVisible({ timeout: 30_000 });
|
||||
const again = await readVersionBadge(page);
|
||||
if (again.v !== v) fail(`version changed across re-login unexpectedly ${v}→${again.v}`);
|
||||
await shot(page, 'UI-14-relogin.png');
|
||||
recordPass({
|
||||
id: 'UI-14',
|
||||
name: 'UI logout + login',
|
||||
input: 'click 退出当前登录; login A',
|
||||
actual: `ledger ${again.v}/${again.r}`,
|
||||
networkLogs: logs,
|
||||
durationMs: Date.now() - t0,
|
||||
verdict: 'PASS',
|
||||
});
|
||||
});
|
||||
|
||||
test('UI-15 phase-main: write evidence then exit (host restarts docker before post-restart run)', async () => {
|
||||
const t0 = Date.now();
|
||||
logs = [];
|
||||
const { v, r } = await readVersionBadge(page);
|
||||
const st = readPhaseState();
|
||||
writePhaseState({
|
||||
...st,
|
||||
projectName: PROJECT_NAME,
|
||||
controlledDocTitle: controlledTitle,
|
||||
versionText: v,
|
||||
revisionText: r,
|
||||
userA: USER_A,
|
||||
userB: USER_B,
|
||||
userC: USER_C,
|
||||
});
|
||||
await shot(page, 'UI-15-pre-restart-evidence.png');
|
||||
// Do NOT mark full UI-15 PASS here — only evidence for post-restart phase.
|
||||
recorded['UI-15-pre'] = {
|
||||
id: 'UI-15-pre',
|
||||
name: 'pre-restart evidence written',
|
||||
input: 'capture project/state; exit suite',
|
||||
actual: `state file written version=${v} revision=${r}`,
|
||||
networkLogs: logs,
|
||||
durationMs: Date.now() - t0,
|
||||
verdict: 'PASS',
|
||||
notes: 'Full UI-15 PASS only after UI_PHASE=post-restart run',
|
||||
};
|
||||
saveResults();
|
||||
});
|
||||
|
||||
test('UI-16 registered upload uses server doc-free ledger (no doc-upload orphan)', async () => {
|
||||
const t0 = Date.now();
|
||||
logs = [];
|
||||
const uploadTab = page.getByText(`upload-${suffix}`).or(page.getByText(/attachment/));
|
||||
if ((await uploadTab.count()) === 0) fail('upload tab missing (UI-11 must have uploaded via page)');
|
||||
await uploadTab.first().click();
|
||||
await expect(page.locator('text=版本账本未就绪')).toHaveCount(0);
|
||||
// Registered upload must mount real toolbar (doc-free), not orphan hint.
|
||||
await expect(page.locator('[data-testid="doc-rev-version"]')).toBeVisible({ timeout: 30_000 });
|
||||
const orphanHits = logs.filter(
|
||||
(l) => l.path.includes('/revision-state') && /\/documents\/doc-upload-/.test(l.path),
|
||||
);
|
||||
if (orphanHits.length) {
|
||||
fail(`must not request revision-state for doc-upload orphan: ${JSON.stringify(orphanHits)}`);
|
||||
}
|
||||
await shot(page, 'UI-16-registered-upload.png');
|
||||
recordPass({
|
||||
id: 'UI-16',
|
||||
name: 'registered upload server ledger',
|
||||
input: 'open page-uploaded attachment after createFreeform registration',
|
||||
actual: 'toolbar V visible; no doc-upload revision-state',
|
||||
networkLogs: logs,
|
||||
durationMs: Date.now() - t0,
|
||||
verdict: 'PASS',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
import * as path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
/**
|
||||
* Ubuntu Server headless UI acceptance config (DOCREV runtime).
|
||||
* Enable only when RUN_UI_ACCEPTANCE=1 against a real dev stack — no mocks.
|
||||
* Script never touches docker; UI_PHASE=post-restart is a second run after host restart.
|
||||
*/
|
||||
const runAcceptance = process.env.RUN_UI_ACCEPTANCE === '1';
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const evidenceRoot = path.resolve(
|
||||
__dirname,
|
||||
'../../.doc/test-reports/assets/2026-09-13-document-revision-runtime-ui',
|
||||
);
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
@@ -12,14 +20,19 @@ export default defineConfig({
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: 0,
|
||||
workers: 1,
|
||||
reporter: [['list'], ['html', { open: 'never', outputFolder: 'playwright-report' }]],
|
||||
timeout: 600_000,
|
||||
outputDir: path.join(evidenceRoot, 'playwright-output'),
|
||||
reporter: [
|
||||
['list'],
|
||||
['html', { open: 'never', outputFolder: path.join(evidenceRoot, 'playwright-report') }],
|
||||
],
|
||||
use: {
|
||||
baseURL: process.env.BASE_URL || 'http://127.0.0.1:8080',
|
||||
headless: true,
|
||||
locale: 'zh-CN',
|
||||
trace: runAcceptance ? 'on' : 'off',
|
||||
screenshot: runAcceptance ? 'on' : 'off',
|
||||
video: runAcceptance ? 'on' : 'off',
|
||||
locale: 'zh-CN',
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
@@ -27,5 +40,4 @@ export default defineConfig({
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
],
|
||||
// Do not start webServer here — acceptance assumes env-service.dev is already up.
|
||||
});
|
||||
|
||||
+73
-67
@@ -11,6 +11,7 @@ import { CommentSidePanel } from './components/doc/CommentSidePanel';
|
||||
import { DocGovActionBar } from './components/doc/DocGovActionBar';
|
||||
import { FreeformDocumentActionBar } from './components/doc/FreeformDocumentActionBar';
|
||||
import {
|
||||
createFreeformDoc,
|
||||
saveMessageToPrivate,
|
||||
listFreeformDocs,
|
||||
fetchFreeformDoc,
|
||||
@@ -18,11 +19,16 @@ import {
|
||||
unshareFreeformDoc,
|
||||
deleteFreeformDoc,
|
||||
} from './api/freeformDoc';
|
||||
import { registerUploadAttachments } from './lib/uploadFreeformRegister';
|
||||
import { fetchChatSessions, upsertChatSession } from './api/chatSessions';
|
||||
import { listProjectWorkflowDocuments } from './api/projectDocuments';
|
||||
import { patchDocumentContent, getRevisionState } from './api/documentRevision';
|
||||
import type { DiffResult, OverlayMode } from './features/documentRevision/types';
|
||||
import { applyHeadBlockMap, astToClientBlockMap } from './features/documentRevision/overlayMatch';
|
||||
import {
|
||||
documentRevisionInactiveHint,
|
||||
revisionToolbarMountMode,
|
||||
} from './features/documentRevision/revisionToolbarMount';
|
||||
import { createProject, fetchProjectLineage, fetchProjects, getProject } from './api/projectWorkflow';
|
||||
import {
|
||||
addProjectMember,
|
||||
@@ -2704,11 +2710,11 @@ export default function App() {
|
||||
};
|
||||
|
||||
// 发送消息与对话驱动双轨逻辑
|
||||
const handleSendMessage = (
|
||||
const handleSendMessage = async (
|
||||
text: string,
|
||||
attachments?: { name: string; size: string; type: string; docId?: string; fileRaw?: File | Blob; fileUrl?: string }[],
|
||||
quotes?: QuotedMessage[]
|
||||
): SendMessageResult => {
|
||||
): Promise<SendMessageResult> => {
|
||||
const trimmed = text.trim();
|
||||
let clientTurnId: string;
|
||||
try {
|
||||
@@ -2720,53 +2726,32 @@ export default function App() {
|
||||
};
|
||||
}
|
||||
|
||||
// 若包含附件,则自动将用户上传的文件批量注册为个人私密文档 (category: 'private')
|
||||
// 附件必须先注册为服务端 freeform(server docId);失败硬拦,禁止本地 orphan 回退
|
||||
let processedAttachments = attachments;
|
||||
if (attachments && attachments.length > 0) {
|
||||
const newDeliverablesList: DeliverableItem[] = [];
|
||||
const newDocCacheEntries: Record<string, DocumentAST> = {};
|
||||
const now = Date.now();
|
||||
|
||||
processedAttachments = attachments.map((att, idx) => {
|
||||
const fileDocId = att.docId || `doc-upload-${now}-${idx}`;
|
||||
const baseTitle = att.name.replace(/\.[^/.]+$/, '');
|
||||
const extMatch = att.name.match(/\.([^.]+)$/);
|
||||
const ext = extMatch ? extMatch[1].toLowerCase() : '';
|
||||
|
||||
const newDeliverable: DeliverableItem = {
|
||||
docId: fileDocId,
|
||||
let registered;
|
||||
try {
|
||||
registered = await registerUploadAttachments({
|
||||
attachments,
|
||||
projectId: currentProjectId,
|
||||
taskKey: 'user_requirements',
|
||||
title: baseTitle,
|
||||
stage: 'V0.5',
|
||||
category: 'private',
|
||||
creatorId: currentUser.id,
|
||||
creatorName: currentUser.name,
|
||||
isFixedWorkflow: false,
|
||||
isTemplateOnly: false,
|
||||
lifecycleState: 'drafting',
|
||||
isClaimed: true,
|
||||
author: currentUser.name,
|
||||
reviewer: '李主管',
|
||||
approver: '王总监',
|
||||
updatedAt: new Date().toISOString().substring(0, 10),
|
||||
summary: `用户上传的私密需求文档《${att.name}》,归档至个人私密文档库,可一键共享至项目参考库。`,
|
||||
space: 'my_private',
|
||||
governanceMode: 'freeform',
|
||||
sourceType: 'uploaded_file',
|
||||
fileMeta: {
|
||||
originalName: att.name,
|
||||
size: att.size,
|
||||
mimeType: att.type,
|
||||
extension: ext,
|
||||
fileRaw: att.fileRaw,
|
||||
fileUrl: att.fileUrl,
|
||||
uploadedAt: new Date().toISOString(),
|
||||
},
|
||||
userId: currentUser.id,
|
||||
userName: currentUser.name,
|
||||
createFreeformDoc,
|
||||
});
|
||||
} catch (err) {
|
||||
return {
|
||||
accepted: false,
|
||||
error: err instanceof Error ? err.message : '附件服务端注册失败,请重试',
|
||||
};
|
||||
const initialMd = `# ${baseTitle}\n\n> 上传人:${currentUser.name} | 上传时间:${new Date().toLocaleString()} | 文件大小:${att.size}\n\n## 1. 文档概述与背景\n\n本文件由用户上传并归档至【个人私密文档库】。原始附件格式:\`${att.type || 'document'}\`。\n\n## 2. 协作与共享说明\n\n- **当前状态**:🔒 **个人私密 (仅本人可见)**\n- 您可在底部操作栏点击「一键共享给项目组」,将该文档共享至项目协作参考库供其他成员查阅与评审。\n- 也可以随时点击「撤回为个人私密」恢复独立隔离。`;
|
||||
}
|
||||
|
||||
const newDeliverablesList = registered.map((r) => r.deliverable);
|
||||
const newDocCacheEntries: Record<string, DocumentAST> = {};
|
||||
for (const r of registered) {
|
||||
const baseTitle = r.deliverable.title;
|
||||
const initialMd = r.detail.content_markdown;
|
||||
const newAST = markdownToBlockAST(initialMd, {
|
||||
docId: fileDocId,
|
||||
docId: r.serverDocId,
|
||||
title: baseTitle,
|
||||
taskKey: 'user_requirements',
|
||||
version: 'V1.0',
|
||||
@@ -2781,24 +2766,15 @@ export default function App() {
|
||||
});
|
||||
newAST.governanceMode = 'freeform';
|
||||
newAST.space = 'my_private';
|
||||
|
||||
newDeliverablesList.push(newDeliverable);
|
||||
newDocCacheEntries[fileDocId] = newAST;
|
||||
|
||||
// ✅ 将文件内容持久化到 IndexedDB,支持页面刷新后恢复预览
|
||||
// fire-and-forget:写入失败不影响正常流程
|
||||
if (att.fileRaw) {
|
||||
saveFile(fileDocId, att.fileRaw, att.name).catch((e) =>
|
||||
console.warn('[fileStore] 保存上传文件失败:', fileDocId, e)
|
||||
newDocCacheEntries[r.serverDocId] = newAST;
|
||||
if (r.attachment.fileRaw) {
|
||||
saveFile(r.serverDocId, r.attachment.fileRaw, r.attachment.name).catch((e) =>
|
||||
console.warn('[fileStore] 保存上传文件失败:', r.serverDocId, e),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...att,
|
||||
docId: fileDocId,
|
||||
};
|
||||
});
|
||||
|
||||
processedAttachments = registered.map((r) => r.attachment);
|
||||
const newDocIds = new Set(newDeliverablesList.map((d) => d.docId));
|
||||
setDeliverables((prev) => [...newDeliverablesList, ...prev.filter((d) => !newDocIds.has(d.docId))]);
|
||||
setDocCache((prev) => ({
|
||||
@@ -2806,7 +2782,6 @@ export default function App() {
|
||||
...newDocCacheEntries,
|
||||
}));
|
||||
|
||||
// 默认激活打开刚上传的第 1 个文档
|
||||
if (processedAttachments.length > 0 && processedAttachments[0].docId) {
|
||||
setActiveDocId(processedAttachments[0].docId);
|
||||
setActiveCategory('private');
|
||||
@@ -3887,13 +3862,44 @@ export default function App() {
|
||||
onExportXlsx={handleExportXlsx}
|
||||
onExportMarkdown={handleExportMarkdown}
|
||||
/>
|
||||
<DocumentRevisionToolbar
|
||||
docId={activeDocId}
|
||||
overlayMode={docRevOverlayMode}
|
||||
onOverlayModeChange={setDocRevOverlayMode}
|
||||
onOverlay={setDocRevOverlay}
|
||||
revisionRefreshKey={docRevRefreshKey}
|
||||
/>
|
||||
{(() => {
|
||||
const revDoc =
|
||||
deliverables.find((d) => d.docId === activeDocId) ||
|
||||
visibleDeliverables.find((d) => d.docId === activeDocId);
|
||||
const mode = revisionToolbarMountMode(
|
||||
revDoc ?? { docId: activeDocId, isTemplateOnly: true },
|
||||
);
|
||||
if (mode === 'toolbar' && revDoc) {
|
||||
return (
|
||||
<DocumentRevisionToolbar
|
||||
docId={activeDocId}
|
||||
overlayMode={docRevOverlayMode}
|
||||
onOverlayModeChange={setDocRevOverlayMode}
|
||||
onOverlay={setDocRevOverlay}
|
||||
revisionRefreshKey={docRevRefreshKey}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const hint = documentRevisionInactiveHint(
|
||||
revDoc ?? { docId: activeDocId, isTemplateOnly: true },
|
||||
);
|
||||
if (!hint) return null;
|
||||
return (
|
||||
<div
|
||||
data-testid="doc-rev-template-hint"
|
||||
className="doc-rev-template-hint"
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
borderBottom: '1px solid var(--border, #ddd)',
|
||||
fontSize: 12,
|
||||
color: 'var(--text-muted, #64748b)',
|
||||
background: 'var(--bg-subtle, #f8fafc)',
|
||||
}}
|
||||
>
|
||||
{hint}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
{docRevConflict && (
|
||||
<div role="alert" style={{ padding: '8px 12px', background: '#fff3cd', borderBottom: '1px solid #e0c36a' }}>
|
||||
{docRevConflict}
|
||||
|
||||
@@ -43,6 +43,36 @@ export interface CreateFromMessageResponse {
|
||||
data: FreeformDocDetail;
|
||||
}
|
||||
|
||||
/**
|
||||
* 直接创建自由文档(上传附件即时注册服务端事实源)
|
||||
*/
|
||||
export async function createFreeformDoc(payload: {
|
||||
projectId: number;
|
||||
title: string;
|
||||
contentMarkdown: string;
|
||||
space?: 'my_private' | 'project_shared';
|
||||
clientKey?: string;
|
||||
}): Promise<FreeformDocDetail> {
|
||||
const res = await fetch('/api/v1/freeform-documents', {
|
||||
method: 'POST',
|
||||
headers: getBearerAuthHeaders('application/json'),
|
||||
body: JSON.stringify({
|
||||
project_id: payload.projectId,
|
||||
title: payload.title,
|
||||
content_markdown: payload.contentMarkdown,
|
||||
space: payload.space || 'my_private',
|
||||
client_key: payload.clientKey || undefined,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const errData = await res.json().catch(() => ({}));
|
||||
const msg = errData.message || errData.error_code || `创建失败 (${res.status})`;
|
||||
throw new Error(typeof msg === 'string' ? msg : `创建失败 (${res.status})`);
|
||||
}
|
||||
const body = await res.json();
|
||||
return body.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从单条智能体回复保存为我的私密文档(幂等)
|
||||
*/
|
||||
|
||||
@@ -26,3 +26,13 @@ test('InputArea shows thinking block message', () => {
|
||||
assert.equal(resolveThinkingBlock(true), '正在思考,请稍候');
|
||||
assert.equal(resolveThinkingBlock(false), null);
|
||||
});
|
||||
|
||||
test('InputArea keeps draft when attachment registration fails', () => {
|
||||
const applied = applySendResult(
|
||||
{ accepted: false, error: '附件「a.pdf」服务端注册失败:forbidden。未发送消息,请检查网络后重试。' },
|
||||
draft,
|
||||
);
|
||||
assert.equal(applied.cleared, false);
|
||||
assert.deepEqual(applied.draft, draft);
|
||||
assert.match(applied.error || '', /服务端注册失败/);
|
||||
});
|
||||
|
||||
@@ -19,7 +19,7 @@ interface InputAreaProps {
|
||||
text: string,
|
||||
attachments?: InputAttachmentItem[],
|
||||
quotes?: QuotedMessage[]
|
||||
) => SendMessageResult;
|
||||
) => SendMessageResult | Promise<SendMessageResult>;
|
||||
isThinking: boolean;
|
||||
isStreaming: boolean;
|
||||
onStopStreaming?: () => void;
|
||||
@@ -45,6 +45,7 @@ export const InputArea: React.FC<InputAreaProps> = ({
|
||||
const [attachments, setAttachments] = useState<InputAttachmentItem[]>([]);
|
||||
const [uploadWarning, setUploadWarning] = useState<string | null>(null);
|
||||
const [sendError, setSendError] = useState<string | null>(null);
|
||||
const [sending, setSending] = useState(false);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
@@ -56,7 +57,7 @@ export const InputArea: React.FC<InputAreaProps> = ({
|
||||
}
|
||||
}, [text]);
|
||||
|
||||
const handleSend = () => {
|
||||
const handleSend = async () => {
|
||||
const thinkingError = resolveThinkingBlock(isThinking);
|
||||
if (thinkingError) {
|
||||
setSendError(thinkingError);
|
||||
@@ -70,30 +71,42 @@ export const InputArea: React.FC<InputAreaProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
if (sending) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!text.trim() && attachments.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = onSendMessage(text.trim(), attachments, quotes);
|
||||
const applied = applySendResult(result, { text: text.trim(), attachments });
|
||||
if (!applied.cleared) {
|
||||
setSendError(applied.error);
|
||||
return;
|
||||
}
|
||||
|
||||
setSending(true);
|
||||
setSendError(null);
|
||||
setText(applied.draft.text);
|
||||
setAttachments(applied.draft.attachments);
|
||||
setUploadWarning(null);
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = '42px';
|
||||
try {
|
||||
const result = await onSendMessage(text.trim(), attachments, quotes);
|
||||
const applied = applySendResult(result, { text: text.trim(), attachments });
|
||||
if (!applied.cleared) {
|
||||
setSendError(applied.error);
|
||||
return;
|
||||
}
|
||||
|
||||
setSendError(null);
|
||||
setText(applied.draft.text);
|
||||
setAttachments(applied.draft.attachments);
|
||||
setUploadWarning(null);
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = '42px';
|
||||
}
|
||||
} catch (err) {
|
||||
setSendError(err instanceof Error ? err.message : '消息发送失败,请重试');
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
void handleSend();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -454,16 +467,16 @@ export const InputArea: React.FC<InputAreaProps> = ({
|
||||
|
||||
{/* 智能发送按钮 */}
|
||||
<button
|
||||
onClick={handleSend}
|
||||
disabled={!text.trim() && attachments.length === 0 && !isStreaming}
|
||||
className={isThinking ? 'agent-pulse-active' : ''}
|
||||
onClick={() => void handleSend()}
|
||||
disabled={sending || (!text.trim() && attachments.length === 0 && !isStreaming)}
|
||||
className={isThinking || sending ? 'agent-pulse-active' : ''}
|
||||
style={{
|
||||
width: '36px',
|
||||
height: '36px',
|
||||
borderRadius: 'var(--r-md)',
|
||||
background: isStreaming
|
||||
? 'var(--danger)'
|
||||
: isThinking
|
||||
: isThinking || sending
|
||||
? 'var(--brand-600)'
|
||||
: 'var(--brand-grad)',
|
||||
color: '#fff',
|
||||
@@ -473,14 +486,19 @@ export const InputArea: React.FC<InputAreaProps> = ({
|
||||
flexShrink: 0,
|
||||
marginBottom: '3px',
|
||||
transition: 'all 0.2s ease',
|
||||
opacity: !text.trim() && attachments.length === 0 && !isThinking && !isStreaming ? 0.45 : 1,
|
||||
boxShadow: isThinking
|
||||
opacity:
|
||||
sending || (!text.trim() && attachments.length === 0 && !isThinking && !isStreaming)
|
||||
? 0.45
|
||||
: 1,
|
||||
boxShadow: isThinking || sending
|
||||
? '0 0 12px rgba(107, 132, 255, 0.7)'
|
||||
: '0 2px 8px rgba(79, 107, 255, 0.25)',
|
||||
}}
|
||||
title={
|
||||
isStreaming
|
||||
? '停止当前生成'
|
||||
: sending
|
||||
? '正在注册附件并发送…'
|
||||
: isThinking
|
||||
? '正在思考,请稍候'
|
||||
: '发送 (Enter)'
|
||||
@@ -488,7 +506,7 @@ export const InputArea: React.FC<InputAreaProps> = ({
|
||||
>
|
||||
{isStreaming ? (
|
||||
<Square size={14} fill="#fff" />
|
||||
) : isThinking ? (
|
||||
) : isThinking || sending ? (
|
||||
<Loader2 size={16} className="animate-spin-fast" />
|
||||
) : (
|
||||
<Send size={15} />
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
LOCAL_REVISION_PREVIEW_HINT,
|
||||
TEMPLATE_REVISION_PREVIEW_HINT,
|
||||
documentRevisionInactiveHint,
|
||||
revisionToolbarMountMode,
|
||||
shouldMountDocumentRevisionToolbar,
|
||||
} from './revisionToolbarMount';
|
||||
|
||||
describe('revisionToolbarMount', () => {
|
||||
it('exposes clear non-error template preview hint copy', () => {
|
||||
assert.equal(TEMPLATE_REVISION_PREVIEW_HINT, '模板预览:首轮编制后启用版本账本');
|
||||
});
|
||||
|
||||
it('template doc-p + isTemplateOnly=true does not mount', () => {
|
||||
const doc = { docId: 'doc-p6059-intake', isTemplateOnly: true as const };
|
||||
assert.equal(shouldMountDocumentRevisionToolbar(doc), false);
|
||||
assert.equal(revisionToolbarMountMode(doc), 'template_hint');
|
||||
assert.equal(documentRevisionInactiveHint(doc), TEMPLATE_REVISION_PREVIEW_HINT);
|
||||
});
|
||||
|
||||
it('legacy doc-001 does not mount', () => {
|
||||
assert.equal(shouldMountDocumentRevisionToolbar({ docId: 'doc-001', isTemplateOnly: false }), false);
|
||||
assert.equal(documentRevisionInactiveHint({ docId: 'doc-001', isTemplateOnly: false }), LOCAL_REVISION_PREVIEW_HINT);
|
||||
});
|
||||
|
||||
it('unregistered doc-upload orphan does not mount (must register first)', () => {
|
||||
assert.equal(
|
||||
shouldMountDocumentRevisionToolbar({
|
||||
docId: 'doc-upload-1789263145197-0',
|
||||
isTemplateOnly: false,
|
||||
sourceType: 'uploaded_file',
|
||||
governanceMode: 'freeform',
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('server-registered upload (doc-free) and freeform mount', () => {
|
||||
assert.equal(
|
||||
shouldMountDocumentRevisionToolbar({
|
||||
docId: 'doc-free-1789263145197-2',
|
||||
isTemplateOnly: false,
|
||||
sourceType: 'uploaded_file',
|
||||
governanceMode: 'freeform',
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldMountDocumentRevisionToolbar({
|
||||
docId: 'doc-free-1710000000000-2',
|
||||
isTemplateOnly: false,
|
||||
governanceMode: 'freeform',
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('canonical doc_proj and generated non-template doc-p mount', () => {
|
||||
assert.equal(
|
||||
shouldMountDocumentRevisionToolbar({ docId: 'doc_proj6059_intake', isTemplateOnly: false }),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldMountDocumentRevisionToolbar({ docId: 'doc-p6059-intake', isTemplateOnly: false }),
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* App 是否挂载 DocumentRevisionToolbar(会请求 revision-state)的可测判断。
|
||||
*
|
||||
* - 模板预览 / legacy seed:不挂载、不请求,显示非错误提示
|
||||
* - 服务端 freeform / 已注册上传(doc-free-* + freeform 事实字段)/ 生成后受控:挂载
|
||||
* - 未注册的 doc-upload-* orphan:不挂载(上传路径必须先 createFreeform 再替换 ID)
|
||||
*/
|
||||
|
||||
import type { DeliverableItem } from '../../types/workbench';
|
||||
|
||||
export const TEMPLATE_REVISION_PREVIEW_HINT = '模板预览:首轮编制后启用版本账本';
|
||||
export const LOCAL_REVISION_PREVIEW_HINT = '本地预览:尚未接入服务端版本账本';
|
||||
|
||||
export type RevisionMountDoc = Pick<DeliverableItem, 'docId' | 'isTemplateOnly'> &
|
||||
Partial<
|
||||
Pick<
|
||||
DeliverableItem,
|
||||
'governanceMode' | 'sourceType' | 'fileMeta' | 'space' | 'isFixedWorkflow' | 'taskKey'
|
||||
>
|
||||
>;
|
||||
|
||||
export type RevisionToolbarMountMode = 'toolbar' | 'template_hint' | 'local_hint' | 'none';
|
||||
|
||||
export function isLegacyLocalMockDocId(docId: string): boolean {
|
||||
const id = (docId || '').trim();
|
||||
if (!id) return true;
|
||||
if (/^doc-\d{3}$/.test(id)) return true;
|
||||
if (/^doc-shared-\d+$/.test(id)) return true;
|
||||
if (/^doc-private-\d{1,3}$/.test(id)) return true;
|
||||
if (id.startsWith('doc-cusa-')) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 未走服务端注册的本地上传 ID(禁止挂工具条冒充账本)。 */
|
||||
export function isUnregisteredUploadOrphanDocId(docId: string): boolean {
|
||||
return /^doc-upload-/.test((docId || '').trim());
|
||||
}
|
||||
|
||||
export function hasServerRevisionFactFields(doc: RevisionMountDoc): boolean {
|
||||
if (doc.sourceType === 'uploaded_file' && !isUnregisteredUploadOrphanDocId(doc.docId)) return true;
|
||||
if (doc.governanceMode === 'freeform' && !isUnregisteredUploadOrphanDocId(doc.docId)) return true;
|
||||
if (doc.governanceMode === 'controlled') return true;
|
||||
if (
|
||||
(doc.space === 'my_private' || doc.space === 'project_shared') &&
|
||||
!isUnregisteredUploadOrphanDocId(doc.docId)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function looksLikeServerOrGeneratedRevisionDocId(docId: string): boolean {
|
||||
const id = (docId || '').trim();
|
||||
if (!id || isLegacyLocalMockDocId(id) || isUnregisteredUploadOrphanDocId(id)) return false;
|
||||
if (id.startsWith('doc_proj')) return true;
|
||||
if (id.startsWith('doc-free-')) return true;
|
||||
if (/^doc-p\d+-/.test(id)) return true;
|
||||
if (/^doc-proj\d+-/.test(id)) return true;
|
||||
if (!id.startsWith('doc-')) return true; // UUID / future server ids
|
||||
return false;
|
||||
}
|
||||
|
||||
export function shouldMountDocumentRevisionToolbar(
|
||||
doc: RevisionMountDoc | null | undefined,
|
||||
): boolean {
|
||||
if (!doc?.docId) return false;
|
||||
if (doc.isTemplateOnly) return false;
|
||||
if (isLegacyLocalMockDocId(doc.docId)) return false;
|
||||
if (isUnregisteredUploadOrphanDocId(doc.docId)) return false;
|
||||
|
||||
if (hasServerRevisionFactFields(doc)) return true;
|
||||
if (looksLikeServerOrGeneratedRevisionDocId(doc.docId)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function revisionToolbarMountMode(
|
||||
doc: RevisionMountDoc | null | undefined,
|
||||
): RevisionToolbarMountMode {
|
||||
if (!doc?.docId) return 'none';
|
||||
if (doc.isTemplateOnly) return 'template_hint';
|
||||
if (shouldMountDocumentRevisionToolbar(doc)) return 'toolbar';
|
||||
if (isLegacyLocalMockDocId(doc.docId) || isUnregisteredUploadOrphanDocId(doc.docId)) {
|
||||
return 'local_hint';
|
||||
}
|
||||
if (/^doc-p\d+-/.test(doc.docId) || /^doc-proj\d+-/.test(doc.docId)) {
|
||||
return 'template_hint';
|
||||
}
|
||||
return 'local_hint';
|
||||
}
|
||||
|
||||
export function documentRevisionInactiveHint(
|
||||
doc: RevisionMountDoc | null | undefined,
|
||||
): string | null {
|
||||
const mode = revisionToolbarMountMode(doc);
|
||||
if (mode === 'toolbar' || mode === 'none') return null;
|
||||
if (mode === 'template_hint') return TEMPLATE_REVISION_PREVIEW_HINT;
|
||||
return LOCAL_REVISION_PREVIEW_HINT;
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
buildUploadClientKey,
|
||||
deliverableFromFreeformUpload,
|
||||
registerUploadAttachments,
|
||||
} from './uploadFreeformRegister';
|
||||
import type { FreeformDocDetail } from '../api/freeformDoc';
|
||||
|
||||
describe('uploadFreeformRegister', () => {
|
||||
it('buildUploadClientKey prefers explicit hint then doc-upload stamp', () => {
|
||||
assert.equal(buildUploadClientKey(100, 0, 'doc-upload-100-0'), 'doc-upload-100-0');
|
||||
assert.equal(buildUploadClientKey(100, 1), 'doc-upload-100-1');
|
||||
});
|
||||
|
||||
it('registerUploadAttachments hard-fails without creating orphan on API error', async () => {
|
||||
await assert.rejects(
|
||||
() =>
|
||||
registerUploadAttachments({
|
||||
attachments: [{ name: 'a.pdf', size: '1KB', type: 'application/pdf' }],
|
||||
projectId: 12,
|
||||
userId: 2,
|
||||
userName: '张工',
|
||||
createFreeformDoc: async () => {
|
||||
throw new Error('network down');
|
||||
},
|
||||
}),
|
||||
/服务端注册失败/,
|
||||
);
|
||||
});
|
||||
|
||||
it('registerUploadAttachments rejects local doc-upload id from server', async () => {
|
||||
await assert.rejects(
|
||||
() =>
|
||||
registerUploadAttachments({
|
||||
attachments: [{ name: 'a.pdf', size: '1KB', type: 'application/pdf' }],
|
||||
projectId: 12,
|
||||
userId: 2,
|
||||
userName: '张工',
|
||||
createFreeformDoc: async () =>
|
||||
({
|
||||
document: {
|
||||
doc_id: 'doc-upload-bad',
|
||||
project_id: 12,
|
||||
owner_user_id: 2,
|
||||
source_content_sha256: 'x',
|
||||
title: 'a',
|
||||
governance_mode: 'freeform',
|
||||
space: 'my_private',
|
||||
current_version_id: 1,
|
||||
current_sha256: 'x',
|
||||
is_deleted: false,
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
},
|
||||
content_markdown: '# a',
|
||||
}) as FreeformDocDetail,
|
||||
}),
|
||||
/未返回服务端文档 ID/,
|
||||
);
|
||||
});
|
||||
|
||||
it('registerUploadAttachments returns server doc-free ids and freeform facts', async () => {
|
||||
const registered = await registerUploadAttachments({
|
||||
attachments: [{ name: 'spec.pdf', size: '2KB', type: 'application/pdf' }],
|
||||
projectId: 12,
|
||||
userId: 2,
|
||||
userName: '张工',
|
||||
now: 1789263145197,
|
||||
createFreeformDoc: async (payload) => {
|
||||
assert.equal(payload.clientKey, 'doc-upload-1789263145197-0');
|
||||
assert.ok(payload.contentMarkdown.includes('spec.pdf'));
|
||||
return {
|
||||
document: {
|
||||
doc_id: 'doc-free-1789263145197-2',
|
||||
project_id: 12,
|
||||
owner_user_id: 2,
|
||||
source_content_sha256: 'abc',
|
||||
client_key: payload.clientKey,
|
||||
title: payload.title,
|
||||
governance_mode: 'freeform',
|
||||
space: 'my_private',
|
||||
current_version_id: 1,
|
||||
current_sha256: 'abc',
|
||||
is_deleted: false,
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
},
|
||||
content_markdown: payload.contentMarkdown,
|
||||
};
|
||||
},
|
||||
});
|
||||
assert.equal(registered.length, 1);
|
||||
assert.equal(registered[0].serverDocId, 'doc-free-1789263145197-2');
|
||||
assert.equal(registered[0].deliverable.governanceMode, 'freeform');
|
||||
assert.equal(registered[0].deliverable.sourceType, 'uploaded_file');
|
||||
assert.equal(registered[0].attachment.docId, 'doc-free-1789263145197-2');
|
||||
});
|
||||
|
||||
it('deliverableFromFreeformUpload maps server id and upload facts', () => {
|
||||
const d = deliverableFromFreeformUpload({
|
||||
detail: {
|
||||
document: {
|
||||
doc_id: 'doc-free-1-2',
|
||||
project_id: 9,
|
||||
owner_user_id: 2,
|
||||
source_content_sha256: 'h',
|
||||
title: 't',
|
||||
governance_mode: 'freeform',
|
||||
space: 'my_private',
|
||||
current_version_id: 1,
|
||||
current_sha256: 'h',
|
||||
is_deleted: false,
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
},
|
||||
content_markdown: '# t',
|
||||
},
|
||||
projectId: 9,
|
||||
userId: 2,
|
||||
userName: '张工',
|
||||
att: { name: 't.pdf', size: '1KB', type: 'application/pdf' },
|
||||
});
|
||||
assert.equal(d.docId, 'doc-free-1-2');
|
||||
assert.equal(d.sourceType, 'uploaded_file');
|
||||
assert.equal(d.governanceMode, 'freeform');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* 上传附件 → 服务端 freeform 注册辅助(失败必须硬失败,禁止本地 orphan 回退)。
|
||||
*/
|
||||
|
||||
import type { FreeformDocDetail } from '../api/freeformDoc';
|
||||
import type { DeliverableItem } from '../types/workbench';
|
||||
import type { DocumentAST } from '../types/block';
|
||||
|
||||
export type UploadAttachmentInput = {
|
||||
name: string;
|
||||
size: string;
|
||||
type: string;
|
||||
docId?: string;
|
||||
fileRaw?: File | Blob;
|
||||
fileUrl?: string;
|
||||
};
|
||||
|
||||
export function buildUploadClientKey(now: number, idx: number, hint?: string): string {
|
||||
const base = hint?.trim() || `doc-upload-${now}-${idx}`;
|
||||
return base;
|
||||
}
|
||||
|
||||
export function buildUploadRegistrationMarkdown(params: {
|
||||
title: string;
|
||||
userName: string;
|
||||
fileName: string;
|
||||
fileSize: string;
|
||||
mimeType: string;
|
||||
}): string {
|
||||
const { title, userName, fileName, fileSize, mimeType } = params;
|
||||
return `# ${title}\n\n> 上传人:${userName} | 上传时间:${new Date().toLocaleString()} | 文件大小:${fileSize}\n\n## 1. 文档概述与背景\n\n本文件由用户上传并归档至【个人私密文档库】。原始附件:\`${fileName}\`(\`${mimeType || 'document'}\`)。\n\n## 2. 协作与共享说明\n\n- **当前状态**:🔒 **个人私密 (仅本人可见)**\n- 您可在底部操作栏点击「一键共享给项目组」,将该文档共享至项目协作参考库供其他成员查阅与评审。\n`;
|
||||
}
|
||||
|
||||
export type RegisteredUpload = {
|
||||
clientKey: string;
|
||||
serverDocId: string;
|
||||
detail: FreeformDocDetail;
|
||||
deliverable: DeliverableItem;
|
||||
attachment: UploadAttachmentInput;
|
||||
};
|
||||
|
||||
export function deliverableFromFreeformUpload(params: {
|
||||
detail: FreeformDocDetail;
|
||||
projectId: number;
|
||||
userId: number;
|
||||
userName: string;
|
||||
att: UploadAttachmentInput;
|
||||
}): DeliverableItem {
|
||||
const { detail, projectId, userId, userName, att } = params;
|
||||
const doc = detail.document;
|
||||
const extMatch = att.name.match(/\.([^.]+)$/);
|
||||
const ext = extMatch ? extMatch[1].toLowerCase() : '';
|
||||
return {
|
||||
docId: doc.doc_id,
|
||||
projectId: doc.project_id || projectId,
|
||||
taskKey: 'user_requirements',
|
||||
title: doc.title || att.name.replace(/\.[^/.]+$/, ''),
|
||||
stage: 'V0.5',
|
||||
category: 'private',
|
||||
creatorId: userId,
|
||||
creatorName: userName,
|
||||
isFixedWorkflow: false,
|
||||
isTemplateOnly: false,
|
||||
lifecycleState: 'drafting',
|
||||
isClaimed: true,
|
||||
author: userName,
|
||||
reviewer: '李主管',
|
||||
approver: '王总监',
|
||||
updatedAt: new Date().toISOString().substring(0, 10),
|
||||
summary: `用户上传的私密需求文档《${att.name}》,已注册服务端版本账本。`,
|
||||
space: doc.space || 'my_private',
|
||||
governanceMode: 'freeform',
|
||||
sourceType: 'uploaded_file',
|
||||
fileMeta: {
|
||||
originalName: att.name,
|
||||
size: att.size,
|
||||
mimeType: att.type,
|
||||
extension: ext,
|
||||
fileRaw: att.fileRaw,
|
||||
fileUrl: att.fileUrl,
|
||||
uploadedAt: new Date().toISOString(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** 将一批附件全部注册为服务端 freeform;任一失败则抛错(调用方不得静默回退 orphan)。 */
|
||||
export async function registerUploadAttachments(params: {
|
||||
attachments: UploadAttachmentInput[];
|
||||
projectId: number;
|
||||
userId: number;
|
||||
userName: string;
|
||||
createFreeformDoc: (payload: {
|
||||
projectId: number;
|
||||
title: string;
|
||||
contentMarkdown: string;
|
||||
space?: 'my_private' | 'project_shared';
|
||||
clientKey?: string;
|
||||
}) => Promise<FreeformDocDetail>;
|
||||
now?: number;
|
||||
}): Promise<RegisteredUpload[]> {
|
||||
const { attachments, projectId, userId, userName, createFreeformDoc } = params;
|
||||
if (!projectId || projectId <= 0) {
|
||||
throw new Error('请先进入有效项目后再上传附件');
|
||||
}
|
||||
const now = params.now ?? Date.now();
|
||||
const out: RegisteredUpload[] = [];
|
||||
for (let idx = 0; idx < attachments.length; idx++) {
|
||||
const att = attachments[idx];
|
||||
const clientKey = buildUploadClientKey(now, idx, att.docId);
|
||||
const baseTitle = att.name.replace(/\.[^/.]+$/, '');
|
||||
const markdown = buildUploadRegistrationMarkdown({
|
||||
title: baseTitle,
|
||||
userName,
|
||||
fileName: att.name,
|
||||
fileSize: att.size,
|
||||
mimeType: att.type,
|
||||
});
|
||||
let detail: FreeformDocDetail;
|
||||
try {
|
||||
detail = await createFreeformDoc({
|
||||
projectId,
|
||||
title: baseTitle || att.name,
|
||||
contentMarkdown: markdown,
|
||||
space: 'my_private',
|
||||
clientKey,
|
||||
});
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
throw new Error(`附件「${att.name}」服务端注册失败:${msg}。未发送消息,请检查网络后重试。`);
|
||||
}
|
||||
const serverDocId = detail?.document?.doc_id;
|
||||
if (!serverDocId || serverDocId.startsWith('doc-upload-')) {
|
||||
throw new Error(`附件「${att.name}」未返回服务端文档 ID,已阻止生成本地 orphan。`);
|
||||
}
|
||||
const deliverable = deliverableFromFreeformUpload({
|
||||
detail,
|
||||
projectId,
|
||||
userId,
|
||||
userName,
|
||||
att,
|
||||
});
|
||||
out.push({
|
||||
clientKey,
|
||||
serverDocId,
|
||||
detail,
|
||||
deliverable,
|
||||
attachment: { ...att, docId: serverDocId },
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export type UploadAstPair = { docId: string; ast: DocumentAST };
|
||||
Reference in New Issue
Block a user