From 638083cc7425fa6076dbcb6660edf8abf4d27ae3 Mon Sep 17 00:00:00 2001 From: weichen Date: Sun, 20 Sep 2026 09:10:04 +0000 Subject: [PATCH 1/6] docs(ops): claim test-env triage fixes (dup name / member session / preview) Co-authored-by: Cursor --- .doc/ops/CURRENT.md | 7 ++- .../2026-09-20-test-env-triage-fixes.md | 56 +++++++++++++++++++ 2 files changed, 60 insertions(+), 3 deletions(-) create mode 100644 .doc/ops/short-term/2026-09-20-test-env-triage-fixes.md diff --git a/.doc/ops/CURRENT.md b/.doc/ops/CURRENT.md index b73cb346..1b217908 100644 --- a/.doc/ops/CURRENT.md +++ b/.doc/ops/CURRENT.md @@ -5,15 +5,16 @@ ## 焦点 -1. **2026-09-20 dev→test 晋升完成 (rc-v1.0.0-beta.6)**:合入 bug-hunt permissions/uploads;test `:8081` schema **56** healthy;助手真实对话 + 小队 intake + 12 项回归 **PASS**(见 [test-report](../test-reports/2026-09-20-test-env-beta6-assistant-squad-test-report.md));验证后库已恢复 S1 基线。 -2. **2026-09-19 审计整改批 1–4 done**(合入 `844f757` / `08f4e0b` / `a2a49a6` / `d73bc79`)。余项见 [long-term](./long-term/2026-09-19-audit-remainder.md)。 -3. **Control Center 已移出本仓库**:项目进度由独立统一管理系统承担;本仓库恢复为智能体系统。见 [任务](./short-term/2026-09-19-remove-control-center.md) +1. **2026-09-20 test 环境三问题整改(进行中)**:全局项目名唯一、成员拉入后会话可见、上传文档服务端预览回退。见 [任务](./short-term/2026-09-20-test-env-triage-fixes.md)。 +2. **2026-09-20 dev→test 晋升完成 (rc-v1.0.0-beta.6)**:合入 bug-hunt permissions/uploads;test `:8081` schema **56** healthy;助手真实对话 + 小队 intake + 12 项回归 **PASS**(见 [test-report](../test-reports/2026-09-20-test-env-beta6-assistant-squad-test-report.md));验证后库已恢复 S1 基线。 +3. **2026-09-19 审计整改批 1–4 done**(合入 `844f757` / `08f4e0b` / `a2a49a6` / `d73bc79`)。余项见 [long-term](./long-term/2026-09-19-audit-remainder.md)。 4. **红线**:禁止整支 merge FAV;不默认升 `MULTI_INTENT_PLAN_TIER=full`;`SEMANTIC_SUMMARY_REPLACE` 不随 W2 开启;FAV live 观察窗证据驱动;`FAV_DEGRADED_READ=false`;项目 143 只读;SWBOOT-10 保持 fail-closed stub;D7 待合规签字 ## 并行认领 | owner | task | branch | worktree | mutex | status | |-------|------|--------|----------|-------|--------| +| Cursor-Auto | 2026-09-20-test-env-triage-fixes | fix/test-env-triage-2026-09-20 | - | projectworkflow+chat+assets | in_progress | | Cursor-Auto | 2026-09-20-test-env-beta6-verification | docs/test-env-beta6-verification | - | infra+docs | done | | Cursor-Auto | 2026-09-20-bug-hunt-permissions-uploads | fix/bug-hunt-2026-09-20-permissions-and-uploads | - | authz+web+projectworkflow | done | | Cursor | audit-b1-data-consistency | fix/audit-b1-data-consistency | - | docgov+workflow+chat | done | diff --git a/.doc/ops/short-term/2026-09-20-test-env-triage-fixes.md b/.doc/ops/short-term/2026-09-20-test-env-triage-fixes.md new file mode 100644 index 00000000..d3ecfa10 --- /dev/null +++ b/.doc/ops/short-term/2026-09-20-test-env-triage-fixes.md @@ -0,0 +1,56 @@ +--- +title: "test 环境三问题整改(重名/成员可见/文档预览)" +status: in_progress +created: 2026-09-20 +updated: 2026-09-20 +owner: Cursor-Auto +branch: fix/test-env-triage-2026-09-20 +worktree: "-" +mutex: projectworkflow+chat+assets +--- + +# test 环境三问题整改(重名/成员可见/文档预览) + +## 目标 + +修复 test 环境实测发现的三项缺陷:全局项目名唯一、成员拉入后可见项目/会话、上传文档可服务端回退预览。 + +## 范围 + +- 迁移 `00057`:全局唯一项目名索引 + 存量改名 +- 应用层创建项目校验改为全局查重 +- 拉人入项目后自动建成员私有会话;upsert 允许项目管理员代注册 +- 资产 raw 下载端点 + 前端预览服务端回退 +- test 库数据修复(退役项目 4、修会话归属、补陈威会话) + +## 非目标 + +- 不改项目 143 / SWBOOT / FAV +- 不物理删除项目 4(仅 `demo_retired`) +- 不改变跨环境数据隔离 + +## 步骤 + +1. [ ] 迁移 00057 + 应用层全局重名校验 +2. [ ] 会话归属修复(拉人建会话 + upsert 代注册) +3. [ ] 资产 raw 端点 + 前端预览回退 +4. [ ] 单测 / vet / npm test +5. [ ] 合入 dev 冒烟 +6. [ ] test 库数据修复 + 端到端验证 + 报告 + +## 验收标准 + +- [ ] test 库无重名项目;跨用户创建重名返回 409 +- [ ] 陈威登录可见项目 5 与会话 `sess-p5-u35` +- [ ] 刷新后 PDF 可通过服务端 raw 回退预览 +- [ ] `go vet ./...` + `go test -short ./...` + `cd web && npm test` 全绿 + +## 相关链接 + +- Plan: `.cursor/plans/test_环境三问题整改_202c4633.plan.md` +- Test report: (完成后补) + +## 证据 / 备注 + +- test 库实测:项目 4/5 同名「全自动冲击波治疗仪」;陈威 (35) 已是 project 5 active member;会话均归属 admin(4) +- file_reference 371 PDF 在 CAS 有 blob,但无 raw 下载 API From 28478bdca5cdbb3bf774d15fa48fddff55a9ddb5 Mon Sep 17 00:00:00 2001 From: weichen Date: Sun, 20 Sep 2026 09:10:55 +0000 Subject: [PATCH 2/6] fix(db): enforce global unique project names across creators Replace per-creator name uniqueness with a global index among non-retired projects, and reject cross-user duplicate creates with 409. Co-authored-by: Cursor --- ..._2026-09-20-global-unique-project-name.sql | 38 +++++++++++ internal/projectworkflow/errors.go | 4 +- internal/projectworkflow/service.go | 4 +- internal/projectworkflow/service_test.go | 11 +++- internal/projectworkflow/store.go | 65 +++++++++++++++++++ internal/server/project_handlers.go | 2 +- 6 files changed, 116 insertions(+), 8 deletions(-) create mode 100644 deploy/postgres/goose/00057_2026-09-20-global-unique-project-name.sql diff --git a/deploy/postgres/goose/00057_2026-09-20-global-unique-project-name.sql b/deploy/postgres/goose/00057_2026-09-20-global-unique-project-name.sql new file mode 100644 index 00000000..660ff277 --- /dev/null +++ b/deploy/postgres/goose/00057_2026-09-20-global-unique-project-name.sql @@ -0,0 +1,38 @@ +-- +goose Up +-- 全局项目名唯一:任意活跃项目不得重名(跨创建人)。 +-- 替换 00056 的 per-creator 唯一索引。 + +-- Deduplicate globally by lower(trim(name)) among non-retired rows (keep lowest id). +WITH dups AS ( + SELECT id, + ROW_NUMBER() OVER ( + PARTITION BY lower(trim(name)) + ORDER BY id ASC + ) AS rn + FROM projects + WHERE length(trim(name)) > 0 + AND COALESCE(status, 'active') <> 'demo_retired' +) +UPDATE projects p +SET name = p.name || ' (dup-' || p.id || ')', + updated_at = now() +FROM dups d +WHERE p.id = d.id AND d.rn > 1; + +DROP INDEX IF EXISTS uq_projects_creator_name; + +CREATE UNIQUE INDEX IF NOT EXISTS uq_projects_name_global + ON projects (lower(trim(name))) + WHERE length(trim(name)) > 0 + AND COALESCE(status, 'active') <> 'demo_retired'; + +COMMENT ON INDEX uq_projects_name_global IS + 'Global unique project name among non-retired projects (case-insensitive, trimmed).'; + +-- +goose Down +DROP INDEX IF EXISTS uq_projects_name_global; + +CREATE UNIQUE INDEX IF NOT EXISTS uq_projects_creator_name + ON projects (creator_user_id, lower(trim(name))) + WHERE creator_user_id IS NOT NULL AND creator_user_id > 0 + AND length(trim(name)) > 0; diff --git a/internal/projectworkflow/errors.go b/internal/projectworkflow/errors.go index 2c431665..e18aaaff 100644 --- a/internal/projectworkflow/errors.go +++ b/internal/projectworkflow/errors.go @@ -24,8 +24,8 @@ var ( // ErrInvalidInput 输入参数不合法 ErrInvalidInput = errors.New("invalid parameter: required field missing or malformed") - // ErrDuplicateProjectName 同一创建人下已存在同名项目 - ErrDuplicateProjectName = errors.New("duplicate project name for creator") + // ErrDuplicateProjectName 全局已存在同名活跃项目 + ErrDuplicateProjectName = errors.New("duplicate project name") // ErrLegacySwitchBlocked 已启用实例化工作流治理,禁止旧式即时切换 ErrLegacySwitchBlocked = errors.New("legacy workflow switch blocked: use project settings activation flow") diff --git a/internal/projectworkflow/service.go b/internal/projectworkflow/service.go index baee578f..87a7a792 100644 --- a/internal/projectworkflow/service.go +++ b/internal/projectworkflow/service.go @@ -60,8 +60,8 @@ func (s *Service) CreateProject(ctx context.Context, user *auth.UserAccount, in } } - if existing, err := s.store.FindProjectByCreatorAndName(ctx, user.ID, name); err == nil && existing != nil { - return nil, fmt.Errorf("%w: 您已创建同名项目「%s」", ErrDuplicateProjectName, name) + if existing, err := s.store.FindProjectByName(ctx, name); err == nil && existing != nil { + return nil, fmt.Errorf("%w: 项目名「%s」已被占用,请更换名称后重试", ErrDuplicateProjectName, name) } else if err != nil && err != ErrProjectNotFound { return nil, err } diff --git a/internal/projectworkflow/service_test.go b/internal/projectworkflow/service_test.go index 441f5775..cee8df9e 100644 --- a/internal/projectworkflow/service_test.go +++ b/internal/projectworkflow/service_test.go @@ -339,12 +339,17 @@ func TestCreateProject_ClientKeyIdempotentAndDuplicateName(t *testing.T) { } member := &auth.UserAccount{ID: 99, Username: "other"} - other, err := svc.CreateProject(ctx, member, CreateProjectInput{Name: "幂等项目"}) + _, err = svc.CreateProject(ctx, member, CreateProjectInput{Name: "幂等项目"}) + if err == nil || !errors.Is(err, ErrDuplicateProjectName) { + t.Fatalf("different creator same name must fail globally, got %v", err) + } + + other, err := svc.CreateProject(ctx, member, CreateProjectInput{Name: "幂等项目-其他"}) if err != nil { - t.Fatalf("different creator same name should succeed: %v", err) + t.Fatalf("different name should succeed: %v", err) } if other.ID == first.ID { - t.Fatalf("different creators must get distinct projects") + t.Fatalf("different names must get distinct projects") } } diff --git a/internal/projectworkflow/store.go b/internal/projectworkflow/store.go index a8bf5239..1352ffc1 100644 --- a/internal/projectworkflow/store.go +++ b/internal/projectworkflow/store.go @@ -20,6 +20,7 @@ type Store interface { GetProjectByCode(ctx context.Context, code string) (*Project, error) GetProjectByClientKey(ctx context.Context, creatorUserID int64, clientKey string) (*Project, error) FindProjectByCreatorAndName(ctx context.Context, creatorUserID int64, name string) (*Project, error) + FindProjectByName(ctx context.Context, name string) (*Project, error) ListAllProjects(ctx context.Context) ([]*Project, error) ListProjectsForUser(ctx context.Context, userID int64) ([]*Project, error) ListProjectWorkflows(ctx context.Context, projectID int64) ([]*ProjectWorkflow, error) @@ -201,6 +202,50 @@ func (s *pgStore) FindProjectByCreatorAndName(ctx context.Context, creatorUserID return p, nil } +func (s *pgStore) FindProjectByName(ctx context.Context, name string) (*Project, error) { + name = strings.TrimSpace(name) + if name == "" { + return nil, ErrProjectNotFound + } + query := ` + SELECT id, code, name, customer_name, device_type, intended_use, target_market, + COALESCE(product_domain, ''), COALESCE(project_type_key, ''), COALESCE(compliance_profile_key, ''), + COALESCE(governance_profile, ''), COALESCE(team_mode, ''), COALESCE(execution_mode, ''), + COALESCE(data_sensitivity, ''), COALESCE(network_policy, ''), COALESCE(budget_class, ''), + COALESCE(problem_statement, ''), COALESCE(target_users, ''), COALESCE(target_outcome, ''), + COALESCE(product_shape, ''), + current_stage, environment, status, COALESCE(creator_user_id, 1), + COALESCE(lifecycle_status, 'planning'), COALESCE(active_workflow_key, ''), + COALESCE(workflow_selection_version, 1), COALESCE(workflow_plan_revision, 0), + active_workflow_changed_at, active_workflow_changed_by, created_at, updated_at, + COALESCE(client_key, '') + FROM projects + WHERE lower(trim(name)) = lower(trim($1)) + AND COALESCE(status, 'active') <> 'demo_retired' + ORDER BY id ASC + LIMIT 1 + ` + p := &Project{} + err := s.pool.QueryRow(ctx, query, name).Scan( + &p.ID, &p.Code, &p.Name, &p.CustomerName, &p.DeviceType, &p.IntendedUse, &p.TargetMarket, + &p.ProductDomain, &p.ProjectTypeKey, &p.ComplianceProfileKey, + &p.GovernanceProfile, &p.TeamMode, &p.ExecutionMode, + &p.DataSensitivity, &p.NetworkPolicy, &p.BudgetClass, + &p.ProblemStatement, &p.TargetUsers, &p.TargetOutcome, &p.ProductShape, + &p.CurrentStage, &p.Environment, &p.Status, &p.CreatorUserID, + &p.LifecycleStatus, &p.ActiveWorkflowKey, &p.WorkflowSelectionVersion, &p.WorkflowPlanRevision, + &p.ActiveWorkflowChangedAt, &p.ActiveWorkflowChangedBy, &p.CreatedAt, &p.UpdatedAt, + &p.ClientKey, + ) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrProjectNotFound + } + if err != nil { + return nil, fmt.Errorf("find project by name failed: %w", err) + } + return p, nil +} + func (s *pgStore) GetProjectByID(ctx context.Context, id int64) (*Project, error) { query := ` SELECT id, code, name, customer_name, device_type, intended_use, target_market, @@ -838,6 +883,26 @@ func (m *memStore) FindProjectByCreatorAndName(ctx context.Context, creatorUserI return nil, ErrProjectNotFound } +func (m *memStore) FindProjectByName(ctx context.Context, name string) (*Project, error) { + name = strings.TrimSpace(name) + if name == "" { + return nil, ErrProjectNotFound + } + m.mu.RLock() + defer m.mu.RUnlock() + want := strings.ToLower(name) + for _, p := range m.projects { + if strings.EqualFold(p.Status, "demo_retired") { + continue + } + if strings.ToLower(strings.TrimSpace(p.Name)) == want { + cpy := *p + return &cpy, nil + } + } + return nil, ErrProjectNotFound +} + func (m *memStore) GetProjectByID(ctx context.Context, id int64) (*Project, error) { m.mu.RLock() defer m.mu.RUnlock() diff --git a/internal/server/project_handlers.go b/internal/server/project_handlers.go index ecd8e53e..80a6deac 100644 --- a/internal/server/project_handlers.go +++ b/internal/server/project_handlers.go @@ -46,7 +46,7 @@ func (s *Server) handleCreateProject(w http.ResponseWriter, r *http.Request) { if errors.Is(err, projectworkflow.ErrDuplicateProjectName) { writeJSONResponse(w, http.StatusConflict, map[string]string{ "error_code": "ERR_DUPLICATE_PROJECT_NAME", - "message": "您已创建同名项目,请更换名称后重试", + "message": "项目名已被占用,请更换名称后重试", }) return } From aef3d6d5cbeb938b692e13d3adfb4c91e35bf9cd Mon Sep 17 00:00:00 2001 From: weichen Date: Sun, 20 Sep 2026 09:11:36 +0000 Subject: [PATCH 3/6] fix(server): create member chat sessions on invite and allow manager upsert Project managers can register sess-p{N}-u{uid} for other members; adding a member also ensures their private session so the sidebar is never empty. Co-authored-by: Cursor --- internal/server/handlers.go | 28 +++++++++++++++++++-- internal/server/project_members_handlers.go | 19 ++++++++++++++ 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/internal/server/handlers.go b/internal/server/handlers.go index 79069c3b..b11de1a9 100644 --- a/internal/server/handlers.go +++ b/internal/server/handlers.go @@ -1067,6 +1067,7 @@ type upsertChatSessionRequest struct { } // handleUpsertChatSession 注册或更新会话元数据(立项/成员加入时同步)。 +// 默认归属认证用户本人;项目管理员可为其他成员代注册(防水平越权)。 func (s *Server) handleUpsertChatSession(w http.ResponseWriter, r *http.Request) { if s.ctxMgr == nil { writeSafeError(w, r, http.StatusServiceUnavailable, "context manager not initialized") @@ -1086,9 +1087,32 @@ func (s *Server) handleUpsertChatSession(w http.ResponseWriter, r *http.Request) writeSafeError(w, r, http.StatusBadRequest, "missing session_id") return } - req.UserID = user.ID - summary, err := s.ctxMgr.EnsureChatSessionOwned(r.Context(), req.SessionID, req.UserID, req.ProjectID, req.Title) + ownerID := user.ID + if req.UserID > 0 && req.UserID != user.ID { + if req.ProjectID <= 0 { + writeJSONResponse(w, http.StatusBadRequest, map[string]string{ + "error_code": "ERR_INVALID_PARAM", + "message": "代注册他人会话时必须指定 project_id", + }) + return + } + can, err := s.authzSvc.CanManageProjectMembers(r.Context(), req.ProjectID, user.ID, user.RoleID) + if err != nil { + writeSafeError(w, r, http.StatusInternalServerError, err.Error()) + return + } + if !can { + writeJSONResponse(w, http.StatusForbidden, map[string]string{ + "error_code": "ERR_FORBIDDEN", + "message": "无权为其他用户注册项目会话", + }) + return + } + ownerID = req.UserID + } + + summary, err := s.ctxMgr.EnsureChatSessionOwned(r.Context(), req.SessionID, ownerID, req.ProjectID, req.Title) if err != nil { if errors.Is(err, ctxmgr.ErrSessionOwnerMismatch) { writeJSONResponse(w, http.StatusForbidden, map[string]string{ diff --git a/internal/server/project_members_handlers.go b/internal/server/project_members_handlers.go index e20328ba..a3eed9de 100644 --- a/internal/server/project_members_handlers.go +++ b/internal/server/project_members_handlers.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "fmt" "log" "net/http" "strconv" @@ -128,9 +129,27 @@ func (s *Server) handleAddProjectMember(w http.ResponseWriter, r *http.Request, writeDutySyncError(w) return } + s.ensureMemberChatSession(r.Context(), projectID, req.UserID) writeJSONResponse(w, http.StatusCreated, map[string]bool{"success": true}) } +// ensureMemberChatSession 为项目成员补齐私有会话(sess-p{id}-u{uid}),失败仅记日志。 +func (s *Server) ensureMemberChatSession(ctx context.Context, projectID, memberUserID int64) { + if s == nil || s.ctxMgr == nil || projectID <= 0 || memberUserID <= 0 { + return + } + title := "" + if s.pwSvc != nil { + if proj, err := s.pwSvc.GetProject(ctx, projectID, 0); err == nil && proj != nil { + title = proj.Name + } + } + sessionID := fmt.Sprintf("sess-p%d-u%d", projectID, memberUserID) + if _, err := s.ctxMgr.EnsureChatSessionOwned(ctx, sessionID, memberUserID, projectID, title); err != nil { + log.Printf("[members] ensure chat session for project=%d user=%d failed: %v", projectID, memberUserID, err) + } +} + func (s *Server) handleUpdateProjectMember(w http.ResponseWriter, r *http.Request, user *auth.UserAccount, projectID int64) { if !s.requireProjectMemberManage(w, r, user, projectID) { return From 21782751ab0eae2d893b701dbfdba32c9660e6c3 Mon Sep 17 00:00:00 2001 From: weichen Date: Sun, 20 Sep 2026 09:14:12 +0000 Subject: [PATCH 4/6] fix(server,web): serve project asset raw and recover preview after refresh Add GET /assets/{id}/raw with project access checks; frontend falls back to CAS by assetId or filename when local blob is gone. Align upload accept list with backend whitelist and register binaries into CAS on chat upload. Co-authored-by: Cursor --- internal/server/asset_handlers.go | 41 +++++++ internal/server/server.go | 1 + web/src/api/projectAssets.ts | 16 +++ web/src/components/chat/InputArea.tsx | 2 +- .../doc/preview/FilePreviewPanel.tsx | 115 +++++++++++++++--- web/src/hooks/useSendMessage.ts | 18 +++ web/src/types/workbench.ts | 2 + 7 files changed, 176 insertions(+), 19 deletions(-) diff --git a/internal/server/asset_handlers.go b/internal/server/asset_handlers.go index 7c299cac..a66cc5c6 100644 --- a/internal/server/asset_handlers.go +++ b/internal/server/asset_handlers.go @@ -118,6 +118,47 @@ func (s *Server) handleListProjectAssets(w http.ResponseWriter, r *http.Request) writeJSONResponse(w, http.StatusOK, out) } +func (s *Server) handleGetProjectAssetRaw(w http.ResponseWriter, r *http.Request) { + projectID, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil || projectID <= 0 { + writeSafeError(w, r, http.StatusBadRequest, "invalid project id") + return + } + assetID, err := strconv.ParseInt(r.PathValue("asset_id"), 10, 64) + if err != nil || assetID <= 0 { + writeSafeError(w, r, http.StatusBadRequest, "invalid asset id") + return + } + if _, ok := s.requireProjectAccess(w, r, projectID); !ok { + return + } + cas := s.cas + if cas == nil { + writeSafeError(w, r, http.StatusServiceUnavailable, "storage unavailable") + return + } + rc, ref, err := cas.OpenFileByRef(r.Context(), assetID) + if err != nil || ref == nil { + writeSafeError(w, r, http.StatusNotFound, "asset not found") + return + } + defer rc.Close() + if ref.ProjectID != projectID { + writeSafeError(w, r, http.StatusNotFound, "asset not found") + return + } + filename := ref.FileName + if filename == "" { + filename = "download.bin" + } + mimeType := mimeFromExt(filename) + w.Header().Set("Content-Type", mimeType) + w.Header().Set("Content-Disposition", "inline; filename=\""+filepath.Base(filename)+"\"") + w.Header().Set("Cache-Control", "private, max-age=300") + w.WriteHeader(http.StatusOK) + _, _ = io.Copy(w, rc) +} + func parseUserIDHeader(r *http.Request) int64 { raw := strings.TrimSpace(r.Header.Get("X-User-ID")) if raw == "" { diff --git a/internal/server/server.go b/internal/server/server.go index 78447c65..c75c3ecf 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -237,6 +237,7 @@ func (s *Server) Routes() http.Handler { mux.HandleFunc("POST /api/v1/admin/workspaces/reconcile", s.handleReconcileWorkspace) mux.HandleFunc("POST /api/v1/projects/{id}/assets", s.handleUploadProjectAsset) mux.HandleFunc("GET /api/v1/projects/{id}/assets", s.handleListProjectAssets) + mux.HandleFunc("GET /api/v1/projects/{id}/assets/{asset_id}/raw", s.handleGetProjectAssetRaw) // 6. 多级上下文与偏好记忆管理 mux.HandleFunc("GET /api/v1/context/user", s.handleGetUserContext) diff --git a/web/src/api/projectAssets.ts b/web/src/api/projectAssets.ts index d69694f5..d4362db5 100644 --- a/web/src/api/projectAssets.ts +++ b/web/src/api/projectAssets.ts @@ -36,3 +36,19 @@ export async function listProjectAssets(projectId: number): Promise { + const res = await fetch(projectAssetRawURL(projectId, assetId), { + headers: getBearerAuthHeaders(), + }); + if (!res.ok) { + throw new Error(`fetch asset raw failed: HTTP ${res.status}`); + } + return res.blob(); +} diff --git a/web/src/components/chat/InputArea.tsx b/web/src/components/chat/InputArea.tsx index 7b306cdc..1975d555 100644 --- a/web/src/components/chat/InputArea.tsx +++ b/web/src/components/chat/InputArea.tsx @@ -228,7 +228,7 @@ export const InputArea: React.FC = ({ multiple style={{ display: 'none' }} onChange={handleFileChange} - accept=".pdf,.doc,.docx,.md,.txt,.xlsx" + accept=".pdf,.docx,.md,.txt,.xlsx" /> {/* 超出限制或操作提示条 */} diff --git a/web/src/components/doc/preview/FilePreviewPanel.tsx b/web/src/components/doc/preview/FilePreviewPanel.tsx index 17a50dd5..907e8611 100644 --- a/web/src/components/doc/preview/FilePreviewPanel.tsx +++ b/web/src/components/doc/preview/FilePreviewPanel.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { FileText, FileSpreadsheet, @@ -15,6 +15,7 @@ import { ShieldCheck, } from 'lucide-react'; import { DeliverableItem } from '../../../types/workbench'; +import { fetchProjectAssetBlob, listProjectAssets } from '../../../api/projectAssets'; import { ImageViewer } from './renderers/ImageViewer'; import { PdfViewer } from './renderers/PdfViewer'; import { PptViewer } from './renderers/PptViewer'; @@ -35,15 +36,69 @@ export const FilePreviewPanel: React.FC = ({ }) => { const [viewMode, setViewMode] = useState<'preview' | 'info'>('preview'); const [isFullscreen, setIsFullscreen] = useState(false); + const [recoveredBlob, setRecoveredBlob] = useState(null); + const [recoverState, setRecoverState] = useState<'idle' | 'loading' | 'done' | 'miss'>('idle'); const fileMeta = deliverable.fileMeta; const fileName = fileMeta?.originalName || deliverable.title; const fileSize = fileMeta?.size || '未知大小'; - const fileRaw = fileMeta?.fileRaw; + const fileRaw = fileMeta?.fileRaw || recoveredBlob || undefined; const fileUrl = fileMeta?.fileUrl; - // 文件内容可用性判断:fileRaw(当前 session 内存)或 fileUrl(非 blob 服务端 URL)任一有效即可预览 - const hasFileContent = Boolean(fileRaw) || Boolean(fileUrl && !fileUrl.startsWith('blob:')); + const localHasContent = Boolean(fileMeta?.fileRaw) || Boolean(fileUrl && !fileUrl.startsWith('blob:')); + const hasFileContent = localHasContent || Boolean(recoveredBlob); + + useEffect(() => { + if (localHasContent || recoveredBlob) { + setRecoverState('done'); + return; + } + const projectId = deliverable.projectId; + if (!projectId || projectId <= 0) { + setRecoverState('miss'); + return; + } + let cancelled = false; + const assetIdHint = fileMeta?.assetId; + setRecoverState('loading'); + void (async () => { + try { + let assetId = assetIdHint && assetIdHint > 0 ? assetIdHint : 0; + if (!assetId) { + const assets = await listProjectAssets(projectId); + if (cancelled) return; + const want = (fileMeta?.originalName || '').trim().toLowerCase(); + const match = assets.find((a) => (a.filename || '').trim().toLowerCase() === want) + || assets.find((a) => want && (a.filename || '').toLowerCase().includes(want.replace(/\.[^.]+$/, ''))); + assetId = match?.asset_id ?? 0; + } + if (!assetId) { + if (!cancelled) setRecoverState('miss'); + return; + } + const blob = await fetchProjectAssetBlob(projectId, assetId); + if (cancelled) return; + setRecoveredBlob(blob); + setRecoverState('done'); + } catch { + if (!cancelled) setRecoverState('miss'); + } + })(); + return () => { + cancelled = true; + }; + }, [localHasContent, recoveredBlob, deliverable.projectId, deliverable.docId, fileMeta?.assetId, fileMeta?.originalName]); + + const recoveredObjectUrl = useMemo(() => { + if (!recoveredBlob) return undefined; + return URL.createObjectURL(recoveredBlob); + }, [recoveredBlob]); + + useEffect(() => { + return () => { + if (recoveredObjectUrl) URL.revokeObjectURL(recoveredObjectUrl); + }; + }, [recoveredObjectUrl]); // 多级智能提取真实文件扩展名(兼容 originalName、summary、title 中包含的 .docx, .pdf 等) const detectExtension = (): string => { @@ -80,6 +135,7 @@ export const FilePreviewPanel: React.FC = ({ }; const fileType = getFileType(); + const effectiveFileUrl = fileUrl && !fileUrl.startsWith('blob:') ? fileUrl : recoveredObjectUrl; // 获取类型对应的图标与主题色 const getTypeBadge = () => { @@ -160,22 +216,44 @@ export const FilePreviewPanel: React.FC = ({ document.body.removeChild(a); // 延迟释放,保证浏览器有足够时间启动下载 setTimeout(() => URL.revokeObjectURL(url), 2000); - } else if (fileUrl && !fileUrl.startsWith('blob:')) { - // 服务端持久化 URL,可直接使用 + } else if (effectiveFileUrl) { + // 服务端持久化 URL 或恢复的 blob URL const a = document.createElement('a'); - a.href = fileUrl; + a.href = effectiveFileUrl; a.download = fileName; document.body.appendChild(a); a.click(); document.body.removeChild(a); } else { // 文件内容不可用(刷新后 File 对象/blob URL 均已失效) - alert(`文件「${fileName}」的本地内容在页面刷新后已释放。请在对话框中重新上传该文件以恢复预览与下载功能。`); + alert(`文件「${fileName}」暂无法下载。请确认项目资产仍在服务端,或重新上传该文件。`); } }; - // 文件内容不可用时的占位说明(刷新后 File 对象丢失,需重新上传) - const renderNoContentPlaceholder = () => ( + // 文件内容不可用时的占位说明(刷新后 File 对象丢失;优先尝试服务端回退) + const renderNoContentPlaceholder = () => { + if (recoverState === 'loading') { + return ( +
+

