merge: fix/test-env-triage-2026-09-20

Global unique project names, member chat session provisioning, and
project asset raw preview recovery for test-env triage.
This commit is contained in:
weichen
2026-09-20 09:19:08 +00:00
20 changed files with 521 additions and 35 deletions
+5 -4
View File
@@ -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 |
@@ -152,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`
@@ -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
@@ -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;
+3 -2
View File
@@ -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)
}
+2 -2
View File
@@ -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")
+2 -2
View File
@@ -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
}
+8 -3
View File
@@ -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")
}
}
+65
View File
@@ -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()
+41
View File
@@ -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 == "" {
+72
View File
@@ -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)
}
}
+26 -2
View File
@@ -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.StatusForbidden, map[string]string{
"error_code": "ERR_FORBIDDEN",
"message": "无权为其他用户注册项目会话",
})
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{
+1 -1
View File
@@ -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
}
@@ -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
+1
View File
@@ -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)
+48
View File
@@ -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;
+16
View File
@@ -36,3 +36,19 @@ export async function listProjectAssets(projectId: number): Promise<ProjectAsset
}
return res.json();
}
/** 构建鉴权后的资产原件 URL(供预览/下载回退)。 */
export function projectAssetRawURL(projectId: number, assetId: number): string {
return `/api/v1/projects/${projectId}/assets/${assetId}/raw`;
}
/** 拉取资产二进制为 Blob(带 Bearer)。 */
export async function fetchProjectAssetBlob(projectId: number, assetId: number): Promise<Blob> {
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();
}
+1 -1
View File
@@ -228,7 +228,7 @@ export const InputArea: React.FC<InputAreaProps> = ({
multiple
style={{ display: 'none' }}
onChange={handleFileChange}
accept=".pdf,.doc,.docx,.md,.txt,.xlsx"
accept=".pdf,.docx,.md,.txt,.xlsx"
/>
{/* 超出限制或操作提示条 */}
@@ -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<FilePreviewPanelProps> = ({
}) => {
const [viewMode, setViewMode] = useState<'preview' | 'info'>('preview');
const [isFullscreen, setIsFullscreen] = useState(false);
const [recoveredBlob, setRecoveredBlob] = useState<Blob | null>(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<FilePreviewPanelProps> = ({
};
const fileType = getFileType();
const effectiveFileUrl = fileUrl && !fileUrl.startsWith('blob:') ? fileUrl : recoveredObjectUrl;
// 获取类型对应的图标与主题色
const getTypeBadge = () => {
@@ -160,22 +216,44 @@ export const FilePreviewPanel: React.FC<FilePreviewPanelProps> = ({
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 (
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
padding: '40px 28px',
background: 'var(--bg-subtle, #f8fafc)',
textAlign: 'center',
}}
>
<h4 style={{ margin: '0 0 8px', fontSize: '15px', fontWeight: 600, color: '#1e293b' }}>
正在从服务端恢复文件…
</h4>
<p style={{ margin: 0, fontSize: '13px', color: '#64748b' }}>文件名:{fileName}</p>
</div>
);
}
return (
<div
style={{
display: 'flex',
@@ -217,7 +295,7 @@ export const FilePreviewPanel: React.FC<FilePreviewPanelProps> = ({
}}
>
浏览器本地文件(<code>{fileName}</code>)仅在上传当次会话内保留于内存。
<strong>页面刷新后,内存中的文件内容会被浏览器释放</strong>,无法再次预览或下载。
页面刷新后若服务端未登记同名项目资产,则无法自动恢复预览。
</p>
<p
style={{
@@ -265,7 +343,8 @@ export const FilePreviewPanel: React.FC<FilePreviewPanelProps> = ({
</div>
</div>
</div>
);
);
};
// 渲染对应的文档预览子组件
const renderViewer = () => {
@@ -279,25 +358,25 @@ export const FilePreviewPanel: React.FC<FilePreviewPanelProps> = ({
<OnDemand
label="Word 预览"
load={loadDocxViewer}
componentProps={{ fileRaw, fileUrl, fileName }}
componentProps={{ fileRaw, fileUrl: effectiveFileUrl, fileName }}
/>
);
case 'pdf':
return <PdfViewer fileRaw={fileRaw} fileUrl={fileUrl} fileName={fileName} />;
return <PdfViewer fileRaw={fileRaw} fileUrl={effectiveFileUrl} fileName={fileName} />;
case 'excel':
return (
<OnDemand
label="表格预览"
load={loadExcelViewer}
componentProps={{ fileRaw, fileUrl, fileName }}
componentProps={{ fileRaw, fileUrl: effectiveFileUrl, fileName }}
/>
);
case 'image':
return <ImageViewer fileRaw={fileRaw} fileUrl={fileUrl} fileName={fileName} />;
return <ImageViewer fileRaw={fileRaw} fileUrl={effectiveFileUrl} fileName={fileName} />;
case 'ppt':
return <PptViewer fileRaw={fileRaw} fileUrl={fileUrl} fileName={fileName} fileSize={fileSize} />;
return <PptViewer fileRaw={fileRaw} fileUrl={effectiveFileUrl} fileName={fileName} fileSize={fileSize} />;
case 'text':
return <TextViewer fileRaw={fileRaw} fileUrl={fileUrl} fileName={fileName} />;
return <TextViewer fileRaw={fileRaw} fileUrl={effectiveFileUrl} fileName={fileName} />;
default:
return (
<div
+18
View File
@@ -209,6 +209,24 @@ export function useSendMessage(input: SendMessageInput) {
saveFile(r.serverDocId, r.attachment.fileRaw, r.attachment.name).catch((e) =>
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);
}
}
}
}
+2
View File
@@ -296,6 +296,8 @@ export interface UploadedFileMetadata {
extension: string;
fileRaw?: File | Blob;
fileUrl?: string;
/** 服务端 CAS 资产 ID(刷新后可经 raw 端点回退预览) */
assetId?: number;
uploadedAt: string;
}