Files
Cyrene_v2/internal/services/gateway/admin_write_personas_users.go
T
AskaEth 074ffc4ebf
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
docs(gateway): gateway 剩余文件注释改中文(逐行 1:1,代码零字节改动)
主人 2026-10-03 拍板「注释一律中文」;gateway 至此整包注释中文化完成(本会话共 3 批:
已推库 16 个文件 + 本批剩余文件)。
校验:机械校验(非注释改动行)= 空;剥注释后代码文本逐行比对全等(不一致文件数 0);
全树 go test ./... 58 包 ok / 0 FAIL;交叉编译绿。
2026-10-03 22:28:25 +08:00

352 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}
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
}
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
}
// 不认识可选 `thinking` 块的客户端(旧面板、脚本)会省略它;
// 应保留已存的那个,而不是静默清掉手工编辑的
// 配置。发送该对象则替换它,全字段为默认值
// 时清除它。
p.Thinking = applyThinkingEdit(req.Thinking, existingPersona(reg, id))
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,
})
}
// apiKeyIssueRequest 是 POST /api/v1/admin/users/apikey 的请求体。
type apiKeyIssueRequest struct {
Role string `json:"role"`
}
// adminIssueApiKey 铸造一个绑定到某角色的真实 API key,并返回一次
// 明文(唯一可见的一次)。响应中的 id 是管理列表所展示的
// 脱敏形式,因此之后可用它吊销该 key。
func (s *Server) adminIssueApiKey(w http.ResponseWriter, r *http.Request) {
var req apiKeyIssueRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body"))
return
}
role, err := auth.ParseRole(req.Role)
if err != nil {
s.auditClaims(r, "apikey.issue", "error")
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid role"))
return
}
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(role)
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(role)})
}
// 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,在省略时
// 填入派生的 system prompt/characterRef,使最小创建也能通过校验。
func personaFromRequest(req personaUpsertRequest) *loader.Persona {
systemPrompt := strings.TrimSpace(req.SystemPrompt)
if systemPrompt == "" {
systemPrompt = fmt.Sprintf("你是 %s,按角色人设 %q 与主人交流。", req.Name, req.Role)
}
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: 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{}
}
// 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 以 <id>.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()
}