Files
Agent-System/internal/server/project_members_handlers.go
T
weichenandCursor 0fa4a5784e fix(authz): clear reviewer/approver slots in the member-write transaction
Duty revoke must share the project advisory lock with reading old holders and writing members, so a post-commit service snapshot cannot miss a transfer. Heal still fills the new holder after commit, and sync failure stays ERR_DUTY_SYNC.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-13 22:37:24 +00:00

207 lines
6.5 KiB
Go

package server
import (
"context"
"encoding/json"
"errors"
"log"
"net/http"
"strconv"
"chat-agent-system/internal/auth"
"chat-agent-system/internal/authz"
"chat-agent-system/internal/docduty"
)
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) {
http.Error(w, `{"error":"ERR_DUTY_SYNC","message":"成员已保存,但受控文档职责同步失败"}`, http.StatusInternalServerError)
}
func (s *Server) healProjectDuties(ctx context.Context, projectID int64) error {
if s == nil || s.svc == nil || projectID <= 0 {
return nil
}
pool := s.svc.Pool()
if pool == nil {
return nil
}
_, _, err := docduty.HealProjectDocuments(ctx, pool, projectID)
if err != nil {
log.Printf("[docduty] heal project %d after member change failed: %v", projectID, err)
}
return err
}
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 {
http.Error(w, `{"error":"ERR_INVALID_PARAM","message":"invalid project id"}`, http.StatusBadRequest)
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:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
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 {
http.Error(w, err.Error(), http.StatusInternalServerError)
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 {
http.Error(w, err.Error(), http.StatusInternalServerError)
return false
}
if !can {
http.Error(w, `{"error":"ERR_FORBIDDEN","message":"无权管理项目成员"}`, http.StatusForbidden)
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 {
http.Error(w, `{"error":"ERR_INVALID_PARAM","message":"user_id and project_role required"}`, http.StatusBadRequest)
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 {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if err := s.healProjectDuties(r.Context(), projectID); err != nil {
writeDutySyncError(w)
return
}
writeJSONResponse(w, http.StatusCreated, map[string]bool{"success": true})
}
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 {
http.Error(w, `{"error":"ERR_INVALID_PARAM","message":"user_id required"}`, http.StatusBadRequest)
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)
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)
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)
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)
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)
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 {
http.Error(w, `{"error":"ERR_INVALID_PARAM","message":"user_id query required"}`, http.StatusBadRequest)
return
}
if err := s.authorizer.SetProjectMemberStatus(r.Context(), projectID, targetID, authz.StatusExited); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if err := s.healProjectDuties(r.Context(), projectID); err != nil {
writeDutySyncError(w)
return
}
writeJSONResponse(w, http.StatusOK, map[string]bool{"success": true})
}