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 }