Files
wxapp-guantong/backend/internal/httpapi/httpapi.go
T
2026-09-22 22:51:19 +08:00

515 lines
14 KiB
Go

package httpapi
import (
"bytes"
"database/sql"
"encoding/csv"
"errors"
"path"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/gofiber/fiber/v3"
"github.com/gofiber/fiber/v3/middleware/compress"
"github.com/gofiber/fiber/v3/middleware/cors"
"github.com/gofiber/fiber/v3/middleware/recover"
"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"
"github.com/sirupsen/logrus"
)
func New(cfg config.Config, svc *service.Service, log *logrus.Logger) *fiber.App {
wechatPhone := newWeChatPhoneClient(cfg)
app := fiber.New(fiber.Config{
AppName: "Smile First API",
BodyLimit: 8 * 1024 * 1024,
ErrorHandler: errorHandler,
})
app.Use(recover.New())
app.Use(compress.New())
app.Use(cors.New(cors.Config{
AllowOrigins: splitList(cfg.AllowOrigins),
AllowHeaders: []string{
fiber.HeaderOrigin,
fiber.HeaderContentType,
fiber.HeaderAccept,
fiber.HeaderAuthorization,
},
AllowMethods: []string{
fiber.MethodGet,
fiber.MethodPost,
fiber.MethodPatch,
fiber.MethodDelete,
fiber.MethodOptions,
},
}))
app.Use(logRequests(log))
app.Get("/uploads/*", serveUploadedFile(cfg))
app.Get("/healthz", func(c fiber.Ctx) error {
return c.JSON(fiber.Map{"ok": true, "time": time.Now().Format(time.RFC3339)})
})
api := app.Group("/api")
api.Get("/app/bootstrap", func(c fiber.Ctx) error {
heroImage, err := svc.HomeHeroImage(c.Context())
if err != nil {
return err
}
categories, err := svc.CategoriesWithProjects(c.Context(), false)
if err != nil {
return err
}
recommended := make([]service.ProjectView, 0, 4)
for _, category := range categories {
for _, project := range category.Projects {
if len(recommended) < 4 {
recommended = append(recommended, service.ToProjectView(project))
}
}
}
return c.JSON(fiber.Map{
"brand": fiber.Map{
"name": "Smile First",
"title": "选好项目,约好护理时间",
"hero": "自然之力,予你自信笑容",
"subtitle": "温和护理 · 精致体验 · 用心陪伴",
"hero_image": heroImage,
},
"hero_image": heroImage,
"features": []string{"自然之力,温和呵护", "精致体验,贴心服务", "预约提醒,省时省心"},
"recommended_projects": recommended,
})
})
api.Get("/projects", func(c fiber.Ctx) error {
categories, err := svc.CategoriesWithProjects(c.Context(), false)
if err != nil {
return err
}
return c.JSON(fiber.Map{"categories": service.CategoryViews(categories)})
})
api.Post("/auth/wechat/session", func(c fiber.Ctx) error {
var req struct {
Code string `json:"code"`
}
if err := bindBody(c, &req); err != nil {
return err
}
session, err := svc.Login(c.Context(), req.Code)
if err != nil {
return err
}
return c.JSON(session)
})
api.Post("/auth/wechat/phone", func(c fiber.Ctx) error {
var req struct {
OpenID string `json:"openid"`
Phone string `json:"phone"`
PhoneCode string `json:"phoneCode"`
}
if err := bindBody(c, &req); err != nil {
return err
}
phone := strings.TrimSpace(req.Phone)
phoneCode := strings.TrimSpace(req.PhoneCode)
log.WithFields(logrus.Fields{
"openid": maskIdentifierForLog(req.OpenID),
"has_openid": strings.TrimSpace(req.OpenID) != "",
"has_phone": phone != "",
"phone": maskPhoneForLog(phone),
"has_phone_code": phoneCode != "",
"phone_code_len": len(phoneCode),
"ip": c.IP(),
}).Info("wechat phone bind request")
if phone == "" {
if phoneCode != "" {
exchangedPhone, err := wechatPhone.PhoneNumber(c.Context(), phoneCode)
if err != nil {
log.WithFields(logrus.Fields{
"openid": maskIdentifierForLog(req.OpenID),
"phone_code_len": len(phoneCode),
}).WithError(err).Error("wechat phone exchange failed")
var configErr missingWeChatConfigError
if errors.As(err, &configErr) {
return fiber.NewError(fiber.StatusInternalServerError, err.Error())
}
return fiber.NewError(fiber.StatusBadGateway, err.Error())
}
phone = exchangedPhone
log.WithFields(logrus.Fields{
"openid": maskIdentifierForLog(req.OpenID),
"phone_code_len": len(phoneCode),
"phone": maskPhoneForLog(phone),
}).Info("wechat phone exchange success")
}
}
if phone == "" {
log.WithField("openid", maskIdentifierForLog(req.OpenID)).Warn("wechat phone bind rejected: phone missing")
return badRequest("phone is required")
}
user, err := svc.BindPhone(c.Context(), req.OpenID, phone)
if err != nil {
log.WithFields(logrus.Fields{
"openid": maskIdentifierForLog(req.OpenID),
"phone": maskPhoneForLog(phone),
}).WithError(err).Error("wechat phone bind failed")
return err
}
log.WithFields(logrus.Fields{
"openid": maskIdentifierForLog(user.Openid),
"user_id": user.ID,
"phone": maskPhoneForLog(phone),
}).Info("wechat phone bind success")
return c.JSON(fiber.Map{"user": user})
})
api.Post("/bookings", func(c fiber.Ctx) error {
var req service.CreateBookingRequest
if err := bindBody(c, &req); err != nil {
return err
}
detail, err := svc.CreateBooking(c.Context(), req)
if err != nil {
return err
}
return c.Status(fiber.StatusCreated).JSON(service.BookingDetailToView(detail))
})
api.Post("/price-inquiries", func(c fiber.Ctx) error {
var req service.CreatePriceInquiryRequest
if err := bindBody(c, &req); err != nil {
return err
}
inquiry, err := svc.CreatePriceInquiry(c.Context(), req)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return badRequest("phone authorization is required")
}
if strings.Contains(err.Error(), "required") || strings.Contains(err.Error(), "unsupported") {
return badRequest(err.Error())
}
return err
}
log.WithFields(logrus.Fields{
"inquiry_id": inquiry.ID,
"openid": maskIdentifierForLog(req.OpenID),
"phone": maskPhoneForLog(inquiry.Phone),
"province": inquiry.Province,
"city": inquiry.City,
"district": inquiry.District,
"project_name": inquiry.ProjectName,
"status": inquiry.Status,
"created_at": inquiry.CreatedAt,
"updated_at": inquiry.UpdatedAt,
}).Info("price inquiry submitted")
return c.Status(fiber.StatusCreated).JSON(fiber.Map{
"id": inquiry.ID,
"status": inquiry.Status,
"created_at": inquiry.CreatedAt,
"updated_at": inquiry.UpdatedAt,
})
})
api.Get("/bookings", func(c fiber.Ctx) error {
phone := strings.TrimSpace(c.Query("phone"))
if phone == "" {
return badRequest("phone is required")
}
details, err := svc.ListBookingDetailsByPhone(c.Context(), phone)
if err != nil {
return err
}
return c.JSON(fiber.Map{"bookings": service.BookingDetailsToViews(details)})
})
api.Get("/bookings/:id", func(c fiber.Ctx) error {
booking, err := svc.Q.GetBooking(c.Context(), c.Params("id"))
if err != nil {
return err
}
detail, err := svc.BookingDetail(c.Context(), booking)
if err != nil {
return err
}
return c.JSON(service.BookingDetailToView(detail))
})
api.Patch("/bookings/:id/cancel", func(c fiber.Ctx) error {
var req struct {
Phone string `json:"phone"`
}
if err := bindBody(c, &req); err != nil {
return err
}
detail, err := svc.CancelBooking(c.Context(), c.Params("id"), req.Phone)
if err != nil {
return err
}
return c.JSON(service.BookingDetailToView(detail))
})
adminUI := newAdminUI(cfg, svc)
adminUI.registerPublic(app)
admin := app.Group("/admin", adminUI.requireLogin)
adminUI.registerPages(admin)
admin.Get("/categories", func(c fiber.Ctx) error {
categories, err := svc.CategoriesWithProjects(c.Context(), true)
if err != nil {
return err
}
return c.JSON(fiber.Map{"categories": categories})
})
admin.Post("/categories", func(c fiber.Ctx) error {
var req db.CreateCategoryParams
if err := bindBody(c, &req); err != nil {
return err
}
if req.ID == "" {
req.ID = "cat_" + strconv.FormatInt(time.Now().UnixNano(), 10)
}
if req.Status == "" {
req.Status = "active"
}
category, err := svc.Q.CreateCategory(c.Context(), req)
if err != nil {
return err
}
return c.Status(fiber.StatusCreated).JSON(category)
})
admin.Get("/projects", func(c fiber.Ctx) error {
categories, err := svc.CategoriesWithProjects(c.Context(), true)
if err != nil {
return err
}
return c.JSON(fiber.Map{"categories": categories})
})
admin.Post("/projects", func(c fiber.Ctx) error {
var req db.CreateProjectParams
if err := bindBody(c, &req); err != nil {
return err
}
if req.ID == "" {
req.ID = "project_" + strconv.FormatInt(time.Now().UnixNano(), 10)
}
if req.Status == "" {
req.Status = "active"
}
project, err := svc.Q.CreateProject(c.Context(), req)
if err != nil {
return err
}
return c.Status(fiber.StatusCreated).JSON(project)
})
admin.Get("/schedules", func(c fiber.Ctx) error {
projectID := firstNonEmpty(c.Query("projectId"), c.Query("project_id"))
days, err := svc.ScheduleDays(c.Context(), projectID, c.Query("month"))
if err != nil {
return err
}
return c.JSON(fiber.Map{"days": days})
})
admin.Post("/schedules/days", func(c fiber.Ctx) error {
var req db.CreateScheduleDayParams
if err := bindBody(c, &req); err != nil {
return err
}
if req.ID == "" {
req.ID = "day_" + req.ProjectID + "_" + strings.ReplaceAll(req.Date, "-", "")
}
if req.Status == "" {
req.Status = "available"
}
day, err := svc.Q.CreateScheduleDay(c.Context(), req)
if err != nil {
return err
}
return c.Status(fiber.StatusCreated).JSON(day)
})
admin.Patch("/schedules/days/:id", func(c fiber.Ctx) error {
var req struct {
Status string `json:"status"`
Note string `json:"note"`
}
if err := bindBody(c, &req); err != nil {
return err
}
day, err := svc.Q.UpdateScheduleDay(c.Context(), db.UpdateScheduleDayParams{
ID: c.Params("id"), Status: req.Status, Note: req.Note,
})
if err != nil {
return err
}
return c.JSON(day)
})
admin.Post("/schedules/slots", func(c fiber.Ctx) error {
var req service.CreateTimeSlotRequest
if err := bindBody(c, &req); err != nil {
return err
}
slot, err := svc.CreateTimeSlot(c.Context(), req)
if err != nil {
return err
}
return c.Status(fiber.StatusCreated).JSON(slot)
})
admin.Patch("/schedules/slots/:id", func(c fiber.Ctx) error {
var req service.UpdateTimeSlotRequest
if err := bindBody(c, &req); err != nil {
return err
}
slot, err := svc.UpdateTimeSlot(c.Context(), c.Params("id"), req)
if err != nil {
return err
}
return c.JSON(slot)
})
admin.Get("/bookings", func(c fiber.Ctx) error {
details, err := svc.AdminListBookingDetails(c.Context(), c.Query("status"), queryInt(c, "limit", 50), queryInt(c, "offset", 0))
if err != nil {
return err
}
return c.JSON(fiber.Map{"bookings": details})
})
admin.Patch("/bookings/:id/status", func(c fiber.Ctx) error {
var req struct {
Status string `json:"status"`
}
if err := bindBody(c, &req); err != nil {
return err
}
booking, err := svc.Q.UpdateBookingStatus(c.Context(), db.UpdateBookingStatusParams{ID: c.Params("id"), Status: req.Status})
if err != nil {
return err
}
detail, err := svc.BookingDetail(c.Context(), booking)
if err != nil {
return err
}
return c.JSON(detail)
})
admin.Get("/reports/bookings", func(c fiber.Ctx) error {
details, err := svc.AdminListBookingDetails(c.Context(), c.Query("status"), 500, 0)
if err != nil {
return err
}
if c.Query("format") != "csv" {
return c.JSON(fiber.Map{"bookings": details})
}
var buf bytes.Buffer
w := csv.NewWriter(&buf)
_ = w.Write([]string{"booking_id", "status", "phone", "project", "date", "time", "created_at"})
for _, detail := range details {
_ = w.Write([]string{
detail.Booking.ID, detail.Booking.Status, detail.Booking.Phone,
detail.Project.Name, detail.Day.Date, detail.Slot.StartTime,
detail.Booking.CreatedAt,
})
}
w.Flush()
c.Set(fiber.HeaderContentType, "text/csv; charset=utf-8")
c.Set(fiber.HeaderContentDisposition, `attachment; filename="bookings.csv"`)
return c.Send(buf.Bytes())
})
return app
}
func serveUploadedFile(cfg config.Config) fiber.Handler {
return func(c fiber.Ctx) error {
cleanPath := path.Clean("/" + c.Params("*"))
if cleanPath == "/" {
return fiber.ErrNotFound
}
fullPath := filepath.Join(cfg.UploadDir, filepath.FromSlash(strings.TrimPrefix(cleanPath, "/")))
base, err := filepath.Abs(cfg.UploadDir)
if err != nil {
return err
}
full, err := filepath.Abs(fullPath)
if err != nil {
return err
}
if full != base && !strings.HasPrefix(full, base+string(filepath.Separator)) {
return fiber.ErrForbidden
}
c.Set(fiber.HeaderCacheControl, "public, max-age=31536000, immutable")
return c.SendFile(full)
}
}
func errorHandler(c fiber.Ctx, err error) error {
var e *fiber.Error
if errors.As(err, &e) {
return c.Status(e.Code).JSON(fiber.Map{"error": e.Message})
}
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
func badRequest(msg string) error {
return fiber.NewError(fiber.StatusBadRequest, msg)
}
func bindBody(c fiber.Ctx, out any) error {
if err := c.Bind().Body(out); err != nil {
return badRequest(err.Error())
}
return nil
}
func logRequests(log *logrus.Logger) fiber.Handler {
return func(c fiber.Ctx) error {
start := time.Now()
err := c.Next()
log.WithFields(logrus.Fields{
"method": c.Method(),
"path": c.Path(),
"status": c.Response().StatusCode(),
"latency_ms": time.Since(start).Milliseconds(),
}).Info("request")
return err
}
}
func maskPhoneForLog(phone string) string {
phone = strings.TrimSpace(phone)
if len(phone) < 7 {
return phone
}
return phone[:3] + "****" + phone[len(phone)-4:]
}
func maskIdentifierForLog(value string) string {
value = strings.TrimSpace(value)
if len(value) <= 8 {
return value
}
return value[:4] + "..." + value[len(value)-4:]
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}
func splitList(value string) []string {
parts := strings.Split(value, ",")
values := make([]string, 0, len(parts))
for _, part := range parts {
if trimmed := strings.TrimSpace(part); trimmed != "" {
values = append(values, trimmed)
}
}
return values
}
func queryInt(c fiber.Ctx, key string, fallback int64) int64 {
value, err := strconv.ParseInt(c.Query(key), 10, 64)
if err != nil {
return fallback
}
return value
}