package httpapi import ( "bytes" "crypto/hmac" "crypto/sha256" "crypto/subtle" "embed" "encoding/base64" "encoding/json" "fmt" "html/template" "net/url" "strconv" "strings" "time" "github.com/gofiber/fiber/v3" "github.com/gofiber/fiber/v3/middleware/limiter" "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" ) //go:embed templates/*.html var adminTemplateFS embed.FS const ( adminSessionCookie = "smilefirst_admin" defaultAdminPage = "/admin/price-inquiries" adminUserLocalKey = "adminUser" defaultAdminPageSize = 50 ) type adminUI struct { cfg config.Config svc *service.Service templates *template.Template } type adminPageData struct { PageTitle string Active string Username string Next string Message string Error string 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) // ponytail: process-local limiter fits the single-container deployment; use shared storage if scaled. app.Post("/admin/login", limiter.New(limiter.Config{ Max: 5, Expiration: 15 * time.Minute, SkipSuccessfulRequests: true, }), a.login) 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 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 _, ok := a.validSession(c.Cookies(adminSessionCookie)); ok { return c.Redirect().To(safeAdminNext(c.Query("next"))) } return a.render(c, "login.html", adminPageData{ PageTitle: "后台登录", Next: safeAdminNext(c.Query("next")), }) } func (a *adminUI) login(c fiber.Ctx) error { next := safeAdminNext(c.FormValue("next")) usernameMatches := constantTimeEqual(c.FormValue("username"), a.cfg.AdminUsername) passwordMatches := constantTimeEqual(c.FormValue("password"), a.cfg.AdminPassword) if a.cfg.AdminUsername == "" || a.cfg.AdminPassword == "" || len(strings.TrimSpace(a.cfg.AdminSessionSecret)) < 32 || !usernameMatches || !passwordMatches { c.Status(fiber.StatusUnauthorized) return a.render(c, "login.html", adminPageData{ PageTitle: "后台登录", Next: next, Error: "用户名或密码错误", }) } expires := time.Now().Add(8 * time.Hour) user := adminUser{Subject: a.cfg.AdminUsername, DisplayName: a.cfg.AdminUsername} c.Cookie(&fiber.Cookie{ Name: adminSessionCookie, Value: a.signSession(user, expires), Path: "/admin", MaxAge: int(time.Until(expires).Seconds()), Expires: expires, HTTPOnly: true, Secure: adminCookieSecure(c), SameSite: "Lax", }) return c.Redirect().To(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, Secure: adminCookieSecure(c), 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 } 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"` } 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) 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 { if len(strings.TrimSpace(a.cfg.AdminSessionSecret)) < 32 { return false } 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 { mac := hmac.New(sha256.New, []byte(a.cfg.AdminSessionSecret)) mac.Write([]byte(payload)) return mac.Sum(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 constantTimeEqual(a, b string) bool { aHash := sha256.Sum256([]byte(a)) bHash := sha256.Sum256([]byte(b)) return subtle.ConstantTimeCompare(aHash[:], bHash[:]) == 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 (next == "/admin" || strings.HasPrefix(next, "/admin/")) && !strings.HasPrefix(next, "/admin/login") { return next } return defaultAdminPage } func adminCookieSecure(c fiber.Ctx) bool { return c.Scheme() == "https" || strings.EqualFold(c.Get("X-Forwarded-Proto"), "https") } 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 }