merge: fix/document-revision-runtime-ui-evidence

This commit is contained in:
weichen
2026-09-13 11:13:58 +00:00
26 changed files with 2377 additions and 147 deletions
+1 -1
View File
@@ -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 实战验收
+5
View File
@@ -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;
+124
View File
@@ -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)
})
}
+83
View File
@@ -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
View File
@@ -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 == "" {
+3 -3
View File
@@ -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) {
+2 -2
View File
@@ -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
View File
@@ -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())
}
}
+71
View File
@@ -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 {
+1
View File
@@ -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)
+882 -26
View File
@@ -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',
});
});
});
+15 -3
View File
@@ -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
View File
@@ -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}
+30
View File
@@ -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;
}
/**
* 从单条智能体回复保存为我的私密文档(幂等)
*/
+10
View File
@@ -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 || '', /服务端注册失败/);
});
+40 -22
View File
@@ -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;
}
+128
View File
@@ -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');
});
});
+153
View File
@@ -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 };