Files
Cyrene_v2/internal/services/gateway/admin_write_personas_users.go
T
AskaEth adef36538f
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(auth,gateway,user): X-API-Key 真接线 + key 一律超管 + owner 档案真落盘(裁决 15.4.2/15.4.3/10.4.5/08.4.2)
- 15.4.2:管理 API 与 Bearer 共用同一 Authorize 路径,X-API-Key 命中签发表即过门;未知/已吊销/空值 401 且不回落 Bearer(fail-closed)
- 15.4.3:key 表去掉角色维度,任一有效 key 鉴权后即 owner;签发接口取消 role 分级(不再读请求体)
- 10.4.5:新增 GET/PUT /api/v1/admin/owner-profile(owner-only,PUT 带审计 owner.profile.update),0600 原子落盘 + 活档案热替换;前端主人档案改接真实 API,读不到即禁用保存,删掉硬编码默认档案
- 08.4.2:persona 的 identity.systemPrompt 必填,缺失/空白 400 显式报错,不再派生回落
2026-10-04 12:07:51 +08:00

481 lines
20 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"
"errors"
"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"
"git.yeij.top/AskaEth/Cyrene_v2/internal/user"
)
// 人格到身份与 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
}
// 不认识可选 `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,
})
}
// 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})
}
// --- 主人档案(裁决 10.4.5)--------------------------------------------------
//
// 档案是 design/11 §0 的身份红线:渠道层用它的 accounts 白名单比对 actor 元数据
// 认出主人,所以读写都只给 owner。落盘 configs/users/owner.yaml(0600 原子写)
// 后热替换活档案 —— 面板的「保存」因此是真保存,不再是只改本地 state 的假成功。
// ownerProfileResponse 是主人档案的线上形式,字段与 user.OwnerProfile 的 YAML
// 一一对应,面板可原样往返回填。
type ownerProfileResponse struct {
ID string `json:"id"`
DisplayName string `json:"displayName"`
DefaultPersona string `json:"defaultPersona,omitempty"`
IdentityFile string `json:"identityFile,omitempty"`
Notes string `json:"notes,omitempty"`
Accounts []ownerAccountRequest `json:"accounts"`
}
// ownerAccountRequest 是一条渠道账号白名单项(平台 + 该平台的账号 id)。
type ownerAccountRequest struct {
Platform string `json:"platform"`
AccountID string `json:"accountId"`
}
// ownerProfileFrom 把档案映射为线上形式,accounts 一律是空数组而不是 null。
func ownerProfileFrom(p *user.OwnerProfile) ownerProfileResponse {
out := ownerProfileResponse{
ID: p.ID, DisplayName: p.DisplayName, DefaultPersona: p.DefaultPersona,
IdentityFile: p.IdentityFile, Notes: p.Notes,
Accounts: make([]ownerAccountRequest, 0, len(p.Accounts)),
}
for _, a := range p.Accounts {
out.Accounts = append(out.Accounts, ownerAccountRequest{Platform: a.Platform, AccountID: a.AccountID})
}
return out
}
// toOwnerProfile 把线上档案映射回 user.OwnerProfile,并去掉各字段首尾空白
// (面板里手打的平台名/账号 id 常带空格,带进白名单就永远匹配不上)。
func (r ownerProfileResponse) toOwnerProfile() *user.OwnerProfile {
p := &user.OwnerProfile{
ID: strings.TrimSpace(r.ID), DisplayName: strings.TrimSpace(r.DisplayName),
DefaultPersona: strings.TrimSpace(r.DefaultPersona), IdentityFile: strings.TrimSpace(r.IdentityFile),
Notes: strings.TrimSpace(r.Notes),
Accounts: make([]user.OwnerAccount, 0, len(r.Accounts)),
}
for _, a := range r.Accounts {
p.Accounts = append(p.Accounts, user.OwnerAccount{
Platform: strings.TrimSpace(a.Platform), AccountID: strings.TrimSpace(a.AccountID),
})
}
return p
}
// adminGetOwnerProfile 返回落盘的主人档案。文件不存在(全新安装,还没人配过
// 主人)时返回一份空白档案而不是 404 —— 面板据此渲染一张空表单让主人首次填写。
func (s *Server) adminGetOwnerProfile(w http.ResponseWriter, r *http.Request) {
p, err := user.LoadOwnerProfile(ownerProfilePath)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
writeJSON(w, http.StatusOK, ownerProfileFrom(&user.OwnerProfile{}))
return
}
// 坏档案(IO 失败或必填字段缺失)如实报告,不返回一份看似正常的空档案:
// 那会让「白名单是空的」和「档案读不出来」看起来一模一样。
s.log.Error("owner profile read", "err", err)
writeError(w, apperr.Wrap(apperr.ErrInternal, "owner_profile_unreadable", "failed to read owner profile: %v", err))
return
}
writeJSON(w, http.StatusOK, ownerProfileFrom(p))
}
// adminPutOwnerProfile 校验并落盘主人档案(0600 原子写),随后热替换活档案,使
// 新白名单**立即**参与渠道层的 actor 判定,不必重启(design/11 §9「可编辑、
// 热重载」)。校验失败或落盘失败都以明确的错误作答,绝不假装保存成功。
func (s *Server) adminPutOwnerProfile(w http.ResponseWriter, r *http.Request) {
var req ownerProfileResponse
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body"))
return
}
p := req.toOwnerProfile()
if err := p.Validate(); err != nil {
s.auditClaims(r, "owner.profile.update", "error")
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid owner profile: %v", err))
return
}
if err := user.SaveOwnerProfile(ownerProfilePath, p); err != nil {
s.auditClaims(r, "owner.profile.update", "error")
s.log.Error("owner profile persist", "err", err)
writeError(w, apperr.Wrap(apperr.ErrInternal, "owner_profile_persist_failed", "failed to persist owner profile"))
return
}
s.hotSwapOwnerProfile(p)
s.auditClaims(r, "owner.profile.update", "ok")
writeJSON(w, http.StatusOK, ownerProfileFrom(p))
}
// liveOwnerProfile 是 OwnerRegistrar 的**可选**扩展:组合根额外实现它时(其
// ownerRegistrar 持有启动时加载的活 *user.OwnerProfile,也就是渠道 bridge 用来
// 解析 actor 的同一个指针),admin 写路径就能原地热替换那份档案。
type liveOwnerProfile interface {
// LiveOwnerProfile 返回活的档案;启动时未能加载档案时为 nil。
LiveOwnerProfile() *user.OwnerProfile
}
// hotSwapOwnerProfile 把刚落盘的档案原地装进活档案。拿不到活档案时只记一条
// WARN 说明「已落盘、需重启生效」,绝不假装热重载成功。
func (s *Server) hotSwapOwnerProfile(p *user.OwnerProfile) {
holder, ok := s.ownerRegistrar.(liveOwnerProfile)
if !ok {
s.log.Warn("owner profile saved; live profile not exposed by composition root, restart to apply")
return
}
live := holder.LiveOwnerProfile()
if live == nil {
s.log.Warn("owner profile saved; no live profile was loaded at boot, restart to apply")
return
}
live.Replace(p)
s.log.Info("owner profile hot-reloaded", "accounts", len(p.Accounts))
}
// 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{}
}
// 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()
}