306 lines
8.6 KiB
Go
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
|
|
}
|