Compare commits

..
Author SHA1 Message Date
weichen 7a6cfeefeb merge: dev to test (rc-v1.0.0-beta.6 promotion) 2026-09-20 07:22:42 +00:00
weichen 9f29b0badb merge: docs/bug-hunt-2026-09-20-closeout 2026-09-20 07:05:51 +00:00
weichenandCursor c7ee6b825e docs(ops): close bug-hunt permissions-uploads task after merge to dev
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-20 07:05:51 +00:00
weichen 032fdb050f merge: fix/bug-hunt-2026-09-20-permissions-and-uploads 2026-09-20 07:05:34 +00:00
weichenandCursor 6800a2af29 docs(ops): record permissions/uploads bug-hunt audit and short-term task
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-20 07:05:06 +00:00
weichenandCursor c8dbefe8a1 fix(web): align settings CAS, PDF preview, and collaboration UX guards
Wire expected_revision, blob PDF framing, mustChangePassword session rewrite,
client_key project create, no-project send disable, and right-rail persistence.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-20 07:05:06 +00:00
weichenandCursor 5cd22c7327 fix(authz): harden permissions, chat gate, and project create idempotency
Add project:manage_members, CSP frame-src, task-run member view, CreateUser
tx error handling, ERR_NO_PROJECT chat gate, and creator-scoped project
name/client_key uniqueness to stop duplicate creates and auth gaps.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-20 07:04:57 +00:00
weichen 8fdcd983a6 docs(ops): record test promotion rc-v1.0.0-beta.5 and release integration slot 2026-09-20 01:10:43 +00:00
45 changed files with 732 additions and 73 deletions
@@ -0,0 +1,48 @@
---
title: "系统权限设置与项目协作多项缺陷排查"
status: done
created: 2026-09-20
updated: 2026-09-20
severity: high
---
# 系统权限设置与项目协作多项缺陷排查
## 现象
用户报告:成员管理勾选无效、系统设置「允许编制人兼任审核批准」400、PDF 预览被拦截、任务 runs 403 刷屏、发起编制被标 NETWORK_ERROR、注册/建号 rollback、改密后刷新再强制改密、退出重登出现同名重复项目、无项目仍可对话、右侧工具栏默认展开且不记忆。
## 根因分析
| # | 根因 | 关键路径 |
|---|------|----------|
| 1 | CSP 缺 `frame-src`,blob iframe fallback 到 `default-src 'self'` | `internal/server/cors.go` |
| 2 | PdfViewer 同源 iframe 直连,触发 `X-Frame-Options: DENY` 原生拦截页 | `web/src/components/doc/preview/renderers/PdfViewer.tsx` |
| 3 | 前端未传 `expected_revision`(该 key 后端强制 CAS) | `web/src/api/systemAdmin.ts` / SettingsSection |
| 4 | `ProjectSettingsApiError` 字段名与消费方 `httpStatus`/`errorCode` 不一致 | `web/src/api/projectSettingsError.ts` |
| 5 | task-run view 仅放行创建者/owner;前端 403 无停止轮询 | `task_run_auth.go` / `App.tsx` |
| 6 | `CanManageProjectMembers` 未读细粒度权限位 | `internal/authz/service.go` |
| 7 | memStore `ListProjectsForUser` 忽略成员表 | `internal/projectworkflow/store.go` |
| 8 | `user_role_bindings` 插入吞错 → commit rollback;handler 一律 400 | `internal/auth/pg_store.go` |
| 9 | 嵌套 `user.mustChangePassword` OR 合并复活 + fetchMe 未回写 session | `authUserMap.ts` / WorkbenchView / useAuthenticatedWorkspaceSync |
| 10 | `projects.name` 无唯一约束 + Create 无幂等键;logout 不清 projects 缓存 | `service.go` / migration 00056 / App logout |
| 11 | `Project()<=0` 时跳过项目访问校验 | `handlers.go` / `sse.go` / `stage_access_helpers.go` |
| 12 | `rightCollapsed` 默认 false 且无持久化 | `AppShell.tsx` / `storage.ts` |
## 严重度
`high`
## 关联整改
- Short-term: `.doc/ops/short-term/2026-09-20-bug-hunt-permissions-uploads.md`
- 分支: `fix/bug-hunt-2026-09-20-permissions-and-uploads`
## 证据
- 产品决策:新增 `project:manage_members`;无项目禁止对话并置灰发送按钮;同创建人内项目名唯一 + `client_key` 幂等。
- 迁移:`00055_...project-manage-members-permission.sql`、`00056_...project-create-idempotency.sql`
## 开放问题
- 无(本批范围内)
+7 -7
View File
@@ -43,7 +43,7 @@ AuthN column is **heuristic**; negative-test pointers filled where known.
| POST | `/api/v1/auth/register` | `s.handleAuthRegister` | `server.go` | handler | see suite |
| GET | `/api/v1/auth/sessions` | `s.handleAuthSessions` | `server.go` | handler | see suite |
| DELETE | `/api/v1/auth/sessions/{id}` | `s.handleAuthSessionByID` | `server.go` | handler | see suite |
| POST | `/api/v1/chat` | `s.handleChat` | `server.go` | session+project | see suite |
| POST | `/api/v1/chat` | `s.handleChat` | `server.go` | session+project (ERR_NO_PROJECT if unbound + zero memberships) | see suite |
| GET | `/api/v1/chat/categories` | `s.handleListChatCategories` | `server.go` | session+project | see suite |
| GET | `/api/v1/chat/dates` | `s.handleListChatDates` | `server.go` | session+project | see suite |
| GET | `/api/v1/chat/digests` | `s.handleListDailyDigests` | `server.go` | session+project | see suite |
@@ -60,8 +60,8 @@ AuthN column is **heuristic**; negative-test pointers filled where known.
| GET | `/api/v1/chat/search` | `s.handleChatSearch` | `server.go` | session+project | see suite |
| GET | `/api/v1/chat/sessions` | `s.handleListChatSessions` | `server.go` | session+project | see suite |
| POST | `/api/v1/chat/sessions` | `s.handleUpsertChatSession` | `server.go` | session+project | see suite |
| GET | `/api/v1/chat/stream` | `s.handleChatStream` | `server.go` | session+project | see suite |
| POST | `/api/v1/chat/stream` | `s.handleChatStream` | `server.go` | session+project | see suite |
| GET | `/api/v1/chat/stream` | `s.handleChatStream` | `server.go` | session+project (ERR_NO_PROJECT if unbound + zero memberships) | see suite |
| POST | `/api/v1/chat/stream` | `s.handleChatStream` | `server.go` | session+project (ERR_NO_PROJECT if unbound + zero memberships) | see suite |
| GET | `/api/v1/chat/turns/{client_turn_id}` | `s.handleGetChatTurn` | `server.go` | session+project | see suite |
| GET | `/api/v1/chat/unread` | `s.handleChatUnread` | `server.go` | session+project | see suite |
| GET | `/api/v1/context/cases/recommend` | `s.handleRecommendCases` | `server.go` | handler | see suite |
@@ -131,10 +131,10 @@ AuthN column is **heuristic**; negative-test pointers filled where known.
| GET | `/api/v1/tasks/run/stream` | `s.handleRunTaskStream` | `server.go` | session+project | see suite |
| POST | `/api/v1/tasks/run/stream` | `s.handleRunTaskStream` | `server.go` | session+project | see suite |
| POST | `/api/v1/tasks/runs` | `s.handleCreateTaskRun` | `server.go` | session+project | see suite |
| GET | `/api/v1/tasks/runs/{run_id}` | `s.handleGetTaskRun` | `server.go` | session+project | see suite |
| POST | `/api/v1/tasks/runs/{run_id}/cancel` | `s.handleCancelTaskRun` | `server.go` | session+project | see suite |
| GET | `/api/v1/tasks/runs/{run_id}/events` | `s.handleTaskRunEvents` | `server.go` | session+project | see suite |
| POST | `/api/v1/tasks/runs/{run_id}/resume` | `s.handleResumeTaskRun` | `server.go` | session+project | see suite |
| GET | `/api/v1/tasks/runs/{run_id}` | `s.handleGetTaskRun` | `server.go` | session+project member (view) | see suite |
| POST | `/api/v1/tasks/runs/{run_id}/cancel` | `s.handleCancelTaskRun` | `server.go` | session+run owner or project owner | see suite |
| GET | `/api/v1/tasks/runs/{run_id}/events` | `s.handleTaskRunEvents` | `server.go` | session+project member (view) | see suite |
| POST | `/api/v1/tasks/runs/{run_id}/resume` | `s.handleResumeTaskRun` | `server.go` | session+run owner or project owner | see suite |
| GET | `/api/v1/workflow-instances/{id}` | `s.handleGetWorkflowInstance` | `server.go` | session+project | see suite |
| POST | `/api/v1/workflow-instances/{id}/stages/{stage}/advance` | `s.handleAdvanceStage` | `server.go` | session+project | see suite |
| GET | `/api/v1/workflow-instances/{id}/stages/{stage}/readiness` | `s.handleGetStageReadiness` | `server.go` | session+project | see suite |
+11 -9
View File
@@ -1,25 +1,27 @@
# 当前焦点
**更新**: 2026-09-19
**production_ready**: yes(门禁 B1–B4 已签;dev schema 53,test/main schema 52。不把 fallback change report 写成完整 PI)
**更新**: 2026-09-20
**production_ready**: yes(门禁 B1–B4 已签;dev/test schema 53,main schema 52。不把 fallback change report 写成完整 PI)
## 焦点
1. **2026-09-19 审计整改批 1–4 done**(合入 `844f757` / `08f4e0b` / `a2a49a6` / `d73bc79`)。dev 已 rebuild 到 schema 53。余项见 [long-term](./long-term/2026-09-19-audit-remainder.md)。未晋升 test/main。
2. **Control Center 已移出本仓库**:项目进度由独立统一管理系统承担;本仓库恢复为智能体系统。见 [任务](./short-term/2026-09-19-remove-control-center.md)
3. **rdev 模板深度 / document-author-only**:已合入 `origin/dev`;未晋升 test/main
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 待合规签字
1. **2026-09-20 dev→test 晋升完成 (rc-v1.0.0-beta.5)**:已合入 fix/audit-remainder-closeout,完成全量深度测试(Go 短测 46 包全绿、Vite 前端构建/测试通过、治理 PG 集成通过、运行时端点/安全响应头通过)。已合入 test 分支并重建测试环境服务(端口 :8081,schema 53 healthy)。
2. **2026-09-19 审计整改批 1–4 done**(合入 `844f757` / `08f4e0b` / `a2a49a6` / `d73bc79`)。dev 已 rebuild 到 schema 53。余项见 [long-term](./long-term/2026-09-19-audit-remainder.md)。
3. **Control Center 已移出本仓库**:项目进度由独立统一管理系统承担;本仓库恢复为智能体系统。见 [任务](./short-term/2026-09-19-remove-control-center.md)
4. **rdev 模板深度 / document-author-only**:已合入 `origin/dev` 及 `test`
5. **红线**:禁止整支 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-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 |
| Cursor | audit-b2-authz-hardening | fix/audit-b2-authz-hardening | - | authz | done |
| Cursor | audit-b3-sse-events | fix/audit-b3-sse-events | - | chat+events | done |
| Cursor | audit-b4-frontend | fix/audit-b4-frontend | - | web | done |
| Cursor-Auto | 2026-09-19-audit-remainder-closeout | fix/audit-remainder-closeout | - | audit-remainder | done |
| Antigravity | 2026-09-20-dev-deep-test-and-promotion | feature/dev-deep-test-and-promotion | - | integration | in_progress |
| Antigravity | 2026-09-20-dev-deep-test-and-promotion | feature/dev-deep-test-and-promotion | - | integration | done |
| Cursor-Auto | 2026-09-19-remove-control-center | fix/remove-control-center | - | controlcenter | done |
| Cursor-Auto | 2026-09-17-control-center-server | feature/control-center-server | - | controlcenter | cancelled |
| Kimi | 2026-09-18-user-manual | feature/user-manual | .worktrees/feature-user-manual | docs | done |
@@ -150,7 +152,7 @@
| holder | phase | since | scope | queue |
|--------|-------|-------|-------|-------|
| Antigravity | merging | 2026-09-20 | 合并 fix/audit-remainder-closeout 至 dev,全量深度测试,晋升至 test 并重启 test 系统 | - |
| - | idle | 2026-09-20 | dev 深度测试全绿;已晋升 test (rc-v1.0.0-beta.5) 并重建测试环境;test 与 dev 均为 schema 53 healthy | - |
**phase**: `idle` | `merging` | `smoke` | `e2e`
@@ -161,7 +163,7 @@
| 环境 | 端口 | schema | 状态 |
|------|------|--------|------|
| dev | 8080 | **53** | healthy(2026-09-19 `fix/audit-remainder-closeout` rebuild;goose `00053` `document_content_projections`;入口 `index-DOO0-Yh7.js`;`pi-agent`;events `:8088`) |
| test | 8081 | **52** | healthy(Wave 2 rebuild 后 schema 与 dev/main 对齐;pi-agent `:18866`;events `:8089`) |
| test | 8081 | **53** | healthy(2026-09-20 `rc-v1.0.0-beta.5` rebuild;goose `00053`;入口 `index-C5YTF8ik.js`;pi-agent `:18866`;events `:8089`) |
| main | 8000 | **52** | healthy(`env=main` app+events 对齐;events 先 healthy 后启 app;consumer modes 全 off;Wave 1 已验证) |
## SSOT
@@ -0,0 +1,58 @@
---
title: "修复系统权限设置与项目协作多项缺陷"
status: done
created: 2026-09-20
updated: 2026-09-20
owner: Cursor-Auto
branch: fix/bug-hunt-2026-09-20-permissions-and-uploads
worktree: "-"
mutex: authz+web+projectworkflow
---
# 修复系统权限设置与项目协作多项缺陷
## 目标
修复两轮排查确认的 12 处后端/前端缺陷(CSP/PDF、系统设置 CAS、任务错误误分类、task-run 403、成员管理 RBAC、注册吞错、强制改密复发、项目重名、无项目可对话、右侧栏不记忆等)。
## 范围
- CSP `frame-src` + PDF blob 预览
- 系统设置 `expected_revision` 全链路
- `ProjectSettingsApiError` httpStatus/errorCode 别名
- task-run 查看放宽为项目成员;前端 403/404 停轮询
- `project:manage_members` 权限位(迁移 00055)
- memStore 成员可见性
- CreateUser 吞错 / 400·500 分类 / LooksInternal
- 强制改密 session 嵌套字段 OR 复活
- CreateProject `client_key` 幂等 + `(creator, name)` 唯一(迁移 00056)
- 无项目禁止对话 + 发送按钮置灰
- 右侧工具栏默认收起并持久化
## 非目标
- 全局项目名唯一(仅同创建人内唯一)
- 放宽 `frame-ancestors` / `X-Frame-Options`
## 步骤
1. [x] 后端 CSP / authz / auth / projectworkflow / chat 入口
2. [x] 前端 PDF / settings / 错误别名 / 轮询 / RBAC UI / 改密 / 建项幂等 / InputArea / AppShell
3. [x] 迁移 00055 / 00056 + 单测
4. [x] `go vet` / `go test -short` / `npm test` / `npm run build`
5. [x] 占集成槽位合回 `dev`
## 验收标准
- [x] 代码与契约补齐(见 audit 笔记)
`- [x] 短测与前端构建通过
`- [x] 合入 `dev`
## 相关链接
- Audit: `.doc/audit/2026-09-20-bug-hunt-permissions-uploads.md`
- Plan: `.cursor/plans/修复系统权限设置与项目协作多项缺陷_ff3242f7.plan.md`
## 证据 / 备注
分支:`fix/bug-hunt-2026-09-20-permissions-and-uploads`
@@ -0,0 +1,12 @@
-- +goose Up
-- 新增「管理项目成员」系统权限位;不默认授予既有角色,由管理员按需勾选。
INSERT INTO system_permissions (perm_key, resource_type, action, risk_level)
VALUES ('project:manage_members', 'project', 'manage_members', 'elevated')
ON CONFLICT (perm_key) DO NOTHING;
-- +goose Down
DELETE FROM system_role_permissions
WHERE permission_id IN (
SELECT id FROM system_permissions WHERE perm_key = 'project:manage_members'
);
DELETE FROM system_permissions WHERE perm_key = 'project:manage_members';
@@ -0,0 +1,41 @@
-- +goose Up
-- 创建项目幂等键 + 同创建人下项目名唯一(防双击/重试重复行)。
-- 约束粒度:同一 creator_user_id 内 name 唯一(允许不同团队同名项目)。
ALTER TABLE projects
ADD COLUMN IF NOT EXISTS client_key TEXT NULL;
COMMENT ON COLUMN projects.client_key IS
'Optional client idempotency key; same creator+key returns the existing project.';
-- Deduplicate same-creator same-name rows before unique index (keep lowest id).
WITH dups AS (
SELECT id,
ROW_NUMBER() OVER (
PARTITION BY creator_user_id, lower(trim(name))
ORDER BY id ASC
) AS rn
FROM projects
WHERE creator_user_id IS NOT NULL
AND creator_user_id > 0
AND length(trim(name)) > 0
)
UPDATE projects p
SET name = p.name || ' (dup-' || p.id || ')'
FROM dups d
WHERE p.id = d.id AND d.rn > 1;
CREATE UNIQUE INDEX IF NOT EXISTS uq_projects_creator_client_key
ON projects (creator_user_id, client_key)
WHERE client_key IS NOT NULL AND length(trim(client_key)) > 0
AND creator_user_id IS NOT NULL AND creator_user_id > 0;
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;
-- +goose Down
DROP INDEX IF EXISTS uq_projects_creator_name;
DROP INDEX IF EXISTS uq_projects_creator_client_key;
ALTER TABLE projects DROP COLUMN IF EXISTS client_key;
+4 -2
View File
@@ -160,11 +160,13 @@ func (s *PgStore) CreateUser(ctx context.Context, user *UserAccount) (*UserAccou
}
if user.RoleID != "" {
_, _ = tx.Exec(ctx, `
if _, err = tx.Exec(ctx, `
INSERT INTO user_role_bindings (user_id, role_id, scope_type, scope_id, assigned_by)
VALUES ($1, $2, 'system', 0, $1)
ON CONFLICT DO NOTHING
`, id, user.RoleID)
`, id, user.RoleID); err != nil {
return nil, fmt.Errorf("insert role binding: %w", err)
}
}
if err := tx.Commit(ctx); err != nil {
+5
View File
@@ -65,6 +65,11 @@ func (a *Authorizer) enabled() bool {
return a != nil && a.pool != nil
}
// Enabled reports whether project membership checks are backed by a database pool.
func (a *Authorizer) Enabled() bool {
return a.enabled()
}
// AddOwnerOnCreate 项目创建时写入 owner 成员关系,并默认编制职责。
func (a *Authorizer) AddOwnerOnCreate(ctx context.Context, projectID, userID int64) error {
duty := docduty.DutyAuthor
+1
View File
@@ -14,6 +14,7 @@ func PermissionCatalog() []PermissionCatalogEntry {
return []PermissionCatalogEntry{
{Key: "project:create", Resource: "project", Action: "create", RiskLevel: "normal", Description: "创建新项目"},
{Key: "project:assign_lead", Resource: "project", Action: "assign_lead", RiskLevel: "elevated", Description: "指派项目负责人"},
{Key: PermManageProjectMembers, Resource: "project", Action: "manage_members", RiskLevel: "elevated", Description: "管理项目成员(添加/调整/移出)"},
{Key: PermManage, Resource: "system", Action: "manage", RiskLevel: "critical", Description: "系统权限管理"},
{Key: "workflow:config_visibility", Resource: "workflow", Action: "config_visibility", RiskLevel: "elevated"},
{Key: "doc:override_visibility", Resource: "document", Action: "override_visibility", RiskLevel: "elevated"},
+9 -1
View File
@@ -8,6 +8,7 @@ import (
)
const PermManage = "perm:manage"
const PermManageProjectMembers = "project:manage_members"
// Service 统一系统 RBAC 与项目成员授权判定。
type Service struct {
@@ -90,7 +91,7 @@ func (s *Service) IsSystemAdmin(ctx context.Context, userID int64, roleID string
return s.HasSystemPermission(ctx, userID, PermManage)
}
// CanManageProjectMembers 项目 owner/创建人或系统管理员可管理成员。
// CanManageProjectMembers 项目 owner/创建人、系统管理员,或持有 project:manage_members 的用户可管理成员。
func (s *Service) CanManageProjectMembers(ctx context.Context, projectID, userID int64, roleID string) (bool, error) {
if userID <= 0 || projectID <= 0 {
return false, nil
@@ -102,6 +103,13 @@ func (s *Service) CanManageProjectMembers(ctx context.Context, projectID, userID
if admin {
return true, nil
}
hasPerm, err := s.HasSystemPermission(ctx, userID, PermManageProjectMembers)
if err != nil {
return false, err
}
if hasPerm {
return true, nil
}
if s.authorizer == nil {
return !s.enabled() && userID > 0, nil
}
+1 -1
View File
@@ -61,7 +61,7 @@ func SafeMessage(code string) string {
func LooksInternal(s string) bool {
low := strings.ToLower(s)
needles := []string{"sql", "pq:", "postgres", "stack", "panic", "token", "password", "secret", "api_key", "bearer ", "../", "prompt"}
needles := []string{"sql", "pq:", "pgx", "postgres", "stack", "panic", "token", "password", "secret", "api_key", "bearer ", "../", "prompt", "commit", "rollback", "transaction", "constraint", "violates"}
for _, n := range needles {
if strings.Contains(low, n) {
return true
+3
View File
@@ -24,6 +24,9 @@ var (
// ErrInvalidInput 输入参数不合法
ErrInvalidInput = errors.New("invalid parameter: required field missing or malformed")
// ErrDuplicateProjectName 同一创建人下已存在同名项目
ErrDuplicateProjectName = errors.New("duplicate project name for creator")
// ErrLegacySwitchBlocked 已启用实例化工作流治理,禁止旧式即时切换
ErrLegacySwitchBlocked = errors.New("legacy workflow switch blocked: use project settings activation flow")
)
+18
View File
@@ -49,6 +49,23 @@ func (s *Service) CreateProject(ctx context.Context, user *auth.UserAccount, in
return nil, fmt.Errorf("%w: 项目名称为必填项", ErrInvalidInput)
}
clientKey := strings.TrimSpace(in.ClientKey)
if clientKey != "" {
existing, err := s.store.GetProjectByClientKey(ctx, user.ID, clientKey)
if err == nil && existing != nil {
return existing, nil
}
if err != nil && err != ErrProjectNotFound {
return nil, err
}
}
if existing, err := s.store.FindProjectByCreatorAndName(ctx, user.ID, name); err == nil && existing != nil {
return nil, fmt.Errorf("%w: 您已创建同名项目「%s」", ErrDuplicateProjectName, name)
} else if err != nil && err != ErrProjectNotFound {
return nil, err
}
code := strings.TrimSpace(in.Code)
if code == "" {
code = generateProjectCode(name)
@@ -97,6 +114,7 @@ func (s *Service) CreateProject(ctx context.Context, user *auth.UserAccount, in
ActiveWorkflowKey: "",
WorkflowSelectionVersion: 1,
WorkflowPlanRevision: 0,
ClientKey: clientKey,
}
// initialWorkflow is catalog-validated only; activation stays on the settings path.
+39
View File
@@ -309,3 +309,42 @@ func TestCreateSoftwareProject_NoMedicalDefaults(t *testing.T) {
t.Fatalf("expected controlled_patch rejection, got %v", err)
}
}
func TestCreateProject_ClientKeyIdempotentAndDuplicateName(t *testing.T) {
svc := NewService(newMemStore(), NewDefaultCatalog())
ctx := context.Background()
creator := &auth.UserAccount{ID: 42, Username: "creator"}
first, err := svc.CreateProject(ctx, creator, CreateProjectInput{
Name: "幂等项目",
ClientKey: "ck-same-1",
})
if err != nil {
t.Fatalf("first create: %v", err)
}
second, err := svc.CreateProject(ctx, creator, CreateProjectInput{
Name: "幂等项目-改名也应返回同一条",
ClientKey: "ck-same-1",
})
if err != nil {
t.Fatalf("idempotent create: %v", err)
}
if first.ID != second.ID {
t.Fatalf("expected same project id, got %d vs %d", first.ID, second.ID)
}
_, err = svc.CreateProject(ctx, creator, CreateProjectInput{Name: "幂等项目"})
if err == nil || !errors.Is(err, ErrDuplicateProjectName) {
t.Fatalf("expected ErrDuplicateProjectName, got %v", err)
}
member := &auth.UserAccount{ID: 99, Username: "other"}
other, err := svc.CreateProject(ctx, member, CreateProjectInput{Name: "幂等项目"})
if err != nil {
t.Fatalf("different creator same name should succeed: %v", err)
}
if other.ID == first.ID {
t.Fatalf("different creators must get distinct projects")
}
}
+166 -3
View File
@@ -5,6 +5,7 @@ import (
"encoding/json"
"errors"
"fmt"
"strings"
"sync"
"time"
@@ -17,6 +18,8 @@ type Store interface {
CreateProject(ctx context.Context, p *Project) (*Project, error)
GetProjectByID(ctx context.Context, id int64) (*Project, error)
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)
ListAllProjects(ctx context.Context) ([]*Project, error)
ListProjectsForUser(ctx context.Context, userID int64) ([]*Project, error)
ListProjectWorkflows(ctx context.Context, projectID int64) ([]*ProjectWorkflow, error)
@@ -78,7 +81,7 @@ func (s *pgStore) CreateProject(ctx context.Context, p *Project) (*Project, erro
problem_statement, target_users, target_outcome, product_shape,
current_stage, environment, status, creator_user_id, lifecycle_status,
active_workflow_key, workflow_selection_version, workflow_plan_revision,
created_at, updated_at
client_key, created_at, updated_at
) VALUES (
$1, $2, $3, $4, $5, $6,
$7, $8, $9, $10,
@@ -86,10 +89,16 @@ func (s *pgStore) CreateProject(ctx context.Context, p *Project) (*Project, erro
$16, $17, $18, $19,
$20, $21, $22, $23, $24,
$25, $26, $27,
$28, $29
NULLIF(trim($28), ''), $29, $30
)
RETURNING id
`
var clientKey any
if strings.TrimSpace(p.ClientKey) != "" {
clientKey = strings.TrimSpace(p.ClientKey)
} else {
clientKey = nil
}
err := s.pool.QueryRow(
ctx, query,
p.Code, p.Name, p.CustomerName, p.DeviceType, p.IntendedUse, p.TargetMarket,
@@ -98,7 +107,7 @@ func (s *pgStore) CreateProject(ctx context.Context, p *Project) (*Project, erro
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.CreatedAt, p.UpdatedAt,
clientKey, p.CreatedAt, p.UpdatedAt,
).Scan(&p.ID)
if err != nil {
return nil, fmt.Errorf("insert project failed: %w", err)
@@ -107,6 +116,91 @@ func (s *pgStore) CreateProject(ctx context.Context, p *Project) (*Project, erro
return p, nil
}
func (s *pgStore) GetProjectByClientKey(ctx context.Context, creatorUserID int64, clientKey string) (*Project, error) {
clientKey = strings.TrimSpace(clientKey)
if creatorUserID <= 0 || clientKey == "" {
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 creator_user_id = $1 AND client_key = $2
LIMIT 1
`
p := &Project{}
err := s.pool.QueryRow(ctx, query, creatorUserID, clientKey).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("get project by client_key failed: %w", err)
}
return p, nil
}
func (s *pgStore) FindProjectByCreatorAndName(ctx context.Context, creatorUserID int64, name string) (*Project, error) {
name = strings.TrimSpace(name)
if creatorUserID <= 0 || 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 creator_user_id = $1 AND lower(trim(name)) = lower(trim($2))
ORDER BY id ASC
LIMIT 1
`
p := &Project{}
err := s.pool.QueryRow(ctx, query, creatorUserID, 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 creator+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,
@@ -600,6 +694,8 @@ func (s *pgStore) MarkDownstreamStale(ctx context.Context, upstreamDocID string,
type memStore struct {
mu sync.RWMutex
projects map[int64]*Project
members map[int64]map[int64]struct{} // projectID -> set of active member userIDs
byClientKey map[string]int64 // creatorID|clientKey -> projectID
workflows map[int64]map[string]*ProjectWorkflow
runs map[string]*WorkflowRun
audits []*WorkflowActivationAudit
@@ -613,6 +709,8 @@ type memStore struct {
func newMemStore() *memStore {
m := &memStore{
projects: make(map[int64]*Project),
members: make(map[int64]map[int64]struct{}),
byClientKey: make(map[string]int64),
workflows: make(map[int64]map[string]*ProjectWorkflow),
runs: make(map[string]*WorkflowRun),
nextProjID: 1000,
@@ -679,9 +777,67 @@ func (m *memStore) CreateProject(ctx context.Context, p *Project) (*Project, err
cpy := *p
m.projects[p.ID] = &cpy
if p.CreatorUserID > 0 {
if m.members[p.ID] == nil {
m.members[p.ID] = make(map[int64]struct{})
}
m.members[p.ID][p.CreatorUserID] = struct{}{}
if key := strings.TrimSpace(p.ClientKey); key != "" {
m.byClientKey[fmt.Sprintf("%d|%s", p.CreatorUserID, key)] = p.ID
}
}
return p, nil
}
// AddProjectMember records an active member for in-memory visibility (aligns with pgStore JOIN).
func (m *memStore) AddProjectMember(projectID, userID int64) {
if projectID <= 0 || userID <= 0 {
return
}
m.mu.Lock()
defer m.mu.Unlock()
if m.members[projectID] == nil {
m.members[projectID] = make(map[int64]struct{})
}
m.members[projectID][userID] = struct{}{}
}
func (m *memStore) GetProjectByClientKey(ctx context.Context, creatorUserID int64, clientKey string) (*Project, error) {
clientKey = strings.TrimSpace(clientKey)
if creatorUserID <= 0 || clientKey == "" {
return nil, ErrProjectNotFound
}
m.mu.RLock()
defer m.mu.RUnlock()
id, ok := m.byClientKey[fmt.Sprintf("%d|%s", creatorUserID, clientKey)]
if !ok {
return nil, ErrProjectNotFound
}
p, ok := m.projects[id]
if !ok {
return nil, ErrProjectNotFound
}
cpy := *p
return &cpy, nil
}
func (m *memStore) FindProjectByCreatorAndName(ctx context.Context, creatorUserID int64, name string) (*Project, error) {
name = strings.TrimSpace(name)
if creatorUserID <= 0 || name == "" {
return nil, ErrProjectNotFound
}
m.mu.RLock()
defer m.mu.RUnlock()
want := strings.ToLower(name)
for _, p := range m.projects {
if p.CreatorUserID == creatorUserID && 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()
@@ -713,6 +869,13 @@ func (m *memStore) ListProjectsForUser(ctx context.Context, userID int64) ([]*Pr
if p.CreatorUserID == userID {
cpy := *p
list = append(list, &cpy)
continue
}
if members, ok := m.members[p.ID]; ok {
if _, isMember := members[userID]; isMember {
cpy := *p
list = append(list, &cpy)
}
}
}
return list, nil
+3
View File
@@ -38,6 +38,7 @@ type Project struct {
ActiveWorkflowChangedBy *int64 `json:"active_workflow_changed_by,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ClientKey string `json:"client_key,omitempty"`
}
// ProjectWorkflow 项目挂载的工作流实例
@@ -121,6 +122,8 @@ type CreateProjectInput struct {
ProductShape string `json:"product_shape,omitempty"`
CreationToken string `json:"creation_token,omitempty"`
PlanHash string `json:"plan_hash,omitempty"`
// ClientKey 可选幂等键;同一创建人重复提交返回已有项目。
ClientKey string `json:"client_key,omitempty"`
// Deprecated: accepted for catalog existence checks only; never activates the project.
InitialWorkflowKey string `json:"initial_workflow_key,omitempty"`
}
+47 -2
View File
@@ -12,6 +12,25 @@ import (
"chat-agent-system/internal/authz"
)
// authIsInfrastructureError classifies DB/driver/tx failures that must not be
// reported as client VALIDATION (400).
func authIsInfrastructureError(err error) bool {
if err == nil {
return false
}
msg := strings.ToLower(err.Error())
for _, needle := range []string{
"insert user:", "insert credentials:", "insert role binding:",
"commit unexpectedly", "rollback", "pgx", "pq:", "sqlstate",
"connection refused", "connection reset", "context deadline",
} {
if strings.Contains(msg, needle) {
return true
}
}
return false
}
// extractBearerToken 从 Authorization 请求头解析 Bearer Token
func extractBearerToken(r *http.Request) string {
authHeader := r.Header.Get("Authorization")
@@ -99,7 +118,11 @@ func (s *Server) handleAuthRegister(w http.ResponseWriter, r *http.Request) {
resp, err := s.authSvc.Register(r.Context(), req)
if err != nil {
writeSafeError(w, r, http.StatusBadRequest, err.Error())
status := http.StatusBadRequest
if authIsInfrastructureError(err) {
status = http.StatusInternalServerError
}
writeSafeError(w, r, status, err.Error())
return
}
@@ -255,9 +278,31 @@ func (s *Server) handleAdminUsers(w http.ResponseWriter, r *http.Request) {
writeSafeError(w, r, http.StatusBadRequest, "无效的 JSON 请求体")
return
}
if roleID := strings.TrimSpace(req.RoleID); roleID != "" && s.authzSvc != nil && s.authzSvc.Enabled() {
roles, listErr := s.authzSvc.ListRoles(r.Context())
if listErr != nil {
writeSafeError(w, r, http.StatusInternalServerError, listErr.Error())
return
}
found := false
for _, role := range roles {
if role.ID == roleID {
found = true
break
}
}
if !found {
writeSafeError(w, r, http.StatusBadRequest, "指定的角色不存在或已停用")
return
}
}
created, err := s.authSvc.CreateUserByAdmin(r.Context(), req)
if err != nil {
writeSafeError(w, r, http.StatusBadRequest, err.Error())
status := http.StatusBadRequest
if authIsInfrastructureError(err) {
status = http.StatusInternalServerError
}
writeSafeError(w, r, status, err.Error())
return
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
+3 -3
View File
@@ -7,9 +7,9 @@ import (
// securityCSP keeps scripts and fonts on this origin. style-src allows inline
// styles because KaTeX and Mermaid inject them into the page. connect-src
// 'self' covers same-origin JSON and SSE. frame-ancestors none matches
// X-Frame-Options DENY.
const securityCSP = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self'; img-src 'self' data: blob:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'"
// 'self' covers same-origin JSON and SSE. frame-src allows same-origin and
// blob: iframes for PDF preview. frame-ancestors none matches X-Frame-Options DENY.
const securityCSP = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self'; img-src 'self' data: blob:; connect-src 'self'; frame-src 'self' blob:; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'"
func applySecurityHeaders(w http.ResponseWriter) {
h := w.Header()
+3
View File
@@ -221,6 +221,9 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
if req.ProjectID > 0 && req.ProjectID != sess.Project() {
sess.SetProjectID(req.ProjectID)
}
if !s.enforceChatRequiresProject(w, r, user, sess.Project()) {
return
}
if sess.Project() > 0 {
if !s.enforceChatProjectAccess(w, r, user, sess.Project()) {
return
+7
View File
@@ -43,6 +43,13 @@ func (s *Server) handleCreateProject(w http.ResponseWriter, r *http.Request) {
writeSafeError(w, r, http.StatusBadRequest, `{"error":"ERR_INVALID_PARAM","message":"`+err.Error()+`"}`)
return
}
if errors.Is(err, projectworkflow.ErrDuplicateProjectName) {
writeJSONResponse(w, http.StatusConflict, map[string]string{
"error_code": "ERR_DUPLICATE_PROJECT_NAME",
"message": "您已创建同名项目,请更换名称后重试",
})
return
}
if errors.Is(err, projectworkflow.ErrWorkflowNotFound) {
writeSafeError(w, r, http.StatusBadRequest, `{"error":"ERR_WORKFLOW_NOT_FOUND","message":"`+err.Error()+`"}`)
return
@@ -70,6 +70,19 @@ func TestProjectMembers_CrossProjectForbidden(t *testing.T) {
}
}
func TestProjectMembers_ManageMembersPermissionKeyInCatalog(t *testing.T) {
found := false
for _, p := range authz.PermissionCatalog() {
if p.Key == authz.PermManageProjectMembers {
found = true
break
}
}
if !found {
t.Fatalf("expected %s in system permission catalog", authz.PermManageProjectMembers)
}
}
func TestProjectMembers_AnonymousReturns401(t *testing.T) {
cfg := &config.Config{
Env: "dev",
+1 -1
View File
@@ -170,7 +170,7 @@ func TestP0_SecurityHeaders(t *testing.T) {
t.Fatalf("referrer = %q", got)
}
csp := rec.Header().Get("Content-Security-Policy")
for _, want := range []string{"default-src 'self'", "script-src 'self'", "font-src 'self'", "frame-ancestors 'none'", "object-src 'none'"} {
for _, want := range []string{"default-src 'self'", "script-src 'self'", "font-src 'self'", "frame-src 'self' blob:", "frame-ancestors 'none'", "object-src 'none'"} {
if !strings.Contains(csp, want) {
t.Fatalf("csp %q missing %s", csp, want)
}
+3
View File
@@ -210,6 +210,9 @@ func (s *Server) handleChatStream(w http.ResponseWriter, r *http.Request) {
if req.ProjectID > 0 && req.ProjectID != sess.Project() {
sess.SetProjectID(req.ProjectID)
}
if !s.enforceChatRequiresProject(w, r, user, sess.Project()) {
return
}
if sess.Project() > 0 {
if !s.enforceChatProjectAccess(w, r, user, sess.Project()) {
return
+32
View File
@@ -245,6 +245,38 @@ func (s *Server) applyChatWindowStageFilter(ctx context.Context, projectID, user
s.applyChatWindowAuthFilter(authCtx, res)
}
// enforceChatRequiresProject blocks chat when the session has no project and the
// user has no active project membership (new accounts with zero projects).
// Only enforced when a DB-backed authorizer can list memberships; memory/stub
// mode without project_members must not false-positive as ERR_NO_PROJECT.
func (s *Server) enforceChatRequiresProject(w http.ResponseWriter, r *http.Request, user *auth.UserAccount, projectID int64) bool {
if projectID > 0 || user == nil {
return true
}
if s.isSystemAdminUser(r.Context(), user) {
return true
}
if s.authorizer == nil || !s.authorizer.Enabled() {
return true
}
ids, err := s.authorizer.ListVisibleProjectIDs(r.Context(), user.ID)
if err != nil {
writeJSONResponse(w, http.StatusInternalServerError, map[string]string{
"error_code": "ERR_INTERNAL",
"message": "无法校验项目归属",
})
return false
}
if len(ids) == 0 {
writeJSONResponse(w, http.StatusForbidden, map[string]string{
"error_code": "ERR_NO_PROJECT",
"message": "请先创建或加入项目后再开始对话",
})
return false
}
return true
}
// enforceChatProjectAccess 项目对话入口:成员/创建者;系统管理员按既有策略放行。
// 必须在 SSE write headers / Flush 之前调用,拒绝时写结构化 JSON 403(非 200 SSE)。
func (s *Server) enforceChatProjectAccess(w http.ResponseWriter, r *http.Request, user *auth.UserAccount, projectID int64) bool {
+4 -1
View File
@@ -33,12 +33,15 @@ func (s *Server) assertTaskRunAccess(w http.ResponseWriter, ctx context.Context,
}
isOwner := rec.UserID > 0 && rec.UserID == user.ID
isProjectOwner := false
isProjectMember := false
if rec.ProjectID > 0 && s.authorizer != nil {
isProjectOwner, _ = s.authorizer.IsProjectOwner(ctx, rec.ProjectID, user.ID)
isProjectMember, _ = s.authorizer.IsProjectMember(ctx, rec.ProjectID, user.ID)
}
switch action {
case taskRunView:
if isOwner || isProjectOwner {
// Align with create-run RequireProjectAccess: any active project member may view.
if isOwner || isProjectOwner || isProjectMember {
return true
}
case taskRunCancel, taskRunResume:
+24 -1
View File
@@ -84,6 +84,7 @@ import {
loadProjects,
filterProjectSessionsForUser,
getProjectMembership,
resetWorkbenchStorage,
} from './data/storage';
import { useChatWindow } from './hooks/useChatWindow';
import { useAuthenticatedWorkspaceSync } from './hooks/useAuthenticatedWorkspaceSync';
@@ -232,6 +233,7 @@ export default function App() {
);
const isCurrentProjectExited = currentMembership?.status === 'exited';
const chatBlockedNoProject = !currentProjectId || currentProjectId <= 0 || projects.length === 0;
// 当前用户专属的项目会话列表 (基于项目成员身份与退出留痕过滤)
const visibleSessions = useMemo(() => {
@@ -355,6 +357,7 @@ export default function App() {
const canManageProjectMembers = useMemo(() => {
if (!currentProject) return false;
if (hasPermission(currentUser, roles, 'perm:manage', capabilities)) return true;
if (hasPermission(currentUser, roles, 'project:manage_members', capabilities)) return true;
if (currentUser.id === currentProject.creatorId) return true;
const membership = currentProject.members.find((m) => m.userId === currentUser.id);
return membership?.role === 'creator' || membership?.role === 'owner';
@@ -939,9 +942,26 @@ export default function App() {
useEffect(() => {
if (!recoverableRunKey) return;
let cancelled = false;
const skipped = new Set<string>();
const poll = async () => {
const activeIds = recoverableRunIds.filter((id) => !skipped.has(id));
if (activeIds.length === 0) return;
const records = await Promise.all(
recoverableRunIds.map((runId) => getTaskRun(runId).catch(() => null)),
activeIds.map(async (runId) => {
try {
return await getTaskRun(runId);
} catch (err) {
const status =
typeof err === 'object' && err !== null && ('httpStatus' in err || 'status' in err)
? Number((err as { httpStatus?: number; status?: number }).httpStatus ?? (err as { status?: number }).status)
: undefined;
// Stop hammering forbidden/missing runs (console 403 spam).
if (status === 403 || status === 404) {
skipped.add(runId);
}
return null;
}
}),
);
if (cancelled) return;
if (records.some((record) => record && record.status !== 'running')) {
@@ -1189,6 +1209,8 @@ export default function App() {
setSession(null);
setCapabilities([]);
setSessionValidated(false);
setProjects([]);
resetWorkbenchStorage();
});
};
@@ -1547,6 +1569,7 @@ export default function App() {
activeQuotes={activeQuotes}
handleRemoveQuote={handleRemoveQuote}
isCurrentProjectExited={isCurrentProjectExited}
chatBlockedNoProject={chatBlockedNoProject}
/>
}
docContent={
+14 -17
View File
@@ -39,28 +39,25 @@ test('mapUserAccount: prefers snake_case when both present', () => {
assert.equal(u.employeeId, 'SYS-000');
});
test('mapLoginResult + mapMeResult map nested user and top-level must_change_password', () => {
const login = mapLoginResult({
token: 'tok-1',
user: { ...snakeAdminUser, must_change_password: true },
must_change_password: true,
env: 'dev',
});
assert.equal(login.user.roleId, 'role-admin');
assert.equal(login.user.employeeId, 'SYS-000');
assert.equal(login.mustChangePassword, true);
assert.equal(login.user.mustChangePassword, true);
test('mapLoginResult/mapMeResult use top-level must_change_password as sole authority', () => {
// Nested true must not revive when top-level is false (post password-change cache).
const me = mapMeResult({
user: snakeAdminUser,
user: { ...snakeAdminUser, must_change_password: true },
must_change_password: false,
env: 'dev',
capabilities: ['admin:manage_users'],
capabilities: [],
});
assert.equal(me.user.roleId, 'role-admin');
assert.equal(me.user.employeeId, 'SYS-000');
assert.equal(me.mustChangePassword, false);
assert.deepEqual(me.capabilities, ['admin:manage_users']);
assert.equal(me.user.mustChangePassword, false);
const login = mapLoginResult({
token: 'tok-2',
user: { ...snakeAdminUser, must_change_password: true },
must_change_password: false,
env: 'dev',
});
assert.equal(login.mustChangePassword, false);
assert.equal(login.user.mustChangePassword, false);
});
test('mapUserAccountList maps admin users list', () => {
+16 -4
View File
@@ -58,6 +58,20 @@ export function mapUserAccountList(raw: unknown): UserAccount[] {
return raw.map((item) => mapUserAccount(item));
}
/**
* Top-level must_change_password is the sole authority when present.
* Never OR-merge with nested user.mustChangePassword — that revived a cleared
* flag after FirstLoginPasswordModal only patched the top-level field.
*/
function resolveMustChangePassword(
o: Record<string, unknown>,
nestedUser: { mustChangePassword?: boolean },
): boolean {
if (typeof o.must_change_password === 'boolean') return o.must_change_password;
if (typeof o.mustChangePassword === 'boolean') return o.mustChangePassword as boolean;
return !!nestedUser.mustChangePassword;
}
/** 映射 /auth/login 响应(token + user + must_change_password + env)。 */
export function mapLoginResult(raw: unknown): LoginResult {
if (!raw || typeof raw !== 'object') {
@@ -65,8 +79,7 @@ export function mapLoginResult(raw: unknown): LoginResult {
}
const o = raw as Record<string, unknown>;
const user = mapUserAccount(o.user);
const mustChangePassword =
pickBool(o, 'must_change_password', 'mustChangePassword') || !!user.mustChangePassword;
const mustChangePassword = resolveMustChangePassword(o, user);
const capabilities = Array.isArray(o.capabilities)
? (o.capabilities as unknown[]).map(String)
: undefined;
@@ -86,8 +99,7 @@ export function mapMeResult(raw: unknown): MeResult {
}
const o = raw as Record<string, unknown>;
const user = mapUserAccount(o.user);
const mustChangePassword =
pickBool(o, 'must_change_password', 'mustChangePassword') || !!user.mustChangePassword;
const mustChangePassword = resolveMustChangePassword(o, user);
return {
user: { ...user, mustChangePassword },
mustChangePassword,
+6
View File
@@ -14,6 +14,10 @@ export class ProjectSettingsApiError extends Error {
readonly status: number;
readonly code: string;
readonly issues: ProjectSettingsIssue[];
/** Alias for consumers that read httpStatus (e.g. useTaskRunAttempts). */
readonly httpStatus: number;
/** Alias for consumers that read errorCode (e.g. useTaskRunAttempts). */
readonly errorCode: string;
constructor(input: {
status: number;
@@ -26,6 +30,8 @@ export class ProjectSettingsApiError extends Error {
this.status = input.status;
this.code = input.code;
this.issues = input.issues ?? [];
this.httpStatus = input.status;
this.errorCode = input.code;
}
}
+6 -1
View File
@@ -48,11 +48,16 @@ export async function fetchSystemSettings(): Promise<SystemSetting[]> {
export async function updateSystemSetting(
key: string,
value: SystemConfigValue,
expectedRevision?: number,
): Promise<SystemSetting> {
const body: { value: SystemConfigValue; expected_revision?: number } = { value };
if (expectedRevision !== undefined) {
body.expected_revision = expectedRevision;
}
const resp = await authorizedFetch(`${BASE}/settings/${encodeURIComponent(key)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ value }),
body: JSON.stringify(body),
});
if (!resp.ok) throw await readError(resp, '保存设置失败');
const data = (await resp.json()) as { setting?: SystemSetting } & SystemSetting;
+7 -1
View File
@@ -153,7 +153,13 @@ export async function getTaskRun(runId: string) {
headers: getBearerAuthHeaders(),
});
if (!res.ok) {
throw new Error(`get task run failed: HTTP ${res.status}`);
const err = new Error(`get task run failed: HTTP ${res.status}`) as Error & {
httpStatus?: number;
status?: number;
};
err.httpStatus = res.status;
err.status = res.status;
throw err;
}
return res.json();
}
@@ -22,6 +22,12 @@ const ALL_PERMISSION_ITEMS: {
desc: '允许在文档治理抽屉为交付物指派编制人、审核人与批准人',
category: '项目与流程',
},
{
key: 'project:manage_members',
label: '管理项目成员',
desc: '允许添加、调整角色或移出项目成员(非项目 owner 时需单独授予)',
category: '项目与流程',
},
{
key: 'perm:manage',
label: '系统权限与角色管理',
@@ -49,7 +49,11 @@ export const SettingsSection: React.FC = () => {
const handleSave = async (setting: SystemSetting) => {
setSavingKey(setting.key);
try {
const updated = await updateSystemSetting(setting.key, drafts[setting.key]);
const updated = await updateSystemSetting(
setting.key,
drafts[setting.key],
typeof setting.revision === 'number' ? setting.revision : undefined,
);
setSettings((prev) => prev.map((s) => (s.key === updated.key ? updated : s)));
setDrafts((prev) => ({ ...prev, [updated.key]: updated.value }));
showTip(
@@ -57,7 +61,13 @@ export const SettingsSection: React.FC = () => {
updated.requiresRestart ? `已保存【${updated.label || updated.key}】,重启服务后生效` : `已保存【${updated.label || updated.key}】`,
);
} catch (err) {
showTip('error', err instanceof Error ? err.message : '保存设置失败');
const status = (err as Error & { status?: number }).status;
if (status === 409) {
showTip('error', '设置版本冲突,请刷新后重试');
void load();
} else {
showTip('error', err instanceof Error ? err.message : '保存设置失败');
}
} finally {
setSavingKey(null);
}
+9 -2
View File
@@ -50,6 +50,8 @@ export interface ChatColumnProps {
activeQuotes: QuotedMessage[];
handleRemoveQuote: (index: number) => void;
isCurrentProjectExited: boolean;
/** When true, user has no project context and cannot send chat. */
chatBlockedNoProject?: boolean;
}
export function ChatColumn({
@@ -87,6 +89,7 @@ export function ChatColumn({
activeQuotes,
handleRemoveQuote,
isCurrentProjectExited,
chatBlockedNoProject = false,
}: ChatColumnProps) {
return (
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
@@ -234,8 +237,12 @@ export function ChatColumn({
onStopStreaming={handleStopStreaming}
quotes={activeQuotes}
onRemoveQuote={handleRemoveQuote}
disabled={isCurrentProjectExited}
disabledMessage="您已退出该研发项目。依据受控体系审计要求,会话已转为只读留痕模式,无法继续发送消息。"
disabled={isCurrentProjectExited || chatBlockedNoProject}
disabledMessage={
chatBlockedNoProject
? '请先创建或加入项目后再开始对话'
: '您已退出该研发项目。依据受控体系审计要求,会话已转为只读留痕模式,无法继续发送消息。'
}
/>
</div>
);
@@ -7,38 +7,68 @@ interface PdfViewerProps {
fileName?: string;
}
export const PdfViewer: React.FC<PdfViewerProps> = ({ fileRaw, fileUrl }) => {
export const PdfViewer: React.FC<PdfViewerProps> = ({ fileRaw, fileUrl, fileName }) => {
const [activeUrl, setActiveUrl] = useState<string>('');
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let createdUrl: string | null = null;
let cancelled = false;
async function loadPdf() {
setLoading(true);
setError(null);
setActiveUrl('');
if (fileUrl) {
setActiveUrl(fileUrl);
setLoading(false);
} else if (fileRaw) {
try {
createdUrl = URL.createObjectURL(fileRaw);
let blob: Blob | null = null;
if (fileRaw) {
blob = fileRaw;
} else if (fileUrl) {
// Always fetch → blob → createObjectURL so iframe never loads a
// same-origin URL that carries X-Frame-Options: DENY / frame-ancestors 'none'.
const res = await fetch(fileUrl);
if (!res.ok) {
throw new Error(`无法获取 PDF 网络资源: ${res.statusText || res.status}`);
}
blob = await res.blob();
}
if (cancelled) return;
if (!blob) {
if (fileName) {
setError(
`文件「${fileName}」的本地内容已释放(页面刷新后浏览器会清除内存中的 File 对象)。如需预览,请重新上传该文件。`,
);
} else {
setError('未检测到有效的 PDF 文件资源');
}
setLoading(false);
return;
}
createdUrl = URL.createObjectURL(blob);
setActiveUrl(createdUrl);
setLoading(false);
} catch (err: any) {
} catch (err: unknown) {
if (cancelled) return;
console.error('无法创建 PDF 预览链接:', err);
setError('生成 PDF 预览地址失败');
setError(err instanceof Error ? err.message : '生成 PDF 预览地址失败');
setLoading(false);
}
} else {
setError('未检测到有效的 PDF 文件资源');
setLoading(false);
}
void loadPdf();
return () => {
cancelled = true;
if (createdUrl && typeof window !== 'undefined' && typeof window.URL?.revokeObjectURL === 'function') {
window.URL.revokeObjectURL(createdUrl);
}
};
}, [fileRaw, fileUrl]);
}, [fileRaw, fileUrl, fileName]);
if (error) {
return (
+12 -2
View File
@@ -6,6 +6,7 @@ import { ChatColumn } from './ChatColumn';
import { RightDocRail } from './RightDocRail';
import { ProjectSession } from '../../types/workbench';
import { UserRole, UserProfile } from '../../types/docgov';
import { loadRightRailCollapsed, saveRightRailCollapsed } from '../../data/storage';
interface AppShellProps {
sessions: ProjectSession[];
@@ -62,12 +63,13 @@ export const AppShell: React.FC<AppShellProps> = ({
const [leftWidth, setLeftWidth] = useState<number>(280);
const [leftCollapsed, setLeftCollapsed] = useState<boolean>(false);
const [rightWidth, setRightWidth] = useState<number>(500);
const [rightCollapsed, setRightCollapsed] = useState<boolean>(false);
const [rightCollapsed, setRightCollapsed] = useState<boolean>(() => loadRightRailCollapsed(true));
useEffect(() => {
const onResize = () => {
const narrow = window.innerWidth <= 880;
if (narrow) {
setLeftCollapsed(true);
// Narrow-screen force-collapse must not overwrite the persisted preference.
setRightCollapsed(true);
}
};
@@ -76,6 +78,14 @@ export const AppShell: React.FC<AppShellProps> = ({
return () => window.removeEventListener('resize', onResize);
}, []);
const toggleRightCollapsed = () => {
setRightCollapsed((prev) => {
const next = !prev;
saveRightRailCollapsed(next);
return next;
});
};
const currentSession =
sessions.find((s) => s.sessionId === activeSessionId) || sessions[0] || {
sessionId: 'sess-default',
@@ -126,7 +136,7 @@ export const AppShell: React.FC<AppShellProps> = ({
leftCollapsed={leftCollapsed}
onToggleLeft={() => setLeftCollapsed(!leftCollapsed)}
rightCollapsed={rightCollapsed}
onToggleRight={() => setRightCollapsed(!rightCollapsed)}
onToggleRight={toggleRightCollapsed}
activeWorkflowKey={activeWorkflowKey}
workflowVersion={workflowVersion}
lifecycleStatus={lifecycleStatus}
+5 -1
View File
@@ -213,7 +213,11 @@ export function WorkbenchView({
user={currentUser}
onPasswordChanged={() => {
if (!session) return;
const updated = { ...session, mustChangePassword: false };
const updated = {
...session,
mustChangePassword: false,
user: { ...session.user, mustChangePassword: false },
};
setSession(updated);
authClient.saveSession(updated);
}}
@@ -32,6 +32,7 @@ export interface UserSettingsModalProps {
const ALL_PERMISSION_DESCRIPTIONS: { key: string; name: string; desc: string; category: string }[] = [
{ key: 'project:create', name: '创建新项目/立项', desc: '具备在协同工作台开启全新研发项目并指派阶段模板的权限', category: '项目协作' },
{ key: 'project:assign_lead', name: '设置文档责任人', desc: '在文档治理面板为交付物指派编制人、审核人与批准人', category: '项目协作' },
{ key: 'project:manage_members', name: '管理项目成员', desc: '添加、调整角色或移出项目成员(非项目创建人时需单独授予)', category: '项目协作' },
{ key: 'perm:manage', name: '系统权限与角色管理', desc: '增删改全局职位角色定义与权限矩阵 (管理员独占)', category: '权限安全' },
{ key: 'workflow:config_visibility', name: '配置工作流默认可见性', desc: '调整业务阶段中各受控交付物的基线查阅准入边界', category: '权限安全' },
{ key: 'doc:override_visibility', name: '配置单文档可见性覆盖', desc: '针对特定核心机密交付物设定独占白名单或责任人收紧', category: '权限安全' },
+1
View File
@@ -1,6 +1,7 @@
import { ProjectInfo, ProjectMember, ProjectSession } from '../types/workbench';
import { DEFAULT_PROJECTS } from '@demo-seeds';
/** Must stay in sync with STORAGE_KEYS.PROJECTS in storage.ts */
const STORAGE_KEY_PROJECTS = 'smdf_agent_workbench_v6_projects';
export { DEFAULT_PROJECTS };
+11
View File
@@ -32,6 +32,8 @@ export const STORAGE_KEYS = {
DOC_CACHE: `${STORAGE_PREFIX}doc_cache`,
ROLES: `${STORAGE_PREFIX}roles`,
USERS: `${STORAGE_PREFIX}users_v7`,
PROJECTS: `${STORAGE_PREFIX}projects`,
RIGHT_RAIL_COLLAPSED: `${STORAGE_PREFIX}right_rail_collapsed`,
};
export {
@@ -361,3 +363,12 @@ export function resetWorkbenchStorage(): void {
Object.values(STORAGE_KEYS).forEach((k) => window.localStorage.removeItem(k));
window.localStorage.removeItem(DEMO_SEED_PURGE_KEY);
}
/** Default true = collapsed (user preference for right doc rail). */
export function loadRightRailCollapsed(fallback = true): boolean {
return safeGetItem<boolean>(STORAGE_KEYS.RIGHT_RAIL_COLLAPSED, fallback);
}
export function saveRightRailCollapsed(collapsed: boolean): void {
safeSetItem(STORAGE_KEYS.RIGHT_RAIL_COLLAPSED, collapsed);
}
@@ -73,6 +73,17 @@ export function useAuthenticatedWorkspaceSync(input: AuthenticatedWorkspaceSyncI
.then((me) => {
setCapabilities(me.capabilities ?? []);
setCurrentUser(mergeSessionUser(me.user, match));
// Rewrite session from /auth/me so a polluted local mustChangePassword
// (nested user flag) cannot survive refresh after a successful change.
const nextSession = {
...saved,
user: { ...saved.user, ...me.user, mustChangePassword: me.mustChangePassword },
mustChangePassword: me.mustChangePassword,
env: me.env || saved.env,
capabilities: me.capabilities ?? saved.capabilities ?? [],
};
setSession(nextSession);
authClient.saveSession(nextSession);
setSessionValidated(true);
})
.catch(() => {
+5
View File
@@ -78,8 +78,13 @@ export function useCreateProjectConfirm(input: CreateProjectConfirmInput) {
const ensured = await ensureCreatedProjectWithMembers({
bootstrapProjectId: createProjectBootstrapIdRef.current,
createProject: async () => {
const clientKey =
typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
? `proj-${crypto.randomUUID()}`
: `proj-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
const created = await createProject({
name: data.title,
client_key: clientKey,
device_type: data.deviceType || undefined,
intended_use: data.productDomain === 'non_medical_software' ? undefined : data.description,
product_domain: data.productDomain,
+2
View File
@@ -72,6 +72,8 @@ export interface CreateProjectInput {
problem_statement?: string;
target_users?: string;
target_outcome?: string;
/** Idempotency key; same creator+key returns the existing project. */
client_key?: string;
/** @deprecated Accepted by API for catalog checks only; create dialog must not send this. */
initial_workflow_key?: string;
}
+1
View File
@@ -1,6 +1,7 @@
export type PermissionKey =
| 'project:create' // 创建新项目/立项
| 'project:assign_lead' // 设置/变更文档责任人(编制/审核/批准)
| 'project:manage_members' // 管理项目成员(添加/调整/移出)
| 'perm:manage' // 系统权限与角色管理配置 (进入管理中心增删改角色)
| 'workflow:config_visibility' // 配置工作流默认可见性
| 'doc:override_visibility' // 配置单文档可见性覆盖
+2
View File
@@ -49,6 +49,8 @@ export interface SystemSetting {
writable: boolean;
requiresRestart: boolean;
description: string;
/** CAS revision for versioned settings (e.g. workflow.allow_author_only_approval). */
revision?: number;
updatedAt: string;
updatedBy: string;
}