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>
210 lines
6.4 KiB
Go
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)
|
|
}
|