Files
Cyrene_v2/internal/services/gateway/admin_write_personas_users.go
T
AskaEth 2dee372fb2
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(backthink/admin): ③b — rag/delete 真实现 + D1 推送出口钩子(自建优先→个推兜底) + api-reference 补 D2/D3/D5 补丁
- B5: POST /admin/rag/delete 真做(DeleteDocument→DeleteProject 级联删章节+索引清理, owner+审计, 200/404/500, 500降级兜底); users/iot scenarios+rules 诚实501(无用户表/IoT场景规则模型, 消息写明缺什么, 不伪造), 锁住 501+not_implemented
- D1: backthink/push_outlet.go PushExporter{Export}; pushOutlet 自建HTTP(2xx=送达, token仅Authorization头+error脱敏)优先→Getui兜底(未接入, 诚实日志'getui not integrated'+(false,nil)); backthink.Deps.Exporter → pusher.withExporter, Push 在WS/IM后 exportAsync(detached+5s超时, 失败仅日志, 不破坏现有投递, 限流不上报); config PushOutletConfig(PUSH_EXPORTER_* + GETUI_*占位)
- 前端 Rag.tsx/api.ts 删除后 reload + 类型; Docs/api-client v0.31 + api-reference(#23 owner/200-404, §8.4, 端点表PATCH/DELETE messages + pinned + messages?q=)
- 测试(push_outlet/admin_rag_delete/admin_honest501/knowledge store), 后端+前端build全绿+3平台交叉编译
- 刻意: Getui SDK 未接入(无SDK不硬造), 主人自建服务填 PUSH_EXPORTER_URL/TOKEN 即可
2026-09-09 00:08:29 +08:00

282 lines
11 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"
)
// persona-to-identity and API-key admin writes (owner-only, audited). Persona
// writes register into the persona registry (real runtime effect) and persist a
// YAML file so they survive a restart; API keys are minted/revoked in the
// auth.APIKey store.
const personaDir = "configs/personas"
// personaUpsertRequest is the POST/PUT /api/v1/admin/personas body.
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"`
}
// adminCreatePersona registers a new persona in the registry (real runtime
// effect) and persists it to the persona YAML library so it survives a restart.
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 := 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, personaSummaryOf(p, characterFromRequest(req)))
}
// adminUpdatePersona edits an existing persona, persisting the YAML and
// replacing the in-memory persona/character.
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 := 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, personaSummaryOf(p, characterFromRequest(req)))
}
// adminCreateUser answers honestly: there is no persistent user table and no
// user-authenticated login to back one (the deployment is a single admin
// account from .env plus runtime API keys; design/11 is single-user first), so
// user creation would be a row nobody could log in with. It reports the missing
// store instead of fabricating a user.
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 is the POST /api/v1/admin/users/apikey body.
type apiKeyIssueRequest struct {
Role string `json:"role"`
}
// adminIssueApiKey mints a real API key bound to a role and returns the plaintext
// once (the only time it is visible). The id in the response is the masked form
// the admin list shows, so it can be used to revoke the key later.
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 revokes a key by its masked id (the identifier the admin list
// returns). It reports not-found when no key carries that mask.
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 builds a loader.Persona from the upsert request, filling a
// derived system prompt/characterRef when omitted so a minimal create validates.
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)},
}
}
// characterFromRequest builds the registry.Character bound to the persona.
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 maps a persona+character to its wire summary.
func 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(),
}
}
// savePersonaYAML persists p to the persona library as <id>.yaml so it survives
// a restart (wire.go loads this directory). The id is sanitized so user input
// cannot traverse out of the library directory.
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 keeps only URL/path-safe characters so a persona id cannot escape
// the persona directory.
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()
}