Files
Rogee ea6811b295
Build and push backend image / backend-image (push) Successful in 45s
fix: use verified WeChat login and recover sessions after cache clearing
2026-09-26 18:47:38 +08:00

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
}