merge: feature/http-error-envelope

This commit is contained in:
weichen
2026-09-15 14:41:09 +00:00
48 changed files with 685 additions and 412 deletions
@@ -0,0 +1,54 @@
# HTTP/SSE 错误信封 H1–H3 测试报告
## 一、 测试背景 (Background)
H0 盘点公开路由存在纯文本 `err.Error()`、JSON `{error,message}` 与 SSE `error_info` 三套错误体。本波 additive 统一信封,不改状态码。
## 二、 测试目标 (Test Objectives)
- H1:`code`/`message`/`request_id`/`error` 双写;SQL/路径/token/XSS 不出现在响应。
- H2:前端优先 `code`;未知 code 不靠 substring。
- H3:去掉「无权访问该项目」文本匹配;不改状态码故无 ADR。
## 三、 测试方案与环境 (Test Plan & Environment)
| 项 | 值 |
|----|-----|
| 分支 | `feature/http-error-envelope` |
| Go | `go test -short ./internal/httpx ./internal/stream ./internal/server` |
| 前端 | `npx tsx --test src/api/projectSettingsError.test.ts` |
## 四、 预期结果 (Expected Results)
信封 golden 负例 PASS;server 短测 PASS;前端解析 `code` 优先于 `error`。
## 五、 实际测试结果与判定 (Actual Results & Evaluation)
### 5.1 httpx + SSE
- **测试输入 (Input)**:`TestWriteSanitizesInternalAndKeepsLegacyCode`;`TestClassifyError_DoesNotLeakSQL`
- **实际输出 (Actual Output)**:`ok httpx`;`ok stream`
- **评价判定 (Evaluation / Verdict)**:**PASS**
- **性能与耗时 (Duration & Latency)**:<10ms
### 5.2 server 回归
- **测试输入 (Input)**:`go test -short ./internal/server`
- **实际输出 (Actual Output)**:`ok 10.069s`
- **评价判定 (Evaluation / Verdict)**:**PASS**
- **性能与耗时 (Duration & Latency)**:10s
### 5.3 前端
- **测试输入 (Input)**:`projectSettingsError.test.ts`
- **实际输出 (Actual Output)**:5 tests pass
- **评价判定 (Evaluation / Verdict)**:**PASS**
- **性能与耗时 (Duration & Latency)**:113ms
## 六、 自动化测试套件汇总 (Test Suite Execution)
上述均 PASS。未改 HTTP 状态码,无 ADR。
## 七、 测试结论与后续优化建议 (Conclusion & Next Steps)
H1–H3 完成。`error` 字段仍作稳定 code 别名以兼容旧客户端。下一步 PI typed-result。
+123
View File
@@ -0,0 +1,123 @@
package httpx
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"net/http"
"strings"
)
const (
CodeAUTHZ = "AUTHZ"
CodeVALIDATION = "VALIDATION"
CodeVERSION_CONFLICT = "VERSION_CONFLICT"
CodeIDEMPOTENCY_CONFLICT = "IDEMPOTENCY_CONFLICT"
CodeINTERNAL = "INTERNAL"
HeaderRequestID = "X-Request-ID"
)
type Envelope struct {
Code string `json:"code"`
Message string `json:"message"`
RequestID string `json:"request_id"`
Error string `json:"error"`
Details map[string]any `json:"details,omitempty"`
}
func CodeFromStatus(status int) string {
switch status {
case http.StatusUnauthorized, http.StatusForbidden:
return CodeAUTHZ
case http.StatusConflict:
return CodeVERSION_CONFLICT
case http.StatusBadRequest, http.StatusUnprocessableEntity:
return CodeVALIDATION
default:
if status >= 500 {
return CodeINTERNAL
}
if status == http.StatusNotFound {
return CodeAUTHZ
}
return CodeVALIDATION
}
}
func SafeMessage(code string) string {
switch code {
case CodeAUTHZ:
return "无权访问"
case CodeVALIDATION:
return "请求参数无效"
case CodeVERSION_CONFLICT:
return "资源版本冲突"
case CodeIDEMPOTENCY_CONFLICT:
return "重复提交"
default:
return "请求无法完成"
}
}
func LooksInternal(s string) bool {
low := strings.ToLower(s)
needles := []string{"sql", "pq:", "postgres", "stack", "panic", "token", "password", "secret", "api_key", "bearer ", "../", "prompt"}
for _, n := range needles {
if strings.Contains(low, n) {
return true
}
}
if strings.Contains(s, "/home/") || strings.Contains(s, "/var/") || strings.Contains(s, "\\") {
return true
}
return false
}
func RequestID(r *http.Request) string {
if r == nil {
return newRequestID()
}
if v := strings.TrimSpace(r.Header.Get(HeaderRequestID)); v != "" {
return v
}
return newRequestID()
}
func newRequestID() string {
var b [8]byte
_, _ = rand.Read(b[:])
return "req_" + hex.EncodeToString(b[:])
}
func PublicLegacy(code, legacy string) string {
legacy = strings.TrimSpace(legacy)
if legacy == "" || LooksInternal(legacy) {
return code
}
if strings.HasPrefix(legacy, "<") || strings.Contains(legacy, "<script") {
return code
}
return legacy
}
func Write(w http.ResponseWriter, status int, code, legacy, requestID string, details map[string]any) {
if code == "" {
code = CodeFromStatus(status)
}
if requestID == "" {
requestID = newRequestID()
}
env := Envelope{
Code: code,
Message: SafeMessage(code),
RequestID: requestID,
Error: PublicLegacy(code, legacy),
Details: details,
}
if w.Header().Get("Content-Type") == "" {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
}
w.Header().Set(HeaderRequestID, requestID)
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(env)
}
+57
View File
@@ -0,0 +1,57 @@
package httpx
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestWriteSanitizesInternalAndKeepsLegacyCode(t *testing.T) {
rec := httptest.NewRecorder()
Write(rec, http.StatusInternalServerError, CodeINTERNAL, "pq: relation foo leaked /home/weichen/secret", "req_test", nil)
var env Envelope
if err := json.Unmarshal(rec.Body.Bytes(), &env); err != nil {
t.Fatal(err)
}
if env.Code != CodeINTERNAL || env.RequestID != "req_test" {
t.Fatalf("%+v", env)
}
if strings.Contains(rec.Body.String(), "pq:") || strings.Contains(rec.Body.String(), "/home/") {
t.Fatalf("leaked: %s", rec.Body.String())
}
if env.Error != CodeINTERNAL {
t.Fatalf("legacy error=%s", env.Error)
}
}
func TestWriteKeepsStablePublicErrorCode(t *testing.T) {
rec := httptest.NewRecorder()
Write(rec, http.StatusForbidden, CodeAUTHZ, "ERR_FORBIDDEN", "req_2", nil)
var env Envelope
_ = json.Unmarshal(rec.Body.Bytes(), &env)
if env.Error != "ERR_FORBIDDEN" || env.Code != CodeAUTHZ {
t.Fatalf("%+v", env)
}
if strings.Contains(rec.Body.String(), "<script") {
t.Fatal("xss")
}
}
func TestLooksInternalXSS(t *testing.T) {
rec := httptest.NewRecorder()
Write(rec, http.StatusBadRequest, CodeVALIDATION, "<script>alert(1)</script>", "req_3", nil)
if strings.Contains(rec.Body.String(), "<script") {
t.Fatalf("%s", rec.Body.String())
}
}
func TestCodeFromStatus(t *testing.T) {
if CodeFromStatus(http.StatusUnauthorized) != CodeAUTHZ {
t.Fatal("401")
}
if CodeFromStatus(http.StatusConflict) != CodeVERSION_CONFLICT {
t.Fatal("409")
}
}
@@ -12,7 +12,7 @@ func (s *Server) handleAdminPermissions(w http.ResponseWriter, r *http.Request)
return return
} }
if r.Method != http.MethodGet { if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed) writeSafeError(w, r, http.StatusMethodNotAllowed, "method not allowed")
return return
} }
catalog := authz.PermissionCatalog() catalog := authz.PermissionCatalog()
+9 -9
View File
@@ -18,14 +18,14 @@ func (s *Server) handleAdminRoles(w http.ResponseWriter, r *http.Request) {
case http.MethodGet: case http.MethodGet:
roles, err := s.authzSvc.ListRoles(r.Context()) roles, err := s.authzSvc.ListRoles(r.Context())
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) writeSafeError(w, r, http.StatusInternalServerError, err.Error())
return return
} }
writeJSONResponse(w, http.StatusOK, map[string]any{"roles": roles}) writeJSONResponse(w, http.StatusOK, map[string]any{"roles": roles})
case http.MethodPost: case http.MethodPost:
var req authz.CreateRoleInput var req authz.CreateRoleInput
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "无效的 JSON 请求体", http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, "无效的 JSON 请求体")
return return
} }
created, err := s.authzSvc.CreateRole(r.Context(), req) created, err := s.authzSvc.CreateRole(r.Context(), req)
@@ -34,12 +34,12 @@ func (s *Server) handleAdminRoles(w http.ResponseWriter, r *http.Request) {
if errors.Is(err, authz.ErrRolesNotAvailable) { if errors.Is(err, authz.ErrRolesNotAvailable) {
status = http.StatusServiceUnavailable status = http.StatusServiceUnavailable
} }
http.Error(w, err.Error(), status) writeSafeError(w, r, status, err.Error())
return return
} }
writeJSONResponse(w, http.StatusCreated, created) writeJSONResponse(w, http.StatusCreated, created)
default: default:
http.Error(w, "不支持的请求方法", http.StatusMethodNotAllowed) writeSafeError(w, r, http.StatusMethodNotAllowed, "不支持的请求方法")
} }
} }
@@ -50,7 +50,7 @@ func (s *Server) handleAdminRoleByID(w http.ResponseWriter, r *http.Request) {
roleID := strings.TrimSpace(r.PathValue("id")) roleID := strings.TrimSpace(r.PathValue("id"))
if roleID == "" { if roleID == "" {
http.Error(w, "无效的角色 ID", http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, "无效的角色 ID")
return return
} }
@@ -58,7 +58,7 @@ func (s *Server) handleAdminRoleByID(w http.ResponseWriter, r *http.Request) {
case http.MethodPatch, http.MethodPut: case http.MethodPatch, http.MethodPut:
var req authz.UpdateRoleInput var req authz.UpdateRoleInput
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "无效的 JSON 请求体", http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, "无效的 JSON 请求体")
return return
} }
updated, err := s.authzSvc.UpdateRole(r.Context(), roleID, req) updated, err := s.authzSvc.UpdateRole(r.Context(), roleID, req)
@@ -72,7 +72,7 @@ func (s *Server) handleAdminRoleByID(w http.ResponseWriter, r *http.Request) {
case errors.Is(err, authz.ErrRolesNotAvailable): case errors.Is(err, authz.ErrRolesNotAvailable):
status = http.StatusServiceUnavailable status = http.StatusServiceUnavailable
} }
http.Error(w, err.Error(), status) writeSafeError(w, r, status, err.Error())
return return
} }
writeJSONResponse(w, http.StatusOK, updated) writeJSONResponse(w, http.StatusOK, updated)
@@ -89,11 +89,11 @@ func (s *Server) handleAdminRoleByID(w http.ResponseWriter, r *http.Request) {
case errors.Is(err, authz.ErrRolesNotAvailable): case errors.Is(err, authz.ErrRolesNotAvailable):
status = http.StatusServiceUnavailable status = http.StatusServiceUnavailable
} }
http.Error(w, err.Error(), status) writeSafeError(w, r, status, err.Error())
return return
} }
writeJSONResponse(w, http.StatusOK, map[string]bool{"success": true}) writeJSONResponse(w, http.StatusOK, map[string]bool{"success": true})
default: default:
http.Error(w, "不支持的请求方法", http.StatusMethodNotAllowed) writeSafeError(w, r, http.StatusMethodNotAllowed, "不支持的请求方法")
} }
} }
+10 -10
View File
@@ -19,17 +19,17 @@ func (s *Server) handleAdminTemplates(w http.ResponseWriter, r *http.Request) {
case http.MethodGet: case http.MethodGet:
assets, err := s.wfDefSvc.ListTemplates(r.Context()) assets, err := s.wfDefSvc.ListTemplates(r.Context())
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) writeSafeError(w, r, http.StatusInternalServerError, err.Error())
return return
} }
writeJSONResponse(w, http.StatusOK, map[string]any{"templates": assets}) writeJSONResponse(w, http.StatusOK, map[string]any{"templates": assets})
case http.MethodPost: case http.MethodPost:
if err := r.ParseMultipartForm(defaultUploadBodyLimit); err != nil { if err := r.ParseMultipartForm(defaultUploadBodyLimit); err != nil {
if isRequestBodyTooLarge(err) { if isRequestBodyTooLarge(err) {
http.Error(w, "upload too large", http.StatusRequestEntityTooLarge) writeSafeError(w, r, http.StatusRequestEntityTooLarge, "upload too large")
return return
} }
http.Error(w, "invalid multipart", http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, "invalid multipart")
return return
} }
assetKey := strings.TrimSpace(r.FormValue("asset_key")) assetKey := strings.TrimSpace(r.FormValue("asset_key"))
@@ -42,13 +42,13 @@ func (s *Server) handleAdminTemplates(w http.ResponseWriter, r *http.Request) {
} }
file, header, err := r.FormFile("file") file, header, err := r.FormFile("file")
if err != nil || assetKey == "" { if err != nil || assetKey == "" {
http.Error(w, "asset_key and file required", http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, "asset_key and file required")
return return
} }
defer file.Close() defer file.Close()
data, err := io.ReadAll(io.LimitReader(file, 32<<20)) data, err := io.ReadAll(io.LimitReader(file, 32<<20))
if err != nil { if err != nil {
http.Error(w, "read file failed", http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, "read file failed")
return 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)
@@ -60,12 +60,12 @@ func (s *Server) handleAdminTemplates(w http.ResponseWriter, r *http.Request) {
case errors.Is(err, workflowdef.ErrNotAvailable): case errors.Is(err, workflowdef.ErrNotAvailable):
status = http.StatusServiceUnavailable status = http.StatusServiceUnavailable
} }
http.Error(w, err.Error(), status) writeSafeError(w, r, status, err.Error())
return return
} }
writeJSONResponse(w, http.StatusCreated, tv) writeJSONResponse(w, http.StatusCreated, tv)
default: default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed) writeSafeError(w, r, http.StatusMethodNotAllowed, "method not allowed")
} }
} }
@@ -74,17 +74,17 @@ func (s *Server) handleAdminTemplateVersions(w http.ResponseWriter, r *http.Requ
return return
} }
if r.Method != http.MethodGet { if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed) writeSafeError(w, r, http.StatusMethodNotAllowed, "method not allowed")
return return
} }
assetKey := strings.TrimSpace(r.PathValue("key")) assetKey := strings.TrimSpace(r.PathValue("key"))
if assetKey == "" { if assetKey == "" {
http.Error(w, "invalid asset key", http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, "invalid asset key")
return return
} }
versions, err := s.wfDefSvc.ListTemplateVersions(r.Context(), assetKey) versions, err := s.wfDefSvc.ListTemplateVersions(r.Context(), assetKey)
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) writeSafeError(w, r, http.StatusInternalServerError, err.Error())
return return
} }
writeJSONResponse(w, http.StatusOK, map[string]any{"versions": versions}) writeJSONResponse(w, http.StatusOK, map[string]any{"versions": versions})
+20 -20
View File
@@ -16,7 +16,7 @@ func (s *Server) requireWorkflowAdmin(w http.ResponseWriter, r *http.Request) (*
return nil, false return nil, false
} }
if s.wfDefSvc == nil || !s.wfDefSvc.Available() { if s.wfDefSvc == nil || !s.wfDefSvc.Available() {
http.Error(w, "工作流定义服务需要数据库 IAM", http.StatusServiceUnavailable) writeSafeError(w, r, http.StatusServiceUnavailable, "工作流定义服务需要数据库 IAM")
return nil, false return nil, false
} }
return user, true return user, true
@@ -31,14 +31,14 @@ func (s *Server) handleAdminWorkflows(w http.ResponseWriter, r *http.Request) {
case http.MethodGet: case http.MethodGet:
defs, err := s.wfDefSvc.ListDefinitions(r.Context()) defs, err := s.wfDefSvc.ListDefinitions(r.Context())
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) writeSafeError(w, r, http.StatusInternalServerError, err.Error())
return return
} }
writeJSONResponse(w, http.StatusOK, map[string]any{"workflows": defs}) writeJSONResponse(w, http.StatusOK, map[string]any{"workflows": defs})
case http.MethodPost: case http.MethodPost:
var req workflowdef.CreateDefinitionInput var req workflowdef.CreateDefinitionInput
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "无效的 JSON", http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, "无效的 JSON")
return return
} }
def, ver, err := s.wfDefSvc.CreateDefinition(r.Context(), req, user.ID) def, ver, err := s.wfDefSvc.CreateDefinition(r.Context(), req, user.ID)
@@ -47,12 +47,12 @@ func (s *Server) handleAdminWorkflows(w http.ResponseWriter, r *http.Request) {
if errors.Is(err, workflowdef.ErrValidationFailed) { if errors.Is(err, workflowdef.ErrValidationFailed) {
status = http.StatusUnprocessableEntity status = http.StatusUnprocessableEntity
} }
http.Error(w, err.Error(), status) writeSafeError(w, r, status, err.Error())
return return
} }
writeJSONResponse(w, http.StatusCreated, map[string]any{"definition": def, "version": ver}) writeJSONResponse(w, http.StatusCreated, map[string]any{"definition": def, "version": ver})
default: default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed) writeSafeError(w, r, http.StatusMethodNotAllowed, "method not allowed")
} }
} }
@@ -62,21 +62,21 @@ func (s *Server) handleAdminWorkflowByID(w http.ResponseWriter, r *http.Request)
} }
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || id <= 0 { if err != nil || id <= 0 {
http.Error(w, "invalid workflow id", http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, "invalid workflow id")
return return
} }
if r.Method != http.MethodGet { if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed) writeSafeError(w, r, http.StatusMethodNotAllowed, "method not allowed")
return return
} }
def, err := s.wfDefSvc.GetDefinition(r.Context(), id) def, err := s.wfDefSvc.GetDefinition(r.Context(), id)
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusNotFound) writeSafeError(w, r, http.StatusNotFound, err.Error())
return return
} }
versions, err := s.wfDefSvc.ListVersions(r.Context(), id) versions, err := s.wfDefSvc.ListVersions(r.Context(), id)
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) writeSafeError(w, r, http.StatusInternalServerError, err.Error())
return return
} }
writeJSONResponse(w, http.StatusOK, map[string]any{"definition": def, "versions": versions}) writeJSONResponse(w, http.StatusOK, map[string]any{"definition": def, "versions": versions})
@@ -89,12 +89,12 @@ func (s *Server) handleAdminWorkflowVersion(w http.ResponseWriter, r *http.Reque
} }
workflowID, err := strconv.ParseInt(r.PathValue("id"), 10, 64) workflowID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || workflowID <= 0 { if err != nil || workflowID <= 0 {
http.Error(w, "invalid workflow id", http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, "invalid workflow id")
return return
} }
version, err := strconv.Atoi(r.PathValue("version")) version, err := strconv.Atoi(r.PathValue("version"))
if err != nil || version <= 0 { if err != nil || version <= 0 {
http.Error(w, "invalid version", http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, "invalid version")
return return
} }
@@ -104,7 +104,7 @@ func (s *Server) handleAdminWorkflowVersion(w http.ResponseWriter, r *http.Reque
Definition workflowdef.VersionDefinition `json:"definition"` Definition workflowdef.VersionDefinition `json:"definition"`
} }
if err := json.NewDecoder(r.Body).Decode(&body); err != nil { if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, "invalid json", http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, "invalid json")
return return
} }
updated, err := s.wfDefSvc.UpdateDraft(r.Context(), workflowID, version, body.Definition) updated, err := s.wfDefSvc.UpdateDraft(r.Context(), workflowID, version, body.Definition)
@@ -113,12 +113,12 @@ func (s *Server) handleAdminWorkflowVersion(w http.ResponseWriter, r *http.Reque
if errors.Is(err, workflowdef.ErrNotDraft) { if errors.Is(err, workflowdef.ErrNotDraft) {
status = http.StatusConflict status = http.StatusConflict
} }
http.Error(w, err.Error(), status) writeSafeError(w, r, status, err.Error())
return return
} }
writeJSONResponse(w, http.StatusOK, updated) writeJSONResponse(w, http.StatusOK, updated)
default: default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed) writeSafeError(w, r, http.StatusMethodNotAllowed, "method not allowed")
} }
_ = user _ = user
} }
@@ -128,14 +128,14 @@ func (s *Server) handleAdminWorkflowValidate(w http.ResponseWriter, r *http.Requ
return return
} }
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed) writeSafeError(w, r, http.StatusMethodNotAllowed, "method not allowed")
return return
} }
workflowID, _ := strconv.ParseInt(r.PathValue("id"), 10, 64) workflowID, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
version, _ := strconv.Atoi(r.PathValue("version")) 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 { if err != nil {
http.Error(w, err.Error(), http.StatusNotFound) writeSafeError(w, r, http.StatusNotFound, err.Error())
return return
} }
writeJSONResponse(w, http.StatusOK, res) writeJSONResponse(w, http.StatusOK, res)
@@ -147,7 +147,7 @@ func (s *Server) handleAdminWorkflowPublish(w http.ResponseWriter, r *http.Reque
return return
} }
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed) writeSafeError(w, r, http.StatusMethodNotAllowed, "method not allowed")
return return
} }
workflowID, _ := strconv.ParseInt(r.PathValue("id"), 10, 64) workflowID, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
@@ -158,7 +158,7 @@ func (s *Server) handleAdminWorkflowPublish(w http.ResponseWriter, r *http.Reque
if errors.Is(err, workflowdef.ErrValidationFailed) { if errors.Is(err, workflowdef.ErrValidationFailed) {
status = http.StatusUnprocessableEntity status = http.StatusUnprocessableEntity
} }
http.Error(w, err.Error(), status) writeSafeError(w, r, status, err.Error())
return return
} }
if dbCat, ok := s.pwSvc.Catalog().(*workflowdef.DBCatalog); ok { if dbCat, ok := s.pwSvc.Catalog().(*workflowdef.DBCatalog); ok {
@@ -172,14 +172,14 @@ func (s *Server) handleAdminWorkflowRetire(w http.ResponseWriter, r *http.Reques
return return
} }
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed) writeSafeError(w, r, http.StatusMethodNotAllowed, "method not allowed")
return return
} }
workflowID, _ := strconv.ParseInt(r.PathValue("id"), 10, 64) workflowID, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
version, _ := strconv.Atoi(r.PathValue("version")) 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 { if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, err.Error())
return return
} }
if dbCat, ok := s.pwSvc.Catalog().(*workflowdef.DBCatalog); ok { if dbCat, ok := s.pwSvc.Catalog().(*workflowdef.DBCatalog); ok {
+11 -11
View File
@@ -22,7 +22,7 @@ type projectAssetResponse struct {
func (s *Server) handleUploadProjectAsset(w http.ResponseWriter, r *http.Request) { func (s *Server) handleUploadProjectAsset(w http.ResponseWriter, r *http.Request) {
projectID, err := strconv.ParseInt(r.PathValue("id"), 10, 64) projectID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || projectID <= 0 { if err != nil || projectID <= 0 {
http.Error(w, "invalid project id", http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, "invalid project id")
return return
} }
user, ok := s.requireProjectAccess(w, r, projectID) user, ok := s.requireProjectAccess(w, r, projectID)
@@ -32,26 +32,26 @@ func (s *Server) handleUploadProjectAsset(w http.ResponseWriter, r *http.Request
userID := user.ID userID := user.ID
if err := r.ParseMultipartForm(defaultUploadBodyLimit); err != nil { if err := r.ParseMultipartForm(defaultUploadBodyLimit); err != nil {
if isRequestBodyTooLarge(err) { if isRequestBodyTooLarge(err) {
http.Error(w, "upload too large", http.StatusRequestEntityTooLarge) writeSafeError(w, r, http.StatusRequestEntityTooLarge, "upload too large")
return return
} }
http.Error(w, "invalid multipart form: "+err.Error(), http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, "invalid multipart form: "+err.Error())
return return
} }
file, header, err := r.FormFile("file") file, header, err := r.FormFile("file")
if err != nil { if err != nil {
http.Error(w, "file required", http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, "file required")
return return
} }
defer file.Close() defer file.Close()
data, err := io.ReadAll(file) data, err := io.ReadAll(file)
if err != nil { if err != nil {
http.Error(w, "read file failed", http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, "read file failed")
return return
} }
if len(data) == 0 { if len(data) == 0 {
http.Error(w, "cannot store empty file", http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, "cannot store empty file")
return return
} }
@@ -62,18 +62,18 @@ func (s *Server) handleUploadProjectAsset(w http.ResponseWriter, r *http.Request
declaredMIME := header.Header.Get("Content-Type") declaredMIME := header.Header.Get("Content-Type")
mimeType, verr := upload.ValidateProjectMaterial(filename, declaredMIME, data) mimeType, verr := upload.ValidateProjectMaterial(filename, declaredMIME, data)
if verr != nil { if verr != nil {
http.Error(w, "upload rejected: "+verr.Error(), http.StatusUnsupportedMediaType) writeSafeError(w, r, http.StatusUnsupportedMediaType, "upload rejected: "+verr.Error())
return return
} }
cas := s.cas cas := s.cas
if cas == nil { if cas == nil {
http.Error(w, "storage unavailable", http.StatusServiceUnavailable) writeSafeError(w, r, http.StatusServiceUnavailable, "storage unavailable")
return return
} }
ref, err := cas.StoreFile(r.Context(), projectID, userID, filepath.Base(filename), "material", data, mimeType) ref, err := cas.StoreFile(r.Context(), projectID, userID, filepath.Base(filename), "material", data, mimeType)
if err != nil { if err != nil {
http.Error(w, "store asset failed: "+err.Error(), http.StatusInternalServerError) writeSafeError(w, r, http.StatusInternalServerError, "store asset failed: "+err.Error())
return return
} }
@@ -90,7 +90,7 @@ func (s *Server) handleUploadProjectAsset(w http.ResponseWriter, r *http.Request
func (s *Server) handleListProjectAssets(w http.ResponseWriter, r *http.Request) { func (s *Server) handleListProjectAssets(w http.ResponseWriter, r *http.Request) {
projectID, err := strconv.ParseInt(r.PathValue("id"), 10, 64) projectID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || projectID <= 0 { if err != nil || projectID <= 0 {
http.Error(w, "invalid project id", http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, "invalid project id")
return return
} }
if _, ok := s.requireProjectAccess(w, r, projectID); !ok { if _, ok := s.requireProjectAccess(w, r, projectID); !ok {
@@ -103,7 +103,7 @@ func (s *Server) handleListProjectAssets(w http.ResponseWriter, r *http.Request)
} }
refs, err := cas.ListProjectRefs(r.Context(), projectID) refs, err := cas.ListProjectRefs(r.Context(), projectID)
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) writeSafeError(w, r, http.StatusInternalServerError, err.Error())
return return
} }
out := make([]projectAssetResponse, 0, len(refs)) out := make([]projectAssetResponse, 0, len(refs))
+34 -34
View File
@@ -43,7 +43,7 @@ func (s *Server) handleAuthLogin(w http.ResponseWriter, r *http.Request) {
Password string `json:"password"` Password string `json:"password"`
} }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "无效的 JSON 请求体", http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, "无效的 JSON 请求体")
return return
} }
@@ -51,14 +51,14 @@ func (s *Server) handleAuthLogin(w http.ResponseWriter, r *http.Request) {
if err != nil { if err != nil {
errMsg := err.Error() errMsg := err.Error()
if strings.Contains(errMsg, "审核") { if strings.Contains(errMsg, "审核") {
http.Error(w, errMsg, http.StatusForbidden) writeSafeError(w, r, http.StatusForbidden, errMsg)
return return
} }
if strings.Contains(errMsg, "停用") { if strings.Contains(errMsg, "停用") {
http.Error(w, errMsg, http.StatusForbidden) writeSafeError(w, r, http.StatusForbidden, errMsg)
return return
} }
http.Error(w, errMsg, http.StatusUnauthorized) writeSafeError(w, r, http.StatusUnauthorized, errMsg)
return return
} }
@@ -70,13 +70,13 @@ func (s *Server) handleAuthLogin(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleAuthRegister(w http.ResponseWriter, r *http.Request) { func (s *Server) handleAuthRegister(w http.ResponseWriter, r *http.Request) {
var req auth.RegisterRequest var req auth.RegisterRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "无效的 JSON 请求体", http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, "无效的 JSON 请求体")
return return
} }
resp, err := s.authSvc.Register(r.Context(), req) resp, err := s.authSvc.Register(r.Context(), req)
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, err.Error())
return return
} }
@@ -98,7 +98,7 @@ func (s *Server) handleAuthLogout(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleAuthChangePassword(w http.ResponseWriter, r *http.Request) { func (s *Server) handleAuthChangePassword(w http.ResponseWriter, r *http.Request) {
user, err := s.authenticateUser(r) user, err := s.authenticateUser(r)
if err != nil { if err != nil {
http.Error(w, "未登录或登录已过期", http.StatusUnauthorized) writeSafeError(w, r, http.StatusUnauthorized, "未登录或登录已过期")
return return
} }
@@ -107,12 +107,12 @@ func (s *Server) handleAuthChangePassword(w http.ResponseWriter, r *http.Request
NewPassword string `json:"new_password"` NewPassword string `json:"new_password"`
} }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "无效的 JSON 请求体", http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, "无效的 JSON 请求体")
return 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 {
http.Error(w, err.Error(), http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, err.Error())
return return
} }
@@ -124,7 +124,7 @@ func (s *Server) handleAuthChangePassword(w http.ResponseWriter, r *http.Request
func (s *Server) handleAuthMe(w http.ResponseWriter, r *http.Request) { func (s *Server) handleAuthMe(w http.ResponseWriter, r *http.Request) {
user, err := s.authenticateUser(r) user, err := s.authenticateUser(r)
if err != nil { if err != nil {
http.Error(w, "未登录或登录已过期", http.StatusUnauthorized) writeSafeError(w, r, http.StatusUnauthorized, "未登录或登录已过期")
return return
} }
@@ -163,14 +163,14 @@ func (s *Server) resolveUserCapabilities(ctx context.Context, user *auth.UserAcc
func (s *Server) handleAuthSessions(w http.ResponseWriter, r *http.Request) { func (s *Server) handleAuthSessions(w http.ResponseWriter, r *http.Request) {
user, err := s.authenticateUser(r) user, err := s.authenticateUser(r)
if err != nil { if err != nil {
http.Error(w, "未登录或登录已过期", http.StatusUnauthorized) writeSafeError(w, r, http.StatusUnauthorized, "未登录或登录已过期")
return return
} }
if r.Method == http.MethodGet { if r.Method == http.MethodGet {
token := extractBearerToken(r) 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 { if listErr != nil {
http.Error(w, listErr.Error(), http.StatusInternalServerError) writeSafeError(w, r, http.StatusInternalServerError, listErr.Error())
return return
} }
if sessions == nil { if sessions == nil {
@@ -179,26 +179,26 @@ func (s *Server) handleAuthSessions(w http.ResponseWriter, r *http.Request) {
writeJSONResponse(w, http.StatusOK, map[string]any{"sessions": sessions}) writeJSONResponse(w, http.StatusOK, map[string]any{"sessions": sessions})
return return
} }
http.Error(w, "不支持的请求方法", http.StatusMethodNotAllowed) writeSafeError(w, r, http.StatusMethodNotAllowed, "不支持的请求方法")
} }
func (s *Server) handleAuthSessionByID(w http.ResponseWriter, r *http.Request) { func (s *Server) handleAuthSessionByID(w http.ResponseWriter, r *http.Request) {
user, err := s.authenticateUser(r) user, err := s.authenticateUser(r)
if err != nil { if err != nil {
http.Error(w, "未登录或登录已过期", http.StatusUnauthorized) writeSafeError(w, r, http.StatusUnauthorized, "未登录或登录已过期")
return return
} }
if r.Method != http.MethodDelete { if r.Method != http.MethodDelete {
http.Error(w, "不支持的请求方法", http.StatusMethodNotAllowed) writeSafeError(w, r, http.StatusMethodNotAllowed, "不支持的请求方法")
return return
} }
sessionID := strings.TrimSpace(r.PathValue("id")) sessionID := strings.TrimSpace(r.PathValue("id"))
if sessionID == "" { if sessionID == "" {
http.Error(w, "无效的会话 ID", http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, "无效的会话 ID")
return 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 {
http.Error(w, "会话不存在或已失效", http.StatusNotFound) writeSafeError(w, r, http.StatusNotFound, "会话不存在或已失效")
return return
} }
writeJSONResponse(w, http.StatusOK, map[string]bool{"success": true}) writeJSONResponse(w, http.StatusOK, map[string]bool{"success": true})
@@ -213,7 +213,7 @@ func (s *Server) handleAdminUsers(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet { if r.Method == http.MethodGet {
users, err := s.authSvc.ListUsers(r.Context()) users, err := s.authSvc.ListUsers(r.Context())
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) writeSafeError(w, r, http.StatusInternalServerError, err.Error())
return return
} }
w.Header().Set("Content-Type", "application/json; charset=utf-8") w.Header().Set("Content-Type", "application/json; charset=utf-8")
@@ -224,12 +224,12 @@ func (s *Server) handleAdminUsers(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost { if r.Method == http.MethodPost {
var req auth.CreateUserRequest var req auth.CreateUserRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "无效的 JSON 请求体", http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, "无效的 JSON 请求体")
return return
} }
created, err := s.authSvc.CreateUserByAdmin(r.Context(), req) created, err := s.authSvc.CreateUserByAdmin(r.Context(), req)
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, err.Error())
return return
} }
w.Header().Set("Content-Type", "application/json; charset=utf-8") w.Header().Set("Content-Type", "application/json; charset=utf-8")
@@ -237,7 +237,7 @@ func (s *Server) handleAdminUsers(w http.ResponseWriter, r *http.Request) {
return return
} }
http.Error(w, "不支持的请求方法", http.StatusMethodNotAllowed) writeSafeError(w, r, http.StatusMethodNotAllowed, "不支持的请求方法")
} }
// handleAdminUpdateRole 管理员为用户指定职位与角色 // handleAdminUpdateRole 管理员为用户指定职位与角色
@@ -248,7 +248,7 @@ func (s *Server) handleAdminUpdateRole(w http.ResponseWriter, r *http.Request) {
targetID, err := strconv.ParseInt(r.PathValue("id"), 10, 64) targetID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || targetID <= 0 { if err != nil || targetID <= 0 {
http.Error(w, "无效的用户ID", http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, "无效的用户ID")
return return
} }
@@ -257,13 +257,13 @@ func (s *Server) handleAdminUpdateRole(w http.ResponseWriter, r *http.Request) {
Position string `json:"position"` Position string `json:"position"`
} }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "无效的请求体", http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, "无效的请求体")
return 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 { if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, err.Error())
return return
} }
@@ -279,7 +279,7 @@ func (s *Server) handleAdminUpdateStatus(w http.ResponseWriter, r *http.Request)
targetID, err := strconv.ParseInt(r.PathValue("id"), 10, 64) targetID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || targetID <= 0 { if err != nil || targetID <= 0 {
http.Error(w, "无效的用户ID", http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, "无效的用户ID")
return return
} }
@@ -289,20 +289,20 @@ func (s *Server) handleAdminUpdateStatus(w http.ResponseWriter, r *http.Request)
Position string `json:"position,omitempty"` Position string `json:"position,omitempty"`
} }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "无效的请求体", http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, "无效的请求体")
return return
} }
if req.RoleID != "" { 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 {
http.Error(w, roleErr.Error(), http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, roleErr.Error())
return return
} }
} }
updated, err := s.authSvc.UpdateUserStatus(r.Context(), targetID, req.Status) updated, err := s.authSvc.UpdateUserStatus(r.Context(), targetID, req.Status)
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, err.Error())
return return
} }
@@ -313,12 +313,12 @@ func (s *Server) handleAdminUpdateStatus(w http.ResponseWriter, r *http.Request)
// handleAuthPublicConfig 返回登录/注册页所需的公开准入配置(无需鉴权) // handleAuthPublicConfig 返回登录/注册页所需的公开准入配置(无需鉴权)
func (s *Server) handleAuthPublicConfig(w http.ResponseWriter, r *http.Request) { func (s *Server) handleAuthPublicConfig(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet { if r.Method != http.MethodGet {
http.Error(w, "不支持的方法", http.StatusMethodNotAllowed) writeSafeError(w, r, http.StatusMethodNotAllowed, "不支持的方法")
return return
} }
cfg, err := s.authSvc.GetConfig(r.Context()) cfg, err := s.authSvc.GetConfig(r.Context())
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) writeSafeError(w, r, http.StatusInternalServerError, err.Error())
return return
} }
w.Header().Set("Content-Type", "application/json; charset=utf-8") w.Header().Set("Content-Type", "application/json; charset=utf-8")
@@ -334,7 +334,7 @@ func (s *Server) handleAdminConfig(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet { if r.Method == http.MethodGet {
cfg, err := s.authSvc.GetConfig(r.Context()) cfg, err := s.authSvc.GetConfig(r.Context())
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) writeSafeError(w, r, http.StatusInternalServerError, err.Error())
return return
} }
w.Header().Set("Content-Type", "application/json; charset=utf-8") w.Header().Set("Content-Type", "application/json; charset=utf-8")
@@ -345,11 +345,11 @@ func (s *Server) handleAdminConfig(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPut || r.Method == http.MethodPost { if r.Method == http.MethodPut || r.Method == http.MethodPost {
var req auth.SystemAuthConfig var req auth.SystemAuthConfig
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "无效的请求体", http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, "无效的请求体")
return return
} }
if err := s.authSvc.UpdateConfig(r.Context(), req); err != nil { if err := s.authSvc.UpdateConfig(r.Context(), req); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) writeSafeError(w, r, http.StatusInternalServerError, err.Error())
return return
} }
w.Header().Set("Content-Type", "application/json; charset=utf-8") w.Header().Set("Content-Type", "application/json; charset=utf-8")
@@ -357,5 +357,5 @@ func (s *Server) handleAdminConfig(w http.ResponseWriter, r *http.Request) {
return return
} }
http.Error(w, "不支持的方法", http.StatusMethodNotAllowed) writeSafeError(w, r, http.StatusMethodNotAllowed, "不支持的方法")
} }
+3 -3
View File
@@ -57,7 +57,7 @@ func writeAuthzError(w http.ResponseWriter, err error) {
"message": "请先登录", "message": "请先登录",
}) })
default: default:
http.Error(w, err.Error(), http.StatusInternalServerError) writeSafeError(w, nil, http.StatusInternalServerError, err.Error())
} }
} }
@@ -68,7 +68,7 @@ func (s *Server) requireAuthenticatedUser(w http.ResponseWriter, r *http.Request
} }
user, err := s.authenticateUser(r) user, err := s.authenticateUser(r)
if err != nil || user == nil { if err != nil || user == nil {
http.Error(w, `{"error":"ERR_UNAUTHENTICATED","message":"请先登录"}`, http.StatusUnauthorized) writeSafeError(w, r, http.StatusUnauthorized, `{"error":"ERR_UNAUTHENTICATED","message":"请先登录"}`)
return nil, false return nil, false
} }
return user, true return user, true
@@ -77,7 +77,7 @@ func (s *Server) requireAuthenticatedUser(w http.ResponseWriter, r *http.Request
// assertSessionOwnedByUser 校验会话归属当前用户;会话不存在或归属他人返回 false。 // assertSessionOwnedByUser 校验会话归属当前用户;会话不存在或归属他人返回 false。
func (s *Server) assertSessionOwnedByUser(w http.ResponseWriter, r *http.Request, sessionID string, userID int64) bool { func (s *Server) assertSessionOwnedByUser(w http.ResponseWriter, r *http.Request, sessionID string, userID int64) bool {
if s.ctxMgr == nil { if s.ctxMgr == nil {
http.Error(w, "context manager not initialized", http.StatusServiceUnavailable) writeSafeError(w, r, http.StatusServiceUnavailable, "context manager not initialized")
return false return false
} }
ownerID, err := s.ctxMgr.GetSessionOwnerID(r.Context(), sessionID) ownerID, err := s.ctxMgr.GetSessionOwnerID(r.Context(), sessionID)
+7 -7
View File
@@ -13,12 +13,12 @@ import (
// GET /api/v1/chat/digests?session_id=&from=&to= // GET /api/v1/chat/digests?session_id=&from=&to=
func (s *Server) handleListDailyDigests(w http.ResponseWriter, r *http.Request) { func (s *Server) handleListDailyDigests(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet { if r.Method != http.MethodGet {
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) writeSafeError(w, r, http.StatusMethodNotAllowed, "Method Not Allowed")
return return
} }
if s.chatLog == nil { if s.chatLog == nil {
http.Error(w, "context manager not initialized", http.StatusServiceUnavailable) writeSafeError(w, r, http.StatusServiceUnavailable, "context manager not initialized")
return return
} }
@@ -29,7 +29,7 @@ func (s *Server) handleListDailyDigests(w http.ResponseWriter, r *http.Request)
sessionID := strings.TrimSpace(r.URL.Query().Get("session_id")) sessionID := strings.TrimSpace(r.URL.Query().Get("session_id"))
if sessionID == "" { if sessionID == "" {
http.Error(w, "missing required query param 'session_id'", http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, "missing required query param 'session_id'")
return return
} }
@@ -60,12 +60,12 @@ type GenerateDigestRequest struct {
// POST /api/v1/chat/digests/generate // POST /api/v1/chat/digests/generate
func (s *Server) handleGenerateDailyDigest(w http.ResponseWriter, r *http.Request) { func (s *Server) handleGenerateDailyDigest(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) writeSafeError(w, r, http.StatusMethodNotAllowed, "Method Not Allowed")
return return
} }
if s.chatLog == nil { if s.chatLog == nil {
http.Error(w, "context manager not initialized", http.StatusServiceUnavailable) writeSafeError(w, r, http.StatusServiceUnavailable, "context manager not initialized")
return return
} }
@@ -76,7 +76,7 @@ func (s *Server) handleGenerateDailyDigest(w http.ResponseWriter, r *http.Reques
var req GenerateDigestRequest var req GenerateDigestRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request json body: "+err.Error(), http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, "invalid request json body: "+err.Error())
return return
} }
@@ -84,7 +84,7 @@ func (s *Server) handleGenerateDailyDigest(w http.ResponseWriter, r *http.Reques
req.DayKey = strings.TrimSpace(req.DayKey) req.DayKey = strings.TrimSpace(req.DayKey)
if req.SessionID == "" || req.DayKey == "" { if req.SessionID == "" || req.DayKey == "" {
http.Error(w, "missing required fields 'session_id' or 'day_key'", http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, "missing required fields 'session_id' or 'day_key'")
return return
} }
+3 -3
View File
@@ -17,7 +17,7 @@ import (
func (s *Server) handleChatExportMessages(w http.ResponseWriter, r *http.Request) { func (s *Server) handleChatExportMessages(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet { if r.Method != http.MethodGet {
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) writeSafeError(w, r, http.StatusMethodNotAllowed, "Method Not Allowed")
return return
} }
@@ -28,12 +28,12 @@ func (s *Server) handleChatExportMessages(w http.ResponseWriter, r *http.Request
sessionID := strings.TrimSpace(r.URL.Query().Get("session_id")) sessionID := strings.TrimSpace(r.URL.Query().Get("session_id"))
if sessionID == "" { if sessionID == "" {
http.Error(w, "missing required query param 'session_id'", http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, "missing required query param 'session_id'")
return return
} }
if s.chatLog == nil { if s.chatLog == nil {
http.Error(w, "context manager not initialized", http.StatusServiceUnavailable) writeSafeError(w, r, http.StatusServiceUnavailable, "context manager not initialized")
return return
} }
+1 -1
View File
@@ -26,7 +26,7 @@ func (s *Server) authorizeSessionRead(
user *auth.UserAccount, user *auth.UserAccount,
) (*chatHistoryAuth, bool) { ) (*chatHistoryAuth, bool) {
if user == nil { if user == nil {
http.Error(w, `{"error":"ERR_UNAUTHENTICATED","message":"请先登录"}`, http.StatusUnauthorized) writeSafeError(w, r, http.StatusUnauthorized, `{"error":"ERR_UNAUTHENTICATED","message":"请先登录"}`)
return nil, false return nil, false
} }
if !s.assertSessionOwnedByUser(w, r, sessionID, user.ID) { if !s.assertSessionOwnedByUser(w, r, sessionID, user.ID) {
+1 -1
View File
@@ -13,7 +13,7 @@ type recategorizeReq struct {
func (s *Server) handleChatRecategorize(w http.ResponseWriter, r *http.Request) { func (s *Server) handleChatRecategorize(w http.ResponseWriter, r *http.Request) {
if s.ctxMgr == nil { if s.ctxMgr == nil {
http.Error(w, "context manager unavailable", http.StatusServiceUnavailable) writeSafeError(w, r, http.StatusServiceUnavailable, "context manager unavailable")
return return
} }
+4 -4
View File
@@ -14,7 +14,7 @@ import (
// GET /api/v1/chat/search?session_id=&q=&mode=&category=&date_from=&date_to=&limit=&user_id= // GET /api/v1/chat/search?session_id=&q=&mode=&category=&date_from=&date_to=&limit=&user_id=
func (s *Server) handleChatSearch(w http.ResponseWriter, r *http.Request) { func (s *Server) handleChatSearch(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet { if r.Method != http.MethodGet {
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) writeSafeError(w, r, http.StatusMethodNotAllowed, "Method Not Allowed")
return return
} }
@@ -24,19 +24,19 @@ func (s *Server) handleChatSearch(w http.ResponseWriter, r *http.Request) {
} }
if s.chatLog == nil { if s.chatLog == nil {
http.Error(w, "context manager not initialized", http.StatusServiceUnavailable) writeSafeError(w, r, http.StatusServiceUnavailable, "context manager not initialized")
return return
} }
sessionID := strings.TrimSpace(r.URL.Query().Get("session_id")) sessionID := strings.TrimSpace(r.URL.Query().Get("session_id"))
if sessionID == "" { if sessionID == "" {
http.Error(w, "missing required query param 'session_id'", http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, "missing required query param 'session_id'")
return return
} }
query := strings.TrimSpace(r.URL.Query().Get("q")) query := strings.TrimSpace(r.URL.Query().Get("q"))
if query == "" { if query == "" {
http.Error(w, "missing or empty query param 'q'", http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, "missing or empty query param 'q'")
return return
} }
+4 -4
View File
@@ -13,23 +13,23 @@ func (s *Server) handleGetChatTurn(w http.ResponseWriter, r *http.Request) {
clientTurnID := r.PathValue("client_turn_id") clientTurnID := r.PathValue("client_turn_id")
sessionID := r.URL.Query().Get("session_id") sessionID := r.URL.Query().Get("session_id")
if clientTurnID == "" || sessionID == "" { if clientTurnID == "" || sessionID == "" {
http.Error(w, "client_turn_id and session_id required", http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, "client_turn_id and session_id required")
return return
} }
ctxMgr := s.ctxMgr ctxMgr := s.ctxMgr
if ctxMgr == nil { if ctxMgr == nil {
http.Error(w, "context manager unavailable", http.StatusServiceUnavailable) writeSafeError(w, r, http.StatusServiceUnavailable, "context manager unavailable")
return return
} }
rec, err := ctxMgr.GetChatTurn(r.Context(), sessionID, clientTurnID) rec, err := ctxMgr.GetChatTurn(r.Context(), sessionID, clientTurnID)
if errors.Is(err, ctxmgr.ErrChatTurnNotFound) { if errors.Is(err, ctxmgr.ErrChatTurnNotFound) {
http.Error(w, "turn not found", http.StatusNotFound) writeSafeError(w, r, http.StatusNotFound, "turn not found")
return return
} }
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) writeSafeError(w, r, http.StatusInternalServerError, err.Error())
return return
} }
+5 -5
View File
@@ -24,7 +24,7 @@ func (s *Server) handleChatUnread(w http.ResponseWriter, r *http.Request) {
sessionID := r.URL.Query().Get("session_id") sessionID := r.URL.Query().Get("session_id")
if sessionID == "" { if sessionID == "" {
http.Error(w, "missing session_id", http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, "missing session_id")
return return
} }
@@ -65,7 +65,7 @@ func (s *Server) handleChatUnread(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleChatRead(w http.ResponseWriter, r *http.Request) { func (s *Server) handleChatRead(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed) writeSafeError(w, r, http.StatusMethodNotAllowed, "method not allowed")
return return
} }
@@ -76,16 +76,16 @@ func (s *Server) handleChatRead(w http.ResponseWriter, r *http.Request) {
var req ctxmgr.MarkChatReadRequest var req ctxmgr.MarkChatReadRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body: "+err.Error(), http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, "invalid request body: "+err.Error())
return return
} }
if req.SessionID == "" { if req.SessionID == "" {
http.Error(w, "missing session_id", http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, "missing session_id")
return return
} }
if s.chatLog == nil { if s.chatLog == nil {
http.Error(w, "context manager not initialized", http.StatusInternalServerError) writeSafeError(w, r, http.StatusInternalServerError, "context manager not initialized")
return return
} }
+12 -12
View File
@@ -14,7 +14,7 @@ import (
// GET /api/v1/chat/messages/window?session_id=&days=&before_day= // GET /api/v1/chat/messages/window?session_id=&days=&before_day=
func (s *Server) handleGetChatWindow(w http.ResponseWriter, r *http.Request) { func (s *Server) handleGetChatWindow(w http.ResponseWriter, r *http.Request) {
if s.chatLog == nil { if s.chatLog == nil {
http.Error(w, "context manager not initialized", http.StatusServiceUnavailable) writeSafeError(w, r, http.StatusServiceUnavailable, "context manager not initialized")
return return
} }
@@ -25,7 +25,7 @@ func (s *Server) handleGetChatWindow(w http.ResponseWriter, r *http.Request) {
sessionID := r.URL.Query().Get("session_id") sessionID := r.URL.Query().Get("session_id")
if sessionID == "" { if sessionID == "" {
http.Error(w, "missing session_id query param", http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, "missing session_id query param")
return return
} }
@@ -52,7 +52,7 @@ func (s *Server) handleGetChatWindow(w http.ResponseWriter, r *http.Request) {
// GET /api/v1/chat/messages/day?session_id=&date=&anchor_id=&before_id=&after_id=&limit= // GET /api/v1/chat/messages/day?session_id=&date=&anchor_id=&before_id=&after_id=&limit=
func (s *Server) handleGetChatDayMessages(w http.ResponseWriter, r *http.Request) { func (s *Server) handleGetChatDayMessages(w http.ResponseWriter, r *http.Request) {
if s.chatLog == nil { if s.chatLog == nil {
http.Error(w, "context manager not initialized", http.StatusServiceUnavailable) writeSafeError(w, r, http.StatusServiceUnavailable, "context manager not initialized")
return return
} }
@@ -63,7 +63,7 @@ func (s *Server) handleGetChatDayMessages(w http.ResponseWriter, r *http.Request
sessionID := r.URL.Query().Get("session_id") sessionID := r.URL.Query().Get("session_id")
if sessionID == "" { if sessionID == "" {
http.Error(w, "missing session_id query param", http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, "missing session_id query param")
return return
} }
@@ -76,7 +76,7 @@ func (s *Server) handleGetChatDayMessages(w http.ResponseWriter, r *http.Request
anchorID, _ := strconv.ParseInt(anchorIDStr, 10, 64) anchorID, _ := strconv.ParseInt(anchorIDStr, 10, 64)
if date == "" && anchorID <= 0 { if date == "" && anchorID <= 0 {
http.Error(w, "missing date/day_key or anchor_id query param", http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, "missing date/day_key or anchor_id query param")
return return
} }
@@ -107,7 +107,7 @@ func (s *Server) handleGetChatDayMessages(w http.ResponseWriter, r *http.Request
// GET /api/v1/chat/dates?session_id= // GET /api/v1/chat/dates?session_id=
func (s *Server) handleListChatDates(w http.ResponseWriter, r *http.Request) { func (s *Server) handleListChatDates(w http.ResponseWriter, r *http.Request) {
if s.chatLog == nil { if s.chatLog == nil {
http.Error(w, "context manager not initialized", http.StatusServiceUnavailable) writeSafeError(w, r, http.StatusServiceUnavailable, "context manager not initialized")
return return
} }
@@ -118,7 +118,7 @@ func (s *Server) handleListChatDates(w http.ResponseWriter, r *http.Request) {
sessionID := r.URL.Query().Get("session_id") sessionID := r.URL.Query().Get("session_id")
if sessionID == "" { if sessionID == "" {
http.Error(w, "missing session_id query param", http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, "missing session_id query param")
return return
} }
@@ -135,7 +135,7 @@ func (s *Server) handleListChatDates(w http.ResponseWriter, r *http.Request) {
// GET /api/v1/chat/categories?session_id= // GET /api/v1/chat/categories?session_id=
func (s *Server) handleListChatCategories(w http.ResponseWriter, r *http.Request) { func (s *Server) handleListChatCategories(w http.ResponseWriter, r *http.Request) {
if s.chatLog == nil { if s.chatLog == nil {
http.Error(w, "context manager not initialized", http.StatusServiceUnavailable) writeSafeError(w, r, http.StatusServiceUnavailable, "context manager not initialized")
return return
} }
@@ -146,7 +146,7 @@ func (s *Server) handleListChatCategories(w http.ResponseWriter, r *http.Request
sessionID := r.URL.Query().Get("session_id") sessionID := r.URL.Query().Get("session_id")
if sessionID == "" { if sessionID == "" {
http.Error(w, "missing session_id query param", http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, "missing session_id query param")
return return
} }
@@ -163,14 +163,14 @@ func (s *Server) handleListChatCategories(w http.ResponseWriter, r *http.Request
// POST /api/v1/chat/messages/import // POST /api/v1/chat/messages/import
func (s *Server) handleImportChatMessages(w http.ResponseWriter, r *http.Request) { func (s *Server) handleImportChatMessages(w http.ResponseWriter, r *http.Request) {
if s.ctxMgr == nil { if s.ctxMgr == nil {
http.Error(w, "context manager not initialized", http.StatusServiceUnavailable) writeSafeError(w, r, http.StatusServiceUnavailable, "context manager not initialized")
return return
} }
// 安全门禁:生产环境默认禁止调用,仅在 dev 或显式开启时开放 // 安全门禁:生产环境默认禁止调用,仅在 dev 或显式开启时开放
isDev := s.cfg.Env == "dev" || s.cfg.Env == "development" || s.cfg.Env == "" || os.Getenv("ENABLE_DEV_IMPORT") == "true" isDev := s.cfg.Env == "dev" || s.cfg.Env == "development" || s.cfg.Env == "" || os.Getenv("ENABLE_DEV_IMPORT") == "true"
if !isDev { if !isDev {
http.Error(w, "chat messages import endpoint is only available in dev/demo environment", http.StatusForbidden) writeSafeError(w, r, http.StatusForbidden, "chat messages import endpoint is only available in dev/demo environment")
return return
} }
@@ -188,7 +188,7 @@ func (s *Server) handleImportChatMessages(w http.ResponseWriter, r *http.Request
var items []importMessageItem var items []importMessageItem
if err := json.NewDecoder(r.Body).Decode(&items); err != nil { if err := json.NewDecoder(r.Body).Decode(&items); err != nil {
http.Error(w, "invalid request body: "+err.Error(), http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, "invalid request body: "+err.Error())
return return
} }
+3 -3
View File
@@ -18,7 +18,7 @@ func (s *Server) chatPrincipal(user *auth.UserAccount, action chatflow.Action) c
func (s *Server) writeChatLogError(w http.ResponseWriter, err error) { func (s *Server) writeChatLogError(w http.ResponseWriter, err error) {
switch { switch {
case errors.Is(err, chatflow.ErrUnauthenticated): case errors.Is(err, chatflow.ErrUnauthenticated):
http.Error(w, `{"error":"ERR_UNAUTHENTICATED","message":"请先登录"}`, http.StatusUnauthorized) writeSafeError(w, nil, http.StatusUnauthorized, `{"error":"ERR_UNAUTHENTICATED","message":"请先登录"}`)
case errors.Is(err, chatflow.ErrForbidden): case errors.Is(err, chatflow.ErrForbidden):
writeJSONResponse(w, http.StatusForbidden, map[string]string{ writeJSONResponse(w, http.StatusForbidden, map[string]string{
"error_code": "SESSION_FORBIDDEN", "error_code": "SESSION_FORBIDDEN",
@@ -30,8 +30,8 @@ func (s *Server) writeChatLogError(w http.ResponseWriter, err error) {
"message": "指定会话不存在或已被删除", "message": "指定会话不存在或已被删除",
}) })
case errors.Is(err, chatflow.ErrClosed): case errors.Is(err, chatflow.ErrClosed):
http.Error(w, "context manager not initialized", http.StatusServiceUnavailable) writeSafeError(w, nil, http.StatusServiceUnavailable, "context manager not initialized")
default: default:
http.Error(w, err.Error(), http.StatusInternalServerError) writeSafeError(w, nil, http.StatusInternalServerError, err.Error())
} }
} }
+8 -8
View File
@@ -37,7 +37,7 @@ func extractDocIDFromPath(r *http.Request, actionSuffix string) string {
// POST /api/v1/freeform-documents — 直接创建(上传附件注册服务端事实源,仅 my_private) // POST /api/v1/freeform-documents — 直接创建(上传附件注册服务端事实源,仅 my_private)
func (s *Server) handleCreateFreeformDoc(w http.ResponseWriter, r *http.Request) { func (s *Server) handleCreateFreeformDoc(w http.ResponseWriter, r *http.Request) {
if s.freeDoc == nil { if s.freeDoc == nil {
http.Error(w, "freeform doc service not initialized", http.StatusServiceUnavailable) writeSafeError(w, r, http.StatusServiceUnavailable, "freeform doc service not initialized")
return return
} }
user, ok := s.requirePrincipal(w, r) user, ok := s.requirePrincipal(w, r)
@@ -107,7 +107,7 @@ func (s *Server) handleCreateFreeformDoc(w http.ResponseWriter, r *http.Request)
// POST /api/v1/freeform-documents/from-message // POST /api/v1/freeform-documents/from-message
func (s *Server) handleCreateFreeformDocFromMessage(w http.ResponseWriter, r *http.Request) { func (s *Server) handleCreateFreeformDocFromMessage(w http.ResponseWriter, r *http.Request) {
if s.freeDoc == nil { if s.freeDoc == nil {
http.Error(w, "freeform doc service not initialized", http.StatusServiceUnavailable) writeSafeError(w, r, http.StatusServiceUnavailable, "freeform doc service not initialized")
return return
} }
@@ -169,7 +169,7 @@ func (s *Server) handleCreateFreeformDocFromMessage(w http.ResponseWriter, r *ht
// GET /api/v1/freeform-documents // GET /api/v1/freeform-documents
func (s *Server) handleListFreeformDocs(w http.ResponseWriter, r *http.Request) { func (s *Server) handleListFreeformDocs(w http.ResponseWriter, r *http.Request) {
if s.freeDoc == nil { if s.freeDoc == nil {
http.Error(w, "freeform doc service not initialized", http.StatusServiceUnavailable) writeSafeError(w, r, http.StatusServiceUnavailable, "freeform doc service not initialized")
return return
} }
@@ -207,7 +207,7 @@ func (s *Server) handleListFreeformDocs(w http.ResponseWriter, r *http.Request)
// GET /api/v1/freeform-documents/{doc_id} // GET /api/v1/freeform-documents/{doc_id}
func (s *Server) handleGetFreeformDoc(w http.ResponseWriter, r *http.Request) { func (s *Server) handleGetFreeformDoc(w http.ResponseWriter, r *http.Request) {
if s.freeDoc == nil { if s.freeDoc == nil {
http.Error(w, "freeform doc service not initialized", http.StatusServiceUnavailable) writeSafeError(w, r, http.StatusServiceUnavailable, "freeform doc service not initialized")
return return
} }
@@ -256,7 +256,7 @@ func (s *Server) handleGetFreeformDoc(w http.ResponseWriter, r *http.Request) {
// PUT /api/v1/freeform-documents/{doc_id} // PUT /api/v1/freeform-documents/{doc_id}
func (s *Server) handleUpdateFreeformDoc(w http.ResponseWriter, r *http.Request) { func (s *Server) handleUpdateFreeformDoc(w http.ResponseWriter, r *http.Request) {
if s.freeDoc == nil { if s.freeDoc == nil {
http.Error(w, "freeform doc service not initialized", http.StatusServiceUnavailable) writeSafeError(w, r, http.StatusServiceUnavailable, "freeform doc service not initialized")
return return
} }
@@ -332,7 +332,7 @@ func (s *Server) handleUpdateFreeformDoc(w http.ResponseWriter, r *http.Request)
// POST /api/v1/freeform-documents/{doc_id}/share // POST /api/v1/freeform-documents/{doc_id}/share
func (s *Server) handleShareFreeformDoc(w http.ResponseWriter, r *http.Request) { func (s *Server) handleShareFreeformDoc(w http.ResponseWriter, r *http.Request) {
if s.freeDoc == nil { if s.freeDoc == nil {
http.Error(w, "freeform doc service not initialized", http.StatusServiceUnavailable) writeSafeError(w, r, http.StatusServiceUnavailable, "freeform doc service not initialized")
return return
} }
@@ -373,7 +373,7 @@ func (s *Server) handleShareFreeformDoc(w http.ResponseWriter, r *http.Request)
// POST /api/v1/freeform-documents/{doc_id}/unshare // POST /api/v1/freeform-documents/{doc_id}/unshare
func (s *Server) handleUnshareFreeformDoc(w http.ResponseWriter, r *http.Request) { func (s *Server) handleUnshareFreeformDoc(w http.ResponseWriter, r *http.Request) {
if s.freeDoc == nil { if s.freeDoc == nil {
http.Error(w, "freeform doc service not initialized", http.StatusServiceUnavailable) writeSafeError(w, r, http.StatusServiceUnavailable, "freeform doc service not initialized")
return return
} }
@@ -414,7 +414,7 @@ func (s *Server) handleUnshareFreeformDoc(w http.ResponseWriter, r *http.Request
// DELETE /api/v1/freeform-documents/{doc_id} // DELETE /api/v1/freeform-documents/{doc_id}
func (s *Server) handleDeleteFreeformDoc(w http.ResponseWriter, r *http.Request) { func (s *Server) handleDeleteFreeformDoc(w http.ResponseWriter, r *http.Request) {
if s.freeDoc == nil { if s.freeDoc == nil {
http.Error(w, "freeform doc service not initialized", http.StatusServiceUnavailable) writeSafeError(w, r, http.StatusServiceUnavailable, "freeform doc service not initialized")
return return
} }
+4 -4
View File
@@ -9,11 +9,11 @@ import (
"net/http/httptest" "net/http/httptest"
"testing" "testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"chat-agent-system/internal/bizdev" "chat-agent-system/internal/bizdev"
"chat-agent-system/internal/config" "chat-agent-system/internal/config"
"chat-agent-system/internal/server" "chat-agent-system/internal/server"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
) )
func setupTestServerForFreeformDoc(t *testing.T) (*server.Server, http.Handler, int64, int64) { func setupTestServerForFreeformDoc(t *testing.T) (*server.Server, http.Handler, int64, int64) {
@@ -66,8 +66,8 @@ func TestFreeformDocAPI_EndToEnd(t *testing.T) {
require.Equal(t, http.StatusCreated, recValid.Code) require.Equal(t, http.StatusCreated, recValid.Code)
var createResp struct { var createResp struct {
Code int `json:"code"` Code int `json:"code"`
IsExisting bool `json:"is_existing"` IsExisting bool `json:"is_existing"`
Data struct { Data struct {
Document struct { Document struct {
DocID string `json:"doc_id"` DocID string `json:"doc_id"`
+78 -73
View File
@@ -16,6 +16,7 @@ import (
"chat-agent-system/internal/bizdev" "chat-agent-system/internal/bizdev"
"chat-agent-system/internal/ctxmgr" "chat-agent-system/internal/ctxmgr"
"chat-agent-system/internal/docgov" "chat-agent-system/internal/docgov"
"chat-agent-system/internal/httpx"
"chat-agent-system/internal/projectinstance" "chat-agent-system/internal/projectinstance"
"chat-agent-system/internal/projectworkflow" "chat-agent-system/internal/projectworkflow"
"chat-agent-system/internal/squad" "chat-agent-system/internal/squad"
@@ -132,13 +133,13 @@ func (s *Server) buildHealthResponse(strict bool) (HealthResponse, int) {
func (s *Server) handleGetProject(w http.ResponseWriter, r *http.Request) { func (s *Server) handleGetProject(w http.ResponseWriter, r *http.Request) {
sessionID := r.URL.Query().Get("session_id") sessionID := r.URL.Query().Get("session_id")
if sessionID == "" { if sessionID == "" {
http.Error(w, "missing session_id query param", http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, "missing session_id query param")
return return
} }
sess := s.getOrCreateSession(sessionID, "", "", "", "", "", "", "", "") sess := s.getOrCreateSession(sessionID, "", "", "", "", "", "", "", "")
if sess == nil || sess.UserCtx == nil { if sess == nil || sess.UserCtx == nil {
http.Error(w, "session or project context not found", http.StatusNotFound) writeSafeError(w, r, http.StatusNotFound, "session or project context not found")
return return
} }
@@ -153,11 +154,11 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
var req ChatRequest var req ChatRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body: "+err.Error(), http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, "invalid request body: "+err.Error())
return return
} }
if req.Message == "" { if req.Message == "" {
http.Error(w, "message cannot be empty", http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, "message cannot be empty")
return return
} }
req.WorkDir = "" req.WorkDir = ""
@@ -192,7 +193,7 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
resp, err := sess.SendMessage(r.Context(), req.Message) resp, err := sess.SendMessage(r.Context(), req.Message)
if err != nil { if err != nil {
http.Error(w, "process chat message failed: "+err.Error(), http.StatusInternalServerError) writeSafeError(w, r, http.StatusInternalServerError, "process chat message failed: "+err.Error())
return return
} }
@@ -207,7 +208,7 @@ func (s *Server) handleRunTask(w http.ResponseWriter, r *http.Request) {
var req TaskRunRequestDTO var req TaskRunRequestDTO
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body: "+err.Error(), http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, "invalid request body: "+err.Error())
return return
} }
req.UserID = user.ID req.UserID = user.ID
@@ -362,7 +363,7 @@ func writeTaskError(w http.ResponseWriter, err error, payload any) {
writeJSONResponse(w, status, payload) writeJSONResponse(w, status, payload)
return return
} }
http.Error(w, err.Error(), status) writeSafeError(w, nil, status, err.Error())
} }
func writeDocGovError(w http.ResponseWriter, err error) { func writeDocGovError(w http.ResponseWriter, err error) {
@@ -375,22 +376,22 @@ func writeDocGovError(w http.ResponseWriter, err error) {
return return
} }
if errors.Is(err, docgov.ErrDocumentBusy) { if errors.Is(err, docgov.ErrDocumentBusy) {
http.Error(w, err.Error(), http.StatusConflict) writeSafeError(w, nil, http.StatusConflict, err.Error())
return return
} }
if errors.Is(err, docgov.ErrDocumentLocked) { if errors.Is(err, docgov.ErrDocumentLocked) {
http.Error(w, err.Error(), http.StatusConflict) writeSafeError(w, nil, http.StatusConflict, err.Error())
return return
} }
if errors.Is(err, docgov.ErrDocumentNotFound) { if errors.Is(err, docgov.ErrDocumentNotFound) {
http.Error(w, err.Error(), http.StatusNotFound) writeSafeError(w, nil, http.StatusNotFound, err.Error())
return return
} }
if errors.Is(err, docgov.ErrLeaseNotHeld) || errors.Is(err, docgov.ErrLeaseExpired) { if errors.Is(err, docgov.ErrLeaseNotHeld) || errors.Is(err, docgov.ErrLeaseExpired) {
http.Error(w, err.Error(), http.StatusConflict) writeSafeError(w, nil, http.StatusConflict, err.Error())
return return
} }
http.Error(w, err.Error(), http.StatusBadRequest) writeSafeError(w, nil, http.StatusBadRequest, err.Error())
} }
func writeJSONResponse(w http.ResponseWriter, status int, data any) { func writeJSONResponse(w http.ResponseWriter, status int, data any) {
@@ -399,6 +400,10 @@ func writeJSONResponse(w http.ResponseWriter, status int, data any) {
_ = json.NewEncoder(w).Encode(data) _ = json.NewEncoder(w).Encode(data)
} }
func writeSafeError(w http.ResponseWriter, r *http.Request, status int, legacy string) {
httpx.Write(w, status, httpx.CodeFromStatus(status), legacy, httpx.RequestID(r), nil)
}
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// 多级上下文与偏好记忆 RESTful 控制器 // 多级上下文与偏好记忆 RESTful 控制器
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
@@ -416,13 +421,13 @@ func (s *Server) handleGetUserContext(w http.ResponseWriter, r *http.Request) {
projectID, _ := strconv.ParseInt(projectIDStr, 10, 64) projectID, _ := strconv.ParseInt(projectIDStr, 10, 64)
if s.ctxMgr == nil { if s.ctxMgr == nil {
http.Error(w, "context manager not initialized", http.StatusServiceUnavailable) writeSafeError(w, r, http.StatusServiceUnavailable, "context manager not initialized")
return return
} }
uCtx, err := s.ctxMgr.GetUserContext(r.Context(), projectID, userID, sessionID) uCtx, err := s.ctxMgr.GetUserContext(r.Context(), projectID, userID, sessionID)
if err != nil { if err != nil {
http.Error(w, "get user context failed: "+err.Error(), http.StatusInternalServerError) writeSafeError(w, r, http.StatusInternalServerError, "get user context failed: "+err.Error())
return return
} }
@@ -442,16 +447,16 @@ type UpdateUserPreferenceRequest struct {
func (s *Server) handleUpdateUserPreference(w http.ResponseWriter, r *http.Request) { func (s *Server) handleUpdateUserPreference(w http.ResponseWriter, r *http.Request) {
var req UpdateUserPreferenceRequest var req UpdateUserPreferenceRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body: "+err.Error(), http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, "invalid request body: "+err.Error())
return return
} }
if req.UserID <= 0 { if req.UserID <= 0 {
http.Error(w, "user_id must be positive", http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, "user_id must be positive")
return return
} }
if s.ctxMgr == nil { if s.ctxMgr == nil {
http.Error(w, "context manager not initialized", http.StatusServiceUnavailable) writeSafeError(w, r, http.StatusServiceUnavailable, "context manager not initialized")
return return
} }
@@ -464,7 +469,7 @@ func (s *Server) handleUpdateUserPreference(w http.ResponseWriter, r *http.Reque
} }
if err := s.ctxMgr.UpdateUserPreference(r.Context(), pref); err != nil { if err := s.ctxMgr.UpdateUserPreference(r.Context(), pref); err != nil {
http.Error(w, "save user preference failed: "+err.Error(), http.StatusInternalServerError) writeSafeError(w, r, http.StatusInternalServerError, "save user preference failed: "+err.Error())
return return
} }
@@ -481,7 +486,7 @@ func (s *Server) handleGetProjectContext(w http.ResponseWriter, r *http.Request)
projectIDStr := r.URL.Query().Get("project_id") projectIDStr := r.URL.Query().Get("project_id")
projectID, _ := strconv.ParseInt(projectIDStr, 10, 64) projectID, _ := strconv.ParseInt(projectIDStr, 10, 64)
if projectID <= 0 { if projectID <= 0 {
http.Error(w, "missing or invalid project_id query param", http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, "missing or invalid project_id query param")
return return
} }
@@ -491,13 +496,13 @@ func (s *Server) handleGetProjectContext(w http.ResponseWriter, r *http.Request)
} }
if s.ctxMgr == nil { if s.ctxMgr == nil {
http.Error(w, "context manager not initialized", http.StatusServiceUnavailable) writeSafeError(w, r, http.StatusServiceUnavailable, "context manager not initialized")
return return
} }
pCtx, err := s.ctxMgr.GetProjectContext(r.Context(), projectID) pCtx, err := s.ctxMgr.GetProjectContext(r.Context(), projectID)
if err != nil { if err != nil {
http.Error(w, "get project context failed: "+err.Error(), http.StatusInternalServerError) writeSafeError(w, r, http.StatusInternalServerError, "get project context failed: "+err.Error())
return return
} }
@@ -512,18 +517,18 @@ func (s *Server) handleGetProjectContext(w http.ResponseWriter, r *http.Request)
func (s *Server) handleDocGovGetDocument(w http.ResponseWriter, r *http.Request) { func (s *Server) handleDocGovGetDocument(w http.ResponseWriter, r *http.Request) {
docID := r.URL.Query().Get("doc_id") docID := r.URL.Query().Get("doc_id")
if docID == "" { if docID == "" {
http.Error(w, "missing doc_id query param", http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, "missing doc_id query param")
return return
} }
if s.gov == nil { if s.gov == nil {
http.Error(w, "docgov engine not initialized", http.StatusServiceUnavailable) writeSafeError(w, r, http.StatusServiceUnavailable, "docgov engine not initialized")
return return
} }
doc, err := s.gov.GetDocument(r.Context(), docID) doc, err := s.gov.GetDocument(r.Context(), docID)
if err != nil { if err != nil {
http.Error(w, "get document failed: "+err.Error(), http.StatusNotFound) writeSafeError(w, r, http.StatusNotFound, "get document failed: "+err.Error())
return return
} }
@@ -547,22 +552,22 @@ func (s *Server) handleDocGovClaim(w http.ResponseWriter, r *http.Request) {
var req DocGovClaimRequest var req DocGovClaimRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body: "+err.Error(), http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, "invalid request body: "+err.Error())
return return
} }
if req.DocID == "" { if req.DocID == "" {
http.Error(w, "doc_id cannot be empty", http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, "doc_id cannot be empty")
return return
} }
if s.gov == nil { if s.gov == nil {
http.Error(w, "docgov engine not initialized", http.StatusServiceUnavailable) writeSafeError(w, r, http.StatusServiceUnavailable, "docgov engine not initialized")
return return
} }
doc, err := s.gov.RecordDocumentViewed(r.Context(), req.DocID, user.ID, docgov.UserRole(req.UserRole), user.Name) doc, err := s.gov.RecordDocumentViewed(r.Context(), req.DocID, user.ID, docgov.UserRole(req.UserRole), user.Name)
if err != nil { if err != nil {
http.Error(w, "claim document failed: "+err.Error(), http.StatusInternalServerError) writeSafeError(w, r, http.StatusInternalServerError, "claim document failed: "+err.Error())
return return
} }
@@ -585,12 +590,12 @@ func (s *Server) handleDocGovSubmitReview(w http.ResponseWriter, r *http.Request
var req DocGovSubmitReviewRequest var req DocGovSubmitReviewRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body: "+err.Error(), http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, "invalid request body: "+err.Error())
return return
} }
if s.gov == nil { if s.gov == nil {
http.Error(w, "docgov engine not initialized", http.StatusServiceUnavailable) writeSafeError(w, r, http.StatusServiceUnavailable, "docgov engine not initialized")
return return
} }
if !s.enforceDocGovWorkflowAction(w, r.Context(), user.ID, req.DocID, projectinstance.DocAccessActionSubmit) { if !s.enforceDocGovWorkflowAction(w, r.Context(), user.ID, req.DocID, projectinstance.DocAccessActionSubmit) {
@@ -599,7 +604,7 @@ func (s *Server) handleDocGovSubmitReview(w http.ResponseWriter, r *http.Request
doc, err := s.gov.SubmitForReview(r.Context(), req.DocID, user.ID, docgov.RoleAuthor, user.Name) doc, err := s.gov.SubmitForReview(r.Context(), req.DocID, user.ID, docgov.RoleAuthor, user.Name)
if err != nil { if err != nil {
http.Error(w, "submit review failed: "+err.Error(), http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, "submit review failed: "+err.Error())
return return
} }
@@ -626,12 +631,12 @@ func (s *Server) handleDocGovReviewDecision(w http.ResponseWriter, r *http.Reque
var req DocGovDecisionRequest var req DocGovDecisionRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body: "+err.Error(), http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, "invalid request body: "+err.Error())
return return
} }
if s.gov == nil { if s.gov == nil {
http.Error(w, "docgov engine not initialized", http.StatusServiceUnavailable) writeSafeError(w, r, http.StatusServiceUnavailable, "docgov engine not initialized")
return return
} }
if !s.enforceDocGovWorkflowAction(w, r.Context(), user.ID, req.DocID, projectinstance.DocAccessActionReview) { if !s.enforceDocGovWorkflowAction(w, r.Context(), user.ID, req.DocID, projectinstance.DocAccessActionReview) {
@@ -651,12 +656,12 @@ func (s *Server) handleDocGovReviewDecision(w http.ResponseWriter, r *http.Reque
} }
doc, err = s.gov.ReviewReject(r.Context(), req.DocID, user.ID, docgov.RoleReviewer, user.Name, reason) doc, err = s.gov.ReviewReject(r.Context(), req.DocID, user.ID, docgov.RoleReviewer, user.Name, reason)
} else { } else {
http.Error(w, "invalid action: must be approve or reject", http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, "invalid action: must be approve or reject")
return return
} }
if err != nil { if err != nil {
http.Error(w, "review decision failed: "+err.Error(), http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, "review decision failed: "+err.Error())
return return
} }
@@ -672,12 +677,12 @@ func (s *Server) handleDocGovApproverDecision(w http.ResponseWriter, r *http.Req
var req DocGovDecisionRequest var req DocGovDecisionRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body: "+err.Error(), http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, "invalid request body: "+err.Error())
return return
} }
if s.gov == nil { if s.gov == nil {
http.Error(w, "docgov engine not initialized", http.StatusServiceUnavailable) writeSafeError(w, r, http.StatusServiceUnavailable, "docgov engine not initialized")
return return
} }
if !s.enforceDocGovWorkflowAction(w, r.Context(), user.ID, req.DocID, projectinstance.DocAccessActionApprove) { if !s.enforceDocGovWorkflowAction(w, r.Context(), user.ID, req.DocID, projectinstance.DocAccessActionApprove) {
@@ -687,7 +692,7 @@ func (s *Server) handleDocGovApproverDecision(w http.ResponseWriter, r *http.Req
action := strings.ToLower(strings.TrimSpace(req.Action)) action := strings.ToLower(strings.TrimSpace(req.Action))
cmds := s.docflowCommands() cmds := s.docflowCommands()
if cmds == nil || cmds.Gov == nil { if cmds == nil || cmds.Gov == nil {
http.Error(w, "docgov engine not initialized", http.StatusServiceUnavailable) writeSafeError(w, r, http.StatusServiceUnavailable, "docgov engine not initialized")
return return
} }
p := docflow.Principal{UserID: user.ID, Name: user.Name, Role: docgov.RoleApprover} p := docflow.Principal{UserID: user.ID, Name: user.Name, Role: docgov.RoleApprover}
@@ -705,20 +710,20 @@ func (s *Server) handleDocGovApproverDecision(w http.ResponseWriter, r *http.Req
DocID: req.DocID, RejectReason: reason, RollbackTo: docgov.DocumentState(req.RollbackTo), DocID: req.DocID, RejectReason: reason, RollbackTo: docgov.DocumentState(req.RollbackTo),
}) })
} else { } else {
http.Error(w, "invalid action: must be approve or reject", http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, "invalid action: must be approve or reject")
return return
} }
if err != nil { if err != nil {
if errors.Is(err, uow.ErrPermissionDenied) { if errors.Is(err, uow.ErrPermissionDenied) {
http.Error(w, "approver decision failed: "+err.Error(), http.StatusForbidden) writeSafeError(w, r, http.StatusForbidden, "approver decision failed: "+err.Error())
return return
} }
if errors.Is(err, uow.ErrBusy) { if errors.Is(err, uow.ErrBusy) {
http.Error(w, "approver decision failed: "+err.Error(), http.StatusConflict) writeSafeError(w, r, http.StatusConflict, "approver decision failed: "+err.Error())
return return
} }
http.Error(w, "approver decision failed: "+err.Error(), http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, "approver decision failed: "+err.Error())
return return
} }
@@ -742,18 +747,18 @@ func (s *Server) handleDocGovDecideProposal(w http.ResponseWriter, r *http.Reque
var req DecideProposalRequest var req DecideProposalRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body: "+err.Error(), http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, "invalid request body: "+err.Error())
return return
} }
if s.gov == nil { if s.gov == nil {
http.Error(w, "docgov engine not initialized", http.StatusServiceUnavailable) writeSafeError(w, r, http.StatusServiceUnavailable, "docgov engine not initialized")
return 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 err != nil {
http.Error(w, "decide proposal failed: "+err.Error(), http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, "decide proposal failed: "+err.Error())
return return
} }
@@ -764,18 +769,18 @@ func (s *Server) handleDocGovDecideProposal(w http.ResponseWriter, r *http.Reque
func (s *Server) handleDocGovListComments(w http.ResponseWriter, r *http.Request) { func (s *Server) handleDocGovListComments(w http.ResponseWriter, r *http.Request) {
docID := r.URL.Query().Get("doc_id") docID := r.URL.Query().Get("doc_id")
if docID == "" { if docID == "" {
http.Error(w, "missing doc_id query param", http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, "missing doc_id query param")
return return
} }
if s.gov == nil { if s.gov == nil {
http.Error(w, "docgov engine not initialized", http.StatusServiceUnavailable) writeSafeError(w, r, http.StatusServiceUnavailable, "docgov engine not initialized")
return return
} }
comments, err := s.gov.ListComments(r.Context(), docID) comments, err := s.gov.ListComments(r.Context(), docID)
if err != nil { if err != nil {
http.Error(w, "list comments failed: "+err.Error(), http.StatusInternalServerError) writeSafeError(w, r, http.StatusInternalServerError, "list comments failed: "+err.Error())
return return
} }
@@ -797,12 +802,12 @@ type AddCommentRequestDTO struct {
func (s *Server) handleDocGovAddComment(w http.ResponseWriter, r *http.Request) { func (s *Server) handleDocGovAddComment(w http.ResponseWriter, r *http.Request) {
var req AddCommentRequestDTO var req AddCommentRequestDTO
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body: "+err.Error(), http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, "invalid request body: "+err.Error())
return return
} }
if s.gov == nil { if s.gov == nil {
http.Error(w, "docgov engine not initialized", http.StatusServiceUnavailable) writeSafeError(w, r, http.StatusServiceUnavailable, "docgov engine not initialized")
return return
} }
@@ -827,18 +832,18 @@ func (s *Server) handleDocGovAddComment(w http.ResponseWriter, r *http.Request)
func (s *Server) handleDocGovListAuditLogs(w http.ResponseWriter, r *http.Request) { func (s *Server) handleDocGovListAuditLogs(w http.ResponseWriter, r *http.Request) {
docID := r.URL.Query().Get("doc_id") docID := r.URL.Query().Get("doc_id")
if docID == "" { if docID == "" {
http.Error(w, "missing doc_id query param", http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, "missing doc_id query param")
return return
} }
if s.gov == nil { if s.gov == nil {
http.Error(w, "docgov engine not initialized", http.StatusServiceUnavailable) writeSafeError(w, r, http.StatusServiceUnavailable, "docgov engine not initialized")
return return
} }
logs, err := s.gov.ListAuditLogs(r.Context(), docID) logs, err := s.gov.ListAuditLogs(r.Context(), docID)
if err != nil { if err != nil {
http.Error(w, "list audit logs failed: "+err.Error(), http.StatusInternalServerError) writeSafeError(w, r, http.StatusInternalServerError, "list audit logs failed: "+err.Error())
return return
} }
@@ -852,7 +857,7 @@ func (s *Server) handleDocGovListAuditLogs(w http.ResponseWriter, r *http.Reques
// handleListChatSessions 分页查询用户/项目关联的历史会话列表 // handleListChatSessions 分页查询用户/项目关联的历史会话列表
func (s *Server) handleListChatSessions(w http.ResponseWriter, r *http.Request) { func (s *Server) handleListChatSessions(w http.ResponseWriter, r *http.Request) {
if s.ctxMgr == nil { if s.ctxMgr == nil {
http.Error(w, "context manager not initialized", http.StatusServiceUnavailable) writeSafeError(w, r, http.StatusServiceUnavailable, "context manager not initialized")
return return
} }
@@ -879,7 +884,7 @@ func (s *Server) handleListChatSessions(w http.ResponseWriter, r *http.Request)
Offset: offset, Offset: offset,
}) })
if err != nil { if err != nil {
http.Error(w, "list chat sessions failed: "+err.Error(), http.StatusInternalServerError) writeSafeError(w, r, http.StatusInternalServerError, "list chat sessions failed: "+err.Error())
return return
} }
@@ -896,23 +901,23 @@ type upsertChatSessionRequest struct {
// handleUpsertChatSession 注册或更新会话元数据(立项/成员加入时同步)。 // handleUpsertChatSession 注册或更新会话元数据(立项/成员加入时同步)。
func (s *Server) handleUpsertChatSession(w http.ResponseWriter, r *http.Request) { func (s *Server) handleUpsertChatSession(w http.ResponseWriter, r *http.Request) {
if s.ctxMgr == nil { if s.ctxMgr == nil {
http.Error(w, "context manager not initialized", http.StatusServiceUnavailable) writeSafeError(w, r, http.StatusServiceUnavailable, "context manager not initialized")
return return
} }
var req upsertChatSessionRequest var req upsertChatSessionRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, "invalid request body")
return return
} }
if req.SessionID == "" { if req.SessionID == "" {
http.Error(w, "missing session_id", http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, "missing session_id")
return return
} }
summary, err := s.ctxMgr.EnsureChatSession(r.Context(), req.SessionID, req.UserID, req.ProjectID, req.Title) summary, err := s.ctxMgr.EnsureChatSession(r.Context(), req.SessionID, req.UserID, req.ProjectID, req.Title)
if err != nil { if err != nil {
http.Error(w, "upsert chat session failed: "+err.Error(), http.StatusInternalServerError) writeSafeError(w, r, http.StatusInternalServerError, "upsert chat session failed: "+err.Error())
return return
} }
@@ -922,13 +927,13 @@ func (s *Server) handleUpsertChatSession(w http.ResponseWriter, r *http.Request)
// handleGetChatMessages 分页获取指定会话的历史多轮消息记录 // handleGetChatMessages 分页获取指定会话的历史多轮消息记录
func (s *Server) handleGetChatMessages(w http.ResponseWriter, r *http.Request) { func (s *Server) handleGetChatMessages(w http.ResponseWriter, r *http.Request) {
if s.ctxMgr == nil { if s.ctxMgr == nil {
http.Error(w, "context manager not initialized", http.StatusServiceUnavailable) writeSafeError(w, r, http.StatusServiceUnavailable, "context manager not initialized")
return return
} }
sessionID := r.URL.Query().Get("session_id") sessionID := r.URL.Query().Get("session_id")
if sessionID == "" { if sessionID == "" {
http.Error(w, "missing session_id query param", http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, "missing session_id query param")
return return
} }
@@ -949,7 +954,7 @@ func (s *Server) handleGetChatMessages(w http.ResponseWriter, r *http.Request) {
} }
if s.chatLog == nil { if s.chatLog == nil {
http.Error(w, "context manager not initialized", http.StatusServiceUnavailable) writeSafeError(w, r, http.StatusServiceUnavailable, "context manager not initialized")
return return
} }
@@ -973,14 +978,14 @@ func (s *Server) handleRecommendCases(w http.ResponseWriter, r *http.Request) {
return return
} }
if s.ctxMgr == nil { if s.ctxMgr == nil {
http.Error(w, "context manager not initialized", http.StatusServiceUnavailable) writeSafeError(w, r, http.StatusServiceUnavailable, "context manager not initialized")
return return
} }
projectID, _ := strconv.ParseInt(r.URL.Query().Get("project_id"), 10, 64) projectID, _ := strconv.ParseInt(r.URL.Query().Get("project_id"), 10, 64)
query := r.URL.Query().Get("query") query := r.URL.Query().Get("query")
if query == "" { if query == "" {
http.Error(w, "missing query param", http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, "missing query param")
return return
} }
@@ -1006,7 +1011,7 @@ func (s *Server) handleRecommendCases(w http.ResponseWriter, r *http.Request) {
MinScore: minScore, MinScore: minScore,
}) })
if err != nil { if err != nil {
http.Error(w, "search similar cases failed: "+err.Error(), http.StatusInternalServerError) writeSafeError(w, r, http.StatusInternalServerError, "search similar cases failed: "+err.Error())
return return
} }
@@ -1040,11 +1045,11 @@ func (s *Server) handleDocGovSaveContent(w http.ResponseWriter, r *http.Request)
} }
var req SaveContentRequestDTO var req SaveContentRequestDTO
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body: "+err.Error(), http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, "invalid request body: "+err.Error())
return return
} }
if s.gov == nil { if s.gov == nil {
http.Error(w, "docgov engine not initialized", http.StatusServiceUnavailable) writeSafeError(w, r, http.StatusServiceUnavailable, "docgov engine not initialized")
return return
} }
role := docgov.RoleAuthor role := docgov.RoleAuthor
@@ -1076,11 +1081,11 @@ func (s *Server) handleDocGovAcquireLease(w http.ResponseWriter, r *http.Request
} }
var req LeaseRequestDTO var req LeaseRequestDTO
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body: "+err.Error(), http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, "invalid request body: "+err.Error())
return return
} }
if s.gov == nil { if s.gov == nil {
http.Error(w, "docgov engine not initialized", http.StatusServiceUnavailable) writeSafeError(w, r, http.StatusServiceUnavailable, "docgov engine not initialized")
return return
} }
workDir, werr := s.resolver.ResolveDocWorkDir(r.Context(), req.DocID) workDir, werr := s.resolver.ResolveDocWorkDir(r.Context(), req.DocID)
@@ -1108,11 +1113,11 @@ func (s *Server) handleDocGovHeartbeatLease(w http.ResponseWriter, r *http.Reque
} }
var req LeaseRequestDTO var req LeaseRequestDTO
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body: "+err.Error(), http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, "invalid request body: "+err.Error())
return return
} }
if s.gov == nil { if s.gov == nil {
http.Error(w, "docgov engine not initialized", http.StatusServiceUnavailable) writeSafeError(w, r, http.StatusServiceUnavailable, "docgov engine not initialized")
return return
} }
ttl := time.Duration(req.TTLSeconds) * time.Second ttl := time.Duration(req.TTLSeconds) * time.Second
@@ -1130,11 +1135,11 @@ func (s *Server) handleDocGovReleaseLease(w http.ResponseWriter, r *http.Request
} }
var req LeaseRequestDTO var req LeaseRequestDTO
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body: "+err.Error(), http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, "invalid request body: "+err.Error())
return return
} }
if s.gov == nil { if s.gov == nil {
http.Error(w, "docgov engine not initialized", http.StatusServiceUnavailable) writeSafeError(w, r, http.StatusServiceUnavailable, "docgov engine not initialized")
return 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)
+7 -7
View File
@@ -28,7 +28,7 @@ func (s *Server) handleMeProfile(w http.ResponseWriter, r *http.Request) {
case http.MethodPatch: case http.MethodPatch:
var patch userProfilePatch var patch userProfilePatch
if err := json.NewDecoder(r.Body).Decode(&patch); err != nil { if err := json.NewDecoder(r.Body).Decode(&patch); err != nil {
http.Error(w, "invalid body", http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, "invalid body")
return return
} }
updated := *user updated := *user
@@ -46,12 +46,12 @@ func (s *Server) handleMeProfile(w http.ResponseWriter, r *http.Request) {
} }
saved, err := s.authSvc.UpdateUserProfile(r.Context(), &updated) saved, err := s.authSvc.UpdateUserProfile(r.Context(), &updated)
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, err.Error())
return return
} }
writeJSONResponse(w, http.StatusOK, saved) writeJSONResponse(w, http.StatusOK, saved)
default: default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed) writeSafeError(w, r, http.StatusMethodNotAllowed, "method not allowed")
} }
} }
@@ -65,23 +65,23 @@ func (s *Server) handleMePreferences(w http.ResponseWriter, r *http.Request) {
case http.MethodGet: case http.MethodGet:
prefs, err := s.authSvc.GetUserPreferences(r.Context(), user.ID) prefs, err := s.authSvc.GetUserPreferences(r.Context(), user.ID)
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) writeSafeError(w, r, http.StatusInternalServerError, err.Error())
return return
} }
writeJSONResponse(w, http.StatusOK, prefs) writeJSONResponse(w, http.StatusOK, prefs)
case http.MethodPut: case http.MethodPut:
var body map[string]any var body map[string]any
if err := json.NewDecoder(r.Body).Decode(&body); err != nil { if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, "invalid body", http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, "invalid body")
return return
} }
prefs, err := s.authSvc.SaveUserPreferences(r.Context(), user.ID, body) prefs, err := s.authSvc.SaveUserPreferences(r.Context(), user.ID, body)
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) writeSafeError(w, r, http.StatusInternalServerError, err.Error())
return return
} }
writeJSONResponse(w, http.StatusOK, prefs) writeJSONResponse(w, http.StatusOK, prefs)
default: default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed) writeSafeError(w, r, http.StatusMethodNotAllowed, "method not allowed")
} }
} }
+3 -3
View File
@@ -11,13 +11,13 @@ func (s *Server) handleListProjectNotifications(w http.ResponseWriter, r *http.R
return return
} }
if s.notifySvc == nil || !s.notifySvc.Available() { if s.notifySvc == nil || !s.notifySvc.Available() {
http.Error(w, "notification service unavailable", http.StatusServiceUnavailable) writeSafeError(w, r, http.StatusServiceUnavailable, "notification service unavailable")
return return
} }
projectID, err := strconv.ParseInt(r.PathValue("id"), 10, 64) projectID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || projectID <= 0 { if err != nil || projectID <= 0 {
http.Error(w, "invalid project id", http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, "invalid project id")
return return
} }
@@ -26,7 +26,7 @@ func (s *Server) handleListProjectNotifications(w http.ResponseWriter, r *http.R
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 { if err != nil {
http.Error(w, "list notifications failed: "+err.Error(), http.StatusInternalServerError) writeSafeError(w, r, http.StatusInternalServerError, "list notifications failed: "+err.Error())
return return
} }
writeJSONResponse(w, http.StatusOK, map[string]any{ writeJSONResponse(w, http.StatusOK, map[string]any{
+1 -1
View File
@@ -130,7 +130,7 @@ func TestPiAgent_FullLifecycle_MultiRole_RealWorld_E2E(t *testing.T) {
targetMarket := "中国NMPA三类创新医疗器械" targetMarket := "中国NMPA三类创新医疗器械"
// 与 dev 种子账号对齐(internal/auth/store.go) // 与 dev 种子账号对齐(internal/auth/store.go)
authorID := int64(2) // zhanggong / 张工 authorID := int64(2) // zhanggong / 张工
authorName := "张工" authorName := "张工"
reviewerID := int64(3) // lizhuguan / 李主管 reviewerID := int64(3) // lizhuguan / 李主管
reviewerName := "李主管" reviewerName := "李主管"
+6 -6
View File
@@ -64,12 +64,12 @@ func (s *Server) repoRoot() string {
func (s *Server) handleListProjectWorkflowDocuments(w http.ResponseWriter, r *http.Request) { func (s *Server) handleListProjectWorkflowDocuments(w http.ResponseWriter, r *http.Request) {
projectID, err := strconv.ParseInt(r.PathValue("id"), 10, 64) projectID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || projectID <= 0 { if err != nil || projectID <= 0 {
http.Error(w, "invalid project id", http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, "invalid project id")
return return
} }
category := r.URL.Query().Get("category") category := r.URL.Query().Get("category")
if category != "" && category != "workflow" { if category != "" && category != "workflow" {
http.Error(w, "unsupported category", http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, "unsupported category")
return return
} }
@@ -79,13 +79,13 @@ func (s *Server) handleListProjectWorkflowDocuments(w http.ResponseWriter, r *ht
} }
if s.gov == nil { if s.gov == nil {
http.Error(w, "docgov engine not initialized", http.StatusServiceUnavailable) writeSafeError(w, r, http.StatusServiceUnavailable, "docgov engine not initialized")
return return
} }
docs, err := s.gov.ListWorkflowDocumentsByProject(r.Context(), projectID) docs, err := s.gov.ListWorkflowDocumentsByProject(r.Context(), projectID)
if err != nil { if err != nil {
http.Error(w, "list project documents failed: "+err.Error(), http.StatusInternalServerError) writeSafeError(w, r, http.StatusInternalServerError, "list project documents failed: "+err.Error())
return return
} }
docs = s.filterWorkflowDocsByStageAccess(r.Context(), projectID, user.ID, docs) docs = s.filterWorkflowDocsByStageAccess(r.Context(), projectID, user.ID, docs)
@@ -96,7 +96,7 @@ func (s *Server) handleListProjectWorkflowDocuments(w http.ResponseWriter, r *ht
func (s *Server) handleDocGovGetDocumentContent(w http.ResponseWriter, r *http.Request) { func (s *Server) handleDocGovGetDocumentContent(w http.ResponseWriter, r *http.Request) {
docID := r.PathValue("doc_id") docID := r.PathValue("doc_id")
if docID == "" { if docID == "" {
http.Error(w, "doc_id required", http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, "doc_id required")
return return
} }
@@ -106,7 +106,7 @@ func (s *Server) handleDocGovGetDocumentContent(w http.ResponseWriter, r *http.R
} }
q := s.docflowQuery() q := s.docflowQuery()
if q == nil || q.Docs == nil || q.Docs.Gov == nil { if q == nil || q.Docs == nil || q.Docs.Gov == nil {
http.Error(w, "docgov engine not initialized", http.StatusServiceUnavailable) writeSafeError(w, r, http.StatusServiceUnavailable, "docgov engine not initialized")
return return
} }
view, err := q.Get(r.Context(), user.ID, docID) view, err := q.Get(r.Context(), user.ID, docID)
+30 -30
View File
@@ -17,11 +17,11 @@ import (
func (s *Server) handleCreateProject(w http.ResponseWriter, r *http.Request) { func (s *Server) handleCreateProject(w http.ResponseWriter, r *http.Request) {
user, err := s.authenticateUser(r) user, err := s.authenticateUser(r)
if err != nil || user == nil { if err != nil || user == nil {
http.Error(w, `{"error":"ERR_UNAUTHENTICATED","message":"请先登录"}`, http.StatusUnauthorized) writeSafeError(w, r, http.StatusUnauthorized, `{"error":"ERR_UNAUTHENTICATED","message":"请先登录"}`)
return return
} }
if ok, permErr := s.userHasSystemPermission(r.Context(), user, "project:create"); permErr != nil { if ok, permErr := s.userHasSystemPermission(r.Context(), user, "project:create"); permErr != nil {
http.Error(w, `{"error":"ERR_INTERNAL","message":"`+permErr.Error()+`"}`, http.StatusInternalServerError) writeSafeError(w, r, http.StatusInternalServerError, `{"error":"ERR_INTERNAL","message":"`+permErr.Error()+`"}`)
return return
} else if !ok { } else if !ok {
writeJSONResponse(w, http.StatusForbidden, map[string]string{ writeJSONResponse(w, http.StatusForbidden, map[string]string{
@@ -33,21 +33,21 @@ func (s *Server) handleCreateProject(w http.ResponseWriter, r *http.Request) {
var req projectworkflow.CreateProjectInput var req projectworkflow.CreateProjectInput
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, `{"error":"ERR_INVALID_PARAM","message":"无效的 JSON 请求体"}`, http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, `{"error":"ERR_INVALID_PARAM","message":"无效的 JSON 请求体"}`)
return return
} }
proj, err := s.pwSvc.CreateProject(r.Context(), user, req) proj, err := s.pwSvc.CreateProject(r.Context(), user, req)
if err != nil { if err != nil {
if errors.Is(err, projectworkflow.ErrInvalidInput) { if errors.Is(err, projectworkflow.ErrInvalidInput) {
http.Error(w, `{"error":"ERR_INVALID_PARAM","message":"`+err.Error()+`"}`, http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, `{"error":"ERR_INVALID_PARAM","message":"`+err.Error()+`"}`)
return return
} }
if errors.Is(err, projectworkflow.ErrWorkflowNotFound) { if errors.Is(err, projectworkflow.ErrWorkflowNotFound) {
http.Error(w, `{"error":"ERR_WORKFLOW_NOT_FOUND","message":"`+err.Error()+`"}`, http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, `{"error":"ERR_WORKFLOW_NOT_FOUND","message":"`+err.Error()+`"}`)
return return
} }
http.Error(w, `{"error":"ERR_INTERNAL","message":"`+err.Error()+`"}`, http.StatusInternalServerError) writeSafeError(w, r, http.StatusInternalServerError, `{"error":"ERR_INTERNAL","message":"`+err.Error()+`"}`)
return return
} }
@@ -86,7 +86,7 @@ func (s *Server) handleListProjects(w http.ResponseWriter, r *http.Request) {
list, err = s.pwSvc.ListProjects(r.Context(), user.ID) list, err = s.pwSvc.ListProjects(r.Context(), user.ID)
} }
if err != nil { if err != nil {
http.Error(w, `{"error":"ERR_INTERNAL","message":"`+err.Error()+`"}`, http.StatusInternalServerError) writeSafeError(w, r, http.StatusInternalServerError, `{"error":"ERR_INTERNAL","message":"`+err.Error()+`"}`)
return return
} }
if list == nil { if list == nil {
@@ -101,7 +101,7 @@ func (s *Server) handleGetProjectByID(w http.ResponseWriter, r *http.Request) {
idStr := r.PathValue("id") idStr := r.PathValue("id")
id, err := strconv.ParseInt(idStr, 10, 64) id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil || id <= 0 { if err != nil || id <= 0 {
http.Error(w, `{"error":"ERR_INVALID_PARAM","message":"invalid project id"}`, http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, `{"error":"ERR_INVALID_PARAM","message":"invalid project id"}`)
return return
} }
@@ -112,10 +112,10 @@ func (s *Server) handleGetProjectByID(w http.ResponseWriter, r *http.Request) {
proj, err := s.pwSvc.GetProject(r.Context(), id, 0) proj, err := s.pwSvc.GetProject(r.Context(), id, 0)
if err != nil { if err != nil {
if errors.Is(err, projectworkflow.ErrProjectNotFound) { if errors.Is(err, projectworkflow.ErrProjectNotFound) {
http.Error(w, `{"error":"ERR_PROJECT_NOT_FOUND","message":"项目不存在或无权限访问"}`, http.StatusNotFound) writeSafeError(w, r, http.StatusNotFound, `{"error":"ERR_PROJECT_NOT_FOUND","message":"项目不存在或无权限访问"}`)
return return
} }
http.Error(w, `{"error":"ERR_INTERNAL","message":"`+err.Error()+`"}`, http.StatusInternalServerError) writeSafeError(w, r, http.StatusInternalServerError, `{"error":"ERR_INTERNAL","message":"`+err.Error()+`"}`)
return return
} }
@@ -127,7 +127,7 @@ func (s *Server) handleListProjectWorkflows(w http.ResponseWriter, r *http.Reque
idStr := r.PathValue("id") idStr := r.PathValue("id")
id, err := strconv.ParseInt(idStr, 10, 64) id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil || id <= 0 { if err != nil || id <= 0 {
http.Error(w, `{"error":"ERR_INVALID_PARAM","message":"invalid project id"}`, http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, `{"error":"ERR_INVALID_PARAM","message":"invalid project id"}`)
return return
} }
if _, ok := s.requireProjectAccess(w, r, id); !ok { if _, ok := s.requireProjectAccess(w, r, id); !ok {
@@ -136,7 +136,7 @@ func (s *Server) handleListProjectWorkflows(w http.ResponseWriter, r *http.Reque
wfs, err := s.pwSvc.ListProjectWorkflows(r.Context(), id) wfs, err := s.pwSvc.ListProjectWorkflows(r.Context(), id)
if err != nil { if err != nil {
http.Error(w, `{"error":"ERR_INTERNAL","message":"`+err.Error()+`"}`, http.StatusInternalServerError) writeSafeError(w, r, http.StatusInternalServerError, `{"error":"ERR_INTERNAL","message":"`+err.Error()+`"}`)
return return
} }
if wfs == nil { if wfs == nil {
@@ -151,18 +151,18 @@ func (s *Server) handleActivateWorkflow(w http.ResponseWriter, r *http.Request)
idStr := r.PathValue("id") idStr := r.PathValue("id")
id, err := strconv.ParseInt(idStr, 10, 64) id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil || id <= 0 { if err != nil || id <= 0 {
http.Error(w, `{"error":"ERR_INVALID_PARAM","message":"invalid project id"}`, http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, `{"error":"ERR_INVALID_PARAM","message":"invalid project id"}`)
return return
} }
user, err := s.authenticateUser(r) user, err := s.authenticateUser(r)
if err != nil || user == nil { if err != nil || user == nil {
http.Error(w, `{"error":"ERR_UNAUTHENTICATED","message":"请先登录"}`, http.StatusUnauthorized) writeSafeError(w, r, http.StatusUnauthorized, `{"error":"ERR_UNAUTHENTICATED","message":"请先登录"}`)
return return
} }
if s.instSvc != nil && s.instSvc.Available() { if s.instSvc != nil && s.instSvc.Available() {
http.Error(w, `{"error":"WORKFLOW_SWITCH_API_RETIRED","message":"请通过项目设置完成工作流规划与激活"}`, http.StatusGone) writeSafeError(w, r, http.StatusGone, `{"error":"WORKFLOW_SWITCH_API_RETIRED","message":"请通过项目设置完成工作流规划与激活"}`)
return return
} }
@@ -172,29 +172,29 @@ func (s *Server) handleActivateWorkflow(w http.ResponseWriter, r *http.Request)
var req projectworkflow.ActivateWorkflowInput var req projectworkflow.ActivateWorkflowInput
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, `{"error":"ERR_INVALID_PARAM","message":"无效的请求体"}`, http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, `{"error":"ERR_INVALID_PARAM","message":"无效的请求体"}`)
return 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 err != nil {
if errors.Is(err, projectworkflow.ErrForbiddenNotCreator) { if errors.Is(err, projectworkflow.ErrForbiddenNotCreator) {
http.Error(w, `{"error":"ERR_NOT_PROJECT_CREATOR","message":"仅项目创建人有权切换激活工作流"}`, http.StatusForbidden) writeSafeError(w, r, http.StatusForbidden, `{"error":"ERR_NOT_PROJECT_CREATOR","message":"仅项目创建人有权切换激活工作流"}`)
return return
} }
if errors.Is(err, projectworkflow.ErrConcurrentConflict) { if errors.Is(err, projectworkflow.ErrConcurrentConflict) {
http.Error(w, `{"error":"ERR_CONCURRENT_SWITCH","message":"工作流选择版本落后或并发冲突,请刷新后重试"}`, http.StatusConflict) writeSafeError(w, r, http.StatusConflict, `{"error":"ERR_CONCURRENT_SWITCH","message":"工作流选择版本落后或并发冲突,请刷新后重试"}`)
return return
} }
if errors.Is(err, projectworkflow.ErrWorkflowNotFound) { if errors.Is(err, projectworkflow.ErrWorkflowNotFound) {
http.Error(w, `{"error":"ERR_WORKFLOW_NOT_FOUND","message":"工作流定义不存在或未在项目启用"}`, http.StatusNotFound) writeSafeError(w, r, http.StatusNotFound, `{"error":"ERR_WORKFLOW_NOT_FOUND","message":"工作流定义不存在或未在项目启用"}`)
return return
} }
if errors.Is(err, projectworkflow.ErrProjectNotFound) { if errors.Is(err, projectworkflow.ErrProjectNotFound) {
http.Error(w, `{"error":"ERR_PROJECT_NOT_FOUND","message":"项目不存在"}`, http.StatusNotFound) writeSafeError(w, r, http.StatusNotFound, `{"error":"ERR_PROJECT_NOT_FOUND","message":"项目不存在"}`)
return return
} }
http.Error(w, `{"error":"ERR_INTERNAL","message":"`+err.Error()+`"}`, http.StatusInternalServerError) writeSafeError(w, r, http.StatusInternalServerError, `{"error":"ERR_INTERNAL","message":"`+err.Error()+`"}`)
return return
} }
@@ -212,17 +212,17 @@ func (s *Server) handleRunProjectWorkflow(w http.ResponseWriter, r *http.Request
idStr := r.PathValue("id") idStr := r.PathValue("id")
id, err := strconv.ParseInt(idStr, 10, 64) id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil || id <= 0 { if err != nil || id <= 0 {
http.Error(w, `{"error":"ERR_INVALID_PARAM","message":"invalid project id"}`, http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, `{"error":"ERR_INVALID_PARAM","message":"invalid project id"}`)
return return
} }
wfKey := r.PathValue("workflow_key") wfKey := r.PathValue("workflow_key")
if wfKey == "" { if wfKey == "" {
http.Error(w, `{"error":"ERR_INVALID_PARAM","message":"workflow_key is required"}`, http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, `{"error":"ERR_INVALID_PARAM","message":"workflow_key is required"}`)
return return
} }
def, ok := s.pwSvc.Catalog().Get(wfKey) def, ok := s.pwSvc.Catalog().Get(wfKey)
if !ok { if !ok {
http.Error(w, `{"error":"ERR_WORKFLOW_NOT_FOUND","message":"unknown workflow"}`, http.StatusNotFound) writeSafeError(w, r, http.StatusNotFound, `{"error":"ERR_WORKFLOW_NOT_FOUND","message":"unknown workflow"}`)
return return
} }
@@ -236,7 +236,7 @@ func (s *Server) handleRunProjectWorkflow(w http.ResponseWriter, r *http.Request
_ = json.NewDecoder(r.Body).Decode(&req) _ = json.NewDecoder(r.Body).Decode(&req)
} }
if req.TaskKey != "" && req.TaskKey != def.TaskKey && s.failClosedAuthz() { if req.TaskKey != "" && req.TaskKey != def.TaskKey && s.failClosedAuthz() {
http.Error(w, `{"error":"ERR_INVALID_PARAM","message":"客户端不可覆盖 task_key"}`, http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, `{"error":"ERR_INVALID_PARAM","message":"客户端不可覆盖 task_key"}`)
return return
} }
if !s.allowWorkflowDocTaskAccess(r.Context(), id, user.ID, def.TaskKey) { if !s.allowWorkflowDocTaskAccess(r.Context(), id, user.ID, def.TaskKey) {
@@ -258,7 +258,7 @@ func (s *Server) handleListProjectWorkflowRuns(w http.ResponseWriter, r *http.Re
idStr := r.PathValue("id") idStr := r.PathValue("id")
id, err := strconv.ParseInt(idStr, 10, 64) id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil || id <= 0 { if err != nil || id <= 0 {
http.Error(w, `{"error":"ERR_INVALID_PARAM","message":"invalid project id"}`, http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, `{"error":"ERR_INVALID_PARAM","message":"invalid project id"}`)
return return
} }
if _, ok := s.requireProjectAccess(w, r, id); !ok { if _, ok := s.requireProjectAccess(w, r, id); !ok {
@@ -267,7 +267,7 @@ func (s *Server) handleListProjectWorkflowRuns(w http.ResponseWriter, r *http.Re
wfKey := r.PathValue("workflow_key") 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 { if err != nil {
http.Error(w, `{"error":"ERR_INTERNAL","message":"`+err.Error()+`"}`, http.StatusInternalServerError) writeSafeError(w, r, http.StatusInternalServerError, `{"error":"ERR_INTERNAL","message":"`+err.Error()+`"}`)
return return
} }
if runs == nil { if runs == nil {
@@ -281,7 +281,7 @@ func (s *Server) handleGetProjectLineage(w http.ResponseWriter, r *http.Request)
idStr := r.PathValue("id") idStr := r.PathValue("id")
projectID, _ := strconv.ParseInt(idStr, 10, 64) projectID, _ := strconv.ParseInt(idStr, 10, 64)
if projectID <= 0 { if projectID <= 0 {
http.Error(w, `{"error":"ERR_INVALID_PARAM","message":"invalid project id"}`, http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, `{"error":"ERR_INVALID_PARAM","message":"invalid project id"}`)
return return
} }
if _, ok := s.requireProjectAccess(w, r, projectID); !ok { if _, ok := s.requireProjectAccess(w, r, projectID); !ok {
@@ -297,12 +297,12 @@ func (s *Server) handleGetProjectLineage(w http.ResponseWriter, r *http.Request)
} }
} }
if docID == "" { if docID == "" {
http.Error(w, `{"error":"ERR_INVALID_PARAM","message":"doc_id is required"}`, http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, `{"error":"ERR_INVALID_PARAM","message":"doc_id is required"}`)
return return
} }
lineages, err := s.pwSvc.GetLineageForDownstream(r.Context(), docID, 0) lineages, err := s.pwSvc.GetLineageForDownstream(r.Context(), docID, 0)
if err != nil { if err != nil {
http.Error(w, `{"error":"ERR_INTERNAL","message":"`+err.Error()+`"}`, http.StatusInternalServerError) writeSafeError(w, r, http.StatusInternalServerError, `{"error":"ERR_INTERNAL","message":"`+err.Error()+`"}`)
return return
} }
if len(lineages) == 0 { if len(lineages) == 0 {
+16 -16
View File
@@ -26,7 +26,7 @@ func resolveUpdateProjectRole(requested string, existing *authz.ProjectMemberRec
} }
func writeDutySyncError(w http.ResponseWriter) { func writeDutySyncError(w http.ResponseWriter) {
http.Error(w, `{"error":"ERR_DUTY_SYNC","message":"成员已保存,但受控文档职责同步失败"}`, http.StatusInternalServerError) writeSafeError(w, nil, http.StatusInternalServerError, `{"error":"ERR_DUTY_SYNC","message":"成员已保存,但受控文档职责同步失败"}`)
} }
func (s *Server) healProjectDuties(ctx context.Context, projectID int64) error { func (s *Server) healProjectDuties(ctx context.Context, projectID int64) error {
@@ -59,7 +59,7 @@ func (s *Server) handleProjectMembers(w http.ResponseWriter, r *http.Request) {
projectID, err := strconv.ParseInt(r.PathValue("id"), 10, 64) projectID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || projectID <= 0 { if err != nil || projectID <= 0 {
http.Error(w, `{"error":"ERR_INVALID_PARAM","message":"invalid project id"}`, http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, `{"error":"ERR_INVALID_PARAM","message":"invalid project id"}`)
return return
} }
@@ -73,7 +73,7 @@ func (s *Server) handleProjectMembers(w http.ResponseWriter, r *http.Request) {
case http.MethodDelete: case http.MethodDelete:
s.handleRemoveProjectMember(w, r, user, projectID) s.handleRemoveProjectMember(w, r, user, projectID)
default: default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed) writeSafeError(w, r, http.StatusMethodNotAllowed, "method not allowed")
} }
} }
@@ -83,7 +83,7 @@ func (s *Server) handleListProjectMembers(w http.ResponseWriter, r *http.Request
} }
members, err := s.authorizer.ListProjectMembers(r.Context(), projectID) members, err := s.authorizer.ListProjectMembers(r.Context(), projectID)
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) writeSafeError(w, r, http.StatusInternalServerError, err.Error())
return return
} }
if members == nil { if members == nil {
@@ -95,11 +95,11 @@ 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 { 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 { if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) writeSafeError(w, r, http.StatusInternalServerError, err.Error())
return false return false
} }
if !can { if !can {
http.Error(w, `{"error":"ERR_FORBIDDEN","message":"无权管理项目成员"}`, http.StatusForbidden) writeSafeError(w, r, http.StatusForbidden, `{"error":"ERR_FORBIDDEN","message":"无权管理项目成员"}`)
return false return false
} }
return true return true
@@ -111,7 +111,7 @@ func (s *Server) handleAddProjectMember(w http.ResponseWriter, r *http.Request,
} }
var req projectMemberRequest var req projectMemberRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.UserID <= 0 { if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.UserID <= 0 {
http.Error(w, `{"error":"ERR_INVALID_PARAM","message":"user_id and project_role required"}`, http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, `{"error":"ERR_INVALID_PARAM","message":"user_id and project_role required"}`)
return return
} }
if req.ProjectRole == "" { if req.ProjectRole == "" {
@@ -121,7 +121,7 @@ func (s *Server) handleAddProjectMember(w http.ResponseWriter, r *http.Request,
ProjectRole: req.ProjectRole, ProjectRole: req.ProjectRole,
DocumentDuty: req.DocumentDuty, DocumentDuty: req.DocumentDuty,
}); err != nil { }); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, err.Error())
return return
} }
if err := s.healProjectDuties(r.Context(), projectID); err != nil { if err := s.healProjectDuties(r.Context(), projectID); err != nil {
@@ -137,40 +137,40 @@ func (s *Server) handleUpdateProjectMember(w http.ResponseWriter, r *http.Reques
} }
var req projectMemberRequest var req projectMemberRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.UserID <= 0 { if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.UserID <= 0 {
http.Error(w, `{"error":"ERR_INVALID_PARAM","message":"user_id required"}`, http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, `{"error":"ERR_INVALID_PARAM","message":"user_id required"}`)
return return
} }
var existing *authz.ProjectMemberRecord var existing *authz.ProjectMemberRecord
if s.authorizer != nil { if s.authorizer != nil {
rec, err := s.authorizer.GetProjectMember(r.Context(), projectID, req.UserID) rec, err := s.authorizer.GetProjectMember(r.Context(), projectID, req.UserID)
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) writeSafeError(w, r, http.StatusInternalServerError, err.Error())
return return
} }
existing = rec existing = rec
} else if req.ProjectRole == "" && req.DocumentDuty != nil { } else if req.ProjectRole == "" && req.DocumentDuty != nil {
http.Error(w, `{"error":"ERR_INVALID_PARAM","message":"project_role required with document_duty"}`, http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, `{"error":"ERR_INVALID_PARAM","message":"project_role required with document_duty"}`)
return return
} }
wrote := false wrote := false
if req.ProjectRole != "" || req.DocumentDuty != nil { if req.ProjectRole != "" || req.DocumentDuty != nil {
role, err := resolveUpdateProjectRole(req.ProjectRole, existing) role, err := resolveUpdateProjectRole(req.ProjectRole, existing)
if err != nil { if err != nil {
http.Error(w, `{"error":"ERR_INVALID_PARAM","message":"project_role required when member does not exist"}`, http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, `{"error":"ERR_INVALID_PARAM","message":"project_role required when member does not exist"}`)
return 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, ProjectRole: role,
DocumentDuty: req.DocumentDuty, DocumentDuty: req.DocumentDuty,
}); err != nil { }); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, err.Error())
return return
} }
wrote = true wrote = true
} }
if req.Status != "" { 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 {
http.Error(w, err.Error(), http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, err.Error())
return return
} }
wrote = true wrote = true
@@ -191,11 +191,11 @@ func (s *Server) handleRemoveProjectMember(w http.ResponseWriter, r *http.Reques
userIDStr := r.URL.Query().Get("user_id") userIDStr := r.URL.Query().Get("user_id")
targetID, err := strconv.ParseInt(userIDStr, 10, 64) targetID, err := strconv.ParseInt(userIDStr, 10, 64)
if err != nil || targetID <= 0 { if err != nil || targetID <= 0 {
http.Error(w, `{"error":"ERR_INVALID_PARAM","message":"user_id query required"}`, http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, `{"error":"ERR_INVALID_PARAM","message":"user_id query required"}`)
return 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 {
http.Error(w, err.Error(), http.StatusInternalServerError) writeSafeError(w, r, http.StatusInternalServerError, err.Error())
return return
} }
if err := s.healProjectDuties(r.Context(), projectID); err != nil { if err := s.healProjectDuties(r.Context(), projectID); err != nil {
+31 -31
View File
@@ -13,14 +13,14 @@ import (
func (s *Server) handleGetDefaultWorkflowPlan(w http.ResponseWriter, r *http.Request) { func (s *Server) handleGetDefaultWorkflowPlan(w http.ResponseWriter, r *http.Request) {
projectID, err := strconv.ParseInt(r.PathValue("id"), 10, 64) projectID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || projectID <= 0 { if err != nil || projectID <= 0 {
http.Error(w, `{"error":"ERR_INVALID_PARAM","message":"invalid project id"}`, http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, `{"error":"ERR_INVALID_PARAM","message":"invalid project id"}`)
return return
} }
if _, ok := s.requireProjectAccess(w, r, projectID); !ok { if _, ok := s.requireProjectAccess(w, r, projectID); !ok {
return return
} }
if s.instSvc == nil || !s.instSvc.Available() { if s.instSvc == nil || !s.instSvc.Available() {
http.Error(w, `{"error":"ERR_NOT_AVAILABLE","message":"项目实例服务不可用"}`, http.StatusServiceUnavailable) writeSafeError(w, r, http.StatusServiceUnavailable, `{"error":"ERR_NOT_AVAILABLE","message":"项目实例服务不可用"}`)
return return
} }
workflowKey := r.URL.Query().Get("workflow_key") workflowKey := r.URL.Query().Get("workflow_key")
@@ -36,7 +36,7 @@ func (s *Server) handleGetDefaultWorkflowPlan(w http.ResponseWriter, r *http.Req
func (s *Server) handleGetProjectSettings(w http.ResponseWriter, r *http.Request) { func (s *Server) handleGetProjectSettings(w http.ResponseWriter, r *http.Request) {
projectID, err := strconv.ParseInt(r.PathValue("id"), 10, 64) projectID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || projectID <= 0 { if err != nil || projectID <= 0 {
http.Error(w, `{"error":"ERR_INVALID_PARAM","message":"invalid project id"}`, http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, `{"error":"ERR_INVALID_PARAM","message":"invalid project id"}`)
return return
} }
user, ok := s.requireProjectAccess(w, r, projectID) user, ok := s.requireProjectAccess(w, r, projectID)
@@ -44,7 +44,7 @@ func (s *Server) handleGetProjectSettings(w http.ResponseWriter, r *http.Request
return return
} }
if s.instSvc == nil || !s.instSvc.Available() { if s.instSvc == nil || !s.instSvc.Available() {
http.Error(w, `{"error":"ERR_NOT_AVAILABLE","message":"项目实例服务不可用,请先执行数据库迁移"}`, http.StatusServiceUnavailable) writeSafeError(w, r, http.StatusServiceUnavailable, `{"error":"ERR_NOT_AVAILABLE","message":"项目实例服务不可用,请先执行数据库迁移"}`)
return return
} }
isCreator, _ := s.pwSvc.IsProjectCreator(r.Context(), projectID, user.ID) isCreator, _ := s.pwSvc.IsProjectCreator(r.Context(), projectID, user.ID)
@@ -81,7 +81,7 @@ type workflowEventSummary struct {
func (s *Server) handleSaveWorkflowPlan(w http.ResponseWriter, r *http.Request) { func (s *Server) handleSaveWorkflowPlan(w http.ResponseWriter, r *http.Request) {
projectID, err := strconv.ParseInt(r.PathValue("id"), 10, 64) projectID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || projectID <= 0 { if err != nil || projectID <= 0 {
http.Error(w, `{"error":"ERR_INVALID_PARAM","message":"invalid project id"}`, http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, `{"error":"ERR_INVALID_PARAM","message":"invalid project id"}`)
return return
} }
user, ok := s.requireProjectAccess(w, r, projectID) user, ok := s.requireProjectAccess(w, r, projectID)
@@ -89,13 +89,13 @@ func (s *Server) handleSaveWorkflowPlan(w http.ResponseWriter, r *http.Request)
return return
} }
if s.instSvc == nil || !s.instSvc.Available() { if s.instSvc == nil || !s.instSvc.Available() {
http.Error(w, `{"error":"ERR_NOT_AVAILABLE","message":"项目实例服务不可用"}`, http.StatusServiceUnavailable) writeSafeError(w, r, http.StatusServiceUnavailable, `{"error":"ERR_NOT_AVAILABLE","message":"项目实例服务不可用"}`)
return return
} }
isCreator, _ := s.pwSvc.IsProjectCreator(r.Context(), projectID, user.ID) isCreator, _ := s.pwSvc.IsProjectCreator(r.Context(), projectID, user.ID)
var req projectinstance.SavePlanInput var req projectinstance.SavePlanInput
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, `{"error":"ERR_INVALID_PARAM","message":"无效的 JSON 请求体"}`, http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, `{"error":"ERR_INVALID_PARAM","message":"无效的 JSON 请求体"}`)
return return
} }
settings, err := s.instSvc.SaveWorkflowPlan(r.Context(), projectID, user.ID, isCreator, req) settings, err := s.instSvc.SaveWorkflowPlan(r.Context(), projectID, user.ID, isCreator, req)
@@ -109,7 +109,7 @@ func (s *Server) handleSaveWorkflowPlan(w http.ResponseWriter, r *http.Request)
func (s *Server) handleActivationPreview(w http.ResponseWriter, r *http.Request) { func (s *Server) handleActivationPreview(w http.ResponseWriter, r *http.Request) {
projectID, err := strconv.ParseInt(r.PathValue("id"), 10, 64) projectID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || projectID <= 0 { if err != nil || projectID <= 0 {
http.Error(w, `{"error":"ERR_INVALID_PARAM","message":"invalid project id"}`, http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, `{"error":"ERR_INVALID_PARAM","message":"invalid project id"}`)
return return
} }
user, ok := s.requireProjectAccess(w, r, projectID) user, ok := s.requireProjectAccess(w, r, projectID)
@@ -117,7 +117,7 @@ func (s *Server) handleActivationPreview(w http.ResponseWriter, r *http.Request)
return return
} }
if s.instSvc == nil || !s.instSvc.Available() { if s.instSvc == nil || !s.instSvc.Available() {
http.Error(w, `{"error":"ERR_NOT_AVAILABLE","message":"项目实例服务不可用"}`, http.StatusServiceUnavailable) writeSafeError(w, r, http.StatusServiceUnavailable, `{"error":"ERR_NOT_AVAILABLE","message":"项目实例服务不可用"}`)
return return
} }
isCreator, _ := s.pwSvc.IsProjectCreator(r.Context(), projectID, user.ID) isCreator, _ := s.pwSvc.IsProjectCreator(r.Context(), projectID, user.ID)
@@ -132,7 +132,7 @@ func (s *Server) handleActivationPreview(w http.ResponseWriter, r *http.Request)
func (s *Server) handleActivateWorkflowInstance(w http.ResponseWriter, r *http.Request) { func (s *Server) handleActivateWorkflowInstance(w http.ResponseWriter, r *http.Request) {
projectID, err := strconv.ParseInt(r.PathValue("id"), 10, 64) projectID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || projectID <= 0 { if err != nil || projectID <= 0 {
http.Error(w, `{"error":"ERR_INVALID_PARAM","message":"invalid project id"}`, http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, `{"error":"ERR_INVALID_PARAM","message":"invalid project id"}`)
return return
} }
user, ok := s.requireProjectAccess(w, r, projectID) user, ok := s.requireProjectAccess(w, r, projectID)
@@ -140,13 +140,13 @@ func (s *Server) handleActivateWorkflowInstance(w http.ResponseWriter, r *http.R
return return
} }
if s.instSvc == nil || !s.instSvc.Available() { if s.instSvc == nil || !s.instSvc.Available() {
http.Error(w, `{"error":"ERR_NOT_AVAILABLE","message":"项目实例服务不可用"}`, http.StatusServiceUnavailable) writeSafeError(w, r, http.StatusServiceUnavailable, `{"error":"ERR_NOT_AVAILABLE","message":"项目实例服务不可用"}`)
return return
} }
isCreator, _ := s.pwSvc.IsProjectCreator(r.Context(), projectID, user.ID) isCreator, _ := s.pwSvc.IsProjectCreator(r.Context(), projectID, user.ID)
var req projectinstance.ActivateInput var req projectinstance.ActivateInput
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, `{"error":"ERR_INVALID_PARAM","message":"无效的 JSON 请求体"}`, http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, `{"error":"ERR_INVALID_PARAM","message":"无效的 JSON 请求体"}`)
return 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)
@@ -160,13 +160,13 @@ func (s *Server) handleActivateWorkflowInstance(w http.ResponseWriter, r *http.R
func writeProjectInstanceError(w http.ResponseWriter, err error) { func writeProjectInstanceError(w http.ResponseWriter, err error) {
switch { switch {
case errors.Is(err, projectinstance.ErrForbiddenNotCreator): case errors.Is(err, projectinstance.ErrForbiddenNotCreator):
http.Error(w, `{"error":"ERR_NOT_PROJECT_CREATOR","message":"仅项目创建人可配置工作流"}`, http.StatusForbidden) writeSafeError(w, nil, http.StatusForbidden, `{"error":"ERR_NOT_PROJECT_CREATOR","message":"仅项目创建人可配置工作流"}`)
case errors.Is(err, projectinstance.ErrPlanFrozen): case errors.Is(err, projectinstance.ErrPlanFrozen):
http.Error(w, `{"error":"PLAN_FROZEN","message":"工作流已激活,规划已冻结"}`, http.StatusConflict) writeSafeError(w, nil, http.StatusConflict, `{"error":"PLAN_FROZEN","message":"工作流已激活,规划已冻结"}`)
case errors.Is(err, projectinstance.ErrWorkflowAlreadyActive): case errors.Is(err, projectinstance.ErrWorkflowAlreadyActive):
http.Error(w, `{"error":"WORKFLOW_ALREADY_ACTIVE","message":"项目已有运行中的工作流实例"}`, http.StatusConflict) writeSafeError(w, nil, http.StatusConflict, `{"error":"WORKFLOW_ALREADY_ACTIVE","message":"项目已有运行中的工作流实例"}`)
case errors.Is(err, projectinstance.ErrPlanChangedReconfirm): case errors.Is(err, projectinstance.ErrPlanChangedReconfirm):
http.Error(w, `{"error":"PLAN_CHANGED_RECONFIRM","message":"规划已变更,请重新预览并确认"}`, http.StatusConflict) writeSafeError(w, nil, http.StatusConflict, `{"error":"PLAN_CHANGED_RECONFIRM","message":"规划已变更,请重新预览并确认"}`)
case errors.Is(err, projectinstance.ErrValidationFailed): case errors.Is(err, projectinstance.ErrValidationFailed):
var validationErr *projectinstance.ValidationError var validationErr *projectinstance.ValidationError
if errors.As(err, &validationErr) { if errors.As(err, &validationErr) {
@@ -177,36 +177,36 @@ func writeProjectInstanceError(w http.ResponseWriter, err error) {
}) })
return return
} }
http.Error(w, `{"error":"VALIDATION_FAILED","message":"工作流配置校验未通过"}`, http.StatusBadRequest) writeSafeError(w, nil, http.StatusBadRequest, `{"error":"VALIDATION_FAILED","message":"工作流配置校验未通过"}`)
case errors.Is(err, projectinstance.ErrTokenInvalid): case errors.Is(err, projectinstance.ErrTokenInvalid):
http.Error(w, `{"error":"TOKEN_INVALID","message":"激活令牌无效或已过期"}`, http.StatusBadRequest) writeSafeError(w, nil, http.StatusBadRequest, `{"error":"TOKEN_INVALID","message":"激活令牌无效或已过期"}`)
case errors.Is(err, projectinstance.ErrConcurrentConflict): case errors.Is(err, projectinstance.ErrConcurrentConflict):
http.Error(w, `{"error":"ERR_CONCURRENT_SWITCH","message":"项目版本冲突,请刷新后重试"}`, http.StatusConflict) writeSafeError(w, nil, http.StatusConflict, `{"error":"ERR_CONCURRENT_SWITCH","message":"项目版本冲突,请刷新后重试"}`)
case errors.Is(err, projectinstance.ErrPlanRevisionConflict): case errors.Is(err, projectinstance.ErrPlanRevisionConflict):
http.Error(w, `{"error":"PLAN_REVISION_CONFLICT","message":"规划版本冲突,请刷新后重试"}`, http.StatusConflict) writeSafeError(w, nil, http.StatusConflict, `{"error":"PLAN_REVISION_CONFLICT","message":"规划版本冲突,请刷新后重试"}`)
case errors.Is(err, projectinstance.ErrProjectNotFound): case errors.Is(err, projectinstance.ErrProjectNotFound):
http.Error(w, `{"error":"ERR_PROJECT_NOT_FOUND","message":"项目不存在"}`, http.StatusNotFound) writeSafeError(w, nil, http.StatusNotFound, `{"error":"ERR_PROJECT_NOT_FOUND","message":"项目不存在"}`)
case errors.Is(err, projectinstance.ErrInvalidPlan): case errors.Is(err, projectinstance.ErrInvalidPlan):
http.Error(w, `{"error":"ERR_INVALID_PLAN","message":"`+err.Error()+`"}`, http.StatusBadRequest) writeSafeError(w, nil, http.StatusBadRequest, `{"error":"ERR_INVALID_PLAN","message":"`+err.Error()+`"}`)
case errors.Is(err, projectinstance.ErrInstanceNotFound): case errors.Is(err, projectinstance.ErrInstanceNotFound):
http.Error(w, `{"error":"ERR_INSTANCE_NOT_FOUND","message":"工作流实例不存在"}`, http.StatusNotFound) writeSafeError(w, nil, http.StatusNotFound, `{"error":"ERR_INSTANCE_NOT_FOUND","message":"工作流实例不存在"}`)
case errors.Is(err, projectinstance.ErrStageGateNotReady): case errors.Is(err, projectinstance.ErrStageGateNotReady):
http.Error(w, `{"error":"STAGE_GATE_NOT_READY","message":"阶段门禁未满足,必需文档尚未全部批准"}`, http.StatusConflict) writeSafeError(w, nil, http.StatusConflict, `{"error":"STAGE_GATE_NOT_READY","message":"阶段门禁未满足,必需文档尚未全部批准"}`)
case errors.Is(err, projectinstance.ErrStageNotActive): case errors.Is(err, projectinstance.ErrStageNotActive):
http.Error(w, `{"error":"STAGE_NOT_ACTIVE","message":"目标阶段不是当前活动阶段"}`, http.StatusConflict) writeSafeError(w, nil, http.StatusConflict, `{"error":"STAGE_NOT_ACTIVE","message":"目标阶段不是当前活动阶段"}`)
case errors.Is(err, projectinstance.ErrInvalidStage): case errors.Is(err, projectinstance.ErrInvalidStage):
http.Error(w, `{"error":"ERR_INVALID_STAGE","message":"无效的阶段"}`, http.StatusBadRequest) writeSafeError(w, nil, http.StatusBadRequest, `{"error":"ERR_INVALID_STAGE","message":"无效的阶段"}`)
case errors.Is(err, projectinstance.ErrInstanceRevisionConflict): case errors.Is(err, projectinstance.ErrInstanceRevisionConflict):
http.Error(w, `{"error":"INSTANCE_REVISION_CONFLICT","message":"实例版本冲突,请刷新后重试"}`, http.StatusConflict) writeSafeError(w, nil, http.StatusConflict, `{"error":"INSTANCE_REVISION_CONFLICT","message":"实例版本冲突,请刷新后重试"}`)
default: default:
http.Error(w, `{"error":"ERR_INTERNAL","message":"`+err.Error()+`"}`, http.StatusInternalServerError) writeSafeError(w, nil, http.StatusInternalServerError, `{"error":"ERR_INTERNAL","message":"`+err.Error()+`"}`)
} }
} }
func (s *Server) blockLegacyWorkflowSwitch(w http.ResponseWriter, r *http.Request, projectID int64) bool { 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 == "" { if err == nil && proj != nil && proj.LifecycleStatus == "planning" && proj.ActiveWorkflowKey == "" {
http.Error(w, `{"error":"LEGACY_SWITCH_BLOCKED","message":"新项目请通过项目设置完成工作流激活"}`, http.StatusConflict) writeSafeError(w, r, http.StatusConflict, `{"error":"LEGACY_SWITCH_BLOCKED","message":"新项目请通过项目设置完成工作流激活"}`)
return true return true
} }
if s.instSvc == nil || !s.instSvc.Available() { if s.instSvc == nil || !s.instSvc.Available() {
@@ -214,11 +214,11 @@ func (s *Server) blockLegacyWorkflowSwitch(w http.ResponseWriter, r *http.Reques
} }
live, err := s.instSvc.HasLiveInstance(r.Context(), projectID) live, err := s.instSvc.HasLiveInstance(r.Context(), projectID)
if err != nil { if err != nil {
http.Error(w, `{"error":"ERR_INTERNAL","message":"`+err.Error()+`"}`, http.StatusInternalServerError) writeSafeError(w, r, http.StatusInternalServerError, `{"error":"ERR_INTERNAL","message":"`+err.Error()+`"}`)
return true return true
} }
if live { if live {
http.Error(w, `{"error":"WORKFLOW_ALREADY_ACTIVE","message":"项目已有运行中的工作流实例,请使用项目设置查看流程"}`, http.StatusConflict) writeSafeError(w, r, http.StatusConflict, `{"error":"WORKFLOW_ALREADY_ACTIVE","message":"项目已有运行中的工作流实例,请使用项目设置查看流程"}`)
return true return true
} }
return false return false
@@ -256,4 +256,3 @@ func TestProjectWorkflow_TestManagerAndLineageStale(t *testing.T) {
require.NotEmpty(t, lineagesAfter) require.NotEmpty(t, lineagesAfter)
assert.True(t, lineagesAfter[0].IsStale) assert.True(t, lineagesAfter[0].IsStale)
} }
+6 -6
View File
@@ -16,33 +16,33 @@ func (s *Server) handleReconcileWorkspace(w http.ResponseWriter, r *http.Request
RunID string `json:"run_id"` RunID string `json:"run_id"`
} }
if err := json.NewDecoder(r.Body).Decode(&body); err != nil { if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, "invalid body", http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, "invalid body")
return return
} }
if body.RunID == "" { if body.RunID == "" {
http.Error(w, "run_id required", http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, "run_id required")
return return
} }
rec, err := s.taskRuns.GetRun(body.RunID) rec, err := s.taskRuns.GetRun(body.RunID)
if err != nil { if err != nil {
http.Error(w, "run not found", http.StatusNotFound) writeSafeError(w, r, http.StatusNotFound, "run not found")
return return
} }
if rec.WorkspaceID == "" { if rec.WorkspaceID == "" {
http.Error(w, "workspace not available for run", http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, "workspace not available for run")
return return
} }
workDir, err := s.resolver.ResolveStoredWorkDir(rec.WorkspaceID) workDir, err := s.resolver.ResolveStoredWorkDir(rec.WorkspaceID)
if err != nil { if err != nil {
http.Error(w, "invalid workspace path", http.StatusForbidden) writeSafeError(w, r, http.StatusForbidden, "invalid workspace path")
return return
} }
report, err := squad.ReconcileWorkspaceStatus(workDir) report, err := squad.ReconcileWorkspaceStatus(workDir)
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, err.Error())
return return
} }
writeJSONResponse(w, http.StatusOK, report) writeJSONResponse(w, http.StatusOK, report)
+2 -2
View File
@@ -18,7 +18,7 @@ func (s *Server) handleGetExportCapability(w http.ResponseWriter, r *http.Reques
} }
if s.ctxMgr == nil { if s.ctxMgr == nil {
http.Error(w, "context manager not initialized", http.StatusServiceUnavailable) writeSafeError(w, r, http.StatusServiceUnavailable, "context manager not initialized")
return return
} }
@@ -85,7 +85,7 @@ func (s *Server) handleGetExportSource(w http.ResponseWriter, r *http.Request) {
} }
if s.ctxMgr == nil { if s.ctxMgr == nil {
http.Error(w, "context manager not initialized", http.StatusServiceUnavailable) writeSafeError(w, r, http.StatusServiceUnavailable, "context manager not initialized")
return return
} }
+5 -5
View File
@@ -172,11 +172,11 @@ func TestPenetration_ProjectAssetRejectsScriptContent(t *testing.T) {
func TestPenetration_ReadyStrictWhenDBConfiguredButDown(t *testing.T) { func TestPenetration_ReadyStrictWhenDBConfiguredButDown(t *testing.T) {
cfg := &config.Config{ cfg := &config.Config{
DataDir: t.TempDir(), DataDir: t.TempDir(),
AgentBackend: "stub", AgentBackend: "stub",
ContextDbHost: "127.0.0.1", ContextDbHost: "127.0.0.1",
ContextDbPort: 59999, ContextDbPort: 59999,
AllowInMemory: false, AllowInMemory: false,
} }
srv := NewServer(cfg, bizdev.MustNewService(t, &config.Config{DataDir: t.TempDir(), AgentBackend: "stub"})) srv := NewServer(cfg, bizdev.MustNewService(t, &config.Config{DataDir: t.TempDir(), AgentBackend: "stub"}))
srv.cfg = cfg srv.cfg = cfg
+6 -6
View File
@@ -129,14 +129,14 @@ func replayExistingTurn(w http.ResponseWriter, flusher http.Flusher, rec *ctxmgr
func (s *Server) handleChatStream(w http.ResponseWriter, r *http.Request) { func (s *Server) handleChatStream(w http.ResponseWriter, r *http.Request) {
flusher, ok := w.(http.Flusher) flusher, ok := w.(http.Flusher)
if !ok { if !ok {
http.Error(w, "streaming unsupported by underlying connection", http.StatusInternalServerError) writeSafeError(w, r, http.StatusInternalServerError, "streaming unsupported by underlying connection")
return return
} }
var req StreamChatRequest var req StreamChatRequest
if r.Method == http.MethodPost { if r.Method == http.MethodPost {
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body: "+err.Error(), http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, "invalid request body: "+err.Error())
return return
} }
} else { } else {
@@ -161,7 +161,7 @@ func (s *Server) handleChatStream(w http.ResponseWriter, r *http.Request) {
} }
if req.Message == "" { if req.Message == "" {
http.Error(w, "message cannot be empty", http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, "message cannot be empty")
return return
} }
@@ -305,7 +305,7 @@ func (s *Server) handleChatStream(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleRunTaskStream(w http.ResponseWriter, r *http.Request) { func (s *Server) handleRunTaskStream(w http.ResponseWriter, r *http.Request) {
flusher, ok := w.(http.Flusher) flusher, ok := w.(http.Flusher)
if !ok { if !ok {
http.Error(w, "streaming unsupported", http.StatusInternalServerError) writeSafeError(w, r, http.StatusInternalServerError, "streaming unsupported")
return return
} }
@@ -381,7 +381,7 @@ func (s *Server) handleRunTaskStream(w http.ResponseWriter, r *http.Request) {
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 { if err != nil {
http.Error(w, "start task run failed: "+err.Error(), http.StatusInternalServerError) writeSafeError(w, r, http.StatusInternalServerError, "start task run failed: "+err.Error())
return return
} }
_ = includeDetails _ = includeDetails
@@ -389,7 +389,7 @@ func (s *Server) handleRunTaskStream(w http.ResponseWriter, r *http.Request) {
ch, replay, err := s.taskRuns.Subscribe(created.RunID, 0) ch, replay, err := s.taskRuns.Subscribe(created.RunID, 0)
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) writeSafeError(w, r, http.StatusInternalServerError, err.Error())
return return
} }
defer s.taskRuns.Unsubscribe(created.RunID, ch) defer s.taskRuns.Unsubscribe(created.RunID, ch)
+4 -4
View File
@@ -10,9 +10,9 @@ import (
type taskRunAction string type taskRunAction string
const ( const (
taskRunView taskRunAction = "view" taskRunView taskRunAction = "view"
taskRunCancel taskRunAction = "cancel" taskRunCancel taskRunAction = "cancel"
taskRunResume taskRunAction = "resume" taskRunResume taskRunAction = "resume"
) )
func (s *Server) assertTaskRunAccess(w http.ResponseWriter, ctx context.Context, runID string, user *auth.UserAccount, action taskRunAction) bool { func (s *Server) assertTaskRunAccess(w http.ResponseWriter, ctx context.Context, runID string, user *auth.UserAccount, action taskRunAction) bool {
@@ -25,7 +25,7 @@ func (s *Server) assertTaskRunAccess(w http.ResponseWriter, ctx context.Context,
} }
rec, err := s.taskRuns.GetRun(runID) rec, err := s.taskRuns.GetRun(runID)
if err != nil { if err != nil {
http.Error(w, "run not found", http.StatusNotFound) writeSafeError(w, nil, http.StatusNotFound, "run not found")
return false return false
} }
if s.isSystemAdminUser(ctx, user) { if s.isSystemAdminUser(ctx, user) {
+18 -18
View File
@@ -51,7 +51,7 @@ func (s *Server) handleCreateTaskRun(w http.ResponseWriter, r *http.Request) {
} }
var req createTaskRunRequest var req createTaskRunRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body: "+err.Error(), http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, "invalid request body: "+err.Error())
return return
} }
req.UserID = user.ID req.UserID = user.ID
@@ -65,7 +65,7 @@ func (s *Server) handleCreateTaskRun(w http.ResponseWriter, r *http.Request) {
} }
} }
if err := bizdev.ValidateTaskRunIdentity(req.SessionID, req.ProjectID); err != nil { if err := bizdev.ValidateTaskRunIdentity(req.SessionID, req.ProjectID); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, err.Error())
return return
} }
if req.TaskKey == "" && req.Stage == "" { if req.TaskKey == "" && req.Stage == "" {
@@ -85,7 +85,7 @@ func (s *Server) handleCreateTaskRun(w http.ResponseWriter, r *http.Request) {
idemKey := taskrun.BuildTaskRunIdempotencyKey(req.SessionID, req.ClientTurnID) idemKey := taskrun.BuildTaskRunIdempotencyKey(req.SessionID, req.ClientTurnID)
if idemKey != "" && s.taskRuns != nil { if idemKey != "" && s.taskRuns != nil {
if existing, findErr := s.taskRuns.FindByIdempotencyKey(r.Context(), idemKey); findErr != nil { if existing, findErr := s.taskRuns.FindByIdempotencyKey(r.Context(), idemKey); findErr != nil {
http.Error(w, findErr.Error(), http.StatusInternalServerError) writeSafeError(w, r, http.StatusInternalServerError, findErr.Error())
return return
} else if existing != nil { } else if existing != nil {
if !taskRunMatchesIdempotentOwner(existing, req) { if !taskRunMatchesIdempotentOwner(existing, req) {
@@ -122,7 +122,7 @@ func (s *Server) handleCreateTaskRun(w http.ResponseWriter, r *http.Request) {
return return
} }
if busy, preflightErr := s.preflightTaskDocumentBusy(r.Context(), req); preflightErr != nil { if busy, preflightErr := s.preflightTaskDocumentBusy(r.Context(), req); preflightErr != nil {
http.Error(w, preflightErr.Error(), http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, preflightErr.Error())
return return
} else if busy != nil { } else if busy != nil {
s.recordBusyPreflightMessages(r.Context(), ctxMgr, req, taskScope, busy) s.recordBusyPreflightMessages(r.Context(), ctxMgr, req, taskScope, busy)
@@ -153,7 +153,7 @@ func (s *Server) handleCreateTaskRun(w http.ResponseWriter, r *http.Request) {
} }
claimed, created, claimErr := s.taskRuns.ClaimRun(r.Context(), record) claimed, created, claimErr := s.taskRuns.ClaimRun(r.Context(), record)
if claimErr != nil { if claimErr != nil {
http.Error(w, claimErr.Error(), http.StatusInternalServerError) writeSafeError(w, r, http.StatusInternalServerError, claimErr.Error())
return return
} }
if !created { if !created {
@@ -205,7 +205,7 @@ func (s *Server) handleCreateTaskRun(w http.ResponseWriter, r *http.Request) {
stageReq := s.buildBizdevStageRequest(req.TaskRunRequestDTO, req.SessionID, req.ClientTurnID, claimed.RunID) 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 { if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) writeSafeError(w, r, http.StatusInternalServerError, err.Error())
return return
} }
@@ -238,7 +238,7 @@ func (s *Server) createBlockedGovernanceRun(w http.ResponseWriter, r *http.Reque
} }
claimed, created, claimErr := s.taskRuns.ClaimRun(r.Context(), record) claimed, created, claimErr := s.taskRuns.ClaimRun(r.Context(), record)
if claimErr != nil { if claimErr != nil {
http.Error(w, claimErr.Error(), http.StatusInternalServerError) writeSafeError(w, r, http.StatusInternalServerError, claimErr.Error())
return return
} }
if !created { if !created {
@@ -387,7 +387,7 @@ func (s *Server) handleTaskRunEvents(w http.ResponseWriter, r *http.Request) {
} }
runID := r.PathValue("run_id") runID := r.PathValue("run_id")
if runID == "" { if runID == "" {
http.Error(w, "run_id required", http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, "run_id required")
return return
} }
if !s.assertTaskRunOwnedByUser(w, r.Context(), runID, user) { if !s.assertTaskRunOwnedByUser(w, r.Context(), runID, user) {
@@ -395,7 +395,7 @@ func (s *Server) handleTaskRunEvents(w http.ResponseWriter, r *http.Request) {
} }
flusher, ok := w.(http.Flusher) flusher, ok := w.(http.Flusher)
if !ok { if !ok {
http.Error(w, "streaming unsupported", http.StatusInternalServerError) writeSafeError(w, r, http.StatusInternalServerError, "streaming unsupported")
return return
} }
@@ -414,7 +414,7 @@ func (s *Server) handleTaskRunEvents(w http.ResponseWriter, r *http.Request) {
ch, replay, err := s.taskRuns.Subscribe(runID, afterSeq) ch, replay, err := s.taskRuns.Subscribe(runID, afterSeq)
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusNotFound) writeSafeError(w, r, http.StatusNotFound, err.Error())
return return
} }
defer s.taskRuns.Unsubscribe(runID, ch) defer s.taskRuns.Unsubscribe(runID, ch)
@@ -475,14 +475,14 @@ func (s *Server) handleCancelTaskRun(w http.ResponseWriter, r *http.Request) {
} }
runID := r.PathValue("run_id") runID := r.PathValue("run_id")
if runID == "" { if runID == "" {
http.Error(w, "run_id required", http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, "run_id required")
return return
} }
if !s.assertTaskRunAccess(w, r.Context(), runID, user, taskRunCancel) { if !s.assertTaskRunAccess(w, r.Context(), runID, user, taskRunCancel) {
return return
} }
if err := s.taskRuns.Cancel(runID); err != nil { if err := s.taskRuns.Cancel(runID); err != nil {
http.Error(w, err.Error(), http.StatusNotFound) writeSafeError(w, r, http.StatusNotFound, err.Error())
return return
} }
if rec, getErr := s.taskRuns.GetRun(runID); getErr == nil && rec.WorkspaceID != "" { if rec, getErr := s.taskRuns.GetRun(runID); getErr == nil && rec.WorkspaceID != "" {
@@ -522,7 +522,7 @@ func (s *Server) handleGetTaskRun(w http.ResponseWriter, r *http.Request) {
} }
rec, err := s.taskRuns.GetRun(runID) rec, err := s.taskRuns.GetRun(runID)
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusNotFound) writeSafeError(w, r, http.StatusNotFound, err.Error())
return return
} }
writeJSONResponse(w, http.StatusOK, s.buildTaskRunResponse(rec)) writeJSONResponse(w, http.StatusOK, s.buildTaskRunResponse(rec))
@@ -535,7 +535,7 @@ func (s *Server) handleResumeTaskRun(w http.ResponseWriter, r *http.Request) {
} }
runID := r.PathValue("run_id") runID := r.PathValue("run_id")
if runID == "" { if runID == "" {
http.Error(w, "run_id required", http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, "run_id required")
return return
} }
if !s.assertTaskRunAccess(w, r.Context(), runID, user, taskRunResume) { if !s.assertTaskRunAccess(w, r.Context(), runID, user, taskRunResume) {
@@ -544,7 +544,7 @@ func (s *Server) handleResumeTaskRun(w http.ResponseWriter, r *http.Request) {
var req resumeTaskRunRequest var req resumeTaskRunRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body: "+err.Error(), http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, "invalid request body: "+err.Error())
return return
} }
if !req.Confirm { if !req.Confirm {
@@ -557,7 +557,7 @@ func (s *Server) handleResumeTaskRun(w http.ResponseWriter, r *http.Request) {
rec, err := s.taskRuns.GetRun(runID) rec, err := s.taskRuns.GetRun(runID)
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusNotFound) writeSafeError(w, r, http.StatusNotFound, err.Error())
return return
} }
if rec.Status != "interrupted" { if rec.Status != "interrupted" {
@@ -575,7 +575,7 @@ func (s *Server) handleResumeTaskRun(w http.ResponseWriter, r *http.Request) {
return return
} }
if s.resolver == nil { if s.resolver == nil {
http.Error(w, "workspace resolver unavailable", http.StatusServiceUnavailable) writeSafeError(w, r, http.StatusServiceUnavailable, "workspace resolver unavailable")
return return
} }
workDir, werr := s.resolver.ResolveStoredWorkDir(rec.WorkspaceID) workDir, werr := s.resolver.ResolveStoredWorkDir(rec.WorkspaceID)
@@ -608,7 +608,7 @@ func (s *Server) handleResumeTaskRun(w http.ResponseWriter, r *http.Request) {
}) })
return return
} }
http.Error(w, err.Error(), http.StatusInternalServerError) writeSafeError(w, r, http.StatusInternalServerError, err.Error())
return return
} }
+4 -5
View File
@@ -258,11 +258,11 @@ func TestTaskStream_MaterialsInjection(t *testing.T) {
b64Content := "55So5oi35LiK5Lyg5paH5qGj5qC45b+D6ZyA5rGC77ya6ZyA6KaB5pSv5oyBIDEwODBQIOi2heWjsOWunuaXtua4suafk+OAgg==" b64Content := "55So5oi35LiK5Lyg5paH5qGj5qC45b+D6ZyA5rGC77ya6ZyA6KaB5pSv5oyBIDEwODBQIOi2heWjsOWunuaXtua4suafk+OAgg=="
body, _ := json.Marshal(map[string]any{ body, _ := json.Marshal(map[string]any{
"task_key": "intake", "task_key": "intake",
"project_name": "超声材料注入测试", "project_name": "超声材料注入测试",
"customer_name": "北京水木东方", "customer_name": "北京水木东方",
"device_type": "超声诊断设备", "device_type": "超声诊断设备",
"backend": "stub", "backend": "stub",
"materials": []map[string]string{ "materials": []map[string]string{
{ {
"filename": "initial_requirements.txt", "filename": "initial_requirements.txt",
@@ -301,4 +301,3 @@ func TestTaskStream_MaterialsInjection(t *testing.T) {
t.Fatalf("material content mismatch: got %q, want %q", string(content), rawText) t.Fatalf("material content mismatch: got %q, want %q", string(content), rawText)
} }
} }
+2 -2
View File
@@ -15,12 +15,12 @@ type workflowCatalogItem struct {
func (s *Server) handleWorkflowCatalog(w http.ResponseWriter, r *http.Request) { func (s *Server) handleWorkflowCatalog(w http.ResponseWriter, r *http.Request) {
if s.wfDefSvc == nil || !s.wfDefSvc.Available() { if s.wfDefSvc == nil || !s.wfDefSvc.Available() {
http.Error(w, `{"error":"ERR_NOT_AVAILABLE","message":"workflow catalog unavailable"}`, http.StatusServiceUnavailable) writeSafeError(w, r, http.StatusServiceUnavailable, `{"error":"ERR_NOT_AVAILABLE","message":"workflow catalog unavailable"}`)
return return
} }
defs, err := s.wfDefSvc.Store().ListProjectSelectableDefinitions(r.Context()) defs, err := s.wfDefSvc.Store().ListProjectSelectableDefinitions(r.Context())
if err != nil { if err != nil {
http.Error(w, `{"error":"ERR_INTERNAL","message":"`+err.Error()+`"}`, http.StatusInternalServerError) writeSafeError(w, r, http.StatusInternalServerError, `{"error":"ERR_INTERNAL","message":"`+err.Error()+`"}`)
return return
} }
items := make([]workflowCatalogItem, 0, len(workflowdef.OfficialWorkflowKeys())) items := make([]workflowCatalogItem, 0, len(workflowdef.OfficialWorkflowKeys()))
@@ -193,8 +193,10 @@ func runPiAgentGovernanceE2E(t *testing.T, spec piAgentGovernanceCase) {
t.Fatalf("pi-agent task HTTP %d: %s", respTask.StatusCode, buf.String()) t.Fatalf("pi-agent task HTTP %d: %s", respTask.StatusCode, buf.String())
} }
var taskRes struct { var taskRes struct {
Success bool `json:"success"` Success bool `json:"success"`
DocRecord *struct{ DocID string `json:"doc_id"` } `json:"doc_record"` DocRecord *struct {
DocID string `json:"doc_id"`
} `json:"doc_record"`
ArtifactTitle string `json:"artifact_title"` ArtifactTitle string `json:"artifact_title"`
} }
_ = json.NewDecoder(respTask.Body).Decode(&taskRes) _ = json.NewDecoder(respTask.Body).Decode(&taskRes)
@@ -11,11 +11,11 @@ import (
func (s *Server) handleGetWorkflowInstance(w http.ResponseWriter, r *http.Request) { func (s *Server) handleGetWorkflowInstance(w http.ResponseWriter, r *http.Request) {
instanceID, err := strconv.ParseInt(r.PathValue("id"), 10, 64) instanceID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || instanceID <= 0 { if err != nil || instanceID <= 0 {
http.Error(w, `{"error":"ERR_INVALID_PARAM","message":"invalid instance id"}`, http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, `{"error":"ERR_INVALID_PARAM","message":"invalid instance id"}`)
return return
} }
if s.instSvc == nil || !s.instSvc.Available() { if s.instSvc == nil || !s.instSvc.Available() {
http.Error(w, `{"error":"ERR_NOT_AVAILABLE","message":"项目实例服务不可用"}`, http.StatusServiceUnavailable) writeSafeError(w, r, http.StatusServiceUnavailable, `{"error":"ERR_NOT_AVAILABLE","message":"项目实例服务不可用"}`)
return return
} }
detail, err := s.instSvc.GetInstanceDetail(r.Context(), instanceID) detail, err := s.instSvc.GetInstanceDetail(r.Context(), instanceID)
@@ -32,12 +32,12 @@ func (s *Server) handleGetWorkflowInstance(w http.ResponseWriter, r *http.Reques
func (s *Server) handleGetStageReadiness(w http.ResponseWriter, r *http.Request) { func (s *Server) handleGetStageReadiness(w http.ResponseWriter, r *http.Request) {
instanceID, err := strconv.ParseInt(r.PathValue("id"), 10, 64) instanceID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || instanceID <= 0 { if err != nil || instanceID <= 0 {
http.Error(w, `{"error":"ERR_INVALID_PARAM","message":"invalid instance id"}`, http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, `{"error":"ERR_INVALID_PARAM","message":"invalid instance id"}`)
return return
} }
stageKey := r.PathValue("stage") stageKey := r.PathValue("stage")
if s.instSvc == nil || !s.instSvc.Available() { if s.instSvc == nil || !s.instSvc.Available() {
http.Error(w, `{"error":"ERR_NOT_AVAILABLE","message":"项目实例服务不可用"}`, http.StatusServiceUnavailable) writeSafeError(w, r, http.StatusServiceUnavailable, `{"error":"ERR_NOT_AVAILABLE","message":"项目实例服务不可用"}`)
return return
} }
detail, err := s.instSvc.GetInstanceDetail(r.Context(), instanceID) detail, err := s.instSvc.GetInstanceDetail(r.Context(), instanceID)
@@ -61,12 +61,12 @@ func (s *Server) handleGetStageReadiness(w http.ResponseWriter, r *http.Request)
func (s *Server) handleAdvanceStage(w http.ResponseWriter, r *http.Request) { func (s *Server) handleAdvanceStage(w http.ResponseWriter, r *http.Request) {
instanceID, err := strconv.ParseInt(r.PathValue("id"), 10, 64) instanceID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || instanceID <= 0 { if err != nil || instanceID <= 0 {
http.Error(w, `{"error":"ERR_INVALID_PARAM","message":"invalid instance id"}`, http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, `{"error":"ERR_INVALID_PARAM","message":"invalid instance id"}`)
return return
} }
stageKey := r.PathValue("stage") stageKey := r.PathValue("stage")
if s.instSvc == nil || !s.instSvc.Available() { if s.instSvc == nil || !s.instSvc.Available() {
http.Error(w, `{"error":"ERR_NOT_AVAILABLE","message":"项目实例服务不可用"}`, http.StatusServiceUnavailable) writeSafeError(w, r, http.StatusServiceUnavailable, `{"error":"ERR_NOT_AVAILABLE","message":"项目实例服务不可用"}`)
return return
} }
detail, err := s.instSvc.GetInstanceDetail(r.Context(), instanceID) detail, err := s.instSvc.GetInstanceDetail(r.Context(), instanceID)
@@ -81,7 +81,7 @@ func (s *Server) handleAdvanceStage(w http.ResponseWriter, r *http.Request) {
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 var req projectinstance.AdvanceStageInput
if err := json.NewDecoder(r.Body).Decode(&req); err != nil && r.ContentLength > 0 { if err := json.NewDecoder(r.Body).Decode(&req); err != nil && r.ContentLength > 0 {
http.Error(w, `{"error":"ERR_INVALID_PARAM","message":"无效的 JSON 请求体"}`, http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, `{"error":"ERR_INVALID_PARAM","message":"无效的 JSON 请求体"}`)
return return
} }
if req.StageKey == "" { if req.StageKey == "" {
+12 -12
View File
@@ -11,22 +11,22 @@ import (
func (s *Server) handleTerminationPreview(w http.ResponseWriter, r *http.Request) { func (s *Server) handleTerminationPreview(w http.ResponseWriter, r *http.Request) {
if s.instSvc == nil || !s.instSvc.Available() { if s.instSvc == nil || !s.instSvc.Available() {
http.Error(w, `{"error":"ERR_NOT_AVAILABLE"}`, http.StatusServiceUnavailable) writeSafeError(w, r, http.StatusServiceUnavailable, `{"error":"ERR_NOT_AVAILABLE"}`)
return return
} }
instanceID, err := strconv.ParseInt(r.PathValue("id"), 10, 64) instanceID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || instanceID <= 0 { if err != nil || instanceID <= 0 {
http.Error(w, `{"error":"ERR_INVALID_PARAM"}`, http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, `{"error":"ERR_INVALID_PARAM"}`)
return return
} }
user, err := s.authenticateUser(r) user, err := s.authenticateUser(r)
if err != nil || user == nil { if err != nil || user == nil {
http.Error(w, `{"error":"ERR_UNAUTHENTICATED"}`, http.StatusUnauthorized) writeSafeError(w, r, http.StatusUnauthorized, `{"error":"ERR_UNAUTHENTICATED"}`)
return return
} }
detail, err := s.instSvc.GetInstanceDetail(r.Context(), instanceID) detail, err := s.instSvc.GetInstanceDetail(r.Context(), instanceID)
if err != nil { if err != nil {
http.Error(w, `{"error":"ERR_NOT_FOUND"}`, http.StatusNotFound) writeSafeError(w, r, http.StatusNotFound, `{"error":"ERR_NOT_FOUND"}`)
return return
} }
isCreator, _ := s.pwSvc.IsProjectCreator(r.Context(), detail.Instance.ProjectID, user.ID) isCreator, _ := s.pwSvc.IsProjectCreator(r.Context(), detail.Instance.ProjectID, user.ID)
@@ -40,27 +40,27 @@ func (s *Server) handleTerminationPreview(w http.ResponseWriter, r *http.Request
func (s *Server) handleTerminateWorkflowInstance(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() {
http.Error(w, `{"error":"ERR_NOT_AVAILABLE"}`, http.StatusServiceUnavailable) writeSafeError(w, r, http.StatusServiceUnavailable, `{"error":"ERR_NOT_AVAILABLE"}`)
return return
} }
instanceID, err := strconv.ParseInt(r.PathValue("id"), 10, 64) instanceID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || instanceID <= 0 { if err != nil || instanceID <= 0 {
http.Error(w, `{"error":"ERR_INVALID_PARAM"}`, http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, `{"error":"ERR_INVALID_PARAM"}`)
return return
} }
user, err := s.authenticateUser(r) user, err := s.authenticateUser(r)
if err != nil || user == nil { if err != nil || user == nil {
http.Error(w, `{"error":"ERR_UNAUTHENTICATED"}`, http.StatusUnauthorized) writeSafeError(w, r, http.StatusUnauthorized, `{"error":"ERR_UNAUTHENTICATED"}`)
return return
} }
detail, err := s.instSvc.GetInstanceDetail(r.Context(), instanceID) detail, err := s.instSvc.GetInstanceDetail(r.Context(), instanceID)
if err != nil { if err != nil {
http.Error(w, `{"error":"ERR_NOT_FOUND"}`, http.StatusNotFound) writeSafeError(w, r, http.StatusNotFound, `{"error":"ERR_NOT_FOUND"}`)
return return
} }
var in projectinstance.TerminateInput var in projectinstance.TerminateInput
if err := json.NewDecoder(r.Body).Decode(&in); err != nil { if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
http.Error(w, `{"error":"ERR_INVALID_PARAM"}`, http.StatusBadRequest) writeSafeError(w, r, http.StatusBadRequest, `{"error":"ERR_INVALID_PARAM"}`)
return return
} }
isCreator, _ := s.pwSvc.IsProjectCreator(r.Context(), detail.Instance.ProjectID, user.ID) isCreator, _ := s.pwSvc.IsProjectCreator(r.Context(), detail.Instance.ProjectID, user.ID)
@@ -68,11 +68,11 @@ func (s *Server) handleTerminateWorkflowInstance(w http.ResponseWriter, r *http.
if err != nil { if err != nil {
switch { switch {
case errors.Is(err, projectinstance.ErrForbiddenNotCreator): case errors.Is(err, projectinstance.ErrForbiddenNotCreator):
http.Error(w, `{"error":"ERR_FORBIDDEN"}`, http.StatusForbidden) writeSafeError(w, r, http.StatusForbidden, `{"error":"ERR_FORBIDDEN"}`)
case errors.Is(err, projectinstance.ErrTerminationNotAllowed): case errors.Is(err, projectinstance.ErrTerminationNotAllowed):
http.Error(w, `{"error":"ERR_TERMINATION_NOT_ALLOWED"}`, http.StatusConflict) writeSafeError(w, r, http.StatusConflict, `{"error":"ERR_TERMINATION_NOT_ALLOWED"}`)
default: default:
http.Error(w, `{"error":"ERR_INTERNAL","message":"`+err.Error()+`"}`, http.StatusInternalServerError) writeSafeError(w, r, http.StatusInternalServerError, `{"error":"ERR_INTERNAL","message":"`+err.Error()+`"}`)
} }
return return
} }
+16 -2
View File
@@ -15,14 +15,17 @@ func ClassifyError(err error, phaseID, requestID, sessionID, workspaceID string)
} }
msg := strings.ToLower(err.Error()) msg := strings.ToLower(err.Error())
upstream := truncateRunes(err.Error(), 80)
info := ErrorInfo{ info := ErrorInfo{
PhaseID: phaseID, PhaseID: phaseID,
RequestID: requestID, RequestID: requestID,
SessionID: sessionID, SessionID: sessionID,
WorkspaceID: workspaceID, WorkspaceID: workspaceID,
Upstream: upstream, }
raw := err.Error()
if !containsInternalLeak(raw) {
info.Upstream = truncateRunes(raw, 80)
} }
switch { switch {
@@ -57,9 +60,20 @@ func ClassifyError(err error, phaseID, requestID, sessionID, workspaceID string)
info.Actions = []string{"restart"} info.Actions = []string{"restart"}
} }
info.Message = info.UserMessage
return info return info
} }
func containsInternalLeak(s string) bool {
low := strings.ToLower(s)
for _, n := range []string{"sql", "pq:", "token", "password", "/home/", "prompt"} {
if strings.Contains(low, n) || strings.Contains(s, n) {
return true
}
}
return false
}
func isTimeoutError(msg string) bool { func isTimeoutError(msg string) bool {
return strings.Contains(msg, "timeout") || return strings.Contains(msg, "timeout") ||
strings.Contains(msg, "deadline") || strings.Contains(msg, "deadline") ||
+11
View File
@@ -3,6 +3,7 @@ package stream
import ( import (
"errors" "errors"
"net" "net"
"strings"
"testing" "testing"
) )
@@ -23,6 +24,16 @@ func TestClassifyError_TimeoutIsTransient(t *testing.T) {
} }
} }
func TestClassifyError_DoesNotLeakSQL(t *testing.T) {
info := ClassifyError(errors.New("pq: password authentication failed /home/weichen/x"), "dialogue_generating", "req_x", "sess_1", "")
if strings.Contains(info.Upstream, "pq:") || strings.Contains(info.Upstream, "/home/") || strings.Contains(info.UserMessage, "pq:") {
t.Fatalf("%+v", info)
}
if info.Message != info.UserMessage {
t.Fatal("message alias")
}
}
func TestClassifyError_GenericIsFatal(t *testing.T) { func TestClassifyError_GenericIsFatal(t *testing.T) {
info := ClassifyError(errors.New("invalid project config"), "intent_recognizing", "req_2", "sess_1", "") info := ClassifyError(errors.New("invalid project config"), "intent_recognizing", "req_2", "sess_1", "")
if info.Class != ErrorClassFatal { if info.Class != ErrorClassFatal {
+31 -30
View File
@@ -23,18 +23,18 @@ const (
// GovernancePayload 文档治理生命周期与认领变更载荷 // GovernancePayload 文档治理生命周期与认领变更载荷
type GovernancePayload struct { type GovernancePayload struct {
DocID string `json:"doc_id"` DocID string `json:"doc_id"`
Action string `json:"action"` // claimed, proposal_required, proposal_decided, submitted_review, rejected, approved Action string `json:"action"` // claimed, proposal_required, proposal_decided, submitted_review, rejected, approved
CurrentState string `json:"current_state"` // drafting, reviewing, approving, approved CurrentState string `json:"current_state"` // drafting, reviewing, approving, approved
IsLocked bool `json:"is_locked"` IsLocked bool `json:"is_locked"`
AuthorClaimed bool `json:"author_claimed"` AuthorClaimed bool `json:"author_claimed"`
ProposalID string `json:"proposal_id,omitempty"` ProposalID string `json:"proposal_id,omitempty"`
ProposedChanges string `json:"proposed_changes,omitempty"` ProposedChanges string `json:"proposed_changes,omitempty"`
ImpactSummary string `json:"impact_summary,omitempty"` ImpactSummary string `json:"impact_summary,omitempty"`
RiskSummary string `json:"risk_summary,omitempty"` RiskSummary string `json:"risk_summary,omitempty"`
AuthorDecision string `json:"author_decision,omitempty"` AuthorDecision string `json:"author_decision,omitempty"`
NotificationTitle string `json:"notification_title"` // 用户前台高亮简报标题 NotificationTitle string `json:"notification_title"` // 用户前台高亮简报标题
NotificationMsg string `json:"notification_msg"` // 用户前台高亮简报内容 NotificationMsg string `json:"notification_msg"` // 用户前台高亮简报内容
} }
// PhasePayload 阶段性任务状态更新载荷 // PhasePayload 阶段性任务状态更新载荷
@@ -66,12 +66,12 @@ type SummaryPayload struct {
// DetailPayload 详细执行与推演过程载荷 (按需显示,默认折叠) // DetailPayload 详细执行与推演过程载荷 (按需显示,默认折叠)
type DetailPayload struct { type DetailPayload struct {
Category string `json:"category"` // agent_thought, step_output, tool_call, log Category string `json:"category"` // agent_thought, step_output, tool_call, log
StepID string `json:"step_id,omitempty"` // 步骤标识 (如 tasks/01_regulatory) StepID string `json:"step_id,omitempty"` // 步骤标识 (如 tasks/01_regulatory)
Role string `json:"role,omitempty"` // 对应角色 Role string `json:"role,omitempty"` // 对应角色
Title string `json:"title"` // 详情标题 Title string `json:"title"` // 详情标题
Content string `json:"content"` // 完整推演正文或技术草稿 Content string `json:"content"` // 完整推演正文或技术草稿
Collapsible bool `json:"collapsible"` // 客户端是否应当默认折叠显示 Collapsible bool `json:"collapsible"` // 客户端是否应当默认折叠显示
} }
// DeliverablePayload A轨正式合规文档交付载荷 // DeliverablePayload A轨正式合规文档交付载荷
@@ -117,6 +117,7 @@ const (
type ErrorInfo struct { type ErrorInfo struct {
UserMessage string `json:"user_message"` UserMessage string `json:"user_message"`
Message string `json:"message,omitempty"`
Code string `json:"code"` Code string `json:"code"`
Class ErrorClass `json:"class"` Class ErrorClass `json:"class"`
PhaseID string `json:"phase_id,omitempty"` PhaseID string `json:"phase_id,omitempty"`
@@ -133,16 +134,16 @@ type StreamEvent struct {
Type EventType `json:"type"` Type EventType `json:"type"`
Timestamp time.Time `json:"timestamp"` Timestamp time.Time `json:"timestamp"`
Phase *PhasePayload `json:"phase,omitempty"` Phase *PhasePayload `json:"phase,omitempty"`
Status *StatusPayload `json:"status,omitempty"` Status *StatusPayload `json:"status,omitempty"`
Summary *SummaryPayload `json:"summary,omitempty"` Summary *SummaryPayload `json:"summary,omitempty"`
Briefing any `json:"briefing,omitempty"` Briefing any `json:"briefing,omitempty"`
Detail *DetailPayload `json:"detail,omitempty"` Detail *DetailPayload `json:"detail,omitempty"`
Delta string `json:"delta,omitempty"` Delta string `json:"delta,omitempty"`
Deliverable *DeliverablePayload `json:"deliverable,omitempty"` Deliverable *DeliverablePayload `json:"deliverable,omitempty"`
Governance *GovernancePayload `json:"governance,omitempty"` Governance *GovernancePayload `json:"governance,omitempty"`
Error string `json:"error,omitempty"` Error string `json:"error,omitempty"`
ErrorInfo *ErrorInfo `json:"error_info,omitempty"` ErrorInfo *ErrorInfo `json:"error_info,omitempty"`
Turn *TurnAckPayload `json:"turn,omitempty"` Turn *TurnAckPayload `json:"turn,omitempty"`
Done *DonePayload `json:"done,omitempty"` Done *DonePayload `json:"done,omitempty"`
} }
+1 -1
View File
@@ -2323,7 +2323,7 @@ export default function App() {
}; };
} }
if (httpStatus === 403) { if (httpStatus === 403) {
if (code === 'ERR_FORBIDDEN' || detail.includes('无权访问该项目')) { if (code === 'ERR_FORBIDDEN' || code === 'AUTHZ') {
return { return {
userMessage: detail || '无权访问该项目,请确认登录账号与项目成员身份', userMessage: detail || '无权访问该项目,请确认登录账号与项目成员身份',
code: code || 'ERR_FORBIDDEN', code: code || 'ERR_FORBIDDEN',
+7
View File
@@ -47,6 +47,13 @@ describe('projectSettingsError', () => {
assert.equal(body.issues[0]?.code, 'DUTY_PARTIAL_ASSIGNMENT'); assert.equal(body.issues[0]?.code, 'DUTY_PARTIAL_ASSIGNMENT');
}); });
it('prefers envelope code over legacy error field', () => {
const body = parseProjectSettingsErrorBody(
JSON.stringify({ code: 'AUTHZ', error: 'ERR_FORBIDDEN', message: '无权访问' }),
);
assert.equal(body.code, 'AUTHZ');
});
it('retains the local draft when a late tab hits PLAN_FROZEN', () => { it('retains the local draft when a late tab hits PLAN_FROZEN', () => {
const draft = { workflow_definition_key: 'main', workflow_version: 1, stages: [] }; const draft = { workflow_definition_key: 'main', workflow_version: 1, stages: [] };
assert.equal(retainDraftOnFrozenConflict(draft), draft); assert.equal(retainDraftOnFrozenConflict(draft), draft);
+2 -1
View File
@@ -37,6 +37,7 @@ export function parseProjectSettingsErrorBody(text: string): {
try { try {
const parsed = JSON.parse(text) as { const parsed = JSON.parse(text) as {
error?: string; error?: string;
code?: string;
message?: string; message?: string;
issues?: ProjectSettingsIssue[]; issues?: ProjectSettingsIssue[];
}; };
@@ -46,7 +47,7 @@ export function parseProjectSettingsErrorBody(text: string): {
.filter((msg): msg is string => Boolean(msg)) .filter((msg): msg is string => Boolean(msg))
.join(';'); .join(';');
return { return {
code: String(parsed.error || ''), code: String(parsed.code || parsed.error || ''),
message: issueText || parsed.message || parsed.error || text || '请求失败', message: issueText || parsed.message || parsed.error || text || '请求失败',
issues, issues,
}; };
+1 -1
View File
@@ -69,7 +69,7 @@ function bucketHasMessages(days: ChatDayBucket[]): boolean {
function isForbiddenResponse(err: unknown): boolean { function isForbiddenResponse(err: unknown): boolean {
if (!(err instanceof Error)) return false; if (!(err instanceof Error)) return false;
const msg = err.message.toLowerCase(); const msg = err.message.toLowerCase();
return msg.includes('403') || msg.includes('forbidden') || msg.includes('无权'); return msg.includes('403') || msg.includes('forbidden');
} }
export function useChatWindow({ export function useChatWindow({