package gateway import ( "encoding/json" "errors" "net/http" "os" "strings" apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors" "git.yeij.top/AskaEth/Cyrene_v2/internal/user" ) // 本文件拥有主人档案的读写(裁决 10.4.5),从 admin_write_personas_users.go 拆出 // (AGENTS §10 文件 ≤500 行)。 // --- 主人档案(裁决 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)) }