package server import ( "context" "crypto/rand" "crypto/sha256" "crypto/subtle" "encoding/hex" "encoding/json" "errors" "fmt" "github.com/gin-gonic/gin" "github.com/gorilla/websocket" "github.com/jackc/pgx/v5/pgxpool" "github.com/redis/go-redis/v9" "net/http" "net/url" "os" "perler-beads-server/internal/domain" "strings" "sync" "time" ) type Server struct { DB *pgxpool.Pool Redis *redis.Client Env string AdminUsername string AdminPassword string WeChatID string WeChatSecret string Origins []string WebUIDir string wsHub *taskHub wsOnce sync.Once } func reply(c *gin.Context, code int, msg string, v any) { c.JSON(200, gin.H{"code": code, "msg": msg, "data": v}) } func ok(c *gin.Context, v any) { reply(c, 200, "ok", v) } func fail(c *gin.Context, e error) { reply(c, 400, e.Error(), nil) } func bind(c *gin.Context, v any) bool { if e := c.ShouldBindJSON(v); e != nil { fail(c, errors.New("请求格式无效")) return false } return true } func user(c *gin.Context) domain.User { v, _ := c.Get("user"); u, _ := v.(domain.User); return u } func need(c *gin.Context, role string) bool { u := user(c) if u.ID == "" { reply(c, 403, "请先登录", nil) return false } if role == "admin" && u.Role != "admin" || role == "staff" && u.Role != "admin" && u.Role != "staff" { reply(c, 401, "无权限", nil) return false } return true } func (s *Server) Router() *gin.Engine { s.wsOnce.Do(func() { s.wsHub = newTaskHub() }) r := gin.New() r.ContextWithFallback = true r.SetTrustedProxies(nil) r.Use(gin.CustomRecovery(func(c *gin.Context, recovered any) { reply(c, 500, "服务暂不可用", nil); c.Abort() }), func(c *gin.Context) { if !(c.Request.URL.Path == "/api/ws" && websocket.IsWebSocketUpgrade(c.Request)) { ctx, cancel := context.WithTimeout(c.Request.Context(), 15*time.Second) defer cancel() c.Request = c.Request.WithContext(ctx) } c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 4<<20) origin := c.GetHeader("Origin") for _, allowed := range s.Origins { if origin == allowed { c.Header("Access-Control-Allow-Origin", origin) c.Header("Vary", "Origin") c.Header("Access-Control-Allow-Headers", "Authorization,Content-Type") c.Header("Access-Control-Allow-Methods", "GET,POST,PUT,PATCH,DELETE,OPTIONS") } } if c.Request.Method == "OPTIONS" { c.Status(204) c.Abort() return } token := strings.TrimPrefix(c.GetHeader("Authorization"), "Bearer ") if token != "" { h := sha256.Sum256([]byte(token)) b, e := s.Redis.Get(c.Request.Context(), "session:"+hex.EncodeToString(h[:])).Bytes() if e == nil { var u domain.User if json.Unmarshal(b, &u) == nil { c.Set("user", u) } } } c.Next() }) a := r.Group("/api") a.GET("/ws", s.websocket) a.GET("/health", func(c *gin.Context) { ok(c, gin.H{"service": "perler-beads", "storeId": "store-001", "version": "0.1.0"}) }) a.GET("/capabilities", func(c *gin.Context) { ok(c, gin.H{"payment": gin.H{"enabled": false}, "storedValue": gin.H{"enabled": false}}) }) a.POST("/auth/login", s.login) a.POST("/auth/admin", s.adminLogin) a.GET("/me", func(c *gin.Context) { if need(c, "") { ok(c, user(c)) } }) a.GET("/stores", func(c *gin.Context) { v, e := s.list(c, "store") if e != nil { fail(c, e) return } ok(c, v) }) a.GET("/palettes", func(c *gin.Context) { var v []domain.Color if e := s.get(c, "palette", storeID(c), &v); e != nil { fail(c, errors.New("门店色板不存在")) return } ok(c, v) }) a.GET("/patterns", s.patternList) a.GET("/my/patterns", s.patternList) a.GET("/patterns/:id", s.patternGet) a.GET("/patterns/:id/print", s.print) a.POST("/patterns", s.patternSave) a.DELETE("/patterns/:id", s.patternDelete) a.GET("/favorites", s.favoriteList) a.PUT("/favorites/:id", s.favoritePut) a.POST("/visits", s.visitCreate) a.GET("/visits/current", s.visitGet) a.DELETE("/visits/current", s.visitClose) a.GET("/tables", s.tableList) a.GET("/service-requests", s.requestList) a.POST("/service-requests", s.requestCreate) a.PATCH("/service-requests/:id", s.requestPatch) a.GET("/admin/patterns", s.patternList) a.POST("/admin/patterns", s.patternSave) a.PATCH("/admin/patterns/:id", s.patternPatch) a.PUT("/admin/palette", s.palettePut) a.PUT("/admin/store", s.storePut) a.PUT("/admin/tables", s.tablePut) r.NoRoute(s.staticFallback) return r } func storeID(c *gin.Context) string { id := c.Query("storeId") if id == "" { id = "store-001" } return id } func (s *Server) rate(c *gin.Context) bool { key := "login-limit:" + c.ClientIP() n, e := s.Redis.Incr(c, key).Result() if e != nil { reply(c, 503, "登录服务暂不可用", nil) return false } if n == 1 { s.Redis.Expire(c, key, time.Minute) } if n > 20 { reply(c, 429, "登录过于频繁", nil) return false } return true } func (s *Server) session(c *gin.Context, u domain.User) { b := make([]byte, 32) if _, e := rand.Read(b); e != nil { fail(c, e) return } token := hex.EncodeToString(b) h := sha256.Sum256([]byte(token)) payload, _ := json.Marshal(u) if e := s.Redis.Set(c, "session:"+hex.EncodeToString(h[:]), payload, 7*24*time.Hour).Err(); e != nil { reply(c, 503, "登录服务暂不可用", nil) return } ok(c, gin.H{"token": token, "user": u}) } func (s *Server) adminLogin(c *gin.Context) { if !s.rate(c) { return } var in struct { Username string `json:"username"` Password string `json:"password"` } if !bind(c, &in) { return } a := sha256.Sum256([]byte(in.Password)) b := sha256.Sum256([]byte(s.AdminPassword)) if s.AdminPassword == "" || in.Username != s.AdminUsername || subtle.ConstantTimeCompare(a[:], b[:]) != 1 { reply(c, 403, "账号或密码错误", nil) return } s.session(c, domain.User{ID: "admin-001", Name: "管理员", Role: "admin", StoreID: "store-001"}) } func (s *Server) login(c *gin.Context) { if !s.rate(c) { return } var in struct { Code string `json:"code"` } if !bind(c, &in) { return } openid := "" if in.Code == "dev-guest" { if s.Env != "development" { reply(c, 403, "生产环境禁止开发登录", nil) return } openid = "development-guest" } else { if in.Code == "" || s.WeChatID == "" || s.WeChatSecret == "" { reply(c, 403, "微信登录尚未配置", nil) return } endpoint := "https://api.weixin.qq.com/sns/jscode2session?" + url.Values{"appid": {s.WeChatID}, "secret": {s.WeChatSecret}, "js_code": {in.Code}, "grant_type": {"authorization_code"}}.Encode() req, e := http.NewRequestWithContext(c, http.MethodGet, endpoint, nil) if e != nil { fail(c, e) return } resp, e := (&http.Client{Timeout: 8 * time.Second}).Do(req) if e != nil { reply(c, 503, "微信登录暂不可用", nil) return } defer resp.Body.Close() var data struct { OpenID string `json:"openid"` ErrCode int `json:"errcode"` } if e = json.NewDecoder(resp.Body).Decode(&data); e != nil || resp.StatusCode != 200 || data.ErrCode != 0 || data.OpenID == "" { reply(c, 403, "微信登录凭证无效", nil) return } openid = data.OpenID } h := sha256.Sum256([]byte("wechat:" + openid)) u := domain.User{ID: "user-" + hex.EncodeToString(h[:16]), Name: "拼豆顾客", Role: "guest", StoreID: "store-001"} if e := s.put(c, "user", u.ID, u.StoreID, u.ID, u); e != nil { reply(c, 503, "用户持久化失败", nil) return } s.session(c, u) } func New(ctx context.Context) (*Server, error) { db, e := pgxpool.New(ctx, os.Getenv("DATABASE_URL")) if e != nil { return nil, e } if e = db.Ping(ctx); e != nil { return nil, e } opt, e := redis.ParseURL(os.Getenv("REDIS_URL")) if e != nil { return nil, e } r := redis.NewClient(opt) if e = r.Ping(ctx).Err(); e != nil { return nil, e } s := &Server{DB: db, Redis: r, Env: os.Getenv("APP_ENV"), AdminUsername: os.Getenv("ADMIN_USERNAME"), AdminPassword: os.Getenv("ADMIN_PASSWORD"), WeChatID: os.Getenv("WECHAT_APP_ID"), WeChatSecret: os.Getenv("WECHAT_APP_SECRET"), Origins: strings.Split(os.Getenv("CORS_ORIGINS"), ",")} s.WebUIDir = os.Getenv("WEBUI_DIR") if s.WebUIDir != "" { if e = validateWebUIDir(s.WebUIDir); e != nil { return nil, e } } if s.AdminUsername == "" || len(s.AdminPassword) < 12 { return nil, fmt.Errorf("admin credentials required (password at least 12 characters)") } if s.Env != "development" && (s.WeChatID == "" || s.WeChatSecret == "") { return nil, errors.New("production requires WeChat credentials") } if e = Migrate(ctx, db); e != nil { return nil, e } if os.Getenv("SEED_DEMO") == "true" { if e = s.Seed(ctx); e != nil { return nil, e } } return s, nil }