package store import ( "database/sql" "errors" "time" core "agent-system-core" ) // 临时会话(scratch session):一个 scratch 项目 + 恰好一个私有会话。 // scratch 属性只由项目 settings 的 host.kind 表达——它随项目行一起存储, // 所以判定只需要一个读函数,任何调用点都不自己比对字符串。 const ( // ScratchHostKind 是 projects.settings.host.kind 的临时会话取值。 ScratchHostKind = "scratch" // ScratchDefaultProjectName 是未指定名称时临时会话项目使用的默认名。 ScratchDefaultProjectName = "临时会话" ) var ( // ErrScratchSessionSingle 会话与临时会话项目是 1:1:第二个会话被拒。 ErrScratchSessionSingle = errors.New("store: scratch session holds exactly one conversation") // ErrScratchMarkerReserved 标记归服务端所有:客户端既不能给普通项目 // 加上 scratch,也不能从已有 scratch 项目上摘掉它。 ErrScratchMarkerReserved = errors.New("store: scratch host marker is server-owned") ) // ScratchHostSettings 返回标记 scratch 项目的 settings 值。 func ScratchHostSettings() map[string]any { return map[string]any{"host": map[string]any{"kind": ScratchHostKind}} } // IsScratchProject 报告 settings 是否带 scratch 宿主标记。已存储的 // projects.settings 与尚未合并的客户端 settings 都用它判定。 func IsScratchProject(settings map[string]any) bool { host, ok := settings["host"].(map[string]any) if !ok { return false } kind, _ := host["kind"].(string) return kind == ScratchHostKind } // ScratchProjectIDs 返回 owner 名下带 scratch 标记的项目 id 集合:一次查询读出 // 该 owner 的全部 projects.settings,逐行按 IsScratchProject 判定。列表响应要给 // 一批会话打标时走它,不必逐个会话回查项目;别人的 scratch 项目不在结果里。 func (s *Store) ScratchProjectIDs(owner string) (map[string]bool, error) { rows, err := s.db.Query(s.q(`SELECT id,settings FROM projects WHERE owner=?`), owner) if err != nil { return nil, err } defer rows.Close() out := map[string]bool{} for rows.Next() { var id, settings string if err := rows.Scan(&id, &settings); err != nil { return nil, err } if IsScratchProject(decodeProjectSettings(settings)) { out[id] = true } } return out, rows.Err() } // IsScratchProjectID 是内部可信判定:按 id 读 projects.settings 的 host 标记。 // 调用点(HTTP 门禁、回合门禁、命令分发)一律走它,不自己比对标记字符串。 func (s *Store) IsScratchProjectID(projectID string) (bool, error) { if projectID == "" { return false, nil } p, err := s.ProjectMetadata(projectID) if err != nil { return false, err } return IsScratchProject(p.Settings), nil } // ScratchProjectDecision 是临时会话的归属判定,回答“这个调用是否发生在某个 // 临时会话里”: // - handled=false:不是临时会话(或项目读不到),调用点继续走原来的判定面; // - handled=true, err=nil:调用者是该临时会话的属主,放行且不必再问 RDM; // - handled=true, err=ErrNotFound:临时会话不是共享资源,其他人一律不可见。 // // 判据只来自存储里的属主列与 host 标记,不接受调用方自称的身份。 func (s *Store) ScratchProjectDecision(projectID, actor string) (bool, error) { if projectID == "" { return false, nil } p, err := s.ProjectMetadata(projectID) if err != nil { // 读不到项目(不存在或存储异常):存在性与共享判定留给原路径, // 这里不把“读不到”当成临时会话,也就不会放宽任何门禁。 return false, nil } if !IsScratchProject(p.Settings) { return false, nil } if p.Owner != core.ID(actor) { return true, ErrNotFound } return true, nil } // scratchQuerier 是本文件里临时会话判定可用的两种执行面(*sql.DB 与 *sql.Tx), // 于是同一个事务内的级联删除与 store 级删除共用一份语义。 type scratchQuerier interface { QueryRow(string, ...any) *sql.Row Exec(string, ...any) (sql.Result, error) } // scratchProjectOfConversation 返回会话所属的 scratch 项目 id;普通项目返回空串。 func (s *Store) scratchProjectOfConversation(q scratchQuerier, owner, conversationID string) (string, error) { var projectID, settings string err := q.QueryRow(s.q(`SELECT c.project_id, COALESCE(p.settings,'') FROM conversations c LEFT JOIN projects p ON p.id=c.project_id WHERE c.id=? AND c.owner=?`), conversationID, owner).Scan(&projectID, &settings) if errors.Is(err, sql.ErrNoRows) { return "", ErrNotFound } if err != nil { return "", err } if !IsScratchProject(decodeProjectSettings(settings)) { return "", nil } return projectID, nil } // deleteScratchProjectIfEmpty 在会话已被删掉之后连带删掉它的临时会话项目: // 临时会话项目只承载这一个会话,删会话即删临时会话(同一个执行面、同一个门禁)。 // 仍有会话时不动(1:1 由创建面保证,删除面不依赖该不变量做正确性);普通项目 // id 传进来也不会被删。 func (s *Store) deleteScratchProjectIfEmpty(q scratchQuerier, owner, projectID string) error { if projectID == "" { return nil } var remaining int if err := q.QueryRow(s.q(`SELECT COUNT(1) FROM conversations WHERE project_id=?`), projectID).Scan(&remaining); err != nil { return err } if remaining > 0 { return nil } res, err := q.Exec(s.q(`DELETE FROM projects WHERE id=? AND owner=?`), projectID, owner) if err != nil { return err } if n, _ := res.RowsAffected(); n == 0 { return ErrNotFound } return nil } // deleteConversationCascade 删除一个会话,并在它是临时会话唯一会话时连带删掉 // 临时会话项目。account-checked 路径在最终写事务内调用,legacy 路径在同一执行 // 面上调用:语义只有一份。 func (s *Store) deleteConversationCascade(q scratchQuerier, owner, id string, del func() error) error { scratchProject, err := s.scratchProjectOfConversation(q, owner, id) if err != nil { return err } if err := del(); err != nil { return err } return s.deleteScratchProjectIfEmpty(q, owner, scratchProject) } // decodeProjectSettings 把存储的 settings JSON 读成 map;缺省或损坏都是空表。 func decodeProjectSettings(raw string) map[string]any { if raw == "" || raw == "{}" { return map[string]any{} } cur, err := unmarshalJSON[map[string]any](raw) if err != nil || cur == nil { return map[string]any{} } return cur } // rejectScratchMarkerWrite 拒绝改变 scratch 标记的客户端写入。保持标记不变的 // 普通设置写入(例如给 scratch 项目改别的键)照旧通过。 func rejectScratchMarkerWrite(current, merged map[string]any) error { if IsScratchProject(current) != IsScratchProject(merged) { return ErrScratchMarkerReserved } return nil } // refuseScratchConversation 按存储的 projects.settings 判定 1:1:scratch 项目 // 下再建会话即拒,错误在会话插入之前返回。 func refuseScratchConversation(rawSettings string) error { if IsScratchProject(decodeProjectSettings(rawSettings)) { return ErrScratchSessionSingle } return nil } // CreateScratchSessionAccountChecked 在一个事务里建出 scratch 项目与它唯一的 // 会话:项目写入 host.kind 标记,会话写 cross_private=true。两者都发生在校验 // 过的 actor 行锁之下,所以临时会话与普通项目走同一条账号有效性边界。 // 调用方不需要(也不应)先调 CreateProjectAccountChecked——该路径不带标记, // 无法表示临时会话。 func (s *Store) CreateScratchSessionAccountChecked(actor, name, title, profileID string, allowImplicitDefault bool) (core.Project, core.Conversation, error) { now := time.Now().UTC() project := core.Project{ ID: newID("prj"), Owner: core.ID(actor), Name: name, CreatedAt: now, UpdatedAt: now, Settings: ScratchHostSettings(), } conversation := core.Conversation{ ID: newID("cnv"), ProjectID: project.ID, Owner: core.ID(actor), Title: title, ProfileID: profileID, AllowCrossRead: true, CrossPrivate: true, CreatedAt: now, UpdatedAt: now, } err := s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { if _, err := tx.Exec(s.q(`INSERT INTO projects(id,owner,name,settings,created_at,updated_at) VALUES(?,?,?,?,?,?)`), project.ID, actor, project.Name, marshalJSON(project.Settings), now.Format(time.RFC3339Nano), now.Format(time.RFC3339Nano)); err != nil { return err } _, err := tx.Exec(s.q(`INSERT INTO conversations(id,project_id,owner,title,profile_id,cross_private,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?)`), conversation.ID, conversation.ProjectID, actor, conversation.Title, conversation.ProfileID, true, now.Format(time.RFC3339Nano), now.Format(time.RFC3339Nano)) return err }) if err != nil { return core.Project{}, core.Conversation{}, err } return project, conversation, nil }