515 lines
14 KiB
Go
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
|
|
}
|