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>
207 lines
6.5 KiB
Go
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})
|
|
}
|