Files
zhanlu_proxy/internal/server/server.go
T
m1saka fa9640d919
build / build (push) Successful in 2m34s
Add token usage stats and consolidate credentials in SQLite
- New internal/stats (types) and internal/store (SQLite owner: requests + credentials tables, WAL); store implements stats.Recorder.
- Stream (SSE tee) and non-stream chat paths parse upstream usage incl. cached_tokens and record per-request; add /api/stats, /api/stats/reset, /admin/stats HTML with cache hit rate.
- Drop credentials.json: remove auth file I/O and ZHANLU_CREDENTIALS_FILE; credential precedence is env vars > db row.
2026-08-19 15:52:00 +08:00

1493 lines
57 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 server
import (
"bufio"
"context"
crand "crypto/rand"
"crypto/subtle"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"html/template"
"io"
"math/rand"
"net/http"
"net/url"
"strings"
"time"
"git.misaka.ren/M1saka/zhanlu_proxy/internal/auth"
"git.misaka.ren/M1saka/zhanlu_proxy/internal/config"
"git.misaka.ren/M1saka/zhanlu_proxy/internal/openai"
"git.misaka.ren/M1saka/zhanlu_proxy/internal/sign"
"git.misaka.ren/M1saka/zhanlu_proxy/internal/stats"
"git.misaka.ren/M1saka/zhanlu_proxy/internal/store"
"git.misaka.ren/M1saka/zhanlu_proxy/internal/zhanlu"
)
type Server struct {
cfg config.Config
mux *http.ServeMux
loginSession string
st *store.Store
statsEnabled bool
}
func New(cfg config.Config, st *store.Store) http.Handler {
s := &Server{cfg: cfg, mux: http.NewServeMux(), st: st, statsEnabled: !cfg.StatsDisabled}
if cfg.LoginPassword != "" {
s.loginSession = randomSessionToken()
}
s.routes()
return s.mux
}
func (s *Server) routes() {
s.mux.HandleFunc("GET /", s.index)
s.mux.HandleFunc("GET /healthz", s.healthz)
s.mux.HandleFunc("GET /login", s.loginPage)
s.mux.HandleFunc("GET /admin/login", s.adminLoginPage)
s.mux.HandleFunc("POST /api/login", s.passwordLogin)
s.mux.HandleFunc("POST /api/logout", s.passwordLogout)
s.mux.HandleFunc("GET /auth/start", s.withLoginSession(s.startSSO))
s.mux.HandleFunc("GET /auth/callback", s.withLoginSession(s.ssoCallback))
s.mux.HandleFunc("POST /api/auth/code", s.withLoginSession(s.requestPhoneCode))
s.mux.HandleFunc("POST /api/auth/login", s.withLoginSession(s.loginWithPhoneCode))
s.mux.HandleFunc("GET /api/credentials", s.withLoginSession(s.getCredentials))
s.mux.HandleFunc("POST /api/credentials", s.withLoginSession(s.saveCredentials))
s.mux.HandleFunc("POST /api/sso/exchange", s.withLoginSession(s.exchangeSSOCode))
s.mux.HandleFunc("GET /api/stats", s.withLoginSession(s.getStats))
s.mux.HandleFunc("POST /api/stats/reset", s.withLoginSession(s.resetStats))
s.mux.HandleFunc("GET /admin/stats", s.withLoginSession(s.statsPage))
s.mux.HandleFunc("GET /v1/models", s.withAPIKey(s.models))
s.mux.HandleFunc("POST /v1/chat/completions", s.withAPIKey(s.chatCompletions))
}
func (s *Server) healthz(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func (s *Server) index(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
http.Redirect(w, r, "/login", http.StatusFound)
}
func (s *Server) withAPIKey(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if s.cfg.OpenAIAPIKey != "" {
got := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
if got != s.cfg.OpenAIAPIKey {
writeOpenAIError(w, http.StatusUnauthorized, "invalid api key", "auth_error", "invalid_api_key")
return
}
}
next(w, r)
}
}
func (s *Server) loginPage(w http.ResponseWriter, r *http.Request) {
if s.hasLoginSession(r) {
http.Redirect(w, r, "/admin/login", http.StatusFound)
return
}
if s.cfg.LoginPassword == "" {
http.Redirect(w, r, "/admin/login", http.StatusFound)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_ = loginTemplate.Execute(w, map[string]any{"DBPath": s.cfg.DBPath, "SSOBaseURL": s.cfg.SSOBaseURL, "PasswordEnabled": true, "AdminMode": false})
}
func (s *Server) adminLoginPage(w http.ResponseWriter, r *http.Request) {
if !s.hasLoginSession(r) {
http.Redirect(w, r, "/login", http.StatusFound)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_ = loginTemplate.Execute(w, map[string]any{"DBPath": s.cfg.DBPath, "SSOBaseURL": s.cfg.SSOBaseURL, "PasswordEnabled": s.cfg.LoginPassword != "", "AdminMode": true})
}
func (s *Server) passwordLogin(w http.ResponseWriter, r *http.Request) {
var in struct {
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": err.Error()})
return
}
if !s.validLoginPassword(in.Password) {
writeJSON(w, http.StatusUnauthorized, map[string]any{"ok": false, "error": "登录密码无效"})
return
}
if s.cfg.LoginPassword != "" {
http.SetCookie(w, &http.Cookie{Name: loginSessionCookieName, Value: s.loginSession, Path: "/", HttpOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: int((24 * time.Hour).Seconds())})
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func (s *Server) passwordLogout(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{Name: loginSessionCookieName, Value: "", Path: "/", HttpOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: -1})
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func (s *Server) startSSO(w http.ResponseWriter, r *http.Request) {
ssoBaseURL := strings.TrimSpace(r.URL.Query().Get("sso_base_url"))
if ssoBaseURL == "" {
ssoBaseURL = s.cfg.SSOBaseURL
}
if err := validateHTTPBaseURL(ssoBaseURL); err != nil {
s.renderLoginResult(w, false, err.Error())
return
}
callback := callbackURL(r)
loginURL := strings.TrimRight(ssoBaseURL, "/") + "/moss/micrologin/#/sso/getauthorizecode" +
"?redirectUri=" + url.QueryEscape(callback) +
"&sourceid=7192038465&moss_sso_account=1"
http.Redirect(w, r, loginURL, http.StatusFound)
}
func (s *Server) ssoCallback(w http.ResponseWriter, r *http.Request) {
code := strings.TrimSpace(r.URL.Query().Get("code"))
if code == "" {
s.renderLoginResult(w, false, "回调中没有授权 code,请重新登录")
return
}
profile, err := auth.ExchangeCode(s.upstreamHTTPClient(), s.cfg.SSOExchangeURL, code, s.cfg.TokenDecryptKey)
if err != nil {
s.renderLoginResult(w, false, err.Error())
return
}
creds, err := s.credentialsFromProfile(r.Context(), profile)
if err != nil {
s.renderLoginResult(w, false, err.Error())
return
}
if err := s.st.SaveCredentials(creds); err != nil {
s.renderLoginResult(w, false, err.Error())
return
}
s.cfg.Credentials = creds
s.renderLoginResult(w, true, "凭据已保存,可以关闭此页面并使用 OpenAI 兼容接口")
}
// credentialsFromProfile provisions a model API key for the given profile and
// returns full credentials.
func (s *Server) credentialsFromProfile(ctx context.Context, profile auth.Profile) (auth.Credentials, error) {
client, err := s.zhanluClient()
if err != nil {
return auth.Credentials{}, err
}
apiKey, err := client.ProvisionAPIKey(ctx, profile.Email, profile.Organization, profile.Team)
if err != nil {
return auth.Credentials{}, err
}
return auth.Credentials{
APIKey: apiKey,
ModelBaseURL: s.cfg.MobileModelBaseURL,
Email: profile.Email,
Organization: profile.Organization,
Team: profile.Team,
}, nil
}
func (s *Server) renderLoginResult(w http.ResponseWriter, success bool, message string) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusOK)
_ = loginResultTemplate.Execute(w, map[string]any{"Success": success, "Message": message})
}
func callbackURL(r *http.Request) string {
host := r.Host
if colon := strings.LastIndex(host, ":"); colon >= 0 {
host = "127.0.0.1" + host[colon:]
} else {
host = "127.0.0.1"
}
return "http://" + host + "/auth/callback"
}
func validateHTTPBaseURL(raw string) error {
u, err := url.Parse(raw)
if err != nil || u.Scheme == "" || u.Host == "" {
return fmt.Errorf("SSO Base URL 无效:%s", raw)
}
if u.Scheme != "http" && u.Scheme != "https" {
return fmt.Errorf("SSO Base URL 只支持 http/https%s", raw)
}
return nil
}
func (s *Server) requestPhoneCode(w http.ResponseWriter, r *http.Request) {
var in struct {
Telephone string `json:"telephone"`
}
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": err.Error()})
return
}
telephone := strings.TrimSpace(in.Telephone)
if !validChineseMobile(telephone) {
writeJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": "请输入有效的 11 位手机号"})
return
}
secret := randomSecret16()
pub, err := auth.ParsePublicKey(s.cfg.PhonePublicKeyPEM)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]any{"ok": false, "error": err.Error()})
return
}
telephoneCipher, err := auth.EncryptAuthorization(pub, telephone)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]any{"ok": false, "error": err.Error()})
return
}
secretCipher, err := auth.EncryptAuthorization(pub, secret)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]any{"ok": false, "error": err.Error()})
return
}
endpoint := strings.TrimRight(s.cfg.MobileLoginBaseURL, "/") + "/api/query/acepilot-h5/manager/code/getAuthCode"
var out phoneAPIResponse
if err := s.postPhoneAPI(endpoint, map[string]string{"telephone": telephoneCipher, "secret": secretCipher}, &out); err != nil {
writeJSON(w, http.StatusBadGateway, map[string]any{"ok": false, "error": err.Error()})
return
}
if out.State != "OK" {
writeJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": firstNonEmpty(out.ErrorMessage, "验证码发送失败")})
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "secret": secret})
}
func (s *Server) loginWithPhoneCode(w http.ResponseWriter, r *http.Request) {
var in struct {
Telephone string `json:"telephone"`
Code string `json:"code"`
Secret string `json:"secret"`
}
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": err.Error()})
return
}
telephone := strings.TrimSpace(in.Telephone)
code := strings.TrimSpace(in.Code)
secret := strings.TrimSpace(in.Secret)
if !validChineseMobile(telephone) || len(code) != 6 || len(secret) != 16 {
writeJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": "手机号、验证码或登录 secret 无效"})
return
}
pub, err := auth.ParsePublicKey(s.cfg.PhonePublicKeyPEM)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]any{"ok": false, "error": err.Error()})
return
}
telephoneCipher, err := auth.EncryptAuthorization(pub, telephone)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]any{"ok": false, "error": err.Error()})
return
}
endpoint := strings.TrimRight(s.cfg.MobileLoginBaseURL, "/") + "/api/query/acepilot-h5/manager/code/checkCode"
var out phoneAPIResponse
if err := s.postPhoneAPI(endpoint, map[string]string{"telephone": telephoneCipher, "code": code}, &out); err != nil {
writeJSON(w, http.StatusBadGateway, map[string]any{"ok": false, "error": err.Error()})
return
}
if !out.Body.Result {
writeJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": firstNonEmpty(out.ErrorMessage, "验证码校验失败")})
return
}
creds, err := decryptPhoneCredentials(out.Body, secret)
if err != nil {
writeJSON(w, http.StatusBadGateway, map[string]any{"ok": false, "error": err.Error()})
return
}
creds.ModelBaseURL = s.cfg.MobileModelBaseURL
creds, err = s.provisionCredentials(r.Context(), creds)
if err != nil {
writeJSON(w, http.StatusBadGateway, map[string]any{"ok": false, "error": err.Error()})
return
}
if err := s.st.SaveCredentials(creds); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": err.Error()})
return
}
s.cfg.Credentials = creds
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "path": s.cfg.DBPath, "access_key": mask(creds.AccessKey)})
}
// provisionCredentials logs the AK/SK/token into the Zhanlu gateway to obtain
// the user profile, then provisions the model API key used for chat.
func (s *Server) provisionCredentials(ctx context.Context, creds auth.Credentials) (auth.Credentials, error) {
client, err := s.zhanluClient()
if err != nil {
return creds, err
}
profile, err := client.LoginProfile(ctx, creds)
if err != nil {
return creds, err
}
creds.Email = profile.Email
creds.Organization = profile.Organization
creds.Team = profile.Team
apiKey, err := client.ProvisionAPIKey(ctx, profile.Email, profile.Organization, profile.Team)
if err != nil {
return creds, err
}
creds.APIKey = apiKey
return creds, nil
}
func (s *Server) validLoginPassword(password string) bool {
if s.cfg.LoginPassword == "" {
return true
}
return subtle.ConstantTimeCompare([]byte(password), []byte(s.cfg.LoginPassword)) == 1
}
func (s *Server) withLoginSession(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !s.hasLoginSession(r) {
writeJSON(w, http.StatusUnauthorized, map[string]any{"ok": false, "error": "请先登录管理页面"})
return
}
next(w, r)
}
}
func (s *Server) hasLoginSession(r *http.Request) bool {
if s.cfg.LoginPassword == "" {
return true
}
c, err := r.Cookie(loginSessionCookieName)
if err != nil {
return false
}
return subtle.ConstantTimeCompare([]byte(c.Value), []byte(s.loginSession)) == 1
}
func randomSessionToken() string {
b := make([]byte, 32)
if _, err := crand.Read(b); err != nil {
return randomRequestID()
}
return hex.EncodeToString(b)
}
const loginSessionCookieName = "zhanlu_proxy_session"
type phoneAPIResponse struct {
State string `json:"state"`
ErrorMessage string `json:"errorMessage"`
Body struct {
Result bool `json:"result"`
AK string `json:"ak"`
SK string `json:"sk"`
License string `json:"license"`
} `json:"body"`
}
func (s *Server) postPhoneAPI(endpoint string, payload map[string]string, out *phoneAPIResponse) error {
body, err := json.Marshal(payload)
if err != nil {
return err
}
req, err := http.NewRequest(http.MethodPost, endpoint, strings.NewReader(string(body)))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("plugin_type", "zhanlu_ide")
req.Header.Set("plugin_version", s.cfg.PluginVersion)
req.Header.Set("request", randomRequestID())
resp, err := s.upstreamHTTPClient().Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
b, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
return fmt.Errorf("phone auth upstream returned %d: %s", resp.StatusCode, string(b))
}
return json.NewDecoder(resp.Body).Decode(out)
}
func decryptPhoneCredentials(body struct {
Result bool `json:"result"`
AK string `json:"ak"`
SK string `json:"sk"`
License string `json:"license"`
}, secret string) (auth.Credentials, error) {
// z4A semantics: try AES-ECB decrypt with secret, fall back to plaintext.
ak := auth.DecryptCredentialOrRaw(strings.TrimSpace(body.AK), secret)
sk := auth.DecryptCredentialOrRaw(strings.TrimSpace(body.SK), secret)
token := auth.DecryptCredentialOrRaw(strings.TrimSpace(body.License), secret)
if ak == "" || sk == "" || token == "" {
return auth.Credentials{}, fmt.Errorf("decrypt phone credentials: missing ak/sk/license")
}
return auth.Credentials{AccessKey: ak, SecretKey: sk, Token: token}, nil
}
func validChineseMobile(s string) bool {
if len(s) != 11 || s[0] != '1' || s[1] < '3' || s[1] > '9' {
return false
}
for _, ch := range s {
if ch < '0' || ch > '9' {
return false
}
}
return true
}
func randomSecret16() string {
const letters = "0123456789abcdef"
r := rand.New(rand.NewSource(time.Now().UnixNano()))
b := make([]byte, 16)
for i := range b {
b[i] = letters[r.Intn(len(letters))]
}
return string(b)
}
func randomRequestID() string {
return fmt.Sprintf("%d-%d", time.Now().UnixNano(), rand.Int63())
}
func (s *Server) getCredentials(w http.ResponseWriter, r *http.Request) {
c, err := s.st.LoadCredentials()
if err != nil || (c.Validate() != nil && !c.HasAPIKey()) {
writeJSON(w, http.StatusOK, map[string]any{"configured": false, "path": s.cfg.DBPath})
return
}
writeJSON(w, http.StatusOK, map[string]any{
"configured": true,
"path": s.cfg.DBPath,
"access_key": mask(c.AccessKey),
"has_api_key": c.APIKey != "",
"model_base": firstNonEmpty(c.ModelBaseURL, c.BaseURL),
"email": c.Email,
"saved_at": c.SavedAt,
})
}
func (s *Server) saveCredentials(w http.ResponseWriter, r *http.Request) {
var c auth.Credentials
if err := json.NewDecoder(r.Body).Decode(&c); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": err.Error()})
return
}
if c.Validate() == nil && !c.HasAPIKey() {
c.ModelBaseURL = s.cfg.MobileModelBaseURL
provisioned, err := s.provisionCredentials(r.Context(), c)
if err != nil {
writeJSON(w, http.StatusBadGateway, map[string]any{"ok": false, "error": err.Error()})
return
}
c = provisioned
}
if err := s.st.SaveCredentials(c); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": err.Error()})
return
}
s.cfg.Credentials = c
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "path": s.cfg.DBPath})
}
func (s *Server) exchangeSSOCode(w http.ResponseWriter, r *http.Request) {
var in struct {
Code string `json:"code"`
Endpoint string `json:"endpoint"`
DecryptKey string `json:"decrypt_key"`
BaseURL string `json:"base_url"`
}
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": err.Error()})
return
}
endpoint := firstNonEmpty(in.Endpoint, s.cfg.SSOExchangeURL)
decryptKey := firstNonEmpty(in.DecryptKey, s.cfg.TokenDecryptKey)
profile, err := auth.ExchangeCode(s.upstreamHTTPClient(), endpoint, in.Code, decryptKey)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": err.Error()})
return
}
creds, err := s.credentialsFromProfile(r.Context(), profile)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": err.Error()})
return
}
if in.BaseURL != "" {
creds.ModelBaseURL = in.BaseURL
}
if err := s.st.SaveCredentials(creds); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": err.Error()})
return
}
s.cfg.Credentials = creds
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "path": s.cfg.DBPath})
}
func (s *Server) getStats(w http.ResponseWriter, r *http.Request) {
if s.st == nil {
writeJSON(w, http.StatusOK, map[string]any{"enabled": false})
return
}
q := stats.Query{
Model: r.URL.Query().Get("model"),
Limit: parseLimit(r.URL.Query().Get("limit")),
}
if v := r.URL.Query().Get("since"); v != "" {
if t, err := time.Parse(time.RFC3339, v); err == nil {
q.Since = t
}
}
if v := r.URL.Query().Get("until"); v != "" {
if t, err := time.Parse(time.RFC3339, v); err == nil {
q.Until = t
}
}
summary, err := s.st.Stats(q)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]any{"ok": false, "error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]any{"enabled": s.statsEnabled, "stats": summary})
}
func (s *Server) resetStats(w http.ResponseWriter, r *http.Request) {
if s.st == nil {
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "enabled": false})
return
}
if err := s.st.Reset(); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]any{"ok": false, "error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func (s *Server) statsPage(w http.ResponseWriter, r *http.Request) {
var summary *stats.Summary
enabled := s.statsEnabled
if s.st != nil {
if sm, err := s.st.Stats(stats.Query{}); err == nil {
summary = sm
}
}
if summary == nil {
summary = &stats.Summary{}
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_ = statsTemplate.Execute(w, map[string]any{
"Enabled": enabled,
"Stats": summary,
})
}
func parseLimit(s string) int {
n := 0
for _, c := range s {
if c < '0' || c > '9' {
return 0
}
n = n*10 + int(c-'0')
if n > 5000 {
return 5000
}
}
return n
}
func (s *Server) models(w http.ResponseWriter, r *http.Request) {
modelIDs := []string{}
if creds, err := s.currentCredentials(); err == nil && creds.HasAPIKey() {
if client, cerr := s.zhanluClient(); cerr == nil {
if fetched, merr := client.Models(r.Context(), creds.APIKey); merr == nil && len(fetched) > 0 {
modelIDs = fetched
}
}
}
data := make([]map[string]any, 0, len(modelIDs))
for _, model := range modelIDs {
data = append(data, map[string]any{"id": model, "object": "model", "created": 0, "owned_by": "zhanlu"})
}
writeJSON(w, http.StatusOK, map[string]any{"object": "list", "data": data})
}
func (s *Server) chatCompletions(w http.ResponseWriter, r *http.Request) {
creds, err := s.currentCredentials()
if err != nil {
writeOpenAIError(w, http.StatusUnauthorized, "zhanlu credentials are not configured; open /login first", "auth_error", "missing_credentials")
return
}
var req openai.ChatCompletionRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeOpenAIError(w, http.StatusBadRequest, err.Error(), "invalid_request_error", "bad_json")
return
}
if req.Model == "" {
req.Model = "zhanlu/auto"
}
start := time.Now()
clientWantsStream := req.Stream
req.Stream = true
body, err := req.MarshalForUpstream()
if err != nil {
writeOpenAIError(w, http.StatusBadRequest, err.Error(), "invalid_request_error", "bad_body")
return
}
if !creds.HasAPIKey() {
creds, err = s.provisionCredentials(r.Context(), creds)
if err != nil {
writeOpenAIError(w, http.StatusBadGateway, "zhanlu api key provisioning failed: "+err.Error(), "auth_error", "zhanlu_provision_failed")
return
}
s.cfg.Credentials = creds
_ = s.st.SaveCredentials(creds)
}
modelBaseURL := firstNonEmpty(creds.ModelBaseURL, s.cfg.MobileModelBaseURL)
client, err := s.zhanluClientWithBase(modelBaseURL)
if err != nil {
writeOpenAIError(w, http.StatusInternalServerError, err.Error(), "sign_error", "signer_init_failed")
return
}
resp, err := client.ChatCompletions(r.Context(), creds.APIKey, body)
if err != nil {
msg := "zhanlu upstream request failed"
if s.cfg.Debug {
msg = redactSensitive(err.Error())
}
writeOpenAIError(w, http.StatusBadGateway, msg, "upstream_error", "zhanlu_request_failed")
s.record(req.Model, clientWantsStream, nil, "upstream_error", start)
return
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
b, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
msg := fmt.Sprintf("zhanlu upstream returned %d", resp.StatusCode)
if s.cfg.Debug && len(b) > 0 {
msg += ": " + string(b)
}
writeOpenAIError(w, http.StatusBadGateway, msg, "upstream_error", "zhanlu_bad_status")
s.record(req.Model, clientWantsStream, nil, "upstream_error", start)
return
}
if clientWantsStream {
usage, status := s.proxyStream(w, resp)
s.record(req.Model, true, usage, status, start)
return
}
usage, status := s.aggregateStream(w, resp, req.Model)
s.record(req.Model, false, usage, status, start)
}
// record appends a usage observation to the stats store when collection is
// enabled. It never affects the response path; recording errors are ignored.
func (s *Server) record(model string, stream bool, usage any, status string, start time.Time) {
if !s.statsEnabled || s.st == nil {
return
}
_ = s.st.Record(stats.RecordFromUsage(model, stream, usage, status, start))
}
func (s *Server) proxyStream(w http.ResponseWriter, resp *http.Response) (any, string) {
w.Header().Set("Content-Type", "text/event-stream; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("X-Accel-Buffering", "no")
w.WriteHeader(http.StatusOK)
flusher, _ := w.(http.Flusher)
reader := bufio.NewReader(resp.Body)
var usage any
status := "success"
for {
line, err := reader.ReadString('\n')
if line != "" {
if _, werr := io.WriteString(w, line); werr != nil {
// client disconnected mid-stream; stop forwarding
status = "upstream_error"
break
}
if flusher != nil {
flusher.Flush()
}
if payload := sseDataPayload(line); payload != "" && payload != "[DONE]" {
var evt struct {
State string `json:"state"`
ErrorMessage string `json:"errorMessage"`
Usage any `json:"usage"`
}
if json.Unmarshal([]byte(payload), &evt) == nil {
if evt.State == "ERROR" {
status = "upstream_error"
}
if evt.Usage != nil {
usage = evt.Usage
}
}
}
}
if err != nil {
if err == io.EOF {
break
}
// upstream read error: surface an error event to the client, mirroring
// the previous io.Copy behavior, then mark the request as failed.
b, _ := json.Marshal(map[string]any{"error": map[string]any{"message": err.Error(), "type": "upstream_error", "code": "zhanlu_stream_error"}})
_, _ = fmt.Fprintf(w, "data: %s\n\n", b)
if flusher != nil {
flusher.Flush()
}
status = "upstream_error"
break
}
}
return usage, status
}
// sseDataPayload returns the payload following a "data:" SSE line, or "" if the
// line is not a data line. Mirrors the parsing in forEachSSEChunk.
func sseDataPayload(line string) string {
trimmed := strings.TrimSpace(line)
if !strings.HasPrefix(trimmed, "data:") {
return ""
}
return strings.TrimSpace(strings.TrimPrefix(trimmed, "data:"))
}
func (s *Server) aggregateStream(w http.ResponseWriter, resp *http.Response, model string) (any, string) {
var content, reasoning, id string
var usage any
finishReason := "stop"
type toolCall struct {
ID string `json:"id"`
Type string `json:"type"`
Function struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
} `json:"function"`
}
toolCalls := map[int]*toolCall{}
err := forEachSSEChunk(resp.Body, func(chunk []byte) error {
var event struct {
ID string `json:"id"`
Choices []struct {
Delta struct {
Content string `json:"content"`
ReasoningContent string `json:"reasoning_content"`
Reasoning string `json:"reasoning"`
ToolCalls []struct {
Index int `json:"index"`
ID string `json:"id"`
Type string `json:"type"`
Function struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
} `json:"function"`
} `json:"tool_calls"`
} `json:"delta"`
FinishReason *string `json:"finish_reason"`
} `json:"choices"`
Usage any `json:"usage"`
}
if err := json.Unmarshal(chunk, &event); err != nil {
return err
}
if event.ID != "" {
id = event.ID
}
if event.Usage != nil {
usage = event.Usage
}
if len(event.Choices) > 0 {
content += event.Choices[0].Delta.Content
reasoning += event.Choices[0].Delta.ReasoningContent + event.Choices[0].Delta.Reasoning
for _, part := range event.Choices[0].Delta.ToolCalls {
call := toolCalls[part.Index]
if call == nil {
call = &toolCall{Type: "function"}
toolCalls[part.Index] = call
}
if part.ID != "" {
call.ID = part.ID
}
if part.Type != "" {
call.Type = part.Type
}
call.Function.Name += part.Function.Name
call.Function.Arguments += part.Function.Arguments
}
if event.Choices[0].FinishReason != nil {
finishReason = *event.Choices[0].FinishReason
}
}
return nil
})
if err != nil {
writeOpenAIError(w, http.StatusBadGateway, err.Error(), "upstream_error", "zhanlu_stream_error")
return nil, "upstream_error"
}
if id == "" {
id = "chatcmpl-" + randomRequestID()
}
message := map[string]any{"role": "assistant", "content": content}
if len(toolCalls) > 0 {
ordered := make([]*toolCall, 0, len(toolCalls))
for i := 0; i < len(toolCalls); i++ {
if call := toolCalls[i]; call != nil {
ordered = append(ordered, call)
}
}
message["tool_calls"] = ordered
if content == "" {
message["content"] = nil
}
}
if reasoning != "" {
message["reasoning_content"] = reasoning
}
result := map[string]any{
"id": id, "object": "chat.completion", "created": time.Now().Unix(), "model": model,
"choices": []map[string]any{{"index": 0, "message": message, "finish_reason": finishReason}},
}
if usage != nil {
result["usage"] = usage
}
writeJSON(w, http.StatusOK, result)
return usage, "success"
}
// forEachSSEChunk feeds each non-empty data: payload to fn, skipping keep-alive
// lines and the [DONE] sentinel. The v1.4.2 gateway streams plain OpenAI SSE.
func forEachSSEChunk(r io.Reader, fn func([]byte) error) error {
scanner := bufio.NewScanner(r)
scanner.Buffer(make([]byte, 64*1024), 2*1024*1024)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || !strings.HasPrefix(line, "data:") {
continue
}
payload := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
if payload == "" || payload == "[DONE]" {
continue
}
var upstreamError map[string]any
if json.Unmarshal([]byte(payload), &upstreamError) == nil && upstreamError["state"] == "ERROR" {
return fmt.Errorf("zhanlu upstream error: %v", upstreamError["errorMessage"])
}
if !json.Valid([]byte(payload)) {
return errors.New("zhanlu stream contained invalid JSON")
}
if err := fn([]byte(payload)); err != nil {
return err
}
}
return scanner.Err()
}
func (s *Server) currentCredentials() (auth.Credentials, error) {
if s.cfg.Credentials.Validate() == nil || s.cfg.Credentials.HasAPIKey() {
return s.cfg.Credentials, nil
}
c, err := s.st.LoadCredentials()
if err != nil {
return auth.Credentials{}, err
}
if c.Validate() != nil && !c.HasAPIKey() {
return auth.Credentials{}, c.Validate()
}
return c, nil
}
func (s *Server) zhanluClient() (*zhanlu.Client, error) {
return s.zhanluClientWithBase(s.cfg.MobileModelBaseURL)
}
// upstreamHTTPClient returns an HTTP/1.1-only client. The Zhanlu gateway drops
// connections that negotiate HTTP/2 (EOF on ALPN handshake).
func (s *Server) upstreamHTTPClient() *http.Client {
return &http.Client{
Timeout: s.cfg.UpstreamTimeout,
Transport: &http.Transport{ForceAttemptHTTP2: false},
}
}
func (s *Server) zhanluClientWithBase(modelBaseURL string) (*zhanlu.Client, error) {
signer, err := s.signer()
if err != nil {
return nil, err
}
return zhanlu.NewClient(s.cfg.MobileLoginBaseURL, modelBaseURL, s.cfg.UpstreamPath, s.cfg.PluginVersion, s.cfg.SM2PrivateKey, signer, s.cfg.UpstreamTimeout), nil
}
func (s *Server) signer() (sign.Signer, error) {
if strings.TrimSpace(s.cfg.PublicKeyPEM) == "" {
return sign.Signer{Encryptor: func(text string) (string, error) {
return "", errors.New("ZHANLU_PUBLIC_KEY_PEM is required for signed upstream requests")
}}, nil
}
pub, err := auth.ParsePublicKey(s.cfg.PublicKeyPEM)
if err != nil {
return sign.Signer{}, err
}
return sign.Signer{PublicKey: pub}, nil
}
func writeJSON(w http.ResponseWriter, code int, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(v)
}
func writeOpenAIError(w http.ResponseWriter, code int, message, typ, errCode string) {
writeJSON(w, code, openai.ErrorResponse{Error: openai.ErrorBody{Message: message, Type: typ, Param: nil, Code: errCode}})
}
func mask(s string) string {
if len(s) <= 8 {
return "****"
}
return s[:4] + "****" + s[len(s)-4:]
}
func firstNonEmpty(a, b string) string {
if a != "" {
return a
}
return b
}
func redactSensitive(s string) string {
for _, key := range []string{"AccessKey", "authorization", "Signature"} {
s = redactQueryValue(s, key)
}
return s
}
func redactQueryValue(s, key string) string {
needle := key + "="
for {
start := strings.Index(s, needle)
if start < 0 {
return s
}
valueStart := start + len(needle)
valueEnd := len(s)
if amp := strings.Index(s[valueStart:], "&"); amp >= 0 {
valueEnd = valueStart + amp
}
s = s[:valueStart] + "<redacted>" + s[valueEnd:]
}
}
var loginTemplate = template.Must(template.New("login").Parse(`<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark">
<title>湛卢代理登录</title>
<style>
:root {
color-scheme: dark;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--bg: #0b1220;
--panel: rgba(15, 23, 42, .82);
--line: rgba(255, 255, 255, .12);
--line-strong: rgba(255, 255, 255, .2);
--accent-1: #3b82f6;
--accent-2: #8b5cf6;
--ink: #f8fafc;
--body: #b6c2d9;
--muted: #8ba0b8;
--ok: #34d399;
--err: #f87171;
--radius-lg: 24px;
--radius-md: 14px;
}
* { box-sizing: border-box; }
body {
margin: 0; min-height: 100vh;
display: grid; place-items: center;
padding: 24px 16px;
color: var(--ink);
background:
radial-gradient(1.5px 1.5px at 12% 20%, rgba(255,255,255,.22), transparent 55%),
radial-gradient(1.5px 1.5px at 78% 14%, rgba(255,255,255,.18), transparent 55%),
radial-gradient(1.5px 1.5px at 88% 68%, rgba(255,255,255,.16), transparent 55%),
radial-gradient(1.5px 1.5px at 26% 82%, rgba(255,255,255,.14), transparent 55%),
radial-gradient(1.5px 1.5px at 58% 92%, rgba(255,255,255,.12), transparent 55%),
linear-gradient(rgba(255,255,255,.022) 1px, transparent 1px),
linear-gradient(90deg, rgba(255,255,255,.022) 1px, transparent 1px),
radial-gradient(60rem 42rem at 12% -8%, rgba(59,130,246,.16), transparent 60%),
radial-gradient(50rem 36rem at 105% 110%, rgba(139,92,246,.14), transparent 60%),
linear-gradient(160deg, #0b1220 0%, #111a2e 55%, #0e1626 100%);
background-size: auto, auto, auto, auto, auto, 44px 44px, 44px 44px, auto, auto, auto;
animation: fade-in .5s ease both;
}
@keyframes fade-in { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
main {
width: min(820px, 100%);
display: grid; grid-template-columns: 1fr 1fr; gap: 28px; align-items: stretch;
}
.panel {
position: relative;
border-radius: var(--radius-lg);
padding: 1px;
background: linear-gradient(180deg, rgba(255,255,255,.2), rgba(255,255,255,.05) 38%, rgba(255,255,255,.09));
box-shadow: 0 24px 80px rgba(0,0,0,.42), inset 0 1px 0 rgba(255,255,255,.08);
}
.panel-inner {
height: 100%;
border-radius: calc(var(--radius-lg) - 1px);
background: var(--panel);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
}
.panel::before {
content: "";
position: absolute; inset: 0; border-radius: var(--radius-lg);
padding: 1px;
background: linear-gradient(180deg, rgba(255,255,255,.14), transparent 30%);
-webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
pointer-events: none;
}
.hero { padding: 34px 30px; display: flex; flex-direction: column; justify-content: space-between; gap: 32px; }
.eyebrow {
display: inline-flex; align-items: center; gap: 8px;
font-size: 12px; font-weight: 600; letter-spacing: .14em; text-transform: uppercase;
color: var(--muted);
}
.eyebrow::before {
content: ""; width: 22px; height: 1.5px;
background: linear-gradient(90deg, var(--accent-1), var(--accent-2));
}
h1 {
margin: 14px 0 0;
font-size: clamp(30px, 4.4vw, 46px);
font-weight: 800; letter-spacing: -0.035em; line-height: 1.08;
background: linear-gradient(92deg, #f8fafc 20%, #bfdbfe 62%, #c4b5fd 100%);
-webkit-background-clip: text; background-clip: text;
-webkit-text-fill-color: transparent; color: transparent;
}
h2 { margin: 0; font-size: 20px; font-weight: 700; letter-spacing: -0.01em; }
p { margin: 0; color: var(--body); line-height: 1.7; font-size: 15px; }
.hero p { max-width: 34ch; }
code {
font-family: "SF Mono", ui-monospace, "Cascadia Code", Consolas, monospace;
font-size: 12.5px; color: #bfdbfe; word-break: break-all;
}
.cred-path { padding: 12px 14px; border-radius: 12px; border: 1px solid var(--line); background: rgba(2,6,23,.5); }
.cred-path .label { display: block; font-size: 11.5px; letter-spacing: .08em; color: var(--muted); margin-bottom: 6px; }
.login-card { padding: 30px 28px; display: grid; gap: 16px; align-content: start; }
.login-card form { display: grid; gap: 18px; }
.login-card form .btn-primary { width: 100%; margin-top: 2px; }
.login-card p { font-size: 13.5px; }
label { display: grid; gap: 8px; font-size: 13.5px; font-weight: 500; color: #cbd5e1; }
input, textarea {
width: 100%;
border: 1px solid rgba(148,163,184,.3);
border-radius: var(--radius-md);
padding: 13px 14px;
background: rgba(2,6,23,.55);
color: var(--ink);
outline: none; font: inherit;
transition: border-color .18s ease, box-shadow .18s ease, background .18s ease;
}
input::placeholder { color: #64748b; }
input:hover { border-color: rgba(148,163,184,.5); }
input:focus, textarea:focus {
border-color: var(--accent-1);
box-shadow: 0 0 0 4px rgba(96,165,250,.16);
background: rgba(2,6,23,.7);
}
.row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.btn {
display: inline-flex; align-items: center; justify-content: center; gap: 8px;
border: 0; border-radius: var(--radius-md);
padding: 14px 16px;
font: inherit; font-weight: 700; font-size: 15px;
cursor: pointer; text-decoration: none; color: #fff;
transition: filter .15s ease, transform .06s ease, box-shadow .15s ease, background .15s ease, border-color .15s ease;
}
.btn:focus-visible { outline: 2px solid var(--accent-1); outline-offset: 2px; }
.btn-primary {
background: linear-gradient(135deg, var(--accent-1), var(--accent-2));
box-shadow: 0 10px 24px -10px rgba(99,102,241,.55);
}
.btn-primary:hover { filter: brightness(1.1); box-shadow: 0 12px 28px -10px rgba(99,102,241,.7); }
.btn-primary:active { transform: translateY(1px); }
.btn-primary:disabled { filter: saturate(.5) brightness(.8); cursor: not-allowed; box-shadow: none; }
.btn-ghost {
background: transparent;
border: 1px solid rgba(148,163,184,.35);
color: #bfdbfe; font-weight: 600;
}
.btn-ghost:hover { border-color: var(--accent-1); color: #dbeafe; background: rgba(96,165,250,.08); }
.btn-ghost:active { transform: translateY(1px); }
.btn-ghost:disabled { opacity: .55; cursor: not-allowed; }
.status {
min-height: 22px;
font-size: 13.5px; line-height: 1.6;
color: var(--muted);
display: flex; align-items: flex-start; gap: 7px;
}
.status::before {
content: ""; flex: none; width: 7px; height: 7px; border-radius: 50%;
margin-top: 6px;
background: currentColor;
box-shadow: 0 0 0 3px transparent;
}
.status[data-state="ok"] { color: var(--ok); }
.status[data-state="err"] { color: var(--err); }
.status[data-state="busy"] { color: #93c5fd; }
.status[data-state="busy"]::before { animation: pulse 1.1s ease-in-out infinite; }
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: .35; } }
.muted { font-size: 12.5px; color: var(--muted); line-height: 1.7; }
.muted a, .link { color: #93c5fd; text-decoration: none; }
.link:hover { text-decoration: underline; }
.logout { border: 0; background: none; padding: 0; font: inherit; font-size: 12.5px; color: #93c5fd; cursor: pointer; }
.logout:hover { text-decoration: underline; }
.spacer { flex: 1; }
@media (max-width: 760px) {
body { place-items: start center; padding-top: 20px; }
main { grid-template-columns: 1fr; gap: 18px; }
.row { grid-template-columns: 1fr; }
.hero { padding: 28px 24px; }
.login-card { padding: 26px 22px; }
}
@media (prefers-reduced-motion: reduce) {
body { animation: none; }
.status[data-state="busy"]::before { animation: none; }
}
</style>
</head>
<body>
<main>
<section class="panel hero">
<div>
<div class="eyebrow">Zhanlu Proxy</div>
<h1>湛卢代理登录</h1>
<p>输入手机号获取验证码,按插件默认的移动云登录接口换取凭据和模型 API Key。服务会保存凭据,后续 OpenAI 兼容接口自动使用。</p>
</div>
<div class="cred-path">
<span class="label">数据库位置</span>
<code>{{.DBPath}}</code>
</div>
</section>
<section class="panel login-card">
{{if not .AdminMode}}
<h2>管理登录</h2>
<p>请输入服务环境变量 <code>ZHANLU_LOGIN_PASSWORD</code> 配置的管理密码。</p>
<form id="password-form">
<label>登录密码<input name="password" type="password" autocomplete="current-password" placeholder="请输入服务访问密码" required></label>
<button class="btn btn-primary" type="submit">进入登录管理</button>
</form>
<div class="status" id="status" data-state="busy">需要登录后才能管理湛卢凭据。</div>
{{else}}
<h2>手机号验证码登录</h2>
<p>手机号和一次性 secret 会按插件逻辑用 RSA 加密后提交到移动云公网接口。</p>
<form id="phone-form">
<label>手机号<input name="telephone" inputmode="numeric" autocomplete="tel" placeholder="请输入 11 位手机号" required></label>
<label>验证码
<div class="row">
<input name="code" inputmode="numeric" autocomplete="one-time-code" placeholder="6 位验证码" required>
<button class="btn btn-ghost" id="code-button" type="button">获取验证码</button>
</div>
</label>
<button class="btn btn-primary" type="submit">登录并保存凭据</button>
</form>
<div class="status" id="status" data-state="busy">正在检查登录状态...</div>
<div class="muted">凭据保存到 JSON;验证码本身不会保存。{{if .PasswordEnabled}} <button class="logout" id="logout-button" type="button">退出管理登录</button>{{end}}</div>
{{end}}
</section>
</main>
<script>
const statusEl = document.getElementById('status');
const form = document.getElementById('phone-form');
const passwordForm = document.getElementById('password-form');
const codeButton = document.getElementById('code-button');
const logoutButton = document.getElementById('logout-button');
let secret = '';
let countdown = 0;
let countdownTimer = null;
function setStatus(text, state) {
statusEl.textContent = text;
statusEl.dataset.state = state || '';
}
function startCountdown() {
countdown = 60;
codeButton.disabled = true;
countdownTimer && clearInterval(countdownTimer);
countdownTimer = setInterval(() => {
if (countdown <= 0) {
clearInterval(countdownTimer);
codeButton.disabled = false;
codeButton.textContent = '获取验证码';
return;
}
codeButton.textContent = countdown + 's';
countdown--;
}, 1000);
}
if (passwordForm) {
passwordForm.addEventListener('submit', async (event) => {
event.preventDefault();
const password = passwordForm.password.value;
if (!password) {
setStatus('请输入登录密码', 'err');
return;
}
setStatus('正在登录...', 'busy');
const res = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password })
});
const data = await res.json();
if (!res.ok || !data.ok) {
setStatus(data.error || '登录失败', 'err');
return;
}
window.location.href = '/admin/login';
});
}
if (logoutButton) {
logoutButton.addEventListener('click', async () => {
await fetch('/api/logout', { method: 'POST' });
window.location.href = '/login';
});
}
if (form) {
fetch('/api/credentials').then(r => r.json()).then(data => {
statusEl.textContent = data.configured ? ('已登录:' + (data.access_key || '')) : '当前未登录';
statusEl.dataset.state = data.configured ? 'ok' : '';
});
}
codeButton && codeButton.addEventListener('click', async () => {
const telephone = form.telephone.value.trim();
if (!/^1[3-9]\d{9}$/.test(telephone)) {
setStatus('请输入有效的 11 位手机号', 'err');
return;
}
codeButton.disabled = true;
setStatus('正在发送验证码...', 'busy');
const res = await fetch('/api/auth/code', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ telephone })
});
const data = await res.json();
if (!res.ok || !data.ok) {
codeButton.disabled = false;
setStatus(data.error || '验证码发送失败', 'err');
return;
}
secret = data.secret;
setStatus('验证码已发送', 'ok');
startCountdown();
});
form && form.addEventListener('submit', async (event) => {
event.preventDefault();
const telephone = form.telephone.value.trim();
const code = form.code.value.trim();
if (!secret) {
setStatus('请先获取验证码', 'err');
return;
}
setStatus('正在登录并保存凭据...', 'busy');
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ telephone, code, secret })
});
const data = await res.json();
if (!res.ok || !data.ok) {
setStatus(data.error || '登录失败', 'err');
return;
}
setStatus('登录成功,已保存凭据:' + (data.access_key || '') + ';数据库:' + (data.path || ''), 'ok');
});
</script>
</body>
</html>`))
var loginResultTemplate = template.Must(template.New("login-result").Parse(`<!doctype html>
<html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="color-scheme" content="dark">
<title>湛卢登录结果</title><style>
:root{color-scheme:dark;font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;--ink:#f8fafc;--body:#b6c2d9;--ok:#34d399;--err:#f87171;--accent-1:#3b82f6;--accent-2:#8b5cf6}
*{box-sizing:border-box}
body{margin:0;min-height:100vh;display:grid;place-items:center;padding:24px 16px;color:var(--ink);
background:
radial-gradient(60rem 42rem at 12% -8%,rgba(59,130,246,.16),transparent 60%),
radial-gradient(50rem 36rem at 105% 110%,rgba(139,92,246,.14),transparent 60%),
linear-gradient(160deg,#0b1220 0%,#111a2e 55%,#0e1626 100%);
animation:fade-in .5s ease both}
@keyframes fade-in{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:none}}
@media(prefers-reduced-motion:reduce){body{animation:none}}
.card{width:min(480px,100%);padding:44px 36px;border-radius:24px;text-align:center;position:relative;background:rgba(15,23,42,.82);backdrop-filter:blur(20px);-webkit-backdrop-filter:blur(20px);box-shadow:0 24px 80px rgba(0,0,0,.42),inset 0 1px 0 rgba(255,255,255,.08);border:1px solid rgba(255,255,255,.12)}
.mark{width:64px;height:64px;margin:0 auto 22px;border-radius:50%;display:grid;place-items:center}
.mark svg{width:30px;height:30px}
.mark.ok{background:rgba(52,211,153,.12);border:1px solid rgba(52,211,153,.35)}
.mark.err{background:rgba(248,113,113,.12);border:1px solid rgba(248,113,113,.35)}
h1{margin:0;font-size:24px;font-weight:800;letter-spacing:-.02em}
p{margin:14px 0 26px;color:var(--body);line-height:1.7;font-size:14.5px;word-break:break-word}
.btn{display:inline-flex;align-items:center;justify-content:center;border:0;border-radius:14px;padding:13px 26px;font:inherit;font-weight:700;font-size:14.5px;text-decoration:none;color:#fff;cursor:pointer;background:linear-gradient(135deg,var(--accent-1),var(--accent-2));box-shadow:0 10px 24px -10px rgba(99,102,241,.55);transition:filter .15s ease}
.btn:hover{filter:brightness(1.1)}
.btn:focus-visible{outline:2px solid var(--accent-1);outline-offset:2px}
</style></head>
<body><main class="card">
<div class="mark {{if .Success}}ok{{else}}err{{end}}">
{{if .Success}}<svg viewBox="0 0 24 24" fill="none" stroke="#34d399" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>{{else}}<svg viewBox="0 0 24 24" fill="none" stroke="#f87171" stroke-width="2.4" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg>{{end}}
</div>
{{if .Success}}<h1>登录成功</h1>{{else}}<h1>登录失败</h1>{{end}}
<p>{{.Message}}</p>
<a class="btn" href="/login">返回登录页</a>
</main></body></html>`))
var statsTemplate = template.Must(template.New("stats").Funcs(template.FuncMap{
"pct": func(f float64) string { return fmt.Sprintf("%.1f%%", f*100) },
"rate": func(cached, prompt int64) string {
if prompt <= 0 {
return "0%"
}
return fmt.Sprintf("%.1f%%", float64(cached)/float64(prompt)*100)
},
}).Parse(`<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark">
<title>湛卢 Token 统计</title>
<style>
:root{color-scheme:dark;font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;--bg:#0b1220;--panel:rgba(15,23,42,.82);--line:rgba(255,255,255,.12);--accent-1:#3b82f6;--accent-2:#8b5cf6;--ink:#f8fafc;--body:#b6c2d9;--muted:#8ba0b8;--ok:#34d399;--err:#f87171}
*{box-sizing:border-box}
body{margin:0;min-height:100vh;padding:28px 16px 60px;color:var(--ink);
background:radial-gradient(60rem 42rem at 12% -8%,rgba(59,130,246,.16),transparent 60%),radial-gradient(50rem 36rem at 105% 110%,rgba(139,92,246,.14),transparent 60%),linear-gradient(160deg,#0b1220 0%,#111a2e 55%,#0e1626 100%)}
.wrap{max-width:1080px;margin:0 auto;display:grid;gap:22px}
header{display:flex;align-items:flex-end;justify-content:space-between;gap:16px;flex-wrap:wrap}
.eyebrow{font-size:12px;font-weight:600;letter-spacing:.14em;text-transform:uppercase;color:var(--muted)}
h1{margin:6px 0 0;font-size:clamp(26px,3.4vw,38px);font-weight:800;letter-spacing:-.03em;background:linear-gradient(92deg,#f8fafc 20%,#bfdbfe 62%,#c4b5fd 100%);-webkit-background-clip:text;background-clip:text;-webkit-text-fill-color:transparent;color:transparent}
.actions{display:flex;gap:10px}
.btn{display:inline-flex;align-items:center;justify-content:center;border:0;border-radius:12px;padding:11px 18px;font:inherit;font-weight:700;font-size:14px;text-decoration:none;color:#fff;cursor:pointer;background:linear-gradient(135deg,var(--accent-1),var(--accent-2));box-shadow:0 10px 24px -10px rgba(99,102,241,.55);transition:filter .15s ease}
.btn:hover{filter:brightness(1.1)}
.btn.ghost{background:transparent;border:1px solid rgba(148,163,184,.35);color:#bfdbfe;box-shadow:none}
.btn.ghost:hover{border-color:var(--accent-1);background:rgba(96,165,250,.08)}
.panel{position:relative;border-radius:20px;padding:1px;background:linear-gradient(180deg,rgba(255,255,255,.2),rgba(255,255,255,.05) 38%,rgba(255,255,255,.09));box-shadow:0 24px 80px rgba(0,0,0,.42)}
.panel-inner{border-radius:19px;background:var(--panel);backdrop-filter:blur(20px);-webkit-backdrop-filter:blur(20px);padding:22px 22px}
.grid4{display:grid;grid-template-columns:repeat(auto-fit,minmax(170px,1fr));gap:14px}
.stat{border:1px solid var(--line);border-radius:14px;padding:16px 16px;background:rgba(2,6,23,.5)}
.stat .label{font-size:11.5px;letter-spacing:.06em;color:var(--muted);margin-bottom:8px}
.stat .val{font-size:26px;font-weight:800;letter-spacing:-.02em}
.stat .sub{font-size:12px;color:var(--body);margin-top:4px}
h2{margin:0 0 14px;font-size:17px;font-weight:700;letter-spacing:-.01em}
table{width:100%;border-collapse:collapse;font-size:13.5px}
th,td{text-align:left;padding:9px 10px;border-bottom:1px solid var(--line);white-space:nowrap}
th{color:var(--muted);font-weight:600;font-size:11.5px;letter-spacing:.05em;text-transform:uppercase}
td.num,th.num{text-align:right;font-variant-numeric:tabular-nums}
.badge{display:inline-block;padding:2px 8px;border-radius:999px;font-size:11.5px;font-weight:600}
.badge.ok{background:rgba(52,211,153,.14);color:var(--ok);border:1px solid rgba(52,211,153,.3)}
.badge.err{background:rgba(248,113,113,.14);color:var(--err);border:1px solid rgba(248,113,113,.3)}
.badge.stream{background:rgba(96,165,250,.14);color:#93c5fd;border:1px solid rgba(96,165,250,.3)}
.badge.nonstream{background:rgba(148,163,184,.12);color:var(--muted);border:1px solid rgba(148,163,184,.25)}
.barrow{display:grid;grid-template-columns:96px 1fr 70px;align-items:center;gap:10px;padding:5px 0}
.barrow .day{font-size:12.5px;color:var(--body)}
.barrow .bar{height:10px;border-radius:6px;background:linear-gradient(90deg,var(--accent-1),var(--accent-2));min-width:2px}
.barrow .amt{font-size:12.5px;color:var(--muted);text-align:right;font-variant-numeric:tabular-nums}
.muted{color:var(--muted);font-size:13px}
.scroll{overflow-x:auto}
</style>
</head>
<body>
<div class="wrap">
<header>
<div>
<div class="eyebrow">Zhanlu Proxy · Token 统计</div>
<h1>Token 消耗统计</h1>
</div>
<div class="actions">
<a class="btn ghost" href="/admin/login">返回登录管理</a>
<button class="btn" id="refresh">刷新</button>
<button class="btn ghost" id="reset">重置统计</button>
</div>
</header>
{{if not .Enabled}}<div class="panel"><div class="panel-inner"><p class="muted">统计已关闭(ZHANLU_STATS_DISABLED=true)。</p></div></div>{{end}}
<div class="panel"><div class="panel-inner">
<h2>总览</h2>
<div class="grid4">
<div class="stat"><div class="label">请求总数</div><div class="val">{{.Stats.Totals.Requests}}</div><div class="sub">成功 {{.Stats.Totals.SuccessRequests}} · 失败 {{.Stats.Totals.ErrorRequests}}</div></div>
<div class="stat"><div class="label">Prompt Tokens</div><div class="val">{{.Stats.Totals.PromptTokens}}</div><div class="sub">缓存 {{.Stats.Totals.CachedTokens}}</div></div>
<div class="stat"><div class="label">Completion Tokens</div><div class="val">{{.Stats.Totals.CompletionTokens}}</div><div class="sub">含思考 {{.Stats.Totals.ReasoningTokens}}</div></div>
<div class="stat"><div class="label">Total Tokens</div><div class="val">{{.Stats.Totals.TotalTokens}}</div></div>
<div class="stat"><div class="label">缓存命中率</div><div class="val">{{pct .Stats.Totals.CacheRate}}</div><div class="sub">缓存 {{.Stats.Totals.CachedTokens}} / Prompt {{.Stats.Totals.PromptTokens}}</div></div>
</div>
</div></div>
<div class="panel"><div class="panel-inner">
<h2>按模型</h2>
{{if .Stats.PerModel}}
<div class="scroll"><table>
<thead><tr><th>模型</th><th class="num">请求数</th><th class="num">Prompt</th><th class="num">Comp</th><th class="num">Total</th><th class="num">缓存</th><th class="num">命中率</th></tr></thead>
<tbody>
{{range .Stats.PerModel}}<tr><td>{{.Model}}</td><td class="num">{{.Requests}}</td><td class="num">{{.PromptTokens}}</td><td class="num">{{.CompletionTokens}}</td><td class="num">{{.TotalTokens}}</td><td class="num">{{.CachedTokens}}</td><td class="num">{{rate .CachedTokens .PromptTokens}}</td></tr>{{end}}
</tbody>
</table></div>
{{else}}<p class="muted">暂无数据</p>{{end}}
</div></div>
<div class="panel"><div class="panel-inner">
<h2>按日</h2>
{{if .Stats.Daily}}
<div id="daily">
{{range .Stats.Daily}}<div class="barrow"><div class="day">{{.Day}}</div><div class="bar" data-token="{{.TotalTokens}}" style="width:0"></div><div class="amt">{{.TotalTokens}}</div></div>{{end}}
</div>
{{else}}<p class="muted">暂无数据</p>{{end}}
</div></div>
<div class="panel"><div class="panel-inner">
<h2>最近请求</h2>
{{if .Stats.Recent}}
<div class="scroll"><table>
<thead><tr><th>时间</th><th>模型</th><th>模式</th><th class="num">Prompt</th><th class="num">Comp</th><th class="num">Total</th><th class="num">缓存</th><th>状态</th><th class="num">耗时</th></tr></thead>
<tbody>
{{range .Stats.Recent}}<tr><td>{{.Ts.Format "01-02 15:04:05"}}</td><td>{{.Model}}</td><td>{{if .Stream}}<span class="badge stream">流式</span>{{else}}<span class="badge nonstream">非流式</span>{{end}}</td><td class="num">{{.PromptTokens}}</td><td class="num">{{.CompletionTokens}}</td><td class="num">{{.TotalTokens}}</td><td class="num">{{.CachedTokens}}</td><td>{{if eq .Status "success"}}<span class="badge ok">成功</span>{{else}}<span class="badge err">失败</span>{{end}}</td><td class="num">{{.LatencyMs}}ms</td></tr>{{end}}
</tbody>
</table></div>
{{else}}<p class="muted">暂无数据</p>{{end}}
</div></div>
</div>
<script>
(function () {
var rows = document.querySelectorAll('#daily .bar');
var max = 1;
for (var i = 0; i < rows.length; i++) {
var t = parseInt(rows[i].getAttribute('data-token') || '0', 10);
if (t > max) max = t;
}
for (var j = 0; j < rows.length; j++) {
var v = parseInt(rows[j].getAttribute('data-token') || '0', 10);
rows[j].style.width = Math.max(2, Math.round(v * 100 / max)) + '%';
}
document.getElementById('refresh').addEventListener('click', function () { location.reload(); });
document.getElementById('reset').addEventListener('click', function () {
if (!confirm('确定清空所有统计数据?')) return;
fetch('/api/stats/reset', { method: 'POST' }).then(function () { location.reload(); });
});
})();
</script>
</body>
</html>`))