Files
rogee 7e41bcaaee
Build and push backend image / backend-image (push) Successful in 52s
chore: remove legacy booking flows and use local admin auth
2026-09-23 21:26:58 +08:00

454 lines
14 KiB
Go

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
}