Files
Agent-System/internal/projectinstance/message_scope.go
T
weichenandCursor 1abafa3176 fix(chat): add message scope columns and write-path resolution
Persist workflow_instance_id, stage_instance_id, scope_kind on chat
messages and task runs; resolve scope from live instance for chat and
document tasks so refresh can apply consistent stage authorization.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-13 03:06:08 +00:00

210 lines
6.4 KiB
Go

package projectinstance
import (
"context"
"errors"
"fmt"
)
const (
ScopeWorkflowStage = "workflow_stage"
ScopePreActivation = "pre_activation"
ScopeLegacyUnscoped = "legacy_unscoped"
ScopeNonProjectPrivate = "non_project_private"
)
var ErrTaskScopeNotResolved = errors.New("无法唯一解析文档任务的工作流阶段作用域")
// MessageScope 不可变消息工作流作用域(服务端解析,客户端不可伪造)。
type MessageScope struct {
ScopeKind string `json:"scope_kind"`
WorkflowInstanceID int64 `json:"workflow_instance_id,omitempty"`
StageInstanceID int64 `json:"stage_instance_id,omitempty"`
StageKey string `json:"stage_key,omitempty"`
}
// ApplyScopeToMetadata 将作用域写入 metadata(冗余,便于审计与兼容)。
func ApplyScopeToMetadata(meta map[string]any, scope *MessageScope) {
if meta == nil || scope == nil {
return
}
meta["scope_kind"] = scope.ScopeKind
if scope.WorkflowInstanceID > 0 {
meta["workflow_instance_id"] = scope.WorkflowInstanceID
}
if scope.StageInstanceID > 0 {
meta["stage_instance_id"] = scope.StageInstanceID
}
if scope.StageKey != "" {
meta["stage_key"] = scope.StageKey
}
}
// ScopeFromMetadata 从 metadata 提取作用域(历史兼容)。
func ScopeFromMetadata(metadata map[string]any) MessageScope {
out := MessageScope{ScopeKind: ScopeLegacyUnscoped}
if metadata == nil {
return out
}
if sk, ok := metadata["scope_kind"].(string); ok && sk != "" {
out.ScopeKind = sk
}
if v, ok := metadata["workflow_instance_id"].(float64); ok && v > 0 {
out.WorkflowInstanceID = int64(v)
} else if v, ok := metadata["workflow_instance_id"].(int64); ok && v > 0 {
out.WorkflowInstanceID = v
}
if v, ok := metadata["stage_instance_id"].(float64); ok && v > 0 {
out.StageInstanceID = int64(v)
} else if v, ok := metadata["stage_instance_id"].(int64); ok && v > 0 {
out.StageInstanceID = v
}
out.StageKey = MessageStageKey(metadata)
return out
}
// ResolveMessageScopeForChat 解析普通聊天消息作用域。
func (s *Service) ResolveMessageScopeForChat(ctx context.Context, projectID int64, activeStageKey string) (*MessageScope, error) {
if projectID <= 0 {
return &MessageScope{ScopeKind: ScopeNonProjectPrivate}, nil
}
if s == nil || s.store == nil {
return &MessageScope{ScopeKind: ScopePreActivation}, nil
}
live, err := s.store.GetLiveInstance(ctx, projectID)
if err != nil || live == nil {
return &MessageScope{ScopeKind: ScopePreActivation}, nil
}
detail, err := s.store.GetInstanceByID(ctx, live.ID)
if err != nil || detail == nil {
return &MessageScope{ScopeKind: ScopePreActivation}, nil
}
stageKey := activeStageKey
if stageKey == "" {
stageKey = detail.ActiveStageKey
}
if stageKey == "" {
return &MessageScope{
ScopeKind: ScopePreActivation,
WorkflowInstanceID: live.ID,
}, nil
}
for _, st := range detail.Stages {
if st.StageKey == stageKey {
return &MessageScope{
ScopeKind: ScopeWorkflowStage,
WorkflowInstanceID: live.ID,
StageInstanceID: st.ID,
StageKey: st.StageKey,
}, nil
}
}
return &MessageScope{
ScopeKind: ScopeWorkflowStage,
WorkflowInstanceID: live.ID,
StageKey: stageKey,
}, nil
}
// ResolveMessageScopeForTask 解析文档任务消息作用域;无法唯一归属时拒绝。
func (s *Service) ResolveMessageScopeForTask(ctx context.Context, projectID int64, taskKey string) (*MessageScope, error) {
if projectID <= 0 || taskKey == "" {
return nil, fmt.Errorf("%w: project_id=%d task_key=%q", ErrTaskScopeNotResolved, projectID, taskKey)
}
if s == nil || s.store == nil {
return nil, ErrTaskScopeNotResolved
}
live, err := s.store.GetLiveInstance(ctx, projectID)
if err != nil || live == nil {
return nil, fmt.Errorf("%w: no live instance for project %d", ErrTaskScopeNotResolved, projectID)
}
detail, err := s.store.GetInstanceByID(ctx, live.ID)
if err != nil || detail == nil {
return nil, ErrTaskScopeNotResolved
}
var matches []MessageScope
for _, st := range detail.Stages {
for _, dt := range st.DocTasks {
if dt.TaskKey == taskKey {
matches = append(matches, MessageScope{
ScopeKind: ScopeWorkflowStage,
WorkflowInstanceID: live.ID,
StageInstanceID: st.ID,
StageKey: st.StageKey,
})
}
}
}
if len(matches) == 0 {
stageKey := TaskKeyToStage(live.ConfigSnapshot, taskKey)
if stageKey == "" {
return nil, fmt.Errorf("%w: task_key %q not in instance snapshot", ErrTaskScopeNotResolved, taskKey)
}
for _, st := range detail.Stages {
if st.StageKey == stageKey {
return &MessageScope{
ScopeKind: ScopeWorkflowStage,
WorkflowInstanceID: live.ID,
StageInstanceID: st.ID,
StageKey: st.StageKey,
}, nil
}
}
return nil, ErrTaskScopeNotResolved
}
if len(matches) > 1 {
first := matches[0]
for _, m := range matches[1:] {
if m.StageKey != first.StageKey {
return nil, fmt.Errorf("%w: ambiguous task_key %q", ErrTaskScopeNotResolved, taskKey)
}
}
}
scope := matches[0]
return &scope, nil
}
// MessageVisible 判断消息对 viewer 是否可见(需会话 owner 与阶段授权上下文)。
func MessageVisible(scope MessageScope, access *StageAccess, sessionOwnerID, viewerUserID int64) bool {
if viewerUserID <= 0 {
return false
}
switch scope.ScopeKind {
case ScopeNonProjectPrivate, ScopePreActivation:
return sessionOwnerID > 0 && viewerUserID == sessionOwnerID
case ScopeLegacyUnscoped:
if access == nil || !access.Restricted {
return sessionOwnerID <= 0 || viewerUserID == sessionOwnerID
}
return sessionOwnerID > 0 && viewerUserID == sessionOwnerID
case ScopeWorkflowStage, "":
if access == nil || !access.Restricted {
return true
}
stageKey := scope.StageKey
if stageKey == "" {
return sessionOwnerID > 0 && viewerUserID == sessionOwnerID
}
return access.StageKeyAllowed(stageKey)
default:
return false
}
}
// MessageRecordVisible 从 ChatMessageRecord 字段判定可见性。
func MessageRecordVisible(
scopeKind, stageKey string,
metadata map[string]any,
access *StageAccess,
sessionOwnerID, viewerUserID int64,
) bool {
scope := MessageScope{ScopeKind: scopeKind, StageKey: stageKey}
if scope.ScopeKind == "" {
scope = ScopeFromMetadata(metadata)
}
if scope.StageKey == "" {
scope.StageKey = MessageStageKey(metadata)
}
return MessageVisible(scope, access, sessionOwnerID, viewerUserID)
}