The 1.4.2 extension replaced the old signed/encrypted chat gateway with an
OpenAI-compatible aigateway. Align the proxy with the new flow:
- Use ecloud.10086.cn login/model base URLs, zhanlu_ide plugin headers and
v1.4.2 plugin version
- Provision the model API key via SM2-signed get-or-create after v1/login
profile fetch; store api_key/model_base_url/email in credentials
- Chat via Bearer apiKey against {modelBaseUrl}/chat/completions with plain
OpenAI SSE passthrough; fetch /v1/models from the gateway model-info endpoint
- Force HTTP/1.1 upstream (gateway drops HTTP/2 ALPN negotiation with EOF)
- Drop obsolete AES body encryption, model name mapping and vscode headers
1029 lines
36 KiB
Go
1029 lines
36 KiB
Go
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/zhanlu"
|
||
)
|
||
|
||
type Server struct {
|
||
cfg config.Config
|
||
mux *http.ServeMux
|
||
loginSession string
|
||
}
|
||
|
||
func New(cfg config.Config) http.Handler {
|
||
s := &Server{cfg: cfg, mux: http.NewServeMux()}
|
||
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 /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{"CredentialsPath": s.cfg.CredentialsPath, "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{"CredentialsPath": s.cfg.CredentialsPath, "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 := auth.SaveCredentials(s.cfg.CredentialsPath, 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 := auth.SaveCredentials(s.cfg.CredentialsPath, 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.CredentialsPath, "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 := auth.LoadCredentials(s.cfg.CredentialsPath)
|
||
if err != nil {
|
||
writeJSON(w, http.StatusOK, map[string]any{"configured": false, "path": s.cfg.CredentialsPath})
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]any{
|
||
"configured": true,
|
||
"path": s.cfg.CredentialsPath,
|
||
"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 := auth.SaveCredentials(s.cfg.CredentialsPath, 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.CredentialsPath})
|
||
}
|
||
|
||
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 := auth.SaveCredentials(s.cfg.CredentialsPath, 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.CredentialsPath})
|
||
}
|
||
|
||
func (s *Server) models(w http.ResponseWriter, r *http.Request) {
|
||
modelIDs := s.cfg.Models
|
||
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 = s.cfg.DefaultModel
|
||
}
|
||
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
|
||
_ = auth.SaveCredentials(s.cfg.CredentialsPath, 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")
|
||
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")
|
||
return
|
||
}
|
||
if clientWantsStream {
|
||
s.proxyStream(w, resp)
|
||
return
|
||
}
|
||
s.aggregateStream(w, resp, req.Model)
|
||
}
|
||
|
||
func (s *Server) proxyStream(w http.ResponseWriter, resp *http.Response) {
|
||
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)
|
||
_, err := io.Copy(w, resp.Body)
|
||
if flusher != nil {
|
||
flusher.Flush()
|
||
}
|
||
if err != nil {
|
||
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)
|
||
}
|
||
}
|
||
|
||
func (s *Server) aggregateStream(w http.ResponseWriter, resp *http.Response, model 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
|
||
}
|
||
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)
|
||
}
|
||
|
||
// 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 := auth.LoadCredentials(s.cfg.CredentialsPath)
|
||
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">
|
||
<title>湛卢代理登录</title>
|
||
<style>
|
||
:root { color-scheme: light dark; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
|
||
body { margin: 0; min-height: 100vh; display: grid; place-items: center; background: radial-gradient(circle at top left, #dde7ff, transparent 34rem), linear-gradient(135deg, #101828, #1f2937); color: #e5e7eb; }
|
||
main { width: min(760px, calc(100vw - 32px)); display: grid; grid-template-columns: 1fr 1fr; gap: 24px; align-items: stretch; }
|
||
.hero, form { border: 1px solid rgba(255,255,255,.14); background: rgba(15,23,42,.78); backdrop-filter: blur(18px); border-radius: 24px; box-shadow: 0 24px 80px rgba(0,0,0,.28); }
|
||
.hero { padding: 30px; display: flex; flex-direction: column; justify-content: space-between; }
|
||
h1 { margin: 0; font-size: clamp(28px, 4vw, 44px); letter-spacing: -0.04em; }
|
||
p { color: #b6c2d9; line-height: 1.7; }
|
||
code { color: #bfdbfe; word-break: break-all; }
|
||
.login-card { padding: 28px; display: grid; gap: 16px; border: 1px solid rgba(255,255,255,.14); background: rgba(15,23,42,.78); backdrop-filter: blur(18px); border-radius: 24px; box-shadow: 0 24px 80px rgba(0,0,0,.28); }
|
||
label { display: grid; gap: 8px; font-size: 14px; color: #cbd5e1; }
|
||
input, textarea { width: 100%; box-sizing: border-box; border: 1px solid rgba(148,163,184,.35); border-radius: 14px; padding: 12px 14px; background: rgba(2,6,23,.55); color: #f8fafc; outline: none; font: inherit; }
|
||
input:focus, textarea:focus { border-color: #60a5fa; box-shadow: 0 0 0 4px rgba(96,165,250,.16); }
|
||
.row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||
.login-button { display: block; text-align: center; text-decoration: none; border: 0; border-radius: 14px; padding: 14px 16px; background: linear-gradient(135deg, #3b82f6, #8b5cf6); color: white; font-weight: 700; cursor: pointer; font: inherit; }
|
||
.login-button:hover { filter: brightness(1.08); }
|
||
.status { min-height: 22px; color: #93c5fd; }
|
||
.muted { font-size: 13px; color: #94a3b8; }
|
||
@media (max-width: 760px) { main { grid-template-columns: 1fr; padding: 18px 0; } .row { grid-template-columns: 1fr; } }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<main>
|
||
<section class="hero">
|
||
<div>
|
||
<h1>湛卢代理登录</h1>
|
||
<p>输入手机号获取验证码,按插件默认的移动云登录接口换取凭据和模型 API Key。服务会保存凭据,后续 OpenAI 兼容接口自动使用。</p>
|
||
</div>
|
||
<div class="muted">保存位置:<br><code>{{.CredentialsPath}}</code></div>
|
||
</section>
|
||
<section class="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="login-button" type="submit">进入登录管理</button>
|
||
</form>
|
||
<div class="status" id="status">需要登录后才能管理湛卢凭据。</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="login-button" id="code-button" type="button">获取验证码</button>
|
||
</div>
|
||
</label>
|
||
<button class="login-button" type="submit">登录并保存凭据</button>
|
||
</form>
|
||
<div class="status" id="status">正在检查登录状态...</div>
|
||
<div class="muted">凭据保存到 JSON;验证码本身不会保存。{{if .PasswordEnabled}} <button 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) {
|
||
statusEl.textContent = text;
|
||
}
|
||
|
||
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('请输入登录密码');
|
||
return;
|
||
}
|
||
setStatus('正在登录...');
|
||
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 || '登录失败');
|
||
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 || '')) : '当前未登录';
|
||
});
|
||
}
|
||
|
||
codeButton && codeButton.addEventListener('click', async () => {
|
||
const telephone = form.telephone.value.trim();
|
||
if (!/^1[3-9]\d{9}$/.test(telephone)) {
|
||
setStatus('请输入有效的 11 位手机号');
|
||
return;
|
||
}
|
||
codeButton.disabled = true;
|
||
setStatus('正在发送验证码...');
|
||
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 || '验证码发送失败');
|
||
return;
|
||
}
|
||
secret = data.secret;
|
||
setStatus('验证码已发送');
|
||
startCountdown();
|
||
});
|
||
|
||
form && form.addEventListener('submit', async (event) => {
|
||
event.preventDefault();
|
||
const telephone = form.telephone.value.trim();
|
||
const code = form.code.value.trim();
|
||
if (!secret) {
|
||
setStatus('请先获取验证码');
|
||
return;
|
||
}
|
||
setStatus('正在登录并保存凭据...');
|
||
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 || '登录失败');
|
||
return;
|
||
}
|
||
setStatus('登录成功,已保存凭据:' + (data.access_key || '') + ';JSON:' + (data.path || ''));
|
||
});
|
||
</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">
|
||
<title>湛卢登录结果</title><style>body{margin:0;min-height:100vh;display:grid;place-items:center;background:#0f172a;color:#e2e8f0;font-family:system-ui}.card{max-width:560px;margin:24px;padding:32px;border:1px solid #334155;border-radius:22px;background:#1e293b;text-align:center}a{color:#93c5fd}</style></head>
|
||
<body><main class="card">{{if .Success}}<h1>登录成功</h1>{{else}}<h1>登录失败</h1>{{end}}<p>{{.Message}}</p><a href="/login">返回登录页</a></main></body></html>`))
|