256 lines
7.2 KiB
Go
256 lines
7.2 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"database/sql"
|
|
"errors"
|
|
"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/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: "骨安通 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("/healthz", func(c fiber.Ctx) error {
|
|
return c.JSON(fiber.Map{"ok": true, "time": time.Now().Format(time.RFC3339)})
|
|
})
|
|
|
|
api := app.Group("/api")
|
|
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
|
|
}
|
|
if strings.TrimSpace(req.Code) == "" {
|
|
return badRequest("login code is required")
|
|
}
|
|
openid, err := wechatPhone.LoginOpenID(c.Context(), req.Code)
|
|
if err != nil {
|
|
log.WithError(err).Error("wechat login exchange failed")
|
|
var configErr missingWeChatConfigError
|
|
if errors.As(err, &configErr) {
|
|
return fiber.NewError(500, err.Error())
|
|
}
|
|
return fiber.NewError(502, err.Error())
|
|
}
|
|
session, err := svc.Login(c.Context(), openid)
|
|
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("/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,
|
|
})
|
|
})
|
|
adminUI := newAdminUI(cfg, svc)
|
|
adminUI.registerPublic(app)
|
|
admin := app.Group("/admin", adminUI.requireLogin)
|
|
adminUI.registerPages(admin)
|
|
|
|
return app
|
|
}
|
|
|
|
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()
|
|
if err != nil {
|
|
if handlerErr := c.App().Config().ErrorHandler(c, err); handlerErr != nil {
|
|
return handlerErr
|
|
}
|
|
}
|
|
entry := log.WithFields(logrus.Fields{
|
|
"method": c.Method(),
|
|
"path": c.Path(),
|
|
"status": c.Response().StatusCode(),
|
|
"latency_ms": time.Since(start).Milliseconds(),
|
|
})
|
|
if err != nil {
|
|
entry.WithError(err).Error("request failed")
|
|
} else {
|
|
entry.Info("request")
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|