package gateway import ( "encoding/json" "fmt" "net/http" "os" "path/filepath" "strings" "gopkg.in/yaml.v3" "git.yeij.top/AskaEth/Cyrene_v2/internal/auth" apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors" "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/loader" personareg "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/registry" ) // 人格到身份与 API key 的管理写操作(仅 owner,带审计)。人格 // 写操作注册进人格注册表(真实的运行时效果),并持久化 // 一个 YAML 文件使其在重启后存活;API key 在 // auth.APIKey 存储中铸造/吊销。 const personaDir = "configs/personas" // personaUpsertRequest 是 POST/PUT /api/v1/admin/personas 的请求体。 type personaUpsertRequest struct { ID string `json:"id"` Name string `json:"name"` CharacterRef string `json:"characterRef"` Role string `json:"role"` SystemPrompt string `json:"systemPrompt"` Personality string `json:"personality"` SpeechStyle string `json:"speechStyle"` Catchphrase string `json:"catchphrase"` Background string `json:"background"` Values []string `json:"values"` Modes []string `json:"modes"` Mode string `json:"mode"` MemoryNS string `json:"memoryNamespace"` // Thinking 是可选的推理/呈现块(design/01 §2.1)。它 // 原样持久化进人格 YAML;更新时省略它会保留 // 已存块(见 adminUpdatePersona)。 Thinking *personaThinkingRequest `json:"thinking,omitempty"` } // personaThinkingRequest 是人格 `thinking` 段的线上形式。 // 其指针/命名与 YAML 一致,使面板能原样往返。 type personaThinkingRequest struct { BudgetTokens int `json:"budgetTokens,omitempty"` Effort string `json:"effort,omitempty"` ShowToUser *bool `json:"showToUser,omitempty"` Mode string `json:"mode,omitempty"` } // adminCreatePersona 在注册表中注册一个新人格(真实的运行时 // 效果),并持久化进人格 YAML 库使其在重启后存活。 func (s *Server) adminCreatePersona(w http.ResponseWriter, r *http.Request) { var req personaUpsertRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body")) return } if strings.TrimSpace(req.ID) == "" || strings.TrimSpace(req.Name) == "" || strings.TrimSpace(req.Role) == "" { s.auditClaims(r, "persona.create", "error") writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "id, name and role are required")) return } // identity.systemPrompt 必填(裁决 08.4.2):这里不做回落、不派生默认值 —— // 否则一条没有身份的「人格」会被静默降级成拼凑出的说明串。载入期 // (loader.Load)执行同一规则,因此写进来的档案必然也能被重新载入。 if strings.TrimSpace(req.SystemPrompt) == "" { s.auditClaims(r, "persona.create", "error") writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "systemPrompt is required (identity.systemPrompt must not be empty)")) return } reg, ok := s.personas.(*personareg.Registry) if !ok { s.auditClaims(r, "persona.create", "error") writeError(w, apperr.Wrap(apperr.ErrInternal, "persona_unavailable", "persona registry is not configured")) return } if _, exists := reg.Persona(req.ID); exists { s.auditClaims(r, "persona.create", "error") writeError(w, &apperr.AppError{Code: "duplicate", Message: "人格已存在,请使用编辑", HTTPCode: http.StatusConflict}) return } p := personaFromRequest(req) if err := p.ValidateThinking(); err != nil { s.auditClaims(r, "persona.create", "error") writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid thinking config: %v", err)) return } if err := savePersonaYAML(p); err != nil { s.auditClaims(r, "persona.create", "error") s.log.Error("persona persist", "id", req.ID, "err", err) writeError(w, apperr.Wrap(apperr.ErrInternal, "persona_persist_failed", "failed to persist persona")) return } if err := reg.RegisterPersona(p); err != nil { s.auditClaims(r, "persona.create", "error") writeError(w, apperr.Wrap(apperr.ErrInternal, "persona_register_failed", "failed to register persona")) return } if err := reg.RegisterCharacter(characterFromRequest(req)); err != nil { s.auditClaims(r, "persona.create", "error") writeError(w, apperr.Wrap(apperr.ErrInternal, "persona_register_failed", "failed to register character")) return } s.auditClaims(r, "persona.create", "ok") writeJSON(w, http.StatusCreated, s.personaSummaryOf(p, characterFromRequest(req))) } // adminUpdatePersona 编辑既有人格,持久化 YAML 并 // 替换内存中的人格/角色。 func (s *Server) adminUpdatePersona(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") var req personaUpsertRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body")) return } if strings.TrimSpace(req.Name) == "" || strings.TrimSpace(req.Role) == "" { s.auditClaims(r, "persona.update", "error") writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "name and role are required")) return } // identity.systemPrompt 必填(裁决 08.4.2),与创建路径同一把门:缺失/空白 // 显式报错,不回落、不静默保留旧值(「没改」要由客户端显式传回原值)。 if strings.TrimSpace(req.SystemPrompt) == "" { s.auditClaims(r, "persona.update", "error") writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "systemPrompt is required (identity.systemPrompt must not be empty)")) return } req.ID = id reg, ok := s.personas.(*personareg.Registry) if !ok { s.auditClaims(r, "persona.update", "error") writeError(w, apperr.Wrap(apperr.ErrInternal, "persona_unavailable", "persona registry is not configured")) return } if _, exists := reg.Persona(id); !exists { s.auditClaims(r, "persona.update", "error") writeError(w, apperr.Wrap(apperr.ErrNotFound, "not_found", "persona %q not found", id)) return } p := personaFromRequest(req) if err := p.ValidateThinking(); err != nil { s.auditClaims(r, "persona.update", "error") writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid thinking config: %v", err)) return } // 面板的写路径是**整块替换**,而请求体只带它认识的字段。请求体里不出现的 // 角色级块一律保留已存值,否则「面板改一次角色」就会抹掉手工写进 YAML 的 // 配置(数据丢失): existing := existingPersona(reg, id) // 不认识可选 `thinking` 块的客户端(旧面板、脚本)会省略它; // 应保留已存的那个,而不是静默清掉手工编辑的 // 配置。发送该对象则替换它,全字段为默认值 // 时清除它。 p.Thinking = applyThinkingEdit(req.Thinking, existing) p.Awakening = applyAwakeningEdit(existing) applyPersonaReadOnlyBlocks(p, existing) if err := savePersonaYAML(p); err != nil { s.auditClaims(r, "persona.update", "error") s.log.Error("persona persist", "id", id, "err", err) writeError(w, apperr.Wrap(apperr.ErrInternal, "persona_persist_failed", "failed to persist persona")) return } if err := reg.UpdatePersona(p); err != nil { s.auditClaims(r, "persona.update", "error") writeError(w, apperr.Wrap(apperr.ErrInternal, "persona_update_failed", "failed to update persona")) return } if err := reg.UpdateCharacter(characterFromRequest(req)); err != nil { s.auditClaims(r, "persona.update", "error") writeError(w, apperr.Wrap(apperr.ErrInternal, "persona_update_failed", "failed to update character")) return } s.auditClaims(r, "persona.update", "ok") writeJSON(w, http.StatusOK, s.personaSummaryOf(p, characterFromRequest(req))) } // adminCreateUser 诚实作答:既没有持久化用户表,也没有 // 可供支撑的用户认证登录(部署形态是来自 .env 的单个管理 // 账号加运行时 API key;design/11 是单用户优先),因此 // 创建用户会是一行没人能登录的记录。它报告缺失的 // 存储,而不是编造一个用户。 func (s *Server) adminCreateUser(w http.ResponseWriter, r *http.Request) { s.auditClaims(r, "user.create", "error") writeError(w, &apperr.AppError{ Code: "not_implemented", Message: "新增用户/角色未实现:无用户表,登录仅用 .env 单 admin(ADMIN_USERNAME/PASSWORD),角色为 owner>admin>viewer 常量;多用户存储与登录接入需先设计(design/11 单用户为主)", HTTPCode: http.StatusNotImplemented, }) } // adminIssueApiKey 铸造一个真实 API key 并返回一次明文(唯一可见的一次)。 // 响应中的 id 是管理列表所展示的脱敏形式,之后可用它吊销该 key。 // // 按裁决 15.4.3,key **不做角色分级**:本端点不再读请求体里的 role(也不 // 接受 role 语义),表里也没有角色维度 —— 任何有效 key 鉴权后都是超级管理员 // (owner)。响应仍回传实际授予的角色(恒为 owner),让调用方一眼看到 // 「签出去的就是超管 key」,而不是以为自己在签一把只读 key。 func (s *Server) adminIssueApiKey(w http.ResponseWriter, r *http.Request) { keys, ok := s.users.(*auth.APIKey) if !ok { s.auditClaims(r, "apikey.issue", "error") writeError(w, apperr.Wrap(apperr.ErrInternal, "users_unavailable", "用户服务未配置")) return } key, err := keys.Generate() if err != nil { s.auditClaims(r, "apikey.issue", "error") writeError(w, apperr.Wrap(apperr.ErrInternal, "apikey_failed", "failed to issue api key")) return } s.auditClaims(r, "apikey.issue", "ok") writeJSON(w, http.StatusCreated, map[string]any{ "id": auth.MaskKey(key), "key": key, "role": string(auth.RoleOwner), }) } // adminRevokeApiKey 按脱敏 id(管理列表返回的标识)吊销一个 // key。没有 key 携带该掩码时报告 not-found。 func (s *Server) adminRevokeApiKey(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") keys, ok := s.users.(*auth.APIKey) if !ok { s.auditClaims(r, "apikey.revoke", "error") writeError(w, apperr.Wrap(apperr.ErrInternal, "users_unavailable", "用户服务未配置")) return } if !keys.RevokeByMask(id) { s.auditClaims(r, "apikey.revoke", "error") writeError(w, apperr.Wrap(apperr.ErrNotFound, "not_found", "api key not found")) return } s.auditClaims(r, "apikey.revoke", "ok") writeJSON(w, http.StatusOK, map[string]any{"id": id, "revoked": true}) } // personaFromRequest 从 upsert 请求构建 loader.Persona。characterRef 省略时 // 回落到 id(它只是资源引用,有人格 id 就够);identity.systemPrompt 则 // **不回落到任何派生串** —— 缺失由调用方显式报 400(裁决 08.4.2),这样写进 // 磁盘的人格必然带着真实身份,也必然能被 loader.Load 重新载入。 func personaFromRequest(req personaUpsertRequest) *loader.Persona { characterRef := strings.TrimSpace(req.CharacterRef) if characterRef == "" { characterRef = req.ID } return &loader.Persona{ ID: req.ID, Name: req.Name, CharacterRef: characterRef, Identity: loader.Identity{Role: req.Role, SystemPrompt: strings.TrimSpace(req.SystemPrompt)}, Personality: req.Personality, SpeechStyle: req.SpeechStyle, Catchphrase: req.Catchphrase, Background: req.Background, Values: req.Values, Emotion: loader.EmotionParams{MemoryNamespace: strings.TrimSpace(req.MemoryNS)}, Thinking: thinkingFromRequest(req.Thinking), } } // thinkingFromRequest 把线上 `thinking` 块映射到人格字段; // 块缺失得到零值,yaml.Marshal 会完全省略它。 func thinkingFromRequest(req *personaThinkingRequest) loader.ThinkingParams { if req == nil { return loader.ThinkingParams{} } return loader.ThinkingParams{ BudgetTokens: req.BudgetTokens, Effort: strings.TrimSpace(req.Effort), ShowToUser: req.ShowToUser, Mode: strings.TrimSpace(req.Mode), } } // existingPersona 返回 id 对应的已存人格,不存在时为 nil。 func existingPersona(reg *personareg.Registry, id string) *loader.Persona { if p, ok := reg.Persona(id); ok { return p } return nil } // applyThinkingEdit 返回更新应持久化的 `thinking` 块:请求自带 // 块时用请求的,否则用已存人格的块 —— // 因此不认识该可选字段的客户端绝不可能清掉它。发送 // 该对象的更新会直接替换整块(全默认字段则清除它)。 func applyThinkingEdit(req *personaThinkingRequest, existing *loader.Persona) loader.ThinkingParams { if req != nil { return thinkingFromRequest(req) } if existing != nil { return existing.Thinking } return loader.ThinkingParams{} } // applyAwakeningEdit 返回更新应持久化的 `awakening` 块(design/14 §3,裁决 // 14.4.3):请求体不携带它就保留已存档位。面板目前**没有**该块的编辑控件 // (档位只能写进 `configs/personas/*.yaml`),因此像 applyThinkingEdit 的 // 「省略即保留」那样,更新路径必须留住它 —— 否则面板保存一次角色,刚手工写好 // 的唤醒档位就被抹成 nil,配置被静默丢失(design/01 §2.1 禁止静默无效的配置)。 // 面板将来支持编辑它时,在这里加请求体分支(自带块则替换、缺省仍保留), // 调用点不动。 func applyAwakeningEdit(existing *loader.Persona) *loader.AwakeningParams { if existing == nil { return nil } return existing.Awakening } // applyPersonaReadOnlyBlocks 保留请求体**根本不携带**的角色级块:视觉资产 // (visual:头像/聊天背景)、角色提醒词(triggerWords,design/18 §2 触发②)、情绪机 // 参数(emotion 的 initialState/decayRate/transitions)与好感度曲线(affection)。 // 它们都只能由人格 YAML 手工声明(面板不提供编辑控件,请求体里也没有对应字段), // 而写路径是**整块替换** —— 不显式保留,一次「改个角色名」的保存就会把它们从 // 档案与注册表里一并抹掉(数据丢失,design/01 §2.1)。 // // emotion.memoryNamespace 刻意不在此列:面板确实编辑它(`memoryNamespace`), // 由请求体决定。 func applyPersonaReadOnlyBlocks(p, existing *loader.Persona) { if p == nil || existing == nil { return } p.Visual = existing.Visual p.TriggerWords = append([]string(nil), existing.TriggerWords...) p.Emotion.InitialState = existing.Emotion.InitialState p.Emotion.DecayRate = existing.Emotion.DecayRate p.Emotion.Transitions = existing.Emotion.Transitions p.Affection = existing.Affection } // characterFromRequest 构建绑定到该人格的 registry.Character。 func characterFromRequest(req personaUpsertRequest) *personareg.Character { modes := append([]string(nil), req.Modes...) if len(modes) == 0 && req.Mode != "" { modes = []string{req.Mode} } return &personareg.Character{ID: req.ID, Name: req.Name, PersonaID: req.ID, Modes: modes} } // personaSummaryOf 把人格+角色映射为其线上摘要。它与 // 管理列表返回的形状相同 —— 角色级 mood 加上 `thinking` 块 —— // 因此写响应与随后的读对人格的描述完全一致 // (客户端可信任任一方用于回填)。 func (s *Server) personaSummaryOf(p *loader.Persona, c *personareg.Character) PersonaSummary { return PersonaSummary{ ID: p.ID, Name: c.Name, Character: c.Name, IsDefault: c.Default, Mode: firstMode(c.Modes), MemoryNS: p.MemoryNamespace(), Mood: s.moodOf(p.ID), Thinking: thinkingSummaryOf(p.Thinking, true), } } // savePersonaYAML 把 p 以 .yaml 持久化进人格库,使其在 // 重启后存活(wire.go 加载该目录)。id 会被净化,使用户 // 输入无法穿越出库目录。 func savePersonaYAML(p *loader.Persona) error { data, err := yaml.Marshal(p) if err != nil { return fmt.Errorf("persona: marshal %q: %w", p.ID, err) } if err := os.MkdirAll(personaDir, 0o755); err != nil { return fmt.Errorf("persona: mkdir: %w", err) } path := filepath.Join(personaDir, sanitizeID(p.ID)+".yaml") if err := os.WriteFile(path, data, 0o644); err != nil { return fmt.Errorf("persona: write %s: %w", path, err) } return nil } // sanitizeID 只保留 URL/路径安全字符,使人格 id 不能 // 逃出人格目录。 func sanitizeID(id string) string { var b strings.Builder for _, r := range id { switch { case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_': b.WriteRune(r) default: b.WriteByte('_') } } return b.String() }