diff --git a/.doc/ops/short-term/2026-09-25-server-image-field-closeout.md b/.doc/ops/short-term/2026-09-25-server-image-field-closeout.md new file mode 100644 index 00000000..f0c9e41c --- /dev/null +++ b/.doc/ops/short-term/2026-09-25-server-image-field-closeout.md @@ -0,0 +1,81 @@ +--- +title: "P7: Server 镜像字段增量收口" +task_id: server-image-field-closeout-r3-2026-09-25 +status: in_progress +created: 2026-09-25 +updated: 2026-09-25 +owner: cursor-auto +task_type: implementation +task_tier: small +policy_revision: "agent-governance-2026-09-21-r1" +branch: feature/server-image-field-closeout-2026-09-25 +worktree: .worktrees/server-image-field-closeout-2026-09-25 +base_dev_sha: "44161d2ad2d7aada3e9ff88127b52148543278e5" +write_scope: + - internal/server/ + - internal/app/ + - deploy/postgres/goose/ + - .doc/ops/short-term/ + - .doc/test-reports/ +allowed_environments: [] +head_sha: "" +mutex: module-decoupling-spec +review_state: pending +integration_state: pending +deployment: not_required +cleanup: pending +cleanup_owner: independent maintainer after owner exit +cleanup_trigger: acceptance green + optional owner exit +remote_sync: not_requested +--- + +# P7: Server 镜像字段增量收口 + +## 用户目标与授权 + +- 用户目标:`/goal 把这些任务都进行规划,并推进至完成` → 总控 [P7 批次](./2026-09-25-planned-tasks-closeout-plan.md)。 +- 来源:MATRIX `module-batches-ag` 行尾注「Server 镜像字段增量收口另立任务」;[batch A 残留债务表](./2026-09-22-server-assembly-cleanup.md)「`gov` 等镜像字段:迁完用例前保留镜像」。 +- 交付终点:删除 `Server` 全部 Runtime 镜像字段、短测+race 绿、arch-check PASS、可合入 dev。 +- 禁止项:不 push;不改 HTTP 状态码语义;不动 FAV live / production_ready。 + +## 开工检索 + +- `matrix|镜像字段|module-batches-ag` → [ISSUES](../../audit/ISSUES.md) 无命中;[lessons INDEX](../lessons/INDEX.md) 无命中。 +- 采纳检查:全仓 grep(镜像字段访问 `s.X` 穷举 485 处)+ 基线 race 复测(临时 worktree 对照)。 + +## 实施内容 + +1. **删除 `Server` 24 个 Runtime 镜像字段**(`svc/pwSvc/rec/engine/authSvc/taskRuns/authorizer/authzSvc/wfDefSvc/instSvc/notifySvc/resolver/wsCap/cmds/stageflow/gov/rev/ctxMgr/cas/freeDoc/kb/sysadmin/maintenance` 及 stop 通道),`NewFromRuntime` 装配行同步删除。 +2. **新增 `internal/server/runtime_accessors.go`**:同名 nil 安全访问器(`s.gov()` → `s.rt.Gov`),集中承接领域服务访问;测试注入走 `serverTestOverrides` + `setOverride*` setter(显式 nil 经 `authorizerNil`/`instSvcNil` 位标记支持 fail-closed 用例)。 +3. **574 处调用点机械替换** `s.X`/`srv.X` → `s.X()`/`srv.X()`(脚本 + 编译核对)。 +4. **附带修复(既有债务撞车)**: + - `Shutdown` 与 4 个后台循环(sweeper/publisher/reminder/wsGC)对 stop 通道的 DATA RACE——dev 基线 `44161d2a` 复测同样失败(`chat_turn_sweeper.go:21` vs `server.go:471`)。新增 `backgroundLoop`(mutex + `sync.Once` 通道),通道只在 loop goroutine 内创建持有,`Shutdown` 经 `stop()` 通知。 + - **goose 00071 版本号冲突**:`1da17a28` dialogue 合并同时带入两个 `00071_*`(maintenance-windows、context-conflict-adjudications),goose sort panic 阻塞全仓 `go test`。dev 库(`agent_system_dev@5433`)已应用至 00070、两个 00071 均未应用 → `context-conflict-adjudications.sql` 重编号 `00072`,零数据迁移。 +5. `task_finalize_test.go` 改用 `newMinimalRuntime`(仅测试所需字段的 `app.Runtime`)。 + +## 非目标 + +- 迁移 handler 到 `internal/app` 用例层(batch-plan 原愿景):本批完成镜像字段删除后,`app.Runtime` 已是唯一服务持有者,后续用例迁移不再受镜像字段干扰。 +- `supplementImpact` 字段保留:本包接口类型非 Runtime 镜像。 +- `intentPlans` 测试 override 经 `intentPlansOverride` 字段(setter 语义无法用访问器表达)。 + +## 验证与证据 + +| 范围 / 被测 SHA | 命令与环境 | 预期 | 实际 / 判定 | 证据 | +|---|---|---|---|---| +| server 包短测 | `go test -short -count=1 ./internal/server/` | PASS | PASS(19.9s) | 本机 Linux | +| server 包 race | `go test -race -short -count=1 ./internal/server/` | PASS | PASS(232s,含既有 race 修复后) | 基线 `44161d2a` 同用例 FAIL(DATA RACE)已复测留证 | +| 相邻包 | `go test -short ./internal/app/... ./internal/chat/... ./internal/intent/...` | PASS | PASS 全 12 包 | | +| 全仓 short | `go test -short -count=1 ./...` | PASS | PASS(含 goose 00072 修复后 bizdev-cli) | 基线因 00071 重复 panic | +| 架构门禁 | `./scripts/arch-check.sh --mode block` | PASS | PASS | | +| 工程工具 | `python3 -B -m unittest discover -s scripts/tests` | PASS | PASS 234 tests | | + +## 收口与交接 + +- 本地提交 / 待 validate + integrate 合入 dev / 不部署 / 不 push。 +- 详见测试报告:[2026-09-25-p7-server-image-field-closeout-test-report.md](../../test-reports/2026-09-25-p7-server-image-field-closeout-test-report.md) + +## 相关链接 + +- 总控计划:[2026-09-25-planned-tasks-closeout-plan.md](./2026-09-25-planned-tasks-closeout-plan.md) +- 债务来源:[2026-09-22-server-assembly-cleanup.md](./2026-09-22-server-assembly-cleanup.md) diff --git a/.doc/test-reports/2026-09-25-p7-server-image-field-closeout-test-report.md b/.doc/test-reports/2026-09-25-p7-server-image-field-closeout-test-report.md new file mode 100644 index 00000000..3ca8178a --- /dev/null +++ b/.doc/test-reports/2026-09-25-p7-server-image-field-closeout-test-report.md @@ -0,0 +1,85 @@ +# P7 Server 镜像字段增量收口 测试报告 + +## 一、 测试背景 (Background) + +MATRIX `module-batches-ag` 行尾注遗留「Server 镜像字段增量收口另立任务」:`internal/server.Server` 结构镜像了 `app.Runtime` 的 24 个领域服务字段(`gov/svc/pwSvc/instSvc/...`),batch A(2026-09-22)时因「多数 docgov/bizdev HTTP 仍直调引擎」保留。现由总控计划 P7 批次授权收口。 + +## 二、 测试目标 (Test Objectives) + +1. `Server` 删除全部 Runtime 镜像字段后编译、vet 干净。 +2. server 包行为不变:short 全量 + race 全量 PASS。 +3. 相邻包(app/chat/intent 链路)无回归。 +4. 全仓 short 无回归(含 goose 迁移可加载)。 +5. 架构门禁 `arch-check --mode block` PASS。 +6. 附带修复验证:既有 DATA RACE(Shutdown vs 后台循环)消失;goose 00071 版本冲突解除。 + +## 三、 测试方案与环境 (Test Plan & Environment) + +- OS:Linux 7.0.0-31-generic;Go 1.27.1(linuxbrew)。 +- 被测 worktree:`.worktrees/server-image-field-closeout-2026-09-25`(base dev `44161d2a`)。 +- 层级:工程工具改动层(Go 单测 + race + 架构门禁 + 工程脚本单测),按验证契约不适用产品 PI 全链路。 +- 用例矩阵: + +| # | 用例 | 命令 | +|---|---|---| +| T1 | server short | `go test -short -count=1 ./internal/server/` | +| T2 | server race(含修复验证) | `go test -race -short -count=1 ./internal/server/` | +| T3 | 相邻包 | `go test -short -count=1 ./internal/app/... ./internal/chat/... ./internal/intent/...` | +| T4 | 全仓 short | `go test -short -count=1 ./...` | +| T5 | 架构门禁 | `./scripts/arch-check.sh --mode block` | +| T6 | 工程工具 | `python3 -B -m unittest discover -s scripts/tests -p 'test_*.py'` | +| T7 | race 基线对照 | dev `44161d2a` 临时 worktree 重跑失败用例 | + +## 四、 预期结果 (Expected Results) + +- T1–T6 全 PASS。 +- T7 基线复现 DATA RACE(证明非本批引入、本批修复有效)。 + +## 五、 实际测试结果与判定 (Actual Results & Evaluation) + +### T1 server short +- 输入:`go test -short -count=1 ./internal/server/` +- 实际输出:`ok chat-agent-system/internal/server 19.946s` +- 判定:**PASS**(首轮曾 FAIL `TestCreateFreeformDoc_FailClosedWhenAuthorizerNil`——override nil 语义未区分「未设置/置 nil」,补 `authorizerNil` 位标记后通过) + +### T2 server race +- 输入:`go test -race -short -count=1 ./internal/server/` +- 实际输出:`ok chat-agent-system/internal/server 232.271s` +- 判定:**PASS** + +### T3 相邻包 +- 实际输出:`ok` × 12(internal/app、app/chatflow、app/docadvance、app/docflow、app/docnotify、app/docproject、app/fav、app/intentflow、app/stageflow、app/uow、chat、intent) +- 判定:**PASS** + +### T4 全仓 short +- 实际输出:仅 `ok`/`no test files`,无 FAIL +- 判定:**PASS**(基线因 goose `00071` 重复 panic `TestBizdevCLIListTasks`;重编号 `00072_2026-09-25-context-conflict-adjudications.sql` 后通过。dev 库已应用至 00070、两个 00071 均未应用,重编号零数据迁移) + +### T5 架构门禁 +- 实际输出:`arch-check block: production gate passed` +- 判定:**PASS** + +### T6 工程工具 +- 实际输出:`Ran 234 tests in 189.907s / OK` +- 判定:**PASS** + +### T7 race 基线对照(附带修复证据) +- 输入:dev `44161d2a` 临时 worktree `go test -race -short -count=1 -run 'TestAdminSystemSettings_ListAndUpdate' ./internal/server/` +- 实际输出:`WARNING: DATA RACE`,栈:`Shutdown (server.go:471 Write)` vs `startChatTurnSweeper.func1 (chat_turn_sweeper.go:21 Read)` +- 判定:**基线 FAIL 复现**(既有债务,非本批引入);本批 `backgroundLoop` 修复后同域用例(`TestAdminSystem*` 全组 + T2 全量)PASS。 + +## 六、 自动化测试套件汇总 (Test Suite Execution) + +| 套件 | 结果 | 通过率 | +|---|---|---| +| `go test -short ./internal/server/` | PASS | 100% | +| `go test -race -short ./internal/server/` | PASS | 100% | +| `go test -short ./internal/app/... ./internal/chat/... ./internal/intent/...` | PASS | 100%(12 包) | +| `go test -short ./...`(全仓 67 包) | PASS | 100% | +| `arch-check --mode block` | PASS | — | +| 工程脚本单测 | PASS | 234/234 | + +## 七、 测试结论与后续优化建议 (Conclusion & Next Steps) + +- 结论:P7 达到授权交付终点——`Server` 24 个镜像字段删除、574 处调用点迁访问器、附带修复既有 DATA RACE 与 goose 版本冲突,全部验证绿。 +- 后续:batch-plan 愿景中的「handler 迁 `internal/app` 用例层」在镜像字段移除后可独立推进,不再受本债务约束;建议列入 backlog 而非本轮。 diff --git a/deploy/postgres/goose/00071_2026-09-25-context-conflict-adjudications.sql b/deploy/postgres/goose/00072_2026-09-25-context-conflict-adjudications.sql similarity index 100% rename from deploy/postgres/goose/00071_2026-09-25-context-conflict-adjudications.sql rename to deploy/postgres/goose/00072_2026-09-25-context-conflict-adjudications.sql diff --git a/internal/server/admin_roles_handlers.go b/internal/server/admin_roles_handlers.go index 3f389161..d951d373 100644 --- a/internal/server/admin_roles_handlers.go +++ b/internal/server/admin_roles_handlers.go @@ -16,7 +16,7 @@ func (s *Server) handleAdminRoles(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: - roles, err := s.authzSvc.ListRoles(r.Context()) + roles, err := s.authzSvc().ListRoles(r.Context()) if err != nil { writeSafeError(w, r, http.StatusInternalServerError, err.Error()) return @@ -28,7 +28,7 @@ func (s *Server) handleAdminRoles(w http.ResponseWriter, r *http.Request) { writeSafeError(w, r, http.StatusBadRequest, "无效的 JSON 请求体") return } - created, err := s.authzSvc.CreateRole(r.Context(), req) + created, err := s.authzSvc().CreateRole(r.Context(), req) if err != nil { status := http.StatusBadRequest if errors.Is(err, authz.ErrRolesNotAvailable) { @@ -61,7 +61,7 @@ func (s *Server) handleAdminRoleByID(w http.ResponseWriter, r *http.Request) { writeSafeError(w, r, http.StatusBadRequest, "无效的 JSON 请求体") return } - updated, err := s.authzSvc.UpdateRole(r.Context(), roleID, req) + updated, err := s.authzSvc().UpdateRole(r.Context(), roleID, req) if err != nil { status := http.StatusBadRequest switch { @@ -77,7 +77,7 @@ func (s *Server) handleAdminRoleByID(w http.ResponseWriter, r *http.Request) { } writeJSONResponse(w, http.StatusOK, updated) case http.MethodDelete: - if err := s.authzSvc.DeleteRole(r.Context(), roleID); err != nil { + if err := s.authzSvc().DeleteRole(r.Context(), roleID); err != nil { status := http.StatusBadRequest switch { case errors.Is(err, authz.ErrRoleNotFound): diff --git a/internal/server/admin_system_handlers.go b/internal/server/admin_system_handlers.go index c26e87cb..5dec8eaf 100644 --- a/internal/server/admin_system_handlers.go +++ b/internal/server/admin_system_handlers.go @@ -16,44 +16,44 @@ func (s *Server) handleAdminSystemOverview(w http.ResponseWriter, r *http.Reques if _, ok := s.requireSystemPermission(w, r, authz.PermSystemOverview); !ok { return } - if s.sysadmin == nil { + if s.sysadmin() == nil { writeSafeError(w, r, http.StatusServiceUnavailable, "sysadmin service unavailable") return } - writeJSONResponse(w, http.StatusOK, s.sysadmin.Overview(r.Context())) + writeJSONResponse(w, http.StatusOK, s.sysadmin().Overview(r.Context())) } func (s *Server) handleAdminSystemKB(w http.ResponseWriter, r *http.Request) { if _, ok := s.requireSystemPermission(w, r, authz.PermSystemKBRead); !ok { return } - if s.sysadmin == nil { + if s.sysadmin() == nil { writeSafeError(w, r, http.StatusServiceUnavailable, "sysadmin service unavailable") return } - writeJSONResponse(w, http.StatusOK, s.sysadmin.KBInfo()) + writeJSONResponse(w, http.StatusOK, s.sysadmin().KBInfo()) } func (s *Server) handleAdminSystemFeatures(w http.ResponseWriter, r *http.Request) { if _, ok := s.requireSystemPermission(w, r, authz.PermSystemFeaturesRead); !ok { return } - if s.sysadmin == nil { + if s.sysadmin() == nil { writeSafeError(w, r, http.StatusServiceUnavailable, "sysadmin service unavailable") return } - writeJSONResponse(w, http.StatusOK, map[string]any{"features": s.sysadmin.Features()}) + writeJSONResponse(w, http.StatusOK, map[string]any{"features": s.sysadmin().Features()}) } func (s *Server) handleAdminSystemSettings(w http.ResponseWriter, r *http.Request) { if _, ok := s.requireSystemPermission(w, r, authz.PermSystemSettingsRead); !ok { return } - if s.sysadmin == nil { + if s.sysadmin() == nil { writeSafeError(w, r, http.StatusServiceUnavailable, "sysadmin service unavailable") return } - settings, err := s.sysadmin.ListSettings(r.Context()) + settings, err := s.sysadmin().ListSettings(r.Context()) if err != nil { writeSafeError(w, r, http.StatusInternalServerError, err.Error()) return @@ -66,7 +66,7 @@ func (s *Server) handleAdminSystemSettingByKey(w http.ResponseWriter, r *http.Re if !ok { return } - if s.sysadmin == nil { + if s.sysadmin() == nil { writeSafeError(w, r, http.StatusServiceUnavailable, "sysadmin service unavailable") return } @@ -79,7 +79,7 @@ func (s *Server) handleAdminSystemSettingByKey(w http.ResponseWriter, r *http.Re writeSafeError(w, r, http.StatusBadRequest, "无效的请求体") return } - setting, err := s.sysadmin.UpdateSettingEx(r.Context(), sysadmin.SettingUpdate{ + setting, err := s.sysadmin().UpdateSettingEx(r.Context(), sysadmin.SettingUpdate{ Key: key, Value: req.Value, UpdatedBy: user.ID, @@ -110,7 +110,7 @@ func (s *Server) handleAdminSystemSettingAudits(w http.ResponseWriter, r *http.R if _, ok := s.requireSystemPermission(w, r, authz.PermSystemAuditRead); !ok { return } - if s.sysadmin == nil { + if s.sysadmin() == nil { writeSafeError(w, r, http.StatusServiceUnavailable, "sysadmin service unavailable") return } @@ -124,7 +124,7 @@ func (s *Server) handleAdminSystemSettingAudits(w http.ResponseWriter, r *http.R } limit = n } - audits, err := s.sysadmin.ListPolicyAudits(r.Context(), key, limit) + audits, err := s.sysadmin().ListPolicyAudits(r.Context(), key, limit) switch { case errors.Is(err, sysadmin.ErrUnknownSetting): writeSafeError(w, r, http.StatusBadRequest, "未知的设置键") diff --git a/internal/server/admin_system_handlers_test.go b/internal/server/admin_system_handlers_test.go index 91ea5751..a5e653fe 100644 --- a/internal/server/admin_system_handlers_test.go +++ b/internal/server/admin_system_handlers_test.go @@ -285,7 +285,7 @@ func TestAdminSystemSettings_ListAndUpdate(t *testing.T) { srv := newTestServer(t) srv.cfg.AllowInMemory = false // 关闭 legacy 未认证兜底,严格鉴权 // 用带内存 store 的 sysadmin 服务替换装配产物(无 DB 时 store 为 nil)。 - srv.sysadmin = sysadmin.NewService(srv.cfg, sysadmin.Options{Store: newFakeSettingsStore()}) + srv.setOverrideSysadmin(sysadmin.NewService(srv.cfg, sysadmin.Options{Store: newFakeSettingsStore()})) token := loginTestAdmin(t, srv) w := doSysadminRequest(t, srv, http.MethodGet, "/api/v1/admin/system/settings", token, "") @@ -341,7 +341,7 @@ func TestAdminSystemSettings_AuthorOnlyPolicyCAS(t *testing.T) { store := newFakeSettingsStore() // 模拟迁移种子行 _, _, _ = store.Upsert(context.Background(), sysadmin.SettingKeyAllowAuthorOnlyApproval, json.RawMessage(`false`), 0) - srv.sysadmin = sysadmin.NewService(srv.cfg, sysadmin.Options{Store: store}) + srv.setOverrideSysadmin(sysadmin.NewService(srv.cfg, sysadmin.Options{Store: store})) token := loginTestAdmin(t, srv) plainToken := registerTestUser(t, srv, "plain_author_only_probe") @@ -387,7 +387,7 @@ func TestAdminSystemSettings_ListAudits(t *testing.T) { srv.cfg.AllowInMemory = false store := newFakeSettingsStore() _, _, _ = store.Upsert(context.Background(), sysadmin.SettingKeyAllowAuthorOnlyApproval, json.RawMessage(`false`), 0) - srv.sysadmin = sysadmin.NewService(srv.cfg, sysadmin.Options{Store: store}) + srv.setOverrideSysadmin(sysadmin.NewService(srv.cfg, sysadmin.Options{Store: store})) token := loginTestAdmin(t, srv) plainToken := registerTestUser(t, srv, "plain_audit_probe") diff --git a/internal/server/admin_template_handlers.go b/internal/server/admin_template_handlers.go index d64ed9b0..6a8e1b44 100644 --- a/internal/server/admin_template_handlers.go +++ b/internal/server/admin_template_handlers.go @@ -17,7 +17,7 @@ func (s *Server) handleAdminTemplates(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: - assets, err := s.wfDefSvc.ListTemplates(r.Context()) + assets, err := s.wfDefSvc().ListTemplates(r.Context()) if err != nil { writeSafeError(w, r, http.StatusInternalServerError, err.Error()) return @@ -51,7 +51,7 @@ func (s *Server) handleAdminTemplates(w http.ResponseWriter, r *http.Request) { writeSafeError(w, r, http.StatusBadRequest, "read file failed") return } - tv, err := s.wfDefSvc.UploadTemplate(r.Context(), assetKey, name, data, header.Filename, user.ID) + tv, err := s.wfDefSvc().UploadTemplate(r.Context(), assetKey, name, data, header.Filename, user.ID) if err != nil { status := http.StatusBadRequest switch { @@ -82,7 +82,7 @@ func (s *Server) handleAdminTemplateVersions(w http.ResponseWriter, r *http.Requ writeSafeError(w, r, http.StatusBadRequest, "invalid asset key") return } - versions, err := s.wfDefSvc.ListTemplateVersions(r.Context(), assetKey) + versions, err := s.wfDefSvc().ListTemplateVersions(r.Context(), assetKey) if err != nil { writeSafeError(w, r, http.StatusInternalServerError, err.Error()) return diff --git a/internal/server/admin_workflow_handlers.go b/internal/server/admin_workflow_handlers.go index 7d44317b..557846ef 100644 --- a/internal/server/admin_workflow_handlers.go +++ b/internal/server/admin_workflow_handlers.go @@ -16,7 +16,7 @@ func (s *Server) requireWorkflowAdmin(w http.ResponseWriter, r *http.Request) (* if !ok { return nil, false } - if s.wfDefSvc == nil || !s.wfDefSvc.Available() { + if s.wfDefSvc() == nil || !s.wfDefSvc().Available() { writeSafeError(w, r, http.StatusServiceUnavailable, "工作流定义服务需要数据库 IAM") return nil, false } @@ -30,7 +30,7 @@ func (s *Server) handleAdminWorkflows(w http.ResponseWriter, r *http.Request) { } switch r.Method { case http.MethodGet: - defs, err := s.wfDefSvc.ListDefinitions(r.Context()) + defs, err := s.wfDefSvc().ListDefinitions(r.Context()) if err != nil { writeSafeError(w, r, http.StatusInternalServerError, err.Error()) return @@ -42,7 +42,7 @@ func (s *Server) handleAdminWorkflows(w http.ResponseWriter, r *http.Request) { writeSafeError(w, r, http.StatusBadRequest, "无效的 JSON") return } - def, ver, err := s.wfDefSvc.CreateDefinition(r.Context(), req, user.ID) + def, ver, err := s.wfDefSvc().CreateDefinition(r.Context(), req, user.ID) if err != nil { status := http.StatusBadRequest if errors.Is(err, workflowdef.ErrValidationFailed) { @@ -70,12 +70,12 @@ func (s *Server) handleAdminWorkflowByID(w http.ResponseWriter, r *http.Request) writeSafeError(w, r, http.StatusMethodNotAllowed, "method not allowed") return } - def, err := s.wfDefSvc.GetDefinition(r.Context(), id) + def, err := s.wfDefSvc().GetDefinition(r.Context(), id) if err != nil { writeSafeError(w, r, http.StatusNotFound, err.Error()) return } - versions, err := s.wfDefSvc.ListVersions(r.Context(), id) + versions, err := s.wfDefSvc().ListVersions(r.Context(), id) if err != nil { writeSafeError(w, r, http.StatusInternalServerError, err.Error()) return @@ -108,7 +108,7 @@ func (s *Server) handleAdminWorkflowVersion(w http.ResponseWriter, r *http.Reque writeSafeError(w, r, http.StatusBadRequest, "invalid json") return } - updated, err := s.wfDefSvc.UpdateDraft(r.Context(), workflowID, version, body.Definition) + updated, err := s.wfDefSvc().UpdateDraft(r.Context(), workflowID, version, body.Definition) if err != nil { status := http.StatusBadRequest if errors.Is(err, workflowdef.ErrNotDraft) { @@ -134,7 +134,7 @@ func (s *Server) handleAdminWorkflowValidate(w http.ResponseWriter, r *http.Requ } workflowID, _ := strconv.ParseInt(r.PathValue("id"), 10, 64) version, _ := strconv.Atoi(r.PathValue("version")) - res, err := s.wfDefSvc.ValidateVersion(r.Context(), workflowID, version) + res, err := s.wfDefSvc().ValidateVersion(r.Context(), workflowID, version) if err != nil { writeSafeError(w, r, http.StatusNotFound, err.Error()) return @@ -153,7 +153,7 @@ func (s *Server) handleAdminWorkflowPublish(w http.ResponseWriter, r *http.Reque } workflowID, _ := strconv.ParseInt(r.PathValue("id"), 10, 64) version, _ := strconv.Atoi(r.PathValue("version")) - ver, err := s.wfDefSvc.PublishVersion(r.Context(), workflowID, version, user.ID) + ver, err := s.wfDefSvc().PublishVersion(r.Context(), workflowID, version, user.ID) if err != nil { status := http.StatusBadRequest if errors.Is(err, workflowdef.ErrValidationFailed) { @@ -162,7 +162,7 @@ func (s *Server) handleAdminWorkflowPublish(w http.ResponseWriter, r *http.Reque writeSafeError(w, r, status, err.Error()) return } - if refresher, ok := s.pwSvc.Catalog().(interface { + if refresher, ok := s.pwSvc().Catalog().(interface { Refresh(context.Context) error }); ok { _ = refresher.Refresh(r.Context()) @@ -180,12 +180,12 @@ func (s *Server) handleAdminWorkflowRetire(w http.ResponseWriter, r *http.Reques } workflowID, _ := strconv.ParseInt(r.PathValue("id"), 10, 64) version, _ := strconv.Atoi(r.PathValue("version")) - ver, err := s.wfDefSvc.RetireVersion(r.Context(), workflowID, version) + ver, err := s.wfDefSvc().RetireVersion(r.Context(), workflowID, version) if err != nil { writeSafeError(w, r, http.StatusBadRequest, err.Error()) return } - if refresher, ok := s.pwSvc.Catalog().(interface { + if refresher, ok := s.pwSvc().Catalog().(interface { Refresh(context.Context) error }); ok { _ = refresher.Refresh(r.Context()) diff --git a/internal/server/asset_handlers.go b/internal/server/asset_handlers.go index 5efed070..f7a8d597 100644 --- a/internal/server/asset_handlers.go +++ b/internal/server/asset_handlers.go @@ -72,7 +72,7 @@ func (s *Server) handleUploadProjectAsset(w http.ResponseWriter, r *http.Request return } - cas := s.cas + cas := s.cas() if cas == nil { writeSafeError(w, r, http.StatusServiceUnavailable, "storage unavailable") return @@ -84,7 +84,7 @@ func (s *Server) handleUploadProjectAsset(w http.ResponseWriter, r *http.Request } parseState := "" - if matIdx := bizdev.Snapshot(s.svc).MaterialIndex; matIdx != nil && matIdx.Enabled() && matIdx.HasStore() { + if matIdx := bizdev.Snapshot(s.svc()).MaterialIndex; matIdx != nil && matIdx.Enabled() && matIdx.HasStore() { if mineru.SupportsExt(ref.FileName) { matIdx.EnqueueAsync(ref.FileName, ref.BlobSHA256, data, projectID) // Report the real state: same-content uploads hit an existing parse @@ -126,7 +126,7 @@ func (s *Server) handleReparseProjectAsset(w http.ResponseWriter, r *http.Reques if _, ok := s.requireProjectAccess(w, r, projectID); !ok { return } - cas := s.cas + cas := s.cas() if cas == nil { writeSafeError(w, r, http.StatusServiceUnavailable, "storage unavailable") return @@ -140,7 +140,7 @@ func (s *Server) handleReparseProjectAsset(w http.ResponseWriter, r *http.Reques writeSafeError(w, r, http.StatusNotFound, "asset not found") return } - matIdx := bizdev.Snapshot(s.svc).MaterialIndex + matIdx := bizdev.Snapshot(s.svc()).MaterialIndex if matIdx == nil || !matIdx.Enabled() || !matIdx.HasStore() { writeSafeError(w, r, http.StatusServiceUnavailable, "parse engine unavailable") return @@ -169,7 +169,7 @@ func (s *Server) handleListProjectAssets(w http.ResponseWriter, r *http.Request) if _, ok := s.requireProjectAccess(w, r, projectID); !ok { return } - cas := s.cas + cas := s.cas() if cas == nil { writeJSONResponse(w, http.StatusOK, []projectAssetResponse{}) return @@ -187,7 +187,7 @@ func (s *Server) handleListProjectAssets(w http.ResponseWriter, r *http.Request) } } infos := map[string]materialindex.ParseInfo{} - if matIdx := bizdev.Snapshot(s.svc).MaterialIndex; matIdx != nil { + if matIdx := bizdev.Snapshot(s.svc()).MaterialIndex; matIdx != nil { infos = matIdx.GetParseInfos(r.Context(), shas) } @@ -225,7 +225,7 @@ func (s *Server) handleListProjectAssets(w http.ResponseWriter, r *http.Request) // 内容已入库但从未触发解析(如早于 MinerU 接入的历史资产): // 列表读取时自动补触发,前端轮询即可看到 pending→running→done。 seenSHA[ref.BlobSHA256] = true - if matIdx := bizdev.Snapshot(s.svc).MaterialIndex; matIdx != nil && matIdx.Enabled() && matIdx.HasStore() && mineru.SupportsExt(ref.FileName) { + if matIdx := bizdev.Snapshot(s.svc()).MaterialIndex; matIdx != nil && matIdx.Enabled() && matIdx.HasStore() && mineru.SupportsExt(ref.FileName) { if data, _, err := cas.ReadRefBytes(r.Context(), ref.ID); err == nil { matIdx.EnqueueAsync(ref.FileName, ref.BlobSHA256, data, projectID) item.ParseState = "pending" @@ -251,7 +251,7 @@ func (s *Server) handleGetProjectAssetRaw(w http.ResponseWriter, r *http.Request if _, ok := s.requireProjectAccess(w, r, projectID); !ok { return } - cas := s.cas + cas := s.cas() if cas == nil { writeSafeError(w, r, http.StatusServiceUnavailable, "storage unavailable") return diff --git a/internal/server/auth_handlers.go b/internal/server/auth_handlers.go index 7b14f8b3..224f361b 100644 --- a/internal/server/auth_handlers.go +++ b/internal/server/auth_handlers.go @@ -48,7 +48,7 @@ func (s *Server) authenticateUser(r *http.Request) (*auth.UserAccount, error) { token = r.Header.Get("X-Session-Token") } if token != "" { - user, err := s.authSvc.GetUserByToken(r.Context(), token) + user, err := s.authSvc().GetUserByToken(r.Context(), token) if err == nil && user != nil { return user, nil } @@ -78,7 +78,7 @@ func (s *Server) handleAuthLogin(w http.ResponseWriter, r *http.Request) { } } - session, err := s.authSvc.Login(r.Context(), req.Username, req.Password) + session, err := s.authSvc().Login(r.Context(), req.Username, req.Password) if err != nil { errMsg := err.Error() if strings.Contains(errMsg, "审核") { @@ -117,7 +117,7 @@ func (s *Server) handleAuthRegister(w http.ResponseWriter, r *http.Request) { return } - resp, err := s.authSvc.Register(r.Context(), req) + resp, err := s.authSvc().Register(r.Context(), req) if err != nil { status := http.StatusBadRequest if authIsInfrastructureError(err) { @@ -135,7 +135,7 @@ func (s *Server) handleAuthRegister(w http.ResponseWriter, r *http.Request) { func (s *Server) handleAuthLogout(w http.ResponseWriter, r *http.Request) { token := extractBearerToken(r) if token != "" { - s.authSvc.Logout(token) + s.authSvc().Logout(token) } w.Header().Set("Content-Type", "application/json; charset=utf-8") _ = json.NewEncoder(w).Encode(map[string]bool{"success": true}) @@ -158,7 +158,7 @@ func (s *Server) handleAuthChangePassword(w http.ResponseWriter, r *http.Request return } - if err := s.authSvc.ChangePassword(r.Context(), user.ID, req.OldPassword, req.NewPassword); err != nil { + if err := s.authSvc().ChangePassword(r.Context(), user.ID, req.OldPassword, req.NewPassword); err != nil { writeSafeError(w, r, http.StatusBadRequest, err.Error()) return } @@ -198,8 +198,8 @@ func (s *Server) handleAuthMe(w http.ResponseWriter, r *http.Request) { func (s *Server) resolveUserCapabilities(ctx context.Context, user *auth.UserAccount) []string { caps := []string{} - if s.authzSvc != nil { - if listed, capErr := s.authzSvc.ListCapabilities(ctx, user.ID); capErr == nil && len(listed) > 0 { + if s.authzSvc() != nil { + if listed, capErr := s.authzSvc().ListCapabilities(ctx, user.ID); capErr == nil && len(listed) > 0 { return listed } } @@ -220,7 +220,7 @@ func (s *Server) handleAuthSessions(w http.ResponseWriter, r *http.Request) { } if r.Method == http.MethodGet { token := extractBearerToken(r) - sessions, listErr := s.authSvc.ListUserSessions(r.Context(), user.ID, token) + sessions, listErr := s.authSvc().ListUserSessions(r.Context(), user.ID, token) if listErr != nil { writeSafeError(w, r, http.StatusInternalServerError, listErr.Error()) return @@ -249,7 +249,7 @@ func (s *Server) handleAuthSessionByID(w http.ResponseWriter, r *http.Request) { writeSafeError(w, r, http.StatusBadRequest, "无效的会话 ID") return } - if err := s.authSvc.RevokeUserSession(r.Context(), user.ID, sessionID); err != nil { + if err := s.authSvc().RevokeUserSession(r.Context(), user.ID, sessionID); err != nil { writeSafeError(w, r, http.StatusNotFound, "会话不存在或已失效") return } @@ -263,7 +263,7 @@ func (s *Server) handleAdminUsers(w http.ResponseWriter, r *http.Request) { } if r.Method == http.MethodGet { - users, err := s.authSvc.ListUsers(r.Context()) + users, err := s.authSvc().ListUsers(r.Context()) if err != nil { writeSafeError(w, r, http.StatusInternalServerError, err.Error()) return @@ -279,8 +279,8 @@ 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 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 @@ -297,7 +297,7 @@ func (s *Server) handleAdminUsers(w http.ResponseWriter, r *http.Request) { return } } - created, err := s.authSvc.CreateUserByAdmin(r.Context(), req) + created, err := s.authSvc().CreateUserByAdmin(r.Context(), req) if err != nil { status := http.StatusBadRequest if authIsInfrastructureError(err) { @@ -335,7 +335,7 @@ func (s *Server) handleAdminUpdateRole(w http.ResponseWriter, r *http.Request) { return } - updated, err := s.authSvc.UpdateUserRole(r.Context(), targetID, req.RoleID, req.Position) + updated, err := s.authSvc().UpdateUserRole(r.Context(), targetID, req.RoleID, req.Position) if err != nil { writeSafeError(w, r, http.StatusBadRequest, err.Error()) return @@ -368,13 +368,13 @@ func (s *Server) handleAdminUpdateStatus(w http.ResponseWriter, r *http.Request) } if req.RoleID != "" { - if _, roleErr := s.authSvc.UpdateUserRole(r.Context(), targetID, req.RoleID, req.Position); roleErr != nil { + if _, roleErr := s.authSvc().UpdateUserRole(r.Context(), targetID, req.RoleID, req.Position); roleErr != nil { writeSafeError(w, r, http.StatusBadRequest, roleErr.Error()) return } } - updated, err := s.authSvc.UpdateUserStatus(r.Context(), targetID, req.Status) + updated, err := s.authSvc().UpdateUserStatus(r.Context(), targetID, req.Status) if err != nil { writeSafeError(w, r, http.StatusBadRequest, err.Error()) return @@ -402,7 +402,7 @@ func (s *Server) handleAdminResetPassword(w http.ResponseWriter, r *http.Request return } - result, err := s.authSvc.AdminResetPassword(r.Context(), targetID, actor.ID, httpx.RequestID(r)) + result, err := s.authSvc().AdminResetPassword(r.Context(), targetID, actor.ID, httpx.RequestID(r)) if err != nil { msg := err.Error() status := http.StatusBadRequest @@ -425,7 +425,7 @@ func (s *Server) handleAuthPublicConfig(w http.ResponseWriter, r *http.Request) writeSafeError(w, r, http.StatusMethodNotAllowed, "不支持的方法") return } - cfg, err := s.authSvc.GetConfig(r.Context()) + cfg, err := s.authSvc().GetConfig(r.Context()) if err != nil { writeSafeError(w, r, http.StatusInternalServerError, err.Error()) return @@ -441,7 +441,7 @@ func (s *Server) handleAdminConfig(w http.ResponseWriter, r *http.Request) { } if r.Method == http.MethodGet { - cfg, err := s.authSvc.GetConfig(r.Context()) + cfg, err := s.authSvc().GetConfig(r.Context()) if err != nil { writeSafeError(w, r, http.StatusInternalServerError, err.Error()) return @@ -457,7 +457,7 @@ func (s *Server) handleAdminConfig(w http.ResponseWriter, r *http.Request) { writeSafeError(w, r, http.StatusBadRequest, "无效的请求体") return } - if err := s.authSvc.UpdateConfig(r.Context(), req); err != nil { + if err := s.authSvc().UpdateConfig(r.Context(), req); err != nil { writeSafeError(w, r, http.StatusInternalServerError, err.Error()) return } diff --git a/internal/server/auth_helpers.go b/internal/server/auth_helpers.go index 595face3..d6a2134d 100644 --- a/internal/server/auth_helpers.go +++ b/internal/server/auth_helpers.go @@ -23,7 +23,7 @@ func (s *Server) userHasSystemPermission(ctx context.Context, user *auth.UserAcc if s.isSystemAdminUser(ctx, user) { return true, nil } - caps, err := s.authzSvc.ListCapabilities(ctx, user.ID) + caps, err := s.authzSvc().ListCapabilities(ctx, user.ID) if err != nil { return false, err } @@ -77,11 +77,11 @@ func (s *Server) requireAuthenticatedUser(w http.ResponseWriter, r *http.Request // assertSessionOwnedByUser 校验会话归属当前用户;会话不存在或归属他人返回 false。 func (s *Server) assertSessionOwnedByUser(w http.ResponseWriter, r *http.Request, sessionID string, userID int64) bool { - if s.ctxMgr == nil { + if s.ctxMgr() == nil { writeSafeError(w, r, http.StatusServiceUnavailable, "context manager not initialized") return false } - ownerID, err := s.ctxMgr.GetSessionOwnerID(r.Context(), sessionID) + ownerID, err := s.ctxMgr().GetSessionOwnerID(r.Context(), sessionID) if err != nil || ownerID <= 0 { writeJSONResponse(w, http.StatusNotFound, map[string]string{ "error_code": "SESSION_NOT_FOUND", @@ -120,7 +120,7 @@ func (s *Server) requireProjectAccess(w http.ResponseWriter, r *http.Request, pr if s.isSystemAdminUser(r.Context(), user) { return user, true } - if err := s.authorizer.RequireProjectAccess(r.Context(), projectID, user.ID); err != nil { + if err := s.authorizer().RequireProjectAccess(r.Context(), projectID, user.ID); err != nil { if err == authz.ErrForbidden { writeJSONResponse(w, http.StatusForbidden, map[string]string{ "error_code": "ERR_FORBIDDEN", @@ -143,7 +143,7 @@ func (s *Server) requireDocGovDocAccess(w http.ResponseWriter, r *http.Request, if !ok { return nil, false } - if s.gov == nil { + if s.gov() == nil { writeJSONResponse(w, http.StatusServiceUnavailable, map[string]string{ "error_code": "ERR_DOCGOV_UNAVAILABLE", "message": "docgov engine not initialized", @@ -153,7 +153,7 @@ func (s *Server) requireDocGovDocAccess(w http.ResponseWriter, r *http.Request, if s.isSystemAdminUser(r.Context(), user) { return user, true } - doc, err := s.gov.GetDocument(r.Context(), docID) + doc, err := s.gov().GetDocument(r.Context(), docID) if err != nil || doc == nil { writeJSONResponse(w, http.StatusNotFound, map[string]string{ "error_code": "ERR_DOCUMENT_NOT_FOUND", @@ -169,7 +169,7 @@ func (s *Server) requireDocGovDocAccess(w http.ResponseWriter, r *http.Request, }) return nil, false } - if err := s.authorizer.RequireProjectAccess(r.Context(), doc.ProjectID, user.ID); err != nil { + if err := s.authorizer().RequireProjectAccess(r.Context(), doc.ProjectID, user.ID); err != nil { writeJSONResponse(w, http.StatusForbidden, map[string]string{ "error_code": "ERR_FORBIDDEN", "message": "无权访问该文档所属项目", @@ -181,10 +181,10 @@ func (s *Server) requireDocGovDocAccess(w http.ResponseWriter, r *http.Request, // resolveDocGovRole 服务端按文档职责分配解析 docgov 角色,不信任客户端自报角色。 func (s *Server) resolveDocGovRole(ctx context.Context, docID string, user *auth.UserAccount) vocabulary.UserRole { - if user == nil || s.gov == nil { + if user == nil || s.gov() == nil { return vocabulary.UserRole("") } - doc, err := s.gov.GetDocument(ctx, docID) + doc, err := s.gov().GetDocument(ctx, docID) if err != nil || doc == nil { return vocabulary.UserRole("") } diff --git a/internal/server/auth_middleware.go b/internal/server/auth_middleware.go index db34e68e..898134f3 100644 --- a/internal/server/auth_middleware.go +++ b/internal/server/auth_middleware.go @@ -49,7 +49,7 @@ func (s *Server) authMiddleware(next http.Handler) http.Handler { user, err := s.authenticateUser(r) if (err != nil || user == nil) && s.allowLegacyTestPrincipal() { - if fallback, fbErr := s.authSvc.GetUserByID(r.Context(), 1); fbErr == nil && fallback != nil { + if fallback, fbErr := s.authSvc().GetUserByID(r.Context(), 1); fbErr == nil && fallback != nil { slog.Warn("legacy_test_principal_fallback", "path", r.URL.Path, "method", r.Method, "remote_addr", r.RemoteAddr, "fallback_user_id", fallback.ID) diff --git a/internal/server/background_loop.go b/internal/server/background_loop.go new file mode 100644 index 00000000..86a4cf39 --- /dev/null +++ b/internal/server/background_loop.go @@ -0,0 +1,36 @@ +package server + +import "sync" + +// backgroundLoop 收口 Server 后台循环的停止语义(修 DATA RACE: Shutdown 与 +// sweeper/publisher/reminder/GC goroutine 对 stop 通道的并发 close/nil 写)。 +// 通道仅在启动 goroutine 内一次创建并被该 goroutine 独占持有; +// Shutdown 经 stopOnce 通知,不触碰通道字段本身。 +type backgroundLoop struct { + mu sync.Mutex + stopCh chan struct{} + stopOnce sync.Once + stopped bool +} + +// start 登记停止通道;返回值是否实际启动(幂等:已存在/已停止返回 false)。 +func (l *backgroundLoop) start() (chan struct{}, bool) { + l.mu.Lock() + defer l.mu.Unlock() + if l.stopCh != nil || l.stopped { + return nil, false + } + l.stopCh = make(chan struct{}) + return l.stopCh, true +} + +// stop 通知循环退出并防止后续再启动;可安全多次调用。 +func (l *backgroundLoop) stop() { + l.mu.Lock() + ch := l.stopCh + l.stopped = true + l.mu.Unlock() + if ch != nil { + l.stopOnce.Do(func() { close(ch) }) + } +} diff --git a/internal/server/chat_document_reader.go b/internal/server/chat_document_reader.go index b1a7c1a1..d46fd3a3 100644 --- a/internal/server/chat_document_reader.go +++ b/internal/server/chat_document_reader.go @@ -33,21 +33,21 @@ func (s *Server) bindSessionDocumentSummaryReader(sess *chat.Session, user *auth if user != nil { userID = user.ID } - if projectID <= 0 || userID <= 0 || s.gov == nil { + if projectID <= 0 || userID <= 0 || s.gov() == nil { return } sess.UserCtx.DocumentSummaryReader = func(ctx context.Context, target string) (*coordinator.ReadableDocument, error) { - if s.authorizer == nil { + if s.authorizer() == nil { return nil, fmt.Errorf("project authorization unavailable") } - if err := s.authorizer.RequireProjectAccessOrAdmin(ctx, projectID, userID, s.isSystemAdminUser(ctx, user)); err != nil { + if err := s.authorizer().RequireProjectAccessOrAdmin(ctx, projectID, userID, s.isSystemAdminUser(ctx, user)); err != nil { return nil, err } if strings.TrimSpace(target) == intent.TargetDocLatestUpload { return s.resolveLatestUploadDocument(ctx, projectID) } - listed, err := s.gov.ListWorkflowDocumentsByProject(ctx, projectID) + listed, err := s.gov().ListWorkflowDocumentsByProject(ctx, projectID) if err != nil { return nil, err } @@ -99,12 +99,12 @@ func (s *Server) bindSessionDocumentSummaryReader(sess *chat.Session, user *auth // resolveLatestUploadDocument 读取项目内最新上传的 CAS 资产正文(供 @latest_upload 哨兵)。 func (s *Server) resolveLatestUploadDocument(ctx context.Context, projectID int64) (*coordinator.ReadableDocument, error) { - if s == nil || s.cas == nil { + if s == nil || s.cas() == nil { return nil, &coordinator.ErrNoUploadAsset{ Message: "当前项目还没有上传过附件。请先在对话中上传文件后再试总结。", } } - refs, err := s.cas.ListProjectRefs(ctx, projectID) + refs, err := s.cas().ListProjectRefs(ctx, projectID) if err != nil { return nil, err } @@ -115,7 +115,7 @@ func (s *Server) resolveLatestUploadDocument(ctx context.Context, projectID int6 } } - matIdx := bizdev.Snapshot(s.svc).MaterialIndex + matIdx := bizdev.Snapshot(s.svc()).MaterialIndex state := "" if matIdx != nil { state = strings.TrimSpace(matIdx.GetParseState(ctx, latest.BlobSHA256)) @@ -167,7 +167,7 @@ func (s *Server) resolveLatestUploadDocument(ctx context.Context, projectID int6 } } - data, ref, err := s.cas.ReadRefBytes(ctx, latest.ID) + data, ref, err := s.cas().ReadRefBytes(ctx, latest.ID) if err != nil || ref == nil { return nil, fmt.Errorf("load latest upload asset: %w", err) } @@ -302,10 +302,10 @@ func defaultUploadTitle(fileName string) string { // activeWorkflowScopeForDocs 读取项目激活工作流键与任务集合;失败时返回空(= 不过滤)。 func (s *Server) activeWorkflowScopeForDocs(ctx context.Context, projectID int64) (string, map[string]bool) { - if s == nil || s.instSvc == nil || !s.instSvc.Available() || projectID <= 0 { + if s == nil || s.instSvc() == nil || !s.instSvc().Available() || projectID <= 0 { return "", nil } - workflowKey, taskKeys, err := s.instSvc.ActiveWorkflowScope(ctx, projectID) + workflowKey, taskKeys, err := s.instSvc().ActiveWorkflowScope(ctx, projectID) if err != nil { return "", nil } diff --git a/internal/server/chat_document_reader_test.go b/internal/server/chat_document_reader_test.go index 85368d41..a416da82 100644 --- a/internal/server/chat_document_reader_test.go +++ b/internal/server/chat_document_reader_test.go @@ -145,12 +145,12 @@ func TestResolveLatestUploadDocument_TextAsset(t *testing.T) { cfg := &config.Config{Env: "test", DataDir: t.TempDir()} svc := bizdev.MustNewService(t, cfg) srv := NewServer(cfg, svc) - if srv.cas == nil { + if srv.cas() == nil { t.Fatal("expected CAS") } projectID := int64(42) userID := int64(7) - _, err := srv.cas.StoreFile(context.Background(), projectID, userID, "shockwave.txt", "material", + _, err := srv.cas().StoreFile(context.Background(), projectID, userID, "shockwave.txt", "material", []byte("冲击波治疗仪核心适应症:软组织损伤与疼痛管理。\n关键约束:治疗强度需医师确认。"), "text/plain") if err != nil { t.Fatalf("store: %v", err) diff --git a/internal/server/chat_history_auth.go b/internal/server/chat_history_auth.go index 3a4e798e..1e13bbce 100644 --- a/internal/server/chat_history_auth.go +++ b/internal/server/chat_history_auth.go @@ -33,8 +33,8 @@ func (s *Server) authorizeSessionRead( return nil, false } sessionOwnerID := user.ID - if s.ctxMgr != nil { - if ownerID, err := s.ctxMgr.GetSessionOwnerID(r.Context(), sessionID); err == nil && ownerID > 0 { + if s.ctxMgr() != nil { + if ownerID, err := s.ctxMgr().GetSessionOwnerID(r.Context(), sessionID); err == nil && ownerID > 0 { sessionOwnerID = ownerID } } @@ -43,8 +43,8 @@ func (s *Server) authorizeSessionRead( SessionOwnerID: sessionOwnerID, ViewerUserID: user.ID, } - if s.ctxMgr != nil { - if projectID, err := s.ctxMgr.GetSessionProjectID(r.Context(), sessionID); err == nil { + if s.ctxMgr() != nil { + if projectID, err := s.ctxMgr().GetSessionProjectID(r.Context(), sessionID); err == nil { authCtx.ProjectID = projectID } } @@ -200,10 +200,10 @@ func aggregateCategoryStats(msgs []ctxmgr.ChatMessageRecord) []ctxmgr.ChatCatego } func (s *Server) listVisibleSessionMessages(ctx context.Context, authCtx *chatHistoryAuth) ([]ctxmgr.ChatMessageRecord, error) { - if s.ctxMgr == nil || authCtx == nil { + if s.ctxMgr() == nil || authCtx == nil { return nil, nil } - msgs, err := s.ctxMgr.ListSessionMessagesForAuth(ctx, authCtx.SessionID) + msgs, err := s.ctxMgr().ListSessionMessagesForAuth(ctx, authCtx.SessionID) if err != nil { return nil, err } @@ -211,23 +211,23 @@ func (s *Server) listVisibleSessionMessages(ctx context.Context, authCtx *chatHi } func (s *Server) resolveTaskMessageScope(ctx context.Context, projectID int64, taskKey string) (*projectinstance.MessageScope, error) { - if s.instSvc == nil || !s.instSvc.Available() { + if s.instSvc() == nil || !s.instSvc().Available() { if s.cfg != nil && s.cfg.AllowInMemory { return &projectinstance.MessageScope{ScopeKind: projectinstance.ScopePreActivation}, nil } return nil, projectinstance.ErrTaskScopeNotResolved } - return s.instSvc.ResolveMessageScopeForTask(ctx, projectID, taskKey) + return s.instSvc().ResolveMessageScopeForTask(ctx, projectID, taskKey) } func (s *Server) resolveChatMessageScope(ctx context.Context, projectID int64, activeStageKey string) (*projectinstance.MessageScope, error) { - if s.instSvc == nil || !s.instSvc.Available() { + if s.instSvc() == nil || !s.instSvc().Available() { if projectID <= 0 { return &projectinstance.MessageScope{ScopeKind: projectinstance.ScopeNonProjectPrivate}, nil } return &projectinstance.MessageScope{ScopeKind: projectinstance.ScopePreActivation}, nil } - return s.instSvc.ResolveMessageScopeForChat(ctx, projectID, activeStageKey) + return s.instSvc().ResolveMessageScopeForChat(ctx, projectID, activeStageKey) } func (s *Server) applyScopeToMeta(ctx context.Context, meta map[string]any, projectID int64, activeStageKey, taskKey string) error { diff --git a/internal/server/chat_recategorize_api.go b/internal/server/chat_recategorize_api.go index 36603518..7e703a53 100644 --- a/internal/server/chat_recategorize_api.go +++ b/internal/server/chat_recategorize_api.go @@ -17,7 +17,7 @@ func (s *Server) handleChatRecategorize(w http.ResponseWriter, r *http.Request) if !ok { return } - if s.ctxMgr == nil { + if s.ctxMgr() == nil { writeSafeError(w, r, http.StatusServiceUnavailable, "context manager unavailable") return } @@ -40,7 +40,7 @@ func (s *Server) handleChatRecategorize(w http.ResponseWriter, r *http.Request) } } - res, err := s.ctxMgr.RecategorizeMessages(r.Context(), req.SessionID) + res, err := s.ctxMgr().RecategorizeMessages(r.Context(), req.SessionID) if err != nil { writeJSONResponse(w, http.StatusInternalServerError, map[string]any{ "success": false, diff --git a/internal/server/chat_turn_api.go b/internal/server/chat_turn_api.go index 17392c79..c4feabe2 100644 --- a/internal/server/chat_turn_api.go +++ b/internal/server/chat_turn_api.go @@ -21,7 +21,7 @@ func (s *Server) handleGetChatTurn(w http.ResponseWriter, r *http.Request) { return } - ctxMgr := s.ctxMgr + ctxMgr := s.ctxMgr() if ctxMgr == nil { writeSafeError(w, r, http.StatusServiceUnavailable, "context manager unavailable") return diff --git a/internal/server/chat_turn_sweeper.go b/internal/server/chat_turn_sweeper.go index 826a2d45..88d85f6f 100644 --- a/internal/server/chat_turn_sweeper.go +++ b/internal/server/chat_turn_sweeper.go @@ -9,21 +9,24 @@ import ( // status=processing 且租约过期的回合批量标 failed,防止崩溃/超时后 // 幂等键被永久占位。间隔保守(2 分钟),批上限 200。 func (s *Server) startChatTurnSweeper() { - if s == nil || s.ctxMgr == nil { + if s == nil || s.ctxMgr() == nil { + return + } + stopCh, ok := s.chatTurnSweepLoop.start() + if !ok { return } - s.chatTurnSweepStop = make(chan struct{}) go func() { t := time.NewTicker(2 * time.Minute) defer t.Stop() for { select { - case <-s.chatTurnSweepStop: + case <-stopCh: return case <-t.C: // 独立超时,避免扫描被慢查询拖住 drain 窗口。 ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - _, _ = s.ctxMgr.SweepExpiredProcessingTurns(ctx, 200) + _, _ = s.ctxMgr().SweepExpiredProcessingTurns(ctx, 200) cancel() } } diff --git a/internal/server/chat_window_api.go b/internal/server/chat_window_api.go index f279a7da..82c4b564 100644 --- a/internal/server/chat_window_api.go +++ b/internal/server/chat_window_api.go @@ -162,7 +162,7 @@ func (s *Server) handleListChatCategories(w http.ResponseWriter, r *http.Request // handleImportChatMessages 一次性批量回填历史消息 (仅允许在 dev/demo 环境下调用) // POST /api/v1/chat/messages/import func (s *Server) handleImportChatMessages(w http.ResponseWriter, r *http.Request) { - if s.ctxMgr == nil { + if s.ctxMgr() == nil { writeSafeError(w, r, http.StatusServiceUnavailable, "context manager not initialized") return } @@ -207,7 +207,7 @@ func (s *Server) handleImportChatMessages(w http.ResponseWriter, r *http.Request item.Metadata["created_at"] = item.CreatedAt } - _, err := s.ctxMgr.RecordUserMessageWithID( + _, err := s.ctxMgr().RecordUserMessageWithID( r.Context(), item.SessionID, item.UserID, diff --git a/internal/server/docgov_comment_audit_nonmember_pg_test.go b/internal/server/docgov_comment_audit_nonmember_pg_test.go index 0dad2031..51dd6d39 100644 --- a/internal/server/docgov_comment_audit_nonmember_pg_test.go +++ b/internal/server/docgov_comment_audit_nonmember_pg_test.go @@ -26,7 +26,7 @@ func TestDocGovCommentsAndAudit_NonMemberIs403_PG(t *testing.T) { svc := bizdev.MustNewService(t, cfg) srv := NewServer(cfg, svc) - srv.authorizer = authz.NewAuthorizer(pool) + srv.setOverrideAuthorizer(authz.NewAuthorizer(pool)) ctx := context.Background() var projectID int64 @@ -75,7 +75,7 @@ func TestDocGovCommentsAndAudit_NonMemberIs403_PG(t *testing.T) { if err := json.NewDecoder(loginW.Body).Decode(&session); err != nil || session.Token == "" || session.User == nil { t.Fatalf("decode outsider session: %v body=%s", err, loginW.Body.String()) } - if err := srv.authorizer.RequireProjectAccess(ctx, projectID, session.User.ID); err != authz.ErrForbidden { + if err := srv.authorizer().RequireProjectAccess(ctx, projectID, session.User.ID); err != authz.ErrForbidden { t.Fatalf("outsider must not be a member of project %d: %v", projectID, err) } diff --git a/internal/server/document_revision_access.go b/internal/server/document_revision_access.go index 7ee276b3..d19dbb5f 100644 --- a/internal/server/document_revision_access.go +++ b/internal/server/document_revision_access.go @@ -27,8 +27,8 @@ func (s *Server) resolveDocRevisionRef(ctx context.Context, user *auth.UserAccou if strings.TrimSpace(docID) == "" { return nil, docrevision.ErrInvalidDocID } - if s.gov != nil { - if doc, err := s.gov.GetDocument(ctx, docID); err == nil && doc != nil { + if s.gov() != nil { + if doc, err := s.gov().GetDocument(ctx, docID); err == nil && doc != nil { ref := &docRevisionRef{ Kind: "controlled", ProjectID: doc.ProjectID, WorkDir: doc.WorkDir, LifecycleState: string(doc.CurrentState), AuthorID: doc.AuthorID, @@ -39,14 +39,14 @@ func (s *Server) resolveDocRevisionRef(ctx context.Context, user *auth.UserAccou return ref, nil } } - if content, cErr := s.gov.ReadDocumentContent(ctx, docID, s.dataRoot()); cErr == nil && content != nil { + if content, cErr := s.gov().ReadDocumentContent(ctx, docID, s.dataRoot()); cErr == nil && content != nil { ref.Markdown = content.Markdown } return ref, nil } } - if s.freeDoc != nil && user != nil { - if detail, err := s.freeDoc.GetDocument(ctx, int(user.ID), docID); err == nil && detail != nil { + if s.freeDoc() != nil && user != nil { + if detail, err := s.freeDoc().GetDocument(ctx, int(user.ID), docID); err == nil && detail != nil { return &docRevisionRef{ Kind: "freeform", ProjectID: int64(detail.Document.ProjectID), LifecycleState: "drafting", Markdown: detail.Content, Title: detail.Document.Title, @@ -67,11 +67,11 @@ func (s *Server) authorizeDocRevision(ctx context.Context, user *auth.UserAccoun return docrevision.ErrNotFound } if ref.ProjectID > 0 { - if s.authorizer == nil { + if s.authorizer() == nil { if s.failClosedAuthz() { return authz.ErrForbidden } - } else if err := s.authorizer.RequireProjectAccessOrAdmin(ctx, ref.ProjectID, user.ID, s.isSystemAdminUser(ctx, user)); err != nil { + } else if err := s.authorizer().RequireProjectAccessOrAdmin(ctx, ref.ProjectID, user.ID, s.isSystemAdminUser(ctx, user)); err != nil { return err } } else if ref.Kind == "controlled" { diff --git a/internal/server/document_revision_authz_test.go b/internal/server/document_revision_authz_test.go index 8e6dbe0c..4ad19027 100644 --- a/internal/server/document_revision_authz_test.go +++ b/internal/server/document_revision_authz_test.go @@ -76,10 +76,10 @@ func TestDocRevision_ControlledDocumentRoutes(t *testing.T) { srv := newTestServer(t) ctx := context.Background() docID := "doc_proj1_intake" - if _, err := srv.gov.CreateOrGetDocument(ctx, docID, 1, "intake", "摸底表", "V0.1", "", 1, "admin", 2, "r", 3, "a"); err != nil { + if _, err := srv.gov().CreateOrGetDocument(ctx, docID, 1, "intake", "摸底表", "V0.1", "", 1, "admin", 2, "r", 3, "a"); err != nil { t.Fatal(err) } - if _, err := srv.rev.OpenTemplateVersion(ctx, docID, "# 摸底\n\n正文\n", docrevision.ActorSnapshot{DisplayName: "t"}, 1, ""); err != nil { + if _, err := srv.rev().OpenTemplateVersion(ctx, docID, "# 摸底\n\n正文\n", docrevision.ActorSnapshot{DisplayName: "t"}, 1, ""); err != nil { t.Fatal(err) } token := loginTestAdmin(t, srv) @@ -102,14 +102,14 @@ func TestDocRevision_PendingCandidateExposedAndDecisionRequiresAuthor(t *testing srv := newTestServer(t) ctx := context.Background() docID := "doc_proj1_pending" - if _, err := srv.gov.CreateOrGetDocument(ctx, docID, 1, "intake", "摸底", "V0.1", "", 1, "admin", 2, "r", 3, "a"); err != nil { + if _, err := srv.gov().CreateOrGetDocument(ctx, docID, 1, "intake", "摸底", "V0.1", "", 1, "admin", 2, "r", 3, "a"); err != nil { t.Fatal(err) } - st, err := srv.rev.OpenTemplateVersion(ctx, docID, "# base\n\n法规 GB9706\n", docrevision.ActorSnapshot{}, 1, "") + st, err := srv.rev().OpenTemplateVersion(ctx, docID, "# base\n\n法规 GB9706\n", docrevision.ActorSnapshot{}, 1, "") if err != nil { t.Fatal(err) } - cand, _, err := srv.rev.CreateAgentCandidate(ctx, docrevision.AgentCandidateRequest{ + cand, _, err := srv.rev().CreateAgentCandidate(ctx, docrevision.AgentCandidateRequest{ DocID: docID, RunID: "high-1", InputSnapshotID: st.HeadSnapshotID, OutputMarkdown: "# base\n\n", ActorKind: docrevision.ActorAgentSquad, }) @@ -141,14 +141,14 @@ func TestDocRevision_PendingCandidateExposedAndDecisionRequiresAuthor(t *testing // Non-author cannot decide (author is 1=admin in this fixture — use author 99 doc). docID2 := "doc_proj1_pending_na" - if _, err := srv.gov.CreateOrGetDocument(ctx, docID2, 1, "research", "研", "V0.5", "", 99, "other", 2, "r", 3, "a"); err != nil { + if _, err := srv.gov().CreateOrGetDocument(ctx, docID2, 1, "research", "研", "V0.5", "", 99, "other", 2, "r", 3, "a"); err != nil { t.Fatal(err) } - st2, err := srv.rev.OpenTemplateVersion(ctx, docID2, "# x\n\n法规\n", docrevision.ActorSnapshot{}, 1, "") + st2, err := srv.rev().OpenTemplateVersion(ctx, docID2, "# x\n\n法规\n", docrevision.ActorSnapshot{}, 1, "") if err != nil { t.Fatal(err) } - cand2, _, err := srv.rev.CreateAgentCandidate(ctx, docrevision.AgentCandidateRequest{ + cand2, _, err := srv.rev().CreateAgentCandidate(ctx, docrevision.AgentCandidateRequest{ DocID: docID2, RunID: "high-2", InputSnapshotID: st2.HeadSnapshotID, OutputMarkdown: "# x\n\n", ActorKind: docrevision.ActorAgentSquad, }) @@ -191,15 +191,15 @@ func TestDocRevision_CommitWithMissingProposalReturnsGovernanceError(t *testing. srv := newTestServer(t) ctx := context.Background() docID := "doc_proj1_gov_prop" - if _, err := srv.gov.CreateOrGetDocument(ctx, docID, 1, "intake", "摸底", "V0.1", "", 1, "admin", 2, "r", 3, "a"); err != nil { + if _, err := srv.gov().CreateOrGetDocument(ctx, docID, 1, "intake", "摸底", "V0.1", "", 1, "admin", 2, "r", 3, "a"); err != nil { t.Fatal(err) } - st, err := srv.rev.OpenTemplateVersion(ctx, docID, "# base\n\n法规 GB9706\n", docrevision.ActorSnapshot{}, 1, "") + st, err := srv.rev().OpenTemplateVersion(ctx, docID, "# base\n\n法规 GB9706\n", docrevision.ActorSnapshot{}, 1, "") if err != nil { t.Fatal(err) } ghostProp := "prop_does_not_exist" - cand, _, err := srv.rev.CreateAgentCandidate(ctx, docrevision.AgentCandidateRequest{ + cand, _, err := srv.rev().CreateAgentCandidate(ctx, docrevision.AgentCandidateRequest{ DocID: docID, RunID: "gov-miss-1", ProposalID: ghostProp, InputSnapshotID: st.HeadSnapshotID, OutputMarkdown: "# base\n\n", ActorKind: docrevision.ActorAgentSquad, @@ -230,7 +230,7 @@ func TestDocRevision_CommitWithMissingProposalReturnsGovernanceError(t *testing. t.Fatalf("code=%v body=%#v", body["code"], body) } - gotCand, err := srv.rev.GetCandidate(ctx, docID, cand.ID) + gotCand, err := srv.rev().GetCandidate(ctx, docID, cand.ID) if err != nil || gotCand.Status != docrevision.CandidateCommitted { t.Fatalf("ledger must stay committed after governance failure: %+v err=%v", gotCand, err) } @@ -257,21 +257,21 @@ func TestDocRevision_DecisionIdempotentWithRealProposal(t *testing.T) { srv := newTestServer(t) ctx := context.Background() docID := "doc_proj1_gov_prop_ok" - if _, err := srv.gov.CreateOrGetDocument(ctx, docID, 1, "intake", "摸底", "V0.1", "", 1, "admin", 2, "r", 3, "a"); err != nil { + if _, err := srv.gov().CreateOrGetDocument(ctx, docID, 1, "intake", "摸底", "V0.1", "", 1, "admin", 2, "r", 3, "a"); err != nil { t.Fatal(err) } - prop, err := srv.gov.ProposeModification(ctx, docID, "agent", "改", "影响", "风险") + prop, err := srv.gov().ProposeModification(ctx, docID, "agent", "改", "影响", "风险") if err != nil { t.Fatal(err) } - if _, err := srv.gov.DecideModificationProposal(ctx, prop.ProposalID, 1, true, "同意"); err != nil { + if _, err := srv.gov().DecideModificationProposal(ctx, prop.ProposalID, 1, true, "同意"); err != nil { t.Fatal(err) } - st, err := srv.rev.OpenTemplateVersion(ctx, docID, "# base\n\n法规 GB9706\n", docrevision.ActorSnapshot{}, 1, "") + st, err := srv.rev().OpenTemplateVersion(ctx, docID, "# base\n\n法规 GB9706\n", docrevision.ActorSnapshot{}, 1, "") if err != nil { t.Fatal(err) } - cand, _, err := srv.rev.CreateAgentCandidate(ctx, docrevision.AgentCandidateRequest{ + cand, _, err := srv.rev().CreateAgentCandidate(ctx, docrevision.AgentCandidateRequest{ DocID: docID, RunID: "gov-ok-1", ProposalID: prop.ProposalID, InputSnapshotID: st.HeadSnapshotID, OutputMarkdown: "# base\n\n", ActorKind: docrevision.ActorAgentSquad, @@ -295,7 +295,7 @@ func TestDocRevision_DecisionIdempotentWithRealProposal(t *testing.T) { t.Fatalf("%s decision want 200 got %d %s", label, w.Code, w.Body.String()) } if i == 0 { - got, err := srv.rev.GetCandidate(ctx, docID, cand.ID) + got, err := srv.rev().GetCandidate(ctx, docID, cand.ID) if err != nil || got.Status != docrevision.CandidateCommitted { t.Fatalf("after first: %+v err=%v", got, err) } diff --git a/internal/server/document_revision_duty_write_test.go b/internal/server/document_revision_duty_write_test.go index cad76068..c4821778 100644 --- a/internal/server/document_revision_duty_write_test.go +++ b/internal/server/document_revision_duty_write_test.go @@ -14,7 +14,7 @@ func TestAuthorizeDocRevision_ReviewerAndNonMemberCannotWrite(t *testing.T) { srv := newTestServer(t) // 隔离写权门闩:跳过项目成员库检查,只验证 AuthorID。禁止依赖共享库或默认拒绝。 srv.cfg.AllowInMemory = true - srv.authorizer = authz.NewAuthorizer(nil) + srv.setOverrideAuthorizer(authz.NewAuthorizer(nil)) ctx := context.Background() const ( authorID int64 = 170 @@ -22,7 +22,7 @@ func TestAuthorizeDocRevision_ReviewerAndNonMemberCannotWrite(t *testing.T) { strangerID int64 = 99 docID = "doc_proj_duty_write" ) - if _, err := srv.gov.CreateOrGetDocument( + if _, err := srv.gov().CreateOrGetDocument( ctx, docID, 6145, "intake", "摸底", "V0.1", "", authorID, "编制人A", reviewerID, "审核人B", 0, "", ); err != nil { diff --git a/internal/server/document_revision_handlers.go b/internal/server/document_revision_handlers.go index e777cddd..ddabe648 100644 --- a/internal/server/document_revision_handlers.go +++ b/internal/server/document_revision_handlers.go @@ -39,10 +39,10 @@ func (s *Server) registerDocumentRevisionRoutes(mux *http.ServeMux) { } func (s *Server) docRev() *docrevision.Service { - if s == nil || s.svc == nil { + if s == nil || s.svc() == nil { return nil } - return s.rev + return s.rev() } func (s *Server) handleDocRevisionState(w http.ResponseWriter, r *http.Request) { @@ -213,9 +213,9 @@ func (s *Server) handleDocRevisionPatchContent(w http.ResponseWriter, r *http.Re // 1) Controlled: author duty + lease WITHOUT writing body (fail-closed before ledger). // This UI has no edit-session heartbeat: release the request-scoped human lease on every exit // (success, 409, projection failure) so a 15m residual cannot block a later squad run. - if kind == "controlled" && s.gov != nil { + if kind == "controlled" && s.gov() != nil { // ActorIsMember=true:上游 enforceDocGovWorkflowAction(Edit) 已按成员级放行。 - if err := s.gov.AssertHumanCanEdit(r.Context(), docID, user.ID, vocabulary.RoleAuthor, true); err != nil { + if err := s.gov().AssertHumanCanEdit(r.Context(), docID, user.ID, vocabulary.RoleAuthor, true); err != nil { writeDocGovError(w, err) return } @@ -224,7 +224,7 @@ func (s *Server) handleDocRevisionPatchContent(w http.ResponseWriter, r *http.Re holderID = "human_anon" } defer func() { - _, _ = s.gov.ReleaseLease(context.Background(), docID, string(vocabulary.LeaseHolderHuman), holderID) + _, _ = s.gov().ReleaseLease(context.Background(), docID, string(vocabulary.LeaseHolderHuman), holderID) }() } @@ -277,8 +277,8 @@ func (s *Server) handleDocRevisionPatchContent(w http.ResponseWriter, r *http.Re // same transaction as the ledger (document_content_projections). Failure keeps that row // pending and returns DOCUMENT_PROJECTION_FAILED. The same Idempotency-Key retries // projection without a second revision. Only a successful projection acks the row. - if kind == "controlled" && s.gov != nil { - if _, err := s.gov.SaveDocumentContent(r.Context(), docgov.ContentSaveRequest{ + if kind == "controlled" && s.gov() != nil { + if _, err := s.gov().SaveDocumentContent(r.Context(), docgov.ContentSaveRequest{ DocID: docID, ActorID: user.ID, ActorRole: vocabulary.RoleAuthor, @@ -304,9 +304,9 @@ func (s *Server) handleDocRevisionPatchContent(w http.ResponseWriter, r *http.Re return } } - if kind == "freeform" && s.freeDoc != nil { + if kind == "freeform" && s.freeDoc() != nil { md := body.Markdown - if _, err := s.freeDoc.UpdateDocument(r.Context(), int(user.ID), docID, freedoc.UpdateFreeformDocRequest{ + if _, err := s.freeDoc().UpdateDocument(r.Context(), int(user.ID), docID, freedoc.UpdateFreeformDocRequest{ ContentMarkdown: &md, ChangeSummary: "revision ledger human save", }); err != nil { @@ -399,8 +399,8 @@ func (s *Server) handleDocRevisionCandidateDecision(w http.ResponseWriter, r *ht }) return } - if s.gov != nil && st != nil { - if err := s.gov.MarkDocumentContentUpdated(r.Context(), docID, st.HeadSHA256, false); err != nil { + if s.gov() != nil && st != nil { + if err := s.gov().MarkDocumentContentUpdated(r.Context(), docID, st.HeadSHA256, false); err != nil { writeJSONResponse(w, http.StatusInternalServerError, map[string]any{ "code": "DOCUMENT_PROJECTION_FAILED", "error": err.Error(), @@ -410,7 +410,7 @@ func (s *Server) handleDocRevisionCandidateDecision(w http.ResponseWriter, r *ht return } if body.Decision == "commit" && cand != nil && strings.TrimSpace(cand.ProposalID) != "" { - if err := s.gov.MarkProposalApplied(r.Context(), cand.ProposalID); err != nil { + if err := s.gov().MarkProposalApplied(r.Context(), cand.ProposalID); err != nil { writeJSONResponse(w, http.StatusInternalServerError, map[string]any{ "code": "DOCUMENT_GOVERNANCE_FAILED", "error": err.Error(), @@ -448,7 +448,7 @@ func (s *Server) handlePatchNotification(w http.ResponseWriter, r *http.Request) if !ok { return } - if s.notifySvc == nil || !s.notifySvc.Available() { + if s.notifySvc() == nil || !s.notifySvc().Available() { writeJSONResponse(w, http.StatusServiceUnavailable, map[string]any{"error": "notifications unavailable"}) return } @@ -460,7 +460,7 @@ func (s *Server) handlePatchNotification(w http.ResponseWriter, r *http.Request) writeJSONResponse(w, http.StatusBadRequest, map[string]any{"error": "invalid json"}) return } - if err := s.notifySvc.UpdateNotificationStatus(r.Context(), user.ID, id, body.Status); err != nil { + if err := s.notifySvc().UpdateNotificationStatus(r.Context(), user.ID, id, body.Status); err != nil { writeJSONResponse(w, http.StatusNotFound, map[string]any{"error": err.Error()}) return } diff --git a/internal/server/document_revision_patch_order_test.go b/internal/server/document_revision_patch_order_test.go index e443781b..d22abc6a 100644 --- a/internal/server/document_revision_patch_order_test.go +++ b/internal/server/document_revision_patch_order_test.go @@ -30,10 +30,10 @@ func TestDocRevision_PatchOrder_AssertBeforeLedger_ProjectionFailureKeepsLedger( workDir := filepath.Join(blocker, "ws-proj") docID := "doc_proj1_patch_order" - if _, err := srv.gov.CreateOrGetDocument(ctx, docID, 1, "intake", "摸底", "V0.1", workDir, 1, "admin", 2, "r", 3, "a"); err != nil { + if _, err := srv.gov().CreateOrGetDocument(ctx, docID, 1, "intake", "摸底", "V0.1", workDir, 1, "admin", 2, "r", 3, "a"); err != nil { t.Fatal(err) } - st, err := srv.rev.OpenTemplateVersion(ctx, docID, "# base\n\nbody\n", docrevision.ActorSnapshot{DisplayName: "t"}, 1, workDir) + st, err := srv.rev().OpenTemplateVersion(ctx, docID, "# base\n\nbody\n", docrevision.ActorSnapshot{DisplayName: "t"}, 1, workDir) if err != nil { t.Fatal(err) } @@ -59,7 +59,7 @@ func TestDocRevision_PatchOrder_AssertBeforeLedger_ProjectionFailureKeepsLedger( if payload["code"] != "DOCUMENT_PROJECTION_FAILED" { t.Fatalf("code want DOCUMENT_PROJECTION_FAILED got %#v", payload["code"]) } - after, err := srv.rev.GetRevisionState(ctx, docID) + after, err := srv.rev().GetRevisionState(ctx, docID) if err != nil { t.Fatal(err) } @@ -77,13 +77,13 @@ func TestDocRevision_PatchOrder_LockedDocDoesNotAdvanceLedger(t *testing.T) { _ = os.MkdirAll(workDir, 0o755) // Author is admin (1); lock via submit so drafting edit is denied regardless of membership. - if _, err := srv.gov.CreateOrGetDocument(ctx, docID, 1, "intake", "摸底", "V0.1", workDir, 1, "admin", 2, "r", 3, "a"); err != nil { + if _, err := srv.gov().CreateOrGetDocument(ctx, docID, 1, "intake", "摸底", "V0.1", workDir, 1, "admin", 2, "r", 3, "a"); err != nil { t.Fatal(err) } - if _, err := srv.gov.SubmitForReview(ctx, docID, 1, vocabulary.RoleAuthor, "admin"); err != nil { + if _, err := srv.gov().SubmitForReview(ctx, docID, 1, vocabulary.RoleAuthor, "admin"); err != nil { t.Fatal(err) } - st, err := srv.rev.OpenTemplateVersion(ctx, docID, "# L\n\nx\n", docrevision.ActorSnapshot{DisplayName: "t"}, 1, workDir) + st, err := srv.rev().OpenTemplateVersion(ctx, docID, "# L\n\nx\n", docrevision.ActorSnapshot{DisplayName: "t"}, 1, workDir) if err != nil { t.Fatal(err) } @@ -100,7 +100,7 @@ func TestDocRevision_PatchOrder_LockedDocDoesNotAdvanceLedger(t *testing.T) { if w.Code == http.StatusOK { t.Fatalf("locked doc must not save successfully: %s", w.Body.String()) } - after, err := srv.rev.GetRevisionState(ctx, docID) + after, err := srv.rev().GetRevisionState(ctx, docID) if err != nil { t.Fatal(err) } @@ -116,10 +116,10 @@ func TestDocRevision_PatchOrder_SaveDocumentContentSetsAuthorAudit(t *testing.T) _ = os.MkdirAll(workDir, 0o755) docID := "doc_proj1_patch_audit" - if _, err := srv.gov.CreateOrGetDocument(ctx, docID, 1, "intake", "摸底", "V0.1", workDir, 1, "admin", 2, "r", 3, "a"); err != nil { + if _, err := srv.gov().CreateOrGetDocument(ctx, docID, 1, "intake", "摸底", "V0.1", workDir, 1, "admin", 2, "r", 3, "a"); err != nil { t.Fatal(err) } - st, err := srv.rev.OpenTemplateVersion(ctx, docID, "# base\n\nbody\n", docrevision.ActorSnapshot{DisplayName: "t"}, 1, workDir) + st, err := srv.rev().OpenTemplateVersion(ctx, docID, "# base\n\nbody\n", docrevision.ActorSnapshot{DisplayName: "t"}, 1, workDir) if err != nil { t.Fatal(err) } @@ -136,7 +136,7 @@ func TestDocRevision_PatchOrder_SaveDocumentContentSetsAuthorAudit(t *testing.T) if w.Code != http.StatusOK { t.Fatalf("want 200 got %d %s", w.Code, w.Body.String()) } - doc, err := srv.gov.GetDocument(ctx, docID) + doc, err := srv.gov().GetDocument(ctx, docID) if err != nil { t.Fatal(err) } @@ -159,10 +159,10 @@ func TestDocRevision_PatchReleasesHumanLeaseOnHeadConflict(t *testing.T) { _ = os.MkdirAll(workDir, 0o755) docID := "doc_proj1_patch_conflict_lease" - if _, err := srv.gov.CreateOrGetDocument(ctx, docID, 1, "intake", "摸底", "V0.1", workDir, 1, "admin", 2, "r", 3, "a"); err != nil { + if _, err := srv.gov().CreateOrGetDocument(ctx, docID, 1, "intake", "摸底", "V0.1", workDir, 1, "admin", 2, "r", 3, "a"); err != nil { t.Fatal(err) } - st, err := srv.rev.OpenTemplateVersion(ctx, docID, "# base\n\nbody\n", docrevision.ActorSnapshot{DisplayName: "t"}, 1, workDir) + st, err := srv.rev().OpenTemplateVersion(ctx, docID, "# base\n\nbody\n", docrevision.ActorSnapshot{DisplayName: "t"}, 1, workDir) if err != nil { t.Fatal(err) } @@ -196,14 +196,14 @@ func TestDocRevision_PatchReleasesHumanLeaseOnHeadConflict(t *testing.T) { func assertHumanLeaseReleasedSquadCanAcquire(t *testing.T, srv *Server, docID string) { t.Helper() ctx := context.Background() - doc, err := srv.gov.GetDocument(ctx, docID) + doc, err := srv.gov().GetDocument(ctx, docID) if err != nil { t.Fatal(err) } if doc.HasActiveLease(time.Now()) { t.Fatalf("human lease must be released after PATCH, holder=%s/%s", doc.LeaseHolderType, doc.LeaseHolderID) } - leased, err := srv.gov.AcquireLease(ctx, docgov.LeaseAcquireRequest{ + leased, err := srv.gov().AcquireLease(ctx, docgov.LeaseAcquireRequest{ DocID: docID, HolderType: docgov.LeaseHolderSquad, HolderID: "run_after_human_save", @@ -215,7 +215,7 @@ func assertHumanLeaseReleasedSquadCanAcquire(t *testing.T, srv *Server, docID st if leased.LeaseHolderType != docgov.LeaseHolderSquad { t.Fatalf("want squad holder, got %s", leased.LeaseHolderType) } - if _, err := srv.gov.ReleaseLease(ctx, docID, string(docgov.LeaseHolderSquad), "run_after_human_save"); err != nil { + if _, err := srv.gov().ReleaseLease(ctx, docID, string(docgov.LeaseHolderSquad), "run_after_human_save"); err != nil { t.Fatal(err) } } @@ -231,10 +231,10 @@ func TestDocRevision_PatchOrder_RetryProjectsWithoutSecondLedgerCommit(t *testin workDir := filepath.Join(blocker, "ws-retry") docID := "doc_proj1_patch_retry" - if _, err := srv.gov.CreateOrGetDocument(ctx, docID, 1, "intake", "摸底", "V0.1", workDir, 1, "admin", 2, "r", 3, "a"); err != nil { + if _, err := srv.gov().CreateOrGetDocument(ctx, docID, 1, "intake", "摸底", "V0.1", workDir, 1, "admin", 2, "r", 3, "a"); err != nil { t.Fatal(err) } - st, err := srv.rev.OpenTemplateVersion(ctx, docID, "# base\n\nbody\n", docrevision.ActorSnapshot{DisplayName: "t"}, 1, workDir) + st, err := srv.rev().OpenTemplateVersion(ctx, docID, "# base\n\nbody\n", docrevision.ActorSnapshot{DisplayName: "t"}, 1, workDir) if err != nil { t.Fatal(err) } @@ -256,11 +256,11 @@ func TestDocRevision_PatchOrder_RetryProjectsWithoutSecondLedgerCommit(t *testin if first.Code != http.StatusInternalServerError { t.Fatalf("first projection want 500 got %d %s", first.Code, first.Body.String()) } - pending, ok := srv.rev.ContentProjectionPending(docID) + pending, ok := srv.rev().ContentProjectionPending(docID) if !ok { t.Fatal("ledger commit must leave a content projection pending") } - afterFail, err := srv.rev.GetRevisionState(ctx, docID) + afterFail, err := srv.rev().GetRevisionState(ctx, docID) if err != nil { t.Fatal(err) } @@ -278,14 +278,14 @@ func TestDocRevision_PatchOrder_RetryProjectsWithoutSecondLedgerCommit(t *testin if second.Code != http.StatusOK { t.Fatalf("retry want 200 got %d %s", second.Code, second.Body.String()) } - afterRetry, err := srv.rev.GetRevisionState(ctx, docID) + afterRetry, err := srv.rev().GetRevisionState(ctx, docID) if err != nil { t.Fatal(err) } if afterRetry.HeadRevisionNo != afterFail.HeadRevisionNo || afterRetry.HeadSnapshotID != afterFail.HeadSnapshotID { t.Fatalf("retry must not write a second revision: fail=%+v retry=%+v", afterFail, afterRetry) } - if _, still := srv.rev.ContentProjectionPending(docID); still { + if _, still := srv.rev().ContentProjectionPending(docID); still { t.Fatal("successful projection must ack the pending row") } if _, err := os.Stat(filepath.Join(workDir, "output.md")); err != nil { diff --git a/internal/server/duty_reassign_handlers.go b/internal/server/duty_reassign_handlers.go index 2eeaa154..5546206a 100644 --- a/internal/server/duty_reassign_handlers.go +++ b/internal/server/duty_reassign_handlers.go @@ -23,12 +23,12 @@ func (s *Server) handleReassignDocTaskDuties(w http.ResponseWriter, r *http.Requ if !ok { return } - if s.instSvc == nil || !s.instSvc.Available() { + if s.instSvc() == nil || !s.instSvc().Available() { writeSafeError(w, r, http.StatusServiceUnavailable, `{"error":"ERR_NOT_AVAILABLE","message":"项目实例服务不可用"}`) return } - isCreator, _ := s.pwSvc.IsProjectCreator(r.Context(), projectID, user.ID) + isCreator, _ := s.pwSvc().IsProjectCreator(r.Context(), projectID, user.ID) canAssign, permErr := s.userHasSystemPermission(r.Context(), user, "project:assign_lead") if permErr != nil { if s.failClosedAuthz() { @@ -46,7 +46,7 @@ func (s *Server) handleReassignDocTaskDuties(w http.ResponseWriter, r *http.Requ } req.TaskKey = taskKey - settings, err := s.instSvc.ReassignDocTaskDuties(r.Context(), projectID, user.ID, allowed, req) + settings, err := s.instSvc().ReassignDocTaskDuties(r.Context(), projectID, user.ID, allowed, req) if err != nil { writeProjectInstanceError(w, err) return diff --git a/internal/server/free_lane.go b/internal/server/free_lane.go index ded6401d..af861fe8 100644 --- a/internal/server/free_lane.go +++ b/internal/server/free_lane.go @@ -48,7 +48,7 @@ func (s *Server) allowFreeLaneChatInitiation(projectID, userID int64) bool { // maybeAutoClaimOnView records an author view (and claim when non-template) after a // successful controlled-content read. Failures are logged and never block the read. func (s *Server) maybeAutoClaimOnView(ctx context.Context, user *auth.UserAccount, docID string) { - if s == nil || s.gov == nil || user == nil || docID == "" { + if s == nil || s.gov() == nil || user == nil || docID == "" { return } if !autoClaimOnViewEnabled() { @@ -58,7 +58,7 @@ func (s *Server) maybeAutoClaimOnView(ctx context.Context, user *auth.UserAccoun if role != vocabulary.RoleAuthor { return } - if _, err := s.gov.RecordDocumentViewed(ctx, docID, user.ID, role, user.Name); err != nil { + if _, err := s.gov().RecordDocumentViewed(ctx, docID, user.ID, role, user.Name); err != nil { slog.Warn("docgov_auto_claim_on_view_failed", "doc_id", docID, "user_id", user.ID, "error", err) } } diff --git a/internal/server/freeform_create_authz_test.go b/internal/server/freeform_create_authz_test.go index da4a663f..b0068671 100644 --- a/internal/server/freeform_create_authz_test.go +++ b/internal/server/freeform_create_authz_test.go @@ -54,7 +54,7 @@ func TestCreateFreeformDoc_RejectsDirectProjectShared(t *testing.T) { func TestCreateFreeformDoc_FailClosedWhenAuthorizerNil(t *testing.T) { srv := newTestServer(t) srv.cfg.AllowInMemory = false - srv.authorizer = nil + srv.setOverrideAuthorizer(nil) token := loginTestAdmin(t, srv) w := postFreeformCreate(t, srv, token, map[string]any{ "project_id": 1, diff --git a/internal/server/freeform_create_nonmember_pg_test.go b/internal/server/freeform_create_nonmember_pg_test.go index cfb8611e..97db3818 100644 --- a/internal/server/freeform_create_nonmember_pg_test.go +++ b/internal/server/freeform_create_nonmember_pg_test.go @@ -83,7 +83,7 @@ func TestCreateFreeformDoc_NonMemberForbidden_PG(t *testing.T) { svc := bizdev.MustNewService(t, cfg) srv := NewServer(cfg, svc) - srv.authorizer = authz.NewAuthorizer(pool) + srv.setOverrideAuthorizer(authz.NewAuthorizer(pool)) uname := fmt.Sprintf("outsider_ff_%d", time.Now().UnixNano()) pass := "OutsiderPass123!" @@ -111,7 +111,7 @@ func TestCreateFreeformDoc_NonMemberForbidden_PG(t *testing.T) { t.Fatalf("decode outsider session: %v body=%s", err, loginW.Body.String()) } - if err := srv.authorizer.RequireProjectAccess(context.Background(), projectID, session.User.ID); err != authz.ErrForbidden { + if err := srv.authorizer().RequireProjectAccess(context.Background(), projectID, session.User.ID); err != authz.ErrForbidden { t.Skipf("registered user unexpectedly has access to project %d: %v", projectID, err) } diff --git a/internal/server/freeform_doc_api.go b/internal/server/freeform_doc_api.go index 6418738e..9f52e88f 100644 --- a/internal/server/freeform_doc_api.go +++ b/internal/server/freeform_doc_api.go @@ -36,7 +36,7 @@ func extractDocIDFromPath(r *http.Request, actionSuffix string) string { // POST /api/v1/freeform-documents — 直接创建(上传附件注册服务端事实源,仅 my_private) func (s *Server) handleCreateFreeformDoc(w http.ResponseWriter, r *http.Request) { - if s.freeDoc == nil { + if s.freeDoc() == nil { writeSafeError(w, r, http.StatusServiceUnavailable, "freeform doc service not initialized") return } @@ -72,17 +72,17 @@ func (s *Server) handleCreateFreeformDoc(w http.ResponseWriter, r *http.Request) } req.Space = "my_private" - if s.authorizer == nil { + if s.authorizer() == nil { if s.failClosedAuthz() { writeAuthzError(w, authz.ErrForbidden) return } - } else if err := s.authorizer.RequireProjectAccessOrAdmin(r.Context(), int64(req.ProjectID), user.ID, s.isSystemAdminUser(r.Context(), user)); err != nil { + } else if err := s.authorizer().RequireProjectAccessOrAdmin(r.Context(), int64(req.ProjectID), user.ID, s.isSystemAdminUser(r.Context(), user)); err != nil { writeAuthzError(w, err) return } - detail, err := s.freeDoc.CreateDocument(r.Context(), int(user.ID), req) + detail, err := s.freeDoc().CreateDocument(r.Context(), int(user.ID), req) if err != nil { if errors.Is(err, freedoc.ErrInvalidParam) { writeJSONResponse(w, http.StatusBadRequest, map[string]any{ @@ -106,7 +106,7 @@ func (s *Server) handleCreateFreeformDoc(w http.ResponseWriter, r *http.Request) // POST /api/v1/freeform-documents/from-message func (s *Server) handleCreateFreeformDocFromMessage(w http.ResponseWriter, r *http.Request) { - if s.freeDoc == nil { + if s.freeDoc() == nil { writeSafeError(w, r, http.StatusServiceUnavailable, "freeform doc service not initialized") return } @@ -124,7 +124,7 @@ func (s *Server) handleCreateFreeformDocFromMessage(w http.ResponseWriter, r *ht return } - detail, isExisting, err := s.freeDoc.CreateFromMessage(r.Context(), userID, req) + detail, isExisting, err := s.freeDoc().CreateFromMessage(r.Context(), userID, req) if err != nil { if errors.Is(err, freedoc.ErrMessageNotExportable) { writeJSONResponse(w, http.StatusForbidden, map[string]any{ @@ -168,7 +168,7 @@ func (s *Server) handleCreateFreeformDocFromMessage(w http.ResponseWriter, r *ht // GET /api/v1/freeform-documents func (s *Server) handleListFreeformDocs(w http.ResponseWriter, r *http.Request) { - if s.freeDoc == nil { + if s.freeDoc() == nil { writeSafeError(w, r, http.StatusServiceUnavailable, "freeform doc service not initialized") return } @@ -188,7 +188,7 @@ func (s *Server) handleListFreeformDocs(w http.ResponseWriter, r *http.Request) } } - list, err := s.freeDoc.ListDocuments(r.Context(), userID, projectID, space) + list, err := s.freeDoc().ListDocuments(r.Context(), userID, projectID, space) if err != nil { writeJSONResponse(w, http.StatusInternalServerError, map[string]any{ "error_code": "LIST_FAILED", @@ -208,7 +208,7 @@ func (s *Server) handleListFreeformDocs(w http.ResponseWriter, r *http.Request) // GET /api/v1/freeform-documents/{doc_id} func (s *Server) handleGetFreeformDoc(w http.ResponseWriter, r *http.Request) { - if s.freeDoc == nil { + if s.freeDoc() == nil { writeSafeError(w, r, http.StatusServiceUnavailable, "freeform doc service not initialized") return } @@ -226,7 +226,7 @@ func (s *Server) handleGetFreeformDoc(w http.ResponseWriter, r *http.Request) { return } - detail, err := s.freeDoc.GetDocument(r.Context(), userID, docID) + detail, err := s.freeDoc().GetDocument(r.Context(), userID, docID) if err != nil { if errors.Is(err, freedoc.ErrNotFound) { writeJSONResponse(w, http.StatusNotFound, map[string]any{ @@ -257,7 +257,7 @@ func (s *Server) handleGetFreeformDoc(w http.ResponseWriter, r *http.Request) { // PUT /api/v1/freeform-documents/{doc_id} func (s *Server) handleUpdateFreeformDoc(w http.ResponseWriter, r *http.Request) { - if s.freeDoc == nil { + if s.freeDoc() == nil { writeSafeError(w, r, http.StatusServiceUnavailable, "freeform doc service not initialized") return } @@ -294,7 +294,7 @@ func (s *Server) handleUpdateFreeformDoc(w http.ResponseWriter, r *http.Request) } } - detail, err := s.freeDoc.UpdateDocument(r.Context(), userID, docID, req) + detail, err := s.freeDoc().UpdateDocument(r.Context(), userID, docID, req) if err != nil { if errors.Is(err, freedoc.ErrNotFound) { writeJSONResponse(w, http.StatusNotFound, map[string]any{ @@ -333,7 +333,7 @@ func (s *Server) handleUpdateFreeformDoc(w http.ResponseWriter, r *http.Request) // POST /api/v1/freeform-documents/{doc_id}/share func (s *Server) handleShareFreeformDoc(w http.ResponseWriter, r *http.Request) { - if s.freeDoc == nil { + if s.freeDoc() == nil { writeSafeError(w, r, http.StatusServiceUnavailable, "freeform doc service not initialized") return } @@ -351,7 +351,7 @@ func (s *Server) handleShareFreeformDoc(w http.ResponseWriter, r *http.Request) return } - doc, err := s.freeDoc.ShareDocument(r.Context(), userID, docID) + doc, err := s.freeDoc().ShareDocument(r.Context(), userID, docID) if err != nil { if errors.Is(err, freedoc.ErrNotFound) { writeJSONResponse(w, http.StatusNotFound, map[string]any{"error_code": "DOC_NOT_FOUND", "message": "文档不存在"}) @@ -374,7 +374,7 @@ func (s *Server) handleShareFreeformDoc(w http.ResponseWriter, r *http.Request) // POST /api/v1/freeform-documents/{doc_id}/unshare func (s *Server) handleUnshareFreeformDoc(w http.ResponseWriter, r *http.Request) { - if s.freeDoc == nil { + if s.freeDoc() == nil { writeSafeError(w, r, http.StatusServiceUnavailable, "freeform doc service not initialized") return } @@ -392,7 +392,7 @@ func (s *Server) handleUnshareFreeformDoc(w http.ResponseWriter, r *http.Request return } - doc, err := s.freeDoc.UnshareDocument(r.Context(), userID, docID) + doc, err := s.freeDoc().UnshareDocument(r.Context(), userID, docID) if err != nil { if errors.Is(err, freedoc.ErrNotFound) { writeJSONResponse(w, http.StatusNotFound, map[string]any{"error_code": "DOC_NOT_FOUND", "message": "文档不存在"}) @@ -415,7 +415,7 @@ func (s *Server) handleUnshareFreeformDoc(w http.ResponseWriter, r *http.Request // DELETE /api/v1/freeform-documents/{doc_id} func (s *Server) handleDeleteFreeformDoc(w http.ResponseWriter, r *http.Request) { - if s.freeDoc == nil { + if s.freeDoc() == nil { writeSafeError(w, r, http.StatusServiceUnavailable, "freeform doc service not initialized") return } @@ -433,7 +433,7 @@ func (s *Server) handleDeleteFreeformDoc(w http.ResponseWriter, r *http.Request) return } - err := s.freeDoc.DeleteDocument(r.Context(), userID, docID) + err := s.freeDoc().DeleteDocument(r.Context(), userID, docID) if err != nil { if errors.Is(err, freedoc.ErrNotFound) { writeJSONResponse(w, http.StatusNotFound, map[string]any{"error_code": "DOC_NOT_FOUND", "message": "文档不存在"}) diff --git a/internal/server/handlers_chat_history.go b/internal/server/handlers_chat_history.go index c00e3820..1c0c1d02 100644 --- a/internal/server/handlers_chat_history.go +++ b/internal/server/handlers_chat_history.go @@ -17,7 +17,7 @@ import ( // handleListChatSessions 分页查询用户/项目关联的历史会话列表 // user_id 仅允许等于当前认证用户本人(或系统管理员),防止水平越权枚举他人会话。 func (s *Server) handleListChatSessions(w http.ResponseWriter, r *http.Request) { - if s.ctxMgr == nil { + if s.ctxMgr() == nil { writeSafeError(w, r, http.StatusServiceUnavailable, "context manager not initialized") return } @@ -54,7 +54,7 @@ func (s *Server) handleListChatSessions(w http.ResponseWriter, r *http.Request) offset = 0 } - sessions, err := s.ctxMgr.ListChatSessions(r.Context(), ctxmgr.SessionFilter{ + sessions, err := s.ctxMgr().ListChatSessions(r.Context(), ctxmgr.SessionFilter{ UserID: userID, ProjectID: projectID, Limit: limit, @@ -71,7 +71,7 @@ func (s *Server) handleListChatSessions(w http.ResponseWriter, r *http.Request) // handleUpsertChatSession 注册或更新会话元数据(立项/成员加入时同步)。 // 默认归属认证用户本人;项目管理员可为其他成员代注册(防水平越权)。 func (s *Server) handleUpsertChatSession(w http.ResponseWriter, r *http.Request) { - if s.ctxMgr == nil { + if s.ctxMgr() == nil { writeSafeError(w, r, http.StatusServiceUnavailable, "context manager not initialized") return } @@ -99,7 +99,7 @@ func (s *Server) handleUpsertChatSession(w http.ResponseWriter, r *http.Request) }) return } - can, err := s.authzSvc.CanManageProjectMembers(r.Context(), req.ProjectID, user.ID, user.RoleID) + can, err := s.authzSvc().CanManageProjectMembers(r.Context(), req.ProjectID, user.ID, user.RoleID) if err != nil { writeSafeError(w, r, http.StatusInternalServerError, err.Error()) return @@ -114,7 +114,7 @@ func (s *Server) handleUpsertChatSession(w http.ResponseWriter, r *http.Request) ownerID = req.UserID } - summary, err := s.ctxMgr.EnsureChatSessionOwned(r.Context(), req.SessionID, ownerID, req.ProjectID, req.Title) + 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{ @@ -132,7 +132,7 @@ func (s *Server) handleUpsertChatSession(w http.ResponseWriter, r *http.Request) // handleGetChatMessages 分页获取指定会话的历史多轮消息记录 func (s *Server) handleGetChatMessages(w http.ResponseWriter, r *http.Request) { - if s.ctxMgr == nil { + if s.ctxMgr() == nil { writeSafeError(w, r, http.StatusServiceUnavailable, "context manager not initialized") return } @@ -183,7 +183,7 @@ func (s *Server) handleRecommendCases(w http.ResponseWriter, r *http.Request) { if !ok { return } - if s.ctxMgr == nil { + if s.ctxMgr() == nil { writeSafeError(w, r, http.StatusServiceUnavailable, "context manager not initialized") return } @@ -209,7 +209,7 @@ func (s *Server) handleRecommendCases(w http.ResponseWriter, r *http.Request) { minScore = 0.50 } - cases, err := s.ctxMgr.SearchSimilarCases(r.Context(), ctxmgr.CaseSearchOptions{ + cases, err := s.ctxMgr().SearchSimilarCases(r.Context(), ctxmgr.CaseSearchOptions{ Query: query, TaskKey: taskKey, ExcludeDoc: excludeDoc, diff --git a/internal/server/handlers_chat_task.go b/internal/server/handlers_chat_task.go index 4a89b17a..9438f2db 100644 --- a/internal/server/handlers_chat_task.go +++ b/internal/server/handlers_chat_task.go @@ -133,7 +133,7 @@ func (s *Server) handleRunTask(w http.ResponseWriter, r *http.Request) { if req.ProjectID > 0 { if !s.isSystemAdminUser(r.Context(), user) { - if err := s.authorizer.RequireProjectAccess(r.Context(), req.ProjectID, user.ID); err != nil { + if err := s.authorizer().RequireProjectAccess(r.Context(), req.ProjectID, user.ID); err != nil { writeJSONResponse(w, http.StatusForbidden, map[string]string{ "error_code": "ERR_FORBIDDEN", "message": "无权访问该项目", @@ -153,10 +153,10 @@ func (s *Server) handleRunTask(w http.ResponseWriter, r *http.Request) { // 0. 如果关联了项目,在 projectworkflow 服务中记录运行实例 var runRecord *projectworkflow.WorkflowRun - if s.pwSvc != nil && req.ProjectID > 0 { + if s.pwSvc() != nil && req.ProjectID > 0 { wfKey := req.WorkflowKey if wfKey == "" { - if p, _ := s.pwSvc.GetProject(r.Context(), req.ProjectID, req.UserID); p != nil { + if p, _ := s.pwSvc().GetProject(r.Context(), req.ProjectID, req.UserID); p != nil { wfKey = p.ActiveWorkflowKey } } @@ -167,7 +167,7 @@ func (s *Server) handleRunTask(w http.ResponseWriter, r *http.Request) { "fields": req.Fields, "doc_id": req.DocID, } - runRecord, _ = s.pwSvc.CreateWorkflowRun(r.Context(), &projectworkflow.WorkflowRun{ + runRecord, _ = s.pwSvc().CreateWorkflowRun(r.Context(), &projectworkflow.WorkflowRun{ ProjectID: req.ProjectID, WorkflowKey: wfKey, Status: "running", @@ -207,7 +207,7 @@ func (s *Server) handleRunTask(w http.ResponseWriter, r *http.Request) { }) if err != nil { if runRecord != nil { - _ = s.pwSvc.UpdateWorkflowRun(r.Context(), runRecord.RunID, "failed", "", "", err.Error()) + _ = s.pwSvc().UpdateWorkflowRun(r.Context(), runRecord.RunID, "failed", "", "", err.Error()) } writeTaskError(w, err, stageRes) return @@ -222,7 +222,7 @@ func (s *Server) handleRunTask(w http.ResponseWriter, r *http.Request) { if outSummary == "" { outSummary = stageRes.StageTitle } - _ = s.pwSvc.UpdateWorkflowRun(r.Context(), runRecord.RunID, status, outDocID, outSummary, "") + _ = s.pwSvc().UpdateWorkflowRun(r.Context(), runRecord.RunID, status, outDocID, outSummary, "") } writeJSONResponse(w, http.StatusOK, stageRes) return @@ -257,7 +257,7 @@ func (s *Server) handleRunTask(w http.ResponseWriter, r *http.Request) { }) if err != nil { if runRecord != nil { - _ = s.pwSvc.UpdateWorkflowRun(r.Context(), runRecord.RunID, "failed", "", "", err.Error()) + _ = s.pwSvc().UpdateWorkflowRun(r.Context(), runRecord.RunID, "failed", "", "", err.Error()) } writeTaskError(w, err, taskRes) return @@ -269,7 +269,7 @@ func (s *Server) handleRunTask(w http.ResponseWriter, r *http.Request) { if taskRes.DocRecord != nil { outDocID = taskRes.DocRecord.DocID } - _ = s.pwSvc.UpdateWorkflowRun(r.Context(), runRecord.RunID, status, outDocID, taskRes.ArtifactTitle, "") + _ = s.pwSvc().UpdateWorkflowRun(r.Context(), runRecord.RunID, status, outDocID, taskRes.ArtifactTitle, "") } // 隐藏不必要的中间大体积字节 diff --git a/internal/server/handlers_context.go b/internal/server/handlers_context.go index 0b6b2dc6..fe755bf2 100644 --- a/internal/server/handlers_context.go +++ b/internal/server/handlers_context.go @@ -36,12 +36,12 @@ func (s *Server) handleGetUserContext(w http.ResponseWriter, r *http.Request) { } projectID, _ := strconv.ParseInt(projectIDStr, 10, 64) - if s.ctxMgr == nil { + if s.ctxMgr() == nil { writeSafeError(w, r, http.StatusServiceUnavailable, "context manager not initialized") return } - uCtx, err := s.ctxMgr.GetUserContext(r.Context(), projectID, userID, sessionID) + uCtx, err := s.ctxMgr().GetUserContext(r.Context(), projectID, userID, sessionID) if err != nil { writeSafeError(w, r, http.StatusInternalServerError, "get user context failed: "+err.Error()) return @@ -72,7 +72,7 @@ func (s *Server) handleUpdateUserPreference(w http.ResponseWriter, r *http.Reque return } - if s.ctxMgr == nil { + if s.ctxMgr() == nil { writeSafeError(w, r, http.StatusServiceUnavailable, "context manager not initialized") return } @@ -85,7 +85,7 @@ func (s *Server) handleUpdateUserPreference(w http.ResponseWriter, r *http.Reque CustomInstructions: req.CustomInstructions, } - if err := s.ctxMgr.UpdateUserPreference(r.Context(), pref); err != nil { + if err := s.ctxMgr().UpdateUserPreference(r.Context(), pref); err != nil { writeSafeError(w, r, http.StatusInternalServerError, "save user preference failed: "+err.Error()) return } @@ -118,12 +118,12 @@ func (s *Server) handleGetProjectContext(w http.ResponseWriter, r *http.Request) return } - if s.ctxMgr == nil { + if s.ctxMgr() == nil { writeSafeError(w, r, http.StatusServiceUnavailable, "context manager not initialized") return } - pCtx, err := s.ctxMgr.GetProjectContext(r.Context(), projectID) + pCtx, err := s.ctxMgr().GetProjectContext(r.Context(), projectID) if err != nil { writeSafeError(w, r, http.StatusInternalServerError, "get project context failed: "+err.Error()) return diff --git a/internal/server/handlers_docgov.go b/internal/server/handlers_docgov.go index cf97f94f..6d24524a 100644 --- a/internal/server/handlers_docgov.go +++ b/internal/server/handlers_docgov.go @@ -28,7 +28,7 @@ func (s *Server) handleDocGovGetDocument(w http.ResponseWriter, r *http.Request) return } - if s.gov == nil { + if s.gov() == nil { writeSafeError(w, r, http.StatusServiceUnavailable, "docgov engine not initialized") return } @@ -37,7 +37,7 @@ func (s *Server) handleDocGovGetDocument(w http.ResponseWriter, r *http.Request) return } - doc, err := s.gov.GetDocument(r.Context(), docID) + doc, err := s.gov().GetDocument(r.Context(), docID) if err != nil { writeSafeError(w, r, http.StatusNotFound, "get document failed: "+err.Error()) return @@ -63,12 +63,12 @@ func (s *Server) handleDocGovClaim(w http.ResponseWriter, r *http.Request) { return } - if s.gov == nil { + if s.gov() == nil { writeSafeError(w, r, http.StatusServiceUnavailable, "docgov engine not initialized") return } - doc, err := s.gov.RecordDocumentViewed(r.Context(), req.DocID, user.ID, s.resolveDocGovRole(r.Context(), req.DocID, user), user.Name) + doc, err := s.gov().RecordDocumentViewed(r.Context(), req.DocID, user.ID, s.resolveDocGovRole(r.Context(), req.DocID, user), user.Name) if err != nil { writeSafeError(w, r, http.StatusInternalServerError, "claim document failed: "+err.Error()) return @@ -90,7 +90,7 @@ func (s *Server) handleDocGovSubmitReview(w http.ResponseWriter, r *http.Request return } - if s.gov == nil { + if s.gov() == nil { writeSafeError(w, r, http.StatusServiceUnavailable, "docgov engine not initialized") return } @@ -98,7 +98,7 @@ func (s *Server) handleDocGovSubmitReview(w http.ResponseWriter, r *http.Request return } - doc, err := s.gov.SubmitForReview(r.Context(), req.DocID, user.ID, vocabulary.RoleAuthor, user.Name) + doc, err := s.gov().SubmitForReview(r.Context(), req.DocID, user.ID, vocabulary.RoleAuthor, user.Name) if err != nil { writeSafeError(w, r, http.StatusBadRequest, "submit review failed: "+err.Error()) return @@ -120,7 +120,7 @@ func (s *Server) handleDocGovReviewDecision(w http.ResponseWriter, r *http.Reque return } - if s.gov == nil { + if s.gov() == nil { writeSafeError(w, r, http.StatusServiceUnavailable, "docgov engine not initialized") return } @@ -133,13 +133,13 @@ func (s *Server) handleDocGovReviewDecision(w http.ResponseWriter, r *http.Reque var err error if action == "approve" { - doc, err = s.gov.ReviewApprove(r.Context(), req.DocID, user.ID, vocabulary.RoleReviewer, user.Name, req.Comment) + doc, err = s.gov().ReviewApprove(r.Context(), req.DocID, user.ID, vocabulary.RoleReviewer, user.Name, req.Comment) } else if action == "reject" { reason := req.Reason if reason == "" { reason = req.Comment } - doc, err = s.gov.ReviewReject(r.Context(), req.DocID, user.ID, vocabulary.RoleReviewer, user.Name, reason) + doc, err = s.gov().ReviewReject(r.Context(), req.DocID, user.ID, vocabulary.RoleReviewer, user.Name, reason) } else { writeSafeError(w, r, http.StatusBadRequest, "invalid action: must be approve or reject") return @@ -166,7 +166,7 @@ func (s *Server) handleDocGovApproverDecision(w http.ResponseWriter, r *http.Req return } - if s.gov == nil { + if s.gov() == nil { writeSafeError(w, r, http.StatusServiceUnavailable, "docgov engine not initialized") return } @@ -239,7 +239,7 @@ func (s *Server) handleDocGovProposeModification(w http.ResponseWriter, r *http. return } - if s.gov == nil { + if s.gov() == nil { writeSafeError(w, r, http.StatusServiceUnavailable, "docgov engine not initialized") return } @@ -249,7 +249,7 @@ func (s *Server) handleDocGovProposeModification(w http.ResponseWriter, r *http. triggeredBy = fmt.Sprintf("user:%d", user.ID) } - proposal, err := s.gov.ProposeModification( + proposal, err := s.gov().ProposeModification( r.Context(), req.DocID, triggeredBy, @@ -286,12 +286,12 @@ func (s *Server) handleDocGovDecideProposal(w http.ResponseWriter, r *http.Reque return } - if s.gov == nil { + if s.gov() == nil { writeSafeError(w, r, http.StatusServiceUnavailable, "docgov engine not initialized") return } - proposal, err := s.gov.DecideModificationProposal(r.Context(), req.ProposalID, user.ID, req.Approve, req.Reason) + proposal, err := s.gov().DecideModificationProposal(r.Context(), req.ProposalID, user.ID, req.Approve, req.Reason) if err != nil { if status, ok := domainHTTPStatus(err); ok { switch status { @@ -308,13 +308,13 @@ func (s *Server) handleDocGovDecideProposal(w http.ResponseWriter, r *http.Reque return } - if req.Approve && s.svc != nil && proposal != nil { + if req.Approve && s.svc() != nil && proposal != nil { // Auto-apply as author for human/supplement-triggered proposals only. triggered := strings.TrimSpace(proposal.TriggeredBy) if strings.HasPrefix(triggered, "user:") || strings.Contains(triggered, "supplement") { base := bizdev.TaskRunRequest{DocID: proposal.DocID, ProjectID: proposal.ProjectID, ForceDirectEdit: true, UserID: user.ID, AuthorID: user.ID, AuthorName: user.Name} go func(prop *docgov.ChangeProposal, uid int64, req bizdev.TaskRunRequest) { - _, _ = s.svc.AfterProposalApproved(context.Background(), prop, uid, req) + _, _ = s.svc().AfterProposalApproved(context.Background(), prop, uid, req) }(proposal, user.ID, base) } } @@ -329,7 +329,7 @@ func (s *Server) handleDocGovListComments(w http.ResponseWriter, r *http.Request return } - if s.gov == nil { + if s.gov() == nil { writeSafeError(w, r, http.StatusServiceUnavailable, "docgov engine not initialized") return } @@ -338,7 +338,7 @@ func (s *Server) handleDocGovListComments(w http.ResponseWriter, r *http.Request return } - comments, err := s.gov.ListComments(r.Context(), docID) + comments, err := s.gov().ListComments(r.Context(), docID) if err != nil { writeSafeError(w, r, http.StatusInternalServerError, "list comments failed: "+err.Error()) return @@ -356,7 +356,7 @@ func (s *Server) handleDocGovAddComment(w http.ResponseWriter, r *http.Request) return } - if s.gov == nil { + if s.gov() == nil { writeSafeError(w, r, http.StatusServiceUnavailable, "docgov engine not initialized") return } @@ -371,7 +371,7 @@ func (s *Server) handleDocGovAddComment(w http.ResponseWriter, r *http.Request) cType = vocabulary.CommentGeneral } - comment, err := s.gov.AddComment( + comment, err := s.gov().AddComment( r.Context(), req.DocID, user.ID, user.Name, s.resolveDocGovRole(r.Context(), req.DocID, user), req.TargetSection, cType, req.Content, ) @@ -391,7 +391,7 @@ func (s *Server) handleDocGovListAuditLogs(w http.ResponseWriter, r *http.Reques return } - if s.gov == nil { + if s.gov() == nil { writeSafeError(w, r, http.StatusServiceUnavailable, "docgov engine not initialized") return } @@ -400,7 +400,7 @@ func (s *Server) handleDocGovListAuditLogs(w http.ResponseWriter, r *http.Reques return } - logs, err := s.gov.ListAuditLogs(r.Context(), docID) + logs, err := s.gov().ListAuditLogs(r.Context(), docID) if err != nil { writeSafeError(w, r, http.StatusInternalServerError, "list audit logs failed: "+err.Error()) return diff --git a/internal/server/handlers_docgov_lease.go b/internal/server/handlers_docgov_lease.go index de209264..b68240db 100644 --- a/internal/server/handlers_docgov_lease.go +++ b/internal/server/handlers_docgov_lease.go @@ -24,7 +24,7 @@ func (s *Server) handleDocGovSaveContent(w http.ResponseWriter, r *http.Request) writeSafeError(w, r, http.StatusBadRequest, "invalid request body: "+err.Error()) return } - if s.gov == nil { + if s.gov() == nil { writeSafeError(w, r, http.StatusServiceUnavailable, "docgov engine not initialized") return } @@ -33,7 +33,7 @@ func (s *Server) handleDocGovSaveContent(w http.ResponseWriter, r *http.Request) return } role := vocabulary.RoleAuthor - res, err := s.gov.SaveDocumentContent(r.Context(), docgov.ContentSaveRequest{ + res, err := s.gov().SaveDocumentContent(r.Context(), docgov.ContentSaveRequest{ DocID: req.DocID, ActorID: user.ID, ActorRole: role, @@ -59,7 +59,7 @@ func (s *Server) handleDocGovAcquireLease(w http.ResponseWriter, r *http.Request writeSafeError(w, r, http.StatusBadRequest, "invalid request body: "+err.Error()) return } - if s.gov == nil { + if s.gov() == nil { writeSafeError(w, r, http.StatusServiceUnavailable, "docgov engine not initialized") return } @@ -67,18 +67,18 @@ func (s *Server) handleDocGovAcquireLease(w http.ResponseWriter, r *http.Request if !s.enforceDocGovWorkflowAction(w, r.Context(), user.ID, req.DocID, projectinstance.DocAccessActionEdit) { return } - if err := s.gov.CheckHumanCanEdit(r.Context(), req.DocID, user.ID, vocabulary.RoleAuthor, true); err != nil { + if err := s.gov().CheckHumanCanEdit(r.Context(), req.DocID, user.ID, vocabulary.RoleAuthor, true); err != nil { writeDocGovError(w, err) return } req.HolderID = fmt.Sprintf("%d", user.ID) } - workDir, werr := s.resolver.ResolveDocWorkDir(r.Context(), req.DocID) + workDir, werr := s.resolver().ResolveDocWorkDir(r.Context(), req.DocID) if werr != nil { workDir = "" } ttl := time.Duration(req.TTLSeconds) * time.Second - doc, err := s.gov.AcquireLease(r.Context(), docgov.LeaseAcquireRequest{ + doc, err := s.gov().AcquireLease(r.Context(), docgov.LeaseAcquireRequest{ DocID: req.DocID, HolderType: vocabulary.LeaseHolderType(req.HolderType), HolderID: req.HolderID, @@ -102,7 +102,7 @@ func (s *Server) handleDocGovHeartbeatLease(w http.ResponseWriter, r *http.Reque writeSafeError(w, r, http.StatusBadRequest, "invalid request body: "+err.Error()) return } - if s.gov == nil { + if s.gov() == nil { writeSafeError(w, r, http.StatusServiceUnavailable, "docgov engine not initialized") return } @@ -117,7 +117,7 @@ func (s *Server) handleDocGovHeartbeatLease(w http.ResponseWriter, r *http.Reque } } ttl := time.Duration(req.TTLSeconds) * time.Second - doc, err := s.gov.HeartbeatLease(r.Context(), req.DocID, req.HolderType, req.HolderID, ttl) + doc, err := s.gov().HeartbeatLease(r.Context(), req.DocID, req.HolderType, req.HolderID, ttl) if err != nil { writeDocGovError(w, err) return @@ -135,7 +135,7 @@ func (s *Server) handleDocGovReleaseLease(w http.ResponseWriter, r *http.Request writeSafeError(w, r, http.StatusBadRequest, "invalid request body: "+err.Error()) return } - if s.gov == nil { + if s.gov() == nil { writeSafeError(w, r, http.StatusServiceUnavailable, "docgov engine not initialized") return } @@ -149,7 +149,7 @@ func (s *Server) handleDocGovReleaseLease(w http.ResponseWriter, r *http.Request return } } - doc, err := s.gov.ReleaseLease(r.Context(), req.DocID, req.HolderType, req.HolderID) + doc, err := s.gov().ReleaseLease(r.Context(), req.DocID, req.HolderType, req.HolderID) if err != nil { writeDocGovError(w, err) return diff --git a/internal/server/handlers_health.go b/internal/server/handlers_health.go index 885beab4..1ed3219c 100644 --- a/internal/server/handlers_health.go +++ b/internal/server/handlers_health.go @@ -37,13 +37,13 @@ func (s *Server) buildHealthResponse(ctx context.Context, strict bool) (HealthRe dbCtx, cancel := context.WithTimeout(ctx, 2*time.Second) defer cancel() - dbReady := s.svc != nil && s.svc.DatabaseReady() + dbReady := s.svc() != nil && s.svc().DatabaseReady() schemaVersion := int64(0) - if s.svc != nil { + if s.svc() != nil { if dbReady { - schemaVersion = s.svc.LiveSchemaVersion(dbCtx) + schemaVersion = s.svc().LiveSchemaVersion(dbCtx) } else { - schemaVersion = s.svc.SchemaVersion() + schemaVersion = s.svc().SchemaVersion() } } schemaReady := schemaVersion > 0 || (s.cfg != nil && s.cfg.AllowInMemory) @@ -70,7 +70,7 @@ func (s *Server) buildHealthResponse(ctx context.Context, strict bool) (HealthRe } } - kbReady := s.kb != nil && s.kb.Online() + kbReady := s.kb() != nil && s.kb().Online() eventsReady := true if s.eventsAlive != nil { diff --git a/internal/server/intent_plan_handlers.go b/internal/server/intent_plan_handlers.go index e78917f4..17c3d2cd 100644 --- a/internal/server/intent_plan_handlers.go +++ b/internal/server/intent_plan_handlers.go @@ -105,7 +105,7 @@ func shouldDispatchAfterDecision(kind intentflow.DecisionKind, view *intentflow. } func (s *Server) planRequestScope(w http.ResponseWriter, r *http.Request) (intentflow.Service, intentflow.Principal, bool) { - if s == nil || s.intentPlans == nil { + if s == nil || s.intentPlans() == nil { writeJSONResponse(w, http.StatusServiceUnavailable, map[string]string{ "error_code": "PLAN_NOT_FOUND_OR_HIDDEN", "message": "计划服务不可用", @@ -151,7 +151,7 @@ func (s *Server) planRequestScope(w http.ResponseWriter, r *http.Request) (inten if s.cfg != nil && strings.TrimSpace(s.cfg.Env) != "" { env = s.cfg.Env } - return s.intentPlans, intentflow.Principal{ + return s.intentPlans(), intentflow.Principal{ UserID: user.ID, Environment: env, ProjectID: projectID, @@ -163,20 +163,20 @@ func (s *Server) planProjectAccess(ctx context.Context, projectID, userID int64) if s.planAccessFn != nil { return s.planAccessFn(ctx, projectID, userID) } - if s.authorizer == nil { + if s.authorizer() == nil { return authz.ErrForbidden } - return s.authorizer.RequireProjectAccess(ctx, projectID, userID) + return s.authorizer().RequireProjectAccess(ctx, projectID, userID) } func (s *Server) planSessionOwner(ctx context.Context, sessionID string) (int64, error) { if s.planSessionOwnerFn != nil { return s.planSessionOwnerFn(ctx, sessionID) } - if s.ctxMgr == nil { + if s.ctxMgr() == nil { return 0, intentflow.ErrPlanNotFoundOrHidden } - return s.ctxMgr.GetSessionOwnerID(ctx, sessionID) + return s.ctxMgr().GetSessionOwnerID(ctx, sessionID) } func writeIntentPlanError(w http.ResponseWriter, r *http.Request, plans intentflow.Service, prin intentflow.Principal, planID string, err error) { diff --git a/internal/server/intent_plan_handlers_test.go b/internal/server/intent_plan_handlers_test.go index 8172703e..ea5ed540 100644 --- a/internal/server/intent_plan_handlers_test.go +++ b/internal/server/intent_plan_handlers_test.go @@ -24,7 +24,7 @@ func u5Server(t *testing.T) (*Server, *intentflow.Plans, *intentflow.FakeActionO srv := NewServer(cfg, bizdev.MustNewService(t, &config.Config{DataDir: t.TempDir(), AgentBackend: "stub"})) owner := intentflow.NewFakeActionOwner() plans := intentflow.NewPlans(&intentflow.SerialUoW{}, intentflow.NewMemoryStore(), intentflow.Options{Owner: owner}) - srv.intentPlans = plans + srv.setOverrideIntentPlans(plans) token, userID := u5Login(t, srv) srv.planAccessFn = func(ctx context.Context, projectID, userID int64) error { if projectID == 99 { diff --git a/internal/server/lease_owner.go b/internal/server/lease_owner.go index e8a126c4..a46308d6 100644 --- a/internal/server/lease_owner.go +++ b/internal/server/lease_owner.go @@ -13,10 +13,10 @@ func (s *Server) callerOwnsSquadLease(userID int64, holderID string) bool { if userID <= 0 || holderID == "" { return false } - if s == nil || s.taskRuns == nil { + if s == nil || s.taskRuns() == nil { return false } - run, err := s.taskRuns.GetRun(holderID) + run, err := s.taskRuns().GetRun(holderID) if err != nil || run == nil { if s.cfg != nil && s.cfg.AllowInMemory { return true diff --git a/internal/server/lineage_access.go b/internal/server/lineage_access.go index acd52284..60798bfd 100644 --- a/internal/server/lineage_access.go +++ b/internal/server/lineage_access.go @@ -48,8 +48,8 @@ func (s *Server) lineageDocVisible(ctx context.Context, projectID, userID int64, } func (s *Server) lineageDocPlacement(ctx context.Context, projectID int64, docID string) (taskKey string, placed bool, ok bool) { - if s.gov != nil { - doc, err := s.gov.GetDocument(ctx, docID) + if s.gov() != nil { + doc, err := s.gov().GetDocument(ctx, docID) if err != nil { if status, mapped := domainHTTPStatus(err); !(mapped && status == http.StatusNotFound) { return "", false, false diff --git a/internal/server/maintenance_handlers.go b/internal/server/maintenance_handlers.go index 4a633659..ed161eff 100644 --- a/internal/server/maintenance_handlers.go +++ b/internal/server/maintenance_handlers.go @@ -20,11 +20,11 @@ import ( ) func (s *Server) handleMaintenanceStatus(w http.ResponseWriter, r *http.Request) { - if s.maintenance == nil { + if s.maintenance() == nil { writeJSONResponse(w, http.StatusOK, maintenance.StatusView{Status: "none"}) return } - view, err := s.maintenance.CurrentStatus(r.Context()) + view, err := s.maintenance().CurrentStatus(r.Context()) if err != nil { writeSafeError(w, r, http.StatusInternalServerError, err.Error()) return @@ -36,11 +36,11 @@ func (s *Server) handleAdminMaintenanceList(w http.ResponseWriter, r *http.Reque if _, ok := s.requireSystemAdmin(w, r); !ok { return } - if s.maintenance == nil { + if s.maintenance() == nil { writeSafeError(w, r, http.StatusServiceUnavailable, "maintenance service unavailable") return } - list, err := s.maintenance.ListRecent(r.Context(), 20) + list, err := s.maintenance().ListRecent(r.Context(), 20) if err != nil { writeSafeError(w, r, http.StatusInternalServerError, err.Error()) return @@ -53,7 +53,7 @@ func (s *Server) handleAdminMaintenanceSchedule(w http.ResponseWriter, r *http.R if !ok { return } - if s.maintenance == nil { + if s.maintenance() == nil { writeSafeError(w, r, http.StatusServiceUnavailable, "maintenance service unavailable") return } @@ -71,7 +71,7 @@ func (s *Server) handleAdminMaintenanceSchedule(w http.ResponseWriter, r *http.R if end.IsZero() && req.Hours > 0 { end = req.Start.Add(time.Duration(req.Hours * float64(time.Hour))) } - wnd, err := s.maintenance.Schedule(r.Context(), maintenance.ScheduleInput{ + wnd, err := s.maintenance().Schedule(r.Context(), maintenance.ScheduleInput{ Start: req.Start, End: end, Message: req.Message, CreatedBy: user.ID, }) switch { @@ -90,7 +90,7 @@ func (s *Server) handleAdminMaintenanceCancel(w http.ResponseWriter, r *http.Req if _, ok := s.requireSystemAdmin(w, r); !ok { return } - if s.maintenance == nil { + if s.maintenance() == nil { writeSafeError(w, r, http.StatusServiceUnavailable, "maintenance service unavailable") return } @@ -99,7 +99,7 @@ func (s *Server) handleAdminMaintenanceCancel(w http.ResponseWriter, r *http.Req writeSafeError(w, r, http.StatusBadRequest, "无效的 id") return } - wnd, err := s.maintenance.Cancel(r.Context(), id) + wnd, err := s.maintenance().Cancel(r.Context(), id) switch { case errors.Is(err, maintenance.ErrBadStatus): writeSafeError(w, r, http.StatusConflict, "当前状态不可取消") @@ -112,11 +112,11 @@ func (s *Server) handleAdminMaintenanceCancel(w http.ResponseWriter, r *http.Req func (s *Server) maintenanceMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if s.maintenance == nil { + if s.maintenance() == nil { next.ServeHTTP(w, r) return } - view, err := s.maintenance.CurrentStatus(r.Context()) + view, err := s.maintenance().CurrentStatus(r.Context()) if err != nil || !view.Active { next.ServeHTTP(w, r) return @@ -128,8 +128,8 @@ func (s *Server) maintenanceMiddleware(next http.Handler) http.Handler { } // 管理员仍可访问管理台与维护 API if user, err := s.authenticateUser(r); err == nil && user != nil { - if s.authzSvc != nil { - if admin, aerr := s.authzSvc.IsSystemAdmin(r.Context(), user.ID, user.RoleID); aerr == nil && admin { + if s.authzSvc() != nil { + if admin, aerr := s.authzSvc().IsSystemAdmin(r.Context(), user.ID, user.RoleID); aerr == nil && admin { next.ServeHTTP(w, r) return } diff --git a/internal/server/me_settings_handlers.go b/internal/server/me_settings_handlers.go index 9e9a1e77..8a43a5f1 100644 --- a/internal/server/me_settings_handlers.go +++ b/internal/server/me_settings_handlers.go @@ -32,7 +32,7 @@ func (s *Server) handleMeProfile(w http.ResponseWriter, r *http.Request) { if user.ProfileUpdatedBy > 0 { resp["profile_updated_by"] = user.ProfileUpdatedBy resp["profile_updated_at"] = user.ProfileUpdatedAt - if admin, err := s.authSvc.GetUserByID(r.Context(), user.ProfileUpdatedBy); err == nil && admin != nil { + if admin, err := s.authSvc().GetUserByID(r.Context(), user.ProfileUpdatedBy); err == nil && admin != nil { name := strings.TrimSpace(admin.Name) if name == "" { name = admin.Username @@ -47,7 +47,7 @@ func (s *Server) handleMeProfile(w http.ResponseWriter, r *http.Request) { writeSafeError(w, r, http.StatusBadRequest, "invalid body") return } - saved, err := s.authSvc.UpdateUserProfile(r.Context(), user.ID, auth.ProfilePatch{ + saved, err := s.authSvc().UpdateUserProfile(r.Context(), user.ID, auth.ProfilePatch{ Name: patch.Name, Email: patch.Email, Department: patch.Department, @@ -72,7 +72,7 @@ func (s *Server) handleMePreferences(w http.ResponseWriter, r *http.Request) { } switch r.Method { case http.MethodGet: - prefs, err := s.authSvc.GetUserPreferences(r.Context(), user.ID) + prefs, err := s.authSvc().GetUserPreferences(r.Context(), user.ID) if err != nil { writeSafeError(w, r, http.StatusInternalServerError, err.Error()) return @@ -84,7 +84,7 @@ func (s *Server) handleMePreferences(w http.ResponseWriter, r *http.Request) { writeSafeError(w, r, http.StatusBadRequest, "invalid body") return } - prefs, err := s.authSvc.SaveUserPreferences(r.Context(), user.ID, body) + prefs, err := s.authSvc().SaveUserPreferences(r.Context(), user.ID, body) if err != nil { writeSafeError(w, r, http.StatusInternalServerError, err.Error()) return diff --git a/internal/server/notification_handlers.go b/internal/server/notification_handlers.go index 09c7f0b0..2ce03cee 100644 --- a/internal/server/notification_handlers.go +++ b/internal/server/notification_handlers.go @@ -17,7 +17,7 @@ func (s *Server) handleListProjectNotifications(w http.ResponseWriter, r *http.R if !ok { return } - if s.notifySvc == nil || !s.notifySvc.Available() { + if s.notifySvc() == nil || !s.notifySvc().Available() { writeSafeError(w, r, http.StatusServiceUnavailable, "notification service unavailable") return } @@ -31,7 +31,7 @@ func (s *Server) handleListProjectNotifications(w http.ResponseWriter, r *http.R sinceID, _ := strconv.ParseInt(r.URL.Query().Get("since_id"), 10, 64) limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) - items, err := s.notifySvc.ListProjectNotifications(r.Context(), user.ID, projectID, sinceID, limit) + items, err := s.notifySvc().ListProjectNotifications(r.Context(), user.ID, projectID, sinceID, limit) if err != nil { writeSafeError(w, r, http.StatusInternalServerError, "list notifications failed: "+err.Error()) return @@ -63,7 +63,7 @@ func (s *Server) handleListUserNotifications(w http.ResponseWriter, r *http.Requ if !ok { return } - if s.notifySvc == nil || !s.notifySvc.Available() { + if s.notifySvc() == nil || !s.notifySvc().Available() { writeSafeError(w, r, http.StatusServiceUnavailable, "notification service unavailable") return } @@ -89,7 +89,7 @@ func (s *Server) listAllScopeNotifications(ctx context.Context, userID, sinceID type userLister interface { ListUserNotifications(ctx context.Context, userID, sinceID int64, limit int) ([]notification.Notification, error) } - if lister, ok := any(s.notifySvc).(userLister); ok { + if lister, ok := any(s.notifySvc()).(userLister); ok { raw, err := lister.ListUserNotifications(ctx, userID, sinceID, limit) if err != nil { return nil, err @@ -193,10 +193,10 @@ func (s *Server) queryUserNotifications(ctx context.Context, userID, sinceID int } func (s *Server) fanOutProjectNotifications(ctx context.Context, userID, sinceID int64, limit int) ([]userNotificationView, error) { - if s.pwSvc == nil { + if s.pwSvc() == nil { return []userNotificationView{}, nil } - projects, err := s.pwSvc.ListProjects(ctx, userID) + projects, err := s.pwSvc().ListProjects(ctx, userID) if err != nil { return nil, err } @@ -209,7 +209,7 @@ func (s *Server) fanOutProjectNotifications(ctx context.Context, userID, sinceID if p == nil { continue } - chunk, listErr := s.notifySvc.ListProjectNotifications(ctx, userID, p.ID, sinceID, per) + chunk, listErr := s.notifySvc().ListProjectNotifications(ctx, userID, p.ID, sinceID, per) if listErr != nil { continue } diff --git a/internal/server/notification_reminder_loop.go b/internal/server/notification_reminder_loop.go index fef61fae..e2dee0fb 100644 --- a/internal/server/notification_reminder_loop.go +++ b/internal/server/notification_reminder_loop.go @@ -9,17 +9,20 @@ import ( const defaultReminderPollInterval = 15 * time.Minute func (s *Server) startNotificationReminderLoop() { - if s == nil || s.notifySvc == nil || !s.notifySvc.Available() { + if s == nil || s.notifySvc() == nil || !s.notifySvc().Available() { + return + } + stopCh, ok := s.reminderLoop.start() + if !ok { return } - s.reminderStop = make(chan struct{}) go func() { ticker := time.NewTicker(defaultReminderPollInterval) defer ticker.Stop() s.runStageAdvanceReminders(context.Background()) for { select { - case <-s.reminderStop: + case <-stopCh: return case <-ticker.C: s.runStageAdvanceReminders(context.Background()) @@ -29,10 +32,10 @@ func (s *Server) startNotificationReminderLoop() { } func (s *Server) runStageAdvanceReminders(ctx context.Context) { - if s.notifySvc == nil || !s.notifySvc.Available() { + if s.notifySvc() == nil || !s.notifySvc().Available() { return } - n, err := s.notifySvc.ProcessStageAdvanceReminders(ctx) + n, err := s.notifySvc().ProcessStageAdvanceReminders(ctx) if err != nil { slog.Warn("stage advance reminder scan failed", "error", err) return diff --git a/internal/server/principal_context.go b/internal/server/principal_context.go index e4424a17..4d91860f 100644 --- a/internal/server/principal_context.go +++ b/internal/server/principal_context.go @@ -52,7 +52,7 @@ func (s *Server) requireSystemAdmin(w http.ResponseWriter, r *http.Request) (*au if !ok { return nil, false } - admin, err := s.authzSvc.IsSystemAdmin(r.Context(), user.ID, user.RoleID) + admin, err := s.authzSvc().IsSystemAdmin(r.Context(), user.ID, user.RoleID) if err != nil { writeJSONResponse(w, http.StatusInternalServerError, map[string]string{ "error_code": "ERR_AUTHZ", @@ -77,7 +77,7 @@ func (s *Server) requireSystemPermission(w http.ResponseWriter, r *http.Request, if !ok { return nil, false } - if s.authzSvc == nil { + if s.authzSvc() == nil { if s.failClosedAuthz() { writeJSONResponse(w, http.StatusForbidden, map[string]string{ "error_code": "ERR_FORBIDDEN", @@ -87,7 +87,7 @@ func (s *Server) requireSystemPermission(w http.ResponseWriter, r *http.Request, } return user, true } - allowed, err := s.authzSvc.HasSystemPermission(r.Context(), user.ID, user.RoleID, perm) + allowed, err := s.authzSvc().HasSystemPermission(r.Context(), user.ID, user.RoleID, perm) if err != nil { writeJSONResponse(w, http.StatusInternalServerError, map[string]string{ "error_code": "ERR_AUTHZ", diff --git a/internal/server/project_doc_handlers.go b/internal/server/project_doc_handlers.go index 264cead4..8825c40b 100644 --- a/internal/server/project_doc_handlers.go +++ b/internal/server/project_doc_handlers.go @@ -19,11 +19,11 @@ import ( ) func (s *Server) docflowQuery() *docflow.Query { - if s == nil || s.svc == nil { + if s == nil || s.svc() == nil { return nil } return &docflow.Query{Docs: &docquery.Service{ - Gov: s.gov, + Gov: s.gov(), Rev: s.docRev(), RepoRoot: s.repoRoot(), Authorize: func(ctx context.Context, p docquery.Principal, rec *docgov.DocumentRecord) error { @@ -41,12 +41,12 @@ func (s *Server) authorizeControlledRead(ctx context.Context, p docquery.Princip return docquery.ErrClosed } admin := false - if s.authzSvc != nil { - ok, _ := s.authzSvc.IsSystemAdmin(ctx, p.UserID, "") + if s.authzSvc() != nil { + ok, _ := s.authzSvc().IsSystemAdmin(ctx, p.UserID, "") admin = ok } - if s.authorizer != nil { - if err := s.authorizer.RequireProjectAccessOrAdmin(ctx, rec.ProjectID, p.UserID, admin); err != nil { + if s.authorizer() != nil { + if err := s.authorizer().RequireProjectAccessOrAdmin(ctx, rec.ProjectID, p.UserID, admin); err != nil { return docquery.ErrClosed } } @@ -82,17 +82,17 @@ func (s *Server) handleListProjectWorkflowDocuments(w http.ResponseWriter, r *ht return } - if s.gov == nil { + if s.gov() == nil { writeSafeError(w, r, http.StatusServiceUnavailable, "docgov engine not initialized") return } - if s.instSvc != nil { - if err := s.instSvc.MaterializeLiveDocs(r.Context(), projectID); err != nil { + if s.instSvc() != nil { + if err := s.instSvc().MaterializeLiveDocs(r.Context(), projectID); err != nil { slog.Warn("materialize live docs", "project_id", projectID, "err", err) } } - docs, err := s.gov.ListWorkflowDocumentsByProject(r.Context(), projectID) + docs, err := s.gov().ListWorkflowDocumentsByProject(r.Context(), projectID) if err != nil { writeSafeError(w, r, http.StatusInternalServerError, "list project documents failed: "+err.Error()) return @@ -138,8 +138,8 @@ func (s *Server) handleDocGovGetDocumentContent(w http.ResponseWriter, r *http.R }) return } - if s.cfg != nil && s.cfg.FAVDegradedRead && s.gov != nil { - docRec, docErr := s.gov.GetDocument(r.Context(), docID) + if s.cfg != nil && s.cfg.FAVDegradedRead && s.gov() != nil { + docRec, docErr := s.gov().GetDocument(r.Context(), docID) if docErr == nil && docRec != nil { env := s.cfg.Env if env == "" { @@ -170,10 +170,10 @@ func (s *Server) handleDocGovGetDocumentContent(w http.ResponseWriter, r *http.R } func (s *Server) loadControlledDocumentContent(ctx context.Context, docID string) (*docgov.DocumentContent, error) { - if s == nil || s.gov == nil { + if s == nil || s.gov() == nil { return nil, vocabulary.ErrDocumentNotFound } - svc := &docquery.Service{Gov: s.gov, Rev: s.docRev(), RepoRoot: s.repoRoot()} + svc := &docquery.Service{Gov: s.gov(), Rev: s.docRev(), RepoRoot: s.repoRoot()} view, err := svc.Get(ctx, docquery.Principal{UserID: 1}, docquery.DocRef{DocID: docID}) if err != nil && !errors.Is(err, docquery.ErrLedgerMissing) { return nil, err @@ -181,7 +181,7 @@ func (s *Server) loadControlledDocumentContent(ctx context.Context, docID string if view != nil && (view.Markdown != "" || view.IsTemplateOnly) { return view.ToDocumentContent(), nil } - return s.gov.ReadDocumentContent(ctx, docID, s.repoRoot()) + return s.gov().ReadDocumentContent(ctx, docID, s.repoRoot()) } // serveLedgerHeadOrConflict keeps the historical helper name. Projection is a diff --git a/internal/server/project_handlers.go b/internal/server/project_handlers.go index 28feed0d..167c4573 100644 --- a/internal/server/project_handlers.go +++ b/internal/server/project_handlers.go @@ -37,7 +37,7 @@ func (s *Server) handleCreateProject(w http.ResponseWriter, r *http.Request) { return } - proj, err := s.pwSvc.CreateProject(r.Context(), user, req) + proj, err := s.pwSvc().CreateProject(r.Context(), user, req) if err != nil { if errors.Is(err, projectworkflow.ErrInvalidInput) { writeSafeError(w, r, http.StatusBadRequest, `{"error":"ERR_INVALID_PARAM","message":"`+err.Error()+`"}`) @@ -58,8 +58,8 @@ func (s *Server) handleCreateProject(w http.ResponseWriter, r *http.Request) { return } - if proj != nil && s.authorizer != nil { - _ = s.authorizer.AddOwnerOnCreate(r.Context(), proj.ID, user.ID) + if proj != nil && s.authorizer() != nil { + _ = s.authorizer().AddOwnerOnCreate(r.Context(), proj.ID, user.ID) } writeJSONResponse(w, http.StatusCreated, proj) @@ -77,7 +77,7 @@ func (s *Server) handleListProjects(w http.ResponseWriter, r *http.Request) { return } - isAdmin, err := s.authzSvc.IsSystemAdmin(r.Context(), user.ID, user.RoleID) + isAdmin, err := s.authzSvc().IsSystemAdmin(r.Context(), user.ID, user.RoleID) if err != nil { writeJSONResponse(w, http.StatusInternalServerError, map[string]string{ "error_code": "ERR_AUTHZ", @@ -88,9 +88,9 @@ func (s *Server) handleListProjects(w http.ResponseWriter, r *http.Request) { var list []*projectworkflow.Project if isAdmin { - list, err = s.pwSvc.ListAllProjects(r.Context()) + list, err = s.pwSvc().ListAllProjects(r.Context()) } else { - list, err = s.pwSvc.ListProjects(r.Context(), user.ID) + list, err = s.pwSvc().ListProjects(r.Context(), user.ID) } if err != nil { writeSafeError(w, r, http.StatusInternalServerError, `{"error":"ERR_INTERNAL","message":"`+err.Error()+`"}`) @@ -116,7 +116,7 @@ func (s *Server) handleGetProjectByID(w http.ResponseWriter, r *http.Request) { return } - proj, err := s.pwSvc.GetProject(r.Context(), id, 0) + proj, err := s.pwSvc().GetProject(r.Context(), id, 0) if err != nil { if errors.Is(err, projectworkflow.ErrProjectNotFound) { writeSafeError(w, r, http.StatusNotFound, `{"error":"ERR_PROJECT_NOT_FOUND","message":"项目不存在或无权限访问"}`) @@ -141,7 +141,7 @@ func (s *Server) handleListProjectWorkflows(w http.ResponseWriter, r *http.Reque return } - wfs, err := s.pwSvc.ListProjectWorkflows(r.Context(), id) + wfs, err := s.pwSvc().ListProjectWorkflows(r.Context(), id) if err != nil { writeSafeError(w, r, http.StatusInternalServerError, `{"error":"ERR_INTERNAL","message":"`+err.Error()+`"}`) return @@ -168,7 +168,7 @@ func (s *Server) handleActivateWorkflow(w http.ResponseWriter, r *http.Request) return } - if s.instSvc != nil && s.instSvc.Available() { + if s.instSvc() != nil && s.instSvc().Available() { writeSafeError(w, r, http.StatusGone, `{"error":"WORKFLOW_SWITCH_API_RETIRED","message":"请通过项目设置完成工作流规划与激活"}`) return } @@ -183,7 +183,7 @@ func (s *Server) handleActivateWorkflow(w http.ResponseWriter, r *http.Request) return } - res, err := s.pwSvc.ActivateWorkflow(r.Context(), id, user.ID, req) + res, err := s.pwSvc().ActivateWorkflow(r.Context(), id, user.ID, req) if err != nil { if errors.Is(err, projectworkflow.ErrForbiddenNotCreator) { writeSafeError(w, r, http.StatusForbidden, `{"error":"ERR_NOT_PROJECT_CREATOR","message":"仅项目创建人有权切换激活工作流"}`) @@ -210,7 +210,7 @@ func (s *Server) handleActivateWorkflow(w http.ResponseWriter, r *http.Request) // handleListWorkflowDefinitions 获取全局工作流规范目录 func (s *Server) handleListWorkflowDefinitions(w http.ResponseWriter, r *http.Request) { - defs := s.pwSvc.Catalog().List() + defs := s.pwSvc().Catalog().List() writeJSONResponse(w, http.StatusOK, defs) } @@ -227,7 +227,7 @@ func (s *Server) handleRunProjectWorkflow(w http.ResponseWriter, r *http.Request writeSafeError(w, r, http.StatusBadRequest, `{"error":"ERR_INVALID_PARAM","message":"workflow_key is required"}`) return } - def, ok := s.pwSvc.Catalog().Get(wfKey) + def, ok := s.pwSvc().Catalog().Get(wfKey) if !ok { writeSafeError(w, r, http.StatusNotFound, `{"error":"ERR_WORKFLOW_NOT_FOUND","message":"unknown workflow"}`) return @@ -272,7 +272,7 @@ func (s *Server) handleListProjectWorkflowRuns(w http.ResponseWriter, r *http.Re return } wfKey := r.PathValue("workflow_key") - runs, err := s.pwSvc.ListWorkflowRuns(r.Context(), id, wfKey, 20) + runs, err := s.pwSvc().ListWorkflowRuns(r.Context(), id, wfKey, 20) if err != nil { writeSafeError(w, r, http.StatusInternalServerError, `{"error":"ERR_INTERNAL","message":"`+err.Error()+`"}`) return @@ -305,14 +305,14 @@ func (s *Server) handleGetProjectLineage(w http.ResponseWriter, r *http.Request) writeEmptyLineage(w) return } - lineages, err := s.pwSvc.GetLineageForProjectDownstream(r.Context(), projectID, docID, 0) + lineages, err := s.pwSvc().GetLineageForProjectDownstream(r.Context(), projectID, docID, 0) if err != nil { writeSafeError(w, r, http.StatusInternalServerError, `{"error":"ERR_INTERNAL","message":"`+err.Error()+`"}`) return } if len(lineages) == 0 { if alt, aliasOK := lineageTestManagerAlias(projectID, docID); aliasOK && s.lineageDocVisible(r.Context(), projectID, user.ID, alt) { - if fb, fbErr := s.pwSvc.GetLineageForProjectDownstream(r.Context(), projectID, alt, 0); fbErr == nil && len(fb) > 0 { + if fb, fbErr := s.pwSvc().GetLineageForProjectDownstream(r.Context(), projectID, alt, 0); fbErr == nil && len(fb) > 0 { lineages = fb } } diff --git a/internal/server/project_handlers_test.go b/internal/server/project_handlers_test.go index b66fd421..6678f68e 100644 --- a/internal/server/project_handlers_test.go +++ b/internal/server/project_handlers_test.go @@ -22,7 +22,7 @@ func TestHandleListProjects_AdminCanSeeProjectsOwnedByOtherUsers(t *testing.T) { if err != nil { t.Fatalf("load creator: %v", err) } - created, err := srv.pwSvc.CreateProject(ctx, creator, projectworkflow.CreateProjectInput{ + created, err := srv.pwSvc().CreateProject(ctx, creator, projectworkflow.CreateProjectInput{ Name: "非管理员创建的项目", }) if err != nil { diff --git a/internal/server/project_members_handlers.go b/internal/server/project_members_handlers.go index 4efb6cea..d100ca3c 100644 --- a/internal/server/project_members_handlers.go +++ b/internal/server/project_members_handlers.go @@ -30,7 +30,7 @@ func writeDutySyncError(w http.ResponseWriter) { } func (s *Server) healProjectDuties(ctx context.Context, projectID int64) error { - if s == nil || s.svc == nil || s.rt == nil || projectID <= 0 { + if s == nil || s.svc() == nil || s.rt == nil || projectID <= 0 { return nil } return s.rt.HealProjectDuties(ctx, projectID) @@ -73,7 +73,7 @@ func (s *Server) handleListProjectMembers(w http.ResponseWriter, r *http.Request if _, ok := s.requireProjectAccess(w, r, projectID); !ok { return } - members, err := s.authorizer.ListProjectMembers(r.Context(), projectID) + members, err := s.authorizer().ListProjectMembers(r.Context(), projectID) if err != nil { writeSafeError(w, r, http.StatusInternalServerError, err.Error()) return @@ -85,7 +85,7 @@ func (s *Server) handleListProjectMembers(w http.ResponseWriter, r *http.Request } func (s *Server) requireProjectMemberManage(w http.ResponseWriter, r *http.Request, user *auth.UserAccount, projectID int64) bool { - can, err := s.authzSvc.CanManageProjectMembers(r.Context(), projectID, user.ID, user.RoleID) + can, err := s.authzSvc().CanManageProjectMembers(r.Context(), projectID, user.ID, user.RoleID) if err != nil { writeSafeError(w, r, http.StatusInternalServerError, err.Error()) return false @@ -109,7 +109,7 @@ func (s *Server) handleAddProjectMember(w http.ResponseWriter, r *http.Request, if req.ProjectRole == "" { req.ProjectRole = authz.RoleMember } - if err := s.authorizer.UpsertProjectMemberSpec(r.Context(), projectID, req.UserID, authz.MemberUpsert{ + if err := s.authorizer().UpsertProjectMemberSpec(r.Context(), projectID, req.UserID, authz.MemberUpsert{ ProjectRole: req.ProjectRole, DocumentDuty: req.DocumentDuty, }); err != nil { @@ -126,17 +126,17 @@ func (s *Server) handleAddProjectMember(w http.ResponseWriter, r *http.Request, // 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 { + 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 { + 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 { + 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) } } @@ -151,8 +151,8 @@ func (s *Server) handleUpdateProjectMember(w http.ResponseWriter, r *http.Reques return } var existing *authz.ProjectMemberRecord - if s.authorizer != nil { - rec, err := s.authorizer.GetProjectMember(r.Context(), projectID, req.UserID) + if s.authorizer() != nil { + rec, err := s.authorizer().GetProjectMember(r.Context(), projectID, req.UserID) if err != nil { writeSafeError(w, r, http.StatusInternalServerError, err.Error()) return @@ -169,7 +169,7 @@ func (s *Server) handleUpdateProjectMember(w http.ResponseWriter, r *http.Reques writeSafeError(w, r, http.StatusBadRequest, `{"error":"ERR_INVALID_PARAM","message":"project_role required when member does not exist"}`) return } - if err := s.authorizer.UpsertProjectMemberSpec(r.Context(), projectID, req.UserID, authz.MemberUpsert{ + if err := s.authorizer().UpsertProjectMemberSpec(r.Context(), projectID, req.UserID, authz.MemberUpsert{ ProjectRole: role, DocumentDuty: req.DocumentDuty, }); err != nil { @@ -179,7 +179,7 @@ func (s *Server) handleUpdateProjectMember(w http.ResponseWriter, r *http.Reques wrote = true } if req.Status != "" { - if err := s.authorizer.SetProjectMemberStatus(r.Context(), projectID, req.UserID, req.Status); err != nil { + if err := s.authorizer().SetProjectMemberStatus(r.Context(), projectID, req.UserID, req.Status); err != nil { writeSafeError(w, r, http.StatusBadRequest, err.Error()) return } @@ -204,7 +204,7 @@ func (s *Server) handleRemoveProjectMember(w http.ResponseWriter, r *http.Reques writeSafeError(w, r, http.StatusBadRequest, `{"error":"ERR_INVALID_PARAM","message":"user_id query required"}`) return } - if err := s.authorizer.SetProjectMemberStatus(r.Context(), projectID, targetID, authz.StatusExited); err != nil { + if err := s.authorizer().SetProjectMemberStatus(r.Context(), projectID, targetID, authz.StatusExited); err != nil { writeSafeError(w, r, http.StatusInternalServerError, err.Error()) return } diff --git a/internal/server/project_settings_handlers.go b/internal/server/project_settings_handlers.go index 4628f7b1..c25fda73 100644 --- a/internal/server/project_settings_handlers.go +++ b/internal/server/project_settings_handlers.go @@ -19,13 +19,13 @@ func (s *Server) handleGetDefaultWorkflowPlan(w http.ResponseWriter, r *http.Req if _, ok := s.requireProjectAccess(w, r, projectID); !ok { return } - if s.instSvc == nil || !s.instSvc.Available() { + if s.instSvc() == nil || !s.instSvc().Available() { writeSafeError(w, r, http.StatusServiceUnavailable, `{"error":"ERR_NOT_AVAILABLE","message":"项目实例服务不可用"}`) return } workflowKey := r.URL.Query().Get("workflow_key") version, _ := strconv.Atoi(r.URL.Query().Get("version")) - plan, err := s.instSvc.BuildDefaultPlan(r.Context(), workflowKey, version) + plan, err := s.instSvc().BuildDefaultPlan(r.Context(), workflowKey, version) if err != nil { writeProjectInstanceError(w, err) return @@ -43,12 +43,12 @@ func (s *Server) handleGetProjectSettings(w http.ResponseWriter, r *http.Request if !ok { return } - if s.instSvc == nil || !s.instSvc.Available() { + if s.instSvc() == nil || !s.instSvc().Available() { writeSafeError(w, r, http.StatusServiceUnavailable, `{"error":"ERR_NOT_AVAILABLE","message":"项目实例服务不可用,请先执行数据库迁移"}`) return } - isCreator, _ := s.pwSvc.IsProjectCreator(r.Context(), projectID, user.ID) - settings, err := s.instSvc.GetSettings(r.Context(), projectID, user.ID, isCreator) + isCreator, _ := s.pwSvc().IsProjectCreator(r.Context(), projectID, user.ID) + settings, err := s.instSvc().GetSettings(r.Context(), projectID, user.ID, isCreator) if err != nil { writeProjectInstanceError(w, err) return @@ -58,8 +58,8 @@ func (s *Server) handleGetProjectSettings(w http.ResponseWriter, r *http.Request RecentWorkflowEvents []workflowEventSummary `json:"recent_workflow_events,omitempty"` } out := settingsResponse{ProjectSettings: settings} - if s.notifySvc != nil && s.notifySvc.Available() { - items, listErr := s.notifySvc.ListProjectNotifications(r.Context(), user.ID, projectID, 0, 20) + if s.notifySvc() != nil && s.notifySvc().Available() { + items, listErr := s.notifySvc().ListProjectNotifications(r.Context(), user.ID, projectID, 0, 20) if listErr == nil { for _, n := range items { out.RecentWorkflowEvents = append(out.RecentWorkflowEvents, workflowEventSummary{ @@ -88,11 +88,11 @@ func (s *Server) handleSaveWorkflowPlan(w http.ResponseWriter, r *http.Request) if !ok { return } - if s.instSvc == nil || !s.instSvc.Available() { + if s.instSvc() == nil || !s.instSvc().Available() { writeSafeError(w, r, http.StatusServiceUnavailable, `{"error":"ERR_NOT_AVAILABLE","message":"项目实例服务不可用"}`) return } - isCreator, _ := s.pwSvc.IsProjectCreator(r.Context(), projectID, user.ID) + isCreator, _ := s.pwSvc().IsProjectCreator(r.Context(), projectID, user.ID) var req projectinstance.SavePlanInput if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeSafeError(w, r, http.StatusBadRequest, `{"error":"ERR_INVALID_PARAM","message":"无效的 JSON 请求体"}`) @@ -100,9 +100,9 @@ func (s *Server) handleSaveWorkflowPlan(w http.ResponseWriter, r *http.Request) } var settings *projectinstance.ProjectSettings if req.LiveEdit { - settings, err = s.instSvc.UpdateLivePlan(r.Context(), projectID, user.ID, isCreator, req) + settings, err = s.instSvc().UpdateLivePlan(r.Context(), projectID, user.ID, isCreator, req) } else { - settings, err = s.instSvc.SaveWorkflowPlan(r.Context(), projectID, user.ID, isCreator, req) + settings, err = s.instSvc().SaveWorkflowPlan(r.Context(), projectID, user.ID, isCreator, req) } if err != nil { writeProjectInstanceError(w, err) @@ -121,12 +121,12 @@ func (s *Server) handleActivationPreview(w http.ResponseWriter, r *http.Request) if !ok { return } - if s.instSvc == nil || !s.instSvc.Available() { + if s.instSvc() == nil || !s.instSvc().Available() { writeSafeError(w, r, http.StatusServiceUnavailable, `{"error":"ERR_NOT_AVAILABLE","message":"项目实例服务不可用"}`) return } - isCreator, _ := s.pwSvc.IsProjectCreator(r.Context(), projectID, user.ID) - preview, err := s.instSvc.ActivationPreview(r.Context(), projectID, user.ID, isCreator) + isCreator, _ := s.pwSvc().IsProjectCreator(r.Context(), projectID, user.ID) + preview, err := s.instSvc().ActivationPreview(r.Context(), projectID, user.ID, isCreator) if err != nil { writeProjectInstanceError(w, err) return @@ -144,17 +144,17 @@ func (s *Server) handleActivateWorkflowInstance(w http.ResponseWriter, r *http.R if !ok { return } - if s.instSvc == nil || !s.instSvc.Available() { + if s.instSvc() == nil || !s.instSvc().Available() { writeSafeError(w, r, http.StatusServiceUnavailable, `{"error":"ERR_NOT_AVAILABLE","message":"项目实例服务不可用"}`) return } - isCreator, _ := s.pwSvc.IsProjectCreator(r.Context(), projectID, user.ID) + isCreator, _ := s.pwSvc().IsProjectCreator(r.Context(), projectID, user.ID) var req projectinstance.ActivateInput if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeSafeError(w, r, http.StatusBadRequest, `{"error":"ERR_INVALID_PARAM","message":"无效的 JSON 请求体"}`) return } - result, err := s.instSvc.Activate(r.Context(), projectID, user.ID, isCreator, req) + result, err := s.instSvc().Activate(r.Context(), projectID, user.ID, isCreator, req) if err != nil { writeProjectInstanceError(w, err) return @@ -237,15 +237,15 @@ func writeProjectInstanceError(w http.ResponseWriter, err error) { } func (s *Server) blockLegacyWorkflowSwitch(w http.ResponseWriter, r *http.Request, projectID int64) bool { - proj, err := s.pwSvc.GetProject(r.Context(), projectID, 0) + proj, err := s.pwSvc().GetProject(r.Context(), projectID, 0) if err == nil && proj != nil && proj.LifecycleStatus == "planning" && proj.ActiveWorkflowKey == "" { writeSafeError(w, r, http.StatusConflict, `{"error":"LEGACY_SWITCH_BLOCKED","message":"新项目请通过项目设置完成工作流激活"}`) return true } - if s.instSvc == nil || !s.instSvc.Available() { + if s.instSvc() == nil || !s.instSvc().Available() { return false } - live, err := s.instSvc.HasLiveInstance(r.Context(), projectID) + live, err := s.instSvc().HasLiveInstance(r.Context(), projectID) if err != nil { writeSafeError(w, r, http.StatusInternalServerError, `{"error":"ERR_INTERNAL","message":"`+err.Error()+`"}`) return true diff --git a/internal/server/reconcile_handlers.go b/internal/server/reconcile_handlers.go index 8179659c..431f86c0 100644 --- a/internal/server/reconcile_handlers.go +++ b/internal/server/reconcile_handlers.go @@ -24,7 +24,7 @@ func (s *Server) handleReconcileWorkspace(w http.ResponseWriter, r *http.Request return } - rec, err := s.taskRuns.GetRun(body.RunID) + rec, err := s.taskRuns().GetRun(body.RunID) if err != nil { writeSafeError(w, r, http.StatusNotFound, "run not found") return @@ -34,7 +34,7 @@ func (s *Server) handleReconcileWorkspace(w http.ResponseWriter, r *http.Request return } - workDir, err := s.resolver.ResolveStoredWorkDir(rec.WorkspaceID) + workDir, err := s.resolver().ResolveStoredWorkDir(rec.WorkspaceID) if err != nil { writeSafeError(w, r, http.StatusForbidden, "invalid workspace path") return diff --git a/internal/server/reply_export_api.go b/internal/server/reply_export_api.go index b127d2d4..a20ae40f 100644 --- a/internal/server/reply_export_api.go +++ b/internal/server/reply_export_api.go @@ -19,7 +19,7 @@ func (s *Server) handleGetExportCapability(w http.ResponseWriter, r *http.Reques return } - if s.ctxMgr == nil { + if s.ctxMgr() == nil { writeSafeError(w, r, http.StatusServiceUnavailable, "context manager not initialized") return } @@ -33,7 +33,7 @@ func (s *Server) handleGetExportCapability(w http.ResponseWriter, r *http.Reques return } - msg, err := s.ctxMgr.GetMessageByID(r.Context(), msgID) + msg, err := s.ctxMgr().GetMessageByID(r.Context(), msgID) if err != nil || msg == nil { writeJSONResponse(w, http.StatusNotFound, map[string]string{ "error_code": "MESSAGE_NOT_FOUND", @@ -86,7 +86,7 @@ func (s *Server) handleGetExportSource(w http.ResponseWriter, r *http.Request) { return } - if s.ctxMgr == nil { + if s.ctxMgr() == nil { writeSafeError(w, r, http.StatusServiceUnavailable, "context manager not initialized") return } @@ -100,7 +100,7 @@ func (s *Server) handleGetExportSource(w http.ResponseWriter, r *http.Request) { return } - msg, err := s.ctxMgr.GetMessageByID(r.Context(), msgID) + msg, err := s.ctxMgr().GetMessageByID(r.Context(), msgID) if err != nil || msg == nil { writeJSONResponse(w, http.StatusNotFound, map[string]string{ "error_code": "MESSAGE_NOT_FOUND", @@ -187,7 +187,7 @@ func (s *Server) handlePinExport(w http.ResponseWriter, r *http.Request) { if !ok { return } - if s.ctxMgr == nil || s.vaultIngest == nil { + if s.ctxMgr() == nil || s.vaultIngest == nil { http.Error(w, "artifact vault not initialized", http.StatusServiceUnavailable) return } @@ -199,7 +199,7 @@ func (s *Server) handlePinExport(w http.ResponseWriter, r *http.Request) { }) return } - msg, err := s.ctxMgr.GetMessageByID(r.Context(), msgID) + msg, err := s.ctxMgr().GetMessageByID(r.Context(), msgID) if err != nil || msg == nil { writeJSONResponse(w, http.StatusNotFound, map[string]string{ "error_code": "MESSAGE_NOT_FOUND", @@ -217,7 +217,7 @@ func (s *Server) handlePinExport(w http.ResponseWriter, r *http.Request) { }) return } - turn, err := s.ctxMgr.GetChatTurnByAssistantMessageID(r.Context(), msg.SessionID, msg.ID) + turn, err := s.ctxMgr().GetChatTurnByAssistantMessageID(r.Context(), msg.SessionID, msg.ID) if err != nil || turn == nil || turn.Status != ctxmgr.TurnStatusCompleted || turn.CompletedAt == nil { writeJSONResponse(w, http.StatusConflict, map[string]string{ "error_code": "TURN_NOT_COMPLETED", diff --git a/internal/server/runtime_accessors.go b/internal/server/runtime_accessors.go new file mode 100644 index 00000000..50876a38 --- /dev/null +++ b/internal/server/runtime_accessors.go @@ -0,0 +1,434 @@ +package server + +import ( + "chat-agent-system/internal/app/intentflow" + "chat-agent-system/internal/app/stageflow" + "chat-agent-system/internal/auth" + "chat-agent-system/internal/authz" + "chat-agent-system/internal/bizdev" + "chat-agent-system/internal/coordinator" + "chat-agent-system/internal/ctxmgr" + "chat-agent-system/internal/docgov" + "chat-agent-system/internal/docrevision" + "chat-agent-system/internal/freedoc" + "chat-agent-system/internal/intent" + "chat-agent-system/internal/kb" + "chat-agent-system/internal/maintenance" + "chat-agent-system/internal/notification" + "chat-agent-system/internal/projectinstance" + "chat-agent-system/internal/projectworkflow" + "chat-agent-system/internal/storage" + "chat-agent-system/internal/sysadmin" + "chat-agent-system/internal/taskrun" + "chat-agent-system/internal/workflowdef" + "chat-agent-system/internal/workspace" +) + +// 本文件集中承接 Server 对 app.Runtime 领域服务的访问(module-batches-ag 镜像字段收口)。 +// Server 不再镜像 Runtime 字段;全部经访问器取用,nil 安全。 +// testOverrides 仅供测试注入替换件;set 字段后(含显式置 nil 经 *X 指针形)覆盖访问器结果, +// 生产路径恒为空。显式置 nil 用 ptr 形(authorizerPtr 等)以区分「未设置」与「设为 nil」。 + +type serverTestOverrides struct { + svc *bizdev.Service + pwSvc *projectworkflow.Service + engine *coordinator.CoordinatorEngine + authSvc *auth.Service + taskRuns *taskrun.Manager + authorizer *authz.Authorizer + authzSvc *authz.Service + wfDefSvc *workflowdef.Service + instSvc *projectinstance.Service + notifySvc *notification.Service + resolver *workspace.Resolver + wsCap *workspace.Capability + cmds *taskrun.Commands + stageflow *stageflow.UseCase + gov *docgov.GovernanceEngine + rev *docrevision.Service + ctxMgr *ctxmgr.ContextManager + cas *storage.CAS + freeDoc *freedoc.Service + kb *kb.Client + sysadmin *sysadmin.Service + maintenance *maintenance.Service + + // 显式置 nil 的位标记(区分「未设置」与「设为 nil」,fail-closed 测试用)。 + authorizerNil bool + instSvcNil bool +} + +func (s *Server) overrides() *serverTestOverrides { + if s == nil { + return nil + } + return s.testOverrides +} + +func (s *Server) svc() *bizdev.Service { + if o := s.overrides(); o != nil && o.svc != nil { + return o.svc + } + if s == nil || s.rt == nil { + return nil + } + return s.rt.Biz +} + +func (s *Server) pwSvc() *projectworkflow.Service { + if o := s.overrides(); o != nil && o.pwSvc != nil { + return o.pwSvc + } + if s == nil || s.rt == nil { + return nil + } + return s.rt.PW +} + +func (s *Server) rec() intent.Recognizer { + if s == nil || s.rt == nil { + return nil + } + return s.rt.Rec +} + +func (s *Server) engine() *coordinator.CoordinatorEngine { + if o := s.overrides(); o != nil && o.engine != nil { + return o.engine + } + if s == nil || s.rt == nil { + return nil + } + return s.rt.Engine +} + +func (s *Server) authSvc() *auth.Service { + if o := s.overrides(); o != nil && o.authSvc != nil { + return o.authSvc + } + if s == nil || s.rt == nil { + return nil + } + return s.rt.Auth +} + +func (s *Server) taskRuns() *taskrun.Manager { + if o := s.overrides(); o != nil && o.taskRuns != nil { + return o.taskRuns + } + if s == nil || s.rt == nil { + return nil + } + return s.rt.TaskRuns +} + +func (s *Server) authorizer() *authz.Authorizer { + if o := s.overrides(); o != nil { + if o.authorizerNil { + return nil + } + if o.authorizer != nil { + return o.authorizer + } + } + if s == nil || s.rt == nil { + return nil + } + return s.rt.Authorizer +} + +func (s *Server) authzSvc() *authz.Service { + if o := s.overrides(); o != nil && o.authzSvc != nil { + return o.authzSvc + } + if s == nil || s.rt == nil { + return nil + } + return s.rt.Authz +} + +func (s *Server) wfDefSvc() *workflowdef.Service { + if o := s.overrides(); o != nil && o.wfDefSvc != nil { + return o.wfDefSvc + } + if s == nil || s.rt == nil { + return nil + } + return s.rt.WFDef +} + +func (s *Server) instSvc() *projectinstance.Service { + if o := s.overrides(); o != nil { + if o.instSvcNil { + return nil + } + if o.instSvc != nil { + return o.instSvc + } + } + if s == nil || s.rt == nil { + return nil + } + return s.rt.Inst +} + +func (s *Server) notifySvc() *notification.Service { + if o := s.overrides(); o != nil && o.notifySvc != nil { + return o.notifySvc + } + if s == nil || s.rt == nil { + return nil + } + return s.rt.Notify +} + +func (s *Server) resolver() *workspace.Resolver { + if o := s.overrides(); o != nil && o.resolver != nil { + return o.resolver + } + if s == nil || s.rt == nil { + return nil + } + return s.rt.Resolver +} + +func (s *Server) wsCap() *workspace.Capability { + if o := s.overrides(); o != nil && o.wsCap != nil { + return o.wsCap + } + if s == nil || s.rt == nil { + return nil + } + return s.rt.WSCap +} + +func (s *Server) cmds() *taskrun.Commands { + if o := s.overrides(); o != nil && o.cmds != nil { + return o.cmds + } + if s == nil || s.rt == nil { + return nil + } + return s.rt.Commands +} + +func (s *Server) stageflow() *stageflow.UseCase { + if o := s.overrides(); o != nil && o.stageflow != nil { + return o.stageflow + } + if s == nil || s.rt == nil { + return nil + } + return s.rt.Stageflow +} + +func (s *Server) gov() *docgov.GovernanceEngine { + if o := s.overrides(); o != nil && o.gov != nil { + return o.gov + } + if s == nil || s.rt == nil { + return nil + } + return s.rt.Gov +} + +func (s *Server) rev() *docrevision.Service { + if o := s.overrides(); o != nil && o.rev != nil { + return o.rev + } + if s == nil || s.rt == nil { + return nil + } + return s.rt.Rev +} + +func (s *Server) ctxMgr() *ctxmgr.ContextManager { + if o := s.overrides(); o != nil && o.ctxMgr != nil { + return o.ctxMgr + } + if s == nil || s.rt == nil { + return nil + } + return s.rt.Ctx +} + +func (s *Server) cas() *storage.CAS { + if o := s.overrides(); o != nil && o.cas != nil { + return o.cas + } + if s == nil || s.rt == nil { + return nil + } + return s.rt.CAS +} + +func (s *Server) freeDoc() *freedoc.Service { + if o := s.overrides(); o != nil && o.freeDoc != nil { + return o.freeDoc + } + if s == nil || s.rt == nil { + return nil + } + return s.rt.FreeDoc +} + +func (s *Server) kb() *kb.Client { + if o := s.overrides(); o != nil && o.kb != nil { + return o.kb + } + if s == nil || s.rt == nil { + return nil + } + return s.rt.KB +} + +func (s *Server) sysadmin() *sysadmin.Service { + if o := s.overrides(); o != nil && o.sysadmin != nil { + return o.sysadmin + } + if s == nil || s.rt == nil { + return nil + } + return s.rt.SysAdmin +} + +func (s *Server) maintenance() *maintenance.Service { + if o := s.overrides(); o != nil && o.maintenance != nil { + return o.maintenance + } + if s == nil || s.rt == nil { + return nil + } + return s.rt.Maintenance +} + +func (s *Server) intentPlans() intentflow.Service { + if s == nil { + return nil + } + if s.intentPlansOverride != nil { + return s.intentPlansOverride + } + if s.rt == nil { + return nil + } + return s.rt.IntentPlans +} + +// setOverride* 系列仅供测试注入替换件(serverTestOverrides);生产代码禁止调用。 + +func (s *Server) setOverrideIntentPlans(v intentflow.Service) { + if s == nil { + return + } + s.intentPlansOverride = v +} + +func (s *Server) setOverrideSvc(v *bizdev.Service) { + s.ensureOverrides().svc = v +} + +func (s *Server) setOverridePwSvc(v *projectworkflow.Service) { + s.ensureOverrides().pwSvc = v +} + +func (s *Server) setOverrideEngine(v *coordinator.CoordinatorEngine) { + s.ensureOverrides().engine = v +} + +func (s *Server) setOverrideAuthSvc(v *auth.Service) { + s.ensureOverrides().authSvc = v +} + +func (s *Server) setOverrideTaskRuns(v *taskrun.Manager) { + s.ensureOverrides().taskRuns = v +} + +func (s *Server) setOverrideAuthorizer(v *authz.Authorizer) { + o := s.ensureOverrides() + if v == nil { + o.authorizerNil = true + o.authorizer = nil + return + } + o.authorizerNil = false + o.authorizer = v +} + +func (s *Server) setOverrideAuthzSvc(v *authz.Service) { + s.ensureOverrides().authzSvc = v +} + +func (s *Server) setOverrideWfDefSvc(v *workflowdef.Service) { + s.ensureOverrides().wfDefSvc = v +} + +func (s *Server) setOverrideInstSvc(v *projectinstance.Service) { + o := s.ensureOverrides() + if v == nil { + o.instSvcNil = true + o.instSvc = nil + return + } + o.instSvcNil = false + o.instSvc = v +} + +func (s *Server) setOverrideNotifySvc(v *notification.Service) { + s.ensureOverrides().notifySvc = v +} + +func (s *Server) setOverrideResolver(v *workspace.Resolver) { + s.ensureOverrides().resolver = v +} + +func (s *Server) setOverrideWsCap(v *workspace.Capability) { + s.ensureOverrides().wsCap = v +} + +func (s *Server) setOverrideCmds(v *taskrun.Commands) { + s.ensureOverrides().cmds = v +} + +func (s *Server) setOverrideStageflow(v *stageflow.UseCase) { + s.ensureOverrides().stageflow = v +} + +func (s *Server) setOverrideGov(v *docgov.GovernanceEngine) { + s.ensureOverrides().gov = v +} + +func (s *Server) setOverrideRev(v *docrevision.Service) { + s.ensureOverrides().rev = v +} + +func (s *Server) setOverrideCtxMgr(v *ctxmgr.ContextManager) { + s.ensureOverrides().ctxMgr = v +} + +func (s *Server) setOverrideCas(v *storage.CAS) { + s.ensureOverrides().cas = v +} + +func (s *Server) setOverrideFreeDoc(v *freedoc.Service) { + s.ensureOverrides().freeDoc = v +} + +func (s *Server) setOverrideKb(v *kb.Client) { + s.ensureOverrides().kb = v +} + +func (s *Server) setOverrideSysadmin(v *sysadmin.Service) { + s.ensureOverrides().sysadmin = v +} + +func (s *Server) setOverrideMaintenance(v *maintenance.Service) { + s.ensureOverrides().maintenance = v +} + +func (s *Server) ensureOverrides() *serverTestOverrides { + if s.testOverrides == nil { + s.testOverrides = &serverTestOverrides{} + } + return s.testOverrides +} diff --git a/internal/server/server.go b/internal/server/server.go index d02a74a6..bd56a170 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -17,26 +17,16 @@ import ( "chat-agent-system/internal/app/stageflow" "chat-agent-system/internal/artifactvault" "chat-agent-system/internal/auth" - "chat-agent-system/internal/authz" "chat-agent-system/internal/bizdev" "chat-agent-system/internal/chat" "chat-agent-system/internal/config" "chat-agent-system/internal/coordinator" "chat-agent-system/internal/ctxmgr" - "chat-agent-system/internal/docgov" - "chat-agent-system/internal/docrevision" - "chat-agent-system/internal/freedoc" "chat-agent-system/internal/handoff" "chat-agent-system/internal/intent" - "chat-agent-system/internal/kb" "chat-agent-system/internal/maintenance" - "chat-agent-system/internal/notification" - "chat-agent-system/internal/projectinstance" "chat-agent-system/internal/projectworkflow" - "chat-agent-system/internal/storage" - "chat-agent-system/internal/sysadmin" "chat-agent-system/internal/taskrun" - "chat-agent-system/internal/workflowdef" "chat-agent-system/internal/workspace" ) @@ -44,43 +34,26 @@ import ( // Pool 与领域服务装配归属 app.Runtime;本结构仅保留 HTTP 适配所需句柄。 // 残留具体领域字段见 short-term 任务记录(增量收口,本批先去掉 *pgxpool.Pool)。 type Server struct { - rt *app.Runtime - cfg *config.Config - svc *bizdev.Service - pwSvc *projectworkflow.Service - rec intent.Recognizer - engine *coordinator.CoordinatorEngine - authSvc *auth.Service - sessions map[string]*chat.Session - sessionsMu sync.RWMutex - taskRuns *taskrun.Manager - authorizer *authz.Authorizer - authzSvc *authz.Service - wfDefSvc *workflowdef.Service - instSvc *projectinstance.Service - notifySvc *notification.Service - supplementImpact SupplementImpactService - resolver *workspace.Resolver - wsCap *workspace.Capability - cmds *taskrun.Commands - chatLog chatflow.ChatLog - assembler chatflow.ContextAssembler - stageflow *stageflow.UseCase - gov *docgov.GovernanceEngine - rev *docrevision.Service - ctxMgr *ctxmgr.ContextManager - cas *storage.CAS - freeDoc *freedoc.Service - kb *kb.Client - sysadmin *sysadmin.Service - maintenance *maintenance.Service - listener net.Listener - lastMaintToken string - wsReg *workspace.FileRegistry - wsGCStop chan struct{} - chatTurnSweepStop chan struct{} - httpServer *http.Server - intentPlans intentflow.Service + rt *app.Runtime + cfg *config.Config + sessions map[string]*chat.Session + sessionsMu sync.RWMutex + supplementImpact SupplementImpactService + // intentPlansOverride 仅测试注入用(SetIntentPlansForTest);为空回落 rt.IntentPlans。 + intentPlansOverride intentflow.Service + // testOverrides 仅供测试注入替换件;生产路径恒为 nil。 + testOverrides *serverTestOverrides + // 后台循环停止控制(backgroundLoop,修 Shutdown 与 loop 的 DATA RACE)。 + wsGCLoop backgroundLoop + reminderLoop backgroundLoop + vaultLoop backgroundLoop + chatTurnSweepLoop backgroundLoop + chatLog chatflow.ChatLog + assembler chatflow.ContextAssembler + listener net.Listener + lastMaintToken string + wsReg *workspace.FileRegistry + httpServer *http.Server planAccessFn func(ctx context.Context, projectID, userID int64) error planSessionOwnerFn func(ctx context.Context, sessionID string) (int64, error) @@ -90,8 +63,6 @@ type Server struct { vaultPub *artifactvault.Publisher vaultIngest *artifactvault.Ingest vaultPublisherStarted int32 - vaultStop chan struct{} - reminderStop chan struct{} loginLimiter *loginRateLimiter healthMu sync.Mutex healthCache healthSnapshot @@ -114,42 +85,17 @@ func NewFromRuntime(rt *app.Runtime) *Server { s := &Server{ rt: rt, cfg: rt.Cfg, - svc: rt.Biz, - pwSvc: rt.PW, - rec: rt.Rec, - engine: rt.Engine, - authSvc: rt.Auth, sessions: make(map[string]*chat.Session), - taskRuns: rt.TaskRuns, - authorizer: rt.Authorizer, - authzSvc: rt.Authz, - wfDefSvc: rt.WFDef, - instSvc: rt.Inst, - notifySvc: rt.Notify, - supplementImpact: newSupplementImpactAdapter(rt.SupplementImpact), - resolver: rt.Resolver, - wsCap: rt.WSCap, - cmds: rt.Commands, - stageflow: rt.Stageflow, - gov: rt.Gov, - rev: rt.Rev, - ctxMgr: rt.Ctx, - cas: rt.CAS, - freeDoc: rt.FreeDoc, - kb: rt.KB, - sysadmin: rt.SysAdmin, - maintenance: rt.Maintenance, wsReg: workspaceRegistryFor(rt.Cfg), - intentPlans: rt.IntentPlans, // 登录/注册限流:10 次 / 5 分钟 / (IP+username) 滑动窗口 loginLimiter: newLoginRateLimiter(10, 5*time.Minute), } - s.chatLog = chatflow.NewAdapter(s.ctxMgr, s.resolveStageAccess, s.failClosedAuthz()) + s.chatLog = chatflow.NewAdapter(s.ctxMgr(), s.resolveStageAccess, s.failClosedAuthz()) s.assembler = chatflow.NewAssembler(coordinator.NewContextAssembler(), s.chatLog) rt.ChatLog = s.chatLog rt.Assembler = s.assembler - if s.instSvc != nil && s.instSvc.Available() && s.svc != nil { - s.svc.SetStageAccessGate(newBizdevStageGate(s.instSvc, s.pwSvc)) + if s.instSvc() != nil && s.instSvc().Available() && s.svc() != nil { + s.svc().SetStageAccessGate(newBizdevStageGate(s.instSvc(), s.pwSvc())) } s.initTaskRunExecutors() s.initArtifactVault() @@ -161,6 +107,17 @@ func NewFromRuntime(rt *app.Runtime) *Server { return s } +// newMinimalRuntime 构造只带测试所需字段的 app.Runtime(task_finalize 系列专用)。 +func newMinimalRuntime(cfg *config.Config, svc *bizdev.Service, pwSvc *projectworkflow.Service, ctxMgr *ctxmgr.ContextManager) *app.Runtime { + return &app.Runtime{ + Cfg: cfg, + Biz: svc, + PW: pwSvc, + Ctx: ctxMgr, + TaskRuns: taskrun.NewManager(pwSvc, ctxMgr), + } +} + // NewServer 构造函数(测试兼容:内部走 app.Assemble) func NewServer(cfg *config.Config, svc *bizdev.Service) *Server { rt, err := app.Assemble(cfg, svc) @@ -172,17 +129,17 @@ func NewServer(cfg *config.Config, svc *bizdev.Service) *Server { // AuthService 获取认证服务 func (s *Server) AuthService() *auth.Service { - return s.authSvc + return s.authSvc() } // BizdevService 获取业务交付服务 func (s *Server) BizdevService() *bizdev.Service { - return s.svc + return s.svc() } // TaskRuns 获取长任务管理器(测试与运维钩子)。 func (s *Server) TaskRuns() *taskrun.Manager { - return s.taskRuns + return s.taskRuns() } // SetIntentPlansForTest swaps the intent-plan service (Wave 2 restart/refresh harness only). @@ -190,12 +147,12 @@ func (s *Server) SetIntentPlansForTest(plans intentflow.Service) { if s == nil { return } - s.intentPlans = plans + s.intentPlansOverride = plans } // ProjectWorkflowService 获取项目多工作流控制服务 func (s *Server) ProjectWorkflowService() *projectworkflow.Service { - return s.pwSvc + return s.pwSvc() } // Routes 构建 HTTP 路由分发器 @@ -411,11 +368,11 @@ func (s *Server) Start() error { // 若从维护公告恢复,标记窗口 ended 并清理 token s.finishMaintenanceResume() - if s.maintenance != nil { - s.maintenance.SetHandoff(func(ctx context.Context, w maintenance.Window) error { + if s.maintenance() != nil { + s.maintenance().SetHandoff(func(ctx context.Context, w maintenance.Window) error { return s.performMaintenanceHandoff(w) }) - s.maintenance.Start(context.Background()) + s.maintenance().Start(context.Background()) } s.httpServer = &http.Server{ @@ -431,14 +388,14 @@ func (s *Server) Start() error { } func (s *Server) finishMaintenanceResume() { - if os.Getenv("MAINT_RESUME") != "1" || s.maintenance == nil { + if os.Getenv("MAINT_RESUME") != "1" || s.maintenance() == nil { return } id, _ := strconv.ParseInt(os.Getenv("MAINT_WINDOW_ID"), 10, 64) if id > 0 { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - _, _ = s.maintenance.MarkEnded(ctx, id) + _, _ = s.maintenance().MarkEnded(ctx, id) } dataDir := config.DefaultDataDir() if s.cfg != nil && s.cfg.DataDir != "" { @@ -451,27 +408,15 @@ func (s *Server) finishMaintenanceResume() { // Shutdown 优雅停机 func (s *Server) Shutdown(ctx context.Context) error { - if s.wsGCStop != nil { - close(s.wsGCStop) - s.wsGCStop = nil + s.wsGCLoop.stop() + s.reminderLoop.stop() + if s.maintenance() != nil { + s.maintenance().Stop() } - if s.reminderStop != nil { - close(s.reminderStop) - s.reminderStop = nil - } - if s.maintenance != nil { - s.maintenance.Stop() - } - if s.vaultStop != nil { - close(s.vaultStop) - s.vaultStop = nil - } - if s.chatTurnSweepStop != nil { - close(s.chatTurnSweepStop) - s.chatTurnSweepStop = nil - } - if s.taskRuns != nil { - _ = s.taskRuns.WaitIdle(ctx) + s.vaultLoop.stop() + s.chatTurnSweepLoop.stop() + if s.taskRuns() != nil { + _ = s.taskRuns().WaitIdle(ctx) } if s.rt != nil { _ = s.rt.Stop(ctx) @@ -549,9 +494,9 @@ func (s *Server) getOrCreateSession( } // 归属校验必须先于任何缓存命中/持久层灌入 - if callerUserID > 0 && s.ctxMgr != nil { + if callerUserID > 0 && s.ctxMgr() != nil { dbCtx, cancel := context.WithTimeout(ctx, 5*time.Second) - owner, err := s.ctxMgr.GetSessionOwnerID(dbCtx, sessionID) + owner, err := s.ctxMgr().GetSessionOwnerID(dbCtx, sessionID) cancel() if err == nil && owner > 0 && owner != callerUserID { return nil, ctxmgr.ErrSessionOwnerMismatch @@ -589,9 +534,9 @@ func (s *Server) getOrCreateSession( // 尝试从持久层恢复会话上下文 (服务重启后跨进程保持 G3) var restored *ctxmgr.RestoredSessionContext - if s.ctxMgr != nil { + if s.ctxMgr() != nil { dbCtx, cancel := context.WithTimeout(ctx, 5*time.Second) - r, err := s.ctxMgr.RestoreSessionContext(dbCtx, sessionID) + r, err := s.ctxMgr().RestoreSessionContext(dbCtx, sessionID) cancel() if err == nil && r != nil && len(r.Messages) > 0 { restored = r @@ -657,8 +602,8 @@ func (s *Server) getOrCreateSession( } } - sess := chat.NewSession(sessionID, userCtx, s.rec, s.engine) - sess.CtxMgr = s.ctxMgr + sess := chat.NewSession(sessionID, userCtx, s.rec(), s.engine()) + sess.CtxMgr = s.ctxMgr() env := "dev" if s.cfg != nil && s.cfg.Env != "" { env = s.cfg.Env @@ -666,9 +611,9 @@ func (s *Server) getOrCreateSession( // Adapters live in internal/app; use server fields so SetIntentPlansForTest stays effective. sess.ChatLog = app.AdaptChatMemory(s.chatLog) sess.Actions = &chat.ActionServices{ - DocGov: s.gov, - TaskRuns: app.AdaptChatTaskStarter(s.taskRuns), - IntentPlans: app.AdaptChatIntentPlans(s.intentPlans), + DocGov: s.gov(), + TaskRuns: app.AdaptChatTaskStarter(s.taskRuns()), + IntentPlans: app.AdaptChatIntentPlans(s.intentPlans()), Environment: env, } if s.vaultIngest != nil { @@ -765,10 +710,10 @@ func (s *Server) taskUseCase() *stageflow.UseCase { if s == nil { return stageflow.New(nil) } - if s.stageflow != nil { - return s.stageflow + if s.stageflow() != nil { + return s.stageflow() } - return stageflow.New(s.svc) + return stageflow.New(s.svc()) } func (s *Server) corsMiddleware(next http.Handler) http.Handler { diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 589a6faa..f04c1fec 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -126,14 +126,14 @@ func TestServer_ChatDocumentSummaryReadsExistingDocumentWithoutWriteAction(t *te if err := os.WriteFile(filepath.Join(workDir, "output.md"), markdown, 0o644); err != nil { t.Fatalf("write output.md: %v", err) } - if _, err := srv.gov.CreateOrGetDocument( + if _, err := srv.gov().CreateOrGetDocument( ctx, "doc_proj143_intake", 143, "intake", "用户初始需求收集表", "V0.1_triage", workDir, 1, "编制人", 1, "审核人", 1, "批准人", ); err != nil { t.Fatalf("create document: %v", err) } hash := fmt.Sprintf("%x", sha256.Sum256(markdown)) - if err := srv.gov.MarkDocumentContentUpdated(ctx, "doc_proj143_intake", hash, false); err != nil { + if err := srv.gov().MarkDocumentContentUpdated(ctx, "doc_proj143_intake", hash, false); err != nil { t.Fatalf("mark content: %v", err) } @@ -469,10 +469,10 @@ func TestServer_DocGovProposeAndDecideHTTP(t *testing.T) { ctx := context.Background() docID := "doc_proj1_proposal_http" - if _, err := srv.gov.CreateOrGetDocument(ctx, docID, 1, "feasibility_reg", "法规评估", "V0.1", "", 1, "admin", 2, "r", 3, "a"); err != nil { + if _, err := srv.gov().CreateOrGetDocument(ctx, docID, 1, "feasibility_reg", "法规评估", "V0.1", "", 1, "admin", 2, "r", 3, "a"); err != nil { t.Fatalf("CreateOrGetDocument: %v", err) } - if _, err := srv.gov.RecordDocumentViewed(ctx, docID, 1, docgov.RoleAuthor, "admin"); err != nil { + if _, err := srv.gov().RecordDocumentViewed(ctx, docID, 1, docgov.RoleAuthor, "admin"); err != nil { t.Fatalf("claim: %v", err) } diff --git a/internal/server/sse.go b/internal/server/sse.go index b4198a63..053f0a74 100644 --- a/internal/server/sse.go +++ b/internal/server/sse.go @@ -404,7 +404,7 @@ func (s *Server) handleRunTaskStream(w http.ResponseWriter, r *http.Request) { } if req.ProjectID > 0 { if !s.isSystemAdminUser(r.Context(), user) { - if err := s.authorizer.RequireProjectAccess(r.Context(), req.ProjectID, user.ID); err != nil { + if err := s.authorizer().RequireProjectAccess(r.Context(), req.ProjectID, user.ID); err != nil { writeJSONResponse(w, http.StatusForbidden, map[string]string{ "error_code": "ERR_FORBIDDEN", "message": "无权访问该项目", @@ -440,8 +440,8 @@ func (s *Server) handleRunTaskStream(w http.ResponseWriter, r *http.Request) { // 幂等前置:与 REST 路径对齐,EventSource 断线重连命中已有 run 时直接复用, // 避免重复执行同一 client_turn_id 的任务。 var reused *taskrun.RunRecord - if idemKey := taskrun.BuildTaskRunIdempotencyKey(req.SessionID, req.ClientTurnID); idemKey != "" && s.taskRuns != nil { - if existing, findErr := s.taskRuns.FindByIdempotencyKey(r.Context(), idemKey); findErr != nil { + if idemKey := taskrun.BuildTaskRunIdempotencyKey(req.SessionID, req.ClientTurnID); idemKey != "" && s.taskRuns() != nil { + if existing, findErr := s.taskRuns().FindByIdempotencyKey(r.Context(), idemKey); findErr != nil { writeSafeError(w, r, http.StatusInternalServerError, findErr.Error()) return } else if existing != nil { @@ -462,7 +462,7 @@ func (s *Server) handleRunTaskStream(w http.ResponseWriter, r *http.Request) { created = reused } else { var err error - created, err = s.taskRuns.CreateAndStart(r.Context(), runID, record, taskReq, stageReq, req.WorkDir) + created, err = s.taskRuns().CreateAndStart(r.Context(), runID, record, taskReq, stageReq, req.WorkDir) if err != nil { writeSafeError(w, r, http.StatusInternalServerError, "start task run failed: "+err.Error()) return @@ -471,12 +471,12 @@ func (s *Server) handleRunTaskStream(w http.ResponseWriter, r *http.Request) { _ = includeDetails _ = requestID - ch, replay, err := s.taskRuns.Subscribe(created.RunID, 0) + ch, replay, err := s.taskRuns().Subscribe(created.RunID, 0) if err != nil { writeSafeError(w, r, http.StatusInternalServerError, err.Error()) return } - defer s.taskRuns.Unsubscribe(created.RunID, ch) + defer s.taskRuns().Unsubscribe(created.RunID, ch) w.Header().Set("X-Request-Id", requestID) w.Header().Set("Content-Type", "text/event-stream") diff --git a/internal/server/sse_wave0_test.go b/internal/server/sse_wave0_test.go index ff59e36b..6c967ce1 100644 --- a/internal/server/sse_wave0_test.go +++ b/internal/server/sse_wave0_test.go @@ -75,7 +75,7 @@ func TestRunTaskStream_CreateAndStartFails_BeforeSSEFlush(t *testing.T) { mgr := taskrun.NewManager(nil, nil) mgr.SetStore(failInsertStore{}) - srv.taskRuns = mgr + srv.setOverrideTaskRuns(mgr) body, _ := json.Marshal(map[string]any{ "task_key": "intake", diff --git a/internal/server/stage_access_helpers.go b/internal/server/stage_access_helpers.go index 03805c8d..48de0f6c 100644 --- a/internal/server/stage_access_helpers.go +++ b/internal/server/stage_access_helpers.go @@ -16,11 +16,11 @@ import ( ) func (s *Server) resolveStageAccess(ctx context.Context, projectID, userID int64) (*projectinstance.StageAccess, error) { - if s == nil || s.instSvc == nil || !s.instSvc.Available() || projectID <= 0 || userID <= 0 { + if s == nil || s.instSvc() == nil || !s.instSvc().Available() || projectID <= 0 || userID <= 0 { return &projectinstance.StageAccess{}, nil } - isCreator, _ := s.pwSvc.IsProjectCreator(ctx, projectID, userID) - return s.instSvc.ResolveStageAccess(ctx, projectID, userID, isCreator) + isCreator, _ := s.pwSvc().IsProjectCreator(ctx, projectID, userID) + return s.instSvc().ResolveStageAccess(ctx, projectID, userID, isCreator) } func (s *Server) checkWorkflowDocTaskAccess(ctx context.Context, projectID, userID int64, taskKey, action string) projectinstance.DocAccessDecision { @@ -30,9 +30,9 @@ func (s *Server) checkWorkflowDocTaskAccess(ctx context.Context, projectID, user if action == "" { action = projectinstance.DocAccessActionRun } - if s.instSvc != nil && s.instSvc.Available() && projectID > 0 && userID > 0 { - isCreator, _ := s.pwSvc.IsProjectCreator(ctx, projectID, userID) - return s.instSvc.CheckDocTaskAccess(ctx, projectID, userID, taskKey, action, isCreator) + if s.instSvc() != nil && s.instSvc().Available() && projectID > 0 && userID > 0 { + isCreator, _ := s.pwSvc().IsProjectCreator(ctx, projectID, userID) + return s.instSvc().CheckDocTaskAccess(ctx, projectID, userID, taskKey, action, isCreator) } access, err := s.resolveStageAccess(ctx, projectID, userID) if err != nil { @@ -68,8 +68,8 @@ func (s *Server) enforceDocGovWorkflowAction(w http.ResponseWriter, ctx context. if docID == "" { return true } - if s.gov != nil { - doc, err := s.gov.GetDocument(ctx, docID) + if s.gov() != nil { + doc, err := s.gov().GetDocument(ctx, docID) if err == nil && doc != nil && doc.ProjectID > 0 && doc.TaskKey != "" { return s.enforceWorkflowDocTaskAction(w, ctx, doc.ProjectID, userID, doc.TaskKey, action) } @@ -125,12 +125,12 @@ func (s *Server) allowAnyStageAccess(ctx context.Context, projectID, userID int6 // View scope assumes the caller already passed project membership // (see ResolveStageAccess comments). Enforce it here: a non-member with a // live governing instance must not read project context. - isCreator, _ := s.pwSvc.IsProjectCreator(ctx, projectID, userID) + isCreator, _ := s.pwSvc().IsProjectCreator(ctx, projectID, userID) if !isCreator { member := false - if s.authorizer != nil { + if s.authorizer() != nil { var mErr error - member, mErr = s.authorizer.IsProjectMember(ctx, projectID, userID) + member, mErr = s.authorizer().IsProjectMember(ctx, projectID, userID) if mErr != nil && s.failClosedAuthz() { return false } @@ -159,8 +159,8 @@ func (s *Server) enrichSessionWorkflowStage(ctx context.Context, sess *chat.Sess if sess == nil { return } - if sess.Project() > 0 && s.instSvc != nil && s.instSvc.Available() { - settings, err := s.instSvc.GetSettings(ctx, sess.Project(), userID, false) + if sess.Project() > 0 && s.instSvc() != nil && s.instSvc().Available() { + settings, err := s.instSvc().GetSettings(ctx, sess.Project(), userID, false) if err == nil && settings != nil { sess.ActiveStageKey = settings.ActiveStageKey } @@ -314,10 +314,10 @@ func (s *Server) enforceChatRequiresProject(w http.ResponseWriter, r *http.Reque if s.isSystemAdminUser(r.Context(), user) { return true } - if s.authorizer == nil || !s.authorizer.Enabled() { + if s.authorizer() == nil || !s.authorizer().Enabled() { return true } - ids, err := s.authorizer.ListVisibleProjectIDs(r.Context(), user.ID) + ids, err := s.authorizer().ListVisibleProjectIDs(r.Context(), user.ID) if err != nil { writeJSONResponse(w, http.StatusInternalServerError, map[string]string{ "error_code": "ERR_INTERNAL", @@ -344,7 +344,7 @@ func (s *Server) enforceChatProjectAccess(w http.ResponseWriter, r *http.Request if s.isSystemAdminUser(r.Context(), user) { return true } - if s.authorizer == nil { + if s.authorizer() == nil { if s.failClosedAuthz() { writeJSONResponse(w, http.StatusForbidden, map[string]string{ "error_code": "ERR_FORBIDDEN", @@ -354,7 +354,7 @@ func (s *Server) enforceChatProjectAccess(w http.ResponseWriter, r *http.Request } return true } - if err := s.authorizer.RequireProjectAccess(r.Context(), projectID, user.ID); err != nil { + if err := s.authorizer().RequireProjectAccess(r.Context(), projectID, user.ID); err != nil { writeJSONResponse(w, http.StatusForbidden, map[string]string{ "error_code": "ERR_FORBIDDEN", "message": "无权访问该项目", diff --git a/internal/server/supplement_impact_handlers.go b/internal/server/supplement_impact_handlers.go index 54644398..ab749290 100644 --- a/internal/server/supplement_impact_handlers.go +++ b/internal/server/supplement_impact_handlers.go @@ -147,14 +147,14 @@ func (s *Server) userCanAccessSupplementBatch(ctx context.Context, userID, proje if projectID <= 0 || userID <= 0 { return false } - if s.authorizer != nil { - ok, err := s.authorizer.IsProjectMember(ctx, projectID, userID) + if s.authorizer() != nil { + ok, err := s.authorizer().IsProjectMember(ctx, projectID, userID) if err == nil && ok { return true } } - if s.pwSvc != nil { - if isCreator, err := s.pwSvc.IsProjectCreator(ctx, projectID, userID); err == nil && isCreator { + if s.pwSvc() != nil { + if isCreator, err := s.pwSvc().IsProjectCreator(ctx, projectID, userID); err == nil && isCreator { return true } } diff --git a/internal/server/task_finalize.go b/internal/server/task_finalize.go index c06d2f16..e5a11f18 100644 --- a/internal/server/task_finalize.go +++ b/internal/server/task_finalize.go @@ -29,8 +29,8 @@ func (s *Server) finalizeTaskRun(ctx context.Context, record *taskrun.RunRecord, } cmds := s.taskCommands() if s != nil { - cmds.PW = s.pwSvc - cmds.Ctx = s.ctxMgr + cmds.PW = s.pwSvc() + cmds.Ctx = s.ctxMgr() } status, err := cmds.Finalize(ctx, record, result) if err != nil { @@ -44,11 +44,11 @@ func (s *Server) finalizeTaskRun(ctx context.Context, record *taskrun.RunRecord, } func (s *Server) taskCommands() *taskrun.Commands { - if s != nil && s.cmds != nil { - return s.cmds + if s != nil && s.cmds() != nil { + return s.cmds() } cmds := &taskrun.Commands{ - PW: s.pwSvc, + PW: s.pwSvc(), Env: "test", } if s != nil && s.cfg != nil { @@ -56,13 +56,13 @@ func (s *Server) taskCommands() *taskrun.Commands { cmds.StorageIdentity = "env:" + s.cfg.Env } if s != nil { - cmds.Ctx = s.ctxMgr + cmds.Ctx = s.ctxMgr() } return cmds } func (s *Server) hashDeliverable(ctx context.Context, record *taskrun.RunRecord, result *bizdev.TaskRunResult) string { - if s == nil || s.wsCap == nil || record == nil { + if s == nil || s.wsCap() == nil || record == nil { return "" } id := workspace.OpaqueID(result.WorkDir, record.WorkspaceID) @@ -80,7 +80,7 @@ func (s *Server) hashDeliverable(ctx context.Context, record *taskrun.RunRecord, RunID: record.RunID, Environment: env, } - body, err := s.wsCap.ReadArtifact(ctx, ref, workspace.DeliverableKey) + body, err := s.wsCap().ReadArtifact(ctx, ref, workspace.DeliverableKey) if err != nil || len(body) == 0 { return "" } diff --git a/internal/server/task_finalize_test.go b/internal/server/task_finalize_test.go index ae9a07c9..859b62e8 100644 --- a/internal/server/task_finalize_test.go +++ b/internal/server/task_finalize_test.go @@ -44,8 +44,8 @@ func TestFinalizeTaskRun_ReplacesPlaceholderContent(t *testing.T) { Status: "running", }) - srv.pwSvc = pwSvc - srv.taskRuns = taskrun.NewManager(pwSvc, ctxMgr) + srv.setOverridePwSvc(pwSvc) + srv.setOverrideTaskRuns(taskrun.NewManager(pwSvc, ctxMgr)) srv.initTaskRunExecutors() record := &taskrun.RunRecord{ @@ -118,8 +118,8 @@ func TestFinalizeTaskRun_PendingAuthorConfirmation_NotCompleted(t *testing.T) { _, _ = pwSvc.CreateWorkflowRun(context.Background(), &projectworkflow.WorkflowRun{ RunID: "run_pending_confirm", ProjectID: 56, TaskKey: "intake", Status: "running", }) - srv.pwSvc = pwSvc - srv.taskRuns = taskrun.NewManager(pwSvc, ctxMgr) + srv.setOverridePwSvc(pwSvc) + srv.setOverrideTaskRuns(taskrun.NewManager(pwSvc, ctxMgr)) srv.initTaskRunExecutors() record := &taskrun.RunRecord{ @@ -185,8 +185,8 @@ func TestAsyncTaskRun_FinalizesAfterWorkerCompletes(t *testing.T) { workDir := t.TempDir() _ = os.WriteFile(filepath.Join(workDir, "output.md"), []byte("# async finalize\n"), 0o644) - srv := &Server{cfg: cfg, svc: svc, pwSvc: pwSvc, ctxMgr: ctxMgr, taskRuns: taskrun.NewManager(pwSvc, ctxMgr)} - srv.taskRuns.SetExecutor( + srv := &Server{cfg: cfg, rt: newMinimalRuntime(cfg, svc, pwSvc, ctxMgr)} + srv.taskRuns().SetExecutor( func(_ context.Context, _ string, _ bizdev.TaskRunRequest, _ *stream.Streamer) (*bizdev.TaskRunResult, error) { return &bizdev.TaskRunResult{ Success: true, @@ -200,7 +200,7 @@ func TestAsyncTaskRun_FinalizesAfterWorkerCompletes(t *testing.T) { nil, nil, ) - srv.taskRuns.SetFinalizer(func(ctx context.Context, record *taskrun.RunRecord, result *bizdev.TaskRunResult) (string, error) { + srv.taskRuns().SetFinalizer(func(ctx context.Context, record *taskrun.RunRecord, result *bizdev.TaskRunResult) (string, error) { return srv.finalizeTaskRun(ctx, record, result) }) @@ -213,14 +213,14 @@ func TestAsyncTaskRun_FinalizesAfterWorkerCompletes(t *testing.T) { record := &taskrun.RunRecord{ RunID: runID, SessionID: "sess-async", ProjectID: 1, TaskKey: "intake", AssistantMsgID: asstID, } - _, err := srv.taskRuns.CreateAndStart(context.Background(), runID, record, bizdev.TaskRunRequest{TaskKey: "intake", ProjectID: 1}, bizdev.StageRunRequest{}, "") + _, err := srv.taskRuns().CreateAndStart(context.Background(), runID, record, bizdev.TaskRunRequest{TaskKey: "intake", ProjectID: 1}, bizdev.StageRunRequest{}, "") if err != nil { t.Fatalf("CreateAndStart: %v", err) } deadline := time.Now().Add(5 * time.Second) for time.Now().Before(deadline) { - rec, getErr := srv.taskRuns.GetRun(runID) + rec, getErr := srv.taskRuns().GetRun(runID) if getErr == nil && rec.Status != "running" { msg, _ := ctxMgr.GetMessageByID(context.Background(), asstID) if msg != nil && msg.Content != "任务执行中…" { @@ -242,8 +242,8 @@ func TestAsyncTaskRun_PendingConfirmation_FinalizesAsPending(t *testing.T) { workDir := t.TempDir() _ = os.WriteFile(filepath.Join(workDir, "output.md"), []byte("# head\n"), 0o644) - srv := &Server{cfg: cfg, svc: svc, pwSvc: pwSvc, ctxMgr: ctxMgr, taskRuns: taskrun.NewManager(pwSvc, ctxMgr)} - srv.taskRuns.SetExecutor( + srv := &Server{cfg: cfg, rt: newMinimalRuntime(cfg, svc, pwSvc, ctxMgr)} + srv.taskRuns().SetExecutor( func(_ context.Context, _ string, _ bizdev.TaskRunRequest, _ *stream.Streamer) (*bizdev.TaskRunResult, error) { return &bizdev.TaskRunResult{ Success: true, @@ -260,7 +260,7 @@ func TestAsyncTaskRun_PendingConfirmation_FinalizesAsPending(t *testing.T) { nil, nil, ) - srv.taskRuns.SetFinalizer(func(ctx context.Context, record *taskrun.RunRecord, result *bizdev.TaskRunResult) (string, error) { + srv.taskRuns().SetFinalizer(func(ctx context.Context, record *taskrun.RunRecord, result *bizdev.TaskRunResult) (string, error) { return srv.finalizeTaskRun(ctx, record, result) }) @@ -273,14 +273,14 @@ func TestAsyncTaskRun_PendingConfirmation_FinalizesAsPending(t *testing.T) { record := &taskrun.RunRecord{ RunID: runID, SessionID: "sess-async-pending", ProjectID: 1, TaskKey: "intake", AssistantMsgID: asstID, } - _, err := srv.taskRuns.CreateAndStart(context.Background(), runID, record, bizdev.TaskRunRequest{TaskKey: "intake", ProjectID: 1}, bizdev.StageRunRequest{}, "") + _, err := srv.taskRuns().CreateAndStart(context.Background(), runID, record, bizdev.TaskRunRequest{TaskKey: "intake", ProjectID: 1}, bizdev.StageRunRequest{}, "") if err != nil { t.Fatalf("CreateAndStart: %v", err) } deadline := time.Now().Add(5 * time.Second) for time.Now().Before(deadline) { - rec, getErr := srv.taskRuns.GetRun(runID) + rec, getErr := srv.taskRuns().GetRun(runID) if getErr == nil && rec.Status != "running" { msg, _ := ctxMgr.GetMessageByID(context.Background(), asstID) if msg == nil { @@ -307,12 +307,12 @@ func TestFinalizeTaskRun_HashesViaWorkspaceCapability(t *testing.T) { svc := bizdev.MustNewService(t, cfg) srv := NewServer(cfg, svc) ctx := context.Background() - ref, err := srv.wsCap.Issue(workspace.WorkspaceRef{ID: "ws_hash", ProjectScope: 1, RunID: "run_hash"}) + ref, err := srv.wsCap().Issue(workspace.WorkspaceRef{ID: "ws_hash", ProjectScope: 1, RunID: "run_hash"}) if err != nil { t.Fatal(err) } body := []byte("# hashed via capability\n") - if err := srv.wsCap.WriteArtifact(ctx, ref, workspace.DeliverableKey, body); err != nil { + if err := srv.wsCap().WriteArtifact(ctx, ref, workspace.DeliverableKey, body); err != nil { t.Fatal(err) } ctxMgr := bizdev.Snapshot(svc).Ctx @@ -324,7 +324,7 @@ func TestFinalizeTaskRun_HashesViaWorkspaceCapability(t *testing.T) { _, _ = pwSvc.CreateWorkflowRun(ctx, &projectworkflow.WorkflowRun{ RunID: "run_hash", ProjectID: 1, TaskKey: "intake", Status: "running", }) - srv.pwSvc = pwSvc + srv.setOverridePwSvc(pwSvc) record := &taskrun.RunRecord{RunID: "run_hash", SessionID: "sess-hash", ProjectID: 1, TaskKey: "intake", AssistantMsgID: asstID, WorkspaceID: ref.ID} status, err := srv.finalizeTaskRun(ctx, record, &bizdev.TaskRunResult{ Success: true, TaskKey: "intake", WorkDir: ref.ID, diff --git a/internal/server/task_run_auth.go b/internal/server/task_run_auth.go index 83fb543c..04923a08 100644 --- a/internal/server/task_run_auth.go +++ b/internal/server/task_run_auth.go @@ -23,7 +23,7 @@ func (s *Server) assertTaskRunAccess(w http.ResponseWriter, ctx context.Context, }) return false } - rec, err := s.taskRuns.GetRun(runID) + rec, err := s.taskRuns().GetRun(runID) if err != nil { writeSafeError(w, nil, http.StatusNotFound, "run not found") return false @@ -34,9 +34,9 @@ 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) + 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: @@ -64,6 +64,6 @@ func (s *Server) isSystemAdminUser(ctx context.Context, user *auth.UserAccount) if user == nil { return false } - admin, err := s.authzSvc.IsSystemAdmin(ctx, user.ID, user.RoleID) + admin, err := s.authzSvc().IsSystemAdmin(ctx, user.ID, user.RoleID) return err == nil && admin } diff --git a/internal/server/task_run_busy_preflight_test.go b/internal/server/task_run_busy_preflight_test.go index bfadcf83..c3e47a40 100644 --- a/internal/server/task_run_busy_preflight_test.go +++ b/internal/server/task_run_busy_preflight_test.go @@ -17,19 +17,19 @@ func TestCreateTaskRunBusyPreflightDoesNotPersistRunOrMessages(t *testing.T) { srv := newTestServer(t) ctx := t.Context() docID := "doc_proj143_intake" - if _, err := srv.gov.CreateOrGetDocument( + if _, err := srv.gov().CreateOrGetDocument( ctx, docID, 143, "intake", "用户初始需求收集表", "V0.1_triage", "", 1, "编制人", 1, "审核人", 1, "批准人", ); err != nil { t.Fatalf("create document: %v", err) } - if _, err := srv.gov.AcquireLease(ctx, docgov.LeaseAcquireRequest{ + if _, err := srv.gov().AcquireLease(ctx, docgov.LeaseAcquireRequest{ DocID: docID, HolderType: docgov.LeaseHolderHuman, HolderID: "editor-9", TTL: time.Minute, }); err != nil { t.Fatalf("acquire fixture lease: %v", err) } defer func() { - _, _ = srv.gov.ReleaseLease(t.Context(), docID, string(docgov.LeaseHolderHuman), "editor-9") + _, _ = srv.gov().ReleaseLease(t.Context(), docID, string(docgov.LeaseHolderHuman), "editor-9") }() requestBody, _ := json.Marshal(createTaskRunRequest{ @@ -53,7 +53,7 @@ func TestCreateTaskRunBusyPreflightDoesNotPersistRunOrMessages(t *testing.T) { if _, exists := payload["run_id"]; exists { t.Fatalf("busy preflight must not return run_id: %+v", payload) } - history, err := srv.ctxMgr.GetChatHistory(ctx, "fixture-busy-preflight", 20, 0) + history, err := srv.ctxMgr().GetChatHistory(ctx, "fixture-busy-preflight", 20, 0) if err != nil { t.Fatalf("get busy history: %v", err) } @@ -77,7 +77,7 @@ func TestCreateTaskRun_PreActivationAcceptedNot409(t *testing.T) { srv := newTestServer(t) store := projectinstance.NewMemoryStore() _ = projectinstance.SeedProjectMeta(store, 9001, projectinstance.LifecyclePlanning, "", 1, 0) - srv.instSvc = projectinstance.NewService(store, projectinstance.TestResolver(), nil, nil) + srv.setOverrideInstSvc(projectinstance.NewService(store, projectinstance.TestResolver(), nil, nil)) requestBody, _ := json.Marshal(createTaskRunRequest{ TaskRunRequestDTO: TaskRunRequestDTO{TaskKey: "intake", ProjectID: 9001, ProjectName: "预激活立项"}, @@ -107,10 +107,10 @@ func TestCreateTaskRun_PreActivationAcceptedNot409(t *testing.T) { if payload.Status != "blocked_pending_governance" { t.Fatalf("status=%q want blocked_pending_governance body=%s", payload.Status, w.Body.String()) } - if rec, err := srv.taskRuns.GetRun(payload.RunID); err == nil && rec != nil && rec.Status == "running" { + if rec, err := srv.taskRuns().GetRun(payload.RunID); err == nil && rec != nil && rec.Status == "running" { t.Fatalf("pre-activation run must not start a live worker: %+v", rec) } - history, err := srv.ctxMgr.GetChatHistory(t.Context(), "sess-p9001-u1", 20, 0) + history, err := srv.ctxMgr().GetChatHistory(t.Context(), "sess-p9001-u1", 20, 0) if err != nil { t.Fatalf("chat history: %v", err) } @@ -130,7 +130,7 @@ func TestCreateTaskRun_ScopeUnresolvedPersistsChat(t *testing.T) { srv := newTestServer(t) token := loginTestAdmin(t, srv) srv.cfg.AllowInMemory = false - srv.instSvc = nil + srv.setOverrideInstSvc(nil) requestBody, _ := json.Marshal(createTaskRunRequest{ TaskRunRequestDTO: TaskRunRequestDTO{TaskKey: "intake", ProjectID: 9102, ProjectName: "作用域失败"}, @@ -152,7 +152,7 @@ func TestCreateTaskRun_ScopeUnresolvedPersistsChat(t *testing.T) { if payload["error_code"] != "TASK_SCOPE_UNRESOLVED" { t.Fatalf("payload=%+v", payload) } - history, err := srv.ctxMgr.GetChatHistory(t.Context(), "sess-scope-unresolved", 20, 0) + history, err := srv.ctxMgr().GetChatHistory(t.Context(), "sess-scope-unresolved", 20, 0) if err != nil { t.Fatalf("history: %v", err) } @@ -175,10 +175,10 @@ func TestDocGovActions_PreActivationRejected(t *testing.T) { srv := newTestServer(t) store := projectinstance.NewMemoryStore() _ = projectinstance.SeedProjectMeta(store, 9002, projectinstance.LifecyclePlanning, "", 1, 0) - srv.instSvc = projectinstance.NewService(store, projectinstance.TestResolver(), nil, nil) + srv.setOverrideInstSvc(projectinstance.NewService(store, projectinstance.TestResolver(), nil, nil)) docID := "doc_proj9002_intake" - if _, err := srv.gov.CreateOrGetDocument( + if _, err := srv.gov().CreateOrGetDocument( t.Context(), docID, 9002, "intake", "立项文档", "V0.1_triage", "", 1, "编制人", 2, "审核人", 3, "批准人", ); err != nil { diff --git a/internal/server/task_run_handlers.go b/internal/server/task_run_handlers.go index f6d7bb01..cac46819 100644 --- a/internal/server/task_run_handlers.go +++ b/internal/server/task_run_handlers.go @@ -58,7 +58,7 @@ func (s *Server) handleCreateTaskRun(w http.ResponseWriter, r *http.Request) { } req.UserID = user.ID if req.ProjectID > 0 { - if err := s.authorizer.RequireProjectAccess(r.Context(), req.ProjectID, user.ID); err != nil { + if err := s.authorizer().RequireProjectAccess(r.Context(), req.ProjectID, user.ID); err != nil { writeJSONResponse(w, http.StatusForbidden, map[string]string{ "error_code": "ERR_FORBIDDEN", "message": "无权访问该项目", @@ -85,8 +85,8 @@ func (s *Server) handleCreateTaskRun(w http.ResponseWriter, r *http.Request) { // 授权后、scope/preflight 前:稳定幂等键命中则直接 202,避免 SSE 断线重试误击 DOC_BUSY / 重复写聊天。 idemKey := taskrun.BuildTaskRunIdempotencyKey(req.SessionID, req.ClientTurnID) - if idemKey != "" && s.taskRuns != nil { - if existing, findErr := s.taskRuns.FindByIdempotencyKey(r.Context(), idemKey); findErr != nil { + if idemKey != "" && s.taskRuns() != nil { + if existing, findErr := s.taskRuns().FindByIdempotencyKey(r.Context(), idemKey); findErr != nil { writeSafeError(w, r, http.StatusInternalServerError, findErr.Error()) return } else if existing != nil { @@ -107,7 +107,7 @@ func (s *Server) handleCreateTaskRun(w http.ResponseWriter, r *http.Request) { } } - ctxMgr := s.ctxMgr + ctxMgr := s.ctxMgr() if ctxMgr != nil { if _, err := ctxMgr.EnsureChatSessionOwned(r.Context(), req.SessionID, req.UserID, req.ProjectID, req.ProjectName); err != nil { if errors.Is(err, ctxmgr.ErrSessionOwnerMismatch) { @@ -164,7 +164,7 @@ func (s *Server) handleCreateTaskRun(w http.ResponseWriter, r *http.Request) { WorkflowStageKey: taskScope.StageKey, Status: "pending", } - claimed, created, claimErr := s.taskRuns.ClaimRun(r.Context(), record) + claimed, created, claimErr := s.taskRuns().ClaimRun(r.Context(), record) if claimErr != nil { writeSafeError(w, r, http.StatusInternalServerError, claimErr.Error()) return @@ -212,7 +212,7 @@ func (s *Server) handleCreateTaskRun(w http.ResponseWriter, r *http.Request) { projectinstance.ApplyScopeToMetadata(asstMeta, taskScope) assistantMsgID, _ = ctxMgr.RecordUserMessageWithID(r.Context(), req.SessionID, 0, "assistant", "任务执行中…", "TRIGGER_TASK", "", asstMeta) claimed.AssistantMsgID = assistantMsgID - _ = s.taskRuns.BindAssistantMsgID(r.Context(), claimed.RunID, assistantMsgID) + _ = s.taskRuns().BindAssistantMsgID(r.Context(), claimed.RunID, assistantMsgID) } taskReq := s.buildBizdevTaskRequest(req.TaskRunRequestDTO, req.SessionID, req.ClientTurnID, claimed.RunID) @@ -221,7 +221,7 @@ func (s *Server) handleCreateTaskRun(w http.ResponseWriter, r *http.Request) { } resumeDir := req.WorkDir stageReq := s.buildBizdevStageRequest(req.TaskRunRequestDTO, req.SessionID, req.ClientTurnID, claimed.RunID) - createdRun, err := s.taskRuns.StartClaimedRun(r.Context(), claimed, taskReq, stageReq, resumeDir) + createdRun, err := s.taskRuns().StartClaimedRun(r.Context(), claimed, taskReq, stageReq, resumeDir) if err != nil { writeSafeError(w, r, http.StatusInternalServerError, err.Error()) return @@ -256,7 +256,7 @@ func (s *Server) createBlockedGovernanceRun(w http.ResponseWriter, r *http.Reque record.StageInstanceID = taskScope.StageInstanceID record.WorkflowStageKey = taskScope.StageKey } - claimed, created, claimErr := s.taskRuns.ClaimRun(r.Context(), record) + claimed, created, claimErr := s.taskRuns().ClaimRun(r.Context(), record) if claimErr != nil { writeSafeError(w, r, http.StatusInternalServerError, claimErr.Error()) return @@ -271,7 +271,7 @@ func (s *Server) createBlockedGovernanceRun(w http.ResponseWriter, r *http.Reque return } var assistantMsgID int64 - ctxMgr := s.ctxMgr + ctxMgr := s.ctxMgr() if ctxMgr != nil && req.UserMessage != "" { userMeta := map[string]any{ "project_id": req.ProjectID, @@ -303,7 +303,7 @@ func (s *Server) createBlockedGovernanceRun(w http.ResponseWriter, r *http.Reque } projectinstance.ApplyScopeToMetadata(asstMeta, taskScope) assistantMsgID, _ = ctxMgr.RecordUserMessageWithID(r.Context(), req.SessionID, 0, "assistant", projectinstance.GovernanceBlockedHint, "TRIGGER_TASK", "", asstMeta) - _ = s.taskRuns.BindAssistantMsgID(r.Context(), claimed.RunID, assistantMsgID) + _ = s.taskRuns().BindAssistantMsgID(r.Context(), claimed.RunID, assistantMsgID) } writeJSONResponse(w, http.StatusAccepted, createTaskRunResponse{ RunID: claimed.RunID, @@ -423,18 +423,18 @@ func (s *Server) recordUnresolvedScopeMessages(ctx context.Context, ctxMgr *ctxm // preflightTaskDocumentBusy 在写入聊天占位与 task_runs 之前拦截已知租约占用。 // 真正的租约获取仍由 docgov 原子执行;本检查用于避免用户重复点击产生必然失败的 run。 func (s *Server) preflightTaskDocumentBusy(ctx context.Context, req createTaskRunRequest) (*docgov.DocumentBusyError, error) { - if req.Stage != "" || req.ProjectID <= 0 || req.TaskKey == "" || s.gov == nil { + if req.Stage != "" || req.ProjectID <= 0 || req.TaskKey == "" || s.gov() == nil { return nil, nil } taskReq := s.buildBizdevTaskRequest(req.TaskRunRequestDTO, req.SessionID, req.ClientTurnID, "") - docID, err := s.svc.ResolveAndValidateDocID(ctx, taskReq) + docID, err := s.svc().ResolveAndValidateDocID(ctx, taskReq) if err != nil { return nil, err } if docID == "" { return nil, nil } - doc, err := s.gov.GetDocument(ctx, docID) + doc, err := s.gov().GetDocument(ctx, docID) if status, ok := domainHTTPStatus(err); ok && status == http.StatusNotFound { return nil, nil } @@ -445,8 +445,8 @@ func (s *Server) preflightTaskDocumentBusy(ctx context.Context, req createTaskRu return nil, nil } // 已结束的小队 run 由 AcquireLease 的孤儿回收逻辑处理,不在这里误拦截。 - if doc.LeaseHolderType == vocabulary.LeaseHolderSquad && s.taskRuns != nil { - if run, runErr := s.taskRuns.GetRun(doc.LeaseHolderID); runErr == nil && run.Status != "running" { + if doc.LeaseHolderType == vocabulary.LeaseHolderSquad && s.taskRuns() != nil { + if run, runErr := s.taskRuns().GetRun(doc.LeaseHolderID); runErr == nil && run.Status != "running" { return nil, nil } } @@ -496,12 +496,12 @@ func (s *Server) handleTaskRunEvents(w http.ResponseWriter, r *http.Request) { // Subscribe before declaring text/event-stream so NotFound stays a normal JSON error // (same order as handleChatStream / CreateAndStart in sse.go). - ch, replay, err := s.taskRuns.Subscribe(runID, afterSeq) + ch, replay, err := s.taskRuns().Subscribe(runID, afterSeq) if err != nil { writeSafeError(w, r, http.StatusNotFound, err.Error()) return } - defer s.taskRuns.Unsubscribe(runID, ch) + defer s.taskRuns().Unsubscribe(runID, ch) w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-cache") @@ -583,11 +583,11 @@ func (s *Server) handleCancelTaskRun(w http.ResponseWriter, r *http.Request) { if !s.assertTaskRunAccess(w, r.Context(), runID, user, taskRunCancel) { return } - if err := s.taskRuns.Cancel(runID); err != nil { + if err := s.taskRuns().Cancel(runID); err != nil { writeSafeError(w, r, http.StatusNotFound, err.Error()) return } - if rec, getErr := s.taskRuns.GetRun(runID); getErr == nil && rec.WorkspaceID != "" { + if rec, getErr := s.taskRuns().GetRun(runID); getErr == nil && rec.WorkspaceID != "" { s.cancelPiAgentRunner(rec.WorkspaceID) } writeJSONResponse(w, http.StatusOK, map[string]string{"run_id": runID, "status": "cancelled"}) @@ -622,7 +622,7 @@ func (s *Server) handleGetTaskRun(w http.ResponseWriter, r *http.Request) { if !s.assertTaskRunOwnedByUser(w, r.Context(), runID, user) { return } - rec, err := s.taskRuns.GetRun(runID) + rec, err := s.taskRuns().GetRun(runID) if err != nil { writeSafeError(w, r, http.StatusNotFound, err.Error()) return @@ -657,7 +657,7 @@ func (s *Server) handleResumeTaskRun(w http.ResponseWriter, r *http.Request) { return } - rec, err := s.taskRuns.GetRun(runID) + rec, err := s.taskRuns().GetRun(runID) if err != nil { writeSafeError(w, r, http.StatusNotFound, err.Error()) return @@ -676,11 +676,11 @@ func (s *Server) handleResumeTaskRun(w http.ResponseWriter, r *http.Request) { }) return } - if s.resolver == nil { + if s.resolver() == nil { writeSafeError(w, r, http.StatusServiceUnavailable, "workspace resolver unavailable") return } - workDir, werr := s.resolver.ResolveStoredWorkDir(rec.WorkspaceID) + workDir, werr := s.resolver().ResolveStoredWorkDir(rec.WorkspaceID) if werr != nil { writeJSONResponse(w, http.StatusForbidden, map[string]string{ "error_code": "ERR_WORKSPACE_FORBIDDEN", @@ -702,7 +702,7 @@ func (s *Server) handleResumeTaskRun(w http.ResponseWriter, r *http.Request) { Stage: rec.Stage, }, rec.SessionID, rec.ClientTurnID, rec.RunID) - resumed, err := s.taskRuns.ResumeInterrupted(r.Context(), rec, rec.WorkspaceID, taskReq, stageReq) + resumed, err := s.taskRuns().ResumeInterrupted(r.Context(), rec, rec.WorkspaceID, taskReq, stageReq) if err != nil { if errors.Is(err, taskrun.ErrRunNotResumable) || errors.Is(err, taskrun.ErrRunAlreadyActive) { writeJSONResponse(w, http.StatusConflict, map[string]string{ @@ -725,8 +725,8 @@ func (s *Server) handleResumeTaskRun(w http.ResponseWriter, r *http.Request) { func (s *Server) buildTaskRunResponse(rec *taskrun.RunRecord) taskRunResponse { resumable := rec != nil && rec.Status == "interrupted" && rec.WorkspaceID != "" - if resumable && s.resolver != nil { - if _, err := s.resolver.ResolveStoredWorkDir(rec.WorkspaceID); err != nil { + if resumable && s.resolver() != nil { + if _, err := s.resolver().ResolveStoredWorkDir(rec.WorkspaceID); err != nil { resumable = false } } @@ -816,12 +816,12 @@ func (s *Server) buildBizdevStageRequest(dto TaskRunRequestDTO, sessionID, clien } func (s *Server) initTaskRunExecutors() { - s.taskRuns.SetExecutor( + s.taskRuns().SetExecutor( func(ctx context.Context, _ string, req bizdev.TaskRunRequest, _ *stream.Streamer) (*bizdev.TaskRunResult, error) { return s.taskUseCase().ExecuteTask(ctx, req) }, func(ctx context.Context, _ string, workDir string, _ *stream.Streamer) (*bizdev.TaskRunResult, error) { - res, err := s.svc.ResumeOrModifyWorkspace(ctx, squad.ModifyWorkflowRequest{WorkDir: workDir}) + res, err := s.svc().ResumeOrModifyWorkspace(ctx, squad.ModifyWorkflowRequest{WorkDir: workDir}) if res == nil { return nil, err } @@ -847,7 +847,7 @@ func (s *Server) initTaskRunExecutors() { }, err }, ) - s.taskRuns.SetFinalizer(func(ctx context.Context, record *taskrun.RunRecord, result *bizdev.TaskRunResult) (string, error) { + s.taskRuns().SetFinalizer(func(ctx context.Context, record *taskrun.RunRecord, result *bizdev.TaskRunResult) (string, error) { return s.finalizeTaskRun(ctx, record, result) }) } diff --git a/internal/server/task_run_idempotent_sse_test.go b/internal/server/task_run_idempotent_sse_test.go index 40bbcfdf..7ea9835c 100644 --- a/internal/server/task_run_idempotent_sse_test.go +++ b/internal/server/task_run_idempotent_sse_test.go @@ -32,12 +32,12 @@ func TestCreateTaskRun_SameTurnIdempotentReturnsExistingDespiteBusyLease(t *test ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() if firstRunID != "" { - _ = srv.taskRuns.WaitRunDone(ctx, firstRunID) - } else if srv.taskRuns != nil { - _ = srv.taskRuns.WaitIdle(ctx) + _ = srv.taskRuns().WaitRunDone(ctx, firstRunID) + } else if srv.taskRuns() != nil { + _ = srv.taskRuns().WaitIdle(ctx) } }) - srv.taskRuns.SetExecutor( + srv.taskRuns().SetExecutor( func(ctx context.Context, _ string, _ bizdev.TaskRunRequest, _ *stream.Streamer) (*bizdev.TaskRunResult, error) { select { case <-ctx.Done(): @@ -53,7 +53,7 @@ func TestCreateTaskRun_SameTurnIdempotentReturnsExistingDespiteBusyLease(t *test ) docID := "doc_proj143_intake" - if _, err := srv.gov.CreateOrGetDocument( + if _, err := srv.gov().CreateOrGetDocument( ctx, docID, 143, "intake", "用户初始需求收集表", "V0.1_triage", "", userID, "编制人", userID, "审核人", userID, "批准人", ); err != nil { @@ -82,7 +82,7 @@ func TestCreateTaskRun_SameTurnIdempotentReturnsExistingDespiteBusyLease(t *test } firstRunID = first.RunID - hist1, err := srv.ctxMgr.GetChatHistory(ctx, "sess-idem-busy", 50, 0) + hist1, err := srv.ctxMgr().GetChatHistory(ctx, "sess-idem-busy", 50, 0) if err != nil { t.Fatalf("history1: %v", err) } @@ -91,13 +91,13 @@ func TestCreateTaskRun_SameTurnIdempotentReturnsExistingDespiteBusyLease(t *test } // 活跃小队租约:模拟长任务仍占文档(旧路径会 409) - if _, err := srv.gov.AcquireLease(ctx, docgov.LeaseAcquireRequest{ + if _, err := srv.gov().AcquireLease(ctx, docgov.LeaseAcquireRequest{ DocID: docID, HolderType: docgov.LeaseHolderSquad, HolderID: first.RunID, TTL: time.Minute, }); err != nil { t.Fatalf("acquire squad lease: %v", err) } t.Cleanup(func() { - _, _ = srv.gov.ReleaseLease(context.Background(), docID, string(docgov.LeaseHolderSquad), first.RunID) + _, _ = srv.gov().ReleaseLease(context.Background(), docID, string(docgov.LeaseHolderSquad), first.RunID) }) body2, _ := json.Marshal(createTaskRunRequest{ @@ -119,7 +119,7 @@ func TestCreateTaskRun_SameTurnIdempotentReturnsExistingDespiteBusyLease(t *test t.Fatalf("want same run %s, got %s", first.RunID, second.RunID) } - hist2, err := srv.ctxMgr.GetChatHistory(ctx, "sess-idem-busy", 50, 0) + hist2, err := srv.ctxMgr().GetChatHistory(ctx, "sess-idem-busy", 50, 0) if err != nil { t.Fatalf("history2: %v", err) } diff --git a/internal/server/task_run_json_test.go b/internal/server/task_run_json_test.go index db368eca..188585c6 100644 --- a/internal/server/task_run_json_test.go +++ b/internal/server/task_run_json_test.go @@ -60,7 +60,7 @@ func TestGetTaskRunJSONUsesSnakeCase(t *testing.T) { TaskKey: "intake", UserID: 1, } - if _, err := srv.taskRuns.CreateAndStart( + if _, err := srv.taskRuns().CreateAndStart( t.Context(), runID, rec, diff --git a/internal/server/task_run_observer_test.go b/internal/server/task_run_observer_test.go index ea03179e..55b288c8 100644 --- a/internal/server/task_run_observer_test.go +++ b/internal/server/task_run_observer_test.go @@ -31,7 +31,7 @@ func TestTaskRun_DisconnectDoesNotCancelWorker(t *testing.T) { runID := "run_disconnect_test" record := &taskrun.RunRecord{RunID: runID, TaskKey: "intake", Status: "running"} taskReq := bizdev.TaskRunRequest{TaskKey: "intake", ProjectName: "disconnect-test"} - _, err := srv.taskRuns.CreateAndStart(context.Background(), runID, record, taskReq, bizdev.StageRunRequest{}, "") + _, err := srv.taskRuns().CreateAndStart(context.Background(), runID, record, taskReq, bizdev.StageRunRequest{}, "") if err != nil { t.Fatalf("CreateAndStart: %v", err) } @@ -50,7 +50,7 @@ func TestTaskRun_DisconnectDoesNotCancelWorker(t *testing.T) { deadline := time.Now().Add(5 * time.Second) for time.Now().Before(deadline) { - rec, getErr := srv.taskRuns.GetRun(runID) + rec, getErr := srv.taskRuns().GetRun(runID) if getErr == nil && rec.Status != "running" { if rec.Status == "failed" { t.Fatalf("worker marked failed after disconnect, status=%s err=%s", rec.Status, rec.ErrorMessage) diff --git a/internal/server/trace_handlers.go b/internal/server/trace_handlers.go index 8ac6b00f..c9af3dd3 100644 --- a/internal/server/trace_handlers.go +++ b/internal/server/trace_handlers.go @@ -110,7 +110,7 @@ func (s *Server) handleProjectTraceDecision(w http.ResponseWriter, r *http.Reque writeSafeError(w, r, http.StatusForbidden, `{"error":"ERR_FORBIDDEN","message":"看不见下游文档,不能裁决"}`) return } - creator, _ := s.pwSvc.IsProjectCreator(r.Context(), projectID, user.ID) + creator, _ := s.pwSvc().IsProjectCreator(r.Context(), projectID, user.ID) if err := s.rt.Trace.DecideCandidate(r.Context(), edgeID, user.ID, creator, body.DownstreamDoc, body.DownstreamAuthor, body.Decision, body.Reason); err != nil { writeJSONResponse(w, http.StatusForbidden, map[string]string{"error": "TRACE_DECISION_DENIED", "message": err.Error()}) return @@ -130,7 +130,7 @@ func (s *Server) handleProjectTraceReopen(w http.ResponseWriter, r *http.Request } req.ProjectID = projectID req.RequestedBy = user.ID - creator, _ := s.pwSvc.IsProjectCreator(r.Context(), projectID, user.ID) + creator, _ := s.pwSvc().IsProjectCreator(r.Context(), projectID, user.ID) if !s.callerMayRequestReopen(r, projectID, user.ID, creator, req) { writeSafeError(w, r, http.StatusForbidden, `{"error":"ERR_FORBIDDEN","message":"只有该阶段文档职责人或创建人可以申请再打开"}`) return @@ -148,7 +148,7 @@ func (s *Server) handleProjectTraceReopenApprove(w http.ResponseWriter, r *http. if !ok { return } - creator, _ := s.pwSvc.IsProjectCreator(r.Context(), projectID, user.ID) + creator, _ := s.pwSvc().IsProjectCreator(r.Context(), projectID, user.ID) if !creator { writeSafeError(w, r, http.StatusForbidden, `{"error":"ERR_NOT_PROJECT_CREATOR","message":"仅项目创建人可批准再打开"}`) return @@ -209,8 +209,8 @@ func (s *Server) handleProjectTraceRecheckClear(w http.ResponseWriter, r *http.R writeJSONResponse(w, http.StatusBadRequest, map[string]string{"error": "TRACE_RECHECK_CLEAR_FAILED", "message": err.Error()}) return } - if body.MessageID > 0 && s.ctxMgr != nil { - _ = s.ctxMgr.SetInteractiveCardStatus(r.Context(), body.MessageID, "resolved", "已标记为无需变更") + if body.MessageID > 0 && s.ctxMgr() != nil { + _ = s.ctxMgr().SetInteractiveCardStatus(r.Context(), body.MessageID, "resolved", "已标记为无需变更") } writeJSONResponse(w, http.StatusOK, map[string]string{"status": "cleared"}) } @@ -236,10 +236,10 @@ func (s *Server) callerMayRequestReopen(r *http.Request, projectID, userID int64 if creator { return true } - if s.instSvc == nil { + if s.instSvc() == nil { return false } - settings, err := s.instSvc.GetSettings(r.Context(), projectID, userID, false) + settings, err := s.instSvc().GetSettings(r.Context(), projectID, userID, false) if err != nil || settings == nil || settings.Plan == nil { return false } diff --git a/internal/server/user_directory_handlers.go b/internal/server/user_directory_handlers.go index 918ca2f6..2b68f16f 100644 --- a/internal/server/user_directory_handlers.go +++ b/internal/server/user_directory_handlers.go @@ -19,7 +19,7 @@ func (s *Server) handleUserDirectory(w http.ResponseWriter, r *http.Request) { writeSafeError(w, r, http.StatusMethodNotAllowed, "method not allowed") return } - users, err := s.authSvc.ListUserDirectory(r.Context()) + users, err := s.authSvc().ListUserDirectory(r.Context()) if err != nil { writeSafeError(w, r, http.StatusInternalServerError, err.Error()) return @@ -60,7 +60,7 @@ func (s *Server) handleAdminUpdateProfile(w http.ResponseWriter, r *http.Request writeSafeError(w, r, http.StatusBadRequest, "姓名不能为空") return } - updated, err := s.authSvc.AdminUpdateUserProfile(r.Context(), targetID, auth.ProfilePatch{ + updated, err := s.authSvc().AdminUpdateUserProfile(r.Context(), targetID, auth.ProfilePatch{ Name: body.Name, Department: body.Department, Email: body.Email, diff --git a/internal/server/vault.go b/internal/server/vault.go index 88cb0b68..e964f0a1 100644 --- a/internal/server/vault.go +++ b/internal/server/vault.go @@ -55,13 +55,16 @@ func (s *Server) startVaultPublisher() { if s == nil || s.vaultPub == nil || s.cfg == nil || !s.cfg.FAVPublisherEnabled { return } - s.vaultStop = make(chan struct{}) + stopCh, ok := s.vaultLoop.start() + if !ok { + return + } go func() { t := time.NewTicker(2 * time.Second) defer t.Stop() for { select { - case <-s.vaultStop: + case <-stopCh: return case <-t.C: _ = s.vaultPub.Drain(context.Background()) @@ -104,7 +107,7 @@ func (s *Server) wireFinalArtifactHooks() { if s == nil || s.vaultIngest == nil { return } - if gov := s.gov; gov != nil { + if gov := s.gov(); gov != nil { gov.OnFinalArtifact = func(ctx context.Context, doc *docgov.DocumentRecord, markdown []byte, snapshotID int64) { if doc == nil { return @@ -118,7 +121,7 @@ func (s *Server) wireFinalArtifactHooks() { return s.vaultIngest.ApprovedTX(ctx, tx, doc.ProjectID, doc.DocID, snapshotID, doc.ApprovedAt, markdown) } } - if rev := s.rev; rev != nil { + if rev := s.rev(); rev != nil { rev.OnFinalArtifact = func(ctx context.Context, kind, docID string, projectID, snapshotID int64, eventID string, payload []byte, sealedAt *time.Time) { switch kind { case "agent_commit": @@ -138,7 +141,7 @@ func (s *Server) wireFinalArtifactHooks() { } } } - if fd := s.freeDoc; fd != nil { + if fd := s.freeDoc(); fd != nil { fd.OnFinalArtifact = func(ctx context.Context, projectID int64, docID, eventID string, snapshotID int64, payload []byte) { _ = s.vaultIngest.FreeformPublish(ctx, projectID, docID, eventID, snapshotID, payload) } diff --git a/internal/server/workflow_approval_hook.go b/internal/server/workflow_approval_hook.go index 3a77ca5b..e1bec6f7 100644 --- a/internal/server/workflow_approval_hook.go +++ b/internal/server/workflow_approval_hook.go @@ -18,24 +18,24 @@ func (s *Server) onWorkflowDocumentApproved(ctx context.Context, doc *docgov.Doc if s.rt != nil && s.rt.WriterLive(ctx, events.ConsumerWorkflowAdvance) { return } - if doc == nil || doc.TaskKey == "" || doc.ProjectID <= 0 || s.instSvc == nil { + if doc == nil || doc.TaskKey == "" || doc.ProjectID <= 0 || s.instSvc() == nil { return } - result, err := s.instSvc.HandleDocumentApproved(ctx, doc.ProjectID, doc.TaskKey) + result, err := s.instSvc().HandleDocumentApproved(ctx, doc.ProjectID, doc.TaskKey) if err != nil || result == nil || !result.StageReady { return } - if s.notifySvc == nil || !s.notifySvc.Available() { + if s.notifySvc() == nil || !s.notifySvc().Available() { return } - project, err := s.pwSvc.GetProject(ctx, doc.ProjectID, 0) + project, err := s.pwSvc().GetProject(ctx, doc.ProjectID, 0) if err != nil || project == nil || project.CreatorUserID <= 0 { return } - _, _ = s.notifySvc.EmitStageReadyToAdvance(ctx, project.CreatorUserID, notification.StageReadyPayload{ + _, _ = s.notifySvc().EmitStageReadyToAdvance(ctx, project.CreatorUserID, notification.StageReadyPayload{ ProjectID: doc.ProjectID, WorkflowInstanceID: result.InstanceID, StageInstanceID: result.StageInstanceID, diff --git a/internal/server/workflow_catalog_handlers.go b/internal/server/workflow_catalog_handlers.go index 3174122b..b51b778f 100644 --- a/internal/server/workflow_catalog_handlers.go +++ b/internal/server/workflow_catalog_handlers.go @@ -16,11 +16,11 @@ type workflowCatalogItem struct { } func (s *Server) handleWorkflowCatalog(w http.ResponseWriter, r *http.Request) { - if s.wfDefSvc == nil || !s.wfDefSvc.Available() { + if s.wfDefSvc() == nil || !s.wfDefSvc().Available() { writeSafeError(w, r, http.StatusServiceUnavailable, `{"error":"ERR_NOT_AVAILABLE","message":"workflow catalog unavailable"}`) return } - defs, err := s.wfDefSvc.Store().ListProjectSelectableDefinitions(r.Context()) + defs, err := s.wfDefSvc().Store().ListProjectSelectableDefinitions(r.Context()) if err != nil { writeSafeError(w, r, http.StatusInternalServerError, `{"error":"ERR_INTERNAL","message":"`+err.Error()+`"}`) return diff --git a/internal/server/workflow_instance_handlers.go b/internal/server/workflow_instance_handlers.go index e1c98c3f..a406b44b 100644 --- a/internal/server/workflow_instance_handlers.go +++ b/internal/server/workflow_instance_handlers.go @@ -14,11 +14,11 @@ func (s *Server) handleGetWorkflowInstance(w http.ResponseWriter, r *http.Reques writeSafeError(w, r, http.StatusBadRequest, `{"error":"ERR_INVALID_PARAM","message":"invalid instance id"}`) return } - if s.instSvc == nil || !s.instSvc.Available() { + if s.instSvc() == nil || !s.instSvc().Available() { writeSafeError(w, r, http.StatusServiceUnavailable, `{"error":"ERR_NOT_AVAILABLE","message":"项目实例服务不可用"}`) return } - detail, err := s.instSvc.GetInstanceDetail(r.Context(), instanceID) + detail, err := s.instSvc().GetInstanceDetail(r.Context(), instanceID) if err != nil { writeProjectInstanceError(w, err) return @@ -36,11 +36,11 @@ func (s *Server) handleGetStageReadiness(w http.ResponseWriter, r *http.Request) return } stageKey := r.PathValue("stage") - if s.instSvc == nil || !s.instSvc.Available() { + if s.instSvc() == nil || !s.instSvc().Available() { writeSafeError(w, r, http.StatusServiceUnavailable, `{"error":"ERR_NOT_AVAILABLE","message":"项目实例服务不可用"}`) return } - detail, err := s.instSvc.GetInstanceDetail(r.Context(), instanceID) + detail, err := s.instSvc().GetInstanceDetail(r.Context(), instanceID) if err != nil { writeProjectInstanceError(w, err) return @@ -49,8 +49,8 @@ func (s *Server) handleGetStageReadiness(w http.ResponseWriter, r *http.Request) if !ok { return } - isCreator, _ := s.pwSvc.IsProjectCreator(r.Context(), detail.Instance.ProjectID, user.ID) - readiness, err := s.instSvc.GetStageReadiness(r.Context(), instanceID, stageKey, user.ID, isCreator) + isCreator, _ := s.pwSvc().IsProjectCreator(r.Context(), detail.Instance.ProjectID, user.ID) + readiness, err := s.instSvc().GetStageReadiness(r.Context(), instanceID, stageKey, user.ID, isCreator) if err != nil { writeProjectInstanceError(w, err) return @@ -65,11 +65,11 @@ func (s *Server) handleAdvanceStage(w http.ResponseWriter, r *http.Request) { return } stageKey := r.PathValue("stage") - if s.instSvc == nil || !s.instSvc.Available() { + if s.instSvc() == nil || !s.instSvc().Available() { writeSafeError(w, r, http.StatusServiceUnavailable, `{"error":"ERR_NOT_AVAILABLE","message":"项目实例服务不可用"}`) return } - detail, err := s.instSvc.GetInstanceDetail(r.Context(), instanceID) + detail, err := s.instSvc().GetInstanceDetail(r.Context(), instanceID) if err != nil { writeProjectInstanceError(w, err) return @@ -78,7 +78,7 @@ func (s *Server) handleAdvanceStage(w http.ResponseWriter, r *http.Request) { if !ok { return } - isCreator, _ := s.pwSvc.IsProjectCreator(r.Context(), detail.Instance.ProjectID, user.ID) + isCreator, _ := s.pwSvc().IsProjectCreator(r.Context(), detail.Instance.ProjectID, user.ID) var req projectinstance.AdvanceStageInput if err := json.NewDecoder(r.Body).Decode(&req); err != nil && r.ContentLength > 0 { writeSafeError(w, r, http.StatusBadRequest, `{"error":"ERR_INVALID_PARAM","message":"无效的 JSON 请求体"}`) @@ -87,13 +87,13 @@ func (s *Server) handleAdvanceStage(w http.ResponseWriter, r *http.Request) { if req.StageKey == "" { req.StageKey = stageKey } - result, err := s.instSvc.AdvanceStage(r.Context(), instanceID, user.ID, isCreator, req) + result, err := s.instSvc().AdvanceStage(r.Context(), instanceID, user.ID, isCreator, req) if err != nil { writeProjectInstanceError(w, err) return } - if s.notifySvc != nil && result.CompletedStageInstanceID > 0 { - _ = s.notifySvc.ConfirmStageAdvanceRequest( + if s.notifySvc() != nil && result.CompletedStageInstanceID > 0 { + _ = s.notifySvc().ConfirmStageAdvanceRequest( r.Context(), result.CompletedStageInstanceID, result.CompletedReadinessRevision, diff --git a/internal/server/workflow_terminate_handlers.go b/internal/server/workflow_terminate_handlers.go index ce391e7d..b46764f7 100644 --- a/internal/server/workflow_terminate_handlers.go +++ b/internal/server/workflow_terminate_handlers.go @@ -10,7 +10,7 @@ import ( ) func (s *Server) handleTerminationPreview(w http.ResponseWriter, r *http.Request) { - if s.instSvc == nil || !s.instSvc.Available() { + if s.instSvc() == nil || !s.instSvc().Available() { writeSafeError(w, r, http.StatusServiceUnavailable, `{"error":"ERR_NOT_AVAILABLE"}`) return } @@ -24,13 +24,13 @@ func (s *Server) handleTerminationPreview(w http.ResponseWriter, r *http.Request writeSafeError(w, r, http.StatusUnauthorized, `{"error":"ERR_UNAUTHENTICATED"}`) return } - detail, err := s.instSvc.GetInstanceDetail(r.Context(), instanceID) + detail, err := s.instSvc().GetInstanceDetail(r.Context(), instanceID) if err != nil { writeSafeError(w, r, http.StatusNotFound, `{"error":"ERR_NOT_FOUND"}`) return } - isCreator, _ := s.pwSvc.IsProjectCreator(r.Context(), detail.Instance.ProjectID, user.ID) - preview, err := s.instSvc.GetTerminationPreview(r.Context(), instanceID, detail.Instance.ProjectID, user.ID, isCreator) + isCreator, _ := s.pwSvc().IsProjectCreator(r.Context(), detail.Instance.ProjectID, user.ID) + preview, err := s.instSvc().GetTerminationPreview(r.Context(), instanceID, detail.Instance.ProjectID, user.ID, isCreator) if err != nil { writeProjectInstanceError(w, err) return @@ -39,7 +39,7 @@ func (s *Server) handleTerminationPreview(w http.ResponseWriter, r *http.Request } func (s *Server) handleTerminateWorkflowInstance(w http.ResponseWriter, r *http.Request) { - if s.instSvc == nil || !s.instSvc.Available() { + if s.instSvc() == nil || !s.instSvc().Available() { writeSafeError(w, r, http.StatusServiceUnavailable, `{"error":"ERR_NOT_AVAILABLE"}`) return } @@ -53,7 +53,7 @@ func (s *Server) handleTerminateWorkflowInstance(w http.ResponseWriter, r *http. writeSafeError(w, r, http.StatusUnauthorized, `{"error":"ERR_UNAUTHENTICATED"}`) return } - detail, err := s.instSvc.GetInstanceDetail(r.Context(), instanceID) + detail, err := s.instSvc().GetInstanceDetail(r.Context(), instanceID) if err != nil { writeSafeError(w, r, http.StatusNotFound, `{"error":"ERR_NOT_FOUND"}`) return @@ -63,8 +63,8 @@ func (s *Server) handleTerminateWorkflowInstance(w http.ResponseWriter, r *http. writeSafeError(w, r, http.StatusBadRequest, `{"error":"ERR_INVALID_PARAM"}`) return } - isCreator, _ := s.pwSvc.IsProjectCreator(r.Context(), detail.Instance.ProjectID, user.ID) - res, err := s.instSvc.TerminateInstance(r.Context(), instanceID, detail.Instance.ProjectID, user.ID, isCreator, in) + isCreator, _ := s.pwSvc().IsProjectCreator(r.Context(), detail.Instance.ProjectID, user.ID) + res, err := s.instSvc().TerminateInstance(r.Context(), instanceID, detail.Instance.ProjectID, user.ID, isCreator, in) if err != nil { switch { case errors.Is(err, projectinstance.ErrForbiddenNotCreator): diff --git a/internal/server/workspace_admin_handlers.go b/internal/server/workspace_admin_handlers.go index 844951ef..b845bbed 100644 --- a/internal/server/workspace_admin_handlers.go +++ b/internal/server/workspace_admin_handlers.go @@ -83,7 +83,7 @@ func (s *Server) serveAdminWorkspaceCleanup(w http.ResponseWriter, r *http.Reque return } } - cap := s.wsCap + cap := s.wsCap() if cap == nil && s.cfg != nil { cap = workspace.NewCapability(s.cfg.DataDir, nil) } @@ -107,22 +107,21 @@ func workspaceRegistryFor(cfg *config.Config) *workspace.FileRegistry { func (s *Server) startWorkspaceGC() { reg := s.workspaceRegistry() - if reg == nil || s.cfg == nil || s.wsGCStop != nil { + stopCh, ok := s.wsGCLoop.start() + if reg == nil || s.cfg == nil || !ok { return } - s.wsGCStop = make(chan struct{}) ttl := s.cfg.WorkspaceTTL quota := s.cfg.WorkspaceQuotaBytes - stop := s.wsGCStop go func() { ticker := time.NewTicker(time.Hour) defer ticker.Stop() for { select { - case <-stop: + case <-stopCh: return case <-ticker.C: - cap := s.wsCap + cap := s.wsCap() if cap == nil { cap = workspace.NewCapability(s.cfg.DataDir, nil) }