Remove production *pgxpool.Pool from Server, centralize domain→HTTP status mapping, and expose Runtime ports for task-store readiness, duty heal, and artifact vault registry. Allow app→docduty for heal wiring.
217 lines
7.2 KiB
Go
217 lines
7.2 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log"
|
|
"net/http"
|
|
"strconv"
|
|
|
|
"chat-agent-system/internal/auth"
|
|
"chat-agent-system/internal/authz"
|
|
)
|
|
|
|
var errProjectRoleRequired = errors.New("project_role required when member does not exist")
|
|
|
|
func resolveUpdateProjectRole(requested string, existing *authz.ProjectMemberRecord) (string, error) {
|
|
if requested != "" {
|
|
return requested, nil
|
|
}
|
|
if existing != nil && existing.ProjectRole != "" {
|
|
return existing.ProjectRole, nil
|
|
}
|
|
return "", errProjectRoleRequired
|
|
}
|
|
|
|
func writeDutySyncError(w http.ResponseWriter) {
|
|
writeSafeError(w, nil, http.StatusInternalServerError, `{"error":"ERR_DUTY_SYNC","message":"成员已保存,但受控文档职责同步失败"}`)
|
|
}
|
|
|
|
func (s *Server) healProjectDuties(ctx context.Context, projectID int64) error {
|
|
if s == nil || s.svc == nil || s.rt == nil || projectID <= 0 {
|
|
return nil
|
|
}
|
|
return s.rt.HealProjectDuties(ctx, projectID)
|
|
}
|
|
|
|
type projectMemberRequest struct {
|
|
UserID int64 `json:"user_id"`
|
|
ProjectRole string `json:"project_role"`
|
|
DocumentDuty *string `json:"document_duty,omitempty"`
|
|
Status string `json:"status,omitempty"`
|
|
}
|
|
|
|
func (s *Server) handleProjectMembers(w http.ResponseWriter, r *http.Request) {
|
|
user, ok := s.requirePrincipal(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
projectID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
|
if err != nil || projectID <= 0 {
|
|
writeSafeError(w, r, http.StatusBadRequest, `{"error":"ERR_INVALID_PARAM","message":"invalid project id"}`)
|
|
return
|
|
}
|
|
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
s.handleListProjectMembers(w, r, projectID)
|
|
case http.MethodPost:
|
|
s.handleAddProjectMember(w, r, user, projectID)
|
|
case http.MethodPut:
|
|
s.handleUpdateProjectMember(w, r, user, projectID)
|
|
case http.MethodDelete:
|
|
s.handleRemoveProjectMember(w, r, user, projectID)
|
|
default:
|
|
writeSafeError(w, r, http.StatusMethodNotAllowed, "method not allowed")
|
|
}
|
|
}
|
|
|
|
func (s *Server) handleListProjectMembers(w http.ResponseWriter, r *http.Request, projectID int64) {
|
|
if _, ok := s.requireProjectAccess(w, r, projectID); !ok {
|
|
return
|
|
}
|
|
members, err := s.authorizer.ListProjectMembers(r.Context(), projectID)
|
|
if err != nil {
|
|
writeSafeError(w, r, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
if members == nil {
|
|
members = []authz.ProjectMemberRecord{}
|
|
}
|
|
writeJSONResponse(w, http.StatusOK, map[string]any{"members": members})
|
|
}
|
|
|
|
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 {
|
|
writeSafeError(w, r, http.StatusInternalServerError, err.Error())
|
|
return false
|
|
}
|
|
if !can {
|
|
writeSafeError(w, r, http.StatusForbidden, `{"error":"ERR_FORBIDDEN","message":"无权管理项目成员"}`)
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func (s *Server) handleAddProjectMember(w http.ResponseWriter, r *http.Request, user *auth.UserAccount, projectID int64) {
|
|
if !s.requireProjectMemberManage(w, r, user, projectID) {
|
|
return
|
|
}
|
|
var req projectMemberRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.UserID <= 0 {
|
|
writeSafeError(w, r, http.StatusBadRequest, `{"error":"ERR_INVALID_PARAM","message":"user_id and project_role required"}`)
|
|
return
|
|
}
|
|
if req.ProjectRole == "" {
|
|
req.ProjectRole = authz.RoleMember
|
|
}
|
|
if err := s.authorizer.UpsertProjectMemberSpec(r.Context(), projectID, req.UserID, authz.MemberUpsert{
|
|
ProjectRole: req.ProjectRole,
|
|
DocumentDuty: req.DocumentDuty,
|
|
}); err != nil {
|
|
writeSafeError(w, r, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
if err := s.healProjectDuties(r.Context(), projectID); err != nil {
|
|
writeDutySyncError(w)
|
|
return
|
|
}
|
|
s.ensureMemberChatSession(r.Context(), projectID, req.UserID)
|
|
writeJSONResponse(w, http.StatusCreated, map[string]bool{"success": true})
|
|
}
|
|
|
|
// ensureMemberChatSession 为项目成员补齐私有会话(sess-p{id}-u{uid}),失败仅记日志。
|
|
func (s *Server) ensureMemberChatSession(ctx context.Context, projectID, memberUserID int64) {
|
|
if s == nil || s.ctxMgr == nil || projectID <= 0 || memberUserID <= 0 {
|
|
return
|
|
}
|
|
title := ""
|
|
if s.pwSvc != nil {
|
|
if proj, err := s.pwSvc.GetProject(ctx, projectID, 0); err == nil && proj != nil {
|
|
title = proj.Name
|
|
}
|
|
}
|
|
sessionID := fmt.Sprintf("sess-p%d-u%d", projectID, memberUserID)
|
|
if _, err := s.ctxMgr.EnsureChatSessionOwned(ctx, sessionID, memberUserID, projectID, title); err != nil {
|
|
log.Printf("[members] ensure chat session for project=%d user=%d failed: %v", projectID, memberUserID, err)
|
|
}
|
|
}
|
|
|
|
func (s *Server) handleUpdateProjectMember(w http.ResponseWriter, r *http.Request, user *auth.UserAccount, projectID int64) {
|
|
if !s.requireProjectMemberManage(w, r, user, projectID) {
|
|
return
|
|
}
|
|
var req projectMemberRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.UserID <= 0 {
|
|
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 {
|
|
writeSafeError(w, r, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
existing = rec
|
|
} else if req.ProjectRole == "" && req.DocumentDuty != nil {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
writeSafeError(w, r, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
wrote = true
|
|
}
|
|
if wrote {
|
|
if err := s.healProjectDuties(r.Context(), projectID); err != nil {
|
|
writeDutySyncError(w)
|
|
return
|
|
}
|
|
}
|
|
writeJSONResponse(w, http.StatusOK, map[string]bool{"success": true})
|
|
}
|
|
|
|
func (s *Server) handleRemoveProjectMember(w http.ResponseWriter, r *http.Request, user *auth.UserAccount, projectID int64) {
|
|
if !s.requireProjectMemberManage(w, r, user, projectID) {
|
|
return
|
|
}
|
|
userIDStr := r.URL.Query().Get("user_id")
|
|
targetID, err := strconv.ParseInt(userIDStr, 10, 64)
|
|
if err != nil || targetID <= 0 {
|
|
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 {
|
|
writeSafeError(w, r, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
if err := s.healProjectDuties(r.Context(), projectID); err != nil {
|
|
writeDutySyncError(w)
|
|
return
|
|
}
|
|
writeJSONResponse(w, http.StatusOK, map[string]bool{"success": true})
|
|
}
|