Files
agent-bot ee1e53b510
CI / go-vet (pull_request) Failing after 10s
CI / backup-restore-drill (pull_request) Failing after 25s
CI / web (pull_request) Successful in 33s
fix(tests): per-package test databases + legitimate approval fixtures (issue #2 close)
Root causes of the remaining 5 CI PG failures (and the hidden flaky set):
1. All PG-integration packages shared ONE database under parallel package
   execution - migrations/seeds/truncates raced (nondeterministic failure
   sets, locally and in CI).
   Fix: RequireTestDatabaseURL/SkipIfNoTestDatabaseURL now derive a
   per-package database (<base>_<pkgHash>, advisory-lock-guarded
   CREATE); 9 raw-env straggler tests funnelled through the helpers;
   db-package tests call it unqualified (no import cycle).
2. Approval-chain fixtures were not production-shaped: freezeReviewSnapshot
   only honors head_snapshot_id, fake content hashes never froze a
   snapshot, so ApproverApprove rejected with 'approved requires frozen
   review_snapshot_id'. Errors were swallowed (_, _ =) hiding this.
   Fix (ownership-respecting, arch-enforced):
   - docrevision.SeedContentSnapshotForTest seeds the ledger tables it owns
   - docgov GovernanceEngine.FreezeHeadSnapshotForTest freezes head/review
     pointers on document_records (docgov-owned) and clears engine cache
   - all four integration fixtures use the pair; swallowed errors checked
3. Fixed-name fixtures collide once a package shares one DB (projects
   uq_projects_name_global): uniquified in projectinstance + server tests.
4. CAS/ctxmgr tests never bootstrapped migrations (relied on other packages
   having migrated the shared DB): explicit RunMigrationsOnceForTest.

Verified: full parallel short suite = 75 packages, 0 failures against an
isolated throwaway PG. CI skip list removed - short tests fully required.
2026-09-29 09:54:43 +00:00

147 lines
4.4 KiB
Go

package server
import (
"encoding/json"
"net/http"
"strconv"
"chat-agent-system/internal/ctxmgr"
)
// -----------------------------------------------------------------------------
// 多级上下文与偏好记忆 RESTful 控制器
// -----------------------------------------------------------------------------
// handleGetUserContext 获取单用户私有上下文与个性化偏好
// user_id 仅允许等于当前认证用户本人(或系统管理员),不再默认回落 user id=1。
func (s *Server) handleGetUserContext(w http.ResponseWriter, r *http.Request) {
user, ok := s.requirePrincipal(w, r)
if !ok {
return
}
userIDStr := r.URL.Query().Get("user_id")
projectIDStr := r.URL.Query().Get("project_id")
sessionID := r.URL.Query().Get("session_id")
userID, _ := strconv.ParseInt(userIDStr, 10, 64)
if userID <= 0 {
userID = user.ID
}
if userID != user.ID && !s.isSystemAdminUser(r.Context(), user) {
writeJSONResponse(w, http.StatusForbidden, map[string]string{
"error_code": "ERR_FORBIDDEN",
"message": "无权查询其他用户的上下文",
})
return
}
projectID, _ := strconv.ParseInt(projectIDStr, 10, 64)
if s.ctxMgr() == nil {
writeSafeError(w, r, http.StatusServiceUnavailable, "context manager not initialized")
return
}
// FIND-SEC-01 回归:session_id 必须归属于目标用户本人,否则拒绝。
// 会话不存在与归属他人同样返回 403,避免泄露会话存在性。
if sessionID != "" {
ownerID, err := s.ctxMgr().GetSessionOwnerID(r.Context(), sessionID)
if err != nil || ownerID <= 0 || ownerID != userID {
writeJSONResponse(w, http.StatusForbidden, map[string]string{
"error_code": "ERR_FORBIDDEN",
"message": "无权访问该会话上下文",
})
return
}
}
uCtx, err := s.ctxMgr().GetUserContext(r.Context(), projectID, userID, sessionID)
if err != nil {
writeDomainError(w, r, http.StatusInternalServerError, err)
return
}
writeJSONResponse(w, http.StatusOK, uCtx)
}
// handleUpdateUserPreference 定期整理或前端保存用户习惯偏好
func (s *Server) handleUpdateUserPreference(w http.ResponseWriter, r *http.Request) {
user, ok := s.requireAuthenticatedUser(w, r)
if !ok {
return
}
var req UpdateUserPreferenceRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeDomainError(w, r, http.StatusBadRequest, err)
return
}
if req.UserID <= 0 {
req.UserID = user.ID
}
if req.UserID != user.ID && !s.isSystemAdminUser(r.Context(), user) {
writeJSONResponse(w, http.StatusForbidden, map[string]string{
"error_code": "ERR_FORBIDDEN",
"message": "无权修改其他用户的偏好",
})
return
}
if s.ctxMgr() == nil {
writeSafeError(w, r, http.StatusServiceUnavailable, "context manager not initialized")
return
}
pref := &ctxmgr.UserPreference{
UserID: req.UserID,
CommunicationStyle: req.CommunicationStyle,
FocusAreas: req.FocusAreas,
OutputFormatPref: req.OutputFormatPref,
CustomInstructions: req.CustomInstructions,
}
if err := s.ctxMgr().UpdateUserPreference(r.Context(), pref); err != nil {
writeDomainError(w, r, http.StatusInternalServerError, err)
return
}
writeJSONResponse(w, http.StatusOK, map[string]any{"status": "ok", "user_id": req.UserID})
}
// handleGetProjectContext 获取项目级多方协同事实黑板与里程碑决策流
func (s *Server) handleGetProjectContext(w http.ResponseWriter, r *http.Request) {
user, ok := s.requireAuthenticatedUser(w, r)
if !ok {
return
}
projectIDStr := r.URL.Query().Get("project_id")
projectID, _ := strconv.ParseInt(projectIDStr, 10, 64)
if projectID <= 0 {
writeSafeError(w, r, http.StatusBadRequest, "missing or invalid project_id query param")
return
}
// Membership first: allowAnyStageAccess assumes the caller already passed
// project membership (see ResolveStageAccess). Without this guard any
// authenticated user can read an activated project's collaborative context.
if !s.enforceChatProjectAccess(w, r, user, projectID) {
return
}
if !s.allowAnyStageAccess(r.Context(), projectID, user.ID) {
s.writeWorkflowAccessDenied(w)
return
}
if s.ctxMgr() == nil {
writeSafeError(w, r, http.StatusServiceUnavailable, "context manager not initialized")
return
}
pCtx, err := s.ctxMgr().GetProjectContext(r.Context(), projectID)
if err != nil {
writeDomainError(w, r, http.StatusInternalServerError, err)
return
}
writeJSONResponse(w, http.StatusOK, pCtx)
}