package httpapi import ( "bytes" "context" "crypto/hmac" "crypto/rand" "crypto/sha256" "crypto/subtle" "embed" "encoding/base64" "encoding/hex" "encoding/json" "fmt" "html/template" "net/url" "strconv" "strings" "sync" "time" "github.com/coreos/go-oidc/v3/oidc" "github.com/gofiber/fiber/v3" "github.com/rogeecn/wxapp-kouqiang-guahao/backend/internal/config" "github.com/rogeecn/wxapp-kouqiang-guahao/backend/internal/db" "github.com/rogeecn/wxapp-kouqiang-guahao/backend/internal/service" "golang.org/x/oauth2" ) //go:embed templates/*.html var adminTemplateFS embed.FS const ( adminSessionCookie = "smilefirst_admin" adminOIDCCookie = "smilefirst_oidc" defaultAdminPage = "/admin/price-inquiries" adminUserLocalKey = "adminUser" defaultAdminPageSize = 50 ) type adminUI struct { cfg config.Config svc *service.Service templates *template.Template oidcMu sync.Mutex oidcProvider *oidc.Provider oauth2Config *oauth2.Config oidcVerifier *oidc.IDTokenVerifier } type adminPageData struct { PageTitle string Active string Username string Next string Message string Error string OIDCLoginURL string PasswordChangeURL string HomeHeroImage string Categories []service.CategoryWithProjects PhoneRecords []service.AdminPhoneRecord BookingRecords []service.AdminBookingRecord PriceInquiryRecords []service.AdminPriceInquiryRecord PhoneFilter string AreaFilter string StatusFilter string TimeFromFilter string TimeToFilter string HasPriceInquiryFilter bool PriceInquiryTotal int64 PriceInquiryPage int PriceInquiryPageSize int PriceInquiryTotalPages int PriceInquiryHasPreviousPage bool PriceInquiryHasNextPage bool PriceInquiryPreviousURL string PriceInquiryNextURL string PriceInquiryPageSizeOptions []adminPageSizeOption } type adminPageSizeOption struct { Value int Selected bool } func newAdminUI(cfg config.Config, svc *service.Service) *adminUI { tmpl := template.Must(template.ParseFS(adminTemplateFS, "templates/*.html")) return &adminUI{cfg: cfg, svc: svc, templates: tmpl} } func (a *adminUI) registerPublic(app *fiber.App) { app.Get("/admin/login", a.loginPage) app.Post("/admin/login", a.loginRedirect) app.Get("/admin/oidc/start", a.oidcStart) app.Get("/admin/oidc/callback", a.oidcCallback) app.Post("/admin/logout", a.logout) } func (a *adminUI) registerPages(admin fiber.Router) { admin.Get("/", func(c fiber.Ctx) error { return c.Redirect().To(defaultAdminPage) }) admin.Get("/price-inquiries", a.priceInquiriesPage) admin.Post("/price-inquiries/settings", a.savePriceInquirySettings) } func (a *adminUI) requireLogin(c fiber.Ctx) error { if a.cfg.AdminAuthDisabled() { c.Locals(adminUserLocalKey, adminUser{ Subject: "auth:none", DisplayName: "免登录管理员", }) return c.Next() } if user, ok := a.validSession(c.Cookies(adminSessionCookie)); ok { c.Locals(adminUserLocalKey, user) return c.Next() } if c.Method() == fiber.MethodGet && wantsHTML(c) { next := url.QueryEscape(c.OriginalURL()) return c.Redirect().To("/admin/login?next=" + next) } return fiber.NewError(fiber.StatusUnauthorized, "admin login required") } func (a *adminUI) loginPage(c fiber.Ctx) error { if a.cfg.AdminAuthDisabled() { return c.Redirect().To(defaultAdminPage) } if _, ok := a.validSession(c.Cookies(adminSessionCookie)); ok { return c.Redirect().To(safeAdminNext(c.Query("next"))) } next := safeAdminNext(c.Query("next")) return a.render(c, "login.html", adminPageData{ PageTitle: "后台登录", Next: next, Error: c.Query("error"), OIDCLoginURL: "/admin/oidc/start?next=" + url.QueryEscape(next), }) } func (a *adminUI) loginRedirect(c fiber.Ctx) error { if a.cfg.AdminAuthDisabled() { return c.Redirect().To(defaultAdminPage) } next := safeAdminNext(c.FormValue("next")) return c.Redirect().To("/admin/oidc/start?next=" + url.QueryEscape(next)) } func (a *adminUI) oidcStart(c fiber.Ctx) error { if !a.cfg.AdminOIDCLoginEnabled() { return c.Redirect().To(defaultAdminPage) } oauthConfig, _, _, err := a.oidcClient(c.Context()) if err != nil { return fiber.NewError(fiber.StatusInternalServerError, err.Error()) } state := adminOIDCState{ State: randomHex(24), Nonce: randomHex(24), Verifier: oauth2.GenerateVerifier(), Next: safeAdminNext(c.Query("next")), Expires: time.Now().Add(10 * time.Minute).Unix(), } expires := time.Unix(state.Expires, 0) c.Cookie(&fiber.Cookie{ Name: adminOIDCCookie, Value: a.signCookieValue(state), Path: "/admin", MaxAge: int(time.Until(expires).Seconds()), Expires: expires, HTTPOnly: true, SameSite: "Lax", }) return c.Redirect().To(oauthConfig.AuthCodeURL( state.State, oauth2.AccessTypeOffline, oauth2.S256ChallengeOption(state.Verifier), oidc.Nonce(state.Nonce), )) } func (a *adminUI) oidcCallback(c fiber.Ctx) error { if !a.cfg.AdminOIDCLoginEnabled() { return c.Redirect().To(defaultAdminPage) } state, ok := a.validOIDCState(c.Cookies(adminOIDCCookie)) a.clearOIDCStateCookie(c) if !ok || !constantTimeEqual(state.State, c.Query("state")) { return c.Redirect().To("/admin/login?error=" + url.QueryEscape("授权状态已失效,请重新登录")) } if errText := strings.TrimSpace(c.Query("error")); errText != "" { return c.Redirect().To("/admin/login?error=" + url.QueryEscape(errText)) } code := strings.TrimSpace(c.Query("code")) if code == "" { return c.Redirect().To("/admin/login?error=" + url.QueryEscape("授权回调缺少 code")) } oauthConfig, provider, verifier, err := a.oidcClient(c.Context()) if err != nil { return fiber.NewError(fiber.StatusInternalServerError, err.Error()) } token, err := oauthConfig.Exchange(c.Context(), code, oauth2.VerifierOption(state.Verifier)) if err != nil { return c.Redirect().To("/admin/login?error=" + url.QueryEscape("授权登录失败,请重试")) } rawIDToken, ok := token.Extra("id_token").(string) if !ok || rawIDToken == "" { return c.Redirect().To("/admin/login?error=" + url.QueryEscape("授权结果缺少 ID Token")) } idToken, err := verifier.Verify(c.Context(), rawIDToken) if err != nil { return c.Redirect().To("/admin/login?error=" + url.QueryEscape("授权身份校验失败")) } if !constantTimeEqual(idToken.Nonce, state.Nonce) { return c.Redirect().To("/admin/login?error=" + url.QueryEscape("授权 nonce 校验失败")) } user, err := a.adminUserFromToken(c.Context(), provider, token, idToken) if err != nil { return c.Redirect().To("/admin/login?error=" + url.QueryEscape("授权用户信息读取失败")) } expires := time.Now().Add(8 * time.Hour) if idToken.Expiry.Before(expires) { expires = idToken.Expiry } if time.Until(expires) <= 0 { return c.Redirect().To("/admin/login?error=" + url.QueryEscape("授权身份已过期,请重新登录")) } c.Cookie(&fiber.Cookie{ Name: adminSessionCookie, Value: a.signSession(user, expires), Path: "/admin", MaxAge: int(time.Until(expires).Seconds()), Expires: expires, HTTPOnly: true, SameSite: "Lax", }) return c.Redirect().To(state.Next) } func (a *adminUI) logout(c fiber.Ctx) error { c.Cookie(&fiber.Cookie{ Name: adminSessionCookie, Value: "", Path: "/admin", MaxAge: -1, Expires: time.Now().Add(-time.Hour), HTTPOnly: true, SameSite: "Lax", }) return c.Redirect().To("/admin/login") } func (a *adminUI) priceInquiriesPage(c fiber.Ctx) error { filter := service.AdminPriceInquiryFilter{ Area: strings.TrimSpace(c.Query("area")), Phone: strings.TrimSpace(c.Query("phone")), Status: strings.TrimSpace(c.Query("status")), TimeFrom: strings.TrimSpace(c.Query("time_from")), TimeTo: strings.TrimSpace(c.Query("time_to")), } if !validAdminPriceInquiryStatusFilter(filter.Status) { return fiber.NewError(fiber.StatusBadRequest, "咨询状态筛选无效") } page, pageSize, err := parseAdminPriceInquiryQueryPagination(c) if err != nil { return err } result, err := a.svc.AdminPriceInquiryRecordsPage(c.Context(), filter, page, pageSize) if err != nil { return err } return a.render(c, "price_inquiries.html", adminPageData{ PageTitle: "价格咨询派单", Active: "price-inquiries", PriceInquiryRecords: result.Records, AreaFilter: filter.Area, PhoneFilter: filter.Phone, StatusFilter: filter.Status, TimeFromFilter: filter.TimeFrom, TimeToFilter: filter.TimeTo, HasPriceInquiryFilter: filter.Area != "" || filter.Phone != "" || filter.Status != "" || filter.TimeFrom != "" || filter.TimeTo != "", PriceInquiryTotal: result.Total, PriceInquiryPage: result.Page, PriceInquiryPageSize: result.PageSize, PriceInquiryTotalPages: result.TotalPages, PriceInquiryHasPreviousPage: result.HasPreviousPage, PriceInquiryHasNextPage: result.HasNextPage, PriceInquiryPreviousURL: priceInquiriesURL(filter, result.PreviousPage, result.PageSize, "", ""), PriceInquiryNextURL: priceInquiriesURL(filter, result.NextPage, result.PageSize, "", ""), PriceInquiryPageSizeOptions: adminPageSizeOptions(result.PageSize), Message: c.Query("message"), Error: c.Query("error"), }) } func (a *adminUI) savePriceInquirySettings(c fiber.Ctx) error { id := strings.TrimSpace(c.FormValue("id")) status := strings.TrimSpace(c.FormValue("status")) filter := service.AdminPriceInquiryFilter{ Area: strings.TrimSpace(c.FormValue("area")), Phone: strings.TrimSpace(c.FormValue("phone")), Status: strings.TrimSpace(c.FormValue("status_filter")), TimeFrom: strings.TrimSpace(c.FormValue("time_from")), TimeTo: strings.TrimSpace(c.FormValue("time_to")), } page, pageSize, err := parseAdminPriceInquiryFormPagination(c) if err != nil { return err } if !validAdminPriceInquiryStatusFilter(filter.Status) { return redirectPriceInquiries(c, service.AdminPriceInquiryFilter{}, page, pageSize, "error", "咨询状态筛选无效") } if id == "" { return redirectPriceInquiries(c, filter, page, pageSize, "error", "咨询单不存在") } switch status { case "pending", "assigned", "completed": default: return redirectPriceInquiries(c, filter, page, pageSize, "error", "咨询状态无效") } if _, err := a.svc.Q.UpdatePriceInquiry(c.Context(), db.UpdatePriceInquiryParams{ ID: id, Status: status, Remark: strings.TrimSpace(c.FormValue("remark")), }); err != nil { return redirectPriceInquiries(c, filter, page, pageSize, "error", fmt.Sprintf("咨询设置保存失败:%v", err)) } return redirectPriceInquiries(c, filter, page, pageSize, "message", "咨询设置已保存") } func validAdminPriceInquiryStatusFilter(status string) bool { switch status { case "", "pending", "assigned", "completed": return true default: return false } } func parseAdminPriceInquiryQueryPagination(c fiber.Ctx) (int, int, error) { return parseAdminPriceInquiryPagination(c.Query("page"), c.Query("page_size")) } func parseAdminPriceInquiryFormPagination(c fiber.Ctx) (int, int, error) { return parseAdminPriceInquiryPagination(c.FormValue("page"), c.FormValue("page_size")) } func parseAdminPriceInquiryPagination(pageRaw, pageSizeRaw string) (int, int, error) { page, err := positiveIntOrDefault(pageRaw, 1) if err != nil { return 0, 0, fiber.NewError(fiber.StatusBadRequest, "分页页码无效") } pageSize, err := positiveIntOrDefault(pageSizeRaw, defaultAdminPageSize) if err != nil { return 0, 0, fiber.NewError(fiber.StatusBadRequest, "每页条数无效") } if !validAdminPageSize(pageSize) { return 0, 0, fiber.NewError(fiber.StatusBadRequest, "每页条数无效") } return page, pageSize, nil } func positiveIntOrDefault(raw string, fallback int) (int, error) { raw = strings.TrimSpace(raw) if raw == "" { return fallback, nil } value, err := strconv.Atoi(raw) if err != nil || value < 1 { return 0, fmt.Errorf("invalid positive integer %q", raw) } return value, nil } func validAdminPageSize(pageSize int) bool { switch pageSize { case 20, 50, 100, 200: return true default: return false } } func adminPageSizeOptions(selected int) []adminPageSizeOption { options := []int{20, 50, 100, 200} result := make([]adminPageSizeOption, 0, len(options)) for _, option := range options { result = append(result, adminPageSizeOption{ Value: option, Selected: option == selected, }) } return result } func (a *adminUI) render(c fiber.Ctx, name string, data adminPageData) error { if user, ok := currentAdminUser(c); ok { data.Username = user.DisplayName } data.PasswordChangeURL = a.passwordChangeURL() var buf bytes.Buffer if err := a.templates.ExecuteTemplate(&buf, name, data); err != nil { return err } c.Type("html", "utf-8") return c.Send(buf.Bytes()) } type adminUser struct { Subject string `json:"sub"` DisplayName string `json:"name"` } type adminSession struct { Subject string `json:"sub"` DisplayName string `json:"name"` Expires int64 `json:"exp"` } type adminOIDCState struct { State string `json:"state"` Nonce string `json:"nonce"` Verifier string `json:"verifier"` Next string `json:"next"` Expires int64 `json:"exp"` } type adminOIDCClaims struct { Name string `json:"name"` PreferredUsername string `json:"preferred_username"` Email string `json:"email"` PhoneNumber string `json:"phone_number"` } func (a *adminUI) signSession(user adminUser, expires time.Time) string { return a.signCookieValue(adminSession{ Subject: user.Subject, DisplayName: user.DisplayName, Expires: expires.Unix(), }) } func (a *adminUI) validSession(token string) (adminUser, bool) { var session adminSession if !a.verifyCookieValue(token, &session) { return adminUser{}, false } if strings.TrimSpace(session.Subject) == "" || time.Now().Unix() >= session.Expires { return adminUser{}, false } displayName := strings.TrimSpace(session.DisplayName) if displayName == "" { displayName = session.Subject } return adminUser{Subject: session.Subject, DisplayName: displayName}, true } func (a *adminUI) validOIDCState(token string) (adminOIDCState, bool) { var state adminOIDCState if !a.verifyCookieValue(token, &state) { return adminOIDCState{}, false } if strings.TrimSpace(state.State) == "" || strings.TrimSpace(state.Nonce) == "" || strings.TrimSpace(state.Verifier) == "" { return adminOIDCState{}, false } if time.Now().Unix() >= state.Expires { return adminOIDCState{}, false } state.Next = safeAdminNext(state.Next) return state, true } func (a *adminUI) signCookieValue(value any) string { payloadBytes, err := json.Marshal(value) if err != nil { return "" } payload := base64.RawURLEncoding.EncodeToString(payloadBytes) sig := a.sessionMAC(payload) return payload + "." + base64.RawURLEncoding.EncodeToString(sig) } func (a *adminUI) verifyCookieValue(token string, value any) bool { parts := strings.Split(token, ".") if len(parts) != 2 { return false } signature, err := base64.RawURLEncoding.DecodeString(parts[1]) if err != nil { return false } if !hmac.Equal(signature, a.sessionMAC(parts[0])) { return false } payloadBytes, err := base64.RawURLEncoding.DecodeString(parts[0]) if err != nil { return false } return json.Unmarshal(payloadBytes, value) == nil } func (a *adminUI) sessionMAC(payload string) []byte { secret := a.cfg.AdminSessionSecret if secret == "" { secret = a.cfg.OIDCClientSecret } mac := hmac.New(sha256.New, []byte(secret)) mac.Write([]byte(payload)) return mac.Sum(nil) } func (a *adminUI) oidcClient(ctx context.Context) (*oauth2.Config, *oidc.Provider, *oidc.IDTokenVerifier, error) { a.oidcMu.Lock() defer a.oidcMu.Unlock() if a.oauth2Config != nil && a.oidcProvider != nil && a.oidcVerifier != nil { return a.oauth2Config, a.oidcProvider, a.oidcVerifier, nil } provider, err := oidc.NewProvider(ctx, a.cfg.OIDCIssuer) if err != nil { return nil, nil, nil, fmt.Errorf("初始化 OIDC Provider 失败:%w", err) } oauthConfig := &oauth2.Config{ ClientID: a.cfg.OIDCClientID, ClientSecret: a.cfg.OIDCClientSecret, RedirectURL: a.cfg.OIDCRedirectURI, Endpoint: provider.Endpoint(), Scopes: oidcScopes(a.cfg.OIDCScopes), } verifier := provider.Verifier(&oidc.Config{ClientID: a.cfg.OIDCClientID}) a.oidcProvider = provider a.oauth2Config = oauthConfig a.oidcVerifier = verifier return oauthConfig, provider, verifier, nil } func (a *adminUI) adminUserFromToken(ctx context.Context, provider *oidc.Provider, token *oauth2.Token, idToken *oidc.IDToken) (adminUser, error) { claims := adminOIDCClaims{} if err := idToken.Claims(&claims); err != nil { return adminUser{}, err } userInfo, err := provider.UserInfo(ctx, oauth2.StaticTokenSource(token)) if err == nil { _ = userInfo.Claims(&claims) } subject := strings.TrimSpace(idToken.Subject) displayName := firstNonEmpty(claims.Name, claims.PreferredUsername, claims.Email, claims.PhoneNumber, subject) if displayName == "" { displayName = "管理员" } return adminUser{Subject: subject, DisplayName: displayName}, nil } func currentAdminUser(c fiber.Ctx) (adminUser, bool) { value := c.Locals(adminUserLocalKey) user, ok := value.(adminUser) if !ok || strings.TrimSpace(user.DisplayName) == "" { return adminUser{}, false } return user, true } func (a *adminUI) clearOIDCStateCookie(c fiber.Ctx) { c.Cookie(&fiber.Cookie{ Name: adminOIDCCookie, Value: "", Path: "/admin", MaxAge: -1, Expires: time.Now().Add(-time.Hour), HTTPOnly: true, SameSite: "Lax", }) } func oidcScopes(value string) []string { seen := map[string]bool{"openid": true} scopes := []string{"openid"} parts := strings.FieldsFunc(value, func(r rune) bool { return r == ',' || r == ' ' || r == '\t' || r == '\n' || r == '\r' }) for _, part := range parts { scope := strings.TrimSpace(part) if scope != "" && !seen[scope] { scopes = append(scopes, scope) seen[scope] = true } } return scopes } func constantTimeEqual(a, b string) bool { return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1 } func wantsHTML(c fiber.Ctx) bool { accept := c.Get(fiber.HeaderAccept) return accept == "" || strings.Contains(accept, "text/html") } func safeAdminNext(next string) string { next = strings.TrimSpace(next) if strings.HasPrefix(next, "/admin") && !strings.HasPrefix(next, "/admin/login") { return next } return defaultAdminPage } func (a *adminUI) passwordChangeURL() string { if !a.cfg.AdminOIDCLoginEnabled() { return "" } issuer := strings.TrimSpace(a.cfg.OIDCIssuer) redirectURL := strings.TrimSpace(a.cfg.AdminPasswordChangeRedirectURL) if issuer == "" || redirectURL == "" { return "" } u, err := url.Parse(issuer) if err != nil || u.Scheme == "" || u.Host == "" { return "" } u.RawQuery = "" u.Fragment = "" issuerPath := strings.TrimRight(u.Path, "/") if strings.HasSuffix(issuerPath, "/oidc") { issuerPath = strings.TrimSuffix(issuerPath, "/oidc") } u.Path = strings.TrimRight(issuerPath, "/") + "/account/password" q := u.Query() q.Set("redirect", redirectURL) q.Set("show_success", "true") u.RawQuery = q.Encode() return u.String() } func redirectPriceInquiries(c fiber.Ctx, filter service.AdminPriceInquiryFilter, page, pageSize int, key, message string) error { return c.Redirect().To(priceInquiriesURL(filter, page, pageSize, key, message)) } func priceInquiriesURL(filter service.AdminPriceInquiryFilter, page, pageSize int, key, message string) string { query := url.Values{} if key != "" { query.Set(key, message) } if filter.Area != "" { query.Set("area", filter.Area) } if filter.Phone != "" { query.Set("phone", filter.Phone) } if filter.Status != "" { query.Set("status", filter.Status) } if filter.TimeFrom != "" { query.Set("time_from", filter.TimeFrom) } if filter.TimeTo != "" { query.Set("time_to", filter.TimeTo) } if page > 1 { query.Set("page", strconv.Itoa(page)) } if pageSize != defaultAdminPageSize { query.Set("page_size", strconv.Itoa(pageSize)) } encoded := query.Encode() if encoded == "" { return "/admin/price-inquiries" } return "/admin/price-inquiries?" + encoded } func randomHex(size int) string { buf := make([]byte, size) if _, err := rand.Read(buf); err != nil { return strconv.FormatInt(time.Now().UnixNano(), 16) } return hex.EncodeToString(buf) }