onesvm-browser-server/server/internal/gateway/adminhandlers.go
chii eb972dfa93 feat: 落地 browser-server 控制面并打通 mgr1 海外订阅
单二进制三角色 + Dock 适配器 + Swarm stack 达到可部署态;mgr1 实测订阅经 central-proxy bootstrap,探活 alive=41/52。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-02 15:05:12 +08:00

160 lines
5.5 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.

// adminhandlers.go:admin 面(同端口,X-Service-Token == BROWSER_SERVER_ADMIN_TOKEN)。
// POST /admin/keys(签发,明文只回一次)、DELETE /admin/keys/{id}(吊销即时)、
// GET /admin/keys(列表只给 prefix/status 不给 hash)。
package gateway
import (
"encoding/json"
"net/http"
"strconv"
"strings"
"time"
)
// adminIssueReq 签发请求。
type adminIssueReq struct {
ConsumerName string `json:"consumer_name"`
Contact string `json:"contact,omitempty"`
Scopes []string `json:"scopes,omitempty"`
RPM int `json:"rpm,omitempty"`
DailyQuota int `json:"daily,omitempty"`
MonthlyQuota int `json:"monthly,omitempty"`
ConcurrentSessions int `json:"concurrent,omitempty"`
ExpiresAt string `json:"expires_at,omitempty"` // RFC3339,可空
}
// adminIssueResp 签发响应(明文只回一次)。
type adminIssueResp struct {
OK bool `json:"ok"`
KeyID int64 `json:"key_id"`
ConsumerID int64 `json:"consumer_id"`
Key string `json:"key"` // 明文仅此一次
Prefix string `json:"prefix"`
Note string `json:"note,omitempty"` // 配额口径声明(ITER-3 DECL-1)
}
// handleAdminIssue POST /admin/keys。
func (s *Server) handleAdminIssue(w http.ResponseWriter, r *http.Request) {
var req adminIssueReq
if err := jsonDecode(r, &req); err != nil {
s.adminWriteErr(w, "请求体非法 JSON: "+err.Error(), http.StatusBadRequest)
return
}
req.ConsumerName = strings.TrimSpace(req.ConsumerName)
if req.ConsumerName == "" {
s.adminWriteErr(w, "consumer_name 必填", http.StatusBadRequest)
return
}
if req.RPM <= 0 {
req.RPM = 60
}
if req.DailyQuota <= 0 {
req.DailyQuota = 1000
}
if req.MonthlyQuota <= 0 {
req.MonthlyQuota = 20000
}
if req.ConcurrentSessions <= 0 {
req.ConcurrentSessions = 2
}
var expires *time.Time
if req.ExpiresAt != "" {
t, err := time.Parse(time.RFC3339, req.ExpiresAt)
if err != nil {
s.adminWriteErr(w, "expires_at 须为 RFC3339", http.StatusBadRequest)
return
}
expires = &t
}
// 消费者主体:不存在即建(幂等名)
cid, err := s.ensureConsumer(req.ConsumerName, req.Contact)
if err != nil {
s.adminWriteErr(w, err.Error(), http.StatusInternalServerError)
return
}
plaintext, keyID, err := s.deps.Verifier.Issue(cid, req.ConsumerName, req.Scopes,
req.RPM, req.DailyQuota, req.MonthlyQuota, req.ConcurrentSessions, expires)
if err != nil {
s.adminWriteErr(w, "签发失败: "+err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(adminIssueResp{
OK: true, KeyID: keyID, ConsumerID: cid, Key: plaintext, Prefix: plaintext[:10],
// ITER-3 DECL-1:月配额首版未生效(仅日窗),响应显式声明口径。
Note: "monthly_quota 首版未生效(仅日窗)",
})
}
// handleAdminRevoke DELETE /admin/keys/{id}(吊销即时)。
func (s *Server) handleAdminRevoke(w http.ResponseWriter, r *http.Request) {
idStr := r.PathValue("id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
s.adminWriteErr(w, "key id 非法", http.StatusBadRequest)
return
}
if err := s.deps.DB.SetKeyStatus(id, "revoked"); err != nil {
if err == errNotFound() {
s.adminWriteErr(w, "key 不存在", http.StatusNotFound)
return
}
s.adminWriteErr(w, "吊销失败: "+err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "key_id": id, "status": "revoked"})
}
// adminKeyItem 列表项(只给 prefix/status,不给 hash/salt)。
type adminKeyItem struct {
ID int64 `json:"id"`
ConsumerID int64 `json:"consumer_id"`
Prefix string `json:"prefix"`
Name string `json:"name"`
Scopes string `json:"scopes"`
RPM int `json:"rpm"`
Daily int `json:"daily_quota"`
Monthly int `json:"monthly_quota"`
Status string `json:"status"`
CreatedAt string `json:"created_at"`
}
// handleAdminList GET /admin/keys。
func (s *Server) handleAdminList(w http.ResponseWriter, _ *http.Request) {
keys, err := s.deps.Glue.KeysAll()
if err != nil {
s.adminWriteErr(w, err.Error(), http.StatusInternalServerError)
return
}
items := make([]adminKeyItem, 0, len(keys))
for _, k := range keys {
items = append(items, adminKeyItem{
ID: k.ID, ConsumerID: k.ConsumerID, Prefix: k.Prefix, Name: k.Name,
Scopes: strings.Join(k.Scopes, ","), RPM: k.RPM,
Daily: k.DailyQuota, Monthly: k.MonthlyQuota,
Status: k.Status, CreatedAt: k.CreatedAt.Format(time.RFC3339),
})
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "keys": items})
}
// adminWriteErr admin 错误输出(简单 JSON 对象,非消费信封)。
func (s *Server) adminWriteErr(w http.ResponseWriter, msg string, status int) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(map[string]any{"ok": false, "error": msg})
}
// errNotFound store.ErrNotFound 镜像(避免 adminhandlers 直引 store 类型判断冗长)。
func errNotFound() error { return storeErrNotFound }
// ensureConsumer 按名取消费者 id,不存在即建。
func (s *Server) ensureConsumer(name, contact string) (int64, error) {
if cid, err := s.deps.Glue.ConsumerByName(name); err == nil {
return cid, nil
}
return s.deps.DB.CreateConsumer(name, contact)
}