diff --git a/Dockerfile b/Dockerfile index 17b69d7..6763009 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,7 +14,7 @@ RUN apk add --no-cache wget ca-certificates tzdata \ USER app WORKDIR /app COPY --from=build /out/wb2api /app/wb2api -COPY config.json /app/config.json +COPY config.example.json /app/config.json EXPOSE 7863 HEALTHCHECK --interval=30s --timeout=5s --start-period=5s \ CMD wget -qO- http://127.0.0.1:7863/healthz || exit 1 diff --git a/cmd/server/main.go b/cmd/server/main.go index 08eda5b..0dd90f6 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -65,13 +65,20 @@ func main() { ErrCooldown: cfg.ErrCooldownDur, }) + // 内置 Web 前端控制台:账号总览 + 一键签到 + 扫码登录(WEB_DISABLED=1 关闭) + var handler http.Handler = h + if os.Getenv("WEB_DISABLED") != "1" { + localAPI := server.NewLocalAPI(p, up, func() { go sch.RunCheckinNow() }, cfg.AuthDir, os.Getenv("LOGIN_DISABLED") != "1") + handler = server.WrapWeb(h, true, localAPI) + } + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() go sch.Run(ctx) srv := &http.Server{ Addr: cfg.Listen, - Handler: h, + Handler: handler, ReadHeaderTimeout: 30 * time.Second, } go func() { diff --git a/internal/login/login.go b/internal/login/login.go new file mode 100644 index 0000000..6f95b52 --- /dev/null +++ b/internal/login/login.go @@ -0,0 +1,188 @@ +// Package login 封装 WorkBuddy CN 的 OAuth 设备流(与 cmd/login 一致), +// 供服务端 Web 登录接口与落盘使用。仅支持 region=cn(与上游 CLI 一致)。 +package login + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/cookiejar" + "os" + "path/filepath" + "time" +) + +const ( + upstreamBaseCN = "https://copilot.tencent.com" + clientUA = "CLI/2.63.2 CodeBuddy/2.63.2" + originReferer = "https://www.codebuddy.cn" + + endpointAuthState = upstreamBaseCN + "/v2/plugin/auth/state?platform=CLI" + endpointAuthToken = upstreamBaseCN + "/v2/plugin/auth/token?state=" + endpointLoginAcct = upstreamBaseCN + "/v2/plugin/login/account?state=" +) + +// Bundle 登录成功后拿到的完整凭证。 +type Bundle struct { + AccessToken string + RefreshToken string + ExpiresIn int64 + Domain string + UID string + EnterpriseID string + Nickname string + ExpiresAt int64 // Unix 秒,由 ExpiresIn 推导 +} + +type apiEnvelope struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data json.RawMessage `json:"data"` +} + +func commonHeaders(req *http.Request) { + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json, text/plain, */*") + req.Header.Set("X-Requested-With", "XMLHttpRequest") + req.Header.Set("Origin", originReferer) + req.Header.Set("Referer", originReferer+"/") + req.Header.Set("User-Agent", clientUA) +} + +func doJSON(client *http.Client, method, fullURL string, headers func(*http.Request), body io.Reader) (json.RawMessage, int, error) { + req, err := http.NewRequest(method, fullURL, body) + if err != nil { + return nil, 0, err + } + if headers != nil { + headers(req) + } else { + commonHeaders(req) + } + resp, err := client.Do(req) + if err != nil { + return nil, 0, err + } + defer resp.Body.Close() + raw, _ := io.ReadAll(resp.Body) + if resp.StatusCode >= 400 { + return nil, resp.StatusCode, fmt.Errorf("http_error: upstream %d", resp.StatusCode) + } + var env apiEnvelope + if err := json.Unmarshal(raw, &env); err != nil { + return nil, resp.StatusCode, fmt.Errorf("parse failed: %w", err) + } + if env.Code != 0 { + return nil, resp.StatusCode, fmt.Errorf("code=%d msg=%s", env.Code, env.Msg) + } + return env.Data, resp.StatusCode, nil +} + +// Start 拿授权 URL + state。state 由调用方保管,传给 Poll。 +func Start() (state, authURL string, err error) { + jar, _ := cookiejar.New(nil) + client := &http.Client{Timeout: 30 * time.Second, Jar: jar} + data, _, err := doJSON(client, http.MethodPost, endpointAuthState, nil, bytes.NewReader([]byte("{}"))) + if err != nil { + return "", "", fmt.Errorf("auth state failed: %w", err) + } + var st struct { + State string `json:"state"` + AuthURL string `json:"authUrl"` + } + if err := json.Unmarshal(data, &st); err != nil || st.State == "" || st.AuthURL == "" { + return "", "", fmt.Errorf("auth state: missing state or authUrl") + } + return st.State, st.AuthURL, nil +} + +// Poll 用之前拿到的 state 轮询 token;pending 时返回 err 且 ok=false(调用方应继续轮询)。 +// ok=true 表示登录完成,返回完整 Bundle。 +func Poll(state string) (b *Bundle, ok bool, err error) { + jar, _ := cookiejar.New(nil) + client := &http.Client{Timeout: 30 * time.Second, Jar: jar} + + tokRaw, status, errTok := doJSON(client, http.MethodGet, endpointAuthToken+state, nil, nil) + if errTok != nil { + if status == 0 || status >= 500 { + return nil, false, fmt.Errorf("token endpoint error: %w", errTok) + } + // 4xx / 业务错误(pending):登录尚未完成 + return nil, false, nil + } + var tok struct { + AccessToken string `json:"accessToken"` + RefreshToken string `json:"refreshToken"` + ExpiresIn int64 `json:"expiresIn"` + Domain string `json:"domain"` + } + if err := json.Unmarshal(tokRaw, &tok); err != nil || tok.AccessToken == "" { + return nil, false, nil + } + + b = &Bundle{ + AccessToken: tok.AccessToken, + RefreshToken: tok.RefreshToken, + ExpiresIn: tok.ExpiresIn, + Domain: tok.Domain, + ExpiresAt: time.Now().Add(time.Duration(tok.ExpiresIn) * time.Second).Unix(), + } + + // login/account 拿 uid/nickname(带 Bearer) + acctHeaders := func(r *http.Request) { + commonHeaders(r) + r.Header.Set("Authorization", "Bearer "+tok.AccessToken) + } + if acctRaw, _, errAcct := doJSON(client, http.MethodGet, endpointLoginAcct+state, acctHeaders, nil); errAcct == nil { + var acct struct { + UID string `json:"uid"` + EnterpriseID string `json:"enterpriseId"` + Nickname string `json:"nickname"` + } + if json.Unmarshal(acctRaw, &acct) == nil { + b.UID = acct.UID + b.EnterpriseID = acct.EnterpriseID + b.Nickname = acct.Nickname + } + } + if b.UID == "" { + return nil, false, fmt.Errorf("login completed but uid empty") + } + return b, true, nil +} + +// SaveToFile 将 Bundle 落盘为 auth_dir/workbuddy-.json(嵌套形,与 auth.Parse 一致)。 +// 返回最终文件路径。 +func (b *Bundle) SaveToFile(authDir string) (string, error) { + if err := os.MkdirAll(authDir, 0o755); err != nil { + return "", err + } + doc := map[string]any{ + "auth": map[string]any{ + "accessToken": b.AccessToken, + "refreshToken": b.RefreshToken, + "expiresAt": b.ExpiresAt, + "domain": b.Domain, + }, + "account": map[string]any{ + "uid": b.UID, + "enterpriseId": b.EnterpriseID, + "nickname": b.Nickname, + }, + } + raw, err := json.MarshalIndent(doc, "", " ") + if err != nil { + return "", err + } + fp := filepath.Join(authDir, "workbuddy-"+b.UID+".json") + tmp := fp + ".tmp" + if err := os.WriteFile(tmp, raw, 0o600); err != nil { + return "", err + } + if err := os.Rename(tmp, fp); err != nil { + return "", err + } + return fp, nil +} diff --git a/internal/server/local_api.go b/internal/server/local_api.go new file mode 100644 index 0000000..0aa87be --- /dev/null +++ b/internal/server/local_api.go @@ -0,0 +1,113 @@ +package server + +import ( + "log" + "net/http" + + "workbuddy2api/internal/auth" + "workbuddy2api/internal/login" + "workbuddy2api/internal/pool" + "workbuddy2api/internal/upstream" +) + +// LocalAPI 提供内置前端需要的本地 API(一键签到、扫码添加账号)。 +// 设计为独立于上游 Handler 的结构:不修改 handler.go,全部代码在本文件, +// 由 WrapWeb 挂载路由,main.go 只传依赖。 +type LocalAPI struct { + Pool *pool.Pool + Upstream *upstream.Client + RunCheckin func() // 触发一次签到轮(由 main 注入 scheduler.RunCheckinNow) + AuthDir string + LoginEnabled bool +} + +func NewLocalAPI(p *pool.Pool, up *upstream.Client, runCheckin func(), authDir string, loginEnabled bool) *LocalAPI { + return &LocalAPI{ + Pool: p, + Upstream: up, + RunCheckin: runCheckin, + AuthDir: authDir, + LoginEnabled: loginEnabled, + } +} + +// handleCheckin 手动触发一次签到轮(同步执行;结果由 scheduler 推送)。 +func (a *LocalAPI) handleCheckin(w http.ResponseWriter, r *http.Request) { + if a.RunCheckin == nil { + writeOpenAIError(w, http.StatusServiceUnavailable, "checkin_unavailable", "scheduler not available") + return + } + a.RunCheckin() + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "message": "签到已触发"}) +} + +// handleLoginStart 发起 OAuth 设备流,返回授权 URL 与 state。 +func (a *LocalAPI) handleLoginStart(w http.ResponseWriter, r *http.Request) { + if !a.LoginEnabled { + writeOpenAIError(w, http.StatusForbidden, "login_disabled", "web login is disabled by config") + return + } + state, authURL, err := login.Start() + if err != nil { + writeOpenAIError(w, http.StatusBadGateway, "login_start_failed", err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "state": state, + "auth_url": authURL, + }) +} + +// handleLoginPoll 用 state 轮询登录结果;pending 时返回 {pending:true}。 +func (a *LocalAPI) handleLoginPoll(w http.ResponseWriter, r *http.Request) { + if !a.LoginEnabled { + writeOpenAIError(w, http.StatusForbidden, "login_disabled", "web login is disabled by config") + return + } + state := r.URL.Query().Get("state") + if state == "" { + writeOpenAIError(w, http.StatusBadRequest, "bad_request", "missing state") + return + } + bundle, ok, err := login.Poll(state) + if err != nil { + writeOpenAIError(w, http.StatusBadGateway, "login_poll_failed", err.Error()) + return + } + if !ok { + writeJSON(w, http.StatusOK, map[string]any{"pending": true}) + return + } + + fp, err := bundle.SaveToFile(a.AuthDir) + if err != nil { + writeOpenAIError(w, http.StatusInternalServerError, "save_failed", "save auth: "+err.Error()) + return + } + + // 加入账号池(带完整凭证,供后续 refresh 写回) + acc := &auth.Auth{ + AccessToken: bundle.AccessToken, + RefreshToken: bundle.RefreshToken, + ExpiresAt: bundle.ExpiresAt, + Domain: bundle.Domain, + UID: bundle.UID, + EnterpriseID: bundle.EnterpriseID, + Nickname: bundle.Nickname, + FilePath: fp, + } + a.Pool.Add(acc) + + // 立即查询一次积分(非阻塞,失败忽略) + if remain, rerr := a.Upstream.UserResource(acc); rerr == nil { + a.Pool.SetCredits(acc.UID, remain) + } + + log.Printf("web login success: uid=%s nickname=%s file=%s", acc.UID, acc.Nickname, fp) + writeJSON(w, http.StatusOK, map[string]any{ + "pending": false, + "uid": acc.UID, + "nickname": acc.Nickname, + "file": fp, + }) +} diff --git a/internal/server/web/app.js b/internal/server/web/app.js new file mode 100644 index 0000000..89c3ecc --- /dev/null +++ b/internal/server/web/app.js @@ -0,0 +1,173 @@ +// WorkBuddy2API 前端(上游兼容版)— /status 展示 + 一键签到 + 扫码添加账号 +(function () { + "use strict"; + + var $ = function (s) { return document.querySelector(s); }; + var key = localStorage.getItem("wb2a_key") || ""; + + function toast(msg, isErr) { + var t = $("#toast"); + t.textContent = msg; + t.classList.remove("hidden"); + t.style.background = isErr ? "#d9534f" : "#27ae60"; + setTimeout(function () { t.classList.add("hidden"); }, 2600); + } + + function api(path, opts) { + var o = opts || {}; + o.headers = Object.assign({ "Content-Type": "application/json" }, o.headers); + if (key) o.headers["Authorization"] = "Bearer " + key; + return fetch(path, o); + } + + // ---- API Key ---- + function bindKey() { + $("#apikey").value = key; + $("#saveKey").addEventListener("click", function () { + key = $("#apikey").value.trim(); + localStorage.setItem("wb2a_key", key); + toast("API Key 已保存"); + loadStatus(); + }); + } + + // ---- 一键签到 ---- + function bindCheckin() { + $("#checkinBtn").addEventListener("click", function () { + if (!key) { toast("请先输入 API Key", true); return; } + $("#checkinBtn").disabled = true; + api("/api/checkin", { method: "POST", body: "{}" }) + .then(function (r) { + if (!r.ok) throw new Error("HTTP " + r.status); + return r.json(); + }) + .then(function (d) { toast(d.message || "签到已触发"); setTimeout(loadStatus, 4000); }) + .catch(function (e) { toast("签到失败: " + e.message, true); }) + .finally(function () { $("#checkinBtn").disabled = false; }); + }); + } + + // ---- 扫码添加账号 ---- + var loginState = null; + var loginTimer = null; + + function stopLogin() { + if (loginTimer) { clearInterval(loginTimer); loginTimer = null; } + loginState = null; + } + + function showLogin(bundle) { + $("#loginOverlay").classList.remove("hidden"); + var qr = $("#qrcode"); + qr.innerHTML = ""; + new QRCode(qr, { + text: bundle.auth_url, + width: 220, + height: 220, + correctLevel: QRCode.CorrectLevel.M + }); + $("#loginTip").textContent = "请用 WorkBuddy/CodeBuddy 扫码,登录完成后自动添加"; + // 开始轮询 + loginTimer = setInterval(function () { + api("/api/login/poll?state=" + encodeURIComponent(bundle.state)) + .then(function (r) { return r.ok ? r.json() : Promise.reject(new Error("HTTP " + r.status)); }) + .then(function (d) { + if (d.pending) return; // 继续等 + stopLogin(); + $("#loginOverlay").classList.add("hidden"); + toast("账号已添加: " + (d.nickname || d.uid)); + loadStatus(); + }) + .catch(function (e) { + stopLogin(); + $("#loginOverlay").classList.add("hidden"); + toast("登录失败: " + e.message, true); + }); + }, 3000); + } + + function bindLogin() { + $("#loginBtn").addEventListener("click", function () { + if (!key) { toast("请先输入 API Key", true); return; } + api("/api/login/start", { method: "POST", body: "{}" }) + .then(function (r) { + if (!r.ok) throw new Error("HTTP " + r.status); + return r.json(); + }) + .then(function (d) { + if (!d.auth_url) throw new Error("未获取到授权地址"); + loginState = d; + showLogin(d); + }) + .catch(function (e) { toast("登录失败: " + e.message, true); }); + }); + $("#loginCancel").addEventListener("click", function () { + stopLogin(); + $("#loginOverlay").classList.add("hidden"); + }); + } + + // ---- 渲染 ---- + function fmtTime(s) { + if (!s || s.indexOf("0001-01-01") === 0) return "—"; + return s.replace("T", " ").replace("Z", "").slice(0, 19); + } + + function render(data) { + var accts = data.accounts || []; + $("#stTotal").textContent = data.total != null ? data.total : accts.length; + $("#stActive").textContent = data.healthy != null ? data.healthy : 0; + $("#stCooling").textContent = data.cooling != null ? data.cooling : 0; + $("#stDisabled").textContent = data.disabled != null ? data.disabled : 0; + var sum = 0; + accts.forEach(function (a) { sum += a.credits || 0; }); + $("#stCredits").textContent = sum; + + $("#stats").classList.remove("hidden"); + var box = $("#accts"); + box.innerHTML = ""; + if (accts.length === 0) { + $("#accountsEmpty").classList.remove("hidden"); + return; + } + $("#accountsEmpty").classList.add("hidden"); + accts.forEach(function (a) { + var st = a.disabled ? "disabled" : (a.cooling ? "cooling" : "active"); + var stText = a.disabled ? "禁用" : (a.cooling ? "冷却" : "可用"); + var div = document.createElement("div"); + div.className = "acct " + st; + div.innerHTML = + '
' + (a.nickname || "?") + "" + + '' + stText + "
" + + '
积分 ' + a.credits + "
" + + '
冷却至 ' + fmtTime(a.until) + "
" + + '
上次成功 ' + fmtTime(a.last_success) + "
" + + '
上次错误 ' + fmtTime(a.last_err) + "
" + + '
' + a.uid + "
"; + box.appendChild(div); + }); + $("#statusMeta").textContent = "更新于 " + new Date().toLocaleTimeString(); + } + + function loadStatus() { + if (!key) { toast("请先输入 API Key", true); return; } + api("/status") + .then(function (r) { + if (r.status === 401) { toast("API Key 无效", true); return null; } + if (!r.ok) { toast("请求失败 HTTP " + r.status, true); return null; } + return r.json(); + }) + .then(function (d) { if (d) render(d); }) + .catch(function (e) { toast("网络错误: " + e.message, true); }); + } + + function bind() { + $("#refreshStatus").addEventListener("click", loadStatus); + } + + bindKey(); + bindCheckin(); + bindLogin(); + bind(); + if (key) loadStatus(); +})(); \ No newline at end of file diff --git a/internal/server/web/index.html b/internal/server/web/index.html new file mode 100644 index 0000000..db564db --- /dev/null +++ b/internal/server/web/index.html @@ -0,0 +1,57 @@ + + + + + +WorkBuddy2API + + + + + +
+

