chore(服务端): 初始化工程仓库
This commit is contained in:
@@ -0,0 +1,305 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user