+ 正在从服务端恢复文件… +

+

文件名:{fileName}

+
+ ); + } + return (
= ({ }} > 浏览器本地文件({fileName})仅在上传当次会话内保留于内存。 - 页面刷新后,内存中的文件内容会被浏览器释放,无法再次预览或下载。 + 页面刷新后若服务端未登记同名项目资产,则无法自动恢复预览。

= ({

- ); + ); + }; // 渲染对应的文档预览子组件 const renderViewer = () => { @@ -279,25 +358,25 @@ export const FilePreviewPanel: React.FC = ({ ); case 'pdf': - return ; + return ; case 'excel': return ( ); case 'image': - return ; + return ; case 'ppt': - return ; + return ; case 'text': - return ; + return ; default: return (
console.warn('[fileStore] 保存上传文件失败:', r.serverDocId, e), ); + // 二进制同步登记 CAS,刷新后可经 raw 端点回退预览 + const extMatch = r.attachment.name.match(/\.([^.]+)$/); + const ext = extMatch ? extMatch[1].toLowerCase() : ''; + const TEXT_EXTS = new Set(['txt', 'md', 'csv', 'json', 'xml', 'yaml', 'yml', 'log']); + if (!TEXT_EXTS.has(ext) && currentProjectId > 0) { + try { + const uploaded = await uploadProjectAsset( + currentProjectId, + r.attachment.fileRaw as Blob, + r.attachment.name, + ); + if (r.deliverable.fileMeta) { + r.deliverable.fileMeta.assetId = uploaded.asset_id; + } + } catch (e) { + console.warn('[assets] 上传附件到 CAS 失败(预览回退可能不可用):', e); + } + } } } diff --git a/web/src/types/workbench.ts b/web/src/types/workbench.ts index cbd30934..b83b0b0a 100644 --- a/web/src/types/workbench.ts +++ b/web/src/types/workbench.ts @@ -296,6 +296,8 @@ export interface UploadedFileMetadata { extension: string; fileRaw?: File | Blob; fileUrl?: string; + /** 服务端 CAS 资产 ID(刷新后可经 raw 端点回退预览) */ + assetId?: number; uploadedAt: string; } From 18a323c4dc35ca55818e43e941d6b78eedb10c15 Mon Sep 17 00:00:00 2001 From: weichen Date: Sun, 20 Sep 2026 09:18:36 +0000 Subject: [PATCH 5/6] test: cover asset raw round-trip and harden cross-user session upsert Deny foreign user_id upsert without manage rights (403); unique project names in ctxmgr PG fixture for global name index; add test-env data repair SQL. Co-authored-by: Cursor --- internal/ctxmgr/manager_test.go | 5 +- internal/server/asset_handlers_test.go | 72 ++++++++++++++++++++++++++ internal/server/handlers.go | 6 +-- scripts/fix-test-env-triage-data.sql | 48 +++++++++++++++++ 4 files changed, 126 insertions(+), 5 deletions(-) create mode 100644 scripts/fix-test-env-triage-data.sql diff --git a/internal/ctxmgr/manager_test.go b/internal/ctxmgr/manager_test.go index 3628b46a..4baa4ca2 100644 --- a/internal/ctxmgr/manager_test.go +++ b/internal/ctxmgr/manager_test.go @@ -153,11 +153,12 @@ func TestContextManager_DatabaseIntegration(t *testing.T) { // 准备真实项目与用户 var projID int64 projCode := "PROJ_MGR_" + time.Now().Format("20060102150405") + projName := "智能微创内窥镜-" + time.Now().Format("150405.000") err = pool.QueryRow(ctx, ` INSERT INTO projects (code, name, customer_name, device_type, current_stage) - VALUES ($1, '智能微创内窥镜', '水木创新中心', '内窥镜', 'V0.5_feasibility') + VALUES ($1, $2, '水木创新中心', '内窥镜', 'V0.5_feasibility') RETURNING id - `, projCode).Scan(&projID) + `, projCode, projName).Scan(&projID) if err != nil { t.Fatalf("insert project failed: %v", err) } diff --git a/internal/server/asset_handlers_test.go b/internal/server/asset_handlers_test.go index 69eb7bad..32c523b7 100644 --- a/internal/server/asset_handlers_test.go +++ b/internal/server/asset_handlers_test.go @@ -3,9 +3,11 @@ package server import ( "bytes" "encoding/json" + "io" "mime/multipart" "net/http" "net/http/httptest" + "strconv" "strings" "testing" @@ -96,3 +98,73 @@ func TestUploadProjectAsset_RejectsOversizedFilename(t *testing.T) { t.Fatalf("expected 415 for long filename, got %d", resp.StatusCode) } } + +func TestGetProjectAssetRaw_RoundTrip(t *testing.T) { + cfg := &config.Config{Env: "test", DataDir: t.TempDir()} + svc := bizdev.MustNewService(t, cfg) + srv := NewServer(cfg, svc) + ts := httptest.NewServer(srv.Routes()) + defer ts.Close() + + payload := []byte("raw asset preview payload") + var body bytes.Buffer + w := multipart.NewWriter(&body) + part, _ := w.CreateFormFile("file", "preview.txt") + _, _ = part.Write(payload) + _ = w.Close() + + token := loginTestAdmin(t, srv) + upReq, _ := http.NewRequest(http.MethodPost, ts.URL+"/api/v1/projects/7/assets", &body) + upReq.Header.Set("Content-Type", w.FormDataContentType()) + withBearerAuth(upReq, token) + upResp, err := http.DefaultClient.Do(upReq) + if err != nil { + t.Fatalf("upload: %v", err) + } + defer upResp.Body.Close() + if upResp.StatusCode != http.StatusOK { + t.Fatalf("upload status=%d", upResp.StatusCode) + } + var uploaded projectAssetResponse + if err := json.NewDecoder(upResp.Body).Decode(&uploaded); err != nil { + t.Fatalf("decode upload: %v", err) + } + + rawReq, _ := http.NewRequest( + http.MethodGet, + ts.URL+"/api/v1/projects/7/assets/"+strconv.FormatInt(uploaded.AssetID, 10)+"/raw", + nil, + ) + withBearerAuth(rawReq, token) + rawResp, err := http.DefaultClient.Do(rawReq) + if err != nil { + t.Fatalf("raw: %v", err) + } + defer rawResp.Body.Close() + if rawResp.StatusCode != http.StatusOK { + t.Fatalf("raw status=%d", rawResp.StatusCode) + } + got, _ := io.ReadAll(rawResp.Body) + if !bytes.Equal(got, payload) { + t.Fatalf("raw body mismatch: got %q want %q", got, payload) + } + if ct := rawResp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/plain") { + t.Fatalf("unexpected content-type: %s", ct) + } + + // Wrong project id must not leak the blob. + wrongReq, _ := http.NewRequest( + http.MethodGet, + ts.URL+"/api/v1/projects/999/assets/"+strconv.FormatInt(uploaded.AssetID, 10)+"/raw", + nil, + ) + withBearerAuth(wrongReq, token) + wrongResp, err := http.DefaultClient.Do(wrongReq) + if err != nil { + t.Fatalf("wrong project raw: %v", err) + } + defer wrongResp.Body.Close() + if wrongResp.StatusCode == http.StatusOK { + t.Fatalf("expected non-200 for mismatched project, got %d", wrongResp.StatusCode) + } +} diff --git a/internal/server/handlers.go b/internal/server/handlers.go index b11de1a9..f0acf80a 100644 --- a/internal/server/handlers.go +++ b/internal/server/handlers.go @@ -1091,9 +1091,9 @@ func (s *Server) handleUpsertChatSession(w http.ResponseWriter, r *http.Request) ownerID := user.ID if req.UserID > 0 && req.UserID != user.ID { if req.ProjectID <= 0 { - writeJSONResponse(w, http.StatusBadRequest, map[string]string{ - "error_code": "ERR_INVALID_PARAM", - "message": "代注册他人会话时必须指定 project_id", + writeJSONResponse(w, http.StatusForbidden, map[string]string{ + "error_code": "ERR_FORBIDDEN", + "message": "无权为其他用户注册项目会话", }) return } diff --git a/scripts/fix-test-env-triage-data.sql b/scripts/fix-test-env-triage-data.sql new file mode 100644 index 00000000..998ce461 --- /dev/null +++ b/scripts/fix-test-env-triage-data.sql @@ -0,0 +1,48 @@ +-- One-shot data repair for test env triage (project 4/5 dup + member sessions). +-- Usage: +-- docker exec -i shuimu-system-pg-test psql -U app -d agent_system_test < scripts/fix-test-env-triage-data.sql +-- Safe to re-run (idempotent where possible). + +BEGIN; + +-- 1) Retire duplicate project 4 (wanjiahao); keep project 5 (admin + members + assets). +UPDATE projects +SET status = 'demo_retired', + updated_at = now() +WHERE id = 4 + AND name = '全自动冲击波治疗仪' + AND COALESCE(status, 'active') <> 'demo_retired'; + +-- 2) Fix mis-owned member sessions created by creator upsert (owner was forced to admin). +UPDATE chat_sessions +SET user_id = 32, + updated_at = now() +WHERE session_id = 'sess-p5-u32' + AND project_id = 5 + AND user_id = 4; + +UPDATE chat_sessions +SET user_id = 33, + updated_at = now() +WHERE session_id = 'sess-p5-u33' + AND project_id = 5 + AND user_id = 4; + +-- 3) Ensure chenwei (35) private session on project 5. +INSERT INTO chat_sessions (session_id, user_id, project_id, title, created_at, updated_at) +SELECT 'sess-p5-u35', 35, 5, '全自动冲击波治疗仪', now(), now() +WHERE EXISTS ( + SELECT 1 FROM project_members + WHERE project_id = 5 AND user_id = 35 AND status = 'active' +) +ON CONFLICT (session_id) DO UPDATE SET + user_id = EXCLUDED.user_id, + project_id = EXCLUDED.project_id, + title = COALESCE(NULLIF(EXCLUDED.title, ''), chat_sessions.title), + updated_at = now(); + +COMMIT; + +-- Verification queries (run after): +-- SELECT id, name, creator_user_id, status FROM projects WHERE name LIKE '%冲击波%' OR id IN (4,5); +-- SELECT session_id, user_id, project_id FROM chat_sessions WHERE project_id = 5 ORDER BY session_id; From f603f63ca7957ef7419a16f4b793030a72db3798 Mon Sep 17 00:00:00 2001 From: weichen Date: Sun, 20 Sep 2026 09:19:08 +0000 Subject: [PATCH 6/6] docs(ops): claim integration slot for test-env triage merge Co-authored-by: Cursor --- .doc/ops/CURRENT.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.doc/ops/CURRENT.md b/.doc/ops/CURRENT.md index 1b217908..a30893d6 100644 --- a/.doc/ops/CURRENT.md +++ b/.doc/ops/CURRENT.md @@ -153,7 +153,7 @@ | holder | phase | since | scope | queue | |--------|-------|-------|-------|-------| -| - | idle | 2026-09-20 | 已晋升 test (rc-v1.0.0-beta.6);C 级助手/小队/回归 PASS;库恢复 S1;test schema 56 healthy | - | +| Cursor-Auto | merging | 2026-09-20 | fix/test-env-triage-2026-09-20 → dev(全局重名/成员会话/资产预览) | - | **phase**: `idle` | `merging` | `smoke` | `e2e`