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:
+5
-4
@@ -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;
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -296,6 +296,8 @@ export interface UploadedFileMetadata {
|
||||
extension: string;
|
||||
fileRaw?: File | Blob;
|
||||
fileUrl?: string;
|
||||
/** 服务端 CAS 资产 ID(刷新后可经 raw 端点回退预览) */
|
||||
assetId?: number;
|
||||
uploadedAt: string;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user