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.
147 lines
4.4 KiB
Go
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)
|
|
}
|