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
}
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
writeSafeError(w, r, http.StatusMethodNotAllowed, "method not allowed")
return
}
catalog := authz.PermissionCatalog()
+9 -9
View File
@@ -18,14 +18,14 @@ func (s *Server) handleAdminRoles(w http.ResponseWriter, r *http.Request) {
case http.MethodGet:
roles, err := s.authzSvc.ListRoles(r.Context())
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
writeSafeError(w, r, http.StatusInternalServerError, err.Error())
return
}
writeJSONResponse(w, http.StatusOK, map[string]any{"roles": roles})
case http.MethodPost:
var req authz.CreateRoleInput
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "无效的 JSON 请求体", http.StatusBadRequest)
writeSafeError(w, r, http.StatusBadRequest, "无效的 JSON 请求体")
return
}
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) {
status = http.StatusServiceUnavailable
}
http.Error(w, err.Error(), status)
writeSafeError(w, r, status, err.Error())
return
}
writeJSONResponse(w, http.StatusCreated, created)
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"))
if roleID == "" {
http.Error(w, "无效的角色 ID", http.StatusBadRequest)
writeSafeError(w, r, http.StatusBadRequest, "无效的角色 ID")
return
}
@@ -58,7 +58,7 @@ func (s *Server) handleAdminRoleByID(w http.ResponseWriter, r *http.Request) {
case http.MethodPatch, http.MethodPut:
var req authz.UpdateRoleInput
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "无效的 JSON 请求体", http.StatusBadRequest)
writeSafeError(w, r, http.StatusBadRequest, "无效的 JSON 请求体")
return
}
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):
status = http.StatusServiceUnavailable
}
http.Error(w, err.Error(), status)
writeSafeError(w, r, status, err.Error())
return
}
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):
status = http.StatusServiceUnavailable
}
http.Error(w, err.Error(), status)
writeSafeError(w, r, status, err.Error())
return
}
writeJSONResponse(w, http.StatusOK, map[string]bool{"success": true})
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:
assets, err := s.wfDefSvc.ListTemplates(r.Context())
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
writeSafeError(w, r, http.StatusInternalServerError, err.Error())
return
}
writeJSONResponse(w, http.StatusOK, map[string]any{"templates": assets})
case http.MethodPost:
if err := r.ParseMultipartForm(defaultUploadBodyLimit); err != nil {
if isRequestBodyTooLarge(err) {
http.Error(w, "upload too large", http.StatusRequestEntityTooLarge)
writeSafeError(w, r, http.StatusRequestEntityTooLarge, "upload too large")
return
}
http.Error(w, "invalid multipart", http.StatusBadRequest)
writeSafeError(w, r, http.StatusBadRequest, "invalid multipart")
return
}
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")
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
}
defer file.Close()
data, err := io.ReadAll(io.LimitReader(file, 32<<20))
if err != nil {
http.Error(w, "read file failed", http.StatusBadRequest)
writeSafeError(w, r, http.StatusBadRequest, "read file failed")
return
}
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):
status = http.StatusServiceUnavailable
}
http.Error(w, err.Error(), status)
writeSafeError(w, r, status, err.Error())
return
}
writeJSONResponse(w, http.StatusCreated, tv)
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
}
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
writeSafeError(w, r, http.StatusMethodNotAllowed, "method not allowed")
return
}
assetKey := strings.TrimSpace(r.PathValue("key"))
if assetKey == "" {
http.Error(w, "invalid asset key", http.StatusBadRequest)
writeSafeError(w, r, http.StatusBadRequest, "invalid asset key")
return
}
versions, err := s.wfDefSvc.ListTemplateVersions(r.Context(), assetKey)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
writeSafeError(w, r, http.StatusInternalServerError, err.Error())
return
}
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
}
if s.wfDefSvc == nil || !s.wfDefSvc.Available() {
http.Error(w, "工作流定义服务需要数据库 IAM", http.StatusServiceUnavailable)
writeSafeError(w, r, http.StatusServiceUnavailable, "工作流定义服务需要数据库 IAM")
return nil, false
}
return user, true
@@ -31,14 +31,14 @@ func (s *Server) handleAdminWorkflows(w http.ResponseWriter, r *http.Request) {
case http.MethodGet:
defs, err := s.wfDefSvc.ListDefinitions(r.Context())
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
writeSafeError(w, r, http.StatusInternalServerError, err.Error())
return
}
writeJSONResponse(w, http.StatusOK, map[string]any{"workflows": defs})
case http.MethodPost:
var req workflowdef.CreateDefinitionInput
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "无效的 JSON", http.StatusBadRequest)
writeSafeError(w, r, http.StatusBadRequest, "无效的 JSON")
return
}
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) {
status = http.StatusUnprocessableEntity
}
http.Error(w, err.Error(), status)
writeSafeError(w, r, status, err.Error())
return
}
writeJSONResponse(w, http.StatusCreated, map[string]any{"definition": def, "version": ver})
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)
if err != nil || id <= 0 {
http.Error(w, "invalid workflow id", http.StatusBadRequest)
writeSafeError(w, r, http.StatusBadRequest, "invalid workflow id")
return
}
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
writeSafeError(w, r, http.StatusMethodNotAllowed, "method not allowed")
return
}
def, err := s.wfDefSvc.GetDefinition(r.Context(), id)
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
writeSafeError(w, r, http.StatusNotFound, err.Error())
return
}
versions, err := s.wfDefSvc.ListVersions(r.Context(), id)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
writeSafeError(w, r, http.StatusInternalServerError, err.Error())
return
}
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)
if err != nil || workflowID <= 0 {
http.Error(w, "invalid workflow id", http.StatusBadRequest)
writeSafeError(w, r, http.StatusBadRequest, "invalid workflow id")
return
}
version, err := strconv.Atoi(r.PathValue("version"))
if err != nil || version <= 0 {
http.Error(w, "invalid version", http.StatusBadRequest)
writeSafeError(w, r, http.StatusBadRequest, "invalid version")
return
}
@@ -104,7 +104,7 @@ func (s *Server) handleAdminWorkflowVersion(w http.ResponseWriter, r *http.Reque
Definition workflowdef.VersionDefinition `json:"definition"`
}
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
}
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) {
status = http.StatusConflict
}
http.Error(w, err.Error(), status)
writeSafeError(w, r, status, err.Error())
return
}
writeJSONResponse(w, http.StatusOK, updated)
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
writeSafeError(w, r, http.StatusMethodNotAllowed, "method not allowed")
}
_ = user
}
@@ -128,14 +128,14 @@ func (s *Server) handleAdminWorkflowValidate(w http.ResponseWriter, r *http.Requ
return
}
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
writeSafeError(w, r, http.StatusMethodNotAllowed, "method not allowed")
return
}
workflowID, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
version, _ := strconv.Atoi(r.PathValue("version"))
res, err := s.wfDefSvc.ValidateVersion(r.Context(), workflowID, version)
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
writeSafeError(w, r, http.StatusNotFound, err.Error())
return
}
writeJSONResponse(w, http.StatusOK, res)
@@ -147,7 +147,7 @@ func (s *Server) handleAdminWorkflowPublish(w http.ResponseWriter, r *http.Reque
return
}
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
writeSafeError(w, r, http.StatusMethodNotAllowed, "method not allowed")
return
}
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) {
status = http.StatusUnprocessableEntity
}
http.Error(w, err.Error(), status)
writeSafeError(w, r, status, err.Error())
return
}
if dbCat, ok := s.pwSvc.Catalog().(*workflowdef.DBCatalog); ok {
@@ -172,14 +172,14 @@ func (s *Server) handleAdminWorkflowRetire(w http.ResponseWriter, r *http.Reques
return
}
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
writeSafeError(w, r, http.StatusMethodNotAllowed, "method not allowed")
return
}
workflowID, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
version, _ := strconv.Atoi(r.PathValue("version"))
ver, err := s.wfDefSvc.RetireVersion(r.Context(), workflowID, version)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
writeSafeError(w, r, http.StatusBadRequest, err.Error())
return
}
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) {
projectID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || projectID <= 0 {
http.Error(w, "invalid project id", http.StatusBadRequest)
writeSafeError(w, r, http.StatusBadRequest, "invalid project id")
return
}
user, ok := s.requireProjectAccess(w, r, projectID)
@@ -32,26 +32,26 @@ func (s *Server) handleUploadProjectAsset(w http.ResponseWriter, r *http.Request
userID := user.ID
if err := r.ParseMultipartForm(defaultUploadBodyLimit); err != nil {
if isRequestBodyTooLarge(err) {
http.Error(w, "upload too large", http.StatusRequestEntityTooLarge)
writeSafeError(w, r, http.StatusRequestEntityTooLarge, "upload too large")
return
}
http.Error(w, "invalid multipart form: "+err.Error(), http.StatusBadRequest)
writeSafeError(w, r, http.StatusBadRequest, "invalid multipart form: "+err.Error())
return
}
file, header, err := r.FormFile("file")
if err != nil {
http.Error(w, "file required", http.StatusBadRequest)
writeSafeError(w, r, http.StatusBadRequest, "file required")
return
}
defer file.Close()
data, err := io.ReadAll(file)
if err != nil {
http.Error(w, "read file failed", http.StatusBadRequest)
writeSafeError(w, r, http.StatusBadRequest, "read file failed")
return
}
if len(data) == 0 {
http.Error(w, "cannot store empty file", http.StatusBadRequest)
writeSafeError(w, r, http.StatusBadRequest, "cannot store empty file")
return
}
@@ -62,18 +62,18 @@ func (s *Server) handleUploadProjectAsset(w http.ResponseWriter, r *http.Request
declaredMIME := header.Header.Get("Content-Type")
mimeType, verr := upload.ValidateProjectMaterial(filename, declaredMIME, data)
if verr != nil {
http.Error(w, "upload rejected: "+verr.Error(), http.StatusUnsupportedMediaType)
writeSafeError(w, r, http.StatusUnsupportedMediaType, "upload rejected: "+verr.Error())
return
}
cas := s.cas
if cas == nil {
http.Error(w, "storage unavailable", http.StatusServiceUnavailable)
writeSafeError(w, r, http.StatusServiceUnavailable, "storage unavailable")
return
}
ref, err := cas.StoreFile(r.Context(), projectID, userID, filepath.Base(filename), "material", data, mimeType)
if err != nil {
http.Error(w, "store asset failed: "+err.Error(), http.StatusInternalServerError)
writeSafeError(w, r, http.StatusInternalServerError, "store asset failed: "+err.Error())
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) {
projectID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || projectID <= 0 {
http.Error(w, "invalid project id", http.StatusBadRequest)
writeSafeError(w, r, http.StatusBadRequest, "invalid project id")
return
}
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)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
writeSafeError(w, r, http.StatusInternalServerError, err.Error())
return
}
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"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "无效的 JSON 请求体", http.StatusBadRequest)
writeSafeError(w, r, http.StatusBadRequest, "无效的 JSON 请求体")
return
}
@@ -51,14 +51,14 @@ func (s *Server) handleAuthLogin(w http.ResponseWriter, r *http.Request) {
if err != nil {
errMsg := err.Error()
if strings.Contains(errMsg, "审核") {
http.Error(w, errMsg, http.StatusForbidden)
writeSafeError(w, r, http.StatusForbidden, errMsg)
return
}
if strings.Contains(errMsg, "停用") {
http.Error(w, errMsg, http.StatusForbidden)
writeSafeError(w, r, http.StatusForbidden, errMsg)
return
}
http.Error(w, errMsg, http.StatusUnauthorized)
writeSafeError(w, r, http.StatusUnauthorized, errMsg)
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) {
var req auth.RegisterRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "无效的 JSON 请求体", http.StatusBadRequest)
writeSafeError(w, r, http.StatusBadRequest, "无效的 JSON 请求体")
return
}
resp, err := s.authSvc.Register(r.Context(), req)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
writeSafeError(w, r, http.StatusBadRequest, err.Error())
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) {
user, err := s.authenticateUser(r)
if err != nil {
http.Error(w, "未登录或登录已过期", http.StatusUnauthorized)
writeSafeError(w, r, http.StatusUnauthorized, "未登录或登录已过期")
return
}
@@ -107,12 +107,12 @@ func (s *Server) handleAuthChangePassword(w http.ResponseWriter, r *http.Request
NewPassword string `json:"new_password"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "无效的 JSON 请求体", http.StatusBadRequest)
writeSafeError(w, r, http.StatusBadRequest, "无效的 JSON 请求体")
return
}
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
}
@@ -124,7 +124,7 @@ func (s *Server) handleAuthChangePassword(w http.ResponseWriter, r *http.Request
func (s *Server) handleAuthMe(w http.ResponseWriter, r *http.Request) {
user, err := s.authenticateUser(r)
if err != nil {
http.Error(w, "未登录或登录已过期", http.StatusUnauthorized)
writeSafeError(w, r, http.StatusUnauthorized, "未登录或登录已过期")
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) {
user, err := s.authenticateUser(r)
if err != nil {
http.Error(w, "未登录或登录已过期", http.StatusUnauthorized)
writeSafeError(w, r, http.StatusUnauthorized, "未登录或登录已过期")
return
}
if r.Method == http.MethodGet {
token := extractBearerToken(r)
sessions, listErr := s.authSvc.ListUserSessions(r.Context(), user.ID, token)
if listErr != nil {
http.Error(w, listErr.Error(), http.StatusInternalServerError)
writeSafeError(w, r, http.StatusInternalServerError, listErr.Error())
return
}
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})
return
}
http.Error(w, "不支持的请求方法", http.StatusMethodNotAllowed)
writeSafeError(w, r, http.StatusMethodNotAllowed, "不支持的请求方法")
}
func (s *Server) handleAuthSessionByID(w http.ResponseWriter, r *http.Request) {
user, err := s.authenticateUser(r)
if err != nil {
http.Error(w, "未登录或登录已过期", http.StatusUnauthorized)
writeSafeError(w, r, http.StatusUnauthorized, "未登录或登录已过期")
return
}
if r.Method != http.MethodDelete {
http.Error(w, "不支持的请求方法", http.StatusMethodNotAllowed)
writeSafeError(w, r, http.StatusMethodNotAllowed, "不支持的请求方法")
return
}
sessionID := strings.TrimSpace(r.PathValue("id"))
if sessionID == "" {
http.Error(w, "无效的会话 ID", http.StatusBadRequest)
writeSafeError(w, r, http.StatusBadRequest, "无效的会话 ID")
return
}
if err := s.authSvc.RevokeUserSession(r.Context(), user.ID, sessionID); err != nil {
http.Error(w, "会话不存在或已失效", http.StatusNotFound)
writeSafeError(w, r, http.StatusNotFound, "会话不存在或已失效")
return
}
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 {
users, err := s.authSvc.ListUsers(r.Context())
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
writeSafeError(w, r, http.StatusInternalServerError, err.Error())
return
}
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 {
var req auth.CreateUserRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "无效的 JSON 请求体", http.StatusBadRequest)
writeSafeError(w, r, http.StatusBadRequest, "无效的 JSON 请求体")
return
}
created, err := s.authSvc.CreateUserByAdmin(r.Context(), req)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
writeSafeError(w, r, http.StatusBadRequest, err.Error())
return
}
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
}
http.Error(w, "不支持的请求方法", http.StatusMethodNotAllowed)
writeSafeError(w, r, http.StatusMethodNotAllowed, "不支持的请求方法")
}
// handleAdminUpdateRole 管理员为用户指定职位与角色
@@ -248,7 +248,7 @@ func (s *Server) handleAdminUpdateRole(w http.ResponseWriter, r *http.Request) {
targetID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || targetID <= 0 {
http.Error(w, "无效的用户ID", http.StatusBadRequest)
writeSafeError(w, r, http.StatusBadRequest, "无效的用户ID")
return
}
@@ -257,13 +257,13 @@ func (s *Server) handleAdminUpdateRole(w http.ResponseWriter, r *http.Request) {
Position string `json:"position"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "无效的请求体", http.StatusBadRequest)
writeSafeError(w, r, http.StatusBadRequest, "无效的请求体")
return
}
updated, err := s.authSvc.UpdateUserRole(r.Context(), targetID, req.RoleID, req.Position)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
writeSafeError(w, r, http.StatusBadRequest, err.Error())
return
}
@@ -279,7 +279,7 @@ func (s *Server) handleAdminUpdateStatus(w http.ResponseWriter, r *http.Request)
targetID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || targetID <= 0 {
http.Error(w, "无效的用户ID", http.StatusBadRequest)
writeSafeError(w, r, http.StatusBadRequest, "无效的用户ID")
return
}
@@ -289,20 +289,20 @@ func (s *Server) handleAdminUpdateStatus(w http.ResponseWriter, r *http.Request)
Position string `json:"position,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "无效的请求体", http.StatusBadRequest)
writeSafeError(w, r, http.StatusBadRequest, "无效的请求体")
return
}
if req.RoleID != "" {
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
}
}
updated, err := s.authSvc.UpdateUserStatus(r.Context(), targetID, req.Status)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
writeSafeError(w, r, http.StatusBadRequest, err.Error())
return
}
@@ -313,12 +313,12 @@ func (s *Server) handleAdminUpdateStatus(w http.ResponseWriter, r *http.Request)
// handleAuthPublicConfig 返回登录/注册页所需的公开准入配置(无需鉴权)
func (s *Server) handleAuthPublicConfig(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "不支持的方法", http.StatusMethodNotAllowed)
writeSafeError(w, r, http.StatusMethodNotAllowed, "不支持的方法")
return
}
cfg, err := s.authSvc.GetConfig(r.Context())
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
writeSafeError(w, r, http.StatusInternalServerError, err.Error())
return
}
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 {
cfg, err := s.authSvc.GetConfig(r.Context())
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
writeSafeError(w, r, http.StatusInternalServerError, err.Error())
return
}
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 {
var req auth.SystemAuthConfig
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "无效的请求体", http.StatusBadRequest)
writeSafeError(w, r, http.StatusBadRequest, "无效的请求体")
return
}
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
}
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
}
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": "请先登录",
})
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)
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 user, true
@@ -77,7 +77,7 @@ func (s *Server) requireAuthenticatedUser(w http.ResponseWriter, r *http.Request
// assertSessionOwnedByUser 校验会话归属当前用户;会话不存在或归属他人返回 false。
func (s *Server) assertSessionOwnedByUser(w http.ResponseWriter, r *http.Request, sessionID string, userID int64) bool {
if s.ctxMgr == nil {
http.Error(w, "context manager not initialized", http.StatusServiceUnavailable)
writeSafeError(w, r, http.StatusServiceUnavailable, "context manager not initialized")
return false
}
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=
func (s *Server) handleListDailyDigests(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
writeSafeError(w, r, http.StatusMethodNotAllowed, "Method Not Allowed")
return
}
if s.chatLog == nil {
http.Error(w, "context manager not initialized", http.StatusServiceUnavailable)
writeSafeError(w, r, http.StatusServiceUnavailable, "context manager not initialized")
return
}
@@ -29,7 +29,7 @@ func (s *Server) handleListDailyDigests(w http.ResponseWriter, r *http.Request)
sessionID := strings.TrimSpace(r.URL.Query().Get("session_id"))
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
}
@@ -60,12 +60,12 @@ type GenerateDigestRequest struct {
// POST /api/v1/chat/digests/generate
func (s *Server) handleGenerateDailyDigest(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
writeSafeError(w, r, http.StatusMethodNotAllowed, "Method Not Allowed")
return
}
if s.chatLog == nil {
http.Error(w, "context manager not initialized", http.StatusServiceUnavailable)
writeSafeError(w, r, http.StatusServiceUnavailable, "context manager not initialized")
return
}
@@ -76,7 +76,7 @@ func (s *Server) handleGenerateDailyDigest(w http.ResponseWriter, r *http.Reques
var req GenerateDigestRequest
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
}
@@ -84,7 +84,7 @@ func (s *Server) handleGenerateDailyDigest(w http.ResponseWriter, r *http.Reques
req.DayKey = strings.TrimSpace(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
}
+3 -3
View File
@@ -17,7 +17,7 @@ import (
func (s *Server) handleChatExportMessages(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
writeSafeError(w, r, http.StatusMethodNotAllowed, "Method Not Allowed")
return
}
@@ -28,12 +28,12 @@ func (s *Server) handleChatExportMessages(w http.ResponseWriter, r *http.Request
sessionID := strings.TrimSpace(r.URL.Query().Get("session_id"))
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
}
if s.chatLog == nil {
http.Error(w, "context manager not initialized", http.StatusServiceUnavailable)
writeSafeError(w, r, http.StatusServiceUnavailable, "context manager not initialized")
return
}
+1 -1
View File
@@ -26,7 +26,7 @@ func (s *Server) authorizeSessionRead(
user *auth.UserAccount,
) (*chatHistoryAuth, bool) {
if user == nil {
http.Error(w, `{"error":"ERR_UNAUTHENTICATED","message":"请先登录"}`, http.StatusUnauthorized)
writeSafeError(w, r, http.StatusUnauthorized, `{"error":"ERR_UNAUTHENTICATED","message":"请先登录"}`)
return nil, false
}
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) {
if s.ctxMgr == nil {
http.Error(w, "context manager unavailable", http.StatusServiceUnavailable)
writeSafeError(w, r, http.StatusServiceUnavailable, "context manager unavailable")
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=
func (s *Server) handleChatSearch(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
writeSafeError(w, r, http.StatusMethodNotAllowed, "Method Not Allowed")
return
}
@@ -24,19 +24,19 @@ func (s *Server) handleChatSearch(w http.ResponseWriter, r *http.Request) {
}
if s.chatLog == nil {
http.Error(w, "context manager not initialized", http.StatusServiceUnavailable)
writeSafeError(w, r, http.StatusServiceUnavailable, "context manager not initialized")
return
}
sessionID := strings.TrimSpace(r.URL.Query().Get("session_id"))
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
}
query := strings.TrimSpace(r.URL.Query().Get("q"))
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
}
+4 -4
View File
@@ -13,23 +13,23 @@ func (s *Server) handleGetChatTurn(w http.ResponseWriter, r *http.Request) {
clientTurnID := r.PathValue("client_turn_id")
sessionID := r.URL.Query().Get("session_id")
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
}
ctxMgr := s.ctxMgr
if ctxMgr == nil {
http.Error(w, "context manager unavailable", http.StatusServiceUnavailable)
writeSafeError(w, r, http.StatusServiceUnavailable, "context manager unavailable")
return
}
rec, err := ctxMgr.GetChatTurn(r.Context(), sessionID, clientTurnID)
if errors.Is(err, ctxmgr.ErrChatTurnNotFound) {
http.Error(w, "turn not found", http.StatusNotFound)
writeSafeError(w, r, http.StatusNotFound, "turn not found")
return
}
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
writeSafeError(w, r, http.StatusInternalServerError, err.Error())
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")
if sessionID == "" {
http.Error(w, "missing session_id", http.StatusBadRequest)
writeSafeError(w, r, http.StatusBadRequest, "missing session_id")
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) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
writeSafeError(w, r, http.StatusMethodNotAllowed, "method not allowed")
return
}
@@ -76,16 +76,16 @@ func (s *Server) handleChatRead(w http.ResponseWriter, r *http.Request) {
var req ctxmgr.MarkChatReadRequest
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
}
if req.SessionID == "" {
http.Error(w, "missing session_id", http.StatusBadRequest)
writeSafeError(w, r, http.StatusBadRequest, "missing session_id")
return
}
if s.chatLog == nil {
http.Error(w, "context manager not initialized", http.StatusInternalServerError)
writeSafeError(w, r, http.StatusInternalServerError, "context manager not initialized")
return
}
+12 -12
View File
@@ -14,7 +14,7 @@ import (
// GET /api/v1/chat/messages/window?session_id=&days=&before_day=
func (s *Server) handleGetChatWindow(w http.ResponseWriter, r *http.Request) {
if s.chatLog == nil {
http.Error(w, "context manager not initialized", http.StatusServiceUnavailable)
writeSafeError(w, r, http.StatusServiceUnavailable, "context manager not initialized")
return
}
@@ -25,7 +25,7 @@ func (s *Server) handleGetChatWindow(w http.ResponseWriter, r *http.Request) {
sessionID := r.URL.Query().Get("session_id")
if sessionID == "" {
http.Error(w, "missing session_id query param", http.StatusBadRequest)
writeSafeError(w, r, http.StatusBadRequest, "missing session_id query param")
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=
func (s *Server) handleGetChatDayMessages(w http.ResponseWriter, r *http.Request) {
if s.chatLog == nil {
http.Error(w, "context manager not initialized", http.StatusServiceUnavailable)
writeSafeError(w, r, http.StatusServiceUnavailable, "context manager not initialized")
return
}
@@ -63,7 +63,7 @@ func (s *Server) handleGetChatDayMessages(w http.ResponseWriter, r *http.Request
sessionID := r.URL.Query().Get("session_id")
if sessionID == "" {
http.Error(w, "missing session_id query param", http.StatusBadRequest)
writeSafeError(w, r, http.StatusBadRequest, "missing session_id query param")
return
}
@@ -76,7 +76,7 @@ func (s *Server) handleGetChatDayMessages(w http.ResponseWriter, r *http.Request
anchorID, _ := strconv.ParseInt(anchorIDStr, 10, 64)
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
}
@@ -107,7 +107,7 @@ func (s *Server) handleGetChatDayMessages(w http.ResponseWriter, r *http.Request
// GET /api/v1/chat/dates?session_id=
func (s *Server) handleListChatDates(w http.ResponseWriter, r *http.Request) {
if s.chatLog == nil {
http.Error(w, "context manager not initialized", http.StatusServiceUnavailable)
writeSafeError(w, r, http.StatusServiceUnavailable, "context manager not initialized")
return
}
@@ -118,7 +118,7 @@ func (s *Server) handleListChatDates(w http.ResponseWriter, r *http.Request) {
sessionID := r.URL.Query().Get("session_id")
if sessionID == "" {
http.Error(w, "missing session_id query param", http.StatusBadRequest)
writeSafeError(w, r, http.StatusBadRequest, "missing session_id query param")
return
}
@@ -135,7 +135,7 @@ func (s *Server) handleListChatDates(w http.ResponseWriter, r *http.Request) {
// GET /api/v1/chat/categories?session_id=
func (s *Server) handleListChatCategories(w http.ResponseWriter, r *http.Request) {
if s.chatLog == nil {
http.Error(w, "context manager not initialized", http.StatusServiceUnavailable)
writeSafeError(w, r, http.StatusServiceUnavailable, "context manager not initialized")
return
}
@@ -146,7 +146,7 @@ func (s *Server) handleListChatCategories(w http.ResponseWriter, r *http.Request
sessionID := r.URL.Query().Get("session_id")
if sessionID == "" {
http.Error(w, "missing session_id query param", http.StatusBadRequest)
writeSafeError(w, r, http.StatusBadRequest, "missing session_id query param")
return
}
@@ -163,14 +163,14 @@ func (s *Server) handleListChatCategories(w http.ResponseWriter, r *http.Request
// POST /api/v1/chat/messages/import
func (s *Server) handleImportChatMessages(w http.ResponseWriter, r *http.Request) {
if s.ctxMgr == nil {
http.Error(w, "context manager not initialized", http.StatusServiceUnavailable)
writeSafeError(w, r, http.StatusServiceUnavailable, "context manager not initialized")
return
}
// 安全门禁:生产环境默认禁止调用,仅在 dev 或显式开启时开放
isDev := s.cfg.Env == "dev" || s.cfg.Env == "development" || s.cfg.Env == "" || os.Getenv("ENABLE_DEV_IMPORT") == "true"
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
}
@@ -188,7 +188,7 @@ func (s *Server) handleImportChatMessages(w http.ResponseWriter, r *http.Request
var items []importMessageItem
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
}
+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) {
switch {
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):
writeJSONResponse(w, http.StatusForbidden, map[string]string{
"error_code": "SESSION_FORBIDDEN",
@@ -30,8 +30,8 @@ func (s *Server) writeChatLogError(w http.ResponseWriter, err error) {
"message": "指定会话不存在或已被删除",
})
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:
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)
func (s *Server) handleCreateFreeformDoc(w http.ResponseWriter, r *http.Request) {
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
}
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
func (s *Server) handleCreateFreeformDocFromMessage(w http.ResponseWriter, r *http.Request) {
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
}
@@ -169,7 +169,7 @@ func (s *Server) handleCreateFreeformDocFromMessage(w http.ResponseWriter, r *ht
// GET /api/v1/freeform-documents
func (s *Server) handleListFreeformDocs(w http.ResponseWriter, r *http.Request) {
if s.freeDoc == nil {
http.Error(w, "freeform doc service not initialized", http.StatusServiceUnavailable)
writeSafeError(w, r, http.StatusServiceUnavailable, "freeform doc service not initialized")
return
}
@@ -207,7 +207,7 @@ func (s *Server) handleListFreeformDocs(w http.ResponseWriter, r *http.Request)
// GET /api/v1/freeform-documents/{doc_id}
func (s *Server) handleGetFreeformDoc(w http.ResponseWriter, r *http.Request) {
if s.freeDoc == nil {
http.Error(w, "freeform doc service not initialized", http.StatusServiceUnavailable)
writeSafeError(w, r, http.StatusServiceUnavailable, "freeform doc service not initialized")
return
}
@@ -256,7 +256,7 @@ func (s *Server) handleGetFreeformDoc(w http.ResponseWriter, r *http.Request) {
// PUT /api/v1/freeform-documents/{doc_id}
func (s *Server) handleUpdateFreeformDoc(w http.ResponseWriter, r *http.Request) {
if s.freeDoc == nil {
http.Error(w, "freeform doc service not initialized", http.StatusServiceUnavailable)
writeSafeError(w, r, http.StatusServiceUnavailable, "freeform doc service not initialized")
return
}
@@ -332,7 +332,7 @@ func (s *Server) handleUpdateFreeformDoc(w http.ResponseWriter, r *http.Request)
// POST /api/v1/freeform-documents/{doc_id}/share
func (s *Server) handleShareFreeformDoc(w http.ResponseWriter, r *http.Request) {
if s.freeDoc == nil {
http.Error(w, "freeform doc service not initialized", http.StatusServiceUnavailable)
writeSafeError(w, r, http.StatusServiceUnavailable, "freeform doc service not initialized")
return
}
@@ -373,7 +373,7 @@ func (s *Server) handleShareFreeformDoc(w http.ResponseWriter, r *http.Request)
// POST /api/v1/freeform-documents/{doc_id}/unshare
func (s *Server) handleUnshareFreeformDoc(w http.ResponseWriter, r *http.Request) {
if s.freeDoc == nil {
http.Error(w, "freeform doc service not initialized", http.StatusServiceUnavailable)
writeSafeError(w, r, http.StatusServiceUnavailable, "freeform doc service not initialized")
return
}
@@ -414,7 +414,7 @@ func (s *Server) handleUnshareFreeformDoc(w http.ResponseWriter, r *http.Request
// DELETE /api/v1/freeform-documents/{doc_id}
func (s *Server) handleDeleteFreeformDoc(w http.ResponseWriter, r *http.Request) {
if s.freeDoc == nil {
http.Error(w, "freeform doc service not initialized", http.StatusServiceUnavailable)
writeSafeError(w, r, http.StatusServiceUnavailable, "freeform doc service not initialized")
return
}
+4 -4
View File
@@ -9,11 +9,11 @@ import (
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"chat-agent-system/internal/bizdev"
"chat-agent-system/internal/config"
"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) {
@@ -66,8 +66,8 @@ func TestFreeformDocAPI_EndToEnd(t *testing.T) {
require.Equal(t, http.StatusCreated, recValid.Code)
var createResp struct {
Code int `json:"code"`
IsExisting bool `json:"is_existing"`
Code int `json:"code"`
IsExisting bool `json:"is_existing"`
Data struct {
Document struct {
DocID string `json:"doc_id"`
+78 -73
View File
@@ -16,6 +16,7 @@ import (
"chat-agent-system/internal/bizdev"
"chat-agent-system/internal/ctxmgr"
"chat-agent-system/internal/docgov"
"chat-agent-system/internal/httpx"
"chat-agent-system/internal/projectinstance"
"chat-agent-system/internal/projectworkflow"
"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) {
sessionID := r.URL.Query().Get("session_id")
if sessionID == "" {
http.Error(w, "missing session_id query param", http.StatusBadRequest)
writeSafeError(w, r, http.StatusBadRequest, "missing session_id query param")
return
}
sess := s.getOrCreateSession(sessionID, "", "", "", "", "", "", "", "")
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
}
@@ -153,11 +154,11 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
var req ChatRequest
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
}
if req.Message == "" {
http.Error(w, "message cannot be empty", http.StatusBadRequest)
writeSafeError(w, r, http.StatusBadRequest, "message cannot be empty")
return
}
req.WorkDir = ""
@@ -192,7 +193,7 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
resp, err := sess.SendMessage(r.Context(), req.Message)
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
}
@@ -207,7 +208,7 @@ func (s *Server) handleRunTask(w http.ResponseWriter, r *http.Request) {
var req TaskRunRequestDTO
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
}
req.UserID = user.ID
@@ -362,7 +363,7 @@ func writeTaskError(w http.ResponseWriter, err error, payload any) {
writeJSONResponse(w, status, payload)
return
}
http.Error(w, err.Error(), status)
writeSafeError(w, nil, status, err.Error())
}
func writeDocGovError(w http.ResponseWriter, err error) {
@@ -375,22 +376,22 @@ func writeDocGovError(w http.ResponseWriter, err error) {
return
}
if errors.Is(err, docgov.ErrDocumentBusy) {
http.Error(w, err.Error(), http.StatusConflict)
writeSafeError(w, nil, http.StatusConflict, err.Error())
return
}
if errors.Is(err, docgov.ErrDocumentLocked) {
http.Error(w, err.Error(), http.StatusConflict)
writeSafeError(w, nil, http.StatusConflict, err.Error())
return
}
if errors.Is(err, docgov.ErrDocumentNotFound) {
http.Error(w, err.Error(), http.StatusNotFound)
writeSafeError(w, nil, http.StatusNotFound, err.Error())
return
}
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
}
http.Error(w, err.Error(), http.StatusBadRequest)
writeSafeError(w, nil, http.StatusBadRequest, err.Error())
}
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)
}
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 控制器
// -----------------------------------------------------------------------------
@@ -416,13 +421,13 @@ func (s *Server) handleGetUserContext(w http.ResponseWriter, r *http.Request) {
projectID, _ := strconv.ParseInt(projectIDStr, 10, 64)
if s.ctxMgr == nil {
http.Error(w, "context manager not initialized", http.StatusServiceUnavailable)
writeSafeError(w, r, http.StatusServiceUnavailable, "context manager not initialized")
return
}
uCtx, err := s.ctxMgr.GetUserContext(r.Context(), projectID, userID, sessionID)
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
}
@@ -442,16 +447,16 @@ type UpdateUserPreferenceRequest struct {
func (s *Server) handleUpdateUserPreference(w http.ResponseWriter, r *http.Request) {
var req UpdateUserPreferenceRequest
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
}
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
}
if s.ctxMgr == nil {
http.Error(w, "context manager not initialized", http.StatusServiceUnavailable)
writeSafeError(w, r, http.StatusServiceUnavailable, "context manager not initialized")
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 {
http.Error(w, "save user preference failed: "+err.Error(), http.StatusInternalServerError)
writeSafeError(w, r, http.StatusInternalServerError, "save user preference failed: "+err.Error())
return
}
@@ -481,7 +486,7 @@ func (s *Server) handleGetProjectContext(w http.ResponseWriter, r *http.Request)
projectIDStr := r.URL.Query().Get("project_id")
projectID, _ := strconv.ParseInt(projectIDStr, 10, 64)
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
}
@@ -491,13 +496,13 @@ func (s *Server) handleGetProjectContext(w http.ResponseWriter, r *http.Request)
}
if s.ctxMgr == nil {
http.Error(w, "context manager not initialized", http.StatusServiceUnavailable)
writeSafeError(w, r, http.StatusServiceUnavailable, "context manager not initialized")
return
}
pCtx, err := s.ctxMgr.GetProjectContext(r.Context(), projectID)
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
}
@@ -512,18 +517,18 @@ func (s *Server) handleGetProjectContext(w http.ResponseWriter, r *http.Request)
func (s *Server) handleDocGovGetDocument(w http.ResponseWriter, r *http.Request) {
docID := r.URL.Query().Get("doc_id")
if docID == "" {
http.Error(w, "missing doc_id query param", http.StatusBadRequest)
writeSafeError(w, r, http.StatusBadRequest, "missing doc_id query param")
return
}
if s.gov == nil {
http.Error(w, "docgov engine not initialized", http.StatusServiceUnavailable)
writeSafeError(w, r, http.StatusServiceUnavailable, "docgov engine not initialized")
return
}
doc, err := s.gov.GetDocument(r.Context(), docID)
if err != nil {
http.Error(w, "get document failed: "+err.Error(), http.StatusNotFound)
writeSafeError(w, r, http.StatusNotFound, "get document failed: "+err.Error())
return
}
@@ -547,22 +552,22 @@ func (s *Server) handleDocGovClaim(w http.ResponseWriter, r *http.Request) {
var req DocGovClaimRequest
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
}
if req.DocID == "" {
http.Error(w, "doc_id cannot be empty", http.StatusBadRequest)
writeSafeError(w, r, http.StatusBadRequest, "doc_id cannot be empty")
return
}
if s.gov == nil {
http.Error(w, "docgov engine not initialized", http.StatusServiceUnavailable)
writeSafeError(w, r, http.StatusServiceUnavailable, "docgov engine not initialized")
return
}
doc, err := s.gov.RecordDocumentViewed(r.Context(), req.DocID, user.ID, docgov.UserRole(req.UserRole), user.Name)
if err != nil {
http.Error(w, "claim document failed: "+err.Error(), http.StatusInternalServerError)
writeSafeError(w, r, http.StatusInternalServerError, "claim document failed: "+err.Error())
return
}
@@ -585,12 +590,12 @@ func (s *Server) handleDocGovSubmitReview(w http.ResponseWriter, r *http.Request
var req DocGovSubmitReviewRequest
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
}
if s.gov == nil {
http.Error(w, "docgov engine not initialized", http.StatusServiceUnavailable)
writeSafeError(w, r, http.StatusServiceUnavailable, "docgov engine not initialized")
return
}
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)
if err != nil {
http.Error(w, "submit review failed: "+err.Error(), http.StatusBadRequest)
writeSafeError(w, r, http.StatusBadRequest, "submit review failed: "+err.Error())
return
}
@@ -626,12 +631,12 @@ func (s *Server) handleDocGovReviewDecision(w http.ResponseWriter, r *http.Reque
var req DocGovDecisionRequest
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
}
if s.gov == nil {
http.Error(w, "docgov engine not initialized", http.StatusServiceUnavailable)
writeSafeError(w, r, http.StatusServiceUnavailable, "docgov engine not initialized")
return
}
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)
} 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
}
if err != nil {
http.Error(w, "review decision failed: "+err.Error(), http.StatusBadRequest)
writeSafeError(w, r, http.StatusBadRequest, "review decision failed: "+err.Error())
return
}
@@ -672,12 +677,12 @@ func (s *Server) handleDocGovApproverDecision(w http.ResponseWriter, r *http.Req
var req DocGovDecisionRequest
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
}
if s.gov == nil {
http.Error(w, "docgov engine not initialized", http.StatusServiceUnavailable)
writeSafeError(w, r, http.StatusServiceUnavailable, "docgov engine not initialized")
return
}
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))
cmds := s.docflowCommands()
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
}
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),
})
} 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
}
if err != nil {
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
}
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
}
http.Error(w, "approver decision failed: "+err.Error(), http.StatusBadRequest)
writeSafeError(w, r, http.StatusBadRequest, "approver decision failed: "+err.Error())
return
}
@@ -742,18 +747,18 @@ func (s *Server) handleDocGovDecideProposal(w http.ResponseWriter, r *http.Reque
var req DecideProposalRequest
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
}
if s.gov == nil {
http.Error(w, "docgov engine not initialized", http.StatusServiceUnavailable)
writeSafeError(w, r, http.StatusServiceUnavailable, "docgov engine not initialized")
return
}
proposal, err := s.gov.DecideModificationProposal(r.Context(), req.ProposalID, user.ID, req.Approve, req.Reason)
if err != nil {
http.Error(w, "decide proposal failed: "+err.Error(), http.StatusBadRequest)
writeSafeError(w, r, http.StatusBadRequest, "decide proposal failed: "+err.Error())
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) {
docID := r.URL.Query().Get("doc_id")
if docID == "" {
http.Error(w, "missing doc_id query param", http.StatusBadRequest)
writeSafeError(w, r, http.StatusBadRequest, "missing doc_id query param")
return
}
if s.gov == nil {
http.Error(w, "docgov engine not initialized", http.StatusServiceUnavailable)
writeSafeError(w, r, http.StatusServiceUnavailable, "docgov engine not initialized")
return
}
comments, err := s.gov.ListComments(r.Context(), docID)
if err != nil {
http.Error(w, "list comments failed: "+err.Error(), http.StatusInternalServerError)
writeSafeError(w, r, http.StatusInternalServerError, "list comments failed: "+err.Error())
return
}
@@ -797,12 +802,12 @@ type AddCommentRequestDTO struct {
func (s *Server) handleDocGovAddComment(w http.ResponseWriter, r *http.Request) {
var req AddCommentRequestDTO
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
}
if s.gov == nil {
http.Error(w, "docgov engine not initialized", http.StatusServiceUnavailable)
writeSafeError(w, r, http.StatusServiceUnavailable, "docgov engine not initialized")
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) {
docID := r.URL.Query().Get("doc_id")
if docID == "" {
http.Error(w, "missing doc_id query param", http.StatusBadRequest)
writeSafeError(w, r, http.StatusBadRequest, "missing doc_id query param")
return
}
if s.gov == nil {
http.Error(w, "docgov engine not initialized", http.StatusServiceUnavailable)
writeSafeError(w, r, http.StatusServiceUnavailable, "docgov engine not initialized")
return
}
logs, err := s.gov.ListAuditLogs(r.Context(), docID)
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
}
@@ -852,7 +857,7 @@ func (s *Server) handleDocGovListAuditLogs(w http.ResponseWriter, r *http.Reques
// handleListChatSessions 分页查询用户/项目关联的历史会话列表
func (s *Server) handleListChatSessions(w http.ResponseWriter, r *http.Request) {
if s.ctxMgr == nil {
http.Error(w, "context manager not initialized", http.StatusServiceUnavailable)
writeSafeError(w, r, http.StatusServiceUnavailable, "context manager not initialized")
return
}
@@ -879,7 +884,7 @@ func (s *Server) handleListChatSessions(w http.ResponseWriter, r *http.Request)
Offset: offset,
})
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
}
@@ -896,23 +901,23 @@ type upsertChatSessionRequest struct {
// handleUpsertChatSession 注册或更新会话元数据(立项/成员加入时同步)。
func (s *Server) handleUpsertChatSession(w http.ResponseWriter, r *http.Request) {
if s.ctxMgr == nil {
http.Error(w, "context manager not initialized", http.StatusServiceUnavailable)
writeSafeError(w, r, http.StatusServiceUnavailable, "context manager not initialized")
return
}
var req upsertChatSessionRequest
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
}
if req.SessionID == "" {
http.Error(w, "missing session_id", http.StatusBadRequest)
writeSafeError(w, r, http.StatusBadRequest, "missing session_id")
return
}
summary, err := s.ctxMgr.EnsureChatSession(r.Context(), req.SessionID, req.UserID, req.ProjectID, req.Title)
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
}
@@ -922,13 +927,13 @@ func (s *Server) handleUpsertChatSession(w http.ResponseWriter, r *http.Request)
// handleGetChatMessages 分页获取指定会话的历史多轮消息记录
func (s *Server) handleGetChatMessages(w http.ResponseWriter, r *http.Request) {
if s.ctxMgr == nil {
http.Error(w, "context manager not initialized", http.StatusServiceUnavailable)
writeSafeError(w, r, http.StatusServiceUnavailable, "context manager not initialized")
return
}
sessionID := r.URL.Query().Get("session_id")
if sessionID == "" {
http.Error(w, "missing session_id query param", http.StatusBadRequest)
writeSafeError(w, r, http.StatusBadRequest, "missing session_id query param")
return
}
@@ -949,7 +954,7 @@ func (s *Server) handleGetChatMessages(w http.ResponseWriter, r *http.Request) {
}
if s.chatLog == nil {
http.Error(w, "context manager not initialized", http.StatusServiceUnavailable)
writeSafeError(w, r, http.StatusServiceUnavailable, "context manager not initialized")
return
}
@@ -973,14 +978,14 @@ func (s *Server) handleRecommendCases(w http.ResponseWriter, r *http.Request) {
return
}
if s.ctxMgr == nil {
http.Error(w, "context manager not initialized", http.StatusServiceUnavailable)
writeSafeError(w, r, http.StatusServiceUnavailable, "context manager not initialized")
return
}
projectID, _ := strconv.ParseInt(r.URL.Query().Get("project_id"), 10, 64)
query := r.URL.Query().Get("query")
if query == "" {
http.Error(w, "missing query param", http.StatusBadRequest)
writeSafeError(w, r, http.StatusBadRequest, "missing query param")
return
}
@@ -1006,7 +1011,7 @@ func (s *Server) handleRecommendCases(w http.ResponseWriter, r *http.Request) {
MinScore: minScore,
})
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
}
@@ -1040,11 +1045,11 @@ func (s *Server) handleDocGovSaveContent(w http.ResponseWriter, r *http.Request)
}
var req SaveContentRequestDTO
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
}
if s.gov == nil {
http.Error(w, "docgov engine not initialized", http.StatusServiceUnavailable)
writeSafeError(w, r, http.StatusServiceUnavailable, "docgov engine not initialized")
return
}
role := docgov.RoleAuthor
@@ -1076,11 +1081,11 @@ func (s *Server) handleDocGovAcquireLease(w http.ResponseWriter, r *http.Request
}
var req LeaseRequestDTO
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
}
if s.gov == nil {
http.Error(w, "docgov engine not initialized", http.StatusServiceUnavailable)
writeSafeError(w, r, http.StatusServiceUnavailable, "docgov engine not initialized")
return
}
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
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
}
if s.gov == nil {
http.Error(w, "docgov engine not initialized", http.StatusServiceUnavailable)
writeSafeError(w, r, http.StatusServiceUnavailable, "docgov engine not initialized")
return
}
ttl := time.Duration(req.TTLSeconds) * time.Second
@@ -1130,11 +1135,11 @@ func (s *Server) handleDocGovReleaseLease(w http.ResponseWriter, r *http.Request
}
var req LeaseRequestDTO
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
}
if s.gov == nil {
http.Error(w, "docgov engine not initialized", http.StatusServiceUnavailable)
writeSafeError(w, r, http.StatusServiceUnavailable, "docgov engine not initialized")
return
}
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:
var patch userProfilePatch
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
}
updated := *user
@@ -46,12 +46,12 @@ func (s *Server) handleMeProfile(w http.ResponseWriter, r *http.Request) {
}
saved, err := s.authSvc.UpdateUserProfile(r.Context(), &updated)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
writeSafeError(w, r, http.StatusBadRequest, err.Error())
return
}
writeJSONResponse(w, http.StatusOK, saved)
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:
prefs, err := s.authSvc.GetUserPreferences(r.Context(), user.ID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
writeSafeError(w, r, http.StatusInternalServerError, err.Error())
return
}
writeJSONResponse(w, http.StatusOK, prefs)
case http.MethodPut:
var body map[string]any
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
}
prefs, err := s.authSvc.SaveUserPreferences(r.Context(), user.ID, body)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
writeSafeError(w, r, http.StatusInternalServerError, err.Error())
return
}
writeJSONResponse(w, http.StatusOK, prefs)
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
}
if s.notifySvc == nil || !s.notifySvc.Available() {
http.Error(w, "notification service unavailable", http.StatusServiceUnavailable)
writeSafeError(w, r, http.StatusServiceUnavailable, "notification service unavailable")
return
}
projectID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || projectID <= 0 {
http.Error(w, "invalid project id", http.StatusBadRequest)
writeSafeError(w, r, http.StatusBadRequest, "invalid project id")
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)
if err != nil {
http.Error(w, "list notifications failed: "+err.Error(), http.StatusInternalServerError)
writeSafeError(w, r, http.StatusInternalServerError, "list notifications failed: "+err.Error())
return
}
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三类创新医疗器械"
// 与 dev 种子账号对齐(internal/auth/store.go)
authorID := int64(2) // zhanggong / 张工
authorID := int64(2) // zhanggong / 张工
authorName := "张工"
reviewerID := int64(3) // lizhuguan / 李主管
reviewerName := "李主管"
+6 -6
View File
@@ -64,12 +64,12 @@ func (s *Server) repoRoot() string {
func (s *Server) handleListProjectWorkflowDocuments(w http.ResponseWriter, r *http.Request) {
projectID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || projectID <= 0 {
http.Error(w, "invalid project id", http.StatusBadRequest)
writeSafeError(w, r, http.StatusBadRequest, "invalid project id")
return
}
category := r.URL.Query().Get("category")
if category != "" && category != "workflow" {
http.Error(w, "unsupported category", http.StatusBadRequest)
writeSafeError(w, r, http.StatusBadRequest, "unsupported category")
return
}
@@ -79,13 +79,13 @@ func (s *Server) handleListProjectWorkflowDocuments(w http.ResponseWriter, r *ht
}
if s.gov == nil {
http.Error(w, "docgov engine not initialized", http.StatusServiceUnavailable)
writeSafeError(w, r, http.StatusServiceUnavailable, "docgov engine not initialized")
return
}
docs, err := s.gov.ListWorkflowDocumentsByProject(r.Context(), projectID)
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
}
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) {
docID := r.PathValue("doc_id")
if docID == "" {
http.Error(w, "doc_id required", http.StatusBadRequest)
writeSafeError(w, r, http.StatusBadRequest, "doc_id required")
return
}
@@ -106,7 +106,7 @@ func (s *Server) handleDocGovGetDocumentContent(w http.ResponseWriter, r *http.R
}
q := s.docflowQuery()
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
}
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) {
user, err := s.authenticateUser(r)
if err != nil || user == nil {
http.Error(w, `{"error":"ERR_UNAUTHENTICATED","message":"请先登录"}`, http.StatusUnauthorized)
writeSafeError(w, r, http.StatusUnauthorized, `{"error":"ERR_UNAUTHENTICATED","message":"请先登录"}`)
return
}
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
} else if !ok {
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
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
}
proj, err := s.pwSvc.CreateProject(r.Context(), user, req)
if err != nil {
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
}
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
}
http.Error(w, `{"error":"ERR_INTERNAL","message":"`+err.Error()+`"}`, http.StatusInternalServerError)
writeSafeError(w, r, http.StatusInternalServerError, `{"error":"ERR_INTERNAL","message":"`+err.Error()+`"}`)
return
}
@@ -86,7 +86,7 @@ func (s *Server) handleListProjects(w http.ResponseWriter, r *http.Request) {
list, err = s.pwSvc.ListProjects(r.Context(), user.ID)
}
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
}
if list == nil {
@@ -101,7 +101,7 @@ func (s *Server) handleGetProjectByID(w http.ResponseWriter, r *http.Request) {
idStr := r.PathValue("id")
id, err := strconv.ParseInt(idStr, 10, 64)
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
}
@@ -112,10 +112,10 @@ func (s *Server) handleGetProjectByID(w http.ResponseWriter, r *http.Request) {
proj, err := s.pwSvc.GetProject(r.Context(), id, 0)
if err != nil {
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
}
http.Error(w, `{"error":"ERR_INTERNAL","message":"`+err.Error()+`"}`, http.StatusInternalServerError)
writeSafeError(w, r, http.StatusInternalServerError, `{"error":"ERR_INTERNAL","message":"`+err.Error()+`"}`)
return
}
@@ -127,7 +127,7 @@ func (s *Server) handleListProjectWorkflows(w http.ResponseWriter, r *http.Reque
idStr := r.PathValue("id")
id, err := strconv.ParseInt(idStr, 10, 64)
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
}
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)
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
}
if wfs == nil {
@@ -151,18 +151,18 @@ func (s *Server) handleActivateWorkflow(w http.ResponseWriter, r *http.Request)
idStr := r.PathValue("id")
id, err := strconv.ParseInt(idStr, 10, 64)
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
}
user, err := s.authenticateUser(r)
if err != nil || user == nil {
http.Error(w, `{"error":"ERR_UNAUTHENTICATED","message":"请先登录"}`, http.StatusUnauthorized)
writeSafeError(w, r, http.StatusUnauthorized, `{"error":"ERR_UNAUTHENTICATED","message":"请先登录"}`)
return
}
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
}
@@ -172,29 +172,29 @@ func (s *Server) handleActivateWorkflow(w http.ResponseWriter, r *http.Request)
var req projectworkflow.ActivateWorkflowInput
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
}
res, err := s.pwSvc.ActivateWorkflow(r.Context(), id, user.ID, req)
if err != nil {
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
}
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
}
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
}
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
}
http.Error(w, `{"error":"ERR_INTERNAL","message":"`+err.Error()+`"}`, http.StatusInternalServerError)
writeSafeError(w, r, http.StatusInternalServerError, `{"error":"ERR_INTERNAL","message":"`+err.Error()+`"}`)
return
}
@@ -212,17 +212,17 @@ func (s *Server) handleRunProjectWorkflow(w http.ResponseWriter, r *http.Request
idStr := r.PathValue("id")
id, err := strconv.ParseInt(idStr, 10, 64)
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
}
wfKey := r.PathValue("workflow_key")
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
}
def, ok := s.pwSvc.Catalog().Get(wfKey)
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
}
@@ -236,7 +236,7 @@ func (s *Server) handleRunProjectWorkflow(w http.ResponseWriter, r *http.Request
_ = json.NewDecoder(r.Body).Decode(&req)
}
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
}
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")
id, err := strconv.ParseInt(idStr, 10, 64)
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
}
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")
runs, err := s.pwSvc.ListWorkflowRuns(r.Context(), id, wfKey, 20)
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
}
if runs == nil {
@@ -281,7 +281,7 @@ func (s *Server) handleGetProjectLineage(w http.ResponseWriter, r *http.Request)
idStr := r.PathValue("id")
projectID, _ := strconv.ParseInt(idStr, 10, 64)
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
}
if _, ok := s.requireProjectAccess(w, r, projectID); !ok {
@@ -297,12 +297,12 @@ func (s *Server) handleGetProjectLineage(w http.ResponseWriter, r *http.Request)
}
}
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
}
lineages, err := s.pwSvc.GetLineageForDownstream(r.Context(), docID, 0)
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
}
if len(lineages) == 0 {
+16 -16
View File
@@ -26,7 +26,7 @@ func resolveUpdateProjectRole(requested string, existing *authz.ProjectMemberRec
}
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 {
@@ -59,7 +59,7 @@ func (s *Server) handleProjectMembers(w http.ResponseWriter, r *http.Request) {
projectID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
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
}
@@ -73,7 +73,7 @@ func (s *Server) handleProjectMembers(w http.ResponseWriter, r *http.Request) {
case http.MethodDelete:
s.handleRemoveProjectMember(w, r, user, projectID)
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)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
writeSafeError(w, r, http.StatusInternalServerError, err.Error())
return
}
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 {
can, err := s.authzSvc.CanManageProjectMembers(r.Context(), projectID, user.ID, user.RoleID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
writeSafeError(w, r, http.StatusInternalServerError, err.Error())
return false
}
if !can {
http.Error(w, `{"error":"ERR_FORBIDDEN","message":"无权管理项目成员"}`, http.StatusForbidden)
writeSafeError(w, r, http.StatusForbidden, `{"error":"ERR_FORBIDDEN","message":"无权管理项目成员"}`)
return false
}
return true
@@ -111,7 +111,7 @@ func (s *Server) handleAddProjectMember(w http.ResponseWriter, r *http.Request,
}
var req projectMemberRequest
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
}
if req.ProjectRole == "" {
@@ -121,7 +121,7 @@ func (s *Server) handleAddProjectMember(w http.ResponseWriter, r *http.Request,
ProjectRole: req.ProjectRole,
DocumentDuty: req.DocumentDuty,
}); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
writeSafeError(w, r, http.StatusBadRequest, err.Error())
return
}
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
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
}
var existing *authz.ProjectMemberRecord
if s.authorizer != nil {
rec, err := s.authorizer.GetProjectMember(r.Context(), projectID, req.UserID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
writeSafeError(w, r, http.StatusInternalServerError, err.Error())
return
}
existing = rec
} 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
}
wrote := false
if req.ProjectRole != "" || req.DocumentDuty != nil {
role, err := resolveUpdateProjectRole(req.ProjectRole, existing)
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
}
if err := s.authorizer.UpsertProjectMemberSpec(r.Context(), projectID, req.UserID, authz.MemberUpsert{
ProjectRole: role,
DocumentDuty: req.DocumentDuty,
}); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
writeSafeError(w, r, http.StatusBadRequest, err.Error())
return
}
wrote = true
}
if req.Status != "" {
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
}
wrote = true
@@ -191,11 +191,11 @@ func (s *Server) handleRemoveProjectMember(w http.ResponseWriter, r *http.Reques
userIDStr := r.URL.Query().Get("user_id")
targetID, err := strconv.ParseInt(userIDStr, 10, 64)
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
}
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
}
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) {
projectID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
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
}
if _, ok := s.requireProjectAccess(w, r, projectID); !ok {
return
}
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
}
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) {
projectID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
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
}
user, ok := s.requireProjectAccess(w, r, projectID)
@@ -44,7 +44,7 @@ func (s *Server) handleGetProjectSettings(w http.ResponseWriter, r *http.Request
return
}
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
}
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) {
projectID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
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
}
user, ok := s.requireProjectAccess(w, r, projectID)
@@ -89,13 +89,13 @@ func (s *Server) handleSaveWorkflowPlan(w http.ResponseWriter, r *http.Request)
return
}
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
}
isCreator, _ := s.pwSvc.IsProjectCreator(r.Context(), projectID, user.ID)
var req projectinstance.SavePlanInput
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
}
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) {
projectID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
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
}
user, ok := s.requireProjectAccess(w, r, projectID)
@@ -117,7 +117,7 @@ func (s *Server) handleActivationPreview(w http.ResponseWriter, r *http.Request)
return
}
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
}
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) {
projectID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
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
}
user, ok := s.requireProjectAccess(w, r, projectID)
@@ -140,13 +140,13 @@ func (s *Server) handleActivateWorkflowInstance(w http.ResponseWriter, r *http.R
return
}
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
}
isCreator, _ := s.pwSvc.IsProjectCreator(r.Context(), projectID, user.ID)
var req projectinstance.ActivateInput
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
}
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) {
switch {
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):
http.Error(w, `{"error":"PLAN_FROZEN","message":"工作流已激活,规划已冻结"}`, http.StatusConflict)
writeSafeError(w, nil, http.StatusConflict, `{"error":"PLAN_FROZEN","message":"工作流已激活,规划已冻结"}`)
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):
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):
var validationErr *projectinstance.ValidationError
if errors.As(err, &validationErr) {
@@ -177,36 +177,36 @@ func writeProjectInstanceError(w http.ResponseWriter, err error) {
})
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):
http.Error(w, `{"error":"TOKEN_INVALID","message":"激活令牌无效或已过期"}`, http.StatusBadRequest)
writeSafeError(w, nil, http.StatusBadRequest, `{"error":"TOKEN_INVALID","message":"激活令牌无效或已过期"}`)
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):
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):
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):
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):
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):
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):
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):
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):
http.Error(w, `{"error":"INSTANCE_REVISION_CONFLICT","message":"实例版本冲突,请刷新后重试"}`, http.StatusConflict)
writeSafeError(w, nil, http.StatusConflict, `{"error":"INSTANCE_REVISION_CONFLICT","message":"实例版本冲突,请刷新后重试"}`)
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 {
proj, err := s.pwSvc.GetProject(r.Context(), projectID, 0)
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
}
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)
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
}
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 false
@@ -256,4 +256,3 @@ func TestProjectWorkflow_TestManagerAndLineageStale(t *testing.T) {
require.NotEmpty(t, lineagesAfter)
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"`
}
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
}
if body.RunID == "" {
http.Error(w, "run_id required", http.StatusBadRequest)
writeSafeError(w, r, http.StatusBadRequest, "run_id required")
return
}
rec, err := s.taskRuns.GetRun(body.RunID)
if err != nil {
http.Error(w, "run not found", http.StatusNotFound)
writeSafeError(w, r, http.StatusNotFound, "run not found")
return
}
if rec.WorkspaceID == "" {
http.Error(w, "workspace not available for run", http.StatusBadRequest)
writeSafeError(w, r, http.StatusBadRequest, "workspace not available for run")
return
}
workDir, err := s.resolver.ResolveStoredWorkDir(rec.WorkspaceID)
if err != nil {
http.Error(w, "invalid workspace path", http.StatusForbidden)
writeSafeError(w, r, http.StatusForbidden, "invalid workspace path")
return
}
report, err := squad.ReconcileWorkspaceStatus(workDir)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
writeSafeError(w, r, http.StatusBadRequest, err.Error())
return
}
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 {
http.Error(w, "context manager not initialized", http.StatusServiceUnavailable)
writeSafeError(w, r, http.StatusServiceUnavailable, "context manager not initialized")
return
}
@@ -85,7 +85,7 @@ func (s *Server) handleGetExportSource(w http.ResponseWriter, r *http.Request) {
}
if s.ctxMgr == nil {
http.Error(w, "context manager not initialized", http.StatusServiceUnavailable)
writeSafeError(w, r, http.StatusServiceUnavailable, "context manager not initialized")
return
}
+5 -5
View File
@@ -172,11 +172,11 @@ func TestPenetration_ProjectAssetRejectsScriptContent(t *testing.T) {
func TestPenetration_ReadyStrictWhenDBConfiguredButDown(t *testing.T) {
cfg := &config.Config{
DataDir: t.TempDir(),
AgentBackend: "stub",
ContextDbHost: "127.0.0.1",
ContextDbPort: 59999,
AllowInMemory: false,
DataDir: t.TempDir(),
AgentBackend: "stub",
ContextDbHost: "127.0.0.1",
ContextDbPort: 59999,
AllowInMemory: false,
}
srv := NewServer(cfg, bizdev.MustNewService(t, &config.Config{DataDir: t.TempDir(), AgentBackend: "stub"}))
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) {
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "streaming unsupported by underlying connection", http.StatusInternalServerError)
writeSafeError(w, r, http.StatusInternalServerError, "streaming unsupported by underlying connection")
return
}
var req StreamChatRequest
if r.Method == http.MethodPost {
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
}
} else {
@@ -161,7 +161,7 @@ func (s *Server) handleChatStream(w http.ResponseWriter, r *http.Request) {
}
if req.Message == "" {
http.Error(w, "message cannot be empty", http.StatusBadRequest)
writeSafeError(w, r, http.StatusBadRequest, "message cannot be empty")
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) {
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "streaming unsupported", http.StatusInternalServerError)
writeSafeError(w, r, http.StatusInternalServerError, "streaming unsupported")
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)
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
}
_ = includeDetails
@@ -389,7 +389,7 @@ func (s *Server) handleRunTaskStream(w http.ResponseWriter, r *http.Request) {
ch, replay, err := s.taskRuns.Subscribe(created.RunID, 0)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
writeSafeError(w, r, http.StatusInternalServerError, err.Error())
return
}
defer s.taskRuns.Unsubscribe(created.RunID, ch)
+4 -4
View File
@@ -10,9 +10,9 @@ import (
type taskRunAction string
const (
taskRunView taskRunAction = "view"
taskRunCancel taskRunAction = "cancel"
taskRunResume taskRunAction = "resume"
taskRunView taskRunAction = "view"
taskRunCancel taskRunAction = "cancel"
taskRunResume taskRunAction = "resume"
)
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)
if err != nil {
http.Error(w, "run not found", http.StatusNotFound)
writeSafeError(w, nil, http.StatusNotFound, "run not found")
return false
}
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
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
}
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 {
http.Error(w, err.Error(), http.StatusBadRequest)
writeSafeError(w, r, http.StatusBadRequest, err.Error())
return
}
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)
if idemKey != "" && s.taskRuns != 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
} else if existing != nil {
if !taskRunMatchesIdempotentOwner(existing, req) {
@@ -122,7 +122,7 @@ func (s *Server) handleCreateTaskRun(w http.ResponseWriter, r *http.Request) {
return
}
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
} else if busy != nil {
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)
if claimErr != nil {
http.Error(w, claimErr.Error(), http.StatusInternalServerError)
writeSafeError(w, r, http.StatusInternalServerError, claimErr.Error())
return
}
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)
createdRun, err := s.taskRuns.StartClaimedRun(r.Context(), claimed, taskReq, stageReq, resumeDir)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
writeSafeError(w, r, http.StatusInternalServerError, err.Error())
return
}
@@ -238,7 +238,7 @@ func (s *Server) createBlockedGovernanceRun(w http.ResponseWriter, r *http.Reque
}
claimed, created, claimErr := s.taskRuns.ClaimRun(r.Context(), record)
if claimErr != nil {
http.Error(w, claimErr.Error(), http.StatusInternalServerError)
writeSafeError(w, r, http.StatusInternalServerError, claimErr.Error())
return
}
if !created {
@@ -387,7 +387,7 @@ func (s *Server) handleTaskRunEvents(w http.ResponseWriter, r *http.Request) {
}
runID := r.PathValue("run_id")
if runID == "" {
http.Error(w, "run_id required", http.StatusBadRequest)
writeSafeError(w, r, http.StatusBadRequest, "run_id required")
return
}
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)
if !ok {
http.Error(w, "streaming unsupported", http.StatusInternalServerError)
writeSafeError(w, r, http.StatusInternalServerError, "streaming unsupported")
return
}
@@ -414,7 +414,7 @@ func (s *Server) handleTaskRunEvents(w http.ResponseWriter, r *http.Request) {
ch, replay, err := s.taskRuns.Subscribe(runID, afterSeq)
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
writeSafeError(w, r, http.StatusNotFound, err.Error())
return
}
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")
if runID == "" {
http.Error(w, "run_id required", http.StatusBadRequest)
writeSafeError(w, r, http.StatusBadRequest, "run_id required")
return
}
if !s.assertTaskRunAccess(w, r.Context(), runID, user, taskRunCancel) {
return
}
if err := s.taskRuns.Cancel(runID); err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
writeSafeError(w, r, http.StatusNotFound, err.Error())
return
}
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)
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
writeSafeError(w, r, http.StatusNotFound, err.Error())
return
}
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")
if runID == "" {
http.Error(w, "run_id required", http.StatusBadRequest)
writeSafeError(w, r, http.StatusBadRequest, "run_id required")
return
}
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
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
}
if !req.Confirm {
@@ -557,7 +557,7 @@ func (s *Server) handleResumeTaskRun(w http.ResponseWriter, r *http.Request) {
rec, err := s.taskRuns.GetRun(runID)
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
writeSafeError(w, r, http.StatusNotFound, err.Error())
return
}
if rec.Status != "interrupted" {
@@ -575,7 +575,7 @@ func (s *Server) handleResumeTaskRun(w http.ResponseWriter, r *http.Request) {
return
}
if s.resolver == nil {
http.Error(w, "workspace resolver unavailable", http.StatusServiceUnavailable)
writeSafeError(w, r, http.StatusServiceUnavailable, "workspace resolver unavailable")
return
}
workDir, werr := s.resolver.ResolveStoredWorkDir(rec.WorkspaceID)
@@ -608,7 +608,7 @@ func (s *Server) handleResumeTaskRun(w http.ResponseWriter, r *http.Request) {
})
return
}
http.Error(w, err.Error(), http.StatusInternalServerError)
writeSafeError(w, r, http.StatusInternalServerError, err.Error())
return
}
+4 -5
View File
@@ -258,11 +258,11 @@ func TestTaskStream_MaterialsInjection(t *testing.T) {
b64Content := "55So5oi35LiK5Lyg5paH5qGj5qC45b+D6ZyA5rGC77ya6ZyA6KaB5pSv5oyBIDEwODBQIOi2heWjsOWunuaXtua4suafk+OAgg=="
body, _ := json.Marshal(map[string]any{
"task_key": "intake",
"project_name": "超声材料注入测试",
"task_key": "intake",
"project_name": "超声材料注入测试",
"customer_name": "北京水木东方",
"device_type": "超声诊断设备",
"backend": "stub",
"device_type": "超声诊断设备",
"backend": "stub",
"materials": []map[string]string{
{
"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)
}
}
+2 -2
View File
@@ -15,12 +15,12 @@ type workflowCatalogItem struct {
func (s *Server) handleWorkflowCatalog(w http.ResponseWriter, r *http.Request) {
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
}
defs, err := s.wfDefSvc.Store().ListProjectSelectableDefinitions(r.Context())
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
}
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())
}
var taskRes struct {
Success bool `json:"success"`
DocRecord *struct{ DocID string `json:"doc_id"` } `json:"doc_record"`
Success bool `json:"success"`
DocRecord *struct {
DocID string `json:"doc_id"`
} `json:"doc_record"`
ArtifactTitle string `json:"artifact_title"`
}
_ = json.NewDecoder(respTask.Body).Decode(&taskRes)
@@ -11,11 +11,11 @@ import (
func (s *Server) handleGetWorkflowInstance(w http.ResponseWriter, r *http.Request) {
instanceID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
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
}
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
}
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) {
instanceID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
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
}
stageKey := r.PathValue("stage")
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
}
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) {
instanceID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
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
}
stageKey := r.PathValue("stage")
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
}
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)
var req projectinstance.AdvanceStageInput
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
}
if req.StageKey == "" {
+12 -12
View File
@@ -11,22 +11,22 @@ import (
func (s *Server) handleTerminationPreview(w http.ResponseWriter, r *http.Request) {
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
}
instanceID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || instanceID <= 0 {
http.Error(w, `{"error":"ERR_INVALID_PARAM"}`, http.StatusBadRequest)
writeSafeError(w, r, http.StatusBadRequest, `{"error":"ERR_INVALID_PARAM"}`)
return
}
user, err := s.authenticateUser(r)
if err != nil || user == nil {
http.Error(w, `{"error":"ERR_UNAUTHENTICATED"}`, http.StatusUnauthorized)
writeSafeError(w, r, http.StatusUnauthorized, `{"error":"ERR_UNAUTHENTICATED"}`)
return
}
detail, err := s.instSvc.GetInstanceDetail(r.Context(), instanceID)
if err != nil {
http.Error(w, `{"error":"ERR_NOT_FOUND"}`, http.StatusNotFound)
writeSafeError(w, r, http.StatusNotFound, `{"error":"ERR_NOT_FOUND"}`)
return
}
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) {
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
}
instanceID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || instanceID <= 0 {
http.Error(w, `{"error":"ERR_INVALID_PARAM"}`, http.StatusBadRequest)
writeSafeError(w, r, http.StatusBadRequest, `{"error":"ERR_INVALID_PARAM"}`)
return
}
user, err := s.authenticateUser(r)
if err != nil || user == nil {
http.Error(w, `{"error":"ERR_UNAUTHENTICATED"}`, http.StatusUnauthorized)
writeSafeError(w, r, http.StatusUnauthorized, `{"error":"ERR_UNAUTHENTICATED"}`)
return
}
detail, err := s.instSvc.GetInstanceDetail(r.Context(), instanceID)
if err != nil {
http.Error(w, `{"error":"ERR_NOT_FOUND"}`, http.StatusNotFound)
writeSafeError(w, r, http.StatusNotFound, `{"error":"ERR_NOT_FOUND"}`)
return
}
var in projectinstance.TerminateInput
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
}
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 {
switch {
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):
http.Error(w, `{"error":"ERR_TERMINATION_NOT_ALLOWED"}`, http.StatusConflict)
writeSafeError(w, r, http.StatusConflict, `{"error":"ERR_TERMINATION_NOT_ALLOWED"}`)
default:
http.Error(w, `{"error":"ERR_INTERNAL","message":"`+err.Error()+`"}`, http.StatusInternalServerError)
writeSafeError(w, r, http.StatusInternalServerError, `{"error":"ERR_INTERNAL","message":"`+err.Error()+`"}`)
}
return
}
+16 -2
View File
@@ -15,14 +15,17 @@ func ClassifyError(err error, phaseID, requestID, sessionID, workspaceID string)
}
msg := strings.ToLower(err.Error())
upstream := truncateRunes(err.Error(), 80)
info := ErrorInfo{
PhaseID: phaseID,
RequestID: requestID,
SessionID: sessionID,
WorkspaceID: workspaceID,
Upstream: upstream,
}
raw := err.Error()
if !containsInternalLeak(raw) {
info.Upstream = truncateRunes(raw, 80)
}
switch {
@@ -57,9 +60,20 @@ func ClassifyError(err error, phaseID, requestID, sessionID, workspaceID string)
info.Actions = []string{"restart"}
}
info.Message = info.UserMessage
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 {
return strings.Contains(msg, "timeout") ||
strings.Contains(msg, "deadline") ||
+11
View File
@@ -3,6 +3,7 @@ package stream
import (
"errors"
"net"
"strings"
"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) {
info := ClassifyError(errors.New("invalid project config"), "intent_recognizing", "req_2", "sess_1", "")
if info.Class != ErrorClassFatal {
+31 -30
View File
@@ -23,18 +23,18 @@ const (
// GovernancePayload 文档治理生命周期与认领变更载荷
type GovernancePayload struct {
DocID string `json:"doc_id"`
Action string `json:"action"` // claimed, proposal_required, proposal_decided, submitted_review, rejected, approved
CurrentState string `json:"current_state"` // drafting, reviewing, approving, approved
IsLocked bool `json:"is_locked"`
AuthorClaimed bool `json:"author_claimed"`
ProposalID string `json:"proposal_id,omitempty"`
ProposedChanges string `json:"proposed_changes,omitempty"`
ImpactSummary string `json:"impact_summary,omitempty"`
RiskSummary string `json:"risk_summary,omitempty"`
AuthorDecision string `json:"author_decision,omitempty"`
NotificationTitle string `json:"notification_title"` // 用户前台高亮简报标题
NotificationMsg string `json:"notification_msg"` // 用户前台高亮简报内容
DocID string `json:"doc_id"`
Action string `json:"action"` // claimed, proposal_required, proposal_decided, submitted_review, rejected, approved
CurrentState string `json:"current_state"` // drafting, reviewing, approving, approved
IsLocked bool `json:"is_locked"`
AuthorClaimed bool `json:"author_claimed"`
ProposalID string `json:"proposal_id,omitempty"`
ProposedChanges string `json:"proposed_changes,omitempty"`
ImpactSummary string `json:"impact_summary,omitempty"`
RiskSummary string `json:"risk_summary,omitempty"`
AuthorDecision string `json:"author_decision,omitempty"`
NotificationTitle string `json:"notification_title"` // 用户前台高亮简报标题
NotificationMsg string `json:"notification_msg"` // 用户前台高亮简报内容
}
// PhasePayload 阶段性任务状态更新载荷
@@ -66,12 +66,12 @@ type SummaryPayload struct {
// DetailPayload 详细执行与推演过程载荷 (按需显示,默认折叠)
type DetailPayload struct {
Category string `json:"category"` // agent_thought, step_output, tool_call, log
StepID string `json:"step_id,omitempty"` // 步骤标识 (如 tasks/01_regulatory)
Role string `json:"role,omitempty"` // 对应角色
Title string `json:"title"` // 详情标题
Content string `json:"content"` // 完整推演正文或技术草稿
Collapsible bool `json:"collapsible"` // 客户端是否应当默认折叠显示
Category string `json:"category"` // agent_thought, step_output, tool_call, log
StepID string `json:"step_id,omitempty"` // 步骤标识 (如 tasks/01_regulatory)
Role string `json:"role,omitempty"` // 对应角色
Title string `json:"title"` // 详情标题
Content string `json:"content"` // 完整推演正文或技术草稿
Collapsible bool `json:"collapsible"` // 客户端是否应当默认折叠显示
}
// DeliverablePayload A轨正式合规文档交付载荷
@@ -117,6 +117,7 @@ const (
type ErrorInfo struct {
UserMessage string `json:"user_message"`
Message string `json:"message,omitempty"`
Code string `json:"code"`
Class ErrorClass `json:"class"`
PhaseID string `json:"phase_id,omitempty"`
@@ -133,16 +134,16 @@ type StreamEvent struct {
Type EventType `json:"type"`
Timestamp time.Time `json:"timestamp"`
Phase *PhasePayload `json:"phase,omitempty"`
Status *StatusPayload `json:"status,omitempty"`
Summary *SummaryPayload `json:"summary,omitempty"`
Briefing any `json:"briefing,omitempty"`
Detail *DetailPayload `json:"detail,omitempty"`
Delta string `json:"delta,omitempty"`
Deliverable *DeliverablePayload `json:"deliverable,omitempty"`
Governance *GovernancePayload `json:"governance,omitempty"`
Error string `json:"error,omitempty"`
ErrorInfo *ErrorInfo `json:"error_info,omitempty"`
Turn *TurnAckPayload `json:"turn,omitempty"`
Done *DonePayload `json:"done,omitempty"`
Phase *PhasePayload `json:"phase,omitempty"`
Status *StatusPayload `json:"status,omitempty"`
Summary *SummaryPayload `json:"summary,omitempty"`
Briefing any `json:"briefing,omitempty"`
Detail *DetailPayload `json:"detail,omitempty"`
Delta string `json:"delta,omitempty"`
Deliverable *DeliverablePayload `json:"deliverable,omitempty"`
Governance *GovernancePayload `json:"governance,omitempty"`
Error string `json:"error,omitempty"`
ErrorInfo *ErrorInfo `json:"error_info,omitempty"`
Turn *TurnAckPayload `json:"turn,omitempty"`
Done *DonePayload `json:"done,omitempty"`
}
+1 -1
View File
@@ -2323,7 +2323,7 @@ export default function App() {
};
}
if (httpStatus === 403) {
if (code === 'ERR_FORBIDDEN' || detail.includes('无权访问该项目')) {
if (code === 'ERR_FORBIDDEN' || code === 'AUTHZ') {
return {
userMessage: detail || '无权访问该项目,请确认登录账号与项目成员身份',
code: code || 'ERR_FORBIDDEN',
+7
View File
@@ -47,6 +47,13 @@ describe('projectSettingsError', () => {
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', () => {
const draft = { workflow_definition_key: 'main', workflow_version: 1, stages: [] };
assert.equal(retainDraftOnFrozenConflict(draft), draft);
+2 -1
View File
@@ -37,6 +37,7 @@ export function parseProjectSettingsErrorBody(text: string): {
try {
const parsed = JSON.parse(text) as {
error?: string;
code?: string;
message?: string;
issues?: ProjectSettingsIssue[];
};
@@ -46,7 +47,7 @@ export function parseProjectSettingsErrorBody(text: string): {
.filter((msg): msg is string => Boolean(msg))
.join(';');
return {
code: String(parsed.error || ''),
code: String(parsed.code || parsed.error || ''),
message: issueText || parsed.message || parsed.error || text || '请求失败',
issues,
};
+1 -1
View File
@@ -69,7 +69,7 @@ function bucketHasMessages(days: ChatDayBucket[]): boolean {
function isForbiddenResponse(err: unknown): boolean {
if (!(err instanceof Error)) return false;
const msg = err.message.toLowerCase();
return msg.includes('403') || msg.includes('forbidden') || msg.includes('无权');
return msg.includes('403') || msg.includes('forbidden');
}
export function useChatWindow({