单二进制三角色 + Dock 适配器 + Swarm stack 达到可部署态;mgr1 实测订阅经 central-proxy bootstrap,探活 alive=41/52。 Co-authored-by: Cursor <cursoragent@cursor.com>
160 lines
5.5 KiB
Go
160 lines
5.5 KiB
Go
// 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)
|
||
}
|