WorkBuddy2API

+
+ + +
+
+ +
+ +
+ + + +
+ + + + + + + + + +
+
+
+ + + + \ No newline at end of file diff --git a/internal/server/web/style.css b/internal/server/web/style.css new file mode 100644 index 0000000..5db29e0 --- /dev/null +++ b/internal/server/web/style.css @@ -0,0 +1,139 @@ +/* WorkBuddy2API 前端 */ +:root { + --bg: #0f1117; + --card: #1a1d26; + --card2: #222633; + --text: #e6e9ef; + --muted: #8b93a7; + --accent: #4a9eff; + --green: #2ecc71; + --red: #e74c3c; + --orange: #f39c12; +} +* { box-sizing: border-box; margin: 0; padding: 0; } +body { + background: var(--bg); color: var(--text); + font-family: -apple-system, "PingFang SC", "Segoe UI", sans-serif; + min-height: 100vh; +} +header { + display: flex; + flex-direction: column; + gap: 10px; + padding: 16px; + background: #1c2230; + border-bottom: 1px solid #2a3344; +} +header h1 { font-size: 18px; font-weight: 700; color: #e8ecf4; } +.key-row { display: flex; gap: 8px; } +.key-row input { + flex: 1; + padding: 10px 12px; + border: 1px solid #2a3344; + border-radius: 8px; + background: #0f1117; + color: #e8ecf4; + font-size: 14px; + outline: none; +} +.key-row input:focus { border-color: var(--accent); } +.key-row button { + padding: 10px 16px; + border: none; + border-radius: 8px; + background: var(--accent); + color: #fff; + font-weight: 600; + cursor: pointer; +} + +.toolbar { + display: flex; + gap: 10px; + padding: 12px 16px; + flex-wrap: wrap; +} +.toolbar button { + flex: 1; + min-width: 90px; + padding: 10px 12px; + border: none; + border-radius: 8px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + background: #2a7de1; + color: #fff; +} +.toolbar button:disabled { opacity: 0.5; cursor: default; } +.toolbar button:hover:not(:disabled) { background: #3b8cf0; } + +.overlay { + position: fixed; + inset: 0; + background: rgba(0,0,0,0.6); + display: flex; + align-items: center; + justify-content: center; + z-index: 50; + padding: 20px; +} +.overlay .box { + background: #1c2230; + border: 1px solid #2a3344; + border-radius: 12px; + padding: 20px; + text-align: center; + width: 100%; + max-width: 300px; +} +.overlay h3 { margin: 0 0 14px; color: #e8ecf4; } +.overlay #qrcode { margin: 0 auto 14px; } +.overlay p { color: #9aa7bd; font-size: 13px; line-height: 1.5; } +.overlay button { + margin-top: 10px; + padding: 8px 18px; + border: none; + border-radius: 8px; + background: #3a455a; + color: #e8ecf4; + cursor: pointer; +} + +main { max-width: 860px; margin: 0 auto; padding: 20px; } +#toast { + position: fixed; top: 12px; left: 50%; transform: translateX(-50%); + padding: 10px 20px; border-radius: 8px; color: #fff; + font-size: 14px; font-weight: 600; z-index: 99; + white-space: nowrap; max-width: 90vw; overflow: hidden; + text-overflow: ellipsis; +} +.hidden { display: none !important; } +.stat-row { + display: flex; gap: 10px; flex-wrap: wrap; + margin-bottom: 16px; +} +.stat { + flex: 1; min-width: 50px; background: #1a1d26; + padding: 12px 8px; border-radius: 10px; text-align: center; +} +.stat .num { font-size: 22px; font-weight: 700; color: #fff; } +.stat .lab { font-size: 11px; color: #8b93a7; margin-top: 2px; } +#accts { display: flex; flex-direction: column; gap: 12px; } +.acct { + background: #1a1d26; border-radius: 10px; padding: 14px; + border-left: 4px solid #2ecc71; +} +.acct.disabled { border-left-color: #e74c3c; opacity: 0.6; } +.acct.cooling { border-left-color: #f39c12; } +.acct-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; } +.acct-nick { font-size: 16px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.badge { font-size: 11px; padding: 2px 8px; border-radius: 8px; } +.badge.active { background: #2ecc71; color: #fff; } +.badge.cooling { background: #f39c12; color: #fff; } +.badge.disabled { background: #e74c3c; color: #fff; } +.acct-row { font-size: 13px; color: #8b93a7; margin-top: 4px; } +.acct-row b { color: #e6e9ef; } +.acct-uid { font-size: 10px; color: #5a6270; margin-top: 6px; word-break: break-all; } +.meta { font-size: 11px; color: #5a6270; text-align: center; margin-top: 16px; } +#accountsEmpty { text-align: center; color: #8b93a7; padding: 40px 0; } \ No newline at end of file diff --git a/internal/server/web_extra.go b/internal/server/web_extra.go new file mode 100644 index 0000000..613af6f --- /dev/null +++ b/internal/server/web_extra.go @@ -0,0 +1,85 @@ +// Package server 前端扩展:嵌入静态资源 + 根路径路由。 +// 本文件是本地新增(上游无此文件),全量同步上游时不会被覆盖。 +package server + +import ( + "embed" + "io/fs" + "net/http" + "strings" +) + +//go:embed all:web +var webFS embed.FS + +// webSub 前端静态资源子文件系统。 +var webSub, _ = fs.Sub(webFS, "web") + +// WrapWeb 包装上游 handler:前端请求(/、/web/*)就地响应, +// 本地 API(/api/checkin、/api/login/*)由 LocalAPI 处理,其余转发给 next。 +// enabled=false 时原样返回 next。 +// 返回 http.Handler(保持接口通用,main.go 用新变量接收)。 +func WrapWeb(next http.Handler, enabled bool, local *LocalAPI) http.Handler { + if !enabled { + return next + } + fileServer := http.FileServer(http.FS(webSub)) + // 精确匹配:"/" 根与 "/web/"、"web/…" 前缀属于前端;"/api/*" 本地 API;其余转上游。 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + p := r.URL.Path + // 本地 API 路由(登录/签到,仅本地新增) + if local != nil { + switch { + case r.Method == http.MethodPost && p == "/api/checkin": + local.handleCheckin(w, r) + return + case r.Method == http.MethodPost && p == "/api/login/start": + local.handleLoginStart(w, r) + return + case r.Method == http.MethodGet && p == "/api/login/poll": + local.handleLoginPoll(w, r) + return + } + } + if p == "/" || strings.HasPrefix(p, "/web/") || p == "/web" { + // /web 重定向到 /web/,避免相对路径错乱 + if p == "/web" { + http.Redirect(w, r, "/web/", http.StatusMovedPermanently) + return + } + if p == "/" { + // 根路径直接返回 index.html + data, err := fs.ReadFile(webSub, "index.html") + if err != nil { + http.Error(w, "web assets missing", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(data) + return + } + // /web/* 静态资源(剥掉 /web 前缀再交给 FileServer) + if p != "/web/" { + r2 := r.Clone(r.Context()) + r2.URL.Path = strings.TrimPrefix(p, "/web") + if r2.URL.Path == "" { + r2.URL.Path = "/" + } + fileServer.ServeHTTP(w, r2) + return + } + // /web/ 目录默认返回 index.html + data, err := fs.ReadFile(webSub, "index.html") + if err != nil { + http.Error(w, "web assets missing", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(data) + return + } + next.ServeHTTP(w, r) + }) +}