Files
perler-beads-server/internal/server/server.go
T

306 lines
8.6 KiB
Go

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
}